Add groups with shared goals, objectives and chat; drop the water tracker
Groups let people keep each other honest: create one or join with a six-letter invite code. Each group has shared goals split into measurable objectives (km, sessions, words) that every member logs toward, a leaderboard of kept rate and streak with nudges, and a group chat where check-ins and nudges appear as cards beside the talk. The open chat polls for new messages. Reached from a new Groups tab (Training moves into the day rail), a Home "Your groups" rail that opens the chat, and a Group tile in the add sheet. Mock mode seeds two groups, a joinable one and members who reply. The water tracker is removed; it did not fit the app. Bottom sheets are now square app-wide via the theme. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KhQh614DMeYoQEH9SoMRUw
This commit is contained in:
464
frontend/lib/Grounded/see/group/GroupChatState.dart
Normal file
464
frontend/lib/Grounded/see/group/GroupChatState.dart
Normal file
@@ -0,0 +1,464 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stacked/stacked.dart';
|
||||
|
||||
import '../../about/external/data/GroupMessage.dart';
|
||||
import '../../about/external/initial/GroupMessageRequest.dart';
|
||||
import '../../about/internal/application/TextType.dart';
|
||||
import '../../designs/AmbientBackground.dart';
|
||||
import '../../designs/GlassSurface.dart';
|
||||
import '../../designs/MemberAvatar.dart';
|
||||
import '../../designs/Responsive.dart';
|
||||
import '../../designs/input/InputFields.dart';
|
||||
import '../../designs/text/Text.dart';
|
||||
import '../../utils/Colors.dart';
|
||||
import '../../utils/CommonUtils.dart';
|
||||
import 'ConnectGroupChat.dart';
|
||||
import 'GroupChat.dart';
|
||||
import 'ViewGroupChat.dart';
|
||||
|
||||
class GroupChatState extends State<GroupChat> implements ConnectGroupChat {
|
||||
ViewGroupChat? _model;
|
||||
|
||||
List<GroupMessage> _messages = <GroupMessage>[];
|
||||
|
||||
final TextEditingController _input = TextEditingController();
|
||||
|
||||
Timer? _poll;
|
||||
|
||||
/// How often the open chat looks for new messages.
|
||||
static const Duration _pollEvery = Duration(seconds: 4);
|
||||
|
||||
static const List<String> _quickReplies = <String>[
|
||||
"Done for today",
|
||||
"On my way",
|
||||
"Need a push",
|
||||
"Who's in tomorrow?",
|
||||
];
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_poll?.cancel();
|
||||
_input.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ViewModelBuilder<ViewGroupChat>.reactive(
|
||||
viewModelBuilder: () => ViewGroupChat(context, this),
|
||||
onViewModelReady: (viewModel) {
|
||||
_model = viewModel;
|
||||
_initiate();
|
||||
},
|
||||
builder: (context, viewModel, child) => LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
return Responsive(
|
||||
mobile: _mobileView(constraints),
|
||||
tablet: _mobileView(constraints),
|
||||
desktop: _mobileView(constraints),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _initiate() {
|
||||
_model?.loadMessages(widget.group.id);
|
||||
_poll = Timer.periodic(_pollEvery, _onPoll);
|
||||
}
|
||||
|
||||
// ── Handlers ──────────────────────────────────────────────────────────────
|
||||
|
||||
void _onBack() {
|
||||
Navigator.pop(context, true);
|
||||
}
|
||||
|
||||
void _onPoll(Timer timer) {
|
||||
_model?.refresh(widget.group.id);
|
||||
}
|
||||
|
||||
void _onSend(String value) {
|
||||
final String body = value.trim();
|
||||
if (body.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
_input.clear();
|
||||
_model?.send(GroupMessageRequest(groupId: widget.group.id, body: body));
|
||||
}
|
||||
|
||||
void _onSendPressed() {
|
||||
_onSend(_input.text);
|
||||
}
|
||||
|
||||
void _onQuickReply(String reply) {
|
||||
_onSend(reply);
|
||||
}
|
||||
|
||||
// ── Views ─────────────────────────────────────────────────────────────────
|
||||
|
||||
Widget _mobileView(BoxConstraints constraints) {
|
||||
final List<GroupMessage> newestFirst = _messages.reversed.toList();
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: colorAmbientMid,
|
||||
body: AmbientBackground(
|
||||
child: Column(
|
||||
children: [
|
||||
_topBar(),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
reverse: true,
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||
itemCount: newestFirst.length,
|
||||
itemBuilder: (context, index) {
|
||||
final GroupMessage message = newestFirst[index];
|
||||
final GroupMessage? older = index + 1 < newestFirst.length
|
||||
? newestFirst[index + 1]
|
||||
: null;
|
||||
final bool newDay = older == null ||
|
||||
!_sameDay(older.sentAt, message.sentAt);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (newDay) _dayLabel(message.sentAt),
|
||||
_message(message, older),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
_composer(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _topBar() {
|
||||
final Color accent = goalColor(widget.group.colourHex);
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: <Color>[
|
||||
Color.lerp(accent, colorHeaderBottom, 0.55)!,
|
||||
colorHeaderBottom,
|
||||
],
|
||||
),
|
||||
),
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
16, MediaQuery.of(context).padding.top + 10, 16, 14),
|
||||
child: Row(
|
||||
children: [
|
||||
glassCircle(CupertinoIcons.arrow_left, _onBack,
|
||||
size: 46, onDark: true),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
text(widget.group.name, 19, TextType.Medium,
|
||||
color: colorWhite,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
const SizedBox(height: 3),
|
||||
text(
|
||||
widget.group.members
|
||||
.map((member) => member.me ? "you" : member.name)
|
||||
.join(", "),
|
||||
12,
|
||||
TextType.Regular,
|
||||
color: colorHeaderMuted,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
avatarStack(
|
||||
widget.group.members.map((member) => member.name).toList(), 30,
|
||||
max: 3, border: colorHeaderBottom),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _dayLabel(DateTime? moment) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
child: Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
color: colorWhite.withValues(alpha: 0.7),
|
||||
child: text(_dayName(moment), 11, TextType.Medium,
|
||||
color: colorInkSoft),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Text as bubbles, check-ins and nudges as cards in the thread, system
|
||||
/// notes as a quiet centred line.
|
||||
Widget _message(GroupMessage message, GroupMessage? older) {
|
||||
if (message.isSystem) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: text(
|
||||
"${message.mine ? "You" : message.authorName} ${message.body}",
|
||||
12,
|
||||
TextType.Regular,
|
||||
color: colorInkSoft,
|
||||
align: TextAlign.center,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (message.isCheckIn || message.isNudge) {
|
||||
return _eventCard(message);
|
||||
}
|
||||
|
||||
// Consecutive lines from one person share a single name and portrait.
|
||||
final bool grouped = older != null &&
|
||||
older.authorId == message.authorId &&
|
||||
!older.isSystem &&
|
||||
!older.isCheckIn &&
|
||||
!older.isNudge &&
|
||||
_sameDay(older.sentAt, message.sentAt);
|
||||
|
||||
if (message.mine) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(top: grouped ? 4 : 12, left: 64),
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.fromLTRB(14, 11, 14, 10),
|
||||
color: colorNight,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
text(message.body, 15, TextType.Regular,
|
||||
color: colorWhite, height: 1.4),
|
||||
const SizedBox(height: 4),
|
||||
text(_time(message.sentAt), 10, TextType.Regular,
|
||||
color: colorHeaderMuted),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final Color tone = _authorColour(message.authorName);
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(top: grouped ? 4 : 12, right: 56),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
grouped
|
||||
? const SizedBox(width: 34)
|
||||
: memberAvatar(message.authorName, 34),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.fromLTRB(14, 10, 14, 10),
|
||||
decoration: BoxDecoration(
|
||||
color: colorWhite,
|
||||
border: Border.all(color: colorGlassBorder),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (!grouped) ...[
|
||||
text(message.authorName, 12, TextType.Medium, color: tone),
|
||||
const SizedBox(height: 3),
|
||||
],
|
||||
text(message.body, 15, TextType.Regular,
|
||||
color: colorInk, height: 1.4),
|
||||
const SizedBox(height: 4),
|
||||
text(_time(message.sentAt), 10, TextType.Regular,
|
||||
color: colorInkSoft),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// A check-in or a nudge, set apart from the talk so the work is visible.
|
||||
Widget _eventCard(GroupMessage message) {
|
||||
final bool checkIn = message.isCheckIn;
|
||||
final String who = message.mine ? "You" : message.authorName;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 14, 12),
|
||||
decoration: BoxDecoration(
|
||||
color: checkIn ? colorStandingGoodBg : colorOverdueWarmEnd,
|
||||
border: Border(
|
||||
left: BorderSide(
|
||||
color: checkIn ? colorPositive : colorBarStandard,
|
||||
width: 4,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
memberAvatar(message.authorName, 34),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
checkIn
|
||||
? CupertinoIcons.checkmark_seal_fill
|
||||
: CupertinoIcons.hand_point_right_fill,
|
||||
size: 13,
|
||||
color: checkIn ? colorPositive : colorWarning,
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
text(checkIn ? "CHECK-IN" : "NUDGE", 10, TextType.Bold,
|
||||
color: checkIn ? colorPositive : colorWarning,
|
||||
letterSpacing: 1.0),
|
||||
const Spacer(),
|
||||
text(_time(message.sentAt), 10, TextType.Regular,
|
||||
color: colorInkSoft),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
text("$who ${message.body}", 14, TextType.Regular,
|
||||
color: colorInk, height: 1.4),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _composer() {
|
||||
return Container(
|
||||
color: colorWhite,
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
0, 10, 0, MediaQuery.of(context).padding.bottom + 10),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 38,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: _quickReplies.length,
|
||||
separatorBuilder: (context, index) => const SizedBox(width: 8),
|
||||
itemBuilder: (context, index) => GestureDetector(
|
||||
onTap: () => _onQuickReply(_quickReplies[index]),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
alignment: Alignment.center,
|
||||
color: colorAmbientMid,
|
||||
child: text(_quickReplies[index], 13, TextType.Regular,
|
||||
color: colorInk),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 10, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: quickAddField(_input, "Message the group", _onSend),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
GestureDetector(
|
||||
onTap: _onSendPressed,
|
||||
child: Container(
|
||||
width: 50,
|
||||
height: 50,
|
||||
color: colorNight,
|
||||
child: Icon(CupertinoIcons.paperplane_fill,
|
||||
size: 20, color: colorLime),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
Color _authorColour(String name) {
|
||||
return goalAccents[sceneSeed(name) % goalAccents.length];
|
||||
}
|
||||
|
||||
bool _sameDay(DateTime? a, DateTime? b) {
|
||||
if (a == null || b == null) return false;
|
||||
final DateTime x = a.toLocal();
|
||||
final DateTime y = b.toLocal();
|
||||
return x.year == y.year && x.month == y.month && x.day == y.day;
|
||||
}
|
||||
|
||||
String _dayName(DateTime? moment) {
|
||||
if (moment == null) return "";
|
||||
final DateTime now = DateTime.now();
|
||||
if (_sameDay(moment, now)) return "Today";
|
||||
if (_sameDay(moment, now.subtract(const Duration(days: 1)))) {
|
||||
return "Yesterday";
|
||||
}
|
||||
return formatDate(moment.toLocal());
|
||||
}
|
||||
|
||||
String _time(DateTime? moment) {
|
||||
if (moment == null) return "";
|
||||
final DateTime local = moment.toLocal();
|
||||
return "${local.hour.toString().padLeft(2, "0")}:${local.minute.toString().padLeft(2, "0")}";
|
||||
}
|
||||
|
||||
// ── ConnectGroupChat ──────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
void onMessagesLoaded(List<GroupMessage> messages) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_messages = messages;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void onMessageSent(GroupMessage message) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_messages = <GroupMessage>[..._messages, message];
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void onSendFailed(String body) {
|
||||
if (!mounted) return;
|
||||
_input.text = body;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user