import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:stacked/stacked.dart'; import '../../about/external/data/Commitment.dart'; import '../../about/external/data/ExcuseCluster.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/Standing.dart'; import '../../about/internal/application/TextType.dart'; import '../../about/internal/application/UserDetails.dart'; import '../../configs/Navigator.dart'; import '../../designs/Checkbox.dart'; import '../../designs/Component.dart'; import '../../designs/Responsive.dart'; import '../../designs/Shell.dart'; import '../../designs/buttons/Buttons.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 '../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 = []; 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(); } // ── 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 _onOpenSettings() { GroundedNavigation() .navigateToPage(NavigatorType.justOpen, const Settings(), context); } void _onOpenExcuses() { GroundedNavigation() .navigateToPage(NavigatorType.justOpen, const ExcuseReport(), context); } // ── 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 Sheet( eyebrow: _user.name.isEmpty ? "Grounded" : _user.name, title: queueIsHome ? "What you owe" : "Today", chrome: _distressed ? colorPrimaryDark : _chromeFor(_standing), banner: _standingBanner(), action: chromeAction( CupertinoIcons.person, _onOpenSettings, dotted: _user.sickMode, dotColor: colorStandingWarned, ), child: _distressed ? _distressBody() : queueIsHome ? _groundedBody() : _planBody(), ); } /// The chrome carries the standing colour, so the tier is legible before a /// single word is read. Color _chromeFor(Standing standing) { switch (standing) { case Standing.Good: return colorPrimaryDark; case Standing.Warned: return colorPrimaryDark; case Standing.Grounded: return colorStandingGrounded; case Standing.Lockdown: return colorStandingLockdown; } } /// The debt strip that sits in the black chrome under the title. Widget _standingBanner() { if (_distressed) { return const SizedBox.shrink(); } final Color tone = standingColor(_standing); return Container( padding: const EdgeInsets.fromLTRB(16, 14, 16, 14), decoration: BoxDecoration( color: colorWhite.withValues(alpha: 0.08), borderRadius: BorderRadius.circular(16), ), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Row( children: [ Container( width: 7, height: 7, decoration: BoxDecoration( color: _standing == Standing.Good ? tone : colorWhite, shape: BoxShape.circle, ), ), const SizedBox(width: 8), text( standingLabel(_standing).toUpperCase(), 9, TextType.Bold, color: colorWhite.withValues(alpha: 0.75), letterSpacing: 1.2, ), ], ), const SizedBox(height: 8), text( ToneEngine.standingHeadline(_standing, _user.tone), 16, TextType.Medium, color: colorWhite, ), ], ), ), const SizedBox(width: 16), Column( crossAxisAlignment: CrossAxisAlignment.end, mainAxisSize: MainAxisSize.min, children: [ text("DEBT", 9, TextType.Bold, color: colorWhite.withValues(alpha: 0.45), letterSpacing: 1.0), const SizedBox(height: 4), text(formatDebt(_debt), 30, TextType.Light, color: colorWhite), ], ), ], ), ); } /// The normal day: the plan, with the overdue count kept visible above it so /// it is never out of sight. Widget _planBody() { final int overdueCount = _overdue.length; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ if (overdueCount > 0) ...[ _overdueCallout(overdueCount), const SizedBox(height: 24), ], if (_insight != null) ...[ _insightCard(_insight!), const SizedBox(height: 24), ], sectionBreak( "The plan", caption: "${_plan.length} committed", trailing: _plan.isEmpty ? null : text(formatMinutes(_plannedMinutes()), 12, TextType.Bold, color: colorGrey2), ), 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 ...[ ..._plan.map(_commitmentRow), addTaskAffordance( _onAddCommitment, enabled: StandingEngine.permitsNewCommitment(_standing), ), ], const SizedBox(height: 12), _quickLinks(), const SizedBox(height: 24), roundedCornerButton( "Commit to something", _onAddCommitment, icon: CupertinoIcons.add, enabled: StandingEngine.permitsNewCommitment(_standing), ), if (!StandingEngine.permitsNewCommitment(_standing)) ...[ const SizedBox(height: 10), text( ToneEngine.standingBody(_standing, _user.tone), 12, TextType.Regular, color: colorGrey2, align: TextAlign.center, ), ], ], ); } /// 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: colorGrey2, 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: colorGrey2, 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: colorGrey2, letterSpacing: 1.2), const SizedBox(height: 10), displayTitle(ToneEngine.distressHeadline()), const SizedBox(height: 14), text( ToneEngine.distressBody(), 15, TextType.Regular, color: colorGrey2, 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: colorPrimaryDark), const SizedBox(height: 6), text( "Nothing you miss this week is counting against you.", 13, TextType.Regular, color: colorGrey2, 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), ], ); } Widget _overdueCallout(int count) { return card( background: colorStandingGroundedBg, borderColor: colorStandingGrounded.withValues(alpha: 0.20), onTap: _onOpenOverdue, child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ Container( width: 44, height: 44, alignment: Alignment.center, decoration: BoxDecoration( color: colorStandingGrounded, borderRadius: BorderRadius.circular(13), ), child: text("$count", 17, TextType.Bold, color: colorWhite), ), const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ text("OVERDUE", 9, TextType.Bold, color: colorStandingGrounded, letterSpacing: 1.2), const SizedBox(height: 5), text( count >= Thresholds.maxOpenOverdue ? "You are at the cap. Nothing new until this drops." : "$count item${count == 1 ? "" : "s"} past the window.", 14, TextType.Medium, color: colorPrimaryDark, ), ], ), ), Icon(CupertinoIcons.chevron_right, size: 15, color: colorStandingGrounded), ], ), ); } /// The excuse confrontation. One pattern, stated plainly, with the /// suggestion attached. Widget _insightCard(ExcuseCluster cluster) { return card( background: colorPrimaryDark, borderColor: colorPrimaryDark, onTap: _onOpenExcuses, child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ text("PATTERN", 9, TextType.Bold, color: colorWhite.withValues(alpha: 0.45), letterSpacing: 1.2), text("${cluster.occurrences}×", 11, TextType.Bold, color: colorWhite.withValues(alpha: 0.45)), ], ), const SizedBox(height: 12), text(cluster.insight, 15, TextType.Regular, color: colorWhite, height: 1.55), ], ), ); } /// A task line with its tick box. Completed items keep their place, struck /// through — the day stays a record of what was asked, not just what is left. 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 = classColor(item.commitmentClass); return Container( margin: const EdgeInsets.only(bottom: 6), child: card( padding: const EdgeInsets.fromLTRB(12, 8, 14, 8), child: TickRow( checked: ticked, title: item.title, accent: item.status == CommitmentStatus.LateCompleted ? colorStandingWarned : accent, onTick: settled ? null : () => _onTick(item), onTap: _onOpenOverdue, detail: Row( children: [ text(formatWindow(item), 11, TextType.Regular, color: colorGrey2), const SizedBox(width: 10), Container( width: 3, height: 3, decoration: BoxDecoration(color: colorGrey, shape: BoxShape.circle), ), const SizedBox(width: 10), text(formatMinutes(item.estMinutes), 11, TextType.Regular, color: colorGrey2), if (late) ...[ const SizedBox(width: 10), pill( overdueLabel(item), colorStandingGrounded, colorStandingGroundedBg, textSize: 9, ), ], if (item.status == CommitmentStatus.LateCompleted) ...[ const SizedBox(width: 10), pill("Late", colorStandingWarned, colorStandingWarnedBg, textSize: 9), ], ], ), trailing: ticked ? null : pill( classLabel(item.commitmentClass), accent, classBackground(item.commitmentClass), textSize: 9, ), ), ), ); } Widget _quickLinks() { return Row( children: [ Expanded( child: _quickLink( CupertinoIcons.flag_fill, "Goals", _onOpenGoals, ), ), const SizedBox(width: 10), Expanded( child: _quickLink( CupertinoIcons.chart_bar_alt_fill, "Report", _onOpenReportCard, ), ), const SizedBox(width: 10), Expanded( child: _quickLink( CupertinoIcons.flame_fill, "Training", _onOpenTraining, ), ), ], ); } Widget _quickLink(IconData icon, String label, VoidCallback onTap) { return card( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 16), onTap: onTap, child: Row( children: [ Icon(icon, size: 17, color: colorPrimaryDark), const SizedBox(width: 10), Expanded( child: text(label, 13, TextType.Medium, color: colorPrimaryDark, maxLines: 1, overflow: TextOverflow.ellipsis), ), ], ), ); } 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 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 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, ); } }