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
This commit is contained in:
alvocool
2026-11-09 08:22:41 +03:00
parent 72786701a9
commit 1b5d105400
33 changed files with 406 additions and 168 deletions

View File

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

View File

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

View File

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

View File

@@ -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<List<Color>> palettes = <List<Color>>[
sceneWorkout,
sceneMeditation,
sceneMustDo,
];
final int seed = sceneSeed(name.isEmpty ? "?" : name);
final String initial =
name.isEmpty ? "?" : name.substring(0, 1).toUpperCase();
return Container(
width: size,
height: size,
decoration: BoxDecoration(
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<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 int extra = names.length - shown.length;
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++)
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(

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

View File

@@ -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),
),
);

View File

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

View File

@@ -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:

View File

@@ -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),
),
),
],