Files
GroundedHelper/frontend/lib/Grounded/see/group/GroupChatState.dart
alvocool 4b71257429 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
2026-11-03 11:46:41 +03:00

465 lines
15 KiB
Dart

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;
}
}