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
473 lines
16 KiB
Dart
473 lines
16 KiB
Dart
// 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};
|
|
}
|
|
}
|