Compare commits
6 Commits
main
...
4548e1f164
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4548e1f164 | ||
|
|
9c858b8146 | ||
|
|
36bc41509d | ||
|
|
fe268cb8aa | ||
|
|
b31bc47cc0 | ||
|
|
1a5867a98f |
@@ -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, square night buttons with a lime spark, no rounded corners.
|
||||
|
||||
- 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`.
|
||||
|
||||
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 69 B After Width: | Height: | Size: 69 B |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 69 B After Width: | Height: | Size: 69 B |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 7.0 KiB |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 31 KiB |
@@ -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 |
|
Before Width: | Height: | Size: 35 KiB |
BIN
frontend/assets/icons/splash_mark.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
@@ -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 square 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 square 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; the selected tab is a filled night block. The side action button is optional. |
|
||||
| `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 square night bars 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
|
||||
|
||||
@@ -20,7 +45,7 @@ directly above a large dark value. Used everywhere numbers appear.
|
||||
|
||||
## Geometry
|
||||
|
||||
- Sheet radius `28`, card radius `16`, chip radius `999`, control radius `12`.
|
||||
- No rounded corners anywhere: sheets, cards, chips, buttons, fields, tiles and portraits are all square (`BorderRadius.zero`). Only small status dots, glows, the sun, progress rings and the breathing circle stay round.
|
||||
- Screen padding `20` horizontal. Card padding `16`. Section gap `28`.
|
||||
- Hairlines at `colorDivider` (black 7%), borders at `colorBorder` (black 8%).
|
||||
- One soft shadow only: `0 4 18 rgba(0,0,0,0.03)`. No coloured glows.
|
||||
@@ -44,7 +69,7 @@ the standing is legible without reading the word.
|
||||
## Structure
|
||||
|
||||
Every screen is: black `headerBar` (back, title, action) → white sheet with a
|
||||
`28` top radius → `sectionHeader` (label + light display title) → content.
|
||||
square top edge → `sectionHeader` (label + light display title) → content.
|
||||
|
||||
## Rules
|
||||
|
||||
@@ -53,3 +78,15 @@ Every screen is: black `headerBar` (back, title, action) → white sheet with a
|
||||
- Chips carry state, never actions.
|
||||
- One primary button per screen; everything else is outlined or text.
|
||||
- Empty states are plain. An empty overdue queue is good news, not a party.
|
||||
|
||||
## Home
|
||||
|
||||
Home opens on a deep forest header block (`colorHeaderTop` → `colorHeaderBottom`):
|
||||
greeting and avatar, a Today / Weekly toggle, a seven-day streak panel ending
|
||||
today (a leaf on every kept day, kept = 80%+), then the numbers. Today shows
|
||||
minutes held against planned, a ring with + that opens the "Add to today"
|
||||
sheet, the plan as thumbnails, the report-card letter as the Score, and one
|
||||
bar per commitment class. Weekly swaps in the week's average, a bar per day
|
||||
and the class completion rates. Below the block, on the light ambient
|
||||
surface: the water tracker (drops of 250 ml, kept on the device), then the
|
||||
rest of the day. Bars on the dark block use the `colorBar*` tokens.
|
||||
|
||||
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 473 B After Width: | Height: | Size: 492 B |
|
Before Width: | Height: | Size: 934 B After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 683 B After Width: | Height: | Size: 831 B |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 934 B After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 69 B After Width: | Height: | Size: 69 B |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 44 KiB |
@@ -1,3 +1,9 @@
|
||||
import '../../internal/application/CommitmentClass.dart';
|
||||
import '../../internal/application/RoutineKind.dart';
|
||||
import '../../internal/application/StepMode.dart';
|
||||
import '../../../utils/ObjectConvertors.dart';
|
||||
import 'RoutineStep.dart';
|
||||
|
||||
/// An ordered sequence where the chain, not the step, is the unit of
|
||||
/// completion. Breaking mid-way logs partial.
|
||||
class RoutineChain {
|
||||
@@ -5,6 +11,24 @@ class RoutineChain {
|
||||
|
||||
String title;
|
||||
|
||||
/// The goal this routine serves — "Workout" holds "Full body strength".
|
||||
/// Null for a routine that stands on its own.
|
||||
String? goalId;
|
||||
|
||||
RoutineKind kind;
|
||||
|
||||
/// Weight class, exactly as for a commitment — a non-negotiable morning
|
||||
/// routine carries non-negotiable debt when it breaks.
|
||||
CommitmentClass commitmentClass;
|
||||
|
||||
/// 1..7, Monday first. Empty means unscheduled — run on demand only.
|
||||
List<int> days;
|
||||
|
||||
/// Minutes after midnight the routine is due to start; -1 when unset.
|
||||
int startMinute;
|
||||
|
||||
String note;
|
||||
|
||||
List<RoutineStep> steps;
|
||||
|
||||
/// Index of the step reached when the chain last broke; -1 when clean.
|
||||
@@ -13,19 +37,30 @@ class RoutineChain {
|
||||
RoutineChain({
|
||||
this.id,
|
||||
this.title = "",
|
||||
this.goalId,
|
||||
this.kind = RoutineKind.Workout,
|
||||
this.commitmentClass = CommitmentClass.Standard,
|
||||
List<int>? days,
|
||||
this.startMinute = -1,
|
||||
this.note = "",
|
||||
List<RoutineStep>? steps,
|
||||
this.lastBreakIndex = -1,
|
||||
}) : steps = steps ?? <RoutineStep>[];
|
||||
}) : days = days ?? <int>[],
|
||||
steps = steps ?? <RoutineStep>[];
|
||||
|
||||
factory RoutineChain.fromJson(Map<String, dynamic> json) {
|
||||
return RoutineChain(
|
||||
id: json['id'],
|
||||
title: json['title'] ?? "",
|
||||
steps: json['steps'] == null
|
||||
? <RoutineStep>[]
|
||||
: (json['steps'] as List)
|
||||
.map((item) => RoutineStep.fromJson(item))
|
||||
.toList(),
|
||||
goalId: json['goalId'],
|
||||
kind: getRoutineKind(json['kind']),
|
||||
commitmentClass: getCommitmentClass(json['commitmentClass']),
|
||||
days: json['days'] == null
|
||||
? <int>[]
|
||||
: (json['days'] as List).map((item) => item as int).toList(),
|
||||
startMinute: json['startMinute'] ?? -1,
|
||||
note: json['note'] ?? "",
|
||||
steps: getRoutineStepList(json['steps']),
|
||||
lastBreakIndex: json['lastBreakIndex'] ?? -1,
|
||||
);
|
||||
}
|
||||
@@ -34,6 +69,12 @@ class RoutineChain {
|
||||
final Map<String, dynamic> data = <String, dynamic>{};
|
||||
data['id'] = id;
|
||||
data['title'] = title;
|
||||
data['goalId'] = goalId;
|
||||
data['kind'] = kind.name;
|
||||
data['commitmentClass'] = commitmentClass.name;
|
||||
data['days'] = days;
|
||||
data['startMinute'] = startMinute;
|
||||
data['note'] = note;
|
||||
data['steps'] = steps.map((item) => item.toJson()).toList();
|
||||
data['lastBreakIndex'] = lastBreakIndex;
|
||||
return data;
|
||||
@@ -54,44 +95,40 @@ class RoutineChain {
|
||||
bool get complete {
|
||||
return steps.isNotEmpty && completedSteps == steps.length;
|
||||
}
|
||||
|
||||
bool get scheduled {
|
||||
return days.isNotEmpty;
|
||||
}
|
||||
|
||||
class RoutineStep {
|
||||
String? id;
|
||||
|
||||
String title;
|
||||
|
||||
int order;
|
||||
|
||||
int timerSeconds;
|
||||
|
||||
bool completed;
|
||||
|
||||
RoutineStep({
|
||||
this.id,
|
||||
this.title = "",
|
||||
this.order = 0,
|
||||
this.timerSeconds = 0,
|
||||
this.completed = false,
|
||||
});
|
||||
|
||||
factory RoutineStep.fromJson(Map<String, dynamic> json) {
|
||||
return RoutineStep(
|
||||
id: json['id'],
|
||||
title: json['title'] ?? "",
|
||||
order: json['order'] ?? 0,
|
||||
timerSeconds: json['timerSeconds'] ?? 0,
|
||||
completed: json['completed'] ?? false,
|
||||
);
|
||||
bool scheduledOn(DateTime day) {
|
||||
return days.contains(day.weekday);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final Map<String, dynamic> data = <String, dynamic>{};
|
||||
data['id'] = id;
|
||||
data['title'] = title;
|
||||
data['order'] = order;
|
||||
data['timerSeconds'] = timerSeconds;
|
||||
data['completed'] = completed;
|
||||
return data;
|
||||
/// Every set of every step, for the "12 sets" figure.
|
||||
int get totalSets {
|
||||
int total = 0;
|
||||
for (RoutineStep step in steps) {
|
||||
total = total + step.sets;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/// Planned seconds, rest included. Rep and tick steps count their estimate.
|
||||
int get totalSeconds {
|
||||
int total = 0;
|
||||
for (RoutineStep step in steps) {
|
||||
total = total + step.plannedSeconds;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
int get timedSteps {
|
||||
return steps.where((step) => step.mode == StepMode.Timed).length;
|
||||
}
|
||||
|
||||
/// A deep copy for the builder, so cancelling an edit leaves the library
|
||||
/// untouched.
|
||||
RoutineChain copy() {
|
||||
return RoutineChain.fromJson(toJson());
|
||||
}
|
||||
}
|
||||
|
||||
75
frontend/lib/Grounded/about/external/data/RoutineStep.dart
vendored
Normal file
@@ -0,0 +1,75 @@
|
||||
import '../../internal/application/StepMode.dart';
|
||||
|
||||
class RoutineStep {
|
||||
String? id;
|
||||
|
||||
String title;
|
||||
|
||||
int order;
|
||||
|
||||
StepMode mode;
|
||||
|
||||
/// Work seconds per set for timed steps; the estimate per set otherwise.
|
||||
int timerSeconds;
|
||||
|
||||
int sets;
|
||||
|
||||
/// Target per set for rep steps; 0 otherwise.
|
||||
int reps;
|
||||
|
||||
/// Rest between sets, and after the last set before the next step.
|
||||
int restSeconds;
|
||||
|
||||
/// One line shown under the clock — a form cue, a breathing pattern.
|
||||
String cue;
|
||||
|
||||
bool completed;
|
||||
|
||||
RoutineStep({
|
||||
this.id,
|
||||
this.title = "",
|
||||
this.order = 0,
|
||||
this.mode = StepMode.Timed,
|
||||
this.timerSeconds = 0,
|
||||
this.sets = 1,
|
||||
this.reps = 0,
|
||||
this.restSeconds = 0,
|
||||
this.cue = "",
|
||||
this.completed = false,
|
||||
});
|
||||
|
||||
factory RoutineStep.fromJson(Map<String, dynamic> json) {
|
||||
return RoutineStep(
|
||||
id: json['id'],
|
||||
title: json['title'] ?? "",
|
||||
order: json['order'] ?? 0,
|
||||
mode: getStepMode(json['mode']),
|
||||
timerSeconds: json['timerSeconds'] ?? 0,
|
||||
sets: json['sets'] ?? 1,
|
||||
reps: json['reps'] ?? 0,
|
||||
restSeconds: json['restSeconds'] ?? 0,
|
||||
cue: json['cue'] ?? "",
|
||||
completed: json['completed'] ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final Map<String, dynamic> data = <String, dynamic>{};
|
||||
data['id'] = id;
|
||||
data['title'] = title;
|
||||
data['order'] = order;
|
||||
data['mode'] = mode.name;
|
||||
data['timerSeconds'] = timerSeconds;
|
||||
data['sets'] = sets;
|
||||
data['reps'] = reps;
|
||||
data['restSeconds'] = restSeconds;
|
||||
data['cue'] = cue;
|
||||
data['completed'] = completed;
|
||||
return data;
|
||||
}
|
||||
|
||||
int get plannedSeconds {
|
||||
final int safeSets = sets < 1 ? 1 : sets;
|
||||
return timerSeconds * safeSets + restSeconds * safeSets;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,9 @@
|
||||
class CommitmentRequest {
|
||||
String? id;
|
||||
|
||||
/// The goal this task belongs to; null for a loose task.
|
||||
String? parentId;
|
||||
|
||||
String type;
|
||||
|
||||
String commitmentClass;
|
||||
@@ -26,6 +29,7 @@ class CommitmentRequest {
|
||||
|
||||
CommitmentRequest({
|
||||
this.id,
|
||||
this.parentId,
|
||||
this.type = "TASK",
|
||||
this.commitmentClass = "Standard",
|
||||
this.title = "",
|
||||
@@ -42,6 +46,7 @@ class CommitmentRequest {
|
||||
Map<String, dynamic> toJson() {
|
||||
final Map<String, dynamic> data = <String, dynamic>{};
|
||||
data['id'] = id;
|
||||
data['parentId'] = parentId;
|
||||
data['type'] = type;
|
||||
data['commitmentClass'] = commitmentClass;
|
||||
data['title'] = title;
|
||||
|
||||
45
frontend/lib/Grounded/about/external/initial/RoutineLogRequest.dart
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
/// One run of a routine. Sent whether the chain held or broke — a broken
|
||||
/// chain is logged as partial, never silently dropped.
|
||||
class RoutineLogRequest {
|
||||
String routineId;
|
||||
|
||||
String startedAt;
|
||||
|
||||
/// Foreground seconds only. Backgrounding pauses the run.
|
||||
int elapsedSeconds;
|
||||
|
||||
int completedSteps;
|
||||
|
||||
int totalSteps;
|
||||
|
||||
/// Step index the chain broke at; -1 when it held.
|
||||
int breakIndex;
|
||||
|
||||
int backgroundedCount;
|
||||
|
||||
int skippedCount;
|
||||
|
||||
RoutineLogRequest({
|
||||
this.routineId = "",
|
||||
this.startedAt = "",
|
||||
this.elapsedSeconds = 0,
|
||||
this.completedSteps = 0,
|
||||
this.totalSteps = 0,
|
||||
this.breakIndex = -1,
|
||||
this.backgroundedCount = 0,
|
||||
this.skippedCount = 0,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final Map<String, dynamic> data = <String, dynamic>{};
|
||||
data['routineId'] = routineId;
|
||||
data['startedAt'] = startedAt;
|
||||
data['elapsedSeconds'] = elapsedSeconds;
|
||||
data['completedSteps'] = completedSteps;
|
||||
data['totalSteps'] = totalSteps;
|
||||
data['breakIndex'] = breakIndex;
|
||||
data['backgroundedCount'] = backgroundedCount;
|
||||
data['skippedCount'] = skippedCount;
|
||||
return data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/// What a routine is for. The kind decides the runner's stage — a barbell
|
||||
/// clock for work, a breathing circle for stillness, a checklist for the
|
||||
/// things that simply have to get done.
|
||||
enum RoutineKind {
|
||||
/// Sets, reps and rest. Timed work auto-advances into rest.
|
||||
Workout,
|
||||
|
||||
/// Long timed holds with a breathing cue. No reps, rarely any rest.
|
||||
Meditation,
|
||||
|
||||
/// An ordered list of tasks that have to be done, in order, every time.
|
||||
MustDo,
|
||||
}
|
||||
|
||||
String kindLabel(RoutineKind value) {
|
||||
switch (value) {
|
||||
case RoutineKind.Workout:
|
||||
return "Workout";
|
||||
case RoutineKind.Meditation:
|
||||
return "Meditation";
|
||||
case RoutineKind.MustDo:
|
||||
return "Must-do";
|
||||
}
|
||||
}
|
||||
|
||||
String kindDescription(RoutineKind value) {
|
||||
switch (value) {
|
||||
case RoutineKind.Workout:
|
||||
return "Sets, reps and timed rest.";
|
||||
case RoutineKind.Meditation:
|
||||
return "Timed sits with a breathing cue.";
|
||||
case RoutineKind.MustDo:
|
||||
return "Tasks, in order, every time.";
|
||||
}
|
||||
}
|
||||
|
||||
RoutineKind getRoutineKind(String? name) {
|
||||
for (RoutineKind value in RoutineKind.values) {
|
||||
if (value.name == name) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return RoutineKind.Workout;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import '../../external/data/RoutineStep.dart';
|
||||
import 'StepMode.dart';
|
||||
|
||||
/// One beat of a running routine — a single work set or the rest after it.
|
||||
/// The runner walks a flat list of these, so "set 2 of 4" and "rest" are the
|
||||
/// same kind of thing to the clock.
|
||||
class RoutinePhase {
|
||||
/// Index of the step this phase belongs to.
|
||||
final int stepIndex;
|
||||
|
||||
final RoutineStep step;
|
||||
|
||||
/// 1-based set number within the step.
|
||||
final int set;
|
||||
|
||||
final bool rest;
|
||||
|
||||
RoutinePhase({
|
||||
required this.stepIndex,
|
||||
required this.step,
|
||||
this.set = 1,
|
||||
this.rest = false,
|
||||
});
|
||||
|
||||
/// Seconds the clock runs for. Zero means the phase waits for a tap.
|
||||
int get seconds {
|
||||
if (rest) {
|
||||
return step.restSeconds;
|
||||
}
|
||||
return step.mode == StepMode.Timed ? step.timerSeconds : 0;
|
||||
}
|
||||
|
||||
bool get countsDown {
|
||||
return seconds > 0;
|
||||
}
|
||||
|
||||
bool get lastSet {
|
||||
return set >= step.sets;
|
||||
}
|
||||
}
|
||||
212
frontend/lib/Grounded/about/internal/application/RoutineRun.dart
Normal file
@@ -0,0 +1,212 @@
|
||||
import '../../external/data/RoutineChain.dart';
|
||||
import '../../external/data/RoutineStep.dart';
|
||||
import 'RoutinePhase.dart';
|
||||
|
||||
/// A routine being run. Like [LiveSession], every clock is derived from
|
||||
/// wall-clock rather than counted by a ticker, so a screen-off period cannot
|
||||
/// lose or invent time.
|
||||
class RoutineRun {
|
||||
final RoutineChain chain;
|
||||
|
||||
final List<RoutinePhase> phases;
|
||||
|
||||
int phaseIndex = 0;
|
||||
|
||||
final DateTime startedAt;
|
||||
|
||||
/// Banked foreground seconds for the whole run.
|
||||
int _runSeconds = 0;
|
||||
|
||||
/// Banked foreground seconds for the current phase.
|
||||
int _phaseSeconds = 0;
|
||||
|
||||
/// Start of the active stretch; null while paused.
|
||||
DateTime? resumedAt;
|
||||
|
||||
/// Times the app was left mid-run. Shown afterwards, never hidden.
|
||||
int backgroundedCount = 0;
|
||||
|
||||
/// Phases skipped forward without being done.
|
||||
int skippedCount = 0;
|
||||
|
||||
RoutineRun(this.chain)
|
||||
: phases = buildPhases(chain),
|
||||
startedAt = DateTime.now();
|
||||
|
||||
/// Each set becomes a work phase; rest follows every set that has rest,
|
||||
/// except after the final set of the final step.
|
||||
static List<RoutinePhase> buildPhases(RoutineChain chain) {
|
||||
final List<RoutinePhase> result = <RoutinePhase>[];
|
||||
|
||||
for (int index = 0; index < chain.steps.length; index++) {
|
||||
final RoutineStep step = chain.steps[index];
|
||||
final int sets = step.sets < 1 ? 1 : step.sets;
|
||||
|
||||
for (int set = 1; set <= sets; set++) {
|
||||
result.add(RoutinePhase(stepIndex: index, step: step, set: set));
|
||||
|
||||
final bool finalBeat = index == chain.steps.length - 1 && set == sets;
|
||||
if (step.restSeconds > 0 && !finalBeat) {
|
||||
result.add(
|
||||
RoutinePhase(stepIndex: index, step: step, set: set, rest: true));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
RoutinePhase get phase {
|
||||
return phases[phaseIndex];
|
||||
}
|
||||
|
||||
bool get running {
|
||||
return resumedAt != null;
|
||||
}
|
||||
|
||||
bool get onLastPhase {
|
||||
return phaseIndex >= phases.length - 1;
|
||||
}
|
||||
|
||||
int _live(DateTime? now) {
|
||||
if (resumedAt == null) {
|
||||
return 0;
|
||||
}
|
||||
return (now ?? DateTime.now()).difference(resumedAt!).inSeconds;
|
||||
}
|
||||
|
||||
int elapsedSeconds({DateTime? now}) {
|
||||
return _runSeconds + _live(now);
|
||||
}
|
||||
|
||||
int phaseElapsed({DateTime? now}) {
|
||||
return _phaseSeconds + _live(now);
|
||||
}
|
||||
|
||||
int phaseRemaining({DateTime? now}) {
|
||||
final int remaining = phase.seconds - phaseElapsed(now: now);
|
||||
return remaining > 0 ? remaining : 0;
|
||||
}
|
||||
|
||||
/// 0..1 through the current phase; 0 for phases that wait for a tap.
|
||||
double phaseProgress({DateTime? now}) {
|
||||
if (!phase.countsDown) {
|
||||
return 0;
|
||||
}
|
||||
final double value = phaseElapsed(now: now) / phase.seconds;
|
||||
return value > 1 ? 1 : value;
|
||||
}
|
||||
|
||||
bool phaseFinished({DateTime? now}) {
|
||||
return phase.countsDown && phaseElapsed(now: now) >= phase.seconds;
|
||||
}
|
||||
|
||||
/// Seconds left in the whole run, by plan — elapsed work in this phase
|
||||
/// counts, phases still ahead count at their planned length.
|
||||
int plannedRemaining({DateTime? now}) {
|
||||
int total = phase.countsDown ? phaseRemaining(now: now) : 0;
|
||||
for (int index = phaseIndex + 1; index < phases.length; index++) {
|
||||
total = total + phases[index].seconds;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
void pause({DateTime? now}) {
|
||||
if (resumedAt == null) {
|
||||
return;
|
||||
}
|
||||
final int live = _live(now);
|
||||
_runSeconds = _runSeconds + live;
|
||||
_phaseSeconds = _phaseSeconds + live;
|
||||
resumedAt = null;
|
||||
}
|
||||
|
||||
void resume({DateTime? now}) {
|
||||
if (resumedAt != null) {
|
||||
return;
|
||||
}
|
||||
resumedAt = now ?? DateTime.now();
|
||||
}
|
||||
|
||||
/// Bank the current phase and start the next. Returns false at the end.
|
||||
/// A step is marked complete when its last set's work phase is done.
|
||||
bool advance({bool skipped = false, DateTime? now}) {
|
||||
final DateTime moment = now ?? DateTime.now();
|
||||
final bool wasRunning = running;
|
||||
|
||||
if (!phase.rest && phase.lastSet && !skipped) {
|
||||
phase.step.completed = true;
|
||||
}
|
||||
if (skipped && !phase.rest) {
|
||||
skippedCount = skippedCount + 1;
|
||||
}
|
||||
|
||||
pause(now: moment);
|
||||
_phaseSeconds = 0;
|
||||
|
||||
if (onLastPhase) {
|
||||
return false;
|
||||
}
|
||||
|
||||
phaseIndex = phaseIndex + 1;
|
||||
if (wasRunning) {
|
||||
resume(now: moment);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Back to the start of the previous work phase. Rest is never a target.
|
||||
void back({DateTime? now}) {
|
||||
final DateTime moment = now ?? DateTime.now();
|
||||
final bool wasRunning = running;
|
||||
|
||||
pause(now: moment);
|
||||
_phaseSeconds = 0;
|
||||
|
||||
int target = phaseIndex - 1;
|
||||
while (target > 0 && phases[target].rest) {
|
||||
target = target - 1;
|
||||
}
|
||||
phaseIndex = target < 0 ? 0 : target;
|
||||
phases[phaseIndex].step.completed = false;
|
||||
|
||||
if (wasRunning) {
|
||||
resume(now: moment);
|
||||
}
|
||||
}
|
||||
|
||||
/// Jump to the first set of [stepIndex].
|
||||
void jumpTo(int stepIndex, {DateTime? now}) {
|
||||
final int target =
|
||||
phases.indexWhere((item) => item.stepIndex == stepIndex && !item.rest);
|
||||
if (target < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
final DateTime moment = now ?? DateTime.now();
|
||||
final bool wasRunning = running;
|
||||
|
||||
pause(now: moment);
|
||||
_phaseSeconds = 0;
|
||||
phaseIndex = target;
|
||||
|
||||
if (wasRunning) {
|
||||
resume(now: moment);
|
||||
}
|
||||
}
|
||||
|
||||
/// The step index the chain broke at, for a partial log; -1 when clean.
|
||||
int get breakIndex {
|
||||
return chain.complete ? -1 : phase.stepIndex;
|
||||
}
|
||||
|
||||
/// The next work phase after this one, for the "up next" line.
|
||||
RoutinePhase? get nextWork {
|
||||
for (int index = phaseIndex + 1; index < phases.length; index++) {
|
||||
if (!phases[index].rest) {
|
||||
return phases[index];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/// How a single routine step is satisfied.
|
||||
enum StepMode {
|
||||
/// A clock runs down and the step advances on its own.
|
||||
Timed,
|
||||
|
||||
/// A rep target per set; the set ends when you say it does.
|
||||
Reps,
|
||||
|
||||
/// A task. Ticked, not timed — the clock still records how long it took.
|
||||
Check,
|
||||
}
|
||||
|
||||
String stepModeLabel(StepMode value) {
|
||||
switch (value) {
|
||||
case StepMode.Timed:
|
||||
return "Timed";
|
||||
case StepMode.Reps:
|
||||
return "Reps";
|
||||
case StepMode.Check:
|
||||
return "Tick";
|
||||
}
|
||||
}
|
||||
|
||||
StepMode getStepMode(String? name) {
|
||||
for (StepMode value in StepMode.values) {
|
||||
if (value.name == name) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return StepMode.Timed;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/// A quick to-do: no window, no class, no debt. Kept on the device so jotting
|
||||
/// something down never waits on the network.
|
||||
class TodoItem {
|
||||
String id;
|
||||
|
||||
String title;
|
||||
|
||||
bool done;
|
||||
|
||||
TodoItem({required this.id, required this.title, this.done = false});
|
||||
|
||||
factory TodoItem.fromJson(Map<String, dynamic> json) {
|
||||
return TodoItem(
|
||||
id: json['id'] ?? '',
|
||||
title: json['title'] ?? '',
|
||||
done: json['done'] ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final Map<String, dynamic> data = <String, dynamic>{};
|
||||
data['id'] = id;
|
||||
data['title'] = title;
|
||||
data['done'] = done;
|
||||
return data;
|
||||
}
|
||||
}
|
||||
@@ -388,6 +388,13 @@ class Comms implements ConnectComms {
|
||||
return await dio.post(navigation.key, data: request);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Response> saveRoutineEntry(Map<String, dynamic> request) async {
|
||||
Pair navigation = await getRequestHeaders(saveRoutine, "");
|
||||
dio.options.headers = navigation.value;
|
||||
return await dio.post(navigation.key, data: request);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Response> logRoutineChainEntry(Map<String, dynamic> request) async {
|
||||
Pair navigation = await getRequestHeaders(logRoutineChain, "");
|
||||
|
||||
@@ -108,6 +108,8 @@ abstract class ConnectComms {
|
||||
|
||||
Future<Response> getMyRoutines(HistoryRequest request);
|
||||
|
||||
Future<Response> saveRoutineEntry(Map<String, dynamic> request);
|
||||
|
||||
Future<Response> logRoutineChainEntry(Map<String, dynamic> request);
|
||||
|
||||
// ── Training ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
61
frontend/lib/Grounded/designs/AmbientBackground.dart
Normal 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)],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,7 @@ class TickBox extends StatelessWidget {
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
color: checked ? tone : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
borderRadius: BorderRadius.zero,
|
||||
border: Border.all(
|
||||
color: checked ? tone : colorGrey.withValues(alpha: 0.55),
|
||||
width: 1.6,
|
||||
@@ -202,7 +202,7 @@ Widget addTaskAffordance(
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: tone.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderRadius: BorderRadius.zero,
|
||||
border: Border.all(color: tone.withValues(alpha: 0.25), width: 1),
|
||||
),
|
||||
child: Icon(Icons.add_rounded, size: 22, color: tone),
|
||||
|
||||
19
frontend/lib/Grounded/designs/ChromeTone.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ Widget pill(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: background,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
borderRadius: BorderRadius.zero,
|
||||
border: Border.all(color: foreground.withValues(alpha: 0.20), width: 1),
|
||||
),
|
||||
child: Row(
|
||||
@@ -57,11 +57,11 @@ Widget pill(
|
||||
/// The standard card surface.
|
||||
Widget card({
|
||||
required Widget child,
|
||||
EdgeInsets padding = const EdgeInsets.all(16),
|
||||
EdgeInsets padding = const EdgeInsets.all(20),
|
||||
EdgeInsets margin = EdgeInsets.zero,
|
||||
Color? background,
|
||||
Color? borderColor,
|
||||
double radius = 16,
|
||||
double radius = 22,
|
||||
VoidCallback? onTap,
|
||||
}) {
|
||||
final Widget body = Container(
|
||||
@@ -70,13 +70,13 @@ Widget card({
|
||||
margin: margin,
|
||||
decoration: BoxDecoration(
|
||||
color: background ?? colorCard,
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
border: Border.all(color: borderColor ?? colorBorder, width: 1),
|
||||
borderRadius: BorderRadius.zero,
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -159,7 +159,6 @@ Widget emptyState(
|
||||
height: 76,
|
||||
decoration: BoxDecoration(
|
||||
color: tone.withValues(alpha: 0.08),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(icon, size: 32, color: tone),
|
||||
),
|
||||
@@ -191,7 +190,7 @@ Widget meter(
|
||||
: fraction;
|
||||
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
borderRadius: BorderRadius.zero,
|
||||
child: LinearProgressIndicator(
|
||||
value: clamped,
|
||||
minHeight: height,
|
||||
|
||||
175
frontend/lib/Grounded/designs/DotDigits.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
127
frontend/lib/Grounded/designs/FloatingNav.dart
Normal file
@@ -0,0 +1,127 @@
|
||||
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 tab bar: the selected tab is a filled night block, the rest
|
||||
/// sit quietly on white. An optional dark action button can sit 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,
|
||||
this.actionIcon,
|
||||
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.zero,
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x1A1A1F1C),
|
||||
blurRadius: 30,
|
||||
offset: Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: GlassSurface(
|
||||
radius: BorderRadius.zero,
|
||||
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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (onAction != null) ...[
|
||||
const SizedBox(width: 10),
|
||||
GestureDetector(
|
||||
onTap: onAction,
|
||||
child: Container(
|
||||
width: 72,
|
||||
height: 72,
|
||||
decoration: BoxDecoration(
|
||||
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: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: active ? colorHeaderBottom : colorWhite.withValues(alpha: 0.0),
|
||||
borderRadius: BorderRadius.zero,
|
||||
boxShadow: active
|
||||
? const [
|
||||
BoxShadow(
|
||||
color: Color(0x141A1F1C),
|
||||
blurRadius: 12,
|
||||
offset: Offset(0, 4),
|
||||
),
|
||||
]
|
||||
: const [],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(entry.icon,
|
||||
size: 23, color: active ? colorWhite : colorInkSoft),
|
||||
const SizedBox(height: 4),
|
||||
text(entry.label, 10, active ? TextType.Medium : TextType.Regular,
|
||||
color: active ? colorWhite : colorInkSoft, maxLines: 1),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
104
frontend/lib/Grounded/designs/GlassSurface.dart
Normal 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.zero),
|
||||
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 = 50,
|
||||
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.zero,
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
127
frontend/lib/Grounded/designs/GoalPicker.dart
Normal file
@@ -0,0 +1,127 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../about/external/data/Goal.dart';
|
||||
import '../about/internal/application/TextType.dart';
|
||||
import '../utils/Colors.dart';
|
||||
import '../utils/CommonUtils.dart';
|
||||
import 'text/Text.dart';
|
||||
|
||||
/// The goal chooser, shared by the task form and the routine builder. Opened
|
||||
/// only once the goals have been loaded — `selectField` loads on tap.
|
||||
///
|
||||
/// Resolves to the chosen goal, to a bare `Goal()` (no id) for "no goal", or
|
||||
/// to null when dismissed without a choice.
|
||||
Future<Goal?> showGoalPicker(
|
||||
BuildContext context,
|
||||
List<Goal> goals, {
|
||||
String? selectedId,
|
||||
}) {
|
||||
return showModalBottomSheet<Goal>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
barrierColor: colorBlack.withValues(alpha: 0.55),
|
||||
builder: (BuildContext sheetContext) {
|
||||
final List<Goal> active = goals.where((goal) => !goal.archived).toList();
|
||||
|
||||
return Container(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(sheetContext).size.height * 0.75,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
color: colorSheetBackground,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.zero,
|
||||
topRight: Radius.zero,
|
||||
),
|
||||
),
|
||||
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.zero,
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
117
frontend/lib/Grounded/designs/GroundedMark.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
116
frontend/lib/Grounded/designs/ProgressRing.dart
Normal file
@@ -0,0 +1,116 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../about/internal/application/TextType.dart';
|
||||
import '../utils/Colors.dart';
|
||||
import 'text/Text.dart';
|
||||
|
||||
/// A thin circular meter with its value in the middle — the routine row's
|
||||
/// "how far did I get" dial, and the runner's phase clock ring.
|
||||
class ProgressRing extends StatelessWidget {
|
||||
final double fraction;
|
||||
|
||||
final double size;
|
||||
|
||||
final double stroke;
|
||||
|
||||
final Color? fill;
|
||||
|
||||
final Color? track;
|
||||
|
||||
/// Drawn in the centre. Defaults to the percentage.
|
||||
final Widget? child;
|
||||
|
||||
const ProgressRing({
|
||||
super.key,
|
||||
required this.fraction,
|
||||
this.size = 44,
|
||||
this.stroke = 3,
|
||||
this.fill,
|
||||
this.track,
|
||||
this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final double clamped = fraction.isNaN ? 0 : fraction.clamp(0, 1).toDouble();
|
||||
|
||||
return SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: TweenAnimationBuilder<double>(
|
||||
tween: Tween<double>(end: clamped),
|
||||
duration: const Duration(milliseconds: 450),
|
||||
curve: Curves.easeOutCubic,
|
||||
builder: (context, value, _) {
|
||||
return CustomPaint(
|
||||
painter: _RingPainter(
|
||||
value,
|
||||
stroke,
|
||||
fill ?? colorPrimaryDark,
|
||||
track ?? colorMuted,
|
||||
),
|
||||
child: Center(
|
||||
child: child ??
|
||||
text("${(clamped * 100).round()}%", size * 0.22,
|
||||
TextType.Bold,
|
||||
color: colorPrimaryDark),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RingPainter extends CustomPainter {
|
||||
final double fraction;
|
||||
|
||||
final double stroke;
|
||||
|
||||
final Color fill;
|
||||
|
||||
final Color track;
|
||||
|
||||
_RingPainter(this.fraction, this.stroke, this.fill, this.track);
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final Offset centre = Offset(size.width / 2, size.height / 2);
|
||||
final double radius = (math.min(size.width, size.height) - stroke) / 2;
|
||||
|
||||
final Paint base = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = stroke
|
||||
..color = track;
|
||||
|
||||
canvas.drawCircle(centre, radius, base);
|
||||
|
||||
if (fraction <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
final Paint arc = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = stroke
|
||||
..strokeCap = StrokeCap.round
|
||||
..color = fill;
|
||||
|
||||
canvas.drawArc(
|
||||
Rect.fromCircle(center: centre, radius: radius),
|
||||
-math.pi / 2,
|
||||
2 * math.pi * fraction,
|
||||
false,
|
||||
arc,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_RingPainter old) {
|
||||
return old.fraction != fraction ||
|
||||
old.fill != fill ||
|
||||
old.track != track ||
|
||||
old.stroke != stroke;
|
||||
}
|
||||
}
|
||||
@@ -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.zero,
|
||||
topRight: Radius.zero,
|
||||
),
|
||||
),
|
||||
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),
|
||||
);
|
||||
}
|
||||
|
||||
// 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(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: <Color>[
|
||||
Color.lerp(stage, colorWhite, 0.14)!,
|
||||
stage,
|
||||
Color.lerp(stage, colorBlack, 0.35)!,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _chromeButton(IconData icon, VoidCallback onTap) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
width: 38,
|
||||
height: 38,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: colorWhite.withValues(alpha: 0.10),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(icon, size: 15, color: colorWhite),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
@@ -27,7 +27,7 @@ Widget roundedCornerButton(
|
||||
disabledBackgroundColor: colorGrey.withValues(alpha: 0.4),
|
||||
padding: EdgeInsets.symmetric(vertical: verticalPadding),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
borderRadius: BorderRadius.zero,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
@@ -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;
|
||||
|
||||
@@ -62,7 +65,7 @@ Widget outlinedActionButton(
|
||||
side: BorderSide(color: fg.withValues(alpha: 0.35), width: 1),
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
borderRadius: BorderRadius.zero,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
@@ -109,7 +112,7 @@ Widget iconButton(
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: background ?? Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
borderRadius: BorderRadius.zero,
|
||||
border: bordered
|
||||
? Border.all(color: borderColor ?? colorBorder, width: 1)
|
||||
: null,
|
||||
@@ -158,7 +161,7 @@ Widget segmentedSelector<T>({
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? active : colorWhite,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderRadius: BorderRadius.zero,
|
||||
border: Border.all(
|
||||
color: isSelected ? active : colorBorder,
|
||||
width: 1,
|
||||
|
||||
63
frontend/lib/Grounded/designs/input/CountStepper.dart
Normal file
@@ -0,0 +1,63 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
|
||||
import '../../about/internal/application/TextType.dart';
|
||||
import '../../utils/Colors.dart';
|
||||
import '../text/Text.dart';
|
||||
|
||||
/// A labelled minus / value / plus control for sets, reps and seconds.
|
||||
/// Numbers are stepped, not typed — nobody wants a keyboard mid-routine-edit.
|
||||
Widget countStepper(
|
||||
String label,
|
||||
String value, {
|
||||
required VoidCallback onMinus,
|
||||
required VoidCallback onPlus,
|
||||
bool canMinus = true,
|
||||
}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(14, 10, 8, 10),
|
||||
decoration: BoxDecoration(
|
||||
color: colorWhite,
|
||||
borderRadius: BorderRadius.zero,
|
||||
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.zero,
|
||||
),
|
||||
child: Icon(icon,
|
||||
size: 14, color: enabled ? colorPrimaryDark : colorGrey),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -54,19 +54,19 @@ Widget inputField(
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: BorderRadius.zero,
|
||||
borderSide: BorderSide(color: colorBorder, width: 1),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: BorderRadius.zero,
|
||||
borderSide: BorderSide(color: colorPrimaryDark, width: 1.4),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: BorderRadius.zero,
|
||||
borderSide: BorderSide(color: colorNegative, width: 1),
|
||||
),
|
||||
focusedErrorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: BorderRadius.zero,
|
||||
borderSide: BorderSide(color: colorNegative, width: 1.4),
|
||||
),
|
||||
errorStyle: TextStyle(
|
||||
@@ -80,6 +80,38 @@ Widget inputField(
|
||||
);
|
||||
}
|
||||
|
||||
/// The bare add-a-line field used inside a card: no label, no box of its
|
||||
/// own; return or the trailing button submits.
|
||||
Widget quickAddField(
|
||||
TextEditingController controller,
|
||||
String hint,
|
||||
ValueChanged<String> onSubmitted, {
|
||||
FocusNode? focusNode,
|
||||
}) {
|
||||
return TextField(
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
onSubmitted: onSubmitted,
|
||||
textInputAction: TextInputAction.done,
|
||||
style: TextStyle(
|
||||
fontFamily: getTextType(TextType.Regular),
|
||||
fontSize: 16,
|
||||
color: colorInk,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: hint,
|
||||
hintStyle: TextStyle(
|
||||
fontFamily: getTextType(TextType.Regular),
|
||||
fontSize: 16,
|
||||
color: colorInkSoft,
|
||||
),
|
||||
isDense: true,
|
||||
border: InputBorder.none,
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 14),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The excuse field. Deliberately unadorned — no templates, no quick-picks, a
|
||||
/// live character count that shows the minimum, because the friction is the
|
||||
/// feature rather than an obstacle to route around.
|
||||
@@ -131,19 +163,19 @@ Widget excuseField(
|
||||
fillColor: colorWhite,
|
||||
contentPadding: const EdgeInsets.all(14),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: BorderRadius.zero,
|
||||
borderSide: BorderSide(color: colorBorder, width: 1),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: BorderRadius.zero,
|
||||
borderSide: BorderSide(color: colorPrimaryDark, width: 1.4),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: BorderRadius.zero,
|
||||
borderSide: BorderSide(color: colorNegative, width: 1),
|
||||
),
|
||||
focusedErrorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: BorderRadius.zero,
|
||||
borderSide: BorderSide(color: colorNegative, width: 1.4),
|
||||
),
|
||||
errorStyle: TextStyle(
|
||||
@@ -180,7 +212,7 @@ Widget selectField(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: colorWhite,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: BorderRadius.zero,
|
||||
border: Border.all(color: colorBorder, width: 1),
|
||||
),
|
||||
child: Row(
|
||||
|
||||
119
frontend/lib/Grounded/designs/scene/SceneArt.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
119
frontend/lib/Grounded/designs/scene/ScenePainter.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import '../about/external/initial/SessionLogRequest.dart';
|
||||
import '../about/external/initial/SickModeRequest.dart';
|
||||
import '../about/external/initial/ToneRequest.dart';
|
||||
import '../about/internal/application/CapacityProfile.dart';
|
||||
import '../about/internal/application/TodoItem.dart';
|
||||
import '../about/internal/application/MeDescription.dart';
|
||||
import '../about/internal/application/Token.dart';
|
||||
import '../about/internal/application/UserDetails.dart';
|
||||
@@ -119,6 +120,18 @@ class AppDataManager implements DataManager {
|
||||
@override
|
||||
Future setAmnestySpent(int spent) => memory.setAmnestySpent(spent);
|
||||
|
||||
@override
|
||||
Future<int> getWaterToday() => memory.getWaterToday();
|
||||
|
||||
@override
|
||||
Future setWaterToday(int ml) => memory.setWaterToday(ml);
|
||||
|
||||
@override
|
||||
Future<List<TodoItem>> getTodos() => memory.getTodos();
|
||||
|
||||
@override
|
||||
Future setTodos(List<TodoItem> todos) => memory.setTodos(todos);
|
||||
|
||||
@override
|
||||
Future<bool> showOnboarding() => memory.showOnboarding();
|
||||
|
||||
@@ -296,6 +309,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);
|
||||
|
||||
@@ -3,6 +3,7 @@ import '../about/external/data/Program.dart';
|
||||
import '../about/external/data/SystemResponse.dart';
|
||||
import '../about/internal/application/CapacityProfile.dart';
|
||||
import '../about/internal/application/MeDescription.dart';
|
||||
import '../about/internal/application/TodoItem.dart';
|
||||
import '../about/internal/application/Token.dart';
|
||||
import '../about/internal/application/UserDetails.dart';
|
||||
|
||||
@@ -59,6 +60,16 @@ abstract class ConnectInternalMemory {
|
||||
|
||||
Future setAmnestySpent(int spent);
|
||||
|
||||
/// Water drunk today in ml; a new day reads as 0.
|
||||
Future<int> getWaterToday();
|
||||
|
||||
Future setWaterToday(int ml);
|
||||
|
||||
/// The quick to-do list on Home.
|
||||
Future<List<TodoItem>> getTodos();
|
||||
|
||||
Future setTodos(List<TodoItem> todos);
|
||||
|
||||
Future<bool> showOnboarding();
|
||||
|
||||
Future setOnboardingOption(bool value);
|
||||
|
||||
@@ -8,6 +8,7 @@ import '../about/external/data/ResponseState.dart';
|
||||
import '../about/external/data/SystemResponse.dart';
|
||||
import '../about/internal/application/CapacityProfile.dart';
|
||||
import '../about/internal/application/MeDescription.dart';
|
||||
import '../about/internal/application/TodoItem.dart';
|
||||
import '../about/internal/application/Token.dart';
|
||||
import '../about/internal/application/UserDetails.dart';
|
||||
import 'ConnectInternalMemory.dart';
|
||||
@@ -40,6 +41,10 @@ class InternalMemory implements ConnectInternalMemory {
|
||||
|
||||
static const String AMNESTY_SPENT = "AMNESTY_SPENT_Jr4xVh9KmT3gLc6B";
|
||||
|
||||
static const String WATER = "WATER_Qe7tNd4ZsM2kBv9C";
|
||||
|
||||
static const String TODOS = "TODOS_Wm3kRb8TqZ5nHc2F";
|
||||
|
||||
static const String SHOW_ONBOARDING = "SHOW_ONBOARDING_Nz6qGf2SdX7wPb4M";
|
||||
|
||||
final groundedStorage = const FlutterSecureStorage();
|
||||
@@ -252,6 +257,51 @@ class InternalMemory implements ConnectInternalMemory {
|
||||
await groundedStorage.write(key: AMNESTY_SPENT, value: spent.toString());
|
||||
}
|
||||
|
||||
String _today() {
|
||||
final DateTime now = DateTime.now();
|
||||
return "${now.year}-${now.month}-${now.day}";
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> getWaterToday() async {
|
||||
String? value = await groundedStorage.read(key: WATER);
|
||||
|
||||
if (value != null) {
|
||||
final Map<String, dynamic> entry = jsonDecode(value);
|
||||
if (entry['day'] == _today()) {
|
||||
return entry['ml'] ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
@override
|
||||
Future setWaterToday(int ml) async {
|
||||
await groundedStorage.write(
|
||||
key: WATER, value: jsonEncode({'day': _today(), 'ml': ml}));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<TodoItem>> getTodos() async {
|
||||
String? value = await groundedStorage.read(key: TODOS);
|
||||
|
||||
if (value != null) {
|
||||
return (jsonDecode(value) as List<dynamic>)
|
||||
.map((item) => TodoItem.fromJson(item as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
return <TodoItem>[];
|
||||
}
|
||||
|
||||
@override
|
||||
Future setTodos(List<TodoItem> todos) async {
|
||||
await groundedStorage.write(
|
||||
key: TODOS,
|
||||
value: jsonEncode(todos.map((item) => item.toJson()).toList()));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> showOnboarding() async {
|
||||
String? value = await groundedStorage.read(key: SHOW_ONBOARDING);
|
||||
|
||||
443
frontend/lib/Grounded/mock/MockComms.dart
Normal 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();
|
||||
}
|
||||
33
frontend/lib/Grounded/mock/MockMemory.dart
Normal file
@@ -0,0 +1,33 @@
|
||||
// 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/TodoItem.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"));
|
||||
setWaterToday(1250);
|
||||
setTodos(<TodoItem>[
|
||||
TodoItem(id: "t1", title: "Refill water bottle"),
|
||||
TodoItem(id: "t2", title: "Book physio for Thursday"),
|
||||
TodoItem(id: "t3", title: "Reply to Sam about Saturday", done: true),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
FlutterSecureStorage get groundedStorage => const MockStorage();
|
||||
}
|
||||
55
frontend/lib/Grounded/mock/MockStorage.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
298
frontend/lib/Grounded/mock/MockStore.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
8
frontend/lib/Grounded/mock/MockSwitch.dart
Normal 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;
|
||||
@@ -1,3 +1,4 @@
|
||||
import '../../about/external/data/Goal.dart';
|
||||
import '../../utils/CapacityEngine.dart';
|
||||
|
||||
abstract class ConnectNewCommitment {
|
||||
@@ -9,4 +10,7 @@ abstract class ConnectNewCommitment {
|
||||
/// The learned multiplier for this category, so the estimate field can show
|
||||
/// what the app actually expects the task to take.
|
||||
void onMultiplierResolved(double multiplier);
|
||||
|
||||
/// Goals are loaded; open the picker.
|
||||
void onGoalsReady(List<Goal> goals);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../about/external/data/Goal.dart';
|
||||
import 'NewCommitmentState.dart';
|
||||
|
||||
class NewCommitment extends StatefulWidget {
|
||||
const NewCommitment({super.key});
|
||||
/// The goal this task is created inside; it can still be changed in the
|
||||
/// form. Null starts the task loose.
|
||||
final Goal? goal;
|
||||
|
||||
const NewCommitment({super.key, this.goal});
|
||||
|
||||
@override
|
||||
State<NewCommitment> createState() => NewCommitmentState();
|
||||
|
||||