Files
GroundedHelper/frontend/lib/Grounded/utils/CommonUtils.dart
alvocool 1a5867a98f 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
2026-11-02 08:45:41 +03:00

355 lines
9.3 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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