Compare commits

4 Commits

Author SHA1 Message Date
alvocool
36bc41509d Animate the splash and fix the web preview hanging on the logo
The splash now raises the sun in the mark and over a painted valley, floats
four feature chips around the mark, shimmers the wordmark, shows a three-beat
"how it works" card, drifts light motes upward and walks a status line
through the session check. A tap skips it.

The web build hung on the native splash because the asset manifest could not
be published: "assets/icons/splash_orb.png" is 27 characters, so its length
prefix in AssetManifest.bin is an ESC byte. Renamed it to splash_mark.png and
removed the unused anchor assets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KhQh614DMeYoQEH9SoMRUw
2026-11-02 12:59:34 +03:00
alvocool
fe268cb8aa Redesign the UI after the fitness reference and replace the logo
Light sage-to-cream ambient backdrop, frosted glass controls and painted
SceneArt landscapes in place of photography. Home becomes a dashboard with a
two-tone greeting, a frosted summary ring, category portraits, an up-next rail
of image-led cards and a floating glass tab bar. The routine library gets an
image header and painted cards; the player gets a scene stage, steps on an
arc, a dotted clock and a night pill with a lime spark. Goals become painted
cards in their own colour.

New logo: a lime sun resting on a ground line under two arcs. The splash
raises the sun and lights the arcs; launcher icons and native splash are
regenerated to match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KhQh614DMeYoQEH9SoMRUw
2026-11-02 12:33:13 +03:00
alvocool
b31bc47cc0 Add removable mock mode for a clickable preview
Runs the whole app on seeded in-memory data with no backend or device
storage, behind useMockData in lib/Grounded/mock/MockSwitch.dart. To
remove: delete lib/Grounded/mock/ and the lines tagged "MOCK:" in
ParentViewModel (or revert this commit, keeping the notification guard).

Also skips local notifications on web, where the plugin and dart:io
Platform both throw.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KhQh614DMeYoQEH9SoMRUw
2026-11-02 08:45:41 +03:00
alvocool
1a5867a98f Add routines module and full goal management
Routines: workout, meditation and must-do routines with timed, rep and
tick steps, sets and rest. A library screen, a builder with templates
and drag-to-reorder steps, and a full-screen player that auto-advances
timed phases, counts backgrounding and logs broken chains as partial.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KhQh614DMeYoQEH9SoMRUw
2026-11-02 08:45:41 +03:00
150 changed files with 9137 additions and 802 deletions

View File

@@ -109,10 +109,12 @@ status field. That's what makes honest history and excuse analysis possible.
## 6. Design language
See `design.md`. In short: black chrome → white sheet with a 28 radius →
oversized **Light** display title → tiny grey all-caps labels above values.
See `design.md`. In short: sage-to-cream ambient backdrop, frosted glass
controls, painted `SceneArt` instead of photos, image-led cards, two-tone
greetings, night pills with a lime spark.
- Every screen is built from `Sheet` (`designs/Shell.dart`).
- Screens are built from `Sheet` (`designs/Shell.dart`); Home, Routines and
the routine player compose `AmbientBackground` + glass directly.
- Display titles are `TextType.Light`. Bold is for labels and metrics only.
- Never raw `Text`/`TextStyle` — use `text()` from `designs/text/Text.dart`.
- Never inline hex — use `utils/Colors.dart`.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 B

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 B

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

View File

@@ -6,9 +6,9 @@
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
<item name="android:windowSplashScreenBackground">#141414</item>
<item name="android:windowSplashScreenBackground">#EAEFE6</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
<item name="android:windowSplashScreenIconBackgroundColor">#141414</item>
<item name="android:windowSplashScreenIconBackgroundColor">#EAEFE6</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your

View File

@@ -6,9 +6,9 @@
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
<item name="android:windowSplashScreenBackground">#141414</item>
<item name="android:windowSplashScreenBackground">#EAEFE6</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
<item name="android:windowSplashScreenIconBackgroundColor">#141414</item>
<item name="android:windowSplashScreenIconBackgroundColor">#EAEFE6</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#141414</color>
<color name="ic_launcher_background">#18211C</color>
</resources>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 31 KiB

View File

@@ -1,17 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<!-- Grounded mark: a horizon line with a weight resting on it. The bar is
the ground; the block is what you are carrying. -->
<rect width="512" height="512" fill="#141414"/>
<!-- The block -->
<rect x="176" y="146" width="160" height="160" rx="34" fill="#EDEEEA"/>
<!-- The notch — a debt taken out of the block -->
<rect x="236" y="146" width="40" height="86" rx="20" fill="#141414"/>
<!-- The ground line -->
<rect x="120" y="348" width="272" height="26" rx="13" fill="#2F6F4E"/>
<!-- Shadow beneath, shortened: the weight sits on the line -->
<rect x="168" y="396" width="176" height="14" rx="7" fill="#EDEEEA" opacity="0.22"/>
</svg>

Before

Width:  |  Height:  |  Size: 760 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

View File

@@ -1,9 +1,34 @@
# Grounded — Design Language
Reference: black device chrome, a white content sheet that rises into it with a
large corner radius, oversized light display titles, tiny grey all-caps metadata
labels, pill chips, and a lot of whitespace. The app reads as a record, not a
dashboard.
Reference: an airy fitness-app look. Sage mist drifting into cream behind
everything (`AmbientBackground`), frosted glass for controls and floating
chrome (`GlassSurface`, `glassCircle`), ink type with a soft grey second
tone, painted landscapes instead of photography (`SceneArt`), a floating
glass tab bar with a dark round action button (`FloatingNav`), and one lime
spark on night-green for the primary control.
## Building blocks (`designs/`)
| Piece | Use |
|---|---|
| `AmbientBackground` | The backdrop for every light screen. |
| `GlassSurface` / `glassCircle` | Frosted panels and round controls; `onDark` over a scene or tinted stage. |
| `SceneArt` | Painted sky, sun and ridges with an optional figure glyph. Palettes come from `scenePalette(kind)`, `goalScene(accent)`, `classScene(class)`; seed with `sceneSeed(title)` so an item always wears the same picture. |
| `DotDigits` | The dotted clock in the routine player. |
| `FloatingNav` | Home's tab bar and action button. |
| `GroundedMark` | The logo, drawn — a lime sun on a ground line under two arcs. The launcher PNGs in `assets/icons/` were rendered from the same geometry. |
Cards are image-led where an item has a kind (routines, goals, tasks): scene on
top or as a thumbnail, white type over the shaded lower half, three light
numbers along the bottom. Greetings are two-tone: Medium ink, then Regular
`colorInkSoft`. Primary buttons are night pills with a lime icon.
`Sheet` is light (ambient) by default; passing `chrome`, `banner` or
`scene` gives a tinted stage with white type. Home, Routines and the player
build their own layouts from the same pieces.
The sections below describe the original record-style type scale, which still
applies to labels and metrics.
## Type — General Sans

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 473 B

After

Width:  |  Height:  |  Size: 492 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 934 B

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 683 B

After

