import 'package:flutter/cupertino.dart'; import 'package:stacked/stacked.dart'; import '../../about/internal/application/ProgressPeriod.dart'; import '../../about/internal/application/ProgressTargets.dart'; import '../../about/internal/application/RoutineKind.dart'; import '../../about/internal/application/RoutineRunRecord.dart'; import '../../about/internal/application/TextType.dart'; import '../../configs/Navigator.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/ProgressEngine.dart'; import '../reportcard/ReportCardScreen.dart'; import 'ConnectProgress.dart'; import 'Progress.dart'; import 'TargetSetup.dart'; import 'ViewProgress.dart'; class ProgressState extends State implements ConnectProgress { ViewProgress? _model; List _records = []; ProgressTargets _targets = ProgressTargets(); ProgressPeriod _period = ProgressPeriod.Week; bool _loaded = false; static const List _weekLetters = [ "M", "T", "W", "T", "F", "S", "S" ]; @override Widget build(BuildContext context) { return ViewModelBuilder.reactive( viewModelBuilder: () => ViewProgress(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 _onBack() { Navigator.pop(context); } void _onPeriodSelected(ProgressPeriod period) { setState(() { _period = period; }); } void _onOpenTargets() async { final dynamic saved = await GroundedNavigation() .navigateToPageWithData(const TargetSetup(), context); if (saved == true) { _model?.initialise(); } } void _onOpenReportCard() { GroundedNavigation() .navigateToPageWithData(const ReportCardScreen(), context); } // ── Derived ─────────────────────────────────────────────────────────────── DateTime get _start => ProgressEngine.periodStart(_period, DateTime.now()); List get _current => ProgressEngine.between( _records, _start, ProgressEngine.periodEnd(_period, _start)); /// The previous period only up to the same point, so eight days into a /// month is not measured against all thirty of the last one. List get _previous { final DateTime start = ProgressEngine.previousStart(_period, _start); final DateTime full = ProgressEngine.periodEnd(_period, start); final DateTime samepoint = start.add(DateTime.now().difference(_start)); return ProgressEngine.between( _records, start, samepoint.isBefore(full) ? samepoint : full); } String get _periodNoun { switch (_period) { case ProgressPeriod.Day: return "today"; case ProgressPeriod.Week: return "this week"; case ProgressPeriod.Month: return "this month"; } } String get _previousNoun { switch (_period) { case ProgressPeriod.Day: return "this time yesterday"; case ProgressPeriod.Week: return "this point last week"; case ProgressPeriod.Month: return "this point last month"; } } // ── Views ───────────────────────────────────────────────────────────────── Widget _mobileView(BoxConstraints constraints) { return Sheet( eyebrow: "Over time", title: "Progress", onBack: _onBack, action: chromeAction(CupertinoIcons.slider_horizontal_3, _onOpenTargets), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ displayTitle("Kept, not\nplanned."), const SizedBox(height: 22), segmentedSelector( options: ProgressPeriod.values, selected: _period, label: periodLabel, onSelected: _onPeriodSelected, activeColor: colorNight, ), const SizedBox(height: 20), if (_loaded && !_targets.set) ...[ _setupPrompt(), const SizedBox(height: 16), ], _headline(), const SizedBox(height: 12), Row( children: [ Expanded(child: _activeTile()), const SizedBox(width: 12), Expanded(child: _streakTile()), ], ), const SizedBox(height: 28), sectionBreak( _period == ProgressPeriod.Day ? "Today's runs" : "Runs", caption: "${_current.length} $_periodNoun", ), if (_current.isEmpty) _emptyRuns() else ..._current.reversed .take(_period == ProgressPeriod.Day ? 20 : 6) .map(_runRow), const SizedBox(height: 22), outlinedActionButton( "Weekly report card", _onOpenReportCard, icon: CupertinoIcons.doc_text, radius: 0, ), const SizedBox(height: 12), ], ), ); } /// Shown until the targets have been saved once: the numbers below use /// defaults until then, and say so. Widget _setupPrompt() { return GestureDetector( onTap: _onOpenTargets, child: Container( padding: const EdgeInsets.fromLTRB(18, 16, 14, 16), color: colorNight, child: Row( children: [ Icon(CupertinoIcons.scope, size: 22, color: colorLime), const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ text("Set your weekly target", 15, TextType.Medium, color: colorWhite), const SizedBox(height: 4), text( "Measuring against your schedule, ${_targets.routinesPerWeek} routines a week, until you choose. Reminders are off.", 12, TextType.Regular, color: colorWhite.withValues(alpha: 0.7), height: 1.4, ), ], ), ), const SizedBox(width: 10), Icon(CupertinoIcons.chevron_right, size: 16, color: colorWhite.withValues(alpha: 0.7)), ], ), ), ); } /// Held routines against the target, the change on the previous period, /// and the bars underneath. Widget _headline() { final int held = ProgressEngine.held(_current); final int target = ProgressEngine.routineTarget(_targets, _period, _start); final int delta = held - ProgressEngine.held(_previous); return card( padding: const EdgeInsets.fromLTRB(20, 18, 20, 20), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ text("ROUTINES HELD ${_periodNoun.toUpperCase()}", 10, TextType.Bold, color: colorInkSoft, letterSpacing: 1.1), const SizedBox(height: 8), Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ text("$held", 46, TextType.Light, color: colorInk, height: 1), const SizedBox(width: 8), Padding( padding: const EdgeInsets.only(bottom: 6), child: text("of $target target", 13, TextType.Regular, color: colorInkSoft), ), const Spacer(), Padding( padding: const EdgeInsets.only(bottom: 6), child: _deltaChip(delta), ), ], ), const SizedBox(height: 18), if (_period == ProgressPeriod.Day) meter(target == 0 ? 0 : held / target, fill: held >= target ? colorPositive : colorNight, track: colorMuted, height: 10) else _bars(), ], ), ); } Widget _deltaChip(int delta) { final Color tone = delta > 0 ? colorPositive : (delta < 0 ? colorNegative : colorInkSoft); final String sign = delta > 0 ? "+" : ""; return Container( padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5), color: tone.withValues(alpha: 0.12), child: text("$sign$delta vs $_previousNoun", 11, TextType.Medium, color: tone), ); } /// One bar per day (week) or per week (month). The current one is night, /// the rest are muted, and future ones are a stub so the shape of the /// period is still visible. Widget _bars() { final DateTime now = DateTime.now(); final List values = ProgressEngine.buckets(_records, _period, _start); final int current = ProgressEngine.currentBucket(_period, _start, now); int peak = 1; for (int value in values) { if (value > peak) { peak = value; } } return SizedBox( height: 132, child: Row( crossAxisAlignment: CrossAxisAlignment.end, children: List.generate(values.length, (index) { final bool isCurrent = index == current; final bool future = index > current; final double fraction = values[index] / peak; return Expanded( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 5), child: Column( mainAxisAlignment: MainAxisAlignment.end, children: [ if (!future && values[index] > 0) text("${values[index]}", 11, TextType.Medium, color: isCurrent ? colorInk : colorInkSoft), const SizedBox(height: 4), Container( height: future ? 4 : 6 + 84 * fraction, color: isCurrent ? colorNight : (future ? colorMuted : colorChartGrid), ), const SizedBox(height: 8), text( _period == ProgressPeriod.Week ? _weekLetters[index] : "W${index + 1}", 11, isCurrent ? TextType.Bold : TextType.Regular, color: isCurrent ? colorInk : colorInkSoft, ), ], ), ), ); }), ), ); } Widget _activeTile() { final int minutes = ProgressEngine.minutes(_current); final int target = ProgressEngine.minuteTarget(_targets, _period, _start); return _tile( CupertinoIcons.clock, formatMinutes(minutes), "active of ${formatMinutes(target)}", footer: meter(target == 0 ? 0 : minutes / target, fill: minutes >= target ? colorPositive : colorNight, track: colorMuted, height: 5), ); } Widget _streakTile() { final int streak = ProgressEngine.streak(_records, DateTime.now()); return _tile( CupertinoIcons.flame_fill, "$streak ${streak == 1 ? 'day' : 'days'}", "streak, held daily", iconColor: streak > 0 ? colorOverdueWarm : colorInkSoft, ); } Widget _tile(IconData icon, String value, String label, {Color? iconColor, Widget? footer}) { return card( padding: const EdgeInsets.fromLTRB(16, 16, 16, 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Icon(icon, size: 18, color: iconColor ?? colorInkSoft), const SizedBox(height: 14), text(value, 24, TextType.Light, color: colorInk), const SizedBox(height: 2), text(label, 11, TextType.Regular, color: colorInkSoft), const SizedBox(height: 12), footer ?? const SizedBox(height: 5), ], ), ); } Widget _runRow(RoutineRunRecord record) { final RoutineKind kind = RoutineKind.values.firstWhere( (value) => value.name == record.kind, orElse: () => RoutineKind.MustDo); return Container( margin: const EdgeInsets.only(bottom: 10), padding: const EdgeInsets.fromLTRB(14, 12, 16, 12), color: colorCard, child: Row( children: [ Container( width: 42, height: 42, alignment: Alignment.center, color: kindColor(kind).withValues(alpha: 0.14), child: Icon(kindIcon(kind), size: 20, color: kindColor(kind)), ), const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ text(record.title, 15, TextType.Medium, color: colorInk, maxLines: 1, overflow: TextOverflow.ellipsis), const SizedBox(height: 3), text( "${_when(record.startedAt)} · ${formatMinutes(record.seconds / 60)}", 12, TextType.Regular, color: colorInkSoft, ), ], ), ), const SizedBox(width: 10), text( record.held ? "Held" : "${record.completedSteps}/${record.totalSteps}", 12, TextType.Medium, color: record.held ? colorPositive : colorWarning, ), ], ), ); } String _when(DateTime value) { final String clock = formatStartMinute(value.hour * 60 + value.minute); if (_period == ProgressPeriod.Day) { return clock; } return "${formatDate(value)} · $clock"; } Widget _emptyRuns() { return Padding( padding: const EdgeInsets.symmetric(vertical: 18), child: emptyState( CupertinoIcons.chart_bar, "Nothing run $_periodNoun", "Finished routines land here, held or partial.", ), ); } // ── ConnectProgress ─────────────────────────────────────────────────────── @override void onProgressLoaded( List records, ProgressTargets targets) { setState(() { _records = records ..sort((first, second) => first.startedAt.compareTo(second.startedAt)); _targets = targets; _loaded = true; }); } }