Compare commits

7 Commits

Author SHA1 Message Date
alvocool
4b71257429 Add groups with shared goals, objectives and chat; drop the water tracker
Groups let people keep each other honest: create one or join with a
six-letter invite code. Each group has shared goals split into measurable
objectives (km, sessions, words) that every member logs toward, a
leaderboard of kept rate and streak with nudges, and a group chat where
check-ins and nudges appear as cards beside the talk. The open chat polls
for new messages.

Reached from a new Groups tab (Training moves into the day rail), a Home
"Your groups" rail that opens the chat, and a Group tile in the add sheet.
Mock mode seeds two groups, a joinable one and members who reply.

The water tracker is removed; it did not fit the app. Bottom sheets are
now square app-wide via the theme.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KhQh614DMeYoQEH9SoMRUw
2026-11-03 11:46:41 +03:00
alvocool
4548e1f164 Rebuild Home around a forest header with streak, score and water
Home now opens on a deep green header: greeting, a Today / Weekly toggle,
a seven-day streak with a leaf on every kept day, minutes held with a +
ring, plan thumbnails, the report-card letter as the score, and one bar per
commitment class (weekly view swaps in per-day bars and class rates). The
+ opens an "Add to today" sheet over the blurred day. A water tracker sits
below the header, kept on the device. The nav marks the selected tab as a
filled block and drops the side action button.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KhQh614DMeYoQEH9SoMRUw
2026-11-02 20:58:41 +03:00
alvocool
9c858b8146 Square every corner, enlarge the main surfaces and add a to-do list
Rounded rectangles and round controls are now square across the app; only
status dots, glows, the sun, rings and the breathing circle stay round.
Cards, rows, thumbnails, featured cards, category tiles, the standing card
and the nav are larger. Home gets a quick To-do list kept on the device
(TodoItem in InternalMemory), seeded with sample items in mock mode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KhQh614DMeYoQEH9SoMRUw
2026-11-02 14:50:31 +03:00
alvocool
36bc41509d Animate the splash and fix the web preview hanging on the logo
The splash now raises the sun in the mark and over a painted valley, floats
four feature chips around the mark, shimmers the wordmark, shows a three-beat
"how it works" card, drifts light motes upward and walks a status line
through the session check. A tap skips it.

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

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

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

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

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

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

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

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 B

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 B

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 31 KiB

View File

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

Before

Width:  |  Height:  |  Size: 760 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

View File

