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

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

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

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

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

View File

@@ -0,0 +1,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;
}
}