Compare commits
7 Commits
main
...
feature/ro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b71257429 | ||
|
|
4548e1f164 | ||
|
|
9c858b8146 | ||
|
|
36bc41509d | ||
|
|
fe268cb8aa | ||
|
|
b31bc47cc0 | ||
|
|
1a5867a98f |
@@ -32,7 +32,7 @@ informatics/ DataManager · AppDataManager
|
|||||||
memory/ ConnectInternalMemory · InternalMemory
|
memory/ ConnectInternalMemory · InternalMemory
|
||||||
configs/ Navigator · Env · NotificationServiceConfig
|
configs/ Navigator · Env · NotificationServiceConfig
|
||||||
designs/ Component · Shell · Responsive · buttons/ input/ text/
|
designs/ Component · Shell · Responsive · buttons/ input/ text/
|
||||||
see/<feature>/ screens, 4 files each
|
see/<feature>/ screens, 4 files each (group/ = groups, shared goals, chat)
|
||||||
utils/ Colors · CommonUtils · engines · validators
|
utils/ Colors · CommonUtils · engines · validators
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -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, square night buttons with a lime spark, no rounded corners.
|
||||||
|
|
||||||
- Every screen is built from `Sheet` (`designs/Shell.dart`).
|
- Screens are built from `Sheet` (`designs/Shell.dart`); Home, Routines and
|
||||||
|
the routine player compose `AmbientBackground` + glass directly.
|
||||||
- Display titles are `TextType.Light`. Bold is for labels and metrics only.
|
- 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 |
@@ -1,17 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
|
||||||
<!-- Grounded mark: a horizon line with a weight resting on it. The bar is
|
|
||||||
the ground; the block is what you are carrying. -->
|
|
||||||
<rect width="512" height="512" fill="#141414"/>
|
|
||||||
|
|
||||||
<!-- The block -->
|
|
||||||
<rect x="176" y="146" width="160" height="160" rx="34" fill="#EDEEEA"/>
|
|
||||||
|
|
||||||
<!-- The notch — a debt taken out of the block -->
|
|
||||||
<rect x="236" y="146" width="40" height="86" rx="20" fill="#141414"/>
|
|
||||||
|
|
||||||
<!-- The ground line -->
|
|
||||||
<rect x="120" y="348" width="272" height="26" rx="13" fill="#2F6F4E"/>
|
|
||||||
|
|
||||||
<!-- Shadow beneath, shortened: the weight sits on the line -->
|
|
||||||
<rect x="168" y="396" width="176" height="14" rx="7" fill="#EDEEEA" opacity="0.22"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 760 B |
|
Before Width: | Height: | Size: 35 KiB |
BIN
frontend/assets/icons/splash_mark.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
@@ -1,9 +1,34 @@
|
|||||||
# Grounded — Design Language
|
# 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 square action button (`FloatingNav`), and one lime
|
||||||
|
spark on night-green for the primary control.
|
||||||
|
|
||||||
|
## Building blocks (`designs/`)
|
||||||
|
|
||||||
|
| Piece | Use |
|
||||||
|
|---|---|
|
||||||
|
| `AmbientBackground` | The backdrop for every light screen. |
|
||||||
|
| `GlassSurface` / `glassCircle` | Frosted panels and square controls; `onDark` over a scene or tinted stage. |
|
||||||
|
| `SceneArt` | Painted sky, sun and ridges with an optional figure glyph. Palettes come from `scenePalette(kind)`, `goalScene(accent)`, `classScene(class)`; seed with `sceneSeed(title)` so an item always wears the same picture. |
|
||||||
|
| `DotDigits` | The dotted clock in the routine player. |
|
||||||
|
| `FloatingNav` | Home's tab bar; the selected tab is a filled night block. The side action button is optional. |
|
||||||
|
| `GroundedMark` | The logo, drawn — a lime sun on a ground line under two arcs. The launcher PNGs in `assets/icons/` were rendered from the same geometry. |
|
||||||
|
|
||||||
|
Cards are image-led where an item has a kind (routines, goals, tasks): scene on
|
||||||
|
top or as a thumbnail, white type over the shaded lower half, three light
|
||||||
|
numbers along the bottom. Greetings are two-tone: Medium ink, then Regular
|
||||||
|
`colorInkSoft`. Primary buttons are square night bars with a lime icon.
|
||||||
|
|
||||||
|
`Sheet` is light (ambient) by default; passing `chrome`, `banner` or
|
||||||
|
`scene` gives a tinted stage with white type. Home, Routines and the player
|
||||||
|
build their own layouts from the same pieces.
|
||||||
|
|
||||||
|
The sections below describe the original record-style type scale, which still
|
||||||
|
applies to labels and metrics.
|
||||||
|
|
||||||
## Type — General Sans
|
## Type — General Sans
|
||||||
|
|
||||||
@@ -20,7 +45,7 @@ directly above a large dark value. Used everywhere numbers appear.
|
|||||||
|
|
||||||
## Geometry
|
## Geometry
|
||||||
|
|
||||||
- Sheet radius `28`, card radius `16`, chip radius `999`, control radius `12`.
|
- No rounded corners anywhere: sheets, cards, chips, buttons, fields, tiles and portraits are all square (`BorderRadius.zero`). Only small status dots, glows, the sun, progress rings and the breathing circle stay round.
|
||||||
- Screen padding `20` horizontal. Card padding `16`. Section gap `28`.
|
- Screen padding `20` horizontal. Card padding `16`. Section gap `28`.
|
||||||
- Hairlines at `colorDivider` (black 7%), borders at `colorBorder` (black 8%).
|
- Hairlines at `colorDivider` (black 7%), borders at `colorBorder` (black 8%).
|
||||||
- One soft shadow only: `0 4 18 rgba(0,0,0,0.03)`. No coloured glows.
|
- One soft shadow only: `0 4 18 rgba(0,0,0,0.03)`. No coloured glows.
|
||||||
@@ -44,7 +69,7 @@ the standing is legible without reading the word.
|
|||||||
## Structure
|
## Structure
|
||||||
|
|
||||||
Every screen is: black `headerBar` (back, title, action) → white sheet with a
|
Every screen is: black `headerBar` (back, title, action) → white sheet with a
|
||||||
`28` top radius → `sectionHeader` (label + light display title) → content.
|
square top edge → `sectionHeader` (label + light display title) → content.
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
@@ -53,3 +78,34 @@ Every screen is: black `headerBar` (back, title, action) → white sheet with a
|
|||||||
- Chips carry state, never actions.
|
- Chips carry state, never actions.
|
||||||
- One primary button per screen; everything else is outlined or text.
|
- One primary button per screen; everything else is outlined or text.
|
||||||
- Empty states are plain. An empty overdue queue is good news, not a party.
|
- Empty states are plain. An empty overdue queue is good news, not a party.
|
||||||
|
|
||||||
|
## Home
|
||||||
|
|
||||||
|
Home opens on a deep forest header block (`colorHeaderTop` → `colorHeaderBottom`):
|
||||||
|
greeting and avatar, a Today / Weekly toggle, a seven-day streak panel ending
|
||||||
|
today (a leaf on every kept day, kept = 80%+), then the numbers. Today shows
|
||||||
|
minutes held against planned, a ring with + that opens the "Add to today"
|
||||||
|
sheet, the plan as thumbnails, the report-card letter as the Score, and one
|
||||||
|
bar per commitment class. Weekly swaps in the week's average, a bar per day
|
||||||
|
and the class completion rates. Below the block, on the light ambient
|
||||||
|
surface: the rest of the day. Bars on the dark block use the `colorBar*` tokens.
|
||||||
|
|
||||||
|
## Groups
|
||||||
|
|
||||||
|
`see/group/`: people keeping each other honest. A group has members, shared
|
||||||
|
goals and a chat.
|
||||||
|
|
||||||
|
- **Shared goals** are split into **objectives** with a number and a unit
|
||||||
|
(200 km, 40 runs, 30,000 words). Every member logs toward them; the card
|
||||||
|
shows the group total, your share and a night **Log** button. Like
|
||||||
|
personal goals, they carry no debt.
|
||||||
|
- **Leaderboard** ranks members by kept rate over the last 7 days, with
|
||||||
|
streak, last check-in (red "quiet" after 24h) and a nudge button.
|
||||||
|
- **Chat** keeps the work and the talk together: plain messages are square
|
||||||
|
bubbles (yours in night, others white with the author's colour), while
|
||||||
|
check-ins (green bar) and nudges (amber bar) are cards in the thread.
|
||||||
|
The open chat polls every 4 seconds.
|
||||||
|
- Members are drawn by `memberAvatar` / `avatarStack`
|
||||||
|
(`designs/MemberAvatar.dart`): square painted portraits with the initial.
|
||||||
|
- Groups are reached from the nav (Groups tab), the Home "Your groups" rail
|
||||||
|
(tap opens the chat) and the add sheet.
|
||||||
|
|||||||
|
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 |
94
frontend/lib/Grounded/about/external/data/Group.dart
vendored
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import '../../../utils/ObjectConvertors.dart';
|
||||||
|
import 'GroupGoal.dart';
|
||||||
|
import 'GroupMember.dart';
|
||||||
|
|
||||||
|
/// People keeping each other honest: members, the goals they share, and the
|
||||||
|
/// chat they talk in.
|
||||||
|
class Group {
|
||||||
|
String id;
|
||||||
|
|
||||||
|
String name;
|
||||||
|
|
||||||
|
String description;
|
||||||
|
|
||||||
|
String colourHex;
|
||||||
|
|
||||||
|
/// Six characters a friend types to join.
|
||||||
|
String inviteCode;
|
||||||
|
|
||||||
|
List<GroupMember> members;
|
||||||
|
|
||||||
|
List<GroupGoal> goals;
|
||||||
|
|
||||||
|
/// Messages the signed-in user has not seen yet.
|
||||||
|
int unread;
|
||||||
|
|
||||||
|
String lastMessage;
|
||||||
|
|
||||||
|
String lastAuthor;
|
||||||
|
|
||||||
|
DateTime? lastMessageAt;
|
||||||
|
|
||||||
|
Group({
|
||||||
|
this.id = "",
|
||||||
|
this.name = "",
|
||||||
|
this.description = "",
|
||||||
|
this.colourHex = "",
|
||||||
|
this.inviteCode = "",
|
||||||
|
List<GroupMember>? members,
|
||||||
|
List<GroupGoal>? goals,
|
||||||
|
this.unread = 0,
|
||||||
|
this.lastMessage = "",
|
||||||
|
this.lastAuthor = "",
|
||||||
|
this.lastMessageAt,
|
||||||
|
}) : members = members ?? <GroupMember>[],
|
||||||
|
goals = goals ?? <GroupGoal>[];
|
||||||
|
|
||||||
|
/// Average progress across the shared goals.
|
||||||
|
double get progress {
|
||||||
|
if (goals.isEmpty) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
double total = 0;
|
||||||
|
for (GroupGoal goal in goals) {
|
||||||
|
total = total + goal.progress;
|
||||||
|
}
|
||||||
|
return total / goals.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get iOwn => members.any((member) => member.me && member.owner);
|
||||||
|
|
||||||
|
factory Group.fromJson(Map<String, dynamic> json) {
|
||||||
|
return Group(
|
||||||
|
id: json['id'] ?? "",
|
||||||
|
name: json['name'] ?? "",
|
||||||
|
description: json['description'] ?? "",
|
||||||
|
colourHex: json['colourHex'] ?? "",
|
||||||
|
inviteCode: json['inviteCode'] ?? "",
|
||||||
|
members: getGroupMemberList(json['members']),
|
||||||
|
goals: getGroupGoalList(json['goals']),
|
||||||
|
unread: json['unread'] ?? 0,
|
||||||
|
lastMessage: json['lastMessage'] ?? "",
|
||||||
|
lastAuthor: json['lastAuthor'] ?? "",
|
||||||
|
lastMessageAt: json['lastMessageAt'] == null
|
||||||
|
? null
|
||||||
|
: DateTime.tryParse(json['lastMessageAt']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
final Map<String, dynamic> data = <String, dynamic>{};
|
||||||
|
data['id'] = id;
|
||||||
|
data['name'] = name;
|
||||||
|
data['description'] = description;
|
||||||
|
data['colourHex'] = colourHex;
|
||||||
|
data['inviteCode'] = inviteCode;
|
||||||
|
data['members'] = members.map((item) => item.toJson()).toList();
|
||||||
|
data['goals'] = goals.map((item) => item.toJson()).toList();
|
||||||
|
data['unread'] = unread;
|
||||||
|
data['lastMessage'] = lastMessage;
|
||||||
|
data['lastAuthor'] = lastAuthor;
|
||||||
|
data['lastMessageAt'] = lastMessageAt?.toIso8601String();
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
67
frontend/lib/Grounded/about/external/data/GroupGoal.dart
vendored
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import '../../../utils/ObjectConvertors.dart';
|
||||||
|
import 'GroupObjective.dart';
|
||||||
|
|
||||||
|
/// A goal the whole group shares, broken into objectives everyone logs
|
||||||
|
/// against. Like a personal goal it carries no debt of its own.
|
||||||
|
class GroupGoal {
|
||||||
|
String id;
|
||||||
|
|
||||||
|
String title;
|
||||||
|
|
||||||
|
String description;
|
||||||
|
|
||||||
|
DateTime? targetDate;
|
||||||
|
|
||||||
|
String colourHex;
|
||||||
|
|
||||||
|
List<GroupObjective> objectives;
|
||||||
|
|
||||||
|
GroupGoal({
|
||||||
|
this.id = "",
|
||||||
|
this.title = "",
|
||||||
|
this.description = "",
|
||||||
|
this.targetDate,
|
||||||
|
this.colourHex = "",
|
||||||
|
List<GroupObjective>? objectives,
|
||||||
|
}) : objectives = objectives ?? <GroupObjective>[];
|
||||||
|
|
||||||
|
/// The average of the objectives, so one runaway objective cannot carry
|
||||||
|
/// the rest.
|
||||||
|
double get progress {
|
||||||
|
if (objectives.isEmpty) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
double total = 0;
|
||||||
|
for (GroupObjective objective in objectives) {
|
||||||
|
total = total + objective.progress;
|
||||||
|
}
|
||||||
|
return total / objectives.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
int get completeObjectives =>
|
||||||
|
objectives.where((objective) => objective.complete).length;
|
||||||
|
|
||||||
|
factory GroupGoal.fromJson(Map<String, dynamic> json) {
|
||||||
|
return GroupGoal(
|
||||||
|
id: json['id'] ?? "",
|
||||||
|
title: json['title'] ?? "",
|
||||||
|
description: json['description'] ?? "",
|
||||||
|
targetDate: json['targetDate'] == null || json['targetDate'] == ""
|
||||||
|
? null
|
||||||
|
: DateTime.tryParse(json['targetDate']),
|
||||||
|
colourHex: json['colourHex'] ?? "",
|
||||||
|
objectives: getGroupObjectiveList(json['objectives']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
final Map<String, dynamic> data = <String, dynamic>{};
|
||||||
|
data['id'] = id;
|
||||||
|
data['title'] = title;
|
||||||
|
data['description'] = description;
|
||||||
|
data['targetDate'] = targetDate?.toIso8601String();
|
||||||
|
data['colourHex'] = colourHex;
|
||||||
|
data['objectives'] = objectives.map((item) => item.toJson()).toList();
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
64
frontend/lib/Grounded/about/external/data/GroupMember.dart
vendored
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
/// Someone in a group, with the numbers the leaderboard ranks on.
|
||||||
|
class GroupMember {
|
||||||
|
String id;
|
||||||
|
|
||||||
|
String name;
|
||||||
|
|
||||||
|
/// "Owner" or "Member".
|
||||||
|
String role;
|
||||||
|
|
||||||
|
/// Share of their own commitments kept over the last seven days, 0..1.
|
||||||
|
double keptRate;
|
||||||
|
|
||||||
|
/// Kept days in a row.
|
||||||
|
int streak;
|
||||||
|
|
||||||
|
/// Their standing name — Good, Warned, Grounded, Lockdown.
|
||||||
|
String standing;
|
||||||
|
|
||||||
|
DateTime? lastCheckIn;
|
||||||
|
|
||||||
|
/// True for the signed-in user.
|
||||||
|
bool me;
|
||||||
|
|
||||||
|
GroupMember({
|
||||||
|
this.id = "",
|
||||||
|
this.name = "",
|
||||||
|
this.role = "Member",
|
||||||
|
this.keptRate = 0,
|
||||||
|
this.streak = 0,
|
||||||
|
this.standing = "Good",
|
||||||
|
this.lastCheckIn,
|
||||||
|
this.me = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
bool get owner => role == "Owner";
|
||||||
|
|
||||||
|
factory GroupMember.fromJson(Map<String, dynamic> json) {
|
||||||
|
return GroupMember(
|
||||||
|
id: json['id'] ?? "",
|
||||||
|
name: json['name'] ?? "",
|
||||||
|
role: json['role'] ?? "Member",
|
||||||
|
keptRate: (json['keptRate'] ?? 0).toDouble(),
|
||||||
|
streak: json['streak'] ?? 0,
|
||||||
|
standing: json['standing'] ?? "Good",
|
||||||
|
lastCheckIn: json['lastCheckIn'] == null
|
||||||
|
? null
|
||||||
|
: DateTime.tryParse(json['lastCheckIn']),
|
||||||
|
me: json['me'] ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
final Map<String, dynamic> data = <String, dynamic>{};
|
||||||
|
data['id'] = id;
|
||||||
|
data['name'] = name;
|
||||||
|
data['role'] = role;
|
||||||
|
data['keptRate'] = keptRate;
|
||||||
|
data['streak'] = streak;
|
||||||
|
data['standing'] = standing;
|
||||||
|
data['lastCheckIn'] = lastCheckIn?.toIso8601String();
|
||||||
|
data['me'] = me;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
66
frontend/lib/Grounded/about/external/data/GroupMessage.dart
vendored
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
/// One line in a group chat. Besides plain text, the app posts check-ins
|
||||||
|
/// (progress logged on an objective), nudges and system notes into the same
|
||||||
|
/// thread so the conversation and the work sit together.
|
||||||
|
class GroupMessage {
|
||||||
|
String id;
|
||||||
|
|
||||||
|
String groupId;
|
||||||
|
|
||||||
|
String authorId;
|
||||||
|
|
||||||
|
String authorName;
|
||||||
|
|
||||||
|
String body;
|
||||||
|
|
||||||
|
/// "Text", "CheckIn", "Nudge" or "System".
|
||||||
|
String kind;
|
||||||
|
|
||||||
|
DateTime? sentAt;
|
||||||
|
|
||||||
|
/// True when the signed-in user wrote it.
|
||||||
|
bool mine;
|
||||||
|
|
||||||
|
GroupMessage({
|
||||||
|
this.id = "",
|
||||||
|
this.groupId = "",
|
||||||
|
this.authorId = "",
|
||||||
|
this.authorName = "",
|
||||||
|
this.body = "",
|
||||||
|
this.kind = "Text",
|
||||||
|
this.sentAt,
|
||||||
|
this.mine = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
bool get isCheckIn => kind == "CheckIn";
|
||||||
|
|
||||||
|
bool get isNudge => kind == "Nudge";
|
||||||
|
|
||||||
|
bool get isSystem => kind == "System";
|
||||||
|
|
||||||
|
factory GroupMessage.fromJson(Map<String, dynamic> json) {
|
||||||
|
return GroupMessage(
|
||||||
|
id: json['id'] ?? "",
|
||||||
|
groupId: json['groupId'] ?? "",
|
||||||
|
authorId: json['authorId'] ?? "",
|
||||||
|
authorName: json['authorName'] ?? "",
|
||||||
|
body: json['body'] ?? "",
|
||||||
|
kind: json['kind'] ?? "Text",
|
||||||
|
sentAt:
|
||||||
|
json['sentAt'] == null ? null : DateTime.tryParse(json['sentAt']),
|
||||||
|
mine: json['mine'] ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
final Map<String, dynamic> data = <String, dynamic>{};
|
||||||
|
data['id'] = id;
|
||||||
|
data['groupId'] = groupId;
|
||||||
|
data['authorId'] = authorId;
|
||||||
|
data['authorName'] = authorName;
|
||||||
|
data['body'] = body;
|
||||||
|
data['kind'] = kind;
|
||||||
|
data['sentAt'] = sentAt?.toIso8601String();
|
||||||
|
data['mine'] = mine;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
58
frontend/lib/Grounded/about/external/data/GroupObjective.dart
vendored
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
/// One measurable part of a shared goal — "Run 100 km", "20 sessions" — that
|
||||||
|
/// every member logs toward.
|
||||||
|
class GroupObjective {
|
||||||
|
String id;
|
||||||
|
|
||||||
|
String title;
|
||||||
|
|
||||||
|
/// km, sessions, pages, hours…
|
||||||
|
String unit;
|
||||||
|
|
||||||
|
double target;
|
||||||
|
|
||||||
|
/// The group's total so far.
|
||||||
|
double current;
|
||||||
|
|
||||||
|
/// What the signed-in user has put in.
|
||||||
|
double mine;
|
||||||
|
|
||||||
|
GroupObjective({
|
||||||
|
this.id = "",
|
||||||
|
this.title = "",
|
||||||
|
this.unit = "",
|
||||||
|
this.target = 0,
|
||||||
|
this.current = 0,
|
||||||
|
this.mine = 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
double get progress {
|
||||||
|
if (target <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return (current / target).clamp(0.0, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get complete => target > 0 && current >= target;
|
||||||
|
|
||||||
|
factory GroupObjective.fromJson(Map<String, dynamic> json) {
|
||||||
|
return GroupObjective(
|
||||||
|
id: json['id'] ?? "",
|
||||||
|
title: json['title'] ?? "",
|
||||||
|
unit: json['unit'] ?? "",
|
||||||
|
target: (json['target'] ?? 0).toDouble(),
|
||||||
|
current: (json['current'] ?? 0).toDouble(),
|
||||||
|
mine: (json['mine'] ?? 0).toDouble(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
final Map<String, dynamic> data = <String, dynamic>{};
|
||||||
|
data['id'] = id;
|
||||||
|
data['title'] = title;
|
||||||
|
data['unit'] = unit;
|
||||||
|
data['target'] = target;
|
||||||
|
data['current'] = current;
|
||||||
|
data['mine'] = mine;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,9 @@
|
|||||||
|
import '../../internal/application/CommitmentClass.dart';
|
||||||
|
import '../../internal/application/RoutineKind.dart';
|
||||||
|
import '../../internal/application/StepMode.dart';
|
||||||
|
import '../../../utils/ObjectConvertors.dart';
|
||||||
|
import 'RoutineStep.dart';
|
||||||
|
|
||||||
/// An ordered sequence where the chain, not the step, is the unit of
|
/// An ordered sequence where the chain, not the step, is the unit of
|
||||||
/// completion. Breaking mid-way logs partial.
|
/// completion. Breaking mid-way logs partial.
|
||||||
class RoutineChain {
|
class RoutineChain {
|
||||||
@@ -5,6 +11,24 @@ class RoutineChain {
|
|||||||
|
|
||||||
String title;
|
String title;
|
||||||
|
|
||||||
|
/// The goal this routine serves — "Workout" holds "Full body strength".
|
||||||
|
/// Null for a routine that stands on its own.
|
||||||
|
String? goalId;
|
||||||
|
|
||||||
|
RoutineKind kind;
|
||||||
|
|
||||||
|
/// Weight class, exactly as for a commitment — a non-negotiable morning
|
||||||
|
/// routine carries non-negotiable debt when it breaks.
|
||||||
|
CommitmentClass commitmentClass;
|
||||||
|
|
||||||
|
/// 1..7, Monday first. Empty means unscheduled — run on demand only.
|
||||||
|
List<int> days;
|
||||||
|
|
||||||
|
/// Minutes after midnight the routine is due to start; -1 when unset.
|
||||||
|
int startMinute;
|
||||||
|
|
||||||
|
String note;
|
||||||
|
|
||||||
List<RoutineStep> steps;
|
List<RoutineStep> steps;
|
||||||
|
|
||||||
/// Index of the step reached when the chain last broke; -1 when clean.
|
/// Index of the step reached when the chain last broke; -1 when clean.
|
||||||
@@ -13,19 +37,30 @@ class RoutineChain {
|
|||||||
RoutineChain({
|
RoutineChain({
|
||||||
this.id,
|
this.id,
|
||||||
this.title = "",
|
this.title = "",
|
||||||
|
this.goalId,
|
||||||
|
this.kind = RoutineKind.Workout,
|
||||||
|
this.commitmentClass = CommitmentClass.Standard,
|
||||||
|
List<int>? days,
|
||||||
|
this.startMinute = -1,
|
||||||
|
this.note = "",
|
||||||
List<RoutineStep>? steps,
|
List<RoutineStep>? steps,
|
||||||
this.lastBreakIndex = -1,
|
this.lastBreakIndex = -1,
|
||||||
}) : steps = steps ?? <RoutineStep>[];
|
}) : days = days ?? <int>[],
|
||||||
|
steps = steps ?? <RoutineStep>[];
|
||||||
|
|
||||||
factory RoutineChain.fromJson(Map<String, dynamic> json) {
|
factory RoutineChain.fromJson(Map<String, dynamic> json) {
|
||||||
return RoutineChain(
|
return RoutineChain(
|
||||||
id: json['id'],
|
id: json['id'],
|
||||||
title: json['title'] ?? "",
|
title: json['title'] ?? "",
|
||||||
steps: json['steps'] == null
|
goalId: json['goalId'],
|
||||||
? <RoutineStep>[]
|
kind: getRoutineKind(json['kind']),
|
||||||
: (json['steps'] as List)
|
commitmentClass: getCommitmentClass(json['commitmentClass']),
|
||||||
.map((item) => RoutineStep.fromJson(item))
|
days: json['days'] == null
|
||||||
.toList(),
|
? <int>[]
|
||||||
|
: (json['days'] as List).map((item) => item as int).toList(),
|
||||||
|
startMinute: json['startMinute'] ?? -1,
|
||||||
|
note: json['note'] ?? "",
|
||||||
|
steps: getRoutineStepList(json['steps']),
|
||||||
lastBreakIndex: json['lastBreakIndex'] ?? -1,
|
lastBreakIndex: json['lastBreakIndex'] ?? -1,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -34,6 +69,12 @@ class RoutineChain {
|
|||||||
final Map<String, dynamic> data = <String, dynamic>{};
|
final Map<String, dynamic> data = <String, dynamic>{};
|
||||||
data['id'] = id;
|
data['id'] = id;
|
||||||
data['title'] = title;
|
data['title'] = title;
|
||||||
|
data['goalId'] = goalId;
|
||||||
|
data['kind'] = kind.name;
|
||||||
|
data['commitmentClass'] = commitmentClass.name;
|
||||||
|
data['days'] = days;
|
||||||
|
data['startMinute'] = startMinute;
|
||||||
|
data['note'] = note;
|
||||||
data['steps'] = steps.map((item) => item.toJson()).toList();
|
data['steps'] = steps.map((item) => item.toJson()).toList();
|
||||||
data['lastBreakIndex'] = lastBreakIndex;
|
data['lastBreakIndex'] = lastBreakIndex;
|
||||||
return data;
|
return data;
|
||||||
@@ -54,44 +95,40 @@ class RoutineChain {
|
|||||||
bool get complete {
|
bool get complete {
|
||||||
return steps.isNotEmpty && completedSteps == steps.length;
|
return steps.isNotEmpty && completedSteps == steps.length;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
class RoutineStep {
|
bool get scheduled {
|
||||||
String? id;
|
return days.isNotEmpty;
|
||||||
|
|
||||||
String title;
|
|
||||||
|
|
||||||
int order;
|
|
||||||
|
|
||||||
int timerSeconds;
|
|
||||||
|
|
||||||
bool completed;
|
|
||||||
|
|
||||||
RoutineStep({
|
|
||||||
this.id,
|
|
||||||
this.title = "",
|
|
||||||
this.order = 0,
|
|
||||||
this.timerSeconds = 0,
|
|
||||||
this.completed = false,
|
|
||||||
});
|
|
||||||
|
|
||||||
factory RoutineStep.fromJson(Map<String, dynamic> json) {
|
|
||||||
return RoutineStep(
|
|
||||||
id: json['id'],
|
|
||||||
title: json['title'] ?? "",
|
|
||||||
order: json['order'] ?? 0,
|
|
||||||
timerSeconds: json['timerSeconds'] ?? 0,
|
|
||||||
completed: json['completed'] ?? false,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
bool scheduledOn(DateTime day) {
|
||||||
final Map<String, dynamic> data = <String, dynamic>{};
|
return days.contains(day.weekday);
|
||||||
data['id'] = id;
|
}
|
||||||
data['title'] = title;
|
|
||||||
data['order'] = order;
|
/// Every set of every step, for the "12 sets" figure.
|
||||||
data['timerSeconds'] = timerSeconds;
|
int get totalSets {
|
||||||
data['completed'] = completed;
|
int total = 0;
|
||||||
return data;
|
for (RoutineStep step in steps) {
|
||||||
|
total = total + step.sets;
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Planned seconds, rest included. Rep and tick steps count their estimate.
|
||||||
|
int get totalSeconds {
|
||||||
|
int total = 0;
|
||||||
|
for (RoutineStep step in steps) {
|
||||||
|
total = total + step.plannedSeconds;
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
int get timedSteps {
|
||||||
|
return steps.where((step) => step.mode == StepMode.Timed).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A deep copy for the builder, so cancelling an edit leaves the library
|
||||||
|
/// untouched.
|
||||||
|
RoutineChain copy() {
|
||||||
|
return RoutineChain.fromJson(toJson());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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;
|
||||||
|
|||||||
40
frontend/lib/Grounded/about/external/initial/GroupGoalRequest.dart
vendored
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import 'ObjectiveDraft.dart';
|
||||||
|
|
||||||
|
/// Creates a shared goal in a group, objectives and all.
|
||||||
|
class GroupGoalRequest {
|
||||||
|
String groupId;
|
||||||
|
|
||||||
|
String? id;
|
||||||
|
|
||||||
|
String title;
|
||||||
|
|
||||||
|
String description;
|
||||||
|
|
||||||
|
String targetDate;
|
||||||
|
|
||||||
|
String colourHex;
|
||||||
|
|
||||||
|
List<ObjectiveDraft> objectives;
|
||||||
|
|
||||||
|
GroupGoalRequest({
|
||||||
|
this.groupId = "",
|
||||||
|
this.id,
|
||||||
|
this.title = "",
|
||||||
|
this.description = "",
|
||||||
|
this.targetDate = "",
|
||||||
|
this.colourHex = "",
|
||||||
|
List<ObjectiveDraft>? objectives,
|
||||||
|
}) : objectives = objectives ?? <ObjectiveDraft>[];
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
final Map<String, dynamic> data = <String, dynamic>{};
|
||||||
|
data['groupId'] = groupId;
|
||||||
|
data['id'] = id;
|
||||||
|
data['title'] = title;
|
||||||
|
data['description'] = description;
|
||||||
|
data['targetDate'] = targetDate;
|
||||||
|
data['colourHex'] = colourHex;
|
||||||
|
data['objectives'] = objectives.map((item) => item.toJson()).toList();
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
18
frontend/lib/Grounded/about/external/initial/GroupMessageRequest.dart
vendored
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
/// Posts a message to a group chat.
|
||||||
|
class GroupMessageRequest {
|
||||||
|
String groupId;
|
||||||
|
|
||||||
|
String body;
|
||||||
|
|
||||||
|
String kind;
|
||||||
|
|
||||||
|
GroupMessageRequest({this.groupId = "", this.body = "", this.kind = "Text"});
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
final Map<String, dynamic> data = <String, dynamic>{};
|
||||||
|
data['groupId'] = groupId;
|
||||||
|
data['body'] = body;
|
||||||
|
data['kind'] = kind;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
21
frontend/lib/Grounded/about/external/initial/GroupRequest.dart
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
/// Creates a group, or renames one when [id] is set.
|
||||||
|
class GroupRequest {
|
||||||
|
String? id;
|
||||||
|
|
||||||
|
String name;
|
||||||
|
|
||||||
|
String description;
|
||||||
|
|
||||||
|
String colourHex;
|
||||||
|
|
||||||
|
GroupRequest({this.id, this.name = "", this.description = "", this.colourHex = ""});
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
final Map<String, dynamic> data = <String, dynamic>{};
|
||||||
|
data['id'] = id;
|
||||||
|
data['name'] = name;
|
||||||
|
data['description'] = description;
|
||||||
|
data['colourHex'] = colourHex;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
12
frontend/lib/Grounded/about/external/initial/JoinGroupRequest.dart
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/// Joins a group by the code a friend shared.
|
||||||
|
class JoinGroupRequest {
|
||||||
|
String inviteCode;
|
||||||
|
|
||||||
|
JoinGroupRequest({this.inviteCode = ""});
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
final Map<String, dynamic> data = <String, dynamic>{};
|
||||||
|
data['inviteCode'] = inviteCode;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
15
frontend/lib/Grounded/about/external/initial/NudgeRequest.dart
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
/// Nudges one member, or everyone when [memberId] is empty.
|
||||||
|
class NudgeRequest {
|
||||||
|
String groupId;
|
||||||
|
|
||||||
|
String memberId;
|
||||||
|
|
||||||
|
NudgeRequest({this.groupId = "", this.memberId = ""});
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
final Map<String, dynamic> data = <String, dynamic>{};
|
||||||
|
data['groupId'] = groupId;
|
||||||
|
data['memberId'] = memberId;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
18
frontend/lib/Grounded/about/external/initial/ObjectiveDraft.dart
vendored
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
/// One objective as typed into the shared-goal editor.
|
||||||
|
class ObjectiveDraft {
|
||||||
|
String title;
|
||||||
|
|
||||||
|
String unit;
|
||||||
|
|
||||||
|
double target;
|
||||||
|
|
||||||
|
ObjectiveDraft({this.title = "", this.unit = "", this.target = 0});
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
final Map<String, dynamic> data = <String, dynamic>{};
|
||||||
|
data['title'] = title;
|
||||||
|
data['unit'] = unit;
|
||||||
|
data['target'] = target;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
24
frontend/lib/Grounded/about/external/initial/ObjectiveProgressRequest.dart
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
/// Logs an amount against one objective of a shared goal.
|
||||||
|
class ObjectiveProgressRequest {
|
||||||
|
String groupId;
|
||||||
|
|
||||||
|
String goalId;
|
||||||
|
|
||||||
|
String objectiveId;
|
||||||
|
|
||||||
|
double amount;
|
||||||
|
|
||||||
|
String note;
|
||||||
|
|
||||||
|
ObjectiveProgressRequest({this.groupId = "", this.goalId = "", this.objectiveId = "", this.amount = 0, this.note = ""});
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
final Map<String, dynamic> data = <String, dynamic>{};
|
||||||
|
data['groupId'] = groupId;
|
||||||
|
data['goalId'] = goalId;
|
||||||
|
data['objectiveId'] = objectiveId;
|
||||||
|
data['amount'] = amount;
|
||||||
|
data['note'] = note;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
45
frontend/lib/Grounded/about/external/initial/RoutineLogRequest.dart
vendored
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
/// One run of a routine. Sent whether the chain held or broke — a broken
|
||||||
|
/// chain is logged as partial, never silently dropped.
|
||||||
|
class RoutineLogRequest {
|
||||||
|
String routineId;
|
||||||
|
|
||||||
|
String startedAt;
|
||||||
|
|
||||||
|
/// Foreground seconds only. Backgrounding pauses the run.
|
||||||
|
int elapsedSeconds;
|
||||||
|
|
||||||
|
int completedSteps;
|
||||||
|
|
||||||
|
int totalSteps;
|
||||||
|
|
||||||
|
/// Step index the chain broke at; -1 when it held.
|
||||||
|
int breakIndex;
|
||||||
|
|
||||||
|
int backgroundedCount;
|
||||||
|
|
||||||
|
int skippedCount;
|
||||||
|
|
||||||
|
RoutineLogRequest({
|
||||||
|
this.routineId = "",
|
||||||
|
this.startedAt = "",
|
||||||
|
this.elapsedSeconds = 0,
|
||||||
|
this.completedSteps = 0,
|
||||||
|
this.totalSteps = 0,
|
||||||
|
this.breakIndex = -1,
|
||||||
|
this.backgroundedCount = 0,
|
||||||
|
this.skippedCount = 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
final Map<String, dynamic> data = <String, dynamic>{};
|
||||||
|
data['routineId'] = routineId;
|
||||||
|
data['startedAt'] = startedAt;
|
||||||
|
data['elapsedSeconds'] = elapsedSeconds;
|
||||||
|
data['completedSteps'] = completedSteps;
|
||||||
|
data['totalSteps'] = totalSteps;
|
||||||
|
data['breakIndex'] = breakIndex;
|
||||||
|
data['backgroundedCount'] = backgroundedCount;
|
||||||
|
data['skippedCount'] = skippedCount;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/// What a routine is for. The kind decides the runner's stage — a barbell
|
||||||
|
/// clock for work, a breathing circle for stillness, a checklist for the
|
||||||
|
/// things that simply have to get done.
|
||||||
|
enum RoutineKind {
|
||||||
|
/// Sets, reps and rest. Timed work auto-advances into rest.
|
||||||
|
Workout,
|
||||||
|
|
||||||
|
/// Long timed holds with a breathing cue. No reps, rarely any rest.
|
||||||
|
Meditation,
|
||||||
|
|
||||||
|
/// An ordered list of tasks that have to be done, in order, every time.
|
||||||
|
MustDo,
|
||||||
|
}
|
||||||
|
|
||||||
|
String kindLabel(RoutineKind value) {
|
||||||
|
switch (value) {
|
||||||
|
case RoutineKind.Workout:
|
||||||
|
return "Workout";
|
||||||
|
case RoutineKind.Meditation:
|
||||||
|
return "Meditation";
|
||||||
|
case RoutineKind.MustDo:
|
||||||
|
return "Must-do";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String kindDescription(RoutineKind value) {
|
||||||
|
switch (value) {
|
||||||
|
case RoutineKind.Workout:
|
||||||
|
return "Sets, reps and timed rest.";
|
||||||
|
case RoutineKind.Meditation:
|
||||||
|
return "Timed sits with a breathing cue.";
|
||||||
|
case RoutineKind.MustDo:
|
||||||
|
return "Tasks, in order, every time.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RoutineKind getRoutineKind(String? name) {
|
||||||
|
for (RoutineKind value in RoutineKind.values) {
|
||||||
|
if (value.name == name) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return RoutineKind.Workout;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import '../../external/data/RoutineStep.dart';
|
||||||
|
import 'StepMode.dart';
|
||||||
|
|
||||||
|
/// One beat of a running routine — a single work set or the rest after it.
|
||||||
|
/// The runner walks a flat list of these, so "set 2 of 4" and "rest" are the
|
||||||
|
/// same kind of thing to the clock.
|
||||||
|
class RoutinePhase {
|
||||||
|
/// Index of the step this phase belongs to.
|
||||||
|
final int stepIndex;
|
||||||
|
|
||||||
|
final RoutineStep step;
|
||||||
|
|
||||||
|
/// 1-based set number within the step.
|
||||||
|
final int set;
|
||||||
|
|
||||||
|
final bool rest;
|
||||||
|
|
||||||
|
RoutinePhase({
|
||||||
|
required this.stepIndex,
|
||||||
|
required this.step,
|
||||||
|
this.set = 1,
|
||||||
|
this.rest = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Seconds the clock runs for. Zero means the phase waits for a tap.
|
||||||
|
int get seconds {
|
||||||
|
if (rest) {
|
||||||
|
return step.restSeconds;
|
||||||
|
}
|
||||||
|
return step.mode == StepMode.Timed ? step.timerSeconds : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get countsDown {
|
||||||
|
return seconds > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get lastSet {
|
||||||
|
return set >= step.sets;
|
||||||
|
}
|
||||||
|
}
|
||||||
212
frontend/lib/Grounded/about/internal/application/RoutineRun.dart
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
import '../../external/data/RoutineChain.dart';
|
||||||
|
import '../../external/data/RoutineStep.dart';
|
||||||
|
import 'RoutinePhase.dart';
|
||||||
|
|
||||||
|
/// A routine being run. Like [LiveSession], every clock is derived from
|
||||||
|
/// wall-clock rather than counted by a ticker, so a screen-off period cannot
|
||||||
|
/// lose or invent time.
|
||||||
|
class RoutineRun {
|
||||||
|
final RoutineChain chain;
|
||||||
|
|
||||||
|
final List<RoutinePhase> phases;
|
||||||
|
|
||||||
|
int phaseIndex = 0;
|
||||||
|
|
||||||
|
final DateTime startedAt;
|
||||||
|
|
||||||
|
/// Banked foreground seconds for the whole run.
|
||||||
|
int _runSeconds = 0;
|
||||||
|
|
||||||
|
/// Banked foreground seconds for the current phase.
|
||||||
|
int _phaseSeconds = 0;
|
||||||
|
|
||||||
|
/// Start of the active stretch; null while paused.
|
||||||
|
DateTime? resumedAt;
|
||||||
|
|
||||||
|
/// Times the app was left mid-run. Shown afterwards, never hidden.
|
||||||
|
int backgroundedCount = 0;
|
||||||
|
|
||||||
|
/// Phases skipped forward without being done.
|
||||||
|
int skippedCount = 0;
|
||||||
|
|
||||||
|
RoutineRun(this.chain)
|
||||||
|
: phases = buildPhases(chain),
|
||||||
|
startedAt = DateTime.now();
|
||||||
|
|
||||||
|
/// Each set becomes a work phase; rest follows every set that has rest,
|
||||||
|
/// except after the final set of the final step.
|
||||||
|
static List<RoutinePhase> buildPhases(RoutineChain chain) {
|
||||||
|
final List<RoutinePhase> result = <RoutinePhase>[];
|
||||||
|
|
||||||
|
for (int index = 0; index < chain.steps.length; index++) {
|
||||||
|
final RoutineStep step = chain.steps[index];
|
||||||
|
final int sets = step.sets < 1 ? 1 : step.sets;
|
||||||
|
|
||||||
|
for (int set = 1; set <= sets; set++) {
|
||||||
|
result.add(RoutinePhase(stepIndex: index, step: step, set: set));
|
||||||
|
|
||||||
|
final bool finalBeat = index == chain.steps.length - 1 && set == sets;
|
||||||
|
if (step.restSeconds > 0 && !finalBeat) {
|
||||||
|
result.add(
|
||||||
|
RoutinePhase(stepIndex: index, step: step, set: set, rest: true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
RoutinePhase get phase {
|
||||||
|
return phases[phaseIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get running {
|
||||||
|
return resumedAt != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get onLastPhase {
|
||||||
|
return phaseIndex >= phases.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
int _live(DateTime? now) {
|
||||||
|
if (resumedAt == null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return (now ?? DateTime.now()).difference(resumedAt!).inSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
int elapsedSeconds({DateTime? now}) {
|
||||||
|
return _runSeconds + _live(now);
|
||||||
|
}
|
||||||
|
|
||||||
|
int phaseElapsed({DateTime? now}) {
|
||||||
|
return _phaseSeconds + _live(now);
|
||||||
|
}
|
||||||
|
|
||||||
|
int phaseRemaining({DateTime? now}) {
|
||||||
|
final int remaining = phase.seconds - phaseElapsed(now: now);
|
||||||
|
return remaining > 0 ? remaining : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 0..1 through the current phase; 0 for phases that wait for a tap.
|
||||||
|
double phaseProgress({DateTime? now}) {
|
||||||
|
if (!phase.countsDown) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
final double value = phaseElapsed(now: now) / phase.seconds;
|
||||||
|
return value > 1 ? 1 : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool phaseFinished({DateTime? now}) {
|
||||||
|
return phase.countsDown && phaseElapsed(now: now) >= phase.seconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seconds left in the whole run, by plan — elapsed work in this phase
|
||||||
|
/// counts, phases still ahead count at their planned length.
|
||||||
|
int plannedRemaining({DateTime? now}) {
|
||||||
|
int total = phase.countsDown ? phaseRemaining(now: now) : 0;
|
||||||
|
for (int index = phaseIndex + 1; index < phases.length; index++) {
|
||||||
|
total = total + phases[index].seconds;
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
void pause({DateTime? now}) {
|
||||||
|
if (resumedAt == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final int live = _live(now);
|
||||||
|
_runSeconds = _runSeconds + live;
|
||||||
|
_phaseSeconds = _phaseSeconds + live;
|
||||||
|
resumedAt = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void resume({DateTime? now}) {
|
||||||
|
if (resumedAt != null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resumedAt = now ?? DateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bank the current phase and start the next. Returns false at the end.
|
||||||
|
/// A step is marked complete when its last set's work phase is done.
|
||||||
|
bool advance({bool skipped = false, DateTime? now}) {
|
||||||
|
final DateTime moment = now ?? DateTime.now();
|
||||||
|
final bool wasRunning = running;
|
||||||
|
|
||||||
|
if (!phase.rest && phase.lastSet && !skipped) {
|
||||||
|
phase.step.completed = true;
|
||||||
|
}
|
||||||
|
if (skipped && !phase.rest) {
|
||||||
|
skippedCount = skippedCount + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
pause(now: moment);
|
||||||
|
_phaseSeconds = 0;
|
||||||
|
|
||||||
|
if (onLastPhase) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
phaseIndex = phaseIndex + 1;
|
||||||
|
if (wasRunning) {
|
||||||
|
resume(now: moment);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Back to the start of the previous work phase. Rest is never a target.
|
||||||
|
void back({DateTime? now}) {
|
||||||
|
final DateTime moment = now ?? DateTime.now();
|
||||||
|
final bool wasRunning = running;
|
||||||
|
|
||||||
|
pause(now: moment);
|
||||||
|
_phaseSeconds = 0;
|
||||||
|
|
||||||
|
int target = phaseIndex - 1;
|
||||||
|
while (target > 0 && phases[target].rest) {
|
||||||
|
target = target - 1;
|
||||||
|
}
|
||||||
|
phaseIndex = target < 0 ? 0 : target;
|
||||||
|
phases[phaseIndex].step.completed = false;
|
||||||
|
|
||||||
|
if (wasRunning) {
|
||||||
|
resume(now: moment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Jump to the first set of [stepIndex].
|
||||||
|
void jumpTo(int stepIndex, {DateTime? now}) {
|
||||||
|
final int target =
|
||||||
|
phases.indexWhere((item) => item.stepIndex == stepIndex && !item.rest);
|
||||||
|
if (target < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final DateTime moment = now ?? DateTime.now();
|
||||||
|
final bool wasRunning = running;
|
||||||
|
|
||||||
|
pause(now: moment);
|
||||||
|
_phaseSeconds = 0;
|
||||||
|
phaseIndex = target;
|
||||||
|
|
||||||
|
if (wasRunning) {
|
||||||
|
resume(now: moment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The step index the chain broke at, for a partial log; -1 when clean.
|
||||||
|
int get breakIndex {
|
||||||
|
return chain.complete ? -1 : phase.stepIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The next work phase after this one, for the "up next" line.
|
||||||
|
RoutinePhase? get nextWork {
|
||||||
|
for (int index = phaseIndex + 1; index < phases.length; index++) {
|
||||||
|
if (!phases[index].rest) {
|
||||||
|
return phases[index];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/// How a single routine step is satisfied.
|
||||||
|
enum StepMode {
|
||||||
|
/// A clock runs down and the step advances on its own.
|
||||||
|
Timed,
|
||||||
|
|
||||||
|
/// A rep target per set; the set ends when you say it does.
|
||||||
|
Reps,
|
||||||
|
|
||||||
|
/// A task. Ticked, not timed — the clock still records how long it took.
|
||||||
|
Check,
|
||||||
|
}
|
||||||
|
|
||||||
|
String stepModeLabel(StepMode value) {
|
||||||
|
switch (value) {
|
||||||
|
case StepMode.Timed:
|
||||||
|
return "Timed";
|
||||||
|
case StepMode.Reps:
|
||||||
|
return "Reps";
|
||||||
|
case StepMode.Check:
|
||||||
|
return "Tick";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StepMode getStepMode(String? name) {
|
||||||
|
for (StepMode value in StepMode.values) {
|
||||||
|
if (value.name == name) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return StepMode.Timed;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/// A quick to-do: no window, no class, no debt. Kept on the device so jotting
|
||||||
|
/// something down never waits on the network.
|
||||||
|
class TodoItem {
|
||||||
|
String id;
|
||||||
|
|
||||||
|
String title;
|
||||||
|
|
||||||
|
bool done;
|
||||||
|
|
||||||
|
TodoItem({required this.id, required this.title, this.done = false});
|
||||||
|
|
||||||
|
factory TodoItem.fromJson(Map<String, dynamic> json) {
|
||||||
|
return TodoItem(
|
||||||
|
id: json['id'] ?? '',
|
||||||
|
title: json['title'] ?? '',
|
||||||
|
done: json['done'] ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
final Map<String, dynamic> data = <String, dynamic>{};
|
||||||
|
data['id'] = id;
|
||||||
|
data['title'] = title;
|
||||||
|
data['done'] = done;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,12 @@ import '../about/external/initial/CompletionRequest.dart';
|
|||||||
import '../about/external/initial/DeferralRequest.dart';
|
import '../about/external/initial/DeferralRequest.dart';
|
||||||
import '../about/external/initial/DeviceRequest.dart';
|
import '../about/external/initial/DeviceRequest.dart';
|
||||||
import '../about/external/initial/GoalRequest.dart';
|
import '../about/external/initial/GoalRequest.dart';
|
||||||
|
import '../about/external/initial/GroupGoalRequest.dart';
|
||||||
|
import '../about/external/initial/GroupMessageRequest.dart';
|
||||||
|
import '../about/external/initial/GroupRequest.dart';
|
||||||
|
import '../about/external/initial/JoinGroupRequest.dart';
|
||||||
|
import '../about/external/initial/NudgeRequest.dart';
|
||||||
|
import '../about/external/initial/ObjectiveProgressRequest.dart';
|
||||||
import '../about/external/initial/IdRequest.dart';
|
import '../about/external/initial/IdRequest.dart';
|
||||||
import '../about/external/initial/LoginData.dart';
|
import '../about/external/initial/LoginData.dart';
|
||||||
import '../about/external/initial/ReportCardRequest.dart';
|
import '../about/external/initial/ReportCardRequest.dart';
|
||||||
@@ -388,6 +394,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, "");
|
||||||
@@ -505,4 +518,76 @@ class Comms implements ConnectComms {
|
|||||||
dio.options.headers = navigation.value;
|
dio.options.headers = navigation.value;
|
||||||
return await dio.post(navigation.key, data: request);
|
return await dio.post(navigation.key, data: request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Groups ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Response> getMyGroups(HistoryRequest request) async {
|
||||||
|
Pair navigation = await getRequestHeaders(myGroups, "");
|
||||||
|
dio.options.headers = navigation.value;
|
||||||
|
return await dio.post(navigation.key, data: request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Response> getGroupEntry(IdRequest request) async {
|
||||||
|
Pair navigation = await getRequestHeaders(oneGroup, "");
|
||||||
|
dio.options.headers = navigation.value;
|
||||||
|
return await dio.post(navigation.key, data: request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Response> saveGroupEntry(GroupRequest request) async {
|
||||||
|
Pair navigation = await getRequestHeaders(saveGroup, "");
|
||||||
|
dio.options.headers = navigation.value;
|
||||||
|
return await dio.post(navigation.key, data: request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Response> joinGroupEntry(JoinGroupRequest request) async {
|
||||||
|
Pair navigation = await getRequestHeaders(joinGroup, "");
|
||||||
|
dio.options.headers = navigation.value;
|
||||||
|
return await dio.post(navigation.key, data: request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Response> leaveGroupEntry(IdRequest request) async {
|
||||||
|
Pair navigation = await getRequestHeaders(leaveGroup, "");
|
||||||
|
dio.options.headers = navigation.value;
|
||||||
|
return await dio.post(navigation.key, data: request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Response> saveGroupGoalEntry(GroupGoalRequest request) async {
|
||||||
|
Pair navigation = await getRequestHeaders(saveGroupGoal, "");
|
||||||
|
dio.options.headers = navigation.value;
|
||||||
|
return await dio.post(navigation.key, data: request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Response> logObjectiveProgress(ObjectiveProgressRequest request) async {
|
||||||
|
Pair navigation = await getRequestHeaders(logObjective, "");
|
||||||
|
dio.options.headers = navigation.value;
|
||||||
|
return await dio.post(navigation.key, data: request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Response> getGroupMessages(IdRequest request) async {
|
||||||
|
Pair navigation = await getRequestHeaders(groupMessages, "");
|
||||||
|
dio.options.headers = navigation.value;
|
||||||
|
return await dio.post(navigation.key, data: request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Response> sendGroupMessage(GroupMessageRequest request) async {
|
||||||
|
Pair navigation = await getRequestHeaders(sendGroupMessageTo, "");
|
||||||
|
dio.options.headers = navigation.value;
|
||||||
|
return await dio.post(navigation.key, data: request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Response> nudgeGroupMember(NudgeRequest request) async {
|
||||||
|
Pair navigation = await getRequestHeaders(nudgeMember, "");
|
||||||
|
dio.options.headers = navigation.value;
|
||||||
|
return await dio.post(navigation.key, data: request);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -172,3 +172,24 @@ String myPartners = 'Partner/Mine';
|
|||||||
String invitePartner = 'Partner/Invite';
|
String invitePartner = 'Partner/Invite';
|
||||||
|
|
||||||
String partnerPolicy = 'Partner/Policy';
|
String partnerPolicy = 'Partner/Policy';
|
||||||
|
|
||||||
|
// ── Groups ────────────────────────────────────────────────────────────────────
|
||||||
|
String myGroups = 'Group/Mine';
|
||||||
|
|
||||||
|
String oneGroup = 'Group/One';
|
||||||
|
|
||||||
|
String saveGroup = 'Group/Save';
|
||||||
|
|
||||||
|
String joinGroup = 'Group/Join';
|
||||||
|
|
||||||
|
String leaveGroup = 'Group/Leave';
|
||||||
|
|
||||||
|
String saveGroupGoal = 'Group/Goal/Save';
|
||||||
|
|
||||||
|
String logObjective = 'Group/Objective/Log';
|
||||||
|
|
||||||
|
String groupMessages = 'Group/Messages';
|
||||||
|
|
||||||
|
String sendGroupMessageTo = 'Group/Message/Send';
|
||||||
|
|
||||||
|
String nudgeMember = 'Group/Nudge';
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ import '../about/external/initial/CompletionRequest.dart';
|
|||||||
import '../about/external/initial/DeferralRequest.dart';
|
import '../about/external/initial/DeferralRequest.dart';
|
||||||
import '../about/external/initial/DeviceRequest.dart';
|
import '../about/external/initial/DeviceRequest.dart';
|
||||||
import '../about/external/initial/GoalRequest.dart';
|
import '../about/external/initial/GoalRequest.dart';
|
||||||
|
import '../about/external/initial/GroupGoalRequest.dart';
|
||||||
|
import '../about/external/initial/GroupMessageRequest.dart';
|
||||||
|
import '../about/external/initial/GroupRequest.dart';
|
||||||
|
import '../about/external/initial/JoinGroupRequest.dart';
|
||||||
|
import '../about/external/initial/NudgeRequest.dart';
|
||||||
|
import '../about/external/initial/ObjectiveProgressRequest.dart';
|
||||||
import '../about/external/initial/IdRequest.dart';
|
import '../about/external/initial/IdRequest.dart';
|
||||||
import '../about/external/initial/LoginData.dart';
|
import '../about/external/initial/LoginData.dart';
|
||||||
import '../about/external/initial/ReportCardRequest.dart';
|
import '../about/external/initial/ReportCardRequest.dart';
|
||||||
@@ -108,6 +114,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 ────────────────────────────────────────────────────────────
|
||||||
@@ -142,4 +150,25 @@ abstract class ConnectComms {
|
|||||||
Future<Response> getMyNotifications(HistoryRequest request);
|
Future<Response> getMyNotifications(HistoryRequest request);
|
||||||
|
|
||||||
Future<Response> acknowledgeNudgeEntry(IdRequest request);
|
Future<Response> acknowledgeNudgeEntry(IdRequest request);
|
||||||
|
|
||||||
|
// ── Groups ──────────────────────────────────────────────────────────────
|
||||||
|
Future<Response> getMyGroups(HistoryRequest request);
|
||||||
|
|
||||||
|
Future<Response> getGroupEntry(IdRequest request);
|
||||||
|
|
||||||
|
Future<Response> saveGroupEntry(GroupRequest request);
|
||||||
|
|
||||||
|
Future<Response> joinGroupEntry(JoinGroupRequest request);
|
||||||
|
|
||||||
|
Future<Response> leaveGroupEntry(IdRequest request);
|
||||||
|
|
||||||
|
Future<Response> saveGroupGoalEntry(GroupGoalRequest request);
|
||||||
|
|
||||||
|
Future<Response> logObjectiveProgress(ObjectiveProgressRequest request);
|
||||||
|
|
||||||
|
Future<Response> getGroupMessages(IdRequest request);
|
||||||
|
|
||||||
|
Future<Response> sendGroupMessage(GroupMessageRequest request);
|
||||||
|
|
||||||
|
Future<Response> nudgeGroupMember(NudgeRequest request);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,7 +48,7 @@ class TickBox extends StatelessWidget {
|
|||||||
height: size,
|
height: size,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: checked ? tone : Colors.transparent,
|
color: checked ? tone : Colors.transparent,
|
||||||
borderRadius: BorderRadius.circular(7),
|
borderRadius: BorderRadius.zero,
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: checked ? tone : colorGrey.withValues(alpha: 0.55),
|
color: checked ? tone : colorGrey.withValues(alpha: 0.55),
|
||||||
width: 1.6,
|
width: 1.6,
|
||||||
@@ -202,7 +202,7 @@ Widget addTaskAffordance(
|
|||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: tone.withValues(alpha: 0.12),
|
color: tone.withValues(alpha: 0.12),
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.zero,
|
||||||
border: Border.all(color: tone.withValues(alpha: 0.25), width: 1),
|
border: Border.all(color: tone.withValues(alpha: 0.25), width: 1),
|
||||||
),
|
),
|
||||||
child: Icon(Icons.add_rounded, size: 22, color: tone),
|
child: Icon(Icons.add_rounded, size: 22, color: tone),
|
||||||
|
|||||||
19
frontend/lib/Grounded/designs/ChromeTone.dart
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
/// Tells controls placed in a [Sheet]'s chrome whether they sit on the light
|
||||||
|
/// ambient backdrop or on a coloured stage, so they pick glass to match.
|
||||||
|
class ChromeTone extends InheritedWidget {
|
||||||
|
final bool light;
|
||||||
|
|
||||||
|
const ChromeTone({super.key, required this.light, required super.child});
|
||||||
|
|
||||||
|
static bool isLight(BuildContext context) {
|
||||||
|
return context.dependOnInheritedWidgetOfExactType<ChromeTone>()?.light ??
|
||||||
|
false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool updateShouldNotify(covariant ChromeTone oldWidget) {
|
||||||
|
return oldWidget.light != light;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,7 +32,7 @@ Widget pill(
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: background,
|
color: background,
|
||||||
borderRadius: BorderRadius.circular(999),
|
borderRadius: BorderRadius.zero,
|
||||||
border: Border.all(color: foreground.withValues(alpha: 0.20), width: 1),
|
border: Border.all(color: foreground.withValues(alpha: 0.20), width: 1),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -57,11 +57,11 @@ Widget pill(
|
|||||||
/// The standard card surface.
|
/// The standard card surface.
|
||||||
Widget card({
|
Widget card({
|
||||||
required Widget child,
|
required Widget child,
|
||||||
EdgeInsets padding = const EdgeInsets.all(16),
|
EdgeInsets padding = const EdgeInsets.all(20),
|
||||||
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(
|
||||||
@@ -70,13 +70,13 @@ Widget card({
|
|||||||
margin: margin,
|
margin: margin,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: background ?? colorCard,
|
color: background ?? colorCard,
|
||||||
borderRadius: BorderRadius.circular(radius),
|
borderRadius: BorderRadius.zero,
|
||||||
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),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -159,7 +159,6 @@ Widget emptyState(
|
|||||||
height: 76,
|
height: 76,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: tone.withValues(alpha: 0.08),
|
color: tone.withValues(alpha: 0.08),
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
),
|
||||||
child: Icon(icon, size: 32, color: tone),
|
child: Icon(icon, size: 32, color: tone),
|
||||||
),
|
),
|
||||||
@@ -191,7 +190,7 @@ Widget meter(
|
|||||||
: fraction;
|
: fraction;
|
||||||
|
|
||||||
return ClipRRect(
|
return ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(999),
|
borderRadius: BorderRadius.zero,
|
||||||
child: LinearProgressIndicator(
|
child: LinearProgressIndicator(
|
||||||
value: clamped,
|
value: clamped,
|
||||||
minHeight: height,
|
minHeight: height,
|
||||||
|
|||||||
175
frontend/lib/Grounded/designs/DotDigits.dart
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../utils/Colors.dart';
|
||||||
|
|
||||||
|
/// A clock face drawn as a string of dots traced along each digit's outline —
|
||||||
|
/// the runner's signature readout. Only digits and ':' are drawn.
|
||||||
|
class DotDigits extends StatelessWidget {
|
||||||
|
final String value;
|
||||||
|
|
||||||
|
/// Glyph height in logical pixels.
|
||||||
|
final double height;
|
||||||
|
|
||||||
|
final Color? color;
|
||||||
|
|
||||||
|
const DotDigits({
|
||||||
|
super.key,
|
||||||
|
required this.value,
|
||||||
|
this.height = 64,
|
||||||
|
this.color,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final _DotDigitsPainter painter =
|
||||||
|
_DotDigitsPainter(value, height, color ?? colorInk);
|
||||||
|
|
||||||
|
return CustomPaint(size: painter.measure(), painter: painter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DotDigitsPainter extends CustomPainter {
|
||||||
|
final String value;
|
||||||
|
|
||||||
|
final double height;
|
||||||
|
|
||||||
|
final Color color;
|
||||||
|
|
||||||
|
_DotDigitsPainter(this.value, this.height, this.color);
|
||||||
|
|
||||||
|
/// Glyphs live in a 1 × 1.6 box.
|
||||||
|
double get _scale => height / 1.6;
|
||||||
|
|
||||||
|
double get _digitWidth => _scale * 1.0;
|
||||||
|
|
||||||
|
double get _colonWidth => _scale * 0.5;
|
||||||
|
|
||||||
|
double get _tracking => _scale * 0.34;
|
||||||
|
|
||||||
|
Size measure() {
|
||||||
|
double width = 0;
|
||||||
|
for (int i = 0; i < value.length; i++) {
|
||||||
|
width = width + (value[i] == ':' ? _colonWidth : _digitWidth);
|
||||||
|
if (i != value.length - 1) {
|
||||||
|
width = width + _tracking;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Size(width + height * 0.08, height + height * 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
final double dot = height * 0.036;
|
||||||
|
final double gap = height * 0.082;
|
||||||
|
final Paint paint = Paint()..color = color;
|
||||||
|
|
||||||
|
canvas.translate(height * 0.04, height * 0.04);
|
||||||
|
|
||||||
|
double x = 0;
|
||||||
|
for (int i = 0; i < value.length; i++) {
|
||||||
|
final String glyph = value[i];
|
||||||
|
|
||||||
|
if (glyph == ':') {
|
||||||
|
canvas.drawCircle(
|
||||||
|
Offset(x + _colonWidth / 2, _scale * 0.52), dot * 1.25, paint);
|
||||||
|
canvas.drawCircle(
|
||||||
|
Offset(x + _colonWidth / 2, _scale * 1.12), dot * 1.25, paint);
|
||||||
|
x = x + _colonWidth + _tracking;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final Path? outline = _glyph(glyph);
|
||||||
|
if (outline != null) {
|
||||||
|
final Path placed = outline.transform(
|
||||||
|
(Matrix4.identity()
|
||||||
|
..translateByDouble(x, 0, 0, 1)
|
||||||
|
..scaleByDouble(_scale, _scale, 1, 1))
|
||||||
|
.storage);
|
||||||
|
|
||||||
|
for (PathMetric metric in placed.computeMetrics()) {
|
||||||
|
final int count = (metric.length / gap).floor().clamp(1, 400);
|
||||||
|
final double step = metric.length / count;
|
||||||
|
final int last = metric.isClosed ? count - 1 : count;
|
||||||
|
for (int n = 0; n <= last; n++) {
|
||||||
|
final Tangent? tangent = metric.getTangentForOffset(n * step);
|
||||||
|
if (tangent != null) {
|
||||||
|
canvas.drawCircle(tangent.position, dot, paint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
x = x + _digitWidth + _tracking;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Path? _glyph(String glyph) {
|
||||||
|
switch (glyph) {
|
||||||
|
case '0':
|
||||||
|
return Path()
|
||||||
|
..addRRect(RRect.fromRectAndRadius(
|
||||||
|
const Rect.fromLTWH(0, 0, 1, 1.6), const Radius.circular(0.5)));
|
||||||
|
case '1':
|
||||||
|
return Path()
|
||||||
|
..moveTo(0.22, 0.32)
|
||||||
|
..lineTo(0.6, 0)
|
||||||
|
..lineTo(0.6, 1.6);
|
||||||
|
case '2':
|
||||||
|
return Path()
|
||||||
|
..moveTo(0.03, 0.4)
|
||||||
|
..cubicTo(0.06, 0.02, 0.97, 0.0, 0.97, 0.44)
|
||||||
|
..cubicTo(0.97, 0.8, 0.05, 1.08, 0.0, 1.6)
|
||||||
|
..lineTo(1.0, 1.6);
|
||||||
|
case '3':
|
||||||
|
return Path()
|
||||||
|
..moveTo(0.04, 0.26)
|
||||||
|
..cubicTo(0.2, -0.06, 0.95, 0.0, 0.95, 0.4)
|
||||||
|
..cubicTo(0.95, 0.68, 0.66, 0.78, 0.4, 0.78)
|
||||||
|
..cubicTo(0.76, 0.78, 1.0, 0.92, 1.0, 1.2)
|
||||||
|
..cubicTo(1.0, 1.66, 0.18, 1.7, 0.0, 1.34);
|
||||||
|
case '4':
|
||||||
|
return Path()
|
||||||
|
..moveTo(0.76, 1.6)
|
||||||
|
..lineTo(0.76, 0.0)
|
||||||
|
..lineTo(0.0, 1.12)
|
||||||
|
..lineTo(1.0, 1.12);
|
||||||
|
case '5':
|
||||||
|
return Path()
|
||||||
|
..moveTo(0.92, 0.0)
|
||||||
|
..lineTo(0.14, 0.0)
|
||||||
|
..lineTo(0.06, 0.72)
|
||||||
|
..cubicTo(0.36, 0.54, 1.0, 0.6, 1.0, 1.08)
|
||||||
|
..cubicTo(1.0, 1.66, 0.2, 1.7, 0.0, 1.36);
|
||||||
|
case '6':
|
||||||
|
return Path()
|
||||||
|
..moveTo(0.86, 0.14)
|
||||||
|
..cubicTo(0.44, -0.12, 0.0, 0.24, 0.0, 1.1)
|
||||||
|
..addOval(const Rect.fromLTWH(0.0, 0.66, 1.0, 0.94));
|
||||||
|
case '7':
|
||||||
|
return Path()
|
||||||
|
..moveTo(0.0, 0.0)
|
||||||
|
..lineTo(1.0, 0.0)
|
||||||
|
..lineTo(0.34, 1.6);
|
||||||
|
case '8':
|
||||||
|
return Path()
|
||||||
|
..addOval(const Rect.fromLTWH(0.08, 0.0, 0.84, 0.74))
|
||||||
|
..addOval(const Rect.fromLTWH(0.0, 0.74, 1.0, 0.86));
|
||||||
|
case '9':
|
||||||
|
return Path()
|
||||||
|
..addOval(const Rect.fromLTWH(0.0, 0.0, 1.0, 0.94))
|
||||||
|
..moveTo(1.0, 0.5)
|
||||||
|
..cubicTo(1.0, 1.36, 0.56, 1.72, 0.14, 1.46);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(covariant _DotDigitsPainter oldDelegate) {
|
||||||
|
return oldDelegate.value != value ||
|
||||||
|
oldDelegate.height != height ||
|
||||||
|
oldDelegate.color != color;
|
||||||
|
}
|
||||||
|
}
|
||||||
127
frontend/lib/Grounded/designs/FloatingNav.dart
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../about/internal/application/TextType.dart';
|
||||||
|
import '../utils/Colors.dart';
|
||||||
|
import 'GlassSurface.dart';
|
||||||
|
import 'text/Text.dart';
|
||||||
|
|
||||||
|
typedef NavEntry = ({IconData icon, String label, VoidCallback onTap});
|
||||||
|
|
||||||
|
/// The floating tab bar: the selected tab is a filled night block, the rest
|
||||||
|
/// sit quietly on white. An optional dark action button can sit beside it.
|
||||||
|
class FloatingNav extends StatelessWidget {
|
||||||
|
final List<NavEntry> entries;
|
||||||
|
|
||||||
|
final int selected;
|
||||||
|
|
||||||
|
final IconData? actionIcon;
|
||||||
|
|
||||||
|
final VoidCallback? onAction;
|
||||||
|
|
||||||
|
const FloatingNav({
|
||||||
|
super.key,
|
||||||
|
required this.entries,
|
||||||
|
required this.selected,
|
||||||
|
this.actionIcon,
|
||||||
|
this.onAction,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SafeArea(
|
||||||
|
top: false,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 14),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.zero,
|
||||||
|
boxShadow: const [
|
||||||
|
BoxShadow(
|
||||||
|
color: Color(0x1A1A1F1C),
|
||||||
|
blurRadius: 30,
|
||||||
|
offset: Offset(0, 10),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: GlassSurface(
|
||||||
|
radius: BorderRadius.zero,
|
||||||
|
tint: colorGlassStrong,
|
||||||
|
padding: const EdgeInsets.all(6),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
for (int index = 0; index < entries.length; index++)
|
||||||
|
Expanded(child: _tab(entries[index], index == selected)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (onAction != null) ...[
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: onAction,
|
||||||
|
child: Container(
|
||||||
|
width: 72,
|
||||||
|
height: 72,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: const RadialGradient(
|
||||||
|
center: Alignment(-0.3, -0.4),
|
||||||
|
colors: <Color>[colorNightGlow, colorNight],
|
||||||
|
),
|
||||||
|
border: Border.all(color: colorWhite, width: 3),
|
||||||
|
boxShadow: const [
|
||||||
|
BoxShadow(
|
||||||
|
color: Color(0x331A1F1C),
|
||||||
|
blurRadius: 24,
|
||||||
|
offset: Offset(0, 10),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Icon(actionIcon, size: 24, color: colorLime),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _tab(NavEntry entry, bool active) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: entry.onTap,
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 220),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: active ? colorHeaderBottom : colorWhite.withValues(alpha: 0.0),
|
||||||
|
borderRadius: BorderRadius.zero,
|
||||||
|
boxShadow: active
|
||||||
|
? const [
|
||||||
|
BoxShadow(
|
||||||
|
color: Color(0x141A1F1C),
|
||||||
|
blurRadius: 12,
|
||||||
|
offset: Offset(0, 4),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: const [],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(entry.icon,
|
||||||
|
size: 23, color: active ? colorWhite : colorInkSoft),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
text(entry.label, 10, active ? TextType.Medium : TextType.Regular,
|
||||||
|
color: active ? colorWhite : colorInkSoft, maxLines: 1),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
104
frontend/lib/Grounded/designs/GlassSurface.dart
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../utils/Colors.dart';
|
||||||
|
|
||||||
|
/// Frosted glass: the background blurred through a translucent white panel
|
||||||
|
/// with a bright hairline edge. Controls and floating chrome are built from it.
|
||||||
|
class GlassSurface extends StatelessWidget {
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
final BorderRadius radius;
|
||||||
|
|
||||||
|
final EdgeInsets padding;
|
||||||
|
|
||||||
|
final Color? tint;
|
||||||
|
|
||||||
|
/// Over a dark or coloured stage the panel goes smoky instead of milky.
|
||||||
|
final bool onDark;
|
||||||
|
|
||||||
|
final double blur;
|
||||||
|
|
||||||
|
const GlassSurface({
|
||||||
|
super.key,
|
||||||
|
required this.child,
|
||||||
|
this.radius = const BorderRadius.all(Radius.zero),
|
||||||
|
this.padding = EdgeInsets.zero,
|
||||||
|
this.tint,
|
||||||
|
this.onDark = false,
|
||||||
|
this.blur = 18,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ClipRRect(
|
||||||
|
borderRadius: radius,
|
||||||
|
child: BackdropFilter(
|
||||||
|
filter: ImageFilter.blur(sigmaX: blur, sigmaY: blur),
|
||||||
|
child: Container(
|
||||||
|
padding: padding,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: tint ??
|
||||||
|
(onDark ? colorWhite.withValues(alpha: 0.16) : colorGlass),
|
||||||
|
borderRadius: radius,
|
||||||
|
border: Border.all(
|
||||||
|
color: onDark
|
||||||
|
? colorWhite.withValues(alpha: 0.22)
|
||||||
|
: colorGlassBorder,
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A round frosted control — back, close, bell, more.
|
||||||
|
Widget glassCircle(
|
||||||
|
IconData icon,
|
||||||
|
VoidCallback onTap, {
|
||||||
|
double size = 50,
|
||||||
|
bool onDark = false,
|
||||||
|
bool dotted = false,
|
||||||
|
Color? dotColor,
|
||||||
|
}) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
child: Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
GlassSurface(
|
||||||
|
onDark: onDark,
|
||||||
|
radius: BorderRadius.zero,
|
||||||
|
child: SizedBox(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
child: Icon(
|
||||||
|
icon,
|
||||||
|
size: size * 0.42,
|
||||||
|
color: onDark ? colorWhite : colorInk,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (dotted)
|
||||||
|
Positioned(
|
||||||
|
top: 2,
|
||||||
|
right: 2,
|
||||||
|
child: Container(
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: dotColor ?? colorAccent,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: colorWhite, width: 1.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
127
frontend/lib/Grounded/designs/GoalPicker.dart
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import 'package:flutter/cupertino.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../about/external/data/Goal.dart';
|
||||||
|
import '../about/internal/application/TextType.dart';
|
||||||
|
import '../utils/Colors.dart';
|
||||||
|
import '../utils/CommonUtils.dart';
|
||||||
|
import 'text/Text.dart';
|
||||||
|
|
||||||
|
/// The goal chooser, shared by the task form and the routine builder. Opened
|
||||||
|
/// only once the goals have been loaded — `selectField` loads on tap.
|
||||||
|
///
|
||||||
|
/// Resolves to the chosen goal, to a bare `Goal()` (no id) for "no goal", or
|
||||||
|
/// to null when dismissed without a choice.
|
||||||
|
Future<Goal?> showGoalPicker(
|
||||||
|
BuildContext context,
|
||||||
|
List<Goal> goals, {
|
||||||
|
String? selectedId,
|
||||||
|
}) {
|
||||||
|
return showModalBottomSheet<Goal>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
barrierColor: colorBlack.withValues(alpha: 0.55),
|
||||||
|
builder: (BuildContext sheetContext) {
|
||||||
|
final List<Goal> active = goals.where((goal) => !goal.archived).toList();
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
maxHeight: MediaQuery.of(sheetContext).size.height * 0.75,
|
||||||
|
),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: colorSheetBackground,
|
||||||
|
borderRadius: BorderRadius.only(
|
||||||
|
topLeft: Radius.zero,
|
||||||
|
topRight: Radius.zero,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: ListView(
|
||||||
|
shrinkWrap: true,
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 24, 24, 32),
|
||||||
|
children: [
|
||||||
|
text("BELONGS TO", 9, TextType.Bold,
|
||||||
|
color: colorGrey2, letterSpacing: 1.2),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
text("Which goal is this for?", 24, TextType.Light,
|
||||||
|
color: colorPrimaryDark),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
_goalOption(
|
||||||
|
sheetContext,
|
||||||
|
Goal(),
|
||||||
|
"No goal",
|
||||||
|
"Stands on its own",
|
||||||
|
colorGrey,
|
||||||
|
selectedId == null,
|
||||||
|
),
|
||||||
|
...active.map((goal) => _goalOption(
|
||||||
|
sheetContext,
|
||||||
|
goal,
|
||||||
|
goal.title,
|
||||||
|
goal.targetDate == null
|
||||||
|
? "${goal.remainingTasks} open"
|
||||||
|
: "${goal.remainingTasks} open · ${targetLabel(goal.targetDate)}",
|
||||||
|
goalColor(goal.colourHex),
|
||||||
|
goal.id == selectedId,
|
||||||
|
)),
|
||||||
|
if (active.isEmpty) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
text("No goals yet. Create one from the Goals screen.", 12,
|
||||||
|
TextType.Regular,
|
||||||
|
color: colorGrey2),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _goalOption(
|
||||||
|
BuildContext sheetContext,
|
||||||
|
Goal goal,
|
||||||
|
String title,
|
||||||
|
String caption,
|
||||||
|
Color accent,
|
||||||
|
bool selected,
|
||||||
|
) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => Navigator.pop(sheetContext, goal),
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
|
padding: const EdgeInsets.fromLTRB(14, 13, 14, 13),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorWhite,
|
||||||
|
borderRadius: BorderRadius.zero,
|
||||||
|
border: Border.all(
|
||||||
|
color: selected ? accent.withValues(alpha: 0.5) : colorBorder,
|
||||||
|
width: selected ? 1.5 : 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
decoration: BoxDecoration(color: accent, shape: BoxShape.circle),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
text(title, 14, TextType.Medium, color: colorPrimaryDark),
|
||||||
|
const SizedBox(height: 3),
|
||||||
|
text(caption, 11, TextType.Regular, color: colorGrey2),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (selected)
|
||||||
|
Icon(CupertinoIcons.checkmark_alt, size: 17, color: accent),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
117
frontend/lib/Grounded/designs/GroundedMark.dart
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../utils/Colors.dart';
|
||||||
|
|
||||||
|
/// The Grounded mark: a lime sun resting on a ground line beneath two
|
||||||
|
/// breathing arcs, on a night-green tile. Drawn, not bitmapped, so the splash
|
||||||
|
/// can raise the sun and light the arcs. Geometry matches the launcher icon.
|
||||||
|
class GroundedMark extends StatelessWidget {
|
||||||
|
final double size;
|
||||||
|
|
||||||
|
/// 0 → the sun is below the line; 1 → fully risen.
|
||||||
|
final double rise;
|
||||||
|
|
||||||
|
/// 0 → arcs hidden; 1 → both lit. The inner arc lights first.
|
||||||
|
final double arcs;
|
||||||
|
|
||||||
|
final bool tile;
|
||||||
|
|
||||||
|
const GroundedMark({
|
||||||
|
super.key,
|
||||||
|
this.size = 120,
|
||||||
|
this.rise = 1,
|
||||||
|
this.arcs = 1,
|
||||||
|
this.tile = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return CustomPaint(
|
||||||
|
size: Size.square(size),
|
||||||
|
painter: _MarkPainter(rise, arcs, tile),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MarkPainter extends CustomPainter {
|
||||||
|
final double rise;
|
||||||
|
|
||||||
|
final double arcs;
|
||||||
|
|
||||||
|
final bool tile;
|
||||||
|
|
||||||
|
_MarkPainter(this.rise, this.arcs, this.tile);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
final double u = size.width / 1024;
|
||||||
|
final Rect bounds = Offset.zero & size;
|
||||||
|
|
||||||
|
if (tile) {
|
||||||
|
canvas.drawRRect(
|
||||||
|
RRect.fromRectAndRadius(bounds, Radius.circular(size.width * 0.235)),
|
||||||
|
Paint()
|
||||||
|
..shader = const LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: <Color>[colorNightLift, colorNight],
|
||||||
|
).createShader(bounds),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final double cx = 512 * u;
|
||||||
|
final double horizon = 630 * u;
|
||||||
|
|
||||||
|
// Arcs — inner then outer.
|
||||||
|
final List<double> radii = <double>[250, 330];
|
||||||
|
final List<double> alphas = <double>[0.6, 0.28];
|
||||||
|
for (int i = 0; i < 2; i++) {
|
||||||
|
final double lit = ((arcs * 2) - i).clamp(0.0, 1.0);
|
||||||
|
if (lit <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final double r = radii[i] * u;
|
||||||
|
canvas.drawArc(
|
||||||
|
Rect.fromCircle(center: Offset(cx, horizon), radius: r - 15 * u),
|
||||||
|
math.pi * 198 / 180,
|
||||||
|
math.pi * 144 / 180,
|
||||||
|
false,
|
||||||
|
Paint()
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = 30 * u
|
||||||
|
..strokeCap = StrokeCap.round
|
||||||
|
..color = colorAmbientMid.withValues(alpha: alphas[i] * lit),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The sun, clipped at the horizon so it rises out of the ground.
|
||||||
|
final double radius = 168 * u;
|
||||||
|
canvas.save();
|
||||||
|
canvas.clipRect(Rect.fromLTRB(0, 0, size.width, horizon));
|
||||||
|
canvas.drawCircle(
|
||||||
|
Offset(cx, horizon + radius * 2 * (1 - rise)),
|
||||||
|
radius,
|
||||||
|
Paint()..color = colorLime,
|
||||||
|
);
|
||||||
|
canvas.restore();
|
||||||
|
|
||||||
|
// Ground line.
|
||||||
|
final double ground = horizon + 52 * u;
|
||||||
|
canvas.drawRRect(
|
||||||
|
RRect.fromRectAndRadius(
|
||||||
|
Rect.fromLTRB(250 * u, ground - 20 * u, 774 * u, ground + 20 * u),
|
||||||
|
Radius.circular(20 * u),
|
||||||
|
),
|
||||||
|
Paint()..color = colorAmbientMid,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(covariant _MarkPainter oldDelegate) {
|
||||||
|
return oldDelegate.rise != rise ||
|
||||||
|
oldDelegate.arcs != arcs ||
|
||||||
|
oldDelegate.tile != tile;
|
||||||
|
}
|
||||||
|
}
|
||||||
74
frontend/lib/Grounded/designs/MemberAvatar.dart
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../about/internal/application/TextType.dart';
|
||||||
|
import '../utils/Colors.dart';
|
||||||
|
import '../utils/CommonUtils.dart';
|
||||||
|
import 'scene/SceneArt.dart';
|
||||||
|
import 'text/Text.dart';
|
||||||
|
|
||||||
|
/// A member's square portrait: a landscape picked from their name with the
|
||||||
|
/// initial on it, so everyone is recognisable without a photo.
|
||||||
|
Widget memberAvatar(String name, double size, {Color? border}) {
|
||||||
|
const List<List<Color>> palettes = <List<Color>>[
|
||||||
|
sceneWorkout,
|
||||||
|
sceneMeditation,
|
||||||
|
sceneMustDo,
|
||||||
|
];
|
||||||
|
final int seed = sceneSeed(name.isEmpty ? "?" : name);
|
||||||
|
final String initial =
|
||||||
|
name.isEmpty ? "?" : name.substring(0, 1).toUpperCase();
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
decoration:
|
||||||
|
BoxDecoration(border: Border.all(color: border ?? colorWhite, width: 2)),
|
||||||
|
child: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
SceneArt(palette: palettes[seed % palettes.length], seed: seed),
|
||||||
|
Center(
|
||||||
|
child: text(initial, size * 0.42, TextType.Medium, color: colorWhite),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Several portraits overlapping, with "+N" once there are more than fit.
|
||||||
|
Widget avatarStack(List<String> names, double size,
|
||||||
|
{int max = 4, Color? border}) {
|
||||||
|
final List<String> shown = names.take(max).toList();
|
||||||
|
final int extra = names.length - shown.length;
|
||||||
|
final double step = size * 0.68;
|
||||||
|
final int slots = shown.length + (extra > 0 ? 1 : 0);
|
||||||
|
|
||||||
|
return SizedBox(
|
||||||
|
width: slots == 0 ? 0 : size + step * (slots - 1),
|
||||||
|
height: size,
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
for (int index = 0; index < shown.length; index++)
|
||||||
|
Positioned(
|
||||||
|
left: step * index,
|
||||||
|
child: memberAvatar(shown[index], size, border: border),
|
||||||
|
),
|
||||||
|
if (extra > 0)
|
||||||
|
Positioned(
|
||||||
|
left: step * shown.length,
|
||||||
|
child: Container(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorNight,
|
||||||
|
border: Border.all(color: border ?? colorWhite, width: 2),
|
||||||
|
),
|
||||||
|
child: text("+$extra", size * 0.34, TextType.Medium,
|
||||||
|
color: colorWhite),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
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.zero,
|
||||||
topRight: Radius.circular(28),
|
topRight: Radius.zero,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
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),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _chromeButton(IconData icon, VoidCallback onTap) {
|
// A tinted stage: the colour deepens downward and a soft light blooms in
|
||||||
return GestureDetector(
|
// the corner, so it reads as lit rather than painted flat.
|
||||||
onTap: onTap,
|
return Scaffold(
|
||||||
child: Container(
|
backgroundColor: stage,
|
||||||
width: 38,
|
body: Stack(
|
||||||
height: 38,
|
children: [
|
||||||
alignment: Alignment.center,
|
Positioned.fill(
|
||||||
|
child: DecoratedBox(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colorWhite.withValues(alpha: 0.10),
|
gradient: LinearGradient(
|
||||||
shape: BoxShape.circle,
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: <Color>[
|
||||||
|
Color.lerp(stage, colorWhite, 0.14)!,
|
||||||
|
stage,
|
||||||
|
Color.lerp(stage, colorBlack, 0.35)!,
|
||||||
|
],
|
||||||
),
|
),
|
||||||
child: Icon(icon, size: 15, color: colorWhite),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (scene != null)
|
||||||
|
Positioned(
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: 250,
|
||||||
|
child: ShaderMask(
|
||||||
|
shaderCallback: (Rect bounds) => const LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: <Color>[Color(0xFF000000), Color(0x00000000)],
|
||||||
|
stops: <double>[0.6, 1.0],
|
||||||
|
).createShader(bounds),
|
||||||
|
blendMode: BlendMode.dstIn,
|
||||||
|
child: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
SceneArt(palette: scene!, seed: sceneSeed),
|
||||||
|
ColoredBox(color: colorBlack.withValues(alpha: 0.42)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
top: -120,
|
||||||
|
right: -90,
|
||||||
|
child: Container(
|
||||||
|
width: 300,
|
||||||
|
height: 300,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
gradient: RadialGradient(
|
||||||
|
colors: <Color>[
|
||||||
|
colorWhite.withValues(alpha: 0.16),
|
||||||
|
colorWhite.withValues(alpha: 0.0),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned.fill(child: column),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A circular control for the black chrome, with an optional unread dot.
|
/// A round frosted control for the chrome, with an optional unread dot. It
|
||||||
|
/// matches the stage it sits on.
|
||||||
Widget chromeAction(
|
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(
|
||||||
@@ -27,7 +27,7 @@ Widget roundedCornerButton(
|
|||||||
disabledBackgroundColor: colorGrey.withValues(alpha: 0.4),
|
disabledBackgroundColor: colorGrey.withValues(alpha: 0.4),
|
||||||
padding: EdgeInsets.symmetric(vertical: verticalPadding),
|
padding: EdgeInsets.symmetric(vertical: verticalPadding),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(radius),
|
borderRadius: BorderRadius.zero,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -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;
|
||||||
|
|
||||||
@@ -62,7 +65,7 @@ Widget outlinedActionButton(
|
|||||||
side: BorderSide(color: fg.withValues(alpha: 0.35), width: 1),
|
side: BorderSide(color: fg.withValues(alpha: 0.35), width: 1),
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(radius),
|
borderRadius: BorderRadius.zero,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -109,7 +112,7 @@ Widget iconButton(
|
|||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: background ?? Colors.transparent,
|
color: background ?? Colors.transparent,
|
||||||
borderRadius: BorderRadius.circular(radius),
|
borderRadius: BorderRadius.zero,
|
||||||
border: bordered
|
border: bordered
|
||||||
? Border.all(color: borderColor ?? colorBorder, width: 1)
|
? Border.all(color: borderColor ?? colorBorder, width: 1)
|
||||||
: null,
|
: null,
|
||||||
@@ -158,7 +161,7 @@ Widget segmentedSelector<T>({
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isSelected ? active : colorWhite,
|
color: isSelected ? active : colorWhite,
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.zero,
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: isSelected ? active : colorBorder,
|
color: isSelected ? active : colorBorder,
|
||||||
width: 1,
|
width: 1,
|
||||||
|
|||||||