@@ -1,9 +1,34 @@
# Grounded — Design Language
Reference: black device chrome, a white content sheet that rises into it with a
large corner radius, oversized light display titles, tiny grey all-caps metadata
labels, pill chips, and a lot of whitespace. The app reads as a record, not a
dashboard.
Reference: an airy fitness-app look. Sage mist drifting into cream behind
everything (`AmbientBackground`), frosted glass for controls and floating
chrome (`GlassSurface`, `glassCircle`), ink type with a soft grey second
tone, painted landscapes instead of photography (`SceneArt`), a floating
glass tab bar with a dark square action button (`FloatingNav`), and one lime
spark on night-green for the primary control.
## Building blocks (`designs/`)
| Piece | Use |
|---|---|
| `AmbientBackground` | The backdrop for every light screen. |
| `GlassSurface` / `glassCircle` | Frosted panels and square controls; `onDark` over a scene or tinted stage. |
| `SceneArt` | Painted sky, sun and ridges with an optional figure glyph. Palettes come from `scenePalette(kind)`, `goalScene(accent)`, `classScene(class)`; seed with `sceneSeed(title)` so an item always wears the same picture. |
| `DotDigits` | The dotted clock in the routine player. |
| `FloatingNav` | Home's tab bar; the selected tab is a filled night block. The side action button is optional. |
| `GroundedMark` | The logo, drawn — a lime sun on a ground line under two arcs. The launcher PNGs in `assets/icons/` were rendered from the same geometry. |
Cards are image-led where an item has a kind (routines, goals, tasks): scene on
top or as a thumbnail, white type over the shaded lower half, three light
numbers along the bottom. Greetings are two-tone: Medium ink, then Regular
`colorInkSoft`. Primary buttons are square night bars with a lime icon.
`Sheet` is light (ambient) by default; passing `chrome`, `banner` or
`scene` gives a tinted stage with white type. Home, Routines and the player
build their own layouts from the same pieces.
The sections below describe the original record-style type scale, which still
applies to labels and metrics.
## Type — General Sans
@@ -20,7 +45,7 @@ directly above a large dark value. Used everywhere numbers appear.
## Geometry
- Sheet radius `28`, card radius `16`, chip radius `999`, control radius `12`.
- No rounded corners anywhere: sheets, cards, chips, buttons, fields, tiles and portraits are all square (`BorderRadius.zero`). Only small status dots, glows, the sun, progress rings and the breathing circle stay round.
- Screen padding `20` horizontal. Card padding `16`. Section gap `28`.
- Hairlines at `colorDivider` (black 7%), borders at `colorBorder` (black 8%).
- One soft shadow only: `0 4 18 rgba(0,0,0,0.03)`. No coloured glows.
@@ -44,7 +69,7 @@ the standing is legible without reading the word.
## Structure
Every screen is: black `headerBar` (back, title, action) → white sheet with a
`28` top radius → `sectionHeader` (label + light display title) → content.
square top edge → `sectionHeader` (label + light display title) → content.
## Rules
@@ -53,3 +78,34 @@ Every screen is: black `headerBar` (back, title, action) → white sheet with a
- Chips carry state, never actions.
- One primary button per screen; everything else is outlined or text.
- Empty states are plain. An empty overdue queue is good news, not a party.
## Home
Home opens on a deep forest header block (`colorHeaderTop` → `colorHeaderBottom`):
greeting and avatar, a Today / Weekly toggle, a seven-day streak panel ending
today (a leaf on every kept day, kept = 80%+), then the numbers. Today shows
minutes held against planned, a ring with + that opens the "Add to today"
sheet, the plan as thumbnails, the report-card letter as the Score, and one
bar per commitment class. Weekly swaps in the week's average, a bar per day
and the class completion rates. Below the block, on the light ambient
surface: the 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.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 473 B

After

Width:  |  Height:  |  Size: 492 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 934 B

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 683 B

After

Width:  |  Height:  |  Size: 831 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 934 B

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 B

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 44 KiB

View File

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

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

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

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

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

View File

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

View File

@@ -0,0 +1,75 @@
import '../../internal/application/StepMode.dart';
class RoutineStep {
String? id;
String title;
int order;
StepMode mode;
/// Work seconds per set for timed steps; the estimate per set otherwise.
int timerSeconds;
int sets;
/// Target per set for rep steps; 0 otherwise.
int reps;
/// Rest between sets, and after the last set before the next step.
int restSeconds;
/// One line shown under the clock — a form cue, a breathing pattern.
String cue;
bool completed;
RoutineStep({
this.id,
this.title = "",
this.order = 0,
this.mode = StepMode.Timed,
this.timerSeconds = 0,
this.sets = 1,
this.reps = 0,
this.restSeconds = 0,
this.cue = "",
this.completed = false,
});
factory RoutineStep.fromJson(Map<String, dynamic> json) {
return RoutineStep(
id: json['id'],
title: json['title'] ?? "",
order: json['order'] ?? 0,
mode: getStepMode(json['mode']),
timerSeconds: json['timerSeconds'] ?? 0,
sets: json['sets'] ?? 1,
reps: json['reps'] ?? 0,
restSeconds: json['restSeconds'] ?? 0,
cue: json['cue'] ?? "",
completed: json['completed'] ?? false,
);
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['id'] = id;
data['title'] = title;
data['order'] = order;
data['mode'] = mode.name;
data['timerSeconds'] = timerSeconds;
data['sets'] = sets;
data['reps'] = reps;
data['restSeconds'] = restSeconds;
data['cue'] = cue;
data['completed'] = completed;
return data;
}
int get plannedSeconds {
final int safeSets = sets < 1 ? 1 : sets;
return timerSeconds * safeSets + restSeconds * safeSets;
}
}

View File

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

View File

