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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
56
frontend/lib/Grounded/designs/Portrait.dart
Normal file
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: child ??
|
||||
text("${(clamped * 100).round()}%", size * 0.22,
|
||||
TextType.Bold,
|
||||
text(
|
||||
"${(clamped * 100).round()}%", size * 0.22, TextType.Bold,
|
||||
color: colorPrimaryDark),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user