Width:  |  Height:  |  Size: 831 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 934 B

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 B

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 44 KiB

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
/// completion. Breaking mid-way logs partial.
class RoutineChain {
@@ -5,6 +11,24 @@ class RoutineChain {
String title;
/// The goal this routine serves — "Workout" holds "Full body strength".
/// Null for a routine that stands on its own.
String? goalId;
RoutineKind kind;
/// Weight class, exactly as for a commitment — a non-negotiable morning
/// routine carries non-negotiable debt when it breaks.
CommitmentClass commitmentClass;
/// 1..7, Monday first. Empty means unscheduled — run on demand only.
List<int> days;
/// Minutes after midnight the routine is due to start; -1 when unset.
int startMinute;
String note;
List<RoutineStep> steps;
/// Index of the step reached when the chain last broke; -1 when clean.
@@ -13,19 +37,30 @@ class RoutineChain {
RoutineChain({
this.id,
this.title = "",
this.goalId,
this.kind = RoutineKind.Workout,
this.commitmentClass = CommitmentClass.Standard,
List<int>? days,
this.startMinute = -1,
this.note = "",
List<RoutineStep>? steps,
this.lastBreakIndex = -1,
}) : steps = steps ?? <RoutineStep>[];
}) : days = days ?? <int>[],
steps = steps ?? <RoutineStep>[];
factory RoutineChain.fromJson(Map<String, dynamic> json) {
return RoutineChain(
id: json['id'],
title: json['title'] ?? "",
steps: json['steps'] == null
? <RoutineStep>[]
: (json['steps'] as List)
.map((item) => RoutineStep.fromJson(item))
.toList(),
goalId: json['goalId'],
kind: getRoutineKind(json['kind']),
commitmentClass: getCommitmentClass(json['commitmentClass']),
days: json['days'] == null
? <int>[]
: (json['days'] as List).map((item) => item as int).toList(),
startMinute: json['startMinute'] ?? -1,
note: json['note'] ?? "",
steps: getRoutineStepList(json['steps']),
lastBreakIndex: json['lastBreakIndex'] ?? -1,
);
}
@@ -34,6 +69,12 @@ class RoutineChain {
final Map<String, dynamic> data = <String, dynamic>{};
data['id'] = id;
data['title'] = title;
data['goalId'] = goalId;
data['kind'] = kind.name;
data['commitmentClass'] = commitmentClass.name;
data['days'] = days;
data['startMinute'] = startMinute;
data['note'] = note;
data['steps'] = steps.map((item) => item.toJson()).toList();
data['lastBreakIndex'] = lastBreakIndex;
return data;
@@ -54,44 +95,40 @@ class RoutineChain {
bool get complete {
return steps.isNotEmpty && completedSteps == steps.length;
}
}
class RoutineStep {
String? id;
String title;
int order;
int timerSeconds;
bool completed;
RoutineStep({
this.id,
this.title = "",
this.order = 0,
this.timerSeconds = 0,
this.completed = false,
});
factory RoutineStep.fromJson(Map<String, dynamic> json) {
return RoutineStep(
id: json['id'],
title: json['title'] ?? "",
order: json['order'] ?? 0,
timerSeconds: json['timerSeconds'] ?? 0,
completed: json['completed'] ?? false,
);
bool get scheduled {
return days.isNotEmpty;
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['id'] = id;
data['title'] = title;
data['order'] = order;
data['timerSeconds'] = timerSeconds;
data['completed'] = completed;
return data;
bool scheduledOn(DateTime day) {
return days.contains(day.weekday);
}
/// Every set of every step, for the "12 sets" figure.
int get totalSets {
int total = 0;
for (RoutineStep step in steps) {
total = total + step.sets;
}
return total;
}
/// Planned seconds, rest included. Rep and tick steps count their estimate.
int get totalSeconds {
int total = 0;
for (RoutineStep step in steps) {
total = total + step.plannedSeconds;
}
return total;
}
int get timedSteps {
return steps.where((step) => step.mode == StepMode.Timed).length;
}
/// A deep copy for the builder, so cancelling an edit leaves the library
/// untouched.
RoutineChain copy() {
return RoutineChain.fromJson(toJson());
}
}

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

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);
}
@override
Future<Response> saveRoutineEntry(Map<String, dynamic> request) async {
Pair navigation = await getRequestHeaders(saveRoutine, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request);
}
@override
Future<Response> logRoutineChainEntry(Map<String, dynamic> request) async {
Pair navigation = await getRequestHeaders(logRoutineChain, "");

View File

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

View File

@@ -1,5 +1,6 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import '../about/external/data/Commitment.dart';
@@ -32,7 +33,7 @@ class LocalNotificationEngine {
static bool _ready = false;
static Future<void> init() async {
if (_ready) {
if (_ready || kIsWeb) {
return;
}
@@ -105,6 +106,10 @@ class LocalNotificationEngine {
/// The ongoing notification for a running task. Shows the live remaining
/// time so it is useful from the lock screen without unlocking.
static Future<void> showSessionNotification(LiveSession session) async {
// The browser has no notification channels; the runner works without.
if (kIsWeb) {
return;
}
await init();
final String remaining = session.requiredSeconds > 0
@@ -144,6 +149,10 @@ class LocalNotificationEngine {
}
static Future<void> cancelSessionNotification() async {
// The browser has no notification channels; the runner works without.
if (kIsWeb) {
return;
}
await init();
await plugin.cancel(id: sessionNotificationId);
}
@@ -156,6 +165,9 @@ class LocalNotificationEngine {
EscalationTier tier,
ToneLevel tone,
) async {
if (kIsWeb) {
return;
}
await init();
final bool nonNegotiable =
@@ -198,6 +210,10 @@ class LocalNotificationEngine {
}
static Future<void> cancelAll() async {
// The browser has no notification channels; the runner works without.
if (kIsWeb) {
return;
}
await init();
await plugin.cancelAll();
}

View File

@@ -0,0 +1,61 @@
import 'package:flutter/material.dart';
import '../utils/Colors.dart';
/// The airy backdrop: sage mist at the top drifting to cream, with two soft
/// glows so the glass controls have something to blur.
class AmbientBackground extends StatelessWidget {
final Widget child;
const AmbientBackground({super.key, required this.child});
@override
Widget build(BuildContext context) {
return Stack(
children: [
const Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: <Color>[
colorAmbientTop,
colorAmbientMid,
colorAmbientBottom,
],
stops: <double>[0.0, 0.45, 1.0],
),
),
),
),
Positioned(
top: -140,
right: -110,
child: _glow(380, colorAmbientGlowSage),
),
Positioned(
top: 300,
left: -170,
child: _glow(360, colorAmbientGlowWarm),
),
Positioned.fill(child: child),
],
);
}
Widget _glow(double size, Color color) {
return IgnorePointer(
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: <Color>[color, color.withValues(alpha: 0.0)],
),
),
),
);
}
}

View File

@@ -0,0 +1,19 @@
import 'package:flutter/widgets.dart';
/// Tells controls placed in a [Sheet]'s chrome whether they sit on the light
/// ambient backdrop or on a coloured stage, so they pick glass to match.
class ChromeTone extends InheritedWidget {
final bool light;
const ChromeTone({super.key, required this.light, required super.child});
static bool isLight(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<ChromeTone>()?.light ??
false;
}
@override
bool updateShouldNotify(covariant ChromeTone oldWidget) {
return oldWidget.light != light;
}
}

View File

@@ -61,7 +61,7 @@ Widget card({
EdgeInsets margin = EdgeInsets.zero,
Color? background,
Color? borderColor,
double radius = 16,
double radius = 22,
VoidCallback? onTap,
}) {
final Widget body = Container(
@@ -71,12 +71,12 @@ Widget card({
decoration: BoxDecoration(
color: background ?? colorCard,
borderRadius: BorderRadius.circular(radius),
border: Border.all(color: borderColor ?? colorBorder, width: 1),
border: Border.all(color: borderColor ?? colorGlassBorder, width: 1),
boxShadow: const [
BoxShadow(
color: Color(0x08000000),
blurRadius: 18,
offset: Offset(0, 4),
color: Color(0x0F2A3A30),
blurRadius: 24,
offset: Offset(0, 8),
),
],
),

View File

@@ -0,0 +1,175 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import '../utils/Colors.dart';
/// A clock face drawn as a string of dots traced along each digit's outline —
/// the runner's signature readout. Only digits and ':' are drawn.
class DotDigits extends StatelessWidget {
final String value;
/// Glyph height in logical pixels.
final double height;
final Color? color;
const DotDigits({
super.key,
required this.value,
this.height = 64,
this.color,
});
@override
Widget build(BuildContext context) {
final _DotDigitsPainter painter =
_DotDigitsPainter(value, height, color ?? colorInk);
return CustomPaint(size: painter.measure(), painter: painter);
}
}
class _DotDigitsPainter extends CustomPainter {
final String value;
final double height;
final Color color;
_DotDigitsPainter(this.value, this.height, this.color);
/// Glyphs live in a 1 × 1.6 box.
double get _scale => height / 1.6;
double get _digitWidth => _scale * 1.0;
double get _colonWidth => _scale * 0.5;
double get _tracking => _scale * 0.34;
Size measure() {
double width = 0;
for (int i = 0; i < value.length; i++) {
width = width + (value[i] == ':' ? _colonWidth : _digitWidth);
if (i != value.length - 1) {
width = width + _tracking;
}
}
return Size(width + height * 0.08, height + height * 0.08);
}
@override
void paint(Canvas canvas, Size size) {
final double dot = height * 0.036;
final double gap = height * 0.082;
final Paint paint = Paint()..color = color;
canvas.translate(height * 0.04, height * 0.04);
double x = 0;
for (int i = 0; i < value.length; i++) {
final String glyph = value[i];
if (glyph == ':') {
canvas.drawCircle(
Offset(x + _colonWidth / 2, _scale * 0.52), dot * 1.25, paint);
canvas.drawCircle(
Offset(x + _colonWidth / 2, _scale * 1.12), dot * 1.25, paint);
x = x + _colonWidth + _tracking;
continue;
}
final Path? outline = _glyph(glyph);
if (outline != null) {
final Path placed = outline.transform(
(Matrix4.identity()
..translateByDouble(x, 0, 0, 1)
..scaleByDouble(_scale, _scale, 1, 1))
.storage);
for (PathMetric metric in placed.computeMetrics()) {
final int count = (metric.length / gap).floor().clamp(1, 400);
final double step = metric.length / count;
final int last = metric.isClosed ? count - 1 : count;
for (int n = 0; n <= last; n++) {
final Tangent? tangent = metric.getTangentForOffset(n * step);
if (tangent != null) {
canvas.drawCircle(tangent.position, dot, paint);
}
}
}
}
x = x + _digitWidth + _tracking;
}
}
Path? _glyph(String glyph) {
switch (glyph) {
case '0':
return Path()
..addRRect(RRect.fromRectAndRadius(
const Rect.fromLTWH(0, 0, 1, 1.6), const Radius.circular(0.5)));
case '1':
return Path()
..moveTo(0.22, 0.32)
..lineTo(0.6, 0)
..lineTo(0.6, 1.6);
case '2':
return Path()
..moveTo(0.03, 0.4)
..cubicTo(0.06, 0.02, 0.97, 0.0, 0.97, 0.44)
..cubicTo(0.97, 0.8, 0.05, 1.08, 0.0, 1.6)
..lineTo(1.0, 1.6);
case '3':
return Path()
..moveTo(0.04, 0.26)
..cubicTo(0.2, -0.06, 0.95, 0.0, 0.95, 0.4)
..cubicTo(0.95, 0.68, 0.66, 0.78, 0.4, 0.78)
..cubicTo(0.76, 0.78, 1.0, 0.92, 1.0, 1.2)
..cubicTo(1.0, 1.66, 0.18, 1.7, 0.0, 1.34);
case '4':
return Path()
..moveTo(0.76, 1.6)
..lineTo(0.76, 0.0)
..lineTo(0.0, 1.12)
..lineTo(1.0, 1.12);
case '5':
return Path()
..moveTo(0.92, 0.0)
..lineTo(0.14, 0.0)
..lineTo(0.06, 0.72)
..cubicTo(0.36, 0.54, 1.0, 0.6, 1.0, 1.08)
..cubicTo(1.0, 1.66, 0.2, 1.7, 0.0, 1.36);
case '6':
return Path()
..moveTo(0.86, 0.14)
..cubicTo(0.44, -0.12, 0.0, 0.24, 0.0, 1.1)
..addOval(const Rect.fromLTWH(0.0, 0.66, 1.0, 0.94));
case '7':
return Path()
..moveTo(0.0, 0.0)
..lineTo(1.0, 0.0)
..lineTo(0.34, 1.6);
case '8':
return Path()
..addOval(const Rect.fromLTWH(0.08, 0.0, 0.84, 0.74))
..addOval(const Rect.fromLTWH(0.0, 0.74, 1.0, 0.86));
case '9':
return Path()
..addOval(const Rect.fromLTWH(0.0, 0.0, 1.0, 0.94))
..moveTo(1.0, 0.5)
..cubicTo(1.0, 1.36, 0.56, 1.72, 0.14, 1.46);
default:
return null;
}
}
@override
bool shouldRepaint(covariant _DotDigitsPainter oldDelegate) {
return oldDelegate.value != value ||
oldDelegate.height != height ||
oldDelegate.color != color;
}
}

View File

@@ -0,0 +1,125 @@
import 'package:flutter/material.dart';
import '../about/internal/application/TextType.dart';
import '../utils/Colors.dart';
import 'GlassSurface.dart';
import 'text/Text.dart';
typedef NavEntry = ({IconData icon, String label, VoidCallback onTap});
/// The floating glass tab bar with the dark round action button beside it.
class FloatingNav extends StatelessWidget {
final List<NavEntry> entries;
final int selected;
final IconData actionIcon;
final VoidCallback onAction;
const FloatingNav({
super.key,
required this.entries,
required this.selected,
required this.actionIcon,
required this.onAction,
});
@override
Widget build(BuildContext context) {
return SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 14),
child: Row(
children: [
Expanded(
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(34),
boxShadow: const [
BoxShadow(
color: Color(0x1A1A1F1C),
blurRadius: 30,
offset: Offset(0, 10),
),
],
),
child: GlassSurface(
radius: BorderRadius.circular(34),
tint: colorGlassStrong,
padding: const EdgeInsets.all(6),
child: Row(
children: [
for (int index = 0; index < entries.length; index++)
Expanded(child: _tab(entries[index], index == selected)),
],
),
),
),
),
const SizedBox(width: 10),
GestureDetector(
onTap: onAction,
child: Container(
width: 64,
height: 64,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: const RadialGradient(
center: Alignment(-0.3, -0.4),
colors: <Color>[colorNightGlow, colorNight],
),
border: Border.all(color: colorWhite, width: 3),
boxShadow: const [
BoxShadow(
color: Color(0x331A1F1C),
blurRadius: 24,
offset: Offset(0, 10),
),
],
),
child: Icon(actionIcon, size: 24, color: colorLime),
),
),
],
),
),
);
}
Widget _tab(NavEntry entry, bool active) {
return GestureDetector(
onTap: entry.onTap,
behavior: HitTestBehavior.opaque,
child: AnimatedContainer(
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
padding: const EdgeInsets.symmetric(vertical: 9),
decoration: BoxDecoration(
color: active ? colorWhite : colorWhite.withValues(alpha: 0.0),
borderRadius: BorderRadius.circular(28),
boxShadow: active
? const [
BoxShadow(
color: Color(0x141A1F1C),
blurRadius: 12,
offset: Offset(0, 4),
),
]
: const [],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(entry.icon,
size: 19, color: active ? colorInk : colorInkSoft),
const SizedBox(height: 4),
text(entry.label, 10, active ? TextType.Medium : TextType.Regular,
color: active ? colorInk : colorInkSoft, maxLines: 1),
],
),
),
);
}
}