@@ -0,0 +1,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;
}
}

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

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

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

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

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

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

View File

@@ -0,0 +1,45 @@
/// One run of a routine. Sent whether the chain held or broke — a broken
/// chain is logged as partial, never silently dropped.
class RoutineLogRequest {
String routineId;
String startedAt;
/// Foreground seconds only. Backgrounding pauses the run.
int elapsedSeconds;
int completedSteps;
int totalSteps;
/// Step index the chain broke at; -1 when it held.
int breakIndex;
int backgroundedCount;
int skippedCount;
RoutineLogRequest({
this.routineId = "",
this.startedAt = "",
this.elapsedSeconds = 0,
this.completedSteps = 0,
this.totalSteps = 0,
this.breakIndex = -1,
this.backgroundedCount = 0,
this.skippedCount = 0,
});
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['routineId'] = routineId;
data['startedAt'] = startedAt;
data['elapsedSeconds'] = elapsedSeconds;
data['completedSteps'] = completedSteps;
data['totalSteps'] = totalSteps;
data['breakIndex'] = breakIndex;
data['backgroundedCount'] = backgroundedCount;
data['skippedCount'] = skippedCount;
return data;
}
}

View File

@@ -0,0 +1,44 @@
/// What a routine is for. The kind decides the runner's stage — a barbell
/// clock for work, a breathing circle for stillness, a checklist for the
/// things that simply have to get done.
enum RoutineKind {
/// Sets, reps and rest. Timed work auto-advances into rest.
Workout,
/// Long timed holds with a breathing cue. No reps, rarely any rest.
Meditation,
/// An ordered list of tasks that have to be done, in order, every time.
MustDo,
}
String kindLabel(RoutineKind value) {
switch (value) {
case RoutineKind.Workout:
return "Workout";
case RoutineKind.Meditation:
return "Meditation";
case RoutineKind.MustDo:
return "Must-do";
}
}
String kindDescription(RoutineKind value) {
switch (value) {
case RoutineKind.Workout:
return "Sets, reps and timed rest.";
case RoutineKind.Meditation:
return "Timed sits with a breathing cue.";
case RoutineKind.MustDo:
return "Tasks, in order, every time.";
}
}
RoutineKind getRoutineKind(String? name) {
for (RoutineKind value in RoutineKind.values) {
if (value.name == name) {
return value;
}
}
return RoutineKind.Workout;
}

View File

@@ -0,0 +1,40 @@
import '../../external/data/RoutineStep.dart';
import 'StepMode.dart';
/// One beat of a running routine — a single work set or the rest after it.
/// The runner walks a flat list of these, so "set 2 of 4" and "rest" are the
/// same kind of thing to the clock.
class RoutinePhase {
/// Index of the step this phase belongs to.
final int stepIndex;
final RoutineStep step;
/// 1-based set number within the step.
final int set;
final bool rest;
RoutinePhase({
required this.stepIndex,
required this.step,
this.set = 1,
this.rest = false,
});
/// Seconds the clock runs for. Zero means the phase waits for a tap.
int get seconds {
if (rest) {
return step.restSeconds;
}
return step.mode == StepMode.Timed ? step.timerSeconds : 0;
}
bool get countsDown {
return seconds > 0;
}
bool get lastSet {
return set >= step.sets;
}
}

View File

