import 'dart:async'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:stacked/stacked.dart'; import '../../about/external/data/RoutineStep.dart'; import '../../about/internal/application/NotificationType.dart'; import '../../about/internal/application/RoutineKind.dart'; import '../../about/internal/application/RoutinePhase.dart'; import '../../about/internal/application/RoutineRun.dart'; import '../../about/internal/application/StepMode.dart'; import '../../about/internal/application/TextType.dart'; import '../../designs/AmbientBackground.dart'; import '../../designs/Component.dart'; import '../../designs/DotDigits.dart'; import '../../designs/GlassSurface.dart'; import '../../designs/ProgressRing.dart'; import '../../designs/Shell.dart'; import '../../designs/buttons/Buttons.dart'; import '../../designs/scene/SceneArt.dart'; import '../../designs/text/Text.dart'; import '../../utils/Colors.dart'; import '../../utils/CommonUtils.dart'; import '../../utils/Thresholds.dart'; import 'ConnectRoutineRunner.dart'; import 'RoutineRunner.dart'; import 'ViewRoutineRunner.dart'; /// The routine player. A painted scene carries the phase — a glass ring that /// drains for timed work, a breathing circle for meditation — and the light /// panel beneath it carries the chain on an arc, the step and the dotted clock. class RoutineRunnerState extends State with WidgetsBindingObserver, SingleTickerProviderStateMixin implements ConnectRoutineRunner { ViewRoutineRunner? _model; late RoutineRun _run; Timer? _ticker; /// Drives repaint only; every value shown is derived from wall-clock. int _tick = 0; /// The last whole second a countdown haptic fired on, so each fires once. int _lastBuzz = -1; bool _haptics = true; bool _finished = false; late AnimationController _breath; final ScrollController _carousel = ScrollController(); static const double _carouselItem = 78; /// Run time below which leaving logs nothing — opening the player by /// mistake is not a broken chain. static const int _graceSeconds = 30; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); _run = RoutineRun(widget.routine); for (RoutineStep step in _run.chain.steps) { step.completed = false; } // Box breathing: four seconds in, four out. _breath = AnimationController( vsync: this, duration: const Duration(seconds: 4), ); _run.resume(); } @override Widget build(BuildContext context) { return ViewModelBuilder.reactive( viewModelBuilder: () => ViewRoutineRunner(context, this), onViewModelReady: (viewModel) { _model = viewModel; _initiate(); }, builder: (context, viewModel, child) => PopScope( // Leaving mid-chain is a decision, not a back gesture. canPop: false, onPopInvokedWithResult: (didPop, result) { if (!didPop) { _onRequestExit(); } }, child: AnnotatedRegion( value: SystemUiOverlayStyle.light, child: Scaffold( backgroundColor: colorAmbientMid, body: _finished ? _summaryView() : _runnerView(), ), ), ), ); } void _initiate() { _startTicker(); _syncBreath(); _model?.publishRun(_run); } void _startTicker() { _ticker?.cancel(); _ticker = Timer.periodic(const Duration(milliseconds: 250), _onTick); } void _onTick(Timer timer) { if (!mounted || _finished) { return; } if (_run.running && _run.phase.countsDown) { final int remaining = _run.phaseRemaining(); if (remaining <= Thresholds.routineCountdownSeconds && remaining > 0 && remaining != _lastBuzz) { _lastBuzz = remaining; _buzz(light: true); } if (_run.phaseFinished()) { _advance(skipped: false); return; } } setState(() { _tick = _tick + 1; }); // Refresh the lock-screen notification every ~5s rather than every tick. if (_tick % 20 == 0 && _run.running) { _model?.publishRun(_run); } } /// Backgrounding pauses the run and is counted, exactly as for a single /// task — that is what makes a logged routine mean anything. @override void didChangeAppLifecycleState(AppLifecycleState state) { if (_finished) { return; } if (state == AppLifecycleState.resumed) { return; } if (state == AppLifecycleState.paused || state == AppLifecycleState.hidden) { if (_run.running) { setState(() { _run.pause(); _run.backgroundedCount = _run.backgroundedCount + 1; }); _syncBreath(); _model?.publishRun(_run); } } } // ── Flow ────────────────────────────────────────────────────────────────── void _advance({required bool skipped}) { final bool more = _run.advance(skipped: skipped); _lastBuzz = -1; if (!more) { _finish(); return; } _buzz(light: false); setState(() {}); _syncBreath(); _syncCarousel(); _model?.publishRun(_run); } void _finish() { _run.pause(); _ticker?.cancel(); _breath.stop(); _buzz(light: false); _model?.clearRun(); setState(() { _finished = true; }); } void _buzz({required bool light}) { if (!_haptics) { return; } if (light) { HapticFeedback.selectionClick(); } else { HapticFeedback.heavyImpact(); } } /// The breathing circle only moves while a meditation phase is running. void _syncBreath() { final bool breathe = _run.chain.kind == RoutineKind.Meditation && _run.running && !_run.phase.rest && !_finished; if (breathe && !_breath.isAnimating) { _breath.repeat(reverse: true); } else if (!breathe && _breath.isAnimating) { _breath.stop(); } } /// Keep the current step centred in the carousel. void _syncCarousel() { if (!_carousel.hasClients) { return; } // The list is padded by half a viewport either side, so step n is // centred at exactly n item-widths of scroll. final double target = _run.phase.stepIndex * _carouselItem; _carousel.animateTo( target.clamp(0, _carousel.position.maxScrollExtent), duration: const Duration(milliseconds: 350), curve: Curves.easeOutCubic, ); } // ── Handlers ────────────────────────────────────────────────────────────── void _onTogglePause() { setState(() { if (_run.running) { _run.pause(); } else { _run.resume(); } }); _syncBreath(); _model?.publishRun(_run); } /// The primary pill: pause/play on a countdown, "done" on anything that /// waits for you. void _onPrimary() { if (_run.phase.countsDown) { _onTogglePause(); return; } if (!_run.running) { _run.resume(); } _advance(skipped: false); } void _onNext() { // Skipping rest is not skipping work, so it is not counted as a skip. _advance(skipped: !_run.phase.rest); } void _onPrevious() { setState(() { _run.back(); _lastBuzz = -1; }); _syncBreath(); _syncCarousel(); _model?.publishRun(_run); } void _onToggleHaptics() { setState(() { _haptics = !_haptics; }); } void _onJumpTo(int stepIndex) { setState(() { _run.jumpTo(stepIndex); _lastBuzz = -1; }); _syncBreath(); _syncCarousel(); _model?.publishRun(_run); } void _onRequestExit() { if (_finished) { Navigator.pop(context, false); return; } final bool worthLogging = _run.elapsedSeconds() >= _graceSeconds; final int done = _run.chain.completedSteps; final int total = _run.chain.steps.length; _model?.showApplicationNotification( NotificationType.warning, "Break the chain here?", worthLogging ? "$done of $total steps done. Stopping now logs this run as partial at step ${_run.phase.stepIndex + 1} — it does not disappear." : "Nothing has been done yet, so leaving now logs nothing.", true, true, null, action: worthLogging ? "Stop and log" : "Leave", positiveAction: _onConfirmExit, ); } void _onConfirmExit() { Navigator.pop(context); if (_run.elapsedSeconds() < _graceSeconds) { _model?.clearRun(); Navigator.pop(context, false); return; } _run.pause(); _model?.log(_run); } void _onLog() { _model?.log(_run); } void _onOpenSteps() { showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: Colors.transparent, barrierColor: colorBlack.withValues(alpha: 0.55), builder: (BuildContext sheetContext) => _stepsSheet(sheetContext), ); } // ── Views ───────────────────────────────────────────────────────────────── Widget _runnerView() { return AmbientBackground( child: LayoutBuilder( builder: (BuildContext context, BoxConstraints constraints) { final double stageHeight = constraints.maxHeight * 0.48; return Stack( children: [ Positioned( top: 0, left: 0, right: 0, height: stageHeight + 70, child: _scene(), ), Positioned( top: 0, left: 0, right: 0, height: stageHeight, child: _stage(), ), Positioned( left: 0, right: 0, top: stageHeight - 40, bottom: 0, child: _panel(), ), ], ); }, ), ); } /// The painted world the run happens in. Rest dims it to dusk. Widget _scene() { return ShaderMask( shaderCallback: (Rect bounds) => const LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Color(0xFF000000), Color(0x00000000)], stops: [0.62, 1.0], ).createShader(bounds), blendMode: BlendMode.dstIn, child: Stack( fit: StackFit.expand, children: [ SceneArt( palette: scenePalette(_run.chain.kind), seed: sceneSeed(_run.chain.title), ), AnimatedContainer( duration: const Duration(milliseconds: 600), color: colorNight.withValues(alpha: _run.phase.rest ? 0.45 : 0.08), ), ], ), ); } /// The top half: frosted controls and the phase visual over the scene. Widget _stage() { final RoutineKind kind = _run.chain.kind; return SafeArea( bottom: false, child: Padding( padding: const EdgeInsets.fromLTRB(16, 6, 16, 50), child: Column( children: [ Row( children: [ glassCircle(CupertinoIcons.arrow_left, _onRequestExit, onDark: true), const Spacer(), _totalPill(), const SizedBox(width: 8), glassCircle(CupertinoIcons.ellipsis_vertical, _onOpenSteps, onDark: true), ], ), Expanded( child: Center( child: kind == RoutineKind.Meditation && !_run.phase.rest ? _breathingCircle() : _phaseRing(), ), ), _stageStatus(), ], ), ), ); } /// Total elapsed and planned remaining — the reference's "Time" pill. Widget _totalPill() { return GlassSurface( onDark: true, radius: BorderRadius.zero, padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(CupertinoIcons.time, size: 13, color: colorWhite), const SizedBox(width: 6), text(formatClock(_run.elapsedSeconds()), 13, TextType.Medium, color: colorWhite), Container( width: 3, height: 3, margin: const EdgeInsets.symmetric(horizontal: 8), decoration: BoxDecoration( color: colorWhite.withValues(alpha: 0.6), shape: BoxShape.circle, ), ), text("~${formatClock(_run.plannedRemaining())} left", 12, TextType.Regular, color: colorWhite.withValues(alpha: 0.8)), ], ), ); } /// A glass disc standing in the scene with the figure in it; the ring /// drains as the phase runs down. Widget _phaseRing() { final RoutinePhase phase = _run.phase; return ProgressRing( fraction: phase.countsDown ? _run.phaseProgress() : 0, size: 180, stroke: 5, fill: colorWhite, track: colorWhite.withValues(alpha: 0.22), child: GlassSurface( onDark: true, radius: BorderRadius.zero, child: SizedBox( width: 150, height: 150, child: phase.rest ? Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(CupertinoIcons.wind, size: 38, color: colorWhite), const SizedBox(height: 8), text("Rest", 14, TextType.Medium, color: colorWhite), ], ) : Icon( phase.step.mode == StepMode.Check ? CupertinoIcons.checkmark_alt : kindIcon(_run.chain.kind), size: 70, color: colorWhite, shadows: const [ Shadow( color: Color(0x40000000), blurRadius: 18, offset: Offset(0, 6)), ], ), ), ), ); } Widget _breathingCircle() { return AnimatedBuilder( animation: _breath, builder: (context, child) { final double t = Curves.easeInOut.transform(_breath.value); final bool inhaling = _breath.status == AnimationStatus.forward; return SizedBox( width: 240, height: 240, child: Stack( alignment: Alignment.center, children: [ Container( width: 140 + 96 * t, height: 140 + 96 * t, decoration: BoxDecoration( shape: BoxShape.circle, color: colorWhite.withValues(alpha: 0.10), border: Border.all( color: colorWhite.withValues(alpha: 0.35), width: 1), ), ), GlassSurface( onDark: true, radius: BorderRadius.zero, child: SizedBox( width: 120 + 50 * t, height: 120 + 50 * t, child: Center( child: text( !_run.running ? "Paused" : inhaling ? "Breathe in" : "Breathe out", 16, TextType.Medium, color: colorWhite, ), ), ), ), ], ), ); }, ); } Widget _stageStatus() { final String status = !_run.running ? "Paused" : _run.phase.rest ? "Resting" : "In progress"; return Row( children: [ GlassSurface( onDark: true, radius: BorderRadius.zero, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), child: Row( mainAxisSize: MainAxisSize.min, children: [ Container( width: 7, height: 7, decoration: BoxDecoration( color: _run.running ? colorLime : colorWarning, shape: BoxShape.circle, ), ), const SizedBox(width: 7), text(status, 11, TextType.Medium, color: colorWhite), ], ), ), if (_run.backgroundedCount > 0) ...[ const SizedBox(width: 8), GlassSurface( onDark: true, radius: BorderRadius.zero, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), child: text("Left app ${_run.backgroundedCount}×", 11, TextType.Medium, color: colorWarmYellow), ), ], ], ); } /// The lower half: the chain on an arc, the step, the dotted clock and the /// transport. Widget _panel() { final RoutinePhase phase = _run.phase; final Color tone = kindColor(_run.chain.kind); return SafeArea( top: false, child: Column( children: [ _stepCarousel(), Expanded( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 24), child: Column( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ Column( mainAxisSize: MainAxisSize.min, children: [ text( "${phase.stepIndex + 1} OF ${_run.chain.steps.length}", 13, TextType.Regular, color: colorInkSoft, letterSpacing: 0.6, ), const SizedBox(height: 4), text( phase.rest ? "Rest" : phase.step.title, 24, TextType.Medium, color: colorInk, align: TextAlign.center, maxLines: 2, overflow: TextOverflow.ellipsis, ), if (phase.step.sets > 1) ...[ const SizedBox(height: 10), _setDots(phase, tone), ], ], ), _clock(phase, tone), _caption(phase), ], ), ), ), _transport(phase, tone), const SizedBox(height: 14), ], ), ); } Widget _stepCarousel() { final List steps = _run.chain.steps; return SizedBox( height: 104, child: LayoutBuilder( builder: (BuildContext context, BoxConstraints constraints) { final double side = (constraints.maxWidth - _carouselItem) / 2; return ListView.builder( controller: _carousel, scrollDirection: Axis.horizontal, padding: EdgeInsets.symmetric(horizontal: side), itemCount: steps.length, itemExtent: _carouselItem, itemBuilder: (context, index) => _carouselStep(index, steps[index]), ); }, ), ); } /// Each step as a painted portrait. They sit on an arc that dips away from /// the current one, like beads on a string. Widget _carouselStep(int index, RoutineStep step) { final int current = _run.phase.stepIndex; final bool isCurrent = index == current; final bool done = step.completed; final int distance = (index - current).abs(); final double drop = (distance * distance * 5.0).clamp(0, 30).toDouble(); final double size = isCurrent ? 58 : 46; return GestureDetector( onTap: () => _onJumpTo(index), behavior: HitTestBehavior.opaque, child: AnimatedPadding( duration: const Duration(milliseconds: 300), curve: Curves.easeOutCubic, padding: EdgeInsets.only(top: drop), child: Column( mainAxisAlignment: MainAxisAlignment.start, children: [ AnimatedContainer( duration: const Duration(milliseconds: 300), curve: Curves.easeOutCubic, width: size, height: size, padding: const EdgeInsets.all(2.5), decoration: BoxDecoration( color: isCurrent ? colorNight : colorWhite, boxShadow: const [ BoxShadow( color: Color(0x262A3A30), blurRadius: 12, offset: Offset(0, 5), ), ], ), child: ClipRect( child: Stack( fit: StackFit.expand, children: [ SceneArt( palette: scenePalette(_run.chain.kind), seed: sceneSeed(step.title), figure: stepModeIcon(step.mode), figureScale: 0.42, figureAlignment: const Alignment(0, 0.2), ), if (done && !isCurrent) Container( color: colorNight.withValues(alpha: 0.55), child: Icon(Icons.check_rounded, size: 20, color: colorLime), ), ], ), ), ), const SizedBox(height: 5), text( step.title, 10, isCurrent ? TextType.Medium : TextType.Regular, color: isCurrent ? colorInk : colorInkSoft, maxLines: 1, overflow: TextOverflow.ellipsis, align: TextAlign.center, ), ], ), ), ); } Widget _setDots(RoutinePhase phase, Color tone) { return Row( mainAxisSize: MainAxisSize.min, children: [ text("Set ${phase.set} of ${phase.step.sets}", 11, TextType.Medium, color: tone), const SizedBox(width: 10), ...List.generate(phase.step.sets, (index) { final int set = index + 1; final bool filled = set < phase.set || (set == phase.set && phase.rest); final bool active = set == phase.set && !phase.rest; return AnimatedContainer( duration: const Duration(milliseconds: 200), width: active ? 20 : 7, height: 7, margin: const EdgeInsets.only(right: 4), decoration: BoxDecoration( color: filled || active ? tone : tone.withValues(alpha: 0.15), borderRadius: BorderRadius.zero, ), ); }), ], ); } /// The dotted clock — counting down for timed work, up for everything else, /// and the rep target for rep sets. Widget _clock(RoutinePhase phase, Color tone) { final Color ink = _run.running ? colorInk : colorInkSoft; if (!phase.countsDown && phase.step.mode == StepMode.Reps) { return Column( mainAxisSize: MainAxisSize.min, children: [ Row( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, children: [ text("×", 44, TextType.Light, color: ink), const SizedBox(width: 10), DotDigits(value: "${phase.step.reps}", height: 62, color: ink), ], ), const SizedBox(height: 8), text("reps · ${formatClock(_run.phaseElapsed())} on this set", 12, TextType.Regular, color: colorInkSoft), ], ); } final int seconds = phase.countsDown ? _run.phaseRemaining() : _run.phaseElapsed(); return Column( mainAxisSize: MainAxisSize.min, children: [ DotDigits(value: formatClock(seconds), height: 62, color: ink), if (phase.countsDown) ...[ const SizedBox(height: 12), SizedBox( width: 180, child: meter(_run.phaseProgress(), fill: phase.rest ? colorInkSoft : tone, height: 4), ), ] else ...[ const SizedBox(height: 8), text("on this task", 12, TextType.Regular, color: colorInkSoft), ], ], ); } /// The cue while working; what is coming while resting. Widget _caption(RoutinePhase phase) { final RoutinePhase? next = _run.nextWork; String line = ""; if (phase.rest && next != null) { line = next.step.sets > 1 ? "Up next: ${next.step.title} · set ${next.set} of ${next.step.sets}" : "Up next: ${next.step.title}"; } else if (phase.step.cue.isNotEmpty) { line = phase.step.cue; } else if (next != null) { line = "Then: ${next.step.title}"; } return text(line, 13, TextType.Regular, color: colorInkSoft, align: TextAlign.center, maxLines: 2, height: 1.4); } Widget _transport(RoutinePhase phase, Color tone) { final bool countdown = phase.countsDown; return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ _roundControl( _haptics ? CupertinoIcons.speaker_2 : CupertinoIcons.speaker_slash, _onToggleHaptics, ), _roundControl(CupertinoIcons.backward_end_fill, _onPrevious, enabled: _run.phaseIndex > 0), GestureDetector( onTap: _onPrimary, child: AnimatedContainer( duration: const Duration(milliseconds: 200), width: 116, height: 58, alignment: Alignment.center, decoration: BoxDecoration( color: colorNight, borderRadius: BorderRadius.zero, boxShadow: const [ BoxShadow( color: Color(0x331A1F1C), blurRadius: 18, offset: Offset(0, 8), ), ], ), child: countdown ? Icon( _run.running ? CupertinoIcons.pause_fill : CupertinoIcons.play_fill, size: 24, color: colorLime, ) : Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.check_rounded, size: 18, color: colorLime), const SizedBox(width: 6), text( phase.step.mode == StepMode.Reps ? "Set done" : "Done", 13, TextType.Medium, color: colorWhite, ), ], ), ), ), _roundControl(CupertinoIcons.forward_end_fill, _onNext), _roundControl(CupertinoIcons.list_number, _onOpenSteps), ], ), ); } Widget _roundControl(IconData icon, VoidCallback onTap, {bool enabled = true}) { return Opacity( opacity: enabled ? 1 : 0.4, child: glassCircle(icon, enabled ? onTap : _onDisabled, size: 48), ); } void _onDisabled() {} /// Every step with its state. Tap one to jump there. Widget _stepsSheet(BuildContext sheetContext) { final Color tone = kindColor(_run.chain.kind); return Container( constraints: BoxConstraints( maxHeight: MediaQuery.of(sheetContext).size.height * 0.7, ), decoration: const BoxDecoration( color: colorSheetBackground, borderRadius: BorderRadius.only( topLeft: Radius.zero, topRight: Radius.zero, ), ), child: ListView( shrinkWrap: true, padding: const EdgeInsets.fromLTRB(24, 24, 24, 32), children: [ text("THE CHAIN", 9, TextType.Bold, color: tone, letterSpacing: 1.2), const SizedBox(height: 8), text(_run.chain.title, 24, TextType.Light, color: colorPrimaryDark), const SizedBox(height: 18), ..._run.chain.steps.asMap().entries.map((entry) { final int index = entry.key; final RoutineStep step = entry.value; final bool current = index == _run.phase.stepIndex; return GestureDetector( onTap: () { Navigator.pop(sheetContext); _onJumpTo(index); }, behavior: HitTestBehavior.opaque, child: Container( margin: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.fromLTRB(12, 12, 14, 12), decoration: BoxDecoration( color: current ? kindBackground(_run.chain.kind) : colorWhite, borderRadius: BorderRadius.zero, border: Border.all( color: current ? tone.withValues(alpha: 0.35) : colorBorder, ), ), child: Row( children: [ Container( width: 28, height: 28, alignment: Alignment.center, decoration: BoxDecoration( color: step.completed ? tone : colorSecondary, ), child: step.completed ? Icon(Icons.check_rounded, size: 16, color: colorWhite) : text("${index + 1}", 11, TextType.Bold, color: colorGrey2), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ text(step.title, 14, TextType.Medium, color: colorPrimaryDark), const SizedBox(height: 3), text(stepSummary(step), 11, TextType.Regular, color: colorGrey2), ], ), ), if (current) pill("Now", tone, colorWhite, textSize: 9), ], ), ), ); }), ], ), ); } /// End of run. Held or not, it is stated plainly and then logged. Widget _summaryView() { final bool held = _run.chain.complete; final int done = _run.chain.completedSteps; final int total = _run.chain.steps.length; final bool broken = !held && _run.chain.partialCompletion < Thresholds.routinePartialFloor; return AmbientBackground( child: Stack( children: [ Positioned( top: 0, left: 0, right: 0, height: 420, child: ShaderMask( shaderCallback: (Rect bounds) => const LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Color(0xFF000000), Color(0x00000000)], stops: [0.5, 1.0], ).createShader(bounds), blendMode: BlendMode.dstIn, child: SceneArt( palette: scenePalette(_run.chain.kind), seed: sceneSeed(_run.chain.title), ), ), ), SafeArea( child: Padding( padding: const EdgeInsets.fromLTRB(24, 20, 24, 24), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( children: [ GlassSurface( onDark: true, radius: BorderRadius.zero, padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 7), child: text( held ? "Chain held" : (broken ? "Chain broken" : "Partial"), 12, TextType.Medium, color: colorWhite, ), ), ], ), const Spacer(), Center( child: ProgressRing( fraction: _run.chain.partialCompletion, size: 190, stroke: 6, fill: held ? colorLime : colorWhite, track: colorWhite.withValues(alpha: 0.3), child: GlassSurface( onDark: true, radius: BorderRadius.zero, child: SizedBox( width: 158, height: 158, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ text("$done/$total", 42, TextType.Light, color: colorWhite), text("steps", 12, TextType.Regular, color: colorWhite.withValues(alpha: 0.8)), ], ), ), ), ), ), const SizedBox(height: 36), text(_run.chain.title, 32, TextType.Medium, color: colorInk, height: 1.1, letterSpacing: -0.6), const SizedBox(height: 8), text( held ? "Every step, in order. That is the whole point." : "$done of $total steps. This goes in as partial — honest, not hidden.", 14, TextType.Regular, color: colorInkSoft, height: 1.5, ), const SizedBox(height: 22), GlassSurface( radius: BorderRadius.zero, tint: colorGlassStrong, padding: const EdgeInsets.fromLTRB(18, 16, 18, 16), child: Row( children: [ Expanded( child: labelled( "Time", formatClock(_run.elapsedSeconds()), valueSize: 22, valueType: TextType.Light), ), Expanded( child: labelled("Skipped", "${_run.skippedCount}", valueSize: 22, valueType: TextType.Light), ), Expanded( child: labelled( "Left app", "${_run.backgroundedCount}×", valueSize: 22, valueType: TextType.Light, valueColor: _run.backgroundedCount > 0 ? colorWarning : colorInk), ), ], ), ), const Spacer(), roundedCornerButton( "Log it", _onLog, icon: CupertinoIcons.checkmark, ), ], ), ), ), ], ), ); } // ── ConnectRoutineRunner ────────────────────────────────────────────────── @override void onLogged(bool held) { Navigator.pop(context, true); } @override void dispose() { _ticker?.cancel(); _breath.dispose(); _carousel.dispose(); WidgetsBinding.instance.removeObserver(this); _model?.clearRun(); super.dispose(); } }