diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 7e9c400..f98408b 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -32,7 +32,7 @@ informatics/ DataManager · AppDataManager memory/ ConnectInternalMemory · InternalMemory configs/ Navigator · Env · NotificationServiceConfig designs/ Component · Shell · Responsive · buttons/ input/ text/ -see// screens, 4 files each +see// screens, 4 files each (group/ = groups, shared goals, chat) utils/ Colors · CommonUtils · engines · validators ``` diff --git a/frontend/design.md b/frontend/design.md index f8ad7d1..76b8829 100644 --- a/frontend/design.md +++ b/frontend/design.md @@ -88,5 +88,24 @@ minutes held against planned, a ring with + that opens the "Add to today" sheet, the plan as thumbnails, the report-card letter as the Score, and one bar per commitment class. Weekly swaps in the week's average, a bar per day and the class completion rates. Below the block, on the light ambient -surface: the water tracker (drops of 250 ml, kept on the device), then the -rest of the day. Bars on the dark block use the `colorBar*` tokens. +surface: the rest of the day. Bars on the dark block use the `colorBar*` tokens. + +## Groups + +`see/group/`: people keeping each other honest. A group has members, shared +goals and a chat. + +- **Shared goals** are split into **objectives** with a number and a unit + (200 km, 40 runs, 30,000 words). Every member logs toward them; the card + shows the group total, your share and a night **Log** button. Like + personal goals, they carry no debt. +- **Leaderboard** ranks members by kept rate over the last 7 days, with + streak, last check-in (red "quiet" after 24h) and a nudge button. +- **Chat** keeps the work and the talk together: plain messages are square + bubbles (yours in night, others white with the author's colour), while + check-ins (green bar) and nudges (amber bar) are cards in the thread. + The open chat polls every 4 seconds. +- Members are drawn by `memberAvatar` / `avatarStack` + (`designs/MemberAvatar.dart`): square painted portraits with the initial. +- Groups are reached from the nav (Groups tab), the Home "Your groups" rail + (tap opens the chat) and the add sheet. diff --git a/frontend/lib/Grounded/about/external/data/Group.dart b/frontend/lib/Grounded/about/external/data/Group.dart new file mode 100644 index 0000000..cbba18e --- /dev/null +++ b/frontend/lib/Grounded/about/external/data/Group.dart @@ -0,0 +1,94 @@ +import '../../../utils/ObjectConvertors.dart'; +import 'GroupGoal.dart'; +import 'GroupMember.dart'; + +/// People keeping each other honest: members, the goals they share, and the +/// chat they talk in. +class Group { + String id; + + String name; + + String description; + + String colourHex; + + /// Six characters a friend types to join. + String inviteCode; + + List members; + + List goals; + + /// Messages the signed-in user has not seen yet. + int unread; + + String lastMessage; + + String lastAuthor; + + DateTime? lastMessageAt; + + Group({ + this.id = "", + this.name = "", + this.description = "", + this.colourHex = "", + this.inviteCode = "", + List? members, + List? goals, + this.unread = 0, + this.lastMessage = "", + this.lastAuthor = "", + this.lastMessageAt, + }) : members = members ?? [], + goals = goals ?? []; + + /// Average progress across the shared goals. + double get progress { + if (goals.isEmpty) { + return 0; + } + double total = 0; + for (GroupGoal goal in goals) { + total = total + goal.progress; + } + return total / goals.length; + } + + bool get iOwn => members.any((member) => member.me && member.owner); + + factory Group.fromJson(Map json) { + return Group( + id: json['id'] ?? "", + name: json['name'] ?? "", + description: json['description'] ?? "", + colourHex: json['colourHex'] ?? "", + inviteCode: json['inviteCode'] ?? "", + members: getGroupMemberList(json['members']), + goals: getGroupGoalList(json['goals']), + unread: json['unread'] ?? 0, + lastMessage: json['lastMessage'] ?? "", + lastAuthor: json['lastAuthor'] ?? "", + lastMessageAt: json['lastMessageAt'] == null + ? null + : DateTime.tryParse(json['lastMessageAt']), + ); + } + + Map toJson() { + final Map data = {}; + data['id'] = id; + data['name'] = name; + data['description'] = description; + data['colourHex'] = colourHex; + data['inviteCode'] = inviteCode; + data['members'] = members.map((item) => item.toJson()).toList(); + data['goals'] = goals.map((item) => item.toJson()).toList(); + data['unread'] = unread; + data['lastMessage'] = lastMessage; + data['lastAuthor'] = lastAuthor; + data['lastMessageAt'] = lastMessageAt?.toIso8601String(); + return data; + } +} diff --git a/frontend/lib/Grounded/about/external/data/GroupGoal.dart b/frontend/lib/Grounded/about/external/data/GroupGoal.dart new file mode 100644 index 0000000..b2ed8ab --- /dev/null +++ b/frontend/lib/Grounded/about/external/data/GroupGoal.dart @@ -0,0 +1,67 @@ +import '../../../utils/ObjectConvertors.dart'; +import 'GroupObjective.dart'; + +/// A goal the whole group shares, broken into objectives everyone logs +/// against. Like a personal goal it carries no debt of its own. +class GroupGoal { + String id; + + String title; + + String description; + + DateTime? targetDate; + + String colourHex; + + List objectives; + + GroupGoal({ + this.id = "", + this.title = "", + this.description = "", + this.targetDate, + this.colourHex = "", + List? objectives, + }) : objectives = objectives ?? []; + + /// The average of the objectives, so one runaway objective cannot carry + /// the rest. + double get progress { + if (objectives.isEmpty) { + return 0; + } + double total = 0; + for (GroupObjective objective in objectives) { + total = total + objective.progress; + } + return total / objectives.length; + } + + int get completeObjectives => + objectives.where((objective) => objective.complete).length; + + factory GroupGoal.fromJson(Map json) { + return GroupGoal( + id: json['id'] ?? "", + title: json['title'] ?? "", + description: json['description'] ?? "", + targetDate: json['targetDate'] == null || json['targetDate'] == "" + ? null + : DateTime.tryParse(json['targetDate']), + colourHex: json['colourHex'] ?? "", + objectives: getGroupObjectiveList(json['objectives']), + ); + } + + Map toJson() { + final Map data = {}; + data['id'] = id; + data['title'] = title; + data['description'] = description; + data['targetDate'] = targetDate?.toIso8601String(); + data['colourHex'] = colourHex; + data['objectives'] = objectives.map((item) => item.toJson()).toList(); + return data; + } +} diff --git a/frontend/lib/Grounded/about/external/data/GroupMember.dart b/frontend/lib/Grounded/about/external/data/GroupMember.dart new file mode 100644 index 0000000..efb16bb --- /dev/null +++ b/frontend/lib/Grounded/about/external/data/GroupMember.dart @@ -0,0 +1,64 @@ +/// Someone in a group, with the numbers the leaderboard ranks on. +class GroupMember { + String id; + + String name; + + /// "Owner" or "Member". + String role; + + /// Share of their own commitments kept over the last seven days, 0..1. + double keptRate; + + /// Kept days in a row. + int streak; + + /// Their standing name — Good, Warned, Grounded, Lockdown. + String standing; + + DateTime? lastCheckIn; + + /// True for the signed-in user. + bool me; + + GroupMember({ + this.id = "", + this.name = "", + this.role = "Member", + this.keptRate = 0, + this.streak = 0, + this.standing = "Good", + this.lastCheckIn, + this.me = false, + }); + + bool get owner => role == "Owner"; + + factory GroupMember.fromJson(Map json) { + return GroupMember( + id: json['id'] ?? "", + name: json['name'] ?? "", + role: json['role'] ?? "Member", + keptRate: (json['keptRate'] ?? 0).toDouble(), + streak: json['streak'] ?? 0, + standing: json['standing'] ?? "Good", + lastCheckIn: json['lastCheckIn'] == null + ? null + : DateTime.tryParse(json['lastCheckIn']), + me: json['me'] ?? false, + ); + } + + Map toJson() { + final Map data = {}; + data['id'] = id; + data['name'] = name; + data['role'] = role; + data['keptRate'] = keptRate; + data['streak'] = streak; + data['standing'] = standing; + data['lastCheckIn'] = lastCheckIn?.toIso8601String(); + data['me'] = me; + return data; + } +} diff --git a/frontend/lib/Grounded/about/external/data/GroupMessage.dart b/frontend/lib/Grounded/about/external/data/GroupMessage.dart new file mode 100644 index 0000000..eb7922c --- /dev/null +++ b/frontend/lib/Grounded/about/external/data/GroupMessage.dart @@ -0,0 +1,66 @@ +/// One line in a group chat. Besides plain text, the app posts check-ins +/// (progress logged on an objective), nudges and system notes into the same +/// thread so the conversation and the work sit together. +class GroupMessage { + String id; + + String groupId; + + String authorId; + + String authorName; + + String body; + + /// "Text", "CheckIn", "Nudge" or "System". + String kind; + + DateTime? sentAt; + + /// True when the signed-in user wrote it. + bool mine; + + GroupMessage({ + this.id = "", + this.groupId = "", + this.authorId = "", + this.authorName = "", + this.body = "", + this.kind = "Text", + this.sentAt, + this.mine = false, + }); + + bool get isCheckIn => kind == "CheckIn"; + + bool get isNudge => kind == "Nudge"; + + bool get isSystem => kind == "System"; + + factory GroupMessage.fromJson(Map json) { + return GroupMessage( + id: json['id'] ?? "", + groupId: json['groupId'] ?? "", + authorId: json['authorId'] ?? "", + authorName: json['authorName'] ?? "", + body: json['body'] ?? "", + kind: json['kind'] ?? "Text", + sentAt: + json['sentAt'] == null ? null : DateTime.tryParse(json['sentAt']), + mine: json['mine'] ?? false, + ); + } + + Map toJson() { + final Map data = {}; + data['id'] = id; + data['groupId'] = groupId; + data['authorId'] = authorId; + data['authorName'] = authorName; + data['body'] = body; + data['kind'] = kind; + data['sentAt'] = sentAt?.toIso8601String(); + data['mine'] = mine; + return data; + } +} diff --git a/frontend/lib/Grounded/about/external/data/GroupObjective.dart b/frontend/lib/Grounded/about/external/data/GroupObjective.dart new file mode 100644 index 0000000..a7ec7ff --- /dev/null +++ b/frontend/lib/Grounded/about/external/data/GroupObjective.dart @@ -0,0 +1,58 @@ +/// One measurable part of a shared goal — "Run 100 km", "20 sessions" — that +/// every member logs toward. +class GroupObjective { + String id; + + String title; + + /// km, sessions, pages, hours… + String unit; + + double target; + + /// The group's total so far. + double current; + + /// What the signed-in user has put in. + double mine; + + GroupObjective({ + this.id = "", + this.title = "", + this.unit = "", + this.target = 0, + this.current = 0, + this.mine = 0, + }); + + double get progress { + if (target <= 0) { + return 0; + } + return (current / target).clamp(0.0, 1.0); + } + + bool get complete => target > 0 && current >= target; + + factory GroupObjective.fromJson(Map json) { + return GroupObjective( + id: json['id'] ?? "", + title: json['title'] ?? "", + unit: json['unit'] ?? "", + target: (json['target'] ?? 0).toDouble(), + current: (json['current'] ?? 0).toDouble(), + mine: (json['mine'] ?? 0).toDouble(), + ); + } + + Map toJson() { + final Map data = {}; + data['id'] = id; + data['title'] = title; + data['unit'] = unit; + data['target'] = target; + data['current'] = current; + data['mine'] = mine; + return data; + } +} diff --git a/frontend/lib/Grounded/about/external/initial/GroupGoalRequest.dart b/frontend/lib/Grounded/about/external/initial/GroupGoalRequest.dart new file mode 100644 index 0000000..405cd8a --- /dev/null +++ b/frontend/lib/Grounded/about/external/initial/GroupGoalRequest.dart @@ -0,0 +1,40 @@ +import 'ObjectiveDraft.dart'; + +/// Creates a shared goal in a group, objectives and all. +class GroupGoalRequest { + String groupId; + + String? id; + + String title; + + String description; + + String targetDate; + + String colourHex; + + List objectives; + + GroupGoalRequest({ + this.groupId = "", + this.id, + this.title = "", + this.description = "", + this.targetDate = "", + this.colourHex = "", + List? objectives, + }) : objectives = objectives ?? []; + + Map toJson() { + final Map data = {}; + data['groupId'] = groupId; + data['id'] = id; + data['title'] = title; + data['description'] = description; + data['targetDate'] = targetDate; + data['colourHex'] = colourHex; + data['objectives'] = objectives.map((item) => item.toJson()).toList(); + return data; + } +} diff --git a/frontend/lib/Grounded/about/external/initial/GroupMessageRequest.dart b/frontend/lib/Grounded/about/external/initial/GroupMessageRequest.dart new file mode 100644 index 0000000..4d49926 --- /dev/null +++ b/frontend/lib/Grounded/about/external/initial/GroupMessageRequest.dart @@ -0,0 +1,18 @@ +/// Posts a message to a group chat. +class GroupMessageRequest { + String groupId; + + String body; + + String kind; + + GroupMessageRequest({this.groupId = "", this.body = "", this.kind = "Text"}); + + Map toJson() { + final Map data = {}; + data['groupId'] = groupId; + data['body'] = body; + data['kind'] = kind; + return data; + } +} diff --git a/frontend/lib/Grounded/about/external/initial/GroupRequest.dart b/frontend/lib/Grounded/about/external/initial/GroupRequest.dart new file mode 100644 index 0000000..66fd5db --- /dev/null +++ b/frontend/lib/Grounded/about/external/initial/GroupRequest.dart @@ -0,0 +1,21 @@ +/// Creates a group, or renames one when [id] is set. +class GroupRequest { + String? id; + + String name; + + String description; + + String colourHex; + + GroupRequest({this.id, this.name = "", this.description = "", this.colourHex = ""}); + + Map toJson() { + final Map data = {}; + data['id'] = id; + data['name'] = name; + data['description'] = description; + data['colourHex'] = colourHex; + return data; + } +} diff --git a/frontend/lib/Grounded/about/external/initial/JoinGroupRequest.dart b/frontend/lib/Grounded/about/external/initial/JoinGroupRequest.dart new file mode 100644 index 0000000..ba90c0c --- /dev/null +++ b/frontend/lib/Grounded/about/external/initial/JoinGroupRequest.dart @@ -0,0 +1,12 @@ +/// Joins a group by the code a friend shared. +class JoinGroupRequest { + String inviteCode; + + JoinGroupRequest({this.inviteCode = ""}); + + Map toJson() { + final Map data = {}; + data['inviteCode'] = inviteCode; + return data; + } +} diff --git a/frontend/lib/Grounded/about/external/initial/NudgeRequest.dart b/frontend/lib/Grounded/about/external/initial/NudgeRequest.dart new file mode 100644 index 0000000..4bf9d83 --- /dev/null +++ b/frontend/lib/Grounded/about/external/initial/NudgeRequest.dart @@ -0,0 +1,15 @@ +/// Nudges one member, or everyone when [memberId] is empty. +class NudgeRequest { + String groupId; + + String memberId; + + NudgeRequest({this.groupId = "", this.memberId = ""}); + + Map toJson() { + final Map data = {}; + data['groupId'] = groupId; + data['memberId'] = memberId; + return data; + } +} diff --git a/frontend/lib/Grounded/about/external/initial/ObjectiveDraft.dart b/frontend/lib/Grounded/about/external/initial/ObjectiveDraft.dart new file mode 100644 index 0000000..5b70dbd --- /dev/null +++ b/frontend/lib/Grounded/about/external/initial/ObjectiveDraft.dart @@ -0,0 +1,18 @@ +/// One objective as typed into the shared-goal editor. +class ObjectiveDraft { + String title; + + String unit; + + double target; + + ObjectiveDraft({this.title = "", this.unit = "", this.target = 0}); + + Map toJson() { + final Map data = {}; + data['title'] = title; + data['unit'] = unit; + data['target'] = target; + return data; + } +} diff --git a/frontend/lib/Grounded/about/external/initial/ObjectiveProgressRequest.dart b/frontend/lib/Grounded/about/external/initial/ObjectiveProgressRequest.dart new file mode 100644 index 0000000..707372e --- /dev/null +++ b/frontend/lib/Grounded/about/external/initial/ObjectiveProgressRequest.dart @@ -0,0 +1,24 @@ +/// Logs an amount against one objective of a shared goal. +class ObjectiveProgressRequest { + String groupId; + + String goalId; + + String objectiveId; + + double amount; + + String note; + + ObjectiveProgressRequest({this.groupId = "", this.goalId = "", this.objectiveId = "", this.amount = 0, this.note = ""}); + + Map toJson() { + final Map data = {}; + data['groupId'] = groupId; + data['goalId'] = goalId; + data['objectiveId'] = objectiveId; + data['amount'] = amount; + data['note'] = note; + return data; + } +} diff --git a/frontend/lib/Grounded/comms/Comms.dart b/frontend/lib/Grounded/comms/Comms.dart index 46b17e6..543741b 100644 --- a/frontend/lib/Grounded/comms/Comms.dart +++ b/frontend/lib/Grounded/comms/Comms.dart @@ -9,6 +9,12 @@ import '../about/external/initial/CompletionRequest.dart'; import '../about/external/initial/DeferralRequest.dart'; import '../about/external/initial/DeviceRequest.dart'; import '../about/external/initial/GoalRequest.dart'; +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 '../about/external/initial/IdRequest.dart'; import '../about/external/initial/LoginData.dart'; import '../about/external/initial/ReportCardRequest.dart'; @@ -512,4 +518,76 @@ class Comms implements ConnectComms { dio.options.headers = navigation.value; return await dio.post(navigation.key, data: request); } + + // ── Groups ──────────────────────────────────────────────────────────────── + + @override + Future getMyGroups(HistoryRequest request) async { + Pair navigation = await getRequestHeaders(myGroups, ""); + dio.options.headers = navigation.value; + return await dio.post(navigation.key, data: request); + } + + @override + Future getGroupEntry(IdRequest request) async { + Pair navigation = await getRequestHeaders(oneGroup, ""); + dio.options.headers = navigation.value; + return await dio.post(navigation.key, data: request); + } + + @override + Future saveGroupEntry(GroupRequest request) async { + Pair navigation = await getRequestHeaders(saveGroup, ""); + dio.options.headers = navigation.value; + return await dio.post(navigation.key, data: request); + } + + @override + Future joinGroupEntry(JoinGroupRequest request) async { + Pair navigation = await getRequestHeaders(joinGroup, ""); + dio.options.headers = navigation.value; + return await dio.post(navigation.key, data: request); + } + + @override + Future leaveGroupEntry(IdRequest request) async { + Pair navigation = await getRequestHeaders(leaveGroup, ""); + dio.options.headers = navigation.value; + return await dio.post(navigation.key, data: request); + } + + @override + Future saveGroupGoalEntry(GroupGoalRequest request) async { + Pair navigation = await getRequestHeaders(saveGroupGoal, ""); + dio.options.headers = navigation.value; + return await dio.post(navigation.key, data: request); + } + + @override + Future logObjectiveProgress(ObjectiveProgressRequest request) async { + Pair navigation = await getRequestHeaders(logObjective, ""); + dio.options.headers = navigation.value; + return await dio.post(navigation.key, data: request); + } + + @override + Future getGroupMessages(IdRequest request) async { + Pair navigation = await getRequestHeaders(groupMessages, ""); + dio.options.headers = navigation.value; + return await dio.post(navigation.key, data: request); + } + + @override + Future sendGroupMessage(GroupMessageRequest request) async { + Pair navigation = await getRequestHeaders(sendGroupMessageTo, ""); + dio.options.headers = navigation.value; + return await dio.post(navigation.key, data: request); + } + + @override + Future nudgeGroupMember(NudgeRequest request) async { + Pair navigation = await getRequestHeaders(nudgeMember, ""); + dio.options.headers = navigation.value; + return await dio.post(navigation.key, data: request); + } } diff --git a/frontend/lib/Grounded/comms/CommsDirections.dart b/frontend/lib/Grounded/comms/CommsDirections.dart index fd618c3..afdde49 100644 --- a/frontend/lib/Grounded/comms/CommsDirections.dart +++ b/frontend/lib/Grounded/comms/CommsDirections.dart @@ -172,3 +172,24 @@ String myPartners = 'Partner/Mine'; String invitePartner = 'Partner/Invite'; String partnerPolicy = 'Partner/Policy'; + +// ── Groups ──────────────────────────────────────────────────────────────────── +String myGroups = 'Group/Mine'; + +String oneGroup = 'Group/One'; + +String saveGroup = 'Group/Save'; + +String joinGroup = 'Group/Join'; + +String leaveGroup = 'Group/Leave'; + +String saveGroupGoal = 'Group/Goal/Save'; + +String logObjective = 'Group/Objective/Log'; + +String groupMessages = 'Group/Messages'; + +String sendGroupMessageTo = 'Group/Message/Send'; + +String nudgeMember = 'Group/Nudge'; diff --git a/frontend/lib/Grounded/comms/ConnectComms.dart b/frontend/lib/Grounded/comms/ConnectComms.dart index 3621500..c3bba0d 100644 --- a/frontend/lib/Grounded/comms/ConnectComms.dart +++ b/frontend/lib/Grounded/comms/ConnectComms.dart @@ -9,6 +9,12 @@ import '../about/external/initial/CompletionRequest.dart'; import '../about/external/initial/DeferralRequest.dart'; import '../about/external/initial/DeviceRequest.dart'; import '../about/external/initial/GoalRequest.dart'; +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 '../about/external/initial/IdRequest.dart'; import '../about/external/initial/LoginData.dart'; import '../about/external/initial/ReportCardRequest.dart'; @@ -144,4 +150,25 @@ abstract class ConnectComms { Future getMyNotifications(HistoryRequest request); Future acknowledgeNudgeEntry(IdRequest request); + + // ── Groups ────────────────────────────────────────────────────────────── + Future getMyGroups(HistoryRequest request); + + Future getGroupEntry(IdRequest request); + + Future saveGroupEntry(GroupRequest request); + + Future joinGroupEntry(JoinGroupRequest request); + + Future leaveGroupEntry(IdRequest request); + + Future saveGroupGoalEntry(GroupGoalRequest request); + + Future logObjectiveProgress(ObjectiveProgressRequest request); + + Future getGroupMessages(IdRequest request); + + Future sendGroupMessage(GroupMessageRequest request); + + Future nudgeGroupMember(NudgeRequest request); } diff --git a/frontend/lib/Grounded/designs/MemberAvatar.dart b/frontend/lib/Grounded/designs/MemberAvatar.dart new file mode 100644 index 0000000..7ef9aa1 --- /dev/null +++ b/frontend/lib/Grounded/designs/MemberAvatar.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; + +import '../about/internal/application/TextType.dart'; +import '../utils/Colors.dart'; +import '../utils/CommonUtils.dart'; +import 'scene/SceneArt.dart'; +import 'text/Text.dart'; + +/// A member's square portrait: a landscape picked from their name with the +/// initial on it, so everyone is recognisable without a photo. +Widget memberAvatar(String name, double size, {Color? border}) { + const List> palettes = >[ + sceneWorkout, + sceneMeditation, + sceneMustDo, + ]; + final int seed = sceneSeed(name.isEmpty ? "?" : name); + final String initial = + name.isEmpty ? "?" : name.substring(0, 1).toUpperCase(); + + return Container( + width: size, + height: size, + decoration: + BoxDecoration(border: Border.all(color: border ?? colorWhite, width: 2)), + child: Stack( + fit: StackFit.expand, + children: [ + SceneArt(palette: palettes[seed % palettes.length], seed: seed), + Center( + child: text(initial, size * 0.42, TextType.Medium, color: colorWhite), + ), + ], + ), + ); +} + +/// Several portraits overlapping, with "+N" once there are more than fit. +Widget avatarStack(List names, double size, + {int max = 4, Color? border}) { + final List shown = names.take(max).toList(); + final int extra = names.length - shown.length; + final double step = size * 0.68; + final int slots = shown.length + (extra > 0 ? 1 : 0); + + return SizedBox( + width: slots == 0 ? 0 : size + step * (slots - 1), + height: size, + child: Stack( + children: [ + for (int index = 0; index < shown.length; index++) + Positioned( + left: step * index, + child: memberAvatar(shown[index], size, border: border), + ), + if (extra > 0) + Positioned( + left: step * shown.length, + child: Container( + width: size, + height: size, + alignment: Alignment.center, + decoration: BoxDecoration( + color: colorNight, + border: Border.all(color: border ?? colorWhite, width: 2), + ), + child: text("+$extra", size * 0.34, TextType.Medium, + color: colorWhite), + ), + ), + ], + ), + ); +} diff --git a/frontend/lib/Grounded/informatics/AppDataManager.dart b/frontend/lib/Grounded/informatics/AppDataManager.dart index 725f57d..5086bcd 100644 --- a/frontend/lib/Grounded/informatics/AppDataManager.dart +++ b/frontend/lib/Grounded/informatics/AppDataManager.dart @@ -14,6 +14,12 @@ import '../about/external/initial/CompletionRequest.dart'; import '../about/external/initial/DeferralRequest.dart'; import '../about/external/initial/DeviceRequest.dart'; import '../about/external/initial/GoalRequest.dart'; +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 '../about/external/initial/IdRequest.dart'; import '../about/external/initial/LoginData.dart'; import '../about/external/initial/ReportCardRequest.dart'; @@ -120,12 +126,6 @@ class AppDataManager implements DataManager { @override Future setAmnestySpent(int spent) => memory.setAmnestySpent(spent); - @override - Future getWaterToday() => memory.getWaterToday(); - - @override - Future setWaterToday(int ml) => memory.setWaterToday(ml); - @override Future> getTodos() => memory.getTodos(); @@ -376,4 +376,36 @@ class AppDataManager implements DataManager { @override Future acknowledgeNudgeEntry(IdRequest request) => comms.acknowledgeNudgeEntry(request); + + // ── Groups ──────────────────────────────────────────────────────────────── + + @override + Future getMyGroups(HistoryRequest request) => comms.getMyGroups(request); + + @override + Future getGroupEntry(IdRequest request) => comms.getGroupEntry(request); + + @override + Future saveGroupEntry(GroupRequest request) => comms.saveGroupEntry(request); + + @override + Future joinGroupEntry(JoinGroupRequest request) => comms.joinGroupEntry(request); + + @override + Future leaveGroupEntry(IdRequest request) => comms.leaveGroupEntry(request); + + @override + Future saveGroupGoalEntry(GroupGoalRequest request) => comms.saveGroupGoalEntry(request); + + @override + Future logObjectiveProgress(ObjectiveProgressRequest request) => comms.logObjectiveProgress(request); + + @override + Future getGroupMessages(IdRequest request) => comms.getGroupMessages(request); + + @override + Future sendGroupMessage(GroupMessageRequest request) => comms.sendGroupMessage(request); + + @override + Future nudgeGroupMember(NudgeRequest request) => comms.nudgeGroupMember(request); } diff --git a/frontend/lib/Grounded/memory/ConnectInternalMemory.dart b/frontend/lib/Grounded/memory/ConnectInternalMemory.dart index cbe3681..5b891fc 100644 --- a/frontend/lib/Grounded/memory/ConnectInternalMemory.dart +++ b/frontend/lib/Grounded/memory/ConnectInternalMemory.dart @@ -60,11 +60,6 @@ abstract class ConnectInternalMemory { Future setAmnestySpent(int spent); - /// Water drunk today in ml; a new day reads as 0. - Future getWaterToday(); - - Future setWaterToday(int ml); - /// The quick to-do list on Home. Future> getTodos(); diff --git a/frontend/lib/Grounded/memory/InternalMemory.dart b/frontend/lib/Grounded/memory/InternalMemory.dart index 236b673..38c45e1 100644 --- a/frontend/lib/Grounded/memory/InternalMemory.dart +++ b/frontend/lib/Grounded/memory/InternalMemory.dart @@ -41,8 +41,6 @@ class InternalMemory implements ConnectInternalMemory { static const String AMNESTY_SPENT = "AMNESTY_SPENT_Jr4xVh9KmT3gLc6B"; - static const String WATER = "WATER_Qe7tNd4ZsM2kBv9C"; - static const String TODOS = "TODOS_Wm3kRb8TqZ5nHc2F"; static const String SHOW_ONBOARDING = "SHOW_ONBOARDING_Nz6qGf2SdX7wPb4M"; @@ -257,31 +255,6 @@ class InternalMemory implements ConnectInternalMemory { await groundedStorage.write(key: AMNESTY_SPENT, value: spent.toString()); } - String _today() { - final DateTime now = DateTime.now(); - return "${now.year}-${now.month}-${now.day}"; - } - - @override - Future getWaterToday() async { - String? value = await groundedStorage.read(key: WATER); - - if (value != null) { - final Map entry = jsonDecode(value); - if (entry['day'] == _today()) { - return entry['ml'] ?? 0; - } - } - - return 0; - } - - @override - Future setWaterToday(int ml) async { - await groundedStorage.write( - key: WATER, value: jsonEncode({'day': _today(), 'ml': ml})); - } - @override Future> getTodos() async { String? value = await groundedStorage.read(key: TODOS); diff --git a/frontend/lib/Grounded/mock/MockComms.dart b/frontend/lib/Grounded/mock/MockComms.dart index a94319f..d13a84f 100644 --- a/frontend/lib/Grounded/mock/MockComms.dart +++ b/frontend/lib/Grounded/mock/MockComms.dart @@ -12,6 +12,12 @@ import '../about/external/initial/CompletionRequest.dart'; import '../about/external/initial/DeferralRequest.dart'; import '../about/external/initial/DeviceRequest.dart'; import '../about/external/initial/GoalRequest.dart'; +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 '../about/external/initial/IdRequest.dart'; import '../about/external/initial/LoginData.dart'; import '../about/external/initial/ReportCardRequest.dart'; @@ -19,6 +25,7 @@ import '../about/external/initial/SessionLogRequest.dart'; import '../about/external/initial/SickModeRequest.dart'; import '../about/external/initial/ToneRequest.dart'; import '../comms/ConnectComms.dart'; +import 'MockGroups.dart'; import 'MockStore.dart'; /// A fake backend. Answers every call from [MockStore] after a short delay, @@ -440,4 +447,36 @@ class MockComms implements ConnectComms { @override Future acknowledgeNudgeEntry(IdRequest request) => _done(); + + // ── Groups ──────────────────────────────────────────────────────────────── + + @override + Future getMyGroups(HistoryRequest request) => _ok(MockGroups.mine()); + + @override + Future getGroupEntry(IdRequest request) => _ok(MockGroups.one(request.id)); + + @override + Future saveGroupEntry(GroupRequest request) => _ok(MockGroups.save(request)); + + @override + Future joinGroupEntry(JoinGroupRequest request) => _ok(MockGroups.join(request)); + + @override + Future leaveGroupEntry(IdRequest request) => _ok(MockGroups.leave(request.id)); + + @override + Future saveGroupGoalEntry(GroupGoalRequest request) => _ok(MockGroups.saveGoal(request)); + + @override + Future logObjectiveProgress(ObjectiveProgressRequest request) => _ok(MockGroups.logProgress(request)); + + @override + Future getGroupMessages(IdRequest request) => _ok(MockGroups.messages(request.id)); + + @override + Future sendGroupMessage(GroupMessageRequest request) => _ok(MockGroups.send(request)); + + @override + Future nudgeGroupMember(NudgeRequest request) => _ok(MockGroups.nudge(request)); } diff --git a/frontend/lib/Grounded/mock/MockGroups.dart b/frontend/lib/Grounded/mock/MockGroups.dart new file mode 100644 index 0000000..271b469 --- /dev/null +++ b/frontend/lib/Grounded/mock/MockGroups.dart @@ -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> _groups = >[]; + + /// Groups that exist but you are not in yet, keyed by invite code. + static final Map> _joinable = + >{}; + + static final Map>> _messages = + >>{}; + + /// Replies that "arrive" once their time has passed. + static final List> _pending = >[]; + + static final Random _random = Random(7); + + static Map _member(String id, String name, + {String role = "Member", + double kept = 0.7, + int streak = 2, + String standing = "Good", + int checkedInHoursAgo = 5}) { + return { + '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 _objective( + String id, String title, String unit, double target, double current, + double mine) { + return { + 'id': id, + 'title': title, + 'unit': unit, + 'target': target, + 'current': current, + 'mine': mine, + }; + } + + static Map _message(String groupId, String authorId, + String authorName, String body, int minutesAgo, + {String kind = "Text"}) { + return { + '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(>[ + { + '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': >[ + _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': >[ + { + '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': >[ + _objective('ob-km', 'Distance run', 'km', 200, 86, 22), + _objective('ob-sessions', 'Runs logged', 'runs', 40, 17, 5), + ], + }, + { + 'id': 'gg-sleep', + 'title': 'In bed before 23:00', + 'description': 'Mornings start the night before.', + 'targetDate': '', + 'colourHex': colorToHex(goalAccents[4]), + 'objectives': >[ + _objective('ob-nights', 'Early nights', 'nights', 60, 31, 9), + ], + }, + ], + }, + { + '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': >[ + _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': >[ + { + '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': >[ + _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'] = { + 'id': 'group-walk', + 'name': 'Weekend Walkers', + 'description': 'Ten thousand steps, Saturday and Sunday, no excuses.', + 'colourHex': colorToHex(goalAccents[5]), + 'inviteCode': 'WLK2GO', + 'unread': 1, + 'members': >[ + _member('m-ade', 'Ade', role: 'Owner', kept: 0.81, streak: 6), + _member('m-lena', 'Lena', kept: 0.74, streak: 3), + ], + 'goals': >[ + { + 'id': 'gg-steps', + 'title': '1 million steps together', + 'description': '', + 'targetDate': '', + 'colourHex': colorToHex(goalAccents[5]), + 'objectives': >[ + _objective('ob-steps', 'Steps', 'steps', 1000000, 412000, 0), + ], + }, + ], + }; + + _messages['group-run'] = >[ + _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'] = >[ + _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'] = >[ + _message('group-walk', 'm-ade', 'Ade', + 'Welcome! Saturday loop starts at the bridge, 9:00.', 60 * 30), + ]; + } + + static Map? _find(String id) { + for (Map 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> due = _pending + .where((item) => DateTime.parse(item['sentAt']).isBefore(now)) + .toList(); + for (Map message in due) { + _pending.remove(message); + _messages.putIfAbsent(message['groupId'], () => >[]) + .add(message); + final Map? group = _find(message['groupId']); + if (group != null) { + group['unread'] = (group['unread'] ?? 0) + 1; + } + } + } + + static Map _withLast(Map group) { + final List> thread = + _messages[group['id']] ?? >[]; + final Map copy = Map.from(group); + if (thread.isNotEmpty) { + final Map 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, () => >[]).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? group = _find(groupId); + if (group == null) { + return; + } + final List others = (group['members'] as List) + .where((member) => member['id'] != _me) + .toList(); + if (others.isEmpty) { + return; + } + const List lines = [ + "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 author = others[_random.nextInt(others.length)]; + final Map 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.generate( + 6, (index) => letters[_random.nextInt(letters.length)]).join(); + } + + // ── Endpoints ───────────────────────────────────────────────────────────── + + static List> mine() { + _ensureSeeded(); + _deliver(); + return _groups.map(_withLast).toList(); + } + + static Map one(String id) { + _ensureSeeded(); + _deliver(); + return _withLast(_find(id) ?? {}); + } + + static Map save(GroupRequest request) { + _ensureSeeded(); + final Map? 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 group = { + 'id': id, + 'name': request.name, + 'description': request.description, + 'colourHex': request.colourHex, + 'inviteCode': _code(), + 'unread': 0, + 'members': >[ + _member(_me, _myName, role: 'Owner', kept: 0.78, streak: 4, + checkedInHoursAgo: 0), + ], + 'goals': >[], + }; + _groups.insert(0, group); + _post(id, 'started the group', 'System'); + return _withLast(group); + } + + static Map join(JoinGroupRequest request) { + _ensureSeeded(); + final String code = request.inviteCode.trim().toUpperCase(); + + for (Map 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 group = _joinable.remove(code) ?? + (_joinable.isNotEmpty + ? _joinable.remove(_joinable.keys.first)! + : { + 'id': MockStore.newId('group'), + 'name': 'Group $code', + 'description': '', + 'colourHex': colorToHex(goalAccents[0]), + 'inviteCode': code, + 'unread': 0, + 'members': >[], + 'goals': >[], + }); + (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 leave(String id) { + _ensureSeeded(); + _groups.removeWhere((group) => group['id'] == id); + return {'success': true}; + } + + static Map saveGoal(GroupGoalRequest request) { + _ensureSeeded(); + final Map? group = _find(request.groupId); + if (group == null) { + return {}; + } + final Map goal = { + '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 logProgress(ObjectiveProgressRequest request) { + _ensureSeeded(); + final Map? group = _find(request.groupId); + if (group == null) { + return {}; + } + for (Map goal in (group['goals'] as List)) { + if (goal['id'] != request.goalId) { + continue; + } + for (Map 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 member in (group['members'] as List)) { + if (member['id'] == _me) { + member['lastCheckIn'] = DateTime.now().toIso8601String(); + } + } + return _withLast(group); + } + + static List> messages(String groupId) { + _ensureSeeded(); + _deliver(); + final Map? group = _find(groupId); + if (group != null) { + group['unread'] = 0; + } + return List>.from( + _messages[groupId] ?? >[]); + } + + static Map send(GroupMessageRequest request) { + _ensureSeeded(); + _post(request.groupId, request.body, request.kind); + _queueReply(request.groupId); + return _messages[request.groupId]!.last; + } + + static Map nudge(NudgeRequest request) { + _ensureSeeded(); + final Map? group = _find(request.groupId); + if (group == null) { + return {'success': false}; + } + String who = "everyone"; + for (Map 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 {'success': true}; + } +} diff --git a/frontend/lib/Grounded/mock/MockMemory.dart b/frontend/lib/Grounded/mock/MockMemory.dart index ed46f7c..f7050d2 100644 --- a/frontend/lib/Grounded/mock/MockMemory.dart +++ b/frontend/lib/Grounded/mock/MockMemory.dart @@ -19,9 +19,8 @@ class MockMemory extends InternalMemory { setMyDescription( MeDescription(id: "mock-user", name: "Alex", token: "mock-token")); setUserDetails(UserDetails(pic: "", name: "Alex")); - setWaterToday(1250); setTodos([ - TodoItem(id: "t1", title: "Refill water bottle"), + TodoItem(id: "t1", title: "Pick up gym kit"), TodoItem(id: "t2", title: "Book physio for Thursday"), TodoItem(id: "t3", title: "Reply to Sam about Saturday", done: true), ]); diff --git a/frontend/lib/Grounded/see/group/ConnectGroupChat.dart b/frontend/lib/Grounded/see/group/ConnectGroupChat.dart new file mode 100644 index 0000000..f554862 --- /dev/null +++ b/frontend/lib/Grounded/see/group/ConnectGroupChat.dart @@ -0,0 +1,10 @@ +import '../../about/external/data/GroupMessage.dart'; + +abstract class ConnectGroupChat { + void onMessagesLoaded(List messages); + + void onMessageSent(GroupMessage message); + + /// The send failed; the text goes back in the box so nothing is lost. + void onSendFailed(String body); +} diff --git a/frontend/lib/Grounded/see/group/ConnectGroupDetail.dart b/frontend/lib/Grounded/see/group/ConnectGroupDetail.dart new file mode 100644 index 0000000..8b8fccf --- /dev/null +++ b/frontend/lib/Grounded/see/group/ConnectGroupDetail.dart @@ -0,0 +1,12 @@ +import '../../about/external/data/Group.dart'; + +abstract class ConnectGroupDetail { + void onGroupLoaded(Group group); + + /// Progress landed; the group comes back with the new totals. + void onProgressLogged(Group group, String summary); + + void onNudged(String who); + + void onLeft(); +} diff --git a/frontend/lib/Grounded/see/group/ConnectGroupEditor.dart b/frontend/lib/Grounded/see/group/ConnectGroupEditor.dart new file mode 100644 index 0000000..820d460 --- /dev/null +++ b/frontend/lib/Grounded/see/group/ConnectGroupEditor.dart @@ -0,0 +1,6 @@ +import '../../about/external/data/Group.dart'; + +abstract class ConnectGroupEditor { + /// Created or joined — either way you are now in it. + void onGroupReady(Group group); +} diff --git a/frontend/lib/Grounded/see/group/ConnectGroupGoalEditor.dart b/frontend/lib/Grounded/see/group/ConnectGroupGoalEditor.dart new file mode 100644 index 0000000..840f4a1 --- /dev/null +++ b/frontend/lib/Grounded/see/group/ConnectGroupGoalEditor.dart @@ -0,0 +1,3 @@ +abstract class ConnectGroupGoalEditor { + void onGoalSaved(); +} diff --git a/frontend/lib/Grounded/see/group/ConnectGroups.dart b/frontend/lib/Grounded/see/group/ConnectGroups.dart new file mode 100644 index 0000000..cbb43b8 --- /dev/null +++ b/frontend/lib/Grounded/see/group/ConnectGroups.dart @@ -0,0 +1,5 @@ +import '../../about/external/data/Group.dart'; + +abstract class ConnectGroups { + void onGroupsLoaded(List groups); +} diff --git a/frontend/lib/Grounded/see/group/GroupChat.dart b/frontend/lib/Grounded/see/group/GroupChat.dart new file mode 100644 index 0000000..257b3cd --- /dev/null +++ b/frontend/lib/Grounded/see/group/GroupChat.dart @@ -0,0 +1,13 @@ +import 'package:flutter/material.dart'; + +import '../../about/external/data/Group.dart'; +import 'GroupChatState.dart'; + +class GroupChat extends StatefulWidget { + final Group group; + + const GroupChat({super.key, required this.group}); + + @override + State createState() => GroupChatState(); +} diff --git a/frontend/lib/Grounded/see/group/GroupChatState.dart b/frontend/lib/Grounded/see/group/GroupChatState.dart new file mode 100644 index 0000000..ddcdd98 --- /dev/null +++ b/frontend/lib/Grounded/see/group/GroupChatState.dart @@ -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 implements ConnectGroupChat { + ViewGroupChat? _model; + + List _messages = []; + + 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 _quickReplies = [ + "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.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 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.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 messages) { + if (!mounted) return; + setState(() { + _messages = messages; + }); + } + + @override + void onMessageSent(GroupMessage message) { + if (!mounted) return; + setState(() { + _messages = [..._messages, message]; + }); + } + + @override + void onSendFailed(String body) { + if (!mounted) return; + _input.text = body; + } +} diff --git a/frontend/lib/Grounded/see/group/GroupDetail.dart b/frontend/lib/Grounded/see/group/GroupDetail.dart new file mode 100644 index 0000000..830a29f --- /dev/null +++ b/frontend/lib/Grounded/see/group/GroupDetail.dart @@ -0,0 +1,13 @@ +import 'package:flutter/material.dart'; + +import '../../about/external/data/Group.dart'; +import 'GroupDetailState.dart'; + +class GroupDetail extends StatefulWidget { + final Group group; + + const GroupDetail({super.key, required this.group}); + + @override + State createState() => GroupDetailState(); +} diff --git a/frontend/lib/Grounded/see/group/GroupDetailState.dart b/frontend/lib/Grounded/see/group/GroupDetailState.dart new file mode 100644 index 0000000..d3f702e --- /dev/null +++ b/frontend/lib/Grounded/see/group/GroupDetailState.dart @@ -0,0 +1,708 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:stacked/stacked.dart'; + +import '../../about/external/data/Group.dart'; +import '../../about/external/data/GroupGoal.dart'; +import '../../about/external/data/GroupMember.dart'; +import '../../about/external/data/GroupObjective.dart'; +import '../../about/external/initial/NudgeRequest.dart'; +import '../../about/external/initial/ObjectiveProgressRequest.dart'; +import '../../about/internal/application/NotificationType.dart'; +import '../../about/internal/application/TextType.dart'; +import '../../configs/Navigator.dart'; +import '../../designs/Component.dart'; +import '../../designs/MemberAvatar.dart'; +import '../../designs/Responsive.dart'; +import '../../designs/Shell.dart'; +import '../../designs/buttons/Buttons.dart'; +import '../../designs/input/InputFields.dart'; +import '../../designs/text/Text.dart'; +import '../../utils/Colors.dart'; +import '../../utils/CommonUtils.dart'; +import 'ConnectGroupDetail.dart'; +import 'GroupChat.dart'; +import 'GroupDetail.dart'; +import 'GroupGoalEditor.dart'; +import 'ViewGroupDetail.dart'; + +class GroupDetailState extends State + implements ConnectGroupDetail { + ViewGroupDetail? _model; + + late Group _group; + + /// Members nudged from this screen, so the button reads "Nudged" after. + final Set _nudged = {}; + + final TextEditingController _amount = TextEditingController(); + + final TextEditingController _note = TextEditingController(); + + @override + void initState() { + super.initState(); + _group = widget.group; + } + + @override + void dispose() { + _amount.dispose(); + _note.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ViewModelBuilder.reactive( + viewModelBuilder: () => ViewGroupDetail(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?.loadGroup(widget.group.id); + } + + Color get _accent => goalColor(_group.colourHex); + + // ── Handlers ────────────────────────────────────────────────────────────── + + void _onBack() { + Navigator.pop(context, true); + } + + void _onOpenChat() async { + await GroundedNavigation() + .navigateToPageWithData(GroupChat(group: _group), context); + _model?.loadGroup(_group.id, quiet: true); + } + + void _onAddGoal() async { + final result = await GroundedNavigation() + .navigateToPageWithData(GroupGoalEditor(group: _group), context); + + if (result == true) { + _model?.loadGroup(_group.id, quiet: true); + } + } + + void _onCopyCode() async { + await Clipboard.setData(ClipboardData(text: _group.inviteCode)); + _model?.showApplicationNotification( + NotificationType.success, + "Code copied", + "Send ${_group.inviteCode} to whoever should join. They enter it under Groups → Join with a code.", + true, + true, + null, + ); + } + + void _onNudge(GroupMember member) { + _model?.nudge( + NudgeRequest(groupId: _group.id, memberId: member.id), member.name); + } + + void _onNudgeEveryone() { + _model?.nudge(NudgeRequest(groupId: _group.id), "everyone"); + } + + void _onLog(GroupGoal goal, GroupObjective objective) { + _amount.text = ""; + _note.text = ""; + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: colorWhite, + builder: (sheetContext) => _logSheet(sheetContext, goal, objective), + ); + } + + void _onQuickAmount(double value) { + _amount.text = formatAmount(value).replaceAll(",", ""); + } + + void _onSubmitLog(BuildContext sheetContext, GroupGoal goal, + GroupObjective objective) { + final double amount = double.tryParse(_amount.text.trim()) ?? 0; + if (amount <= 0) { + return; + } + + Navigator.pop(sheetContext); + + _model?.logProgress( + ObjectiveProgressRequest( + groupId: _group.id, + goalId: goal.id, + objectiveId: objective.id, + amount: amount, + note: _note.text.trim(), + ), + "${formatAmount(amount)} ${objective.unit} on ${objective.title}", + ); + } + + void _onLeave() { + _model?.showApplicationNotification( + NotificationType.warning, + "Leave ${_group.name}?", + "You stop seeing the shared goals and the chat. What you logged stays in the group's totals.", + true, + true, + null, + action: "Leave", + positiveAction: _onConfirmLeave, + ); + } + + void _onConfirmLeave() { + Navigator.pop(context); + _model?.leave(_group.id); + } + + // ── Views ───────────────────────────────────────────────────────────────── + + Widget _mobileView(BoxConstraints constraints) { + final List ranked = List.from(_group.members) + ..sort((a, b) => b.keptRate.compareTo(a.keptRate)); + + return Sheet( + eyebrow: "${_group.members.length} members", + title: _group.name, + onBack: _onBack, + scene: goalScene(_accent), + sceneSeed: sceneSeed(_group.name), + action: chromeAction(CupertinoIcons.chat_bubble_2_fill, _onOpenChat), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + if (_group.description.isNotEmpty) ...[ + text(_group.description, 15, TextType.Regular, + color: colorGrey2, height: 1.55), + const SizedBox(height: 22), + ], + _statsRow(), + const SizedBox(height: 20), + _chatCard(), + const SizedBox(height: 30), + sectionBreak( + "Shared goals", + caption: "${_group.goals.length} on the go", + trailing: textButton("+ Add", _onAddGoal, + color: colorInk, textSize: 14, type: TextType.Medium), + ), + if (_group.goals.isEmpty) + emptyState( + CupertinoIcons.flag, + "Nothing shared yet", + "Add a goal and split it into objectives everyone can log toward, like kilometres, pages or sessions.", + ) + else + ..._group.goals.map(_goalCard), + const SizedBox(height: 30), + sectionBreak("Leaderboard", + caption: "kept, last 7 days", + trailing: textButton("Nudge all", _onNudgeEveryone, + color: colorInk, textSize: 14, type: TextType.Medium)), + for (int index = 0; index < ranked.length; index++) + _memberRow(index + 1, ranked[index]), + const SizedBox(height: 26), + Center( + child: textButton("Leave group", _onLeave, + color: colorDestructive, textSize: 13), + ), + ], + ), + ); + } + + Widget _statsRow() { + final double average = _group.members.isEmpty + ? 0 + : _group.members + .map((member) => member.keptRate) + .reduce((a, b) => a + b) / + _group.members.length; + + return card( + padding: const EdgeInsets.fromLTRB(20, 18, 12, 18), + child: Row( + children: [ + Expanded( + child: labelled("Together", "${(_group.progress * 100).round()}%", + valueSize: 26, valueType: TextType.Light), + ), + Expanded( + child: labelled("Kept avg", "${(average * 100).round()}%", + valueSize: 26, valueType: TextType.Light), + ), + GestureDetector( + onTap: _onCopyCode, + behavior: HitTestBehavior.opaque, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + color: colorAmbientMid, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + text("INVITE", 9, TextType.Bold, + color: colorGrey2, letterSpacing: 1.0), + const SizedBox(width: 6), + Icon(CupertinoIcons.doc_on_doc, + size: 12, color: colorGrey2), + ], + ), + const SizedBox(height: 4), + text(_group.inviteCode, 18, TextType.Medium, + color: colorInk, letterSpacing: 1.5), + ], + ), + ), + ), + ], + ), + ); + } + + /// The way into the chat: the last line said and how much is unread. + Widget _chatCard() { + return GestureDetector( + onTap: _onOpenChat, + child: Container( + padding: const EdgeInsets.fromLTRB(20, 18, 16, 18), + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [colorNightLift, colorNight], + ), + boxShadow: [ + BoxShadow( + color: Color(0x332A3A30), + blurRadius: 24, + offset: Offset(0, 10), + ), + ], + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Icon(CupertinoIcons.chat_bubble_2_fill, + size: 16, color: colorLime), + const SizedBox(width: 8), + text("Group chat", 14, TextType.Medium, + color: colorLime), + if (_group.unread > 0) ...[ + const SizedBox(width: 10), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 7, vertical: 3), + color: colorLime, + child: text("${_group.unread} new", 10, + TextType.Medium, + color: colorNight), + ), + ], + ], + ), + const SizedBox(height: 10), + text( + _group.lastMessage.isEmpty + ? "Say hello. Check-ins and nudges show up here too." + : "${_group.lastAuthor}: ${_group.lastMessage}", + 15, + TextType.Regular, + color: colorWhite, + maxLines: 2, + overflow: TextOverflow.ellipsis, + height: 1.45, + ), + ], + ), + ), + const SizedBox(width: 12), + Container( + width: 46, + height: 46, + color: colorWhite.withValues(alpha: 0.12), + child: Icon(CupertinoIcons.arrow_right, + size: 20, color: colorWhite), + ), + ], + ), + ), + ); + } + + /// A shared goal: its overall bar, then each objective with the group's + /// total, your share, and a button to log more. + Widget _goalCard(GroupGoal goal) { + final Color tone = + goal.colourHex.isEmpty ? _accent : goalColor(goal.colourHex); + + return Container( + margin: const EdgeInsets.only(bottom: 14), + child: card( + padding: EdgeInsets.zero, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Container(height: 6, color: tone), + Padding( + padding: const EdgeInsets.fromLTRB(20, 18, 20, 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: text(goal.title, 19, TextType.Medium, + color: colorInk), + ), + const SizedBox(width: 10), + text("${(goal.progress * 100).round()}%", 22, + TextType.Light, + color: colorInk), + ], + ), + const SizedBox(height: 4), + text( + [ + "${goal.completeObjectives}/${goal.objectives.length} objectives met", + goal.targetDate == null + ? "no deadline" + : targetLabel(goal.targetDate), + ].join(" · "), + 12, + TextType.Regular, + color: colorGrey2, + ), + const SizedBox(height: 12), + meter(goal.progress, + fill: tone, track: tone.withValues(alpha: 0.14), height: 6), + const SizedBox(height: 8), + ], + ), + ), + for (GroupObjective objective in goal.objectives) + _objectiveRow(goal, objective, tone), + ], + ), + ), + ); + } + + Widget _objectiveRow(GroupGoal goal, GroupObjective objective, Color tone) { + return Container( + padding: const EdgeInsets.fromLTRB(20, 14, 14, 16), + decoration: BoxDecoration( + border: Border(top: BorderSide(color: colorBorder)), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + if (objective.complete) ...[ + Icon(CupertinoIcons.checkmark_seal_fill, + size: 15, color: colorPositive), + const SizedBox(width: 6), + ], + Flexible( + child: text(objective.title, 15, TextType.Medium, + color: colorInk, + maxLines: 1, + overflow: TextOverflow.ellipsis), + ), + ], + ), + const SizedBox(height: 4), + text( + "${formatAmount(objective.current)} / ${formatAmount(objective.target)} ${objective.unit}", + 13, + TextType.Regular, + color: colorGrey2, + ), + const SizedBox(height: 8), + meter(objective.progress, + fill: tone, track: tone.withValues(alpha: 0.12), height: 4), + const SizedBox(height: 6), + text("You: ${formatAmount(objective.mine)} ${objective.unit}", + 12, TextType.Medium, + color: tone), + ], + ), + ), + const SizedBox(width: 14), + GestureDetector( + onTap: () => _onLog(goal, objective), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + color: colorNight, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(CupertinoIcons.add, size: 16, color: colorLime), + const SizedBox(width: 6), + text("Log", 14, TextType.Medium, color: colorWhite), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _memberRow(int rank, GroupMember member) { + final bool nudged = _nudged.contains(member.id); + final bool quiet = member.lastCheckIn == null || + DateTime.now().difference(member.lastCheckIn!).inHours >= 24; + + return Container( + margin: const EdgeInsets.only(bottom: 10), + child: card( + padding: const EdgeInsets.fromLTRB(14, 12, 12, 12), + child: Row( + children: [ + SizedBox( + width: 24, + child: text("$rank", 16, TextType.Medium, + color: rank == 1 ? colorInk : colorGrey2), + ), + memberAvatar(member.name, 46), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Flexible( + child: text( + member.me ? "${member.name} (you)" : member.name, + 16, + TextType.Medium, + color: colorInk, + maxLines: 1, + overflow: TextOverflow.ellipsis), + ), + if (member.owner) ...[ + const SizedBox(width: 6), + Icon(CupertinoIcons.star_fill, + size: 12, color: colorBarStandard), + ], + ], + ), + const SizedBox(height: 4), + Row( + children: [ + Icon(CupertinoIcons.flame_fill, + size: 12, color: colorBarNonNegotiable), + const SizedBox(width: 3), + text("${member.streak}d", 12, TextType.Regular, + color: colorGrey2), + const SizedBox(width: 10), + text( + quiet + ? "quiet ${member.lastCheckIn == null ? "" : _ago(member.lastCheckIn!)}" + : "checked in ${_ago(member.lastCheckIn!)}", + 12, + TextType.Regular, + color: quiet ? colorStandingGrounded : colorGrey2, + ), + ], + ), + ], + ), + ), + text("${(member.keptRate * 100).round()}%", 20, TextType.Light, + color: colorInk), + if (!member.me) ...[ + const SizedBox(width: 10), + GestureDetector( + onTap: nudged ? null : () => _onNudge(member), + child: Container( + width: 44, + height: 44, + color: nudged ? colorAmbientMid : colorNight, + child: Icon( + nudged + ? CupertinoIcons.checkmark + : CupertinoIcons.hand_point_right_fill, + size: 18, + color: nudged ? colorGrey2 : colorLime, + ), + ), + ), + ], + ], + ), + ), + ); + } + + /// Log progress: an amount (with one-tap presets that suit the unit) and + /// an optional note that rides along into the chat. + Widget _logSheet(BuildContext sheetContext, GroupGoal goal, + GroupObjective objective) { + final List presets = _presets(objective.unit); + + return Padding( + padding: EdgeInsets.fromLTRB(20, 16, 20, + MediaQuery.of(sheetContext).viewInsets.bottom + 28), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Center(child: Container(width: 40, height: 4, color: colorGrabber)), + const SizedBox(height: 18), + text("LOG PROGRESS", 10, TextType.Bold, + color: colorGrey2, letterSpacing: 1.2), + const SizedBox(height: 6), + text(objective.title, 24, TextType.Medium, color: colorInk), + const SizedBox(height: 4), + text( + "${formatAmount(objective.current)} of ${formatAmount(objective.target)} ${objective.unit} so far", + 13, + TextType.Regular, + color: colorGrey2), + const SizedBox(height: 18), + Row( + children: [ + for (double preset in presets) ...[ + Expanded( + child: GestureDetector( + onTap: () => _onQuickAmount(preset), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 14), + alignment: Alignment.center, + color: colorAmbientMid, + child: text("+${formatAmount(preset)}", 15, + TextType.Medium, + color: colorInk), + ), + ), + ), + if (preset != presets.last) const SizedBox(width: 8), + ], + ], + ), + const SizedBox(height: 16), + inputField( + "Amount (${objective.unit})", + _amount, + hint: "How much?", + keyboard: const TextInputType.numberWithOptions(decimal: true), + formatters: [ + FilteringTextInputFormatter.allow(RegExp(r"[0-9.]")), + ], + ), + const SizedBox(height: 14), + inputField("Note", _note, hint: "Optional: posted with the check-in"), + const SizedBox(height: 22), + roundedCornerButton("Log it", + () => _onSubmitLog(sheetContext, goal, objective), + icon: CupertinoIcons.checkmark), + ], + ), + ); + } + + List _presets(String unit) { + switch (unit.toLowerCase()) { + case "steps": + return [1000, 5000, 10000]; + case "words": + return [250, 500, 1000]; + case "hours": + return [0.5, 1, 2]; + case "km": + return [3, 5, 10]; + default: + return [1, 2, 5]; + } + } + + String _ago(DateTime moment) { + final Duration gap = DateTime.now().difference(moment); + if (gap.inMinutes < 60) return "${gap.inMinutes.clamp(1, 59)}m ago"; + if (gap.inHours < 24) return "${gap.inHours}h ago"; + return "${gap.inDays}d ago"; + } + + // ── ConnectGroupDetail ──────────────────────────────────────────────────── + + @override + void onGroupLoaded(Group group) { + setState(() { + _group = group; + }); + } + + @override + void onProgressLogged(Group group, String summary) { + setState(() { + _group = group; + }); + _model?.showApplicationNotification( + NotificationType.success, + "Logged", + "$summary. The group sees it in the chat.", + true, + true, + null, + ); + } + + @override + void onNudged(String who) { + setState(() { + for (GroupMember member in _group.members) { + if (who == "everyone" ? !member.me : member.name == who) { + _nudged.add(member.id); + } + } + }); + } + + @override + void onLeft() { + Navigator.pop(context, true); + } +} diff --git a/frontend/lib/Grounded/see/group/GroupEditor.dart b/frontend/lib/Grounded/see/group/GroupEditor.dart new file mode 100644 index 0000000..2d99c97 --- /dev/null +++ b/frontend/lib/Grounded/see/group/GroupEditor.dart @@ -0,0 +1,12 @@ +import 'package:flutter/material.dart'; + +import 'GroupEditorState.dart'; + +class GroupEditor extends StatefulWidget { + final bool joining; + + const GroupEditor({super.key, this.joining = false}); + + @override + State createState() => GroupEditorState(); +} diff --git a/frontend/lib/Grounded/see/group/GroupEditorState.dart b/frontend/lib/Grounded/see/group/GroupEditorState.dart new file mode 100644 index 0000000..25e71ab --- /dev/null +++ b/frontend/lib/Grounded/see/group/GroupEditorState.dart @@ -0,0 +1,293 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:stacked/stacked.dart'; + +import '../../about/external/data/Group.dart'; +import '../../about/external/initial/GroupRequest.dart'; +import '../../about/external/initial/JoinGroupRequest.dart'; +import '../../about/internal/application/TextType.dart'; +import '../../designs/MemberAvatar.dart'; +import '../../designs/Responsive.dart'; +import '../../designs/Shell.dart'; +import '../../designs/buttons/Buttons.dart'; +import '../../designs/input/InputFields.dart'; +import '../../designs/text/Text.dart'; +import '../../utils/Colors.dart'; +import '../../utils/Validators.dart'; +import 'ConnectGroupEditor.dart'; +import 'GroupEditor.dart'; +import 'ViewGroupEditor.dart'; + +class GroupEditorState extends State + implements ConnectGroupEditor { + ViewGroupEditor? _model; + + final GlobalKey _formKey = GlobalKey(); + + final TextEditingController _name = TextEditingController(); + + final TextEditingController _description = TextEditingController(); + + final TextEditingController _code = TextEditingController(); + + Color _accent = goalAccents[1]; + + bool _joining = false; + + @override + void initState() { + super.initState(); + _joining = widget.joining; + } + + @override + void dispose() { + _name.dispose(); + _description.dispose(); + _code.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ViewModelBuilder.reactive( + viewModelBuilder: () => ViewGroupEditor(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() {} + + // ── Handlers ────────────────────────────────────────────────────────────── + + void _onBack() { + Navigator.pop(context, false); + } + + void _onModeSelected(String mode) { + setState(() { + _joining = mode == "Join"; + }); + } + + void _onNameChanged(String value) { + setState(() {}); + } + + void _onAccentSelected(Color colour) { + setState(() { + _accent = colour; + }); + } + + void _onSubmit() { + if (_formKey.currentState?.validate() != true) { + return; + } + + if (_joining) { + _model?.join(JoinGroupRequest(inviteCode: _code.text.trim().toUpperCase())); + } else { + _model?.create(GroupRequest( + name: _name.text.trim(), + description: _description.text.trim(), + colourHex: colorToHex(_accent), + )); + } + } + + String? _validateCode(String? value) { + final String code = (value ?? "").trim(); + if (code.length != 6) { + return "Invite codes are six characters."; + } + return null; + } + + // ── Views ───────────────────────────────────────────────────────────────── + + Widget _mobileView(BoxConstraints constraints) { + return Sheet( + eyebrow: _joining ? "Join" : "New", + title: "Group", + onBack: _onBack, + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + displayTitle(_joining + ? "Got a code?" + : "Who's keeping\nyou honest?"), + const SizedBox(height: 22), + segmentedSelector( + options: const ["Create", "Join"], + selected: _joining ? "Join" : "Create", + label: _modeLabel, + onSelected: _onModeSelected, + activeColor: colorNight, + ), + const SizedBox(height: 26), + if (_joining) ..._joinFields() else ..._createFields(), + const SizedBox(height: 32), + roundedCornerButton( + _joining ? "Join group" : "Create group", + _onSubmit, + icon: _joining ? CupertinoIcons.arrow_right : CupertinoIcons.add, + ), + ], + ), + ), + ); + } + + String _modeLabel(String mode) => mode; + + List _createFields() { + return [ + _preview(), + const SizedBox(height: 26), + inputField( + "Group name", + _name, + hint: "Morning Crew, Thesis Sprint…", + validator: Validators.title, + onChanged: _onNameChanged, + ), + const SizedBox(height: 16), + inputField( + "What it's for", + _description, + hint: "Optional: the promise you're all making", + maxLines: 3, + ), + const SizedBox(height: 26), + sectionBreak("Colour", caption: "the group's banner"), + _swatches(), + const SizedBox(height: 14), + text( + "You'll get a six-letter invite code to send to the others once it's created.", + 12, + TextType.Regular, + color: colorGrey2, + height: 1.5, + ), + ]; + } + + List _joinFields() { + return [ + inputField( + "Invite code", + _code, + hint: "6 characters, like MRN7AM", + validator: _validateCode, + icon: CupertinoIcons.ticket, + formatters: [ + LengthLimitingTextInputFormatter(6), + FilteringTextInputFormatter.allow(RegExp(r"[A-Za-z0-9]")), + ], + ), + const SizedBox(height: 12), + text( + "Ask whoever started the group: the code is on the group's page. Joining shows you their shared goals and the chat straight away.", + 12, + TextType.Regular, + color: colorGrey2, + height: 1.5, + ), + ]; + } + + /// The banner as it will look, so the colour is chosen by seeing it. + Widget _preview() { + final String name = + _name.text.trim().isEmpty ? "Your group" : _name.text.trim(); + + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [_accent, Color.lerp(_accent, colorBlack, 0.45)!], + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Icon(CupertinoIcons.person_3_fill, + size: 16, color: colorWhite.withValues(alpha: 0.7)), + const SizedBox(width: 8), + text("1 MEMBER", 9, TextType.Bold, + color: colorWhite.withValues(alpha: 0.6), + letterSpacing: 1.2), + ], + ), + const SizedBox(height: 14), + text(name, 26, TextType.Light, + color: colorWhite, maxLines: 2, overflow: TextOverflow.ellipsis), + const SizedBox(height: 16), + avatarStack(const ["Alex"], 34), + ], + ), + ); + } + + Widget _swatches() { + return Wrap( + spacing: 9, + runSpacing: 9, + children: goalAccents.map((colour) { + final bool selected = colour.toARGB32() == _accent.toARGB32(); + + return GestureDetector( + onTap: () => _onAccentSelected(colour), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + width: 40, + height: 40, + alignment: Alignment.center, + decoration: BoxDecoration( + color: colour, + border: Border.all( + color: selected ? colorWhite : Colors.transparent, + width: 3, + ), + boxShadow: selected + ? [BoxShadow(color: colour, spreadRadius: 1.5)] + : null, + ), + child: selected + ? Icon(Icons.check_rounded, size: 18, color: colorWhite) + : null, + ), + ); + }).toList(), + ); + } + + // ── ConnectGroupEditor ──────────────────────────────────────────────────── + + @override + void onGroupReady(Group group) { + Navigator.pop(context, group); + } +} diff --git a/frontend/lib/Grounded/see/group/GroupGoalEditor.dart b/frontend/lib/Grounded/see/group/GroupGoalEditor.dart new file mode 100644 index 0000000..8cc1098 --- /dev/null +++ b/frontend/lib/Grounded/see/group/GroupGoalEditor.dart @@ -0,0 +1,13 @@ +import 'package:flutter/material.dart'; + +import '../../about/external/data/Group.dart'; +import 'GroupGoalEditorState.dart'; + +class GroupGoalEditor extends StatefulWidget { + final Group group; + + const GroupGoalEditor({super.key, required this.group}); + + @override + State createState() => GroupGoalEditorState(); +} diff --git a/frontend/lib/Grounded/see/group/GroupGoalEditorState.dart b/frontend/lib/Grounded/see/group/GroupGoalEditorState.dart new file mode 100644 index 0000000..a16079f --- /dev/null +++ b/frontend/lib/Grounded/see/group/GroupGoalEditorState.dart @@ -0,0 +1,317 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:stacked/stacked.dart'; + +import '../../about/external/initial/GroupGoalRequest.dart'; +import '../../about/external/initial/ObjectiveDraft.dart'; +import '../../about/internal/application/TextType.dart'; +import '../../designs/Component.dart'; +import '../../designs/Responsive.dart'; +import '../../designs/Shell.dart'; +import '../../designs/buttons/Buttons.dart'; +import '../../designs/input/InputFields.dart'; +import '../../designs/text/Text.dart'; +import '../../utils/Colors.dart'; +import '../../utils/CommonUtils.dart'; +import '../../utils/Validators.dart'; +import 'ConnectGroupGoalEditor.dart'; +import 'GroupGoalEditor.dart'; +import 'ViewGroupGoalEditor.dart'; + +/// The three fields of one objective while it is being typed. +typedef _ObjectiveFields = ({ + TextEditingController title, + TextEditingController target, + TextEditingController unit, +}); + +class GroupGoalEditorState extends State + implements ConnectGroupGoalEditor { + ViewGroupGoalEditor? _model; + + final GlobalKey _formKey = GlobalKey(); + + final TextEditingController _title = TextEditingController(); + + final TextEditingController _description = TextEditingController(); + + final List<_ObjectiveFields> _objectives = <_ObjectiveFields>[]; + + DateTime? _targetDate; + + @override + void initState() { + super.initState(); + _objectives.add(_blankObjective()); + } + + @override + void dispose() { + _title.dispose(); + _description.dispose(); + for (_ObjectiveFields fields in _objectives) { + fields.title.dispose(); + fields.target.dispose(); + fields.unit.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ViewModelBuilder.reactive( + viewModelBuilder: () => ViewGroupGoalEditor(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() {} + + _ObjectiveFields _blankObjective() { + return ( + title: TextEditingController(), + target: TextEditingController(), + unit: TextEditingController(), + ); + } + + // ── Handlers ────────────────────────────────────────────────────────────── + + void _onBack() { + Navigator.pop(context, false); + } + + void _onAddObjective() { + setState(() { + _objectives.add(_blankObjective()); + }); + } + + void _onRemoveObjective(_ObjectiveFields fields) { + setState(() { + _objectives.remove(fields); + }); + fields.title.dispose(); + fields.target.dispose(); + fields.unit.dispose(); + } + + void _onPickTarget() async { + final DateTime now = DateTime.now(); + final DateTime? picked = await showDatePicker( + context: context, + initialDate: _targetDate ?? now.add(const Duration(days: 30)), + firstDate: now, + lastDate: now.add(const Duration(days: 365 * 3)), + ); + + if (picked == null) { + return; + } + + setState(() { + _targetDate = picked; + }); + } + + void _onClearTarget() { + setState(() { + _targetDate = null; + }); + } + + void _onSave() { + if (_formKey.currentState?.validate() != true) { + return; + } + + _model?.save(GroupGoalRequest( + groupId: widget.group.id, + title: _title.text.trim(), + description: _description.text.trim(), + targetDate: _targetDate?.toIso8601String() ?? "", + colourHex: widget.group.colourHex, + objectives: _objectives + .map((fields) => ObjectiveDraft( + title: fields.title.text.trim(), + unit: fields.unit.text.trim(), + target: double.tryParse(fields.target.text.trim()) ?? 0, + )) + .toList(), + )); + } + + String? _validateTarget(String? value) { + final double? target = double.tryParse((value ?? "").trim()); + if (target == null || target <= 0) { + return "A number above 0."; + } + return null; + } + + String? _validateUnit(String? value) { + return Validators.required(value, "Unit"); + } + + // ── Views ───────────────────────────────────────────────────────────────── + + Widget _mobileView(BoxConstraints constraints) { + return Sheet( + eyebrow: widget.group.name, + title: "Shared goal", + onBack: _onBack, + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + displayTitle("What are you\nall going for?"), + const SizedBox(height: 24), + inputField( + "Goal", + _title, + hint: "Run 200 km this month, finish the drafts…", + validator: Validators.title, + ), + const SizedBox(height: 16), + inputField( + "Why it matters", + _description, + hint: "Optional", + maxLines: 2, + ), + const SizedBox(height: 28), + sectionBreak("Objectives", + caption: "what everyone logs toward"), + text( + "Break the goal into numbers people can add to: kilometres, sessions, pages, hours. Each member logs their share and the totals add up.", + 12, + TextType.Regular, + color: colorGrey2, + height: 1.5, + ), + const SizedBox(height: 14), + for (int index = 0; index < _objectives.length; index++) + _objectiveEditor(index, _objectives[index]), + outlinedActionButton("Add objective", _onAddObjective, + icon: CupertinoIcons.add), + const SizedBox(height: 28), + sectionBreak("Deadline", caption: "optional"), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: selectField( + "Target date", + _targetDate == null ? "" : formatDate(_targetDate), + _onPickTarget, + hint: "No deadline", + icon: CupertinoIcons.calendar, + ), + ), + if (_targetDate != null) ...[ + const SizedBox(width: 8), + iconButton( + Icon(CupertinoIcons.xmark, size: 15, color: colorGrey2), + _onClearTarget, + bordered: true, + background: colorWhite, + size: 50, + ), + ], + ], + ), + const SizedBox(height: 32), + roundedCornerButton("Share with the group", _onSave, + icon: CupertinoIcons.person_3_fill), + ], + ), + ), + ); + } + + Widget _objectiveEditor(int index, _ObjectiveFields fields) { + return Container( + margin: const EdgeInsets.only(bottom: 12), + child: card( + padding: const EdgeInsets.fromLTRB(16, 12, 8, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + text("OBJECTIVE ${index + 1}", 10, TextType.Bold, + color: colorGrey2, letterSpacing: 1.0), + const Spacer(), + if (_objectives.length > 1) + iconButton( + Icon(CupertinoIcons.xmark, size: 15, color: colorGrey2), + () => _onRemoveObjective(fields), + size: 36, + ), + ], + ), + const SizedBox(height: 6), + Padding( + padding: const EdgeInsets.only(right: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + inputField("Name", fields.title, + hint: "Distance run", validator: Validators.title), + const SizedBox(height: 12), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: inputField( + "Target", + fields.target, + hint: "200", + validator: _validateTarget, + keyboard: const TextInputType.numberWithOptions( + decimal: true), + formatters: [ + FilteringTextInputFormatter.allow(RegExp(r"[0-9.]")), + ], + ), + ), + const SizedBox(width: 10), + Expanded( + child: inputField("Unit", fields.unit, + hint: "km", validator: _validateUnit), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ); + } + + // ── ConnectGroupGoalEditor ──────────────────────────────────────────────── + + @override + void onGoalSaved() { + Navigator.pop(context, true); + } +} diff --git a/frontend/lib/Grounded/see/group/Groups.dart b/frontend/lib/Grounded/see/group/Groups.dart new file mode 100644 index 0000000..8cd2fb9 --- /dev/null +++ b/frontend/lib/Grounded/see/group/Groups.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; + +import 'GroupsState.dart'; + +class Groups extends StatefulWidget { + const Groups({super.key}); + + @override + State createState() => GroupsState(); +} diff --git a/frontend/lib/Grounded/see/group/GroupsState.dart b/frontend/lib/Grounded/see/group/GroupsState.dart new file mode 100644 index 0000000..141da41 --- /dev/null +++ b/frontend/lib/Grounded/see/group/GroupsState.dart @@ -0,0 +1,241 @@ +import 'package:flutter/cupertino.dart'; +import 'package:stacked/stacked.dart'; + +import '../../about/external/data/Group.dart'; +import '../../about/internal/application/TextType.dart'; +import '../../configs/Navigator.dart'; +import '../../designs/Component.dart'; +import '../../designs/GlassSurface.dart'; +import '../../designs/MemberAvatar.dart'; +import '../../designs/Responsive.dart'; +import '../../designs/Shell.dart'; +import '../../designs/buttons/Buttons.dart'; +import '../../designs/scene/SceneArt.dart'; +import '../../designs/text/Text.dart'; +import '../../utils/Colors.dart'; +import '../../utils/CommonUtils.dart'; +import 'ConnectGroups.dart'; +import 'GroupDetail.dart'; +import 'GroupEditor.dart'; +import 'Groups.dart'; +import 'ViewGroups.dart'; + +class GroupsState extends State implements ConnectGroups { + ViewGroups? _model; + + List _groups = []; + + @override + Widget build(BuildContext context) { + return ViewModelBuilder.reactive( + viewModelBuilder: () => ViewGroups(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?.loadGroups(); + } + + // ── Handlers ────────────────────────────────────────────────────────────── + + void _onBack() { + Navigator.pop(context, true); + } + + void _onOpenGroup(Group group) async { + await GroundedNavigation() + .navigateToPageWithData(GroupDetail(group: group), context); + _model?.loadGroups(); + } + + void _onNewGroup() { + _openEditor(false); + } + + void _onJoinGroup() { + _openEditor(true); + } + + /// Created or joined, you land straight in the group. + void _openEditor(bool joining) async { + final result = await GroundedNavigation() + .navigateToPageWithData(GroupEditor(joining: joining), context); + + if (result is Group) { + _model?.loadGroups(); + _onOpenGroup(result); + } + } + + // ── Views ───────────────────────────────────────────────────────────────── + + Widget _mobileView(BoxConstraints constraints) { + return Sheet( + eyebrow: "Grounded", + title: "Groups", + onBack: _onBack, + action: chromeAction(CupertinoIcons.add, _onNewGroup), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + displayTitle("Keep each other\nhonest."), + const SizedBox(height: 14), + text( + "Share goals with people who will notice when you slip. Everyone logs toward the same objectives, and the chat sees every check-in.", + 14, + TextType.Regular, + color: colorGrey2, + height: 1.55, + ), + const SizedBox(height: 28), + if (_groups.isEmpty) + emptyState( + CupertinoIcons.person_3, + "No groups yet", + "Start one and send the code to a friend, or join with a code someone sent you.", + ) + else + ..._groups.map(_groupCard), + const SizedBox(height: 24), + roundedCornerButton("New group", _onNewGroup, + icon: CupertinoIcons.add), + const SizedBox(height: 10), + outlinedActionButton("Join with a code", _onJoinGroup, + icon: CupertinoIcons.ticket), + ], + ), + ); + } + + /// The group as a painted card: who is in it, how the shared goals are + /// going, and the latest line from the chat. + Widget _groupCard(Group group) { + final Color accent = goalColor(group.colourHex); + final Color soft = colorWhite.withValues(alpha: 0.78); + + return GestureDetector( + onTap: () => _onOpenGroup(group), + child: Container( + height: 250, + margin: const EdgeInsets.only(bottom: 14), + decoration: const BoxDecoration( + boxShadow: [ + BoxShadow( + color: Color(0x262A3A30), + blurRadius: 24, + offset: Offset(0, 10), + ), + ], + ), + child: Stack( + fit: StackFit.expand, + children: [ + SceneArt( + palette: goalScene(accent), + seed: sceneSeed(group.name), + figure: CupertinoIcons.person_3_fill, + figureScale: 0.42, + figureAlignment: const Alignment(0.7, -0.4), + shade: true, + ), + Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + GlassSurface( + onDark: true, + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 6), + child: text( + "${group.members.length} members · ${group.goals.length} shared goal${group.goals.length == 1 ? "" : "s"}", + 11, + TextType.Medium, + color: colorWhite, + ), + ), + const Spacer(), + if (group.unread > 0) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 6), + color: colorLime, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(CupertinoIcons.chat_bubble_fill, + size: 12, color: colorNight), + const SizedBox(width: 6), + text("${group.unread} new", 11, TextType.Medium, + color: colorNight), + ], + ), + ), + ], + ), + const Spacer(), + text(group.name, 24, TextType.Medium, + color: colorWhite, + maxLines: 1, + overflow: TextOverflow.ellipsis), + if (group.lastMessage.isNotEmpty) ...[ + const SizedBox(height: 4), + text("${group.lastAuthor}: ${group.lastMessage}", 13, + TextType.Regular, + color: soft, + maxLines: 1, + overflow: TextOverflow.ellipsis), + ], + const SizedBox(height: 14), + meter(group.progress, + fill: colorLime, + track: colorWhite.withValues(alpha: 0.25), + height: 6), + const SizedBox(height: 12), + Row( + children: [ + avatarStack( + group.members.map((member) => member.name).toList(), + 32), + const Spacer(), + text("${(group.progress * 100).round()}%", 22, + TextType.Light, + color: colorWhite), + const SizedBox(width: 6), + text("together", 12, TextType.Regular, color: soft), + ], + ), + ], + ), + ), + ], + ), + ), + ); + } + + // ── ConnectGroups ───────────────────────────────────────────────────────── + + @override + void onGroupsLoaded(List groups) { + setState(() { + _groups = groups; + }); + } +} diff --git a/frontend/lib/Grounded/see/group/ViewGroupChat.dart b/frontend/lib/Grounded/see/group/ViewGroupChat.dart new file mode 100644 index 0000000..3d9a5e6 --- /dev/null +++ b/frontend/lib/Grounded/see/group/ViewGroupChat.dart @@ -0,0 +1,53 @@ +import '../../about/external/data/GroupMessage.dart'; +import '../../about/external/initial/GroupMessageRequest.dart'; +import '../../about/external/initial/IdRequest.dart'; +import '../../utils/ObjectConvertors.dart'; +import '../parent/ParentViewModel.dart'; +import 'ConnectGroupChat.dart'; + +class ViewGroupChat extends ParentViewModel { + ConnectGroupChat connection; + + ViewGroupChat(super.context, this.connection); + + void loadMessages(String groupId) async { + if (!await hasNetwork(() => loadMessages(groupId))) return; + + showLoading("Opening the chat"); + + try { + final response = + await getDataManager().getGroupMessages(IdRequest(id: groupId)); + + closeLoading(); + + connection.onMessagesLoaded(getGroupMessageList(response.data)); + } catch (e) { + handleError(e, () => loadMessages(groupId), () => dismissError(), "Retry"); + } + } + + /// The background refresh while the chat is open. Silent either way — a + /// missed poll is picked up by the next one. + void refresh(String groupId) async { + try { + final response = + await getDataManager().getGroupMessages(IdRequest(id: groupId)); + + connection.onMessagesLoaded(getGroupMessageList(response.data)); + } catch (e) { + // Next tick tries again. + } + } + + void send(GroupMessageRequest request) async { + try { + final response = await getDataManager().sendGroupMessage(request); + + connection.onMessageSent(GroupMessage.fromJson(response.data)); + } catch (e) { + connection.onSendFailed(request.body); + handleError(e, () => send(request), () => dismissError(), "Retry"); + } + } +} diff --git a/frontend/lib/Grounded/see/group/ViewGroupDetail.dart b/frontend/lib/Grounded/see/group/ViewGroupDetail.dart new file mode 100644 index 0000000..e0797b0 --- /dev/null +++ b/frontend/lib/Grounded/see/group/ViewGroupDetail.dart @@ -0,0 +1,77 @@ +import '../../about/external/data/Group.dart'; +import '../../about/external/initial/IdRequest.dart'; +import '../../about/external/initial/NudgeRequest.dart'; +import '../../about/external/initial/ObjectiveProgressRequest.dart'; +import '../parent/ParentViewModel.dart'; +import 'ConnectGroupDetail.dart'; + +class ViewGroupDetail extends ParentViewModel { + ConnectGroupDetail connection; + + ViewGroupDetail(super.context, this.connection); + + /// [quiet] refreshes behind the screen without the loading overlay, for + /// coming back from the chat or the goal editor. + void loadGroup(String groupId, {bool quiet = false}) async { + if (!await hasNetwork(() => loadGroup(groupId, quiet: quiet))) return; + + if (!quiet) showLoading("Loading the group"); + + try { + final response = + await getDataManager().getGroupEntry(IdRequest(id: groupId)); + + if (!quiet) closeLoading(); + + connection.onGroupLoaded(Group.fromJson(response.data)); + } catch (e) { + handleError(e, () => loadGroup(groupId, quiet: quiet), + () => dismissError(), "Retry"); + } + } + + void logProgress(ObjectiveProgressRequest request, String summary) async { + if (!await hasNetwork(() => logProgress(request, summary))) return; + + showLoading("Logging"); + + try { + final response = await getDataManager().logObjectiveProgress(request); + + closeLoading(); + + connection.onProgressLogged(Group.fromJson(response.data), summary); + } catch (e) { + handleError(e, () => logProgress(request, summary), + () => dismissError(), "Retry"); + } + } + + void nudge(NudgeRequest request, String who) async { + if (!await hasNetwork(() => nudge(request, who))) return; + + try { + await getDataManager().nudgeGroupMember(request); + + connection.onNudged(who); + } catch (e) { + handleError(e, () => nudge(request, who), () => dismissError(), "Retry"); + } + } + + void leave(String groupId) async { + if (!await hasNetwork(() => leave(groupId))) return; + + showLoading("Leaving"); + + try { + await getDataManager().leaveGroupEntry(IdRequest(id: groupId)); + + closeLoading(); + + connection.onLeft(); + } catch (e) { + handleError(e, () => leave(groupId), () => dismissError(), "Retry"); + } + } +} diff --git a/frontend/lib/Grounded/see/group/ViewGroupEditor.dart b/frontend/lib/Grounded/see/group/ViewGroupEditor.dart new file mode 100644 index 0000000..744b36e --- /dev/null +++ b/frontend/lib/Grounded/see/group/ViewGroupEditor.dart @@ -0,0 +1,43 @@ +import '../../about/external/data/Group.dart'; +import '../../about/external/initial/GroupRequest.dart'; +import '../../about/external/initial/JoinGroupRequest.dart'; +import '../parent/ParentViewModel.dart'; +import 'ConnectGroupEditor.dart'; + +class ViewGroupEditor extends ParentViewModel { + ConnectGroupEditor connection; + + ViewGroupEditor(super.context, this.connection); + + void create(GroupRequest request) async { + if (!await hasNetwork(() => create(request))) return; + + showLoading("Creating the group"); + + try { + final response = await getDataManager().saveGroupEntry(request); + + closeLoading(); + + connection.onGroupReady(Group.fromJson(response.data)); + } catch (e) { + handleError(e, () => create(request), () => dismissError(), "Retry"); + } + } + + void join(JoinGroupRequest request) async { + if (!await hasNetwork(() => join(request))) return; + + showLoading("Joining"); + + try { + final response = await getDataManager().joinGroupEntry(request); + + closeLoading(); + + connection.onGroupReady(Group.fromJson(response.data)); + } catch (e) { + handleError(e, () => join(request), () => dismissError(), "Retry"); + } + } +} diff --git a/frontend/lib/Grounded/see/group/ViewGroupGoalEditor.dart b/frontend/lib/Grounded/see/group/ViewGroupGoalEditor.dart new file mode 100644 index 0000000..9a90af1 --- /dev/null +++ b/frontend/lib/Grounded/see/group/ViewGroupGoalEditor.dart @@ -0,0 +1,25 @@ +import '../../about/external/initial/GroupGoalRequest.dart'; +import '../parent/ParentViewModel.dart'; +import 'ConnectGroupGoalEditor.dart'; + +class ViewGroupGoalEditor extends ParentViewModel { + ConnectGroupGoalEditor connection; + + ViewGroupGoalEditor(super.context, this.connection); + + void save(GroupGoalRequest request) async { + if (!await hasNetwork(() => save(request))) return; + + showLoading("Saving the shared goal"); + + try { + await getDataManager().saveGroupGoalEntry(request); + + closeLoading(); + + connection.onGoalSaved(); + } catch (e) { + handleError(e, () => save(request), () => dismissError(), "Retry"); + } + } +} diff --git a/frontend/lib/Grounded/see/group/ViewGroups.dart b/frontend/lib/Grounded/see/group/ViewGroups.dart new file mode 100644 index 0000000..e63af89 --- /dev/null +++ b/frontend/lib/Grounded/see/group/ViewGroups.dart @@ -0,0 +1,34 @@ +import '../../about/external/data/pages/request/HistoryRequest.dart'; +import '../../about/external/data/pages/request/PageAndSort.dart'; +import '../../about/external/data/pages/request/Pageable.dart'; +import '../../about/external/data/pages/request/Sort.dart'; +import '../../utils/ObjectConvertors.dart'; +import '../parent/ParentViewModel.dart'; +import 'ConnectGroups.dart'; + +class ViewGroups extends ParentViewModel { + ConnectGroups connection; + + ViewGroups(super.context, this.connection); + + void loadGroups() async { + if (!await hasNetwork(() => loadGroups())) return; + + showLoading("Loading your groups"); + + try { + final response = await getDataManager().getMyGroups(HistoryRequest( + query: PageAndSort( + sort: Sort('desc', 'lastMessageAt'), + page: Pageable(0, 0, 50, 0), + ), + )); + + closeLoading(); + + connection.onGroupsLoaded(getGroupList(response.data)); + } catch (e) { + handleError(e, () => loadGroups(), () => dismissError(), "Retry"); + } + } +} diff --git a/frontend/lib/Grounded/see/home/ConnectHome.dart b/frontend/lib/Grounded/see/home/ConnectHome.dart index 9134f57..6b35653 100644 --- a/frontend/lib/Grounded/see/home/ConnectHome.dart +++ b/frontend/lib/Grounded/see/home/ConnectHome.dart @@ -1,5 +1,6 @@ import '../../about/external/data/Commitment.dart'; import '../../about/external/data/ExcuseCluster.dart'; +import '../../about/external/data/Group.dart'; import '../../about/external/data/ReportCard.dart'; import '../../about/external/data/RoutineChain.dart'; import '../../about/internal/application/Standing.dart'; @@ -20,8 +21,8 @@ abstract class ConnectHome { /// view. Null when it could not be loaded. void onWeekLoaded(ReportCard? week); - /// Water drunk today, in ml. - void onWaterLoaded(int ml); + /// Your groups, for the rail on Home. + void onGroupsLoaded(List groups); /// The quick to-do list, read from the device. void onTodosLoaded(List todos); diff --git a/frontend/lib/Grounded/see/home/HomeState.dart b/frontend/lib/Grounded/see/home/HomeState.dart index 92bb617..2de0ac1 100644 --- a/frontend/lib/Grounded/see/home/HomeState.dart +++ b/frontend/lib/Grounded/see/home/HomeState.dart @@ -8,6 +8,7 @@ import 'package:stacked/stacked.dart'; import '../../about/external/data/Commitment.dart'; import '../../about/external/data/ExcuseCluster.dart'; +import '../../about/external/data/Group.dart'; import '../../about/external/data/ReportCard.dart'; import '../../about/external/data/RoutineChain.dart'; import '../../about/internal/application/CommitmentClass.dart'; @@ -25,6 +26,7 @@ import '../../designs/Checkbox.dart'; import '../../designs/Component.dart'; import '../../designs/FloatingNav.dart'; import '../../designs/GlassSurface.dart'; +import '../../designs/MemberAvatar.dart'; import '../../designs/ProgressRing.dart'; import '../../designs/Responsive.dart'; import '../../designs/Shell.dart'; @@ -40,6 +42,9 @@ import '../../utils/ToneEngine.dart'; import '../commitment/NewCommitment.dart'; import '../excuse/ExcuseReport.dart'; import '../goal/Goals.dart'; +import '../group/GroupChat.dart'; +import '../group/GroupEditor.dart'; +import '../group/Groups.dart'; import '../overdue/OverdueQueue.dart'; import '../reportcard/ReportCardScreen.dart'; import '../routine/RoutineRunner.dart'; @@ -63,6 +68,8 @@ class HomeState extends State implements ConnectHome { List _todos = []; + List _groups = []; + final TextEditingController _todoInput = TextEditingController(); final FocusNode _todoFocus = FocusNode(); @@ -73,11 +80,6 @@ class HomeState extends State implements ConnectHome { bool _weekly = false; - int _waterMl = 0; - - static const int _waterTarget = 2000; - - static const int _waterStep = 250; Standing _standing = Standing.Good; @@ -194,6 +196,24 @@ class HomeState extends State implements ConnectHome { } } + void _onOpenGroups() async { + await GroundedNavigation().navigateToPageWithData(const Groups(), context); + _model?.loadGroups(); + } + + void _onOpenGroupChat(Group group) async { + await GroundedNavigation() + .navigateToPageWithData(GroupChat(group: group), context); + _model?.loadGroups(); + } + + void _onAddPickGroup() async { + _onCloseAddSheet(); + await GroundedNavigation() + .navigateToPageWithData(const GroupEditor(), context); + _model?.loadGroups(); + } + void _onOpenTraining() { GroundedNavigation() .navigateToPage(NavigatorType.justOpen, const Training(), context); @@ -249,20 +269,6 @@ class HomeState extends State implements ConnectHome { }); } - void _onAddWater() { - _onSetWaterDrops((_waterMl ~/ _waterStep) + 1); - } - - /// Tapping the last filled drop empties it again, so a mis-tap is undoable. - void _onSetWaterDrops(int drops) { - final int current = _waterMl ~/ _waterStep; - final int next = drops == current ? drops - 1 : drops; - setState(() { - _waterMl = (next * _waterStep).clamp(0, _waterTarget); - }); - _model?.saveWater(_waterMl); - } - void _onOpenAddSheet() { showGeneralDialog( context: context, @@ -398,10 +404,6 @@ class HomeState extends State implements ConnectHome { crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ - if (!_distressed) ...[ - _waterCard(), - const SizedBox(height: 28), - ], if (_distressed) _frosted(_distressBody()) else if (queueIsHome) @@ -436,16 +438,16 @@ class HomeState extends State implements ConnectHome { label: "Goals", onTap: _onOpenGoals ), + ( + icon: CupertinoIcons.person_3_fill, + label: "Groups", + onTap: _onOpenGroups + ), ( icon: CupertinoIcons.chart_bar_alt_fill, label: "Insights", onTap: _onOpenReportCard ), - ( - icon: CupertinoIcons.flame_fill, - label: "Training", - onTap: _onOpenTraining - ), ], ).animate().fadeIn(duration: 400.ms, delay: 250.ms).slideY( begin: 0.4, @@ -945,72 +947,6 @@ class HomeState extends State implements ConnectHome { ); } - // ── Water ───────────────────────────────────────────────────────────────── - - /// Water against the daily target, as drops you tap to fill. - Widget _waterCard() { - final int filled = (_waterMl / _waterStep).floor(); - - return GlassSurface( - tint: colorGlassStrong, - padding: const EdgeInsets.fromLTRB(20, 18, 16, 20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - children: [ - Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - text("Water", 15, TextType.Regular, color: colorInkSoft), - const SizedBox(height: 4), - Row( - crossAxisAlignment: CrossAxisAlignment.baseline, - textBaseline: TextBaseline.alphabetic, - children: [ - text(_thousands(_waterMl), 34, TextType.Medium, - color: colorInk, letterSpacing: -1), - const SizedBox(width: 4), - text("/${_thousands(_waterTarget)} ml", 16, - TextType.Regular, - color: colorInkSoft), - ], - ), - ], - ), - ), - glassCircle(CupertinoIcons.add, _onAddWater, size: 52), - ], - ), - const SizedBox(height: 18), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - for (int index = 0; index < _waterTarget ~/ _waterStep; index++) - GestureDetector( - onTap: () => _onSetWaterDrops(index + 1), - behavior: HitTestBehavior.opaque, - child: AnimatedScale( - duration: const Duration(milliseconds: 240), - curve: Curves.easeOutBack, - scale: index < filled ? 1 : 0.9, - child: Icon( - Icons.water_drop, - size: 38, - color: index < filled ? colorWater : colorWaterEmpty, - ), - ), - ), - ], - ), - ], - ), - ); - } - // ── Add sheet ───────────────────────────────────────────────────────────── /// Everything you can add, over the blurred day — a close button floating @@ -1053,7 +989,7 @@ class HomeState extends State implements ConnectHome { children: [ Center( child: Container( - width: 40, height: 4, color: colorWaterEmpty), + width: 40, height: 4, color: colorGrabber), ), const SizedBox(height: 18), text("Add to today", 24, TextType.Medium, @@ -1078,12 +1014,16 @@ class HomeState extends State implements ConnectHome { Row( children: [ Expanded( - child: _addWideTile(kindIcon(RoutineKind.MustDo), - "Must-do chain", _onAddPickMustDo)), + child: _addTile(kindIcon(RoutineKind.MustDo), + "Must-do", _onAddPickMustDo)), const SizedBox(width: 10), Expanded( - child: _addWideTile(CupertinoIcons.flag, + child: _addTile(CupertinoIcons.flag, "Goal", _onAddPickGoal)), + const SizedBox(width: 10), + Expanded( + child: _addTile(CupertinoIcons.person_3, + "Group", _onAddPickGroup)), ], ), const SizedBox(height: 10), @@ -1275,6 +1215,13 @@ class HomeState extends State implements ConnectHome { const SizedBox(height: 28), _todoSection(), const SizedBox(height: 28), + if (_groups.isNotEmpty) ...[ + sectionBreak("Your groups", + caption: _groupsCaption(), + trailing: _viewAll("All groups", _onOpenGroups)), + _groupRail(), + const SizedBox(height: 28), + ], if (_upNext.isNotEmpty) ...[ sectionBreak("Up next", caption: "${_upNext.length} routines", @@ -1354,8 +1301,8 @@ class HomeState extends State implements ConnectHome { () => _onOpenKind(RoutineKind.MustDo)), _category(goalScene(goalAccents[4]), CupertinoIcons.flag_fill, "Goals", 13, _onOpenGoals), - _category(goalScene(goalAccents[2]), - CupertinoIcons.chart_bar_alt_fill, "Report", 21, _onOpenReportCard), + _category(goalScene(goalAccents[2]), CupertinoIcons.flame_fill, + "Training", 21, _onOpenTraining), ], ); } @@ -1533,6 +1480,125 @@ class HomeState extends State implements ConnectHome { ); } + String _groupsCaption() { + int unread = 0; + for (Group group in _groups) { + unread = unread + group.unread; + } + return unread == 0 ? "${_groups.length} groups" : "$unread new messages"; + } + + /// Each group as a tile: its banner, who is in it, and the latest line. + /// Tapping goes straight into the chat. + Widget _groupRail() { + return SizedBox( + height: 214, + child: ListView.separated( + scrollDirection: Axis.horizontal, + clipBehavior: Clip.none, + itemCount: _groups.length, + separatorBuilder: (context, index) => const SizedBox(width: 12), + itemBuilder: (context, index) => _groupTile(_groups[index]), + ), + ); + } + + Widget _groupTile(Group group) { + final Color accent = goalColor(group.colourHex); + + return GestureDetector( + onTap: () => _onOpenGroupChat(group), + child: Container( + width: 250, + decoration: BoxDecoration( + color: colorWhite, + boxShadow: const [ + BoxShadow( + color: Color(0x142A3A30), + blurRadius: 20, + offset: Offset(0, 8), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + height: 96, + child: Stack( + fit: StackFit.expand, + children: [ + SceneArt( + palette: goalScene(accent), + seed: sceneSeed(group.name), + figure: CupertinoIcons.person_3_fill, + figureScale: 0.5, + figureAlignment: const Alignment(0.75, 0), + shade: true, + ), + Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (group.unread > 0) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 4), + color: colorLime, + child: text("${group.unread} new", 10, + TextType.Medium, + color: colorNight), + ), + const Spacer(), + text(group.name, 18, TextType.Medium, + color: colorWhite, + maxLines: 1, + overflow: TextOverflow.ellipsis), + ], + ), + ), + ], + ), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + text( + group.lastMessage.isEmpty + ? "No messages yet" + : "${group.lastAuthor}: ${group.lastMessage}", + 13, + TextType.Regular, + color: colorInk, + maxLines: 2, + overflow: TextOverflow.ellipsis, + height: 1.4, + ), + const Spacer(), + Row( + children: [ + avatarStack( + group.members.map((member) => member.name).toList(), + 26), + const Spacer(), + Icon(CupertinoIcons.chat_bubble_2_fill, + size: 16, color: colorInkSoft), + ], + ), + ], + ), + ), + ), + ], + ), + ), + ); + } + /// The quick to-do list: things to remember that are not promises. No /// window, no debt. Open items first, done ones struck through below. Widget _todoSection() { @@ -2078,9 +2144,9 @@ class HomeState extends State implements ConnectHome { } @override - void onWaterLoaded(int ml) { + void onGroupsLoaded(List groups) { setState(() { - _waterMl = ml; + _groups = groups; }); } diff --git a/frontend/lib/Grounded/see/home/ViewHome.dart b/frontend/lib/Grounded/see/home/ViewHome.dart index 8e30556..7bb4c51 100644 --- a/frontend/lib/Grounded/see/home/ViewHome.dart +++ b/frontend/lib/Grounded/see/home/ViewHome.dart @@ -1,5 +1,6 @@ import '../../about/external/data/Commitment.dart'; import '../../about/external/data/ExcuseCluster.dart'; +import '../../about/external/data/Group.dart'; import '../../about/external/data/ReportCard.dart'; import '../../about/external/data/RoutineChain.dart'; import '../../about/external/data/pages/request/CommitmentsRequest.dart'; @@ -35,10 +36,10 @@ class ViewHome extends ParentViewModel { loadTodos(); - loadWater(); - loadWeek(); + loadGroups(); + loadPlan(); } @@ -61,12 +62,20 @@ class ViewHome extends ParentViewModel { } } - void loadWater() async { - connection.onWaterLoaded(await getDataManager().getWaterToday()); - } + /// The groups rail. Quiet on failure: the rail just stays hidden. + void loadGroups() async { + try { + final response = await getDataManager().getMyGroups(HistoryRequest( + query: PageAndSort( + sort: Sort('desc', 'lastMessageAt'), + page: Pageable(0, 0, 20, 0), + ), + )); - void saveWater(int ml) async { - await getDataManager().setWaterToday(ml); + connection.onGroupsLoaded(getGroupList(response.data)); + } catch (e) { + connection.onGroupsLoaded([]); + } } void loadTodos() async { diff --git a/frontend/lib/Grounded/utils/Colors.dart b/frontend/lib/Grounded/utils/Colors.dart index 868b4f6..01e1fce 100644 --- a/frontend/lib/Grounded/utils/Colors.dart +++ b/frontend/lib/Grounded/utils/Colors.dart @@ -138,9 +138,8 @@ const Color colorHeaderSelected = Color(0xFF3D6B53); const Color colorBarNonNegotiable = Color(0xFFE8735A); const Color colorBarStandard = Color(0xFFF2C14E); const Color colorBarElective = Color(0xFF6FD39B); -// Water: filled drops and the empty ones. -const Color colorWater = Color(0xFF6E8EF0); -const Color colorWaterEmpty = Color(0xFFDDE1DE); +// The drag handle on white sheets. +const Color colorGrabber = Color(0xFFDDE1DE); // ── Scene art ───────────────────────────────────────────────────────────────── // The painted landscapes that stand in for photography on cards. Each kind has diff --git a/frontend/lib/Grounded/utils/CommonUtils.dart b/frontend/lib/Grounded/utils/CommonUtils.dart index 14a45ac..299b939 100644 --- a/frontend/lib/Grounded/utils/CommonUtils.dart +++ b/frontend/lib/Grounded/utils/CommonUtils.dart @@ -404,3 +404,20 @@ int sceneSeed(String key) { } return hash; } + +/// An amount the way a person reads it: whole numbers with thousands +/// separators, anything else to one decimal place. +String formatAmount(double value) { + if (value == value.roundToDouble()) { + final String digits = value.round().abs().toString(); + final StringBuffer out = StringBuffer(value < 0 ? "-" : ""); + for (int index = 0; index < digits.length; index++) { + if (index > 0 && (digits.length - index) % 3 == 0) { + out.write(","); + } + out.write(digits[index]); + } + return out.toString(); + } + return value.toStringAsFixed(1); +} diff --git a/frontend/lib/Grounded/utils/ObjectConvertors.dart b/frontend/lib/Grounded/utils/ObjectConvertors.dart index f339854..186e3fd 100644 --- a/frontend/lib/Grounded/utils/ObjectConvertors.dart +++ b/frontend/lib/Grounded/utils/ObjectConvertors.dart @@ -4,6 +4,11 @@ import '../about/external/data/DebtEntry.dart'; import '../about/external/data/ExcuseCluster.dart'; import '../about/external/data/ExercisePrescription.dart'; import '../about/external/data/Goal.dart'; +import '../about/external/data/Group.dart'; +import '../about/external/data/GroupGoal.dart'; +import '../about/external/data/GroupMember.dart'; +import '../about/external/data/GroupMessage.dart'; +import '../about/external/data/GroupObjective.dart'; import '../about/external/data/Habit.dart'; import '../about/external/data/Program.dart'; import '../about/external/data/RoutineChain.dart'; @@ -122,3 +127,38 @@ List getStringList(dynamic data) { } return (data as List).map((item) => item.toString()).toList(); } + +List getGroupList(dynamic data) { + if (data == null) { + return []; + } + return (data as List).map((item) => Group.fromJson(item)).toList(); +} + +List getGroupMemberList(dynamic data) { + if (data == null) { + return []; + } + return (data as List).map((item) => GroupMember.fromJson(item)).toList(); +} + +List getGroupGoalList(dynamic data) { + if (data == null) { + return []; + } + return (data as List).map((item) => GroupGoal.fromJson(item)).toList(); +} + +List getGroupObjectiveList(dynamic data) { + if (data == null) { + return []; + } + return (data as List).map((item) => GroupObjective.fromJson(item)).toList(); +} + +List getGroupMessageList(dynamic data) { + if (data == null) { + return []; + } + return (data as List).map((item) => GroupMessage.fromJson(item)).toList(); +} diff --git a/frontend/lib/main.dart b/frontend/lib/main.dart index 9561b33..39537c7 100644 --- a/frontend/lib/main.dart +++ b/frontend/lib/main.dart @@ -51,6 +51,10 @@ class GroundedApp extends StatelessWidget { borderRadius: BorderRadius.zero, ), ), + // Material 3 rounds sheet tops by default; the app is square. + bottomSheetTheme: const BottomSheetThemeData( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.zero), + ), ), home: const Splash(), );