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:
@@ -8,6 +8,7 @@ import 'package:stacked/stacked.dart';
|
||||
|
||||
import '../../about/external/data/Commitment.dart';
|
||||
import '../../about/external/data/ExcuseCluster.dart';
|
||||
import '../../about/external/data/Group.dart';
|
||||
import '../../about/external/data/ReportCard.dart';
|
||||
import '../../about/external/data/RoutineChain.dart';
|
||||
import '../../about/internal/application/CommitmentClass.dart';
|
||||
@@ -25,6 +26,7 @@ import '../../designs/Checkbox.dart';
|
||||
import '../../designs/Component.dart';
|
||||
import '../../designs/FloatingNav.dart';
|
||||
import '../../designs/GlassSurface.dart';
|
||||
import '../../designs/MemberAvatar.dart';
|
||||
import '../../designs/ProgressRing.dart';
|
||||
import '../../designs/Responsive.dart';
|
||||
import '../../designs/Shell.dart';
|
||||
@@ -40,6 +42,9 @@ import '../../utils/ToneEngine.dart';
|
||||
import '../commitment/NewCommitment.dart';
|
||||
import '../excuse/ExcuseReport.dart';
|
||||
import '../goal/Goals.dart';
|
||||
import '../group/GroupChat.dart';
|
||||
import '../group/GroupEditor.dart';
|
||||
import '../group/Groups.dart';
|
||||
import '../overdue/OverdueQueue.dart';
|
||||
import '../reportcard/ReportCardScreen.dart';
|
||||
import '../routine/RoutineRunner.dart';
|
||||
@@ -63,6 +68,8 @@ class HomeState extends State<Home> implements ConnectHome {
|
||||
|
||||
List<TodoItem> _todos = <TodoItem>[];
|
||||
|
||||
List<Group> _groups = <Group>[];
|
||||
|
||||
final TextEditingController _todoInput = TextEditingController();
|
||||
|
||||
final FocusNode _todoFocus = FocusNode();
|
||||
@@ -73,11 +80,6 @@ class HomeState extends State<Home> implements ConnectHome {
|
||||
|
||||
bool _weekly = false;
|
||||
|
||||
int _waterMl = 0;
|
||||
|
||||
static const int _waterTarget = 2000;
|
||||
|
||||
static const int _waterStep = 250;
|
||||
|
||||
Standing _standing = Standing.Good;
|
||||
|
||||
@@ -194,6 +196,24 @@ class HomeState extends State<Home> implements ConnectHome {
|
||||
}
|
||||
}
|
||||
|
||||
void _onOpenGroups() async {
|
||||
await GroundedNavigation().navigateToPageWithData(const Groups(), context);
|
||||
_model?.loadGroups();
|
||||
}
|
||||
|
||||
void _onOpenGroupChat(Group group) async {
|
||||
await GroundedNavigation()
|
||||
.navigateToPageWithData(GroupChat(group: group), context);
|
||||
_model?.loadGroups();
|
||||
}
|
||||
|
||||
void _onAddPickGroup() async {
|
||||
_onCloseAddSheet();
|
||||
await GroundedNavigation()
|
||||
.navigateToPageWithData(const GroupEditor(), context);
|
||||
_model?.loadGroups();
|
||||
}
|
||||
|
||||
void _onOpenTraining() {
|
||||
GroundedNavigation()
|
||||
.navigateToPage(NavigatorType.justOpen, const Training(), context);
|
||||
@@ -249,20 +269,6 @@ class HomeState extends State<Home> implements ConnectHome {
|
||||
});
|
||||
}
|
||||
|
||||
void _onAddWater() {
|
||||
_onSetWaterDrops((_waterMl ~/ _waterStep) + 1);
|
||||
}
|
||||
|
||||
/// Tapping the last filled drop empties it again, so a mis-tap is undoable.
|
||||
void _onSetWaterDrops(int drops) {
|
||||
final int current = _waterMl ~/ _waterStep;
|
||||
final int next = drops == current ? drops - 1 : drops;
|
||||
setState(() {
|
||||
_waterMl = (next * _waterStep).clamp(0, _waterTarget);
|
||||
});
|
||||
_model?.saveWater(_waterMl);
|
||||
}
|
||||
|
||||
void _onOpenAddSheet() {
|
||||
showGeneralDialog(
|
||||
context: context,
|
||||
@@ -398,10 +404,6 @@ class HomeState extends State<Home> implements ConnectHome {
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (!_distressed) ...[
|
||||
_waterCard(),
|
||||
const SizedBox(height: 28),
|
||||
],
|
||||
if (_distressed)
|
||||
_frosted(_distressBody())
|
||||
else if (queueIsHome)
|
||||
@@ -436,16 +438,16 @@ class HomeState extends State<Home> implements ConnectHome {
|
||||
label: "Goals",
|
||||
onTap: _onOpenGoals
|
||||
),
|
||||
(
|
||||
icon: CupertinoIcons.person_3_fill,
|
||||
label: "Groups",
|
||||
onTap: _onOpenGroups
|
||||
),
|
||||
(
|
||||
icon: CupertinoIcons.chart_bar_alt_fill,
|
||||
label: "Insights",
|
||||
onTap: _onOpenReportCard
|
||||
),
|
||||
(
|
||||
icon: CupertinoIcons.flame_fill,
|
||||
label: "Training",
|
||||
onTap: _onOpenTraining
|
||||
),
|
||||
],
|
||||
).animate().fadeIn(duration: 400.ms, delay: 250.ms).slideY(
|
||||
begin: 0.4,
|
||||
@@ -945,72 +947,6 @@ class HomeState extends State<Home> implements ConnectHome {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Water ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Water against the daily target, as drops you tap to fill.
|
||||
Widget _waterCard() {
|
||||
final int filled = (_waterMl / _waterStep).floor();
|
||||
|
||||
return GlassSurface(
|
||||
tint: colorGlassStrong,
|
||||
padding: const EdgeInsets.fromLTRB(20, 18, 16, 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
text("Water", 15, TextType.Regular, color: colorInkSoft),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
text(_thousands(_waterMl), 34, TextType.Medium,
|
||||
color: colorInk, letterSpacing: -1),
|
||||
const SizedBox(width: 4),
|
||||
text("/${_thousands(_waterTarget)} ml", 16,
|
||||
TextType.Regular,
|
||||
color: colorInkSoft),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
glassCircle(CupertinoIcons.add, _onAddWater, size: 52),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
for (int index = 0; index < _waterTarget ~/ _waterStep; index++)
|
||||
GestureDetector(
|
||||
onTap: () => _onSetWaterDrops(index + 1),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: AnimatedScale(
|
||||
duration: const Duration(milliseconds: 240),
|
||||
curve: Curves.easeOutBack,
|
||||
scale: index < filled ? 1 : 0.9,
|
||||
child: Icon(
|
||||
Icons.water_drop,
|
||||
size: 38,
|
||||
color: index < filled ? colorWater : colorWaterEmpty,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Add sheet ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Everything you can add, over the blurred day — a close button floating
|
||||
@@ -1053,7 +989,7 @@ class HomeState extends State<Home> implements ConnectHome {
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 40, height: 4, color: colorWaterEmpty),
|
||||
width: 40, height: 4, color: colorGrabber),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
text("Add to today", 24, TextType.Medium,
|
||||
@@ -1078,12 +1014,16 @@ class HomeState extends State<Home> implements ConnectHome {
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _addWideTile(kindIcon(RoutineKind.MustDo),
|
||||
"Must-do chain", _onAddPickMustDo)),
|
||||
child: _addTile(kindIcon(RoutineKind.MustDo),
|
||||
"Must-do", _onAddPickMustDo)),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: _addWideTile(CupertinoIcons.flag,
|
||||
child: _addTile(CupertinoIcons.flag,
|
||||
"Goal", _onAddPickGoal)),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: _addTile(CupertinoIcons.person_3,
|
||||
"Group", _onAddPickGroup)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
@@ -1275,6 +1215,13 @@ class HomeState extends State<Home> implements ConnectHome {
|
||||
const SizedBox(height: 28),
|
||||
_todoSection(),
|
||||
const SizedBox(height: 28),
|
||||
if (_groups.isNotEmpty) ...[
|
||||
sectionBreak("Your groups",
|
||||
caption: _groupsCaption(),
|
||||
trailing: _viewAll("All groups", _onOpenGroups)),
|
||||
_groupRail(),
|
||||
const SizedBox(height: 28),
|
||||
],
|
||||
if (_upNext.isNotEmpty) ...[
|
||||
sectionBreak("Up next",
|
||||
caption: "${_upNext.length} routines",
|
||||
@@ -1354,8 +1301,8 @@ class HomeState extends State<Home> implements ConnectHome {
|
||||
() => _onOpenKind(RoutineKind.MustDo)),
|
||||
_category(goalScene(goalAccents[4]), CupertinoIcons.flag_fill,
|
||||
"Goals", 13, _onOpenGoals),
|
||||
_category(goalScene(goalAccents[2]),
|
||||
CupertinoIcons.chart_bar_alt_fill, "Report", 21, _onOpenReportCard),
|
||||
_category(goalScene(goalAccents[2]), CupertinoIcons.flame_fill,
|
||||
"Training", 21, _onOpenTraining),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -1533,6 +1480,125 @@ class HomeState extends State<Home> implements ConnectHome {
|
||||
);
|
||||
}
|
||||
|
||||
String _groupsCaption() {
|
||||
int unread = 0;
|
||||
for (Group group in _groups) {
|
||||
unread = unread + group.unread;
|
||||
}
|
||||
return unread == 0 ? "${_groups.length} groups" : "$unread new messages";
|
||||
}
|
||||
|
||||
/// Each group as a tile: its banner, who is in it, and the latest line.
|
||||
/// Tapping goes straight into the chat.
|
||||
Widget _groupRail() {
|
||||
return SizedBox(
|
||||
height: 214,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
clipBehavior: Clip.none,
|
||||
itemCount: _groups.length,
|
||||
separatorBuilder: (context, index) => const SizedBox(width: 12),
|
||||
itemBuilder: (context, index) => _groupTile(_groups[index]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _groupTile(Group group) {
|
||||
final Color accent = goalColor(group.colourHex);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => _onOpenGroupChat(group),
|
||||
child: Container(
|
||||
width: 250,
|
||||
decoration: BoxDecoration(
|
||||
color: colorWhite,
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x142A3A30),
|
||||
blurRadius: 20,
|
||||
offset: Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 96,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
SceneArt(
|
||||
palette: goalScene(accent),
|
||||
seed: sceneSeed(group.name),
|
||||
figure: CupertinoIcons.person_3_fill,
|
||||
figureScale: 0.5,
|
||||
figureAlignment: const Alignment(0.75, 0),
|
||||
shade: true,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (group.unread > 0)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 4),
|
||||
color: colorLime,
|
||||
child: text("${group.unread} new", 10,
|
||||
TextType.Medium,
|
||||
color: colorNight),
|
||||
),
|
||||
const Spacer(),
|
||||
text(group.name, 18, TextType.Medium,
|
||||
color: colorWhite,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
text(
|
||||
group.lastMessage.isEmpty
|
||||
? "No messages yet"
|
||||
: "${group.lastAuthor}: ${group.lastMessage}",
|
||||
13,
|
||||
TextType.Regular,
|
||||
color: colorInk,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
height: 1.4,
|
||||
),
|
||||
const Spacer(),
|
||||
Row(
|
||||
children: [
|
||||
avatarStack(
|
||||
group.members.map((member) => member.name).toList(),
|
||||
26),
|
||||
const Spacer(),
|
||||
Icon(CupertinoIcons.chat_bubble_2_fill,
|
||||
size: 16, color: colorInkSoft),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The quick to-do list: things to remember that are not promises. No
|
||||
/// window, no debt. Open items first, done ones struck through below.
|
||||
Widget _todoSection() {
|
||||
@@ -2078,9 +2144,9 @@ class HomeState extends State<Home> implements ConnectHome {
|
||||
}
|
||||
|
||||
@override
|
||||
void onWaterLoaded(int ml) {
|
||||
void onGroupsLoaded(List<Group> groups) {
|
||||
setState(() {
|
||||
_waterMl = ml;
|
||||
_groups = groups;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user