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:
@@ -1,3 +1,9 @@
|
||||
import '../../internal/application/CommitmentClass.dart';
|
||||
import '../../internal/application/RoutineKind.dart';
|
||||
import '../../internal/application/StepMode.dart';
|
||||
import '../../../utils/ObjectConvertors.dart';
|
||||
import 'RoutineStep.dart';
|
||||
|
||||
/// An ordered sequence where the chain, not the step, is the unit of
|
||||
/// completion. Breaking mid-way logs partial.
|
||||
class RoutineChain {
|
||||
@@ -5,6 +11,24 @@ class RoutineChain {
|
||||
|
||||
String title;
|
||||
|
||||
/// The goal this routine serves — "Workout" holds "Full body strength".
|
||||
/// Null for a routine that stands on its own.
|
||||
String? goalId;
|
||||
|
||||
RoutineKind kind;
|
||||
|
||||
/// Weight class, exactly as for a commitment — a non-negotiable morning
|
||||
/// routine carries non-negotiable debt when it breaks.
|
||||
CommitmentClass commitmentClass;
|
||||
|
||||
/// 1..7, Monday first. Empty means unscheduled — run on demand only.
|
||||
List<int> days;
|
||||
|
||||
/// Minutes after midnight the routine is due to start; -1 when unset.
|
||||
int startMinute;
|
||||
|
||||
String note;
|
||||
|
||||
List<RoutineStep> steps;
|
||||
|
||||
/// Index of the step reached when the chain last broke; -1 when clean.
|
||||
@@ -13,19 +37,30 @@ class RoutineChain {
|
||||
RoutineChain({
|
||||
this.id,
|
||||
this.title = "",
|
||||
this.goalId,
|
||||
this.kind = RoutineKind.Workout,
|
||||
this.commitmentClass = CommitmentClass.Standard,
|
||||
List<int>? days,
|
||||
this.startMinute = -1,
|
||||
this.note = "",
|
||||
List<RoutineStep>? steps,
|
||||
this.lastBreakIndex = -1,
|
||||
}) : steps = steps ?? <RoutineStep>[];
|
||||
}) : days = days ?? <int>[],
|
||||
steps = steps ?? <RoutineStep>[];
|
||||
|
||||
factory RoutineChain.fromJson(Map<String, dynamic> json) {
|
||||
return RoutineChain(
|
||||
id: json['id'],
|
||||
title: json['title'] ?? "",
|
||||
steps: json['steps'] == null
|
||||
? <RoutineStep>[]
|
||||
: (json['steps'] as List)
|
||||
.map((item) => RoutineStep.fromJson(item))
|
||||
.toList(),
|
||||
goalId: json['goalId'],
|
||||
kind: getRoutineKind(json['kind']),
|
||||
commitmentClass: getCommitmentClass(json['commitmentClass']),
|
||||
days: json['days'] == null
|
||||
? <int>[]
|
||||
: (json['days'] as List).map((item) => item as int).toList(),
|
||||
startMinute: json['startMinute'] ?? -1,
|
||||
note: json['note'] ?? "",
|
||||
steps: getRoutineStepList(json['steps']),
|
||||
lastBreakIndex: json['lastBreakIndex'] ?? -1,
|
||||
);
|
||||
}
|
||||
@@ -34,6 +69,12 @@ class RoutineChain {
|
||||
final Map<String, dynamic> data = <String, dynamic>{};
|
||||
data['id'] = id;
|
||||
data['title'] = title;
|
||||
data['goalId'] = goalId;
|
||||
data['kind'] = kind.name;
|
||||
data['commitmentClass'] = commitmentClass.name;
|
||||
data['days'] = days;
|
||||
data['startMinute'] = startMinute;
|
||||
data['note'] = note;
|
||||
data['steps'] = steps.map((item) => item.toJson()).toList();
|
||||
data['lastBreakIndex'] = lastBreakIndex;
|
||||
return data;
|
||||
@@ -54,44 +95,40 @@ class RoutineChain {
|
||||
bool get complete {
|
||||
return steps.isNotEmpty && completedSteps == steps.length;
|
||||
}
|
||||
|
||||
bool get scheduled {
|
||||
return days.isNotEmpty;
|
||||
}
|
||||
|
||||
class RoutineStep {
|
||||
String? id;
|
||||
|
||||
String title;
|
||||
|
||||
int order;
|
||||
|
||||
int timerSeconds;
|
||||
|
||||
bool completed;
|
||||
|
||||
RoutineStep({
|
||||
this.id,
|
||||
this.title = "",
|
||||
this.order = 0,
|
||||
this.timerSeconds = 0,
|
||||
this.completed = false,
|
||||
});
|
||||
|
||||
factory RoutineStep.fromJson(Map<String, dynamic> json) {
|
||||
return RoutineStep(
|
||||
id: json['id'],
|
||||
title: json['title'] ?? "",
|
||||
order: json['order'] ?? 0,
|
||||
timerSeconds: json['timerSeconds'] ?? 0,
|
||||
completed: json['completed'] ?? false,
|
||||
);
|
||||
bool scheduledOn(DateTime day) {
|
||||
return days.contains(day.weekday);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final Map<String, dynamic> data = <String, dynamic>{};
|
||||
data['id'] = id;
|
||||
data['title'] = title;
|
||||
data['order'] = order;
|
||||
data['timerSeconds'] = timerSeconds;
|
||||
data['completed'] = completed;
|
||||
return data;
|
||||
/// Every set of every step, for the "12 sets" figure.
|
||||
int get totalSets {
|
||||
int total = 0;
|
||||
for (RoutineStep step in steps) {
|
||||
total = total + step.sets;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/// Planned seconds, rest included. Rep and tick steps count their estimate.
|
||||
int get totalSeconds {
|
||||
int total = 0;
|
||||
for (RoutineStep step in steps) {
|
||||
total = total + step.plannedSeconds;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
int get timedSteps {
|
||||
return steps.where((step) => step.mode == StepMode.Timed).length;
|
||||
}
|
||||
|
||||
/// A deep copy for the builder, so cancelling an edit leaves the library
|
||||
/// untouched.
|
||||
RoutineChain copy() {
|
||||
return RoutineChain.fromJson(toJson());
|
||||
}
|
||||
}
|
||||
|
||||
75
frontend/lib/Grounded/about/external/data/RoutineStep.dart
vendored
Normal file
75
frontend/lib/Grounded/about/external/data/RoutineStep.dart
vendored
Normal file
@@ -0,0 +1,75 @@
|
||||
import '../../internal/application/StepMode.dart';
|
||||
|
||||
class RoutineStep {
|
||||
String? id;
|
||||
|
||||
String title;
|
||||
|
||||
int order;
|
||||
|
||||
StepMode mode;
|
||||
|
||||
/// Work seconds per set for timed steps; the estimate per set otherwise.
|
||||
int timerSeconds;
|
||||
|
||||
int sets;
|
||||
|
||||
/// Target per set for rep steps; 0 otherwise.
|
||||
int reps;
|
||||
|
||||
/// Rest between sets, and after the last set before the next step.
|
||||
int restSeconds;
|
||||
|
||||
/// One line shown under the clock — a form cue, a breathing pattern.
|
||||
String cue;
|
||||
|
||||
bool completed;
|
||||
|
||||
RoutineStep({
|
||||
this.id,
|
||||
this.title = "",
|
||||
this.order = 0,
|
||||
this.mode = StepMode.Timed,
|
||||
this.timerSeconds = 0,
|
||||
this.sets = 1,
|
||||
this.reps = 0,
|
||||
this.restSeconds = 0,
|
||||
this.cue = "",
|
||||
this.completed = false,
|
||||
});
|
||||
|
||||
factory RoutineStep.fromJson(Map<String, dynamic> json) {
|
||||
return RoutineStep(
|
||||
id: json['id'],
|
||||
title: json['title'] ?? "",
|
||||
order: json['order'] ?? 0,
|
||||
mode: getStepMode(json['mode']),
|
||||
timerSeconds: json['timerSeconds'] ?? 0,
|
||||
sets: json['sets'] ?? 1,
|
||||
reps: json['reps'] ?? 0,
|
||||
restSeconds: json['restSeconds'] ?? 0,
|
||||
cue: json['cue'] ?? "",
|
||||
completed: json['completed'] ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final Map<String, dynamic> data = <String, dynamic>{};
|
||||
data['id'] = id;
|
||||
data['title'] = title;
|
||||
data['order'] = order;
|
||||
data['mode'] = mode.name;
|
||||
data['timerSeconds'] = timerSeconds;
|
||||
data['sets'] = sets;
|
||||
data['reps'] = reps;
|
||||
data['restSeconds'] = restSeconds;
|
||||
data['cue'] = cue;
|
||||
data['completed'] = completed;
|
||||
return data;
|
||||
}
|
||||
|
||||
int get plannedSeconds {
|
||||
final int safeSets = sets < 1 ? 1 : sets;
|
||||
return timerSeconds * safeSets + restSeconds * safeSets;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,9 @@
|
||||
class CommitmentRequest {
|
||||
String? id;
|
||||
|
||||
/// The goal this task belongs to; null for a loose task.
|
||||
String? parentId;
|
||||
|
||||
String type;
|
||||
|
||||
String commitmentClass;
|
||||
@@ -26,6 +29,7 @@ class CommitmentRequest {
|
||||
|
||||
CommitmentRequest({
|
||||
this.id,
|
||||
this.parentId,
|
||||
this.type = "TASK",
|
||||
this.commitmentClass = "Standard",
|
||||
this.title = "",
|
||||
@@ -42,6 +46,7 @@ class CommitmentRequest {
|
||||
Map<String, dynamic> toJson() {
|
||||
final Map<String, dynamic> data = <String, dynamic>{};
|
||||
data['id'] = id;
|
||||
data['parentId'] = parentId;
|
||||
data['type'] = type;
|
||||
data['commitmentClass'] = commitmentClass;
|
||||
data['title'] = title;
|
||||
|
||||
45
frontend/lib/Grounded/about/external/initial/RoutineLogRequest.dart
vendored
Normal file
45
frontend/lib/Grounded/about/external/initial/RoutineLogRequest.dart
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
/// One run of a routine. Sent whether the chain held or broke — a broken
|
||||
/// chain is logged as partial, never silently dropped.
|
||||
class RoutineLogRequest {
|
||||
String routineId;
|
||||
|
||||
String startedAt;
|
||||
|
||||
/// Foreground seconds only. Backgrounding pauses the run.
|
||||
int elapsedSeconds;
|
||||
|
||||
int completedSteps;
|
||||
|
||||
int totalSteps;
|
||||
|
||||
/// Step index the chain broke at; -1 when it held.
|
||||
int breakIndex;
|
||||
|
||||
int backgroundedCount;
|
||||
|
||||
int skippedCount;
|
||||
|
||||
RoutineLogRequest({
|
||||
this.routineId = "",
|
||||
this.startedAt = "",
|
||||
this.elapsedSeconds = 0,
|
||||
this.completedSteps = 0,
|
||||
this.totalSteps = 0,
|
||||
this.breakIndex = -1,
|
||||
this.backgroundedCount = 0,
|
||||
this.skippedCount = 0,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final Map<String, dynamic> data = <String, dynamic>{};
|
||||
data['routineId'] = routineId;
|
||||
data['startedAt'] = startedAt;
|
||||
data['elapsedSeconds'] = elapsedSeconds;
|
||||
data['completedSteps'] = completedSteps;
|
||||
data['totalSteps'] = totalSteps;
|
||||
data['breakIndex'] = breakIndex;
|
||||
data['backgroundedCount'] = backgroundedCount;
|
||||
data['skippedCount'] = skippedCount;
|
||||
return data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/// What a routine is for. The kind decides the runner's stage — a barbell
|
||||
/// clock for work, a breathing circle for stillness, a checklist for the
|
||||
/// things that simply have to get done.
|
||||
enum RoutineKind {
|
||||
/// Sets, reps and rest. Timed work auto-advances into rest.
|
||||
Workout,
|
||||
|
||||
/// Long timed holds with a breathing cue. No reps, rarely any rest.
|
||||
Meditation,
|
||||
|
||||
/// An ordered list of tasks that have to be done, in order, every time.
|
||||
MustDo,
|
||||
}
|
||||
|
||||
String kindLabel(RoutineKind value) {
|
||||
switch (value) {
|
||||
case RoutineKind.Workout:
|
||||
return "Workout";
|
||||
case RoutineKind.Meditation:
|
||||
return "Meditation";
|
||||
case RoutineKind.MustDo:
|
||||
return "Must-do";
|
||||
}
|
||||
}
|
||||
|
||||
String kindDescription(RoutineKind value) {
|
||||
switch (value) {
|
||||
case RoutineKind.Workout:
|
||||
return "Sets, reps and timed rest.";
|
||||
case RoutineKind.Meditation:
|
||||
return "Timed sits with a breathing cue.";
|
||||
case RoutineKind.MustDo:
|
||||
return "Tasks, in order, every time.";
|
||||
}
|
||||
}
|
||||
|
||||
RoutineKind getRoutineKind(String? name) {
|
||||
for (RoutineKind value in RoutineKind.values) {
|
||||
if (value.name == name) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return RoutineKind.Workout;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import '../../external/data/RoutineStep.dart';
|
||||
import 'StepMode.dart';
|
||||
|
||||
/// One beat of a running routine — a single work set or the rest after it.
|
||||
/// The runner walks a flat list of these, so "set 2 of 4" and "rest" are the
|
||||
/// same kind of thing to the clock.
|
||||
class RoutinePhase {
|
||||
/// Index of the step this phase belongs to.
|
||||
final int stepIndex;
|
||||
|
||||
final RoutineStep step;
|
||||
|
||||
/// 1-based set number within the step.
|
||||
final int set;
|
||||
|
||||
final bool rest;
|
||||
|
||||
RoutinePhase({
|
||||
required this.stepIndex,
|
||||
required this.step,
|
||||
this.set = 1,
|
||||
this.rest = false,
|
||||
});
|
||||
|
||||
/// Seconds the clock runs for. Zero means the phase waits for a tap.
|
||||
int get seconds {
|
||||
if (rest) {
|
||||
return step.restSeconds;
|
||||
}
|
||||
return step.mode == StepMode.Timed ? step.timerSeconds : 0;
|
||||
}
|
||||
|
||||
bool get countsDown {
|
||||
return seconds > 0;
|
||||
}
|
||||
|
||||
bool get lastSet {
|
||||
return set >= step.sets;
|
||||
}
|
||||
}
|
||||
212
frontend/lib/Grounded/about/internal/application/RoutineRun.dart
Normal file
212
frontend/lib/Grounded/about/internal/application/RoutineRun.dart
Normal file
@@ -0,0 +1,212 @@
|
||||
import '../../external/data/RoutineChain.dart';
|
||||
import '../../external/data/RoutineStep.dart';
|
||||
import 'RoutinePhase.dart';
|
||||
|
||||
/// A routine being run. Like [LiveSession], every clock is derived from
|
||||
/// wall-clock rather than counted by a ticker, so a screen-off period cannot
|
||||
/// lose or invent time.
|
||||
class RoutineRun {
|
||||
final RoutineChain chain;
|
||||
|
||||
final List<RoutinePhase> phases;
|
||||
|
||||
int phaseIndex = 0;
|
||||
|
||||
final DateTime startedAt;
|
||||
|
||||
/// Banked foreground seconds for the whole run.
|
||||
int _runSeconds = 0;
|
||||
|
||||
/// Banked foreground seconds for the current phase.
|
||||
int _phaseSeconds = 0;
|
||||
|
||||
/// Start of the active stretch; null while paused.
|
||||
DateTime? resumedAt;
|
||||
|
||||
/// Times the app was left mid-run. Shown afterwards, never hidden.
|
||||
int backgroundedCount = 0;
|
||||
|
||||
/// Phases skipped forward without being done.
|
||||
int skippedCount = 0;
|
||||
|
||||
RoutineRun(this.chain)
|
||||
: phases = buildPhases(chain),
|
||||
startedAt = DateTime.now();
|
||||
|
||||
/// Each set becomes a work phase; rest follows every set that has rest,
|
||||
/// except after the final set of the final step.
|
||||
static List<RoutinePhase> buildPhases(RoutineChain chain) {
|
||||
final List<RoutinePhase> result = <RoutinePhase>[];
|
||||
|
||||
for (int index = 0; index < chain.steps.length; index++) {
|
||||
final RoutineStep step = chain.steps[index];
|
||||
final int sets = step.sets < 1 ? 1 : step.sets;
|
||||
|
||||
for (int set = 1; set <= sets; set++) {
|
||||
result.add(RoutinePhase(stepIndex: index, step: step, set: set));
|
||||
|
||||
final bool finalBeat = index == chain.steps.length - 1 && set == sets;
|
||||
if (step.restSeconds > 0 && !finalBeat) {
|
||||
result.add(
|
||||
RoutinePhase(stepIndex: index, step: step, set: set, rest: true));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
RoutinePhase get phase {
|
||||
return phases[phaseIndex];
|
||||
}
|
||||
|
||||
bool get running {
|
||||
return resumedAt != null;
|
||||
}
|
||||
|
||||
bool get onLastPhase {
|
||||
return phaseIndex >= phases.length - 1;
|
||||
}
|
||||
|
||||
int _live(DateTime? now) {
|
||||
if (resumedAt == null) {
|
||||
return 0;
|
||||
}
|
||||
return (now ?? DateTime.now()).difference(resumedAt!).inSeconds;
|
||||
}
|
||||
|
||||
int elapsedSeconds({DateTime? now}) {
|
||||
return _runSeconds + _live(now);
|
||||
}
|
||||
|
||||
int phaseElapsed({DateTime? now}) {
|
||||
return _phaseSeconds + _live(now);
|
||||
}
|
||||
|
||||
int phaseRemaining({DateTime? now}) {
|
||||
final int remaining = phase.seconds - phaseElapsed(now: now);
|
||||
return remaining > 0 ? remaining : 0;
|
||||
}
|
||||
|
||||
/// 0..1 through the current phase; 0 for phases that wait for a tap.
|
||||
double phaseProgress({DateTime? now}) {
|
||||
if (!phase.countsDown) {
|
||||
return 0;
|
||||
}
|
||||
final double value = phaseElapsed(now: now) / phase.seconds;
|
||||
return value > 1 ? 1 : value;
|
||||
}
|
||||
|
||||
bool phaseFinished({DateTime? now}) {
|
||||
return phase.countsDown && phaseElapsed(now: now) >= phase.seconds;
|
||||
}
|
||||
|
||||
/// Seconds left in the whole run, by plan — elapsed work in this phase
|
||||
/// counts, phases still ahead count at their planned length.
|
||||
int plannedRemaining({DateTime? now}) {
|
||||
int total = phase.countsDown ? phaseRemaining(now: now) : 0;
|
||||
for (int index = phaseIndex + 1; index < phases.length; index++) {
|
||||
total = total + phases[index].seconds;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
void pause({DateTime? now}) {
|
||||
if (resumedAt == null) {
|
||||
return;
|
||||
}
|
||||
final int live = _live(now);
|
||||
_runSeconds = _runSeconds + live;
|
||||
_phaseSeconds = _phaseSeconds + live;
|
||||
resumedAt = null;
|
||||
}
|
||||
|
||||
void resume({DateTime? now}) {
|
||||
if (resumedAt != null) {
|
||||
return;
|
||||
}
|
||||
resumedAt = now ?? DateTime.now();
|
||||
}
|
||||
|
||||
/// Bank the current phase and start the next. Returns false at the end.
|
||||
/// A step is marked complete when its last set's work phase is done.
|
||||
bool advance({bool skipped = false, DateTime? now}) {
|
||||
final DateTime moment = now ?? DateTime.now();
|
||||
final bool wasRunning = running;
|
||||
|
||||
if (!phase.rest && phase.lastSet && !skipped) {
|
||||
phase.step.completed = true;
|
||||
}
|
||||
if (skipped && !phase.rest) {
|
||||
skippedCount = skippedCount + 1;
|
||||
}
|
||||
|
||||
pause(now: moment);
|
||||
_phaseSeconds = 0;
|
||||
|
||||
if (onLastPhase) {
|
||||
return false;
|
||||
}
|
||||
|
||||
phaseIndex = phaseIndex + 1;
|
||||
if (wasRunning) {
|
||||
resume(now: moment);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Back to the start of the previous work phase. Rest is never a target.
|
||||
void back({DateTime? now}) {
|
||||
final DateTime moment = now ?? DateTime.now();
|
||||
final bool wasRunning = running;
|
||||
|
||||
pause(now: moment);
|
||||
_phaseSeconds = 0;
|
||||
|
||||
int target = phaseIndex - 1;
|
||||
while (target > 0 && phases[target].rest) {
|
||||
target = target - 1;
|
||||
}
|
||||
phaseIndex = target < 0 ? 0 : target;
|
||||
phases[phaseIndex].step.completed = false;
|
||||
|
||||
if (wasRunning) {
|
||||
resume(now: moment);
|
||||
}
|
||||
}
|
||||
|
||||
/// Jump to the first set of [stepIndex].
|
||||
void jumpTo(int stepIndex, {DateTime? now}) {
|
||||
final int target =
|
||||
phases.indexWhere((item) => item.stepIndex == stepIndex && !item.rest);
|
||||
if (target < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
final DateTime moment = now ?? DateTime.now();
|
||||
final bool wasRunning = running;
|
||||
|
||||
pause(now: moment);
|
||||
_phaseSeconds = 0;
|
||||
phaseIndex = target;
|
||||
|
||||
if (wasRunning) {
|
||||
resume(now: moment);
|
||||
}
|
||||
}
|
||||
|
||||
/// The step index the chain broke at, for a partial log; -1 when clean.
|
||||
int get breakIndex {
|
||||
return chain.complete ? -1 : phase.stepIndex;
|
||||
}
|
||||
|
||||
/// The next work phase after this one, for the "up next" line.
|
||||
RoutinePhase? get nextWork {
|
||||
for (int index = phaseIndex + 1; index < phases.length; index++) {
|
||||
if (!phases[index].rest) {
|
||||
return phases[index];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/// How a single routine step is satisfied.
|
||||
enum StepMode {
|
||||
/// A clock runs down and the step advances on its own.
|
||||
Timed,
|
||||
|
||||
/// A rep target per set; the set ends when you say it does.
|
||||
Reps,
|
||||
|
||||
/// A task. Ticked, not timed — the clock still records how long it took.
|
||||
Check,
|
||||
}
|
||||
|
||||
String stepModeLabel(StepMode value) {
|
||||
switch (value) {
|
||||
case StepMode.Timed:
|
||||
return "Timed";
|
||||
case StepMode.Reps:
|
||||
return "Reps";
|
||||
case StepMode.Check:
|
||||
return "Tick";
|
||||
}
|
||||
}
|
||||
|
||||
StepMode getStepMode(String? name) {
|
||||
for (StepMode value in StepMode.values) {
|
||||
if (value.name == name) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return StepMode.Timed;
|
||||
}
|
||||
@@ -388,6 +388,13 @@ class Comms implements ConnectComms {
|
||||
return await dio.post(navigation.key, data: request);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Response> saveRoutineEntry(Map<String, dynamic> request) async {
|
||||
Pair navigation = await getRequestHeaders(saveRoutine, "");
|
||||
dio.options.headers = navigation.value;
|
||||
return await dio.post(navigation.key, data: request);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Response> logRoutineChainEntry(Map<String, dynamic> request) async {
|
||||
Pair navigation = await getRequestHeaders(logRoutineChain, "");
|
||||
|
||||
@@ -108,6 +108,8 @@ abstract class ConnectComms {
|
||||
|
||||
Future<Response> getMyRoutines(HistoryRequest request);
|
||||
|
||||
Future<Response> saveRoutineEntry(Map<String, dynamic> request);
|
||||
|
||||
Future<Response> logRoutineChainEntry(Map<String, dynamic> request);
|
||||
|
||||
// ── Training ────────────────────────────────────────────────────────────
|
||||
|
||||
127
frontend/lib/Grounded/designs/GoalPicker.dart
Normal file
127
frontend/lib/Grounded/designs/GoalPicker.dart
Normal file
@@ -0,0 +1,127 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../about/external/data/Goal.dart';
|
||||
import '../about/internal/application/TextType.dart';
|
||||
import '../utils/Colors.dart';
|
||||
import '../utils/CommonUtils.dart';
|
||||
import 'text/Text.dart';
|
||||
|
||||
/// The goal chooser, shared by the task form and the routine builder. Opened
|
||||
/// only once the goals have been loaded — `selectField` loads on tap.
|
||||
///
|
||||
/// Resolves to the chosen goal, to a bare `Goal()` (no id) for "no goal", or
|
||||
/// to null when dismissed without a choice.
|
||||
Future<Goal?> showGoalPicker(
|
||||
BuildContext context,
|
||||
List<Goal> goals, {
|
||||
String? selectedId,
|
||||
}) {
|
||||
return showModalBottomSheet<Goal>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
barrierColor: colorBlack.withValues(alpha: 0.55),
|
||||
builder: (BuildContext sheetContext) {
|
||||
final List<Goal> active = goals.where((goal) => !goal.archived).toList();
|
||||
|
||||
return Container(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(sheetContext).size.height * 0.75,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
color: colorSheetBackground,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(28),
|
||||
topRight: Radius.circular(28),
|
||||
),
|
||||
),
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 32),
|
||||
children: [
|
||||
text("BELONGS TO", 9, TextType.Bold,
|
||||
color: colorGrey2, letterSpacing: 1.2),
|
||||
const SizedBox(height: 8),
|
||||
text("Which goal is this for?", 24, TextType.Light,
|
||||
color: colorPrimaryDark),
|
||||
const SizedBox(height: 18),
|
||||
_goalOption(
|
||||
sheetContext,
|
||||
Goal(),
|
||||
"No goal",
|
||||
"Stands on its own",
|
||||
colorGrey,
|
||||
selectedId == null,
|
||||
),
|
||||
...active.map((goal) => _goalOption(
|
||||
sheetContext,
|
||||
goal,
|
||||
goal.title,
|
||||
goal.targetDate == null
|
||||
? "${goal.remainingTasks} open"
|
||||
: "${goal.remainingTasks} open · ${targetLabel(goal.targetDate)}",
|
||||
goalColor(goal.colourHex),
|
||||
goal.id == selectedId,
|
||||
)),
|
||||
if (active.isEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
text("No goals yet. Create one from the Goals screen.", 12,
|
||||
TextType.Regular,
|
||||
color: colorGrey2),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _goalOption(
|
||||
BuildContext sheetContext,
|
||||
Goal goal,
|
||||
String title,
|
||||
String caption,
|
||||
Color accent,
|
||||
bool selected,
|
||||
) {
|
||||
return GestureDetector(
|
||||
onTap: () => Navigator.pop(sheetContext, goal),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.fromLTRB(14, 13, 14, 13),
|
||||
decoration: BoxDecoration(
|
||||
color: colorWhite,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: selected ? accent.withValues(alpha: 0.5) : colorBorder,
|
||||
width: selected ? 1.5 : 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 12,
|
||||
height: 12,
|
||||
decoration: BoxDecoration(color: accent, shape: BoxShape.circle),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
text(title, 14, TextType.Medium, color: colorPrimaryDark),
|
||||
const SizedBox(height: 3),
|
||||
text(caption, 11, TextType.Regular, color: colorGrey2),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (selected)
|
||||
Icon(CupertinoIcons.checkmark_alt, size: 17, color: accent),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
116
frontend/lib/Grounded/designs/ProgressRing.dart
Normal file
116
frontend/lib/Grounded/designs/ProgressRing.dart
Normal file
@@ -0,0 +1,116 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../about/internal/application/TextType.dart';
|
||||
import '../utils/Colors.dart';
|
||||
import 'text/Text.dart';
|
||||
|
||||
/// A thin circular meter with its value in the middle — the routine row's
|
||||
/// "how far did I get" dial, and the runner's phase clock ring.
|
||||
class ProgressRing extends StatelessWidget {
|
||||
final double fraction;
|
||||
|
||||
final double size;
|
||||
|
||||
final double stroke;
|
||||
|
||||
final Color? fill;
|
||||
|
||||
final Color? track;
|
||||
|
||||
/// Drawn in the centre. Defaults to the percentage.
|
||||
final Widget? child;
|
||||
|
||||
const ProgressRing({
|
||||
super.key,
|
||||
required this.fraction,
|
||||
this.size = 44,
|
||||
this.stroke = 3,
|
||||
this.fill,
|
||||
this.track,
|
||||
this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final double clamped = fraction.isNaN ? 0 : fraction.clamp(0, 1).toDouble();
|
||||
|
||||
return SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: TweenAnimationBuilder<double>(
|
||||
tween: Tween<double>(end: clamped),
|
||||
duration: const Duration(milliseconds: 450),
|
||||
curve: Curves.easeOutCubic,
|
||||
builder: (context, value, _) {
|
||||
return CustomPaint(
|
||||
painter: _RingPainter(
|
||||
value,
|
||||
stroke,
|
||||
fill ?? colorPrimaryDark,
|
||||
track ?? colorMuted,
|
||||
),
|
||||
child: Center(
|
||||
child: child ??
|
||||
text("${(clamped * 100).round()}%", size * 0.22,
|
||||
TextType.Bold,
|
||||
color: colorPrimaryDark),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RingPainter extends CustomPainter {
|
||||
final double fraction;
|
||||
|
||||
final double stroke;
|
||||
|
||||
final Color fill;
|
||||
|
||||
final Color track;
|
||||
|
||||
_RingPainter(this.fraction, this.stroke, this.fill, this.track);
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final Offset centre = Offset(size.width / 2, size.height / 2);
|
||||
final double radius = (math.min(size.width, size.height) - stroke) / 2;
|
||||
|
||||
final Paint base = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = stroke
|
||||
..color = track;
|
||||
|
||||
canvas.drawCircle(centre, radius, base);
|
||||
|
||||
if (fraction <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
final Paint arc = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = stroke
|
||||
..strokeCap = StrokeCap.round
|
||||
..color = fill;
|
||||
|
||||
canvas.drawArc(
|
||||
Rect.fromCircle(center: centre, radius: radius),
|
||||
-math.pi / 2,
|
||||
2 * math.pi * fraction,
|
||||
false,
|
||||
arc,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_RingPainter old) {
|
||||
return old.fraction != fraction ||
|
||||
old.fill != fill ||
|
||||
old.track != track ||
|
||||
old.stroke != stroke;
|
||||
}
|
||||
}
|
||||
63
frontend/lib/Grounded/designs/input/CountStepper.dart
Normal file
63
frontend/lib/Grounded/designs/input/CountStepper.dart
Normal file
@@ -0,0 +1,63 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
|
||||
import '../../about/internal/application/TextType.dart';
|
||||
import '../../utils/Colors.dart';
|
||||
import '../text/Text.dart';
|
||||
|
||||
/// A labelled minus / value / plus control for sets, reps and seconds.
|
||||
/// Numbers are stepped, not typed — nobody wants a keyboard mid-routine-edit.
|
||||
Widget countStepper(
|
||||
String label,
|
||||
String value, {
|
||||
required VoidCallback onMinus,
|
||||
required VoidCallback onPlus,
|
||||
bool canMinus = true,
|
||||
}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(14, 10, 8, 10),
|
||||
decoration: BoxDecoration(
|
||||
color: colorWhite,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: colorBorder, width: 1),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
text(label.toUpperCase(), 9, TextType.Bold,
|
||||
color: colorGrey2, letterSpacing: 1.0),
|
||||
const SizedBox(height: 4),
|
||||
text(value, 17, TextType.Medium, color: colorPrimaryDark),
|
||||
],
|
||||
),
|
||||
),
|
||||
_stepButton(CupertinoIcons.minus, canMinus ? onMinus : null),
|
||||
const SizedBox(width: 6),
|
||||
_stepButton(CupertinoIcons.plus, onPlus),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _stepButton(IconData icon, VoidCallback? onTap) {
|
||||
final bool enabled = onTap != null;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
width: 34,
|
||||
height: 34,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: enabled ? colorSecondary : colorInset,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Icon(icon,
|
||||
size: 14, color: enabled ? colorPrimaryDark : colorGrey),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -296,6 +296,10 @@ class AppDataManager implements DataManager {
|
||||
Future<Response> getMyRoutines(HistoryRequest request) =>
|
||||
comms.getMyRoutines(request);
|
||||
|
||||
@override
|
||||
Future<Response> saveRoutineEntry(Map<String, dynamic> request) =>
|
||||
comms.saveRoutineEntry(request);
|
||||
|
||||
@override
|
||||
Future<Response> logRoutineChainEntry(Map<String, dynamic> request) =>
|
||||
comms.logRoutineChainEntry(request);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import '../../about/external/data/Goal.dart';
|
||||
import '../../utils/CapacityEngine.dart';
|
||||
|
||||
abstract class ConnectNewCommitment {
|
||||
@@ -9,4 +10,7 @@ abstract class ConnectNewCommitment {
|
||||
/// The learned multiplier for this category, so the estimate field can show
|
||||
/// what the app actually expects the task to take.
|
||||
void onMultiplierResolved(double multiplier);
|
||||
|
||||
/// Goals are loaded; open the picker.
|
||||
void onGoalsReady(List<Goal> goals);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../about/external/data/Goal.dart';
|
||||
import 'NewCommitmentState.dart';
|
||||
|
||||
class NewCommitment extends StatefulWidget {
|
||||
const NewCommitment({super.key});
|
||||
/// The goal this task is created inside; it can still be changed in the
|
||||
/// form. Null starts the task loose.
|
||||
final Goal? goal;
|
||||
|
||||
const NewCommitment({super.key, this.goal});
|
||||
|
||||
@override
|
||||
State<NewCommitment> createState() => NewCommitmentState();
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:stacked/stacked.dart';
|
||||
|
||||
import '../../about/external/data/Commitment.dart';
|
||||
import '../../about/external/data/Goal.dart';
|
||||
import '../../about/external/initial/CommitmentRequest.dart';
|
||||
import '../../about/internal/application/CommitmentClass.dart';
|
||||
import '../../about/internal/application/EnergyCost.dart';
|
||||
@@ -10,6 +11,7 @@ import '../../about/internal/application/NotificationType.dart';
|
||||
import '../../about/internal/application/ProofType.dart';
|
||||
import '../../about/internal/application/TextType.dart';
|
||||
import '../../designs/Component.dart';
|
||||
import '../../designs/GoalPicker.dart';
|
||||
import '../../designs/Responsive.dart';
|
||||
import '../../designs/Shell.dart';
|
||||
import '../../designs/buttons/Buttons.dart';
|
||||
@@ -49,6 +51,9 @@ class NewCommitmentState extends State<NewCommitment>
|
||||
|
||||
String _windowError = "";
|
||||
|
||||
/// The goal this task belongs to; null for a loose task.
|
||||
Goal? _goal;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ViewModelBuilder<ViewNewCommitment>.reactive(
|
||||
@@ -76,6 +81,11 @@ class NewCommitmentState extends State<NewCommitment>
|
||||
setState(() {
|
||||
_windowStart = now;
|
||||
_windowEnd = now.add(const Duration(hours: 2));
|
||||
// A task made inside a goal starts in it, at the goal's default weight.
|
||||
if (widget.goal != null) {
|
||||
_goal = widget.goal;
|
||||
_class = widget.goal!.defaultClass;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -85,6 +95,10 @@ class NewCommitmentState extends State<NewCommitment>
|
||||
Navigator.pop(context, false);
|
||||
}
|
||||
|
||||
void _onPickGoal() {
|
||||
_model?.loadGoals();
|
||||
}
|
||||
|
||||
void _onCategoryChanged(String value) {
|
||||
_model?.resolveMultiplier(value.trim());
|
||||
}
|
||||
@@ -185,6 +199,7 @@ class NewCommitmentState extends State<NewCommitment>
|
||||
|
||||
CommitmentRequest _buildRequest() {
|
||||
return CommitmentRequest(
|
||||
parentId: _goal?.id,
|
||||
commitmentClass: _class.name,
|
||||
title: _title.text.trim(),
|
||||
category: _category.text.trim(),
|
||||
@@ -200,6 +215,7 @@ class NewCommitmentState extends State<NewCommitment>
|
||||
|
||||
Commitment _buildCandidate() {
|
||||
return Commitment(
|
||||
parentId: _goal?.id,
|
||||
commitmentClass: _class,
|
||||
title: _title.text.trim(),
|
||||
category: _category.text.trim(),
|
||||
@@ -237,6 +253,14 @@ class NewCommitmentState extends State<NewCommitment>
|
||||
validator: Validators.title,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
selectField(
|
||||
"Goal",
|
||||
_goal?.title ?? "",
|
||||
_onPickGoal,
|
||||
hint: "No goal — a loose task",
|
||||
icon: CupertinoIcons.flag,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
inputField(
|
||||
"Category",
|
||||
_category,
|
||||
@@ -542,6 +566,20 @@ class NewCommitmentState extends State<NewCommitment>
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void onGoalsReady(List<Goal> goals) async {
|
||||
final Goal? picked =
|
||||
await showGoalPicker(context, goals, selectedId: _goal?.id);
|
||||
|
||||
if (picked == null || !mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_goal = picked.id == null ? null : picked;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_title.dispose();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import '../../about/external/data/Commitment.dart';
|
||||
import '../../about/external/data/pages/request/CommitmentsRequest.dart';
|
||||
import '../../about/external/data/pages/request/HistoryRequest.dart';
|
||||
import '../../about/external/data/pages/request/PageAndSort.dart';
|
||||
import '../../about/external/data/pages/request/Pageable.dart';
|
||||
import '../../about/external/data/pages/request/Sort.dart';
|
||||
@@ -7,6 +8,7 @@ import '../../about/external/data/pages/response/CommitmentPage.dart';
|
||||
import '../../about/external/initial/CommitmentRequest.dart';
|
||||
import '../../about/internal/application/CapacityProfile.dart';
|
||||
import '../../utils/CapacityEngine.dart';
|
||||
import '../../utils/ObjectConvertors.dart';
|
||||
import '../parent/ParentViewModel.dart';
|
||||
import 'ConnectNewCommitment.dart';
|
||||
|
||||
@@ -24,6 +26,28 @@ class ViewNewCommitment extends ParentViewModel {
|
||||
connection.onMultiplierResolved(profile.multiplierFor(category));
|
||||
}
|
||||
|
||||
/// Goals for the picker — loaded on tap, never up front.
|
||||
void loadGoals() async {
|
||||
if (!await hasNetwork(() => loadGoals())) return;
|
||||
|
||||
showLoading("Loading goals");
|
||||
|
||||
try {
|
||||
final response = await getDataManager().getMyGoals(HistoryRequest(
|
||||
query: PageAndSort(
|
||||
sort: Sort('desc', 'startDate'),
|
||||
page: Pageable(0, 0, 50, 0),
|
||||
),
|
||||
));
|
||||
|
||||
closeLoading();
|
||||
|
||||
connection.onGoalsReady(getGoalList(response.data));
|
||||
} catch (e) {
|
||||
handleError(e, () => loadGoals(), () => dismissError(), "Retry");
|
||||
}
|
||||
}
|
||||
|
||||
/// The capacity gate. Chronic overdue is usually overcommitment misdiagnosed
|
||||
/// as laziness, so the plan is checked against what history says actually
|
||||
/// gets done before anything is accepted.
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import '../../about/external/data/Commitment.dart';
|
||||
import '../../about/external/data/Goal.dart';
|
||||
import '../../about/external/data/RoutineChain.dart';
|
||||
|
||||
abstract class ConnectGoalDetail {
|
||||
void onGoalLoaded(Goal goal, List<Commitment> tasks);
|
||||
|
||||
/// The routines that serve this goal.
|
||||
void onRoutinesLoaded(List<RoutineChain> routines);
|
||||
|
||||
/// The task is ready to run — hand off to the full-screen runner.
|
||||
void onTaskReady(Commitment task);
|
||||
|
||||
|
||||
5
frontend/lib/Grounded/see/goal/ConnectGoalEditor.dart
Normal file
5
frontend/lib/Grounded/see/goal/ConnectGoalEditor.dart
Normal file
@@ -0,0 +1,5 @@
|
||||
abstract class ConnectGoalEditor {
|
||||
void onSaved();
|
||||
|
||||
void onArchived();
|
||||
}
|
||||
@@ -2,6 +2,4 @@ import '../../about/external/data/Goal.dart';
|
||||
|
||||
abstract class ConnectGoals {
|
||||
void onGoalsLoaded(List<Goal> goals);
|
||||
|
||||
void onGoalSaved();
|
||||
}
|
||||
|
||||
@@ -4,9 +4,11 @@ import 'package:stacked/stacked.dart';
|
||||
|
||||
import '../../about/external/data/Commitment.dart';
|
||||
import '../../about/external/data/Goal.dart';
|
||||
import '../../about/external/data/RoutineChain.dart';
|
||||
import '../../about/internal/application/CommitmentStatus.dart';
|
||||
import '../../about/internal/application/NotificationType.dart';
|
||||
import '../../about/internal/application/ProofType.dart';
|
||||
import '../../about/internal/application/RoutineKind.dart';
|
||||
import '../../about/internal/application/TextType.dart';
|
||||
import '../../configs/Navigator.dart';
|
||||
import '../../designs/Checkbox.dart';
|
||||
@@ -20,8 +22,11 @@ import '../../utils/CommonUtils.dart';
|
||||
import '../../utils/DebtEngine.dart';
|
||||
import '../commitment/NewCommitment.dart';
|
||||
import '../live/LiveTask.dart';
|
||||
import '../routine/RoutineBuilder.dart';
|
||||
import '../routine/RoutineRunner.dart';
|
||||
import 'ConnectGoalDetail.dart';
|
||||
import 'GoalDetail.dart';
|
||||
import 'GoalEditor.dart';
|
||||
import 'ViewGoalDetail.dart';
|
||||
|
||||
class GoalDetailState extends State<GoalDetail>
|
||||
@@ -32,6 +37,8 @@ class GoalDetailState extends State<GoalDetail>
|
||||
|
||||
List<Commitment> _tasks = <Commitment>[];
|
||||
|
||||
List<RoutineChain> _routines = <RoutineChain>[];
|
||||
|
||||
bool _changed = false;
|
||||
|
||||
/// Ticked locally, awaiting the server.
|
||||
@@ -90,7 +97,7 @@ class GoalDetailState extends State<GoalDetail>
|
||||
|
||||
void _onAddTask() async {
|
||||
final result = await GroundedNavigation()
|
||||
.navigateToPageWithData(const NewCommitment(), context);
|
||||
.navigateToPageWithData(NewCommitment(goal: _goal), context);
|
||||
|
||||
if (result == true) {
|
||||
_changed = true;
|
||||
@@ -98,6 +105,61 @@ class GoalDetailState extends State<GoalDetail>
|
||||
}
|
||||
}
|
||||
|
||||
void _onAddRoutine() async {
|
||||
final result = await GroundedNavigation()
|
||||
.navigateToPageWithData(RoutineBuilder(goal: _goal), context);
|
||||
|
||||
if (result == true) {
|
||||
_model?.loadRoutines(_goal);
|
||||
}
|
||||
}
|
||||
|
||||
void _onEditRoutine(RoutineChain routine) async {
|
||||
final result = await GroundedNavigation()
|
||||
.navigateToPageWithData(
|
||||
RoutineBuilder(routine: routine, goal: _goal), context);
|
||||
|
||||
if (result == true) {
|
||||
_model?.loadRoutines(_goal);
|
||||
}
|
||||
}
|
||||
|
||||
void _onStartRoutine(RoutineChain routine) async {
|
||||
if (routine.steps.isEmpty) {
|
||||
_onEditRoutine(routine);
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await GroundedNavigation().navigateToPageWithData(
|
||||
RoutineRunner(routine: routine.copy()), context);
|
||||
|
||||
if (result == true) {
|
||||
_changed = true;
|
||||
_model?.loadRoutines(_goal);
|
||||
}
|
||||
}
|
||||
|
||||
void _onEditGoal() async {
|
||||
final result = await GroundedNavigation()
|
||||
.navigateToPageWithData(GoalEditor(goal: _goal), context);
|
||||
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result == "archived") {
|
||||
Navigator.pop(context, true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result is Goal) {
|
||||
_changed = true;
|
||||
setState(() {
|
||||
_goal = result;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Views ─────────────────────────────────────────────────────────────────
|
||||
|
||||
Widget _mobileView(BoxConstraints constraints) {
|
||||
@@ -115,10 +177,11 @@ class GoalDetailState extends State<GoalDetail>
|
||||
.toList();
|
||||
|
||||
return Sheet(
|
||||
eyebrow: "Goal",
|
||||
eyebrow: _goal.category.isEmpty ? "Goal" : _goal.category,
|
||||
title: _goal.title,
|
||||
onBack: _onBack,
|
||||
action: chromeAction(CupertinoIcons.add, _onAddTask),
|
||||
chrome: _goal.colourHex.isEmpty ? null : goalColor(_goal.colourHex),
|
||||
action: chromeAction(CupertinoIcons.pencil, _onEditGoal),
|
||||
banner: _progressBanner(),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
@@ -138,6 +201,26 @@ class GoalDetailState extends State<GoalDetail>
|
||||
)
|
||||
else
|
||||
...open.map(_taskRow),
|
||||
const SizedBox(height: 28),
|
||||
sectionBreak(
|
||||
"Routines",
|
||||
caption: _routines.isEmpty
|
||||
? "none yet"
|
||||
: "${_routines.length} serving this goal",
|
||||
),
|
||||
if (_routines.isEmpty)
|
||||
text(
|
||||
"Routines are the repeatable part of a goal — the Monday session, the morning sit. Add one and it runs from here.",
|
||||
13,
|
||||
TextType.Regular,
|
||||
color: colorGrey2,
|
||||
height: 1.5,
|
||||
)
|
||||
else
|
||||
..._routines.map(_routineRow),
|
||||
const SizedBox(height: 12),
|
||||
outlinedActionButton("Add a routine", _onAddRoutine,
|
||||
icon: CupertinoIcons.square_stack_3d_up),
|
||||
if (done.isNotEmpty) ...[
|
||||
const SizedBox(height: 28),
|
||||
sectionBreak("Done", caption: "${done.length}"),
|
||||
@@ -179,6 +262,40 @@ class GoalDetailState extends State<GoalDetail>
|
||||
track: colorWhite.withValues(alpha: 0.14),
|
||||
height: 5,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: labelled(
|
||||
"Tasks",
|
||||
"${_goal.completedTasks}/${_goal.totalTasks}",
|
||||
valueSize: 15,
|
||||
valueColor: colorWhite,
|
||||
labelColor: colorWhite.withValues(alpha: 0.45),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: labelled(
|
||||
"Routines",
|
||||
"${_routines.length}",
|
||||
valueSize: 15,
|
||||
valueColor: colorWhite,
|
||||
labelColor: colorWhite.withValues(alpha: 0.45),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: labelled(
|
||||
"Deadline",
|
||||
_goal.targetDate == null
|
||||
? "None"
|
||||
: targetLabel(_goal.targetDate),
|
||||
valueSize: 15,
|
||||
valueColor: colorWhite,
|
||||
labelColor: colorWhite.withValues(alpha: 0.45),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -235,12 +352,15 @@ class GoalDetailState extends State<GoalDetail>
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 14),
|
||||
roundedCornerButton(
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: roundedCornerButton(
|
||||
"Start",
|
||||
() => _onStartTask(task),
|
||||
icon: CupertinoIcons.play_fill,
|
||||
verticalPadding: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -268,8 +388,86 @@ class GoalDetailState extends State<GoalDetail>
|
||||
);
|
||||
}
|
||||
|
||||
Widget _routineRow(RoutineChain routine) {
|
||||
final Color tone = kindColor(routine.kind);
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
child: card(
|
||||
padding: const EdgeInsets.fromLTRB(10, 10, 10, 10),
|
||||
onTap: () => _onEditRoutine(routine),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: kindBackground(routine.kind),
|
||||
borderRadius: BorderRadius.circular(13),
|
||||
),
|
||||
child: Icon(kindIcon(routine.kind), size: 21, color: tone),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
text(routine.title, 14, TextType.Medium,
|
||||
color: colorPrimaryDark,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
const SizedBox(height: 4),
|
||||
text(
|
||||
[
|
||||
kindLabel(routine.kind),
|
||||
formatMinutes(routine.totalSeconds / 60),
|
||||
if (routine.startMinute >= 0)
|
||||
formatStartMinute(routine.startMinute),
|
||||
formatDays(routine.days),
|
||||
].join(" · "),
|
||||
11,
|
||||
TextType.Regular,
|
||||
color: colorGrey2,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
GestureDetector(
|
||||
onTap: () => _onStartRoutine(routine),
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: routine.kind == RoutineKind.MustDo
|
||||
? colorPrimaryDark
|
||||
: tone,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(CupertinoIcons.play_fill,
|
||||
size: 15, color: colorWhite),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── ConnectGoalDetail ─────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
void onRoutinesLoaded(List<RoutineChain> routines) {
|
||||
setState(() {
|
||||
_routines = routines;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void onGoalLoaded(Goal goal, List<Commitment> tasks) {
|
||||
setState(() {
|
||||
|
||||
16
frontend/lib/Grounded/see/goal/GoalEditor.dart
Normal file
16
frontend/lib/Grounded/see/goal/GoalEditor.dart
Normal file
@@ -0,0 +1,16 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../about/external/data/Goal.dart';
|
||||
import 'GoalEditorState.dart';
|
||||
|
||||
/// Create or edit a goal. Pops the saved [Goal], or `"archived"` when the goal
|
||||
/// was archived, so a detail screen underneath knows to close as well.
|
||||
class GoalEditor extends StatefulWidget {
|
||||
/// The goal being edited; null creates a new one.
|
||||
final Goal? goal;
|
||||
|
||||
const GoalEditor({super.key, this.goal});
|
||||
|
||||
@override
|
||||
State<GoalEditor> createState() => GoalEditorState();
|
||||
}
|
||||
398
frontend/lib/Grounded/see/goal/GoalEditorState.dart
Normal file
398
frontend/lib/Grounded/see/goal/GoalEditorState.dart
Normal file
@@ -0,0 +1,398 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stacked/stacked.dart';
|
||||
|
||||
import '../../about/external/data/Goal.dart';
|
||||
import '../../about/external/initial/GoalRequest.dart';
|
||||
import '../../about/internal/application/CommitmentClass.dart';
|
||||
import '../../about/internal/application/NotificationType.dart';
|
||||
import '../../about/internal/application/TextType.dart';
|
||||
import '../../designs/Component.dart';
|
||||
import '../../designs/Responsive.dart';
|
||||
import '../../designs/Shell.dart';
|
||||
import '../../designs/buttons/Buttons.dart';
|
||||
import '../../designs/input/InputFields.dart';
|
||||
import '../../designs/text/Text.dart';
|
||||
import '../../utils/Colors.dart';
|
||||
import '../../utils/CommonUtils.dart';
|
||||
import '../../utils/Validators.dart';
|
||||
import 'ConnectGoalEditor.dart';
|
||||
import 'GoalEditor.dart';
|
||||
import 'ViewGoalEditor.dart';
|
||||
|
||||
class GoalEditorState extends State<GoalEditor> implements ConnectGoalEditor {
|
||||
ViewGoalEditor? _model;
|
||||
|
||||
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
|
||||
|
||||
final TextEditingController _title = TextEditingController();
|
||||
|
||||
final TextEditingController _description = TextEditingController();
|
||||
|
||||
final TextEditingController _category = TextEditingController();
|
||||
|
||||
CommitmentClass _defaultClass = CommitmentClass.Standard;
|
||||
|
||||
Color _accent = goalAccents.first;
|
||||
|
||||
DateTime? _targetDate;
|
||||
|
||||
bool get _editing {
|
||||
return widget.goal != null;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
final goal = widget.goal;
|
||||
if (goal != null) {
|
||||
_title.text = goal.title;
|
||||
_description.text = goal.description;
|
||||
_category.text = goal.category;
|
||||
_defaultClass = goal.defaultClass;
|
||||
_targetDate = goal.targetDate;
|
||||
if (goal.colourHex.isNotEmpty) {
|
||||
_accent = goalColor(goal.colourHex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ViewModelBuilder<ViewGoalEditor>.reactive(
|
||||
viewModelBuilder: () => ViewGoalEditor(context, this),
|
||||
onViewModelReady: (viewModel) {
|
||||
_model = viewModel;
|
||||
_initiate();
|
||||
},
|
||||
builder: (context, viewModel, child) => LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
return Responsive(
|
||||
mobile: _mobileView(constraints),
|
||||
tablet: _mobileView(constraints),
|
||||
desktop: _mobileView(constraints),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _initiate() {}
|
||||
|
||||
// ── Handlers ──────────────────────────────────────────────────────────────
|
||||
|
||||
void _onBack() {
|
||||
Navigator.pop(context, false);
|
||||
}
|
||||
|
||||
void _onTitleChanged(String value) {
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void _onAccentSelected(Color colour) {
|
||||
setState(() {
|
||||
_accent = colour;
|
||||
});
|
||||
}
|
||||
|
||||
void _onClassSelected(CommitmentClass value) {
|
||||
setState(() {
|
||||
_defaultClass = value;
|
||||
});
|
||||
}
|
||||
|
||||
void _onPickTarget() async {
|
||||
final DateTime now = DateTime.now();
|
||||
|
||||
final DateTime? picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _targetDate ?? now.add(const Duration(days: 30)),
|
||||
firstDate: now,
|
||||
lastDate: now.add(const Duration(days: 365 * 5)),
|
||||
);
|
||||
|
||||
if (picked == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_targetDate = picked;
|
||||
});
|
||||
}
|
||||
|
||||
void _onClearTarget() {
|
||||
setState(() {
|
||||
_targetDate = null;
|
||||
});
|
||||
}
|
||||
|
||||
void _onSave() {
|
||||
if (_formKey.currentState?.validate() != true) {
|
||||
return;
|
||||
}
|
||||
|
||||
_model?.save(GoalRequest(
|
||||
id: widget.goal?.id,
|
||||
title: _title.text.trim(),
|
||||
description: _description.text.trim(),
|
||||
category: _category.text.trim(),
|
||||
defaultClass: _defaultClass.name,
|
||||
startDate: (widget.goal?.startDate ?? DateTime.now()).toIso8601String(),
|
||||
targetDate: _targetDate?.toIso8601String() ?? "",
|
||||
colourHex: colorToHex(_accent),
|
||||
));
|
||||
}
|
||||
|
||||
void _onArchive() {
|
||||
_model?.showApplicationNotification(
|
||||
NotificationType.warning,
|
||||
"Archive this goal?",
|
||||
"It leaves your goals list. Its tasks and their history stay on the record, and any debt they carry is not forgiven.",
|
||||
true,
|
||||
true,
|
||||
null,
|
||||
action: "Archive",
|
||||
positiveAction: _onConfirmArchive,
|
||||
);
|
||||
}
|
||||
|
||||
void _onConfirmArchive() {
|
||||
Navigator.pop(context);
|
||||
_model?.archive(widget.goal?.id ?? "");
|
||||
}
|
||||
|
||||
// ── Views ─────────────────────────────────────────────────────────────────
|
||||
|
||||
Widget _mobileView(BoxConstraints constraints) {
|
||||
return Sheet(
|
||||
eyebrow: _editing ? "Edit" : "New",
|
||||
title: "Goal",
|
||||
onBack: _onBack,
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
displayTitle(_editing ? "Adjust the goal." : "What are you\nworking toward?"),
|
||||
const SizedBox(height: 24),
|
||||
_preview(),
|
||||
const SizedBox(height: 28),
|
||||
inputField(
|
||||
"Goal",
|
||||
_title,
|
||||
hint: "Workout, thesis, get the flat sorted…",
|
||||
validator: Validators.title,
|
||||
onChanged: _onTitleChanged,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
inputField(
|
||||
"Why it matters",
|
||||
_description,
|
||||
hint: "Optional, but it helps on the bad days",
|
||||
maxLines: 3,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
inputField(
|
||||
"Category",
|
||||
_category,
|
||||
hint: "Health, work, home…",
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
sectionBreak("Colour", caption: "how it reads everywhere"),
|
||||
_swatches(),
|
||||
const SizedBox(height: 28),
|
||||
sectionBreak("Deadline", caption: "optional"),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(
|
||||
child: selectField(
|
||||
"Target date",
|
||||
_targetDate == null ? "" : formatDate(_targetDate),
|
||||
_onPickTarget,
|
||||
hint: "No deadline",
|
||||
icon: CupertinoIcons.calendar,
|
||||
),
|
||||
),
|
||||
if (_targetDate != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
iconButton(
|
||||
Icon(CupertinoIcons.xmark, size: 15, color: colorGrey2),
|
||||
_onClearTarget,
|
||||
bordered: true,
|
||||
background: colorWhite,
|
||||
radius: 12,
|
||||
size: 50,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
text(
|
||||
"A deadline is for the goal as a whole. The tasks and routines inside it keep their own windows.",
|
||||
12,
|
||||
TextType.Regular,
|
||||
color: colorGrey2,
|
||||
height: 1.5,
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
sectionBreak("Tasks default to", caption: "you can change each one"),
|
||||
segmentedSelector<CommitmentClass>(
|
||||
options: CommitmentClass.values,
|
||||
selected: _defaultClass,
|
||||
label: classLabel,
|
||||
onSelected: _onClassSelected,
|
||||
activeColor: classColor(_defaultClass),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
text(
|
||||
"The goal never carries debt itself — the tasks and routines inside it do.",
|
||||
12,
|
||||
TextType.Regular,
|
||||
color: colorGrey2,
|
||||
height: 1.5,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
roundedCornerButton(
|
||||
_editing ? "Save changes" : "Create goal",
|
||||
_onSave,
|
||||
icon: _editing ? CupertinoIcons.checkmark : CupertinoIcons.add,
|
||||
),
|
||||
if (_editing) ...[
|
||||
const SizedBox(height: 8),
|
||||
Center(
|
||||
child: textButton("Archive goal", _onArchive,
|
||||
color: colorDestructive, textSize: 13),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// A live preview of the goal card, so colour and deadline are chosen by
|
||||
/// looking, not imagining.
|
||||
Widget _preview() {
|
||||
final String title =
|
||||
_title.text.trim().isEmpty ? "Your goal" : _title.text.trim();
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(18),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: <Color>[_accent, Color.lerp(_accent, colorBlack, 0.45)!],
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(CupertinoIcons.flag_fill,
|
||||
size: 14, color: colorWhite.withValues(alpha: 0.7)),
|
||||
const SizedBox(width: 8),
|
||||
text(
|
||||
_targetDate == null
|
||||
? "NO DEADLINE"
|
||||
: targetLabel(_targetDate).toUpperCase(),
|
||||
9,
|
||||
TextType.Bold,
|
||||
color: colorWhite.withValues(alpha: 0.6),
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
text(title, 24, TextType.Light,
|
||||
color: colorWhite, maxLines: 2, overflow: TextOverflow.ellipsis),
|
||||
const SizedBox(height: 16),
|
||||
meter(_editing ? widget.goal!.progress : 0,
|
||||
fill: colorWhite,
|
||||
track: colorWhite.withValues(alpha: 0.16),
|
||||
height: 5),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _swatches() {
|
||||
return Wrap(
|
||||
spacing: 9,
|
||||
runSpacing: 9,
|
||||
children: goalAccents.map((colour) {
|
||||
final bool selected = colour.toARGB32() == _accent.toARGB32();
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => _onAccentSelected(colour),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
width: 36,
|
||||
height: 36,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: colour,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: selected ? colorWhite : Colors.transparent,
|
||||
width: 3,
|
||||
),
|
||||
boxShadow: selected
|
||||
? [BoxShadow(color: colour, spreadRadius: 1.5)]
|
||||
: null,
|
||||
),
|
||||
child: selected
|
||||
? Icon(Icons.check_rounded, size: 18, color: colorWhite)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
// ── ConnectGoalEditor ─────────────────────────────────────────────────────
|
||||
|
||||
/// Pops the goal as saved, carrying over the server-derived counts, so the
|
||||
/// screen underneath can update in place without a reload.
|
||||
@override
|
||||
void onSaved() {
|
||||
final Goal? before = widget.goal;
|
||||
|
||||
Navigator.pop(
|
||||
context,
|
||||
Goal(
|
||||
id: before?.id,
|
||||
title: _title.text.trim(),
|
||||
description: _description.text.trim(),
|
||||
category: _category.text.trim(),
|
||||
defaultClass: _defaultClass,
|
||||
startDate: before?.startDate ?? DateTime.now(),
|
||||
targetDate: _targetDate,
|
||||
colourHex: colorToHex(_accent),
|
||||
totalTasks: before?.totalTasks ?? 0,
|
||||
completedTasks: before?.completedTasks ?? 0,
|
||||
overdueTasks: before?.overdueTasks ?? 0,
|
||||
debtContribution: before?.debtContribution ?? 0,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void onArchived() {
|
||||
Navigator.pop(context, "archived");
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_title.dispose();
|
||||
_description.dispose();
|
||||
_category.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,19 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stacked/stacked.dart';
|
||||
|
||||
import '../../about/external/data/Goal.dart';
|
||||
import '../../about/external/initial/GoalRequest.dart';
|
||||
import '../../about/internal/application/CommitmentClass.dart';
|
||||
import '../../about/internal/application/TextType.dart';
|
||||
import '../../configs/Navigator.dart';
|
||||
import '../../designs/Component.dart';
|
||||
import '../../designs/Responsive.dart';
|
||||
import '../../designs/Shell.dart';
|
||||
import '../../designs/buttons/Buttons.dart';
|
||||
import '../../designs/input/InputFields.dart';
|
||||
import '../../designs/text/Text.dart';
|
||||
import '../../utils/Colors.dart';
|
||||
import '../../utils/CommonUtils.dart';
|
||||
import '../../utils/Validators.dart';
|
||||
import 'ConnectGoals.dart';
|
||||
import 'GoalDetail.dart';
|
||||
import 'GoalEditor.dart';
|
||||
import 'Goals.dart';
|
||||
import 'ViewGoals.dart';
|
||||
|
||||
@@ -63,124 +59,13 @@ class GoalsState extends State<Goals> implements ConnectGoals {
|
||||
}
|
||||
}
|
||||
|
||||
void _onNewGoal() {
|
||||
_openGoalSheet();
|
||||
void _onNewGoal() async {
|
||||
final result = await GroundedNavigation()
|
||||
.navigateToPageWithData(const GoalEditor(), context);
|
||||
|
||||
if (result is Goal) {
|
||||
_model?.loadGoals();
|
||||
}
|
||||
|
||||
/// Goals are lightweight on purpose — a name and a default class. The
|
||||
/// weight lives on the tasks inside them.
|
||||
void _openGoalSheet() {
|
||||
final TextEditingController title = TextEditingController();
|
||||
final TextEditingController description = TextEditingController();
|
||||
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
|
||||
|
||||
CommitmentClass defaultClass = CommitmentClass.Standard;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
barrierColor: colorPrimaryDark.withValues(alpha: 0.6),
|
||||
builder: (BuildContext sheetContext) {
|
||||
return StatefulBuilder(
|
||||
builder: (BuildContext sheetContext, StateSetter setSheetState) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(sheetContext).viewInsets.bottom,
|
||||
),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorSheetBackground,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(28),
|
||||
topRight: Radius.circular(28),
|
||||
),
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 32),
|
||||
child: Form(
|
||||
key: formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.only(bottom: 20),
|
||||
decoration: BoxDecoration(
|
||||
color: colorGrey.withValues(alpha: 0.35),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
),
|
||||
),
|
||||
text("NEW GOAL", 9, TextType.Bold,
|
||||
color: colorGrey2, letterSpacing: 1.2),
|
||||
const SizedBox(height: 10),
|
||||
text("What are you\nworking toward?", 26,
|
||||
TextType.Light,
|
||||
color: colorPrimaryDark, height: 1.2),
|
||||
const SizedBox(height: 20),
|
||||
inputField(
|
||||
"Goal",
|
||||
title,
|
||||
hint: "Workout, thesis, get the flat sorted…",
|
||||
validator: Validators.title,
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
inputField(
|
||||
"Why it matters",
|
||||
description,
|
||||
hint: "Optional, but it helps on the bad days",
|
||||
maxLines: 3,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
text("TASKS DEFAULT TO", 9, TextType.Bold,
|
||||
color: colorGrey2, letterSpacing: 1.0),
|
||||
const SizedBox(height: 10),
|
||||
segmentedSelector<CommitmentClass>(
|
||||
options: CommitmentClass.values,
|
||||
selected: defaultClass,
|
||||
label: classLabel,
|
||||
onSelected: (value) => setSheetState(() {
|
||||
defaultClass = value;
|
||||
}),
|
||||
activeColor: classColor(defaultClass),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
roundedCornerButton(
|
||||
"Create goal",
|
||||
() {
|
||||
if (formKey.currentState?.validate() != true) {
|
||||
return;
|
||||
}
|
||||
Navigator.pop(sheetContext);
|
||||
_model?.save(GoalRequest(
|
||||
title: title.text.trim(),
|
||||
description: description.text.trim(),
|
||||
defaultClass: defaultClass.name,
|
||||
startDate: DateTime.now().toIso8601String(),
|
||||
));
|
||||
},
|
||||
icon: CupertinoIcons.add,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Center(
|
||||
child: textButton("Cancel",
|
||||
() => Navigator.pop(sheetContext),
|
||||
textSize: 13),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _mobileView(BoxConstraints constraints) {
|
||||
@@ -221,7 +106,7 @@ class GoalsState extends State<Goals> implements ConnectGoals {
|
||||
|
||||
Widget _goalCard(Goal goal) {
|
||||
final Color accent =
|
||||
goal.slipping ? colorStandingGrounded : colorPrimaryDark;
|
||||
goal.slipping ? colorStandingGrounded : goalColor(goal.colourHex);
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
@@ -239,6 +124,35 @@ class GoalsState extends State<Goals> implements ConnectGoals {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: goalColor(goal.colourHex),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
text(
|
||||
[
|
||||
if (goal.category.isNotEmpty) goal.category,
|
||||
goal.targetDate == null
|
||||
? "No deadline"
|
||||
: targetLabel(goal.targetDate),
|
||||
].join(" · ").toUpperCase(),
|
||||
9,
|
||||
TextType.Bold,
|
||||
color: goal.targetDate != null &&
|
||||
goal.targetDate!.isBefore(DateTime.now())
|
||||
? colorStandingGrounded
|
||||
: colorGrey2,
|
||||
letterSpacing: 1.0,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
text(goal.title, 19, TextType.Light,
|
||||
color: colorPrimaryDark,
|
||||
maxLines: 2,
|
||||
@@ -302,9 +216,4 @@ class GoalsState extends State<Goals> implements ConnectGoals {
|
||||
_goals = goals;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void onGoalSaved() {
|
||||
_model?.loadGoals();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import '../../about/external/data/Commitment.dart';
|
||||
import '../../about/external/data/Goal.dart';
|
||||
import '../../about/external/data/RoutineChain.dart';
|
||||
import '../../about/external/data/pages/request/HistoryRequest.dart';
|
||||
import '../../about/external/data/pages/request/PageAndSort.dart';
|
||||
import '../../about/external/data/pages/request/Pageable.dart';
|
||||
import '../../about/external/data/pages/request/Sort.dart';
|
||||
import '../../about/external/initial/CompletionRequest.dart';
|
||||
import '../../about/external/initial/IdRequest.dart';
|
||||
import '../../utils/ObjectConvertors.dart';
|
||||
@@ -23,11 +28,34 @@ class ViewGoalDetail extends ParentViewModel {
|
||||
closeLoading();
|
||||
|
||||
connection.onGoalLoaded(goal, getCommitmentList(response.data));
|
||||
|
||||
loadRoutines(goal);
|
||||
} catch (e) {
|
||||
handleError(e, () => loadTasks(goal), () => dismissError(), "Retry");
|
||||
}
|
||||
}
|
||||
|
||||
/// Routines are fetched with the library and filtered to this goal, so a
|
||||
/// routine moved between goals shows up in the right place immediately.
|
||||
void loadRoutines(Goal goal) async {
|
||||
try {
|
||||
final response = await getDataManager().getMyRoutines(HistoryRequest(
|
||||
query: PageAndSort(
|
||||
sort: Sort('asc', 'startMinute'),
|
||||
page: Pageable(0, 0, 50, 0),
|
||||
),
|
||||
));
|
||||
|
||||
final List<RoutineChain> routines = getRoutineChainList(response.data)
|
||||
.where((routine) => routine.goalId == goal.id)
|
||||
.toList();
|
||||
|
||||
connection.onRoutinesLoaded(routines);
|
||||
} catch (e) {
|
||||
handleError(e, () => loadRoutines(goal), () => dismissError(), "Retry");
|
||||
}
|
||||
}
|
||||
|
||||
/// Completes a task straight from the goal list.
|
||||
void tick(Commitment task) async {
|
||||
if (!await hasNetwork(() => tick(task))) return;
|
||||
|
||||
44
frontend/lib/Grounded/see/goal/ViewGoalEditor.dart
Normal file
44
frontend/lib/Grounded/see/goal/ViewGoalEditor.dart
Normal file
@@ -0,0 +1,44 @@
|
||||
import '../../about/external/initial/GoalRequest.dart';
|
||||
import '../../about/external/initial/IdRequest.dart';
|
||||
import '../parent/ParentViewModel.dart';
|
||||
import 'ConnectGoalEditor.dart';
|
||||
|
||||
class ViewGoalEditor extends ParentViewModel {
|
||||
ConnectGoalEditor connection;
|
||||
|
||||
ViewGoalEditor(super.context, this.connection);
|
||||
|
||||
void save(GoalRequest request) async {
|
||||
if (!await hasNetwork(() => save(request))) return;
|
||||
|
||||
showLoading("Saving goal");
|
||||
|
||||
try {
|
||||
await getDataManager().saveGoalEntry(request);
|
||||
|
||||
closeLoading();
|
||||
|
||||
connection.onSaved();
|
||||
} catch (e) {
|
||||
handleError(e, () => save(request), () => dismissError(), "Retry");
|
||||
}
|
||||
}
|
||||
|
||||
/// Archiving hides the goal; its tasks and their history stay exactly as
|
||||
/// they were. Debt already accrued is not forgiven by archiving.
|
||||
void archive(String goalId) async {
|
||||
if (!await hasNetwork(() => archive(goalId))) return;
|
||||
|
||||
showLoading("Archiving");
|
||||
|
||||
try {
|
||||
await getDataManager().archiveGoalEntry(IdRequest(id: goalId));
|
||||
|
||||
closeLoading();
|
||||
|
||||
connection.onArchived();
|
||||
} catch (e) {
|
||||
handleError(e, () => archive(goalId), () => dismissError(), "Retry");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import '../../about/external/data/pages/request/HistoryRequest.dart';
|
||||
import '../../about/external/data/pages/request/PageAndSort.dart';
|
||||
import '../../about/external/data/pages/request/Pageable.dart';
|
||||
import '../../about/external/data/pages/request/Sort.dart';
|
||||
import '../../about/external/initial/GoalRequest.dart';
|
||||
import '../../utils/ObjectConvertors.dart';
|
||||
import '../parent/ParentViewModel.dart';
|
||||
import 'ConnectGoals.dart';
|
||||
@@ -32,20 +31,4 @@ class ViewGoals extends ParentViewModel {
|
||||
handleError(e, () => loadGoals(), () => dismissError(), "Retry");
|
||||
}
|
||||
}
|
||||
|
||||
void save(GoalRequest request) async {
|
||||
if (!await hasNetwork(() => save(request))) return;
|
||||
|
||||
showLoading("Saving");
|
||||
|
||||
try {
|
||||
await getDataManager().saveGoalEntry(request);
|
||||
|
||||
closeLoading();
|
||||
|
||||
connection.onGoalSaved();
|
||||
} catch (e) {
|
||||
handleError(e, () => save(request), () => dismissError(), "Retry");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import '../excuse/ExcuseReport.dart';
|
||||
import '../goal/Goals.dart';
|
||||
import '../overdue/OverdueQueue.dart';
|
||||
import '../reportcard/ReportCardScreen.dart';
|
||||
import '../routine/Routines.dart';
|
||||
import '../settings/Settings.dart';
|
||||
import '../training/Training.dart';
|
||||
import 'ConnectHome.dart';
|
||||
@@ -136,6 +137,11 @@ class HomeState extends State<Home> implements ConnectHome {
|
||||
.navigateToPage(NavigatorType.justOpen, const Training(), context);
|
||||
}
|
||||
|
||||
void _onOpenRoutines() {
|
||||
GroundedNavigation()
|
||||
.navigateToPage(NavigatorType.justOpen, const Routines(), context);
|
||||
}
|
||||
|
||||
void _onOpenSettings() {
|
||||
GroundedNavigation()
|
||||
.navigateToPage(NavigatorType.justOpen, const Settings(), context);
|
||||
@@ -588,7 +594,9 @@ class HomeState extends State<Home> implements ConnectHome {
|
||||
}
|
||||
|
||||
Widget _quickLinks() {
|
||||
return Row(
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _quickLink(
|
||||
@@ -600,12 +608,16 @@ class HomeState extends State<Home> implements ConnectHome {
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: _quickLink(
|
||||
CupertinoIcons.chart_bar_alt_fill,
|
||||
"Report",
|
||||
_onOpenReportCard,
|
||||
CupertinoIcons.square_stack_3d_up_fill,
|
||||
"Routines",
|
||||
_onOpenRoutines,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _quickLink(
|
||||
CupertinoIcons.flame_fill,
|
||||
@@ -613,6 +625,16 @@ class HomeState extends State<Home> implements ConnectHome {
|
||||
_onOpenTraining,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: _quickLink(
|
||||
CupertinoIcons.chart_bar_alt_fill,
|
||||
"Report",
|
||||
_onOpenReportCard,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import '../../about/external/data/Goal.dart';
|
||||
|
||||
abstract class ConnectRoutineBuilder {
|
||||
void onSaved();
|
||||
|
||||
/// Goals are loaded; open the picker.
|
||||
void onGoalsReady(List<Goal> goals);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
abstract class ConnectRoutineRunner {
|
||||
/// The run was recorded. [held] is false when the chain broke and the run
|
||||
/// went in as partial.
|
||||
void onLogged(bool held);
|
||||
}
|
||||
5
frontend/lib/Grounded/see/routine/ConnectRoutines.dart
Normal file
5
frontend/lib/Grounded/see/routine/ConnectRoutines.dart
Normal file
@@ -0,0 +1,5 @@
|
||||
import '../../about/external/data/RoutineChain.dart';
|
||||
|
||||
abstract class ConnectRoutines {
|
||||
void onRoutinesLoaded(List<RoutineChain> routines);
|
||||
}
|
||||
23
frontend/lib/Grounded/see/routine/RoutineBuilder.dart
Normal file
23
frontend/lib/Grounded/see/routine/RoutineBuilder.dart
Normal file
@@ -0,0 +1,23 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../about/external/data/Goal.dart';
|
||||
import '../../about/external/data/RoutineChain.dart';
|
||||
import '../../about/internal/application/RoutineKind.dart';
|
||||
import 'RoutineBuilderState.dart';
|
||||
|
||||
class RoutineBuilder extends StatefulWidget {
|
||||
/// The routine being edited; null builds a new one.
|
||||
final RoutineChain? routine;
|
||||
|
||||
/// The kind a new routine starts as — the library's active filter.
|
||||
final RoutineKind? kind;
|
||||
|
||||
/// The goal a new routine is created inside, or the goal an edited one
|
||||
/// already belongs to (for its title). Changeable in the form.
|
||||
final Goal? goal;
|
||||
|
||||
const RoutineBuilder({super.key, this.routine, this.kind, this.goal});
|
||||
|
||||
@override
|
||||
State<RoutineBuilder> createState() => RoutineBuilderState();
|
||||
}
|
||||
686
frontend/lib/Grounded/see/routine/RoutineBuilderState.dart
Normal file
686
frontend/lib/Grounded/see/routine/RoutineBuilderState.dart
Normal file
@@ -0,0 +1,686 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stacked/stacked.dart';
|
||||
|
||||
import '../../about/external/data/Goal.dart';
|
||||
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/TextType.dart';
|
||||
import '../../designs/Checkbox.dart';
|
||||
import '../../designs/Component.dart';
|
||||
import '../../designs/GoalPicker.dart';
|
||||
import '../../designs/Responsive.dart';
|
||||
import '../../designs/Shell.dart';
|
||||
import '../../designs/buttons/Buttons.dart';
|
||||
import '../../designs/input/InputFields.dart';
|
||||
import '../../designs/text/Text.dart';
|
||||
import '../../utils/Colors.dart';
|
||||
import '../../utils/CommonUtils.dart';
|
||||
import '../../utils/RoutineTemplates.dart';
|
||||
import '../../utils/Validators.dart';
|
||||
import 'ConnectRoutineBuilder.dart';
|
||||
import 'RoutineBuilder.dart';
|
||||
import 'StepEditor.dart';
|
||||
import 'ViewRoutineBuilder.dart';
|
||||
|
||||
class RoutineBuilderState extends State<RoutineBuilder>
|
||||
implements ConnectRoutineBuilder {
|
||||
ViewRoutineBuilder? _model;
|
||||
|
||||
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
|
||||
|
||||
final TextEditingController _title = TextEditingController();
|
||||
|
||||
final TextEditingController _note = TextEditingController();
|
||||
|
||||
late RoutineChain _routine;
|
||||
|
||||
String _stepsError = "";
|
||||
|
||||
/// The goal this routine serves, when its title is known.
|
||||
Goal? _goal;
|
||||
|
||||
bool get _editing {
|
||||
return widget.routine != null;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// Edit a copy, so backing out leaves the library exactly as it was.
|
||||
_routine = widget.routine?.copy() ??
|
||||
RoutineChain(kind: widget.kind ?? RoutineKind.Workout);
|
||||
|
||||
_title.text = _routine.title;
|
||||
_note.text = _routine.note;
|
||||
|
||||
final Goal? goal = widget.goal;
|
||||
if (goal != null && (widget.routine == null || goal.id == _routine.goalId)) {
|
||||
_goal = goal;
|
||||
_routine.goalId = goal.id;
|
||||
if (widget.routine == null) {
|
||||
_routine.commitmentClass = goal.defaultClass;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ViewModelBuilder<ViewRoutineBuilder>.reactive(
|
||||
viewModelBuilder: () => ViewRoutineBuilder(context, this),
|
||||
onViewModelReady: (viewModel) {
|
||||
_model = viewModel;
|
||||
_initiate();
|
||||
},
|
||||
builder: (context, viewModel, child) => LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
return Responsive(
|
||||
mobile: _mobileView(constraints),
|
||||
tablet: _mobileView(constraints),
|
||||
desktop: _mobileView(constraints),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _initiate() {}
|
||||
|
||||
// ── Handlers ──────────────────────────────────────────────────────────────
|
||||
|
||||
void _onBack() {
|
||||
Navigator.pop(context, false);
|
||||
}
|
||||
|
||||
void _onKindSelected(RoutineKind kind) {
|
||||
if (kind == _routine.kind) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_routine.kind = kind;
|
||||
});
|
||||
}
|
||||
|
||||
void _onTemplateSelected(RoutineChain template) {
|
||||
setState(() {
|
||||
_routine.title = template.title;
|
||||
_routine.commitmentClass = template.commitmentClass;
|
||||
_routine.days = List<int>.from(template.days);
|
||||
_routine.startMinute = template.startMinute;
|
||||
_routine.steps = template.copy().steps;
|
||||
_title.text = template.title;
|
||||
_stepsError = "";
|
||||
});
|
||||
}
|
||||
|
||||
void _onPickGoal() {
|
||||
_model?.loadGoals();
|
||||
}
|
||||
|
||||
void _onClassSelected(CommitmentClass value) {
|
||||
setState(() {
|
||||
_routine.commitmentClass = value;
|
||||
});
|
||||
}
|
||||
|
||||
void _onToggleDay(int day) {
|
||||
setState(() {
|
||||
if (_routine.days.contains(day)) {
|
||||
_routine.days.remove(day);
|
||||
} else {
|
||||
_routine.days.add(day);
|
||||
_routine.days.sort();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _onWeekdays() {
|
||||
setState(() {
|
||||
_routine.days = <int>[1, 2, 3, 4, 5];
|
||||
});
|
||||
}
|
||||
|
||||
void _onEveryDay() {
|
||||
setState(() {
|
||||
_routine.days = <int>[1, 2, 3, 4, 5, 6, 7];
|
||||
});
|
||||
}
|
||||
|
||||
void _onClearDays() {
|
||||
setState(() {
|
||||
_routine.days = <int>[];
|
||||
});
|
||||
}
|
||||
|
||||
void _onPickStart() async {
|
||||
final int current = _routine.startMinute < 0 ? 7 * 60 : _routine.startMinute;
|
||||
|
||||
final TimeOfDay? time = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: TimeOfDay(hour: current ~/ 60, minute: current % 60),
|
||||
);
|
||||
|
||||
if (time == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_routine.startMinute = time.hour * 60 + time.minute;
|
||||
});
|
||||
}
|
||||
|
||||
void _onAddStep() {
|
||||
_openStepEditor(null);
|
||||
}
|
||||
|
||||
void _openStepEditor(int? index) async {
|
||||
final dynamic result = await showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
barrierColor: colorBlack.withValues(alpha: 0.55),
|
||||
builder: (BuildContext sheetContext) {
|
||||
return StepEditor(
|
||||
step: index == null ? null : _routine.steps[index],
|
||||
kind: _routine.kind,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (result == null || !mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
if (result == false && index != null) {
|
||||
_routine.steps.removeAt(index);
|
||||
} else if (result is RoutineStep) {
|
||||
if (index == null) {
|
||||
_routine.steps.add(result);
|
||||
} else {
|
||||
_routine.steps[index] = result;
|
||||
}
|
||||
_stepsError = "";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _onDuplicateStep(int index) {
|
||||
setState(() {
|
||||
_routine.steps.insert(
|
||||
index + 1, RoutineStep.fromJson(_routine.steps[index].toJson())..id = null);
|
||||
});
|
||||
}
|
||||
|
||||
void _onReorder(int oldIndex, int newIndex) {
|
||||
setState(() {
|
||||
final RoutineStep moved = _routine.steps.removeAt(oldIndex);
|
||||
_routine.steps.insert(newIndex, moved);
|
||||
});
|
||||
}
|
||||
|
||||
void _onSave() {
|
||||
final bool fieldsValid = _formKey.currentState?.validate() == true;
|
||||
|
||||
if (_routine.steps.isEmpty) {
|
||||
setState(() {
|
||||
_stepsError = "A routine needs at least one step.";
|
||||
});
|
||||
}
|
||||
|
||||
if (!fieldsValid || _routine.steps.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
_routine.title = _title.text.trim();
|
||||
_routine.note = _note.text.trim();
|
||||
|
||||
_model?.save(_routine);
|
||||
}
|
||||
|
||||
// ── Views ─────────────────────────────────────────────────────────────────
|
||||
|
||||
Widget _mobileView(BoxConstraints constraints) {
|
||||
return Sheet(
|
||||
eyebrow: kindLabel(_routine.kind),
|
||||
title: _editing ? "Edit routine" : "New routine",
|
||||
onBack: _onBack,
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
displayTitle(_editing ? "Adjust the chain." : "What are you\nbuilding?"),
|
||||
const SizedBox(height: 24),
|
||||
_kindPicker(),
|
||||
if (!_editing) ...[
|
||||
const SizedBox(height: 24),
|
||||
text("START FROM", 10, TextType.Bold,
|
||||
color: colorGrey2, letterSpacing: 0.8),
|
||||
const SizedBox(height: 10),
|
||||
_templateRail(),
|
||||
],
|
||||
const SizedBox(height: 28),
|
||||
sectionBreak("Details"),
|
||||
inputField(
|
||||
"Title",
|
||||
_title,
|
||||
hint: "Morning strength, box breathing…",
|
||||
validator: Validators.title,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
selectField(
|
||||
"Goal",
|
||||
_goalLabel(),
|
||||
_onPickGoal,
|
||||
hint: "No goal — stands on its own",
|
||||
icon: CupertinoIcons.flag,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
inputField(
|
||||
"Note",
|
||||
_note,
|
||||
hint: "Why this routine exists (optional)",
|
||||
maxLines: 2,
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
sectionBreak("Weight", caption: "decides the debt when it breaks"),
|
||||
segmentedSelector<CommitmentClass>(
|
||||
options: CommitmentClass.values,
|
||||
selected: _routine.commitmentClass,
|
||||
label: classLabel,
|
||||
onSelected: _onClassSelected,
|
||||
activeColor: classColor(_routine.commitmentClass),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
text(_classDescription(_routine.commitmentClass), 12,
|
||||
TextType.Regular,
|
||||
color: colorGrey2, height: 1.5),
|
||||
const SizedBox(height: 28),
|
||||
sectionBreak("Schedule", caption: formatDays(_routine.days)),
|
||||
_dayPicker(),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
textButton("Weekdays", _onWeekdays, textSize: 12),
|
||||
textButton("Every day", _onEveryDay, textSize: 12),
|
||||
textButton("On demand", _onClearDays, textSize: 12),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
selectField(
|
||||
"Starts at",
|
||||
formatStartMinute(_routine.startMinute),
|
||||
_onPickStart,
|
||||
hint: "Pick a time",
|
||||
icon: CupertinoIcons.clock,
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
sectionBreak(
|
||||
"Steps",
|
||||
caption:
|
||||
"${_routine.steps.length} step${_routine.steps.length == 1 ? "" : "s"}",
|
||||
trailing: _routine.steps.isEmpty
|
||||
? null
|
||||
: text("~${formatMinutes(_routine.totalSeconds / 60)}", 12,
|
||||
TextType.Bold,
|
||||
color: colorGrey2),
|
||||
),
|
||||
if (_stepsError.isNotEmpty) ...[
|
||||
text(_stepsError, 11, TextType.Regular, color: colorNegative),
|
||||
const SizedBox(height: 10),
|
||||
],
|
||||
if (_routine.steps.isNotEmpty)
|
||||
text("Hold and drag the handle to reorder.", 11,
|
||||
TextType.Regular,
|
||||
color: colorGrey),
|
||||
if (_routine.steps.isNotEmpty) const SizedBox(height: 10),
|
||||
_stepList(),
|
||||
addTaskAffordance(_onAddStep, label: "Add step"),
|
||||
if (_routine.steps.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
_chainSummary(),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
roundedCornerButton(
|
||||
_editing ? "Save changes" : "Save routine",
|
||||
_onSave,
|
||||
icon: CupertinoIcons.checkmark,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// A routine linked from the library may not have its goal's title to
|
||||
/// hand until the picker loads the goals.
|
||||
String _goalLabel() {
|
||||
if (_goal != null) {
|
||||
return _goal!.title;
|
||||
}
|
||||
return _routine.goalId == null ? "" : "Linked to a goal";
|
||||
}
|
||||
|
||||
String _classDescription(CommitmentClass value) {
|
||||
switch (value) {
|
||||
case CommitmentClass.NonNegotiable:
|
||||
return "Breaking this chain carries the heaviest debt and cannot be deferred. Use it for the few routines your day is built on.";
|
||||
case CommitmentClass.Standard:
|
||||
return "A broken chain is logged as partial and costs normal debt.";
|
||||
case CommitmentClass.Elective:
|
||||
return "Tracked, never punished. Good for routines you are still trying out.";
|
||||
}
|
||||
}
|
||||
|
||||
Widget _kindPicker() {
|
||||
return Row(
|
||||
children: RoutineKind.values.map((kind) {
|
||||
final bool last = kind == RoutineKind.values.last;
|
||||
return Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(right: last ? 0 : 8),
|
||||
child: _kindOption(kind),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _kindOption(RoutineKind kind) {
|
||||
final bool selected = _routine.kind == kind;
|
||||
final Color tone = kindColor(kind);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => _onKindSelected(kind),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
height: 124,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
gradient: selected
|
||||
? LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: <Color>[tone, kindDeep(kind)],
|
||||
)
|
||||
: null,
|
||||
color: selected ? null : colorWhite,
|
||||
border: Border.all(color: selected ? tone : colorBorder),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 34,
|
||||
height: 34,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: selected
|
||||
? colorWhite.withValues(alpha: 0.16)
|
||||
: kindBackground(kind),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(kindIcon(kind),
|
||||
size: 17, color: selected ? colorWhite : tone),
|
||||
),
|
||||
const Spacer(),
|
||||
text(kindLabel(kind), 13, TextType.Bold,
|
||||
color: selected ? colorWhite : colorPrimaryDark),
|
||||
const SizedBox(height: 3),
|
||||
text(kindDescription(kind), 10, TextType.Regular,
|
||||
color: selected
|
||||
? colorWhite.withValues(alpha: 0.7)
|
||||
: colorGrey2,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
height: 1.3),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _templateRail() {
|
||||
final List<RoutineChain> templates = RoutineTemplates.forKind(_routine.kind);
|
||||
|
||||
return SizedBox(
|
||||
height: 76,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: templates.length,
|
||||
separatorBuilder: (context, index) => const SizedBox(width: 10),
|
||||
itemBuilder: (context, index) => _templateChip(templates[index]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _templateChip(RoutineChain template) {
|
||||
final bool applied = _routine.title == template.title;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => _onTemplateSelected(template),
|
||||
child: Container(
|
||||
width: 176,
|
||||
padding: const EdgeInsets.fromLTRB(14, 12, 14, 12),
|
||||
decoration: BoxDecoration(
|
||||
color: applied ? kindBackground(_routine.kind) : colorWhite,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: applied
|
||||
? kindColor(_routine.kind).withValues(alpha: 0.4)
|
||||
: colorBorder,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
text(template.title, 13, TextType.Medium,
|
||||
color: colorPrimaryDark,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
const SizedBox(height: 5),
|
||||
text(
|
||||
"${template.steps.length} steps · ${formatMinutes(template.totalSeconds / 60)}",
|
||||
11,
|
||||
TextType.Regular,
|
||||
color: colorGrey2,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _dayPicker() {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: List<Widget>.generate(7, (index) {
|
||||
final int day = index + 1;
|
||||
final bool on = _routine.days.contains(day);
|
||||
final Color tone = kindColor(_routine.kind);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => _onToggleDay(day),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
width: 40,
|
||||
height: 40,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: on ? tone : colorWhite,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: on ? tone : colorBorder),
|
||||
),
|
||||
child: text(weekdayInitial(day), 13,
|
||||
on ? TextType.Bold : TextType.Regular,
|
||||
color: on ? colorWhite : colorGrey2),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _stepList() {
|
||||
return ReorderableListView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
buildDefaultDragHandles: false,
|
||||
itemCount: _routine.steps.length,
|
||||
onReorderItem: _onReorder,
|
||||
proxyDecorator: _dragDecorator,
|
||||
itemBuilder: (context, index) => _stepCard(index),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _dragDecorator(Widget child, int index, Animation<double> animation) {
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
elevation: 0,
|
||||
child: ScaleTransition(
|
||||
scale: Tween<double>(begin: 1, end: 1.03).animate(animation),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _stepCard(int index) {
|
||||
final RoutineStep step = _routine.steps[index];
|
||||
final Color tone = kindColor(_routine.kind);
|
||||
|
||||
return Container(
|
||||
key: ObjectKey(step),
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: card(
|
||||
padding: const EdgeInsets.fromLTRB(8, 10, 6, 10),
|
||||
onTap: () => _openStepEditor(index),
|
||||
child: Row(
|
||||
children: [
|
||||
ReorderableDragStartListener(
|
||||
index: index,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 8),
|
||||
child: Icon(Icons.drag_indicator_rounded,
|
||||
size: 18, color: colorGrey),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: 30,
|
||||
height: 30,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: kindBackground(_routine.kind),
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
),
|
||||
child: text("${index + 1}", 12, TextType.Bold, color: tone),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
text(step.title, 14, TextType.Medium,
|
||||
color: colorPrimaryDark,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Icon(stepModeIcon(step.mode), size: 11, color: colorGrey2),
|
||||
const SizedBox(width: 5),
|
||||
Expanded(
|
||||
child: text(stepSummary(step), 11, TextType.Regular,
|
||||
color: colorGrey2,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
iconButton(
|
||||
Icon(CupertinoIcons.plus_square_on_square,
|
||||
size: 16, color: colorGrey2),
|
||||
() => _onDuplicateStep(index),
|
||||
size: 34,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The dark totals card under the steps — the whole chain in three numbers.
|
||||
Widget _chainSummary() {
|
||||
final Color label = colorWhite.withValues(alpha: 0.45);
|
||||
|
||||
return card(
|
||||
background: kindDeep(_routine.kind),
|
||||
borderColor: kindDeep(_routine.kind),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: labelled("Planned", formatMinutes(_routine.totalSeconds / 60),
|
||||
valueSize: 20,
|
||||
valueType: TextType.Light,
|
||||
valueColor: colorWhite,
|
||||
labelColor: label),
|
||||
),
|
||||
Expanded(
|
||||
child: labelled("Steps", "${_routine.steps.length}",
|
||||
valueSize: 20,
|
||||
valueType: TextType.Light,
|
||||
valueColor: colorWhite,
|
||||
labelColor: label),
|
||||
),
|
||||
Expanded(
|
||||
child: labelled(
|
||||
_routine.kind == RoutineKind.MustDo ? "Tasks" : "Sets",
|
||||
"${_routine.kind == RoutineKind.MustDo ? _routine.steps.length : _routine.totalSets}",
|
||||
valueSize: 20,
|
||||
valueType: TextType.Light,
|
||||
valueColor: colorWhite,
|
||||
labelColor: label),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── ConnectRoutineBuilder ─────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
void onSaved() {
|
||||
Navigator.pop(context, true);
|
||||
}
|
||||
|
||||
@override
|
||||
void onGoalsReady(List<Goal> goals) async {
|
||||
final Goal? picked =
|
||||
await showGoalPicker(context, goals, selectedId: _routine.goalId);
|
||||
|
||||
if (picked == null || !mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_goal = picked.id == null ? null : picked;
|
||||
_routine.goalId = picked.id;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_title.dispose();
|
||||
_note.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
14
frontend/lib/Grounded/see/routine/RoutineRunner.dart
Normal file
14
frontend/lib/Grounded/see/routine/RoutineRunner.dart
Normal file
@@ -0,0 +1,14 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../about/external/data/RoutineChain.dart';
|
||||
import 'RoutineRunnerState.dart';
|
||||
|
||||
class RoutineRunner extends StatefulWidget {
|
||||
/// A copy of the routine — the runner marks steps complete as it goes.
|
||||
final RoutineChain routine;
|
||||
|
||||
const RoutineRunner({super.key, required this.routine});
|
||||
|
||||
@override
|
||||
State<RoutineRunner> createState() => RoutineRunnerState();
|
||||
}
|
||||
1100
frontend/lib/Grounded/see/routine/RoutineRunnerState.dart
Normal file
1100
frontend/lib/Grounded/see/routine/RoutineRunnerState.dart
Normal file
File diff suppressed because it is too large
Load Diff
10
frontend/lib/Grounded/see/routine/Routines.dart
Normal file
10
frontend/lib/Grounded/see/routine/Routines.dart
Normal file
@@ -0,0 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'RoutinesState.dart';
|
||||
|
||||
class Routines extends StatefulWidget {
|
||||
const Routines({super.key});
|
||||
|
||||
@override
|
||||
State<Routines> createState() => RoutinesState();
|
||||
}
|
||||
787
frontend/lib/Grounded/see/routine/RoutinesState.dart
Normal file
787
frontend/lib/Grounded/see/routine/RoutinesState.dart
Normal file
@@ -0,0 +1,787 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stacked/stacked.dart';
|
||||
|
||||
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/TextType.dart';
|
||||
import '../../configs/Navigator.dart';
|
||||
import '../../designs/Component.dart';
|
||||
import '../../designs/ProgressRing.dart';
|
||||
import '../../designs/Responsive.dart';
|
||||
import '../../designs/Shell.dart';
|
||||
import '../../designs/buttons/Buttons.dart';
|
||||
import '../../designs/text/Text.dart';
|
||||
import '../../utils/Colors.dart';
|
||||
import '../../utils/CommonUtils.dart';
|
||||
import 'ConnectRoutines.dart';
|
||||
import 'RoutineBuilder.dart';
|
||||
import 'RoutineRunner.dart';
|
||||
import 'Routines.dart';
|
||||
import 'ViewRoutines.dart';
|
||||
|
||||
/// The routine library: what is due today up front, everything else below,
|
||||
/// filtered by kind.
|
||||
class RoutinesState extends State<Routines> implements ConnectRoutines {
|
||||
ViewRoutines? _model;
|
||||
|
||||
List<RoutineChain> _routines = <RoutineChain>[];
|
||||
|
||||
/// Null shows every kind.
|
||||
RoutineKind? _kind;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ViewModelBuilder<ViewRoutines>.reactive(
|
||||
viewModelBuilder: () => ViewRoutines(context, this),
|
||||
onViewModelReady: (viewModel) {
|
||||
_model = viewModel;
|
||||
_initiate();
|
||||
},
|
||||
builder: (context, viewModel, child) => LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
return Responsive(
|
||||
mobile: _mobileView(constraints),
|
||||
tablet: _mobileView(constraints),
|
||||
desktop: _mobileView(constraints),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _initiate() {
|
||||
_model?.loadRoutines();
|
||||
}
|
||||
|
||||
// ── Derived ───────────────────────────────────────────────────────────────
|
||||
|
||||
List<RoutineChain> get _filtered {
|
||||
if (_kind == null) {
|
||||
return _routines;
|
||||
}
|
||||
return _routines.where((item) => item.kind == _kind).toList();
|
||||
}
|
||||
|
||||
List<RoutineChain> get _today {
|
||||
final DateTime now = DateTime.now();
|
||||
return _filtered.where((item) => item.scheduledOn(now)).toList();
|
||||
}
|
||||
|
||||
List<RoutineChain> get _rest {
|
||||
final DateTime now = DateTime.now();
|
||||
return _filtered.where((item) => !item.scheduledOn(now)).toList();
|
||||
}
|
||||
|
||||
/// Today's routines that are not finished, soonest first — the featured
|
||||
/// rail. Falls back to the whole library when nothing is due.
|
||||
List<RoutineChain> get _upNext {
|
||||
final List<RoutineChain> open =
|
||||
_today.where((item) => !item.complete).toList();
|
||||
final List<RoutineChain> source = open.isEmpty ? _filtered : open;
|
||||
return source.take(5).toList();
|
||||
}
|
||||
|
||||
int _countFor(RoutineKind? kind) {
|
||||
if (kind == null) {
|
||||
return _routines.length;
|
||||
}
|
||||
return _routines.where((item) => item.kind == kind).length;
|
||||
}
|
||||
|
||||
// ── Handlers ──────────────────────────────────────────────────────────────
|
||||
|
||||
void _onBack() {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
|
||||
void _onSelectKind(RoutineKind? kind) {
|
||||
setState(() {
|
||||
_kind = _kind == kind ? null : kind;
|
||||
});
|
||||
}
|
||||
|
||||
void _onBuild() async {
|
||||
final result = await GroundedNavigation()
|
||||
.navigateToPageWithData(RoutineBuilder(kind: _kind), context);
|
||||
|
||||
if (result == true) {
|
||||
_model?.loadRoutines();
|
||||
}
|
||||
}
|
||||
|
||||
void _onEdit(RoutineChain routine) async {
|
||||
final result = await GroundedNavigation()
|
||||
.navigateToPageWithData(RoutineBuilder(routine: routine), context);
|
||||
|
||||
if (result == true) {
|
||||
_model?.loadRoutines();
|
||||
}
|
||||
}
|
||||
|
||||
void _onStart(RoutineChain routine) async {
|
||||
if (routine.steps.isEmpty) {
|
||||
_onEdit(routine);
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await GroundedNavigation()
|
||||
.navigateToPageWithData(RoutineRunner(routine: routine.copy()), context);
|
||||
|
||||
if (result == true) {
|
||||
_model?.loadRoutines();
|
||||
}
|
||||
}
|
||||
|
||||
void _onPreview(RoutineChain routine) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
barrierColor: colorBlack.withValues(alpha: 0.55),
|
||||
builder: (BuildContext sheetContext) {
|
||||
return _previewSheet(sheetContext, routine);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ── Views ─────────────────────────────────────────────────────────────────
|
||||
|
||||
Widget _mobileView(BoxConstraints constraints) {
|
||||
return Sheet(
|
||||
eyebrow: "${_routines.length} routine${_routines.length == 1 ? "" : "s"}",
|
||||
title: "Routines",
|
||||
onBack: _onBack,
|
||||
action: chromeAction(CupertinoIcons.add, _onBuild),
|
||||
banner: _routines.isEmpty ? null : _todayBanner(),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
text(
|
||||
"${weekdayName(DateTime.now().weekday).toUpperCase()} · ${_today.length} DUE",
|
||||
11,
|
||||
TextType.Bold,
|
||||
color: colorGrey2,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
displayTitle(_headline()),
|
||||
const SizedBox(height: 24),
|
||||
_kindRail(),
|
||||
const SizedBox(height: 28),
|
||||
if (_filtered.isEmpty)
|
||||
emptyState(
|
||||
_kind == null ? CupertinoIcons.square_stack_3d_up : kindIcon(_kind!),
|
||||
_kind == null
|
||||
? "No routines yet"
|
||||
: "No ${kindLabel(_kind!).toLowerCase()} routines",
|
||||
"A routine is a chain: every step, in order. Break it and it is logged as partial, not quietly forgotten.",
|
||||
)
|
||||
else ...[
|
||||
sectionBreak(
|
||||
"Up next",
|
||||
caption: _today.isEmpty ? "from your library" : "today",
|
||||
),
|
||||
_featuredRail(),
|
||||
const SizedBox(height: 28),
|
||||
if (_today.isNotEmpty) ...[
|
||||
sectionBreak(
|
||||
"Scheduled today",
|
||||
caption: "${_today.where((item) => item.complete).length} of ${_today.length} done",
|
||||
),
|
||||
..._today.map(_routineRow),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
if (_rest.isNotEmpty) ...[
|
||||
sectionBreak("Library", caption: "${_rest.length} more"),
|
||||
..._rest.map(_routineRow),
|
||||
],
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
roundedCornerButton(
|
||||
"Build a routine",
|
||||
_onBuild,
|
||||
icon: CupertinoIcons.add,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _headline() {
|
||||
if (_routines.isEmpty) {
|
||||
return "Build the things you do every time.";
|
||||
}
|
||||
final int open = _today.where((item) => !item.complete).length;
|
||||
if (_today.isEmpty) {
|
||||
return "Nothing scheduled. Run one anyway.";
|
||||
}
|
||||
if (open == 0) {
|
||||
return "Every chain held today.";
|
||||
}
|
||||
return "Here's what to hold today.";
|
||||
}
|
||||
|
||||
/// The strip in the black chrome: today's load at a glance.
|
||||
Widget _todayBanner() {
|
||||
final DateTime now = DateTime.now();
|
||||
final List<RoutineChain> due =
|
||||
_routines.where((item) => item.scheduledOn(now)).toList();
|
||||
|
||||
int seconds = 0;
|
||||
for (RoutineChain item in due) {
|
||||
seconds = seconds + item.totalSeconds;
|
||||
}
|
||||
|
||||
final int nowMinute = now.hour * 60 + now.minute;
|
||||
final List<RoutineChain> ahead = due
|
||||
.where((item) => !item.complete && item.startMinute >= nowMinute)
|
||||
.toList()
|
||||
..sort((a, b) => a.startMinute.compareTo(b.startMinute));
|
||||
|
||||
final Color label = colorWhite.withValues(alpha: 0.45);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
|
||||
decoration: BoxDecoration(
|
||||
color: colorWhite.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: labelled("Due today", "${due.length}",
|
||||
valueSize: 22,
|
||||
valueType: TextType.Light,
|
||||
valueColor: colorWhite,
|
||||
labelColor: label),
|
||||
),
|
||||
Expanded(
|
||||
child: labelled("Planned", formatMinutes(seconds / 60),
|
||||
valueSize: 22,
|
||||
valueType: TextType.Light,
|
||||
valueColor: colorWhite,
|
||||
labelColor: label),
|
||||
),
|
||||
Expanded(
|
||||
child: labelled(
|
||||
"Next",
|
||||
ahead.isEmpty ? "—" : formatStartMinute(ahead.first.startMinute),
|
||||
valueSize: 22,
|
||||
valueType: TextType.Light,
|
||||
valueColor: colorWhite,
|
||||
labelColor: label),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Round category tiles — tap to filter, tap again to clear.
|
||||
Widget _kindRail() {
|
||||
final List<RoutineKind?> options = <RoutineKind?>[null, ...RoutineKind.values];
|
||||
|
||||
return SizedBox(
|
||||
height: 96,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: options.length,
|
||||
separatorBuilder: (context, index) => const SizedBox(width: 14),
|
||||
itemBuilder: (context, index) => _kindTile(options[index]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _kindTile(RoutineKind? kind) {
|
||||
final bool selected = _kind == kind;
|
||||
final Color tone = kind == null ? colorPrimaryDark : kindColor(kind);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => _onSelectKind(kind),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: SizedBox(
|
||||
width: 70,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
curve: Curves.easeOut,
|
||||
width: 60,
|
||||
height: 60,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: selected
|
||||
? LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: <Color>[
|
||||
tone,
|
||||
kind == null ? colorPrimaryDark2 : kindDeep(kind),
|
||||
],
|
||||
)
|
||||
: null,
|
||||
color: selected ? null : colorWhite,
|
||||
border: Border.all(
|
||||
color: selected ? tone : colorBorder,
|
||||
width: selected ? 2 : 1,
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
kind == null ? CupertinoIcons.square_grid_2x2_fill : kindIcon(kind),
|
||||
size: 22,
|
||||
color: selected ? colorWhite : tone,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
text(
|
||||
kind == null ? "All" : kindLabel(kind),
|
||||
11,
|
||||
selected ? TextType.Bold : TextType.Regular,
|
||||
color: selected ? colorPrimaryDark : colorGrey2,
|
||||
maxLines: 1,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
text("${_countFor(kind)}", 9, TextType.Bold,
|
||||
color: colorGrey, letterSpacing: 0.6),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _featuredRail() {
|
||||
final List<RoutineChain> items = _upNext;
|
||||
|
||||
return SizedBox(
|
||||
height: 214,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
clipBehavior: Clip.none,
|
||||
itemCount: items.length,
|
||||
separatorBuilder: (context, index) => const SizedBox(width: 12),
|
||||
itemBuilder: (context, index) => _featuredCard(items[index]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The large gradient card. The kind's icon sits oversized in the corner as
|
||||
/// the card's picture — routines have no stock photography.
|
||||
Widget _featuredCard(RoutineChain routine) {
|
||||
final Color tone = kindColor(routine.kind);
|
||||
final Color label = colorWhite.withValues(alpha: 0.55);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => _onPreview(routine),
|
||||
child: Container(
|
||||
width: 248,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: <Color>[tone, kindDeep(routine.kind)],
|
||||
),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x08000000),
|
||||
blurRadius: 18,
|
||||
offset: Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
right: -26,
|
||||
bottom: 34,
|
||||
child: Icon(
|
||||
kindIcon(routine.kind),
|
||||
size: 150,
|
||||
color: colorWhite.withValues(alpha: 0.08),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
pill(kindLabel(routine.kind), colorWhite,
|
||||
colorWhite.withValues(alpha: 0.14),
|
||||
textSize: 9),
|
||||
if (routine.commitmentClass ==
|
||||
CommitmentClass.NonNegotiable) ...[
|
||||
const SizedBox(width: 6),
|
||||
pill("Must hold", colorWhite,
|
||||
colorWhite.withValues(alpha: 0.14),
|
||||
textSize: 9),
|
||||
],
|
||||
const Spacer(),
|
||||
Container(
|
||||
width: 34,
|
||||
height: 34,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: colorWhite,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(CupertinoIcons.play_fill,
|
||||
size: 14, color: kindDeep(routine.kind)),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Spacer(),
|
||||
if (routine.startMinute >= 0) ...[
|
||||
text(
|
||||
"${formatStartMinute(routine.startMinute)} · ${formatDays(routine.days)}"
|
||||
.toUpperCase(),
|
||||
9,
|
||||
TextType.Bold,
|
||||
color: label,
|
||||
letterSpacing: 1.0,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
],
|
||||
text(routine.title, 21, TextType.Light,
|
||||
color: colorWhite,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
height: 1.15),
|
||||
const SizedBox(height: 14),
|
||||
Container(
|
||||
height: 1,
|
||||
color: colorWhite.withValues(alpha: 0.12),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: labelled(
|
||||
"Minutes",
|
||||
"${(routine.totalSeconds / 60).round()}",
|
||||
valueSize: 17,
|
||||
valueColor: colorWhite,
|
||||
labelColor: label,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: labelled(
|
||||
"Steps",
|
||||
"${routine.steps.length}",
|
||||
valueSize: 17,
|
||||
valueColor: colorWhite,
|
||||
labelColor: label,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: labelled(
|
||||
routine.kind == RoutineKind.Workout ? "Sets" : "Starts",
|
||||
routine.kind == RoutineKind.Workout
|
||||
? "${routine.totalSets}"
|
||||
: routine.startMinute < 0
|
||||
? "Any"
|
||||
: formatStartMinute(routine.startMinute),
|
||||
valueSize: 17,
|
||||
valueColor: colorWhite,
|
||||
labelColor: label,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _routineRow(RoutineChain routine) {
|
||||
final Color tone = kindColor(routine.kind);
|
||||
final bool weighted = routine.commitmentClass != CommitmentClass.Standard;
|
||||
|
||||
final List<String> eyebrow = <String>[
|
||||
if (weighted) classLabel(routine.commitmentClass),
|
||||
if (routine.startMinute >= 0) formatStartMinute(routine.startMinute),
|
||||
formatDays(routine.days),
|
||||
];
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
child: card(
|
||||
padding: const EdgeInsets.fromLTRB(10, 10, 14, 10),
|
||||
onTap: () => _onPreview(routine),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 58,
|
||||
height: 58,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: <Color>[kindBackground(routine.kind), colorWhite],
|
||||
),
|
||||
border: Border.all(color: tone.withValues(alpha: 0.15)),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Icon(kindIcon(routine.kind), size: 24, color: tone),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
text(
|
||||
eyebrow.join(" · ").toUpperCase(),
|
||||
9,
|
||||
TextType.Bold,
|
||||
color: weighted
|
||||
? classColor(routine.commitmentClass)
|
||||
: colorGrey2,
|
||||
letterSpacing: 0.9,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
text(routine.title, 15, TextType.Medium,
|
||||
color: colorPrimaryDark,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
_meta(CupertinoIcons.clock,
|
||||
formatMinutes(routine.totalSeconds / 60)),
|
||||
const SizedBox(width: 12),
|
||||
_countMeta(routine),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
routine.complete
|
||||
? Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: tone,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(Icons.check_rounded,
|
||||
size: 22, color: colorWhite),
|
||||
)
|
||||
: ProgressRing(
|
||||
fraction: routine.partialCompletion,
|
||||
fill: tone,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _countMeta(RoutineChain routine) {
|
||||
switch (routine.kind) {
|
||||
case RoutineKind.Workout:
|
||||
return _meta(CupertinoIcons.repeat, "${routine.totalSets} sets");
|
||||
case RoutineKind.Meditation:
|
||||
return _meta(CupertinoIcons.circle_grid_hex,
|
||||
"${routine.steps.length} steps");
|
||||
case RoutineKind.MustDo:
|
||||
return _meta(CupertinoIcons.checkmark_square,
|
||||
"${routine.steps.length} tasks");
|
||||
}
|
||||
}
|
||||
|
||||
Widget _meta(IconData icon, String value) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 12, color: colorGrey2),
|
||||
const SizedBox(width: 4),
|
||||
text(value, 11, TextType.Regular, color: colorGrey2),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// The preview: the whole chain laid out before you commit to starting it.
|
||||
Widget _previewSheet(BuildContext sheetContext, RoutineChain routine) {
|
||||
final Color tone = kindColor(routine.kind);
|
||||
|
||||
return Container(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(sheetContext).size.height * 0.86,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
color: colorSheetBackground,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(28),
|
||||
topRight: Radius.circular(28),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 38,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.only(top: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: colorGrey.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(24, 20, 24, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
pill(kindLabel(routine.kind), tone,
|
||||
kindBackground(routine.kind),
|
||||
icon: kindIcon(routine.kind), textSize: 9),
|
||||
const SizedBox(width: 6),
|
||||
pill(classLabel(routine.commitmentClass),
|
||||
classColor(routine.commitmentClass),
|
||||
classBackground(routine.commitmentClass),
|
||||
textSize: 9),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
displayTitle(routine.title, size: 30),
|
||||
if (routine.note.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
text(routine.note, 13, TextType.Regular,
|
||||
color: colorGrey2, height: 1.5),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: statTile("Minutes",
|
||||
"${(routine.totalSeconds / 60).round()}")),
|
||||
Expanded(
|
||||
child: statTile("Steps", "${routine.steps.length}")),
|
||||
Expanded(
|
||||
child: statTile(
|
||||
"When",
|
||||
routine.startMinute < 0
|
||||
? "Any"
|
||||
: formatStartMinute(routine.startMinute),
|
||||
caption: formatDays(routine.days),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
sectionBreak("The chain",
|
||||
caption: routine.kind == RoutineKind.Workout
|
||||
? "${routine.totalSets} sets, in order"
|
||||
: "${routine.steps.length} steps, in order"),
|
||||
...routine.steps
|
||||
.asMap()
|
||||
.entries
|
||||
.map((entry) => _previewStep(entry.key, entry.value, tone)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 8, 24, 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: outlinedActionButton("Edit", () {
|
||||
Navigator.pop(sheetContext);
|
||||
_onEdit(routine);
|
||||
}, icon: CupertinoIcons.pencil),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: roundedCornerButton("Start", () {
|
||||
Navigator.pop(sheetContext);
|
||||
_onStart(routine);
|
||||
},
|
||||
icon: CupertinoIcons.play_fill,
|
||||
enabled: routine.steps.isNotEmpty),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _previewStep(int index, RoutineStep step, Color tone) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: tone.withValues(alpha: 0.10),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: text("${index + 1}", 11, TextType.Bold, color: tone),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 4),
|
||||
text(step.title, 14, TextType.Medium, color: colorPrimaryDark),
|
||||
const SizedBox(height: 3),
|
||||
text(stepSummary(step), 11, TextType.Regular,
|
||||
color: colorGrey2),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 5),
|
||||
child: Icon(stepModeIcon(step.mode), size: 15, color: colorGrey),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── ConnectRoutines ───────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
void onRoutinesLoaded(List<RoutineChain> routines) {
|
||||
setState(() {
|
||||
_routines = routines;
|
||||
});
|
||||
}
|
||||
}
|
||||
20
frontend/lib/Grounded/see/routine/StepEditor.dart
Normal file
20
frontend/lib/Grounded/see/routine/StepEditor.dart
Normal file
@@ -0,0 +1,20 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../about/external/data/RoutineStep.dart';
|
||||
import '../../about/internal/application/RoutineKind.dart';
|
||||
import 'StepEditorState.dart';
|
||||
|
||||
/// The bottom sheet for one step. Pops with the edited [RoutineStep], with
|
||||
/// `false` when the step is removed, or with nothing when dismissed.
|
||||
class StepEditor extends StatefulWidget {
|
||||
/// The step being edited; null adds a new one.
|
||||
final RoutineStep? step;
|
||||
|
||||
/// Decides the defaults for a new step.
|
||||
final RoutineKind kind;
|
||||
|
||||
const StepEditor({super.key, this.step, required this.kind});
|
||||
|
||||
@override
|
||||
State<StepEditor> createState() => StepEditorState();
|
||||
}
|
||||
362
frontend/lib/Grounded/see/routine/StepEditorState.dart
Normal file
362
frontend/lib/Grounded/see/routine/StepEditorState.dart
Normal file
@@ -0,0 +1,362 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
|
||||
import '../../about/external/data/RoutineStep.dart';
|
||||
import '../../about/internal/application/RoutineKind.dart';
|
||||
import '../../about/internal/application/StepMode.dart';
|
||||
import '../../about/internal/application/TextType.dart';
|
||||
import '../../designs/buttons/Buttons.dart';
|
||||
import '../../designs/input/CountStepper.dart';
|
||||
import '../../designs/input/InputFields.dart';
|
||||
import '../../designs/text/Text.dart';
|
||||
import '../../utils/Colors.dart';
|
||||
import '../../utils/CommonUtils.dart';
|
||||
import '../../utils/Validators.dart';
|
||||
import 'StepEditor.dart';
|
||||
|
||||
class StepEditorState extends State<StepEditor> {
|
||||
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
|
||||
|
||||
final TextEditingController _title = TextEditingController();
|
||||
|
||||
final TextEditingController _cue = TextEditingController();
|
||||
|
||||
late RoutineStep _step;
|
||||
|
||||
bool get _editing {
|
||||
return widget.step != null;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_step = widget.step == null
|
||||
? _defaultsFor(widget.kind)
|
||||
: RoutineStep.fromJson(widget.step!.toJson());
|
||||
|
||||
_title.text = _step.title;
|
||||
_cue.text = _step.cue;
|
||||
}
|
||||
|
||||
/// A new step starts as the kind's most common shape, so most edits are
|
||||
/// just a title.
|
||||
RoutineStep _defaultsFor(RoutineKind kind) {
|
||||
switch (kind) {
|
||||
case RoutineKind.Workout:
|
||||
return RoutineStep(
|
||||
mode: StepMode.Reps,
|
||||
sets: 3,
|
||||
reps: 10,
|
||||
timerSeconds: 40,
|
||||
restSeconds: 60);
|
||||
case RoutineKind.Meditation:
|
||||
return RoutineStep(mode: StepMode.Timed, timerSeconds: 300);
|
||||
case RoutineKind.MustDo:
|
||||
return RoutineStep(mode: StepMode.Check, timerSeconds: 120);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
||||
child: Container(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.9,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
color: colorSheetBackground,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(28),
|
||||
topRight: Radius.circular(28),
|
||||
),
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 28),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
text(_editing ? "EDIT STEP" : "NEW STEP", 9, TextType.Bold,
|
||||
color: kindColor(widget.kind), letterSpacing: 1.2),
|
||||
const SizedBox(height: 10),
|
||||
text(_title.text.isEmpty ? "What happens here?" : _title.text,
|
||||
24, TextType.Light,
|
||||
color: colorPrimaryDark, height: 1.2),
|
||||
const SizedBox(height: 20),
|
||||
inputField(
|
||||
"Title",
|
||||
_title,
|
||||
hint: _titleHint(),
|
||||
validator: Validators.title,
|
||||
onChanged: _onTitleChanged,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
text("MODE", 10, TextType.Bold,
|
||||
color: colorGrey2, letterSpacing: 0.8),
|
||||
const SizedBox(height: 8),
|
||||
segmentedSelector<StepMode>(
|
||||
options: StepMode.values,
|
||||
selected: _step.mode,
|
||||
label: stepModeLabel,
|
||||
onSelected: _onModeSelected,
|
||||
activeColor: kindColor(widget.kind),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
text(_modeDescription(_step.mode), 12, TextType.Regular,
|
||||
color: colorGrey2, height: 1.5),
|
||||
const SizedBox(height: 20),
|
||||
..._numbers(),
|
||||
const SizedBox(height: 20),
|
||||
inputField(
|
||||
"Cue",
|
||||
_cue,
|
||||
hint: widget.kind == RoutineKind.Meditation
|
||||
? "In 4 · hold 4 · out 4"
|
||||
: "One line to read mid-step (optional)",
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_summaryLine(),
|
||||
const SizedBox(height: 20),
|
||||
roundedCornerButton(
|
||||
_editing ? "Update step" : "Add step",
|
||||
_onSave,
|
||||
background: colorPrimaryDark,
|
||||
icon: _editing ? CupertinoIcons.checkmark : CupertinoIcons.add,
|
||||
),
|
||||
if (_editing) ...[
|
||||
const SizedBox(height: 6),
|
||||
Center(
|
||||
child: textButton("Remove step", _onRemove,
|
||||
color: colorDestructive, textSize: 13),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _titleHint() {
|
||||
switch (widget.kind) {
|
||||
case RoutineKind.Workout:
|
||||
return "Deadlifts, plank, warm up…";
|
||||
case RoutineKind.Meditation:
|
||||
return "Settle, breathe, body scan…";
|
||||
case RoutineKind.MustDo:
|
||||
return "Make the bed, plan tomorrow…";
|
||||
}
|
||||
}
|
||||
|
||||
String _modeDescription(StepMode mode) {
|
||||
switch (mode) {
|
||||
case StepMode.Timed:
|
||||
return "The clock runs down and the next beat starts on its own.";
|
||||
case StepMode.Reps:
|
||||
return "You end each set when the reps are done. Rest is timed.";
|
||||
case StepMode.Check:
|
||||
return "A task. You tick it; the clock records how long it took.";
|
||||
}
|
||||
}
|
||||
|
||||
/// The steppers that apply to the chosen mode.
|
||||
List<Widget> _numbers() {
|
||||
final List<Widget> rows = <Widget>[];
|
||||
|
||||
switch (_step.mode) {
|
||||
case StepMode.Timed:
|
||||
rows.add(_pair(
|
||||
countStepper("Duration", formatSeconds(_step.timerSeconds),
|
||||
onMinus: _onDurationMinus,
|
||||
onPlus: _onDurationPlus,
|
||||
canMinus: _step.timerSeconds > 5),
|
||||
countStepper("Sets", "${_step.sets}",
|
||||
onMinus: _onSetsMinus,
|
||||
onPlus: _onSetsPlus,
|
||||
canMinus: _step.sets > 1),
|
||||
));
|
||||
break;
|
||||
case StepMode.Reps:
|
||||
rows.add(_pair(
|
||||
countStepper("Reps", "${_step.reps}",
|
||||
onMinus: _onRepsMinus,
|
||||
onPlus: _onRepsPlus,
|
||||
canMinus: _step.reps > 1),
|
||||
countStepper("Sets", "${_step.sets}",
|
||||
onMinus: _onSetsMinus,
|
||||
onPlus: _onSetsPlus,
|
||||
canMinus: _step.sets > 1),
|
||||
));
|
||||
rows.add(const SizedBox(height: 10));
|
||||
rows.add(countStepper("Est. per set", formatSeconds(_step.timerSeconds),
|
||||
onMinus: _onDurationMinus,
|
||||
onPlus: _onDurationPlus,
|
||||
canMinus: _step.timerSeconds > 5));
|
||||
break;
|
||||
case StepMode.Check:
|
||||
rows.add(countStepper("Estimate", formatSeconds(_step.timerSeconds),
|
||||
onMinus: _onDurationMinus,
|
||||
onPlus: _onDurationPlus,
|
||||
canMinus: _step.timerSeconds > 5));
|
||||
break;
|
||||
}
|
||||
|
||||
if (_step.mode != StepMode.Check) {
|
||||
rows.add(const SizedBox(height: 10));
|
||||
rows.add(countStepper(
|
||||
"Rest after each set",
|
||||
_step.restSeconds == 0 ? "None" : formatSeconds(_step.restSeconds),
|
||||
onMinus: _onRestMinus,
|
||||
onPlus: _onRestPlus,
|
||||
canMinus: _step.restSeconds > 0,
|
||||
));
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
Widget _pair(Widget left, Widget right) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(child: left),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(child: right),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _summaryLine() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: kindBackground(widget.kind),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(stepModeIcon(_step.mode),
|
||||
size: 15, color: kindColor(widget.kind)),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: text(stepSummary(_step), 12, TextType.Medium,
|
||||
color: colorPrimaryDark),
|
||||
),
|
||||
text("~${formatSeconds(_step.plannedSeconds)}", 12, TextType.Bold,
|
||||
color: kindColor(widget.kind)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Handlers ──────────────────────────────────────────────────────────────
|
||||
|
||||
void _onTitleChanged(String value) {
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void _onModeSelected(StepMode mode) {
|
||||
setState(() {
|
||||
_step.mode = mode;
|
||||
if (mode == StepMode.Reps && _step.reps == 0) {
|
||||
_step.reps = 10;
|
||||
}
|
||||
if (mode == StepMode.Check) {
|
||||
_step.sets = 1;
|
||||
_step.restSeconds = 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Steps get coarser as the duration grows: 5s near the bottom, a minute
|
||||
/// once you are into meditation lengths.
|
||||
int _durationIncrement(int seconds) {
|
||||
if (seconds < 60) {
|
||||
return 5;
|
||||
}
|
||||
if (seconds < 180) {
|
||||
return 15;
|
||||
}
|
||||
if (seconds < 600) {
|
||||
return 30;
|
||||
}
|
||||
return 60;
|
||||
}
|
||||
|
||||
void _onDurationMinus() {
|
||||
setState(() {
|
||||
final int next =
|
||||
_step.timerSeconds - _durationIncrement(_step.timerSeconds - 1);
|
||||
_step.timerSeconds = next < 5 ? 5 : next;
|
||||
});
|
||||
}
|
||||
|
||||
void _onDurationPlus() {
|
||||
setState(() {
|
||||
_step.timerSeconds =
|
||||
_step.timerSeconds + _durationIncrement(_step.timerSeconds);
|
||||
});
|
||||
}
|
||||
|
||||
void _onSetsMinus() {
|
||||
setState(() {
|
||||
_step.sets = _step.sets > 1 ? _step.sets - 1 : 1;
|
||||
});
|
||||
}
|
||||
|
||||
void _onSetsPlus() {
|
||||
setState(() {
|
||||
_step.sets = _step.sets + 1;
|
||||
});
|
||||
}
|
||||
|
||||
void _onRepsMinus() {
|
||||
setState(() {
|
||||
_step.reps = _step.reps > 1 ? _step.reps - 1 : 1;
|
||||
});
|
||||
}
|
||||
|
||||
void _onRepsPlus() {
|
||||
setState(() {
|
||||
_step.reps = _step.reps + 1;
|
||||
});
|
||||
}
|
||||
|
||||
void _onRestMinus() {
|
||||
setState(() {
|
||||
final int next = _step.restSeconds - 15;
|
||||
_step.restSeconds = next < 0 ? 0 : next;
|
||||
});
|
||||
}
|
||||
|
||||
void _onRestPlus() {
|
||||
setState(() {
|
||||
_step.restSeconds = _step.restSeconds + 15;
|
||||
});
|
||||
}
|
||||
|
||||
void _onSave() {
|
||||
if (_formKey.currentState?.validate() != true) {
|
||||
return;
|
||||
}
|
||||
|
||||
_step.title = _title.text.trim();
|
||||
_step.cue = _cue.text.trim();
|
||||
|
||||
Navigator.pop(context, _step);
|
||||
}
|
||||
|
||||
void _onRemove() {
|
||||
Navigator.pop(context, false);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_title.dispose();
|
||||
_cue.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
57
frontend/lib/Grounded/see/routine/ViewRoutineBuilder.dart
Normal file
57
frontend/lib/Grounded/see/routine/ViewRoutineBuilder.dart
Normal file
@@ -0,0 +1,57 @@
|
||||
import '../../about/external/data/RoutineChain.dart';
|
||||
import '../../about/external/data/pages/request/HistoryRequest.dart';
|
||||
import '../../about/external/data/pages/request/PageAndSort.dart';
|
||||
import '../../about/external/data/pages/request/Pageable.dart';
|
||||
import '../../about/external/data/pages/request/Sort.dart';
|
||||
import '../../utils/ObjectConvertors.dart';
|
||||
import '../parent/ParentViewModel.dart';
|
||||
import 'ConnectRoutineBuilder.dart';
|
||||
|
||||
class ViewRoutineBuilder extends ParentViewModel {
|
||||
ConnectRoutineBuilder connection;
|
||||
|
||||
ViewRoutineBuilder(super.context, this.connection);
|
||||
|
||||
/// Goals for the picker — loaded on tap, never up front.
|
||||
void loadGoals() async {
|
||||
if (!await hasNetwork(() => loadGoals())) return;
|
||||
|
||||
showLoading("Loading goals");
|
||||
|
||||
try {
|
||||
final response = await getDataManager().getMyGoals(HistoryRequest(
|
||||
query: PageAndSort(
|
||||
sort: Sort('desc', 'startDate'),
|
||||
page: Pageable(0, 0, 50, 0),
|
||||
),
|
||||
));
|
||||
|
||||
closeLoading();
|
||||
|
||||
connection.onGoalsReady(getGoalList(response.data));
|
||||
} catch (e) {
|
||||
handleError(e, () => loadGoals(), () => dismissError(), "Retry");
|
||||
}
|
||||
}
|
||||
|
||||
void save(RoutineChain routine) async {
|
||||
if (!await hasNetwork(() => save(routine))) return;
|
||||
|
||||
showLoading("Saving routine");
|
||||
|
||||
try {
|
||||
for (int index = 0; index < routine.steps.length; index++) {
|
||||
routine.steps[index].order = index;
|
||||
routine.steps[index].completed = false;
|
||||
}
|
||||
|
||||
await getDataManager().saveRoutineEntry(routine.toJson());
|
||||
|
||||
closeLoading();
|
||||
|
||||
connection.onSaved();
|
||||
} catch (e) {
|
||||
handleError(e, () => save(routine), () => dismissError(), "Retry");
|
||||
}
|
||||
}
|
||||
}
|
||||
60
frontend/lib/Grounded/see/routine/ViewRoutineRunner.dart
Normal file
60
frontend/lib/Grounded/see/routine/ViewRoutineRunner.dart
Normal file
@@ -0,0 +1,60 @@
|
||||
import '../../about/external/data/LiveSession.dart';
|
||||
import '../../about/external/initial/RoutineLogRequest.dart';
|
||||
import '../../about/internal/application/RoutineRun.dart';
|
||||
import '../../configs/NotificationServiceConfig.dart';
|
||||
import '../parent/ParentViewModel.dart';
|
||||
import 'ConnectRoutineRunner.dart';
|
||||
|
||||
class ViewRoutineRunner extends ParentViewModel {
|
||||
ConnectRoutineRunner connection;
|
||||
|
||||
ViewRoutineRunner(super.context, this.connection);
|
||||
|
||||
/// Mirrors the current phase into the ongoing notification, through the
|
||||
/// same [LiveSession] shape the single-task runner uses.
|
||||
void publishRun(RoutineRun run) {
|
||||
LocalNotificationEngine.showSessionNotification(LiveSession(
|
||||
commitmentId: run.chain.id ?? "",
|
||||
title: run.phase.rest ? "Rest" : run.phase.step.title,
|
||||
goalTitle: run.chain.title,
|
||||
startedAt: run.startedAt,
|
||||
accumulatedSeconds: run.phaseElapsed(),
|
||||
resumedAt: run.running ? DateTime.now() : null,
|
||||
requiredSeconds: run.phase.seconds,
|
||||
backgroundedCount: run.backgroundedCount,
|
||||
));
|
||||
}
|
||||
|
||||
void clearRun() {
|
||||
LocalNotificationEngine.cancelSessionNotification();
|
||||
}
|
||||
|
||||
/// Held or broken, the run is always logged — a broken chain goes in as
|
||||
/// partial at the step it broke on.
|
||||
void log(RoutineRun run) async {
|
||||
if (!await hasNetwork(() => log(run))) return;
|
||||
|
||||
showLoading("Recording");
|
||||
|
||||
try {
|
||||
await getDataManager().logRoutineChainEntry(RoutineLogRequest(
|
||||
routineId: run.chain.id ?? "",
|
||||
startedAt: run.startedAt.toIso8601String(),
|
||||
elapsedSeconds: run.elapsedSeconds(),
|
||||
completedSteps: run.chain.completedSteps,
|
||||
totalSteps: run.chain.steps.length,
|
||||
breakIndex: run.breakIndex,
|
||||
backgroundedCount: run.backgroundedCount,
|
||||
skippedCount: run.skippedCount,
|
||||
).toJson());
|
||||
|
||||
clearRun();
|
||||
|
||||
closeLoading();
|
||||
|
||||
connection.onLogged(run.chain.complete);
|
||||
} catch (e) {
|
||||
handleError(e, () => log(run), () => dismissError(), "Retry");
|
||||
}
|
||||
}
|
||||
}
|
||||
37
frontend/lib/Grounded/see/routine/ViewRoutines.dart
Normal file
37
frontend/lib/Grounded/see/routine/ViewRoutines.dart
Normal file
@@ -0,0 +1,37 @@
|
||||
import '../../about/external/data/RoutineChain.dart';
|
||||
import '../../about/external/data/pages/request/HistoryRequest.dart';
|
||||
import '../../about/external/data/pages/request/PageAndSort.dart';
|
||||
import '../../about/external/data/pages/request/Pageable.dart';
|
||||
import '../../about/external/data/pages/request/Sort.dart';
|
||||
import '../../utils/ObjectConvertors.dart';
|
||||
import '../parent/ParentViewModel.dart';
|
||||
import 'ConnectRoutines.dart';
|
||||
|
||||
class ViewRoutines extends ParentViewModel {
|
||||
ConnectRoutines connection;
|
||||
|
||||
ViewRoutines(super.context, this.connection);
|
||||
|
||||
void loadRoutines() async {
|
||||
if (!await hasNetwork(() => loadRoutines())) return;
|
||||
|
||||
showLoading("Loading your routines");
|
||||
|
||||
try {
|
||||
final response = await getDataManager().getMyRoutines(HistoryRequest(
|
||||
query: PageAndSort(
|
||||
sort: Sort('asc', 'startMinute'),
|
||||
page: Pageable(0, 0, 50, 0),
|
||||
),
|
||||
));
|
||||
|
||||
final List<RoutineChain> routines = getRoutineChainList(response.data);
|
||||
|
||||
closeLoading();
|
||||
|
||||
connection.onRoutinesLoaded(routines);
|
||||
} catch (e) {
|
||||
handleError(e, () => loadRoutines(), () => dismissError(), "Retry");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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>[];
|
||||
|
||||
232
frontend/lib/Grounded/utils/RoutineTemplates.dart
Normal file
232
frontend/lib/Grounded/utils/RoutineTemplates.dart
Normal 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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
65
frontend/test/routine_run_test.dart
Normal file
65
frontend/test/routine_run_test.dart
Normal file
@@ -0,0 +1,65 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:Grounded/Grounded/about/external/data/RoutineChain.dart';
|
||||
import 'package:Grounded/Grounded/about/external/data/RoutineStep.dart';
|
||||
import 'package:Grounded/Grounded/about/internal/application/RoutineRun.dart';
|
||||
import 'package:Grounded/Grounded/about/internal/application/StepMode.dart';
|
||||
|
||||
RoutineChain _chain() {
|
||||
return RoutineChain(title: "Test", steps: <RoutineStep>[
|
||||
RoutineStep(title: "Squat", mode: StepMode.Reps, sets: 2, reps: 5, restSeconds: 30),
|
||||
RoutineStep(title: "Plank", mode: StepMode.Timed, sets: 1, timerSeconds: 45, restSeconds: 15),
|
||||
]);
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('phases interleave rest, with none after the final set', () {
|
||||
final RoutineRun run = RoutineRun(_chain());
|
||||
final List<String> shape = run.phases
|
||||
.map((p) => p.rest ? "rest" : "${p.step.title}${p.set}")
|
||||
.toList();
|
||||
expect(shape, <String>["Squat1", "rest", "Squat2", "rest", "Plank1"]);
|
||||
});
|
||||
|
||||
test('clock is wall-clock derived and pauses cleanly', () {
|
||||
final RoutineRun run = RoutineRun(_chain());
|
||||
final DateTime t0 = DateTime(2026, 1, 1, 7);
|
||||
run.resume(now: t0);
|
||||
run.pause(now: t0.add(const Duration(seconds: 20)));
|
||||
expect(run.elapsedSeconds(), 20);
|
||||
expect(run.phaseElapsed(), 20);
|
||||
run.resume(now: t0.add(const Duration(seconds: 100)));
|
||||
expect(run.elapsedSeconds(now: t0.add(const Duration(seconds: 105))), 25);
|
||||
});
|
||||
|
||||
test('step completes on its last set; skips are counted and break the chain', () {
|
||||
final RoutineRun run = RoutineRun(_chain());
|
||||
run.advance(); // Squat1 -> rest
|
||||
run.advance(); // rest -> Squat2
|
||||
expect(run.chain.steps[0].completed, false);
|
||||
run.advance(); // Squat2 done -> rest
|
||||
expect(run.chain.steps[0].completed, true);
|
||||
run.advance(); // rest -> Plank1
|
||||
expect(run.advance(skipped: true), false); // end of run
|
||||
expect(run.skippedCount, 1);
|
||||
expect(run.chain.complete, false);
|
||||
expect(run.breakIndex, 1);
|
||||
});
|
||||
|
||||
test('back skips over rest to the previous work phase', () {
|
||||
final RoutineRun run = RoutineRun(_chain());
|
||||
run.advance();
|
||||
run.advance(); // Squat2
|
||||
run.back();
|
||||
expect(run.phase.set, 1);
|
||||
expect(run.phase.rest, false);
|
||||
});
|
||||
|
||||
test('planned time includes rest after every set', () {
|
||||
final RoutineChain chain = RoutineChain(steps: <RoutineStep>[
|
||||
RoutineStep(mode: StepMode.Timed, sets: 3, timerSeconds: 40, restSeconds: 20),
|
||||
]);
|
||||
expect(chain.totalSeconds, 180);
|
||||
expect(RoutineChain.fromJson(chain.toJson()).steps.first.restSeconds, 20);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user