View File

@@ -0,0 +1,104 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import '../utils/Colors.dart';
/// Frosted glass: the background blurred through a translucent white panel
/// with a bright hairline edge. Controls and floating chrome are built from it.
class GlassSurface extends StatelessWidget {
final Widget child;
final BorderRadius radius;
final EdgeInsets padding;
final Color? tint;
/// Over a dark or coloured stage the panel goes smoky instead of milky.
final bool onDark;
final double blur;
const GlassSurface({
super.key,
required this.child,
this.radius = const BorderRadius.all(Radius.circular(20)),
this.padding = EdgeInsets.zero,
this.tint,
this.onDark = false,
this.blur = 18,
});
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: radius,
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: blur, sigmaY: blur),
child: Container(
padding: padding,
decoration: BoxDecoration(
color: tint ??
(onDark ? colorWhite.withValues(alpha: 0.16) : colorGlass),
borderRadius: radius,
border: Border.all(
color: onDark
? colorWhite.withValues(alpha: 0.22)
: colorGlassBorder,
width: 1,
),
),
child: child,
),
),
);
}
}
/// A round frosted control — back, close, bell, more.
Widget glassCircle(
IconData icon,
VoidCallback onTap, {
double size = 42,
bool onDark = false,
bool dotted = false,
Color? dotColor,
}) {
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Stack(
clipBehavior: Clip.none,
children: [
GlassSurface(
onDark: onDark,
radius: BorderRadius.circular(size),
child: SizedBox(
width: size,
height: size,
child: Icon(
icon,
size: size * 0.42,
color: onDark ? colorWhite : colorInk,
),
),
),
if (dotted)
Positioned(
top: 2,
right: 2,
child: Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: dotColor ?? colorAccent,
shape: BoxShape.circle,
border: Border.all(color: colorWhite, width: 1.5),
),
),
),
],
),
);
}

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,117 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../utils/Colors.dart';
/// The Grounded mark: a lime sun resting on a ground line beneath two
/// breathing arcs, on a night-green tile. Drawn, not bitmapped, so the splash
/// can raise the sun and light the arcs. Geometry matches the launcher icon.
class GroundedMark extends StatelessWidget {
final double size;
/// 0 → the sun is below the line; 1 → fully risen.
final double rise;
/// 0 → arcs hidden; 1 → both lit. The inner arc lights first.
final double arcs;
final bool tile;
const GroundedMark({
super.key,
this.size = 120,
this.rise = 1,
this.arcs = 1,
this.tile = true,
});
@override
Widget build(BuildContext context) {
return CustomPaint(
size: Size.square(size),
painter: _MarkPainter(rise, arcs, tile),
);
}
}
class _MarkPainter extends CustomPainter {
final double rise;
final double arcs;
final bool tile;
_MarkPainter(this.rise, this.arcs, this.tile);
@override
void paint(Canvas canvas, Size size) {
final double u = size.width / 1024;
final Rect bounds = Offset.zero & size;
if (tile) {
canvas.drawRRect(
RRect.fromRectAndRadius(bounds, Radius.circular(size.width * 0.235)),
Paint()
..shader = const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: <Color>[colorNightLift, colorNight],
).createShader(bounds),
);
}
final double cx = 512 * u;
final double horizon = 630 * u;
// Arcs — inner then outer.
final List<double> radii = <double>[250, 330];
final List<double> alphas = <double>[0.6, 0.28];
for (int i = 0; i < 2; i++) {
final double lit = ((arcs * 2) - i).clamp(0.0, 1.0);
if (lit <= 0) {
continue;
}
final double r = radii[i] * u;
canvas.drawArc(
Rect.fromCircle(center: Offset(cx, horizon), radius: r - 15 * u),
math.pi * 198 / 180,
math.pi * 144 / 180,
false,
Paint()
..style = PaintingStyle.stroke
..strokeWidth = 30 * u
..strokeCap = StrokeCap.round
..color = colorAmbientMid.withValues(alpha: alphas[i] * lit),
);
}
// The sun, clipped at the horizon so it rises out of the ground.
final double radius = 168 * u;
canvas.save();
canvas.clipRect(Rect.fromLTRB(0, 0, size.width, horizon));
canvas.drawCircle(
Offset(cx, horizon + radius * 2 * (1 - rise)),
radius,
Paint()..color = colorLime,
);
canvas.restore();
// Ground line.
final double ground = horizon + 52 * u;
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromLTRB(250 * u, ground - 20 * u, 774 * u, ground + 20 * u),
Radius.circular(20 * u),
),
Paint()..color = colorAmbientMid,
);
}
@override
bool shouldRepaint(covariant _MarkPainter oldDelegate) {
return oldDelegate.rise != rise ||
oldDelegate.arcs != arcs ||
oldDelegate.tile != tile;
}
}

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

