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:
708
frontend/lib/Grounded/see/group/GroupDetailState.dart
Normal file
708
frontend/lib/Grounded/see/group/GroupDetailState.dart
Normal file
@@ -0,0 +1,708 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user