diff --git a/frontend/design.md b/frontend/design.md index a536e17..f8ad7d1 100644 --- a/frontend/design.md +++ b/frontend/design.md @@ -15,7 +15,7 @@ spark on night-green for the primary control. | `GlassSurface` / `glassCircle` | Frosted panels and square controls; `onDark` over a scene or tinted stage. | | `SceneArt` | Painted sky, sun and ridges with an optional figure glyph. Palettes come from `scenePalette(kind)`, `goalScene(accent)`, `classScene(class)`; seed with `sceneSeed(title)` so an item always wears the same picture. | | `DotDigits` | The dotted clock in the routine player. | -| `FloatingNav` | Home's tab bar and action button. | +| `FloatingNav` | Home's tab bar; the selected tab is a filled night block. The side action button is optional. | | `GroundedMark` | The logo, drawn — a lime sun on a ground line under two arcs. The launcher PNGs in `assets/icons/` were rendered from the same geometry. | Cards are image-led where an item has a kind (routines, goals, tasks): scene on @@ -78,3 +78,15 @@ square top edge → `sectionHeader` (label + light display title) → content. - Chips carry state, never actions. - One primary button per screen; everything else is outlined or text. - Empty states are plain. An empty overdue queue is good news, not a party. + +## Home + +Home opens on a deep forest header block (`colorHeaderTop` → `colorHeaderBottom`): +greeting and avatar, a Today / Weekly toggle, a seven-day streak panel ending +today (a leaf on every kept day, kept = 80%+), then the numbers. Today shows +minutes held against planned, a ring with + that opens the "Add to today" +sheet, the plan as thumbnails, the report-card letter as the Score, and one +bar per commitment class. Weekly swaps in the week's average, a bar per day +and the class completion rates. Below the block, on the light ambient +surface: the water tracker (drops of 250 ml, kept on the device), then the +rest of the day. Bars on the dark block use the `colorBar*` tokens. diff --git a/frontend/lib/Grounded/designs/FloatingNav.dart b/frontend/lib/Grounded/designs/FloatingNav.dart index 0b47ed3..319ae0d 100644 --- a/frontend/lib/Grounded/designs/FloatingNav.dart +++ b/frontend/lib/Grounded/designs/FloatingNav.dart @@ -7,22 +7,23 @@ import 'text/Text.dart'; typedef NavEntry = ({IconData icon, String label, VoidCallback onTap}); -/// The floating glass tab bar with the dark round action button beside it. +/// The floating tab bar: the selected tab is a filled night block, the rest +/// sit quietly on white. An optional dark action button can sit beside it. class FloatingNav extends StatelessWidget { final List entries; final int selected; - final IconData actionIcon; + final IconData? actionIcon; - final VoidCallback onAction; + final VoidCallback? onAction; const FloatingNav({ super.key, required this.entries, required this.selected, - required this.actionIcon, - required this.onAction, + this.actionIcon, + this.onAction, }); @override @@ -58,6 +59,7 @@ class FloatingNav extends StatelessWidget { ), ), ), + if (onAction != null) ...[ const SizedBox(width: 10), GestureDetector( onTap: onAction, @@ -81,6 +83,7 @@ class FloatingNav extends StatelessWidget { child: Icon(actionIcon, size: 24, color: colorLime), ), ), + ], ], ), ), @@ -94,9 +97,9 @@ class FloatingNav extends StatelessWidget { child: AnimatedContainer( duration: const Duration(milliseconds: 220), curve: Curves.easeOutCubic, - padding: const EdgeInsets.symmetric(vertical: 9), + padding: const EdgeInsets.symmetric(vertical: 10), decoration: BoxDecoration( - color: active ? colorWhite : colorWhite.withValues(alpha: 0.0), + color: active ? colorHeaderBottom : colorWhite.withValues(alpha: 0.0), borderRadius: BorderRadius.zero, boxShadow: active ? const [ @@ -112,10 +115,10 @@ class FloatingNav extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ Icon(entry.icon, - size: 23, color: active ? colorInk : colorInkSoft), + size: 23, color: active ? colorWhite : colorInkSoft), const SizedBox(height: 4), text(entry.label, 10, active ? TextType.Medium : TextType.Regular, - color: active ? colorInk : colorInkSoft, maxLines: 1), + color: active ? colorWhite : colorInkSoft, maxLines: 1), ], ), ), diff --git a/frontend/lib/Grounded/designs/input/InputFields.dart b/frontend/lib/Grounded/designs/input/InputFields.dart index df901ca..46043d9 100644 --- a/frontend/lib/Grounded/designs/input/InputFields.dart +++ b/frontend/lib/Grounded/designs/input/InputFields.dart @@ -85,10 +85,12 @@ Widget inputField( Widget quickAddField( TextEditingController controller, String hint, - ValueChanged onSubmitted, -) { + ValueChanged onSubmitted, { + FocusNode? focusNode, +}) { return TextField( controller: controller, + focusNode: focusNode, onSubmitted: onSubmitted, textInputAction: TextInputAction.done, style: TextStyle( diff --git a/frontend/lib/Grounded/informatics/AppDataManager.dart b/frontend/lib/Grounded/informatics/AppDataManager.dart index 47d31c9..725f57d 100644 --- a/frontend/lib/Grounded/informatics/AppDataManager.dart +++ b/frontend/lib/Grounded/informatics/AppDataManager.dart @@ -120,6 +120,12 @@ class AppDataManager implements DataManager { @override Future setAmnestySpent(int spent) => memory.setAmnestySpent(spent); + @override + Future getWaterToday() => memory.getWaterToday(); + + @override + Future setWaterToday(int ml) => memory.setWaterToday(ml); + @override Future> getTodos() => memory.getTodos(); diff --git a/frontend/lib/Grounded/memory/ConnectInternalMemory.dart b/frontend/lib/Grounded/memory/ConnectInternalMemory.dart index 5b891fc..cbe3681 100644 --- a/frontend/lib/Grounded/memory/ConnectInternalMemory.dart +++ b/frontend/lib/Grounded/memory/ConnectInternalMemory.dart @@ -60,6 +60,11 @@ abstract class ConnectInternalMemory { Future setAmnestySpent(int spent); + /// Water drunk today in ml; a new day reads as 0. + Future getWaterToday(); + + Future setWaterToday(int ml); + /// The quick to-do list on Home. Future> getTodos(); diff --git a/frontend/lib/Grounded/memory/InternalMemory.dart b/frontend/lib/Grounded/memory/InternalMemory.dart index 38c45e1..236b673 100644 --- a/frontend/lib/Grounded/memory/InternalMemory.dart +++ b/frontend/lib/Grounded/memory/InternalMemory.dart @@ -41,6 +41,8 @@ class InternalMemory implements ConnectInternalMemory { static const String AMNESTY_SPENT = "AMNESTY_SPENT_Jr4xVh9KmT3gLc6B"; + static const String WATER = "WATER_Qe7tNd4ZsM2kBv9C"; + static const String TODOS = "TODOS_Wm3kRb8TqZ5nHc2F"; static const String SHOW_ONBOARDING = "SHOW_ONBOARDING_Nz6qGf2SdX7wPb4M"; @@ -255,6 +257,31 @@ class InternalMemory implements ConnectInternalMemory { await groundedStorage.write(key: AMNESTY_SPENT, value: spent.toString()); } + String _today() { + final DateTime now = DateTime.now(); + return "${now.year}-${now.month}-${now.day}"; + } + + @override + Future getWaterToday() async { + String? value = await groundedStorage.read(key: WATER); + + if (value != null) { + final Map entry = jsonDecode(value); + if (entry['day'] == _today()) { + return entry['ml'] ?? 0; + } + } + + return 0; + } + + @override + Future setWaterToday(int ml) async { + await groundedStorage.write( + key: WATER, value: jsonEncode({'day': _today(), 'ml': ml})); + } + @override Future> getTodos() async { String? value = await groundedStorage.read(key: TODOS); diff --git a/frontend/lib/Grounded/mock/MockMemory.dart b/frontend/lib/Grounded/mock/MockMemory.dart index c8e650d..ed46f7c 100644 --- a/frontend/lib/Grounded/mock/MockMemory.dart +++ b/frontend/lib/Grounded/mock/MockMemory.dart @@ -19,6 +19,7 @@ class MockMemory extends InternalMemory { setMyDescription( MeDescription(id: "mock-user", name: "Alex", token: "mock-token")); setUserDetails(UserDetails(pic: "", name: "Alex")); + setWaterToday(1250); setTodos([ TodoItem(id: "t1", title: "Refill water bottle"), TodoItem(id: "t2", title: "Book physio for Thursday"), diff --git a/frontend/lib/Grounded/see/home/ConnectHome.dart b/frontend/lib/Grounded/see/home/ConnectHome.dart index 916cfe2..9134f57 100644 --- a/frontend/lib/Grounded/see/home/ConnectHome.dart +++ b/frontend/lib/Grounded/see/home/ConnectHome.dart @@ -1,5 +1,6 @@ import '../../about/external/data/Commitment.dart'; import '../../about/external/data/ExcuseCluster.dart'; +import '../../about/external/data/ReportCard.dart'; import '../../about/external/data/RoutineChain.dart'; import '../../about/internal/application/Standing.dart'; import '../../about/internal/application/TodoItem.dart'; @@ -15,6 +16,13 @@ abstract class ConnectHome { /// The routine library, for the up-next rail. void onRoutinesLoaded(List routines); + /// This week's report card, for the streak row, the score and the weekly + /// view. Null when it could not be loaded. + void onWeekLoaded(ReportCard? week); + + /// Water drunk today, in ml. + void onWaterLoaded(int ml); + /// The quick to-do list, read from the device. void onTodosLoaded(List todos); diff --git a/frontend/lib/Grounded/see/home/HomeState.dart b/frontend/lib/Grounded/see/home/HomeState.dart index fe2e668..92bb617 100644 --- a/frontend/lib/Grounded/see/home/HomeState.dart +++ b/frontend/lib/Grounded/see/home/HomeState.dart @@ -1,10 +1,14 @@ +import 'dart:ui'; + import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:stacked/stacked.dart'; import '../../about/external/data/Commitment.dart'; import '../../about/external/data/ExcuseCluster.dart'; +import '../../about/external/data/ReportCard.dart'; import '../../about/external/data/RoutineChain.dart'; import '../../about/internal/application/CommitmentClass.dart'; import '../../about/internal/application/CommitmentStatus.dart'; @@ -61,6 +65,20 @@ class HomeState extends State implements ConnectHome { final TextEditingController _todoInput = TextEditingController(); + final FocusNode _todoFocus = FocusNode(); + + final GlobalKey _todoKey = GlobalKey(); + + ReportCard? _week; + + bool _weekly = false; + + int _waterMl = 0; + + static const int _waterTarget = 2000; + + static const int _waterStep = 250; + Standing _standing = Standing.Good; double _debt = 0; @@ -101,6 +119,7 @@ class HomeState extends State implements ConnectHome { @override void dispose() { _todoInput.dispose(); + _todoFocus.dispose(); super.dispose(); } @@ -218,6 +237,97 @@ class HomeState extends State implements ConnectHome { void _onNavToday() {} + void _onShowToday() { + setState(() { + _weekly = false; + }); + } + + void _onShowWeekly() { + setState(() { + _weekly = true; + }); + } + + 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, + barrierDismissible: false, + barrierColor: colorNight.withValues(alpha: 0), + transitionDuration: const Duration(milliseconds: 320), + pageBuilder: (sheetContext, animation, secondary) => + _addSheet(sheetContext), + transitionBuilder: (sheetContext, animation, secondary, child) { + final Animation curve = + CurvedAnimation(parent: animation, curve: Curves.easeOutCubic); + return FadeTransition( + opacity: curve, + child: SlideTransition( + position: Tween( + begin: const Offset(0, 0.12), end: Offset.zero) + .animate(curve), + child: child, + ), + ); + }, + ); + } + + void _onCloseAddSheet() { + Navigator.of(context).pop(); + } + + void _onAddPickCommitment() { + _onCloseAddSheet(); + _onAddCommitment(); + } + + void _onAddPickWorkout() { + _onCloseAddSheet(); + _onOpenKind(RoutineKind.Workout); + } + + void _onAddPickMeditation() { + _onCloseAddSheet(); + _onOpenKind(RoutineKind.Meditation); + } + + void _onAddPickMustDo() { + _onCloseAddSheet(); + _onOpenKind(RoutineKind.MustDo); + } + + void _onAddPickGoal() { + _onCloseAddSheet(); + _onOpenGoals(); + } + + void _onAddPickTodo() { + _onCloseAddSheet(); + final BuildContext? target = _todoKey.currentContext; + if (target != null) { + Scrollable.ensureVisible(target, + duration: const Duration(milliseconds: 400), + curve: Curves.easeOutCubic, + alignment: 0.1); + } + _todoFocus.requestFocus(); + } + void _onAddTodo(String value) { final String title = value.trim(); if (title.isEmpty) { @@ -268,73 +378,759 @@ class HomeState extends State implements ConnectHome { final bool queueIsHome = StandingEngine.showsOverdueQueueAsHome(_standing) && !_distressed; - return Scaffold( - backgroundColor: colorAmbientMid, - body: AmbientBackground( - child: Stack( - children: [ - SafeArea( - bottom: false, - child: SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(20, 8, 20, 130), + return AnnotatedRegion( + value: SystemUiOverlayStyle.light, + child: Scaffold( + backgroundColor: colorAmbientMid, + body: AmbientBackground( + child: Stack( + children: [ + SingleChildScrollView( + padding: const EdgeInsets.only(bottom: 130), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ - _topBar(), - const SizedBox(height: 26), - _greeting(queueIsHome), - const SizedBox(height: 24), - if (!_distressed) ...[ - _standingCard(), - const SizedBox(height: 28), - ], - if (_distressed) - _frosted(_distressBody()) - else if (queueIsHome) - _frosted(_groundedBody()) - else - _planBody(), + _header(), + Padding( + padding: const EdgeInsets.fromLTRB(20, 24, 20, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + if (!_distressed) ...[ + _waterCard(), + const SizedBox(height: 28), + ], + if (_distressed) + _frosted(_distressBody()) + else if (queueIsHome) + _frosted(_groundedBody()) + else + _planBody(), + ], + ), + ), ], ), ), - ), - Positioned( - left: 0, - right: 0, - bottom: 0, - child: FloatingNav( - selected: 0, - actionIcon: CupertinoIcons.add, - onAction: _onAddCommitment, - entries: [ - ( - icon: CupertinoIcons.house_fill, - label: "Today", - onTap: _onNavToday - ), - ( - icon: CupertinoIcons.square_stack_3d_up_fill, - label: "Routines", - onTap: _onOpenRoutines - ), - ( - icon: CupertinoIcons.flag_fill, - label: "Goals", - onTap: _onOpenGoals - ), - ( - icon: CupertinoIcons.flame_fill, - label: "Training", - onTap: _onOpenTraining - ), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: FloatingNav( + selected: 0, + entries: [ + ( + icon: CupertinoIcons.house_fill, + label: "Today", + onTap: _onNavToday + ), + ( + icon: CupertinoIcons.square_stack_3d_up_fill, + label: "Routines", + onTap: _onOpenRoutines + ), + ( + icon: CupertinoIcons.flag_fill, + label: "Goals", + onTap: _onOpenGoals + ), + ( + 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, + end: 0, + duration: 500.ms, + delay: 250.ms, + curve: Curves.easeOutCubic), + ), + ], + ), + ), + ), + ); + } + + // ── Header ──────────────────────────────────────────────────────────────── + + /// The deep forest block: who you are, the week as a streak, and the day + /// (or the week) in numbers. + Widget _header() { + return Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [colorHeaderTop, colorHeaderBottom], + ), + boxShadow: [ + BoxShadow( + color: Color(0x2E15281F), + blurRadius: 30, + offset: Offset(0, 12), + ), + ], + ), + padding: EdgeInsets.fromLTRB( + 20, MediaQuery.of(context).padding.top + 14, 20, 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + _headerTopRow(), + const SizedBox(height: 22), + _periodToggle(), + const SizedBox(height: 16), + _streakPanel(), + const SizedBox(height: 24), + AnimatedSwitcher( + duration: const Duration(milliseconds: 260), + child: _weekly + ? KeyedSubtree( + key: const ValueKey("weekly"), child: _weeklyNumbers()) + : KeyedSubtree( + key: const ValueKey("today"), child: _todayNumbers()), + ), + const SizedBox(height: 20), + _standingLine(), + ], + ), + ).animate().fadeIn(duration: 450.ms); + } + + Widget _headerTopRow() { + final String name = _user.name.isEmpty ? "there" : _user.name; + + return Row( + children: [ + _avatar(54), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + text(_greetingWord(), 14, TextType.Regular, + color: colorHeaderMuted), + const SizedBox(width: 6), + Icon(_greetingIcon(), size: 15, color: colorLime), ], - ).animate().fadeIn(duration: 400.ms, delay: 250.ms).slideY( - begin: 0.4, - end: 0, - duration: 500.ms, - delay: 250.ms, - curve: Curves.easeOutCubic), + ), + const SizedBox(height: 3), + text(name, 22, TextType.Medium, + color: colorWhite, + maxLines: 1, + overflow: TextOverflow.ellipsis), + ], + ), + ), + glassCircle( + CupertinoIcons.bell, + _onOpenOverdue, + size: 46, + onDark: true, + dotted: _overdue.isNotEmpty, + dotColor: colorBarNonNegotiable, + ), + const SizedBox(width: 8), + glassCircle( + CupertinoIcons.person, + _onOpenSettings, + size: 46, + onDark: true, + dotted: _user.sickMode, + dotColor: colorBarStandard, + ), + ], + ); + } + + Widget _periodToggle() { + return Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: colorHeaderPanel, + border: Border.all(color: colorHeaderLine), + ), + child: Row( + children: [ + Expanded(child: _periodTab("Today", !_weekly, _onShowToday)), + Expanded(child: _periodTab("Weekly", _weekly, _onShowWeekly)), + ], + ), + ); + } + + Widget _periodTab(String label, bool active, VoidCallback onTap) { + return GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: AnimatedContainer( + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + padding: const EdgeInsets.symmetric(vertical: 13), + alignment: Alignment.center, + color: active ? colorHeaderSelected : colorHeaderSelected.withValues(alpha: 0), + child: text(label, 16, active ? TextType.Medium : TextType.Regular, + color: active ? colorWhite : colorHeaderMuted), + ), + ); + } + + /// "8 day streak" over the last seven days, ending today, with a leaf on + /// every day that was kept. + Widget _streakPanel() { + final DateTime now = DateTime.now(); + final DateTime first = DateTime(now.year, now.month, now.day) + .subtract(const Duration(days: 6)); + const List days = [ + "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun" + ]; + + return Container( + padding: const EdgeInsets.fromLTRB(18, 16, 18, 18), + decoration: BoxDecoration( + color: colorHeaderPanel, + border: Border.all(color: colorHeaderLine), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + text("${_streak()}", 28, TextType.Medium, color: colorWhite), + const SizedBox(width: 8), + text("day streak", 16, TextType.Regular, + color: colorHeaderMuted), + const Spacer(), + Icon(Icons.eco, size: 32, color: colorLime), + ], + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + for (int index = 0; index < 7; index++) + _streakDay( + days[first.add(Duration(days: index)).weekday - 1], + first.add(Duration(days: index)), + index == 6, + _kept(6 - index)), + ], + ), + ], + ), + ); + } + + Widget _streakDay(String label, DateTime date, bool today, bool kept) { + return SizedBox( + width: 40, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + text(label, 14, today ? TextType.Medium : TextType.Regular, + color: today ? colorWhite : colorHeaderMuted), + const SizedBox(height: 10), + SizedBox( + height: 26, + child: Center( + child: kept + ? Icon(Icons.eco, size: 24, color: colorBarElective) + : text("${date.day}", 16, + today ? TextType.Medium : TextType.Regular, + color: today ? colorWhite : colorHeaderMuted), + ), + ), + ], + ), + ); + } + + /// The day in numbers: minutes held against planned, the + ring that opens + /// the add sheet, the plan as thumbnails, the score, and the three classes. + Widget _todayNumbers() { + final double fraction = _plan.isEmpty ? 0 : _doneCount / _plan.length; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + text("Minutes held", 15, TextType.Regular, + color: colorHeaderMuted), + const SizedBox(height: 4), + _bigFigure(_thousands(_heldMinutes().round()), + "/${_thousands(_plannedMinutes().round())} min"), + ], + ), + ), + GestureDetector( + onTap: _onOpenAddSheet, + child: ProgressRing( + fraction: fraction, + size: 70, + stroke: 4, + fill: colorBarElective, + track: colorHeaderLine, + child: Icon(CupertinoIcons.add, size: 30, color: colorWhite), + ), + ), + ], + ), + const SizedBox(height: 18), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + for (Commitment item in _plan.take(3)) ...[ + _planThumb(item), + const SizedBox(width: 6), + ], + const Spacer(), + _score(), + ], + ), + const SizedBox(height: 22), + Row( + children: [ + for (CommitmentClass value in CommitmentClass.values) ...[ + Expanded(child: _todayClassBar(value)), + if (value != CommitmentClass.values.last) + const SizedBox(width: 18), + ], + ], + ), + ], + ); + } + + /// The week in numbers: the average kept, a bar per day, and the classes. + Widget _weeklyNumbers() { + final Map byDay = _week?.completionByWeekday ?? {}; + final double average = byDay.isEmpty + ? 0 + : byDay.values.reduce((a, b) => a + b) / byDay.length; + const List letters = ["M", "T", "W", "T", "F", "S", "S"]; + final DateTime now = DateTime.now(); + final List order = [ + for (int daysAgo = 6; daysAgo >= 0; daysAgo--) + now.subtract(Duration(days: daysAgo)).weekday + ]; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + text("Kept this week", 15, TextType.Regular, + color: colorHeaderMuted), + const SizedBox(height: 4), + _bigFigure("${(average * 100).round()}", "% on average"), + ], + ), + ), + _score(), + ], + ), + const SizedBox(height: 20), + SizedBox( + height: 118, + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + for (int day in order) ...[ + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 400), + curve: Curves.easeOutCubic, + height: 90 * (byDay[day] ?? 0).clamp(0.04, 1.0), + color: (byDay[day] ?? 0) >= _keptFloor + ? colorBarElective + : colorHeaderLine, + ), + const SizedBox(height: 8), + text(letters[day - 1], 13, + day == now.weekday + ? TextType.Medium + : TextType.Regular, + color: day == now.weekday + ? colorWhite + : colorHeaderMuted), + ], + ), + ), + if (day != order.last) const SizedBox(width: 10), + ], + ], + ), + ), + const SizedBox(height: 22), + Row( + children: [ + for (CommitmentClass value in CommitmentClass.values) ...[ + Expanded(child: _weeklyClassBar(value)), + if (value != CommitmentClass.values.last) + const SizedBox(width: 18), + ], + ], + ), + ], + ); + } + + Widget _bigFigure(String value, String unit) { + return Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + text(value, 38, TextType.Medium, color: colorWhite, letterSpacing: -1), + const SizedBox(width: 4), + Flexible( + child: text(unit, 16, TextType.Regular, + color: colorHeaderMuted, maxLines: 1), + ), + ], + ); + } + + Widget _planThumb(Commitment item) { + return Container( + width: 74, + height: 74, + decoration: BoxDecoration( + border: Border.all(color: colorWhite, width: 2), + ), + child: Opacity( + opacity: _isDone(item) ? 0.55 : 1, + child: SceneArt( + palette: classScene(item.commitmentClass), + seed: sceneSeed(item.title), + figure: _classIcon(item.commitmentClass), + figureScale: 0.46, + figureAlignment: const Alignment(0, 0.1), + ), + ), + ); + } + + /// The report-card letter, large, with "Score" under it. + Widget _score() { + final String grade = + (_week?.grade.isNotEmpty ?? false) ? _week!.grade : "–"; + + return GestureDetector( + onTap: _onOpenReportCard, + behavior: HitTestBehavior.opaque, + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + text(grade, 46, TextType.Medium, color: colorWhite, height: 1), + const SizedBox(height: 2), + text("Score", 16, TextType.Regular, color: colorHeaderMuted), + ], + ), + ); + } + + Widget _todayClassBar(CommitmentClass value) { + final List items = + _plan.where((item) => item.commitmentClass == value).toList(); + final int done = items.where(_isDone).length; + + return _bar( + _barLabel(value), + items.isEmpty ? 0 : done / items.length, + "$done/${items.length} done", + _barColor(value), + ); + } + + Widget _weeklyClassBar(CommitmentClass value) { + final double rate = _week?.completionByClass[value.name] ?? 0; + + return _bar(_barLabel(value), rate, "${(rate * 100).round()}% kept", + _barColor(value)); + } + + Widget _bar(String label, double fraction, String caption, Color fill) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + text(label, 14, TextType.Medium, color: colorWhite, maxLines: 1), + const SizedBox(height: 9), + Container( + height: 7, + color: colorHeaderLine, + alignment: Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: fraction.clamp(0.0, 1.0), + child: Container(color: fill), + ), + ), + const SizedBox(height: 9), + text(caption, 13, TextType.Regular, color: colorHeaderMuted), + ], + ); + } + + /// Standing, debt and overdue in one quiet line under the numbers. + Widget _standingLine() { + final Color tone = _standing == Standing.Good + ? colorBarElective + : _standing == Standing.Warned + ? colorBarStandard + : colorBarNonNegotiable; + + return GestureDetector( + onTap: _overdue.isEmpty ? _onOpenReportCard : _onOpenOverdue, + behavior: HitTestBehavior.opaque, + child: Container( + padding: const EdgeInsets.only(top: 16), + decoration: BoxDecoration( + border: Border(top: BorderSide(color: colorHeaderLine)), + ), + child: Row( + children: [ + Container(width: 8, height: 8, color: tone), + const SizedBox(width: 8), + text(standingLabel(_standing), 13, TextType.Medium, color: tone), + const SizedBox(width: 10), + Expanded( + child: text( + "Debt ${formatDebt(_debt)} · ${_overdue.length} overdue", + 13, + TextType.Regular, + color: colorHeaderMuted, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Icon(CupertinoIcons.chevron_right, + size: 14, color: colorHeaderMuted), + ], + ), + ), + ); + } + + // ── 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 + /// above a white sheet of tiles. + Widget _addSheet(BuildContext sheetContext) { + return Stack( + children: [ + Positioned.fill( + child: GestureDetector( + onTap: _onCloseAddSheet, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), + child: Container(color: colorNight.withValues(alpha: 0.35)), + ), + ), + ), + Align( + alignment: Alignment.bottomCenter, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + GestureDetector( + onTap: _onCloseAddSheet, + child: Container( + width: 54, + height: 54, + color: colorWhite, + child: Icon(CupertinoIcons.xmark, size: 22, color: colorInk), + ), + ), + const SizedBox(height: 16), + Container( + width: double.infinity, + color: colorWhite, + padding: EdgeInsets.fromLTRB( + 18, 14, 18, MediaQuery.of(sheetContext).padding.bottom + 22), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Center( + child: Container( + width: 40, height: 4, color: colorWaterEmpty), + ), + const SizedBox(height: 18), + text("Add to today", 24, TextType.Medium, + color: colorInk, align: TextAlign.center), + const SizedBox(height: 20), + Row( + children: [ + Expanded( + child: _addTile(CupertinoIcons.checkmark_seal, + "Commitment", _onAddPickCommitment)), + const SizedBox(width: 10), + Expanded( + child: _addTile(kindIcon(RoutineKind.Workout), + "Workout", _onAddPickWorkout)), + const SizedBox(width: 10), + Expanded( + child: _addTile(kindIcon(RoutineKind.Meditation), + "Meditate", _onAddPickMeditation)), + ], + ), + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: _addWideTile(kindIcon(RoutineKind.MustDo), + "Must-do chain", _onAddPickMustDo)), + const SizedBox(width: 10), + Expanded( + child: _addWideTile(CupertinoIcons.flag, + "Goal", _onAddPickGoal)), + ], + ), + const SizedBox(height: 10), + _addWideTile(CupertinoIcons.square_list, "Quick to-do", + _onAddPickTodo), + ], + ), + ), + ], + ), + ), + ], + ); + } + + Widget _addTile(IconData icon, String label, VoidCallback onTap) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 22), + color: colorAmbientMid, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 26, color: colorInk), + const SizedBox(height: 10), + text(label, 15, TextType.Regular, color: colorInk), + ], + ), + ), + ); + } + + Widget _addWideTile(IconData icon, String label, VoidCallback onTap) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 22), + color: colorAmbientMid, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 24, color: colorInk), + const SizedBox(width: 12), + Flexible( + child: text(label, 16, TextType.Regular, + color: colorInk, maxLines: 1), ), ], ), @@ -342,47 +1138,9 @@ class HomeState extends State implements ConnectHome { ); } - Widget _topBar() { - return Row( - children: [ - _avatar(54), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - text( - "${weekdayName(DateTime.now().weekday)}, ${DateTime.now().day} ${_monthName(DateTime.now().month)}", - 12, - TextType.Regular, - color: colorInkSoft, - ), - const SizedBox(height: 2), - text("Grounded", 15, TextType.Medium, color: colorInk), - ], - ), - ), - glassCircle( - CupertinoIcons.bell, - _onOpenOverdue, - dotted: _overdue.isNotEmpty, - dotColor: colorStandingGrounded, - ), - const SizedBox(width: 8), - glassCircle(CupertinoIcons.chart_bar_alt_fill, _onOpenReportCard), - const SizedBox(width: 8), - glassCircle( - CupertinoIcons.person, - _onOpenSettings, - dotted: _user.sickMode, - dotColor: colorStandingWarned, - ), - ], - ); - } + // ── Header helpers ──────────────────────────────────────────────────────── - /// A round portrait cut from the painted landscape, with the initial on it. + /// A portrait cut from the painted landscape, with the initial on it. Widget _avatar(double size) { final String initial = _user.name.isEmpty ? "G" : _user.name.substring(0, 1).toUpperCase(); @@ -392,188 +1150,97 @@ class HomeState extends State implements ConnectHome { height: size, decoration: BoxDecoration( border: Border.all(color: colorWhite, width: 2), - boxShadow: const [ - BoxShadow( - color: Color(0x1A1A1F1C), - blurRadius: 12, - offset: Offset(0, 4), + ), + child: Stack( + fit: StackFit.expand, + children: [ + SceneArt(palette: sceneWorkout, seed: sceneSeed(_user.name)), + Center( + child: text(initial, size * 0.4, TextType.Medium, + color: colorWhite), ), ], ), - child: ClipRect( - child: Stack( - fit: StackFit.expand, - children: [ - SceneArt(palette: sceneWorkout, seed: sceneSeed(_user.name)), - Center( - child: text(initial, size * 0.4, TextType.Medium, - color: colorWhite), - ), - ], - ), - ), ); } - /// "Hey ◯ Alex / Here's what to hold today." — two tones, oversized. - Widget _greeting(bool queueIsHome) { - final String name = _user.name.isEmpty ? "there" : _user.name; + String _greetingWord() { + final int hour = DateTime.now().hour; + if (hour < 12) return "Good morning"; + if (hour < 17) return "Good afternoon"; + return "Good evening"; + } - String second; - if (_distressed) { - second = "Go easy on\nyourself today."; - } else if (queueIsHome) { - second = "Clear what you\nowe first."; - } else if (_plan.isNotEmpty && _doneCount == _plan.length) { - second = "Every promise\nkept today."; - } else { - second = "Here's what to\nhold today."; + IconData _greetingIcon() { + final int hour = DateTime.now().hour; + if (hour < 17) return CupertinoIcons.sun_max_fill; + return CupertinoIcons.moon_stars_fill; + } + + /// A day counts as kept at 80% of what was promised. + static const double _keptFloor = 0.8; + + /// Whether the day [daysAgo] back was kept. Today counts once everything + /// on the plan is done. + bool _kept(int daysAgo) { + if (daysAgo == 0) { + return _plan.isNotEmpty && _doneCount == _plan.length; } - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Row( - children: [ - text("Hey", 38, TextType.Medium, - color: colorInk, letterSpacing: -1.0), - const SizedBox(width: 10), - _avatar(36), - const SizedBox(width: 10), - Flexible( - child: text(name, 38, TextType.Medium, - color: colorInk, - letterSpacing: -1.0, - maxLines: 1, - overflow: TextOverflow.ellipsis), - ), - ], - ), - const SizedBox(height: 4), - text(second, 34, TextType.Regular, - color: colorInkSoft, height: 1.12, letterSpacing: -0.8), - ], - ) - .animate() - .fadeIn(duration: 500.ms) - .slideY(begin: 0.15, end: 0, duration: 600.ms, curve: Curves.easeOut); + final int weekday = + DateTime.now().subtract(Duration(days: daysAgo)).weekday; + return (_week?.completionByWeekday[weekday] ?? 0) >= _keptFloor; } - /// The day at a glance: a ring for what is done, the standing, and the - /// three numbers that matter. - Widget _standingCard() { - final Color tone = standingColor(_standing); - final double fraction = _plan.isEmpty ? 0 : _doneCount / _plan.length; - - return GestureDetector( - onTap: _overdue.isEmpty ? _onOpenReportCard : _onOpenOverdue, - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.zero, - boxShadow: const [ - BoxShadow( - color: Color(0x142A3A30), - blurRadius: 30, - offset: Offset(0, 12), - ), - ], - ), - child: GlassSurface( - radius: BorderRadius.zero, - tint: colorGlassStrong, - padding: const EdgeInsets.all(22), - child: Row( - children: [ - ProgressRing( - fraction: fraction, - size: 104, - stroke: 9, - fill: colorPrimary, - track: colorPrimary.withValues(alpha: 0.12), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - text("$_doneCount/${_plan.length}", 23, TextType.Medium, - color: colorInk), - text("done", 10, TextType.Regular, color: colorInkSoft), - ], - ), - ), - const SizedBox(width: 18), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Row( - children: [ - Container( - width: 8, - height: 8, - decoration: BoxDecoration( - color: tone, - shape: BoxShape.circle, - boxShadow: [ - BoxShadow( - color: tone.withValues(alpha: 0.5), - blurRadius: 6), - ], - ), - ), - const SizedBox(width: 7), - text(standingLabel(_standing).toUpperCase(), 9, - TextType.Bold, - color: tone, letterSpacing: 1.2), - ], - ), - const SizedBox(height: 6), - text( - ToneEngine.standingHeadline(_standing, _user.tone), - 17, - TextType.Medium, - color: colorInk, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 14), - Row( - children: [ - Expanded( - child: _miniStat("Debt", formatDebt(_debt))), - Expanded( - child: _miniStat( - "Planned", formatMinutes(_plannedMinutes())), - ), - Expanded( - child: _miniStat("Overdue", "${_overdue.length}", - alert: _overdue.isNotEmpty), - ), - ], - ), - ], - ), - ), - ], - ), - ), - ), - ).animate().fadeIn(duration: 500.ms, delay: 120.ms).slideY( - begin: 0.15, end: 0, duration: 600.ms, delay: 120.ms, - curve: Curves.easeOut); + /// Kept days in a row, counted back from yesterday, plus today once kept. + int _streak() { + int count = _kept(0) ? 1 : 0; + for (int daysAgo = 1; daysAgo <= 6; daysAgo++) { + if (!_kept(daysAgo)) break; + count++; + } + return count; } - Widget _miniStat(String label, String value, {bool alert = false}) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - text(value, 23, TextType.Light, - color: alert ? colorStandingGrounded : colorInk), - const SizedBox(height: 2), - text(label, 10, TextType.Regular, color: colorInkSoft), - ], - ); + String _barLabel(CommitmentClass value) { + switch (value) { + case CommitmentClass.NonNegotiable: + return "Must"; + case CommitmentClass.Standard: + return "Standard"; + case CommitmentClass.Elective: + return "Elective"; + } + } + + Color _barColor(CommitmentClass value) { + switch (value) { + case CommitmentClass.NonNegotiable: + return colorBarNonNegotiable; + case CommitmentClass.Standard: + return colorBarStandard; + case CommitmentClass.Elective: + return colorBarElective; + } + } + + double _heldMinutes() { + double total = 0; + for (Commitment item in _plan.where(_isDone)) { + total = total + item.estMinutes; + } + return total; + } + + String _thousands(int value) { + final String digits = value.toString(); + final StringBuffer out = StringBuffer(); + for (int index = 0; index < digits.length; index++) { + if (index > 0 && (digits.length - index) % 3 == 0) { + out.write(","); + } + out.write(digits[index]); + } + return out.toString(); } /// Grounded and distress bodies keep their copy; they sit on a frosted @@ -873,6 +1540,7 @@ class HomeState extends State implements ConnectHome { final List done = _todos.where((item) => item.done).toList(); return Column( + key: _todoKey, crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ @@ -897,7 +1565,8 @@ class HomeState extends State implements ConnectHome { const SizedBox(width: 12), Expanded( child: quickAddField( - _todoInput, "Add a to-do", _onAddTodo), + _todoInput, "Add a to-do", _onAddTodo, + focusNode: _todoFocus), ), GestureDetector( onTap: _onAddTodoPressed, @@ -1384,14 +2053,6 @@ class HomeState extends State implements ConnectHome { ); } - String _monthName(int month) { - const List names = [ - "January", "February", "March", "April", "May", "June", "July", - "August", "September", "October", "November", "December", - ]; - return names[month - 1]; - } - double _plannedMinutes() { double total = 0; for (Commitment item in _plan) { @@ -1409,6 +2070,20 @@ class HomeState extends State implements ConnectHome { }); } + @override + void onWeekLoaded(ReportCard? week) { + setState(() { + _week = week; + }); + } + + @override + void onWaterLoaded(int ml) { + setState(() { + _waterMl = ml; + }); + } + @override void onTodosLoaded(List todos) { setState(() { diff --git a/frontend/lib/Grounded/see/home/ViewHome.dart b/frontend/lib/Grounded/see/home/ViewHome.dart index 31c3e8a..8e30556 100644 --- a/frontend/lib/Grounded/see/home/ViewHome.dart +++ b/frontend/lib/Grounded/see/home/ViewHome.dart @@ -1,5 +1,6 @@ import '../../about/external/data/Commitment.dart'; import '../../about/external/data/ExcuseCluster.dart'; +import '../../about/external/data/ReportCard.dart'; import '../../about/external/data/RoutineChain.dart'; import '../../about/external/data/pages/request/CommitmentsRequest.dart'; import '../../about/external/data/pages/request/HistoryRequest.dart'; @@ -34,9 +35,40 @@ class ViewHome extends ParentViewModel { loadTodos(); + loadWater(); + + loadWeek(); + loadPlan(); } + /// The last seven days. Quiet on failure: the header falls back to today. + void loadWeek() async { + try { + final DateTime now = DateTime.now(); + final DateTime start = DateTime(now.year, now.month, now.day) + .subtract(const Duration(days: 6)); + + final response = + await getDataManager().getWeeklyReportCard(ReportCardRequest( + periodStart: start.toIso8601String(), + periodEnd: now.toIso8601String(), + )); + + connection.onWeekLoaded(ReportCard.fromJson(response.data)); + } catch (e) { + connection.onWeekLoaded(null); + } + } + + void loadWater() async { + connection.onWaterLoaded(await getDataManager().getWaterToday()); + } + + void saveWater(int ml) async { + await getDataManager().setWaterToday(ml); + } + void loadTodos() async { connection.onTodosLoaded(await getDataManager().getTodos()); } diff --git a/frontend/lib/Grounded/utils/Colors.dart b/frontend/lib/Grounded/utils/Colors.dart index cbe132a..868b4f6 100644 --- a/frontend/lib/Grounded/utils/Colors.dart +++ b/frontend/lib/Grounded/utils/Colors.dart @@ -127,6 +127,21 @@ const Color colorOverdueWarm = Color(0xFFF9E1D8); const Color colorOverdueWarmEnd = Color(0xFFFFF6EF); const Color colorOverdueGlow = Color(0xFFD7654B); +// ── Home header ─────────────────────────────────────────────────────────────── +// The deep forest block the day sits in, with brighter bars that read on it. +const Color colorHeaderTop = Color(0xFF203D30); +const Color colorHeaderBottom = Color(0xFF15281F); +const Color colorHeaderPanel = Color(0x14FFFFFF); +const Color colorHeaderLine = Color(0x26FFFFFF); +const Color colorHeaderMuted = Color(0x99FFFFFF); +const Color colorHeaderSelected = Color(0xFF3D6B53); +const Color colorBarNonNegotiable = Color(0xFFE8735A); +const Color colorBarStandard = Color(0xFFF2C14E); +const Color colorBarElective = Color(0xFF6FD39B); +// Water: filled drops and the empty ones. +const Color colorWater = Color(0xFF6E8EF0); +const Color colorWaterEmpty = Color(0xFFDDE1DE); + // ── Scene art ───────────────────────────────────────────────────────────────── // The painted landscapes that stand in for photography on cards. Each kind has // a sky (top → horizon), a sun, three ridges (far → near) and an ember accent.