@@ -1,15 +1,20 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import '../about/internal/application/TextType.dart';
import '../utils/Colors.dart';
import 'Component.dart';
import 'AmbientBackground.dart';
import 'ChromeTone.dart';
import 'GlassSurface.dart';
import 'scene/SceneArt.dart';
import 'text/Text.dart';
/// The house layout: black chrome at the top, a white sheet rising into it with
/// a large corner radius. Every screen is built from this so the app reads as
/// one object rather than a stack of pages.
/// The house layout. On the default ambient tone the screen floats on sage
/// mist with frosted controls and ink type, and the content rises on a frosted
/// sheet. Screens that carry a colour — a goal, a standing tier — or a banner
/// get a tinted stage instead, with white type and smoky glass.
class Sheet extends StatelessWidget {
/// Small all-caps label rendered in the black chrome, above the title.
/// Small all-caps label rendered in the chrome, above the title.
final String eyebrow;
/// The chrome title — small and centred, not the display title.
@@ -22,7 +27,7 @@ class Sheet extends StatelessWidget {
/// Optional trailing control in the chrome.
final Widget? action;
/// Chrome colour. Defaults to near-black; standing screens tint it.
/// Stage colour. Leaving it null (with no banner) gives the ambient tone.
final Color? chrome;
/// Rendered inside the chrome beneath the title — the standing strip.
@@ -30,6 +35,12 @@ class Sheet extends StatelessWidget {
final bool scrollable;
/// A painted landscape behind a tinted stage's header, in place of the
/// flat gradient.
final List<Color>? scene;
final int sceneSeed;
const Sheet({
super.key,
required this.title,
@@ -40,33 +51,39 @@ class Sheet extends StatelessWidget {
this.chrome,
this.banner,
this.scrollable = true,
this.scene,
this.sceneSeed = 0,
});
bool get _light => chrome == null && banner == null && scene == null;
@override
Widget build(BuildContext context) {
final Color chromeColor = chrome ?? colorPrimaryDark;
final bool light = _light;
final Color stage = chrome ?? colorPrimaryDark;
final Color ink = light ? colorInk : colorWhite;
final Color soft =
light ? colorInkSoft : colorWhite.withValues(alpha: 0.5);
return Scaffold(
backgroundColor: chromeColor,
body: Column(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SafeArea(
final Widget header = SafeArea(
bottom: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 4, 12, 16),
padding: const EdgeInsets.fromLTRB(16, 6, 16, 18),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
SizedBox(
width: 44,
width: 46,
child: onBack == null
? null
: _chromeButton(
Icons.arrow_back_ios_new_rounded, onBack!),
: Align(
alignment: Alignment.centerLeft,
child: glassCircle(
CupertinoIcons.arrow_left, onBack!,
onDark: !light),
),
),
Expanded(
child: Column(
@@ -77,24 +94,25 @@ class Sheet extends StatelessWidget {
eyebrow.toUpperCase(),
9,
TextType.Bold,
color: colorWhite.withValues(alpha: 0.45),
color: soft,
letterSpacing: 1.2,
align: TextAlign.center,
),
const SizedBox(height: 3),
],
text(
title,
15,
title.toUpperCase(),
14,
TextType.Medium,
color: colorWhite,
color: ink,
letterSpacing: 0.6,
align: TextAlign.center,
),
],
),
),
SizedBox(
width: 44,
width: 46,
child: action == null
? null
: Align(
@@ -105,20 +123,21 @@ class Sheet extends StatelessWidget {
],
),
if (banner != null) ...[
const SizedBox(height: 16),
const SizedBox(height: 18),
banner!,
],
],
),
),
),
Expanded(
);
final Widget body = Expanded(
child: Container(
decoration: BoxDecoration(
color: colorPrimaryLight,
color: light ? colorFrost : colorPrimaryLight,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(28),
topRight: Radius.circular(28),
topLeft: Radius.circular(32),
topRight: Radius.circular(32),
),
),
clipBehavior: Clip.antiAlias,
@@ -132,66 +151,107 @@ class Sheet extends StatelessWidget {
child: child,
),
),
);
final Widget column = ChromeTone(
light: light,
child: Column(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [header, body],
),
],
),
);
if (light) {
return Scaffold(
backgroundColor: colorAmbientMid,
body: AmbientBackground(child: column),
);
}
Widget _chromeButton(IconData icon, VoidCallback onTap) {
return GestureDetector(
onTap: onTap,
child: Container(
width: 38,
height: 38,
alignment: Alignment.center,
// A tinted stage: the colour deepens downward and a soft light blooms in
// the corner, so it reads as lit rather than painted flat.
return Scaffold(
backgroundColor: stage,
body: Stack(
children: [
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
color: colorWhite.withValues(alpha: 0.10),
shape: BoxShape.circle,
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: <Color>[
Color.lerp(stage, colorWhite, 0.14)!,
stage,
Color.lerp(stage, colorBlack, 0.35)!,
],
),
child: Icon(icon, size: 15, color: colorWhite),
),
),
),
if (scene != null)
Positioned(
top: 0,
left: 0,
right: 0,
height: 250,
child: ShaderMask(
shaderCallback: (Rect bounds) => const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: <Color>[Color(0xFF000000), Color(0x00000000)],
stops: <double>[0.6, 1.0],
).createShader(bounds),
blendMode: BlendMode.dstIn,
child: Stack(
fit: StackFit.expand,
children: [
SceneArt(palette: scene!, seed: sceneSeed),
ColoredBox(color: colorBlack.withValues(alpha: 0.42)),
],
),
),
),
Positioned(
top: -120,
right: -90,
child: Container(
width: 300,
height: 300,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: <Color>[
colorWhite.withValues(alpha: 0.16),
colorWhite.withValues(alpha: 0.0),
],
),
),
),
),
Positioned.fill(child: column),
],
),
);
}
}
/// A circular control for the black chrome, with an optional unread dot.
/// A round frosted control for the chrome, with an optional unread dot. It
/// matches the stage it sits on.
Widget chromeAction(
IconData icon,
VoidCallback onTap, {
bool dotted = false,
Color? dotColor,
}) {
return GestureDetector(
onTap: onTap,
child: Stack(
clipBehavior: Clip.none,
children: [
Container(
width: 38,
height: 38,
alignment: Alignment.center,
decoration: BoxDecoration(
color: colorWhite.withValues(alpha: 0.10),
shape: BoxShape.circle,
),
child: Icon(icon, size: 17, color: colorWhite),
),
if (dotted)
Positioned(
top: 1,
right: 1,
child: Container(
width: 9,
height: 9,
decoration: BoxDecoration(
color: dotColor ?? colorAccent,
shape: BoxShape.circle,
border: Border.all(color: colorPrimaryDark, width: 1.5),
),
),
),
],
return Builder(
builder: (BuildContext context) => glassCircle(
icon,
onTap,
onDark: !ChromeTone.isLight(context),
dotted: dotted,
dotColor: dotColor,
),
);
}
@@ -215,12 +275,11 @@ Widget labelled(
label.toUpperCase(),
9,
TextType.Bold,
color: labelColor ?? colorGrey2,
color: labelColor ?? colorInkSoft,
letterSpacing: 1.0,
),
const SizedBox(height: 5),
text(value, valueSize, valueType,
color: valueColor ?? colorPrimaryDark),
text(value, valueSize, valueType, color: valueColor ?? colorInk),
],
);
}
@@ -247,18 +306,15 @@ Widget metaRow(List<Widget> items) {
/// The oversized light display title that opens a section.
Widget displayTitle(String value, {Color? color, double size = 34}) {
return text(value, size, TextType.Light,
color: color ?? colorPrimaryDark, height: 1.15);
color: color ?? colorInk, height: 1.15);
}
/// A section break: hairline, then a small bold heading with an optional
/// trailing chip.
/// A section heading: medium ink label, a soft caption beside it and an
/// optional trailing control ("View all").
Widget sectionBreak(String heading, {Widget? trailing, String caption = ""}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
hairline(margin: const EdgeInsets.only(bottom: 20)),
Row(
return Padding(
padding: const EdgeInsets.only(top: 6, bottom: 14),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
@@ -266,13 +322,17 @@ Widget sectionBreak(String heading, {Widget? trailing, String caption = ""}) {
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
text(heading, 17, TextType.Bold, color: colorPrimaryDark),
text(heading, 16, TextType.Medium, color: colorInk),
if (caption.isNotEmpty) ...[
const SizedBox(width: 10),
Padding(
padding: const EdgeInsets.only(bottom: 2),
const SizedBox(width: 8),
Flexible(
child: Padding(
padding: const EdgeInsets.only(bottom: 1),
child: text(caption, 11, TextType.Regular,
color: colorGrey2),
color: colorInkSoft,
maxLines: 1,
overflow: TextOverflow.ellipsis),
),
),
],
],
@@ -281,7 +341,5 @@ Widget sectionBreak(String heading, {Widget? trailing, String caption = ""}) {
if (trailing != null) trailing,
],
),
const SizedBox(height: 16),
],
);
}

View File

