Compare commits
3 Commits
main
...
fe268cb8aa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe268cb8aa | ||
|
|
b31bc47cc0 | ||
|
|
1a5867a98f |
@@ -109,10 +109,12 @@ status field. That's what makes honest history and excuse analysis possible.
|
|||||||
|
|
||||||
## 6. Design language
|
## 6. Design language
|
||||||
|
|
||||||
See `design.md`. In short: black chrome → white sheet with a 28 radius →
|
See `design.md`. In short: sage-to-cream ambient backdrop, frosted glass
|
||||||
oversized **Light** display title → tiny grey all-caps labels above values.
|
controls, painted `SceneArt` instead of photos, image-led cards, two-tone
|
||||||
|
greetings, night pills with a lime spark.
|
||||||
|
|
||||||
- Every screen is built from `Sheet` (`designs/Shell.dart`).
|
- Screens are built from `Sheet` (`designs/Shell.dart`); Home, Routines and
|
||||||
|
the routine player compose `AmbientBackground` + glass directly.
|
||||||
- Display titles are `TextType.Light`. Bold is for labels and metrics only.
|
- Display titles are `TextType.Light`. Bold is for labels and metrics only.
|
||||||
- Never raw `Text`/`TextStyle` — use `text()` from `designs/text/Text.dart`.
|
- Never raw `Text`/`TextStyle` — use `text()` from `designs/text/Text.dart`.
|
||||||
- Never inline hex — use `utils/Colors.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:windowFullscreen">false</item>
|
||||||
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
||||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</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:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
|
||||||
<item name="android:windowSplashScreenIconBackgroundColor">#141414</item>
|
<item name="android:windowSplashScreenIconBackgroundColor">#EAEFE6</item>
|
||||||
</style>
|
</style>
|
||||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||||
This theme determines the color of the Android Window while your
|
This theme determines the color of the Android Window while your
|
||||||
|
|||||||
@@ -6,9 +6,9 @@
|
|||||||
<item name="android:windowFullscreen">false</item>
|
<item name="android:windowFullscreen">false</item>
|
||||||
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
||||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</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:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
|
||||||
<item name="android:windowSplashScreenIconBackgroundColor">#141414</item>
|
<item name="android:windowSplashScreenIconBackgroundColor">#EAEFE6</item>
|
||||||
</style>
|
</style>
|
||||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||||
This theme determines the color of the Android Window while your
|
This theme determines the color of the Android Window while your
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<color name="ic_launcher_background">#141414</color>
|
<color name="ic_launcher_background">#18211C</color>
|
||||||
</resources>
|
</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 |
BIN
frontend/assets/icons/splash_orb.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
@@ -1,9 +1,34 @@
|
|||||||
# Grounded — Design Language
|
# Grounded — Design Language
|
||||||
|
|
||||||
Reference: black device chrome, a white content sheet that rises into it with a
|
Reference: an airy fitness-app look. Sage mist drifting into cream behind
|
||||||
large corner radius, oversized light display titles, tiny grey all-caps metadata
|
everything (`AmbientBackground`), frosted glass for controls and floating
|
||||||
labels, pill chips, and a lot of whitespace. The app reads as a record, not a
|
chrome (`GlassSurface`, `glassCircle`), ink type with a soft grey second
|
||||||
dashboard.
|
tone, painted landscapes instead of photography (`SceneArt`), a floating
|
||||||
|
glass tab bar with a dark round action button (`FloatingNav`), and one lime
|
||||||
|
spark on night-green for the primary control.
|
||||||
|
|
||||||
|
## Building blocks (`designs/`)
|
||||||
|
|
||||||
|
| Piece | Use |
|
||||||
|
|---|---|
|
||||||
|
| `AmbientBackground` | The backdrop for every light screen. |
|
||||||
|
| `GlassSurface` / `glassCircle` | Frosted panels and round controls; `onDark` over a scene or tinted stage. |
|
||||||
|
| `SceneArt` | Painted sky, sun and ridges with an optional figure glyph. Palettes come from `scenePalette(kind)`, `goalScene(accent)`, `classScene(class)`; seed with `sceneSeed(title)` so an item always wears the same picture. |
|
||||||
|
| `DotDigits` | The dotted clock in the routine player. |
|
||||||
|
| `FloatingNav` | Home's tab bar and action button. |
|
||||||
|
| `GroundedMark` | The logo, drawn — a lime sun on a ground line under two arcs. The launcher PNGs in `assets/icons/` were rendered from the same geometry. |
|
||||||
|
|
||||||
|
Cards are image-led where an item has a kind (routines, goals, tasks): scene on
|
||||||
|
top or as a thumbnail, white type over the shaded lower half, three light
|
||||||
|
numbers along the bottom. Greetings are two-tone: Medium ink, then Regular
|
||||||
|
`colorInkSoft`. Primary buttons are night pills with a lime icon.
|
||||||
|
|
||||||
|
`Sheet` is light (ambient) by default; passing `chrome`, `banner` or
|
||||||
|
`scene` gives a tinted stage with white type. Home, Routines and the player
|
||||||
|
build their own layouts from the same pieces.
|
||||||
|
|
||||||
|
The sections below describe the original record-style type scale, which still
|
||||||
|
applies to labels and metrics.
|
||||||
|
|
||||||
## Type — General Sans
|
## Type — General Sans
|
||||||
|
|
||||||
|
|||||||
|
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
|
/// An ordered sequence where the chain, not the step, is the unit of
|
||||||
/// completion. Breaking mid-way logs partial.
|
/// completion. Breaking mid-way logs partial.
|
||||||
class RoutineChain {
|
class RoutineChain {
|
||||||
@@ -5,6 +11,24 @@ class RoutineChain {
|
|||||||
|
|
||||||
String title;
|
String title;
|
||||||
|
|
||||||
|
/// The goal this routine serves — "Workout" holds "Full body strength".
|
||||||
|
/// Null for a routine that stands on its own.
|
||||||
|
String? goalId;
|
||||||
|
|
||||||
|
RoutineKind kind;
|
||||||
|
|
||||||
|
/// Weight class, exactly as for a commitment — a non-negotiable morning
|
||||||
|
/// routine carries non-negotiable debt when it breaks.
|
||||||
|
CommitmentClass commitmentClass;
|
||||||
|
|
||||||
|
/// 1..7, Monday first. Empty means unscheduled — run on demand only.
|
||||||
|
List<int> days;
|
||||||
|
|
||||||
|
/// Minutes after midnight the routine is due to start; -1 when unset.
|
||||||
|
int startMinute;
|
||||||
|
|
||||||
|
String note;
|
||||||
|
|
||||||
List<RoutineStep> steps;
|
List<RoutineStep> steps;
|
||||||
|
|
||||||
/// Index of the step reached when the chain last broke; -1 when clean.
|
/// Index of the step reached when the chain last broke; -1 when clean.
|
||||||
@@ -13,19 +37,30 @@ class RoutineChain {
|
|||||||
RoutineChain({
|
RoutineChain({
|
||||||
this.id,
|
this.id,
|
||||||
this.title = "",
|
this.title = "",
|
||||||
|
this.goalId,
|
||||||
|
this.kind = RoutineKind.Workout,
|
||||||
|
this.commitmentClass = CommitmentClass.Standard,
|
||||||
|
List<int>? days,
|
||||||
|
this.startMinute = -1,
|
||||||
|
this.note = "",
|
||||||
List<RoutineStep>? steps,
|
List<RoutineStep>? steps,
|
||||||
this.lastBreakIndex = -1,
|
this.lastBreakIndex = -1,
|
||||||
}) : steps = steps ?? <RoutineStep>[];
|
}) : days = days ?? <int>[],
|
||||||
|
steps = steps ?? <RoutineStep>[];
|
||||||
|
|
||||||
factory RoutineChain.fromJson(Map<String, dynamic> json) {
|
factory RoutineChain.fromJson(Map<String, dynamic> json) {
|
||||||
return RoutineChain(
|
return RoutineChain(
|
||||||
id: json['id'],
|
id: json['id'],
|
||||||
title: json['title'] ?? "",
|
title: json['title'] ?? "",
|
||||||
steps: json['steps'] == null
|
goalId: json['goalId'],
|
||||||
? <RoutineStep>[]
|
kind: getRoutineKind(json['kind']),
|
||||||
: (json['steps'] as List)
|
commitmentClass: getCommitmentClass(json['commitmentClass']),
|
||||||
.map((item) => RoutineStep.fromJson(item))
|
days: json['days'] == null
|
||||||
.toList(),
|
? <int>[]
|
||||||
|
: (json['days'] as List).map((item) => item as int).toList(),
|
||||||
|
startMinute: json['startMinute'] ?? -1,
|
||||||
|
note: json['note'] ?? "",
|
||||||
|
steps: getRoutineStepList(json['steps']),
|
||||||
lastBreakIndex: json['lastBreakIndex'] ?? -1,
|
lastBreakIndex: json['lastBreakIndex'] ?? -1,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -34,6 +69,12 @@ class RoutineChain {
|
|||||||
final Map<String, dynamic> data = <String, dynamic>{};
|
final Map<String, dynamic> data = <String, dynamic>{};
|
||||||
data['id'] = id;
|
data['id'] = id;
|
||||||
data['title'] = title;
|
data['title'] = title;
|
||||||
|
data['goalId'] = goalId;
|
||||||
|
data['kind'] = kind.name;
|
||||||
|
data['commitmentClass'] = commitmentClass.name;
|
||||||
|
data['days'] = days;
|
||||||
|
data['startMinute'] = startMinute;
|
||||||
|
data['note'] = note;
|
||||||
data['steps'] = steps.map((item) => item.toJson()).toList();
|
data['steps'] = steps.map((item) => item.toJson()).toList();
|
||||||
data['lastBreakIndex'] = lastBreakIndex;
|
data['lastBreakIndex'] = lastBreakIndex;
|
||||||
return data;
|
return data;
|
||||||
@@ -54,44 +95,40 @@ class RoutineChain {
|
|||||||
bool get complete {
|
bool get complete {
|
||||||
return steps.isNotEmpty && completedSteps == steps.length;
|
return steps.isNotEmpty && completedSteps == steps.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool get scheduled {
|
||||||
|
return days.isNotEmpty;
|
||||||
}
|
}
|
||||||
|
|
||||||
class RoutineStep {
|
bool scheduledOn(DateTime day) {
|
||||||
String? id;
|
return days.contains(day.weekday);
|
||||||
|
|
||||||
String title;
|
|
||||||
|
|
||||||
int order;
|
|
||||||
|
|
||||||
int timerSeconds;
|
|
||||||
|
|
||||||
bool completed;
|
|
||||||
|
|
||||||
RoutineStep({
|
|
||||||
this.id,
|
|
||||||
this.title = "",
|
|
||||||
this.order = 0,
|
|
||||||
this.timerSeconds = 0,
|
|
||||||
this.completed = false,
|
|
||||||
});
|
|
||||||
|
|
||||||
factory RoutineStep.fromJson(Map<String, dynamic> json) {
|
|
||||||
return RoutineStep(
|
|
||||||
id: json['id'],
|
|
||||||
title: json['title'] ?? "",
|
|
||||||
order: json['order'] ?? 0,
|
|
||||||
timerSeconds: json['timerSeconds'] ?? 0,
|
|
||||||
completed: json['completed'] ?? false,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
/// Every set of every step, for the "12 sets" figure.
|
||||||
final Map<String, dynamic> data = <String, dynamic>{};
|
int get totalSets {
|
||||||
data['id'] = id;
|
int total = 0;
|
||||||
data['title'] = title;
|
for (RoutineStep step in steps) {
|
||||||
data['order'] = order;
|
total = total + step.sets;
|
||||||
data['timerSeconds'] = timerSeconds;
|
}
|
||||||
data['completed'] = completed;
|
return total;
|
||||||
return data;
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
class CommitmentRequest {
|
||||||
String? id;
|
String? id;
|
||||||
|
|
||||||
|
/// The goal this task belongs to; null for a loose task.
|
||||||
|
String? parentId;
|
||||||
|
|
||||||
String type;
|
String type;
|
||||||
|
|
||||||
String commitmentClass;
|
String commitmentClass;
|
||||||
@@ -26,6 +29,7 @@ class CommitmentRequest {
|
|||||||
|
|
||||||
CommitmentRequest({
|
CommitmentRequest({
|
||||||
this.id,
|
this.id,
|
||||||
|
this.parentId,
|
||||||
this.type = "TASK",
|
this.type = "TASK",
|
||||||
this.commitmentClass = "Standard",
|
this.commitmentClass = "Standard",
|
||||||
this.title = "",
|
this.title = "",
|
||||||
@@ -42,6 +46,7 @@ class CommitmentRequest {
|
|||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
final Map<String, dynamic> data = <String, dynamic>{};
|
final Map<String, dynamic> data = <String, dynamic>{};
|
||||||
data['id'] = id;
|
data['id'] = id;
|
||||||
|
data['parentId'] = parentId;
|
||||||
data['type'] = type;
|
data['type'] = type;
|
||||||
data['commitmentClass'] = commitmentClass;
|
data['commitmentClass'] = commitmentClass;
|
||||||
data['title'] = title;
|
data['title'] = title;
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
@@ -388,6 +388,13 @@ class Comms implements ConnectComms {
|
|||||||
return await dio.post(navigation.key, data: request);
|
return await dio.post(navigation.key, data: request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Response> saveRoutineEntry(Map<String, dynamic> request) async {
|
||||||
|
Pair navigation = await getRequestHeaders(saveRoutine, "");
|
||||||
|
dio.options.headers = navigation.value;
|
||||||
|
return await dio.post(navigation.key, data: request);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Response> logRoutineChainEntry(Map<String, dynamic> request) async {
|
Future<Response> logRoutineChainEntry(Map<String, dynamic> request) async {
|
||||||
Pair navigation = await getRequestHeaders(logRoutineChain, "");
|
Pair navigation = await getRequestHeaders(logRoutineChain, "");
|
||||||
|
|||||||
@@ -108,6 +108,8 @@ abstract class ConnectComms {
|
|||||||
|
|
||||||
Future<Response> getMyRoutines(HistoryRequest request);
|
Future<Response> getMyRoutines(HistoryRequest request);
|
||||||
|
|
||||||
|
Future<Response> saveRoutineEntry(Map<String, dynamic> request);
|
||||||
|
|
||||||
Future<Response> logRoutineChainEntry(Map<String, dynamic> request);
|
Future<Response> logRoutineChainEntry(Map<String, dynamic> request);
|
||||||
|
|
||||||
// ── Training ────────────────────────────────────────────────────────────
|
// ── Training ────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
|
|
||||||
import '../about/external/data/Commitment.dart';
|
import '../about/external/data/Commitment.dart';
|
||||||
@@ -32,7 +33,7 @@ class LocalNotificationEngine {
|
|||||||
static bool _ready = false;
|
static bool _ready = false;
|
||||||
|
|
||||||
static Future<void> init() async {
|
static Future<void> init() async {
|
||||||
if (_ready) {
|
if (_ready || kIsWeb) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,6 +106,10 @@ class LocalNotificationEngine {
|
|||||||
/// The ongoing notification for a running task. Shows the live remaining
|
/// The ongoing notification for a running task. Shows the live remaining
|
||||||
/// time so it is useful from the lock screen without unlocking.
|
/// time so it is useful from the lock screen without unlocking.
|
||||||
static Future<void> showSessionNotification(LiveSession session) async {
|
static Future<void> showSessionNotification(LiveSession session) async {
|
||||||
|
// The browser has no notification channels; the runner works without.
|
||||||
|
if (kIsWeb) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
await init();
|
await init();
|
||||||
|
|
||||||
final String remaining = session.requiredSeconds > 0
|
final String remaining = session.requiredSeconds > 0
|
||||||
@@ -144,6 +149,10 @@ class LocalNotificationEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> cancelSessionNotification() async {
|
static Future<void> cancelSessionNotification() async {
|
||||||
|
// The browser has no notification channels; the runner works without.
|
||||||
|
if (kIsWeb) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
await init();
|
await init();
|
||||||
await plugin.cancel(id: sessionNotificationId);
|
await plugin.cancel(id: sessionNotificationId);
|
||||||
}
|
}
|
||||||
@@ -156,6 +165,9 @@ class LocalNotificationEngine {
|
|||||||
EscalationTier tier,
|
EscalationTier tier,
|
||||||
ToneLevel tone,
|
ToneLevel tone,
|
||||||
) async {
|
) async {
|
||||||
|
if (kIsWeb) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
await init();
|
await init();
|
||||||
|
|
||||||
final bool nonNegotiable =
|
final bool nonNegotiable =
|
||||||
@@ -198,6 +210,10 @@ class LocalNotificationEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> cancelAll() async {
|
static Future<void> cancelAll() async {
|
||||||
|
// The browser has no notification channels; the runner works without.
|
||||||
|
if (kIsWeb) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
await init();
|
await init();
|
||||||
await plugin.cancelAll();
|
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)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,7 +61,7 @@ Widget card({
|
|||||||
EdgeInsets margin = EdgeInsets.zero,
|
EdgeInsets margin = EdgeInsets.zero,
|
||||||
Color? background,
|
Color? background,
|
||||||
Color? borderColor,
|
Color? borderColor,
|
||||||
double radius = 16,
|
double radius = 22,
|
||||||
VoidCallback? onTap,
|
VoidCallback? onTap,
|
||||||
}) {
|
}) {
|
||||||
final Widget body = Container(
|
final Widget body = Container(
|
||||||
@@ -71,12 +71,12 @@ Widget card({
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: background ?? colorCard,
|
color: background ?? colorCard,
|
||||||
borderRadius: BorderRadius.circular(radius),
|
borderRadius: BorderRadius.circular(radius),
|
||||||
border: Border.all(color: borderColor ?? colorBorder, width: 1),
|
border: Border.all(color: borderColor ?? colorGlassBorder, width: 1),
|
||||||
boxShadow: const [
|
boxShadow: const [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Color(0x08000000),
|
color: Color(0x0F2A3A30),
|
||||||
blurRadius: 18,
|
blurRadius: 24,
|
||||||
offset: Offset(0, 4),
|
offset: Offset(0, 8),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
125
frontend/lib/Grounded/designs/FloatingNav.dart
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../about/internal/application/TextType.dart';
|
||||||
|
import '../utils/Colors.dart';
|
||||||
|
import 'GlassSurface.dart';
|
||||||
|
import 'text/Text.dart';
|
||||||
|
|
||||||
|
typedef NavEntry = ({IconData icon, String label, VoidCallback onTap});
|
||||||
|
|
||||||
|
/// The floating glass tab bar with the dark round action button beside it.
|
||||||
|
class FloatingNav extends StatelessWidget {
|
||||||
|
final List<NavEntry> entries;
|
||||||
|
|
||||||
|
final int selected;
|
||||||
|
|
||||||
|
final IconData actionIcon;
|
||||||
|
|
||||||
|
final VoidCallback onAction;
|
||||||
|
|
||||||
|
const FloatingNav({
|
||||||
|
super.key,
|
||||||
|
required this.entries,
|
||||||
|
required this.selected,
|
||||||
|
required this.actionIcon,
|
||||||
|
required this.onAction,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SafeArea(
|
||||||
|
top: false,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 14),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(34),
|
||||||
|
boxShadow: const [
|
||||||
|
BoxShadow(
|
||||||
|
color: Color(0x1A1A1F1C),
|
||||||
|
blurRadius: 30,
|
||||||
|
offset: Offset(0, 10),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: GlassSurface(
|
||||||
|
radius: BorderRadius.circular(34),
|
||||||
|
tint: colorGlassStrong,
|
||||||
|
padding: const EdgeInsets.all(6),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
for (int index = 0; index < entries.length; index++)
|
||||||
|
Expanded(child: _tab(entries[index], index == selected)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: onAction,
|
||||||
|
child: Container(
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
gradient: const RadialGradient(
|
||||||
|
center: Alignment(-0.3, -0.4),
|
||||||
|
colors: <Color>[colorNightGlow, colorNight],
|
||||||
|
),
|
||||||
|
border: Border.all(color: colorWhite, width: 3),
|
||||||
|
boxShadow: const [
|
||||||
|
BoxShadow(
|
||||||
|
color: Color(0x331A1F1C),
|
||||||
|
blurRadius: 24,
|
||||||
|
offset: Offset(0, 10),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Icon(actionIcon, size: 24, color: colorLime),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _tab(NavEntry entry, bool active) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: entry.onTap,
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 220),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 9),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: active ? colorWhite : colorWhite.withValues(alpha: 0.0),
|
||||||
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
boxShadow: active
|
||||||
|
? const [
|
||||||
|
BoxShadow(
|
||||||
|
color: Color(0x141A1F1C),
|
||||||
|
blurRadius: 12,
|
||||||
|
offset: Offset(0, 4),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: const [],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(entry.icon,
|
||||||
|
size: 19, color: active ? colorInk : colorInkSoft),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
text(entry.label, 10, active ? TextType.Medium : TextType.Regular,
|
||||||
|
color: active ? colorInk : colorInkSoft, maxLines: 1),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
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.circular(20)),
|
||||||
|
this.padding = EdgeInsets.zero,
|
||||||
|
this.tint,
|
||||||
|
this.onDark = false,
|
||||||
|
this.blur = 18,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ClipRRect(
|
||||||
|
borderRadius: radius,
|
||||||
|
child: BackdropFilter(
|
||||||
|
filter: ImageFilter.blur(sigmaX: blur, sigmaY: blur),
|
||||||
|
child: Container(
|
||||||
|
padding: padding,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: tint ??
|
||||||
|
(onDark ? colorWhite.withValues(alpha: 0.16) : colorGlass),
|
||||||
|
borderRadius: radius,
|
||||||
|
border: Border.all(
|
||||||
|
color: onDark
|
||||||
|
? colorWhite.withValues(alpha: 0.22)
|
||||||
|
: colorGlassBorder,
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A round frosted control — back, close, bell, more.
|
||||||
|
Widget glassCircle(
|
||||||
|
IconData icon,
|
||||||
|
VoidCallback onTap, {
|
||||||
|
double size = 42,
|
||||||
|
bool onDark = false,
|
||||||
|
bool dotted = false,
|
||||||
|
Color? dotColor,
|
||||||
|
}) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
child: Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
GlassSurface(
|
||||||
|
onDark: onDark,
|
||||||
|
radius: BorderRadius.circular(size),
|
||||||
|
child: SizedBox(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
child: Icon(
|
||||||
|
icon,
|
||||||
|
size: size * 0.42,
|
||||||
|
color: onDark ? colorWhite : colorInk,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (dotted)
|
||||||
|
Positioned(
|
||||||
|
top: 2,
|
||||||
|
right: 2,
|
||||||
|
child: Container(
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: dotColor ?? colorAccent,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: colorWhite, width: 1.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
127
frontend/lib/Grounded/designs/GoalPicker.dart
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import 'package:flutter/cupertino.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../about/external/data/Goal.dart';
|
||||||
|
import '../about/internal/application/TextType.dart';
|
||||||
|
import '../utils/Colors.dart';
|
||||||
|
import '../utils/CommonUtils.dart';
|
||||||
|
import 'text/Text.dart';
|
||||||
|
|
||||||
|
/// The goal chooser, shared by the task form and the routine builder. Opened
|
||||||
|
/// only once the goals have been loaded — `selectField` loads on tap.
|
||||||
|
///
|
||||||
|
/// Resolves to the chosen goal, to a bare `Goal()` (no id) for "no goal", or
|
||||||
|
/// to null when dismissed without a choice.
|
||||||
|
Future<Goal?> showGoalPicker(
|
||||||
|
BuildContext context,
|
||||||
|
List<Goal> goals, {
|
||||||
|
String? selectedId,
|
||||||
|
}) {
|
||||||
|
return showModalBottomSheet<Goal>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
barrierColor: colorBlack.withValues(alpha: 0.55),
|
||||||
|
builder: (BuildContext sheetContext) {
|
||||||
|
final List<Goal> active = goals.where((goal) => !goal.archived).toList();
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
maxHeight: MediaQuery.of(sheetContext).size.height * 0.75,
|
||||||
|
),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: colorSheetBackground,
|
||||||
|
borderRadius: BorderRadius.only(
|
||||||
|
topLeft: Radius.circular(28),
|
||||||
|
topRight: Radius.circular(28),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: ListView(
|
||||||
|
shrinkWrap: true,
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 24, 24, 32),
|
||||||
|
children: [
|
||||||
|
text("BELONGS TO", 9, TextType.Bold,
|
||||||
|
color: colorGrey2, letterSpacing: 1.2),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
text("Which goal is this for?", 24, TextType.Light,
|
||||||
|
color: colorPrimaryDark),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
_goalOption(
|
||||||
|
sheetContext,
|
||||||
|
Goal(),
|
||||||
|
"No goal",
|
||||||
|
"Stands on its own",
|
||||||
|
colorGrey,
|
||||||
|
selectedId == null,
|
||||||
|
),
|
||||||
|
...active.map((goal) => _goalOption(
|
||||||
|
sheetContext,
|
||||||
|
goal,
|
||||||
|
goal.title,
|
||||||
|
goal.targetDate == null
|
||||||
|
? "${goal.remainingTasks} open"
|
||||||
|
: "${goal.remainingTasks} open · ${targetLabel(goal.targetDate)}",
|
||||||
|
goalColor(goal.colourHex),
|
||||||
|
goal.id == selectedId,
|
||||||
|
)),
|
||||||
|
if (active.isEmpty) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
text("No goals yet. Create one from the Goals screen.", 12,
|
||||||
|
TextType.Regular,
|
||||||
|
color: colorGrey2),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _goalOption(
|
||||||
|
BuildContext sheetContext,
|
||||||
|
Goal goal,
|
||||||
|
String title,
|
||||||
|
String caption,
|
||||||
|
Color accent,
|
||||||
|
bool selected,
|
||||||
|
) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => Navigator.pop(sheetContext, goal),
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
|
padding: const EdgeInsets.fromLTRB(14, 13, 14, 13),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorWhite,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(
|
||||||
|
color: selected ? accent.withValues(alpha: 0.5) : colorBorder,
|
||||||
|
width: selected ? 1.5 : 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
decoration: BoxDecoration(color: accent, shape: BoxShape.circle),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
text(title, 14, TextType.Medium, color: colorPrimaryDark),
|
||||||
|
const SizedBox(height: 3),
|
||||||
|
text(caption, 11, TextType.Regular, color: colorGrey2),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (selected)
|
||||||
|
Icon(CupertinoIcons.checkmark_alt, size: 17, color: accent),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
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 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../about/internal/application/TextType.dart';
|
import '../about/internal/application/TextType.dart';
|
||||||
import '../utils/Colors.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';
|
import 'text/Text.dart';
|
||||||
|
|
||||||
/// The house layout: black chrome at the top, a white sheet rising into it with
|
/// The house layout. On the default ambient tone the screen floats on sage
|
||||||
/// a large corner radius. Every screen is built from this so the app reads as
|
/// mist with frosted controls and ink type, and the content rises on a frosted
|
||||||
/// one object rather than a stack of pages.
|
/// 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 {
|
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;
|
final String eyebrow;
|
||||||
|
|
||||||
/// The chrome title — small and centred, not the display title.
|
/// The chrome title — small and centred, not the display title.
|
||||||
@@ -22,7 +27,7 @@ class Sheet extends StatelessWidget {
|
|||||||
/// Optional trailing control in the chrome.
|
/// Optional trailing control in the chrome.
|
||||||
final Widget? action;
|
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;
|
final Color? chrome;
|
||||||
|
|
||||||
/// Rendered inside the chrome beneath the title — the standing strip.
|
/// Rendered inside the chrome beneath the title — the standing strip.
|
||||||
@@ -30,6 +35,12 @@ class Sheet extends StatelessWidget {
|
|||||||
|
|
||||||
final bool scrollable;
|
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({
|
const Sheet({
|
||||||
super.key,
|
super.key,
|
||||||
required this.title,
|
required this.title,
|
||||||
@@ -40,33 +51,39 @@ class Sheet extends StatelessWidget {
|
|||||||
this.chrome,
|
this.chrome,
|
||||||
this.banner,
|
this.banner,
|
||||||
this.scrollable = true,
|
this.scrollable = true,
|
||||||
|
this.scene,
|
||||||
|
this.sceneSeed = 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
bool get _light => chrome == null && banner == null && scene == null;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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(
|
final Widget header = SafeArea(
|
||||||
backgroundColor: chromeColor,
|
|
||||||
body: Column(
|
|
||||||
mainAxisSize: MainAxisSize.max,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
SafeArea(
|
|
||||||
bottom: false,
|
bottom: false,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(12, 4, 12, 16),
|
padding: const EdgeInsets.fromLTRB(16, 6, 16, 18),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 44,
|
width: 46,
|
||||||
child: onBack == null
|
child: onBack == null
|
||||||
? null
|
? null
|
||||||
: _chromeButton(
|
: Align(
|
||||||
Icons.arrow_back_ios_new_rounded, onBack!),
|
alignment: Alignment.centerLeft,
|
||||||
|
child: glassCircle(
|
||||||
|
CupertinoIcons.arrow_left, onBack!,
|
||||||
|
onDark: !light),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -77,24 +94,25 @@ class Sheet extends StatelessWidget {
|
|||||||
eyebrow.toUpperCase(),
|
eyebrow.toUpperCase(),
|
||||||
9,
|
9,
|
||||||
TextType.Bold,
|
TextType.Bold,
|
||||||
color: colorWhite.withValues(alpha: 0.45),
|
color: soft,
|
||||||
letterSpacing: 1.2,
|
letterSpacing: 1.2,
|
||||||
align: TextAlign.center,
|
align: TextAlign.center,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 3),
|
const SizedBox(height: 3),
|
||||||
],
|
],
|
||||||
text(
|
text(
|
||||||
title,
|
title.toUpperCase(),
|
||||||
15,
|
14,
|
||||||
TextType.Medium,
|
TextType.Medium,
|
||||||
color: colorWhite,
|
color: ink,
|
||||||
|
letterSpacing: 0.6,
|
||||||
align: TextAlign.center,
|
align: TextAlign.center,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 44,
|
width: 46,
|
||||||
child: action == null
|
child: action == null
|
||||||
? null
|
? null
|
||||||
: Align(
|
: Align(
|
||||||
@@ -105,20 +123,21 @@ class Sheet extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
if (banner != null) ...[
|
if (banner != null) ...[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 18),
|
||||||
banner!,
|
banner!,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
Expanded(
|
|
||||||
|
final Widget body = Expanded(
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colorPrimaryLight,
|
color: light ? colorFrost : colorPrimaryLight,
|
||||||
borderRadius: const BorderRadius.only(
|
borderRadius: const BorderRadius.only(
|
||||||
topLeft: Radius.circular(28),
|
topLeft: Radius.circular(32),
|
||||||
topRight: Radius.circular(28),
|
topRight: Radius.circular(32),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
@@ -132,66 +151,107 @@ class Sheet extends StatelessWidget {
|
|||||||
child: child,
|
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(
|
Widget chromeAction(
|
||||||
IconData icon,
|
IconData icon,
|
||||||
VoidCallback onTap, {
|
VoidCallback onTap, {
|
||||||
bool dotted = false,
|
bool dotted = false,
|
||||||
Color? dotColor,
|
Color? dotColor,
|
||||||
}) {
|
}) {
|
||||||
return GestureDetector(
|
return Builder(
|
||||||
onTap: onTap,
|
builder: (BuildContext context) => glassCircle(
|
||||||
child: Stack(
|
icon,
|
||||||
clipBehavior: Clip.none,
|
onTap,
|
||||||
children: [
|
onDark: !ChromeTone.isLight(context),
|
||||||
Container(
|
dotted: dotted,
|
||||||
width: 38,
|
dotColor: dotColor,
|
||||||
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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -215,12 +275,11 @@ Widget labelled(
|
|||||||
label.toUpperCase(),
|
label.toUpperCase(),
|
||||||
9,
|
9,
|
||||||
TextType.Bold,
|
TextType.Bold,
|
||||||
color: labelColor ?? colorGrey2,
|
color: labelColor ?? colorInkSoft,
|
||||||
letterSpacing: 1.0,
|
letterSpacing: 1.0,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 5),
|
const SizedBox(height: 5),
|
||||||
text(value, valueSize, valueType,
|
text(value, valueSize, valueType, color: valueColor ?? colorInk),
|
||||||
color: valueColor ?? colorPrimaryDark),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -247,18 +306,15 @@ Widget metaRow(List<Widget> items) {
|
|||||||
/// The oversized light display title that opens a section.
|
/// The oversized light display title that opens a section.
|
||||||
Widget displayTitle(String value, {Color? color, double size = 34}) {
|
Widget displayTitle(String value, {Color? color, double size = 34}) {
|
||||||
return text(value, size, TextType.Light,
|
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
|
/// A section heading: medium ink label, a soft caption beside it and an
|
||||||
/// trailing chip.
|
/// optional trailing control ("View all").
|
||||||
Widget sectionBreak(String heading, {Widget? trailing, String caption = ""}) {
|
Widget sectionBreak(String heading, {Widget? trailing, String caption = ""}) {
|
||||||
return Column(
|
return Padding(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
padding: const EdgeInsets.only(top: 6, bottom: 14),
|
||||||
mainAxisSize: MainAxisSize.min,
|
child: Row(
|
||||||
children: [
|
|
||||||
hairline(margin: const EdgeInsets.only(bottom: 20)),
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
@@ -266,13 +322,17 @@ Widget sectionBreak(String heading, {Widget? trailing, String caption = ""}) {
|
|||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
text(heading, 17, TextType.Bold, color: colorPrimaryDark),
|
text(heading, 16, TextType.Medium, color: colorInk),
|
||||||
if (caption.isNotEmpty) ...[
|
if (caption.isNotEmpty) ...[
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 8),
|
||||||
Padding(
|
Flexible(
|
||||||
padding: const EdgeInsets.only(bottom: 2),
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 1),
|
||||||
child: text(caption, 11, TextType.Regular,
|
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,
|
if (trailing != null) trailing,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ Widget roundedCornerButton(
|
|||||||
Color? foreground,
|
Color? foreground,
|
||||||
IconData? icon,
|
IconData? icon,
|
||||||
bool enabled = true,
|
bool enabled = true,
|
||||||
double radius = 14,
|
double radius = 99,
|
||||||
double verticalPadding = 16,
|
double verticalPadding = 16,
|
||||||
}) {
|
}) {
|
||||||
final Color bg = enabled ? (background ?? colorPrimaryDark) : colorGrey;
|
final Color bg = enabled ? (background ?? colorNight) : colorGrey;
|
||||||
final Color fg = foreground ?? colorWhite;
|
final Color fg = foreground ?? colorWhite;
|
||||||
|
|
||||||
return ElevatedButton(
|
return ElevatedButton(
|
||||||
@@ -35,10 +35,13 @@ Widget roundedCornerButton(
|
|||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
if (icon != null) ...[
|
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),
|
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,
|
Color? foreground,
|
||||||
IconData? icon,
|
IconData? icon,
|
||||||
bool enabled = true,
|
bool enabled = true,
|
||||||
double radius = 14,
|
double radius = 99,
|
||||||
}) {
|
}) {
|
||||||
final Color fg = enabled ? (foreground ?? colorPrimaryDark) : colorGrey;
|
final Color fg = enabled ? (foreground ?? colorPrimaryDark) : colorGrey;
|
||||||
|
|
||||||
|
|||||||
63
frontend/lib/Grounded/designs/input/CountStepper.dart
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import 'package:flutter/cupertino.dart';
|
||||||
|
|
||||||
|
import '../../about/internal/application/TextType.dart';
|
||||||
|
import '../../utils/Colors.dart';
|
||||||
|
import '../text/Text.dart';
|
||||||
|
|
||||||
|
/// A labelled minus / value / plus control for sets, reps and seconds.
|
||||||
|
/// Numbers are stepped, not typed — nobody wants a keyboard mid-routine-edit.
|
||||||
|
Widget countStepper(
|
||||||
|
String label,
|
||||||
|
String value, {
|
||||||
|
required VoidCallback onMinus,
|
||||||
|
required VoidCallback onPlus,
|
||||||
|
bool canMinus = true,
|
||||||
|
}) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.fromLTRB(14, 10, 8, 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorWhite,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: colorBorder, width: 1),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
text(label.toUpperCase(), 9, TextType.Bold,
|
||||||
|
color: colorGrey2, letterSpacing: 1.0),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
text(value, 17, TextType.Medium, color: colorPrimaryDark),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_stepButton(CupertinoIcons.minus, canMinus ? onMinus : null),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
_stepButton(CupertinoIcons.plus, onPlus),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _stepButton(IconData icon, VoidCallback? onTap) {
|
||||||
|
final bool enabled = onTap != null;
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
child: Container(
|
||||||
|
width: 34,
|
||||||
|
height: 34,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: enabled ? colorSecondary : colorInset,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Icon(icon,
|
||||||
|
size: 14, color: enabled ? colorPrimaryDark : colorGrey),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
114
frontend/lib/Grounded/designs/scene/SceneArt.dart
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
@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)),
|
||||||
|
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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
114
frontend/lib/Grounded/designs/scene/ScenePainter.dart
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
ScenePainter(this.palette, this.seed);
|
||||||
|
|
||||||
|
@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),
|
||||||
|
);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -296,6 +296,10 @@ class AppDataManager implements DataManager {
|
|||||||
Future<Response> getMyRoutines(HistoryRequest request) =>
|
Future<Response> getMyRoutines(HistoryRequest request) =>
|
||||||
comms.getMyRoutines(request);
|
comms.getMyRoutines(request);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Response> saveRoutineEntry(Map<String, dynamic> request) =>
|
||||||
|
comms.saveRoutineEntry(request);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Response> logRoutineChainEntry(Map<String, dynamic> request) =>
|
Future<Response> logRoutineChainEntry(Map<String, dynamic> request) =>
|
||||||
comms.logRoutineChainEntry(request);
|
comms.logRoutineChainEntry(request);
|
||||||
|
|||||||
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();
|
||||||
|
}
|
||||||
26
frontend/lib/Grounded/mock/MockMemory.dart
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
// MOCK DATA — TEMPORARY. See MockSwitch.dart.
|
||||||
|
|
||||||
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
|
||||||
|
import '../about/internal/application/MeDescription.dart';
|
||||||
|
import '../about/internal/application/UserDetails.dart';
|
||||||
|
import '../memory/InternalMemory.dart';
|
||||||
|
import 'MockStorage.dart';
|
||||||
|
|
||||||
|
/// The real [InternalMemory], pointed at in-memory storage and pre-seeded
|
||||||
|
/// with a signed-in user so the app opens straight onto Home.
|
||||||
|
class MockMemory extends InternalMemory {
|
||||||
|
static bool _seeded = false;
|
||||||
|
|
||||||
|
MockMemory() {
|
||||||
|
if (!_seeded) {
|
||||||
|
_seeded = true;
|
||||||
|
setMyDescription(
|
||||||
|
MeDescription(id: "mock-user", name: "Alex", token: "mock-token"));
|
||||||
|
setUserDetails(UserDetails(pic: "", name: "Alex"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
FlutterSecureStorage get groundedStorage => const MockStorage();
|
||||||
|
}
|
||||||
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';
|
import '../../utils/CapacityEngine.dart';
|
||||||
|
|
||||||
abstract class ConnectNewCommitment {
|
abstract class ConnectNewCommitment {
|
||||||
@@ -9,4 +10,7 @@ abstract class ConnectNewCommitment {
|
|||||||
/// The learned multiplier for this category, so the estimate field can show
|
/// The learned multiplier for this category, so the estimate field can show
|
||||||
/// what the app actually expects the task to take.
|
/// what the app actually expects the task to take.
|
||||||
void onMultiplierResolved(double multiplier);
|
void onMultiplierResolved(double multiplier);
|
||||||
|
|
||||||
|
/// Goals are loaded; open the picker.
|
||||||
|
void onGoalsReady(List<Goal> goals);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../about/external/data/Goal.dart';
|
||||||
import 'NewCommitmentState.dart';
|
import 'NewCommitmentState.dart';
|
||||||
|
|
||||||
class NewCommitment extends StatefulWidget {
|
class NewCommitment extends StatefulWidget {
|
||||||
const NewCommitment({super.key});
|
/// The goal this task is created inside; it can still be changed in the
|
||||||
|
/// form. Null starts the task loose.
|
||||||
|
final Goal? goal;
|
||||||
|
|
||||||
|
const NewCommitment({super.key, this.goal});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<NewCommitment> createState() => NewCommitmentState();
|
State<NewCommitment> createState() => NewCommitmentState();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:stacked/stacked.dart';
|
import 'package:stacked/stacked.dart';
|
||||||
|
|
||||||
import '../../about/external/data/Commitment.dart';
|
import '../../about/external/data/Commitment.dart';
|
||||||
|
import '../../about/external/data/Goal.dart';
|
||||||
import '../../about/external/initial/CommitmentRequest.dart';
|
import '../../about/external/initial/CommitmentRequest.dart';
|
||||||
import '../../about/internal/application/CommitmentClass.dart';
|
import '../../about/internal/application/CommitmentClass.dart';
|
||||||
import '../../about/internal/application/EnergyCost.dart';
|
import '../../about/internal/application/EnergyCost.dart';
|
||||||
@@ -10,6 +11,7 @@ import '../../about/internal/application/NotificationType.dart';
|
|||||||
import '../../about/internal/application/ProofType.dart';
|
import '../../about/internal/application/ProofType.dart';
|
||||||
import '../../about/internal/application/TextType.dart';
|
import '../../about/internal/application/TextType.dart';
|
||||||
import '../../designs/Component.dart';
|
import '../../designs/Component.dart';
|
||||||
|
import '../../designs/GoalPicker.dart';
|
||||||
import '../../designs/Responsive.dart';
|
import '../../designs/Responsive.dart';
|
||||||
import '../../designs/Shell.dart';
|
import '../../designs/Shell.dart';
|
||||||
import '../../designs/buttons/Buttons.dart';
|
import '../../designs/buttons/Buttons.dart';
|
||||||
@@ -49,6 +51,9 @@ class NewCommitmentState extends State<NewCommitment>
|
|||||||
|
|
||||||
String _windowError = "";
|
String _windowError = "";
|
||||||
|
|
||||||
|
/// The goal this task belongs to; null for a loose task.
|
||||||
|
Goal? _goal;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ViewModelBuilder<ViewNewCommitment>.reactive(
|
return ViewModelBuilder<ViewNewCommitment>.reactive(
|
||||||
@@ -76,6 +81,11 @@ class NewCommitmentState extends State<NewCommitment>
|
|||||||
setState(() {
|
setState(() {
|
||||||
_windowStart = now;
|
_windowStart = now;
|
||||||
_windowEnd = now.add(const Duration(hours: 2));
|
_windowEnd = now.add(const Duration(hours: 2));
|
||||||
|
// A task made inside a goal starts in it, at the goal's default weight.
|
||||||
|
if (widget.goal != null) {
|
||||||
|
_goal = widget.goal;
|
||||||
|
_class = widget.goal!.defaultClass;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,6 +95,10 @@ class NewCommitmentState extends State<NewCommitment>
|
|||||||
Navigator.pop(context, false);
|
Navigator.pop(context, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onPickGoal() {
|
||||||
|
_model?.loadGoals();
|
||||||
|
}
|
||||||
|
|
||||||
void _onCategoryChanged(String value) {
|
void _onCategoryChanged(String value) {
|
||||||
_model?.resolveMultiplier(value.trim());
|
_model?.resolveMultiplier(value.trim());
|
||||||
}
|
}
|
||||||
@@ -185,6 +199,7 @@ class NewCommitmentState extends State<NewCommitment>
|
|||||||
|
|
||||||
CommitmentRequest _buildRequest() {
|
CommitmentRequest _buildRequest() {
|
||||||
return CommitmentRequest(
|
return CommitmentRequest(
|
||||||
|
parentId: _goal?.id,
|
||||||
commitmentClass: _class.name,
|
commitmentClass: _class.name,
|
||||||
title: _title.text.trim(),
|
title: _title.text.trim(),
|
||||||
category: _category.text.trim(),
|
category: _category.text.trim(),
|
||||||
@@ -200,6 +215,7 @@ class NewCommitmentState extends State<NewCommitment>
|
|||||||
|
|
||||||
Commitment _buildCandidate() {
|
Commitment _buildCandidate() {
|
||||||
return Commitment(
|
return Commitment(
|
||||||
|
parentId: _goal?.id,
|
||||||
commitmentClass: _class,
|
commitmentClass: _class,
|
||||||
title: _title.text.trim(),
|
title: _title.text.trim(),
|
||||||
category: _category.text.trim(),
|
category: _category.text.trim(),
|
||||||
@@ -237,6 +253,14 @@ class NewCommitmentState extends State<NewCommitment>
|
|||||||
validator: Validators.title,
|
validator: Validators.title,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
|
selectField(
|
||||||
|
"Goal",
|
||||||
|
_goal?.title ?? "",
|
||||||
|
_onPickGoal,
|
||||||
|
hint: "No goal — a loose task",
|
||||||
|
icon: CupertinoIcons.flag,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
inputField(
|
inputField(
|
||||||
"Category",
|
"Category",
|
||||||
_category,
|
_category,
|
||||||
@@ -542,6 +566,20 @@ class NewCommitmentState extends State<NewCommitment>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onGoalsReady(List<Goal> goals) async {
|
||||||
|
final Goal? picked =
|
||||||
|
await showGoalPicker(context, goals, selectedId: _goal?.id);
|
||||||
|
|
||||||
|
if (picked == null || !mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_goal = picked.id == null ? null : picked;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_title.dispose();
|
_title.dispose();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import '../../about/external/data/Commitment.dart';
|
import '../../about/external/data/Commitment.dart';
|
||||||
import '../../about/external/data/pages/request/CommitmentsRequest.dart';
|
import '../../about/external/data/pages/request/CommitmentsRequest.dart';
|
||||||
|
import '../../about/external/data/pages/request/HistoryRequest.dart';
|
||||||
import '../../about/external/data/pages/request/PageAndSort.dart';
|
import '../../about/external/data/pages/request/PageAndSort.dart';
|
||||||
import '../../about/external/data/pages/request/Pageable.dart';
|
import '../../about/external/data/pages/request/Pageable.dart';
|
||||||
import '../../about/external/data/pages/request/Sort.dart';
|
import '../../about/external/data/pages/request/Sort.dart';
|
||||||
@@ -7,6 +8,7 @@ import '../../about/external/data/pages/response/CommitmentPage.dart';
|
|||||||
import '../../about/external/initial/CommitmentRequest.dart';
|
import '../../about/external/initial/CommitmentRequest.dart';
|
||||||
import '../../about/internal/application/CapacityProfile.dart';
|
import '../../about/internal/application/CapacityProfile.dart';
|
||||||
import '../../utils/CapacityEngine.dart';
|
import '../../utils/CapacityEngine.dart';
|
||||||
|
import '../../utils/ObjectConvertors.dart';
|
||||||
import '../parent/ParentViewModel.dart';
|
import '../parent/ParentViewModel.dart';
|
||||||
import 'ConnectNewCommitment.dart';
|
import 'ConnectNewCommitment.dart';
|
||||||
|
|
||||||
@@ -24,6 +26,28 @@ class ViewNewCommitment extends ParentViewModel {
|
|||||||
connection.onMultiplierResolved(profile.multiplierFor(category));
|
connection.onMultiplierResolved(profile.multiplierFor(category));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Goals for the picker — loaded on tap, never up front.
|
||||||
|
void loadGoals() async {
|
||||||
|
if (!await hasNetwork(() => loadGoals())) return;
|
||||||
|
|
||||||
|
showLoading("Loading goals");
|
||||||
|
|
||||||
|
try {
|
||||||
|
final response = await getDataManager().getMyGoals(HistoryRequest(
|
||||||
|
query: PageAndSort(
|
||||||
|
sort: Sort('desc', 'startDate'),
|
||||||
|
page: Pageable(0, 0, 50, 0),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
|
||||||
|
closeLoading();
|
||||||
|
|
||||||
|
connection.onGoalsReady(getGoalList(response.data));
|
||||||
|
} catch (e) {
|
||||||
|
handleError(e, () => loadGoals(), () => dismissError(), "Retry");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The capacity gate. Chronic overdue is usually overcommitment misdiagnosed
|
/// The capacity gate. Chronic overdue is usually overcommitment misdiagnosed
|
||||||
/// as laziness, so the plan is checked against what history says actually
|
/// as laziness, so the plan is checked against what history says actually
|
||||||
/// gets done before anything is accepted.
|
/// gets done before anything is accepted.
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import '../../about/external/data/Commitment.dart';
|
import '../../about/external/data/Commitment.dart';
|
||||||
import '../../about/external/data/Goal.dart';
|
import '../../about/external/data/Goal.dart';
|
||||||
|
import '../../about/external/data/RoutineChain.dart';
|
||||||
|
|
||||||
abstract class ConnectGoalDetail {
|
abstract class ConnectGoalDetail {
|
||||||
void onGoalLoaded(Goal goal, List<Commitment> tasks);
|
void onGoalLoaded(Goal goal, List<Commitment> tasks);
|
||||||
|
|
||||||
|
/// The routines that serve this goal.
|
||||||
|
void onRoutinesLoaded(List<RoutineChain> routines);
|
||||||
|
|
||||||
/// The task is ready to run — hand off to the full-screen runner.
|
/// The task is ready to run — hand off to the full-screen runner.
|
||||||
void onTaskReady(Commitment task);
|
void onTaskReady(Commitment task);
|
||||||
|
|
||||||
|
|||||||
5
frontend/lib/Grounded/see/goal/ConnectGoalEditor.dart
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
abstract class ConnectGoalEditor {
|
||||||
|
void onSaved();
|
||||||
|
|
||||||
|
void onArchived();
|
||||||
|
}
|
||||||
@@ -2,6 +2,4 @@ import '../../about/external/data/Goal.dart';
|
|||||||
|
|
||||||
abstract class ConnectGoals {
|
abstract class ConnectGoals {
|
||||||
void onGoalsLoaded(List<Goal> goals);
|
void onGoalsLoaded(List<Goal> goals);
|
||||||
|
|
||||||
void onGoalSaved();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import 'package:stacked/stacked.dart';
|
|||||||
|
|
||||||
import '../../about/external/data/Commitment.dart';
|
import '../../about/external/data/Commitment.dart';
|
||||||
import '../../about/external/data/Goal.dart';
|
import '../../about/external/data/Goal.dart';
|
||||||
|
import '../../about/external/data/RoutineChain.dart';
|
||||||
import '../../about/internal/application/CommitmentStatus.dart';
|
import '../../about/internal/application/CommitmentStatus.dart';
|
||||||
import '../../about/internal/application/NotificationType.dart';
|
import '../../about/internal/application/NotificationType.dart';
|
||||||
import '../../about/internal/application/ProofType.dart';
|
import '../../about/internal/application/ProofType.dart';
|
||||||
|
import '../../about/internal/application/RoutineKind.dart';
|
||||||
import '../../about/internal/application/TextType.dart';
|
import '../../about/internal/application/TextType.dart';
|
||||||
import '../../configs/Navigator.dart';
|
import '../../configs/Navigator.dart';
|
||||||
import '../../designs/Checkbox.dart';
|
import '../../designs/Checkbox.dart';
|
||||||
@@ -14,14 +16,18 @@ import '../../designs/Component.dart';
|
|||||||
import '../../designs/Responsive.dart';
|
import '../../designs/Responsive.dart';
|
||||||
import '../../designs/Shell.dart';
|
import '../../designs/Shell.dart';
|
||||||
import '../../designs/buttons/Buttons.dart';
|
import '../../designs/buttons/Buttons.dart';
|
||||||
|
import '../../designs/scene/SceneArt.dart';
|
||||||
import '../../designs/text/Text.dart';
|
import '../../designs/text/Text.dart';
|
||||||
import '../../utils/Colors.dart';
|
import '../../utils/Colors.dart';
|
||||||
import '../../utils/CommonUtils.dart';
|
import '../../utils/CommonUtils.dart';
|
||||||
import '../../utils/DebtEngine.dart';
|
import '../../utils/DebtEngine.dart';
|
||||||
import '../commitment/NewCommitment.dart';
|
import '../commitment/NewCommitment.dart';
|
||||||
import '../live/LiveTask.dart';
|
import '../live/LiveTask.dart';
|
||||||
|
import '../routine/RoutineBuilder.dart';
|
||||||
|
import '../routine/RoutineRunner.dart';
|
||||||
import 'ConnectGoalDetail.dart';
|
import 'ConnectGoalDetail.dart';
|
||||||
import 'GoalDetail.dart';
|
import 'GoalDetail.dart';
|
||||||
|
import 'GoalEditor.dart';
|
||||||
import 'ViewGoalDetail.dart';
|
import 'ViewGoalDetail.dart';
|
||||||
|
|
||||||
class GoalDetailState extends State<GoalDetail>
|
class GoalDetailState extends State<GoalDetail>
|
||||||
@@ -32,6 +38,8 @@ class GoalDetailState extends State<GoalDetail>
|
|||||||
|
|
||||||
List<Commitment> _tasks = <Commitment>[];
|
List<Commitment> _tasks = <Commitment>[];
|
||||||
|
|
||||||
|
List<RoutineChain> _routines = <RoutineChain>[];
|
||||||
|
|
||||||
bool _changed = false;
|
bool _changed = false;
|
||||||
|
|
||||||
/// Ticked locally, awaiting the server.
|
/// Ticked locally, awaiting the server.
|
||||||
@@ -90,7 +98,7 @@ class GoalDetailState extends State<GoalDetail>
|
|||||||
|
|
||||||
void _onAddTask() async {
|
void _onAddTask() async {
|
||||||
final result = await GroundedNavigation()
|
final result = await GroundedNavigation()
|
||||||
.navigateToPageWithData(const NewCommitment(), context);
|
.navigateToPageWithData(NewCommitment(goal: _goal), context);
|
||||||
|
|
||||||
if (result == true) {
|
if (result == true) {
|
||||||
_changed = true;
|
_changed = true;
|
||||||
@@ -98,6 +106,61 @@ class GoalDetailState extends State<GoalDetail>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onAddRoutine() async {
|
||||||
|
final result = await GroundedNavigation()
|
||||||
|
.navigateToPageWithData(RoutineBuilder(goal: _goal), context);
|
||||||
|
|
||||||
|
if (result == true) {
|
||||||
|
_model?.loadRoutines(_goal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onEditRoutine(RoutineChain routine) async {
|
||||||
|
final result = await GroundedNavigation()
|
||||||
|
.navigateToPageWithData(
|
||||||
|
RoutineBuilder(routine: routine, goal: _goal), context);
|
||||||
|
|
||||||
|
if (result == true) {
|
||||||
|
_model?.loadRoutines(_goal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onStartRoutine(RoutineChain routine) async {
|
||||||
|
if (routine.steps.isEmpty) {
|
||||||
|
_onEditRoutine(routine);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = await GroundedNavigation().navigateToPageWithData(
|
||||||
|
RoutineRunner(routine: routine.copy()), context);
|
||||||
|
|
||||||
|
if (result == true) {
|
||||||
|
_changed = true;
|
||||||
|
_model?.loadRoutines(_goal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onEditGoal() async {
|
||||||
|
final result = await GroundedNavigation()
|
||||||
|
.navigateToPageWithData(GoalEditor(goal: _goal), context);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result == "archived") {
|
||||||
|
Navigator.pop(context, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result is Goal) {
|
||||||
|
_changed = true;
|
||||||
|
setState(() {
|
||||||
|
_goal = result;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Views ─────────────────────────────────────────────────────────────────
|
// ── Views ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
Widget _mobileView(BoxConstraints constraints) {
|
Widget _mobileView(BoxConstraints constraints) {
|
||||||
@@ -115,10 +178,13 @@ class GoalDetailState extends State<GoalDetail>
|
|||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
return Sheet(
|
return Sheet(
|
||||||
eyebrow: "Goal",
|
eyebrow: _goal.category.isEmpty ? "Goal" : _goal.category,
|
||||||
title: _goal.title,
|
title: _goal.title,
|
||||||
onBack: _onBack,
|
onBack: _onBack,
|
||||||
action: chromeAction(CupertinoIcons.add, _onAddTask),
|
chrome: _goal.colourHex.isEmpty ? null : goalColor(_goal.colourHex),
|
||||||
|
scene: goalScene(goalColor(_goal.colourHex)),
|
||||||
|
sceneSeed: sceneSeed(_goal.title),
|
||||||
|
action: chromeAction(CupertinoIcons.pencil, _onEditGoal),
|
||||||
banner: _progressBanner(),
|
banner: _progressBanner(),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
@@ -138,6 +204,26 @@ class GoalDetailState extends State<GoalDetail>
|
|||||||
)
|
)
|
||||||
else
|
else
|
||||||
...open.map(_taskRow),
|
...open.map(_taskRow),
|
||||||
|
const SizedBox(height: 28),
|
||||||
|
sectionBreak(
|
||||||
|
"Routines",
|
||||||
|
caption: _routines.isEmpty
|
||||||
|
? "none yet"
|
||||||
|
: "${_routines.length} serving this goal",
|
||||||
|
),
|
||||||
|
if (_routines.isEmpty)
|
||||||
|
text(
|
||||||
|
"Routines are the repeatable part of a goal — the Monday session, the morning sit. Add one and it runs from here.",
|
||||||
|
13,
|
||||||
|
TextType.Regular,
|
||||||
|
color: colorGrey2,
|
||||||
|
height: 1.5,
|
||||||
|
)
|
||||||
|
else
|
||||||
|
..._routines.map(_routineRow),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
outlinedActionButton("Add a routine", _onAddRoutine,
|
||||||
|
icon: CupertinoIcons.square_stack_3d_up),
|
||||||
if (done.isNotEmpty) ...[
|
if (done.isNotEmpty) ...[
|
||||||
const SizedBox(height: 28),
|
const SizedBox(height: 28),
|
||||||
sectionBreak("Done", caption: "${done.length}"),
|
sectionBreak("Done", caption: "${done.length}"),
|
||||||
@@ -179,6 +265,40 @@ class GoalDetailState extends State<GoalDetail>
|
|||||||
track: colorWhite.withValues(alpha: 0.14),
|
track: colorWhite.withValues(alpha: 0.14),
|
||||||
height: 5,
|
height: 5,
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: labelled(
|
||||||
|
"Tasks",
|
||||||
|
"${_goal.completedTasks}/${_goal.totalTasks}",
|
||||||
|
valueSize: 15,
|
||||||
|
valueColor: colorWhite,
|
||||||
|
labelColor: colorWhite.withValues(alpha: 0.45),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: labelled(
|
||||||
|
"Routines",
|
||||||
|
"${_routines.length}",
|
||||||
|
valueSize: 15,
|
||||||
|
valueColor: colorWhite,
|
||||||
|
labelColor: colorWhite.withValues(alpha: 0.45),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: labelled(
|
||||||
|
"Deadline",
|
||||||
|
_goal.targetDate == null
|
||||||
|
? "None"
|
||||||
|
: targetLabel(_goal.targetDate),
|
||||||
|
valueSize: 15,
|
||||||
|
valueColor: colorWhite,
|
||||||
|
labelColor: colorWhite.withValues(alpha: 0.45),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -235,12 +355,15 @@ class GoalDetailState extends State<GoalDetail>
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
roundedCornerButton(
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: roundedCornerButton(
|
||||||
"Start",
|
"Start",
|
||||||
() => _onStartTask(task),
|
() => _onStartTask(task),
|
||||||
icon: CupertinoIcons.play_fill,
|
icon: CupertinoIcons.play_fill,
|
||||||
verticalPadding: 13,
|
verticalPadding: 13,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -268,8 +391,90 @@ class GoalDetailState extends State<GoalDetail>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _routineRow(RoutineChain routine) {
|
||||||
|
final Color tone = kindColor(routine.kind);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 10),
|
||||||
|
child: card(
|
||||||
|
padding: const EdgeInsets.fromLTRB(10, 10, 10, 10),
|
||||||
|
onTap: () => _onEditRoutine(routine),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 58,
|
||||||
|
height: 58,
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
child: SceneArt(
|
||||||
|
palette: scenePalette(routine.kind),
|
||||||
|
seed: sceneSeed(routine.title),
|
||||||
|
figure: kindIcon(routine.kind),
|
||||||
|
figureScale: 0.46,
|
||||||
|
figureAlignment: const Alignment(0, 0.1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
text(routine.title, 14, TextType.Medium,
|
||||||
|
color: colorPrimaryDark,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
text(
|
||||||
|
[
|
||||||
|
kindLabel(routine.kind),
|
||||||
|
formatMinutes(routine.totalSeconds / 60),
|
||||||
|
if (routine.startMinute >= 0)
|
||||||
|
formatStartMinute(routine.startMinute),
|
||||||
|
formatDays(routine.days),
|
||||||
|
].join(" · "),
|
||||||
|
11,
|
||||||
|
TextType.Regular,
|
||||||
|
color: colorGrey2,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => _onStartRoutine(routine),
|
||||||
|
child: Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: routine.kind == RoutineKind.MustDo
|
||||||
|
? colorPrimaryDark
|
||||||
|
: tone,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(CupertinoIcons.play_fill,
|
||||||
|
size: 15, color: colorWhite),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── ConnectGoalDetail ─────────────────────────────────────────────────────
|
// ── ConnectGoalDetail ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onRoutinesLoaded(List<RoutineChain> routines) {
|
||||||
|
setState(() {
|
||||||
|
_routines = routines;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void onGoalLoaded(Goal goal, List<Commitment> tasks) {
|
void onGoalLoaded(Goal goal, List<Commitment> tasks) {
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|||||||
16
frontend/lib/Grounded/see/goal/GoalEditor.dart
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../about/external/data/Goal.dart';
|
||||||
|
import 'GoalEditorState.dart';
|
||||||
|
|
||||||
|
/// Create or edit a goal. Pops the saved [Goal], or `"archived"` when the goal
|
||||||
|
/// was archived, so a detail screen underneath knows to close as well.
|
||||||
|
class GoalEditor extends StatefulWidget {
|
||||||
|
/// The goal being edited; null creates a new one.
|
||||||
|
final Goal? goal;
|
||||||
|
|
||||||
|
const GoalEditor({super.key, this.goal});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<GoalEditor> createState() => GoalEditorState();
|
||||||
|
}
|
||||||