Add routines module and full goal management
Routines: workout, meditation and must-do routines with timed, rep and tick steps, sets and rest. A library screen, a builder with templates and drag-to-reorder steps, and a full-screen player that auto-advances timed phases, counts backgrounding and logs broken chains as partial. Goals: a goal editor (deadline, colour, category, default class, archive), routines linked to goals, and a goal field on tasks and routines. Tasks added from inside a goal are now actually attached to it via parentId. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KhQh614DMeYoQEH9SoMRUw
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
212
frontend/lib/Grounded/about/internal/application/RoutineRun.dart
Normal file
212
frontend/lib/Grounded/about/internal/application/RoutineRun.dart
Normal file
@@ -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<RoutinePhase> 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<RoutinePhase> buildPhases(RoutineChain chain) {
|
||||
final List<RoutinePhase> result = <RoutinePhase>[];
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user