Add routines module and full goal management

Routines: workout, meditation and must-do routines with timed, rep and
tick steps, sets and rest. A library screen, a builder with templates
and drag-to-reorder steps, and a full-screen player that auto-advances
timed phases, counts backgrounding and logs broken chains as partial.

Goals: a goal editor (deadline, colour, category, default class, archive),
routines linked to goals, and a goal field on tasks and routines. Tasks
added from inside a goal are now actually attached to it via parentId.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KhQh614DMeYoQEH9SoMRUw
This commit is contained in:
alvocool
2026-11-02 08:45:41 +03:00
parent c88887b639
commit 1a5867a98f
49 changed files with 5384 additions and 220 deletions

View File

@@ -1,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 /// An ordered sequence where the chain, not the step, is the unit of
/// completion. Breaking mid-way logs partial. /// completion. Breaking mid-way logs partial.
class RoutineChain { class RoutineChain {
@@ -5,6 +11,24 @@ class RoutineChain {
String title; 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; List<RoutineStep> steps;
/// Index of the step reached when the chain last broke; -1 when clean. /// Index of the step reached when the chain last broke; -1 when clean.
@@ -13,19 +37,30 @@ class RoutineChain {
RoutineChain({ RoutineChain({
this.id, this.id,
this.title = "", this.title = "",
this.goalId,
this.kind = RoutineKind.Workout,
this.commitmentClass = CommitmentClass.Standard,
List<int>? days,
this.startMinute = -1,
this.note = "",
List<RoutineStep>? steps, List<RoutineStep>? steps,
this.lastBreakIndex = -1, this.lastBreakIndex = -1,
}) : steps = steps ?? <RoutineStep>[]; }) : days = days ?? <int>[],
steps = steps ?? <RoutineStep>[];
factory RoutineChain.fromJson(Map<String, dynamic> json) { factory RoutineChain.fromJson(Map<String, dynamic> json) {
return RoutineChain( return RoutineChain(
id: json['id'], id: json['id'],
title: json['title'] ?? "", title: json['title'] ?? "",
steps: json['steps'] == null goalId: json['goalId'],
? <RoutineStep>[] kind: getRoutineKind(json['kind']),
: (json['steps'] as List) commitmentClass: getCommitmentClass(json['commitmentClass']),
.map((item) => RoutineStep.fromJson(item)) days: json['days'] == null
.toList(), ? <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, lastBreakIndex: json['lastBreakIndex'] ?? -1,
); );
} }
@@ -34,6 +69,12 @@ class RoutineChain {
final Map<String, dynamic> data = <String, dynamic>{}; final Map<String, dynamic> data = <String, dynamic>{};
data['id'] = id; data['id'] = id;
data['title'] = title; 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['steps'] = steps.map((item) => item.toJson()).toList();
data['lastBreakIndex'] = lastBreakIndex; data['lastBreakIndex'] = lastBreakIndex;
return data; return data;
@@ -54,44 +95,40 @@ class RoutineChain {
bool get complete { bool get complete {
return steps.isNotEmpty && completedSteps == steps.length; return steps.isNotEmpty && completedSteps == steps.length;
} }
}
class RoutineStep { bool get scheduled {
String? id; return days.isNotEmpty;
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,
);
} }
Map<String, dynamic> toJson() { bool scheduledOn(DateTime day) {
final Map<String, dynamic> data = <String, dynamic>{}; return days.contains(day.weekday);
data['id'] = id; }
data['title'] = title;
data['order'] = order; /// Every set of every step, for the "12 sets" figure.
data['timerSeconds'] = timerSeconds; int get totalSets {
data['completed'] = completed; int total = 0;
return data; 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());
} }
} }

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

View File

@@ -2,6 +2,9 @@
class CommitmentRequest { class CommitmentRequest {
String? id; String? id;
/// The goal this task belongs to; null for a loose task.
String? parentId;
String type; String type;
String commitmentClass; String commitmentClass;
@@ -26,6 +29,7 @@ class CommitmentRequest {
CommitmentRequest({ CommitmentRequest({
this.id, this.id,
this.parentId,
this.type = "TASK", this.type = "TASK",
this.commitmentClass = "Standard", this.commitmentClass = "Standard",
this.title = "", this.title = "",
@@ -42,6 +46,7 @@ class CommitmentRequest {
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{}; final Map<String, dynamic> data = <String, dynamic>{};
data['id'] = id; data['id'] = id;
data['parentId'] = parentId;
data['type'] = type; data['type'] = type;
data['commitmentClass'] = commitmentClass; data['commitmentClass'] = commitmentClass;
data['title'] = title; data['title'] = title;

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

View File

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

View File

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

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

View File

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

View File

@@ -388,6 +388,13 @@ class Comms implements ConnectComms {
return await dio.post(navigation.key, data: request); 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 @override
Future<Response> logRoutineChainEntry(Map<String, dynamic> request) async { Future<Response> logRoutineChainEntry(Map<String, dynamic> request) async {
Pair navigation = await getRequestHeaders(logRoutineChain, ""); Pair navigation = await getRequestHeaders(logRoutineChain, "");

View File

@@ -108,6 +108,8 @@ abstract class ConnectComms {
Future<Response> getMyRoutines(HistoryRequest request); Future<Response> getMyRoutines(HistoryRequest request);
Future<Response> saveRoutineEntry(Map<String, dynamic> request);
Future<Response> logRoutineChainEntry(Map<String, dynamic> request); Future<Response> logRoutineChainEntry(Map<String, dynamic> request);
// ── Training ──────────────────────────────────────────────────────────── // ── Training ────────────────────────────────────────────────────────────

View 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),
],
),
),
);
}

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

View 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),
),
);
}

View File

@@ -296,6 +296,10 @@ class AppDataManager implements DataManager {
Future<Response> getMyRoutines(HistoryRequest request) => Future<Response> getMyRoutines(HistoryRequest request) =>
comms.getMyRoutines(request); comms.getMyRoutines(request);
@override
Future<Response> saveRoutineEntry(Map<String, dynamic> request) =>
comms.saveRoutineEntry(request);
@override @override
Future<Response> logRoutineChainEntry(Map<String, dynamic> request) => Future<Response> logRoutineChainEntry(Map<String, dynamic> request) =>
comms.logRoutineChainEntry(request); comms.logRoutineChainEntry(request);

View File

@@ -1,3 +1,4 @@
import '../../about/external/data/Goal.dart';
import '../../utils/CapacityEngine.dart'; import '../../utils/CapacityEngine.dart';
abstract class ConnectNewCommitment { abstract class ConnectNewCommitment {
@@ -9,4 +10,7 @@ abstract class ConnectNewCommitment {
/// The learned multiplier for this category, so the estimate field can show /// The learned multiplier for this category, so the estimate field can show
/// what the app actually expects the task to take. /// what the app actually expects the task to take.
void onMultiplierResolved(double multiplier); void onMultiplierResolved(double multiplier);
/// Goals are loaded; open the picker.
void onGoalsReady(List<Goal> goals);
} }

View File

@@ -1,9 +1,14 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../about/external/data/Goal.dart';
import 'NewCommitmentState.dart'; import 'NewCommitmentState.dart';
class NewCommitment extends StatefulWidget { 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 @override
State<NewCommitment> createState() => NewCommitmentState(); State<NewCommitment> createState() => NewCommitmentState();

View File

@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:stacked/stacked.dart'; import 'package:stacked/stacked.dart';
import '../../about/external/data/Commitment.dart'; import '../../about/external/data/Commitment.dart';
import '../../about/external/data/Goal.dart';
import '../../about/external/initial/CommitmentRequest.dart'; import '../../about/external/initial/CommitmentRequest.dart';
import '../../about/internal/application/CommitmentClass.dart'; import '../../about/internal/application/CommitmentClass.dart';
import '../../about/internal/application/EnergyCost.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/ProofType.dart';
import '../../about/internal/application/TextType.dart'; import '../../about/internal/application/TextType.dart';
import '../../designs/Component.dart'; import '../../designs/Component.dart';
import '../../designs/GoalPicker.dart';
import '../../designs/Responsive.dart'; import '../../designs/Responsive.dart';
import '../../designs/Shell.dart'; import '../../designs/Shell.dart';
import '../../designs/buttons/Buttons.dart'; import '../../designs/buttons/Buttons.dart';
@@ -49,6 +51,9 @@ class NewCommitmentState extends State<NewCommitment>
String _windowError = ""; String _windowError = "";
/// The goal this task belongs to; null for a loose task.
Goal? _goal;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ViewModelBuilder<ViewNewCommitment>.reactive( return ViewModelBuilder<ViewNewCommitment>.reactive(
@@ -76,6 +81,11 @@ class NewCommitmentState extends State<NewCommitment>
setState(() { setState(() {
_windowStart = now; _windowStart = now;
_windowEnd = now.add(const Duration(hours: 2)); _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); Navigator.pop(context, false);
} }
void _onPickGoal() {
_model?.loadGoals();
}
void _onCategoryChanged(String value) { void _onCategoryChanged(String value) {
_model?.resolveMultiplier(value.trim()); _model?.resolveMultiplier(value.trim());
} }
@@ -185,6 +199,7 @@ class NewCommitmentState extends State<NewCommitment>
CommitmentRequest _buildRequest() { CommitmentRequest _buildRequest() {
return CommitmentRequest( return CommitmentRequest(
parentId: _goal?.id,
commitmentClass: _class.name, commitmentClass: _class.name,
title: _title.text.trim(), title: _title.text.trim(),
category: _category.text.trim(), category: _category.text.trim(),
@@ -200,6 +215,7 @@ class NewCommitmentState extends State<NewCommitment>
Commitment _buildCandidate() { Commitment _buildCandidate() {
return Commitment( return Commitment(
parentId: _goal?.id,
commitmentClass: _class, commitmentClass: _class,
title: _title.text.trim(), title: _title.text.trim(),
category: _category.text.trim(), category: _category.text.trim(),
@@ -237,6 +253,14 @@ class NewCommitmentState extends State<NewCommitment>
validator: Validators.title, validator: Validators.title,
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
selectField(
"Goal",
_goal?.title ?? "",
_onPickGoal,
hint: "No goal — a loose task",
icon: CupertinoIcons.flag,
),
const SizedBox(height: 20),
inputField( inputField(
"Category", "Category",
_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 @override
void dispose() { void dispose() {
_title.dispose(); _title.dispose();

View File

@@ -1,5 +1,6 @@
import '../../about/external/data/Commitment.dart'; import '../../about/external/data/Commitment.dart';
import '../../about/external/data/pages/request/CommitmentsRequest.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/PageAndSort.dart';
import '../../about/external/data/pages/request/Pageable.dart'; import '../../about/external/data/pages/request/Pageable.dart';
import '../../about/external/data/pages/request/Sort.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/external/initial/CommitmentRequest.dart';
import '../../about/internal/application/CapacityProfile.dart'; import '../../about/internal/application/CapacityProfile.dart';
import '../../utils/CapacityEngine.dart'; import '../../utils/CapacityEngine.dart';
import '../../utils/ObjectConvertors.dart';
import '../parent/ParentViewModel.dart'; import '../parent/ParentViewModel.dart';
import 'ConnectNewCommitment.dart'; import 'ConnectNewCommitment.dart';
@@ -24,6 +26,28 @@ class ViewNewCommitment extends ParentViewModel {
connection.onMultiplierResolved(profile.multiplierFor(category)); 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 /// The capacity gate. Chronic overdue is usually overcommitment misdiagnosed
/// as laziness, so the plan is checked against what history says actually /// as laziness, so the plan is checked against what history says actually
/// gets done before anything is accepted. /// gets done before anything is accepted.

View File

@@ -1,9 +1,13 @@
import '../../about/external/data/Commitment.dart'; import '../../about/external/data/Commitment.dart';
import '../../about/external/data/Goal.dart'; import '../../about/external/data/Goal.dart';
import '../../about/external/data/RoutineChain.dart';
abstract class ConnectGoalDetail { abstract class ConnectGoalDetail {
void onGoalLoaded(Goal goal, List<Commitment> tasks); 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. /// The task is ready to run — hand off to the full-screen runner.
void onTaskReady(Commitment task); void onTaskReady(Commitment task);

View File

@@ -0,0 +1,5 @@
abstract class ConnectGoalEditor {
void onSaved();
void onArchived();
}

View File

@@ -2,6 +2,4 @@ import '../../about/external/data/Goal.dart';
abstract class ConnectGoals { abstract class ConnectGoals {
void onGoalsLoaded(List<Goal> goals); void onGoalsLoaded(List<Goal> goals);
void onGoalSaved();
} }

View File

@@ -4,9 +4,11 @@ import 'package:stacked/stacked.dart';
import '../../about/external/data/Commitment.dart'; import '../../about/external/data/Commitment.dart';
import '../../about/external/data/Goal.dart'; import '../../about/external/data/Goal.dart';
import '../../about/external/data/RoutineChain.dart';
import '../../about/internal/application/CommitmentStatus.dart'; import '../../about/internal/application/CommitmentStatus.dart';
import '../../about/internal/application/NotificationType.dart'; import '../../about/internal/application/NotificationType.dart';
import '../../about/internal/application/ProofType.dart'; import '../../about/internal/application/ProofType.dart';
import '../../about/internal/application/RoutineKind.dart';
import '../../about/internal/application/TextType.dart'; import '../../about/internal/application/TextType.dart';
import '../../configs/Navigator.dart'; import '../../configs/Navigator.dart';
import '../../designs/Checkbox.dart'; import '../../designs/Checkbox.dart';
@@ -20,8 +22,11 @@ import '../../utils/CommonUtils.dart';
import '../../utils/DebtEngine.dart'; import '../../utils/DebtEngine.dart';
import '../commitment/NewCommitment.dart'; import '../commitment/NewCommitment.dart';
import '../live/LiveTask.dart'; import '../live/LiveTask.dart';
import '../routine/RoutineBuilder.dart';
import '../routine/RoutineRunner.dart';
import 'ConnectGoalDetail.dart'; import 'ConnectGoalDetail.dart';
import 'GoalDetail.dart'; import 'GoalDetail.dart';
import 'GoalEditor.dart';
import 'ViewGoalDetail.dart'; import 'ViewGoalDetail.dart';
class GoalDetailState extends State<GoalDetail> class GoalDetailState extends State<GoalDetail>
@@ -32,6 +37,8 @@ class GoalDetailState extends State<GoalDetail>
List<Commitment> _tasks = <Commitment>[]; List<Commitment> _tasks = <Commitment>[];
List<RoutineChain> _routines = <RoutineChain>[];
bool _changed = false; bool _changed = false;
/// Ticked locally, awaiting the server. /// Ticked locally, awaiting the server.
@@ -90,7 +97,7 @@ class GoalDetailState extends State<GoalDetail>
void _onAddTask() async { void _onAddTask() async {
final result = await GroundedNavigation() final result = await GroundedNavigation()
.navigateToPageWithData(const NewCommitment(), context); .navigateToPageWithData(NewCommitment(goal: _goal), context);
if (result == true) { if (result == true) {
_changed = 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 ───────────────────────────────────────────────────────────────── // ── Views ─────────────────────────────────────────────────────────────────
Widget _mobileView(BoxConstraints constraints) { Widget _mobileView(BoxConstraints constraints) {
@@ -115,10 +177,11 @@ class GoalDetailState extends State<GoalDetail>
.toList(); .toList();
return Sheet( return Sheet(
eyebrow: "Goal", eyebrow: _goal.category.isEmpty ? "Goal" : _goal.category,
title: _goal.title, title: _goal.title,
onBack: _onBack, onBack: _onBack,
action: chromeAction(CupertinoIcons.add, _onAddTask), chrome: _goal.colourHex.isEmpty ? null : goalColor(_goal.colourHex),
action: chromeAction(CupertinoIcons.pencil, _onEditGoal),
banner: _progressBanner(), banner: _progressBanner(),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -138,6 +201,26 @@ class GoalDetailState extends State<GoalDetail>
) )
else else
...open.map(_taskRow), ...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) ...[ if (done.isNotEmpty) ...[
const SizedBox(height: 28), const SizedBox(height: 28),
sectionBreak("Done", caption: "${done.length}"), sectionBreak("Done", caption: "${done.length}"),
@@ -179,6 +262,40 @@ class GoalDetailState extends State<GoalDetail>
track: colorWhite.withValues(alpha: 0.14), track: colorWhite.withValues(alpha: 0.14),
height: 5, 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), const SizedBox(height: 14),
roundedCornerButton( SizedBox(
width: double.infinity,
child: roundedCornerButton(
"Start", "Start",
() => _onStartTask(task), () => _onStartTask(task),
icon: CupertinoIcons.play_fill, icon: CupertinoIcons.play_fill,
verticalPadding: 13, 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 ───────────────────────────────────────────────────── // ── ConnectGoalDetail ─────────────────────────────────────────────────────
@override
void onRoutinesLoaded(List<RoutineChain> routines) {
setState(() {
_routines = routines;
});
}
@override @override
void onGoalLoaded(Goal goal, List<Commitment> tasks) { void onGoalLoaded(Goal goal, List<Commitment> tasks) {
setState(() { setState(() {

View 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();
}

View 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();
}
}

View File

@@ -1,23 +1,19 @@
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:stacked/stacked.dart'; import 'package:stacked/stacked.dart';
import '../../about/external/data/Goal.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 '../../about/internal/application/TextType.dart';
import '../../configs/Navigator.dart'; import '../../configs/Navigator.dart';
import '../../designs/Component.dart'; import '../../designs/Component.dart';
import '../../designs/Responsive.dart'; import '../../designs/Responsive.dart';
import '../../designs/Shell.dart'; import '../../designs/Shell.dart';
import '../../designs/buttons/Buttons.dart'; import '../../designs/buttons/Buttons.dart';
import '../../designs/input/InputFields.dart';
import '../../designs/text/Text.dart'; import '../../designs/text/Text.dart';
import '../../utils/Colors.dart'; import '../../utils/Colors.dart';
import '../../utils/CommonUtils.dart'; import '../../utils/CommonUtils.dart';
import '../../utils/Validators.dart';
import 'ConnectGoals.dart'; import 'ConnectGoals.dart';
import 'GoalDetail.dart'; import 'GoalDetail.dart';
import 'GoalEditor.dart';
import 'Goals.dart'; import 'Goals.dart';
import 'ViewGoals.dart'; import 'ViewGoals.dart';
@@ -63,124 +59,13 @@ class GoalsState extends State<Goals> implements ConnectGoals {
} }
} }
void _onNewGoal() { void _onNewGoal() async {
_openGoalSheet(); 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) { Widget _mobileView(BoxConstraints constraints) {
@@ -221,7 +106,7 @@ class GoalsState extends State<Goals> implements ConnectGoals {
Widget _goalCard(Goal goal) { Widget _goalCard(Goal goal) {
final Color accent = final Color accent =
goal.slipping ? colorStandingGrounded : colorPrimaryDark; goal.slipping ? colorStandingGrounded : goalColor(goal.colourHex);
return Container( return Container(
margin: const EdgeInsets.only(bottom: 12), margin: const EdgeInsets.only(bottom: 12),
@@ -239,6 +124,35 @@ class GoalsState extends State<Goals> implements ConnectGoals {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ 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, text(goal.title, 19, TextType.Light,
color: colorPrimaryDark, color: colorPrimaryDark,
maxLines: 2, maxLines: 2,
@@ -302,9 +216,4 @@ class GoalsState extends State<Goals> implements ConnectGoals {
_goals = goals; _goals = goals;
}); });
} }
@override
void onGoalSaved() {
_model?.loadGoals();
}
} }

View File

@@ -1,5 +1,10 @@
import '../../about/external/data/Commitment.dart'; import '../../about/external/data/Commitment.dart';
import '../../about/external/data/Goal.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/CompletionRequest.dart';
import '../../about/external/initial/IdRequest.dart'; import '../../about/external/initial/IdRequest.dart';
import '../../utils/ObjectConvertors.dart'; import '../../utils/ObjectConvertors.dart';
@@ -23,11 +28,34 @@ class ViewGoalDetail extends ParentViewModel {
closeLoading(); closeLoading();
connection.onGoalLoaded(goal, getCommitmentList(response.data)); connection.onGoalLoaded(goal, getCommitmentList(response.data));
loadRoutines(goal);
} catch (e) { } catch (e) {
handleError(e, () => loadTasks(goal), () => dismissError(), "Retry"); 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. /// Completes a task straight from the goal list.
void tick(Commitment task) async { void tick(Commitment task) async {
if (!await hasNetwork(() => tick(task))) return; if (!await hasNetwork(() => tick(task))) return;

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

View File

@@ -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/PageAndSort.dart';
import '../../about/external/data/pages/request/Pageable.dart'; import '../../about/external/data/pages/request/Pageable.dart';
import '../../about/external/data/pages/request/Sort.dart'; import '../../about/external/data/pages/request/Sort.dart';
import '../../about/external/initial/GoalRequest.dart';
import '../../utils/ObjectConvertors.dart'; import '../../utils/ObjectConvertors.dart';
import '../parent/ParentViewModel.dart'; import '../parent/ParentViewModel.dart';
import 'ConnectGoals.dart'; import 'ConnectGoals.dart';
@@ -32,20 +31,4 @@ class ViewGoals extends ParentViewModel {
handleError(e, () => loadGoals(), () => dismissError(), "Retry"); 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");
}
}
} }

View File

@@ -28,6 +28,7 @@ import '../excuse/ExcuseReport.dart';
import '../goal/Goals.dart'; import '../goal/Goals.dart';
import '../overdue/OverdueQueue.dart'; import '../overdue/OverdueQueue.dart';
import '../reportcard/ReportCardScreen.dart'; import '../reportcard/ReportCardScreen.dart';
import '../routine/Routines.dart';
import '../settings/Settings.dart'; import '../settings/Settings.dart';
import '../training/Training.dart'; import '../training/Training.dart';
import 'ConnectHome.dart'; import 'ConnectHome.dart';
@@ -136,6 +137,11 @@ class HomeState extends State<Home> implements ConnectHome {
.navigateToPage(NavigatorType.justOpen, const Training(), context); .navigateToPage(NavigatorType.justOpen, const Training(), context);
} }
void _onOpenRoutines() {
GroundedNavigation()
.navigateToPage(NavigatorType.justOpen, const Routines(), context);
}
void _onOpenSettings() { void _onOpenSettings() {
GroundedNavigation() GroundedNavigation()
.navigateToPage(NavigatorType.justOpen, const Settings(), context); .navigateToPage(NavigatorType.justOpen, const Settings(), context);
@@ -588,7 +594,9 @@ class HomeState extends State<Home> implements ConnectHome {
} }
Widget _quickLinks() { Widget _quickLinks() {
return Row( return Column(
children: [
Row(
children: [ children: [
Expanded( Expanded(
child: _quickLink( child: _quickLink(
@@ -600,12 +608,16 @@ class HomeState extends State<Home> implements ConnectHome {
const SizedBox(width: 10), const SizedBox(width: 10),
Expanded( Expanded(
child: _quickLink( child: _quickLink(
CupertinoIcons.chart_bar_alt_fill, CupertinoIcons.square_stack_3d_up_fill,
"Report", "Routines",
_onOpenReportCard, _onOpenRoutines,
), ),
), ),
const SizedBox(width: 10), ],
),
const SizedBox(height: 10),
Row(
children: [
Expanded( Expanded(
child: _quickLink( child: _quickLink(
CupertinoIcons.flame_fill, CupertinoIcons.flame_fill,
@@ -613,6 +625,16 @@ class HomeState extends State<Home> implements ConnectHome {
_onOpenTraining, _onOpenTraining,
), ),
), ),
const SizedBox(width: 10),
Expanded(
child: _quickLink(
CupertinoIcons.chart_bar_alt_fill,
"Report",
_onOpenReportCard,
),
),
],
),
], ],
); );
} }

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -0,0 +1,5 @@
import '../../about/external/data/RoutineChain.dart';
abstract class ConnectRoutines {
void onRoutinesLoaded(List<RoutineChain> routines);
}

View 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();
}

View 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();
}
}

View 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();
}

File diff suppressed because it is too large Load Diff

View 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();
}

View 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;
});
}
}

View 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();
}

View 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();
}
}

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

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

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

View File

@@ -75,6 +75,33 @@ const Color colorClassStandardBg = Color(0xFFE0E8EF);
const Color colorClassElective = Color(0xFF6E6E6E); const Color colorClassElective = Color(0xFF6E6E6E);
const Color colorClassElectiveBg = Color(0xFFEDEDED); 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 ─────────────────────────────────────────────────────────── // ── Debt / charting ───────────────────────────────────────────────────────────
const Color colorDebtLine = Color(0xFFB5442F); const Color colorDebtLine = Color(0xFFB5442F);
const Color colorDebtFill = Color(0x1AB5442F); const Color colorDebtFill = Color(0x1AB5442F);

View File

@@ -1,9 +1,13 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import '../about/external/data/Commitment.dart'; import '../about/external/data/Commitment.dart';
import '../about/external/data/RoutineStep.dart';
import '../about/internal/application/CommitmentClass.dart'; import '../about/internal/application/CommitmentClass.dart';
import '../about/internal/application/RoutineKind.dart';
import '../about/internal/application/Standing.dart'; import '../about/internal/application/Standing.dart';
import '../about/internal/application/StepMode.dart';
import 'Colors.dart'; import 'Colors.dart';
/// Formatting and small shared derivations. Anything a screen would otherwise /// Formatting and small shared derivations. Anything a screen would otherwise
@@ -186,3 +190,165 @@ String hourLabel(int hour) {
} }
return "${hour - 12}pm"; 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";
}

View File

@@ -7,6 +7,7 @@ import '../about/external/data/Goal.dart';
import '../about/external/data/Habit.dart'; import '../about/external/data/Habit.dart';
import '../about/external/data/Program.dart'; import '../about/external/data/Program.dart';
import '../about/external/data/RoutineChain.dart'; import '../about/external/data/RoutineChain.dart';
import '../about/external/data/RoutineStep.dart';
import '../about/external/data/SessionLog.dart'; import '../about/external/data/SessionLog.dart';
import '../about/external/data/SessionTemplate.dart'; import '../about/external/data/SessionTemplate.dart';
import '../about/external/data/SetLog.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(); 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) { List<Program> getProgramList(dynamic data) {
if (data == null) { if (data == null) {
return <Program>[]; return <Program>[];

View 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),
],
);
}
}

View File

@@ -36,4 +36,12 @@ class Thresholds {
/// Minutes a push can go unacknowledged before SMS fallback fires. /// Minutes a push can go unacknowledged before SMS fallback fires.
static const int smsFallbackMinutes = 20; 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;
} }

View 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);
});
}