@@ -0,0 +1,212 @@
import '../../external/data/RoutineChain.dart';
import '../../external/data/RoutineStep.dart';
import 'RoutinePhase.dart';
/// A routine being run. Like [LiveSession], every clock is derived from
/// wall-clock rather than counted by a ticker, so a screen-off period cannot
/// lose or invent time.
class RoutineRun {
final RoutineChain chain;
final List<RoutinePhase> phases;
int phaseIndex = 0;
final DateTime startedAt;
/// Banked foreground seconds for the whole run.
int _runSeconds = 0;
/// Banked foreground seconds for the current phase.
int _phaseSeconds = 0;
/// Start of the active stretch; null while paused.
DateTime? resumedAt;
/// Times the app was left mid-run. Shown afterwards, never hidden.
int backgroundedCount = 0;
/// Phases skipped forward without being done.
int skippedCount = 0;
RoutineRun(this.chain)
: phases = buildPhases(chain),
startedAt = DateTime.now();
/// Each set becomes a work phase; rest follows every set that has rest,
/// except after the final set of the final step.
static List<RoutinePhase> buildPhases(RoutineChain chain) {
final List<RoutinePhase> result = <RoutinePhase>[];
for (int index = 0; index < chain.steps.length; index++) {
final RoutineStep step = chain.steps[index];
final int sets = step.sets < 1 ? 1 : step.sets;
for (int set = 1; set <= sets; set++) {
result.add(RoutinePhase(stepIndex: index, step: step, set: set));
final bool finalBeat = index == chain.steps.length - 1 && set == sets;
if (step.restSeconds > 0 && !finalBeat) {
result.add(
RoutinePhase(stepIndex: index, step: step, set: set, rest: true));
}
}
}
return result;
}
RoutinePhase get phase {
return phases[phaseIndex];
}
bool get running {
return resumedAt != null;
}
bool get onLastPhase {
return phaseIndex >= phases.length - 1;
}
int _live(DateTime? now) {
if (resumedAt == null) {
return 0;
}
return (now ?? DateTime.now()).difference(resumedAt!).inSeconds;
}
int elapsedSeconds({DateTime? now}) {
return _runSeconds + _live(now);
}
int phaseElapsed({DateTime? now}) {
return _phaseSeconds + _live(now);
}
int phaseRemaining({DateTime? now}) {
final int remaining = phase.seconds - phaseElapsed(now: now);
return remaining > 0 ? remaining : 0;
}
/// 0..1 through the current phase; 0 for phases that wait for a tap.
double phaseProgress({DateTime? now}) {
if (!phase.countsDown) {
return 0;
}
final double value = phaseElapsed(now: now) / phase.seconds;
return value > 1 ? 1 : value;
}
bool phaseFinished({DateTime? now}) {
return phase.countsDown && phaseElapsed(now: now) >= phase.seconds;
}
/// Seconds left in the whole run, by plan — elapsed work in this phase
/// counts, phases still ahead count at their planned length.
int plannedRemaining({DateTime? now}) {
int total = phase.countsDown ? phaseRemaining(now: now) : 0;
for (int index = phaseIndex + 1; index < phases.length; index++) {
total = total + phases[index].seconds;
}
return total;
}
void pause({DateTime? now}) {
if (resumedAt == null) {
return;
}
final int live = _live(now);
_runSeconds = _runSeconds + live;
_phaseSeconds = _phaseSeconds + live;
resumedAt = null;
}
void resume({DateTime? now}) {
if (resumedAt != null) {
return;
}
resumedAt = now ?? DateTime.now();
}
/// Bank the current phase and start the next. Returns false at the end.
/// A step is marked complete when its last set's work phase is done.
bool advance({bool skipped = false, DateTime? now}) {
final DateTime moment = now ?? DateTime.now();
final bool wasRunning = running;
if (!phase.rest && phase.lastSet && !skipped) {
phase.step.completed = true;
}
if (skipped && !phase.rest) {
skippedCount = skippedCount + 1;
}
pause(now: moment);
_phaseSeconds = 0;
if (onLastPhase) {
return false;
}
phaseIndex = phaseIndex + 1;
if (wasRunning) {
resume(now: moment);
}
return true;
}
/// Back to the start of the previous work phase. Rest is never a target.
void back({DateTime? now}) {
final DateTime moment = now ?? DateTime.now();
final bool wasRunning = running;
pause(now: moment);
_phaseSeconds = 0;
int target = phaseIndex - 1;
while (target > 0 && phases[target].rest) {
target = target - 1;
}
phaseIndex = target < 0 ? 0 : target;
phases[phaseIndex].step.completed = false;
if (wasRunning) {
resume(now: moment);
}
}
/// Jump to the first set of [stepIndex].
void jumpTo(int stepIndex, {DateTime? now}) {
final int target =
phases.indexWhere((item) => item.stepIndex == stepIndex && !item.rest);
if (target < 0) {
return;
}
final DateTime moment = now ?? DateTime.now();
final bool wasRunning = running;
pause(now: moment);
_phaseSeconds = 0;
phaseIndex = target;
if (wasRunning) {
resume(now: moment);
}
}
/// The step index the chain broke at, for a partial log; -1 when clean.
int get breakIndex {
return chain.complete ? -1 : phase.stepIndex;
}
/// The next work phase after this one, for the "up next" line.
RoutinePhase? get nextWork {
for (int index = phaseIndex + 1; index < phases.length; index++) {
if (!phases[index].rest) {
return phases[index];
}
}
return null;
}
}

