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,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";
}