@@ -13,10 +13,10 @@ Widget roundedCornerButton(
Color? foreground,
IconData? icon,
bool enabled = true,
double radius = 14,
double radius = 99,
double verticalPadding = 16,
}) {
final Color bg = enabled ? (background ?? colorPrimaryDark) : colorGrey;
final Color bg = enabled ? (background ?? colorNight) : colorGrey;
final Color fg = foreground ?? colorWhite;
return ElevatedButton(
@@ -35,10 +35,13 @@ Widget roundedCornerButton(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (icon != null) ...[
Icon(icon, size: 16, color: fg),
// The default night pill carries the lime spark on its icon.
Icon(icon,
size: 16,
color: background == null && foreground == null ? colorLime : fg),
const SizedBox(width: 8),
],
text(label, 14, TextType.Bold, color: fg),
text(label, 14, TextType.Medium, color: fg),
],
),
);
@@ -52,7 +55,7 @@ Widget outlinedActionButton(
Color? foreground,
IconData? icon,
bool enabled = true,
double radius = 14,
double radius = 99,
}) {
final Color fg = enabled ? (foreground ?? colorPrimaryDark) : colorGrey;

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

@@ -0,0 +1,119 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../../utils/Colors.dart';
import 'ScenePainter.dart';
/// A painted landscape that stands in for photography: sky, a low sun, three
/// ridges and an oversized figure glyph standing in front of them. The [seed]
/// reshapes the ridges and moves the sun, so two routines of the same kind
/// never wear the identical picture.
class SceneArt extends StatelessWidget {
/// sky top, horizon, sun, far ridge, mid ridge, near ridge, ember.
final List<Color> palette;
final int seed;
/// The glyph standing in the scene — the kind's icon, usually.
final IconData? figure;
/// Figure size as a fraction of the shorter side.
final double figureScale;
final Alignment figureAlignment;
/// Darkens the lower half so white type laid over it stays legible.
final bool shade;
/// Lowers the sun behind the ridges; animate it for a sunrise.
final double sunLift;
const SceneArt({
super.key,
required this.palette,
this.seed = 0,
this.figure,
this.figureScale = 0.5,
this.figureAlignment = const Alignment(0.35, 0.15),
this.shade = false,
this.sunLift = 1,
});
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
final double shortest = math.min(
constraints.maxWidth.isFinite ? constraints.maxWidth : 200,
constraints.maxHeight.isFinite ? constraints.maxHeight : 200,
);
final double glyph = shortest * figureScale;
return RepaintBoundary(
child: Stack(
fit: StackFit.expand,
children: [
CustomPaint(
painter: ScenePainter(palette, seed, sunLift: sunLift)),
if (figure != null)
Align(
alignment: figureAlignment,
child: SizedBox(
width: glyph * 1.6,
height: glyph * 1.6,
child: Stack(
alignment: Alignment.center,
children: [
// The ember halo behind the figure — the reference's
// orange gi, reduced to light.
Container(
width: glyph * 1.5,
height: glyph * 1.5,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: <Color>[
palette[6].withValues(alpha: 0.45),
palette[6].withValues(alpha: 0.0),
],
),
),
),
Icon(
figure,
size: glyph,
color: colorWhite.withValues(alpha: 0.96),
shadows: <Shadow>[
Shadow(
color: palette[5].withValues(alpha: 0.55),
blurRadius: glyph * 0.22,
offset: Offset(0, glyph * 0.08),
),
],
),
],
),
),
),
if (shade)
DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: const <double>[0.35, 1.0],
colors: <Color>[
colorBlack.withValues(alpha: 0.0),
colorBlack.withValues(alpha: 0.55),
],
),
),
),
],
),
);
},
);
}
}

View File

@@ -0,0 +1,119 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../../utils/Colors.dart';
/// Paints the landscape behind [SceneArt]. Everything is derived from the
/// palette and the seed, so the same routine always gets the same picture.
class ScenePainter extends CustomPainter {
final List<Color> palette;
final int seed;
/// 1 → the sun sits in its place; 0 → it is down behind the ridges.
final double sunLift;
ScenePainter(this.palette, this.seed, {this.sunLift = 1});
@override
void paint(Canvas canvas, Size size) {
final double w = size.width;
final double h = size.height;
final math.Random random = math.Random(seed);
final Rect bounds = Offset.zero & size;
// Sky.
canvas.drawRect(
bounds,
Paint()
..shader = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: <Color>[palette[0], palette[1]],
stops: const <double>[0.0, 0.62],
).createShader(bounds),
);
// Sun, with a wide soft glow.
final double radius = math.min(w, h) * (0.13 + random.nextDouble() * 0.05);
final Offset sun = Offset(
w * (0.18 + random.nextDouble() * 0.64),
h * (0.26 + random.nextDouble() * 0.12) + h * 0.5 * (1 - sunLift),
);
canvas.drawCircle(
sun,
radius * 2.6,
Paint()
..shader = RadialGradient(
colors: <Color>[
palette[2].withValues(alpha: 0.55),
palette[2].withValues(alpha: 0.0),
],
).createShader(Rect.fromCircle(center: sun, radius: radius * 2.6)),
);
canvas.drawCircle(sun, radius, Paint()..color = palette[2]);
// A haze band along the horizon so the far ridge sits back.
final Rect haze = Rect.fromLTWH(0, h * 0.42, w, h * 0.22);
canvas.drawRect(
haze,
Paint()
..shader = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: <Color>[
colorWhite.withValues(alpha: 0.0),
colorWhite.withValues(alpha: 0.35),
colorWhite.withValues(alpha: 0.0),
],
).createShader(haze),
);
// Three ridges, far to near.
final List<double> baselines = <double>[0.56, 0.68, 0.82];
final List<double> amplitudes = <double>[0.07, 0.06, 0.05];
for (int layer = 0; layer < 3; layer++) {
final double base = h * baselines[layer];
final double amp = h * amplitudes[layer];
final double f1 = 0.8 + random.nextDouble() * 1.4;
final double f2 = 2.0 + random.nextDouble() * 2.5;
final double p1 = random.nextDouble() * math.pi * 2;
final double p2 = random.nextDouble() * math.pi * 2;
final Path ridge = Path()..moveTo(0, h);
const int steps = 36;
for (int i = 0; i <= steps; i++) {
final double x = w * i / steps;
final double t = i / steps;
final double y = base -
amp *
(math.sin(t * f1 * math.pi * 2 + p1) * 0.65 +
math.sin(t * f2 * math.pi * 2 + p2) * 0.35);
ridge.lineTo(x, y);
}
ridge
..lineTo(w, h)
..close();
final Color tone = palette[3 + layer];
canvas.drawPath(
ridge,
Paint()
..shader = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: <Color>[tone, Color.lerp(tone, colorBlack, 0.18)!],
).createShader(Rect.fromLTWH(0, base - amp, w, h - base + amp)),
);
}
}
@override
bool shouldRepaint(covariant ScenePainter oldDelegate) {
return oldDelegate.seed != seed ||
oldDelegate.palette != palette ||
oldDelegate.sunLift != sunLift;
}
}

View File

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

View File