View File

@@ -0,0 +1,31 @@
/// How a single routine step is satisfied.
enum StepMode {
/// A clock runs down and the step advances on its own.
Timed,
/// A rep target per set; the set ends when you say it does.
Reps,
/// A task. Ticked, not timed — the clock still records how long it took.
Check,
}
String stepModeLabel(StepMode value) {
switch (value) {
case StepMode.Timed:
return "Timed";
case StepMode.Reps:
return "Reps";
case StepMode.Check:
return "Tick";
}
}
StepMode getStepMode(String? name) {
for (StepMode value in StepMode.values) {
if (value.name == name) {
return value;
}
}
return StepMode.Timed;
}

View File

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

View File

@@ -9,6 +9,12 @@ import '../about/external/initial/CompletionRequest.dart';
import '../about/external/initial/DeferralRequest.dart';
import '../about/external/initial/DeviceRequest.dart';
import '../about/external/initial/GoalRequest.dart';
import '../about/external/initial/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/LoginData.dart';
import '../about/external/initial/ReportCardRequest.dart';
@@ -388,6 +394,13 @@ class Comms implements ConnectComms {
return await dio.post(navigation.key, data: request);
}
@override
Future<Response> saveRoutineEntry(Map<String, dynamic> request) async {
Pair navigation = await getRequestHeaders(saveRoutine, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request);
}
@override
Future<Response> logRoutineChainEntry(Map<String, dynamic> request) async {
Pair navigation = await getRequestHeaders(logRoutineChain, "");
@@ -505,4 +518,76 @@ class Comms implements ConnectComms {
dio.options.headers = navigation.value;
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);
}
}

View File

@@ -172,3 +172,24 @@ String myPartners = 'Partner/Mine';
String invitePartner = 'Partner/Invite';
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';

View File

