Groups let people keep each other honest: create one or join with a six-letter invite code. Each group has shared goals split into measurable objectives (km, sessions, words) that every member logs toward, a leaderboard of kept rate and streak with nudges, and a group chat where check-ins and nudges appear as cards beside the talk. The open chat polls for new messages. Reached from a new Groups tab (Training moves into the day rail), a Home "Your groups" rail that opens the chat, and a Group tile in the add sheet. Mock mode seeds two groups, a joinable one and members who reply. The water tracker is removed; it did not fit the app. Bottom sheets are now square app-wide via the theme. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KhQh614DMeYoQEH9SoMRUw
424 lines
11 KiB
Dart
424 lines
11 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";
|
||
}
|
||
|
||
/// The painted landscape a routine kind wears on its cards.
|
||
List<Color> scenePalette(RoutineKind value) {
|
||
switch (value) {
|
||
case RoutineKind.Workout:
|
||
return sceneWorkout;
|
||
case RoutineKind.Meditation:
|
||
return sceneMeditation;
|
||
case RoutineKind.MustDo:
|
||
return sceneMustDo;
|
||
}
|
||
}
|
||
|
||
/// A landscape tinted from a goal's accent, so a goal's cards read as its own.
|
||
List<Color> goalScene(Color accent) {
|
||
final HSLColor base = HSLColor.fromColor(accent);
|
||
Color shade(double lightness, double saturation) => base
|
||
.withLightness(lightness)
|
||
.withSaturation((base.saturation * saturation).clamp(0.0, 1.0))
|
||
.toColor();
|
||
|
||
return <Color>[
|
||
shade(0.80, 0.55),
|
||
const Color(0xFFF6EFE2),
|
||
const Color(0xFFFFD9A0),
|
||
shade(0.66, 0.6),
|
||
shade(0.48, 0.8),
|
||
shade(0.30, 0.9),
|
||
const Color(0xFFE5793D),
|
||
];
|
||
}
|
||
|
||
/// The landscape for a task row, chosen by how much the task weighs.
|
||
List<Color> classScene(CommitmentClass value) {
|
||
switch (value) {
|
||
case CommitmentClass.NonNegotiable:
|
||
return sceneMustDo;
|
||
case CommitmentClass.Standard:
|
||
return sceneMeditation;
|
||
case CommitmentClass.Elective:
|
||
return sceneWorkout;
|
||
}
|
||
}
|
||
|
||
/// A stable seed from any string, so the same item always gets the same scene.
|
||
int sceneSeed(String key) {
|
||
int hash = 7;
|
||
for (int unit in key.codeUnits) {
|
||
hash = (hash * 31 + unit) & 0x7fffffff;
|
||
}
|
||
return hash;
|
||
}
|
||
|
||
/// An amount the way a person reads it: whole numbers with thousands
|
||
/// separators, anything else to one decimal place.
|
||
String formatAmount(double value) {
|
||
if (value == value.roundToDouble()) {
|
||
final String digits = value.round().abs().toString();
|
||
final StringBuffer out = StringBuffer(value < 0 ? "-" : "");
|
||
for (int index = 0; index < digits.length; index++) {
|
||
if (index > 0 && (digits.length - index) % 3 == 0) {
|
||
out.write(",");
|
||
}
|
||
out.write(digits[index]);
|
||
}
|
||
return out.toString();
|
||
}
|
||
return value.toStringAsFixed(1);
|
||
}
|