diff --git a/frontend/lib/Grounded/about/external/data/RoutineChain.dart b/frontend/lib/Grounded/about/external/data/RoutineChain.dart index 14cb5e4..7549edf 100644 --- a/frontend/lib/Grounded/about/external/data/RoutineChain.dart +++ b/frontend/lib/Grounded/about/external/data/RoutineChain.dart @@ -1,3 +1,9 @@ +import '../../internal/application/CommitmentClass.dart'; +import '../../internal/application/RoutineKind.dart'; +import '../../internal/application/StepMode.dart'; +import '../../../utils/ObjectConvertors.dart'; +import 'RoutineStep.dart'; + /// An ordered sequence where the chain, not the step, is the unit of /// completion. Breaking mid-way logs partial. class RoutineChain { @@ -5,6 +11,24 @@ class RoutineChain { String title; + /// The goal this routine serves — "Workout" holds "Full body strength". + /// Null for a routine that stands on its own. + String? goalId; + + RoutineKind kind; + + /// Weight class, exactly as for a commitment — a non-negotiable morning + /// routine carries non-negotiable debt when it breaks. + CommitmentClass commitmentClass; + + /// 1..7, Monday first. Empty means unscheduled — run on demand only. + List days; + + /// Minutes after midnight the routine is due to start; -1 when unset. + int startMinute; + + String note; + List steps; /// Index of the step reached when the chain last broke; -1 when clean. @@ -13,19 +37,30 @@ class RoutineChain { RoutineChain({ this.id, this.title = "", + this.goalId, + this.kind = RoutineKind.Workout, + this.commitmentClass = CommitmentClass.Standard, + List? days, + this.startMinute = -1, + this.note = "", List? steps, this.lastBreakIndex = -1, - }) : steps = steps ?? []; + }) : days = days ?? [], + steps = steps ?? []; factory RoutineChain.fromJson(Map json) { return RoutineChain( id: json['id'], title: json['title'] ?? "", - steps: json['steps'] == null - ? [] - : (json['steps'] as List) - .map((item) => RoutineStep.fromJson(item)) - .toList(), + goalId: json['goalId'], + kind: getRoutineKind(json['kind']), + commitmentClass: getCommitmentClass(json['commitmentClass']), + days: json['days'] == null + ? [] + : (json['days'] as List).map((item) => item as int).toList(), + startMinute: json['startMinute'] ?? -1, + note: json['note'] ?? "", + steps: getRoutineStepList(json['steps']), lastBreakIndex: json['lastBreakIndex'] ?? -1, ); } @@ -34,6 +69,12 @@ class RoutineChain { final Map data = {}; data['id'] = id; data['title'] = title; + data['goalId'] = goalId; + data['kind'] = kind.name; + data['commitmentClass'] = commitmentClass.name; + data['days'] = days; + data['startMinute'] = startMinute; + data['note'] = note; data['steps'] = steps.map((item) => item.toJson()).toList(); data['lastBreakIndex'] = lastBreakIndex; return data; @@ -54,44 +95,40 @@ class RoutineChain { bool get complete { return steps.isNotEmpty && completedSteps == steps.length; } -} -class RoutineStep { - String? id; - - String title; - - int order; - - int timerSeconds; - - bool completed; - - RoutineStep({ - this.id, - this.title = "", - this.order = 0, - this.timerSeconds = 0, - this.completed = false, - }); - - factory RoutineStep.fromJson(Map json) { - return RoutineStep( - id: json['id'], - title: json['title'] ?? "", - order: json['order'] ?? 0, - timerSeconds: json['timerSeconds'] ?? 0, - completed: json['completed'] ?? false, - ); + bool get scheduled { + return days.isNotEmpty; } - Map toJson() { - final Map data = {}; - data['id'] = id; - data['title'] = title; - data['order'] = order; - data['timerSeconds'] = timerSeconds; - data['completed'] = completed; - return data; + bool scheduledOn(DateTime day) { + return days.contains(day.weekday); + } + + /// Every set of every step, for the "12 sets" figure. + int get totalSets { + int total = 0; + for (RoutineStep step in steps) { + total = total + step.sets; + } + return total; + } + + /// Planned seconds, rest included. Rep and tick steps count their estimate. + int get totalSeconds { + int total = 0; + for (RoutineStep step in steps) { + total = total + step.plannedSeconds; + } + return total; + } + + int get timedSteps { + return steps.where((step) => step.mode == StepMode.Timed).length; + } + + /// A deep copy for the builder, so cancelling an edit leaves the library + /// untouched. + RoutineChain copy() { + return RoutineChain.fromJson(toJson()); } } diff --git a/frontend/lib/Grounded/about/external/data/RoutineStep.dart b/frontend/lib/Grounded/about/external/data/RoutineStep.dart new file mode 100644 index 0000000..aaba98b --- /dev/null +++ b/frontend/lib/Grounded/about/external/data/RoutineStep.dart @@ -0,0 +1,75 @@ +import '../../internal/application/StepMode.dart'; + +class RoutineStep { + String? id; + + String title; + + int order; + + StepMode mode; + + /// Work seconds per set for timed steps; the estimate per set otherwise. + int timerSeconds; + + int sets; + + /// Target per set for rep steps; 0 otherwise. + int reps; + + /// Rest between sets, and after the last set before the next step. + int restSeconds; + + /// One line shown under the clock — a form cue, a breathing pattern. + String cue; + + bool completed; + + RoutineStep({ + this.id, + this.title = "", + this.order = 0, + this.mode = StepMode.Timed, + this.timerSeconds = 0, + this.sets = 1, + this.reps = 0, + this.restSeconds = 0, + this.cue = "", + this.completed = false, + }); + + factory RoutineStep.fromJson(Map json) { + return RoutineStep( + id: json['id'], + title: json['title'] ?? "", + order: json['order'] ?? 0, + mode: getStepMode(json['mode']), + timerSeconds: json['timerSeconds'] ?? 0, + sets: json['sets'] ?? 1, + reps: json['reps'] ?? 0, + restSeconds: json['restSeconds'] ?? 0, + cue: json['cue'] ?? "", + completed: json['completed'] ?? false, + ); + } + + Map toJson() { + final Map data = {}; + data['id'] = id; + data['title'] = title; + data['order'] = order; + data['mode'] = mode.name; + data['timerSeconds'] = timerSeconds; + data['sets'] = sets; + data['reps'] = reps; + data['restSeconds'] = restSeconds; + data['cue'] = cue; + data['completed'] = completed; + return data; + } + + int get plannedSeconds { + final int safeSets = sets < 1 ? 1 : sets; + return timerSeconds * safeSets + restSeconds * safeSets; + } +} diff --git a/frontend/lib/Grounded/about/external/initial/CommitmentRequest.dart b/frontend/lib/Grounded/about/external/initial/CommitmentRequest.dart index d112505..85a27b1 100644 --- a/frontend/lib/Grounded/about/external/initial/CommitmentRequest.dart +++ b/frontend/lib/Grounded/about/external/initial/CommitmentRequest.dart @@ -2,6 +2,9 @@ class CommitmentRequest { String? id; + /// The goal this task belongs to; null for a loose task. + String? parentId; + String type; String commitmentClass; @@ -26,6 +29,7 @@ class CommitmentRequest { CommitmentRequest({ this.id, + this.parentId, this.type = "TASK", this.commitmentClass = "Standard", this.title = "", @@ -42,6 +46,7 @@ class CommitmentRequest { Map toJson() { final Map data = {}; data['id'] = id; + data['parentId'] = parentId; data['type'] = type; data['commitmentClass'] = commitmentClass; data['title'] = title; diff --git a/frontend/lib/Grounded/about/external/initial/RoutineLogRequest.dart b/frontend/lib/Grounded/about/external/initial/RoutineLogRequest.dart new file mode 100644 index 0000000..6b95ed1 --- /dev/null +++ b/frontend/lib/Grounded/about/external/initial/RoutineLogRequest.dart @@ -0,0 +1,45 @@ +/// One run of a routine. Sent whether the chain held or broke — a broken +/// chain is logged as partial, never silently dropped. +class RoutineLogRequest { + String routineId; + + String startedAt; + + /// Foreground seconds only. Backgrounding pauses the run. + int elapsedSeconds; + + int completedSteps; + + int totalSteps; + + /// Step index the chain broke at; -1 when it held. + int breakIndex; + + int backgroundedCount; + + int skippedCount; + + RoutineLogRequest({ + this.routineId = "", + this.startedAt = "", + this.elapsedSeconds = 0, + this.completedSteps = 0, + this.totalSteps = 0, + this.breakIndex = -1, + this.backgroundedCount = 0, + this.skippedCount = 0, + }); + + Map toJson() { + final Map data = {}; + data['routineId'] = routineId; + data['startedAt'] = startedAt; + data['elapsedSeconds'] = elapsedSeconds; + data['completedSteps'] = completedSteps; + data['totalSteps'] = totalSteps; + data['breakIndex'] = breakIndex; + data['backgroundedCount'] = backgroundedCount; + data['skippedCount'] = skippedCount; + return data; + } +} diff --git a/frontend/lib/Grounded/about/internal/application/RoutineKind.dart b/frontend/lib/Grounded/about/internal/application/RoutineKind.dart new file mode 100644 index 0000000..9de936d --- /dev/null +++ b/frontend/lib/Grounded/about/internal/application/RoutineKind.dart @@ -0,0 +1,44 @@ +/// What a routine is for. The kind decides the runner's stage — a barbell +/// clock for work, a breathing circle for stillness, a checklist for the +/// things that simply have to get done. +enum RoutineKind { + /// Sets, reps and rest. Timed work auto-advances into rest. + Workout, + + /// Long timed holds with a breathing cue. No reps, rarely any rest. + Meditation, + + /// An ordered list of tasks that have to be done, in order, every time. + MustDo, +} + +String kindLabel(RoutineKind value) { + switch (value) { + case RoutineKind.Workout: + return "Workout"; + case RoutineKind.Meditation: + return "Meditation"; + case RoutineKind.MustDo: + return "Must-do"; + } +} + +String kindDescription(RoutineKind value) { + switch (value) { + case RoutineKind.Workout: + return "Sets, reps and timed rest."; + case RoutineKind.Meditation: + return "Timed sits with a breathing cue."; + case RoutineKind.MustDo: + return "Tasks, in order, every time."; + } +} + +RoutineKind getRoutineKind(String? name) { + for (RoutineKind value in RoutineKind.values) { + if (value.name == name) { + return value; + } + } + return RoutineKind.Workout; +} diff --git a/frontend/lib/Grounded/about/internal/application/RoutinePhase.dart b/frontend/lib/Grounded/about/internal/application/RoutinePhase.dart new file mode 100644 index 0000000..74e122f --- /dev/null +++ b/frontend/lib/Grounded/about/internal/application/RoutinePhase.dart @@ -0,0 +1,40 @@ +import '../../external/data/RoutineStep.dart'; +import 'StepMode.dart'; + +/// One beat of a running routine — a single work set or the rest after it. +/// The runner walks a flat list of these, so "set 2 of 4" and "rest" are the +/// same kind of thing to the clock. +class RoutinePhase { + /// Index of the step this phase belongs to. + final int stepIndex; + + final RoutineStep step; + + /// 1-based set number within the step. + final int set; + + final bool rest; + + RoutinePhase({ + required this.stepIndex, + required this.step, + this.set = 1, + this.rest = false, + }); + + /// Seconds the clock runs for. Zero means the phase waits for a tap. + int get seconds { + if (rest) { + return step.restSeconds; + } + return step.mode == StepMode.Timed ? step.timerSeconds : 0; + } + + bool get countsDown { + return seconds > 0; + } + + bool get lastSet { + return set >= step.sets; + } +} diff --git a/frontend/lib/Grounded/about/internal/application/RoutineRun.dart b/frontend/lib/Grounded/about/internal/application/RoutineRun.dart new file mode 100644 index 0000000..f81f557 --- /dev/null +++ b/frontend/lib/Grounded/about/internal/application/RoutineRun.dart @@ -0,0 +1,212 @@ +import '../../external/data/RoutineChain.dart'; +import '../../external/data/RoutineStep.dart'; +import 'RoutinePhase.dart'; + +/// A routine being run. Like [LiveSession], every clock is derived from +/// wall-clock rather than counted by a ticker, so a screen-off period cannot +/// lose or invent time. +class RoutineRun { + final RoutineChain chain; + + final List phases; + + int phaseIndex = 0; + + final DateTime startedAt; + + /// Banked foreground seconds for the whole run. + int _runSeconds = 0; + + /// Banked foreground seconds for the current phase. + int _phaseSeconds = 0; + + /// Start of the active stretch; null while paused. + DateTime? resumedAt; + + /// Times the app was left mid-run. Shown afterwards, never hidden. + int backgroundedCount = 0; + + /// Phases skipped forward without being done. + int skippedCount = 0; + + RoutineRun(this.chain) + : phases = buildPhases(chain), + startedAt = DateTime.now(); + + /// Each set becomes a work phase; rest follows every set that has rest, + /// except after the final set of the final step. + static List buildPhases(RoutineChain chain) { + final List result = []; + + for (int index = 0; index < chain.steps.length; index++) { + final RoutineStep step = chain.steps[index]; + final int sets = step.sets < 1 ? 1 : step.sets; + + for (int set = 1; set <= sets; set++) { + result.add(RoutinePhase(stepIndex: index, step: step, set: set)); + + final bool finalBeat = index == chain.steps.length - 1 && set == sets; + if (step.restSeconds > 0 && !finalBeat) { + result.add( + RoutinePhase(stepIndex: index, step: step, set: set, rest: true)); + } + } + } + + return result; + } + + RoutinePhase get phase { + return phases[phaseIndex]; + } + + bool get running { + return resumedAt != null; + } + + bool get onLastPhase { + return phaseIndex >= phases.length - 1; + } + + int _live(DateTime? now) { + if (resumedAt == null) { + return 0; + } + return (now ?? DateTime.now()).difference(resumedAt!).inSeconds; + } + + int elapsedSeconds({DateTime? now}) { + return _runSeconds + _live(now); + } + + int phaseElapsed({DateTime? now}) { + return _phaseSeconds + _live(now); + } + + int phaseRemaining({DateTime? now}) { + final int remaining = phase.seconds - phaseElapsed(now: now); + return remaining > 0 ? remaining : 0; + } + + /// 0..1 through the current phase; 0 for phases that wait for a tap. + double phaseProgress({DateTime? now}) { + if (!phase.countsDown) { + return 0; + } + final double value = phaseElapsed(now: now) / phase.seconds; + return value > 1 ? 1 : value; + } + + bool phaseFinished({DateTime? now}) { + return phase.countsDown && phaseElapsed(now: now) >= phase.seconds; + } + + /// Seconds left in the whole run, by plan — elapsed work in this phase + /// counts, phases still ahead count at their planned length. + int plannedRemaining({DateTime? now}) { + int total = phase.countsDown ? phaseRemaining(now: now) : 0; + for (int index = phaseIndex + 1; index < phases.length; index++) { + total = total + phases[index].seconds; + } + return total; + } + + void pause({DateTime? now}) { + if (resumedAt == null) { + return; + } + final int live = _live(now); + _runSeconds = _runSeconds + live; + _phaseSeconds = _phaseSeconds + live; + resumedAt = null; + } + + void resume({DateTime? now}) { + if (resumedAt != null) { + return; + } + resumedAt = now ?? DateTime.now(); + } + + /// Bank the current phase and start the next. Returns false at the end. + /// A step is marked complete when its last set's work phase is done. + bool advance({bool skipped = false, DateTime? now}) { + final DateTime moment = now ?? DateTime.now(); + final bool wasRunning = running; + + if (!phase.rest && phase.lastSet && !skipped) { + phase.step.completed = true; + } + if (skipped && !phase.rest) { + skippedCount = skippedCount + 1; + } + + pause(now: moment); + _phaseSeconds = 0; + + if (onLastPhase) { + return false; + } + + phaseIndex = phaseIndex + 1; + if (wasRunning) { + resume(now: moment); + } + return true; + } + + /// Back to the start of the previous work phase. Rest is never a target. + void back({DateTime? now}) { + final DateTime moment = now ?? DateTime.now(); + final bool wasRunning = running; + + pause(now: moment); + _phaseSeconds = 0; + + int target = phaseIndex - 1; + while (target > 0 && phases[target].rest) { + target = target - 1; + } + phaseIndex = target < 0 ? 0 : target; + phases[phaseIndex].step.completed = false; + + if (wasRunning) { + resume(now: moment); + } + } + + /// Jump to the first set of [stepIndex]. + void jumpTo(int stepIndex, {DateTime? now}) { + final int target = + phases.indexWhere((item) => item.stepIndex == stepIndex && !item.rest); + if (target < 0) { + return; + } + + final DateTime moment = now ?? DateTime.now(); + final bool wasRunning = running; + + pause(now: moment); + _phaseSeconds = 0; + phaseIndex = target; + + if (wasRunning) { + resume(now: moment); + } + } + + /// The step index the chain broke at, for a partial log; -1 when clean. + int get breakIndex { + return chain.complete ? -1 : phase.stepIndex; + } + + /// The next work phase after this one, for the "up next" line. + RoutinePhase? get nextWork { + for (int index = phaseIndex + 1; index < phases.length; index++) { + if (!phases[index].rest) { + return phases[index]; + } + } + return null; + } +} diff --git a/frontend/lib/Grounded/about/internal/application/StepMode.dart b/frontend/lib/Grounded/about/internal/application/StepMode.dart new file mode 100644 index 0000000..c6363ee --- /dev/null +++ b/frontend/lib/Grounded/about/internal/application/StepMode.dart @@ -0,0 +1,31 @@ +/// How a single routine step is satisfied. +enum StepMode { + /// A clock runs down and the step advances on its own. + Timed, + + /// A rep target per set; the set ends when you say it does. + Reps, + + /// A task. Ticked, not timed — the clock still records how long it took. + Check, +} + +String stepModeLabel(StepMode value) { + switch (value) { + case StepMode.Timed: + return "Timed"; + case StepMode.Reps: + return "Reps"; + case StepMode.Check: + return "Tick"; + } +} + +StepMode getStepMode(String? name) { + for (StepMode value in StepMode.values) { + if (value.name == name) { + return value; + } + } + return StepMode.Timed; +} diff --git a/frontend/lib/Grounded/comms/Comms.dart b/frontend/lib/Grounded/comms/Comms.dart index 17d3775..46b17e6 100644 --- a/frontend/lib/Grounded/comms/Comms.dart +++ b/frontend/lib/Grounded/comms/Comms.dart @@ -388,6 +388,13 @@ class Comms implements ConnectComms { return await dio.post(navigation.key, data: request); } + @override + Future saveRoutineEntry(Map request) async { + Pair navigation = await getRequestHeaders(saveRoutine, ""); + dio.options.headers = navigation.value; + return await dio.post(navigation.key, data: request); + } + @override Future logRoutineChainEntry(Map request) async { Pair navigation = await getRequestHeaders(logRoutineChain, ""); diff --git a/frontend/lib/Grounded/comms/ConnectComms.dart b/frontend/lib/Grounded/comms/ConnectComms.dart index 950d4ad..3621500 100644 --- a/frontend/lib/Grounded/comms/ConnectComms.dart +++ b/frontend/lib/Grounded/comms/ConnectComms.dart @@ -108,6 +108,8 @@ abstract class ConnectComms { Future getMyRoutines(HistoryRequest request); + Future saveRoutineEntry(Map request); + Future logRoutineChainEntry(Map request); // ── Training ──────────────────────────────────────────────────────────── diff --git a/frontend/lib/Grounded/designs/GoalPicker.dart b/frontend/lib/Grounded/designs/GoalPicker.dart new file mode 100644 index 0000000..552125c --- /dev/null +++ b/frontend/lib/Grounded/designs/GoalPicker.dart @@ -0,0 +1,127 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +import '../about/external/data/Goal.dart'; +import '../about/internal/application/TextType.dart'; +import '../utils/Colors.dart'; +import '../utils/CommonUtils.dart'; +import 'text/Text.dart'; + +/// The goal chooser, shared by the task form and the routine builder. Opened +/// only once the goals have been loaded — `selectField` loads on tap. +/// +/// Resolves to the chosen goal, to a bare `Goal()` (no id) for "no goal", or +/// to null when dismissed without a choice. +Future showGoalPicker( + BuildContext context, + List goals, { + String? selectedId, +}) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + barrierColor: colorBlack.withValues(alpha: 0.55), + builder: (BuildContext sheetContext) { + final List active = goals.where((goal) => !goal.archived).toList(); + + return Container( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(sheetContext).size.height * 0.75, + ), + 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("BELONGS TO", 9, TextType.Bold, + color: colorGrey2, letterSpacing: 1.2), + const SizedBox(height: 8), + text("Which goal is this for?", 24, TextType.Light, + color: colorPrimaryDark), + const SizedBox(height: 18), + _goalOption( + sheetContext, + Goal(), + "No goal", + "Stands on its own", + colorGrey, + selectedId == null, + ), + ...active.map((goal) => _goalOption( + sheetContext, + goal, + goal.title, + goal.targetDate == null + ? "${goal.remainingTasks} open" + : "${goal.remainingTasks} open · ${targetLabel(goal.targetDate)}", + goalColor(goal.colourHex), + goal.id == selectedId, + )), + if (active.isEmpty) ...[ + const SizedBox(height: 8), + text("No goals yet. Create one from the Goals screen.", 12, + TextType.Regular, + color: colorGrey2), + ], + ], + ), + ); + }, + ); +} + +Widget _goalOption( + BuildContext sheetContext, + Goal goal, + String title, + String caption, + Color accent, + bool selected, +) { + return GestureDetector( + onTap: () => Navigator.pop(sheetContext, goal), + behavior: HitTestBehavior.opaque, + child: Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.fromLTRB(14, 13, 14, 13), + decoration: BoxDecoration( + color: colorWhite, + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: selected ? accent.withValues(alpha: 0.5) : colorBorder, + width: selected ? 1.5 : 1, + ), + ), + child: Row( + children: [ + Container( + width: 12, + height: 12, + decoration: BoxDecoration(color: accent, shape: BoxShape.circle), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + text(title, 14, TextType.Medium, color: colorPrimaryDark), + const SizedBox(height: 3), + text(caption, 11, TextType.Regular, color: colorGrey2), + ], + ), + ), + if (selected) + Icon(CupertinoIcons.checkmark_alt, size: 17, color: accent), + ], + ), + ), + ); +} diff --git a/frontend/lib/Grounded/designs/ProgressRing.dart b/frontend/lib/Grounded/designs/ProgressRing.dart new file mode 100644 index 0000000..61a1d4f --- /dev/null +++ b/frontend/lib/Grounded/designs/ProgressRing.dart @@ -0,0 +1,116 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import '../about/internal/application/TextType.dart'; +import '../utils/Colors.dart'; +import 'text/Text.dart'; + +/// A thin circular meter with its value in the middle — the routine row's +/// "how far did I get" dial, and the runner's phase clock ring. +class ProgressRing extends StatelessWidget { + final double fraction; + + final double size; + + final double stroke; + + final Color? fill; + + final Color? track; + + /// Drawn in the centre. Defaults to the percentage. + final Widget? child; + + const ProgressRing({ + super.key, + required this.fraction, + this.size = 44, + this.stroke = 3, + this.fill, + this.track, + this.child, + }); + + @override + Widget build(BuildContext context) { + final double clamped = fraction.isNaN ? 0 : fraction.clamp(0, 1).toDouble(); + + return SizedBox( + width: size, + height: size, + child: TweenAnimationBuilder( + tween: Tween(end: clamped), + duration: const Duration(milliseconds: 450), + curve: Curves.easeOutCubic, + builder: (context, value, _) { + return CustomPaint( + painter: _RingPainter( + value, + stroke, + fill ?? colorPrimaryDark, + track ?? colorMuted, + ), + child: Center( + child: child ?? + text("${(clamped * 100).round()}%", size * 0.22, + TextType.Bold, + color: colorPrimaryDark), + ), + ); + }, + ), + ); + } +} + +class _RingPainter extends CustomPainter { + final double fraction; + + final double stroke; + + final Color fill; + + final Color track; + + _RingPainter(this.fraction, this.stroke, this.fill, this.track); + + @override + void paint(Canvas canvas, Size size) { + final Offset centre = Offset(size.width / 2, size.height / 2); + final double radius = (math.min(size.width, size.height) - stroke) / 2; + + final Paint base = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = stroke + ..color = track; + + canvas.drawCircle(centre, radius, base); + + if (fraction <= 0) { + return; + } + + final Paint arc = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = stroke + ..strokeCap = StrokeCap.round + ..color = fill; + + canvas.drawArc( + Rect.fromCircle(center: centre, radius: radius), + -math.pi / 2, + 2 * math.pi * fraction, + false, + arc, + ); + } + + @override + bool shouldRepaint(_RingPainter old) { + return old.fraction != fraction || + old.fill != fill || + old.track != track || + old.stroke != stroke; + } +} diff --git a/frontend/lib/Grounded/designs/input/CountStepper.dart b/frontend/lib/Grounded/designs/input/CountStepper.dart new file mode 100644 index 0000000..b9ae504 --- /dev/null +++ b/frontend/lib/Grounded/designs/input/CountStepper.dart @@ -0,0 +1,63 @@ +import 'package:flutter/cupertino.dart'; + +import '../../about/internal/application/TextType.dart'; +import '../../utils/Colors.dart'; +import '../text/Text.dart'; + +/// A labelled minus / value / plus control for sets, reps and seconds. +/// Numbers are stepped, not typed — nobody wants a keyboard mid-routine-edit. +Widget countStepper( + String label, + String value, { + required VoidCallback onMinus, + required VoidCallback onPlus, + bool canMinus = true, +}) { + return Container( + padding: const EdgeInsets.fromLTRB(14, 10, 8, 10), + decoration: BoxDecoration( + color: colorWhite, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: colorBorder, width: 1), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + text(label.toUpperCase(), 9, TextType.Bold, + color: colorGrey2, letterSpacing: 1.0), + const SizedBox(height: 4), + text(value, 17, TextType.Medium, color: colorPrimaryDark), + ], + ), + ), + _stepButton(CupertinoIcons.minus, canMinus ? onMinus : null), + const SizedBox(width: 6), + _stepButton(CupertinoIcons.plus, onPlus), + ], + ), + ); +} + +Widget _stepButton(IconData icon, VoidCallback? onTap) { + final bool enabled = onTap != null; + + return GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: Container( + width: 34, + height: 34, + alignment: Alignment.center, + decoration: BoxDecoration( + color: enabled ? colorSecondary : colorInset, + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, + size: 14, color: enabled ? colorPrimaryDark : colorGrey), + ), + ); +} diff --git a/frontend/lib/Grounded/informatics/AppDataManager.dart b/frontend/lib/Grounded/informatics/AppDataManager.dart index fdc9064..d8bc551 100644 --- a/frontend/lib/Grounded/informatics/AppDataManager.dart +++ b/frontend/lib/Grounded/informatics/AppDataManager.dart @@ -296,6 +296,10 @@ class AppDataManager implements DataManager { Future getMyRoutines(HistoryRequest request) => comms.getMyRoutines(request); + @override + Future saveRoutineEntry(Map request) => + comms.saveRoutineEntry(request); + @override Future logRoutineChainEntry(Map request) => comms.logRoutineChainEntry(request); diff --git a/frontend/lib/Grounded/see/commitment/ConnectNewCommitment.dart b/frontend/lib/Grounded/see/commitment/ConnectNewCommitment.dart index 5051cf6..ff5143d 100644 --- a/frontend/lib/Grounded/see/commitment/ConnectNewCommitment.dart +++ b/frontend/lib/Grounded/see/commitment/ConnectNewCommitment.dart @@ -1,3 +1,4 @@ +import '../../about/external/data/Goal.dart'; import '../../utils/CapacityEngine.dart'; abstract class ConnectNewCommitment { @@ -9,4 +10,7 @@ abstract class ConnectNewCommitment { /// The learned multiplier for this category, so the estimate field can show /// what the app actually expects the task to take. void onMultiplierResolved(double multiplier); + + /// Goals are loaded; open the picker. + void onGoalsReady(List goals); } diff --git a/frontend/lib/Grounded/see/commitment/NewCommitment.dart b/frontend/lib/Grounded/see/commitment/NewCommitment.dart index 69da0c3..503b1d4 100644 --- a/frontend/lib/Grounded/see/commitment/NewCommitment.dart +++ b/frontend/lib/Grounded/see/commitment/NewCommitment.dart @@ -1,9 +1,14 @@ import 'package:flutter/material.dart'; +import '../../about/external/data/Goal.dart'; import 'NewCommitmentState.dart'; class NewCommitment extends StatefulWidget { - const NewCommitment({super.key}); + /// The goal this task is created inside; it can still be changed in the + /// form. Null starts the task loose. + final Goal? goal; + + const NewCommitment({super.key, this.goal}); @override State createState() => NewCommitmentState(); diff --git a/frontend/lib/Grounded/see/commitment/NewCommitmentState.dart b/frontend/lib/Grounded/see/commitment/NewCommitmentState.dart index c3cecdc..963c75e 100644 --- a/frontend/lib/Grounded/see/commitment/NewCommitmentState.dart +++ b/frontend/lib/Grounded/see/commitment/NewCommitmentState.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:stacked/stacked.dart'; import '../../about/external/data/Commitment.dart'; +import '../../about/external/data/Goal.dart'; import '../../about/external/initial/CommitmentRequest.dart'; import '../../about/internal/application/CommitmentClass.dart'; import '../../about/internal/application/EnergyCost.dart'; @@ -10,6 +11,7 @@ import '../../about/internal/application/NotificationType.dart'; import '../../about/internal/application/ProofType.dart'; import '../../about/internal/application/TextType.dart'; import '../../designs/Component.dart'; +import '../../designs/GoalPicker.dart'; import '../../designs/Responsive.dart'; import '../../designs/Shell.dart'; import '../../designs/buttons/Buttons.dart'; @@ -49,6 +51,9 @@ class NewCommitmentState extends State String _windowError = ""; + /// The goal this task belongs to; null for a loose task. + Goal? _goal; + @override Widget build(BuildContext context) { return ViewModelBuilder.reactive( @@ -76,6 +81,11 @@ class NewCommitmentState extends State setState(() { _windowStart = now; _windowEnd = now.add(const Duration(hours: 2)); + // A task made inside a goal starts in it, at the goal's default weight. + if (widget.goal != null) { + _goal = widget.goal; + _class = widget.goal!.defaultClass; + } }); } @@ -85,6 +95,10 @@ class NewCommitmentState extends State Navigator.pop(context, false); } + void _onPickGoal() { + _model?.loadGoals(); + } + void _onCategoryChanged(String value) { _model?.resolveMultiplier(value.trim()); } @@ -185,6 +199,7 @@ class NewCommitmentState extends State CommitmentRequest _buildRequest() { return CommitmentRequest( + parentId: _goal?.id, commitmentClass: _class.name, title: _title.text.trim(), category: _category.text.trim(), @@ -200,6 +215,7 @@ class NewCommitmentState extends State Commitment _buildCandidate() { return Commitment( + parentId: _goal?.id, commitmentClass: _class, title: _title.text.trim(), category: _category.text.trim(), @@ -237,6 +253,14 @@ class NewCommitmentState extends State validator: Validators.title, ), const SizedBox(height: 20), + selectField( + "Goal", + _goal?.title ?? "", + _onPickGoal, + hint: "No goal — a loose task", + icon: CupertinoIcons.flag, + ), + const SizedBox(height: 20), inputField( "Category", _category, @@ -542,6 +566,20 @@ class NewCommitmentState extends State }); } + @override + void onGoalsReady(List goals) async { + final Goal? picked = + await showGoalPicker(context, goals, selectedId: _goal?.id); + + if (picked == null || !mounted) { + return; + } + + setState(() { + _goal = picked.id == null ? null : picked; + }); + } + @override void dispose() { _title.dispose(); diff --git a/frontend/lib/Grounded/see/commitment/ViewNewCommitment.dart b/frontend/lib/Grounded/see/commitment/ViewNewCommitment.dart index 4e97324..c1deb78 100644 --- a/frontend/lib/Grounded/see/commitment/ViewNewCommitment.dart +++ b/frontend/lib/Grounded/see/commitment/ViewNewCommitment.dart @@ -1,5 +1,6 @@ import '../../about/external/data/Commitment.dart'; import '../../about/external/data/pages/request/CommitmentsRequest.dart'; +import '../../about/external/data/pages/request/HistoryRequest.dart'; import '../../about/external/data/pages/request/PageAndSort.dart'; import '../../about/external/data/pages/request/Pageable.dart'; import '../../about/external/data/pages/request/Sort.dart'; @@ -7,6 +8,7 @@ import '../../about/external/data/pages/response/CommitmentPage.dart'; import '../../about/external/initial/CommitmentRequest.dart'; import '../../about/internal/application/CapacityProfile.dart'; import '../../utils/CapacityEngine.dart'; +import '../../utils/ObjectConvertors.dart'; import '../parent/ParentViewModel.dart'; import 'ConnectNewCommitment.dart'; @@ -24,6 +26,28 @@ class ViewNewCommitment extends ParentViewModel { connection.onMultiplierResolved(profile.multiplierFor(category)); } + /// Goals for the picker — loaded on tap, never up front. + void loadGoals() async { + if (!await hasNetwork(() => loadGoals())) return; + + showLoading("Loading goals"); + + try { + final response = await getDataManager().getMyGoals(HistoryRequest( + query: PageAndSort( + sort: Sort('desc', 'startDate'), + page: Pageable(0, 0, 50, 0), + ), + )); + + closeLoading(); + + connection.onGoalsReady(getGoalList(response.data)); + } catch (e) { + handleError(e, () => loadGoals(), () => dismissError(), "Retry"); + } + } + /// The capacity gate. Chronic overdue is usually overcommitment misdiagnosed /// as laziness, so the plan is checked against what history says actually /// gets done before anything is accepted. diff --git a/frontend/lib/Grounded/see/goal/ConnectGoalDetail.dart b/frontend/lib/Grounded/see/goal/ConnectGoalDetail.dart index 6d6697c..996b69f 100644 --- a/frontend/lib/Grounded/see/goal/ConnectGoalDetail.dart +++ b/frontend/lib/Grounded/see/goal/ConnectGoalDetail.dart @@ -1,9 +1,13 @@ import '../../about/external/data/Commitment.dart'; import '../../about/external/data/Goal.dart'; +import '../../about/external/data/RoutineChain.dart'; abstract class ConnectGoalDetail { void onGoalLoaded(Goal goal, List tasks); + /// The routines that serve this goal. + void onRoutinesLoaded(List routines); + /// The task is ready to run — hand off to the full-screen runner. void onTaskReady(Commitment task); diff --git a/frontend/lib/Grounded/see/goal/ConnectGoalEditor.dart b/frontend/lib/Grounded/see/goal/ConnectGoalEditor.dart new file mode 100644 index 0000000..a72c7e9 --- /dev/null +++ b/frontend/lib/Grounded/see/goal/ConnectGoalEditor.dart @@ -0,0 +1,5 @@ +abstract class ConnectGoalEditor { + void onSaved(); + + void onArchived(); +} diff --git a/frontend/lib/Grounded/see/goal/ConnectGoals.dart b/frontend/lib/Grounded/see/goal/ConnectGoals.dart index a896a4f..198e7a6 100644 --- a/frontend/lib/Grounded/see/goal/ConnectGoals.dart +++ b/frontend/lib/Grounded/see/goal/ConnectGoals.dart @@ -2,6 +2,4 @@ import '../../about/external/data/Goal.dart'; abstract class ConnectGoals { void onGoalsLoaded(List goals); - - void onGoalSaved(); } diff --git a/frontend/lib/Grounded/see/goal/GoalDetailState.dart b/frontend/lib/Grounded/see/goal/GoalDetailState.dart index f0630aa..2016678 100644 --- a/frontend/lib/Grounded/see/goal/GoalDetailState.dart +++ b/frontend/lib/Grounded/see/goal/GoalDetailState.dart @@ -4,9 +4,11 @@ import 'package:stacked/stacked.dart'; import '../../about/external/data/Commitment.dart'; import '../../about/external/data/Goal.dart'; +import '../../about/external/data/RoutineChain.dart'; import '../../about/internal/application/CommitmentStatus.dart'; import '../../about/internal/application/NotificationType.dart'; import '../../about/internal/application/ProofType.dart'; +import '../../about/internal/application/RoutineKind.dart'; import '../../about/internal/application/TextType.dart'; import '../../configs/Navigator.dart'; import '../../designs/Checkbox.dart'; @@ -20,8 +22,11 @@ import '../../utils/CommonUtils.dart'; import '../../utils/DebtEngine.dart'; import '../commitment/NewCommitment.dart'; import '../live/LiveTask.dart'; +import '../routine/RoutineBuilder.dart'; +import '../routine/RoutineRunner.dart'; import 'ConnectGoalDetail.dart'; import 'GoalDetail.dart'; +import 'GoalEditor.dart'; import 'ViewGoalDetail.dart'; class GoalDetailState extends State @@ -32,6 +37,8 @@ class GoalDetailState extends State List _tasks = []; + List _routines = []; + bool _changed = false; /// Ticked locally, awaiting the server. @@ -90,7 +97,7 @@ class GoalDetailState extends State void _onAddTask() async { final result = await GroundedNavigation() - .navigateToPageWithData(const NewCommitment(), context); + .navigateToPageWithData(NewCommitment(goal: _goal), context); if (result == true) { _changed = true; @@ -98,6 +105,61 @@ class GoalDetailState extends State } } + void _onAddRoutine() async { + final result = await GroundedNavigation() + .navigateToPageWithData(RoutineBuilder(goal: _goal), context); + + if (result == true) { + _model?.loadRoutines(_goal); + } + } + + void _onEditRoutine(RoutineChain routine) async { + final result = await GroundedNavigation() + .navigateToPageWithData( + RoutineBuilder(routine: routine, goal: _goal), context); + + if (result == true) { + _model?.loadRoutines(_goal); + } + } + + void _onStartRoutine(RoutineChain routine) async { + if (routine.steps.isEmpty) { + _onEditRoutine(routine); + return; + } + + final result = await GroundedNavigation().navigateToPageWithData( + RoutineRunner(routine: routine.copy()), context); + + if (result == true) { + _changed = true; + _model?.loadRoutines(_goal); + } + } + + void _onEditGoal() async { + final result = await GroundedNavigation() + .navigateToPageWithData(GoalEditor(goal: _goal), context); + + if (!mounted) { + return; + } + + if (result == "archived") { + Navigator.pop(context, true); + return; + } + + if (result is Goal) { + _changed = true; + setState(() { + _goal = result; + }); + } + } + // ── Views ───────────────────────────────────────────────────────────────── Widget _mobileView(BoxConstraints constraints) { @@ -115,10 +177,11 @@ class GoalDetailState extends State .toList(); return Sheet( - eyebrow: "Goal", + eyebrow: _goal.category.isEmpty ? "Goal" : _goal.category, title: _goal.title, onBack: _onBack, - action: chromeAction(CupertinoIcons.add, _onAddTask), + chrome: _goal.colourHex.isEmpty ? null : goalColor(_goal.colourHex), + action: chromeAction(CupertinoIcons.pencil, _onEditGoal), banner: _progressBanner(), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -138,6 +201,26 @@ class GoalDetailState extends State ) else ...open.map(_taskRow), + const SizedBox(height: 28), + sectionBreak( + "Routines", + caption: _routines.isEmpty + ? "none yet" + : "${_routines.length} serving this goal", + ), + if (_routines.isEmpty) + text( + "Routines are the repeatable part of a goal — the Monday session, the morning sit. Add one and it runs from here.", + 13, + TextType.Regular, + color: colorGrey2, + height: 1.5, + ) + else + ..._routines.map(_routineRow), + const SizedBox(height: 12), + outlinedActionButton("Add a routine", _onAddRoutine, + icon: CupertinoIcons.square_stack_3d_up), if (done.isNotEmpty) ...[ const SizedBox(height: 28), sectionBreak("Done", caption: "${done.length}"), @@ -179,6 +262,40 @@ class GoalDetailState extends State track: colorWhite.withValues(alpha: 0.14), height: 5, ), + const SizedBox(height: 14), + Row( + children: [ + Expanded( + child: labelled( + "Tasks", + "${_goal.completedTasks}/${_goal.totalTasks}", + valueSize: 15, + valueColor: colorWhite, + labelColor: colorWhite.withValues(alpha: 0.45), + ), + ), + Expanded( + child: labelled( + "Routines", + "${_routines.length}", + valueSize: 15, + valueColor: colorWhite, + labelColor: colorWhite.withValues(alpha: 0.45), + ), + ), + Expanded( + child: labelled( + "Deadline", + _goal.targetDate == null + ? "None" + : targetLabel(_goal.targetDate), + valueSize: 15, + valueColor: colorWhite, + labelColor: colorWhite.withValues(alpha: 0.45), + ), + ), + ], + ), ], ), ); @@ -235,11 +352,14 @@ class GoalDetailState extends State ), ], const SizedBox(height: 14), - roundedCornerButton( - "Start", - () => _onStartTask(task), - icon: CupertinoIcons.play_fill, - verticalPadding: 13, + SizedBox( + width: double.infinity, + child: roundedCornerButton( + "Start", + () => _onStartTask(task), + icon: CupertinoIcons.play_fill, + verticalPadding: 13, + ), ), ], ), @@ -268,8 +388,86 @@ class GoalDetailState extends State ); } + Widget _routineRow(RoutineChain routine) { + final Color tone = kindColor(routine.kind); + + return Container( + margin: const EdgeInsets.only(bottom: 10), + child: card( + padding: const EdgeInsets.fromLTRB(10, 10, 10, 10), + onTap: () => _onEditRoutine(routine), + child: Row( + children: [ + Container( + width: 48, + height: 48, + alignment: Alignment.center, + decoration: BoxDecoration( + color: kindBackground(routine.kind), + borderRadius: BorderRadius.circular(13), + ), + child: Icon(kindIcon(routine.kind), size: 21, color: tone), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + text(routine.title, 14, TextType.Medium, + color: colorPrimaryDark, + maxLines: 1, + overflow: TextOverflow.ellipsis), + const SizedBox(height: 4), + text( + [ + kindLabel(routine.kind), + formatMinutes(routine.totalSeconds / 60), + if (routine.startMinute >= 0) + formatStartMinute(routine.startMinute), + formatDays(routine.days), + ].join(" · "), + 11, + TextType.Regular, + color: colorGrey2, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + const SizedBox(width: 8), + GestureDetector( + onTap: () => _onStartRoutine(routine), + child: Container( + width: 40, + height: 40, + alignment: Alignment.center, + decoration: BoxDecoration( + color: routine.kind == RoutineKind.MustDo + ? colorPrimaryDark + : tone, + shape: BoxShape.circle, + ), + child: Icon(CupertinoIcons.play_fill, + size: 15, color: colorWhite), + ), + ), + ], + ), + ), + ); + } + // ── ConnectGoalDetail ───────────────────────────────────────────────────── + @override + void onRoutinesLoaded(List routines) { + setState(() { + _routines = routines; + }); + } + @override void onGoalLoaded(Goal goal, List tasks) { setState(() { diff --git a/frontend/lib/Grounded/see/goal/GoalEditor.dart b/frontend/lib/Grounded/see/goal/GoalEditor.dart new file mode 100644 index 0000000..b8bc661 --- /dev/null +++ b/frontend/lib/Grounded/see/goal/GoalEditor.dart @@ -0,0 +1,16 @@ +import 'package:flutter/material.dart'; + +import '../../about/external/data/Goal.dart'; +import 'GoalEditorState.dart'; + +/// Create or edit a goal. Pops the saved [Goal], or `"archived"` when the goal +/// was archived, so a detail screen underneath knows to close as well. +class GoalEditor extends StatefulWidget { + /// The goal being edited; null creates a new one. + final Goal? goal; + + const GoalEditor({super.key, this.goal}); + + @override + State createState() => GoalEditorState(); +} diff --git a/frontend/lib/Grounded/see/goal/GoalEditorState.dart b/frontend/lib/Grounded/see/goal/GoalEditorState.dart new file mode 100644 index 0000000..5418b7a --- /dev/null +++ b/frontend/lib/Grounded/see/goal/GoalEditorState.dart @@ -0,0 +1,398 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:stacked/stacked.dart'; + +import '../../about/external/data/Goal.dart'; +import '../../about/external/initial/GoalRequest.dart'; +import '../../about/internal/application/CommitmentClass.dart'; +import '../../about/internal/application/NotificationType.dart'; +import '../../about/internal/application/TextType.dart'; +import '../../designs/Component.dart'; +import '../../designs/Responsive.dart'; +import '../../designs/Shell.dart'; +import '../../designs/buttons/Buttons.dart'; +import '../../designs/input/InputFields.dart'; +import '../../designs/text/Text.dart'; +import '../../utils/Colors.dart'; +import '../../utils/CommonUtils.dart'; +import '../../utils/Validators.dart'; +import 'ConnectGoalEditor.dart'; +import 'GoalEditor.dart'; +import 'ViewGoalEditor.dart'; + +class GoalEditorState extends State implements ConnectGoalEditor { + ViewGoalEditor? _model; + + final GlobalKey _formKey = GlobalKey(); + + final TextEditingController _title = TextEditingController(); + + final TextEditingController _description = TextEditingController(); + + final TextEditingController _category = TextEditingController(); + + CommitmentClass _defaultClass = CommitmentClass.Standard; + + Color _accent = goalAccents.first; + + DateTime? _targetDate; + + bool get _editing { + return widget.goal != null; + } + + @override + void initState() { + super.initState(); + + final goal = widget.goal; + if (goal != null) { + _title.text = goal.title; + _description.text = goal.description; + _category.text = goal.category; + _defaultClass = goal.defaultClass; + _targetDate = goal.targetDate; + if (goal.colourHex.isNotEmpty) { + _accent = goalColor(goal.colourHex); + } + } + } + + @override + Widget build(BuildContext context) { + return ViewModelBuilder.reactive( + viewModelBuilder: () => ViewGoalEditor(context, this), + onViewModelReady: (viewModel) { + _model = viewModel; + _initiate(); + }, + builder: (context, viewModel, child) => LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return Responsive( + mobile: _mobileView(constraints), + tablet: _mobileView(constraints), + desktop: _mobileView(constraints), + ); + }, + ), + ); + } + + void _initiate() {} + + // ── Handlers ────────────────────────────────────────────────────────────── + + void _onBack() { + Navigator.pop(context, false); + } + + void _onTitleChanged(String value) { + setState(() {}); + } + + void _onAccentSelected(Color colour) { + setState(() { + _accent = colour; + }); + } + + void _onClassSelected(CommitmentClass value) { + setState(() { + _defaultClass = value; + }); + } + + void _onPickTarget() async { + final DateTime now = DateTime.now(); + + final DateTime? picked = await showDatePicker( + context: context, + initialDate: _targetDate ?? now.add(const Duration(days: 30)), + firstDate: now, + lastDate: now.add(const Duration(days: 365 * 5)), + ); + + if (picked == null) { + return; + } + + setState(() { + _targetDate = picked; + }); + } + + void _onClearTarget() { + setState(() { + _targetDate = null; + }); + } + + void _onSave() { + if (_formKey.currentState?.validate() != true) { + return; + } + + _model?.save(GoalRequest( + id: widget.goal?.id, + title: _title.text.trim(), + description: _description.text.trim(), + category: _category.text.trim(), + defaultClass: _defaultClass.name, + startDate: (widget.goal?.startDate ?? DateTime.now()).toIso8601String(), + targetDate: _targetDate?.toIso8601String() ?? "", + colourHex: colorToHex(_accent), + )); + } + + void _onArchive() { + _model?.showApplicationNotification( + NotificationType.warning, + "Archive this goal?", + "It leaves your goals list. Its tasks and their history stay on the record, and any debt they carry is not forgiven.", + true, + true, + null, + action: "Archive", + positiveAction: _onConfirmArchive, + ); + } + + void _onConfirmArchive() { + Navigator.pop(context); + _model?.archive(widget.goal?.id ?? ""); + } + + // ── Views ───────────────────────────────────────────────────────────────── + + Widget _mobileView(BoxConstraints constraints) { + return Sheet( + eyebrow: _editing ? "Edit" : "New", + title: "Goal", + onBack: _onBack, + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + displayTitle(_editing ? "Adjust the goal." : "What are you\nworking toward?"), + const SizedBox(height: 24), + _preview(), + const SizedBox(height: 28), + inputField( + "Goal", + _title, + hint: "Workout, thesis, get the flat sorted…", + validator: Validators.title, + onChanged: _onTitleChanged, + ), + const SizedBox(height: 16), + inputField( + "Why it matters", + _description, + hint: "Optional, but it helps on the bad days", + maxLines: 3, + ), + const SizedBox(height: 16), + inputField( + "Category", + _category, + hint: "Health, work, home…", + ), + const SizedBox(height: 28), + sectionBreak("Colour", caption: "how it reads everywhere"), + _swatches(), + const SizedBox(height: 28), + sectionBreak("Deadline", caption: "optional"), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: selectField( + "Target date", + _targetDate == null ? "" : formatDate(_targetDate), + _onPickTarget, + hint: "No deadline", + icon: CupertinoIcons.calendar, + ), + ), + if (_targetDate != null) ...[ + const SizedBox(width: 8), + iconButton( + Icon(CupertinoIcons.xmark, size: 15, color: colorGrey2), + _onClearTarget, + bordered: true, + background: colorWhite, + radius: 12, + size: 50, + ), + ], + ], + ), + const SizedBox(height: 10), + text( + "A deadline is for the goal as a whole. The tasks and routines inside it keep their own windows.", + 12, + TextType.Regular, + color: colorGrey2, + height: 1.5, + ), + const SizedBox(height: 28), + sectionBreak("Tasks default to", caption: "you can change each one"), + segmentedSelector( + options: CommitmentClass.values, + selected: _defaultClass, + label: classLabel, + onSelected: _onClassSelected, + activeColor: classColor(_defaultClass), + ), + const SizedBox(height: 10), + text( + "The goal never carries debt itself — the tasks and routines inside it do.", + 12, + TextType.Regular, + color: colorGrey2, + height: 1.5, + ), + const SizedBox(height: 32), + roundedCornerButton( + _editing ? "Save changes" : "Create goal", + _onSave, + icon: _editing ? CupertinoIcons.checkmark : CupertinoIcons.add, + ), + if (_editing) ...[ + const SizedBox(height: 8), + Center( + child: textButton("Archive goal", _onArchive, + color: colorDestructive, textSize: 13), + ), + ], + ], + ), + ), + ); + } + + /// A live preview of the goal card, so colour and deadline are chosen by + /// looking, not imagining. + Widget _preview() { + final String title = + _title.text.trim().isEmpty ? "Your goal" : _title.text.trim(); + + return Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [_accent, Color.lerp(_accent, colorBlack, 0.45)!], + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Icon(CupertinoIcons.flag_fill, + size: 14, color: colorWhite.withValues(alpha: 0.7)), + const SizedBox(width: 8), + text( + _targetDate == null + ? "NO DEADLINE" + : targetLabel(_targetDate).toUpperCase(), + 9, + TextType.Bold, + color: colorWhite.withValues(alpha: 0.6), + letterSpacing: 1.2, + ), + ], + ), + const SizedBox(height: 14), + text(title, 24, TextType.Light, + color: colorWhite, maxLines: 2, overflow: TextOverflow.ellipsis), + const SizedBox(height: 16), + meter(_editing ? widget.goal!.progress : 0, + fill: colorWhite, + track: colorWhite.withValues(alpha: 0.16), + height: 5), + ], + ), + ); + } + + Widget _swatches() { + return Wrap( + spacing: 9, + runSpacing: 9, + children: goalAccents.map((colour) { + final bool selected = colour.toARGB32() == _accent.toARGB32(); + + return GestureDetector( + onTap: () => _onAccentSelected(colour), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + width: 36, + height: 36, + alignment: Alignment.center, + decoration: BoxDecoration( + color: colour, + shape: BoxShape.circle, + border: Border.all( + color: selected ? colorWhite : Colors.transparent, + width: 3, + ), + boxShadow: selected + ? [BoxShadow(color: colour, spreadRadius: 1.5)] + : null, + ), + child: selected + ? Icon(Icons.check_rounded, size: 18, color: colorWhite) + : null, + ), + ); + }).toList(), + ); + } + + // ── ConnectGoalEditor ───────────────────────────────────────────────────── + + /// Pops the goal as saved, carrying over the server-derived counts, so the + /// screen underneath can update in place without a reload. + @override + void onSaved() { + final Goal? before = widget.goal; + + Navigator.pop( + context, + Goal( + id: before?.id, + title: _title.text.trim(), + description: _description.text.trim(), + category: _category.text.trim(), + defaultClass: _defaultClass, + startDate: before?.startDate ?? DateTime.now(), + targetDate: _targetDate, + colourHex: colorToHex(_accent), + totalTasks: before?.totalTasks ?? 0, + completedTasks: before?.completedTasks ?? 0, + overdueTasks: before?.overdueTasks ?? 0, + debtContribution: before?.debtContribution ?? 0, + ), + ); + } + + @override + void onArchived() { + Navigator.pop(context, "archived"); + } + + @override + void dispose() { + _title.dispose(); + _description.dispose(); + _category.dispose(); + super.dispose(); + } +} diff --git a/frontend/lib/Grounded/see/goal/GoalsState.dart b/frontend/lib/Grounded/see/goal/GoalsState.dart index 682e47b..114072f 100644 --- a/frontend/lib/Grounded/see/goal/GoalsState.dart +++ b/frontend/lib/Grounded/see/goal/GoalsState.dart @@ -1,23 +1,19 @@ import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; import 'package:stacked/stacked.dart'; import '../../about/external/data/Goal.dart'; -import '../../about/external/initial/GoalRequest.dart'; -import '../../about/internal/application/CommitmentClass.dart'; import '../../about/internal/application/TextType.dart'; import '../../configs/Navigator.dart'; import '../../designs/Component.dart'; import '../../designs/Responsive.dart'; import '../../designs/Shell.dart'; import '../../designs/buttons/Buttons.dart'; -import '../../designs/input/InputFields.dart'; import '../../designs/text/Text.dart'; import '../../utils/Colors.dart'; import '../../utils/CommonUtils.dart'; -import '../../utils/Validators.dart'; import 'ConnectGoals.dart'; import 'GoalDetail.dart'; +import 'GoalEditor.dart'; import 'Goals.dart'; import 'ViewGoals.dart'; @@ -63,124 +59,13 @@ class GoalsState extends State implements ConnectGoals { } } - void _onNewGoal() { - _openGoalSheet(); - } + void _onNewGoal() async { + final result = await GroundedNavigation() + .navigateToPageWithData(const GoalEditor(), context); - /// Goals are lightweight on purpose — a name and a default class. The - /// weight lives on the tasks inside them. - void _openGoalSheet() { - final TextEditingController title = TextEditingController(); - final TextEditingController description = TextEditingController(); - final GlobalKey formKey = GlobalKey(); - - CommitmentClass defaultClass = CommitmentClass.Standard; - - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - barrierColor: colorPrimaryDark.withValues(alpha: 0.6), - builder: (BuildContext sheetContext) { - return StatefulBuilder( - builder: (BuildContext sheetContext, StateSetter setSheetState) { - return Padding( - padding: EdgeInsets.only( - bottom: MediaQuery.of(sheetContext).viewInsets.bottom, - ), - child: Container( - decoration: BoxDecoration( - color: colorSheetBackground, - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(28), - topRight: Radius.circular(28), - ), - ), - child: SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(24, 16, 24, 32), - child: Form( - key: formKey, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Center( - child: Container( - width: 36, - height: 4, - margin: const EdgeInsets.only(bottom: 20), - decoration: BoxDecoration( - color: colorGrey.withValues(alpha: 0.35), - borderRadius: BorderRadius.circular(999), - ), - ), - ), - text("NEW GOAL", 9, TextType.Bold, - color: colorGrey2, letterSpacing: 1.2), - const SizedBox(height: 10), - text("What are you\nworking toward?", 26, - TextType.Light, - color: colorPrimaryDark, height: 1.2), - const SizedBox(height: 20), - inputField( - "Goal", - title, - hint: "Workout, thesis, get the flat sorted…", - validator: Validators.title, - ), - const SizedBox(height: 18), - inputField( - "Why it matters", - description, - hint: "Optional, but it helps on the bad days", - maxLines: 3, - ), - const SizedBox(height: 20), - text("TASKS DEFAULT TO", 9, TextType.Bold, - color: colorGrey2, letterSpacing: 1.0), - const SizedBox(height: 10), - segmentedSelector( - options: CommitmentClass.values, - selected: defaultClass, - label: classLabel, - onSelected: (value) => setSheetState(() { - defaultClass = value; - }), - activeColor: classColor(defaultClass), - ), - const SizedBox(height: 24), - roundedCornerButton( - "Create goal", - () { - if (formKey.currentState?.validate() != true) { - return; - } - Navigator.pop(sheetContext); - _model?.save(GoalRequest( - title: title.text.trim(), - description: description.text.trim(), - defaultClass: defaultClass.name, - startDate: DateTime.now().toIso8601String(), - )); - }, - icon: CupertinoIcons.add, - ), - const SizedBox(height: 8), - Center( - child: textButton("Cancel", - () => Navigator.pop(sheetContext), - textSize: 13), - ), - ], - ), - ), - ), - ), - ); - }, - ); - }, - ); + if (result is Goal) { + _model?.loadGoals(); + } } Widget _mobileView(BoxConstraints constraints) { @@ -221,7 +106,7 @@ class GoalsState extends State implements ConnectGoals { Widget _goalCard(Goal goal) { final Color accent = - goal.slipping ? colorStandingGrounded : colorPrimaryDark; + goal.slipping ? colorStandingGrounded : goalColor(goal.colourHex); return Container( margin: const EdgeInsets.only(bottom: 12), @@ -239,6 +124,35 @@ class GoalsState extends State implements ConnectGoals { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ + Row( + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: goalColor(goal.colourHex), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + text( + [ + if (goal.category.isNotEmpty) goal.category, + goal.targetDate == null + ? "No deadline" + : targetLabel(goal.targetDate), + ].join(" · ").toUpperCase(), + 9, + TextType.Bold, + color: goal.targetDate != null && + goal.targetDate!.isBefore(DateTime.now()) + ? colorStandingGrounded + : colorGrey2, + letterSpacing: 1.0, + ), + ], + ), + const SizedBox(height: 8), text(goal.title, 19, TextType.Light, color: colorPrimaryDark, maxLines: 2, @@ -302,9 +216,4 @@ class GoalsState extends State implements ConnectGoals { _goals = goals; }); } - - @override - void onGoalSaved() { - _model?.loadGoals(); - } } diff --git a/frontend/lib/Grounded/see/goal/ViewGoalDetail.dart b/frontend/lib/Grounded/see/goal/ViewGoalDetail.dart index 158f582..72de8f5 100644 --- a/frontend/lib/Grounded/see/goal/ViewGoalDetail.dart +++ b/frontend/lib/Grounded/see/goal/ViewGoalDetail.dart @@ -1,5 +1,10 @@ import '../../about/external/data/Commitment.dart'; import '../../about/external/data/Goal.dart'; +import '../../about/external/data/RoutineChain.dart'; +import '../../about/external/data/pages/request/HistoryRequest.dart'; +import '../../about/external/data/pages/request/PageAndSort.dart'; +import '../../about/external/data/pages/request/Pageable.dart'; +import '../../about/external/data/pages/request/Sort.dart'; import '../../about/external/initial/CompletionRequest.dart'; import '../../about/external/initial/IdRequest.dart'; import '../../utils/ObjectConvertors.dart'; @@ -23,11 +28,34 @@ class ViewGoalDetail extends ParentViewModel { closeLoading(); connection.onGoalLoaded(goal, getCommitmentList(response.data)); + + loadRoutines(goal); } catch (e) { handleError(e, () => loadTasks(goal), () => dismissError(), "Retry"); } } + /// Routines are fetched with the library and filtered to this goal, so a + /// routine moved between goals shows up in the right place immediately. + void loadRoutines(Goal goal) async { + try { + final response = await getDataManager().getMyRoutines(HistoryRequest( + query: PageAndSort( + sort: Sort('asc', 'startMinute'), + page: Pageable(0, 0, 50, 0), + ), + )); + + final List routines = getRoutineChainList(response.data) + .where((routine) => routine.goalId == goal.id) + .toList(); + + connection.onRoutinesLoaded(routines); + } catch (e) { + handleError(e, () => loadRoutines(goal), () => dismissError(), "Retry"); + } + } + /// Completes a task straight from the goal list. void tick(Commitment task) async { if (!await hasNetwork(() => tick(task))) return; diff --git a/frontend/lib/Grounded/see/goal/ViewGoalEditor.dart b/frontend/lib/Grounded/see/goal/ViewGoalEditor.dart new file mode 100644 index 0000000..9052bf7 --- /dev/null +++ b/frontend/lib/Grounded/see/goal/ViewGoalEditor.dart @@ -0,0 +1,44 @@ +import '../../about/external/initial/GoalRequest.dart'; +import '../../about/external/initial/IdRequest.dart'; +import '../parent/ParentViewModel.dart'; +import 'ConnectGoalEditor.dart'; + +class ViewGoalEditor extends ParentViewModel { + ConnectGoalEditor connection; + + ViewGoalEditor(super.context, this.connection); + + void save(GoalRequest request) async { + if (!await hasNetwork(() => save(request))) return; + + showLoading("Saving goal"); + + try { + await getDataManager().saveGoalEntry(request); + + closeLoading(); + + connection.onSaved(); + } catch (e) { + handleError(e, () => save(request), () => dismissError(), "Retry"); + } + } + + /// Archiving hides the goal; its tasks and their history stay exactly as + /// they were. Debt already accrued is not forgiven by archiving. + void archive(String goalId) async { + if (!await hasNetwork(() => archive(goalId))) return; + + showLoading("Archiving"); + + try { + await getDataManager().archiveGoalEntry(IdRequest(id: goalId)); + + closeLoading(); + + connection.onArchived(); + } catch (e) { + handleError(e, () => archive(goalId), () => dismissError(), "Retry"); + } + } +} diff --git a/frontend/lib/Grounded/see/goal/ViewGoals.dart b/frontend/lib/Grounded/see/goal/ViewGoals.dart index 3418bab..1b866f9 100644 --- a/frontend/lib/Grounded/see/goal/ViewGoals.dart +++ b/frontend/lib/Grounded/see/goal/ViewGoals.dart @@ -2,7 +2,6 @@ import '../../about/external/data/pages/request/HistoryRequest.dart'; import '../../about/external/data/pages/request/PageAndSort.dart'; import '../../about/external/data/pages/request/Pageable.dart'; import '../../about/external/data/pages/request/Sort.dart'; -import '../../about/external/initial/GoalRequest.dart'; import '../../utils/ObjectConvertors.dart'; import '../parent/ParentViewModel.dart'; import 'ConnectGoals.dart'; @@ -32,20 +31,4 @@ class ViewGoals extends ParentViewModel { handleError(e, () => loadGoals(), () => dismissError(), "Retry"); } } - - void save(GoalRequest request) async { - if (!await hasNetwork(() => save(request))) return; - - showLoading("Saving"); - - try { - await getDataManager().saveGoalEntry(request); - - closeLoading(); - - connection.onGoalSaved(); - } catch (e) { - handleError(e, () => save(request), () => dismissError(), "Retry"); - } - } } diff --git a/frontend/lib/Grounded/see/home/HomeState.dart b/frontend/lib/Grounded/see/home/HomeState.dart index b0c2e0f..9f6fd46 100644 --- a/frontend/lib/Grounded/see/home/HomeState.dart +++ b/frontend/lib/Grounded/see/home/HomeState.dart @@ -28,6 +28,7 @@ import '../excuse/ExcuseReport.dart'; import '../goal/Goals.dart'; import '../overdue/OverdueQueue.dart'; import '../reportcard/ReportCardScreen.dart'; +import '../routine/Routines.dart'; import '../settings/Settings.dart'; import '../training/Training.dart'; import 'ConnectHome.dart'; @@ -136,6 +137,11 @@ class HomeState extends State implements ConnectHome { .navigateToPage(NavigatorType.justOpen, const Training(), context); } + void _onOpenRoutines() { + GroundedNavigation() + .navigateToPage(NavigatorType.justOpen, const Routines(), context); + } + void _onOpenSettings() { GroundedNavigation() .navigateToPage(NavigatorType.justOpen, const Settings(), context); @@ -588,30 +594,46 @@ class HomeState extends State implements ConnectHome { } Widget _quickLinks() { - return Row( + return Column( children: [ - Expanded( - child: _quickLink( - CupertinoIcons.flag_fill, - "Goals", - _onOpenGoals, - ), + Row( + children: [ + Expanded( + child: _quickLink( + CupertinoIcons.flag_fill, + "Goals", + _onOpenGoals, + ), + ), + const SizedBox(width: 10), + Expanded( + child: _quickLink( + CupertinoIcons.square_stack_3d_up_fill, + "Routines", + _onOpenRoutines, + ), + ), + ], ), - const SizedBox(width: 10), - Expanded( - child: _quickLink( - CupertinoIcons.chart_bar_alt_fill, - "Report", - _onOpenReportCard, - ), - ), - const SizedBox(width: 10), - Expanded( - child: _quickLink( - CupertinoIcons.flame_fill, - "Training", - _onOpenTraining, - ), + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: _quickLink( + CupertinoIcons.flame_fill, + "Training", + _onOpenTraining, + ), + ), + const SizedBox(width: 10), + Expanded( + child: _quickLink( + CupertinoIcons.chart_bar_alt_fill, + "Report", + _onOpenReportCard, + ), + ), + ], ), ], ); diff --git a/frontend/lib/Grounded/see/routine/ConnectRoutineBuilder.dart b/frontend/lib/Grounded/see/routine/ConnectRoutineBuilder.dart new file mode 100644 index 0000000..d87ba50 --- /dev/null +++ b/frontend/lib/Grounded/see/routine/ConnectRoutineBuilder.dart @@ -0,0 +1,8 @@ +import '../../about/external/data/Goal.dart'; + +abstract class ConnectRoutineBuilder { + void onSaved(); + + /// Goals are loaded; open the picker. + void onGoalsReady(List goals); +} diff --git a/frontend/lib/Grounded/see/routine/ConnectRoutineRunner.dart b/frontend/lib/Grounded/see/routine/ConnectRoutineRunner.dart new file mode 100644 index 0000000..f39746f --- /dev/null +++ b/frontend/lib/Grounded/see/routine/ConnectRoutineRunner.dart @@ -0,0 +1,5 @@ +abstract class ConnectRoutineRunner { + /// The run was recorded. [held] is false when the chain broke and the run + /// went in as partial. + void onLogged(bool held); +} diff --git a/frontend/lib/Grounded/see/routine/ConnectRoutines.dart b/frontend/lib/Grounded/see/routine/ConnectRoutines.dart new file mode 100644 index 0000000..dbbc34e --- /dev/null +++ b/frontend/lib/Grounded/see/routine/ConnectRoutines.dart @@ -0,0 +1,5 @@ +import '../../about/external/data/RoutineChain.dart'; + +abstract class ConnectRoutines { + void onRoutinesLoaded(List routines); +} diff --git a/frontend/lib/Grounded/see/routine/RoutineBuilder.dart b/frontend/lib/Grounded/see/routine/RoutineBuilder.dart new file mode 100644 index 0000000..c02773c --- /dev/null +++ b/frontend/lib/Grounded/see/routine/RoutineBuilder.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; + +import '../../about/external/data/Goal.dart'; +import '../../about/external/data/RoutineChain.dart'; +import '../../about/internal/application/RoutineKind.dart'; +import 'RoutineBuilderState.dart'; + +class RoutineBuilder extends StatefulWidget { + /// The routine being edited; null builds a new one. + final RoutineChain? routine; + + /// The kind a new routine starts as — the library's active filter. + final RoutineKind? kind; + + /// The goal a new routine is created inside, or the goal an edited one + /// already belongs to (for its title). Changeable in the form. + final Goal? goal; + + const RoutineBuilder({super.key, this.routine, this.kind, this.goal}); + + @override + State createState() => RoutineBuilderState(); +} diff --git a/frontend/lib/Grounded/see/routine/RoutineBuilderState.dart b/frontend/lib/Grounded/see/routine/RoutineBuilderState.dart new file mode 100644 index 0000000..d28ae45 --- /dev/null +++ b/frontend/lib/Grounded/see/routine/RoutineBuilderState.dart @@ -0,0 +1,686 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:stacked/stacked.dart'; + +import '../../about/external/data/Goal.dart'; +import '../../about/external/data/RoutineChain.dart'; +import '../../about/external/data/RoutineStep.dart'; +import '../../about/internal/application/CommitmentClass.dart'; +import '../../about/internal/application/RoutineKind.dart'; +import '../../about/internal/application/TextType.dart'; +import '../../designs/Checkbox.dart'; +import '../../designs/Component.dart'; +import '../../designs/GoalPicker.dart'; +import '../../designs/Responsive.dart'; +import '../../designs/Shell.dart'; +import '../../designs/buttons/Buttons.dart'; +import '../../designs/input/InputFields.dart'; +import '../../designs/text/Text.dart'; +import '../../utils/Colors.dart'; +import '../../utils/CommonUtils.dart'; +import '../../utils/RoutineTemplates.dart'; +import '../../utils/Validators.dart'; +import 'ConnectRoutineBuilder.dart'; +import 'RoutineBuilder.dart'; +import 'StepEditor.dart'; +import 'ViewRoutineBuilder.dart'; + +class RoutineBuilderState extends State + implements ConnectRoutineBuilder { + ViewRoutineBuilder? _model; + + final GlobalKey _formKey = GlobalKey(); + + final TextEditingController _title = TextEditingController(); + + final TextEditingController _note = TextEditingController(); + + late RoutineChain _routine; + + String _stepsError = ""; + + /// The goal this routine serves, when its title is known. + Goal? _goal; + + bool get _editing { + return widget.routine != null; + } + + @override + void initState() { + super.initState(); + + // Edit a copy, so backing out leaves the library exactly as it was. + _routine = widget.routine?.copy() ?? + RoutineChain(kind: widget.kind ?? RoutineKind.Workout); + + _title.text = _routine.title; + _note.text = _routine.note; + + final Goal? goal = widget.goal; + if (goal != null && (widget.routine == null || goal.id == _routine.goalId)) { + _goal = goal; + _routine.goalId = goal.id; + if (widget.routine == null) { + _routine.commitmentClass = goal.defaultClass; + } + } + } + + @override + Widget build(BuildContext context) { + return ViewModelBuilder.reactive( + viewModelBuilder: () => ViewRoutineBuilder(context, this), + onViewModelReady: (viewModel) { + _model = viewModel; + _initiate(); + }, + builder: (context, viewModel, child) => LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return Responsive( + mobile: _mobileView(constraints), + tablet: _mobileView(constraints), + desktop: _mobileView(constraints), + ); + }, + ), + ); + } + + void _initiate() {} + + // ── Handlers ────────────────────────────────────────────────────────────── + + void _onBack() { + Navigator.pop(context, false); + } + + void _onKindSelected(RoutineKind kind) { + if (kind == _routine.kind) { + return; + } + setState(() { + _routine.kind = kind; + }); + } + + void _onTemplateSelected(RoutineChain template) { + setState(() { + _routine.title = template.title; + _routine.commitmentClass = template.commitmentClass; + _routine.days = List.from(template.days); + _routine.startMinute = template.startMinute; + _routine.steps = template.copy().steps; + _title.text = template.title; + _stepsError = ""; + }); + } + + void _onPickGoal() { + _model?.loadGoals(); + } + + void _onClassSelected(CommitmentClass value) { + setState(() { + _routine.commitmentClass = value; + }); + } + + void _onToggleDay(int day) { + setState(() { + if (_routine.days.contains(day)) { + _routine.days.remove(day); + } else { + _routine.days.add(day); + _routine.days.sort(); + } + }); + } + + void _onWeekdays() { + setState(() { + _routine.days = [1, 2, 3, 4, 5]; + }); + } + + void _onEveryDay() { + setState(() { + _routine.days = [1, 2, 3, 4, 5, 6, 7]; + }); + } + + void _onClearDays() { + setState(() { + _routine.days = []; + }); + } + + void _onPickStart() async { + final int current = _routine.startMinute < 0 ? 7 * 60 : _routine.startMinute; + + final TimeOfDay? time = await showTimePicker( + context: context, + initialTime: TimeOfDay(hour: current ~/ 60, minute: current % 60), + ); + + if (time == null) { + return; + } + + setState(() { + _routine.startMinute = time.hour * 60 + time.minute; + }); + } + + void _onAddStep() { + _openStepEditor(null); + } + + void _openStepEditor(int? index) async { + final dynamic result = await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + barrierColor: colorBlack.withValues(alpha: 0.55), + builder: (BuildContext sheetContext) { + return StepEditor( + step: index == null ? null : _routine.steps[index], + kind: _routine.kind, + ); + }, + ); + + if (result == null || !mounted) { + return; + } + + setState(() { + if (result == false && index != null) { + _routine.steps.removeAt(index); + } else if (result is RoutineStep) { + if (index == null) { + _routine.steps.add(result); + } else { + _routine.steps[index] = result; + } + _stepsError = ""; + } + }); + } + + void _onDuplicateStep(int index) { + setState(() { + _routine.steps.insert( + index + 1, RoutineStep.fromJson(_routine.steps[index].toJson())..id = null); + }); + } + + void _onReorder(int oldIndex, int newIndex) { + setState(() { + final RoutineStep moved = _routine.steps.removeAt(oldIndex); + _routine.steps.insert(newIndex, moved); + }); + } + + void _onSave() { + final bool fieldsValid = _formKey.currentState?.validate() == true; + + if (_routine.steps.isEmpty) { + setState(() { + _stepsError = "A routine needs at least one step."; + }); + } + + if (!fieldsValid || _routine.steps.isEmpty) { + return; + } + + _routine.title = _title.text.trim(); + _routine.note = _note.text.trim(); + + _model?.save(_routine); + } + + // ── Views ───────────────────────────────────────────────────────────────── + + Widget _mobileView(BoxConstraints constraints) { + return Sheet( + eyebrow: kindLabel(_routine.kind), + title: _editing ? "Edit routine" : "New routine", + onBack: _onBack, + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + displayTitle(_editing ? "Adjust the chain." : "What are you\nbuilding?"), + const SizedBox(height: 24), + _kindPicker(), + if (!_editing) ...[ + const SizedBox(height: 24), + text("START FROM", 10, TextType.Bold, + color: colorGrey2, letterSpacing: 0.8), + const SizedBox(height: 10), + _templateRail(), + ], + const SizedBox(height: 28), + sectionBreak("Details"), + inputField( + "Title", + _title, + hint: "Morning strength, box breathing…", + validator: Validators.title, + ), + const SizedBox(height: 16), + selectField( + "Goal", + _goalLabel(), + _onPickGoal, + hint: "No goal — stands on its own", + icon: CupertinoIcons.flag, + ), + const SizedBox(height: 16), + inputField( + "Note", + _note, + hint: "Why this routine exists (optional)", + maxLines: 2, + ), + const SizedBox(height: 28), + sectionBreak("Weight", caption: "decides the debt when it breaks"), + segmentedSelector( + options: CommitmentClass.values, + selected: _routine.commitmentClass, + label: classLabel, + onSelected: _onClassSelected, + activeColor: classColor(_routine.commitmentClass), + ), + const SizedBox(height: 10), + text(_classDescription(_routine.commitmentClass), 12, + TextType.Regular, + color: colorGrey2, height: 1.5), + const SizedBox(height: 28), + sectionBreak("Schedule", caption: formatDays(_routine.days)), + _dayPicker(), + const SizedBox(height: 8), + Row( + children: [ + textButton("Weekdays", _onWeekdays, textSize: 12), + textButton("Every day", _onEveryDay, textSize: 12), + textButton("On demand", _onClearDays, textSize: 12), + ], + ), + const SizedBox(height: 8), + selectField( + "Starts at", + formatStartMinute(_routine.startMinute), + _onPickStart, + hint: "Pick a time", + icon: CupertinoIcons.clock, + ), + const SizedBox(height: 28), + sectionBreak( + "Steps", + caption: + "${_routine.steps.length} step${_routine.steps.length == 1 ? "" : "s"}", + trailing: _routine.steps.isEmpty + ? null + : text("~${formatMinutes(_routine.totalSeconds / 60)}", 12, + TextType.Bold, + color: colorGrey2), + ), + if (_stepsError.isNotEmpty) ...[ + text(_stepsError, 11, TextType.Regular, color: colorNegative), + const SizedBox(height: 10), + ], + if (_routine.steps.isNotEmpty) + text("Hold and drag the handle to reorder.", 11, + TextType.Regular, + color: colorGrey), + if (_routine.steps.isNotEmpty) const SizedBox(height: 10), + _stepList(), + addTaskAffordance(_onAddStep, label: "Add step"), + if (_routine.steps.isNotEmpty) ...[ + const SizedBox(height: 8), + _chainSummary(), + ], + const SizedBox(height: 24), + roundedCornerButton( + _editing ? "Save changes" : "Save routine", + _onSave, + icon: CupertinoIcons.checkmark, + ), + ], + ), + ), + ); + } + + /// A routine linked from the library may not have its goal's title to + /// hand until the picker loads the goals. + String _goalLabel() { + if (_goal != null) { + return _goal!.title; + } + return _routine.goalId == null ? "" : "Linked to a goal"; + } + + String _classDescription(CommitmentClass value) { + switch (value) { + case CommitmentClass.NonNegotiable: + return "Breaking this chain carries the heaviest debt and cannot be deferred. Use it for the few routines your day is built on."; + case CommitmentClass.Standard: + return "A broken chain is logged as partial and costs normal debt."; + case CommitmentClass.Elective: + return "Tracked, never punished. Good for routines you are still trying out."; + } + } + + Widget _kindPicker() { + return Row( + children: RoutineKind.values.map((kind) { + final bool last = kind == RoutineKind.values.last; + return Expanded( + child: Padding( + padding: EdgeInsets.only(right: last ? 0 : 8), + child: _kindOption(kind), + ), + ); + }).toList(), + ); + } + + Widget _kindOption(RoutineKind kind) { + final bool selected = _routine.kind == kind; + final Color tone = kindColor(kind); + + return GestureDetector( + onTap: () => _onKindSelected(kind), + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + height: 124, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + gradient: selected + ? LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [tone, kindDeep(kind)], + ) + : null, + color: selected ? null : colorWhite, + border: Border.all(color: selected ? tone : colorBorder), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 34, + height: 34, + alignment: Alignment.center, + decoration: BoxDecoration( + color: selected + ? colorWhite.withValues(alpha: 0.16) + : kindBackground(kind), + shape: BoxShape.circle, + ), + child: Icon(kindIcon(kind), + size: 17, color: selected ? colorWhite : tone), + ), + const Spacer(), + text(kindLabel(kind), 13, TextType.Bold, + color: selected ? colorWhite : colorPrimaryDark), + const SizedBox(height: 3), + text(kindDescription(kind), 10, TextType.Regular, + color: selected + ? colorWhite.withValues(alpha: 0.7) + : colorGrey2, + maxLines: 2, + overflow: TextOverflow.ellipsis, + height: 1.3), + ], + ), + ), + ); + } + + Widget _templateRail() { + final List templates = RoutineTemplates.forKind(_routine.kind); + + return SizedBox( + height: 76, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: templates.length, + separatorBuilder: (context, index) => const SizedBox(width: 10), + itemBuilder: (context, index) => _templateChip(templates[index]), + ), + ); + } + + Widget _templateChip(RoutineChain template) { + final bool applied = _routine.title == template.title; + + return GestureDetector( + onTap: () => _onTemplateSelected(template), + child: Container( + width: 176, + padding: const EdgeInsets.fromLTRB(14, 12, 14, 12), + decoration: BoxDecoration( + color: applied ? kindBackground(_routine.kind) : colorWhite, + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: applied + ? kindColor(_routine.kind).withValues(alpha: 0.4) + : colorBorder, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + text(template.title, 13, TextType.Medium, + color: colorPrimaryDark, + maxLines: 1, + overflow: TextOverflow.ellipsis), + const SizedBox(height: 5), + text( + "${template.steps.length} steps · ${formatMinutes(template.totalSeconds / 60)}", + 11, + TextType.Regular, + color: colorGrey2, + ), + ], + ), + ), + ); + } + + Widget _dayPicker() { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: List.generate(7, (index) { + final int day = index + 1; + final bool on = _routine.days.contains(day); + final Color tone = kindColor(_routine.kind); + + return GestureDetector( + onTap: () => _onToggleDay(day), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + width: 40, + height: 40, + alignment: Alignment.center, + decoration: BoxDecoration( + color: on ? tone : colorWhite, + shape: BoxShape.circle, + border: Border.all(color: on ? tone : colorBorder), + ), + child: text(weekdayInitial(day), 13, + on ? TextType.Bold : TextType.Regular, + color: on ? colorWhite : colorGrey2), + ), + ); + }), + ); + } + + Widget _stepList() { + return ReorderableListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + buildDefaultDragHandles: false, + itemCount: _routine.steps.length, + onReorderItem: _onReorder, + proxyDecorator: _dragDecorator, + itemBuilder: (context, index) => _stepCard(index), + ); + } + + Widget _dragDecorator(Widget child, int index, Animation animation) { + return Material( + color: Colors.transparent, + elevation: 0, + child: ScaleTransition( + scale: Tween(begin: 1, end: 1.03).animate(animation), + child: child, + ), + ); + } + + Widget _stepCard(int index) { + final RoutineStep step = _routine.steps[index]; + final Color tone = kindColor(_routine.kind); + + return Container( + key: ObjectKey(step), + margin: const EdgeInsets.only(bottom: 8), + child: card( + padding: const EdgeInsets.fromLTRB(8, 10, 6, 10), + onTap: () => _openStepEditor(index), + child: Row( + children: [ + ReorderableDragStartListener( + index: index, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 8), + child: Icon(Icons.drag_indicator_rounded, + size: 18, color: colorGrey), + ), + ), + Container( + width: 30, + height: 30, + alignment: Alignment.center, + decoration: BoxDecoration( + color: kindBackground(_routine.kind), + borderRadius: BorderRadius.circular(9), + ), + child: text("${index + 1}", 12, TextType.Bold, color: tone), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + text(step.title, 14, TextType.Medium, + color: colorPrimaryDark, + maxLines: 1, + overflow: TextOverflow.ellipsis), + const SizedBox(height: 4), + Row( + children: [ + Icon(stepModeIcon(step.mode), size: 11, color: colorGrey2), + const SizedBox(width: 5), + Expanded( + child: text(stepSummary(step), 11, TextType.Regular, + color: colorGrey2, + maxLines: 1, + overflow: TextOverflow.ellipsis), + ), + ], + ), + ], + ), + ), + iconButton( + Icon(CupertinoIcons.plus_square_on_square, + size: 16, color: colorGrey2), + () => _onDuplicateStep(index), + size: 34, + ), + ], + ), + ), + ); + } + + /// The dark totals card under the steps — the whole chain in three numbers. + Widget _chainSummary() { + final Color label = colorWhite.withValues(alpha: 0.45); + + return card( + background: kindDeep(_routine.kind), + borderColor: kindDeep(_routine.kind), + child: Row( + children: [ + Expanded( + child: labelled("Planned", formatMinutes(_routine.totalSeconds / 60), + valueSize: 20, + valueType: TextType.Light, + valueColor: colorWhite, + labelColor: label), + ), + Expanded( + child: labelled("Steps", "${_routine.steps.length}", + valueSize: 20, + valueType: TextType.Light, + valueColor: colorWhite, + labelColor: label), + ), + Expanded( + child: labelled( + _routine.kind == RoutineKind.MustDo ? "Tasks" : "Sets", + "${_routine.kind == RoutineKind.MustDo ? _routine.steps.length : _routine.totalSets}", + valueSize: 20, + valueType: TextType.Light, + valueColor: colorWhite, + labelColor: label), + ), + ], + ), + ); + } + + // ── ConnectRoutineBuilder ───────────────────────────────────────────────── + + @override + void onSaved() { + Navigator.pop(context, true); + } + + @override + void onGoalsReady(List goals) async { + final Goal? picked = + await showGoalPicker(context, goals, selectedId: _routine.goalId); + + if (picked == null || !mounted) { + return; + } + + setState(() { + _goal = picked.id == null ? null : picked; + _routine.goalId = picked.id; + }); + } + + @override + void dispose() { + _title.dispose(); + _note.dispose(); + super.dispose(); + } +} diff --git a/frontend/lib/Grounded/see/routine/RoutineRunner.dart b/frontend/lib/Grounded/see/routine/RoutineRunner.dart new file mode 100644 index 0000000..a29bff1 --- /dev/null +++ b/frontend/lib/Grounded/see/routine/RoutineRunner.dart @@ -0,0 +1,14 @@ +import 'package:flutter/material.dart'; + +import '../../about/external/data/RoutineChain.dart'; +import 'RoutineRunnerState.dart'; + +class RoutineRunner extends StatefulWidget { + /// A copy of the routine — the runner marks steps complete as it goes. + final RoutineChain routine; + + const RoutineRunner({super.key, required this.routine}); + + @override + State createState() => RoutineRunnerState(); +} diff --git a/frontend/lib/Grounded/see/routine/RoutineRunnerState.dart b/frontend/lib/Grounded/see/routine/RoutineRunnerState.dart new file mode 100644 index 0000000..ca5d023 --- /dev/null +++ b/frontend/lib/Grounded/see/routine/RoutineRunnerState.dart @@ -0,0 +1,1100 @@ +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(); + } +} diff --git a/frontend/lib/Grounded/see/routine/Routines.dart b/frontend/lib/Grounded/see/routine/Routines.dart new file mode 100644 index 0000000..ff14e1b --- /dev/null +++ b/frontend/lib/Grounded/see/routine/Routines.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; + +import 'RoutinesState.dart'; + +class Routines extends StatefulWidget { + const Routines({super.key}); + + @override + State createState() => RoutinesState(); +} diff --git a/frontend/lib/Grounded/see/routine/RoutinesState.dart b/frontend/lib/Grounded/see/routine/RoutinesState.dart new file mode 100644 index 0000000..8853f19 --- /dev/null +++ b/frontend/lib/Grounded/see/routine/RoutinesState.dart @@ -0,0 +1,787 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:stacked/stacked.dart'; + +import '../../about/external/data/RoutineChain.dart'; +import '../../about/external/data/RoutineStep.dart'; +import '../../about/internal/application/CommitmentClass.dart'; +import '../../about/internal/application/RoutineKind.dart'; +import '../../about/internal/application/TextType.dart'; +import '../../configs/Navigator.dart'; +import '../../designs/Component.dart'; +import '../../designs/ProgressRing.dart'; +import '../../designs/Responsive.dart'; +import '../../designs/Shell.dart'; +import '../../designs/buttons/Buttons.dart'; +import '../../designs/text/Text.dart'; +import '../../utils/Colors.dart'; +import '../../utils/CommonUtils.dart'; +import 'ConnectRoutines.dart'; +import 'RoutineBuilder.dart'; +import 'RoutineRunner.dart'; +import 'Routines.dart'; +import 'ViewRoutines.dart'; + +/// The routine library: what is due today up front, everything else below, +/// filtered by kind. +class RoutinesState extends State implements ConnectRoutines { + ViewRoutines? _model; + + List _routines = []; + + /// Null shows every kind. + RoutineKind? _kind; + + @override + Widget build(BuildContext context) { + return ViewModelBuilder.reactive( + viewModelBuilder: () => ViewRoutines(context, this), + onViewModelReady: (viewModel) { + _model = viewModel; + _initiate(); + }, + builder: (context, viewModel, child) => LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return Responsive( + mobile: _mobileView(constraints), + tablet: _mobileView(constraints), + desktop: _mobileView(constraints), + ); + }, + ), + ); + } + + void _initiate() { + _model?.loadRoutines(); + } + + // ── Derived ─────────────────────────────────────────────────────────────── + + List get _filtered { + if (_kind == null) { + return _routines; + } + return _routines.where((item) => item.kind == _kind).toList(); + } + + List get _today { + final DateTime now = DateTime.now(); + return _filtered.where((item) => item.scheduledOn(now)).toList(); + } + + List get _rest { + final DateTime now = DateTime.now(); + return _filtered.where((item) => !item.scheduledOn(now)).toList(); + } + + /// Today's routines that are not finished, soonest first — the featured + /// rail. Falls back to the whole library when nothing is due. + List get _upNext { + final List open = + _today.where((item) => !item.complete).toList(); + final List source = open.isEmpty ? _filtered : open; + return source.take(5).toList(); + } + + int _countFor(RoutineKind? kind) { + if (kind == null) { + return _routines.length; + } + return _routines.where((item) => item.kind == kind).length; + } + + // ── Handlers ────────────────────────────────────────────────────────────── + + void _onBack() { + Navigator.pop(context); + } + + void _onSelectKind(RoutineKind? kind) { + setState(() { + _kind = _kind == kind ? null : kind; + }); + } + + void _onBuild() async { + final result = await GroundedNavigation() + .navigateToPageWithData(RoutineBuilder(kind: _kind), context); + + if (result == true) { + _model?.loadRoutines(); + } + } + + void _onEdit(RoutineChain routine) async { + final result = await GroundedNavigation() + .navigateToPageWithData(RoutineBuilder(routine: routine), context); + + if (result == true) { + _model?.loadRoutines(); + } + } + + void _onStart(RoutineChain routine) async { + if (routine.steps.isEmpty) { + _onEdit(routine); + return; + } + + final result = await GroundedNavigation() + .navigateToPageWithData(RoutineRunner(routine: routine.copy()), context); + + if (result == true) { + _model?.loadRoutines(); + } + } + + void _onPreview(RoutineChain routine) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + barrierColor: colorBlack.withValues(alpha: 0.55), + builder: (BuildContext sheetContext) { + return _previewSheet(sheetContext, routine); + }, + ); + } + + // ── Views ───────────────────────────────────────────────────────────────── + + Widget _mobileView(BoxConstraints constraints) { + return Sheet( + eyebrow: "${_routines.length} routine${_routines.length == 1 ? "" : "s"}", + title: "Routines", + onBack: _onBack, + action: chromeAction(CupertinoIcons.add, _onBuild), + banner: _routines.isEmpty ? null : _todayBanner(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + text( + "${weekdayName(DateTime.now().weekday).toUpperCase()} · ${_today.length} DUE", + 11, + TextType.Bold, + color: colorGrey2, + letterSpacing: 1.2, + ), + const SizedBox(height: 8), + displayTitle(_headline()), + const SizedBox(height: 24), + _kindRail(), + const SizedBox(height: 28), + if (_filtered.isEmpty) + emptyState( + _kind == null ? CupertinoIcons.square_stack_3d_up : kindIcon(_kind!), + _kind == null + ? "No routines yet" + : "No ${kindLabel(_kind!).toLowerCase()} routines", + "A routine is a chain: every step, in order. Break it and it is logged as partial, not quietly forgotten.", + ) + else ...[ + sectionBreak( + "Up next", + caption: _today.isEmpty ? "from your library" : "today", + ), + _featuredRail(), + const SizedBox(height: 28), + if (_today.isNotEmpty) ...[ + sectionBreak( + "Scheduled today", + caption: "${_today.where((item) => item.complete).length} of ${_today.length} done", + ), + ..._today.map(_routineRow), + const SizedBox(height: 20), + ], + if (_rest.isNotEmpty) ...[ + sectionBreak("Library", caption: "${_rest.length} more"), + ..._rest.map(_routineRow), + ], + ], + const SizedBox(height: 24), + roundedCornerButton( + "Build a routine", + _onBuild, + icon: CupertinoIcons.add, + ), + ], + ), + ); + } + + String _headline() { + if (_routines.isEmpty) { + return "Build the things you do every time."; + } + final int open = _today.where((item) => !item.complete).length; + if (_today.isEmpty) { + return "Nothing scheduled. Run one anyway."; + } + if (open == 0) { + return "Every chain held today."; + } + return "Here's what to hold today."; + } + + /// The strip in the black chrome: today's load at a glance. + Widget _todayBanner() { + final DateTime now = DateTime.now(); + final List due = + _routines.where((item) => item.scheduledOn(now)).toList(); + + int seconds = 0; + for (RoutineChain item in due) { + seconds = seconds + item.totalSeconds; + } + + final int nowMinute = now.hour * 60 + now.minute; + final List ahead = due + .where((item) => !item.complete && item.startMinute >= nowMinute) + .toList() + ..sort((a, b) => a.startMinute.compareTo(b.startMinute)); + + final Color label = colorWhite.withValues(alpha: 0.45); + + return Container( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 14), + decoration: BoxDecoration( + color: colorWhite.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(16), + ), + child: Row( + children: [ + Expanded( + child: labelled("Due today", "${due.length}", + valueSize: 22, + valueType: TextType.Light, + valueColor: colorWhite, + labelColor: label), + ), + Expanded( + child: labelled("Planned", formatMinutes(seconds / 60), + valueSize: 22, + valueType: TextType.Light, + valueColor: colorWhite, + labelColor: label), + ), + Expanded( + child: labelled( + "Next", + ahead.isEmpty ? "—" : formatStartMinute(ahead.first.startMinute), + valueSize: 22, + valueType: TextType.Light, + valueColor: colorWhite, + labelColor: label), + ), + ], + ), + ); + } + + /// Round category tiles — tap to filter, tap again to clear. + Widget _kindRail() { + final List options = [null, ...RoutineKind.values]; + + return SizedBox( + height: 96, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: options.length, + separatorBuilder: (context, index) => const SizedBox(width: 14), + itemBuilder: (context, index) => _kindTile(options[index]), + ), + ); + } + + Widget _kindTile(RoutineKind? kind) { + final bool selected = _kind == kind; + final Color tone = kind == null ? colorPrimaryDark : kindColor(kind); + + return GestureDetector( + onTap: () => _onSelectKind(kind), + behavior: HitTestBehavior.opaque, + child: SizedBox( + width: 70, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOut, + width: 60, + height: 60, + alignment: Alignment.center, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: selected + ? LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + tone, + kind == null ? colorPrimaryDark2 : kindDeep(kind), + ], + ) + : null, + color: selected ? null : colorWhite, + border: Border.all( + color: selected ? tone : colorBorder, + width: selected ? 2 : 1, + ), + ), + child: Icon( + kind == null ? CupertinoIcons.square_grid_2x2_fill : kindIcon(kind), + size: 22, + color: selected ? colorWhite : tone, + ), + ), + const SizedBox(height: 8), + text( + kind == null ? "All" : kindLabel(kind), + 11, + selected ? TextType.Bold : TextType.Regular, + color: selected ? colorPrimaryDark : colorGrey2, + maxLines: 1, + ), + const SizedBox(height: 2), + text("${_countFor(kind)}", 9, TextType.Bold, + color: colorGrey, letterSpacing: 0.6), + ], + ), + ), + ); + } + + Widget _featuredRail() { + final List items = _upNext; + + return SizedBox( + height: 214, + child: ListView.separated( + scrollDirection: Axis.horizontal, + clipBehavior: Clip.none, + itemCount: items.length, + separatorBuilder: (context, index) => const SizedBox(width: 12), + itemBuilder: (context, index) => _featuredCard(items[index]), + ), + ); + } + + /// The large gradient card. The kind's icon sits oversized in the corner as + /// the card's picture — routines have no stock photography. + Widget _featuredCard(RoutineChain routine) { + final Color tone = kindColor(routine.kind); + final Color label = colorWhite.withValues(alpha: 0.55); + + return GestureDetector( + onTap: () => _onPreview(routine), + child: Container( + width: 248, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [tone, kindDeep(routine.kind)], + ), + boxShadow: const [ + BoxShadow( + color: Color(0x08000000), + blurRadius: 18, + offset: Offset(0, 4), + ), + ], + ), + clipBehavior: Clip.antiAlias, + child: Stack( + children: [ + Positioned( + right: -26, + bottom: 34, + child: Icon( + kindIcon(routine.kind), + size: 150, + color: colorWhite.withValues(alpha: 0.08), + ), + ), + Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + pill(kindLabel(routine.kind), colorWhite, + colorWhite.withValues(alpha: 0.14), + textSize: 9), + if (routine.commitmentClass == + CommitmentClass.NonNegotiable) ...[ + const SizedBox(width: 6), + pill("Must hold", colorWhite, + colorWhite.withValues(alpha: 0.14), + textSize: 9), + ], + const Spacer(), + Container( + width: 34, + height: 34, + alignment: Alignment.center, + decoration: BoxDecoration( + color: colorWhite, + shape: BoxShape.circle, + ), + child: Icon(CupertinoIcons.play_fill, + size: 14, color: kindDeep(routine.kind)), + ), + ], + ), + const Spacer(), + if (routine.startMinute >= 0) ...[ + text( + "${formatStartMinute(routine.startMinute)} · ${formatDays(routine.days)}" + .toUpperCase(), + 9, + TextType.Bold, + color: label, + letterSpacing: 1.0, + ), + const SizedBox(height: 6), + ], + text(routine.title, 21, TextType.Light, + color: colorWhite, + maxLines: 2, + overflow: TextOverflow.ellipsis, + height: 1.15), + const SizedBox(height: 14), + Container( + height: 1, + color: colorWhite.withValues(alpha: 0.12), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: labelled( + "Minutes", + "${(routine.totalSeconds / 60).round()}", + valueSize: 17, + valueColor: colorWhite, + labelColor: label, + ), + ), + Expanded( + child: labelled( + "Steps", + "${routine.steps.length}", + valueSize: 17, + valueColor: colorWhite, + labelColor: label, + ), + ), + Expanded( + child: labelled( + routine.kind == RoutineKind.Workout ? "Sets" : "Starts", + routine.kind == RoutineKind.Workout + ? "${routine.totalSets}" + : routine.startMinute < 0 + ? "Any" + : formatStartMinute(routine.startMinute), + valueSize: 17, + valueColor: colorWhite, + labelColor: label, + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _routineRow(RoutineChain routine) { + final Color tone = kindColor(routine.kind); + final bool weighted = routine.commitmentClass != CommitmentClass.Standard; + + final List eyebrow = [ + if (weighted) classLabel(routine.commitmentClass), + if (routine.startMinute >= 0) formatStartMinute(routine.startMinute), + formatDays(routine.days), + ]; + + return Container( + margin: const EdgeInsets.only(bottom: 10), + child: card( + padding: const EdgeInsets.fromLTRB(10, 10, 14, 10), + onTap: () => _onPreview(routine), + child: Row( + children: [ + Container( + width: 58, + height: 58, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [kindBackground(routine.kind), colorWhite], + ), + border: Border.all(color: tone.withValues(alpha: 0.15)), + ), + alignment: Alignment.center, + child: Icon(kindIcon(routine.kind), size: 24, color: tone), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + text( + eyebrow.join(" · ").toUpperCase(), + 9, + TextType.Bold, + color: weighted + ? classColor(routine.commitmentClass) + : colorGrey2, + letterSpacing: 0.9, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 5), + text(routine.title, 15, TextType.Medium, + color: colorPrimaryDark, + maxLines: 1, + overflow: TextOverflow.ellipsis), + const SizedBox(height: 6), + Row( + children: [ + _meta(CupertinoIcons.clock, + formatMinutes(routine.totalSeconds / 60)), + const SizedBox(width: 12), + _countMeta(routine), + ], + ), + ], + ), + ), + const SizedBox(width: 10), + routine.complete + ? Container( + width: 44, + height: 44, + alignment: Alignment.center, + decoration: BoxDecoration( + color: tone, + shape: BoxShape.circle, + ), + child: Icon(Icons.check_rounded, + size: 22, color: colorWhite), + ) + : ProgressRing( + fraction: routine.partialCompletion, + fill: tone, + ), + ], + ), + ), + ); + } + + Widget _countMeta(RoutineChain routine) { + switch (routine.kind) { + case RoutineKind.Workout: + return _meta(CupertinoIcons.repeat, "${routine.totalSets} sets"); + case RoutineKind.Meditation: + return _meta(CupertinoIcons.circle_grid_hex, + "${routine.steps.length} steps"); + case RoutineKind.MustDo: + return _meta(CupertinoIcons.checkmark_square, + "${routine.steps.length} tasks"); + } + } + + Widget _meta(IconData icon, String value) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 12, color: colorGrey2), + const SizedBox(width: 4), + text(value, 11, TextType.Regular, color: colorGrey2), + ], + ); + } + + /// The preview: the whole chain laid out before you commit to starting it. + Widget _previewSheet(BuildContext sheetContext, RoutineChain routine) { + final Color tone = kindColor(routine.kind); + + return Container( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(sheetContext).size.height * 0.86, + ), + decoration: const BoxDecoration( + color: colorSheetBackground, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(28), + topRight: Radius.circular(28), + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: Container( + width: 38, + height: 4, + margin: const EdgeInsets.only(top: 10), + decoration: BoxDecoration( + color: colorGrey.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(999), + ), + ), + ), + Flexible( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(24, 20, 24, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + pill(kindLabel(routine.kind), tone, + kindBackground(routine.kind), + icon: kindIcon(routine.kind), textSize: 9), + const SizedBox(width: 6), + pill(classLabel(routine.commitmentClass), + classColor(routine.commitmentClass), + classBackground(routine.commitmentClass), + textSize: 9), + ], + ), + const SizedBox(height: 14), + displayTitle(routine.title, size: 30), + if (routine.note.isNotEmpty) ...[ + const SizedBox(height: 8), + text(routine.note, 13, TextType.Regular, + color: colorGrey2, height: 1.5), + ], + const SizedBox(height: 20), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: statTile("Minutes", + "${(routine.totalSeconds / 60).round()}")), + Expanded( + child: statTile("Steps", "${routine.steps.length}")), + Expanded( + child: statTile( + "When", + routine.startMinute < 0 + ? "Any" + : formatStartMinute(routine.startMinute), + caption: formatDays(routine.days), + ), + ), + ], + ), + const SizedBox(height: 24), + sectionBreak("The chain", + caption: routine.kind == RoutineKind.Workout + ? "${routine.totalSets} sets, in order" + : "${routine.steps.length} steps, in order"), + ...routine.steps + .asMap() + .entries + .map((entry) => _previewStep(entry.key, entry.value, tone)), + ], + ), + ), + ), + SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 8, 24, 16), + child: Row( + children: [ + Expanded( + child: outlinedActionButton("Edit", () { + Navigator.pop(sheetContext); + _onEdit(routine); + }, icon: CupertinoIcons.pencil), + ), + const SizedBox(width: 10), + Expanded( + flex: 2, + child: roundedCornerButton("Start", () { + Navigator.pop(sheetContext); + _onStart(routine); + }, + icon: CupertinoIcons.play_fill, + enabled: routine.steps.isNotEmpty), + ), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _previewStep(int index, RoutineStep step, Color tone) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 28, + height: 28, + alignment: Alignment.center, + decoration: BoxDecoration( + color: tone.withValues(alpha: 0.10), + shape: BoxShape.circle, + ), + child: text("${index + 1}", 11, TextType.Bold, color: tone), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 4), + text(step.title, 14, TextType.Medium, color: colorPrimaryDark), + const SizedBox(height: 3), + text(stepSummary(step), 11, TextType.Regular, + color: colorGrey2), + ], + ), + ), + Padding( + padding: const EdgeInsets.only(top: 5), + child: Icon(stepModeIcon(step.mode), size: 15, color: colorGrey), + ), + ], + ), + ); + } + + // ── ConnectRoutines ─────────────────────────────────────────────────────── + + @override + void onRoutinesLoaded(List routines) { + setState(() { + _routines = routines; + }); + } +} diff --git a/frontend/lib/Grounded/see/routine/StepEditor.dart b/frontend/lib/Grounded/see/routine/StepEditor.dart new file mode 100644 index 0000000..084259e --- /dev/null +++ b/frontend/lib/Grounded/see/routine/StepEditor.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; + +import '../../about/external/data/RoutineStep.dart'; +import '../../about/internal/application/RoutineKind.dart'; +import 'StepEditorState.dart'; + +/// The bottom sheet for one step. Pops with the edited [RoutineStep], with +/// `false` when the step is removed, or with nothing when dismissed. +class StepEditor extends StatefulWidget { + /// The step being edited; null adds a new one. + final RoutineStep? step; + + /// Decides the defaults for a new step. + final RoutineKind kind; + + const StepEditor({super.key, this.step, required this.kind}); + + @override + State createState() => StepEditorState(); +} diff --git a/frontend/lib/Grounded/see/routine/StepEditorState.dart b/frontend/lib/Grounded/see/routine/StepEditorState.dart new file mode 100644 index 0000000..5d4c4d1 --- /dev/null +++ b/frontend/lib/Grounded/see/routine/StepEditorState.dart @@ -0,0 +1,362 @@ +import 'package:flutter/cupertino.dart'; + +import '../../about/external/data/RoutineStep.dart'; +import '../../about/internal/application/RoutineKind.dart'; +import '../../about/internal/application/StepMode.dart'; +import '../../about/internal/application/TextType.dart'; +import '../../designs/buttons/Buttons.dart'; +import '../../designs/input/CountStepper.dart'; +import '../../designs/input/InputFields.dart'; +import '../../designs/text/Text.dart'; +import '../../utils/Colors.dart'; +import '../../utils/CommonUtils.dart'; +import '../../utils/Validators.dart'; +import 'StepEditor.dart'; + +class StepEditorState extends State { + final GlobalKey _formKey = GlobalKey(); + + final TextEditingController _title = TextEditingController(); + + final TextEditingController _cue = TextEditingController(); + + late RoutineStep _step; + + bool get _editing { + return widget.step != null; + } + + @override + void initState() { + super.initState(); + + _step = widget.step == null + ? _defaultsFor(widget.kind) + : RoutineStep.fromJson(widget.step!.toJson()); + + _title.text = _step.title; + _cue.text = _step.cue; + } + + /// A new step starts as the kind's most common shape, so most edits are + /// just a title. + RoutineStep _defaultsFor(RoutineKind kind) { + switch (kind) { + case RoutineKind.Workout: + return RoutineStep( + mode: StepMode.Reps, + sets: 3, + reps: 10, + timerSeconds: 40, + restSeconds: 60); + case RoutineKind.Meditation: + return RoutineStep(mode: StepMode.Timed, timerSeconds: 300); + case RoutineKind.MustDo: + return RoutineStep(mode: StepMode.Check, timerSeconds: 120); + } + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), + child: Container( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.9, + ), + decoration: const BoxDecoration( + color: colorSheetBackground, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(28), + topRight: Radius.circular(28), + ), + ), + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 28), + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + text(_editing ? "EDIT STEP" : "NEW STEP", 9, TextType.Bold, + color: kindColor(widget.kind), letterSpacing: 1.2), + const SizedBox(height: 10), + text(_title.text.isEmpty ? "What happens here?" : _title.text, + 24, TextType.Light, + color: colorPrimaryDark, height: 1.2), + const SizedBox(height: 20), + inputField( + "Title", + _title, + hint: _titleHint(), + validator: Validators.title, + onChanged: _onTitleChanged, + ), + const SizedBox(height: 20), + text("MODE", 10, TextType.Bold, + color: colorGrey2, letterSpacing: 0.8), + const SizedBox(height: 8), + segmentedSelector( + options: StepMode.values, + selected: _step.mode, + label: stepModeLabel, + onSelected: _onModeSelected, + activeColor: kindColor(widget.kind), + ), + const SizedBox(height: 8), + text(_modeDescription(_step.mode), 12, TextType.Regular, + color: colorGrey2, height: 1.5), + const SizedBox(height: 20), + ..._numbers(), + const SizedBox(height: 20), + inputField( + "Cue", + _cue, + hint: widget.kind == RoutineKind.Meditation + ? "In 4 · hold 4 · out 4" + : "One line to read mid-step (optional)", + ), + const SizedBox(height: 16), + _summaryLine(), + const SizedBox(height: 20), + roundedCornerButton( + _editing ? "Update step" : "Add step", + _onSave, + background: colorPrimaryDark, + icon: _editing ? CupertinoIcons.checkmark : CupertinoIcons.add, + ), + if (_editing) ...[ + const SizedBox(height: 6), + Center( + child: textButton("Remove step", _onRemove, + color: colorDestructive, textSize: 13), + ), + ], + ], + ), + ), + ), + ), + ); + } + + String _titleHint() { + switch (widget.kind) { + case RoutineKind.Workout: + return "Deadlifts, plank, warm up…"; + case RoutineKind.Meditation: + return "Settle, breathe, body scan…"; + case RoutineKind.MustDo: + return "Make the bed, plan tomorrow…"; + } + } + + String _modeDescription(StepMode mode) { + switch (mode) { + case StepMode.Timed: + return "The clock runs down and the next beat starts on its own."; + case StepMode.Reps: + return "You end each set when the reps are done. Rest is timed."; + case StepMode.Check: + return "A task. You tick it; the clock records how long it took."; + } + } + + /// The steppers that apply to the chosen mode. + List _numbers() { + final List rows = []; + + switch (_step.mode) { + case StepMode.Timed: + rows.add(_pair( + countStepper("Duration", formatSeconds(_step.timerSeconds), + onMinus: _onDurationMinus, + onPlus: _onDurationPlus, + canMinus: _step.timerSeconds > 5), + countStepper("Sets", "${_step.sets}", + onMinus: _onSetsMinus, + onPlus: _onSetsPlus, + canMinus: _step.sets > 1), + )); + break; + case StepMode.Reps: + rows.add(_pair( + countStepper("Reps", "${_step.reps}", + onMinus: _onRepsMinus, + onPlus: _onRepsPlus, + canMinus: _step.reps > 1), + countStepper("Sets", "${_step.sets}", + onMinus: _onSetsMinus, + onPlus: _onSetsPlus, + canMinus: _step.sets > 1), + )); + rows.add(const SizedBox(height: 10)); + rows.add(countStepper("Est. per set", formatSeconds(_step.timerSeconds), + onMinus: _onDurationMinus, + onPlus: _onDurationPlus, + canMinus: _step.timerSeconds > 5)); + break; + case StepMode.Check: + rows.add(countStepper("Estimate", formatSeconds(_step.timerSeconds), + onMinus: _onDurationMinus, + onPlus: _onDurationPlus, + canMinus: _step.timerSeconds > 5)); + break; + } + + if (_step.mode != StepMode.Check) { + rows.add(const SizedBox(height: 10)); + rows.add(countStepper( + "Rest after each set", + _step.restSeconds == 0 ? "None" : formatSeconds(_step.restSeconds), + onMinus: _onRestMinus, + onPlus: _onRestPlus, + canMinus: _step.restSeconds > 0, + )); + } + + return rows; + } + + Widget _pair(Widget left, Widget right) { + return Row( + children: [ + Expanded(child: left), + const SizedBox(width: 10), + Expanded(child: right), + ], + ); + } + + Widget _summaryLine() { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration( + color: kindBackground(widget.kind), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon(stepModeIcon(_step.mode), + size: 15, color: kindColor(widget.kind)), + const SizedBox(width: 10), + Expanded( + child: text(stepSummary(_step), 12, TextType.Medium, + color: colorPrimaryDark), + ), + text("~${formatSeconds(_step.plannedSeconds)}", 12, TextType.Bold, + color: kindColor(widget.kind)), + ], + ), + ); + } + + // ── Handlers ────────────────────────────────────────────────────────────── + + void _onTitleChanged(String value) { + setState(() {}); + } + + void _onModeSelected(StepMode mode) { + setState(() { + _step.mode = mode; + if (mode == StepMode.Reps && _step.reps == 0) { + _step.reps = 10; + } + if (mode == StepMode.Check) { + _step.sets = 1; + _step.restSeconds = 0; + } + }); + } + + /// Steps get coarser as the duration grows: 5s near the bottom, a minute + /// once you are into meditation lengths. + int _durationIncrement(int seconds) { + if (seconds < 60) { + return 5; + } + if (seconds < 180) { + return 15; + } + if (seconds < 600) { + return 30; + } + return 60; + } + + void _onDurationMinus() { + setState(() { + final int next = + _step.timerSeconds - _durationIncrement(_step.timerSeconds - 1); + _step.timerSeconds = next < 5 ? 5 : next; + }); + } + + void _onDurationPlus() { + setState(() { + _step.timerSeconds = + _step.timerSeconds + _durationIncrement(_step.timerSeconds); + }); + } + + void _onSetsMinus() { + setState(() { + _step.sets = _step.sets > 1 ? _step.sets - 1 : 1; + }); + } + + void _onSetsPlus() { + setState(() { + _step.sets = _step.sets + 1; + }); + } + + void _onRepsMinus() { + setState(() { + _step.reps = _step.reps > 1 ? _step.reps - 1 : 1; + }); + } + + void _onRepsPlus() { + setState(() { + _step.reps = _step.reps + 1; + }); + } + + void _onRestMinus() { + setState(() { + final int next = _step.restSeconds - 15; + _step.restSeconds = next < 0 ? 0 : next; + }); + } + + void _onRestPlus() { + setState(() { + _step.restSeconds = _step.restSeconds + 15; + }); + } + + void _onSave() { + if (_formKey.currentState?.validate() != true) { + return; + } + + _step.title = _title.text.trim(); + _step.cue = _cue.text.trim(); + + Navigator.pop(context, _step); + } + + void _onRemove() { + Navigator.pop(context, false); + } + + @override + void dispose() { + _title.dispose(); + _cue.dispose(); + super.dispose(); + } +} diff --git a/frontend/lib/Grounded/see/routine/ViewRoutineBuilder.dart b/frontend/lib/Grounded/see/routine/ViewRoutineBuilder.dart new file mode 100644 index 0000000..b8795ed --- /dev/null +++ b/frontend/lib/Grounded/see/routine/ViewRoutineBuilder.dart @@ -0,0 +1,57 @@ +import '../../about/external/data/RoutineChain.dart'; +import '../../about/external/data/pages/request/HistoryRequest.dart'; +import '../../about/external/data/pages/request/PageAndSort.dart'; +import '../../about/external/data/pages/request/Pageable.dart'; +import '../../about/external/data/pages/request/Sort.dart'; +import '../../utils/ObjectConvertors.dart'; +import '../parent/ParentViewModel.dart'; +import 'ConnectRoutineBuilder.dart'; + +class ViewRoutineBuilder extends ParentViewModel { + ConnectRoutineBuilder connection; + + ViewRoutineBuilder(super.context, this.connection); + + /// Goals for the picker — loaded on tap, never up front. + void loadGoals() async { + if (!await hasNetwork(() => loadGoals())) return; + + showLoading("Loading goals"); + + try { + final response = await getDataManager().getMyGoals(HistoryRequest( + query: PageAndSort( + sort: Sort('desc', 'startDate'), + page: Pageable(0, 0, 50, 0), + ), + )); + + closeLoading(); + + connection.onGoalsReady(getGoalList(response.data)); + } catch (e) { + handleError(e, () => loadGoals(), () => dismissError(), "Retry"); + } + } + + void save(RoutineChain routine) async { + if (!await hasNetwork(() => save(routine))) return; + + showLoading("Saving routine"); + + try { + for (int index = 0; index < routine.steps.length; index++) { + routine.steps[index].order = index; + routine.steps[index].completed = false; + } + + await getDataManager().saveRoutineEntry(routine.toJson()); + + closeLoading(); + + connection.onSaved(); + } catch (e) { + handleError(e, () => save(routine), () => dismissError(), "Retry"); + } + } +} diff --git a/frontend/lib/Grounded/see/routine/ViewRoutineRunner.dart b/frontend/lib/Grounded/see/routine/ViewRoutineRunner.dart new file mode 100644 index 0000000..f0f40d6 --- /dev/null +++ b/frontend/lib/Grounded/see/routine/ViewRoutineRunner.dart @@ -0,0 +1,60 @@ +import '../../about/external/data/LiveSession.dart'; +import '../../about/external/initial/RoutineLogRequest.dart'; +import '../../about/internal/application/RoutineRun.dart'; +import '../../configs/NotificationServiceConfig.dart'; +import '../parent/ParentViewModel.dart'; +import 'ConnectRoutineRunner.dart'; + +class ViewRoutineRunner extends ParentViewModel { + ConnectRoutineRunner connection; + + ViewRoutineRunner(super.context, this.connection); + + /// Mirrors the current phase into the ongoing notification, through the + /// same [LiveSession] shape the single-task runner uses. + void publishRun(RoutineRun run) { + LocalNotificationEngine.showSessionNotification(LiveSession( + commitmentId: run.chain.id ?? "", + title: run.phase.rest ? "Rest" : run.phase.step.title, + goalTitle: run.chain.title, + startedAt: run.startedAt, + accumulatedSeconds: run.phaseElapsed(), + resumedAt: run.running ? DateTime.now() : null, + requiredSeconds: run.phase.seconds, + backgroundedCount: run.backgroundedCount, + )); + } + + void clearRun() { + LocalNotificationEngine.cancelSessionNotification(); + } + + /// Held or broken, the run is always logged — a broken chain goes in as + /// partial at the step it broke on. + void log(RoutineRun run) async { + if (!await hasNetwork(() => log(run))) return; + + showLoading("Recording"); + + try { + await getDataManager().logRoutineChainEntry(RoutineLogRequest( + routineId: run.chain.id ?? "", + startedAt: run.startedAt.toIso8601String(), + elapsedSeconds: run.elapsedSeconds(), + completedSteps: run.chain.completedSteps, + totalSteps: run.chain.steps.length, + breakIndex: run.breakIndex, + backgroundedCount: run.backgroundedCount, + skippedCount: run.skippedCount, + ).toJson()); + + clearRun(); + + closeLoading(); + + connection.onLogged(run.chain.complete); + } catch (e) { + handleError(e, () => log(run), () => dismissError(), "Retry"); + } + } +} diff --git a/frontend/lib/Grounded/see/routine/ViewRoutines.dart b/frontend/lib/Grounded/see/routine/ViewRoutines.dart new file mode 100644 index 0000000..6b7a41b --- /dev/null +++ b/frontend/lib/Grounded/see/routine/ViewRoutines.dart @@ -0,0 +1,37 @@ +import '../../about/external/data/RoutineChain.dart'; +import '../../about/external/data/pages/request/HistoryRequest.dart'; +import '../../about/external/data/pages/request/PageAndSort.dart'; +import '../../about/external/data/pages/request/Pageable.dart'; +import '../../about/external/data/pages/request/Sort.dart'; +import '../../utils/ObjectConvertors.dart'; +import '../parent/ParentViewModel.dart'; +import 'ConnectRoutines.dart'; + +class ViewRoutines extends ParentViewModel { + ConnectRoutines connection; + + ViewRoutines(super.context, this.connection); + + void loadRoutines() async { + if (!await hasNetwork(() => loadRoutines())) return; + + showLoading("Loading your routines"); + + try { + final response = await getDataManager().getMyRoutines(HistoryRequest( + query: PageAndSort( + sort: Sort('asc', 'startMinute'), + page: Pageable(0, 0, 50, 0), + ), + )); + + final List routines = getRoutineChainList(response.data); + + closeLoading(); + + connection.onRoutinesLoaded(routines); + } catch (e) { + handleError(e, () => loadRoutines(), () => dismissError(), "Retry"); + } + } +} diff --git a/frontend/lib/Grounded/utils/Colors.dart b/frontend/lib/Grounded/utils/Colors.dart index a171ee0..b42508c 100644 --- a/frontend/lib/Grounded/utils/Colors.dart +++ b/frontend/lib/Grounded/utils/Colors.dart @@ -75,6 +75,33 @@ const Color colorClassStandardBg = Color(0xFFE0E8EF); const Color colorClassElective = Color(0xFF6E6E6E); const Color colorClassElectiveBg = Color(0xFFEDEDED); +// ── Routine kinds ───────────────────────────────────────────────────────────── +// Kind colours tint the routine library and the runner stage. They are kept +// clear of the standing palette so a workout never reads as a warning. +const Color colorKindWorkout = Color(0xFF2F6F4E); +const Color colorKindWorkoutBg = Color(0xFFE4EFE8); +const Color colorKindWorkoutDeep = Color(0xFF173D2A); +const Color colorKindMeditation = Color(0xFF4F5D86); +const Color colorKindMeditationBg = Color(0xFFE5E8F2); +const Color colorKindMeditationDeep = Color(0xFF232A45); +const Color colorKindMustDo = Color(0xFF3A3A3A); +const Color colorKindMustDoBg = Color(0xFFEAEAE6); +const Color colorKindMustDoDeep = Color(0xFF1E1E1E); + +// ── Goal accents ────────────────────────────────────────────────────────────── +// The swatches a goal can wear. Muted on purpose — a goal is an ambition, not +// an alarm, so none of these collide with the standing tiers. +const List goalAccents = [ + Color(0xFF141414), + Color(0xFF2F6F4E), + Color(0xFF2C4A63), + Color(0xFF4F5D86), + Color(0xFF6B4F86), + Color(0xFF8A5A2B), + Color(0xFF5E6B3A), + Color(0xFF3F6E73), +]; + // ── Debt / charting ─────────────────────────────────────────────────────────── const Color colorDebtLine = Color(0xFFB5442F); const Color colorDebtFill = Color(0x1AB5442F); diff --git a/frontend/lib/Grounded/utils/CommonUtils.dart b/frontend/lib/Grounded/utils/CommonUtils.dart index cf1d2bc..edb83ce 100644 --- a/frontend/lib/Grounded/utils/CommonUtils.dart +++ b/frontend/lib/Grounded/utils/CommonUtils.dart @@ -1,9 +1,13 @@ +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import '../about/external/data/Commitment.dart'; +import '../about/external/data/RoutineStep.dart'; import '../about/internal/application/CommitmentClass.dart'; +import '../about/internal/application/RoutineKind.dart'; import '../about/internal/application/Standing.dart'; +import '../about/internal/application/StepMode.dart'; import 'Colors.dart'; /// Formatting and small shared derivations. Anything a screen would otherwise @@ -186,3 +190,165 @@ String hourLabel(int hour) { } return "${hour - 12}pm"; } + +Color kindColor(RoutineKind value) { + switch (value) { + case RoutineKind.Workout: + return colorKindWorkout; + case RoutineKind.Meditation: + return colorKindMeditation; + case RoutineKind.MustDo: + return colorKindMustDo; + } +} + +Color kindBackground(RoutineKind value) { + switch (value) { + case RoutineKind.Workout: + return colorKindWorkoutBg; + case RoutineKind.Meditation: + return colorKindMeditationBg; + case RoutineKind.MustDo: + return colorKindMustDoBg; + } +} + +/// The dark end of the kind's gradient — featured cards and the runner stage. +Color kindDeep(RoutineKind value) { + switch (value) { + case RoutineKind.Workout: + return colorKindWorkoutDeep; + case RoutineKind.Meditation: + return colorKindMeditationDeep; + case RoutineKind.MustDo: + return colorKindMustDoDeep; + } +} + +IconData kindIcon(RoutineKind value) { + switch (value) { + case RoutineKind.Workout: + return Icons.fitness_center_rounded; + case RoutineKind.Meditation: + return Icons.self_improvement_rounded; + case RoutineKind.MustDo: + return CupertinoIcons.checkmark_seal_fill; + } +} + +IconData stepModeIcon(StepMode value) { + switch (value) { + case StepMode.Timed: + return CupertinoIcons.timer; + case StepMode.Reps: + return CupertinoIcons.repeat; + case StepMode.Check: + return CupertinoIcons.checkmark_square; + } +} + +/// "45s", "2m", "1m 30s" — for step durations, where a clock face would be +/// noise. +String formatSeconds(int seconds) { + final int safe = seconds < 0 ? 0 : seconds; + if (safe < 60) { + return "${safe}s"; + } + final int minutes = safe ~/ 60; + final int remainder = safe % 60; + if (remainder == 0) { + return "${minutes}m"; + } + return "${minutes}m ${remainder}s"; +} + +/// "07:30" from minutes after midnight; empty when unset. +String formatStartMinute(int minute) { + if (minute < 0) { + return ""; + } + final int hour = minute ~/ 60; + final int rest = minute % 60; + return "${hour.toString().padLeft(2, '0')}:${rest.toString().padLeft(2, '0')}"; +} + +/// "M T W" style day initials for a 1..7 index. +String weekdayInitial(int weekday) { + const List initials = ["M", "T", "W", "T", "F", "S", "S"]; + if (weekday < 1 || weekday > 7) { + return ""; + } + return initials[weekday - 1]; +} + +/// "Weekdays", "Every day", "Mon · Wed · Fri" — a schedule read at a glance. +String formatDays(List days) { + if (days.isEmpty) { + return "On demand"; + } + final List sorted = List.from(days)..sort(); + if (sorted.length == 7) { + return "Every day"; + } + if (sorted.length == 5 && sorted.every((day) => day <= 5)) { + return "Weekdays"; + } + if (sorted.length == 2 && sorted.contains(6) && sorted.contains(7)) { + return "Weekends"; + } + return sorted.map((day) => weekdayName(day).substring(0, 3)).join(" · "); +} + +/// "4 × 12 reps · rest 90s" — one line that says what a step asks of you. +String stepSummary(RoutineStep step) { + final List parts = []; + + switch (step.mode) { + case StepMode.Timed: + parts.add(step.sets > 1 + ? "${step.sets} × ${formatSeconds(step.timerSeconds)}" + : formatSeconds(step.timerSeconds)); + break; + case StepMode.Reps: + parts.add("${step.sets} × ${step.reps} reps"); + break; + case StepMode.Check: + parts.add(step.timerSeconds > 0 + ? "Tick · ~${formatSeconds(step.timerSeconds)}" + : "Tick"); + break; + } + + if (step.restSeconds > 0) { + parts.add("rest ${formatSeconds(step.restSeconds)}"); + } + + return parts.join(" · "); +} + +/// A goal's accent, falling back to ink when it has none. +Color goalColor(String colourHex) { + if (colourHex.isEmpty) { + return colorPrimaryDark; + } + return getColorFromHex(colourHex); +} + +/// "41 days left", "Due today", "12 days over" — the deadline read as a +/// distance, which is what actually changes behaviour. +String targetLabel(DateTime? target) { + if (target == null) { + return ""; + } + final DateTime now = DateTime.now(); + final DateTime today = DateTime(now.year, now.month, now.day); + final DateTime day = DateTime(target.year, target.month, target.day); + final int days = day.difference(today).inDays; + if (days == 0) { + return "Due today"; + } + if (days < 0) { + return "${-days} day${days == -1 ? "" : "s"} over"; + } + return "$days day${days == 1 ? "" : "s"} left"; +} diff --git a/frontend/lib/Grounded/utils/ObjectConvertors.dart b/frontend/lib/Grounded/utils/ObjectConvertors.dart index 09024aa..f339854 100644 --- a/frontend/lib/Grounded/utils/ObjectConvertors.dart +++ b/frontend/lib/Grounded/utils/ObjectConvertors.dart @@ -7,6 +7,7 @@ import '../about/external/data/Goal.dart'; import '../about/external/data/Habit.dart'; import '../about/external/data/Program.dart'; import '../about/external/data/RoutineChain.dart'; +import '../about/external/data/RoutineStep.dart'; import '../about/external/data/SessionLog.dart'; import '../about/external/data/SessionTemplate.dart'; import '../about/external/data/SetLog.dart'; @@ -71,6 +72,13 @@ List getRoutineChainList(dynamic data) { return (data as List).map((item) => RoutineChain.fromJson(item)).toList(); } +List getRoutineStepList(dynamic data) { + if (data == null) { + return []; + } + return (data as List).map((item) => RoutineStep.fromJson(item)).toList(); +} + List getProgramList(dynamic data) { if (data == null) { return []; diff --git a/frontend/lib/Grounded/utils/RoutineTemplates.dart b/frontend/lib/Grounded/utils/RoutineTemplates.dart new file mode 100644 index 0000000..58932e0 --- /dev/null +++ b/frontend/lib/Grounded/utils/RoutineTemplates.dart @@ -0,0 +1,232 @@ +import '../about/external/data/RoutineChain.dart'; +import '../about/external/data/RoutineStep.dart'; +import '../about/internal/application/CommitmentClass.dart'; +import '../about/internal/application/RoutineKind.dart'; +import '../about/internal/application/StepMode.dart'; + +/// Starting points for the routine builder. A blank routine is a harder +/// thing to commit to than one you only have to adjust. +class RoutineTemplates { + static List forKind(RoutineKind kind) { + switch (kind) { + case RoutineKind.Workout: + return [fullBody(), upperPush(), mobility()]; + case RoutineKind.Meditation: + return [boxBreathing(), bodyScan()]; + case RoutineKind.MustDo: + return [morning(), shutdown()]; + } + } + + static RoutineChain fullBody() { + return RoutineChain( + title: "Full body strength", + kind: RoutineKind.Workout, + days: [1, 3, 5], + startMinute: 7 * 60, + steps: [ + RoutineStep( + title: "Warm up", + mode: StepMode.Timed, + timerSeconds: 300, + cue: "Easy pace. Raise the heart rate, not the effort."), + RoutineStep( + title: "Deadlifts", + mode: StepMode.Reps, + sets: 4, + reps: 6, + timerSeconds: 45, + restSeconds: 120, + cue: "Brace, then push the floor away."), + RoutineStep( + title: "Goblet squats", + mode: StepMode.Reps, + sets: 3, + reps: 10, + timerSeconds: 40, + restSeconds: 90), + RoutineStep( + title: "Push-ups", + mode: StepMode.Reps, + sets: 3, + reps: 12, + timerSeconds: 30, + restSeconds: 60), + RoutineStep( + title: "Plank", + mode: StepMode.Timed, + sets: 3, + timerSeconds: 45, + restSeconds: 30, + cue: "Ribs down. Squeeze everything."), + RoutineStep( + title: "Cool down", + mode: StepMode.Timed, + timerSeconds: 180), + ], + ); + } + + static RoutineChain upperPush() { + return RoutineChain( + title: "Shoulders & chest", + kind: RoutineKind.Workout, + days: [2, 4], + startMinute: 18 * 60, + steps: [ + RoutineStep( + title: "Band pull-aparts", + mode: StepMode.Reps, + sets: 2, + reps: 15, + timerSeconds: 30, + restSeconds: 30), + RoutineStep( + title: "Overhead press", + mode: StepMode.Reps, + sets: 4, + reps: 8, + timerSeconds: 40, + restSeconds: 90), + RoutineStep( + title: "Incline press", + mode: StepMode.Reps, + sets: 3, + reps: 10, + timerSeconds: 40, + restSeconds: 90), + RoutineStep( + title: "Lateral raises", + mode: StepMode.Reps, + sets: 3, + reps: 15, + timerSeconds: 35, + restSeconds: 45), + RoutineStep( + title: "Dips", + mode: StepMode.Reps, + sets: 3, + reps: 10, + timerSeconds: 35, + restSeconds: 60), + ], + ); + } + + static RoutineChain mobility() { + return RoutineChain( + title: "Mobility flow", + kind: RoutineKind.Workout, + commitmentClass: CommitmentClass.Elective, + steps: [ + RoutineStep( + title: "Cat-cow", mode: StepMode.Timed, timerSeconds: 60), + RoutineStep( + title: "World's greatest stretch", + mode: StepMode.Timed, + sets: 2, + timerSeconds: 45, + restSeconds: 10), + RoutineStep( + title: "Hip 90/90", mode: StepMode.Timed, timerSeconds: 90), + RoutineStep( + title: "Thoracic rotations", + mode: StepMode.Timed, + timerSeconds: 60), + ], + ); + } + + static RoutineChain boxBreathing() { + return RoutineChain( + title: "Box breathing", + kind: RoutineKind.Meditation, + days: [1, 2, 3, 4, 5, 6, 7], + startMinute: 6 * 60 + 30, + steps: [ + RoutineStep( + title: "Settle", + mode: StepMode.Timed, + timerSeconds: 60, + cue: "Sit tall. Let the breath find its own pace."), + RoutineStep( + title: "Box breathing", + mode: StepMode.Timed, + timerSeconds: 300, + cue: "In 4 · hold 4 · out 4 · hold 4"), + RoutineStep( + title: "Open awareness", + mode: StepMode.Timed, + timerSeconds: 120, + cue: "Notice sounds, then let them go."), + ], + ); + } + + static RoutineChain bodyScan() { + return RoutineChain( + title: "Evening body scan", + kind: RoutineKind.Meditation, + commitmentClass: CommitmentClass.Elective, + startMinute: 22 * 60, + steps: [ + RoutineStep( + title: "Feet to hips", + mode: StepMode.Timed, + timerSeconds: 240, + cue: "Move attention slowly. Nothing to fix."), + RoutineStep( + title: "Torso and arms", + mode: StepMode.Timed, + timerSeconds: 240), + RoutineStep( + title: "Neck and face", mode: StepMode.Timed, timerSeconds: 180), + ], + ); + } + + static RoutineChain morning() { + return RoutineChain( + title: "Morning non-negotiables", + kind: RoutineKind.MustDo, + commitmentClass: CommitmentClass.NonNegotiable, + days: [1, 2, 3, 4, 5], + startMinute: 6 * 60, + steps: [ + RoutineStep( + title: "Out of bed, no phone", + mode: StepMode.Check, + timerSeconds: 60), + RoutineStep( + title: "Glass of water", mode: StepMode.Check, timerSeconds: 60), + RoutineStep( + title: "Make the bed", mode: StepMode.Check, timerSeconds: 120), + RoutineStep( + title: "Write the three things for today", + mode: StepMode.Check, + timerSeconds: 300), + ], + ); + } + + static RoutineChain shutdown() { + return RoutineChain( + title: "Evening shutdown", + kind: RoutineKind.MustDo, + days: [1, 2, 3, 4, 5], + startMinute: 21 * 60 + 30, + steps: [ + RoutineStep( + title: "Clear the desk", mode: StepMode.Check, timerSeconds: 180), + RoutineStep( + title: "Plan tomorrow's first task", + mode: StepMode.Check, + timerSeconds: 300), + RoutineStep( + title: "Phone on the charger, outside the bedroom", + mode: StepMode.Check, + timerSeconds: 60), + ], + ); + } +} diff --git a/frontend/lib/Grounded/utils/Thresholds.dart b/frontend/lib/Grounded/utils/Thresholds.dart index 68d6744..6744988 100644 --- a/frontend/lib/Grounded/utils/Thresholds.dart +++ b/frontend/lib/Grounded/utils/Thresholds.dart @@ -36,4 +36,12 @@ class Thresholds { /// Minutes a push can go unacknowledged before SMS fallback fires. static const int smsFallbackMinutes = 20; + + /// Routine runner: the last seconds of a timed phase tick with a haptic, so + /// the change of phase never arrives unannounced. + static const int routineCountdownSeconds = 3; + + /// A routine stopped before this fraction of its steps is logged as broken + /// rather than partial. + static const double routinePartialFloor = 0.5; } diff --git a/frontend/test/routine_run_test.dart b/frontend/test/routine_run_test.dart new file mode 100644 index 0000000..1506a23 --- /dev/null +++ b/frontend/test/routine_run_test.dart @@ -0,0 +1,65 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:Grounded/Grounded/about/external/data/RoutineChain.dart'; +import 'package:Grounded/Grounded/about/external/data/RoutineStep.dart'; +import 'package:Grounded/Grounded/about/internal/application/RoutineRun.dart'; +import 'package:Grounded/Grounded/about/internal/application/StepMode.dart'; + +RoutineChain _chain() { + return RoutineChain(title: "Test", steps: [ + RoutineStep(title: "Squat", mode: StepMode.Reps, sets: 2, reps: 5, restSeconds: 30), + RoutineStep(title: "Plank", mode: StepMode.Timed, sets: 1, timerSeconds: 45, restSeconds: 15), + ]); +} + +void main() { + test('phases interleave rest, with none after the final set', () { + final RoutineRun run = RoutineRun(_chain()); + final List shape = run.phases + .map((p) => p.rest ? "rest" : "${p.step.title}${p.set}") + .toList(); + expect(shape, ["Squat1", "rest", "Squat2", "rest", "Plank1"]); + }); + + test('clock is wall-clock derived and pauses cleanly', () { + final RoutineRun run = RoutineRun(_chain()); + final DateTime t0 = DateTime(2026, 1, 1, 7); + run.resume(now: t0); + run.pause(now: t0.add(const Duration(seconds: 20))); + expect(run.elapsedSeconds(), 20); + expect(run.phaseElapsed(), 20); + run.resume(now: t0.add(const Duration(seconds: 100))); + expect(run.elapsedSeconds(now: t0.add(const Duration(seconds: 105))), 25); + }); + + test('step completes on its last set; skips are counted and break the chain', () { + final RoutineRun run = RoutineRun(_chain()); + run.advance(); // Squat1 -> rest + run.advance(); // rest -> Squat2 + expect(run.chain.steps[0].completed, false); + run.advance(); // Squat2 done -> rest + expect(run.chain.steps[0].completed, true); + run.advance(); // rest -> Plank1 + expect(run.advance(skipped: true), false); // end of run + expect(run.skippedCount, 1); + expect(run.chain.complete, false); + expect(run.breakIndex, 1); + }); + + test('back skips over rest to the previous work phase', () { + final RoutineRun run = RoutineRun(_chain()); + run.advance(); + run.advance(); // Squat2 + run.back(); + expect(run.phase.set, 1); + expect(run.phase.rest, false); + }); + + test('planned time includes rest after every set', () { + final RoutineChain chain = RoutineChain(steps: [ + RoutineStep(mode: StepMode.Timed, sets: 3, timerSeconds: 40, restSeconds: 20), + ]); + expect(chain.totalSeconds, 180); + expect(RoutineChain.fromJson(chain.toJson()).steps.first.restSeconds, 20); + }); +}