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