Files
GroundedHelper/frontend/lib/Grounded/see/routine/RoutineRunnerState.dart
alvocool 1a5867a98f Add routines module and full goal management
Routines: workout, meditation and must-do routines with timed, rep and
tick steps, sets and rest. A library screen, a builder with templates
and drag-to-reorder steps, and a full-screen player that auto-advances
timed phases, counts backgrounding and logs broken chains as partial.

Goals: a goal editor (deadline, colour, category, default class, archive),
routines linked to goals, and a goal field on tasks and routines. Tasks
added from inside a goal are now actually attached to it via parentId.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KhQh614DMeYoQEH9SoMRUw
2026-11-02 08:45:41 +03:00

1101 lines
34 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<RoutineRunner>
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<ViewRoutineRunner>.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<SystemUiOverlayStyle>(
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: <Color>[
_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<RoutineStep> 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: <Color>[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<Widget>.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: <Color>[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();
}
}