Files
GroundedHelper/frontend/lib/Grounded/see/routine/RoutineRunnerState.dart
alvocool 9c858b8146 Square every corner, enlarge the main surfaces and add a to-do list
Rounded rectangles and round controls are now square across the app; only
status dots, glows, the sun, rings and the breathing circle stay round.
Cards, rows, thumbnails, featured cards, category tiles, the standing card
and the nav are larger. Home gets a quick To-do list kept on the device
(TodoItem in InternalMemory), seeded with sample items in mock mode.

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

1187 lines
37 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/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<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 = 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<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: 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>[Color(0xFF000000), Color(0x00000000)],
stops: <double>[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>[
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<RoutineStep> 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<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 ? 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>[Color(0xFF000000), Color(0x00000000)],
stops: <double>[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();
}
}