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
355 lines
9.3 KiB
Dart
355 lines
9.3 KiB
Dart
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
|
||
/// inline twice belongs here.
|
||
|
||
/// "Mon 06:00–08:00" — the window, which is the whole point.
|
||
String formatWindow(Commitment commitment) {
|
||
if (commitment.dueStart == null || commitment.dueEnd == null) {
|
||
return "No window set";
|
||
}
|
||
|
||
final DateFormat day = DateFormat('EEE');
|
||
final DateFormat time = DateFormat('HH:mm');
|
||
|
||
final String startDay = day.format(commitment.dueStart!);
|
||
final String startTime = time.format(commitment.dueStart!);
|
||
final String endTime = time.format(commitment.dueEnd!);
|
||
|
||
final bool sameDay = commitment.dueStart!.day == commitment.dueEnd!.day &&
|
||
commitment.dueStart!.month == commitment.dueEnd!.month;
|
||
|
||
if (sameDay) {
|
||
return "$startDay $startTime–$endTime";
|
||
}
|
||
|
||
return "$startDay $startTime – ${day.format(commitment.dueEnd!)} $endTime";
|
||
}
|
||
|
||
String formatDate(DateTime? value) {
|
||
if (value == null) {
|
||
return "";
|
||
}
|
||
return DateFormat('d MMM yyyy').format(value);
|
||
}
|
||
|
||
String formatDateTime(DateTime? value) {
|
||
if (value == null) {
|
||
return "";
|
||
}
|
||
return DateFormat('d MMM, HH:mm').format(value);
|
||
}
|
||
|
||
/// "3h 20m" from raw minutes.
|
||
String formatMinutes(num minutes) {
|
||
final int total = minutes.round();
|
||
if (total < 60) {
|
||
return "${total}m";
|
||
}
|
||
final int hours = total ~/ 60;
|
||
final int remainder = total % 60;
|
||
if (remainder == 0) {
|
||
return "${hours}h";
|
||
}
|
||
return "${hours}h ${remainder}m";
|
||
}
|
||
|
||
/// "12:04" from raw seconds — for the proof timer and rest clocks.
|
||
String formatClock(int seconds) {
|
||
final int safe = seconds < 0 ? 0 : seconds;
|
||
final int minutes = safe ~/ 60;
|
||
final int remainder = safe % 60;
|
||
return "${minutes.toString().padLeft(2, '0')}:${remainder.toString().padLeft(2, '0')}";
|
||
}
|
||
|
||
/// Debt is shown to one decimal — precise enough to move visibly when you
|
||
/// clear something, coarse enough not to look like a lie.
|
||
String formatDebt(double debt) {
|
||
return debt.toStringAsFixed(1);
|
||
}
|
||
|
||
/// "2 days overdue", "Due in 40m", "Window closes in 3h".
|
||
String overdueLabel(Commitment commitment) {
|
||
if (commitment.dueEnd == null) {
|
||
return "";
|
||
}
|
||
|
||
final Duration difference = DateTime.now().difference(commitment.dueEnd!);
|
||
|
||
if (difference.isNegative) {
|
||
final Duration remaining = difference.abs();
|
||
if (remaining.inHours < 1) {
|
||
return "Closes in ${remaining.inMinutes}m";
|
||
}
|
||
if (remaining.inDays < 1) {
|
||
return "Closes in ${remaining.inHours}h";
|
||
}
|
||
return "Closes in ${remaining.inDays}d";
|
||
}
|
||
|
||
if (difference.inHours < 1) {
|
||
return "${difference.inMinutes}m overdue";
|
||
}
|
||
if (difference.inDays < 1) {
|
||
return "${difference.inHours}h overdue";
|
||
}
|
||
if (difference.inDays == 1) {
|
||
return "1 day overdue";
|
||
}
|
||
return "${difference.inDays} days overdue";
|
||
}
|
||
|
||
Color standingColor(Standing standing) {
|
||
switch (standing) {
|
||
case Standing.Good:
|
||
return colorStandingGood;
|
||
case Standing.Warned:
|
||
return colorStandingWarned;
|
||
case Standing.Grounded:
|
||
return colorStandingGrounded;
|
||
case Standing.Lockdown:
|
||
return colorStandingLockdown;
|
||
}
|
||
}
|
||
|
||
Color standingBackground(Standing standing) {
|
||
switch (standing) {
|
||
case Standing.Good:
|
||
return colorStandingGoodBg;
|
||
case Standing.Warned:
|
||
return colorStandingWarnedBg;
|
||
case Standing.Grounded:
|
||
return colorStandingGroundedBg;
|
||
case Standing.Lockdown:
|
||
return colorStandingLockdownBg;
|
||
}
|
||
}
|
||
|
||
Color classColor(CommitmentClass value) {
|
||
switch (value) {
|
||
case CommitmentClass.NonNegotiable:
|
||
return colorClassNonNegotiable;
|
||
case CommitmentClass.Standard:
|
||
return colorClassStandard;
|
||
case CommitmentClass.Elective:
|
||
return colorClassElective;
|
||
}
|
||
}
|
||
|
||
Color classBackground(CommitmentClass value) {
|
||
switch (value) {
|
||
case CommitmentClass.NonNegotiable:
|
||
return colorClassNonNegotiableBg;
|
||
case CommitmentClass.Standard:
|
||
return colorClassStandardBg;
|
||
case CommitmentClass.Elective:
|
||
return colorClassElectiveBg;
|
||
}
|
||
}
|
||
|
||
/// The weekday name for a 1..7 index.
|
||
String weekdayName(int weekday) {
|
||
const List<String> names = <String>[
|
||
"Monday",
|
||
"Tuesday",
|
||
"Wednesday",
|
||
"Thursday",
|
||
"Friday",
|
||
"Saturday",
|
||
"Sunday",
|
||
];
|
||
if (weekday < 1 || weekday > 7) {
|
||
return "";
|
||
}
|
||
return names[weekday - 1];
|
||
}
|
||
|
||
/// "7pm" for an hour-of-day index.
|
||
String hourLabel(int hour) {
|
||
if (hour < 0) {
|
||
return "";
|
||
}
|
||
if (hour == 0) {
|
||
return "midnight";
|
||
}
|
||
if (hour < 12) {
|
||
return "${hour}am";
|
||
}
|
||
if (hour == 12) {
|
||
return "noon";
|
||
}
|
||
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";
|
||
}
|