Files
GroundedHelper/frontend/lib/Grounded/see/group/GroupDetailState.dart
alvocool 4b71257429 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
2026-11-03 11:46:41 +03:00

709 lines
24 KiB
Dart

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