@@ -0,0 +1,443 @@
// MOCK DATA — TEMPORARY. See MockSwitch.dart.
import 'package:dio/dio.dart';
import '../about/external/data/RoutineChain.dart';
import '../about/external/data/pages/request/CommitmentsRequest.dart';
import '../about/external/data/pages/request/HistoryRequest.dart';
import '../about/external/initial/AbandonRequest.dart';
import '../about/external/initial/AmnestyRequest.dart';
import '../about/external/initial/CommitmentRequest.dart';
import '../about/external/initial/CompletionRequest.dart';
import '../about/external/initial/DeferralRequest.dart';
import '../about/external/initial/DeviceRequest.dart';
import '../about/external/initial/GoalRequest.dart';
import '../about/external/initial/IdRequest.dart';
import '../about/external/initial/LoginData.dart';
import '../about/external/initial/ReportCardRequest.dart';
import '../about/external/initial/SessionLogRequest.dart';
import '../about/external/initial/SickModeRequest.dart';
import '../about/external/initial/ToneRequest.dart';
import '../comms/ConnectComms.dart';
import 'MockStore.dart';
/// A fake backend. Answers every call from [MockStore] after a short delay,
/// so loading states still flash the way they will against the real thing.
class MockComms implements ConnectComms {
MockComms() {
MockStore.ensureSeeded();
}
Future<Response> _ok(dynamic data) async {
await Future<void>.delayed(const Duration(milliseconds: 250));
return Response(
requestOptions: RequestOptions(path: 'mock'),
statusCode: 200,
data: data,
);
}
Future<Response> _done() => _ok(<String, dynamic>{'success': true});
Future<Response> _emptyPage() => _ok(MockStore.page(<Map<String, dynamic>>[]));
// ── Auth ──────────────────────────────────────────────────────────────────
@override
Future<Response> registerDevice(DeviceRequest request) => _done();
@override
Future<Response> registerDeviceToken(String token) => _done();
@override
Future<Response> login(LoginData request) => _ok(<String, dynamic>{
'id': 'mock-user',
'name': 'Alex',
'token': 'mock-token',
'pic': '',
'tone': 'Firm',
'standing': 'Good',
'amnestyTokens': 3,
'sickMode': false,
});
@override
Future<Response> logout() => _done();
@override
Future<Response> me() => _ok(<String, dynamic>{'id': 'mock-user', 'name': 'Alex'});
// ── Goals ─────────────────────────────────────────────────────────────────
@override
Future<Response> getMyGoals(HistoryRequest request) =>
_ok(MockStore.goalsWithCounts());
@override
Future<Response> saveGoalEntry(GoalRequest request) {
final Map<String, dynamic> json = request.toJson();
json['targetDate'] = request.targetDate.isEmpty ? null : request.targetDate;
json['archived'] = false;
final int index =
MockStore.goals.indexWhere((goal) => goal['id'] == request.id);
if (request.id == null || index < 0) {
json['id'] = MockStore.newId('goal');
MockStore.goals.add(json);
} else {
MockStore.goals[index] = json;
}
return _ok(json);
}
@override
Future<Response> getGoalTasks(IdRequest request) =>
_ok(MockStore.goalTasks(request.id));
@override
Future<Response> archiveGoalEntry(IdRequest request) {
for (Map<String, dynamic> goal in MockStore.goals) {
if (goal['id'] == request.id) {
goal['archived'] = true;
}
}
return _done();
}
// ── Commitments ───────────────────────────────────────────────────────────
@override
Future<Response> getTodayPlan(CommitmentsRequest request) =>
_ok(MockStore.page(MockStore.todayPlan()));
@override
Future<Response> getMyCommitments(CommitmentsRequest request) =>
_ok(MockStore.page(MockStore.commitments));
@override
Future<Response> getOverdueQueue(HistoryRequest request) =>
_ok(MockStore.page(MockStore.overdue()));
@override
Future<Response> saveCommitmentEntry(CommitmentRequest request) {
final Map<String, dynamic> json = request.toJson();
json['id'] = MockStore.newId('task');
json['status'] = 'Open';
json['deferralCount'] = 0;
MockStore.commitments.add(json);
return _ok(json);
}
@override
Future<Response> updateCommitmentEntry(CommitmentRequest request) {
final Map<String, dynamic>? item = MockStore.commitment(request.id ?? '');
if (item != null) {
item.addAll(request.toJson());
}
return _done();
}
@override
Future<Response> deleteCommitmentEntry(IdRequest request) {
MockStore.commitments.removeWhere((item) => item['id'] == request.id);
return _done();
}
@override
Future<Response> completeCommitmentEntry(CompletionRequest request) {
final Map<String, dynamic>? item = MockStore.commitment(request.commitmentId);
if (item != null) {
final DateTime? end = DateTime.tryParse(item['dueEnd'] ?? '');
item['status'] = end != null && end.isBefore(DateTime.now())
? 'LateCompleted'
: 'Completed';
}
return _done();
}
@override
Future<Response> deferCommitmentEntry(DeferralRequest request) {
final Map<String, dynamic>? item = MockStore.commitment(request.commitmentId);
if (item != null) {
final DateTime now = DateTime.now();
item['dueStart'] = request.newDueStart.isNotEmpty
? request.newDueStart
: now.add(const Duration(hours: 1)).toIso8601String();
item['dueEnd'] = request.newDueEnd.isNotEmpty
? request.newDueEnd
: now.add(const Duration(days: 1)).toIso8601String();
item['deferralCount'] = (item['deferralCount'] ?? 0) + 1;
item['status'] = 'Deferred';
}
return _done();
}
@override
Future<Response> abandonCommitmentEntry(AbandonRequest request) {
final Map<String, dynamic>? item = MockStore.commitment(request.commitmentId);
if (item != null) {
item['status'] = 'Abandoned';
}
return _done();
}
@override
Future<Response> getCommitmentHistory(HistoryRequest request) => _emptyPage();
@override
Future<Response> getCommitmentEvents(IdRequest request) => _emptyPage();
// ── Capacity ──────────────────────────────────────────────────────────────
@override
Future<Response> checkCapacity(CommitmentsRequest request) => _done();
@override
Future<Response> getCapacityProfileEntry() => _ok(<String, dynamic>{});
// ── Debt & standing ───────────────────────────────────────────────────────
@override
Future<Response> getDebtSummary() => _ok(<String, dynamic>{});
@override
Future<Response> getDebtLedger(HistoryRequest request) => _emptyPage();
@override
Future<Response> getDebtTrend(ReportCardRequest request) => _ok(<dynamic>[]);
@override
Future<Response> getStanding() => _ok(<String, dynamic>{'standing': 'Good'});
@override
Future<Response> getStandingHistory(HistoryRequest request) => _emptyPage();
// ── Excuses ───────────────────────────────────────────────────────────────
@override
Future<Response> getExcuseClusters(ReportCardRequest request) =>
_ok(<Map<String, dynamic>>[
{
'id': 'cluster-tired',
'label': 'Too tired',
'occurrences': 4,
'dominantCategory': 'Deep work',
'byWeekday': {'4': 3, '2': 1},
'byHour': {'19': 3, '21': 1},
'insight':
"You've said \"too tired\" 4 times this month — 3 of them on Thursday evenings. Put deep work before lunch on Thursdays.",
},
{
'id': 'cluster-time',
'label': 'Ran out of time',
'occurrences': 3,
'dominantCategory': 'Admin',
'byWeekday': {'1': 2, '5': 1},
'byHour': {'17': 3},
'insight': '',
},
]);
// ── Proof ─────────────────────────────────────────────────────────────────
@override
Future<Response> uploadPhotoProof(FormData request) =>
_ok(<String, dynamic>{'proofRef': 'mock-photo'});
@override
Future<Response> submitTimerProofEntry(CompletionRequest request) =>
completeCommitmentEntry(request);
@override
Future<Response> submitLocationProofEntry(CompletionRequest request) =>
completeCommitmentEntry(request);
// ── Guardrails ────────────────────────────────────────────────────────────
@override
Future<Response> spendAmnestyToken(AmnestyRequest request) => _done();
@override
Future<Response> getAmnestyBalance() => _ok(<String, dynamic>{'balance': 3});
@override
Future<Response> updateSickMode(SickModeRequest request) => _done();
@override
Future<Response> checkDistress() => _ok(<String, dynamic>{'distressed': false});
@override
Future<Response> updateTone(ToneRequest request) => _done();
// ── Habits & routines ─────────────────────────────────────────────────────
@override
Future<Response> getMyHabits(HistoryRequest request) => _ok(<dynamic>[]);
@override
Future<Response> saveHabitEntry(Map<String, dynamic> request) => _done();
@override
Future<Response> logHabitEntry(IdRequest request) => _done();
@override
Future<Response> getKeystoneHabits() => _ok(<dynamic>[]);
@override
Future<Response> getMyRoutines(HistoryRequest request) => _ok(
MockStore.routines.map((routine) => routine.toJson()).toList());
@override
Future<Response> saveRoutineEntry(Map<String, dynamic> request) {
final RoutineChain routine = RoutineChain.fromJson(request);
final int index = MockStore.routines
.indexWhere((item) => item.id != null && item.id == routine.id);
if (routine.id == null || index < 0) {
routine.id = MockStore.newId('routine');
MockStore.routines.add(routine);
} else {
// Keep today's progress across an edit.
final RoutineChain before = MockStore.routines[index];
for (int step = 0;
step < routine.steps.length && step < before.steps.length;
step++) {
routine.steps[step].completed = before.steps[step].completed;
}
MockStore.routines[index] = routine;
}
return _ok(routine.toJson());
}
/// A logged run marks today's progress on the routine, so the library ring
/// and the "done" tick reflect what just happened.
@override
Future<Response> logRoutineChainEntry(Map<String, dynamic> request) {
for (RoutineChain routine in MockStore.routines) {
if (routine.id == request['routineId']) {
final int done = request['completedSteps'] ?? 0;
for (int step = 0; step < routine.steps.length; step++) {
routine.steps[step].completed = step < done;
}
routine.lastBreakIndex = request['breakIndex'] ?? -1;
}
}
return _done();
}
// ── Training ──────────────────────────────────────────────────────────────
@override
Future<Response> getMyPrograms(HistoryRequest request) =>
_ok(<Map<String, dynamic>>[
{
'id': 'program-base',
'name': 'Base strength · 8 weeks',
'weeks': 8,
'sessionsPerWeek': 3,
'deloadWeeks': <int>[4, 8],
'weeklyContactCeiling': 120,
'lowerBodyRecoveryHours': 48,
'active': true,
},
]);
@override
Future<Response> saveProgramEntry(Map<String, dynamic> request) => _done();
@override
Future<Response> activateProgramEntry(IdRequest request) => _done();
@override
Future<Response> getProgramSessions(IdRequest request) =>
_ok(<Map<String, dynamic>>[
{
'id': 'session-a',
'programId': 'program-base',
'dayIndex': 0,
'name': 'Day A · Squat & press',
'highIntensityLowerBody': true,
'prescriptions': <dynamic>[{}, {}, {}, {}],
},
{
'id': 'session-b',
'programId': 'program-base',
'dayIndex': 2,
'name': 'Day B · Pull & hinge',
'highIntensityLowerBody': false,
'prescriptions': <dynamic>[{}, {}, {}],
},
]);
@override
Future<Response> startSessionEntry(IdRequest request) => _done();
@override
Future<Response> saveSessionLogEntry(SessionLogRequest request) => _done();
@override
Future<Response> getSessionHistory(HistoryRequest request) => _emptyPage();
@override
Future<Response> getLastSessionFor(IdRequest request) => _ok(<String, dynamic>{});
@override
Future<Response> getWeeklyVolume(ReportCardRequest request) => _ok(<String, dynamic>{});
@override
Future<Response> getContactVolume(ReportCardRequest request) => _ok(<String, dynamic>{});
@override
Future<Response> getPersonalRecords(HistoryRequest request) => _emptyPage();
// ── Report card ───────────────────────────────────────────────────────────
@override
Future<Response> getWeeklyReportCard(ReportCardRequest request) => _ok(_report());
@override
Future<Response> getMonthlyReportCard(ReportCardRequest request) => _ok(_report());
Map<String, dynamic> _report() {
final DateTime now = DateTime.now();
return {
'id': 'report-week',
'periodStart': now.subtract(const Duration(days: 7)).toIso8601String(),
'periodEnd': now.toIso8601String(),
'completionByClass': {
'NonNegotiable': 0.92,
'Standard': 0.71,
'Elective': 0.40,
},
'completionByCategory': {
'Deep work': 0.64,
'Admin': 0.88,
'Health': 0.79,
},
'completionByWeekday': {
'1': 0.9, '2': 0.8, '3': 0.75, '4': 0.45, '5': 0.7, '6': 0.6, '7': 0.85,
},
'worstHour': 19,
'debtTrend': <double>[8, 9.5, 11, 10.2, 7.4, 6.8, 5.9],
'deferralLeaderboard': <Map<String, dynamic>>[
{'commitmentId': 'c-email', 'title': 'Email supervisor the outline', 'count': 2},
{'commitmentId': 'c-kitchen', 'title': 'Deep clean the kitchen', 'count': 1},
],
'excuseTaxonomy': <dynamic>[],
'estimationAccuracy': {'Deep work': 1.6, 'Admin': 1.1},
'trainingAdherence': 0.83,
'programIntegrity': 0.9,
'assignedAction':
'Move deep work before noon on Thursdays. That is where the week leaks.',
'grade': 'B',
'praise': 'Every non-negotiable but one landed inside its window.',
};
}
// ── Notifications ─────────────────────────────────────────────────────────
@override
Future<Response> getMyNotifications(HistoryRequest request) => _emptyPage();
@override
Future<Response> acknowledgeNudgeEntry(IdRequest request) => _done();
}

