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

@@ -98,6 +98,16 @@ class GroupChatState extends State<GroupChat> 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<GroupChat> 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<GroupChat> 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<GroupChat> 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<GroupChat> implements ConnectGroupChat {
),
child: Row(
children: [
memberAvatar(message.authorName, 34),
memberAvatar(message.authorName, 34,
photo: _photoOf(message.authorId)),
const SizedBox(width: 12),
Expanded(
child: Column(

View File

@@ -329,7 +329,8 @@ class GroupDetailState extends State<GroupDetail>
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<GroupDetail>
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<GroupDetail>
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,

View File

@@ -245,7 +245,7 @@ class GroupEditorState extends State<GroupEditor>
text(name, 26, TextType.Light,
color: colorWhite, maxLines: 2, overflow: TextOverflow.ellipsis),
const SizedBox(height: 16),
avatarStack(const <String>["Alex"], 34),
avatarStack(const <String>["You"], 34),
],
),
);

View File

@@ -180,7 +180,8 @@ class GroupsState extends State<Groups> 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<Groups> 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,

View File

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

View File

@@ -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<RoutineChain> routines) async {
final ProgressTargets targets =
await getDataManager().getProgressTargets();
final ProgressTargets targets = await getDataManager().getProgressTargets();
if (targets.reminders) {
await LocalNotificationEngine.scheduleRoutineReminders(
routines, targets.reminderLead);

View File

@@ -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<Settings> 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<Settings> 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<ToneLevel>(
options: ToneLevel.values,
@@ -270,8 +326,7 @@ class SettingsState extends State<Settings> 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<Settings> 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),
),
],
),

View File

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