Add groups with shared goals, objectives and chat; drop the water tracker

Groups let people keep each other honest: create one or join with a
six-letter invite code. Each group has shared goals split into measurable
objectives (km, sessions, words) that every member logs toward, a
leaderboard of kept rate and streak with nudges, and a group chat where
check-ins and nudges appear as cards beside the talk. The open chat polls
for new messages.

Reached from a new Groups tab (Training moves into the day rail), a Home
"Your groups" rail that opens the chat, and a Group tile in the add sheet.
Mock mode seeds two groups, a joinable one and members who reply.

The water tracker is removed; it did not fit the app. Bottom sheets are
now square app-wide via the theme.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KhQh614DMeYoQEH9SoMRUw
This commit is contained in:
alvocool
2026-11-03 11:46:41 +03:00
parent 4548e1f164
commit 4b71257429
51 changed files with 3871 additions and 157 deletions

View File

@@ -32,7 +32,7 @@ informatics/ DataManager · AppDataManager
memory/ ConnectInternalMemory · InternalMemory
configs/ Navigator · Env · NotificationServiceConfig
designs/ Component · Shell · Responsive · buttons/ input/ text/
see/<feature>/ screens, 4 files each
see/<feature>/ screens, 4 files each (group/ = groups, shared goals, chat)
utils/ Colors · CommonUtils · engines · validators
```

View File

@@ -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.

View File

@@ -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<GroupMember> members;
List<GroupGoal> 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<GroupMember>? members,
List<GroupGoal>? goals,
this.unread = 0,
this.lastMessage = "",
this.lastAuthor = "",
this.lastMessageAt,
}) : members = members ?? <GroupMember>[],
goals = goals ?? <GroupGoal>[];
/// 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<String, dynamic> 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<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
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;
}
}

View File

@@ -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<GroupObjective> objectives;
GroupGoal({
this.id = "",
this.title = "",
this.description = "",
this.targetDate,
this.colourHex = "",
List<GroupObjective>? objectives,
}) : objectives = objectives ?? <GroupObjective>[];
/// 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<String, dynamic> 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<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
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;
}
}

View File

@@ -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<String, dynamic> 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<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
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;
}
}

View File

@@ -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<String, dynamic> 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<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
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;
}
}

View File

@@ -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<String, dynamic> 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<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['id'] = id;
data['title'] = title;
data['unit'] = unit;
data['target'] = target;
data['current'] = current;
data['mine'] = mine;
return data;
}
}

View File

@@ -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<ObjectiveDraft> objectives;
GroupGoalRequest({
this.groupId = "",
this.id,
this.title = "",
this.description = "",
this.targetDate = "",
this.colourHex = "",
List<ObjectiveDraft>? objectives,
}) : objectives = objectives ?? <ObjectiveDraft>[];
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
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;
}
}

View File

@@ -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<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['groupId'] = groupId;
data['body'] = body;
data['kind'] = kind;
return data;
}
}

View File

@@ -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<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['id'] = id;
data['name'] = name;
data['description'] = description;
data['colourHex'] = colourHex;
return data;
}
}

View File

@@ -0,0 +1,12 @@
/// Joins a group by the code a friend shared.
class JoinGroupRequest {
String inviteCode;
JoinGroupRequest({this.inviteCode = ""});
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['inviteCode'] = inviteCode;
return data;
}
}

View File

@@ -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<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['groupId'] = groupId;
data['memberId'] = memberId;
return data;
}
}

View File

@@ -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<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['title'] = title;
data['unit'] = unit;
data['target'] = target;
return data;
}
}

View File

@@ -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<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['groupId'] = groupId;
data['goalId'] = goalId;
data['objectiveId'] = objectiveId;
data['amount'] = amount;
data['note'] = note;
return data;
}
}

View File

@@ -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<Response> getMyGroups(HistoryRequest request) async {
Pair navigation = await getRequestHeaders(myGroups, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request);
}
@override
Future<Response> getGroupEntry(IdRequest request) async {
Pair navigation = await getRequestHeaders(oneGroup, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request);
}
@override
Future<Response> saveGroupEntry(GroupRequest request) async {
Pair navigation = await getRequestHeaders(saveGroup, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request);
}
@override
Future<Response> joinGroupEntry(JoinGroupRequest request) async {
Pair navigation = await getRequestHeaders(joinGroup, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request);
}
@override
Future<Response> leaveGroupEntry(IdRequest request) async {
Pair navigation = await getRequestHeaders(leaveGroup, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request);
}
@override
Future<Response> saveGroupGoalEntry(GroupGoalRequest request) async {
Pair navigation = await getRequestHeaders(saveGroupGoal, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request);
}
@override
Future<Response> logObjectiveProgress(ObjectiveProgressRequest request) async {
Pair navigation = await getRequestHeaders(logObjective, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request);
}
@override
Future<Response> getGroupMessages(IdRequest request) async {
Pair navigation = await getRequestHeaders(groupMessages, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request);
}
@override
Future<Response> sendGroupMessage(GroupMessageRequest request) async {
Pair navigation = await getRequestHeaders(sendGroupMessageTo, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request);
}
@override
Future<Response> nudgeGroupMember(NudgeRequest request) async {
Pair navigation = await getRequestHeaders(nudgeMember, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request);
}
}

View File

@@ -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';

View File

@@ -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<Response> getMyNotifications(HistoryRequest request);
Future<Response> acknowledgeNudgeEntry(IdRequest request);
// ── Groups ──────────────────────────────────────────────────────────────
Future<Response> getMyGroups(HistoryRequest request);
Future<Response> getGroupEntry(IdRequest request);
Future<Response> saveGroupEntry(GroupRequest request);
Future<Response> joinGroupEntry(JoinGroupRequest request);
Future<Response> leaveGroupEntry(IdRequest request);
Future<Response> saveGroupGoalEntry(GroupGoalRequest request);
Future<Response> logObjectiveProgress(ObjectiveProgressRequest request);
Future<Response> getGroupMessages(IdRequest request);
Future<Response> sendGroupMessage(GroupMessageRequest request);
Future<Response> nudgeGroupMember(NudgeRequest request);
}

View File

@@ -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<List<Color>> palettes = <List<Color>>[
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<String> names, double size,
{int max = 4, Color? border}) {
final List<String> 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),
),
),
],
),
);
}

View File

@@ -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<int> getWaterToday() => memory.getWaterToday();
@override
Future setWaterToday(int ml) => memory.setWaterToday(ml);
@override
Future<List<TodoItem>> getTodos() => memory.getTodos();
@@ -376,4 +376,36 @@ class AppDataManager implements DataManager {
@override
Future<Response> acknowledgeNudgeEntry(IdRequest request) =>
comms.acknowledgeNudgeEntry(request);
// ── Groups ────────────────────────────────────────────────────────────────
@override
Future<Response> getMyGroups(HistoryRequest request) => comms.getMyGroups(request);
@override
Future<Response> getGroupEntry(IdRequest request) => comms.getGroupEntry(request);
@override
Future<Response> saveGroupEntry(GroupRequest request) => comms.saveGroupEntry(request);
@override
Future<Response> joinGroupEntry(JoinGroupRequest request) => comms.joinGroupEntry(request);
@override
Future<Response> leaveGroupEntry(IdRequest request) => comms.leaveGroupEntry(request);
@override
Future<Response> saveGroupGoalEntry(GroupGoalRequest request) => comms.saveGroupGoalEntry(request);
@override
Future<Response> logObjectiveProgress(ObjectiveProgressRequest request) => comms.logObjectiveProgress(request);
@override
Future<Response> getGroupMessages(IdRequest request) => comms.getGroupMessages(request);
@override
Future<Response> sendGroupMessage(GroupMessageRequest request) => comms.sendGroupMessage(request);
@override
Future<Response> nudgeGroupMember(NudgeRequest request) => comms.nudgeGroupMember(request);
}

View File

@@ -60,11 +60,6 @@ abstract class ConnectInternalMemory {
Future setAmnestySpent(int spent);
/// Water drunk today in ml; a new day reads as 0.
Future<int> getWaterToday();
Future setWaterToday(int ml);
/// The quick to-do list on Home.
Future<List<TodoItem>> getTodos();

View File

@@ -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<int> getWaterToday() async {
String? value = await groundedStorage.read(key: WATER);
if (value != null) {
final Map<String, dynamic> 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<List<TodoItem>> getTodos() async {
String? value = await groundedStorage.read(key: TODOS);

View File

@@ -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<Response> acknowledgeNudgeEntry(IdRequest request) => _done();
// ── Groups ────────────────────────────────────────────────────────────────
@override
Future<Response> getMyGroups(HistoryRequest request) => _ok(MockGroups.mine());
@override
Future<Response> getGroupEntry(IdRequest request) => _ok(MockGroups.one(request.id));
@override
Future<Response> saveGroupEntry(GroupRequest request) => _ok(MockGroups.save(request));
@override
Future<Response> joinGroupEntry(JoinGroupRequest request) => _ok(MockGroups.join(request));
@override
Future<Response> leaveGroupEntry(IdRequest request) => _ok(MockGroups.leave(request.id));
@override
Future<Response> saveGroupGoalEntry(GroupGoalRequest request) => _ok(MockGroups.saveGoal(request));
@override
Future<Response> logObjectiveProgress(ObjectiveProgressRequest request) => _ok(MockGroups.logProgress(request));
@override
Future<Response> getGroupMessages(IdRequest request) => _ok(MockGroups.messages(request.id));
@override
Future<Response> sendGroupMessage(GroupMessageRequest request) => _ok(MockGroups.send(request));
@override
Future<Response> nudgeGroupMember(NudgeRequest request) => _ok(MockGroups.nudge(request));
}

View File

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

View File

@@ -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>[
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),
]);

View File

@@ -0,0 +1,10 @@
import '../../about/external/data/GroupMessage.dart';
abstract class ConnectGroupChat {
void onMessagesLoaded(List<GroupMessage> messages);
void onMessageSent(GroupMessage message);
/// The send failed; the text goes back in the box so nothing is lost.
void onSendFailed(String body);
}

View File

@@ -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();
}

View File

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

View File

@@ -0,0 +1,3 @@
abstract class ConnectGroupGoalEditor {
void onGoalSaved();
}

View File

@@ -0,0 +1,5 @@
import '../../about/external/data/Group.dart';
abstract class ConnectGroups {
void onGroupsLoaded(List<Group> groups);
}

View File

@@ -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<GroupChat> createState() => GroupChatState();
}

View File

@@ -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<GroupChat> implements ConnectGroupChat {
ViewGroupChat? _model;
List<GroupMessage> _messages = <GroupMessage>[];
final TextEditingController _input = TextEditingController();
Timer? _poll;
/// How often the open chat looks for new messages.
static const Duration _pollEvery = Duration(seconds: 4);
static const List<String> _quickReplies = <String>[
"Done for today",
"On my way",
"Need a push",
"Who's in tomorrow?",
];
@override
void dispose() {
_poll?.cancel();
_input.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ViewModelBuilder<ViewGroupChat>.reactive(
viewModelBuilder: () => ViewGroupChat(context, this),
onViewModelReady: (viewModel) {
_model = viewModel;
_initiate();
},
builder: (context, viewModel, child) => LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
return Responsive(
mobile: _mobileView(constraints),
tablet: _mobileView(constraints),
desktop: _mobileView(constraints),
);
},
),
);
}
void _initiate() {
_model?.loadMessages(widget.group.id);
_poll = Timer.periodic(_pollEvery, _onPoll);
}
// ── Handlers ──────────────────────────────────────────────────────────────
void _onBack() {
Navigator.pop(context, true);
}
void _onPoll(Timer timer) {
_model?.refresh(widget.group.id);
}
void _onSend(String value) {
final String body = value.trim();
if (body.isEmpty) {
return;
}
_input.clear();
_model?.send(GroupMessageRequest(groupId: widget.group.id, body: body));
}
void _onSendPressed() {
_onSend(_input.text);
}
void _onQuickReply(String reply) {
_onSend(reply);
}
// ── Views ─────────────────────────────────────────────────────────────────
Widget _mobileView(BoxConstraints constraints) {
final List<GroupMessage> newestFirst = _messages.reversed.toList();
return Scaffold(
backgroundColor: colorAmbientMid,
body: AmbientBackground(
child: Column(
children: [
_topBar(),
Expanded(
child: ListView.builder(
reverse: true,
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
itemCount: newestFirst.length,
itemBuilder: (context, index) {
final GroupMessage message = newestFirst[index];
final GroupMessage? older = index + 1 < newestFirst.length
? newestFirst[index + 1]
: null;
final bool newDay = older == null ||
!_sameDay(older.sentAt, message.sentAt);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
if (newDay) _dayLabel(message.sentAt),
_message(message, older),
],
);
},
),
),
_composer(),
],
),
),
);
}
Widget _topBar() {
final Color accent = goalColor(widget.group.colourHex);
return Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: <Color>[
Color.lerp(accent, colorHeaderBottom, 0.55)!,
colorHeaderBottom,
],
),
),
padding: EdgeInsets.fromLTRB(
16, MediaQuery.of(context).padding.top + 10, 16, 14),
child: Row(
children: [
glassCircle(CupertinoIcons.arrow_left, _onBack,
size: 46, onDark: true),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
text(widget.group.name, 19, TextType.Medium,
color: colorWhite,
maxLines: 1,
overflow: TextOverflow.ellipsis),
const SizedBox(height: 3),
text(
widget.group.members
.map((member) => member.me ? "you" : member.name)
.join(", "),
12,
TextType.Regular,
color: colorHeaderMuted,
maxLines: 1,
overflow: TextOverflow.ellipsis),
],
),
),
const SizedBox(width: 10),
avatarStack(
widget.group.members.map((member) => member.name).toList(), 30,
max: 3, border: colorHeaderBottom),
],
),
);
}
Widget _dayLabel(DateTime? moment) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 14),
child: Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
color: colorWhite.withValues(alpha: 0.7),
child: text(_dayName(moment), 11, TextType.Medium,
color: colorInkSoft),
),
),
);
}
/// Text as bubbles, check-ins and nudges as cards in the thread, system
/// notes as a quiet centred line.
Widget _message(GroupMessage message, GroupMessage? older) {
if (message.isSystem) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: text(
"${message.mine ? "You" : message.authorName} ${message.body}",
12,
TextType.Regular,
color: colorInkSoft,
align: TextAlign.center,
),
);
}
if (message.isCheckIn || message.isNudge) {
return _eventCard(message);
}
// Consecutive lines from one person share a single name and portrait.
final bool grouped = older != null &&
older.authorId == message.authorId &&
!older.isSystem &&
!older.isCheckIn &&
!older.isNudge &&
_sameDay(older.sentAt, message.sentAt);
if (message.mine) {
return Padding(
padding: EdgeInsets.only(top: grouped ? 4 : 12, left: 64),
child: Align(
alignment: Alignment.centerRight,
child: Container(
padding: const EdgeInsets.fromLTRB(14, 11, 14, 10),
color: colorNight,
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
text(message.body, 15, TextType.Regular,
color: colorWhite, height: 1.4),
const SizedBox(height: 4),
text(_time(message.sentAt), 10, TextType.Regular,
color: colorHeaderMuted),
],
),
),
),
);
}
final Color tone = _authorColour(message.authorName);
return Padding(
padding: EdgeInsets.only(top: grouped ? 4 : 12, right: 56),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
grouped
? const SizedBox(width: 34)
: memberAvatar(message.authorName, 34),
const SizedBox(width: 8),
Flexible(
child: Container(
padding: const EdgeInsets.fromLTRB(14, 10, 14, 10),
decoration: BoxDecoration(
color: colorWhite,
border: Border.all(color: colorGlassBorder),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (!grouped) ...[
text(message.authorName, 12, TextType.Medium, color: tone),
const SizedBox(height: 3),
],
text(message.body, 15, TextType.Regular,
color: colorInk, height: 1.4),
const SizedBox(height: 4),
text(_time(message.sentAt), 10, TextType.Regular,
color: colorInkSoft),
],
),
),
),
],
),
);
}
/// A check-in or a nudge, set apart from the talk so the work is visible.
Widget _eventCard(GroupMessage message) {
final bool checkIn = message.isCheckIn;
final String who = message.mine ? "You" : message.authorName;
return Padding(
padding: const EdgeInsets.only(top: 12),
child: Container(
padding: const EdgeInsets.fromLTRB(12, 12, 14, 12),
decoration: BoxDecoration(
color: checkIn ? colorStandingGoodBg : colorOverdueWarmEnd,
border: Border(
left: BorderSide(
color: checkIn ? colorPositive : colorBarStandard,
width: 4,
),
),
),
child: Row(
children: [
memberAvatar(message.authorName, 34),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Icon(
checkIn
? CupertinoIcons.checkmark_seal_fill
: CupertinoIcons.hand_point_right_fill,
size: 13,
color: checkIn ? colorPositive : colorWarning,
),
const SizedBox(width: 5),
text(checkIn ? "CHECK-IN" : "NUDGE", 10, TextType.Bold,
color: checkIn ? colorPositive : colorWarning,
letterSpacing: 1.0),
const Spacer(),
text(_time(message.sentAt), 10, TextType.Regular,
color: colorInkSoft),
],
),
const SizedBox(height: 4),
text("$who ${message.body}", 14, TextType.Regular,
color: colorInk, height: 1.4),
],
),
),
],
),
),
);
}
Widget _composer() {
return Container(
color: colorWhite,
padding: EdgeInsets.fromLTRB(
0, 10, 0, MediaQuery.of(context).padding.bottom + 10),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: 38,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: _quickReplies.length,
separatorBuilder: (context, index) => const SizedBox(width: 8),
itemBuilder: (context, index) => GestureDetector(
onTap: () => _onQuickReply(_quickReplies[index]),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14),
alignment: Alignment.center,
color: colorAmbientMid,
child: text(_quickReplies[index], 13, TextType.Regular,
color: colorInk),
),
),
),
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 10, 0),
child: Row(
children: [
Expanded(
child: quickAddField(_input, "Message the group", _onSend),
),
const SizedBox(width: 8),
GestureDetector(
onTap: _onSendPressed,
child: Container(
width: 50,
height: 50,
color: colorNight,
child: Icon(CupertinoIcons.paperplane_fill,
size: 20, color: colorLime),
),
),
],
),
),
],
),
);
}
// ── Helpers ───────────────────────────────────────────────────────────────
Color _authorColour(String name) {
return goalAccents[sceneSeed(name) % goalAccents.length];
}
bool _sameDay(DateTime? a, DateTime? b) {
if (a == null || b == null) return false;
final DateTime x = a.toLocal();
final DateTime y = b.toLocal();
return x.year == y.year && x.month == y.month && x.day == y.day;
}
String _dayName(DateTime? moment) {
if (moment == null) return "";
final DateTime now = DateTime.now();
if (_sameDay(moment, now)) return "Today";
if (_sameDay(moment, now.subtract(const Duration(days: 1)))) {
return "Yesterday";
}
return formatDate(moment.toLocal());
}
String _time(DateTime? moment) {
if (moment == null) return "";
final DateTime local = moment.toLocal();
return "${local.hour.toString().padLeft(2, "0")}:${local.minute.toString().padLeft(2, "0")}";
}
// ── ConnectGroupChat ──────────────────────────────────────────────────────
@override
void onMessagesLoaded(List<GroupMessage> messages) {
if (!mounted) return;
setState(() {
_messages = messages;
});
}
@override
void onMessageSent(GroupMessage message) {
if (!mounted) return;
setState(() {
_messages = <GroupMessage>[..._messages, message];
});
}
@override
void onSendFailed(String body) {
if (!mounted) return;
_input.text = body;
}
}

View File

@@ -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<GroupDetail> createState() => GroupDetailState();
}

View File

@@ -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<GroupDetail>
implements ConnectGroupDetail {
ViewGroupDetail? _model;
late Group _group;
/// Members nudged from this screen, so the button reads "Nudged" after.
final Set<String> _nudged = <String>{};
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<ViewGroupDetail>.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<GroupMember> ranked = List<GroupMember>.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: <Color>[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<double> 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: <TextInputFormatter>[
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<double> _presets(String unit) {
switch (unit.toLowerCase()) {
case "steps":
return <double>[1000, 5000, 10000];
case "words":
return <double>[250, 500, 1000];
case "hours":
return <double>[0.5, 1, 2];
case "km":
return <double>[3, 5, 10];
default:
return <double>[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);
}
}

View File

@@ -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<GroupEditor> createState() => GroupEditorState();
}

View File

@@ -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<GroupEditor>
implements ConnectGroupEditor {
ViewGroupEditor? _model;
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
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<ViewGroupEditor>.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<String>(
options: const <String>["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<Widget> _createFields() {
return <Widget>[
_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<Widget> _joinFields() {
return <Widget>[
inputField(
"Invite code",
_code,
hint: "6 characters, like MRN7AM",
validator: _validateCode,
icon: CupertinoIcons.ticket,
formatters: <TextInputFormatter>[
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: <Color>[_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 <String>["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);
}
}

View File

@@ -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<GroupGoalEditor> createState() => GroupGoalEditorState();
}

View File

@@ -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<GroupGoalEditor>
implements ConnectGroupGoalEditor {
ViewGroupGoalEditor? _model;
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
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<ViewGroupGoalEditor>.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: <TextInputFormatter>[
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);
}
}

View File

@@ -0,0 +1,10 @@
import 'package:flutter/material.dart';
import 'GroupsState.dart';
class Groups extends StatefulWidget {
const Groups({super.key});
@override
State<Groups> createState() => GroupsState();
}

View File

@@ -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<Groups> implements ConnectGroups {
ViewGroups? _model;
List<Group> _groups = <Group>[];
@override
Widget build(BuildContext context) {
return ViewModelBuilder<ViewGroups>.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<Group> groups) {
setState(() {
_groups = groups;
});
}
}

View File

@@ -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");
}
}
}

View File

@@ -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");
}
}
}

View File

@@ -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");
}
}
}

View File

@@ -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");
}
}
}

View File

@@ -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");
}
}
}

View File

@@ -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<Group> groups);
/// The quick to-do list, read from the device.
void onTodosLoaded(List<TodoItem> todos);

View File

@@ -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<Home> implements ConnectHome {
List<TodoItem> _todos = <TodoItem>[];
List<Group> _groups = <Group>[];
final TextEditingController _todoInput = TextEditingController();
final FocusNode _todoFocus = FocusNode();
@@ -73,11 +80,6 @@ class HomeState extends State<Home> 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<Home> 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<Home> 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<Home> 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<Home> 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<Home> 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<Home> 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<Home> 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<Home> 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<Home> 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<Home> 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<Home> implements ConnectHome {
}
@override
void onWaterLoaded(int ml) {
void onGroupsLoaded(List<Group> groups) {
setState(() {
_waterMl = ml;
_groups = groups;
});
}

View File

@@ -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(<Group>[]);
}
}
void loadTodos() async {

View File

@@ -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

View File

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

View File

@@ -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<String> getStringList(dynamic data) {
}
return (data as List).map((item) => item.toString()).toList();
}
List<Group> getGroupList(dynamic data) {
if (data == null) {
return <Group>[];
}
return (data as List).map((item) => Group.fromJson(item)).toList();
}
List<GroupMember> getGroupMemberList(dynamic data) {
if (data == null) {
return <GroupMember>[];
}
return (data as List).map((item) => GroupMember.fromJson(item)).toList();
}
List<GroupGoal> getGroupGoalList(dynamic data) {
if (data == null) {
return <GroupGoal>[];
}
return (data as List).map((item) => GroupGoal.fromJson(item)).toList();
}
List<GroupObjective> getGroupObjectiveList(dynamic data) {
if (data == null) {
return <GroupObjective>[];
}
return (data as List).map((item) => GroupObjective.fromJson(item)).toList();
}
List<GroupMessage> getGroupMessageList(dynamic data) {
if (data == null) {
return <GroupMessage>[];
}
return (data as List).map((item) => GroupMessage.fromJson(item)).toList();
}

View File

@@ -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(),
);