View File

@@ -0,0 +1,26 @@
// MOCK DATA — TEMPORARY. See MockSwitch.dart.
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../about/internal/application/MeDescription.dart';
import '../about/internal/application/UserDetails.dart';
import '../memory/InternalMemory.dart';
import 'MockStorage.dart';
/// The real [InternalMemory], pointed at in-memory storage and pre-seeded
/// with a signed-in user so the app opens straight onto Home.
class MockMemory extends InternalMemory {
static bool _seeded = false;
MockMemory() {
if (!_seeded) {
_seeded = true;
setMyDescription(
MeDescription(id: "mock-user", name: "Alex", token: "mock-token"));
setUserDetails(UserDetails(pic: "", name: "Alex"));
}
}
@override
FlutterSecureStorage get groundedStorage => const MockStorage();
}

View File

@@ -0,0 +1,55 @@
// MOCK DATA — TEMPORARY. See MockSwitch.dart.
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// Secure storage held in memory, so mock mode never touches the keychain or
/// browser storage. Seeded as signed in.
class MockStorage extends FlutterSecureStorage {
static final Map<String, String> _values = <String, String>{};
const MockStorage();
@override
Future<String?> read({
required String key,
AppleOptions? iOptions,
AndroidOptions? aOptions,
LinuxOptions? lOptions,
WebOptions? webOptions,
AppleOptions? mOptions,
WindowsOptions? wOptions,
}) async {
return _values[key];
}
@override
Future<void> write({
required String key,
required String? value,
AppleOptions? iOptions,
AndroidOptions? aOptions,
LinuxOptions? lOptions,
WebOptions? webOptions,
AppleOptions? mOptions,
WindowsOptions? wOptions,
}) async {
if (value == null) {
_values.remove(key);
return;
}
_values[key] = value;
}
@override
Future<void> delete({
required String key,
AppleOptions? iOptions,
AndroidOptions? aOptions,
LinuxOptions? lOptions,
WebOptions? webOptions,
AppleOptions? mOptions,
WindowsOptions? wOptions,
}) async {
_values.remove(key);
}
}

View File

@@ -0,0 +1,298 @@
// MOCK DATA — TEMPORARY. See MockSwitch.dart.
import '../about/external/data/RoutineChain.dart';
import '../about/internal/application/CommitmentClass.dart';
import '../utils/RoutineTemplates.dart';
/// The fake backend's database: plain JSON maps, seeded relative to today,
/// mutated by [MockComms] so taps actually change what you see. Static, so
/// every screen shares one copy for the life of the app.
class MockStore {
static bool _seeded = false;
static int _nextId = 100;
static final List<Map<String, dynamic>> goals = <Map<String, dynamic>>[];
static final List<Map<String, dynamic>> commitments =
<Map<String, dynamic>>[];
static final List<RoutineChain> routines = <RoutineChain>[];
static String newId(String prefix) {
_nextId = _nextId + 1;
return "$prefix-$_nextId";
}
static void ensureSeeded() {
if (_seeded) {
return;
}
_seeded = true;
final DateTime now = DateTime.now();
final DateTime today = DateTime(now.year, now.month, now.day);
final int weekday = now.weekday;
DateTime at(int dayOffset, int hour, [int minute = 0]) {
return today.add(Duration(days: dayOffset, hours: hour, minutes: minute));
}
// ── Goals ──────────────────────────────────────────────────────────────
goals.addAll(<Map<String, dynamic>>[
{
'id': 'goal-strong',
'title': 'Get strong',
'description': 'Deadlift my bodyweight by spring, without getting hurt.',
'category': 'Health',
'defaultClass': 'Standard',
'startDate': at(-30, 0).toIso8601String(),
'targetDate': at(60, 0).toIso8601String(),
'colourHex': '#FF2F6F4E',
'archived': false,
},
{
'id': 'goal-thesis',
'title': 'Finish the thesis',
'description': 'Submit before the department deadline. No extensions.',
'category': 'Work',
'defaultClass': 'NonNegotiable',
'startDate': at(-60, 0).toIso8601String(),
'targetDate': at(45, 0).toIso8601String(),
'colourHex': '#FF2C4A63',
'archived': false,
},
{
'id': 'goal-calm',
'title': 'Calm mornings',
'description': 'Start the day on purpose instead of on the phone.',
'category': 'Mind',
'defaultClass': 'Elective',
'startDate': at(-10, 0).toIso8601String(),
'targetDate': null,
'colourHex': '#FF4F5D86',
'archived': false,
},
]);
// ── Commitments ────────────────────────────────────────────────────────
Map<String, dynamic> task(
String id,
String title, {
String? goal,
String cls = 'Standard',
required DateTime start,
required DateTime end,
int minutes = 30,
String proof = 'Honour',
int timer = 0,
String status = 'Open',
int deferrals = 0,
String category = '',
}) {
return {
'id': id,
'type': 'TASK',
'commitmentClass': cls,
'title': title,
'category': category,
'dueStart': start.toIso8601String(),
'dueEnd': end.toIso8601String(),
'estMinutes': minutes,
'energy': 'Medium',
'proofType': proof,
'proofTimerMinutes': timer,
'rrule': '',
'parentId': goal,
'status': status,
'deferralCount': deferrals,
};
}
commitments.addAll(<Map<String, dynamic>>[
task('c-walk', 'Morning walk, no phone',
goal: 'goal-calm',
cls: 'Elective',
start: at(0, 6, 30),
end: at(0, 8),
minutes: 20,
status: 'Completed'),
task('c-chapter', 'Write 500 words of chapter 3',
goal: 'goal-thesis',
cls: 'NonNegotiable',
start: at(0, 9),
end: at(0, 23, 30),
minutes: 25,
proof: 'Timer',
timer: 1,
category: 'Deep work'),
task('c-rent', 'Pay rent',
cls: 'NonNegotiable',
start: at(0, 8),
end: at(0, 23, 59),
minutes: 5,
category: 'Admin'),
task('c-physio', 'Book physio appointment',
goal: 'goal-strong',
start: at(0, 12),
end: at(0, 23),
minutes: 10,
category: 'Admin'),
task('c-read', 'Read two papers for the literature review',
goal: 'goal-thesis',
start: at(0, 18),
end: at(0, 23, 45),
minutes: 60,
category: 'Deep work'),
task('c-meal', 'Meal prep for the week',
goal: 'goal-strong',
start: at(1, 17),
end: at(1, 20),
minutes: 90),
// Two things already past their window, so the overdue queue and debt
// have something to show.
task('c-email', 'Email supervisor the outline',
goal: 'goal-thesis',
start: at(-2, 9),
end: at(-2, 12),
minutes: 15,
status: 'Overdue',
deferrals: 1),
task('c-kitchen', 'Deep clean the kitchen',
start: at(-1, 18),
end: at(-1, 21),
minutes: 45,
status: 'Overdue'),
]);
// ── Routines ───────────────────────────────────────────────────────────
final RoutineChain strength = RoutineTemplates.fullBody()
..id = 'routine-strength'
..goalId = 'goal-strong'
..days = _withToday(<int>[1, 3, 5], weekday)
..startMinute = 7 * 60;
final RoutineChain push = RoutineTemplates.upperPush()
..id = 'routine-push'
..goalId = 'goal-strong'
..days = <int>[1, 2, 3, 4, 5, 6, 7].where((day) => day != weekday).take(2).toList();
final RoutineChain breathing = RoutineTemplates.boxBreathing()
..id = 'routine-breathing'
..goalId = 'goal-calm';
final RoutineChain morning = RoutineTemplates.morning()
..id = 'routine-morning'
..goalId = 'goal-calm'
..days = _withToday(<int>[1, 2, 3, 4, 5], weekday);
for (int index = 0; index < morning.steps.length; index++) {
morning.steps[index].completed = true;
}
final RoutineChain shutdown = RoutineTemplates.shutdown()
..id = 'routine-shutdown'
..days = _withToday(<int>[1, 2, 3, 4, 5], weekday)
..commitmentClass = CommitmentClass.Standard;
shutdown.steps.first.completed = true;
final RoutineChain mobility = RoutineTemplates.mobility()
..id = 'routine-mobility';
routines.addAll(<RoutineChain>[
breathing,
morning,
strength,
shutdown,
push,
mobility,
]);
}
static List<int> _withToday(List<int> days, int weekday) {
final List<int> result = List<int>.from(days);
if (!result.contains(weekday)) {
result.add(weekday);
}
result.sort();
return result;
}
// ── Derived views ──────────────────────────────────────────────────────
static bool _settled(Map<String, dynamic> item) {
final String status = item['status'];
return status == 'Completed' ||
status == 'LateCompleted' ||
status == 'Abandoned' ||
status == 'Archived';
}
static bool _windowClosed(Map<String, dynamic> item) {
final DateTime? end = DateTime.tryParse(item['dueEnd'] ?? '');
return end != null && end.isBefore(DateTime.now());
}
static List<Map<String, dynamic>> todayPlan() {
final DateTime now = DateTime.now();
return commitments.where((item) {
final DateTime? start = DateTime.tryParse(item['dueStart'] ?? '');
return start != null &&
start.year == now.year &&
start.month == now.month &&
start.day == now.day &&
item['status'] != 'Abandoned';
}).toList()
..sort((a, b) => (a['dueStart'] as String).compareTo(b['dueStart']));
}
static List<Map<String, dynamic>> overdue() {
return commitments
.where((item) => !_settled(item) && _windowClosed(item))
.map((item) => Map<String, dynamic>.from(item)..['status'] = 'Overdue')
.toList();
}
static List<Map<String, dynamic>> goalTasks(String goalId) {
return commitments.where((item) => item['parentId'] == goalId).toList();
}
/// Goals with the server-derived counts filled in from the tasks.
static List<Map<String, dynamic>> goalsWithCounts() {
return goals.where((goal) => goal['archived'] != true).map((goal) {
final List<Map<String, dynamic>> tasks = goalTasks(goal['id']);
final int completed = tasks
.where((item) =>
item['status'] == 'Completed' || item['status'] == 'LateCompleted')
.length;
final int late =
tasks.where((item) => !_settled(item) && _windowClosed(item)).length;
return Map<String, dynamic>.from(goal)
..['totalTasks'] = tasks.where((item) => item['status'] != 'Abandoned').length
..['completedTasks'] = completed
..['overdueTasks'] = late
..['debtContribution'] = late * 2.0;
}).toList();
}
static Map<String, dynamic>? commitment(String id) {
for (Map<String, dynamic> item in commitments) {
if (item['id'] == id) {
return item;
}
}
return null;
}
static Map<String, dynamic> page(List<Map<String, dynamic>> content) {
return {
'number': 0,
'size': content.length,
'totalElements': content.length,
'totalPages': 1,
'numberOfElements': content.length,
'first': true,
'last': true,
'content': content,
};
}
}

