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

@@ -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<Color> goalAccents = <Color>[
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);

View File

@@ -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<String> initials = <String>["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<int> days) {
if (days.isEmpty) {
return "On demand";
}
final List<int> sorted = List<int>.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<String> parts = <String>[];
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";
}

View File

@@ -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<RoutineChain> getRoutineChainList(dynamic data) {
return (data as List).map((item) => RoutineChain.fromJson(item)).toList();
}
List<RoutineStep> getRoutineStepList(dynamic data) {
if (data == null) {
return <RoutineStep>[];
}
return (data as List).map((item) => RoutineStep.fromJson(item)).toList();
}
List<Program> getProgramList(dynamic data) {
if (data == null) {
return <Program>[];

View File

@@ -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<RoutineChain> forKind(RoutineKind kind) {
switch (kind) {
case RoutineKind.Workout:
return <RoutineChain>[fullBody(), upperPush(), mobility()];
case RoutineKind.Meditation:
return <RoutineChain>[boxBreathing(), bodyScan()];
case RoutineKind.MustDo:
return <RoutineChain>[morning(), shutdown()];
}
}
static RoutineChain fullBody() {
return RoutineChain(
title: "Full body strength",
kind: RoutineKind.Workout,
days: <int>[1, 3, 5],
startMinute: 7 * 60,
steps: <RoutineStep>[
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: <int>[2, 4],
startMinute: 18 * 60,
steps: <RoutineStep>[
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>[
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: <int>[1, 2, 3, 4, 5, 6, 7],
startMinute: 6 * 60 + 30,
steps: <RoutineStep>[
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>[
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: <int>[1, 2, 3, 4, 5],
startMinute: 6 * 60,
steps: <RoutineStep>[
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: <int>[1, 2, 3, 4, 5],
startMinute: 21 * 60 + 30,
steps: <RoutineStep>[
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),
],
);
}
}

View File

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