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:
alvocool
2026-11-02 08:45:41 +03:00
parent c88887b639
commit 1a5867a98f
49 changed files with 5384 additions and 220 deletions

View File

@@ -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<int> days;
/// Minutes after midnight the routine is due to start; -1 when unset.
int startMinute;
String note;
List<RoutineStep> 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<int>? days,
this.startMinute = -1,
this.note = "",
List<RoutineStep>? steps,
this.lastBreakIndex = -1,
}) : steps = steps ?? <RoutineStep>[];
}) : days = days ?? <int>[],
steps = steps ?? <RoutineStep>[];
factory RoutineChain.fromJson(Map<String, dynamic> json) {
return RoutineChain(
id: json['id'],
title: json['title'] ?? "",
steps: json['steps'] == null
? <RoutineStep>[]
: (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
? <int>[]
: (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<String, dynamic> data = <String, dynamic>{};
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<String, dynamic> 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<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
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());
}
}

View File

@@ -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<String, dynamic> 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<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
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;
}
}

View File

@@ -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<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['id'] = id;
data['parentId'] = parentId;
data['type'] = type;
data['commitmentClass'] = commitmentClass;
data['title'] = title;

View File

@@ -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<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
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;
}
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View 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;
}
}

View File

@@ -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;
}