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
213 lines
5.5 KiB
Dart
213 lines
5.5 KiB
Dart
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;
|
|
}
|
|
}
|