diff --git a/frontend/assets/images/PEOPLE-CREDITS.txt b/frontend/assets/images/PEOPLE-CREDITS.txt new file mode 100644 index 0000000..c58d268 --- /dev/null +++ b/frontend/assets/images/PEOPLE-CREDITS.txt @@ -0,0 +1,13 @@ +Sample portraits for mock mode (person_*.jpg), from Unsplash under the +Unsplash License (free to use, no attribution required). Source photo ids: + +person_alex.jpg photo-1507003211169-0a1dd7228f2d +person_sam.jpg photo-1500648767791-00dcc994a43e +person_priya.jpg photo-1494790108377-be9c29b29330 +person_jordan.jpg photo-1472099645785-5658abf4ff4e +person_mei.jpg photo-1544005313-94ddf0286df2 +person_tom.jpg photo-1506794778202-cad84cf45f1d +person_ade.jpg photo-1531123897727-8f129e1688ce +person_lena.jpg photo-1438761681033-6461ffad8d80 + +They stand in for real users' photos in the preview only. diff --git a/frontend/assets/images/person_ade.jpg b/frontend/assets/images/person_ade.jpg new file mode 100644 index 0000000..cbab9c1 Binary files /dev/null and b/frontend/assets/images/person_ade.jpg differ diff --git a/frontend/assets/images/person_alex.jpg b/frontend/assets/images/person_alex.jpg new file mode 100644 index 0000000..c194c27 Binary files /dev/null and b/frontend/assets/images/person_alex.jpg differ diff --git a/frontend/assets/images/person_jordan.jpg b/frontend/assets/images/person_jordan.jpg new file mode 100644 index 0000000..be7675b Binary files /dev/null and b/frontend/assets/images/person_jordan.jpg differ diff --git a/frontend/assets/images/person_lena.jpg b/frontend/assets/images/person_lena.jpg new file mode 100644 index 0000000..242abf7 Binary files /dev/null and b/frontend/assets/images/person_lena.jpg differ diff --git a/frontend/assets/images/person_mei.jpg b/frontend/assets/images/person_mei.jpg new file mode 100644 index 0000000..d20423f Binary files /dev/null and b/frontend/assets/images/person_mei.jpg differ diff --git a/frontend/assets/images/person_priya.jpg b/frontend/assets/images/person_priya.jpg new file mode 100644 index 0000000..b89f413 Binary files /dev/null and b/frontend/assets/images/person_priya.jpg differ diff --git a/frontend/assets/images/person_sam.jpg b/frontend/assets/images/person_sam.jpg new file mode 100644 index 0000000..ea4dbf2 Binary files /dev/null and b/frontend/assets/images/person_sam.jpg differ diff --git a/frontend/assets/images/person_tom.jpg b/frontend/assets/images/person_tom.jpg new file mode 100644 index 0000000..f02229d Binary files /dev/null and b/frontend/assets/images/person_tom.jpg differ diff --git a/frontend/design.md b/frontend/design.md index e9fd6fe..ff9acff 100644 --- a/frontend/design.md +++ b/frontend/design.md @@ -51,6 +51,7 @@ directly above a large dark value. Used everywhere numbers appear. - Softly rounded, never pill-soft: large cards and image cards `16`, buttons `14`, rows, tiles and fields `12`, small controls `10`, chips and badges `8`, bars `4`. Sheets round their top at `20`. - Portraits, the nav buttons, status dots, rings and the breathing circle are full circles. +- People are real photos (`designs/Portrait.dart`), never scene art: your photo is picked in Settings or by tapping the Home avatar, shrunk to 256 px and kept on the phone; members show the `photo` the server sends. No photo means an initial on a soft neutral disc. Mock mode uses bundled Unsplash portraits (`assets/images/person_*.jpg`, see `PEOPLE-CREDITS.txt`). - 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. diff --git a/frontend/lib/Grounded/about/external/data/GroupMember.dart b/frontend/lib/Grounded/about/external/data/GroupMember.dart index efb16bb..436e97a 100644 --- a/frontend/lib/Grounded/about/external/data/GroupMember.dart +++ b/frontend/lib/Grounded/about/external/data/GroupMember.dart @@ -4,6 +4,9 @@ class GroupMember { String name; + /// Their profile photo (URL), or empty for none. + String photo; + /// "Owner" or "Member". String role; @@ -24,6 +27,7 @@ class GroupMember { GroupMember({ this.id = "", this.name = "", + this.photo = "", this.role = "Member", this.keptRate = 0, this.streak = 0, @@ -38,6 +42,7 @@ class GroupMember { return GroupMember( id: json['id'] ?? "", name: json['name'] ?? "", + photo: json['photo'] ?? "", role: json['role'] ?? "Member", keptRate: (json['keptRate'] ?? 0).toDouble(), streak: json['streak'] ?? 0, @@ -53,6 +58,7 @@ class GroupMember { final Map data = {}; data['id'] = id; data['name'] = name; + data['photo'] = photo; data['role'] = role; data['keptRate'] = keptRate; data['streak'] = streak; diff --git a/frontend/lib/Grounded/designs/Checkbox.dart b/frontend/lib/Grounded/designs/Checkbox.dart index 8bd50d6..09d3863 100644 --- a/frontend/lib/Grounded/designs/Checkbox.dart +++ b/frontend/lib/Grounded/designs/Checkbox.dart @@ -137,7 +137,8 @@ class TickRow extends StatelessWidget { duration: const Duration(milliseconds: 160), style: TextStyle( fontSize: 15, - fontFamily: struck ? "GroundedRegular" : "GroundedMedium", + fontFamily: + struck ? "GroundedRegular" : "GroundedMedium", color: struck ? colorGrey2 : colorPrimaryDark, decoration: struck ? TextDecoration.lineThrough diff --git a/frontend/lib/Grounded/designs/DashedOutline.dart b/frontend/lib/Grounded/designs/DashedOutline.dart index d3c3184..207f690 100644 --- a/frontend/lib/Grounded/designs/DashedOutline.dart +++ b/frontend/lib/Grounded/designs/DashedOutline.dart @@ -19,16 +19,15 @@ class DashedOutline extends CustomPainter { ..strokeWidth = 1.2; final Path outline = Path() - ..addRRect(RRect.fromRectAndRadius( - Offset.zero & size, Radius.circular(radius))); + ..addRRect( + RRect.fromRectAndRadius(Offset.zero & size, Radius.circular(radius))); const double dash = 4; const double gap = 3; for (final metric in outline.computeMetrics()) { double distance = 0; while (distance < metric.length) { - canvas.drawPath( - metric.extractPath(distance, distance + dash), paint); + canvas.drawPath(metric.extractPath(distance, distance + dash), paint); distance = distance + dash + gap; } } diff --git a/frontend/lib/Grounded/designs/DotDigits.dart b/frontend/lib/Grounded/designs/DotDigits.dart index 47dee5c..8d91155 100644 --- a/frontend/lib/Grounded/designs/DotDigits.dart +++ b/frontend/lib/Grounded/designs/DotDigits.dart @@ -82,11 +82,10 @@ class _DotDigitsPainter extends CustomPainter { final Path? outline = _glyph(glyph); if (outline != null) { - final Path placed = outline.transform( - (Matrix4.identity() - ..translateByDouble(x, 0, 0, 1) - ..scaleByDouble(_scale, _scale, 1, 1)) - .storage); + final Path placed = outline.transform((Matrix4.identity() + ..translateByDouble(x, 0, 0, 1) + ..scaleByDouble(_scale, _scale, 1, 1)) + .storage); for (PathMetric metric in placed.computeMetrics()) { final int count = (metric.length / gap).floor().clamp(1, 400); diff --git a/frontend/lib/Grounded/designs/MemberAvatar.dart b/frontend/lib/Grounded/designs/MemberAvatar.dart index 8a2cd86..239b965 100644 --- a/frontend/lib/Grounded/designs/MemberAvatar.dart +++ b/frontend/lib/Grounded/designs/MemberAvatar.dart @@ -2,47 +2,19 @@ import 'package:flutter/material.dart'; import '../about/internal/application/TextType.dart'; import '../utils/Colors.dart'; -import '../utils/CommonUtils.dart'; -import 'scene/SceneArt.dart'; +import 'Portrait.dart'; import 'text/Text.dart'; -/// A member's round 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> palettes = >[ - 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( - shape: BoxShape.circle, - border: Border.all(color: border ?? colorWhite, width: 2), - ), - child: ClipOval( - 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), - ), - ], - ), - ), - ); +/// A member's round photo, or their initial when they have none. +Widget memberAvatar(String name, double size, + {String photo = "", Color? border}) { + return portrait(photo, name, size, border: border); } /// Several portraits overlapping, with "+N" once there are more than fit. +/// [photos] lines up with [names]; missing entries fall back to initials. Widget avatarStack(List names, double size, - {int max = 4, Color? border}) { + {List photos = const [], int max = 4, Color? border}) { final List shown = names.take(max).toList(); final int extra = names.length - shown.length; final double step = size * 0.68; @@ -56,7 +28,9 @@ Widget avatarStack(List names, double size, for (int index = 0; index < shown.length; index++) Positioned( left: step * index, - child: memberAvatar(shown[index], size, border: border), + child: memberAvatar(shown[index], size, + photo: index < photos.length ? photos[index] : "", + border: border), ), if (extra > 0) Positioned( diff --git a/frontend/lib/Grounded/designs/Portrait.dart b/frontend/lib/Grounded/designs/Portrait.dart new file mode 100644 index 0000000..6b0f6c7 --- /dev/null +++ b/frontend/lib/Grounded/designs/Portrait.dart @@ -0,0 +1,56 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; + +import '../about/internal/application/TextType.dart'; +import '../utils/Colors.dart'; +import 'text/Text.dart'; + +/// Where a portrait comes from. A stored `pic` is one of: +/// - `data:image/...;base64,...` — a photo picked on this device +/// - `http(s)://...` — a photo the server holds +/// - `asset:assets/images/...` — a bundled sample (mock mode) +/// Anything else, or empty, has no photo. +ImageProvider? portraitImage(String pic) { + if (pic.startsWith("data:image")) { + final int comma = pic.indexOf(","); + if (comma < 0) { + return null; + } + return MemoryImage(base64Decode(pic.substring(comma + 1))); + } + if (pic.startsWith("http://") || pic.startsWith("https://")) { + return NetworkImage(pic); + } + if (pic.startsWith("asset:")) { + return AssetImage(pic.substring("asset:".length)); + } + return null; +} + +/// A round photo of a person. Without a photo, their initial on a soft +/// neutral disc — never a landscape: a person should look like a person. +Widget portrait(String pic, String name, double size, + {Color? border, double borderWidth = 2}) { + final ImageProvider? image = portraitImage(pic); + final String initial = + name.trim().isEmpty ? "?" : name.trim().substring(0, 1).toUpperCase(); + + return Container( + width: size, + height: size, + alignment: Alignment.center, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: colorMuted, + border: Border.all(color: border ?? colorWhite, width: borderWidth), + image: image == null + ? null + : DecorationImage(image: image, fit: BoxFit.cover), + ), + child: image == null + ? text(initial, size * 0.4, TextType.Medium, color: colorInkSoft) + : null, + ); +} diff --git a/frontend/lib/Grounded/designs/ProgressRing.dart b/frontend/lib/Grounded/designs/ProgressRing.dart index 61a1d4f..d88077d 100644 --- a/frontend/lib/Grounded/designs/ProgressRing.dart +++ b/frontend/lib/Grounded/designs/ProgressRing.dart @@ -53,8 +53,8 @@ class ProgressRing extends StatelessWidget { ), child: Center( child: child ?? - text("${(clamped * 100).round()}%", size * 0.22, - TextType.Bold, + text( + "${(clamped * 100).round()}%", size * 0.22, TextType.Bold, color: colorPrimaryDark), ), ); diff --git a/frontend/lib/Grounded/designs/input/CountStepper.dart b/frontend/lib/Grounded/designs/input/CountStepper.dart index b14577c..b01f432 100644 --- a/frontend/lib/Grounded/designs/input/CountStepper.dart +++ b/frontend/lib/Grounded/designs/input/CountStepper.dart @@ -56,8 +56,8 @@ Widget _stepButton(IconData icon, VoidCallback? onTap) { color: enabled ? colorSecondary : colorInset, borderRadius: BorderRadius.circular(10), ), - child: Icon(icon, - size: 14, color: enabled ? colorPrimaryDark : colorGrey), + child: + Icon(icon, size: 14, color: enabled ? colorPrimaryDark : colorGrey), ), ); } diff --git a/frontend/lib/Grounded/designs/input/InputFields.dart b/frontend/lib/Grounded/designs/input/InputFields.dart index 7753708..126875a 100644 --- a/frontend/lib/Grounded/designs/input/InputFields.dart +++ b/frontend/lib/Grounded/designs/input/InputFields.dart @@ -46,9 +46,8 @@ Widget inputField( fontSize: 13, color: colorGrey, ), - prefixIcon: icon == null - ? null - : Icon(icon, size: 18, color: colorGrey2), + prefixIcon: + icon == null ? null : Icon(icon, size: 18, color: colorGrey2), filled: true, fillColor: colorWhite, contentPadding: diff --git a/frontend/lib/Grounded/designs/text/Text.dart b/frontend/lib/Grounded/designs/text/Text.dart index e3720c1..7c8743d 100644 --- a/frontend/lib/Grounded/designs/text/Text.dart +++ b/frontend/lib/Grounded/designs/text/Text.dart @@ -55,8 +55,8 @@ Widget markedText( TextSpan( text: parts[index], style: TextStyle( - fontFamily: getTextType( - index.isOdd ? TextType.Medium : TextType.Regular), + fontFamily: + getTextType(index.isOdd ? TextType.Medium : TextType.Regular), ), ), ], diff --git a/frontend/lib/Grounded/mock/MockComms.dart b/frontend/lib/Grounded/mock/MockComms.dart index d13a84f..ad326e2 100644 --- a/frontend/lib/Grounded/mock/MockComms.dart +++ b/frontend/lib/Grounded/mock/MockComms.dart @@ -46,7 +46,8 @@ class MockComms implements ConnectComms { Future _done() => _ok({'success': true}); - Future _emptyPage() => _ok(MockStore.page(>[])); + Future _emptyPage() => + _ok(MockStore.page(>[])); // ── Auth ────────────────────────────────────────────────────────────────── @@ -72,7 +73,8 @@ class MockComms implements ConnectComms { Future logout() => _done(); @override - Future me() => _ok({'id': 'mock-user', 'name': 'Alex'}); + Future me() => + _ok({'id': 'mock-user', 'name': 'Alex'}); // ── Goals ───────────────────────────────────────────────────────────────── @@ -152,7 +154,8 @@ class MockComms implements ConnectComms { @override Future completeCommitmentEntry(CompletionRequest request) { - final Map? item = MockStore.commitment(request.commitmentId); + final Map? item = + MockStore.commitment(request.commitmentId); if (item != null) { final DateTime? end = DateTime.tryParse(item['dueEnd'] ?? ''); item['status'] = end != null && end.isBefore(DateTime.now()) @@ -164,7 +167,8 @@ class MockComms implements ConnectComms { @override Future deferCommitmentEntry(DeferralRequest request) { - final Map? item = MockStore.commitment(request.commitmentId); + final Map? item = + MockStore.commitment(request.commitmentId); if (item != null) { final DateTime now = DateTime.now(); item['dueStart'] = request.newDueStart.isNotEmpty @@ -181,7 +185,8 @@ class MockComms implements ConnectComms { @override Future abandonCommitmentEntry(AbandonRequest request) { - final Map? item = MockStore.commitment(request.commitmentId); + final Map? item = + MockStore.commitment(request.commitmentId); if (item != null) { item['status'] = 'Abandoned'; } @@ -271,7 +276,8 @@ class MockComms implements ConnectComms { Future updateSickMode(SickModeRequest request) => _done(); @override - Future checkDistress() => _ok({'distressed': false}); + Future checkDistress() => + _ok({'distressed': false}); @override Future updateTone(ToneRequest request) => _done(); @@ -291,8 +297,8 @@ class MockComms implements ConnectComms { Future getKeystoneHabits() => _ok([]); @override - Future getMyRoutines(HistoryRequest request) => _ok( - MockStore.routines.map((routine) => routine.toJson()).toList()); + Future getMyRoutines(HistoryRequest request) => + _ok(MockStore.routines.map((routine) => routine.toJson()).toList()); @override Future saveRoutineEntry(Map request) { @@ -385,13 +391,16 @@ class MockComms implements ConnectComms { Future getSessionHistory(HistoryRequest request) => _emptyPage(); @override - Future getLastSessionFor(IdRequest request) => _ok({}); + Future getLastSessionFor(IdRequest request) => + _ok({}); @override - Future getWeeklyVolume(ReportCardRequest request) => _ok({}); + Future getWeeklyVolume(ReportCardRequest request) => + _ok({}); @override - Future getContactVolume(ReportCardRequest request) => _ok({}); + Future getContactVolume(ReportCardRequest request) => + _ok({}); @override Future getPersonalRecords(HistoryRequest request) => _emptyPage(); @@ -399,10 +408,12 @@ class MockComms implements ConnectComms { // ── Report card ─────────────────────────────────────────────────────────── @override - Future getWeeklyReportCard(ReportCardRequest request) => _ok(_report()); + Future getWeeklyReportCard(ReportCardRequest request) => + _ok(_report()); @override - Future getMonthlyReportCard(ReportCardRequest request) => _ok(_report()); + Future getMonthlyReportCard(ReportCardRequest request) => + _ok(_report()); Map _report() { final DateTime now = DateTime.now(); @@ -421,13 +432,27 @@ class MockComms implements ConnectComms { 'Health': 0.79, }, 'completionByWeekday': { - '1': 0.9, '2': 0.8, '3': 0.75, '4': 0.45, '5': 0.7, '6': 0.6, '7': 0.85, + '1': 0.9, + '2': 0.8, + '3': 0.75, + '4': 0.45, + '5': 0.7, + '6': 0.6, + '7': 0.85, }, 'worstHour': 19, 'debtTrend': [8, 9.5, 11, 10.2, 7.4, 6.8, 5.9], 'deferralLeaderboard': >[ - {'commitmentId': 'c-email', 'title': 'Email supervisor the outline', 'count': 2}, - {'commitmentId': 'c-kitchen', 'title': 'Deep clean the kitchen', 'count': 1}, + { + 'commitmentId': 'c-email', + 'title': 'Email supervisor the outline', + 'count': 2 + }, + { + 'commitmentId': 'c-kitchen', + 'title': 'Deep clean the kitchen', + 'count': 1 + }, ], 'excuseTaxonomy': [], 'estimationAccuracy': {'Deep work': 1.6, 'Admin': 1.1}, @@ -451,32 +476,42 @@ class MockComms implements ConnectComms { // ── Groups ──────────────────────────────────────────────────────────────── @override - Future getMyGroups(HistoryRequest request) => _ok(MockGroups.mine()); + Future getMyGroups(HistoryRequest request) => + _ok(MockGroups.mine()); @override - Future getGroupEntry(IdRequest request) => _ok(MockGroups.one(request.id)); + Future getGroupEntry(IdRequest request) => + _ok(MockGroups.one(request.id)); @override - Future saveGroupEntry(GroupRequest request) => _ok(MockGroups.save(request)); + Future saveGroupEntry(GroupRequest request) => + _ok(MockGroups.save(request)); @override - Future joinGroupEntry(JoinGroupRequest request) => _ok(MockGroups.join(request)); + Future joinGroupEntry(JoinGroupRequest request) => + _ok(MockGroups.join(request)); @override - Future leaveGroupEntry(IdRequest request) => _ok(MockGroups.leave(request.id)); + Future leaveGroupEntry(IdRequest request) => + _ok(MockGroups.leave(request.id)); @override - Future saveGroupGoalEntry(GroupGoalRequest request) => _ok(MockGroups.saveGoal(request)); + Future saveGroupGoalEntry(GroupGoalRequest request) => + _ok(MockGroups.saveGoal(request)); @override - Future logObjectiveProgress(ObjectiveProgressRequest request) => _ok(MockGroups.logProgress(request)); + Future logObjectiveProgress(ObjectiveProgressRequest request) => + _ok(MockGroups.logProgress(request)); @override - Future getGroupMessages(IdRequest request) => _ok(MockGroups.messages(request.id)); + Future getGroupMessages(IdRequest request) => + _ok(MockGroups.messages(request.id)); @override - Future sendGroupMessage(GroupMessageRequest request) => _ok(MockGroups.send(request)); + Future sendGroupMessage(GroupMessageRequest request) => + _ok(MockGroups.send(request)); @override - Future nudgeGroupMember(NudgeRequest request) => _ok(MockGroups.nudge(request)); + Future nudgeGroupMember(NudgeRequest request) => + _ok(MockGroups.nudge(request)); } diff --git a/frontend/lib/Grounded/mock/MockGroups.dart b/frontend/lib/Grounded/mock/MockGroups.dart index 271b469..19d7ed0 100644 --- a/frontend/lib/Grounded/mock/MockGroups.dart +++ b/frontend/lib/Grounded/mock/MockGroups.dart @@ -35,6 +35,18 @@ class MockGroups { static final Random _random = Random(7); + /// Sample people with a bundled portrait (assets/images/person_*.jpg). + static const List _photos = [ + 'alex', + 'sam', + 'priya', + 'jordan', + 'mei', + 'tom', + 'ade', + 'lena' + ]; + static Map _member(String id, String name, {String role = "Member", double kept = 0.7, @@ -44,6 +56,9 @@ class MockGroups { return { 'id': id, 'name': name, + 'photo': _photos.contains(name.toLowerCase()) + ? 'asset:assets/images/person_${name.toLowerCase()}.jpg' + : '', 'role': role, 'keptRate': kept, 'streak': streak, @@ -55,9 +70,8 @@ class MockGroups { }; } - static Map _objective( - String id, String title, String unit, double target, double current, - double mine) { + static Map _objective(String id, String title, String unit, + double target, double current, double mine) { return { 'id': id, 'title': title, @@ -105,7 +119,8 @@ class MockGroups { _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-priya', 'Priya', + kept: 0.85, streak: 7, checkedInHoursAgo: 3), _member('m-jordan', 'Jordan', kept: 0.41, streak: 0, standing: 'Warned', checkedInHoursAgo: 52), ], @@ -114,7 +129,8 @@ class MockGroups { 'id': 'gg-run', 'title': 'Run 200 km this month', 'description': 'Every kilometre counts, treadmill included.', - 'targetDate': DateTime(now.year, now.month + 1, 0).toIso8601String(), + 'targetDate': + DateTime(now.year, now.month + 1, 0).toIso8601String(), 'colourHex': colorToHex(goalAccents[1]), 'objectives': >[ _objective('ob-km', 'Distance run', 'km', 200, 86, 22), @@ -151,11 +167,14 @@ class MockGroups { 'id': 'gg-drafts', 'title': 'Full drafts submitted', 'description': 'Three theses, one deadline.', - 'targetDate': DateTime(now.year, now.month + 1, 15).toIso8601String(), + 'targetDate': + DateTime(now.year, now.month + 1, 15).toIso8601String(), 'colourHex': colorToHex(goalAccents[3]), 'objectives': >[ - _objective('ob-words', 'Words written', 'words', 30000, 18400, 7200), - _objective('ob-chapters', 'Chapters reviewed', 'chapters', 9, 3, 1), + _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), ], }, @@ -191,15 +210,17 @@ class MockGroups { _messages['group-run'] = >[ _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', _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'), + 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'), + '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), ]; @@ -208,10 +229,15 @@ class MockGroups { 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), + _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'] = >[ _message('group-walk', 'm-ade', 'Ade', @@ -236,7 +262,8 @@ class MockGroups { .toList(); for (Map message in due) { _pending.remove(message); - _messages.putIfAbsent(message['groupId'], () => >[]) + _messages + .putIfAbsent(message['groupId'], () => >[]) .add(message); final Map? group = _find(message['groupId']); if (group != null) { @@ -259,8 +286,9 @@ class MockGroups { } static void _post(String groupId, String body, String kind) { - _messages.putIfAbsent(groupId, () => >[]).add( - _message(groupId, _me, _myName, body, 0, kind: kind)); + _messages + .putIfAbsent(groupId, () => >[]) + .add(_message(groupId, _me, _myName, body, 0, kind: kind)); } /// Someone answers a few seconds later, so the chat feels inhabited. @@ -331,8 +359,8 @@ class MockGroups { 'inviteCode': _code(), 'unread': 0, 'members': >[ - _member(_me, _myName, role: 'Owner', kept: 0.78, streak: 4, - checkedInHoursAgo: 0), + _member(_me, _myName, + role: 'Owner', kept: 0.78, streak: 4, checkedInHoursAgo: 0), ], 'goals': >[], }; @@ -366,8 +394,8 @@ class MockGroups { 'members': >[], 'goals': >[], }); - (group['members'] as List).add(_member(_me, _myName, - kept: 0.78, streak: 4, checkedInHoursAgo: 0)); + (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']); @@ -422,7 +450,7 @@ class MockGroups { _post( group['id'], 'logged $amount ${objective['unit']} on ${objective['title']}' - '${request.note.isEmpty ? "" : " — ${request.note}"}', + '${request.note.isEmpty ? "" : " — ${request.note}"}', 'CheckIn'); _queueReply(group['id']); } @@ -465,8 +493,8 @@ class MockGroups { who = member['name']; } } - _post(request.groupId, 'nudged $who: today still counts, go get it', - 'Nudge'); + _post( + request.groupId, 'nudged $who: today still counts, go get it', 'Nudge'); return {'success': true}; } } diff --git a/frontend/lib/Grounded/mock/MockMemory.dart b/frontend/lib/Grounded/mock/MockMemory.dart index a5d905d..14cb12a 100644 --- a/frontend/lib/Grounded/mock/MockMemory.dart +++ b/frontend/lib/Grounded/mock/MockMemory.dart @@ -21,7 +21,8 @@ class MockMemory extends InternalMemory { _seeded = true; setMyDescription( MeDescription(id: "mock-user", name: "Alex", token: "mock-token")); - setUserDetails(UserDetails(pic: "", name: "Alex")); + setUserDetails(UserDetails( + pic: "asset:assets/images/person_alex.jpg", name: "Alex")); setTodos([ TodoItem(id: "t1", title: "Pick up gym kit"), TodoItem(id: "t2", title: "Book physio for Thursday"), @@ -51,8 +52,9 @@ class MockMemory extends InternalMemory { title: title, kind: kind, startedAt: at, - seconds: (seconds * (held ? 1 : 0.5) * (0.85 + random.nextDouble() * 0.3)) - .round(), + seconds: + (seconds * (held ? 1 : 0.5) * (0.85 + random.nextDouble() * 0.3)) + .round(), completedSteps: held ? steps : 1 + random.nextInt(steps - 1), totalSteps: steps, )); diff --git a/frontend/lib/Grounded/mock/MockStore.dart b/frontend/lib/Grounded/mock/MockStore.dart index 3c8eaef..c8ada8c 100644 --- a/frontend/lib/Grounded/mock/MockStore.dart +++ b/frontend/lib/Grounded/mock/MockStore.dart @@ -43,7 +43,8 @@ class MockStore { { 'id': 'goal-strong', 'title': 'Get strong', - 'description': 'Deadlift my bodyweight by spring, without getting hurt.', + 'description': + 'Deadlift my bodyweight by spring, without getting hurt.', 'category': 'Health', 'defaultClass': 'Standard', 'startDate': at(-30, 0).toIso8601String(), @@ -145,10 +146,7 @@ class MockStore { minutes: 60, category: 'Deep work'), task('c-meal', 'Meal prep for the week', - goal: 'goal-strong', - start: at(1, 17), - end: at(1, 20), - minutes: 90), + goal: 'goal-strong', start: at(1, 17), end: at(1, 20), minutes: 90), // Two things already past their window, so the overdue queue and debt // have something to show. task('c-email', 'Email supervisor the outline', @@ -159,10 +157,7 @@ class MockStore { status: 'Overdue', deferrals: 1), task('c-kitchen', 'Deep clean the kitchen', - start: at(-1, 18), - end: at(-1, 21), - minutes: 45, - status: 'Overdue'), + start: at(-1, 18), end: at(-1, 21), minutes: 45, status: 'Overdue'), ]); // ── Routines ─────────────────────────────────────────────────────────── @@ -175,7 +170,10 @@ class MockStore { final RoutineChain push = RoutineTemplates.upperPush() ..id = 'routine-push' ..goalId = 'goal-strong' - ..days = [1, 2, 3, 4, 5, 6, 7].where((day) => day != weekday).take(2).toList(); + ..days = [1, 2, 3, 4, 5, 6, 7] + .where((day) => day != weekday) + .take(2) + .toList(); final RoutineChain breathing = RoutineTemplates.boxBreathing() ..id = 'routine-breathing' @@ -262,12 +260,14 @@ class MockStore { final List> tasks = goalTasks(goal['id']); final int completed = tasks .where((item) => - item['status'] == 'Completed' || item['status'] == 'LateCompleted') + item['status'] == 'Completed' || + item['status'] == 'LateCompleted') .length; final int late = tasks.where((item) => !_settled(item) && _windowClosed(item)).length; return Map.from(goal) - ..['totalTasks'] = tasks.where((item) => item['status'] != 'Abandoned').length + ..['totalTasks'] = + tasks.where((item) => item['status'] != 'Abandoned').length ..['completedTasks'] = completed ..['overdueTasks'] = late ..['debtContribution'] = late * 2.0; diff --git a/frontend/lib/Grounded/see/group/GroupChatState.dart b/frontend/lib/Grounded/see/group/GroupChatState.dart index f0fc8cf..0d76996 100644 --- a/frontend/lib/Grounded/see/group/GroupChatState.dart +++ b/frontend/lib/Grounded/see/group/GroupChatState.dart @@ -98,6 +98,16 @@ class GroupChatState extends State implements ConnectGroupChat { _onSend(reply); } + /// A message author's photo, looked up among the group's members. + String _photoOf(String authorId) { + for (final member in widget.group.members) { + if (member.id == authorId) { + return member.photo; + } + } + return ""; + } + // ── Views ───────────────────────────────────────────────────────────────── Widget _mobileView(BoxConstraints constraints) { @@ -145,8 +155,7 @@ class GroupChatState extends State implements ConnectGroupChat { return Container( decoration: BoxDecoration( - borderRadius: - const BorderRadius.vertical(bottom: Radius.circular(20)), + borderRadius: const BorderRadius.vertical(bottom: Radius.circular(20)), gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, @@ -188,7 +197,10 @@ class GroupChatState extends State implements ConnectGroupChat { const SizedBox(width: 10), avatarStack( widget.group.members.map((member) => member.name).toList(), 30, - max: 3, border: colorNight), + photos: + widget.group.members.map((member) => member.photo).toList(), + max: 3, + border: colorNight), ], ), ); @@ -275,7 +287,8 @@ class GroupChatState extends State implements ConnectGroupChat { children: [ grouped ? const SizedBox(width: 34) - : memberAvatar(message.authorName, 34), + : memberAvatar(message.authorName, 34, + photo: _photoOf(message.authorId)), const SizedBox(width: 8), Flexible( child: Container( @@ -328,7 +341,8 @@ class GroupChatState extends State implements ConnectGroupChat { ), child: Row( children: [ - memberAvatar(message.authorName, 34), + memberAvatar(message.authorName, 34, + photo: _photoOf(message.authorId)), const SizedBox(width: 12), Expanded( child: Column( diff --git a/frontend/lib/Grounded/see/group/GroupDetailState.dart b/frontend/lib/Grounded/see/group/GroupDetailState.dart index a888cfd..5db34bf 100644 --- a/frontend/lib/Grounded/see/group/GroupDetailState.dart +++ b/frontend/lib/Grounded/see/group/GroupDetailState.dart @@ -329,7 +329,8 @@ class GroupDetailState extends State horizontal: 7, vertical: 3), decoration: BoxDecoration( color: colorLime, - borderRadius: const BorderRadius.all(Radius.circular(8)), + borderRadius: + const BorderRadius.all(Radius.circular(8)), ), child: text( "${_group.unread} new", 10, TextType.Medium, @@ -519,7 +520,7 @@ class GroupDetailState extends State child: text("$rank", 16, TextType.Medium, color: rank == 1 ? colorInk : colorGrey2), ), - memberAvatar(member.name, 46), + memberAvatar(member.name, 46, photo: member.photo), const SizedBox(width: 12), Expanded( child: Column( @@ -632,7 +633,8 @@ class GroupDetailState extends State alignment: Alignment.center, decoration: BoxDecoration( color: colorAmbientMid, - borderRadius: const BorderRadius.all(Radius.circular(12)), + borderRadius: + const BorderRadius.all(Radius.circular(12)), ), child: text( "+${formatAmount(preset)}", 15, TextType.Medium, diff --git a/frontend/lib/Grounded/see/group/GroupEditorState.dart b/frontend/lib/Grounded/see/group/GroupEditorState.dart index 7878809..0319bc6 100644 --- a/frontend/lib/Grounded/see/group/GroupEditorState.dart +++ b/frontend/lib/Grounded/see/group/GroupEditorState.dart @@ -245,7 +245,7 @@ class GroupEditorState extends State text(name, 26, TextType.Light, color: colorWhite, maxLines: 2, overflow: TextOverflow.ellipsis), const SizedBox(height: 16), - avatarStack(const ["Alex"], 34), + avatarStack(const ["You"], 34), ], ), ); diff --git a/frontend/lib/Grounded/see/group/GroupsState.dart b/frontend/lib/Grounded/see/group/GroupsState.dart index 5913732..a5c4405 100644 --- a/frontend/lib/Grounded/see/group/GroupsState.dart +++ b/frontend/lib/Grounded/see/group/GroupsState.dart @@ -180,7 +180,8 @@ class GroupsState extends State implements ConnectGroups { horizontal: 10, vertical: 6), decoration: BoxDecoration( color: colorLime, - borderRadius: const BorderRadius.all(Radius.circular(8)), + borderRadius: + const BorderRadius.all(Radius.circular(8)), ), child: Row( mainAxisSize: MainAxisSize.min, @@ -218,7 +219,10 @@ class GroupsState extends State implements ConnectGroups { children: [ avatarStack( group.members.map((member) => member.name).toList(), - 32), + 32, + photos: group.members + .map((member) => member.photo) + .toList()), const Spacer(), text("${(group.progress * 100).round()}%", 22, TextType.Light, diff --git a/frontend/lib/Grounded/see/home/HomeState.dart b/frontend/lib/Grounded/see/home/HomeState.dart index fe9d335..cfd58ef 100644 --- a/frontend/lib/Grounded/see/home/HomeState.dart +++ b/frontend/lib/Grounded/see/home/HomeState.dart @@ -27,6 +27,7 @@ import '../../designs/Component.dart'; import '../../designs/FloatingNav.dart'; import '../../designs/GlassSurface.dart'; import '../../designs/MemberAvatar.dart'; +import '../../designs/Portrait.dart'; import '../../designs/ProgressRing.dart'; import '../../designs/Responsive.dart'; import '../../designs/Shell.dart'; @@ -35,6 +36,7 @@ import '../../designs/input/InputFields.dart'; import '../../designs/scene/SceneArt.dart'; import '../../designs/text/Text.dart'; import '../../utils/Colors.dart'; +import '../../utils/PhotoPicker.dart'; import '../../utils/CommonUtils.dart'; import '../../utils/StandingEngine.dart'; import '../../utils/Thresholds.dart'; @@ -247,6 +249,13 @@ class HomeState extends State implements ConnectHome { } } + void _onChangePhoto() async { + final String? pic = await PhotoPicker.pickPortrait(); + if (pic != null) { + _model?.savePhoto(pic); + } + } + void _onOpenSettings() { GroundedNavigation() .navigateToPage(NavigatorType.justOpen, const Settings(), context); @@ -1194,28 +1203,11 @@ class HomeState extends State implements ConnectHome { // ── Header helpers ──────────────────────────────────────────────────────── /// A portrait cut from the painted landscape, with the initial on it. + /// Your photo. Tapping it picks a new one. Widget _avatar(double size) { - final String initial = - _user.name.isEmpty ? "G" : _user.name.substring(0, 1).toUpperCase(); - - return Container( - width: size, - height: size, - clipBehavior: Clip.antiAlias, - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all(color: colorWhite, width: 2), - ), - child: Stack( - fit: StackFit.expand, - children: [ - SceneArt(palette: sceneWorkout, seed: sceneSeed(_user.name)), - Center( - child: - text(initial, size * 0.4, TextType.Medium, color: colorWhite), - ), - ], - ), + return GestureDetector( + onTap: _onChangePhoto, + child: portrait(_user.pic, _user.name, size), ); } @@ -1705,7 +1697,10 @@ class HomeState extends State implements ConnectHome { children: [ avatarStack( group.members.map((member) => member.name).toList(), - 26), + 26, + photos: group.members + .map((member) => member.photo) + .toList()), const Spacer(), Icon(CupertinoIcons.chat_bubble_2_fill, size: 16, color: colorInkSoft), diff --git a/frontend/lib/Grounded/see/home/ViewHome.dart b/frontend/lib/Grounded/see/home/ViewHome.dart index 739f67a..e8cd172 100644 --- a/frontend/lib/Grounded/see/home/ViewHome.dart +++ b/frontend/lib/Grounded/see/home/ViewHome.dart @@ -80,6 +80,15 @@ class ViewHome extends ParentViewModel { } } + /// A new profile photo, kept on the device with the rest of the user's + /// details. There is no upload endpoint yet, so it does not leave the phone. + void savePhoto(String pic) async { + final UserDetails details = await getDataManager().getUserDetails(); + details.pic = pic; + await getDataManager().setUserDetails(details); + connection.onUserLoaded(details); + } + void loadTodos() async { connection.onTodosLoaded(await getDataManager().getTodos()); } @@ -140,8 +149,7 @@ class ViewHome extends ParentViewModel { /// Reminders are one-shots for the coming week, so they are re-placed /// whenever the schedule is read. void _placeReminders(List routines) async { - final ProgressTargets targets = - await getDataManager().getProgressTargets(); + final ProgressTargets targets = await getDataManager().getProgressTargets(); if (targets.reminders) { await LocalNotificationEngine.scheduleRoutineReminders( routines, targets.reminderLead); diff --git a/frontend/lib/Grounded/see/settings/SettingsState.dart b/frontend/lib/Grounded/see/settings/SettingsState.dart index 3385c1b..ae0a68d 100644 --- a/frontend/lib/Grounded/see/settings/SettingsState.dart +++ b/frontend/lib/Grounded/see/settings/SettingsState.dart @@ -8,12 +8,14 @@ import '../../about/internal/application/ToneLevel.dart'; import '../../about/internal/application/UserDetails.dart'; import '../../configs/Navigator.dart'; import '../../designs/Component.dart'; +import '../../designs/Portrait.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/PhotoPicker.dart'; import '../../utils/Thresholds.dart'; import '../../utils/Validators.dart'; import '../login/Login.dart'; @@ -59,9 +61,19 @@ class SettingsState extends State implements ConnectSettings { Navigator.pop(context); } + void _onChangePhoto() async { + final String? pic = await PhotoPicker.pickPortrait(); + if (pic != null) { + _model?.savePhoto(pic); + } + } + + void _onRemovePhoto() { + _model?.savePhoto(""); + } + void _onOpenTargets() { - GroundedNavigation() - .navigateToPageWithData(const TargetSetup(), context); + GroundedNavigation().navigateToPageWithData(const TargetSetup(), context); } void _onToneSelected(ToneLevel tone) { @@ -184,6 +196,50 @@ class SettingsState extends State implements ConnectSettings { crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ + sectionBreak("Profile photo", caption: "kept on this phone"), + card( + child: Row( + children: [ + portrait(_user.pic, _user.name, 64), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + text(_user.name.isEmpty ? "You" : _user.name, 16, + TextType.Medium, + color: colorPrimaryDark), + const SizedBox(height: 8), + Row( + children: [ + GestureDetector( + onTap: _onChangePhoto, + child: text( + _user.pic.isEmpty + ? "Add a photo" + : "Change photo", + 13, + TextType.Medium, + color: colorAccent), + ), + if (_user.pic.isNotEmpty) ...[ + const SizedBox(width: 18), + GestureDetector( + onTap: _onRemovePhoto, + child: text("Remove", 13, TextType.Regular, + color: colorGrey2), + ), + ], + ], + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 28), sectionBreak("Tone", caption: "how it speaks to you"), segmentedSelector( options: ToneLevel.values, @@ -270,8 +326,7 @@ class SettingsState extends State implements ConnectSettings { ], ), ), - Icon(CupertinoIcons.chevron_right, - size: 18, color: colorGrey2), + Icon(CupertinoIcons.chevron_right, size: 18, color: colorGrey2), ], ), ), @@ -327,8 +382,8 @@ class SettingsState extends State implements ConnectSettings { ), const SizedBox(height: 20), Center( - child: text("Grounded 1.0.0", 11, TextType.Regular, - color: colorGrey), + child: + text("Grounded 1.0.0", 11, TextType.Regular, color: colorGrey), ), ], ), diff --git a/frontend/lib/Grounded/see/settings/ViewSettings.dart b/frontend/lib/Grounded/see/settings/ViewSettings.dart index 40d07c8..7294ea4 100644 --- a/frontend/lib/Grounded/see/settings/ViewSettings.dart +++ b/frontend/lib/Grounded/see/settings/ViewSettings.dart @@ -24,6 +24,14 @@ class ViewSettings extends ParentViewModel { ); } + /// A new profile photo (or "" to remove it), kept on the device. + void savePhoto(String pic) async { + final UserDetails details = await getDataManager().getUserDetails(); + details.pic = pic; + await getDataManager().setUserDetails(details); + initialise(); + } + /// The tone slider is capped: it changes register, never cruelty. void changeTone(ToneLevel tone) async { if (!await hasNetwork(() => changeTone(tone))) return; @@ -66,8 +74,8 @@ class ViewSettings extends ParentViewModel { connection.onSickModeChanged(details); } catch (e) { - handleError(e, () => setSickMode(enabled, reason), () => dismissError(), - "Retry"); + handleError( + e, () => setSickMode(enabled, reason), () => dismissError(), "Retry"); } } diff --git a/frontend/lib/Grounded/utils/PhotoPicker.dart b/frontend/lib/Grounded/utils/PhotoPicker.dart new file mode 100644 index 0000000..509527e --- /dev/null +++ b/frontend/lib/Grounded/utils/PhotoPicker.dart @@ -0,0 +1,39 @@ +import 'dart:convert'; +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:file_picker/file_picker.dart'; + +/// Picks a photo from the device and returns it as a small PNG data URI, +/// ready to store as a profile `pic`. Null when nothing was picked or the +/// file was not an image. +class PhotoPicker { + /// Long edge of the stored portrait. Big enough for the largest avatar on + /// a 3x screen, small enough to keep in local storage. + static const int _edge = 256; + + static Future pickPortrait() async { + final FilePickerResult? result = + await FilePicker.pickFiles(type: FileType.image, withData: true); + final Uint8List? bytes = result == null || result.files.isEmpty + ? null + : result.files.first.bytes; + if (bytes == null) { + return null; + } + + try { + final ui.Codec codec = await ui.instantiateImageCodec(bytes, + targetWidth: _edge, targetHeight: null); + final ui.FrameInfo frame = await codec.getNextFrame(); + final ByteData? png = + await frame.image.toByteData(format: ui.ImageByteFormat.png); + if (png == null) { + return null; + } + return "data:image/png;base64,${base64Encode(png.buffer.asUint8List())}"; + } catch (e) { + return null; + } + } +}