@@ -9,6 +9,12 @@ import '../about/external/initial/CompletionRequest.dart';
import '../about/external/initial/DeferralRequest.dart';
import '../about/external/initial/DeviceRequest.dart';
import '../about/external/initial/GoalRequest.dart';
import '../about/external/initial/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/LoginData.dart';
import '../about/external/initial/ReportCardRequest.dart';
@@ -108,6 +114,8 @@ abstract class ConnectComms {
Future<Response> getMyRoutines(HistoryRequest request);
Future<Response> saveRoutineEntry(Map<String, dynamic> request);
Future<Response> logRoutineChainEntry(Map<String, dynamic> request);
// ── Training ────────────────────────────────────────────────────────────
@@ -142,4 +150,25 @@ abstract class ConnectComms {
Future<Response> getMyNotifications(HistoryRequest 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);
}

View File

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

View File

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

View File

@@ -48,7 +48,7 @@ class TickBox extends StatelessWidget {
height: size,
decoration: BoxDecoration(
color: checked ? tone : Colors.transparent,
borderRadius: BorderRadius.circular(7),
borderRadius: BorderRadius.zero,
border: Border.all(
color: checked ? tone : colorGrey.withValues(alpha: 0.55),
width: 1.6,
@@ -202,7 +202,7 @@ Widget addTaskAffordance(
alignment: Alignment.center,
decoration: BoxDecoration(
color: tone.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(14),
borderRadius: BorderRadius.zero,
border: Border.all(color: tone.withValues(alpha: 0.25), width: 1),
),
child: Icon(Icons.add_rounded, size: 22, color: tone),

View File

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

View File

@@ -32,7 +32,7 @@ Widget pill(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(999),
borderRadius: BorderRadius.zero,
border: Border.all(color: foreground.withValues(alpha: 0.20), width: 1),
),
child: Row(
@@ -57,11 +57,11 @@ Widget pill(
/// The standard card surface.
Widget card({
required Widget child,
EdgeInsets padding = const EdgeInsets.all(16),
EdgeInsets padding = const EdgeInsets.all(20),
EdgeInsets margin = EdgeInsets.zero,
Color? background,
Color? borderColor,
double radius = 16,
double radius = 22,
VoidCallback? onTap,
}) {
final Widget body = Container(
@@ -70,13 +70,13 @@ Widget card({
margin: margin,
decoration: BoxDecoration(
color: background ?? colorCard,
borderRadius: BorderRadius.circular(radius),
border: Border.all(color: borderColor ?? colorBorder, width: 1),
borderRadius: BorderRadius.zero,
border: Border.all(color: borderColor ?? colorGlassBorder, width: 1),
boxShadow: const [
BoxShadow(
color: Color(0x08000000),
blurRadius: 18,
offset: Offset(0, 4),
color: Color(0x0F2A3A30),
blurRadius: 24,
offset: Offset(0, 8),
),
],
),
@@ -159,7 +159,6 @@ Widget emptyState(
height: 76,
decoration: BoxDecoration(
color: tone.withValues(alpha: 0.08),
shape: BoxShape.circle,
),
child: Icon(icon, size: 32, color: tone),
),
@@ -191,7 +190,7 @@ Widget meter(
: fraction;
return ClipRRect(
borderRadius: BorderRadius.circular(999),
borderRadius: BorderRadius.zero,
child: LinearProgressIndicator(
value: clamped,
minHeight: height,

View File

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

View File

@@ -0,0 +1,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),
],
),
),
);
}
}

View File

@@ -0,0 +1,104 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import '../utils/Colors.dart';
/// Frosted glass: the background blurred through a translucent white panel
/// with a bright hairline edge. Controls and floating chrome are built from it.
class GlassSurface extends StatelessWidget {
final Widget child;
final BorderRadius radius;
final EdgeInsets padding;
final Color? tint;
/// Over a dark or coloured stage the panel goes smoky instead of milky.
final bool onDark;
final double blur;
const GlassSurface({
super.key,
required this.child,
this.radius = const BorderRadius.all(Radius.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),
),
),
),
],
),
);
}

View File

@@ -0,0 +1,127 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import '../about/external/data/Goal.dart';
import '../about/internal/application/TextType.dart';
import '../utils/Colors.dart';
import '../utils/CommonUtils.dart';
import 'text/Text.dart';
/// The goal chooser, shared by the task form and the routine builder. Opened
/// only once the goals have been loaded — `selectField` loads on tap.
///
/// Resolves to the chosen goal, to a bare `Goal()` (no id) for "no goal", or
/// to null when dismissed without a choice.
Future<Goal?> showGoalPicker(
BuildContext context,
List<Goal> goals, {
String? selectedId,
}) {
return showModalBottomSheet<Goal>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
barrierColor: colorBlack.withValues(alpha: 0.55),
builder: (BuildContext sheetContext) {
final List<Goal> active = goals.where((goal) => !goal.archived).toList();
return Container(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(sheetContext).size.height * 0.75,
),
decoration: const BoxDecoration(
color: colorSheetBackground,
borderRadius: BorderRadius.only(
topLeft: Radius.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),
],
),
),
);
}

View File

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

View File

@@ -0,0 +1,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),
),
),
],
),
);
}

View File

