Show real photos for people instead of scene art
Your avatar is your photo, picked from the device by tapping it on Home or from a new Profile photo row in Settings, shrunk to 256 px and kept on the phone (there is no upload endpoint yet). Group members show the photo the server sends in a new `photo` field, everywhere avatars appear. Without a photo, an initial on a neutral disc. Mock mode bundles free Unsplash portraits for the sample people. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KhQh614DMeYoQEH9SoMRUw
13
frontend/assets/images/PEOPLE-CREDITS.txt
Normal file
@@ -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.
|
||||||
BIN
frontend/assets/images/person_ade.jpg
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
BIN
frontend/assets/images/person_alex.jpg
Normal file
|
After Width: | Height: | Size: 9.6 KiB |
BIN
frontend/assets/images/person_jordan.jpg
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
frontend/assets/images/person_lena.jpg
Normal file
|
After Width: | Height: | Size: 9.5 KiB |
BIN
frontend/assets/images/person_mei.jpg
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
frontend/assets/images/person_priya.jpg
Normal file
|
After Width: | Height: | Size: 9.9 KiB |
BIN
frontend/assets/images/person_sam.jpg
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
frontend/assets/images/person_tom.jpg
Normal file
|
After Width: | Height: | Size: 9.7 KiB |
@@ -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`.
|
- 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.
|
- 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`.
|
- Screen padding `20` horizontal. Card padding `16`. Section gap `28`.
|
||||||
- Hairlines at `colorDivider` (black 7%), borders at `colorBorder` (black 8%).
|
- Hairlines at `colorDivider` (black 7%), borders at `colorBorder` (black 8%).
|
||||||
- One soft shadow only: `0 4 18 rgba(0,0,0,0.03)`. No coloured glows.
|
- One soft shadow only: `0 4 18 rgba(0,0,0,0.03)`. No coloured glows.
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ class GroupMember {
|
|||||||
|
|
||||||
String name;
|
String name;
|
||||||
|
|
||||||
|
/// Their profile photo (URL), or empty for none.
|
||||||
|
String photo;
|
||||||
|
|
||||||
/// "Owner" or "Member".
|
/// "Owner" or "Member".
|
||||||
String role;
|
String role;
|
||||||
|
|
||||||
@@ -24,6 +27,7 @@ class GroupMember {
|
|||||||
GroupMember({
|
GroupMember({
|
||||||
this.id = "",
|
this.id = "",
|
||||||
this.name = "",
|
this.name = "",
|
||||||
|
this.photo = "",
|
||||||
this.role = "Member",
|
this.role = "Member",
|
||||||
this.keptRate = 0,
|
this.keptRate = 0,
|
||||||
this.streak = 0,
|
this.streak = 0,
|
||||||
@@ -38,6 +42,7 @@ class GroupMember {
|
|||||||
return GroupMember(
|
return GroupMember(
|
||||||
id: json['id'] ?? "",
|
id: json['id'] ?? "",
|
||||||
name: json['name'] ?? "",
|
name: json['name'] ?? "",
|
||||||
|
photo: json['photo'] ?? "",
|
||||||
role: json['role'] ?? "Member",
|
role: json['role'] ?? "Member",
|
||||||
keptRate: (json['keptRate'] ?? 0).toDouble(),
|
keptRate: (json['keptRate'] ?? 0).toDouble(),
|
||||||
streak: json['streak'] ?? 0,
|
streak: json['streak'] ?? 0,
|
||||||
@@ -53,6 +58,7 @@ class GroupMember {
|
|||||||
final Map<String, dynamic> data = <String, dynamic>{};
|
final Map<String, dynamic> data = <String, dynamic>{};
|
||||||
data['id'] = id;
|
data['id'] = id;
|
||||||
data['name'] = name;
|
data['name'] = name;
|
||||||
|
data['photo'] = photo;
|
||||||
data['role'] = role;
|
data['role'] = role;
|
||||||
data['keptRate'] = keptRate;
|
data['keptRate'] = keptRate;
|
||||||
data['streak'] = streak;
|
data['streak'] = streak;
|
||||||
|
|||||||
@@ -137,7 +137,8 @@ class TickRow extends StatelessWidget {
|
|||||||
duration: const Duration(milliseconds: 160),
|
duration: const Duration(milliseconds: 160),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontFamily: struck ? "GroundedRegular" : "GroundedMedium",
|
fontFamily:
|
||||||
|
struck ? "GroundedRegular" : "GroundedMedium",
|
||||||
color: struck ? colorGrey2 : colorPrimaryDark,
|
color: struck ? colorGrey2 : colorPrimaryDark,
|
||||||
decoration: struck
|
decoration: struck
|
||||||
? TextDecoration.lineThrough
|
? TextDecoration.lineThrough
|
||||||
|
|||||||
@@ -19,16 +19,15 @@ class DashedOutline extends CustomPainter {
|
|||||||
..strokeWidth = 1.2;
|
..strokeWidth = 1.2;
|
||||||
|
|
||||||
final Path outline = Path()
|
final Path outline = Path()
|
||||||
..addRRect(RRect.fromRectAndRadius(
|
..addRRect(
|
||||||
Offset.zero & size, Radius.circular(radius)));
|
RRect.fromRectAndRadius(Offset.zero & size, Radius.circular(radius)));
|
||||||
|
|
||||||
const double dash = 4;
|
const double dash = 4;
|
||||||
const double gap = 3;
|
const double gap = 3;
|
||||||
for (final metric in outline.computeMetrics()) {
|
for (final metric in outline.computeMetrics()) {
|
||||||
double distance = 0;
|
double distance = 0;
|
||||||
while (distance < metric.length) {
|
while (distance < metric.length) {
|
||||||
canvas.drawPath(
|
canvas.drawPath(metric.extractPath(distance, distance + dash), paint);
|
||||||
metric.extractPath(distance, distance + dash), paint);
|
|
||||||
distance = distance + dash + gap;
|
distance = distance + dash + gap;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,11 +82,10 @@ class _DotDigitsPainter extends CustomPainter {
|
|||||||
|
|
||||||
final Path? outline = _glyph(glyph);
|
final Path? outline = _glyph(glyph);
|
||||||
if (outline != null) {
|
if (outline != null) {
|
||||||
final Path placed = outline.transform(
|
final Path placed = outline.transform((Matrix4.identity()
|
||||||
(Matrix4.identity()
|
..translateByDouble(x, 0, 0, 1)
|
||||||
..translateByDouble(x, 0, 0, 1)
|
..scaleByDouble(_scale, _scale, 1, 1))
|
||||||
..scaleByDouble(_scale, _scale, 1, 1))
|
.storage);
|
||||||
.storage);
|
|
||||||
|
|
||||||
for (PathMetric metric in placed.computeMetrics()) {
|
for (PathMetric metric in placed.computeMetrics()) {
|
||||||
final int count = (metric.length / gap).floor().clamp(1, 400);
|
final int count = (metric.length / gap).floor().clamp(1, 400);
|
||||||
|
|||||||
@@ -2,47 +2,19 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
import '../about/internal/application/TextType.dart';
|
import '../about/internal/application/TextType.dart';
|
||||||
import '../utils/Colors.dart';
|
import '../utils/Colors.dart';
|
||||||
import '../utils/CommonUtils.dart';
|
import 'Portrait.dart';
|
||||||
import 'scene/SceneArt.dart';
|
|
||||||
import 'text/Text.dart';
|
import 'text/Text.dart';
|
||||||
|
|
||||||
/// A member's round portrait: a landscape picked from their name with the
|
/// A member's round photo, or their initial when they have none.
|
||||||
/// initial on it, so everyone is recognisable without a photo.
|
Widget memberAvatar(String name, double size,
|
||||||
Widget memberAvatar(String name, double size, {Color? border}) {
|
{String photo = "", Color? border}) {
|
||||||
const List<List<Color>> palettes = <List<Color>>[
|
return portrait(photo, name, size, border: border);
|
||||||
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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Several portraits overlapping, with "+N" once there are more than fit.
|
/// 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<String> names, double size,
|
Widget avatarStack(List<String> names, double size,
|
||||||
{int max = 4, Color? border}) {
|
{List<String> photos = const <String>[], int max = 4, Color? border}) {
|
||||||
final List<String> shown = names.take(max).toList();
|
final List<String> shown = names.take(max).toList();
|
||||||
final int extra = names.length - shown.length;
|
final int extra = names.length - shown.length;
|
||||||
final double step = size * 0.68;
|
final double step = size * 0.68;
|
||||||
@@ -56,7 +28,9 @@ Widget avatarStack(List<String> names, double size,
|
|||||||
for (int index = 0; index < shown.length; index++)
|
for (int index = 0; index < shown.length; index++)
|
||||||
Positioned(
|
Positioned(
|
||||||
left: step * index,
|
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)
|
if (extra > 0)
|
||||||
Positioned(
|
Positioned(
|
||||||
|
|||||||
56
frontend/lib/Grounded/designs/Portrait.dart
Normal file
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -53,8 +53,8 @@ class ProgressRing extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: child ??
|
child: child ??
|
||||||
text("${(clamped * 100).round()}%", size * 0.22,
|
text(
|
||||||
TextType.Bold,
|
"${(clamped * 100).round()}%", size * 0.22, TextType.Bold,
|
||||||
color: colorPrimaryDark),
|
color: colorPrimaryDark),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -56,8 +56,8 @@ Widget _stepButton(IconData icon, VoidCallback? onTap) {
|
|||||||
color: enabled ? colorSecondary : colorInset,
|
color: enabled ? colorSecondary : colorInset,
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
),
|
),
|
||||||
child: Icon(icon,
|
child:
|
||||||
size: 14, color: enabled ? colorPrimaryDark : colorGrey),
|
Icon(icon, size: 14, color: enabled ? colorPrimaryDark : colorGrey),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,9 +46,8 @@ Widget inputField(
|
|||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
color: colorGrey,
|
color: colorGrey,
|
||||||
),
|
),
|
||||||
prefixIcon: icon == null
|
prefixIcon:
|
||||||
? null
|
icon == null ? null : Icon(icon, size: 18, color: colorGrey2),
|
||||||
: Icon(icon, size: 18, color: colorGrey2),
|
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: colorWhite,
|
fillColor: colorWhite,
|
||||||
contentPadding:
|
contentPadding:
|
||||||
|
|||||||
@@ -55,8 +55,8 @@ Widget markedText(
|
|||||||
TextSpan(
|
TextSpan(
|
||||||
text: parts[index],
|
text: parts[index],
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: getTextType(
|
fontFamily:
|
||||||
index.isOdd ? TextType.Medium : TextType.Regular),
|
getTextType(index.isOdd ? TextType.Medium : TextType.Regular),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -46,7 +46,8 @@ class MockComms implements ConnectComms {
|
|||||||
|
|
||||||
Future<Response> _done() => _ok(<String, dynamic>{'success': true});
|
Future<Response> _done() => _ok(<String, dynamic>{'success': true});
|
||||||
|
|
||||||
Future<Response> _emptyPage() => _ok(MockStore.page(<Map<String, dynamic>>[]));
|
Future<Response> _emptyPage() =>
|
||||||
|
_ok(MockStore.page(<Map<String, dynamic>>[]));
|
||||||
|
|
||||||
// ── Auth ──────────────────────────────────────────────────────────────────
|
// ── Auth ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -72,7 +73,8 @@ class MockComms implements ConnectComms {
|
|||||||
Future<Response> logout() => _done();
|
Future<Response> logout() => _done();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Response> me() => _ok(<String, dynamic>{'id': 'mock-user', 'name': 'Alex'});
|
Future<Response> me() =>
|
||||||
|
_ok(<String, dynamic>{'id': 'mock-user', 'name': 'Alex'});
|
||||||
|
|
||||||
// ── Goals ─────────────────────────────────────────────────────────────────
|
// ── Goals ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -152,7 +154,8 @@ class MockComms implements ConnectComms {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Response> completeCommitmentEntry(CompletionRequest request) {
|
Future<Response> completeCommitmentEntry(CompletionRequest request) {
|
||||||
final Map<String, dynamic>? item = MockStore.commitment(request.commitmentId);
|
final Map<String, dynamic>? item =
|
||||||
|
MockStore.commitment(request.commitmentId);
|
||||||
if (item != null) {
|
if (item != null) {
|
||||||
final DateTime? end = DateTime.tryParse(item['dueEnd'] ?? '');
|
final DateTime? end = DateTime.tryParse(item['dueEnd'] ?? '');
|
||||||
item['status'] = end != null && end.isBefore(DateTime.now())
|
item['status'] = end != null && end.isBefore(DateTime.now())
|
||||||
@@ -164,7 +167,8 @@ class MockComms implements ConnectComms {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Response> deferCommitmentEntry(DeferralRequest request) {
|
Future<Response> deferCommitmentEntry(DeferralRequest request) {
|
||||||
final Map<String, dynamic>? item = MockStore.commitment(request.commitmentId);
|
final Map<String, dynamic>? item =
|
||||||
|
MockStore.commitment(request.commitmentId);
|
||||||
if (item != null) {
|
if (item != null) {
|
||||||
final DateTime now = DateTime.now();
|
final DateTime now = DateTime.now();
|
||||||
item['dueStart'] = request.newDueStart.isNotEmpty
|
item['dueStart'] = request.newDueStart.isNotEmpty
|
||||||
@@ -181,7 +185,8 @@ class MockComms implements ConnectComms {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Response> abandonCommitmentEntry(AbandonRequest request) {
|
Future<Response> abandonCommitmentEntry(AbandonRequest request) {
|
||||||
final Map<String, dynamic>? item = MockStore.commitment(request.commitmentId);
|
final Map<String, dynamic>? item =
|
||||||
|
MockStore.commitment(request.commitmentId);
|
||||||
if (item != null) {
|
if (item != null) {
|
||||||
item['status'] = 'Abandoned';
|
item['status'] = 'Abandoned';
|
||||||
}
|
}
|
||||||
@@ -271,7 +276,8 @@ class MockComms implements ConnectComms {
|
|||||||
Future<Response> updateSickMode(SickModeRequest request) => _done();
|
Future<Response> updateSickMode(SickModeRequest request) => _done();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Response> checkDistress() => _ok(<String, dynamic>{'distressed': false});
|
Future<Response> checkDistress() =>
|
||||||
|
_ok(<String, dynamic>{'distressed': false});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Response> updateTone(ToneRequest request) => _done();
|
Future<Response> updateTone(ToneRequest request) => _done();
|
||||||
@@ -291,8 +297,8 @@ class MockComms implements ConnectComms {
|
|||||||
Future<Response> getKeystoneHabits() => _ok(<dynamic>[]);
|
Future<Response> getKeystoneHabits() => _ok(<dynamic>[]);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Response> getMyRoutines(HistoryRequest request) => _ok(
|
Future<Response> getMyRoutines(HistoryRequest request) =>
|
||||||
MockStore.routines.map((routine) => routine.toJson()).toList());
|
_ok(MockStore.routines.map((routine) => routine.toJson()).toList());
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Response> saveRoutineEntry(Map<String, dynamic> request) {
|
Future<Response> saveRoutineEntry(Map<String, dynamic> request) {
|
||||||
@@ -385,13 +391,16 @@ class MockComms implements ConnectComms {
|
|||||||
Future<Response> getSessionHistory(HistoryRequest request) => _emptyPage();
|
Future<Response> getSessionHistory(HistoryRequest request) => _emptyPage();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Response> getLastSessionFor(IdRequest request) => _ok(<String, dynamic>{});
|
Future<Response> getLastSessionFor(IdRequest request) =>
|
||||||
|
_ok(<String, dynamic>{});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Response> getWeeklyVolume(ReportCardRequest request) => _ok(<String, dynamic>{});
|
Future<Response> getWeeklyVolume(ReportCardRequest request) =>
|
||||||
|
_ok(<String, dynamic>{});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Response> getContactVolume(ReportCardRequest request) => _ok(<String, dynamic>{});
|
Future<Response> getContactVolume(ReportCardRequest request) =>
|
||||||
|
_ok(<String, dynamic>{});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Response> getPersonalRecords(HistoryRequest request) => _emptyPage();
|
Future<Response> getPersonalRecords(HistoryRequest request) => _emptyPage();
|
||||||
@@ -399,10 +408,12 @@ class MockComms implements ConnectComms {
|
|||||||
// ── Report card ───────────────────────────────────────────────────────────
|
// ── Report card ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Response> getWeeklyReportCard(ReportCardRequest request) => _ok(_report());
|
Future<Response> getWeeklyReportCard(ReportCardRequest request) =>
|
||||||
|
_ok(_report());
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Response> getMonthlyReportCard(ReportCardRequest request) => _ok(_report());
|
Future<Response> getMonthlyReportCard(ReportCardRequest request) =>
|
||||||
|
_ok(_report());
|
||||||
|
|
||||||
Map<String, dynamic> _report() {
|
Map<String, dynamic> _report() {
|
||||||
final DateTime now = DateTime.now();
|
final DateTime now = DateTime.now();
|
||||||
@@ -421,13 +432,27 @@ class MockComms implements ConnectComms {
|
|||||||
'Health': 0.79,
|
'Health': 0.79,
|
||||||
},
|
},
|
||||||
'completionByWeekday': {
|
'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,
|
'worstHour': 19,
|
||||||
'debtTrend': <double>[8, 9.5, 11, 10.2, 7.4, 6.8, 5.9],
|
'debtTrend': <double>[8, 9.5, 11, 10.2, 7.4, 6.8, 5.9],
|
||||||
'deferralLeaderboard': <Map<String, dynamic>>[
|
'deferralLeaderboard': <Map<String, dynamic>>[
|
||||||
{'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': <dynamic>[],
|
'excuseTaxonomy': <dynamic>[],
|
||||||
'estimationAccuracy': {'Deep work': 1.6, 'Admin': 1.1},
|
'estimationAccuracy': {'Deep work': 1.6, 'Admin': 1.1},
|
||||||
@@ -451,32 +476,42 @@ class MockComms implements ConnectComms {
|
|||||||
// ── Groups ────────────────────────────────────────────────────────────────
|
// ── Groups ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Response> getMyGroups(HistoryRequest request) => _ok(MockGroups.mine());
|
Future<Response> getMyGroups(HistoryRequest request) =>
|
||||||
|
_ok(MockGroups.mine());
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Response> getGroupEntry(IdRequest request) => _ok(MockGroups.one(request.id));
|
Future<Response> getGroupEntry(IdRequest request) =>
|
||||||
|
_ok(MockGroups.one(request.id));
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Response> saveGroupEntry(GroupRequest request) => _ok(MockGroups.save(request));
|
Future<Response> saveGroupEntry(GroupRequest request) =>
|
||||||
|
_ok(MockGroups.save(request));
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Response> joinGroupEntry(JoinGroupRequest request) => _ok(MockGroups.join(request));
|
Future<Response> joinGroupEntry(JoinGroupRequest request) =>
|
||||||
|
_ok(MockGroups.join(request));
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Response> leaveGroupEntry(IdRequest request) => _ok(MockGroups.leave(request.id));
|
Future<Response> leaveGroupEntry(IdRequest request) =>
|
||||||
|
_ok(MockGroups.leave(request.id));
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Response> saveGroupGoalEntry(GroupGoalRequest request) => _ok(MockGroups.saveGoal(request));
|
Future<Response> saveGroupGoalEntry(GroupGoalRequest request) =>
|
||||||
|
_ok(MockGroups.saveGoal(request));
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Response> logObjectiveProgress(ObjectiveProgressRequest request) => _ok(MockGroups.logProgress(request));
|
Future<Response> logObjectiveProgress(ObjectiveProgressRequest request) =>
|
||||||
|
_ok(MockGroups.logProgress(request));
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Response> getGroupMessages(IdRequest request) => _ok(MockGroups.messages(request.id));
|
Future<Response> getGroupMessages(IdRequest request) =>
|
||||||
|
_ok(MockGroups.messages(request.id));
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Response> sendGroupMessage(GroupMessageRequest request) => _ok(MockGroups.send(request));
|
Future<Response> sendGroupMessage(GroupMessageRequest request) =>
|
||||||
|
_ok(MockGroups.send(request));
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Response> nudgeGroupMember(NudgeRequest request) => _ok(MockGroups.nudge(request));
|
Future<Response> nudgeGroupMember(NudgeRequest request) =>
|
||||||
|
_ok(MockGroups.nudge(request));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,18 @@ class MockGroups {
|
|||||||
|
|
||||||
static final Random _random = Random(7);
|
static final Random _random = Random(7);
|
||||||
|
|
||||||
|
/// Sample people with a bundled portrait (assets/images/person_*.jpg).
|
||||||
|
static const List<String> _photos = <String>[
|
||||||
|
'alex',
|
||||||
|
'sam',
|
||||||
|
'priya',
|
||||||
|
'jordan',
|
||||||
|
'mei',
|
||||||
|
'tom',
|
||||||
|
'ade',
|
||||||
|
'lena'
|
||||||
|
];
|
||||||
|
|
||||||
static Map<String, dynamic> _member(String id, String name,
|
static Map<String, dynamic> _member(String id, String name,
|
||||||
{String role = "Member",
|
{String role = "Member",
|
||||||
double kept = 0.7,
|
double kept = 0.7,
|
||||||
@@ -44,6 +56,9 @@ class MockGroups {
|
|||||||
return <String, dynamic>{
|
return <String, dynamic>{
|
||||||
'id': id,
|
'id': id,
|
||||||
'name': name,
|
'name': name,
|
||||||
|
'photo': _photos.contains(name.toLowerCase())
|
||||||
|
? 'asset:assets/images/person_${name.toLowerCase()}.jpg'
|
||||||
|
: '',
|
||||||
'role': role,
|
'role': role,
|
||||||
'keptRate': kept,
|
'keptRate': kept,
|
||||||
'streak': streak,
|
'streak': streak,
|
||||||
@@ -55,9 +70,8 @@ class MockGroups {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
static Map<String, dynamic> _objective(
|
static Map<String, dynamic> _objective(String id, String title, String unit,
|
||||||
String id, String title, String unit, double target, double current,
|
double target, double current, double mine) {
|
||||||
double mine) {
|
|
||||||
return <String, dynamic>{
|
return <String, dynamic>{
|
||||||
'id': id,
|
'id': id,
|
||||||
'title': title,
|
'title': title,
|
||||||
@@ -105,7 +119,8 @@ class MockGroups {
|
|||||||
_member('m-sam', 'Sam',
|
_member('m-sam', 'Sam',
|
||||||
role: 'Owner', kept: 0.92, streak: 11, checkedInHoursAgo: 2),
|
role: 'Owner', kept: 0.92, streak: 11, checkedInHoursAgo: 2),
|
||||||
_member(_me, _myName, kept: 0.78, streak: 4, checkedInHoursAgo: 4),
|
_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',
|
_member('m-jordan', 'Jordan',
|
||||||
kept: 0.41, streak: 0, standing: 'Warned', checkedInHoursAgo: 52),
|
kept: 0.41, streak: 0, standing: 'Warned', checkedInHoursAgo: 52),
|
||||||
],
|
],
|
||||||
@@ -114,7 +129,8 @@ class MockGroups {
|
|||||||
'id': 'gg-run',
|
'id': 'gg-run',
|
||||||
'title': 'Run 200 km this month',
|
'title': 'Run 200 km this month',
|
||||||
'description': 'Every kilometre counts, treadmill included.',
|
'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]),
|
'colourHex': colorToHex(goalAccents[1]),
|
||||||
'objectives': <Map<String, dynamic>>[
|
'objectives': <Map<String, dynamic>>[
|
||||||
_objective('ob-km', 'Distance run', 'km', 200, 86, 22),
|
_objective('ob-km', 'Distance run', 'km', 200, 86, 22),
|
||||||
@@ -151,11 +167,14 @@ class MockGroups {
|
|||||||
'id': 'gg-drafts',
|
'id': 'gg-drafts',
|
||||||
'title': 'Full drafts submitted',
|
'title': 'Full drafts submitted',
|
||||||
'description': 'Three theses, one deadline.',
|
'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]),
|
'colourHex': colorToHex(goalAccents[3]),
|
||||||
'objectives': <Map<String, dynamic>>[
|
'objectives': <Map<String, dynamic>>[
|
||||||
_objective('ob-words', 'Words written', 'words', 30000, 18400, 7200),
|
_objective(
|
||||||
_objective('ob-chapters', 'Chapters reviewed', 'chapters', 9, 3, 1),
|
'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),
|
_objective('ob-hours', 'Deep-work hours', 'hours', 120, 64, 23),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -191,15 +210,17 @@ class MockGroups {
|
|||||||
_messages['group-run'] = <Map<String, dynamic>>[
|
_messages['group-run'] = <Map<String, dynamic>>[
|
||||||
_message('group-run', 'm-sam', 'Sam',
|
_message('group-run', 'm-sam', 'Sam',
|
||||||
'Same route tomorrow? Gate at 6:15.', 60 * 20),
|
'Same route tomorrow? Gate at 6:15.', 60 * 20),
|
||||||
_message('group-run', _me, _myName, "I'm in. Bringing Priya's spare torch.",
|
_message('group-run', _me, _myName,
|
||||||
60 * 19 + 40),
|
"I'm in. Bringing Priya's spare torch.", 60 * 19 + 40),
|
||||||
_message('group-run', 'm-priya', 'Priya', 'logged 6 km on Distance run',
|
_message('group-run', 'm-priya', 'Priya', 'logged 6 km on Distance run',
|
||||||
60 * 3, kind: 'CheckIn'),
|
60 * 3,
|
||||||
_message('group-run', 'm-sam', 'Sam', 'logged 8 km on Distance run',
|
kind: 'CheckIn'),
|
||||||
60 * 2, kind: 'CheckIn'),
|
_message(
|
||||||
|
'group-run', 'm-sam', 'Sam', 'logged 8 km on Distance run', 60 * 2,
|
||||||
|
kind: 'CheckIn'),
|
||||||
_message('group-run', 'm-sam', 'Sam',
|
_message('group-run', 'm-sam', 'Sam',
|
||||||
'nudged Jordan: two days without a run, we miss you out there',
|
'nudged Jordan: two days without a run, we miss you out there', 95,
|
||||||
95, kind: 'Nudge'),
|
kind: 'Nudge'),
|
||||||
_message('group-run', 'm-priya', 'Priya',
|
_message('group-run', 'm-priya', 'Priya',
|
||||||
'86 km down. We need about 9 a day to make it, doable.', 40),
|
'86 km down. We need about 9 a day to make it, doable.', 40),
|
||||||
];
|
];
|
||||||
@@ -208,10 +229,15 @@ class MockGroups {
|
|||||||
kind: 'System'),
|
kind: 'System'),
|
||||||
_message('group-thesis', 'm-mei', 'Mei',
|
_message('group-thesis', 'm-mei', 'Mei',
|
||||||
'Chapter 3 is with my supervisor. Terrified.', 60 * 26),
|
'Chapter 3 is with my supervisor. Terrified.', 60 * 26),
|
||||||
_message('group-thesis', _me, _myName, 'logged 1,200 words on Words written',
|
_message('group-thesis', _me, _myName,
|
||||||
60 * 22, kind: 'CheckIn'),
|
'logged 1,200 words on Words written', 60 * 22,
|
||||||
_message('group-thesis', 'm-tom', 'Tom',
|
kind: 'CheckIn'),
|
||||||
'Library 9 to 1 tomorrow if anyone wants a co-working block.', 60 * 7),
|
_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>>[
|
_messages['group-walk'] = <Map<String, dynamic>>[
|
||||||
_message('group-walk', 'm-ade', 'Ade',
|
_message('group-walk', 'm-ade', 'Ade',
|
||||||
@@ -236,7 +262,8 @@ class MockGroups {
|
|||||||
.toList();
|
.toList();
|
||||||
for (Map<String, dynamic> message in due) {
|
for (Map<String, dynamic> message in due) {
|
||||||
_pending.remove(message);
|
_pending.remove(message);
|
||||||
_messages.putIfAbsent(message['groupId'], () => <Map<String, dynamic>>[])
|
_messages
|
||||||
|
.putIfAbsent(message['groupId'], () => <Map<String, dynamic>>[])
|
||||||
.add(message);
|
.add(message);
|
||||||
final Map<String, dynamic>? group = _find(message['groupId']);
|
final Map<String, dynamic>? group = _find(message['groupId']);
|
||||||
if (group != null) {
|
if (group != null) {
|
||||||
@@ -259,8 +286,9 @@ class MockGroups {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static void _post(String groupId, String body, String kind) {
|
static void _post(String groupId, String body, String kind) {
|
||||||
_messages.putIfAbsent(groupId, () => <Map<String, dynamic>>[]).add(
|
_messages
|
||||||
_message(groupId, _me, _myName, body, 0, kind: kind));
|
.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.
|
/// Someone answers a few seconds later, so the chat feels inhabited.
|
||||||
@@ -331,8 +359,8 @@ class MockGroups {
|
|||||||
'inviteCode': _code(),
|
'inviteCode': _code(),
|
||||||
'unread': 0,
|
'unread': 0,
|
||||||
'members': <Map<String, dynamic>>[
|
'members': <Map<String, dynamic>>[
|
||||||
_member(_me, _myName, role: 'Owner', kept: 0.78, streak: 4,
|
_member(_me, _myName,
|
||||||
checkedInHoursAgo: 0),
|
role: 'Owner', kept: 0.78, streak: 4, checkedInHoursAgo: 0),
|
||||||
],
|
],
|
||||||
'goals': <Map<String, dynamic>>[],
|
'goals': <Map<String, dynamic>>[],
|
||||||
};
|
};
|
||||||
@@ -366,8 +394,8 @@ class MockGroups {
|
|||||||
'members': <Map<String, dynamic>>[],
|
'members': <Map<String, dynamic>>[],
|
||||||
'goals': <Map<String, dynamic>>[],
|
'goals': <Map<String, dynamic>>[],
|
||||||
});
|
});
|
||||||
(group['members'] as List).add(_member(_me, _myName,
|
(group['members'] as List).add(
|
||||||
kept: 0.78, streak: 4, checkedInHoursAgo: 0));
|
_member(_me, _myName, kept: 0.78, streak: 4, checkedInHoursAgo: 0));
|
||||||
_groups.insert(0, group);
|
_groups.insert(0, group);
|
||||||
_post(group['id'], 'joined the group', 'System');
|
_post(group['id'], 'joined the group', 'System');
|
||||||
_queueReply(group['id']);
|
_queueReply(group['id']);
|
||||||
@@ -422,7 +450,7 @@ class MockGroups {
|
|||||||
_post(
|
_post(
|
||||||
group['id'],
|
group['id'],
|
||||||
'logged $amount ${objective['unit']} on ${objective['title']}'
|
'logged $amount ${objective['unit']} on ${objective['title']}'
|
||||||
'${request.note.isEmpty ? "" : " — ${request.note}"}',
|
'${request.note.isEmpty ? "" : " — ${request.note}"}',
|
||||||
'CheckIn');
|
'CheckIn');
|
||||||
_queueReply(group['id']);
|
_queueReply(group['id']);
|
||||||
}
|
}
|
||||||
@@ -465,8 +493,8 @@ class MockGroups {
|
|||||||
who = member['name'];
|
who = member['name'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_post(request.groupId, 'nudged $who: today still counts, go get it',
|
_post(
|
||||||
'Nudge');
|
request.groupId, 'nudged $who: today still counts, go get it', 'Nudge');
|
||||||
return <String, dynamic>{'success': true};
|
return <String, dynamic>{'success': true};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ class MockMemory extends InternalMemory {
|
|||||||
_seeded = true;
|
_seeded = true;
|
||||||
setMyDescription(
|
setMyDescription(
|
||||||
MeDescription(id: "mock-user", name: "Alex", token: "mock-token"));
|
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>[
|
setTodos(<TodoItem>[
|
||||||
TodoItem(id: "t1", title: "Pick up gym kit"),
|
TodoItem(id: "t1", title: "Pick up gym kit"),
|
||||||
TodoItem(id: "t2", title: "Book physio for Thursday"),
|
TodoItem(id: "t2", title: "Book physio for Thursday"),
|
||||||
@@ -51,8 +52,9 @@ class MockMemory extends InternalMemory {
|
|||||||
title: title,
|
title: title,
|
||||||
kind: kind,
|
kind: kind,
|
||||||
startedAt: at,
|
startedAt: at,
|
||||||
seconds: (seconds * (held ? 1 : 0.5) * (0.85 + random.nextDouble() * 0.3))
|
seconds:
|
||||||
.round(),
|
(seconds * (held ? 1 : 0.5) * (0.85 + random.nextDouble() * 0.3))
|
||||||
|
.round(),
|
||||||
completedSteps: held ? steps : 1 + random.nextInt(steps - 1),
|
completedSteps: held ? steps : 1 + random.nextInt(steps - 1),
|
||||||
totalSteps: steps,
|
totalSteps: steps,
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ class MockStore {
|
|||||||
{
|
{
|
||||||
'id': 'goal-strong',
|
'id': 'goal-strong',
|
||||||
'title': 'Get strong',
|
'title': 'Get strong',
|
||||||
'description': 'Deadlift my bodyweight by spring, without getting hurt.',
|
'description':
|
||||||
|
'Deadlift my bodyweight by spring, without getting hurt.',
|
||||||
'category': 'Health',
|
'category': 'Health',
|
||||||
'defaultClass': 'Standard',
|
'defaultClass': 'Standard',
|
||||||
'startDate': at(-30, 0).toIso8601String(),
|
'startDate': at(-30, 0).toIso8601String(),
|
||||||
@@ -145,10 +146,7 @@ class MockStore {
|
|||||||
minutes: 60,
|
minutes: 60,
|
||||||
category: 'Deep work'),
|
category: 'Deep work'),
|
||||||
task('c-meal', 'Meal prep for the week',
|
task('c-meal', 'Meal prep for the week',
|
||||||
goal: 'goal-strong',
|
goal: 'goal-strong', start: at(1, 17), end: at(1, 20), minutes: 90),
|
||||||
start: at(1, 17),
|
|
||||||
end: at(1, 20),
|
|
||||||
minutes: 90),
|
|
||||||
// Two things already past their window, so the overdue queue and debt
|
// Two things already past their window, so the overdue queue and debt
|
||||||
// have something to show.
|
// have something to show.
|
||||||
task('c-email', 'Email supervisor the outline',
|
task('c-email', 'Email supervisor the outline',
|
||||||
@@ -159,10 +157,7 @@ class MockStore {
|
|||||||
status: 'Overdue',
|
status: 'Overdue',
|
||||||
deferrals: 1),
|
deferrals: 1),
|
||||||
task('c-kitchen', 'Deep clean the kitchen',
|
task('c-kitchen', 'Deep clean the kitchen',
|
||||||
start: at(-1, 18),
|
start: at(-1, 18), end: at(-1, 21), minutes: 45, status: 'Overdue'),
|
||||||
end: at(-1, 21),
|
|
||||||
minutes: 45,
|
|
||||||
status: 'Overdue'),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// ── Routines ───────────────────────────────────────────────────────────
|
// ── Routines ───────────────────────────────────────────────────────────
|
||||||
@@ -175,7 +170,10 @@ class MockStore {
|
|||||||
final RoutineChain push = RoutineTemplates.upperPush()
|
final RoutineChain push = RoutineTemplates.upperPush()
|
||||||
..id = 'routine-push'
|
..id = 'routine-push'
|
||||||
..goalId = 'goal-strong'
|
..goalId = 'goal-strong'
|
||||||
..days = <int>[1, 2, 3, 4, 5, 6, 7].where((day) => day != weekday).take(2).toList();
|
..days = <int>[1, 2, 3, 4, 5, 6, 7]
|
||||||
|
.where((day) => day != weekday)
|
||||||
|
.take(2)
|
||||||
|
.toList();
|
||||||
|
|
||||||
final RoutineChain breathing = RoutineTemplates.boxBreathing()
|
final RoutineChain breathing = RoutineTemplates.boxBreathing()
|
||||||
..id = 'routine-breathing'
|
..id = 'routine-breathing'
|
||||||
@@ -262,12 +260,14 @@ class MockStore {
|
|||||||
final List<Map<String, dynamic>> tasks = goalTasks(goal['id']);
|
final List<Map<String, dynamic>> tasks = goalTasks(goal['id']);
|
||||||
final int completed = tasks
|
final int completed = tasks
|
||||||
.where((item) =>
|
.where((item) =>
|
||||||
item['status'] == 'Completed' || item['status'] == 'LateCompleted')
|
item['status'] == 'Completed' ||
|
||||||
|
item['status'] == 'LateCompleted')
|
||||||
.length;
|
.length;
|
||||||
final int late =
|
final int late =
|
||||||
tasks.where((item) => !_settled(item) && _windowClosed(item)).length;
|
tasks.where((item) => !_settled(item) && _windowClosed(item)).length;
|
||||||
return Map<String, dynamic>.from(goal)
|
return Map<String, dynamic>.from(goal)
|
||||||
..['totalTasks'] = tasks.where((item) => item['status'] != 'Abandoned').length
|
..['totalTasks'] =
|
||||||
|
tasks.where((item) => item['status'] != 'Abandoned').length
|
||||||
..['completedTasks'] = completed
|
..['completedTasks'] = completed
|
||||||
..['overdueTasks'] = late
|
..['overdueTasks'] = late
|
||||||
..['debtContribution'] = late * 2.0;
|
..['debtContribution'] = late * 2.0;
|
||||||
|
|||||||
@@ -98,6 +98,16 @@ class GroupChatState extends State<GroupChat> implements ConnectGroupChat {
|
|||||||
_onSend(reply);
|
_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 ─────────────────────────────────────────────────────────────────
|
// ── Views ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
Widget _mobileView(BoxConstraints constraints) {
|
Widget _mobileView(BoxConstraints constraints) {
|
||||||
@@ -145,8 +155,7 @@ class GroupChatState extends State<GroupChat> implements ConnectGroupChat {
|
|||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius:
|
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(20)),
|
||||||
const BorderRadius.vertical(bottom: Radius.circular(20)),
|
|
||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
@@ -188,7 +197,10 @@ class GroupChatState extends State<GroupChat> implements ConnectGroupChat {
|
|||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
avatarStack(
|
avatarStack(
|
||||||
widget.group.members.map((member) => member.name).toList(), 30,
|
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<GroupChat> implements ConnectGroupChat {
|
|||||||
children: [
|
children: [
|
||||||
grouped
|
grouped
|
||||||
? const SizedBox(width: 34)
|
? const SizedBox(width: 34)
|
||||||
: memberAvatar(message.authorName, 34),
|
: memberAvatar(message.authorName, 34,
|
||||||
|
photo: _photoOf(message.authorId)),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Flexible(
|
Flexible(
|
||||||
child: Container(
|
child: Container(
|
||||||
@@ -328,7 +341,8 @@ class GroupChatState extends State<GroupChat> implements ConnectGroupChat {
|
|||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
memberAvatar(message.authorName, 34),
|
memberAvatar(message.authorName, 34,
|
||||||
|
photo: _photoOf(message.authorId)),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|||||||
@@ -329,7 +329,8 @@ class GroupDetailState extends State<GroupDetail>
|
|||||||
horizontal: 7, vertical: 3),
|
horizontal: 7, vertical: 3),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colorLime,
|
color: colorLime,
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
borderRadius:
|
||||||
|
const BorderRadius.all(Radius.circular(8)),
|
||||||
),
|
),
|
||||||
child: text(
|
child: text(
|
||||||
"${_group.unread} new", 10, TextType.Medium,
|
"${_group.unread} new", 10, TextType.Medium,
|
||||||
@@ -519,7 +520,7 @@ class GroupDetailState extends State<GroupDetail>
|
|||||||
child: text("$rank", 16, TextType.Medium,
|
child: text("$rank", 16, TextType.Medium,
|
||||||
color: rank == 1 ? colorInk : colorGrey2),
|
color: rank == 1 ? colorInk : colorGrey2),
|
||||||
),
|
),
|
||||||
memberAvatar(member.name, 46),
|
memberAvatar(member.name, 46, photo: member.photo),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -632,7 +633,8 @@ class GroupDetailState extends State<GroupDetail>
|
|||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colorAmbientMid,
|
color: colorAmbientMid,
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
borderRadius:
|
||||||
|
const BorderRadius.all(Radius.circular(12)),
|
||||||
),
|
),
|
||||||
child: text(
|
child: text(
|
||||||
"+${formatAmount(preset)}", 15, TextType.Medium,
|
"+${formatAmount(preset)}", 15, TextType.Medium,
|
||||||
|
|||||||
@@ -245,7 +245,7 @@ class GroupEditorState extends State<GroupEditor>
|
|||||||
text(name, 26, TextType.Light,
|
text(name, 26, TextType.Light,
|
||||||
color: colorWhite, maxLines: 2, overflow: TextOverflow.ellipsis),
|
color: colorWhite, maxLines: 2, overflow: TextOverflow.ellipsis),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
avatarStack(const <String>["Alex"], 34),
|
avatarStack(const <String>["You"], 34),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -180,7 +180,8 @@ class GroupsState extends State<Groups> implements ConnectGroups {
|
|||||||
horizontal: 10, vertical: 6),
|
horizontal: 10, vertical: 6),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colorLime,
|
color: colorLime,
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
borderRadius:
|
||||||
|
const BorderRadius.all(Radius.circular(8)),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@@ -218,7 +219,10 @@ class GroupsState extends State<Groups> implements ConnectGroups {
|
|||||||
children: [
|
children: [
|
||||||
avatarStack(
|
avatarStack(
|
||||||
group.members.map((member) => member.name).toList(),
|
group.members.map((member) => member.name).toList(),
|
||||||
32),
|
32,
|
||||||
|
photos: group.members
|
||||||
|
.map((member) => member.photo)
|
||||||
|
.toList()),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
text("${(group.progress * 100).round()}%", 22,
|
text("${(group.progress * 100).round()}%", 22,
|
||||||
TextType.Light,
|
TextType.Light,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import '../../designs/Component.dart';
|
|||||||
import '../../designs/FloatingNav.dart';
|
import '../../designs/FloatingNav.dart';
|
||||||
import '../../designs/GlassSurface.dart';
|
import '../../designs/GlassSurface.dart';
|
||||||
import '../../designs/MemberAvatar.dart';
|
import '../../designs/MemberAvatar.dart';
|
||||||
|
import '../../designs/Portrait.dart';
|
||||||
import '../../designs/ProgressRing.dart';
|
import '../../designs/ProgressRing.dart';
|
||||||
import '../../designs/Responsive.dart';
|
import '../../designs/Responsive.dart';
|
||||||
import '../../designs/Shell.dart';
|
import '../../designs/Shell.dart';
|
||||||
@@ -35,6 +36,7 @@ import '../../designs/input/InputFields.dart';
|
|||||||
import '../../designs/scene/SceneArt.dart';
|
import '../../designs/scene/SceneArt.dart';
|
||||||
import '../../designs/text/Text.dart';
|
import '../../designs/text/Text.dart';
|
||||||
import '../../utils/Colors.dart';
|
import '../../utils/Colors.dart';
|
||||||
|
import '../../utils/PhotoPicker.dart';
|
||||||
import '../../utils/CommonUtils.dart';
|
import '../../utils/CommonUtils.dart';
|
||||||
import '../../utils/StandingEngine.dart';
|
import '../../utils/StandingEngine.dart';
|
||||||
import '../../utils/Thresholds.dart';
|
import '../../utils/Thresholds.dart';
|
||||||
@@ -247,6 +249,13 @@ class HomeState extends State<Home> implements ConnectHome {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onChangePhoto() async {
|
||||||
|
final String? pic = await PhotoPicker.pickPortrait();
|
||||||
|
if (pic != null) {
|
||||||
|
_model?.savePhoto(pic);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _onOpenSettings() {
|
void _onOpenSettings() {
|
||||||
GroundedNavigation()
|
GroundedNavigation()
|
||||||
.navigateToPage(NavigatorType.justOpen, const Settings(), context);
|
.navigateToPage(NavigatorType.justOpen, const Settings(), context);
|
||||||
@@ -1194,28 +1203,11 @@ class HomeState extends State<Home> implements ConnectHome {
|
|||||||
// ── Header helpers ────────────────────────────────────────────────────────
|
// ── Header helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// A portrait cut from the painted landscape, with the initial on it.
|
/// A portrait cut from the painted landscape, with the initial on it.
|
||||||
|
/// Your photo. Tapping it picks a new one.
|
||||||
Widget _avatar(double size) {
|
Widget _avatar(double size) {
|
||||||
final String initial =
|
return GestureDetector(
|
||||||
_user.name.isEmpty ? "G" : _user.name.substring(0, 1).toUpperCase();
|
onTap: _onChangePhoto,
|
||||||
|
child: portrait(_user.pic, _user.name, size),
|
||||||
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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1705,7 +1697,10 @@ class HomeState extends State<Home> implements ConnectHome {
|
|||||||
children: [
|
children: [
|
||||||
avatarStack(
|
avatarStack(
|
||||||
group.members.map((member) => member.name).toList(),
|
group.members.map((member) => member.name).toList(),
|
||||||
26),
|
26,
|
||||||
|
photos: group.members
|
||||||
|
.map((member) => member.photo)
|
||||||
|
.toList()),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
Icon(CupertinoIcons.chat_bubble_2_fill,
|
Icon(CupertinoIcons.chat_bubble_2_fill,
|
||||||
size: 16, color: colorInkSoft),
|
size: 16, color: colorInkSoft),
|
||||||
|
|||||||
@@ -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 {
|
void loadTodos() async {
|
||||||
connection.onTodosLoaded(await getDataManager().getTodos());
|
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
|
/// Reminders are one-shots for the coming week, so they are re-placed
|
||||||
/// whenever the schedule is read.
|
/// whenever the schedule is read.
|
||||||
void _placeReminders(List<RoutineChain> routines) async {
|
void _placeReminders(List<RoutineChain> routines) async {
|
||||||
final ProgressTargets targets =
|
final ProgressTargets targets = await getDataManager().getProgressTargets();
|
||||||
await getDataManager().getProgressTargets();
|
|
||||||
if (targets.reminders) {
|
if (targets.reminders) {
|
||||||
await LocalNotificationEngine.scheduleRoutineReminders(
|
await LocalNotificationEngine.scheduleRoutineReminders(
|
||||||
routines, targets.reminderLead);
|
routines, targets.reminderLead);
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ import '../../about/internal/application/ToneLevel.dart';
|
|||||||
import '../../about/internal/application/UserDetails.dart';
|
import '../../about/internal/application/UserDetails.dart';
|
||||||
import '../../configs/Navigator.dart';
|
import '../../configs/Navigator.dart';
|
||||||
import '../../designs/Component.dart';
|
import '../../designs/Component.dart';
|
||||||
|
import '../../designs/Portrait.dart';
|
||||||
import '../../designs/Responsive.dart';
|
import '../../designs/Responsive.dart';
|
||||||
import '../../designs/Shell.dart';
|
import '../../designs/Shell.dart';
|
||||||
import '../../designs/buttons/Buttons.dart';
|
import '../../designs/buttons/Buttons.dart';
|
||||||
import '../../designs/input/InputFields.dart';
|
import '../../designs/input/InputFields.dart';
|
||||||
import '../../designs/text/Text.dart';
|
import '../../designs/text/Text.dart';
|
||||||
import '../../utils/Colors.dart';
|
import '../../utils/Colors.dart';
|
||||||
|
import '../../utils/PhotoPicker.dart';
|
||||||
import '../../utils/Thresholds.dart';
|
import '../../utils/Thresholds.dart';
|
||||||
import '../../utils/Validators.dart';
|
import '../../utils/Validators.dart';
|
||||||
import '../login/Login.dart';
|
import '../login/Login.dart';
|
||||||
@@ -59,9 +61,19 @@ class SettingsState extends State<Settings> implements ConnectSettings {
|
|||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onChangePhoto() async {
|
||||||
|
final String? pic = await PhotoPicker.pickPortrait();
|
||||||
|
if (pic != null) {
|
||||||
|
_model?.savePhoto(pic);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onRemovePhoto() {
|
||||||
|
_model?.savePhoto("");
|
||||||
|
}
|
||||||
|
|
||||||
void _onOpenTargets() {
|
void _onOpenTargets() {
|
||||||
GroundedNavigation()
|
GroundedNavigation().navigateToPageWithData(const TargetSetup(), context);
|
||||||
.navigateToPageWithData(const TargetSetup(), context);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onToneSelected(ToneLevel tone) {
|
void _onToneSelected(ToneLevel tone) {
|
||||||
@@ -184,6 +196,50 @@ class SettingsState extends State<Settings> implements ConnectSettings {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
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"),
|
sectionBreak("Tone", caption: "how it speaks to you"),
|
||||||
segmentedSelector<ToneLevel>(
|
segmentedSelector<ToneLevel>(
|
||||||
options: ToneLevel.values,
|
options: ToneLevel.values,
|
||||||
@@ -270,8 +326,7 @@ class SettingsState extends State<Settings> implements ConnectSettings {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Icon(CupertinoIcons.chevron_right,
|
Icon(CupertinoIcons.chevron_right, size: 18, color: colorGrey2),
|
||||||
size: 18, color: colorGrey2),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -327,8 +382,8 @@ class SettingsState extends State<Settings> implements ConnectSettings {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
Center(
|
Center(
|
||||||
child: text("Grounded 1.0.0", 11, TextType.Regular,
|
child:
|
||||||
color: colorGrey),
|
text("Grounded 1.0.0", 11, TextType.Regular, color: colorGrey),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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.
|
/// The tone slider is capped: it changes register, never cruelty.
|
||||||
void changeTone(ToneLevel tone) async {
|
void changeTone(ToneLevel tone) async {
|
||||||
if (!await hasNetwork(() => changeTone(tone))) return;
|
if (!await hasNetwork(() => changeTone(tone))) return;
|
||||||
@@ -66,8 +74,8 @@ class ViewSettings extends ParentViewModel {
|
|||||||
|
|
||||||
connection.onSickModeChanged(details);
|
connection.onSickModeChanged(details);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
handleError(e, () => setSickMode(enabled, reason), () => dismissError(),
|
handleError(
|
||||||
"Retry");
|
e, () => setSickMode(enabled, reason), () => dismissError(), "Retry");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
39
frontend/lib/Grounded/utils/PhotoPicker.dart
Normal file
@@ -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<String?> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||