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
709 lines
24 KiB
Dart
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);
|
|
}
|
|
}
|