View File

@@ -0,0 +1,8 @@
// ─────────────────────────────────────────────────────────────────────────────
// MOCK DATA — TEMPORARY. Delete the whole `lib/Grounded/mock/` folder and the
// lines tagged `MOCK:` (search the project for "MOCK:") once the backend is up.
// ─────────────────────────────────────────────────────────────────────────────
/// True runs the app entirely on seeded, in-memory data: no backend, no
/// device storage, no sign-in. Everything resets on restart.
const bool useMockData = true;

View File

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

View File

@@ -1,9 +1,14 @@
import 'package:flutter/material.dart';
import '../../about/external/data/Goal.dart';
import 'NewCommitmentState.dart';
class NewCommitment extends StatefulWidget {
const NewCommitment({super.key});
/// The goal this task is created inside; it can still be changed in the
/// form. Null starts the task loose.
final Goal? goal;
const NewCommitment({super.key, this.goal});
@override
State<NewCommitment> createState() => NewCommitmentState();

View File

@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:stacked/stacked.dart';
import '../../about/external/data/Commitment.dart';
import '../../about/external/data/Goal.dart';
import '../../about/external/initial/CommitmentRequest.dart';
import '../../about/internal/application/CommitmentClass.dart';
import '../../about/internal/application/EnergyCost.dart';
@@ -10,6 +11,7 @@ import '../../about/internal/application/NotificationType.dart';
import '../../about/internal/application/ProofType.dart';
import '../../about/internal/application/TextType.dart';
import '../../designs/Component.dart';
import '../../designs/GoalPicker.dart';
import '../../designs/Responsive.dart';
import '../../designs/Shell.dart';
import '../../designs/buttons/Buttons.dart';
@@ -49,6 +51,9 @@ class NewCommitmentState extends State<NewCommitment>
String _windowError = "";
/// The goal this task belongs to; null for a loose task.
Goal? _goal;
@override
Widget build(BuildContext context) {
return ViewModelBuilder<ViewNewCommitment>.reactive(
@@ -76,6 +81,11 @@ class NewCommitmentState extends State<NewCommitment>
setState(() {
_windowStart = now;
_windowEnd = now.add(const Duration(hours: 2));
// A task made inside a goal starts in it, at the goal's default weight.
if (widget.goal != null) {
_goal = widget.goal;
_class = widget.goal!.defaultClass;
}
});
}
@@ -85,6 +95,10 @@ class NewCommitmentState extends State<NewCommitment>
Navigator.pop(context, false);
}
void _onPickGoal() {
_model?.loadGoals();
}
void _onCategoryChanged(String value) {
_model?.resolveMultiplier(value.trim());
}
@@ -185,6 +199,7 @@ class NewCommitmentState extends State<NewCommitment>
CommitmentRequest _buildRequest() {
return CommitmentRequest(
parentId: _goal?.id,
commitmentClass: _class.name,
title: _title.text.trim(),
category: _category.text.trim(),
@@ -200,6 +215,7 @@ class NewCommitmentState extends State<NewCommitment>
Commitment _buildCandidate() {
return Commitment(
parentId: _goal?.id,
commitmentClass: _class,
title: _title.text.trim(),
category: _category.text.trim(),
@@ -237,6 +253,14 @@ class NewCommitmentState extends State<NewCommitment>
validator: Validators.title,
),
const SizedBox(height: 20),
selectField(
"Goal",
_goal?.title ?? "",
_onPickGoal,
hint: "No goal — a loose task",
icon: CupertinoIcons.flag,
),
const SizedBox(height: 20),
inputField(
"Category",
_category,
@@ -542,6 +566,20 @@ class NewCommitmentState extends State<NewCommitment>
});
}
@override
void onGoalsReady(List<Goal> goals) async {
final Goal? picked =
await showGoalPicker(context, goals, selectedId: _goal?.id);
if (picked == null || !mounted) {
return;
}
setState(() {
_goal = picked.id == null ? null : picked;
});
}
@override
void dispose() {
_title.dispose();

View File

@@ -1,5 +1,6 @@
import '../../about/external/data/Commitment.dart';
import '../../about/external/data/pages/request/CommitmentsRequest.dart';
import '../../about/external/data/pages/request/HistoryRequest.dart';
import '../../about/external/data/pages/request/PageAndSort.dart';
import '../../about/external/data/pages/request/Pageable.dart';
import '../../about/external/data/pages/request/Sort.dart';
@@ -7,6 +8,7 @@ import '../../about/external/data/pages/response/CommitmentPage.dart';
import '../../about/external/initial/CommitmentRequest.dart';
import '../../about/internal/application/CapacityProfile.dart';
import '../../utils/CapacityEngine.dart';
import '../../utils/ObjectConvertors.dart';
import '../parent/ParentViewModel.dart';
import 'ConnectNewCommitment.dart';
@@ -24,6 +26,28 @@ class ViewNewCommitment extends ParentViewModel {
connection.onMultiplierResolved(profile.multiplierFor(category));
}
/// Goals for the picker — loaded on tap, never up front.
void loadGoals() async {
if (!await hasNetwork(() => loadGoals())) return;
showLoading("Loading goals");
try {
final response = await getDataManager().getMyGoals(HistoryRequest(
query: PageAndSort(
sort: Sort('desc', 'startDate'),
page: Pageable(0, 0, 50, 0),
),
));
closeLoading();
connection.onGoalsReady(getGoalList(response.data));
} catch (e) {
handleError(e, () => loadGoals(), () => dismissError(), "Retry");
}
}
/// The capacity gate. Chronic overdue is usually overcommitment misdiagnosed
/// as laziness, so the plan is checked against what history says actually
/// gets done before anything is accepted.

View File

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

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 {
void onGoalsLoaded(List<Goal> goals);
void onGoalSaved();
}

Some files were not shown because too many files have changed in this diff Show More