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'; import '../../about/internal/application/NavigatorType.dart'; import '../../about/internal/application/NotificationType.dart'; import '../../about/internal/application/RoutineKind.dart'; import '../../about/internal/application/Standing.dart'; import '../../about/internal/application/TextType.dart'; import '../../about/internal/application/TodoItem.dart'; import '../../about/internal/application/UserDetails.dart'; import '../../configs/Navigator.dart'; import '../../designs/AmbientBackground.dart'; import '../../designs/Checkbox.dart'; import '../../designs/Component.dart'; import '../../designs/FloatingNav.dart'; import '../../designs/GlassSurface.dart'; import '../../designs/ProgressRing.dart'; import '../../designs/Responsive.dart'; import '../../designs/Shell.dart'; import '../../designs/buttons/Buttons.dart'; import '../../designs/input/InputFields.dart'; import '../../designs/scene/SceneArt.dart'; import '../../designs/text/Text.dart'; import '../../utils/Colors.dart'; import '../../utils/CommonUtils.dart'; import '../../utils/StandingEngine.dart'; import '../../utils/Thresholds.dart'; import '../../utils/ToneEngine.dart'; import '../commitment/NewCommitment.dart'; import '../excuse/ExcuseReport.dart'; import '../goal/Goals.dart'; import '../overdue/OverdueQueue.dart'; import '../reportcard/ReportCardScreen.dart'; import '../routine/RoutineRunner.dart'; import '../routine/Routines.dart'; import '../settings/Settings.dart'; import '../training/Training.dart'; import 'ConnectHome.dart'; import 'Home.dart'; import 'ViewHome.dart'; class HomeState extends State implements ConnectHome { ViewHome? _model; UserDetails _user = UserDetails(pic: '', name: ''); List _plan = []; List _overdue = []; List _routines = []; List _todos = []; 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; ExcuseCluster? _insight; bool _distressed = false; /// Ids ticked locally but not yet confirmed by the server. The row reads as /// done immediately so the tick feels instant, and rolls back if the call /// fails. final Set _ticked = {}; @override Widget build(BuildContext context) { return ViewModelBuilder.reactive( viewModelBuilder: () => ViewHome(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?.initialise(); } @override void dispose() { _todoInput.dispose(); _todoFocus.dispose(); super.dispose(); } // ── Derived ─────────────────────────────────────────────────────────────── bool _isDone(Commitment item) { return item.status == CommitmentStatus.Completed || item.status == CommitmentStatus.LateCompleted || _ticked.contains(item.id); } int get _doneCount => _plan.where(_isDone).length; /// Today's routines that are still open, soonest first; the whole library /// when nothing is due. List get _upNext { final DateTime now = DateTime.now(); final List open = _routines .where((item) => item.scheduledOn(now) && !item.complete) .toList(); return (open.isEmpty ? _routines : open).take(6).toList(); } // ── Handlers ────────────────────────────────────────────────────────────── void _onOpenOverdue() async { final result = await GroundedNavigation() .navigateToPageWithData(const OverdueQueue(), context); if (result == true) { _model?.loadPlan(); } } void _onAddCommitment() async { if (!StandingEngine.permitsNewCommitment(_standing)) { _model?.requestNewCommitment(_standing, CommitmentClass.Standard); return; } final result = await GroundedNavigation() .navigateToPageWithData(const NewCommitment(), context); if (result == true) { _model?.loadPlan(); } } void _onTick(Commitment item) { if (item.id == null || _ticked.contains(item.id)) { return; } setState(() { _ticked.add(item.id!); }); _model?.tick(item); } void _onOpenReportCard() { GroundedNavigation().navigateToPage( NavigatorType.justOpen, const ReportCardScreen(), context); } void _onOpenGoals() async { final result = await GroundedNavigation() .navigateToPageWithData(const Goals(), context); if (result == true) { _model?.loadPlan(); } } void _onOpenTraining() { GroundedNavigation() .navigateToPage(NavigatorType.justOpen, const Training(), context); } void _onOpenRoutines() async { await GroundedNavigation() .navigateToPageWithData(const Routines(), context); _model?.loadRoutines(); } void _onOpenKind(RoutineKind kind) async { await GroundedNavigation() .navigateToPageWithData(Routines(kind: kind), context); _model?.loadRoutines(); } void _onStartRoutine(RoutineChain routine) async { if (routine.steps.isEmpty) { _onOpenRoutines(); return; } final result = await GroundedNavigation().navigateToPageWithData( RoutineRunner(routine: routine.copy()), context); if (result == true) { _model?.loadRoutines(); } } void _onOpenSettings() { GroundedNavigation() .navigateToPage(NavigatorType.justOpen, const Settings(), context); } void _onOpenExcuses() { GroundedNavigation() .navigateToPage(NavigatorType.justOpen, const ExcuseReport(), context); } 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) { return; } setState(() { _todos.insert( 0, TodoItem( id: DateTime.now().microsecondsSinceEpoch.toString(), title: title)); }); _todoInput.clear(); _model?.saveTodos(_todos); } void _onAddTodoPressed() { _onAddTodo(_todoInput.text); } void _onToggleTodo(TodoItem item) { setState(() { item.done = !item.done; }); _model?.saveTodos(_todos); } void _onRemoveTodo(TodoItem item) { setState(() { _todos.remove(item); }); _model?.saveTodos(_todos); } void _onClearDoneTodos() { setState(() { _todos.removeWhere((item) => item.done); }); _model?.saveTodos(_todos); } // ── Views ───────────────────────────────────────────────────────────────── Widget _mobileView(BoxConstraints constraints) { // Grounded and Lockdown replace the home screen with the overdue queue — // you do not get to look at your nice plans, only at your mess. final bool queueIsHome = StandingEngine.showsOverdueQueueAsHome(_standing) && !_distressed; 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: [ _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, 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), ], ), 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), ), ], ), ), ); } // ── Header helpers ──────────────────────────────────────────────────────── /// 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(); return Container( width: size, height: size, decoration: BoxDecoration( border: Border.all(color: colorWhite, width: 2), ), child: Stack( fit: StackFit.expand, children: [ SceneArt(palette: sceneWorkout, seed: sceneSeed(_user.name)), Center( child: text(initial, size * 0.4, TextType.Medium, color: colorWhite), ), ], ), ); } String _greetingWord() { final int hour = DateTime.now().hour; if (hour < 12) return "Good morning"; if (hour < 17) return "Good afternoon"; return "Good evening"; } 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; } final int weekday = DateTime.now().subtract(Duration(days: daysAgo)).weekday; return (_week?.completionByWeekday[weekday] ?? 0) >= _keptFloor; } /// 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; } 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 /// panel so they belong to the same surface as everything else. Widget _frosted(Widget child) { return Container( padding: const EdgeInsets.fromLTRB(20, 24, 20, 24), decoration: BoxDecoration( color: colorFrost, borderRadius: BorderRadius.zero, border: Border.all(color: colorGlassBorder), ), child: child, ); } /// The normal day: categories, the up-next rail, the pattern and the plan. Widget _planBody() { final int overdueCount = _overdue.length; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ if (overdueCount > 0) ...[ _overdueCallout(overdueCount), const SizedBox(height: 26), ], sectionBreak("Your day", trailing: _viewAll("View all", _onOpenRoutines)), _categoryRail(), const SizedBox(height: 28), _todoSection(), const SizedBox(height: 28), if (_upNext.isNotEmpty) ...[ sectionBreak("Up next", caption: "${_upNext.length} routines", trailing: _viewAll("Library", _onOpenRoutines)), _featuredRail(), const SizedBox(height: 28), ], if (_insight != null) ...[ _insightCard(_insight!), const SizedBox(height: 28), ], sectionBreak( "The plan", caption: "$_doneCount of ${_plan.length} done", trailing: _plan.isEmpty ? null : text(formatMinutes(_plannedMinutes()), 12, TextType.Medium, color: colorInkSoft), ), if (_plan.isEmpty) emptyState( CupertinoIcons.square_list, "Nothing committed today", "An empty plan is a decision too. Add something you actually intend to do.", ) else ...[ for (int index = 0; index < _plan.length; index++) _commitmentRow(_plan[index]) .animate() .fadeIn(duration: 350.ms, delay: (60 * index).ms) .slideX( begin: 0.06, end: 0, duration: 400.ms, delay: (60 * index).ms, curve: Curves.easeOut), addTaskAffordance( _onAddCommitment, enabled: StandingEngine.permitsNewCommitment(_standing), ), ], if (!StandingEngine.permitsNewCommitment(_standing)) ...[ const SizedBox(height: 4), text( ToneEngine.standingBody(_standing, _user.tone), 12, TextType.Regular, color: colorInkSoft, align: TextAlign.center, ), ], ], ); } Widget _viewAll(String label, VoidCallback onTap) { return GestureDetector( onTap: onTap, behavior: HitTestBehavior.opaque, child: Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: text(label, 12, TextType.Regular, color: colorInkSoft), ), ); } /// Round painted portraits — tap one to open that part of the day. Widget _categoryRail() { return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ _category(sceneWorkout, kindIcon(RoutineKind.Workout), "Workout", 3, () => _onOpenKind(RoutineKind.Workout)), _category(sceneMeditation, kindIcon(RoutineKind.Meditation), "Meditate", 5, () => _onOpenKind(RoutineKind.Meditation)), _category(sceneMustDo, kindIcon(RoutineKind.MustDo), "Must-do", 8, () => _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), ], ); } Widget _category(List palette, IconData icon, String label, int seed, VoidCallback onTap) { return GestureDetector( onTap: onTap, behavior: HitTestBehavior.opaque, child: Column( mainAxisSize: MainAxisSize.min, children: [ Container( width: 74, height: 74, decoration: BoxDecoration( border: Border.all(color: colorWhite, width: 2.5), boxShadow: const [ BoxShadow( color: Color(0x1F2A3A30), blurRadius: 14, offset: Offset(0, 6), ), ], ), child: ClipRect( child: SceneArt( palette: palette, seed: seed, figure: icon, figureScale: 0.48, figureAlignment: const Alignment(0, 0.25), ), ), ), const SizedBox(height: 8), text(label, 12, TextType.Regular, color: colorInk), ], ), ); } Widget _featuredRail() { final List items = _upNext; return SizedBox( height: 290, child: ListView.separated( scrollDirection: Axis.horizontal, clipBehavior: Clip.none, itemCount: items.length, separatorBuilder: (context, index) => const SizedBox(width: 12), itemBuilder: (context, index) => _featuredCard(items[index]), ), ); } /// The image-led card: a painted scene, a glass chip, the title and three /// light numbers along the bottom. Widget _featuredCard(RoutineChain routine) { final Color label = colorWhite.withValues(alpha: 0.75); final bool workout = routine.kind == RoutineKind.Workout; return GestureDetector( onTap: () => _onStartRoutine(routine), child: Container( width: 244, decoration: BoxDecoration( borderRadius: BorderRadius.zero, boxShadow: const [ BoxShadow( color: Color(0x262A3A30), blurRadius: 24, offset: Offset(0, 10), ), ], ), child: ClipRRect( borderRadius: BorderRadius.zero, child: Stack( fit: StackFit.expand, children: [ SceneArt( palette: scenePalette(routine.kind), seed: sceneSeed(routine.title), figure: kindIcon(routine.kind), figureScale: 0.44, figureAlignment: const Alignment(0.1, -0.25), shade: true, ), Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ GlassSurface( onDark: true, radius: BorderRadius.zero, padding: const EdgeInsets.symmetric( horizontal: 10, vertical: 5), child: text( routine.startMinute >= 0 ? formatStartMinute(routine.startMinute) : kindLabel(routine.kind), 10, TextType.Medium, color: colorWhite, ), ), const Spacer(), glassCircle(CupertinoIcons.play_fill, () => _onStartRoutine(routine), size: 40, onDark: true), ], ), const Spacer(), text(routine.title, 20, TextType.Medium, color: colorWhite, maxLines: 2, overflow: TextOverflow.ellipsis), const SizedBox(height: 10), Row( children: [ Expanded( child: _cardStat( "${(routine.totalSeconds / 60).round()}", CupertinoIcons.clock, "Min", label), ), Expanded( child: _cardStat("${routine.steps.length}", CupertinoIcons.list_bullet, "Steps", label), ), Expanded( child: _cardStat( workout ? "${routine.totalSets}" : "${(routine.partialCompletion * 100).round()}%", workout ? CupertinoIcons.repeat : CupertinoIcons.chart_pie, workout ? "Sets" : "Held", label), ), ], ), ], ), ), ], ), ), ), ); } Widget _cardStat(String value, IconData icon, String caption, Color tone) { return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ text(value, 24, TextType.Light, color: colorWhite), const SizedBox(height: 2), Row( children: [ Icon(icon, size: 10, color: tone), const SizedBox(width: 3), text(caption, 9, TextType.Regular, color: tone), ], ), ], ); } /// 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() { final List open = _todos.where((item) => !item.done).toList(); final List done = _todos.where((item) => item.done).toList(); return Column( key: _todoKey, crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ sectionBreak( "To-do", caption: "${open.length} open, no debt", trailing: done.isEmpty ? null : _viewAll("Clear done", _onClearDoneTodos), ), GlassSurface( tint: colorGlassStrong, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ Padding( padding: const EdgeInsets.fromLTRB(20, 6, 6, 6), child: Row( children: [ Icon(CupertinoIcons.pencil, size: 20, color: colorInkSoft), const SizedBox(width: 12), Expanded( child: quickAddField( _todoInput, "Add a to-do", _onAddTodo, focusNode: _todoFocus), ), GestureDetector( onTap: _onAddTodoPressed, behavior: HitTestBehavior.opaque, child: Container( width: 48, height: 48, color: colorNight, alignment: Alignment.center, child: Icon(CupertinoIcons.add, size: 22, color: colorLime), ), ), ], ), ), for (TodoItem item in [...open, ...done]) _todoRow(item), if (_todos.isEmpty) Padding( padding: const EdgeInsets.fromLTRB(20, 4, 20, 20), child: text( "Nothing to remember. Jot anything here; it stays on this phone.", 13, TextType.Regular, color: colorInkSoft, ), ), ], ), ), ], ); } Widget _todoRow(TodoItem item) { return Container( decoration: BoxDecoration( border: Border(top: BorderSide(color: colorGlassBorder)), ), padding: const EdgeInsets.fromLTRB(14, 4, 4, 4), child: Row( children: [ GestureDetector( onTap: () => _onToggleTodo(item), behavior: HitTestBehavior.opaque, child: Padding( padding: const EdgeInsets.all(6), child: AnimatedContainer( duration: const Duration(milliseconds: 200), width: 30, height: 30, decoration: BoxDecoration( color: item.done ? colorPrimary : colorWhite, border: Border.all( color: item.done ? colorPrimary : colorInk.withValues(alpha: 0.25), width: 2, ), ), child: item.done ? Icon(Icons.check_rounded, size: 20, color: colorWhite) : null, ), ), ), const SizedBox(width: 10), Expanded( child: GestureDetector( onTap: () => _onToggleTodo(item), behavior: HitTestBehavior.opaque, child: Padding( padding: const EdgeInsets.symmetric(vertical: 14), child: text( item.title, 16, TextType.Regular, color: item.done ? colorInkSoft : colorInk, decoration: item.done ? TextDecoration.lineThrough : null, ), ), ), ), GestureDetector( onTap: () => _onRemoveTodo(item), behavior: HitTestBehavior.opaque, child: Padding( padding: const EdgeInsets.all(14), child: Icon(CupertinoIcons.xmark, size: 16, color: colorInkSoft), ), ), ], ), ); } /// Grounded: the plan is hidden entirely and only the mess is shown. Widget _groundedBody() { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ text("YOUR PLANS ARE HIDDEN", 10, TextType.Bold, color: colorInkSoft, letterSpacing: 1.2), const SizedBox(height: 10), displayTitle( _standing == Standing.Lockdown ? "One at a time." : "Clear this first.", ), const SizedBox(height: 12), text( ToneEngine.standingBody(_standing, _user.tone), 14, TextType.Regular, color: colorInkSoft, height: 1.55, ), const SizedBox(height: 28), card( background: standingBackground(_standing), borderColor: standingColor(_standing).withValues(alpha: 0.20), child: Row( children: [ Expanded( child: labelled( "Open overdue", "${_overdue.length}", valueSize: 26, valueType: TextType.Light, valueColor: standingColor(_standing), ), ), Expanded( child: labelled( "Debt to clear", formatDebt( StandingEngine.debtToNextTierDown(_debt, _standing)), valueSize: 26, valueType: TextType.Light, valueColor: standingColor(_standing), ), ), ], ), ), const SizedBox(height: 24), sectionBreak("Outstanding", caption: "${_overdue.length} items"), if (_overdue.isEmpty) emptyState( CupertinoIcons.checkmark_seal, "The queue is empty", "Your standing will recover as the debt decays.", ) else ..._overdue.take(_standing == Standing.Lockdown ? 1 : _overdue.length) .map(_commitmentRow), const SizedBox(height: 24), roundedCornerButton( _standing == Standing.Lockdown ? "Deal with this one" : "Open the queue", _onOpenOverdue, background: standingColor(_standing), icon: CupertinoIcons.arrow_right, ), ], ); } /// Distress: the strict persona drops entirely. This is the difference /// between a product people keep and one they resent. Widget _distressBody() { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ text("A NOTE", 10, TextType.Bold, color: colorInkSoft, letterSpacing: 1.2), const SizedBox(height: 10), displayTitle(ToneEngine.distressHeadline()), const SizedBox(height: 14), text( ToneEngine.distressBody(), 15, TextType.Regular, color: colorInkSoft, height: 1.6, ), const SizedBox(height: 28), card( background: colorStandingGoodBg, borderColor: colorPositive.withValues(alpha: 0.20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ text("PAUSED", 9, TextType.Bold, color: colorPositive, letterSpacing: 1.2), const SizedBox(height: 8), text("Debt is not accruing right now.", 16, TextType.Medium, color: colorInk), const SizedBox(height: 6), text( "Nothing you miss this week is counting against you.", 13, TextType.Regular, color: colorInkSoft, height: 1.5, ), ], ), ), const SizedBox(height: 24), sectionBreak("Three things", caption: "that actually matter"), ..._plan .where((item) => item.commitmentClass == CommitmentClass.NonNegotiable) .take(3) .map(_commitmentRow), const SizedBox(height: 24), outlinedActionButton("Open settings", _onOpenSettings, icon: CupertinoIcons.slider_horizontal_3), ], ); } /// Overdue, as a warm card with a painted strip — impossible to miss, /// still part of the same world. Widget _overdueCallout(int count) { return GestureDetector( onTap: _onOpenOverdue, child: Container( decoration: BoxDecoration( borderRadius: BorderRadius.zero, gradient: const LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [colorOverdueWarm, colorOverdueWarmEnd], ), border: Border.all(color: colorWhite, width: 1.5), boxShadow: const [ BoxShadow( color: Color(0x1AB5442F), blurRadius: 24, offset: Offset(0, 10), ), ], ), padding: const EdgeInsets.all(18), child: Row( children: [ Container( width: 58, height: 58, alignment: Alignment.center, decoration: BoxDecoration( gradient: const RadialGradient( center: Alignment(-0.3, -0.4), colors: [colorOverdueGlow, colorStandingGrounded], ), boxShadow: [ BoxShadow( color: colorStandingGrounded.withValues(alpha: 0.35), blurRadius: 12, offset: const Offset(0, 5), ), ], ), child: text("$count", 19, TextType.Medium, color: colorWhite), ), const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ text("Overdue", 11, TextType.Medium, color: colorStandingGrounded), const SizedBox(height: 3), text( count >= Thresholds.maxOpenOverdue ? "You are at the cap. Nothing new until this drops." : "$count item${count == 1 ? "" : "s"} past the window.", 15, TextType.Medium, color: colorInk, ), ], ), ), glassCircle(CupertinoIcons.arrow_right, _onOpenOverdue, size: 44), ], ), ), ); } /// The excuse confrontation. One pattern, stated plainly, on the dark /// night card with the lime spark. Widget _insightCard(ExcuseCluster cluster) { return GestureDetector( onTap: _onOpenExcuses, child: Container( padding: const EdgeInsets.fromLTRB(22, 22, 22, 24), decoration: BoxDecoration( borderRadius: BorderRadius.zero, gradient: const LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [colorNightLift, colorNight], ), boxShadow: const [ BoxShadow( color: Color(0x332A3A30), blurRadius: 24, offset: Offset(0, 10), ), ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Row( children: [ Icon(CupertinoIcons.sparkles, size: 14, color: colorLime), const SizedBox(width: 6), text("Pattern spotted", 12, TextType.Medium, color: colorLime), const Spacer(), text("${cluster.occurrences}× this month", 11, TextType.Regular, color: colorWhite.withValues(alpha: 0.5)), ], ), const SizedBox(height: 12), text(cluster.insight, 18, TextType.Regular, color: colorWhite, height: 1.5), ], ), ), ); } IconData _classIcon(CommitmentClass value) { switch (value) { case CommitmentClass.NonNegotiable: return CupertinoIcons.flame_fill; case CommitmentClass.Standard: return CupertinoIcons.bolt_fill; case CommitmentClass.Elective: return CupertinoIcons.sun_max_fill; } } /// A task row: painted thumbnail, the class as a coloured kicker, the title, /// the window and cost, and a round tick on the right. Widget _commitmentRow(Commitment item) { final bool settled = item.status == CommitmentStatus.Completed || item.status == CommitmentStatus.LateCompleted; final bool ticked = settled || _ticked.contains(item.id); final bool late = item.windowClosed && !settled; final Color accent = item.status == CommitmentStatus.LateCompleted ? colorStandingWarned : classColor(item.commitmentClass); return Container( margin: const EdgeInsets.only(bottom: 12), child: card( padding: const EdgeInsets.fromLTRB(10, 10, 14, 10), onTap: _onOpenOverdue, child: Row( children: [ SizedBox( width: 88, height: 88, child: ClipRRect( borderRadius: BorderRadius.zero, child: Opacity( opacity: ticked ? 0.45 : 1, child: SceneArt( palette: classScene(item.commitmentClass), seed: sceneSeed(item.title), figure: _classIcon(item.commitmentClass), figureScale: 0.46, figureAlignment: const Alignment(0, 0.1), ), ), ), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ text( late ? overdueLabel(item) : item.status == CommitmentStatus.LateCompleted ? "Completed late" : classLabel(item.commitmentClass), 10, TextType.Medium, color: late ? colorStandingGrounded : accent, ), const SizedBox(height: 3), text( item.title, 17, TextType.Medium, color: ticked ? colorInkSoft : colorInk, maxLines: 2, overflow: TextOverflow.ellipsis, decoration: ticked ? TextDecoration.lineThrough : null, ), const SizedBox(height: 5), Row( children: [ Icon(CupertinoIcons.clock, size: 11, color: colorInkSoft), const SizedBox(width: 4), Flexible( child: text(formatWindow(item), 11, TextType.Regular, color: colorInkSoft, maxLines: 1, overflow: TextOverflow.ellipsis), ), const SizedBox(width: 10), Icon(CupertinoIcons.timer, size: 11, color: colorInkSoft), const SizedBox(width: 4), text(formatMinutes(item.estMinutes), 11, TextType.Regular, color: colorInkSoft), ], ), ], ), ), const SizedBox(width: 8), _roundTick(ticked, accent, settled ? null : () => _onTick(item)), ], ), ), ); } /// The tick as a ring: hollow while open, filled with a check once done. Widget _roundTick(bool checked, Color accent, VoidCallback? onTap) { return GestureDetector( onTap: onTap, behavior: HitTestBehavior.opaque, child: Padding( padding: const EdgeInsets.all(4), child: AnimatedContainer( duration: const Duration(milliseconds: 220), curve: Curves.easeOutBack, width: 44, height: 44, decoration: BoxDecoration( color: checked ? colorPrimary : colorWhite, border: Border.all( color: checked ? colorPrimary : accent.withValues(alpha: 0.35), width: 2, ), boxShadow: checked ? [ BoxShadow( color: colorPrimary.withValues(alpha: 0.35), blurRadius: 10, offset: const Offset(0, 4), ), ] : const [], ), child: checked ? Icon(Icons.check_rounded, size: 24, color: colorWhite) : null, ), ), ); } double _plannedMinutes() { double total = 0; for (Commitment item in _plan) { total = total + item.estMinutes; } return total; } // ── ConnectHome ─────────────────────────────────────────────────────────── @override void onUserLoaded(UserDetails details) { setState(() { _user = details; }); } @override void onWeekLoaded(ReportCard? week) { setState(() { _week = week; }); } @override void onWaterLoaded(int ml) { setState(() { _waterMl = ml; }); } @override void onTodosLoaded(List todos) { setState(() { _todos = todos; }); } @override void onPlanLoaded(List plan) { setState(() { _plan = plan; // The server is now authoritative; anything it reports as settled no // longer needs the local optimistic flag. _ticked.removeWhere((id) => plan.every((item) => item.id != id)); }); } @override void onOverdueLoaded(List overdue) { setState(() { _overdue = overdue; }); } @override void onRoutinesLoaded(List routines) { setState(() { _routines = routines; }); } @override void onStandingResolved(Standing standing, double debtScore) { setState(() { _standing = standing; _debt = debtScore; }); } @override void onExcuseInsight(ExcuseCluster? cluster) { setState(() { _insight = cluster; }); } @override void onDistressDetected() { setState(() { _distressed = true; }); } @override void onTicked(Commitment item, bool late) { if (!late) { return; } // A clean tick needs no comment. A late one does — otherwise the user // learns that the window never mattered. _model?.showApplicationNotification( NotificationType.warning, "Late complete", "The window had already closed, so this is recorded as a late complete. It reduces the debt but does not clear it.", true, true, null, ); } @override void onTickFailed(Commitment item) { setState(() { _ticked.remove(item.id); }); } @override void onCreationBlocked(String reason) { _model?.showApplicationNotification( NotificationType.warning, "Not right now", reason, true, true, null, ); } }