Compare commits
3 Commits
36bc41509d
...
feature/ro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b71257429 | ||
|
|
4548e1f164 | ||
|
|
9c858b8146 |
@@ -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
|
||||
```
|
||||
|
||||
@@ -111,7 +111,7 @@ status field. That's what makes honest history and excuse analysis possible.
|
||||
|
||||
See `design.md`. In short: sage-to-cream ambient backdrop, frosted glass
|
||||
controls, painted `SceneArt` instead of photos, image-led cards, two-tone
|
||||
greetings, night pills with a lime spark.
|
||||
greetings, square night buttons with a lime spark, no rounded corners.
|
||||
|
||||
- Screens are built from `Sheet` (`designs/Shell.dart`); Home, Routines and
|
||||
the routine player compose `AmbientBackground` + glass directly.
|
||||
|
||||
@@ -4,7 +4,7 @@ Reference: an airy fitness-app look. Sage mist drifting into cream behind
|
||||
everything (`AmbientBackground`), frosted glass for controls and floating
|
||||
chrome (`GlassSurface`, `glassCircle`), ink type with a soft grey second
|
||||
tone, painted landscapes instead of photography (`SceneArt`), a floating
|
||||
glass tab bar with a dark round action button (`FloatingNav`), and one lime
|
||||
glass tab bar with a dark square action button (`FloatingNav`), and one lime
|
||||
spark on night-green for the primary control.
|
||||
|
||||
## Building blocks (`designs/`)
|
||||
@@ -12,16 +12,16 @@ spark on night-green for the primary control.
|
||||
| Piece | Use |
|
||||
|---|---|
|
||||
| `AmbientBackground` | The backdrop for every light screen. |
|
||||
| `GlassSurface` / `glassCircle` | Frosted panels and round controls; `onDark` over a scene or tinted stage. |
|
||||
| `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 and action button. |
|
||||
| `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 night pills with a lime icon.
|
||||
`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
|
||||
@@ -45,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.
|
||||
@@ -69,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
|
||||
|
||||
@@ -78,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.
|
||||
|
||||
94
frontend/lib/Grounded/about/external/data/Group.dart
vendored
Normal file
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
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
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
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
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;
|
||||
}
|
||||
}
|
||||
40
frontend/lib/Grounded/about/external/initial/GroupGoalRequest.dart
vendored
Normal file
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
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
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
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
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
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
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;
|
||||
}
|
||||
}
|
||||
@@ -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/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';
|
||||
@@ -512,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
@@ -144,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);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,7 +57,7 @@ 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,
|
||||
@@ -70,7 +70,7 @@ Widget card({
|
||||
margin: margin,
|
||||
decoration: BoxDecoration(
|
||||
color: background ?? colorCard,
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
borderRadius: BorderRadius.zero,
|
||||
border: Border.all(color: borderColor ?? colorGlassBorder, width: 1),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
@@ -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,
|
||||
|
||||
@@ -7,22 +7,23 @@ import 'text/Text.dart';
|
||||
|
||||
typedef NavEntry = ({IconData icon, String label, VoidCallback onTap});
|
||||
|
||||
/// The floating glass tab bar with the dark round action button beside it.
|
||||
/// 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 IconData? actionIcon;
|
||||
|
||||
final VoidCallback onAction;
|
||||
final VoidCallback? onAction;
|
||||
|
||||
const FloatingNav({
|
||||
super.key,
|
||||
required this.entries,
|
||||
required this.selected,
|
||||
required this.actionIcon,
|
||||
required this.onAction,
|
||||
this.actionIcon,
|
||||
this.onAction,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -36,7 +37,7 @@ class FloatingNav extends StatelessWidget {
|
||||
Expanded(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(34),
|
||||
borderRadius: BorderRadius.zero,
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x1A1A1F1C),
|
||||
@@ -46,7 +47,7 @@ class FloatingNav extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
child: GlassSurface(
|
||||
radius: BorderRadius.circular(34),
|
||||
radius: BorderRadius.zero,
|
||||
tint: colorGlassStrong,
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: Row(
|
||||
@@ -58,14 +59,14 @@ class FloatingNav extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (onAction != null) ...[
|
||||
const SizedBox(width: 10),
|
||||
GestureDetector(
|
||||
onTap: onAction,
|
||||
child: Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
width: 72,
|
||||
height: 72,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: const RadialGradient(
|
||||
center: Alignment(-0.3, -0.4),
|
||||
colors: <Color>[colorNightGlow, colorNight],
|
||||
@@ -82,6 +83,7 @@ class FloatingNav extends StatelessWidget {
|
||||
child: Icon(actionIcon, size: 24, color: colorLime),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -95,10 +97,10 @@ class FloatingNav extends StatelessWidget {
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 220),
|
||||
curve: Curves.easeOutCubic,
|
||||
padding: const EdgeInsets.symmetric(vertical: 9),
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: active ? colorWhite : colorWhite.withValues(alpha: 0.0),
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
color: active ? colorHeaderBottom : colorWhite.withValues(alpha: 0.0),
|
||||
borderRadius: BorderRadius.zero,
|
||||
boxShadow: active
|
||||
? const [
|
||||
BoxShadow(
|
||||
@@ -113,10 +115,10 @@ class FloatingNav extends StatelessWidget {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(entry.icon,
|
||||
size: 19, color: active ? colorInk : colorInkSoft),
|
||||
size: 23, color: active ? colorWhite : colorInkSoft),
|
||||
const SizedBox(height: 4),
|
||||
text(entry.label, 10, active ? TextType.Medium : TextType.Regular,
|
||||
color: active ? colorInk : colorInkSoft, maxLines: 1),
|
||||
color: active ? colorWhite : colorInkSoft, maxLines: 1),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -23,7 +23,7 @@ class GlassSurface extends StatelessWidget {
|
||||
const GlassSurface({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.radius = const BorderRadius.all(Radius.circular(20)),
|
||||
this.radius = const BorderRadius.all(Radius.zero),
|
||||
this.padding = EdgeInsets.zero,
|
||||
this.tint,
|
||||
this.onDark = false,
|
||||
@@ -60,7 +60,7 @@ class GlassSurface extends StatelessWidget {
|
||||
Widget glassCircle(
|
||||
IconData icon,
|
||||
VoidCallback onTap, {
|
||||
double size = 42,
|
||||
double size = 50,
|
||||
bool onDark = false,
|
||||
bool dotted = false,
|
||||
Color? dotColor,
|
||||
@@ -73,7 +73,7 @@ Widget glassCircle(
|
||||
children: [
|
||||
GlassSurface(
|
||||
onDark: onDark,
|
||||
radius: BorderRadius.circular(size),
|
||||
radius: BorderRadius.zero,
|
||||
child: SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
|
||||
@@ -32,8 +32,8 @@ Future<Goal?> showGoalPicker(
|
||||
decoration: const BoxDecoration(
|
||||
color: colorSheetBackground,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(28),
|
||||
topRight: Radius.circular(28),
|
||||
topLeft: Radius.zero,
|
||||
topRight: Radius.zero,
|
||||
),
|
||||
),
|
||||
child: ListView(
|
||||
@@ -93,7 +93,7 @@ Widget _goalOption(
|
||||
padding: const EdgeInsets.fromLTRB(14, 13, 14, 13),
|
||||
decoration: BoxDecoration(
|
||||
color: colorWhite,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderRadius: BorderRadius.zero,
|
||||
border: Border.all(
|
||||
color: selected ? accent.withValues(alpha: 0.5) : colorBorder,
|
||||
width: selected ? 1.5 : 1,
|
||||
|
||||
74
frontend/lib/Grounded/designs/MemberAvatar.dart
Normal file
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -136,8 +136,8 @@ class Sheet extends StatelessWidget {
|
||||
decoration: BoxDecoration(
|
||||
color: light ? colorFrost : colorPrimaryLight,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(32),
|
||||
topRight: Radius.circular(32),
|
||||
topLeft: Radius.zero,
|
||||
topRight: Radius.zero,
|
||||
),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
|
||||
@@ -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(
|
||||
@@ -65,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(
|
||||
@@ -112,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,
|
||||
@@ -161,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,
|
||||
|
||||
@@ -17,7 +17,7 @@ Widget countStepper(
|
||||
padding: const EdgeInsets.fromLTRB(14, 10, 8, 10),
|
||||
decoration: BoxDecoration(
|
||||
color: colorWhite,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: BorderRadius.zero,
|
||||
border: Border.all(color: colorBorder, width: 1),
|
||||
),
|
||||
child: Row(
|
||||
@@ -54,7 +54,7 @@ Widget _stepButton(IconData icon, VoidCallback? onTap) {
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: enabled ? colorSecondary : colorInset,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderRadius: BorderRadius.zero,
|
||||
),
|
||||
child: Icon(icon,
|
||||
size: 14, color: enabled ? colorPrimaryDark : colorGrey),
|
||||
|
||||
@@ -54,19 +54,19 @@ Widget inputField(
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: BorderRadius.zero,
|
||||
borderSide: BorderSide(color: colorBorder, width: 1),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: BorderRadius.zero,
|
||||
borderSide: BorderSide(color: colorPrimaryDark, width: 1.4),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: BorderRadius.zero,
|
||||
borderSide: BorderSide(color: colorNegative, width: 1),
|
||||
),
|
||||
focusedErrorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: BorderRadius.zero,
|
||||
borderSide: BorderSide(color: colorNegative, width: 1.4),
|
||||
),
|
||||
errorStyle: TextStyle(
|
||||
@@ -80,6 +80,38 @@ Widget inputField(
|
||||
);
|
||||
}
|
||||
|
||||
/// The bare add-a-line field used inside a card: no label, no box of its
|
||||
/// own; return or the trailing button submits.
|
||||
Widget quickAddField(
|
||||
TextEditingController controller,
|
||||
String hint,
|
||||
ValueChanged<String> onSubmitted, {
|
||||
FocusNode? focusNode,
|
||||
}) {
|
||||
return TextField(
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
onSubmitted: onSubmitted,
|
||||
textInputAction: TextInputAction.done,
|
||||
style: TextStyle(
|
||||
fontFamily: getTextType(TextType.Regular),
|
||||
fontSize: 16,
|
||||
color: colorInk,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: hint,
|
||||
hintStyle: TextStyle(
|
||||
fontFamily: getTextType(TextType.Regular),
|
||||
fontSize: 16,
|
||||
color: colorInkSoft,
|
||||
),
|
||||
isDense: true,
|
||||
border: InputBorder.none,
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 14),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The excuse field. Deliberately unadorned — no templates, no quick-picks, a
|
||||
/// live character count that shows the minimum, because the friction is the
|
||||
/// feature rather than an obstacle to route around.
|
||||
@@ -131,19 +163,19 @@ Widget excuseField(
|
||||
fillColor: colorWhite,
|
||||
contentPadding: const EdgeInsets.all(14),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: BorderRadius.zero,
|
||||
borderSide: BorderSide(color: colorBorder, width: 1),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: BorderRadius.zero,
|
||||
borderSide: BorderSide(color: colorPrimaryDark, width: 1.4),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: BorderRadius.zero,
|
||||
borderSide: BorderSide(color: colorNegative, width: 1),
|
||||
),
|
||||
focusedErrorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: BorderRadius.zero,
|
||||
borderSide: BorderSide(color: colorNegative, width: 1.4),
|
||||
),
|
||||
errorStyle: TextStyle(
|
||||
@@ -180,7 +212,7 @@ Widget selectField(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: colorWhite,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: BorderRadius.zero,
|
||||
border: Border.all(color: colorBorder, width: 1),
|
||||
),
|
||||
child: Row(
|
||||
|
||||
@@ -14,6 +14,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';
|
||||
@@ -21,6 +27,7 @@ import '../about/external/initial/SessionLogRequest.dart';
|
||||
import '../about/external/initial/SickModeRequest.dart';
|
||||
import '../about/external/initial/ToneRequest.dart';
|
||||
import '../about/internal/application/CapacityProfile.dart';
|
||||
import '../about/internal/application/TodoItem.dart';
|
||||
import '../about/internal/application/MeDescription.dart';
|
||||
import '../about/internal/application/Token.dart';
|
||||
import '../about/internal/application/UserDetails.dart';
|
||||
@@ -119,6 +126,12 @@ class AppDataManager implements DataManager {
|
||||
@override
|
||||
Future setAmnestySpent(int spent) => memory.setAmnestySpent(spent);
|
||||
|
||||
@override
|
||||
Future<List<TodoItem>> getTodos() => memory.getTodos();
|
||||
|
||||
@override
|
||||
Future setTodos(List<TodoItem> todos) => memory.setTodos(todos);
|
||||
|
||||
@override
|
||||
Future<bool> showOnboarding() => memory.showOnboarding();
|
||||
|
||||
@@ -363,4 +376,36 @@ class AppDataManager implements DataManager {
|
||||
@override
|
||||
Future<Response> acknowledgeNudgeEntry(IdRequest request) =>
|
||||
comms.acknowledgeNudgeEntry(request);
|
||||
|
||||
// ── Groups ────────────────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
Future<Response> getMyGroups(HistoryRequest request) => comms.getMyGroups(request);
|
||||
|
||||
@override
|
||||
Future<Response> getGroupEntry(IdRequest request) => comms.getGroupEntry(request);
|
||||
|
||||
@override
|
||||
Future<Response> saveGroupEntry(GroupRequest request) => comms.saveGroupEntry(request);
|
||||
|
||||
@override
|
||||
Future<Response> joinGroupEntry(JoinGroupRequest request) => comms.joinGroupEntry(request);
|
||||
|
||||
@override
|
||||
Future<Response> leaveGroupEntry(IdRequest request) => comms.leaveGroupEntry(request);
|
||||
|
||||
@override
|
||||
Future<Response> saveGroupGoalEntry(GroupGoalRequest request) => comms.saveGroupGoalEntry(request);
|
||||
|
||||
@override
|
||||
Future<Response> logObjectiveProgress(ObjectiveProgressRequest request) => comms.logObjectiveProgress(request);
|
||||
|
||||
@override
|
||||
Future<Response> getGroupMessages(IdRequest request) => comms.getGroupMessages(request);
|
||||
|
||||
@override
|
||||
Future<Response> sendGroupMessage(GroupMessageRequest request) => comms.sendGroupMessage(request);
|
||||
|
||||
@override
|
||||
Future<Response> nudgeGroupMember(NudgeRequest request) => comms.nudgeGroupMember(request);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import '../about/external/data/Program.dart';
|
||||
import '../about/external/data/SystemResponse.dart';
|
||||
import '../about/internal/application/CapacityProfile.dart';
|
||||
import '../about/internal/application/MeDescription.dart';
|
||||
import '../about/internal/application/TodoItem.dart';
|
||||
import '../about/internal/application/Token.dart';
|
||||
import '../about/internal/application/UserDetails.dart';
|
||||
|
||||
@@ -59,6 +60,11 @@ abstract class ConnectInternalMemory {
|
||||
|
||||
Future setAmnestySpent(int spent);
|
||||
|
||||
/// The quick to-do list on Home.
|
||||
Future<List<TodoItem>> getTodos();
|
||||
|
||||
Future setTodos(List<TodoItem> todos);
|
||||
|
||||
Future<bool> showOnboarding();
|
||||
|
||||
Future setOnboardingOption(bool value);
|
||||
|
||||
@@ -8,6 +8,7 @@ import '../about/external/data/ResponseState.dart';
|
||||
import '../about/external/data/SystemResponse.dart';
|
||||
import '../about/internal/application/CapacityProfile.dart';
|
||||
import '../about/internal/application/MeDescription.dart';
|
||||
import '../about/internal/application/TodoItem.dart';
|
||||
import '../about/internal/application/Token.dart';
|
||||
import '../about/internal/application/UserDetails.dart';
|
||||
import 'ConnectInternalMemory.dart';
|
||||
@@ -40,6 +41,8 @@ class InternalMemory implements ConnectInternalMemory {
|
||||
|
||||
static const String AMNESTY_SPENT = "AMNESTY_SPENT_Jr4xVh9KmT3gLc6B";
|
||||
|
||||
static const String TODOS = "TODOS_Wm3kRb8TqZ5nHc2F";
|
||||
|
||||
static const String SHOW_ONBOARDING = "SHOW_ONBOARDING_Nz6qGf2SdX7wPb4M";
|
||||
|
||||
final groundedStorage = const FlutterSecureStorage();
|
||||
@@ -252,6 +255,26 @@ class InternalMemory implements ConnectInternalMemory {
|
||||
await groundedStorage.write(key: AMNESTY_SPENT, value: spent.toString());
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<TodoItem>> getTodos() async {
|
||||
String? value = await groundedStorage.read(key: TODOS);
|
||||
|
||||
if (value != null) {
|
||||
return (jsonDecode(value) as List<dynamic>)
|
||||
.map((item) => TodoItem.fromJson(item as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
return <TodoItem>[];
|
||||
}
|
||||
|
||||
@override
|
||||
Future setTodos(List<TodoItem> todos) async {
|
||||
await groundedStorage.write(
|
||||
key: TODOS,
|
||||
value: jsonEncode(todos.map((item) => item.toJson()).toList()));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> showOnboarding() async {
|
||||
String? value = await groundedStorage.read(key: SHOW_ONBOARDING);
|
||||
|
||||
@@ -12,6 +12,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';
|
||||
@@ -19,6 +25,7 @@ import '../about/external/initial/SessionLogRequest.dart';
|
||||
import '../about/external/initial/SickModeRequest.dart';
|
||||
import '../about/external/initial/ToneRequest.dart';
|
||||
import '../comms/ConnectComms.dart';
|
||||
import 'MockGroups.dart';
|
||||
import 'MockStore.dart';
|
||||
|
||||
/// A fake backend. Answers every call from [MockStore] after a short delay,
|
||||
@@ -440,4 +447,36 @@ class MockComms implements ConnectComms {
|
||||
|
||||
@override
|
||||
Future<Response> acknowledgeNudgeEntry(IdRequest request) => _done();
|
||||
|
||||
// ── Groups ────────────────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
Future<Response> getMyGroups(HistoryRequest request) => _ok(MockGroups.mine());
|
||||
|
||||
@override
|
||||
Future<Response> getGroupEntry(IdRequest request) => _ok(MockGroups.one(request.id));
|
||||
|
||||
@override
|
||||
Future<Response> saveGroupEntry(GroupRequest request) => _ok(MockGroups.save(request));
|
||||
|
||||
@override
|
||||
Future<Response> joinGroupEntry(JoinGroupRequest request) => _ok(MockGroups.join(request));
|
||||
|
||||
@override
|
||||
Future<Response> leaveGroupEntry(IdRequest request) => _ok(MockGroups.leave(request.id));
|
||||
|
||||
@override
|
||||
Future<Response> saveGroupGoalEntry(GroupGoalRequest request) => _ok(MockGroups.saveGoal(request));
|
||||
|
||||
@override
|
||||
Future<Response> logObjectiveProgress(ObjectiveProgressRequest request) => _ok(MockGroups.logProgress(request));
|
||||
|
||||
@override
|
||||
Future<Response> getGroupMessages(IdRequest request) => _ok(MockGroups.messages(request.id));
|
||||
|
||||
@override
|
||||
Future<Response> sendGroupMessage(GroupMessageRequest request) => _ok(MockGroups.send(request));
|
||||
|
||||
@override
|
||||
Future<Response> nudgeGroupMember(NudgeRequest request) => _ok(MockGroups.nudge(request));
|
||||
}
|
||||
|
||||
472
frontend/lib/Grounded/mock/MockGroups.dart
Normal file
472
frontend/lib/Grounded/mock/MockGroups.dart
Normal file
@@ -0,0 +1,472 @@
|
||||
// MOCK DATA — TEMPORARY. See MockSwitch.dart.
|
||||
|
||||
import 'dart:math';
|
||||
|
||||
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 '../utils/Colors.dart';
|
||||
import '../utils/CommonUtils.dart';
|
||||
import 'MockStore.dart';
|
||||
|
||||
/// The fake backend's groups: a couple already joined, one waiting behind an
|
||||
/// invite code, and members who answer in the chat a moment after you post.
|
||||
class MockGroups {
|
||||
static const String _me = "mock-user";
|
||||
|
||||
static const String _myName = "Alex";
|
||||
|
||||
static bool _seeded = false;
|
||||
|
||||
static final List<Map<String, dynamic>> _groups = <Map<String, dynamic>>[];
|
||||
|
||||
/// Groups that exist but you are not in yet, keyed by invite code.
|
||||
static final Map<String, Map<String, dynamic>> _joinable =
|
||||
<String, Map<String, dynamic>>{};
|
||||
|
||||
static final Map<String, List<Map<String, dynamic>>> _messages =
|
||||
<String, List<Map<String, dynamic>>>{};
|
||||
|
||||
/// Replies that "arrive" once their time has passed.
|
||||
static final List<Map<String, dynamic>> _pending = <Map<String, dynamic>>[];
|
||||
|
||||
static final Random _random = Random(7);
|
||||
|
||||
static Map<String, dynamic> _member(String id, String name,
|
||||
{String role = "Member",
|
||||
double kept = 0.7,
|
||||
int streak = 2,
|
||||
String standing = "Good",
|
||||
int checkedInHoursAgo = 5}) {
|
||||
return <String, dynamic>{
|
||||
'id': id,
|
||||
'name': name,
|
||||
'role': role,
|
||||
'keptRate': kept,
|
||||
'streak': streak,
|
||||
'standing': standing,
|
||||
'lastCheckIn': DateTime.now()
|
||||
.subtract(Duration(hours: checkedInHoursAgo))
|
||||
.toIso8601String(),
|
||||
'me': id == _me,
|
||||
};
|
||||
}
|
||||
|
||||
static Map<String, dynamic> _objective(
|
||||
String id, String title, String unit, double target, double current,
|
||||
double mine) {
|
||||
return <String, dynamic>{
|
||||
'id': id,
|
||||
'title': title,
|
||||
'unit': unit,
|
||||
'target': target,
|
||||
'current': current,
|
||||
'mine': mine,
|
||||
};
|
||||
}
|
||||
|
||||
static Map<String, dynamic> _message(String groupId, String authorId,
|
||||
String authorName, String body, int minutesAgo,
|
||||
{String kind = "Text"}) {
|
||||
return <String, dynamic>{
|
||||
'id': MockStore.newId('msg'),
|
||||
'groupId': groupId,
|
||||
'authorId': authorId,
|
||||
'authorName': authorName,
|
||||
'body': body,
|
||||
'kind': kind,
|
||||
'sentAt': DateTime.now()
|
||||
.subtract(Duration(minutes: minutesAgo))
|
||||
.toIso8601String(),
|
||||
'mine': authorId == _me,
|
||||
};
|
||||
}
|
||||
|
||||
static void _ensureSeeded() {
|
||||
if (_seeded) {
|
||||
return;
|
||||
}
|
||||
_seeded = true;
|
||||
|
||||
final DateTime now = DateTime.now();
|
||||
|
||||
_groups.addAll(<Map<String, dynamic>>[
|
||||
<String, dynamic>{
|
||||
'id': 'group-run',
|
||||
'name': 'Morning Crew',
|
||||
'description': 'Out the door before 7, five days a week.',
|
||||
'colourHex': colorToHex(goalAccents[1]),
|
||||
'inviteCode': 'MRN7AM',
|
||||
'unread': 3,
|
||||
'members': <Map<String, dynamic>>[
|
||||
_member('m-sam', 'Sam',
|
||||
role: 'Owner', kept: 0.92, streak: 11, checkedInHoursAgo: 2),
|
||||
_member(_me, _myName, kept: 0.78, streak: 4, checkedInHoursAgo: 4),
|
||||
_member('m-priya', 'Priya', kept: 0.85, streak: 7, checkedInHoursAgo: 3),
|
||||
_member('m-jordan', 'Jordan',
|
||||
kept: 0.41, streak: 0, standing: 'Warned', checkedInHoursAgo: 52),
|
||||
],
|
||||
'goals': <Map<String, dynamic>>[
|
||||
<String, dynamic>{
|
||||
'id': 'gg-run',
|
||||
'title': 'Run 200 km this month',
|
||||
'description': 'Every kilometre counts, treadmill included.',
|
||||
'targetDate': DateTime(now.year, now.month + 1, 0).toIso8601String(),
|
||||
'colourHex': colorToHex(goalAccents[1]),
|
||||
'objectives': <Map<String, dynamic>>[
|
||||
_objective('ob-km', 'Distance run', 'km', 200, 86, 22),
|
||||
_objective('ob-sessions', 'Runs logged', 'runs', 40, 17, 5),
|
||||
],
|
||||
},
|
||||
<String, dynamic>{
|
||||
'id': 'gg-sleep',
|
||||
'title': 'In bed before 23:00',
|
||||
'description': 'Mornings start the night before.',
|
||||
'targetDate': '',
|
||||
'colourHex': colorToHex(goalAccents[4]),
|
||||
'objectives': <Map<String, dynamic>>[
|
||||
_objective('ob-nights', 'Early nights', 'nights', 60, 31, 9),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
<String, dynamic>{
|
||||
'id': 'group-thesis',
|
||||
'name': 'Thesis Sprint',
|
||||
'description': 'Drafts in by the 15th. No heroics, just pages.',
|
||||
'colourHex': colorToHex(goalAccents[3]),
|
||||
'inviteCode': 'THS15D',
|
||||
'unread': 0,
|
||||
'members': <Map<String, dynamic>>[
|
||||
_member(_me, _myName,
|
||||
role: 'Owner', kept: 0.78, streak: 4, checkedInHoursAgo: 4),
|
||||
_member('m-mei', 'Mei', kept: 0.88, streak: 9, checkedInHoursAgo: 6),
|
||||
_member('m-tom', 'Tom', kept: 0.63, streak: 1, checkedInHoursAgo: 20),
|
||||
],
|
||||
'goals': <Map<String, dynamic>>[
|
||||
<String, dynamic>{
|
||||
'id': 'gg-drafts',
|
||||
'title': 'Full drafts submitted',
|
||||
'description': 'Three theses, one deadline.',
|
||||
'targetDate': DateTime(now.year, now.month + 1, 15).toIso8601String(),
|
||||
'colourHex': colorToHex(goalAccents[3]),
|
||||
'objectives': <Map<String, dynamic>>[
|
||||
_objective('ob-words', 'Words written', 'words', 30000, 18400, 7200),
|
||||
_objective('ob-chapters', 'Chapters reviewed', 'chapters', 9, 3, 1),
|
||||
_objective('ob-hours', 'Deep-work hours', 'hours', 120, 64, 23),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
_joinable['WLK2GO'] = <String, dynamic>{
|
||||
'id': 'group-walk',
|
||||
'name': 'Weekend Walkers',
|
||||
'description': 'Ten thousand steps, Saturday and Sunday, no excuses.',
|
||||
'colourHex': colorToHex(goalAccents[5]),
|
||||
'inviteCode': 'WLK2GO',
|
||||
'unread': 1,
|
||||
'members': <Map<String, dynamic>>[
|
||||
_member('m-ade', 'Ade', role: 'Owner', kept: 0.81, streak: 6),
|
||||
_member('m-lena', 'Lena', kept: 0.74, streak: 3),
|
||||
],
|
||||
'goals': <Map<String, dynamic>>[
|
||||
<String, dynamic>{
|
||||
'id': 'gg-steps',
|
||||
'title': '1 million steps together',
|
||||
'description': '',
|
||||
'targetDate': '',
|
||||
'colourHex': colorToHex(goalAccents[5]),
|
||||
'objectives': <Map<String, dynamic>>[
|
||||
_objective('ob-steps', 'Steps', 'steps', 1000000, 412000, 0),
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
_messages['group-run'] = <Map<String, dynamic>>[
|
||||
_message('group-run', 'm-sam', 'Sam',
|
||||
'Same route tomorrow? Gate at 6:15.', 60 * 20),
|
||||
_message('group-run', _me, _myName, "I'm in. Bringing Priya's spare torch.",
|
||||
60 * 19 + 40),
|
||||
_message('group-run', 'm-priya', 'Priya', 'logged 6 km on Distance run',
|
||||
60 * 3, kind: 'CheckIn'),
|
||||
_message('group-run', 'm-sam', 'Sam', 'logged 8 km on Distance run',
|
||||
60 * 2, kind: 'CheckIn'),
|
||||
_message('group-run', 'm-sam', 'Sam',
|
||||
'nudged Jordan: two days without a run, we miss you out there',
|
||||
95, kind: 'Nudge'),
|
||||
_message('group-run', 'm-priya', 'Priya',
|
||||
'86 km down. We need about 9 a day to make it, doable.', 40),
|
||||
];
|
||||
_messages['group-thesis'] = <Map<String, dynamic>>[
|
||||
_message('group-thesis', _me, _myName, 'started the group', 60 * 24 * 6,
|
||||
kind: 'System'),
|
||||
_message('group-thesis', 'm-mei', 'Mei',
|
||||
'Chapter 3 is with my supervisor. Terrified.', 60 * 26),
|
||||
_message('group-thesis', _me, _myName, 'logged 1,200 words on Words written',
|
||||
60 * 22, kind: 'CheckIn'),
|
||||
_message('group-thesis', 'm-tom', 'Tom',
|
||||
'Library 9 to 1 tomorrow if anyone wants a co-working block.', 60 * 7),
|
||||
];
|
||||
_messages['group-walk'] = <Map<String, dynamic>>[
|
||||
_message('group-walk', 'm-ade', 'Ade',
|
||||
'Welcome! Saturday loop starts at the bridge, 9:00.', 60 * 30),
|
||||
];
|
||||
}
|
||||
|
||||
static Map<String, dynamic>? _find(String id) {
|
||||
for (Map<String, dynamic> group in _groups) {
|
||||
if (group['id'] == id) {
|
||||
return group;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Moves any replies whose moment has come into their threads.
|
||||
static void _deliver() {
|
||||
final DateTime now = DateTime.now();
|
||||
final List<Map<String, dynamic>> due = _pending
|
||||
.where((item) => DateTime.parse(item['sentAt']).isBefore(now))
|
||||
.toList();
|
||||
for (Map<String, dynamic> message in due) {
|
||||
_pending.remove(message);
|
||||
_messages.putIfAbsent(message['groupId'], () => <Map<String, dynamic>>[])
|
||||
.add(message);
|
||||
final Map<String, dynamic>? group = _find(message['groupId']);
|
||||
if (group != null) {
|
||||
group['unread'] = (group['unread'] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static Map<String, dynamic> _withLast(Map<String, dynamic> group) {
|
||||
final List<Map<String, dynamic>> thread =
|
||||
_messages[group['id']] ?? <Map<String, dynamic>>[];
|
||||
final Map<String, dynamic> copy = Map<String, dynamic>.from(group);
|
||||
if (thread.isNotEmpty) {
|
||||
final Map<String, dynamic> last = thread.last;
|
||||
copy['lastMessage'] = last['body'];
|
||||
copy['lastAuthor'] = last['authorName'];
|
||||
copy['lastMessageAt'] = last['sentAt'];
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
static void _post(String groupId, String body, String kind) {
|
||||
_messages.putIfAbsent(groupId, () => <Map<String, dynamic>>[]).add(
|
||||
_message(groupId, _me, _myName, body, 0, kind: kind));
|
||||
}
|
||||
|
||||
/// Someone answers a few seconds later, so the chat feels inhabited.
|
||||
static void _queueReply(String groupId) {
|
||||
final Map<String, dynamic>? group = _find(groupId);
|
||||
if (group == null) {
|
||||
return;
|
||||
}
|
||||
final List<dynamic> others = (group['members'] as List)
|
||||
.where((member) => member['id'] != _me)
|
||||
.toList();
|
||||
if (others.isEmpty) {
|
||||
return;
|
||||
}
|
||||
const List<String> lines = <String>[
|
||||
"Love it. Keep that going.",
|
||||
"Same here, just finished mine.",
|
||||
"That's the spirit.",
|
||||
"Noted. I'll match you tomorrow.",
|
||||
"Good — that puts us back on pace.",
|
||||
"Who's in for the next one?",
|
||||
];
|
||||
final Map<String, dynamic> author = others[_random.nextInt(others.length)];
|
||||
final Map<String, dynamic> reply = _message(groupId, author['id'],
|
||||
author['name'], lines[_random.nextInt(lines.length)], 0);
|
||||
reply['sentAt'] =
|
||||
DateTime.now().add(const Duration(seconds: 3)).toIso8601String();
|
||||
_pending.add(reply);
|
||||
}
|
||||
|
||||
static String _code() {
|
||||
const String letters = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
return List<String>.generate(
|
||||
6, (index) => letters[_random.nextInt(letters.length)]).join();
|
||||
}
|
||||
|
||||
// ── Endpoints ─────────────────────────────────────────────────────────────
|
||||
|
||||
static List<Map<String, dynamic>> mine() {
|
||||
_ensureSeeded();
|
||||
_deliver();
|
||||
return _groups.map(_withLast).toList();
|
||||
}
|
||||
|
||||
static Map<String, dynamic> one(String id) {
|
||||
_ensureSeeded();
|
||||
_deliver();
|
||||
return _withLast(_find(id) ?? <String, dynamic>{});
|
||||
}
|
||||
|
||||
static Map<String, dynamic> save(GroupRequest request) {
|
||||
_ensureSeeded();
|
||||
final Map<String, dynamic>? existing =
|
||||
request.id == null ? null : _find(request.id!);
|
||||
if (existing != null) {
|
||||
existing['name'] = request.name;
|
||||
existing['description'] = request.description;
|
||||
existing['colourHex'] = request.colourHex;
|
||||
return _withLast(existing);
|
||||
}
|
||||
|
||||
final String id = MockStore.newId('group');
|
||||
final Map<String, dynamic> group = <String, dynamic>{
|
||||
'id': id,
|
||||
'name': request.name,
|
||||
'description': request.description,
|
||||
'colourHex': request.colourHex,
|
||||
'inviteCode': _code(),
|
||||
'unread': 0,
|
||||
'members': <Map<String, dynamic>>[
|
||||
_member(_me, _myName, role: 'Owner', kept: 0.78, streak: 4,
|
||||
checkedInHoursAgo: 0),
|
||||
],
|
||||
'goals': <Map<String, dynamic>>[],
|
||||
};
|
||||
_groups.insert(0, group);
|
||||
_post(id, 'started the group', 'System');
|
||||
return _withLast(group);
|
||||
}
|
||||
|
||||
static Map<String, dynamic> join(JoinGroupRequest request) {
|
||||
_ensureSeeded();
|
||||
final String code = request.inviteCode.trim().toUpperCase();
|
||||
|
||||
for (Map<String, dynamic> group in _groups) {
|
||||
if (group['inviteCode'] == code) {
|
||||
return _withLast(group);
|
||||
}
|
||||
}
|
||||
|
||||
// Any well-formed code finds the waiting group, so the preview always
|
||||
// has something to join.
|
||||
final Map<String, dynamic> group = _joinable.remove(code) ??
|
||||
(_joinable.isNotEmpty
|
||||
? _joinable.remove(_joinable.keys.first)!
|
||||
: <String, dynamic>{
|
||||
'id': MockStore.newId('group'),
|
||||
'name': 'Group $code',
|
||||
'description': '',
|
||||
'colourHex': colorToHex(goalAccents[0]),
|
||||
'inviteCode': code,
|
||||
'unread': 0,
|
||||
'members': <Map<String, dynamic>>[],
|
||||
'goals': <Map<String, dynamic>>[],
|
||||
});
|
||||
(group['members'] as List).add(_member(_me, _myName,
|
||||
kept: 0.78, streak: 4, checkedInHoursAgo: 0));
|
||||
_groups.insert(0, group);
|
||||
_post(group['id'], 'joined the group', 'System');
|
||||
_queueReply(group['id']);
|
||||
return _withLast(group);
|
||||
}
|
||||
|
||||
static Map<String, dynamic> leave(String id) {
|
||||
_ensureSeeded();
|
||||
_groups.removeWhere((group) => group['id'] == id);
|
||||
return <String, dynamic>{'success': true};
|
||||
}
|
||||
|
||||
static Map<String, dynamic> saveGoal(GroupGoalRequest request) {
|
||||
_ensureSeeded();
|
||||
final Map<String, dynamic>? group = _find(request.groupId);
|
||||
if (group == null) {
|
||||
return <String, dynamic>{};
|
||||
}
|
||||
final Map<String, dynamic> goal = <String, dynamic>{
|
||||
'id': MockStore.newId('gg'),
|
||||
'title': request.title,
|
||||
'description': request.description,
|
||||
'targetDate': request.targetDate,
|
||||
'colourHex': request.colourHex,
|
||||
'objectives': request.objectives
|
||||
.map((draft) => _objective(MockStore.newId('ob'), draft.title,
|
||||
draft.unit, draft.target, 0, 0))
|
||||
.toList(),
|
||||
};
|
||||
(group['goals'] as List).add(goal);
|
||||
_post(group['id'], 'added a shared goal: ${request.title}', 'System');
|
||||
return _withLast(group);
|
||||
}
|
||||
|
||||
static Map<String, dynamic> logProgress(ObjectiveProgressRequest request) {
|
||||
_ensureSeeded();
|
||||
final Map<String, dynamic>? group = _find(request.groupId);
|
||||
if (group == null) {
|
||||
return <String, dynamic>{};
|
||||
}
|
||||
for (Map<String, dynamic> goal in (group['goals'] as List)) {
|
||||
if (goal['id'] != request.goalId) {
|
||||
continue;
|
||||
}
|
||||
for (Map<String, dynamic> objective in (goal['objectives'] as List)) {
|
||||
if (objective['id'] != request.objectiveId) {
|
||||
continue;
|
||||
}
|
||||
objective['current'] = (objective['current'] as num) + request.amount;
|
||||
objective['mine'] = (objective['mine'] as num) + request.amount;
|
||||
final String amount = formatAmount(request.amount);
|
||||
_post(
|
||||
group['id'],
|
||||
'logged $amount ${objective['unit']} on ${objective['title']}'
|
||||
'${request.note.isEmpty ? "" : " — ${request.note}"}',
|
||||
'CheckIn');
|
||||
_queueReply(group['id']);
|
||||
}
|
||||
}
|
||||
for (Map<String, dynamic> member in (group['members'] as List)) {
|
||||
if (member['id'] == _me) {
|
||||
member['lastCheckIn'] = DateTime.now().toIso8601String();
|
||||
}
|
||||
}
|
||||
return _withLast(group);
|
||||
}
|
||||
|
||||
static List<Map<String, dynamic>> messages(String groupId) {
|
||||
_ensureSeeded();
|
||||
_deliver();
|
||||
final Map<String, dynamic>? group = _find(groupId);
|
||||
if (group != null) {
|
||||
group['unread'] = 0;
|
||||
}
|
||||
return List<Map<String, dynamic>>.from(
|
||||
_messages[groupId] ?? <Map<String, dynamic>>[]);
|
||||
}
|
||||
|
||||
static Map<String, dynamic> send(GroupMessageRequest request) {
|
||||
_ensureSeeded();
|
||||
_post(request.groupId, request.body, request.kind);
|
||||
_queueReply(request.groupId);
|
||||
return _messages[request.groupId]!.last;
|
||||
}
|
||||
|
||||
static Map<String, dynamic> nudge(NudgeRequest request) {
|
||||
_ensureSeeded();
|
||||
final Map<String, dynamic>? group = _find(request.groupId);
|
||||
if (group == null) {
|
||||
return <String, dynamic>{'success': false};
|
||||
}
|
||||
String who = "everyone";
|
||||
for (Map<String, dynamic> member in (group['members'] as List)) {
|
||||
if (member['id'] == request.memberId) {
|
||||
who = member['name'];
|
||||
}
|
||||
}
|
||||
_post(request.groupId, 'nudged $who: today still counts, go get it',
|
||||
'Nudge');
|
||||
return <String, dynamic>{'success': true};
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
import '../about/internal/application/MeDescription.dart';
|
||||
import '../about/internal/application/TodoItem.dart';
|
||||
import '../about/internal/application/UserDetails.dart';
|
||||
import '../memory/InternalMemory.dart';
|
||||
import 'MockStorage.dart';
|
||||
@@ -18,6 +19,11 @@ class MockMemory extends InternalMemory {
|
||||
setMyDescription(
|
||||
MeDescription(id: "mock-user", name: "Alex", token: "mock-token"));
|
||||
setUserDetails(UserDetails(pic: "", name: "Alex"));
|
||||
setTodos(<TodoItem>[
|
||||
TodoItem(id: "t1", title: "Pick up gym kit"),
|
||||
TodoItem(id: "t2", title: "Book physio for Thursday"),
|
||||
TodoItem(id: "t3", title: "Reply to Sam about Saturday", done: true),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -443,8 +443,8 @@ class NewCommitmentState extends State<NewCommitment>
|
||||
decoration: BoxDecoration(
|
||||
color: colorSheetBackground,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(28),
|
||||
topRight: Radius.circular(28),
|
||||
topLeft: Radius.zero,
|
||||
topRight: Radius.zero,
|
||||
),
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
@@ -460,7 +460,7 @@ class NewCommitmentState extends State<NewCommitment>
|
||||
margin: const EdgeInsets.only(bottom: 20),
|
||||
decoration: BoxDecoration(
|
||||
color: colorGrey.withValues(alpha: 0.35),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
borderRadius: BorderRadius.zero,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -142,7 +142,7 @@ class ExcuseReportState extends State<ExcuseReport>
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: colorInset,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: BorderRadius.zero,
|
||||
border: Border.all(color: colorBorder, width: 1),
|
||||
),
|
||||
child: Row(
|
||||
|
||||
@@ -242,7 +242,7 @@ class GoalDetailState extends State<GoalDetail>
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
|
||||
decoration: BoxDecoration(
|
||||
color: colorWhite.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderRadius: BorderRadius.zero,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -405,7 +405,7 @@ class GoalDetailState extends State<GoalDetail>
|
||||
width: 58,
|
||||
height: 58,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderRadius: BorderRadius.zero,
|
||||
child: SceneArt(
|
||||
palette: scenePalette(routine.kind),
|
||||
seed: sceneSeed(routine.title),
|
||||
@@ -454,7 +454,6 @@ class GoalDetailState extends State<GoalDetail>
|
||||
color: routine.kind == RoutineKind.MustDo
|
||||
? colorPrimaryDark
|
||||
: tone,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(CupertinoIcons.play_fill,
|
||||
size: 15, color: colorWhite),
|
||||
|
||||
@@ -282,7 +282,7 @@ class GoalEditorState extends State<GoalEditor> implements ConnectGoalEditor {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(18),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
borderRadius: BorderRadius.zero,
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
@@ -338,7 +338,6 @@ class GoalEditorState extends State<GoalEditor> implements ConnectGoalEditor {
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: colour,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: selected ? colorWhite : Colors.transparent,
|
||||
width: 3,
|
||||
|
||||
@@ -117,10 +117,10 @@ class GoalsState extends State<Goals> implements ConnectGoals {
|
||||
return GestureDetector(
|
||||
onTap: () => _onOpenGoal(goal),
|
||||
child: Container(
|
||||
height: 214,
|
||||
height: 260,
|
||||
margin: const EdgeInsets.only(bottom: 14),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(26),
|
||||
borderRadius: BorderRadius.zero,
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x262A3A30),
|
||||
@@ -130,7 +130,7 @@ class GoalsState extends State<Goals> implements ConnectGoals {
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(26),
|
||||
borderRadius: BorderRadius.zero,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
@@ -151,7 +151,7 @@ class GoalsState extends State<Goals> implements ConnectGoals {
|
||||
children: [
|
||||
GlassSurface(
|
||||
onDark: true,
|
||||
radius: BorderRadius.circular(99),
|
||||
radius: BorderRadius.zero,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10, vertical: 5),
|
||||
child: text(
|
||||
|
||||
10
frontend/lib/Grounded/see/group/ConnectGroupChat.dart
Normal file
10
frontend/lib/Grounded/see/group/ConnectGroupChat.dart
Normal file
@@ -0,0 +1,10 @@
|
||||
import '../../about/external/data/GroupMessage.dart';
|
||||
|
||||
abstract class ConnectGroupChat {
|
||||
void onMessagesLoaded(List<GroupMessage> messages);
|
||||
|
||||
void onMessageSent(GroupMessage message);
|
||||
|
||||
/// The send failed; the text goes back in the box so nothing is lost.
|
||||
void onSendFailed(String body);
|
||||
}
|
||||
12
frontend/lib/Grounded/see/group/ConnectGroupDetail.dart
Normal file
12
frontend/lib/Grounded/see/group/ConnectGroupDetail.dart
Normal file
@@ -0,0 +1,12 @@
|
||||
import '../../about/external/data/Group.dart';
|
||||
|
||||
abstract class ConnectGroupDetail {
|
||||
void onGroupLoaded(Group group);
|
||||
|
||||
/// Progress landed; the group comes back with the new totals.
|
||||
void onProgressLogged(Group group, String summary);
|
||||
|
||||
void onNudged(String who);
|
||||
|
||||
void onLeft();
|
||||
}
|
||||
6
frontend/lib/Grounded/see/group/ConnectGroupEditor.dart
Normal file
6
frontend/lib/Grounded/see/group/ConnectGroupEditor.dart
Normal file
@@ -0,0 +1,6 @@
|
||||
import '../../about/external/data/Group.dart';
|
||||
|
||||
abstract class ConnectGroupEditor {
|
||||
/// Created or joined — either way you are now in it.
|
||||
void onGroupReady(Group group);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
abstract class ConnectGroupGoalEditor {
|
||||
void onGoalSaved();
|
||||
}
|
||||
5
frontend/lib/Grounded/see/group/ConnectGroups.dart
Normal file
5
frontend/lib/Grounded/see/group/ConnectGroups.dart
Normal file
@@ -0,0 +1,5 @@
|
||||
import '../../about/external/data/Group.dart';
|
||||
|
||||
abstract class ConnectGroups {
|
||||
void onGroupsLoaded(List<Group> groups);
|
||||
}
|
||||
13
frontend/lib/Grounded/see/group/GroupChat.dart
Normal file
13
frontend/lib/Grounded/see/group/GroupChat.dart
Normal file
@@ -0,0 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../about/external/data/Group.dart';
|
||||
import 'GroupChatState.dart';
|
||||
|
||||
class GroupChat extends StatefulWidget {
|
||||
final Group group;
|
||||
|
||||
const GroupChat({super.key, required this.group});
|
||||
|
||||
@override
|
||||
State<GroupChat> createState() => GroupChatState();
|
||||
}
|
||||
464
frontend/lib/Grounded/see/group/GroupChatState.dart
Normal file
464
frontend/lib/Grounded/see/group/GroupChatState.dart
Normal file
@@ -0,0 +1,464 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stacked/stacked.dart';
|
||||
|
||||
import '../../about/external/data/GroupMessage.dart';
|
||||
import '../../about/external/initial/GroupMessageRequest.dart';
|
||||
import '../../about/internal/application/TextType.dart';
|
||||
import '../../designs/AmbientBackground.dart';
|
||||
import '../../designs/GlassSurface.dart';
|
||||
import '../../designs/MemberAvatar.dart';
|
||||
import '../../designs/Responsive.dart';
|
||||
import '../../designs/input/InputFields.dart';
|
||||
import '../../designs/text/Text.dart';
|
||||
import '../../utils/Colors.dart';
|
||||
import '../../utils/CommonUtils.dart';
|
||||
import 'ConnectGroupChat.dart';
|
||||
import 'GroupChat.dart';
|
||||
import 'ViewGroupChat.dart';
|
||||
|
||||
class GroupChatState extends State<GroupChat> implements ConnectGroupChat {
|
||||
ViewGroupChat? _model;
|
||||
|
||||
List<GroupMessage> _messages = <GroupMessage>[];
|
||||
|
||||
final TextEditingController _input = TextEditingController();
|
||||
|
||||
Timer? _poll;
|
||||
|
||||
/// How often the open chat looks for new messages.
|
||||
static const Duration _pollEvery = Duration(seconds: 4);
|
||||
|
||||
static const List<String> _quickReplies = <String>[
|
||||
"Done for today",
|
||||
"On my way",
|
||||
"Need a push",
|
||||
"Who's in tomorrow?",
|
||||
];
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_poll?.cancel();
|
||||
_input.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ViewModelBuilder<ViewGroupChat>.reactive(
|
||||
viewModelBuilder: () => ViewGroupChat(context, this),
|
||||
onViewModelReady: (viewModel) {
|
||||
_model = viewModel;
|
||||
_initiate();
|
||||
},
|
||||
builder: (context, viewModel, child) => LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
return Responsive(
|
||||
mobile: _mobileView(constraints),
|
||||
tablet: _mobileView(constraints),
|
||||
desktop: _mobileView(constraints),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _initiate() {
|
||||
_model?.loadMessages(widget.group.id);
|
||||
_poll = Timer.periodic(_pollEvery, _onPoll);
|
||||
}
|
||||
|
||||
// ── Handlers ──────────────────────────────────────────────────────────────
|
||||
|
||||
void _onBack() {
|
||||
Navigator.pop(context, true);
|
||||
}
|
||||
|
||||
void _onPoll(Timer timer) {
|
||||
_model?.refresh(widget.group.id);
|
||||
}
|
||||
|
||||
void _onSend(String value) {
|
||||
final String body = value.trim();
|
||||
if (body.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
_input.clear();
|
||||
_model?.send(GroupMessageRequest(groupId: widget.group.id, body: body));
|
||||
}
|
||||
|
||||
void _onSendPressed() {
|
||||
_onSend(_input.text);
|
||||
}
|
||||
|
||||
void _onQuickReply(String reply) {
|
||||
_onSend(reply);
|
||||
}
|
||||
|
||||
// ── Views ─────────────────────────────────────────────────────────────────
|
||||
|
||||
Widget _mobileView(BoxConstraints constraints) {
|
||||
final List<GroupMessage> newestFirst = _messages.reversed.toList();
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: colorAmbientMid,
|
||||
body: AmbientBackground(
|
||||
child: Column(
|
||||
children: [
|
||||
_topBar(),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
reverse: true,
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||
itemCount: newestFirst.length,
|
||||
itemBuilder: (context, index) {
|
||||
final GroupMessage message = newestFirst[index];
|
||||
final GroupMessage? older = index + 1 < newestFirst.length
|
||||
? newestFirst[index + 1]
|
||||
: null;
|
||||
final bool newDay = older == null ||
|
||||
!_sameDay(older.sentAt, message.sentAt);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (newDay) _dayLabel(message.sentAt),
|
||||
_message(message, older),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
_composer(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _topBar() {
|
||||
final Color accent = goalColor(widget.group.colourHex);
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: <Color>[
|
||||
Color.lerp(accent, colorHeaderBottom, 0.55)!,
|
||||
colorHeaderBottom,
|
||||
],
|
||||
),
|
||||
),
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
16, MediaQuery.of(context).padding.top + 10, 16, 14),
|
||||
child: Row(
|
||||
children: [
|
||||
glassCircle(CupertinoIcons.arrow_left, _onBack,
|
||||
size: 46, onDark: true),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
text(widget.group.name, 19, TextType.Medium,
|
||||
color: colorWhite,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
const SizedBox(height: 3),
|
||||
text(
|
||||
widget.group.members
|
||||
.map((member) => member.me ? "you" : member.name)
|
||||
.join(", "),
|
||||
12,
|
||||
TextType.Regular,
|
||||
color: colorHeaderMuted,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
avatarStack(
|
||||
widget.group.members.map((member) => member.name).toList(), 30,
|
||||
max: 3, border: colorHeaderBottom),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _dayLabel(DateTime? moment) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
child: Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
color: colorWhite.withValues(alpha: 0.7),
|
||||
child: text(_dayName(moment), 11, TextType.Medium,
|
||||
color: colorInkSoft),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Text as bubbles, check-ins and nudges as cards in the thread, system
|
||||
/// notes as a quiet centred line.
|
||||
Widget _message(GroupMessage message, GroupMessage? older) {
|
||||
if (message.isSystem) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: text(
|
||||
"${message.mine ? "You" : message.authorName} ${message.body}",
|
||||
12,
|
||||
TextType.Regular,
|
||||
color: colorInkSoft,
|
||||
align: TextAlign.center,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (message.isCheckIn || message.isNudge) {
|
||||
return _eventCard(message);
|
||||
}
|
||||
|
||||
// Consecutive lines from one person share a single name and portrait.
|
||||
final bool grouped = older != null &&
|
||||
older.authorId == message.authorId &&
|
||||
!older.isSystem &&
|
||||
!older.isCheckIn &&
|
||||
!older.isNudge &&
|
||||
_sameDay(older.sentAt, message.sentAt);
|
||||
|
||||
if (message.mine) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(top: grouped ? 4 : 12, left: 64),
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.fromLTRB(14, 11, 14, 10),
|
||||
color: colorNight,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
text(message.body, 15, TextType.Regular,
|
||||
color: colorWhite, height: 1.4),
|
||||
const SizedBox(height: 4),
|
||||
text(_time(message.sentAt), 10, TextType.Regular,
|
||||
color: colorHeaderMuted),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final Color tone = _authorColour(message.authorName);
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(top: grouped ? 4 : 12, right: 56),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
grouped
|
||||
? const SizedBox(width: 34)
|
||||
: memberAvatar(message.authorName, 34),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.fromLTRB(14, 10, 14, 10),
|
||||
decoration: BoxDecoration(
|
||||
color: colorWhite,
|
||||
border: Border.all(color: colorGlassBorder),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (!grouped) ...[
|
||||
text(message.authorName, 12, TextType.Medium, color: tone),
|
||||
const SizedBox(height: 3),
|
||||
],
|
||||
text(message.body, 15, TextType.Regular,
|
||||
color: colorInk, height: 1.4),
|
||||
const SizedBox(height: 4),
|
||||
text(_time(message.sentAt), 10, TextType.Regular,
|
||||
color: colorInkSoft),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// A check-in or a nudge, set apart from the talk so the work is visible.
|
||||
Widget _eventCard(GroupMessage message) {
|
||||
final bool checkIn = message.isCheckIn;
|
||||
final String who = message.mine ? "You" : message.authorName;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 14, 12),
|
||||
decoration: BoxDecoration(
|
||||
color: checkIn ? colorStandingGoodBg : colorOverdueWarmEnd,
|
||||
border: Border(
|
||||
left: BorderSide(
|
||||
color: checkIn ? colorPositive : colorBarStandard,
|
||||
width: 4,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
memberAvatar(message.authorName, 34),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
checkIn
|
||||
? CupertinoIcons.checkmark_seal_fill
|
||||
: CupertinoIcons.hand_point_right_fill,
|
||||
size: 13,
|
||||
color: checkIn ? colorPositive : colorWarning,
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
text(checkIn ? "CHECK-IN" : "NUDGE", 10, TextType.Bold,
|
||||
color: checkIn ? colorPositive : colorWarning,
|
||||
letterSpacing: 1.0),
|
||||
const Spacer(),
|
||||
text(_time(message.sentAt), 10, TextType.Regular,
|
||||
color: colorInkSoft),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
text("$who ${message.body}", 14, TextType.Regular,
|
||||
color: colorInk, height: 1.4),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _composer() {
|
||||
return Container(
|
||||
color: colorWhite,
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
0, 10, 0, MediaQuery.of(context).padding.bottom + 10),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 38,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: _quickReplies.length,
|
||||
separatorBuilder: (context, index) => const SizedBox(width: 8),
|
||||
itemBuilder: (context, index) => GestureDetector(
|
||||
onTap: () => _onQuickReply(_quickReplies[index]),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
alignment: Alignment.center,
|
||||
color: colorAmbientMid,
|
||||
child: text(_quickReplies[index], 13, TextType.Regular,
|
||||
color: colorInk),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 10, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: quickAddField(_input, "Message the group", _onSend),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
GestureDetector(
|
||||
onTap: _onSendPressed,
|
||||
child: Container(
|
||||
width: 50,
|
||||
height: 50,
|
||||
color: colorNight,
|
||||
child: Icon(CupertinoIcons.paperplane_fill,
|
||||
size: 20, color: colorLime),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
Color _authorColour(String name) {
|
||||
return goalAccents[sceneSeed(name) % goalAccents.length];
|
||||
}
|
||||
|
||||
bool _sameDay(DateTime? a, DateTime? b) {
|
||||
if (a == null || b == null) return false;
|
||||
final DateTime x = a.toLocal();
|
||||
final DateTime y = b.toLocal();
|
||||
return x.year == y.year && x.month == y.month && x.day == y.day;
|
||||
}
|
||||
|
||||
String _dayName(DateTime? moment) {
|
||||
if (moment == null) return "";
|
||||
final DateTime now = DateTime.now();
|
||||
if (_sameDay(moment, now)) return "Today";
|
||||
if (_sameDay(moment, now.subtract(const Duration(days: 1)))) {
|
||||
return "Yesterday";
|
||||
}
|
||||
return formatDate(moment.toLocal());
|
||||
}
|
||||
|
||||
String _time(DateTime? moment) {
|
||||
if (moment == null) return "";
|
||||
final DateTime local = moment.toLocal();
|
||||
return "${local.hour.toString().padLeft(2, "0")}:${local.minute.toString().padLeft(2, "0")}";
|
||||
}
|
||||
|
||||
// ── ConnectGroupChat ──────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
void onMessagesLoaded(List<GroupMessage> messages) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_messages = messages;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void onMessageSent(GroupMessage message) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_messages = <GroupMessage>[..._messages, message];
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void onSendFailed(String body) {
|
||||
if (!mounted) return;
|
||||
_input.text = body;
|
||||
}
|
||||
}
|
||||
13
frontend/lib/Grounded/see/group/GroupDetail.dart
Normal file
13
frontend/lib/Grounded/see/group/GroupDetail.dart
Normal file
@@ -0,0 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../about/external/data/Group.dart';
|
||||
import 'GroupDetailState.dart';
|
||||
|
||||
class GroupDetail extends StatefulWidget {
|
||||
final Group group;
|
||||
|
||||
const GroupDetail({super.key, required this.group});
|
||||
|
||||
@override
|
||||
State<GroupDetail> createState() => GroupDetailState();
|
||||
}
|
||||
708
frontend/lib/Grounded/see/group/GroupDetailState.dart
Normal file
708
frontend/lib/Grounded/see/group/GroupDetailState.dart
Normal file
@@ -0,0 +1,708 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:stacked/stacked.dart';
|
||||
|
||||
import '../../about/external/data/Group.dart';
|
||||
import '../../about/external/data/GroupGoal.dart';
|
||||
import '../../about/external/data/GroupMember.dart';
|
||||
import '../../about/external/data/GroupObjective.dart';
|
||||
import '../../about/external/initial/NudgeRequest.dart';
|
||||
import '../../about/external/initial/ObjectiveProgressRequest.dart';
|
||||
import '../../about/internal/application/NotificationType.dart';
|
||||
import '../../about/internal/application/TextType.dart';
|
||||
import '../../configs/Navigator.dart';
|
||||
import '../../designs/Component.dart';
|
||||
import '../../designs/MemberAvatar.dart';
|
||||
import '../../designs/Responsive.dart';
|
||||
import '../../designs/Shell.dart';
|
||||
import '../../designs/buttons/Buttons.dart';
|
||||
import '../../designs/input/InputFields.dart';
|
||||
import '../../designs/text/Text.dart';
|
||||
import '../../utils/Colors.dart';
|
||||
import '../../utils/CommonUtils.dart';
|
||||
import 'ConnectGroupDetail.dart';
|
||||
import 'GroupChat.dart';
|
||||
import 'GroupDetail.dart';
|
||||
import 'GroupGoalEditor.dart';
|
||||
import 'ViewGroupDetail.dart';
|
||||
|
||||
class GroupDetailState extends State<GroupDetail>
|
||||
implements ConnectGroupDetail {
|
||||
ViewGroupDetail? _model;
|
||||
|
||||
late Group _group;
|
||||
|
||||
/// Members nudged from this screen, so the button reads "Nudged" after.
|
||||
final Set<String> _nudged = <String>{};
|
||||
|
||||
final TextEditingController _amount = TextEditingController();
|
||||
|
||||
final TextEditingController _note = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_group = widget.group;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_amount.dispose();
|
||||
_note.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ViewModelBuilder<ViewGroupDetail>.reactive(
|
||||
viewModelBuilder: () => ViewGroupDetail(context, this),
|
||||
onViewModelReady: (viewModel) {
|
||||
_model = viewModel;
|
||||
_initiate();
|
||||
},
|
||||
builder: (context, viewModel, child) => LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
return Responsive(
|
||||
mobile: _mobileView(constraints),
|
||||
tablet: _mobileView(constraints),
|
||||
desktop: _mobileView(constraints),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _initiate() {
|
||||
_model?.loadGroup(widget.group.id);
|
||||
}
|
||||
|
||||
Color get _accent => goalColor(_group.colourHex);
|
||||
|
||||
// ── Handlers ──────────────────────────────────────────────────────────────
|
||||
|
||||
void _onBack() {
|
||||
Navigator.pop(context, true);
|
||||
}
|
||||
|
||||
void _onOpenChat() async {
|
||||
await GroundedNavigation()
|
||||
.navigateToPageWithData(GroupChat(group: _group), context);
|
||||
_model?.loadGroup(_group.id, quiet: true);
|
||||
}
|
||||
|
||||
void _onAddGoal() async {
|
||||
final result = await GroundedNavigation()
|
||||
.navigateToPageWithData(GroupGoalEditor(group: _group), context);
|
||||
|
||||
if (result == true) {
|
||||
_model?.loadGroup(_group.id, quiet: true);
|
||||
}
|
||||
}
|
||||
|
||||
void _onCopyCode() async {
|
||||
await Clipboard.setData(ClipboardData(text: _group.inviteCode));
|
||||
_model?.showApplicationNotification(
|
||||
NotificationType.success,
|
||||
"Code copied",
|
||||
"Send ${_group.inviteCode} to whoever should join. They enter it under Groups → Join with a code.",
|
||||
true,
|
||||
true,
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
void _onNudge(GroupMember member) {
|
||||
_model?.nudge(
|
||||
NudgeRequest(groupId: _group.id, memberId: member.id), member.name);
|
||||
}
|
||||
|
||||
void _onNudgeEveryone() {
|
||||
_model?.nudge(NudgeRequest(groupId: _group.id), "everyone");
|
||||
}
|
||||
|
||||
void _onLog(GroupGoal goal, GroupObjective objective) {
|
||||
_amount.text = "";
|
||||
_note.text = "";
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: colorWhite,
|
||||
builder: (sheetContext) => _logSheet(sheetContext, goal, objective),
|
||||
);
|
||||
}
|
||||
|
||||
void _onQuickAmount(double value) {
|
||||
_amount.text = formatAmount(value).replaceAll(",", "");
|
||||
}
|
||||
|
||||
void _onSubmitLog(BuildContext sheetContext, GroupGoal goal,
|
||||
GroupObjective objective) {
|
||||
final double amount = double.tryParse(_amount.text.trim()) ?? 0;
|
||||
if (amount <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
Navigator.pop(sheetContext);
|
||||
|
||||
_model?.logProgress(
|
||||
ObjectiveProgressRequest(
|
||||
groupId: _group.id,
|
||||
goalId: goal.id,
|
||||
objectiveId: objective.id,
|
||||
amount: amount,
|
||||
note: _note.text.trim(),
|
||||
),
|
||||
"${formatAmount(amount)} ${objective.unit} on ${objective.title}",
|
||||
);
|
||||
}
|
||||
|
||||
void _onLeave() {
|
||||
_model?.showApplicationNotification(
|
||||
NotificationType.warning,
|
||||
"Leave ${_group.name}?",
|
||||
"You stop seeing the shared goals and the chat. What you logged stays in the group's totals.",
|
||||
true,
|
||||
true,
|
||||
null,
|
||||
action: "Leave",
|
||||
positiveAction: _onConfirmLeave,
|
||||
);
|
||||
}
|
||||
|
||||
void _onConfirmLeave() {
|
||||
Navigator.pop(context);
|
||||
_model?.leave(_group.id);
|
||||
}
|
||||
|
||||
// ── Views ─────────────────────────────────────────────────────────────────
|
||||
|
||||
Widget _mobileView(BoxConstraints constraints) {
|
||||
final List<GroupMember> ranked = List<GroupMember>.from(_group.members)
|
||||
..sort((a, b) => b.keptRate.compareTo(a.keptRate));
|
||||
|
||||
return Sheet(
|
||||
eyebrow: "${_group.members.length} members",
|
||||
title: _group.name,
|
||||
onBack: _onBack,
|
||||
scene: goalScene(_accent),
|
||||
sceneSeed: sceneSeed(_group.name),
|
||||
action: chromeAction(CupertinoIcons.chat_bubble_2_fill, _onOpenChat),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (_group.description.isNotEmpty) ...[
|
||||
text(_group.description, 15, TextType.Regular,
|
||||
color: colorGrey2, height: 1.55),
|
||||
const SizedBox(height: 22),
|
||||
],
|
||||
_statsRow(),
|
||||
const SizedBox(height: 20),
|
||||
_chatCard(),
|
||||
const SizedBox(height: 30),
|
||||
sectionBreak(
|
||||
"Shared goals",
|
||||
caption: "${_group.goals.length} on the go",
|
||||
trailing: textButton("+ Add", _onAddGoal,
|
||||
color: colorInk, textSize: 14, type: TextType.Medium),
|
||||
),
|
||||
if (_group.goals.isEmpty)
|
||||
emptyState(
|
||||
CupertinoIcons.flag,
|
||||
"Nothing shared yet",
|
||||
"Add a goal and split it into objectives everyone can log toward, like kilometres, pages or sessions.",
|
||||
)
|
||||
else
|
||||
..._group.goals.map(_goalCard),
|
||||
const SizedBox(height: 30),
|
||||
sectionBreak("Leaderboard",
|
||||
caption: "kept, last 7 days",
|
||||
trailing: textButton("Nudge all", _onNudgeEveryone,
|
||||
color: colorInk, textSize: 14, type: TextType.Medium)),
|
||||
for (int index = 0; index < ranked.length; index++)
|
||||
_memberRow(index + 1, ranked[index]),
|
||||
const SizedBox(height: 26),
|
||||
Center(
|
||||
child: textButton("Leave group", _onLeave,
|
||||
color: colorDestructive, textSize: 13),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _statsRow() {
|
||||
final double average = _group.members.isEmpty
|
||||
? 0
|
||||
: _group.members
|
||||
.map((member) => member.keptRate)
|
||||
.reduce((a, b) => a + b) /
|
||||
_group.members.length;
|
||||
|
||||
return card(
|
||||
padding: const EdgeInsets.fromLTRB(20, 18, 12, 18),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: labelled("Together", "${(_group.progress * 100).round()}%",
|
||||
valueSize: 26, valueType: TextType.Light),
|
||||
),
|
||||
Expanded(
|
||||
child: labelled("Kept avg", "${(average * 100).round()}%",
|
||||
valueSize: 26, valueType: TextType.Light),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: _onCopyCode,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
color: colorAmbientMid,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
text("INVITE", 9, TextType.Bold,
|
||||
color: colorGrey2, letterSpacing: 1.0),
|
||||
const SizedBox(width: 6),
|
||||
Icon(CupertinoIcons.doc_on_doc,
|
||||
size: 12, color: colorGrey2),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
text(_group.inviteCode, 18, TextType.Medium,
|
||||
color: colorInk, letterSpacing: 1.5),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The way into the chat: the last line said and how much is unread.
|
||||
Widget _chatCard() {
|
||||
return GestureDetector(
|
||||
onTap: _onOpenChat,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.fromLTRB(20, 18, 16, 18),
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: <Color>[colorNightLift, colorNight],
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Color(0x332A3A30),
|
||||
blurRadius: 24,
|
||||
offset: Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(CupertinoIcons.chat_bubble_2_fill,
|
||||
size: 16, color: colorLime),
|
||||
const SizedBox(width: 8),
|
||||
text("Group chat", 14, TextType.Medium,
|
||||
color: colorLime),
|
||||
if (_group.unread > 0) ...[
|
||||
const SizedBox(width: 10),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 7, vertical: 3),
|
||||
color: colorLime,
|
||||
child: text("${_group.unread} new", 10,
|
||||
TextType.Medium,
|
||||
color: colorNight),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
text(
|
||||
_group.lastMessage.isEmpty
|
||||
? "Say hello. Check-ins and nudges show up here too."
|
||||
: "${_group.lastAuthor}: ${_group.lastMessage}",
|
||||
15,
|
||||
TextType.Regular,
|
||||
color: colorWhite,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
height: 1.45,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Container(
|
||||
width: 46,
|
||||
height: 46,
|
||||
color: colorWhite.withValues(alpha: 0.12),
|
||||
child: Icon(CupertinoIcons.arrow_right,
|
||||
size: 20, color: colorWhite),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// A shared goal: its overall bar, then each objective with the group's
|
||||
/// total, your share, and a button to log more.
|
||||
Widget _goalCard(GroupGoal goal) {
|
||||
final Color tone =
|
||||
goal.colourHex.isEmpty ? _accent : goalColor(goal.colourHex);
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 14),
|
||||
child: card(
|
||||
padding: EdgeInsets.zero,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(height: 6, color: tone),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 18, 20, 6),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: text(goal.title, 19, TextType.Medium,
|
||||
color: colorInk),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
text("${(goal.progress * 100).round()}%", 22,
|
||||
TextType.Light,
|
||||
color: colorInk),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
text(
|
||||
[
|
||||
"${goal.completeObjectives}/${goal.objectives.length} objectives met",
|
||||
goal.targetDate == null
|
||||
? "no deadline"
|
||||
: targetLabel(goal.targetDate),
|
||||
].join(" · "),
|
||||
12,
|
||||
TextType.Regular,
|
||||
color: colorGrey2,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
meter(goal.progress,
|
||||
fill: tone, track: tone.withValues(alpha: 0.14), height: 6),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
for (GroupObjective objective in goal.objectives)
|
||||
_objectiveRow(goal, objective, tone),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _objectiveRow(GroupGoal goal, GroupObjective objective, Color tone) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(20, 14, 14, 16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(top: BorderSide(color: colorBorder)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
if (objective.complete) ...[
|
||||
Icon(CupertinoIcons.checkmark_seal_fill,
|
||||
size: 15, color: colorPositive),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
Flexible(
|
||||
child: text(objective.title, 15, TextType.Medium,
|
||||
color: colorInk,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
text(
|
||||
"${formatAmount(objective.current)} / ${formatAmount(objective.target)} ${objective.unit}",
|
||||
13,
|
||||
TextType.Regular,
|
||||
color: colorGrey2,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
meter(objective.progress,
|
||||
fill: tone, track: tone.withValues(alpha: 0.12), height: 4),
|
||||
const SizedBox(height: 6),
|
||||
text("You: ${formatAmount(objective.mine)} ${objective.unit}",
|
||||
12, TextType.Medium,
|
||||
color: tone),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
GestureDetector(
|
||||
onTap: () => _onLog(goal, objective),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
color: colorNight,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(CupertinoIcons.add, size: 16, color: colorLime),
|
||||
const SizedBox(width: 6),
|
||||
text("Log", 14, TextType.Medium, color: colorWhite),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _memberRow(int rank, GroupMember member) {
|
||||
final bool nudged = _nudged.contains(member.id);
|
||||
final bool quiet = member.lastCheckIn == null ||
|
||||
DateTime.now().difference(member.lastCheckIn!).inHours >= 24;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
child: card(
|
||||
padding: const EdgeInsets.fromLTRB(14, 12, 12, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 24,
|
||||
child: text("$rank", 16, TextType.Medium,
|
||||
color: rank == 1 ? colorInk : colorGrey2),
|
||||
),
|
||||
memberAvatar(member.name, 46),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: text(
|
||||
member.me ? "${member.name} (you)" : member.name,
|
||||
16,
|
||||
TextType.Medium,
|
||||
color: colorInk,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
if (member.owner) ...[
|
||||
const SizedBox(width: 6),
|
||||
Icon(CupertinoIcons.star_fill,
|
||||
size: 12, color: colorBarStandard),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Icon(CupertinoIcons.flame_fill,
|
||||
size: 12, color: colorBarNonNegotiable),
|
||||
const SizedBox(width: 3),
|
||||
text("${member.streak}d", 12, TextType.Regular,
|
||||
color: colorGrey2),
|
||||
const SizedBox(width: 10),
|
||||
text(
|
||||
quiet
|
||||
? "quiet ${member.lastCheckIn == null ? "" : _ago(member.lastCheckIn!)}"
|
||||
: "checked in ${_ago(member.lastCheckIn!)}",
|
||||
12,
|
||||
TextType.Regular,
|
||||
color: quiet ? colorStandingGrounded : colorGrey2,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
text("${(member.keptRate * 100).round()}%", 20, TextType.Light,
|
||||
color: colorInk),
|
||||
if (!member.me) ...[
|
||||
const SizedBox(width: 10),
|
||||
GestureDetector(
|
||||
onTap: nudged ? null : () => _onNudge(member),
|
||||
child: Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
color: nudged ? colorAmbientMid : colorNight,
|
||||
child: Icon(
|
||||
nudged
|
||||
? CupertinoIcons.checkmark
|
||||
: CupertinoIcons.hand_point_right_fill,
|
||||
size: 18,
|
||||
color: nudged ? colorGrey2 : colorLime,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Log progress: an amount (with one-tap presets that suit the unit) and
|
||||
/// an optional note that rides along into the chat.
|
||||
Widget _logSheet(BuildContext sheetContext, GroupGoal goal,
|
||||
GroupObjective objective) {
|
||||
final List<double> presets = _presets(objective.unit);
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(20, 16, 20,
|
||||
MediaQuery.of(sheetContext).viewInsets.bottom + 28),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Center(child: Container(width: 40, height: 4, color: colorGrabber)),
|
||||
const SizedBox(height: 18),
|
||||
text("LOG PROGRESS", 10, TextType.Bold,
|
||||
color: colorGrey2, letterSpacing: 1.2),
|
||||
const SizedBox(height: 6),
|
||||
text(objective.title, 24, TextType.Medium, color: colorInk),
|
||||
const SizedBox(height: 4),
|
||||
text(
|
||||
"${formatAmount(objective.current)} of ${formatAmount(objective.target)} ${objective.unit} so far",
|
||||
13,
|
||||
TextType.Regular,
|
||||
color: colorGrey2),
|
||||
const SizedBox(height: 18),
|
||||
Row(
|
||||
children: [
|
||||
for (double preset in presets) ...[
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () => _onQuickAmount(preset),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
alignment: Alignment.center,
|
||||
color: colorAmbientMid,
|
||||
child: text("+${formatAmount(preset)}", 15,
|
||||
TextType.Medium,
|
||||
color: colorInk),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (preset != presets.last) const SizedBox(width: 8),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
inputField(
|
||||
"Amount (${objective.unit})",
|
||||
_amount,
|
||||
hint: "How much?",
|
||||
keyboard: const TextInputType.numberWithOptions(decimal: true),
|
||||
formatters: <TextInputFormatter>[
|
||||
FilteringTextInputFormatter.allow(RegExp(r"[0-9.]")),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
inputField("Note", _note, hint: "Optional: posted with the check-in"),
|
||||
const SizedBox(height: 22),
|
||||
roundedCornerButton("Log it",
|
||||
() => _onSubmitLog(sheetContext, goal, objective),
|
||||
icon: CupertinoIcons.checkmark),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<double> _presets(String unit) {
|
||||
switch (unit.toLowerCase()) {
|
||||
case "steps":
|
||||
return <double>[1000, 5000, 10000];
|
||||
case "words":
|
||||
return <double>[250, 500, 1000];
|
||||
case "hours":
|
||||
return <double>[0.5, 1, 2];
|
||||
case "km":
|
||||
return <double>[3, 5, 10];
|
||||
default:
|
||||
return <double>[1, 2, 5];
|
||||
}
|
||||
}
|
||||
|
||||
String _ago(DateTime moment) {
|
||||
final Duration gap = DateTime.now().difference(moment);
|
||||
if (gap.inMinutes < 60) return "${gap.inMinutes.clamp(1, 59)}m ago";
|
||||
if (gap.inHours < 24) return "${gap.inHours}h ago";
|
||||
return "${gap.inDays}d ago";
|
||||
}
|
||||
|
||||
// ── ConnectGroupDetail ────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
void onGroupLoaded(Group group) {
|
||||
setState(() {
|
||||
_group = group;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void onProgressLogged(Group group, String summary) {
|
||||
setState(() {
|
||||
_group = group;
|
||||
});
|
||||
_model?.showApplicationNotification(
|
||||
NotificationType.success,
|
||||
"Logged",
|
||||
"$summary. The group sees it in the chat.",
|
||||
true,
|
||||
true,
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void onNudged(String who) {
|
||||
setState(() {
|
||||
for (GroupMember member in _group.members) {
|
||||
if (who == "everyone" ? !member.me : member.name == who) {
|
||||
_nudged.add(member.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void onLeft() {
|
||||
Navigator.pop(context, true);
|
||||
}
|
||||
}
|
||||
12
frontend/lib/Grounded/see/group/GroupEditor.dart
Normal file
12
frontend/lib/Grounded/see/group/GroupEditor.dart
Normal file
@@ -0,0 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'GroupEditorState.dart';
|
||||
|
||||
class GroupEditor extends StatefulWidget {
|
||||
final bool joining;
|
||||
|
||||
const GroupEditor({super.key, this.joining = false});
|
||||
|
||||
@override
|
||||
State<GroupEditor> createState() => GroupEditorState();
|
||||
}
|
||||
293
frontend/lib/Grounded/see/group/GroupEditorState.dart
Normal file
293
frontend/lib/Grounded/see/group/GroupEditorState.dart
Normal file
@@ -0,0 +1,293 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:stacked/stacked.dart';
|
||||
|
||||
import '../../about/external/data/Group.dart';
|
||||
import '../../about/external/initial/GroupRequest.dart';
|
||||
import '../../about/external/initial/JoinGroupRequest.dart';
|
||||
import '../../about/internal/application/TextType.dart';
|
||||
import '../../designs/MemberAvatar.dart';
|
||||
import '../../designs/Responsive.dart';
|
||||
import '../../designs/Shell.dart';
|
||||
import '../../designs/buttons/Buttons.dart';
|
||||
import '../../designs/input/InputFields.dart';
|
||||
import '../../designs/text/Text.dart';
|
||||
import '../../utils/Colors.dart';
|
||||
import '../../utils/Validators.dart';
|
||||
import 'ConnectGroupEditor.dart';
|
||||
import 'GroupEditor.dart';
|
||||
import 'ViewGroupEditor.dart';
|
||||
|
||||
class GroupEditorState extends State<GroupEditor>
|
||||
implements ConnectGroupEditor {
|
||||
ViewGroupEditor? _model;
|
||||
|
||||
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
|
||||
|
||||
final TextEditingController _name = TextEditingController();
|
||||
|
||||
final TextEditingController _description = TextEditingController();
|
||||
|
||||
final TextEditingController _code = TextEditingController();
|
||||
|
||||
Color _accent = goalAccents[1];
|
||||
|
||||
bool _joining = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_joining = widget.joining;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_name.dispose();
|
||||
_description.dispose();
|
||||
_code.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ViewModelBuilder<ViewGroupEditor>.reactive(
|
||||
viewModelBuilder: () => ViewGroupEditor(context, this),
|
||||
onViewModelReady: (viewModel) {
|
||||
_model = viewModel;
|
||||
_initiate();
|
||||
},
|
||||
builder: (context, viewModel, child) => LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
return Responsive(
|
||||
mobile: _mobileView(constraints),
|
||||
tablet: _mobileView(constraints),
|
||||
desktop: _mobileView(constraints),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _initiate() {}
|
||||
|
||||
// ── Handlers ──────────────────────────────────────────────────────────────
|
||||
|
||||
void _onBack() {
|
||||
Navigator.pop(context, false);
|
||||
}
|
||||
|
||||
void _onModeSelected(String mode) {
|
||||
setState(() {
|
||||
_joining = mode == "Join";
|
||||
});
|
||||
}
|
||||
|
||||
void _onNameChanged(String value) {
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void _onAccentSelected(Color colour) {
|
||||
setState(() {
|
||||
_accent = colour;
|
||||
});
|
||||
}
|
||||
|
||||
void _onSubmit() {
|
||||
if (_formKey.currentState?.validate() != true) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_joining) {
|
||||
_model?.join(JoinGroupRequest(inviteCode: _code.text.trim().toUpperCase()));
|
||||
} else {
|
||||
_model?.create(GroupRequest(
|
||||
name: _name.text.trim(),
|
||||
description: _description.text.trim(),
|
||||
colourHex: colorToHex(_accent),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
String? _validateCode(String? value) {
|
||||
final String code = (value ?? "").trim();
|
||||
if (code.length != 6) {
|
||||
return "Invite codes are six characters.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Views ─────────────────────────────────────────────────────────────────
|
||||
|
||||
Widget _mobileView(BoxConstraints constraints) {
|
||||
return Sheet(
|
||||
eyebrow: _joining ? "Join" : "New",
|
||||
title: "Group",
|
||||
onBack: _onBack,
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
displayTitle(_joining
|
||||
? "Got a code?"
|
||||
: "Who's keeping\nyou honest?"),
|
||||
const SizedBox(height: 22),
|
||||
segmentedSelector<String>(
|
||||
options: const <String>["Create", "Join"],
|
||||
selected: _joining ? "Join" : "Create",
|
||||
label: _modeLabel,
|
||||
onSelected: _onModeSelected,
|
||||
activeColor: colorNight,
|
||||
),
|
||||
const SizedBox(height: 26),
|
||||
if (_joining) ..._joinFields() else ..._createFields(),
|
||||
const SizedBox(height: 32),
|
||||
roundedCornerButton(
|
||||
_joining ? "Join group" : "Create group",
|
||||
_onSubmit,
|
||||
icon: _joining ? CupertinoIcons.arrow_right : CupertinoIcons.add,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _modeLabel(String mode) => mode;
|
||||
|
||||
List<Widget> _createFields() {
|
||||
return <Widget>[
|
||||
_preview(),
|
||||
const SizedBox(height: 26),
|
||||
inputField(
|
||||
"Group name",
|
||||
_name,
|
||||
hint: "Morning Crew, Thesis Sprint…",
|
||||
validator: Validators.title,
|
||||
onChanged: _onNameChanged,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
inputField(
|
||||
"What it's for",
|
||||
_description,
|
||||
hint: "Optional: the promise you're all making",
|
||||
maxLines: 3,
|
||||
),
|
||||
const SizedBox(height: 26),
|
||||
sectionBreak("Colour", caption: "the group's banner"),
|
||||
_swatches(),
|
||||
const SizedBox(height: 14),
|
||||
text(
|
||||
"You'll get a six-letter invite code to send to the others once it's created.",
|
||||
12,
|
||||
TextType.Regular,
|
||||
color: colorGrey2,
|
||||
height: 1.5,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
List<Widget> _joinFields() {
|
||||
return <Widget>[
|
||||
inputField(
|
||||
"Invite code",
|
||||
_code,
|
||||
hint: "6 characters, like MRN7AM",
|
||||
validator: _validateCode,
|
||||
icon: CupertinoIcons.ticket,
|
||||
formatters: <TextInputFormatter>[
|
||||
LengthLimitingTextInputFormatter(6),
|
||||
FilteringTextInputFormatter.allow(RegExp(r"[A-Za-z0-9]")),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
text(
|
||||
"Ask whoever started the group: the code is on the group's page. Joining shows you their shared goals and the chat straight away.",
|
||||
12,
|
||||
TextType.Regular,
|
||||
color: colorGrey2,
|
||||
height: 1.5,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// The banner as it will look, so the colour is chosen by seeing it.
|
||||
Widget _preview() {
|
||||
final String name =
|
||||
_name.text.trim().isEmpty ? "Your group" : _name.text.trim();
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: <Color>[_accent, Color.lerp(_accent, colorBlack, 0.45)!],
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(CupertinoIcons.person_3_fill,
|
||||
size: 16, color: colorWhite.withValues(alpha: 0.7)),
|
||||
const SizedBox(width: 8),
|
||||
text("1 MEMBER", 9, TextType.Bold,
|
||||
color: colorWhite.withValues(alpha: 0.6),
|
||||
letterSpacing: 1.2),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
text(name, 26, TextType.Light,
|
||||
color: colorWhite, maxLines: 2, overflow: TextOverflow.ellipsis),
|
||||
const SizedBox(height: 16),
|
||||
avatarStack(const <String>["Alex"], 34),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _swatches() {
|
||||
return Wrap(
|
||||
spacing: 9,
|
||||
runSpacing: 9,
|
||||
children: goalAccents.map((colour) {
|
||||
final bool selected = colour.toARGB32() == _accent.toARGB32();
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => _onAccentSelected(colour),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
width: 40,
|
||||
height: 40,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: colour,
|
||||
border: Border.all(
|
||||
color: selected ? colorWhite : Colors.transparent,
|
||||
width: 3,
|
||||
),
|
||||
boxShadow: selected
|
||||
? [BoxShadow(color: colour, spreadRadius: 1.5)]
|
||||
: null,
|
||||
),
|
||||
child: selected
|
||||
? Icon(Icons.check_rounded, size: 18, color: colorWhite)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
// ── ConnectGroupEditor ────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
void onGroupReady(Group group) {
|
||||
Navigator.pop(context, group);
|
||||
}
|
||||
}
|
||||
13
frontend/lib/Grounded/see/group/GroupGoalEditor.dart
Normal file
13
frontend/lib/Grounded/see/group/GroupGoalEditor.dart
Normal file
@@ -0,0 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../about/external/data/Group.dart';
|
||||
import 'GroupGoalEditorState.dart';
|
||||
|
||||
class GroupGoalEditor extends StatefulWidget {
|
||||
final Group group;
|
||||
|
||||
const GroupGoalEditor({super.key, required this.group});
|
||||
|
||||
@override
|
||||
State<GroupGoalEditor> createState() => GroupGoalEditorState();
|
||||
}
|
||||
317
frontend/lib/Grounded/see/group/GroupGoalEditorState.dart
Normal file
317
frontend/lib/Grounded/see/group/GroupGoalEditorState.dart
Normal file
@@ -0,0 +1,317 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:stacked/stacked.dart';
|
||||
|
||||
import '../../about/external/initial/GroupGoalRequest.dart';
|
||||
import '../../about/external/initial/ObjectiveDraft.dart';
|
||||
import '../../about/internal/application/TextType.dart';
|
||||
import '../../designs/Component.dart';
|
||||
import '../../designs/Responsive.dart';
|
||||
import '../../designs/Shell.dart';
|
||||
import '../../designs/buttons/Buttons.dart';
|
||||
import '../../designs/input/InputFields.dart';
|
||||
import '../../designs/text/Text.dart';
|
||||
import '../../utils/Colors.dart';
|
||||
import '../../utils/CommonUtils.dart';
|
||||
import '../../utils/Validators.dart';
|
||||
import 'ConnectGroupGoalEditor.dart';
|
||||
import 'GroupGoalEditor.dart';
|
||||
import 'ViewGroupGoalEditor.dart';
|
||||
|
||||
/// The three fields of one objective while it is being typed.
|
||||
typedef _ObjectiveFields = ({
|
||||
TextEditingController title,
|
||||
TextEditingController target,
|
||||
TextEditingController unit,
|
||||
});
|
||||
|
||||
class GroupGoalEditorState extends State<GroupGoalEditor>
|
||||
implements ConnectGroupGoalEditor {
|
||||
ViewGroupGoalEditor? _model;
|
||||
|
||||
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
|
||||
|
||||
final TextEditingController _title = TextEditingController();
|
||||
|
||||
final TextEditingController _description = TextEditingController();
|
||||
|
||||
final List<_ObjectiveFields> _objectives = <_ObjectiveFields>[];
|
||||
|
||||
DateTime? _targetDate;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_objectives.add(_blankObjective());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_title.dispose();
|
||||
_description.dispose();
|
||||
for (_ObjectiveFields fields in _objectives) {
|
||||
fields.title.dispose();
|
||||
fields.target.dispose();
|
||||
fields.unit.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ViewModelBuilder<ViewGroupGoalEditor>.reactive(
|
||||
viewModelBuilder: () => ViewGroupGoalEditor(context, this),
|
||||
onViewModelReady: (viewModel) {
|
||||
_model = viewModel;
|
||||
_initiate();
|
||||
},
|
||||
builder: (context, viewModel, child) => LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
return Responsive(
|
||||
mobile: _mobileView(constraints),
|
||||
tablet: _mobileView(constraints),
|
||||
desktop: _mobileView(constraints),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _initiate() {}
|
||||
|
||||
_ObjectiveFields _blankObjective() {
|
||||
return (
|
||||
title: TextEditingController(),
|
||||
target: TextEditingController(),
|
||||
unit: TextEditingController(),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Handlers ──────────────────────────────────────────────────────────────
|
||||
|
||||
void _onBack() {
|
||||
Navigator.pop(context, false);
|
||||
}
|
||||
|
||||
void _onAddObjective() {
|
||||
setState(() {
|
||||
_objectives.add(_blankObjective());
|
||||
});
|
||||
}
|
||||
|
||||
void _onRemoveObjective(_ObjectiveFields fields) {
|
||||
setState(() {
|
||||
_objectives.remove(fields);
|
||||
});
|
||||
fields.title.dispose();
|
||||
fields.target.dispose();
|
||||
fields.unit.dispose();
|
||||
}
|
||||
|
||||
void _onPickTarget() async {
|
||||
final DateTime now = DateTime.now();
|
||||
final DateTime? picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _targetDate ?? now.add(const Duration(days: 30)),
|
||||
firstDate: now,
|
||||
lastDate: now.add(const Duration(days: 365 * 3)),
|
||||
);
|
||||
|
||||
if (picked == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_targetDate = picked;
|
||||
});
|
||||
}
|
||||
|
||||
void _onClearTarget() {
|
||||
setState(() {
|
||||
_targetDate = null;
|
||||
});
|
||||
}
|
||||
|
||||
void _onSave() {
|
||||
if (_formKey.currentState?.validate() != true) {
|
||||
return;
|
||||
}
|
||||
|
||||
_model?.save(GroupGoalRequest(
|
||||
groupId: widget.group.id,
|
||||
title: _title.text.trim(),
|
||||
description: _description.text.trim(),
|
||||
targetDate: _targetDate?.toIso8601String() ?? "",
|
||||
colourHex: widget.group.colourHex,
|
||||
objectives: _objectives
|
||||
.map((fields) => ObjectiveDraft(
|
||||
title: fields.title.text.trim(),
|
||||
unit: fields.unit.text.trim(),
|
||||
target: double.tryParse(fields.target.text.trim()) ?? 0,
|
||||
))
|
||||
.toList(),
|
||||
));
|
||||
}
|
||||
|
||||
String? _validateTarget(String? value) {
|
||||
final double? target = double.tryParse((value ?? "").trim());
|
||||
if (target == null || target <= 0) {
|
||||
return "A number above 0.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _validateUnit(String? value) {
|
||||
return Validators.required(value, "Unit");
|
||||
}
|
||||
|
||||
// ── Views ─────────────────────────────────────────────────────────────────
|
||||
|
||||
Widget _mobileView(BoxConstraints constraints) {
|
||||
return Sheet(
|
||||
eyebrow: widget.group.name,
|
||||
title: "Shared goal",
|
||||
onBack: _onBack,
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
displayTitle("What are you\nall going for?"),
|
||||
const SizedBox(height: 24),
|
||||
inputField(
|
||||
"Goal",
|
||||
_title,
|
||||
hint: "Run 200 km this month, finish the drafts…",
|
||||
validator: Validators.title,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
inputField(
|
||||
"Why it matters",
|
||||
_description,
|
||||
hint: "Optional",
|
||||
maxLines: 2,
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
sectionBreak("Objectives",
|
||||
caption: "what everyone logs toward"),
|
||||
text(
|
||||
"Break the goal into numbers people can add to: kilometres, sessions, pages, hours. Each member logs their share and the totals add up.",
|
||||
12,
|
||||
TextType.Regular,
|
||||
color: colorGrey2,
|
||||
height: 1.5,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
for (int index = 0; index < _objectives.length; index++)
|
||||
_objectiveEditor(index, _objectives[index]),
|
||||
outlinedActionButton("Add objective", _onAddObjective,
|
||||
icon: CupertinoIcons.add),
|
||||
const SizedBox(height: 28),
|
||||
sectionBreak("Deadline", caption: "optional"),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(
|
||||
child: selectField(
|
||||
"Target date",
|
||||
_targetDate == null ? "" : formatDate(_targetDate),
|
||||
_onPickTarget,
|
||||
hint: "No deadline",
|
||||
icon: CupertinoIcons.calendar,
|
||||
),
|
||||
),
|
||||
if (_targetDate != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
iconButton(
|
||||
Icon(CupertinoIcons.xmark, size: 15, color: colorGrey2),
|
||||
_onClearTarget,
|
||||
bordered: true,
|
||||
background: colorWhite,
|
||||
size: 50,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
roundedCornerButton("Share with the group", _onSave,
|
||||
icon: CupertinoIcons.person_3_fill),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _objectiveEditor(int index, _ObjectiveFields fields) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
child: card(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 8, 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
text("OBJECTIVE ${index + 1}", 10, TextType.Bold,
|
||||
color: colorGrey2, letterSpacing: 1.0),
|
||||
const Spacer(),
|
||||
if (_objectives.length > 1)
|
||||
iconButton(
|
||||
Icon(CupertinoIcons.xmark, size: 15, color: colorGrey2),
|
||||
() => _onRemoveObjective(fields),
|
||||
size: 36,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
inputField("Name", fields.title,
|
||||
hint: "Distance run", validator: Validators.title),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: inputField(
|
||||
"Target",
|
||||
fields.target,
|
||||
hint: "200",
|
||||
validator: _validateTarget,
|
||||
keyboard: const TextInputType.numberWithOptions(
|
||||
decimal: true),
|
||||
formatters: <TextInputFormatter>[
|
||||
FilteringTextInputFormatter.allow(RegExp(r"[0-9.]")),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: inputField("Unit", fields.unit,
|
||||
hint: "km", validator: _validateUnit),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── ConnectGroupGoalEditor ────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
void onGoalSaved() {
|
||||
Navigator.pop(context, true);
|
||||
}
|
||||
}
|
||||
10
frontend/lib/Grounded/see/group/Groups.dart
Normal file
10
frontend/lib/Grounded/see/group/Groups.dart
Normal file
@@ -0,0 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'GroupsState.dart';
|
||||
|
||||
class Groups extends StatefulWidget {
|
||||
const Groups({super.key});
|
||||
|
||||
@override
|
||||
State<Groups> createState() => GroupsState();
|
||||
}
|
||||
241
frontend/lib/Grounded/see/group/GroupsState.dart
Normal file
241
frontend/lib/Grounded/see/group/GroupsState.dart
Normal file
@@ -0,0 +1,241 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:stacked/stacked.dart';
|
||||
|
||||
import '../../about/external/data/Group.dart';
|
||||
import '../../about/internal/application/TextType.dart';
|
||||
import '../../configs/Navigator.dart';
|
||||
import '../../designs/Component.dart';
|
||||
import '../../designs/GlassSurface.dart';
|
||||
import '../../designs/MemberAvatar.dart';
|
||||
import '../../designs/Responsive.dart';
|
||||
import '../../designs/Shell.dart';
|
||||
import '../../designs/buttons/Buttons.dart';
|
||||
import '../../designs/scene/SceneArt.dart';
|
||||
import '../../designs/text/Text.dart';
|
||||
import '../../utils/Colors.dart';
|
||||
import '../../utils/CommonUtils.dart';
|
||||
import 'ConnectGroups.dart';
|
||||
import 'GroupDetail.dart';
|
||||
import 'GroupEditor.dart';
|
||||
import 'Groups.dart';
|
||||
import 'ViewGroups.dart';
|
||||
|
||||
class GroupsState extends State<Groups> implements ConnectGroups {
|
||||
ViewGroups? _model;
|
||||
|
||||
List<Group> _groups = <Group>[];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ViewModelBuilder<ViewGroups>.reactive(
|
||||
viewModelBuilder: () => ViewGroups(context, this),
|
||||
onViewModelReady: (viewModel) {
|
||||
_model = viewModel;
|
||||
_initiate();
|
||||
},
|
||||
builder: (context, viewModel, child) => LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
return Responsive(
|
||||
mobile: _mobileView(constraints),
|
||||
tablet: _mobileView(constraints),
|
||||
desktop: _mobileView(constraints),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _initiate() {
|
||||
_model?.loadGroups();
|
||||
}
|
||||
|
||||
// ── Handlers ──────────────────────────────────────────────────────────────
|
||||
|
||||
void _onBack() {
|
||||
Navigator.pop(context, true);
|
||||
}
|
||||
|
||||
void _onOpenGroup(Group group) async {
|
||||
await GroundedNavigation()
|
||||
.navigateToPageWithData(GroupDetail(group: group), context);
|
||||
_model?.loadGroups();
|
||||
}
|
||||
|
||||
void _onNewGroup() {
|
||||
_openEditor(false);
|
||||
}
|
||||
|
||||
void _onJoinGroup() {
|
||||
_openEditor(true);
|
||||
}
|
||||
|
||||
/// Created or joined, you land straight in the group.
|
||||
void _openEditor(bool joining) async {
|
||||
final result = await GroundedNavigation()
|
||||
.navigateToPageWithData(GroupEditor(joining: joining), context);
|
||||
|
||||
if (result is Group) {
|
||||
_model?.loadGroups();
|
||||
_onOpenGroup(result);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Views ─────────────────────────────────────────────────────────────────
|
||||
|
||||
Widget _mobileView(BoxConstraints constraints) {
|
||||
return Sheet(
|
||||
eyebrow: "Grounded",
|
||||
title: "Groups",
|
||||
onBack: _onBack,
|
||||
action: chromeAction(CupertinoIcons.add, _onNewGroup),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
displayTitle("Keep each other\nhonest."),
|
||||
const SizedBox(height: 14),
|
||||
text(
|
||||
"Share goals with people who will notice when you slip. Everyone logs toward the same objectives, and the chat sees every check-in.",
|
||||
14,
|
||||
TextType.Regular,
|
||||
color: colorGrey2,
|
||||
height: 1.55,
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
if (_groups.isEmpty)
|
||||
emptyState(
|
||||
CupertinoIcons.person_3,
|
||||
"No groups yet",
|
||||
"Start one and send the code to a friend, or join with a code someone sent you.",
|
||||
)
|
||||
else
|
||||
..._groups.map(_groupCard),
|
||||
const SizedBox(height: 24),
|
||||
roundedCornerButton("New group", _onNewGroup,
|
||||
icon: CupertinoIcons.add),
|
||||
const SizedBox(height: 10),
|
||||
outlinedActionButton("Join with a code", _onJoinGroup,
|
||||
icon: CupertinoIcons.ticket),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The group as a painted card: who is in it, how the shared goals are
|
||||
/// going, and the latest line from the chat.
|
||||
Widget _groupCard(Group group) {
|
||||
final Color accent = goalColor(group.colourHex);
|
||||
final Color soft = colorWhite.withValues(alpha: 0.78);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => _onOpenGroup(group),
|
||||
child: Container(
|
||||
height: 250,
|
||||
margin: const EdgeInsets.only(bottom: 14),
|
||||
decoration: const BoxDecoration(
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Color(0x262A3A30),
|
||||
blurRadius: 24,
|
||||
offset: Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
SceneArt(
|
||||
palette: goalScene(accent),
|
||||
seed: sceneSeed(group.name),
|
||||
figure: CupertinoIcons.person_3_fill,
|
||||
figureScale: 0.42,
|
||||
figureAlignment: const Alignment(0.7, -0.4),
|
||||
shade: true,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
GlassSurface(
|
||||
onDark: true,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10, vertical: 6),
|
||||
child: text(
|
||||
"${group.members.length} members · ${group.goals.length} shared goal${group.goals.length == 1 ? "" : "s"}",
|
||||
11,
|
||||
TextType.Medium,
|
||||
color: colorWhite,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (group.unread > 0)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10, vertical: 6),
|
||||
color: colorLime,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(CupertinoIcons.chat_bubble_fill,
|
||||
size: 12, color: colorNight),
|
||||
const SizedBox(width: 6),
|
||||
text("${group.unread} new", 11, TextType.Medium,
|
||||
color: colorNight),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Spacer(),
|
||||
text(group.name, 24, TextType.Medium,
|
||||
color: colorWhite,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
if (group.lastMessage.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
text("${group.lastAuthor}: ${group.lastMessage}", 13,
|
||||
TextType.Regular,
|
||||
color: soft,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
],
|
||||
const SizedBox(height: 14),
|
||||
meter(group.progress,
|
||||
fill: colorLime,
|
||||
track: colorWhite.withValues(alpha: 0.25),
|
||||
height: 6),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
avatarStack(
|
||||
group.members.map((member) => member.name).toList(),
|
||||
32),
|
||||
const Spacer(),
|
||||
text("${(group.progress * 100).round()}%", 22,
|
||||
TextType.Light,
|
||||
color: colorWhite),
|
||||
const SizedBox(width: 6),
|
||||
text("together", 12, TextType.Regular, color: soft),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── ConnectGroups ─────────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
void onGroupsLoaded(List<Group> groups) {
|
||||
setState(() {
|
||||
_groups = groups;
|
||||
});
|
||||
}
|
||||
}
|
||||
53
frontend/lib/Grounded/see/group/ViewGroupChat.dart
Normal file
53
frontend/lib/Grounded/see/group/ViewGroupChat.dart
Normal file
@@ -0,0 +1,53 @@
|
||||
import '../../about/external/data/GroupMessage.dart';
|
||||
import '../../about/external/initial/GroupMessageRequest.dart';
|
||||
import '../../about/external/initial/IdRequest.dart';
|
||||
import '../../utils/ObjectConvertors.dart';
|
||||
import '../parent/ParentViewModel.dart';
|
||||
import 'ConnectGroupChat.dart';
|
||||
|
||||
class ViewGroupChat extends ParentViewModel {
|
||||
ConnectGroupChat connection;
|
||||
|
||||
ViewGroupChat(super.context, this.connection);
|
||||
|
||||
void loadMessages(String groupId) async {
|
||||
if (!await hasNetwork(() => loadMessages(groupId))) return;
|
||||
|
||||
showLoading("Opening the chat");
|
||||
|
||||
try {
|
||||
final response =
|
||||
await getDataManager().getGroupMessages(IdRequest(id: groupId));
|
||||
|
||||
closeLoading();
|
||||
|
||||
connection.onMessagesLoaded(getGroupMessageList(response.data));
|
||||
} catch (e) {
|
||||
handleError(e, () => loadMessages(groupId), () => dismissError(), "Retry");
|
||||
}
|
||||
}
|
||||
|
||||
/// The background refresh while the chat is open. Silent either way — a
|
||||
/// missed poll is picked up by the next one.
|
||||
void refresh(String groupId) async {
|
||||
try {
|
||||
final response =
|
||||
await getDataManager().getGroupMessages(IdRequest(id: groupId));
|
||||
|
||||
connection.onMessagesLoaded(getGroupMessageList(response.data));
|
||||
} catch (e) {
|
||||
// Next tick tries again.
|
||||
}
|
||||
}
|
||||
|
||||
void send(GroupMessageRequest request) async {
|
||||
try {
|
||||
final response = await getDataManager().sendGroupMessage(request);
|
||||
|
||||
connection.onMessageSent(GroupMessage.fromJson(response.data));
|
||||
} catch (e) {
|
||||
connection.onSendFailed(request.body);
|
||||
handleError(e, () => send(request), () => dismissError(), "Retry");
|
||||
}
|
||||
}
|
||||
}
|
||||
77
frontend/lib/Grounded/see/group/ViewGroupDetail.dart
Normal file
77
frontend/lib/Grounded/see/group/ViewGroupDetail.dart
Normal file
@@ -0,0 +1,77 @@
|
||||
import '../../about/external/data/Group.dart';
|
||||
import '../../about/external/initial/IdRequest.dart';
|
||||
import '../../about/external/initial/NudgeRequest.dart';
|
||||
import '../../about/external/initial/ObjectiveProgressRequest.dart';
|
||||
import '../parent/ParentViewModel.dart';
|
||||
import 'ConnectGroupDetail.dart';
|
||||
|
||||
class ViewGroupDetail extends ParentViewModel {
|
||||
ConnectGroupDetail connection;
|
||||
|
||||
ViewGroupDetail(super.context, this.connection);
|
||||
|
||||
/// [quiet] refreshes behind the screen without the loading overlay, for
|
||||
/// coming back from the chat or the goal editor.
|
||||
void loadGroup(String groupId, {bool quiet = false}) async {
|
||||
if (!await hasNetwork(() => loadGroup(groupId, quiet: quiet))) return;
|
||||
|
||||
if (!quiet) showLoading("Loading the group");
|
||||
|
||||
try {
|
||||
final response =
|
||||
await getDataManager().getGroupEntry(IdRequest(id: groupId));
|
||||
|
||||
if (!quiet) closeLoading();
|
||||
|
||||
connection.onGroupLoaded(Group.fromJson(response.data));
|
||||
} catch (e) {
|
||||
handleError(e, () => loadGroup(groupId, quiet: quiet),
|
||||
() => dismissError(), "Retry");
|
||||
}
|
||||
}
|
||||
|
||||
void logProgress(ObjectiveProgressRequest request, String summary) async {
|
||||
if (!await hasNetwork(() => logProgress(request, summary))) return;
|
||||
|
||||
showLoading("Logging");
|
||||
|
||||
try {
|
||||
final response = await getDataManager().logObjectiveProgress(request);
|
||||
|
||||
closeLoading();
|
||||
|
||||
connection.onProgressLogged(Group.fromJson(response.data), summary);
|
||||
} catch (e) {
|
||||
handleError(e, () => logProgress(request, summary),
|
||||
() => dismissError(), "Retry");
|
||||
}
|
||||
}
|
||||
|
||||
void nudge(NudgeRequest request, String who) async {
|
||||
if (!await hasNetwork(() => nudge(request, who))) return;
|
||||
|
||||
try {
|
||||
await getDataManager().nudgeGroupMember(request);
|
||||
|
||||
connection.onNudged(who);
|
||||
} catch (e) {
|
||||
handleError(e, () => nudge(request, who), () => dismissError(), "Retry");
|
||||
}
|
||||
}
|
||||
|
||||
void leave(String groupId) async {
|
||||
if (!await hasNetwork(() => leave(groupId))) return;
|
||||
|
||||
showLoading("Leaving");
|
||||
|
||||
try {
|
||||
await getDataManager().leaveGroupEntry(IdRequest(id: groupId));
|
||||
|
||||
closeLoading();
|
||||
|
||||
connection.onLeft();
|
||||
} catch (e) {
|
||||
handleError(e, () => leave(groupId), () => dismissError(), "Retry");
|
||||
}
|
||||
}
|
||||
}
|
||||
43
frontend/lib/Grounded/see/group/ViewGroupEditor.dart
Normal file
43
frontend/lib/Grounded/see/group/ViewGroupEditor.dart
Normal file
@@ -0,0 +1,43 @@
|
||||
import '../../about/external/data/Group.dart';
|
||||
import '../../about/external/initial/GroupRequest.dart';
|
||||
import '../../about/external/initial/JoinGroupRequest.dart';
|
||||
import '../parent/ParentViewModel.dart';
|
||||
import 'ConnectGroupEditor.dart';
|
||||
|
||||
class ViewGroupEditor extends ParentViewModel {
|
||||
ConnectGroupEditor connection;
|
||||
|
||||
ViewGroupEditor(super.context, this.connection);
|
||||
|
||||
void create(GroupRequest request) async {
|
||||
if (!await hasNetwork(() => create(request))) return;
|
||||
|
||||
showLoading("Creating the group");
|
||||
|
||||
try {
|
||||
final response = await getDataManager().saveGroupEntry(request);
|
||||
|
||||
closeLoading();
|
||||
|
||||
connection.onGroupReady(Group.fromJson(response.data));
|
||||
} catch (e) {
|
||||
handleError(e, () => create(request), () => dismissError(), "Retry");
|
||||
}
|
||||
}
|
||||
|
||||
void join(JoinGroupRequest request) async {
|
||||
if (!await hasNetwork(() => join(request))) return;
|
||||
|
||||
showLoading("Joining");
|
||||
|
||||
try {
|
||||
final response = await getDataManager().joinGroupEntry(request);
|
||||
|
||||
closeLoading();
|
||||
|
||||
connection.onGroupReady(Group.fromJson(response.data));
|
||||
} catch (e) {
|
||||
handleError(e, () => join(request), () => dismissError(), "Retry");
|
||||
}
|
||||
}
|
||||
}
|
||||
25
frontend/lib/Grounded/see/group/ViewGroupGoalEditor.dart
Normal file
25
frontend/lib/Grounded/see/group/ViewGroupGoalEditor.dart
Normal file
@@ -0,0 +1,25 @@
|
||||
import '../../about/external/initial/GroupGoalRequest.dart';
|
||||
import '../parent/ParentViewModel.dart';
|
||||
import 'ConnectGroupGoalEditor.dart';
|
||||
|
||||
class ViewGroupGoalEditor extends ParentViewModel {
|
||||
ConnectGroupGoalEditor connection;
|
||||
|
||||
ViewGroupGoalEditor(super.context, this.connection);
|
||||
|
||||
void save(GroupGoalRequest request) async {
|
||||
if (!await hasNetwork(() => save(request))) return;
|
||||
|
||||
showLoading("Saving the shared goal");
|
||||
|
||||
try {
|
||||
await getDataManager().saveGroupGoalEntry(request);
|
||||
|
||||
closeLoading();
|
||||
|
||||
connection.onGoalSaved();
|
||||
} catch (e) {
|
||||
handleError(e, () => save(request), () => dismissError(), "Retry");
|
||||
}
|
||||
}
|
||||
}
|
||||
34
frontend/lib/Grounded/see/group/ViewGroups.dart
Normal file
34
frontend/lib/Grounded/see/group/ViewGroups.dart
Normal file
@@ -0,0 +1,34 @@
|
||||
import '../../about/external/data/pages/request/HistoryRequest.dart';
|
||||
import '../../about/external/data/pages/request/PageAndSort.dart';
|
||||
import '../../about/external/data/pages/request/Pageable.dart';
|
||||
import '../../about/external/data/pages/request/Sort.dart';
|
||||
import '../../utils/ObjectConvertors.dart';
|
||||
import '../parent/ParentViewModel.dart';
|
||||
import 'ConnectGroups.dart';
|
||||
|
||||
class ViewGroups extends ParentViewModel {
|
||||
ConnectGroups connection;
|
||||
|
||||
ViewGroups(super.context, this.connection);
|
||||
|
||||
void loadGroups() async {
|
||||
if (!await hasNetwork(() => loadGroups())) return;
|
||||
|
||||
showLoading("Loading your groups");
|
||||
|
||||
try {
|
||||
final response = await getDataManager().getMyGroups(HistoryRequest(
|
||||
query: PageAndSort(
|
||||
sort: Sort('desc', 'lastMessageAt'),
|
||||
page: Pageable(0, 0, 50, 0),
|
||||
),
|
||||
));
|
||||
|
||||
closeLoading();
|
||||
|
||||
connection.onGroupsLoaded(getGroupList(response.data));
|
||||
} catch (e) {
|
||||
handleError(e, () => loadGroups(), () => dismissError(), "Retry");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import '../../about/external/data/Commitment.dart';
|
||||
import '../../about/external/data/ExcuseCluster.dart';
|
||||
import '../../about/external/data/Group.dart';
|
||||
import '../../about/external/data/ReportCard.dart';
|
||||
import '../../about/external/data/RoutineChain.dart';
|
||||
import '../../about/internal/application/Standing.dart';
|
||||
import '../../about/internal/application/TodoItem.dart';
|
||||
import '../../about/internal/application/UserDetails.dart';
|
||||
|
||||
abstract class ConnectHome {
|
||||
@@ -14,6 +17,16 @@ abstract class ConnectHome {
|
||||
/// The routine library, for the up-next rail.
|
||||
void onRoutinesLoaded(List<RoutineChain> routines);
|
||||
|
||||
/// This week's report card, for the streak row, the score and the weekly
|
||||
/// view. Null when it could not be loaded.
|
||||
void onWeekLoaded(ReportCard? week);
|
||||
|
||||
/// Your groups, for the rail on Home.
|
||||
void onGroupsLoaded(List<Group> groups);
|
||||
|
||||
/// The quick to-do list, read from the device.
|
||||
void onTodosLoaded(List<TodoItem> todos);
|
||||
|
||||
/// Standing arrives derived, with the debt it was derived from.
|
||||
void onStandingResolved(Standing standing, double debtScore);
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,7 @@
|
||||
import '../../about/external/data/Commitment.dart';
|
||||
import '../../about/external/data/ExcuseCluster.dart';
|
||||
import '../../about/external/data/Group.dart';
|
||||
import '../../about/external/data/ReportCard.dart';
|
||||
import '../../about/external/data/RoutineChain.dart';
|
||||
import '../../about/external/data/pages/request/CommitmentsRequest.dart';
|
||||
import '../../about/external/data/pages/request/HistoryRequest.dart';
|
||||
@@ -11,6 +13,7 @@ import '../../about/external/initial/CompletionRequest.dart';
|
||||
import '../../about/external/initial/ReportCardRequest.dart';
|
||||
import '../../about/internal/application/CommitmentClass.dart';
|
||||
import '../../about/internal/application/Standing.dart';
|
||||
import '../../about/internal/application/TodoItem.dart';
|
||||
import '../../about/internal/application/UserDetails.dart';
|
||||
import '../../utils/DebtEngine.dart';
|
||||
import '../../utils/GuardrailEngine.dart';
|
||||
@@ -31,9 +34,59 @@ class ViewHome extends ParentViewModel {
|
||||
final UserDetails cached = await getDataManager().getUserDetails();
|
||||
connection.onUserLoaded(cached);
|
||||
|
||||
loadTodos();
|
||||
|
||||
loadWeek();
|
||||
|
||||
loadGroups();
|
||||
|
||||
loadPlan();
|
||||
}
|
||||
|
||||
/// The last seven days. Quiet on failure: the header falls back to today.
|
||||
void loadWeek() async {
|
||||
try {
|
||||
final DateTime now = DateTime.now();
|
||||
final DateTime start = DateTime(now.year, now.month, now.day)
|
||||
.subtract(const Duration(days: 6));
|
||||
|
||||
final response =
|
||||
await getDataManager().getWeeklyReportCard(ReportCardRequest(
|
||||
periodStart: start.toIso8601String(),
|
||||
periodEnd: now.toIso8601String(),
|
||||
));
|
||||
|
||||
connection.onWeekLoaded(ReportCard.fromJson(response.data));
|
||||
} catch (e) {
|
||||
connection.onWeekLoaded(null);
|
||||
}
|
||||
}
|
||||
|
||||
/// The groups rail. Quiet on failure: the rail just stays hidden.
|
||||
void loadGroups() async {
|
||||
try {
|
||||
final response = await getDataManager().getMyGroups(HistoryRequest(
|
||||
query: PageAndSort(
|
||||
sort: Sort('desc', 'lastMessageAt'),
|
||||
page: Pageable(0, 0, 20, 0),
|
||||
),
|
||||
));
|
||||
|
||||
connection.onGroupsLoaded(getGroupList(response.data));
|
||||
} catch (e) {
|
||||
connection.onGroupsLoaded(<Group>[]);
|
||||
}
|
||||
}
|
||||
|
||||
void loadTodos() async {
|
||||
connection.onTodosLoaded(await getDataManager().getTodos());
|
||||
}
|
||||
|
||||
/// Saves the whole list; it is small and only ever lives on the device.
|
||||
void saveTodos(List<TodoItem> todos) async {
|
||||
await getDataManager().setTodos(todos);
|
||||
}
|
||||
|
||||
void loadPlan() async {
|
||||
if (!await hasNetwork(() => loadPlan())) return;
|
||||
|
||||
|
||||
@@ -184,8 +184,8 @@ class LiveTaskState extends State<LiveTask>
|
||||
decoration: BoxDecoration(
|
||||
color: colorSheetBackground,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(28),
|
||||
topRight: Radius.circular(28),
|
||||
topLeft: Radius.zero,
|
||||
topRight: Radius.zero,
|
||||
),
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
|
||||
@@ -107,8 +107,8 @@ class LoginState extends State<Login> implements ConnectLogin {
|
||||
decoration: BoxDecoration(
|
||||
color: colorPrimaryLight,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(28),
|
||||
topRight: Radius.circular(28),
|
||||
topLeft: Radius.zero,
|
||||
topRight: Radius.zero,
|
||||
),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
|
||||
@@ -147,8 +147,8 @@ class OverdueQueueState extends State<OverdueQueue>
|
||||
decoration: BoxDecoration(
|
||||
color: colorSheetBackground,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(28),
|
||||
topRight: Radius.circular(28),
|
||||
topLeft: Radius.zero,
|
||||
topRight: Radius.zero,
|
||||
),
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
@@ -166,7 +166,7 @@ class OverdueQueueState extends State<OverdueQueue>
|
||||
margin: const EdgeInsets.only(bottom: 20),
|
||||
decoration: BoxDecoration(
|
||||
color: colorGrey.withValues(alpha: 0.35),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
borderRadius: BorderRadius.zero,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -242,8 +242,8 @@ class OverdueQueueState extends State<OverdueQueue>
|
||||
decoration: BoxDecoration(
|
||||
color: colorSheetBackground,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(28),
|
||||
topRight: Radius.circular(28),
|
||||
topLeft: Radius.zero,
|
||||
topRight: Radius.zero,
|
||||
),
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
@@ -261,7 +261,7 @@ class OverdueQueueState extends State<OverdueQueue>
|
||||
margin: const EdgeInsets.only(bottom: 20),
|
||||
decoration: BoxDecoration(
|
||||
color: colorGrey.withValues(alpha: 0.35),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
borderRadius: BorderRadius.zero,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -343,8 +343,8 @@ class OverdueQueueState extends State<OverdueQueue>
|
||||
decoration: BoxDecoration(
|
||||
color: colorSheetBackground,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(28),
|
||||
topRight: Radius.circular(28),
|
||||
topLeft: Radius.zero,
|
||||
topRight: Radius.zero,
|
||||
),
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
@@ -360,7 +360,7 @@ class OverdueQueueState extends State<OverdueQueue>
|
||||
margin: const EdgeInsets.only(bottom: 20),
|
||||
decoration: BoxDecoration(
|
||||
color: colorGrey.withValues(alpha: 0.35),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
borderRadius: BorderRadius.zero,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -504,7 +504,7 @@ class OverdueQueueState extends State<OverdueQueue>
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
|
||||
decoration: BoxDecoration(
|
||||
color: colorWhite.withValues(alpha: 0.10),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderRadius: BorderRadius.zero,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
|
||||
@@ -152,7 +152,7 @@ class ParentViewModel extends ChangeNotifier {
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: colorPrimaryDark,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
borderRadius: BorderRadius.zero,
|
||||
),
|
||||
child: Icon(CupertinoIcons.wifi_slash,
|
||||
size: 26, color: colorWhite),
|
||||
@@ -396,7 +396,7 @@ class ParentViewModel extends ChangeNotifier {
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: colorStandingGroundedBg,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: BorderRadius.zero,
|
||||
),
|
||||
child: Icon(
|
||||
CupertinoIcons.exclamationmark_triangle_fill,
|
||||
@@ -544,8 +544,8 @@ class ParentViewModel extends ChangeNotifier {
|
||||
decoration: BoxDecoration(
|
||||
color: colorSheetBackground,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(28),
|
||||
topRight: Radius.circular(28),
|
||||
topLeft: Radius.zero,
|
||||
topRight: Radius.zero,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
@@ -559,7 +559,7 @@ class ParentViewModel extends ChangeNotifier {
|
||||
margin: const EdgeInsets.only(top: 12, bottom: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: colorGrey.withValues(alpha: 0.35),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
borderRadius: BorderRadius.zero,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -579,7 +579,7 @@ class ParentViewModel extends ChangeNotifier {
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: actionBgColor,
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
borderRadius: BorderRadius.zero,
|
||||
),
|
||||
child: Icon(actionIconData,
|
||||
size: 24, color: actionColor),
|
||||
|
||||
@@ -99,7 +99,7 @@ class ReportCardScreenState extends State<ReportCardScreen>
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
|
||||
decoration: BoxDecoration(
|
||||
color: colorWhite.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderRadius: BorderRadius.zero,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
@@ -128,7 +128,7 @@ class ReportCardScreenState extends State<ReportCardScreen>
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: colorWhite,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderRadius: BorderRadius.zero,
|
||||
),
|
||||
child: text(_report.grade, 26, TextType.Light,
|
||||
color: colorPrimaryDark),
|
||||
|
||||
@@ -402,7 +402,7 @@ class RoutineBuilderState extends State<RoutineBuilder>
|
||||
height: 124,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderRadius: BorderRadius.zero,
|
||||
gradient: selected
|
||||
? LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
@@ -424,7 +424,6 @@ class RoutineBuilderState extends State<RoutineBuilder>
|
||||
color: selected
|
||||
? colorWhite.withValues(alpha: 0.16)
|
||||
: kindBackground(kind),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(kindIcon(kind),
|
||||
size: 17, color: selected ? colorWhite : tone),
|
||||
@@ -470,7 +469,7 @@ class RoutineBuilderState extends State<RoutineBuilder>
|
||||
padding: const EdgeInsets.fromLTRB(14, 12, 14, 12),
|
||||
decoration: BoxDecoration(
|
||||
color: applied ? kindBackground(_routine.kind) : colorWhite,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderRadius: BorderRadius.zero,
|
||||
border: Border.all(
|
||||
color: applied
|
||||
? kindColor(_routine.kind).withValues(alpha: 0.4)
|
||||
@@ -515,7 +514,6 @@ class RoutineBuilderState extends State<RoutineBuilder>
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: on ? tone : colorWhite,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: on ? tone : colorBorder),
|
||||
),
|
||||
child: text(weekdayInitial(day), 13,
|
||||
@@ -576,7 +574,7 @@ class RoutineBuilderState extends State<RoutineBuilder>
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: kindBackground(_routine.kind),
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
borderRadius: BorderRadius.zero,
|
||||
),
|
||||
child: text("${index + 1}", 12, TextType.Bold, color: tone),
|
||||
),
|
||||
|
||||
@@ -457,7 +457,7 @@ class RoutineRunnerState extends State<RoutineRunner>
|
||||
Widget _totalPill() {
|
||||
return GlassSurface(
|
||||
onDark: true,
|
||||
radius: BorderRadius.circular(99),
|
||||
radius: BorderRadius.zero,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -496,7 +496,7 @@ class RoutineRunnerState extends State<RoutineRunner>
|
||||
track: colorWhite.withValues(alpha: 0.22),
|
||||
child: GlassSurface(
|
||||
onDark: true,
|
||||
radius: BorderRadius.circular(100),
|
||||
radius: BorderRadius.zero,
|
||||
child: SizedBox(
|
||||
width: 150,
|
||||
height: 150,
|
||||
@@ -552,7 +552,7 @@ class RoutineRunnerState extends State<RoutineRunner>
|
||||
),
|
||||
GlassSurface(
|
||||
onDark: true,
|
||||
radius: BorderRadius.circular(200),
|
||||
radius: BorderRadius.zero,
|
||||
child: SizedBox(
|
||||
width: 120 + 50 * t,
|
||||
height: 120 + 50 * t,
|
||||
@@ -588,7 +588,7 @@ class RoutineRunnerState extends State<RoutineRunner>
|
||||
children: [
|
||||
GlassSurface(
|
||||
onDark: true,
|
||||
radius: BorderRadius.circular(99),
|
||||
radius: BorderRadius.zero,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -610,7 +610,7 @@ class RoutineRunnerState extends State<RoutineRunner>
|
||||
const SizedBox(width: 8),
|
||||
GlassSurface(
|
||||
onDark: true,
|
||||
radius: BorderRadius.circular(99),
|
||||
radius: BorderRadius.zero,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
|
||||
child: text("Left app ${_run.backgroundedCount}×", 11,
|
||||
TextType.Medium,
|
||||
@@ -727,7 +727,6 @@ class RoutineRunnerState extends State<RoutineRunner>
|
||||
height: size,
|
||||
padding: const EdgeInsets.all(2.5),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: isCurrent ? colorNight : colorWhite,
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
@@ -737,7 +736,7 @@ class RoutineRunnerState extends State<RoutineRunner>
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipOval(
|
||||
child: ClipRect(
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
@@ -794,7 +793,7 @@ class RoutineRunnerState extends State<RoutineRunner>
|
||||
margin: const EdgeInsets.only(right: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: filled || active ? tone : tone.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
borderRadius: BorderRadius.zero,
|
||||
),
|
||||
);
|
||||
}),
|
||||
@@ -894,7 +893,7 @@ class RoutineRunnerState extends State<RoutineRunner>
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: colorNight,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
borderRadius: BorderRadius.zero,
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x331A1F1C),
|
||||
@@ -956,8 +955,8 @@ class RoutineRunnerState extends State<RoutineRunner>
|
||||
decoration: const BoxDecoration(
|
||||
color: colorSheetBackground,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(28),
|
||||
topRight: Radius.circular(28),
|
||||
topLeft: Radius.zero,
|
||||
topRight: Radius.zero,
|
||||
),
|
||||
),
|
||||
child: ListView(
|
||||
@@ -985,7 +984,7 @@ class RoutineRunnerState extends State<RoutineRunner>
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 14, 12),
|
||||
decoration: BoxDecoration(
|
||||
color: current ? kindBackground(_run.chain.kind) : colorWhite,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderRadius: BorderRadius.zero,
|
||||
border: Border.all(
|
||||
color: current ? tone.withValues(alpha: 0.35) : colorBorder,
|
||||
),
|
||||
@@ -998,7 +997,6 @@ class RoutineRunnerState extends State<RoutineRunner>
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: step.completed ? tone : colorSecondary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: step.completed
|
||||
? Icon(Icons.check_rounded,
|
||||
@@ -1072,7 +1070,7 @@ class RoutineRunnerState extends State<RoutineRunner>
|
||||
children: [
|
||||
GlassSurface(
|
||||
onDark: true,
|
||||
radius: BorderRadius.circular(99),
|
||||
radius: BorderRadius.zero,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 7),
|
||||
child: text(
|
||||
@@ -1096,7 +1094,7 @@ class RoutineRunnerState extends State<RoutineRunner>
|
||||
track: colorWhite.withValues(alpha: 0.3),
|
||||
child: GlassSurface(
|
||||
onDark: true,
|
||||
radius: BorderRadius.circular(100),
|
||||
radius: BorderRadius.zero,
|
||||
child: SizedBox(
|
||||
width: 158,
|
||||
height: 158,
|
||||
@@ -1128,7 +1126,7 @@ class RoutineRunnerState extends State<RoutineRunner>
|
||||
),
|
||||
const SizedBox(height: 22),
|
||||
GlassSurface(
|
||||
radius: BorderRadius.circular(24),
|
||||
radius: BorderRadius.zero,
|
||||
tint: colorGlassStrong,
|
||||
padding: const EdgeInsets.fromLTRB(18, 16, 18, 16),
|
||||
child: Row(
|
||||
|
||||
@@ -330,7 +330,7 @@ class RoutinesState extends State<Routines> implements ConnectRoutines {
|
||||
..sort((a, b) => a.startMinute.compareTo(b.startMinute));
|
||||
|
||||
return GlassSurface(
|
||||
radius: BorderRadius.circular(24),
|
||||
radius: BorderRadius.zero,
|
||||
tint: colorGlassStrong,
|
||||
padding: const EdgeInsets.fromLTRB(18, 16, 18, 16),
|
||||
child: Row(
|
||||
@@ -391,18 +391,17 @@ class RoutinesState extends State<Routines> implements ConnectRoutines {
|
||||
onTap: () => _onSelectKind(kind),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: SizedBox(
|
||||
width: 74,
|
||||
width: 88,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOut,
|
||||
width: 64,
|
||||
height: 64,
|
||||
width: 78,
|
||||
height: 78,
|
||||
padding: const EdgeInsets.all(2.5),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: selected ? colorNight : colorWhite,
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
@@ -412,7 +411,7 @@ class RoutinesState extends State<Routines> implements ConnectRoutines {
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipOval(
|
||||
child: ClipRect(
|
||||
child: kind == null
|
||||
? Container(
|
||||
color: colorWhite,
|
||||
@@ -450,7 +449,7 @@ class RoutinesState extends State<Routines> implements ConnectRoutines {
|
||||
final List<RoutineChain> items = _upNext;
|
||||
|
||||
return SizedBox(
|
||||
height: 240,
|
||||
height: 290,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
clipBehavior: Clip.none,
|
||||
@@ -470,9 +469,9 @@ class RoutinesState extends State<Routines> implements ConnectRoutines {
|
||||
return GestureDetector(
|
||||
onTap: () => _onPreview(routine),
|
||||
child: Container(
|
||||
width: 200,
|
||||
width: 244,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(26),
|
||||
borderRadius: BorderRadius.zero,
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x262A3A30),
|
||||
@@ -482,7 +481,7 @@ class RoutinesState extends State<Routines> implements ConnectRoutines {
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(26),
|
||||
borderRadius: BorderRadius.zero,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
@@ -503,7 +502,7 @@ class RoutinesState extends State<Routines> implements ConnectRoutines {
|
||||
children: [
|
||||
GlassSurface(
|
||||
onDark: true,
|
||||
radius: BorderRadius.circular(99),
|
||||
radius: BorderRadius.zero,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10, vertical: 5),
|
||||
child: text(
|
||||
@@ -611,10 +610,10 @@ class RoutinesState extends State<Routines> implements ConnectRoutines {
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 70,
|
||||
height: 70,
|
||||
width: 90,
|
||||
height: 90,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
borderRadius: BorderRadius.zero,
|
||||
child: SceneArt(
|
||||
palette: scenePalette(routine.kind),
|
||||
seed: sceneSeed(routine.title),
|
||||
@@ -665,7 +664,6 @@ class RoutinesState extends State<Routines> implements ConnectRoutines {
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: tone,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(Icons.check_rounded,
|
||||
size: 22, color: colorWhite),
|
||||
@@ -717,8 +715,8 @@ class RoutinesState extends State<Routines> implements ConnectRoutines {
|
||||
decoration: const BoxDecoration(
|
||||
color: colorAmbientMid,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(32),
|
||||
topRight: Radius.circular(32),
|
||||
topLeft: Radius.zero,
|
||||
topRight: Radius.zero,
|
||||
),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
@@ -755,7 +753,7 @@ class RoutinesState extends State<Routines> implements ConnectRoutines {
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: colorWhite.withValues(alpha: 0.7),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
borderRadius: BorderRadius.zero,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -772,7 +770,7 @@ class RoutinesState extends State<Routines> implements ConnectRoutines {
|
||||
children: [
|
||||
GlassSurface(
|
||||
onDark: true,
|
||||
radius: BorderRadius.circular(99),
|
||||
radius: BorderRadius.zero,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10, vertical: 5),
|
||||
child: text(kindLabel(routine.kind), 10,
|
||||
@@ -782,7 +780,7 @@ class RoutinesState extends State<Routines> implements ConnectRoutines {
|
||||
const SizedBox(width: 6),
|
||||
GlassSurface(
|
||||
onDark: true,
|
||||
radius: BorderRadius.circular(99),
|
||||
radius: BorderRadius.zero,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10, vertical: 5),
|
||||
child: text(
|
||||
@@ -816,7 +814,7 @@ class RoutinesState extends State<Routines> implements ConnectRoutines {
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
GlassSurface(
|
||||
radius: BorderRadius.circular(22),
|
||||
radius: BorderRadius.zero,
|
||||
tint: colorGlassStrong,
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
|
||||
child: Row(
|
||||
@@ -895,7 +893,7 @@ class RoutinesState extends State<Routines> implements ConnectRoutines {
|
||||
padding: const EdgeInsets.fromLTRB(10, 10, 14, 10),
|
||||
decoration: BoxDecoration(
|
||||
color: colorWhite,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
borderRadius: BorderRadius.zero,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
@@ -905,7 +903,6 @@ class RoutinesState extends State<Routines> implements ConnectRoutines {
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: tone.withValues(alpha: 0.10),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: text("${index + 1}", 12, TextType.Medium, color: tone),
|
||||
),
|
||||
|
||||
@@ -67,8 +67,8 @@ class StepEditorState extends State<StepEditor> {
|
||||
decoration: const BoxDecoration(
|
||||
color: colorSheetBackground,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(28),
|
||||
topRight: Radius.circular(28),
|
||||
topLeft: Radius.zero,
|
||||
topRight: Radius.zero,
|
||||
),
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
@@ -234,7 +234,7 @@ class StepEditorState extends State<StepEditor> {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: kindBackground(widget.kind),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: BorderRadius.zero,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
|
||||
@@ -93,8 +93,8 @@ class SettingsState extends State<Settings> implements ConnectSettings {
|
||||
decoration: BoxDecoration(
|
||||
color: colorSheetBackground,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(28),
|
||||
topRight: Radius.circular(28),
|
||||
topLeft: Radius.zero,
|
||||
topRight: Radius.zero,
|
||||
),
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
@@ -112,7 +112,7 @@ class SettingsState extends State<Settings> implements ConnectSettings {
|
||||
margin: const EdgeInsets.only(bottom: 20),
|
||||
decoration: BoxDecoration(
|
||||
color: colorGrey.withValues(alpha: 0.35),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
borderRadius: BorderRadius.zero,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -222,7 +222,7 @@ class SettingsState extends State<Settings> implements ConnectSettings {
|
||||
color: _amnestyRemaining > 0
|
||||
? colorStandingGoodBg
|
||||
: colorMuted,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderRadius: BorderRadius.zero,
|
||||
),
|
||||
child: Icon(
|
||||
CupertinoIcons.checkmark_shield_fill,
|
||||
|
||||
@@ -239,7 +239,7 @@ class SplashState extends State<Splash> implements ConnectSplash {
|
||||
],
|
||||
),
|
||||
GlassSurface(
|
||||
radius: BorderRadius.circular(99),
|
||||
radius: BorderRadius.zero,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -299,11 +299,11 @@ class SplashState extends State<Splash> implements ConnectSplash {
|
||||
|
||||
Widget _mark() {
|
||||
return GlassSurface(
|
||||
radius: BorderRadius.circular(46),
|
||||
radius: BorderRadius.zero,
|
||||
padding: const EdgeInsets.all(10),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(34),
|
||||
borderRadius: BorderRadius.zero,
|
||||
boxShadow: <BoxShadow>[
|
||||
BoxShadow(
|
||||
color: colorPrimary.withValues(alpha: 0.4),
|
||||
@@ -342,7 +342,7 @@ class SplashState extends State<Splash> implements ConnectSplash {
|
||||
alignment: alignment,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(99),
|
||||
borderRadius: BorderRadius.zero,
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x142A3A30),
|
||||
@@ -352,7 +352,7 @@ class SplashState extends State<Splash> implements ConnectSplash {
|
||||
],
|
||||
),
|
||||
child: GlassSurface(
|
||||
radius: BorderRadius.circular(99),
|
||||
radius: BorderRadius.zero,
|
||||
tint: colorGlassStrong,
|
||||
padding: const EdgeInsets.fromLTRB(4, 4, 12, 4),
|
||||
child: Row(
|
||||
@@ -424,7 +424,7 @@ class SplashState extends State<Splash> implements ConnectSplash {
|
||||
/// The whole idea in three beats, on glass over the valley.
|
||||
Widget _howItWorks() {
|
||||
return GlassSurface(
|
||||
radius: BorderRadius.circular(24),
|
||||
radius: BorderRadius.zero,
|
||||
tint: colorGlassStrong,
|
||||
padding: const EdgeInsets.fromLTRB(14, 14, 14, 14),
|
||||
child: Row(
|
||||
@@ -499,7 +499,7 @@ class SplashState extends State<Splash> implements ConnectSplash {
|
||||
return Column(
|
||||
children: [
|
||||
GlassSurface(
|
||||
radius: BorderRadius.circular(99),
|
||||
radius: BorderRadius.zero,
|
||||
padding: const EdgeInsets.all(3),
|
||||
child: SizedBox(
|
||||
width: 150,
|
||||
@@ -510,7 +510,7 @@ class SplashState extends State<Splash> implements ConnectSplash {
|
||||
width: 150,
|
||||
decoration: BoxDecoration(
|
||||
color: colorNight,
|
||||
borderRadius: BorderRadius.circular(99),
|
||||
borderRadius: BorderRadius.zero,
|
||||
),
|
||||
).animate().scaleX(
|
||||
begin: 0.04,
|
||||
|
||||
@@ -71,7 +71,7 @@ class SessionExpiredState extends State<SessionExpired>
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: colorWhite.withValues(alpha: 0.10),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
borderRadius: BorderRadius.zero,
|
||||
),
|
||||
child: Icon(CupertinoIcons.lock_fill,
|
||||
size: 24, color: colorWhite),
|
||||
|
||||
@@ -68,7 +68,7 @@ class UpdateMeState extends State<UpdateMe> implements ConnectUpdateMe {
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: colorWhite.withValues(alpha: 0.10),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
borderRadius: BorderRadius.zero,
|
||||
),
|
||||
child: Icon(CupertinoIcons.arrow_up_circle_fill,
|
||||
size: 24, color: colorWhite),
|
||||
|
||||
@@ -127,6 +127,20 @@ const Color colorOverdueWarm = Color(0xFFF9E1D8);
|
||||
const Color colorOverdueWarmEnd = Color(0xFFFFF6EF);
|
||||
const Color colorOverdueGlow = Color(0xFFD7654B);
|
||||
|
||||
// ── Home header ───────────────────────────────────────────────────────────────
|
||||
// The deep forest block the day sits in, with brighter bars that read on it.
|
||||
const Color colorHeaderTop = Color(0xFF203D30);
|
||||
const Color colorHeaderBottom = Color(0xFF15281F);
|
||||
const Color colorHeaderPanel = Color(0x14FFFFFF);
|
||||
const Color colorHeaderLine = Color(0x26FFFFFF);
|
||||
const Color colorHeaderMuted = Color(0x99FFFFFF);
|
||||
const Color colorHeaderSelected = Color(0xFF3D6B53);
|
||||
const Color colorBarNonNegotiable = Color(0xFFE8735A);
|
||||
const Color colorBarStandard = Color(0xFFF2C14E);
|
||||
const Color colorBarElective = Color(0xFF6FD39B);
|
||||
// The drag handle on white sheets.
|
||||
const Color colorGrabber = Color(0xFFDDE1DE);
|
||||
|
||||
// ── Scene art ─────────────────────────────────────────────────────────────────
|
||||
// The painted landscapes that stand in for photography on cards. Each kind has
|
||||
// a sky (top → horizon), a sun, three ridges (far → near) and an ember accent.
|
||||
|
||||
@@ -404,3 +404,20 @@ int sceneSeed(String key) {
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
/// An amount the way a person reads it: whole numbers with thousands
|
||||
/// separators, anything else to one decimal place.
|
||||
String formatAmount(double value) {
|
||||
if (value == value.roundToDouble()) {
|
||||
final String digits = value.round().abs().toString();
|
||||
final StringBuffer out = StringBuffer(value < 0 ? "-" : "");
|
||||
for (int index = 0; index < digits.length; index++) {
|
||||
if (index > 0 && (digits.length - index) % 3 == 0) {
|
||||
out.write(",");
|
||||
}
|
||||
out.write(digits[index]);
|
||||
}
|
||||
return out.toString();
|
||||
}
|
||||
return value.toStringAsFixed(1);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,11 @@ import '../about/external/data/DebtEntry.dart';
|
||||
import '../about/external/data/ExcuseCluster.dart';
|
||||
import '../about/external/data/ExercisePrescription.dart';
|
||||
import '../about/external/data/Goal.dart';
|
||||
import '../about/external/data/Group.dart';
|
||||
import '../about/external/data/GroupGoal.dart';
|
||||
import '../about/external/data/GroupMember.dart';
|
||||
import '../about/external/data/GroupMessage.dart';
|
||||
import '../about/external/data/GroupObjective.dart';
|
||||
import '../about/external/data/Habit.dart';
|
||||
import '../about/external/data/Program.dart';
|
||||
import '../about/external/data/RoutineChain.dart';
|
||||
@@ -122,3 +127,38 @@ List<String> getStringList(dynamic data) {
|
||||
}
|
||||
return (data as List).map((item) => item.toString()).toList();
|
||||
}
|
||||
|
||||
List<Group> getGroupList(dynamic data) {
|
||||
if (data == null) {
|
||||
return <Group>[];
|
||||
}
|
||||
return (data as List).map((item) => Group.fromJson(item)).toList();
|
||||
}
|
||||
|
||||
List<GroupMember> getGroupMemberList(dynamic data) {
|
||||
if (data == null) {
|
||||
return <GroupMember>[];
|
||||
}
|
||||
return (data as List).map((item) => GroupMember.fromJson(item)).toList();
|
||||
}
|
||||
|
||||
List<GroupGoal> getGroupGoalList(dynamic data) {
|
||||
if (data == null) {
|
||||
return <GroupGoal>[];
|
||||
}
|
||||
return (data as List).map((item) => GroupGoal.fromJson(item)).toList();
|
||||
}
|
||||
|
||||
List<GroupObjective> getGroupObjectiveList(dynamic data) {
|
||||
if (data == null) {
|
||||
return <GroupObjective>[];
|
||||
}
|
||||
return (data as List).map((item) => GroupObjective.fromJson(item)).toList();
|
||||
}
|
||||
|
||||
List<GroupMessage> getGroupMessageList(dynamic data) {
|
||||
if (data == null) {
|
||||
return <GroupMessage>[];
|
||||
}
|
||||
return (data as List).map((item) => GroupMessage.fromJson(item)).toList();
|
||||
}
|
||||
|
||||
@@ -48,9 +48,13 @@ class GroundedApp extends StatelessWidget {
|
||||
fontSize: 17,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
borderRadius: BorderRadius.zero,
|
||||
),
|
||||
),
|
||||
// Material 3 rounds sheet tops by default; the app is square.
|
||||
bottomSheetTheme: const BottomSheetThemeData(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.zero),
|
||||
),
|
||||
),
|
||||
home: const Splash(),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user