@@ -0,0 +1,116 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../about/internal/application/TextType.dart';
import '../utils/Colors.dart';
import 'text/Text.dart';
/// A thin circular meter with its value in the middle — the routine row's
/// "how far did I get" dial, and the runner's phase clock ring.
class ProgressRing extends StatelessWidget {
final double fraction;
final double size;
final double stroke;
final Color? fill;
final Color? track;
/// Drawn in the centre. Defaults to the percentage.
final Widget? child;
const ProgressRing({
super.key,
required this.fraction,
this.size = 44,
this.stroke = 3,
this.fill,
this.track,
this.child,
});
@override
Widget build(BuildContext context) {
final double clamped = fraction.isNaN ? 0 : fraction.clamp(0, 1).toDouble();
return SizedBox(
width: size,
height: size,
child: TweenAnimationBuilder<double>(
tween: Tween<double>(end: clamped),
duration: const Duration(milliseconds: 450),
curve: Curves.easeOutCubic,
builder: (context, value, _) {
return CustomPaint(
painter: _RingPainter(
value,
stroke,
fill ?? colorPrimaryDark,
track ?? colorMuted,
),
child: Center(
child: child ??
text("${(clamped * 100).round()}%", size * 0.22,
TextType.Bold,
color: colorPrimaryDark),
),
);
},
),
);
}
}
class _RingPainter extends CustomPainter {
final double fraction;
final double stroke;
final Color fill;
final Color track;
_RingPainter(this.fraction, this.stroke, this.fill, this.track);
@override
void paint(Canvas canvas, Size size) {
final Offset centre = Offset(size.width / 2, size.height / 2);
final double radius = (math.min(size.width, size.height) - stroke) / 2;
final Paint base = Paint()
..style = PaintingStyle.stroke
..strokeWidth = stroke
..color = track;
canvas.drawCircle(centre, radius, base);
if (fraction <= 0) {
return;
}
final Paint arc = Paint()
..style = PaintingStyle.stroke
..strokeWidth = stroke
..strokeCap = StrokeCap.round
..color = fill;
canvas.drawArc(
Rect.fromCircle(center: centre, radius: radius),
-math.pi / 2,
2 * math.pi * fraction,
false,
arc,
);
}
@override
bool shouldRepaint(_RingPainter old) {
return old.fraction != fraction ||
old.fill != fill ||
old.track != track ||
old.stroke != stroke;
}
}

View File

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

View File

@@ -13,10 +13,10 @@ Widget roundedCornerButton(
Color? foreground,
IconData? icon,
bool enabled = true,
double radius = 14,
double radius = 99,
double verticalPadding = 16,
}) {
final Color bg = enabled ? (background ?? colorPrimaryDark) : colorGrey;
final Color bg = enabled ? (background ?? colorNight) : colorGrey;
final Color fg = foreground ?? colorWhite;
return ElevatedButton(
@@ -27,7 +27,7 @@ Widget roundedCornerButton(
disabledBackgroundColor: colorGrey.withValues(alpha: 0.4),
padding: EdgeInsets.symmetric(vertical: verticalPadding),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radius),
borderRadius: BorderRadius.zero,
),
),
child: Row(
@@ -35,10 +35,13 @@ Widget roundedCornerButton(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (icon != null) ...[
Icon(icon, size: 16, color: fg),
// The default night pill carries the lime spark on its icon.
Icon(icon,
size: 16,
color: background == null && foreground == null ? colorLime : fg),
const SizedBox(width: 8),
],
text(label, 14, TextType.Bold, color: fg),
text(label, 14, TextType.Medium, color: fg),
],
),
);
@@ -52,7 +55,7 @@ Widget outlinedActionButton(
Color? foreground,
IconData? icon,
bool enabled = true,
double radius = 14,
double radius = 99,
}) {
final Color fg = enabled ? (foreground ?? colorPrimaryDark) : colorGrey;
@@ -62,7 +65,7 @@ Widget outlinedActionButton(
side: BorderSide(color: fg.withValues(alpha: 0.35), width: 1),
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radius),
borderRadius: BorderRadius.zero,
),
),
child: Row(
@@ -109,7 +112,7 @@ Widget iconButton(
alignment: Alignment.center,
decoration: BoxDecoration(
color: background ?? Colors.transparent,
borderRadius: BorderRadius.circular(radius),
borderRadius: BorderRadius.zero,
border: bordered
? Border.all(color: borderColor ?? colorBorder, width: 1)
: null,
@@ -158,7 +161,7 @@ Widget segmentedSelector<T>({
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: isSelected ? active : colorWhite,
borderRadius: BorderRadius.circular(10),
borderRadius: BorderRadius.zero,
border: Border.all(
color: isSelected ? active : colorBorder,
width: 1,

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