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
465 lines
15 KiB
Dart
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;
|
|
}
|
|
}
|