import 'package:flutter/cupertino.dart'; import 'package:flutter/material.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/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(); 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(); 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 _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 Scaffold( backgroundColor: colorAmbientMid, body: AmbientBackground( child: Stack( children: [ SafeArea( bottom: false, child: SingleChildScrollView( padding: const EdgeInsets.fromLTRB(20, 8, 20, 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(), ], ), ), ), 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 ), ], ).animate().fadeIn(duration: 400.ms, delay: 250.ms).slideY( begin: 0.4, end: 0, duration: 500.ms, delay: 250.ms, curve: Curves.easeOutCubic), ), ], ), ), ); } 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, ), ], ); } /// A round 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), boxShadow: const [ BoxShadow( color: Color(0x1A1A1F1C), blurRadius: 12, offset: Offset(0, 4), ), ], ), 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 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."; } 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); } /// 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); } 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), ], ); } /// 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( 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), ), 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, ), ), ); } 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) { total = total + item.estMinutes; } return total; } // ── ConnectHome ─────────────────────────────────────────────────────────── @override void onUserLoaded(UserDetails details) { setState(() { _user = details; }); } @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, ); } }