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:
alvocool
2026-11-03 11:46:41 +03:00
parent 4548e1f164
commit 4b71257429
51 changed files with 3871 additions and 157 deletions

View File

@@ -0,0 +1,472 @@
// MOCK DATA — TEMPORARY. See MockSwitch.dart.
import 'dart:math';
import '../about/external/initial/GroupGoalRequest.dart';
import '../about/external/initial/GroupMessageRequest.dart';
import '../about/external/initial/GroupRequest.dart';
import '../about/external/initial/JoinGroupRequest.dart';
import '../about/external/initial/NudgeRequest.dart';
import '../about/external/initial/ObjectiveProgressRequest.dart';
import '../utils/Colors.dart';
import '../utils/CommonUtils.dart';
import 'MockStore.dart';
/// The fake backend's groups: a couple already joined, one waiting behind an
/// invite code, and members who answer in the chat a moment after you post.
class MockGroups {
static const String _me = "mock-user";
static const String _myName = "Alex";
static bool _seeded = false;
static final List<Map<String, dynamic>> _groups = <Map<String, dynamic>>[];
/// Groups that exist but you are not in yet, keyed by invite code.
static final Map<String, Map<String, dynamic>> _joinable =
<String, Map<String, dynamic>>{};
static final Map<String, List<Map<String, dynamic>>> _messages =
<String, List<Map<String, dynamic>>>{};
/// Replies that "arrive" once their time has passed.
static final List<Map<String, dynamic>> _pending = <Map<String, dynamic>>[];
static final Random _random = Random(7);
static Map<String, dynamic> _member(String id, String name,
{String role = "Member",
double kept = 0.7,
int streak = 2,
String standing = "Good",
int checkedInHoursAgo = 5}) {
return <String, dynamic>{
'id': id,
'name': name,
'role': role,
'keptRate': kept,
'streak': streak,
'standing': standing,
'lastCheckIn': DateTime.now()
.subtract(Duration(hours: checkedInHoursAgo))
.toIso8601String(),
'me': id == _me,
};
}
static Map<String, dynamic> _objective(
String id, String title, String unit, double target, double current,
double mine) {
return <String, dynamic>{
'id': id,
'title': title,
'unit': unit,
'target': target,
'current': current,
'mine': mine,
};
}
static Map<String, dynamic> _message(String groupId, String authorId,
String authorName, String body, int minutesAgo,
{String kind = "Text"}) {
return <String, dynamic>{
'id': MockStore.newId('msg'),
'groupId': groupId,
'authorId': authorId,
'authorName': authorName,
'body': body,
'kind': kind,
'sentAt': DateTime.now()
.subtract(Duration(minutes: minutesAgo))
.toIso8601String(),
'mine': authorId == _me,
};
}
static void _ensureSeeded() {
if (_seeded) {
return;
}
_seeded = true;
final DateTime now = DateTime.now();
_groups.addAll(<Map<String, dynamic>>[
<String, dynamic>{
'id': 'group-run',
'name': 'Morning Crew',
'description': 'Out the door before 7, five days a week.',
'colourHex': colorToHex(goalAccents[1]),
'inviteCode': 'MRN7AM',
'unread': 3,
'members': <Map<String, dynamic>>[
_member('m-sam', 'Sam',
role: 'Owner', kept: 0.92, streak: 11, checkedInHoursAgo: 2),
_member(_me, _myName, kept: 0.78, streak: 4, checkedInHoursAgo: 4),
_member('m-priya', 'Priya', kept: 0.85, streak: 7, checkedInHoursAgo: 3),
_member('m-jordan', 'Jordan',
kept: 0.41, streak: 0, standing: 'Warned', checkedInHoursAgo: 52),
],
'goals': <Map<String, dynamic>>[
<String, dynamic>{
'id': 'gg-run',
'title': 'Run 200 km this month',
'description': 'Every kilometre counts, treadmill included.',
'targetDate': DateTime(now.year, now.month + 1, 0).toIso8601String(),
'colourHex': colorToHex(goalAccents[1]),
'objectives': <Map<String, dynamic>>[
_objective('ob-km', 'Distance run', 'km', 200, 86, 22),
_objective('ob-sessions', 'Runs logged', 'runs', 40, 17, 5),
],
},
<String, dynamic>{
'id': 'gg-sleep',
'title': 'In bed before 23:00',
'description': 'Mornings start the night before.',
'targetDate': '',
'colourHex': colorToHex(goalAccents[4]),
'objectives': <Map<String, dynamic>>[
_objective('ob-nights', 'Early nights', 'nights', 60, 31, 9),
],
},
],
},
<String, dynamic>{
'id': 'group-thesis',
'name': 'Thesis Sprint',
'description': 'Drafts in by the 15th. No heroics, just pages.',
'colourHex': colorToHex(goalAccents[3]),
'inviteCode': 'THS15D',
'unread': 0,
'members': <Map<String, dynamic>>[
_member(_me, _myName,
role: 'Owner', kept: 0.78, streak: 4, checkedInHoursAgo: 4),
_member('m-mei', 'Mei', kept: 0.88, streak: 9, checkedInHoursAgo: 6),
_member('m-tom', 'Tom', kept: 0.63, streak: 1, checkedInHoursAgo: 20),
],
'goals': <Map<String, dynamic>>[
<String, dynamic>{
'id': 'gg-drafts',
'title': 'Full drafts submitted',
'description': 'Three theses, one deadline.',
'targetDate': DateTime(now.year, now.month + 1, 15).toIso8601String(),
'colourHex': colorToHex(goalAccents[3]),
'objectives': <Map<String, dynamic>>[
_objective('ob-words', 'Words written', 'words', 30000, 18400, 7200),
_objective('ob-chapters', 'Chapters reviewed', 'chapters', 9, 3, 1),
_objective('ob-hours', 'Deep-work hours', 'hours', 120, 64, 23),
],
},
],
},
]);
_joinable['WLK2GO'] = <String, dynamic>{
'id': 'group-walk',
'name': 'Weekend Walkers',
'description': 'Ten thousand steps, Saturday and Sunday, no excuses.',
'colourHex': colorToHex(goalAccents[5]),
'inviteCode': 'WLK2GO',
'unread': 1,
'members': <Map<String, dynamic>>[
_member('m-ade', 'Ade', role: 'Owner', kept: 0.81, streak: 6),
_member('m-lena', 'Lena', kept: 0.74, streak: 3),
],
'goals': <Map<String, dynamic>>[
<String, dynamic>{
'id': 'gg-steps',
'title': '1 million steps together',
'description': '',
'targetDate': '',
'colourHex': colorToHex(goalAccents[5]),
'objectives': <Map<String, dynamic>>[
_objective('ob-steps', 'Steps', 'steps', 1000000, 412000, 0),
],
},
],
};
_messages['group-run'] = <Map<String, dynamic>>[
_message('group-run', 'm-sam', 'Sam',
'Same route tomorrow? Gate at 6:15.', 60 * 20),
_message('group-run', _me, _myName, "I'm in. Bringing Priya's spare torch.",
60 * 19 + 40),
_message('group-run', 'm-priya', 'Priya', 'logged 6 km on Distance run',
60 * 3, kind: 'CheckIn'),
_message('group-run', 'm-sam', 'Sam', 'logged 8 km on Distance run',
60 * 2, kind: 'CheckIn'),
_message('group-run', 'm-sam', 'Sam',
'nudged Jordan: two days without a run, we miss you out there',
95, kind: 'Nudge'),
_message('group-run', 'm-priya', 'Priya',
'86 km down. We need about 9 a day to make it, doable.', 40),
];
_messages['group-thesis'] = <Map<String, dynamic>>[
_message('group-thesis', _me, _myName, 'started the group', 60 * 24 * 6,
kind: 'System'),
_message('group-thesis', 'm-mei', 'Mei',
'Chapter 3 is with my supervisor. Terrified.', 60 * 26),
_message('group-thesis', _me, _myName, 'logged 1,200 words on Words written',
60 * 22, kind: 'CheckIn'),
_message('group-thesis', 'm-tom', 'Tom',
'Library 9 to 1 tomorrow if anyone wants a co-working block.', 60 * 7),
];
_messages['group-walk'] = <Map<String, dynamic>>[
_message('group-walk', 'm-ade', 'Ade',
'Welcome! Saturday loop starts at the bridge, 9:00.', 60 * 30),
];
}
static Map<String, dynamic>? _find(String id) {
for (Map<String, dynamic> group in _groups) {
if (group['id'] == id) {
return group;
}
}
return null;
}
/// Moves any replies whose moment has come into their threads.
static void _deliver() {
final DateTime now = DateTime.now();
final List<Map<String, dynamic>> due = _pending
.where((item) => DateTime.parse(item['sentAt']).isBefore(now))
.toList();
for (Map<String, dynamic> message in due) {
_pending.remove(message);
_messages.putIfAbsent(message['groupId'], () => <Map<String, dynamic>>[])
.add(message);
final Map<String, dynamic>? group = _find(message['groupId']);
if (group != null) {
group['unread'] = (group['unread'] ?? 0) + 1;
}
}
}
static Map<String, dynamic> _withLast(Map<String, dynamic> group) {
final List<Map<String, dynamic>> thread =
_messages[group['id']] ?? <Map<String, dynamic>>[];
final Map<String, dynamic> copy = Map<String, dynamic>.from(group);
if (thread.isNotEmpty) {
final Map<String, dynamic> last = thread.last;
copy['lastMessage'] = last['body'];
copy['lastAuthor'] = last['authorName'];
copy['lastMessageAt'] = last['sentAt'];
}
return copy;
}
static void _post(String groupId, String body, String kind) {
_messages.putIfAbsent(groupId, () => <Map<String, dynamic>>[]).add(
_message(groupId, _me, _myName, body, 0, kind: kind));
}
/// Someone answers a few seconds later, so the chat feels inhabited.
static void _queueReply(String groupId) {
final Map<String, dynamic>? group = _find(groupId);
if (group == null) {
return;
}
final List<dynamic> others = (group['members'] as List)
.where((member) => member['id'] != _me)
.toList();
if (others.isEmpty) {
return;
}
const List<String> lines = <String>[
"Love it. Keep that going.",
"Same here, just finished mine.",
"That's the spirit.",
"Noted. I'll match you tomorrow.",
"Good — that puts us back on pace.",
"Who's in for the next one?",
];
final Map<String, dynamic> author = others[_random.nextInt(others.length)];
final Map<String, dynamic> reply = _message(groupId, author['id'],
author['name'], lines[_random.nextInt(lines.length)], 0);
reply['sentAt'] =
DateTime.now().add(const Duration(seconds: 3)).toIso8601String();
_pending.add(reply);
}
static String _code() {
const String letters = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
return List<String>.generate(
6, (index) => letters[_random.nextInt(letters.length)]).join();
}
// ── Endpoints ─────────────────────────────────────────────────────────────
static List<Map<String, dynamic>> mine() {
_ensureSeeded();
_deliver();
return _groups.map(_withLast).toList();
}
static Map<String, dynamic> one(String id) {
_ensureSeeded();
_deliver();
return _withLast(_find(id) ?? <String, dynamic>{});
}
static Map<String, dynamic> save(GroupRequest request) {
_ensureSeeded();
final Map<String, dynamic>? existing =
request.id == null ? null : _find(request.id!);
if (existing != null) {
existing['name'] = request.name;
existing['description'] = request.description;
existing['colourHex'] = request.colourHex;
return _withLast(existing);
}
final String id = MockStore.newId('group');
final Map<String, dynamic> group = <String, dynamic>{
'id': id,
'name': request.name,
'description': request.description,
'colourHex': request.colourHex,
'inviteCode': _code(),
'unread': 0,
'members': <Map<String, dynamic>>[
_member(_me, _myName, role: 'Owner', kept: 0.78, streak: 4,
checkedInHoursAgo: 0),
],
'goals': <Map<String, dynamic>>[],
};
_groups.insert(0, group);
_post(id, 'started the group', 'System');
return _withLast(group);
}
static Map<String, dynamic> join(JoinGroupRequest request) {
_ensureSeeded();
final String code = request.inviteCode.trim().toUpperCase();
for (Map<String, dynamic> group in _groups) {
if (group['inviteCode'] == code) {
return _withLast(group);
}
}
// Any well-formed code finds the waiting group, so the preview always
// has something to join.
final Map<String, dynamic> group = _joinable.remove(code) ??
(_joinable.isNotEmpty
? _joinable.remove(_joinable.keys.first)!
: <String, dynamic>{
'id': MockStore.newId('group'),
'name': 'Group $code',
'description': '',
'colourHex': colorToHex(goalAccents[0]),
'inviteCode': code,
'unread': 0,
'members': <Map<String, dynamic>>[],
'goals': <Map<String, dynamic>>[],
});
(group['members'] as List).add(_member(_me, _myName,
kept: 0.78, streak: 4, checkedInHoursAgo: 0));
_groups.insert(0, group);
_post(group['id'], 'joined the group', 'System');
_queueReply(group['id']);
return _withLast(group);
}
static Map<String, dynamic> leave(String id) {
_ensureSeeded();
_groups.removeWhere((group) => group['id'] == id);
return <String, dynamic>{'success': true};
}
static Map<String, dynamic> saveGoal(GroupGoalRequest request) {
_ensureSeeded();
final Map<String, dynamic>? group = _find(request.groupId);
if (group == null) {
return <String, dynamic>{};
}
final Map<String, dynamic> goal = <String, dynamic>{
'id': MockStore.newId('gg'),
'title': request.title,
'description': request.description,
'targetDate': request.targetDate,
'colourHex': request.colourHex,
'objectives': request.objectives
.map((draft) => _objective(MockStore.newId('ob'), draft.title,
draft.unit, draft.target, 0, 0))
.toList(),
};
(group['goals'] as List).add(goal);
_post(group['id'], 'added a shared goal: ${request.title}', 'System');
return _withLast(group);
}
static Map<String, dynamic> logProgress(ObjectiveProgressRequest request) {
_ensureSeeded();
final Map<String, dynamic>? group = _find(request.groupId);
if (group == null) {
return <String, dynamic>{};
}
for (Map<String, dynamic> goal in (group['goals'] as List)) {
if (goal['id'] != request.goalId) {
continue;
}
for (Map<String, dynamic> objective in (goal['objectives'] as List)) {
if (objective['id'] != request.objectiveId) {
continue;
}
objective['current'] = (objective['current'] as num) + request.amount;
objective['mine'] = (objective['mine'] as num) + request.amount;
final String amount = formatAmount(request.amount);
_post(
group['id'],
'logged $amount ${objective['unit']} on ${objective['title']}'
'${request.note.isEmpty ? "" : " — ${request.note}"}',
'CheckIn');
_queueReply(group['id']);
}
}
for (Map<String, dynamic> member in (group['members'] as List)) {
if (member['id'] == _me) {
member['lastCheckIn'] = DateTime.now().toIso8601String();
}
}
return _withLast(group);
}
static List<Map<String, dynamic>> messages(String groupId) {
_ensureSeeded();
_deliver();
final Map<String, dynamic>? group = _find(groupId);
if (group != null) {
group['unread'] = 0;
}
return List<Map<String, dynamic>>.from(
_messages[groupId] ?? <Map<String, dynamic>>[]);
}
static Map<String, dynamic> send(GroupMessageRequest request) {
_ensureSeeded();
_post(request.groupId, request.body, request.kind);
_queueReply(request.groupId);
return _messages[request.groupId]!.last;
}
static Map<String, dynamic> nudge(NudgeRequest request) {
_ensureSeeded();
final Map<String, dynamic>? group = _find(request.groupId);
if (group == null) {
return <String, dynamic>{'success': false};
}
String who = "everyone";
for (Map<String, dynamic> member in (group['members'] as List)) {
if (member['id'] == request.memberId) {
who = member['name'];
}
}
_post(request.groupId, 'nudged $who: today still counts, go get it',
'Nudge');
return <String, dynamic>{'success': true};
}
}