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

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