Files
GroundedHelper/frontend/lib/Grounded/see/group/GroupDetailState.dart
alvocool d3431ca5de Switch to Inter, round the corners softly and make the nav switch tabs
Inter replaces General Sans (Inter Display for titles and big numbers).
Corners come back from before the squaring commit, scaled to a soft set
(16 cards, 14 buttons, 12 rows, 8 chips) and applied to the screens built
since. The nav is now a cluster of round frosted buttons that switches
Today, Routines, Goals, Groups and Progress in place inside Home: no
pushed pages, no back arrows, and Android back returns to Today.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KhQh614DMeYoQEH9SoMRUw
2026-11-09 07:08:46 +03:00

730 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),
decoration: BoxDecoration(
color: colorAmbientMid,
borderRadius: const BorderRadius.all(Radius.circular(12)),
),
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(
borderRadius: BorderRadius.all(Radius.circular(12)),
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),
decoration: BoxDecoration(
color: colorLime,
borderRadius: const BorderRadius.all(Radius.circular(8)),
),
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,
decoration: BoxDecoration(
color: colorWhite.withValues(alpha: 0.12),
borderRadius: const BorderRadius.all(Radius.circular(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(
borderRadius: const BorderRadius.all(Radius.circular(12)),
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),
decoration: BoxDecoration(
color: colorNight,
borderRadius: const BorderRadius.all(Radius.circular(12)),
),
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,
decoration: BoxDecoration(
color: nudged ? colorAmbientMid : colorNight,
borderRadius: const BorderRadius.all(Radius.circular(12)),
),
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,
decoration: BoxDecoration(
color: colorAmbientMid,
borderRadius: const BorderRadius.all(Radius.circular(12)),
),
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);
}
}