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
1101 lines
34 KiB
Dart
1101 lines
34 KiB
Dart
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();
|
||
}
|
||
}
|