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/Component.dart'; import '../../designs/ProgressRing.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/Thresholds.dart'; import 'ConnectRoutineRunner.dart'; import 'RoutineRunner.dart'; import 'ViewRoutineRunner.dart'; /// The routine player. A coloured stage carries the phase — a ring that /// drains for timed work, a breathing circle for meditation — and the sheet /// beneath it carries the chain: where you are, what is next, and the 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 = 76; /// 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: kindDeep(_run.chain.kind), 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; } final double viewport = _carousel.position.viewportDimension; final double target = (_run.phase.stepIndex * _carouselItem) - (viewport / 2) + (_carouselItem / 2); _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 Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Expanded(flex: 5, child: _stage()), Expanded(flex: 6, child: _panel()), ], ); } /// The top half: kind gradient, chrome controls and the phase visual. Widget _stage() { final RoutineKind kind = _run.chain.kind; return Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ _run.phase.rest ? colorPrimaryDark2 : kindColor(kind), kindDeep(kind), ], ), ), child: SafeArea( bottom: false, child: Padding( padding: const EdgeInsets.fromLTRB(16, 4, 16, 18), child: Column( children: [ Row( children: [ chromeAction(CupertinoIcons.xmark, _onRequestExit), Expanded(child: Center(child: _totalPill())), chromeAction(CupertinoIcons.list_bullet, _onOpenSteps), ], ), 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 Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9), decoration: BoxDecoration( color: colorWhite.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(999), ), 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.45), shape: BoxShape.circle, ), ), text("~${formatClock(_run.plannedRemaining())} left", 12, TextType.Regular, color: colorWhite.withValues(alpha: 0.6)), ], ), ); } Widget _phaseRing() { final RoutinePhase phase = _run.phase; return Stack( alignment: Alignment.center, children: [ Container( width: 236, height: 236, decoration: BoxDecoration( shape: BoxShape.circle, color: colorWhite.withValues(alpha: 0.04), ), ), ProgressRing( fraction: phase.countsDown ? _run.phaseProgress() : 0, size: 208, stroke: 4, fill: colorWhite, track: colorWhite.withValues(alpha: 0.12), child: Container( width: 166, height: 166, alignment: Alignment.center, decoration: BoxDecoration( shape: BoxShape.circle, color: colorWhite.withValues(alpha: 0.08), ), child: phase.rest ? Column( mainAxisSize: MainAxisSize.min, children: [ Icon(CupertinoIcons.wind, size: 34, color: colorWhite.withValues(alpha: 0.8)), const SizedBox(height: 8), text("REST", 11, TextType.Bold, color: colorWhite.withValues(alpha: 0.7), letterSpacing: 2), ], ) : Icon( phase.step.mode == StepMode.Check ? CupertinoIcons.checkmark_alt : kindIcon(_run.chain.kind), size: 64, color: colorWhite, ), ), ), ], ); } 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 Stack( alignment: Alignment.center, children: [ Container( width: 150 + 90 * t, height: 150 + 90 * t, decoration: BoxDecoration( shape: BoxShape.circle, color: colorWhite.withValues(alpha: 0.05), ), ), Container( width: 130 + 60 * t, height: 130 + 60 * t, decoration: BoxDecoration( shape: BoxShape.circle, color: colorWhite.withValues(alpha: 0.10), border: Border.all( color: colorWhite.withValues(alpha: 0.25), width: 1), ), ), text( !_run.running ? "Paused" : inhaling ? "Breathe in" : "Breathe out", 17, TextType.Light, color: colorWhite, ), ], ); }, ); } Widget _stageStatus() { final String status = !_run.running ? "PAUSED" : _run.phase.rest ? "RESTING" : "IN PROGRESS"; return Row( children: [ Container( width: 7, height: 7, decoration: BoxDecoration( color: _run.running ? colorWhite : colorWarning, shape: BoxShape.circle, ), ), const SizedBox(width: 8), text(status, 9, TextType.Bold, color: colorWhite.withValues(alpha: 0.6), letterSpacing: 1.4), if (_run.backgroundedCount > 0) ...[ const SizedBox(width: 10), pill("Left ${_run.backgroundedCount}×", colorWarning, colorWarning.withValues(alpha: 0.14), textSize: 9), ], const Spacer(), GestureDetector( onTap: _onTogglePause, child: pill( _run.running ? "Pause" : "Resume", colorWhite, colorWhite.withValues(alpha: 0.12), icon: _run.running ? CupertinoIcons.pause_fill : CupertinoIcons.play_fill, textSize: 9, ), ), ], ); } /// The bottom sheet: chain carousel, step counter, clock and transport. Widget _panel() { final RoutinePhase phase = _run.phase; final Color tone = kindColor(_run.chain.kind); return Container( decoration: BoxDecoration( color: colorPrimaryLight, borderRadius: BorderRadius.only( topLeft: Radius.circular(28), topRight: Radius.circular(28), ), ), child: SafeArea( top: false, child: Column( children: [ const SizedBox(height: 14), _stepCarousel(), const SizedBox(height: 4), 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}", 10, TextType.Bold, color: colorGrey2, letterSpacing: 1.4, ), const SizedBox(height: 6), text( phase.rest ? "Rest" : phase.step.title, 24, TextType.Light, color: colorPrimaryDark, 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: 84, child: ListView.builder( controller: _carousel, scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 12), itemCount: steps.length, itemExtent: _carouselItem, itemBuilder: (context, index) => _carouselStep(index, steps[index]), ), ); } Widget _carouselStep(int index, RoutineStep step) { final bool current = index == _run.phase.stepIndex; final bool done = step.completed; final Color tone = kindColor(_run.chain.kind); final double size = current ? 50 : 40; return GestureDetector( onTap: () => _onJumpTo(index), behavior: HitTestBehavior.opaque, child: Column( mainAxisAlignment: MainAxisAlignment.start, children: [ AnimatedContainer( duration: const Duration(milliseconds: 250), curve: Curves.easeOutCubic, margin: EdgeInsets.only(top: current ? 0 : 6), width: size, height: size, alignment: Alignment.center, decoration: BoxDecoration( shape: BoxShape.circle, gradient: current ? LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [tone, kindDeep(_run.chain.kind)], ) : null, color: current ? null : (done ? tone.withValues(alpha: 0.12) : colorWhite), border: Border.all( color: current ? colorWhite : done ? tone.withValues(alpha: 0.3) : colorBorder, width: current ? 3 : 1, ), boxShadow: current ? [ BoxShadow( color: tone.withValues(alpha: 0.25), blurRadius: 12, offset: const Offset(0, 4), ), ] : null, ), child: done && !current ? Icon(Icons.check_rounded, size: 18, color: tone) : text("${index + 1}", current ? 15 : 12, TextType.Bold, color: current ? colorWhite : colorGrey2), ), const SizedBox(height: 6), text( step.title, 10, current ? TextType.Bold : TextType.Regular, color: current ? colorPrimaryDark : colorGrey2, maxLines: 1, overflow: TextOverflow.ellipsis, align: TextAlign.center, ), ], ), ); } Widget _setDots(RoutinePhase phase, Color tone) { return Row( mainAxisSize: MainAxisSize.min, children: [ text("SET ${phase.set} / ${phase.step.sets}", 9, TextType.Bold, color: tone, letterSpacing: 1.0), 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 ? 18 : 7, height: 7, margin: const EdgeInsets.only(right: 4), decoration: BoxDecoration( color: filled || active ? tone : colorMuted, borderRadius: BorderRadius.circular(999), ), ); }), ], ); } Widget _clock(RoutinePhase phase, Color tone) { final String label; final String value; if (phase.countsDown) { label = phase.rest ? "REST REMAINING" : "REMAINING"; value = formatClock(_run.phaseRemaining()); } else if (phase.step.mode == StepMode.Reps) { label = "REPS · ${formatClock(_run.phaseElapsed())}"; value = "× ${phase.step.reps}"; } else { label = "ON THIS TASK"; value = formatClock(_run.phaseElapsed()); } return Column( mainAxisSize: MainAxisSize.min, children: [ text(label, 9, TextType.Bold, color: colorGrey2, letterSpacing: 1.6), const SizedBox(height: 4), text(value, 64, TextType.Light, color: _run.running ? colorPrimaryDark : colorGrey2, height: 1.0), if (phase.countsDown) ...[ const SizedBox(height: 12), SizedBox( width: 180, child: meter(_run.phaseProgress(), fill: phase.rest ? colorGrey2 : tone, height: 4), ), ], ], ); } /// 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: colorGrey2, 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.waveform : CupertinoIcons.waveform_path_badge_minus, _onToggleHaptics, ), _roundControl(CupertinoIcons.backward_end_fill, _onPrevious, enabled: _run.phaseIndex > 0), GestureDetector( onTap: _onPrimary, child: AnimatedContainer( duration: const Duration(milliseconds: 200), width: 118, height: 56, alignment: Alignment.center, decoration: BoxDecoration( color: colorPrimaryDark, borderRadius: BorderRadius.circular(999), ), child: countdown ? Icon( _run.running ? CupertinoIcons.pause_fill : CupertinoIcons.play_fill, size: 22, color: colorKindWorkoutBg, ) : Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.check_rounded, size: 18, color: colorKindWorkoutBg), const SizedBox(width: 6), text( phase.step.mode == StepMode.Reps ? "Set done" : "Done", 13, TextType.Bold, color: colorWhite, ), ], ), ), ), _roundControl(CupertinoIcons.forward_end_fill, _onNext), _roundControl(CupertinoIcons.list_number, _onOpenSteps), ], ), ); } Widget _roundControl(IconData icon, VoidCallback onTap, {bool enabled = true}) { return GestureDetector( onTap: enabled ? onTap : null, child: Container( width: 46, height: 46, alignment: Alignment.center, decoration: BoxDecoration( color: colorWhite, shape: BoxShape.circle, border: Border.all(color: colorBorder), ), child: Icon(icon, size: 17, color: enabled ? colorPrimaryDark : colorGrey), ), ); } /// 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.circular(28), topRight: Radius.circular(28), ), ), 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.circular(14), 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, shape: BoxShape.circle, ), 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 RoutineKind kind = _run.chain.kind; 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; final Color label = colorWhite.withValues(alpha: 0.5); return Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [kindColor(kind), kindDeep(kind)], ), ), child: SafeArea( child: Padding( padding: const EdgeInsets.fromLTRB(28, 20, 28, 24), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ text( held ? "CHAIN HELD" : (broken ? "CHAIN BROKEN" : "PARTIAL"), 10, TextType.Bold, color: label, letterSpacing: 1.6, ), const SizedBox(height: 12), text(_run.chain.title, 36, TextType.Light, color: colorWhite, height: 1.1), const SizedBox(height: 14), 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: colorWhite.withValues(alpha: 0.7), height: 1.5, ), const Spacer(), Center( child: ProgressRing( fraction: _run.chain.partialCompletion, size: 180, stroke: 5, fill: colorWhite, track: colorWhite.withValues(alpha: 0.14), child: Column( mainAxisSize: MainAxisSize.min, children: [ text("$done/$total", 40, TextType.Light, color: colorWhite), text("STEPS", 9, TextType.Bold, color: label, letterSpacing: 1.4), ], ), ), ), const Spacer(), Row( children: [ Expanded( child: labelled("Time", formatClock(_run.elapsedSeconds()), valueSize: 22, valueType: TextType.Light, valueColor: colorWhite, labelColor: label), ), Expanded( child: labelled("Skipped", "${_run.skippedCount}", valueSize: 22, valueType: TextType.Light, valueColor: colorWhite, labelColor: label), ), Expanded( child: labelled("Left app", "${_run.backgroundedCount}×", valueSize: 22, valueType: TextType.Light, valueColor: _run.backgroundedCount > 0 ? colorWarning : colorWhite, labelColor: label), ), ], ), const SizedBox(height: 28), roundedCornerButton( "Log it", _onLog, background: colorWhite, foreground: colorPrimaryDark, 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(); } }