From 1c2b3d5e8d99a6a9d33c49d91e56daed5bcac536 Mon Sep 17 00:00:00 2001 From: vjrj Date: Sun, 12 Jul 2026 12:49:06 +0200 Subject: [PATCH] feat(profile): generated default avatars (boring-avatars beam) from pubkey People without a photo or seed illustration now get a deterministic pattern avatar drawn from their pubkey (flutter_boring_avatars, MIT), in an earthy seed-tone palette, replacing the coloured-initial disc. Same pubkey renders the same picture on every device with nothing shared. --- apps/app_seeds/lib/ui/avatar.dart | 2 +- apps/app_seeds/lib/ui/peer_avatar.dart | 56 ++++++++++--------- apps/app_seeds/pubspec.yaml | 1 + .../test/ui/chat_message_list_test.dart | 11 ++-- apps/app_seeds/test/ui/peer_avatar_test.dart | 26 +++++---- pubspec.lock | 8 +++ 6 files changed, 59 insertions(+), 45 deletions(-) diff --git a/apps/app_seeds/lib/ui/avatar.dart b/apps/app_seeds/lib/ui/avatar.dart index ee3c570..3c2e29a 100644 --- a/apps/app_seeds/lib/ui/avatar.dart +++ b/apps/app_seeds/lib/ui/avatar.dart @@ -6,7 +6,7 @@ import 'seed_glyph.dart'; /// media server, like offer photos); /// - `tane:seed:` — one of our own seed illustrations (for people who'd /// rather not put a real photo — pseudonymity stays the default); -/// - empty — the deterministic coloured disc with the name's initial. +/// - empty — a pattern avatar generated deterministically from the pubkey. const avatarGlyphPrefix = 'tane:seed:'; /// The seed illustrations offered as ready-made avatars. Names are the stable diff --git a/apps/app_seeds/lib/ui/peer_avatar.dart b/apps/app_seeds/lib/ui/peer_avatar.dart index 3dcce9b..7f1b60c 100644 --- a/apps/app_seeds/lib/ui/peer_avatar.dart +++ b/apps/app_seeds/lib/ui/peer_avatar.dart @@ -1,15 +1,27 @@ import 'package:flutter/material.dart'; +import 'package:flutter_boring_avatars/flutter_boring_avatars.dart'; import '../services/offer_thumbnail.dart' show decodeDataUri; import '../services/profile_cache.dart'; import 'avatar.dart'; import 'seed_glyph.dart'; +import 'theme.dart'; + +/// Palette for the generated default avatars — earthy seed-and-leaf tones so +/// the fallback art sits inside the app's palette rather than fighting it. +const _generatedAvatarPalette = BoringAvatarPalette([ + seedGreen, + seedRating, // warm amber + Color(0xFFB5541F), // terracotta pot + Color(0xFF6B4F2A), // seed-coat brown + Color(0xFFD3E8C8), // pale leaf +]); /// A small round avatar for a person. When they've set an avatar ([picture] — a /// `data:` photo thumbnail or a `tane:seed:` illustration) it's shown; -/// otherwise it falls back to a deterministic disc coloured from their [pubkey], -/// with the first letter of their [name] (else a person glyph). Same pubkey → -/// same colour on every device, so a person stays visually recognizable. +/// otherwise it falls back to a generated pattern drawn deterministically from +/// their [pubkey] (boring-avatars "beam"). Same pubkey → same picture on every +/// device, so a person stays visually recognizable without sharing anything. class PeerAvatar extends StatelessWidget { const PeerAvatar({ required this.pubkey, @@ -46,35 +58,25 @@ class PeerAvatar extends StatelessWidget { } } - final letter = _initial(name); - return CircleAvatar( - radius: radius, - backgroundColor: peerAvatarColor(pubkey), - child: letter == null - ? Icon(Icons.person_outline, size: radius, color: Colors.white) - : Text( - letter, - style: TextStyle( - color: Colors.white, - fontSize: radius, - fontWeight: FontWeight.w600, - ), - ), + return Semantics( + label: name, + image: true, + child: SizedBox.square( + dimension: radius * 2, + child: BoringAvatar( + name: pubkey, + type: BoringAvatarType.beam, + palette: _generatedAvatarPalette, + shape: const OvalBorder(), + ), + ), ); } - - static String? _initial(String? name) { - if (name == null) return null; - final trimmed = name.trim(); - if (trimmed.isEmpty) return null; - // characters.first handles emoji/combining marks safely. - return trimmed.characters.first.toUpperCase(); - } } /// A [PeerAvatar] that looks the person's published avatar up from the -/// [ProfileCache] (their kind:0 `picture`), falling back to the coloured-initial -/// disc while it loads or when none is cached / no cache is available. Use at +/// [ProfileCache] (their kind:0 `picture`), falling back to the generated +/// pattern while it loads or when none is cached / no cache is available. Use at /// list/row sites (one avatar each); for many avatars of the same few people /// (chat bubbles) resolve the picture once into state instead. class CachedAvatar extends StatelessWidget { diff --git a/apps/app_seeds/pubspec.yaml b/apps/app_seeds/pubspec.yaml index 525d7bd..a9a6c84 100644 --- a/apps/app_seeds/pubspec.yaml +++ b/apps/app_seeds/pubspec.yaml @@ -78,6 +78,7 @@ dependencies: # while the app is foregrounded. Mobile/Linux/macOS only; a no-op on web and # Windows. Foreground-only by design — background/push is a later concern. flutter_local_notifications: ^18.0.1 + flutter_boring_avatars: ^2.1.0 dev_dependencies: flutter_test: diff --git a/apps/app_seeds/test/ui/chat_message_list_test.dart b/apps/app_seeds/test/ui/chat_message_list_test.dart index 2eea2a2..3e07e59 100644 --- a/apps/app_seeds/test/ui/chat_message_list_test.dart +++ b/apps/app_seeds/test/ui/chat_message_list_test.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:commons_core/commons_core.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_boring_avatars/flutter_boring_avatars.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:tane/domain/chat_timeline.dart'; @@ -124,14 +125,14 @@ void main() { await tester.pumpWidget(host(cubit)); await tester.pump(); - // Both sides carry an avatar, and the two people are different colours — + // Both sides carry an avatar, each generated from its own pubkey — // so you can tell who said what at a glance. expect(find.byType(PeerAvatar), findsNWidgets(2)); - final discs = tester - .widgetList(find.byType(CircleAvatar)) + final generated = tester + .widgetList(find.byType(BoringAvatar)) .toList(); - expect(discs, hasLength(2)); - expect(discs.first.backgroundColor, isNot(discs.last.backgroundColor)); + expect(generated, hasLength(2)); + expect(generated.first.name, isNot(generated.last.name)); }); testWidgets('a new message lands in view at the bottom, not below the fold', ( diff --git a/apps/app_seeds/test/ui/peer_avatar_test.dart b/apps/app_seeds/test/ui/peer_avatar_test.dart index 7dc0485..00515dd 100644 --- a/apps/app_seeds/test/ui/peer_avatar_test.dart +++ b/apps/app_seeds/test/ui/peer_avatar_test.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_boring_avatars/flutter_boring_avatars.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:tane/services/profile_cache.dart'; import 'package:tane/ui/avatar.dart'; @@ -37,19 +38,20 @@ void main() { home: Scaffold(body: Center(child: child)), ); - testWidgets('shows the name initial when known', (tester) async { - await tester.pumpWidget(host(PeerAvatar(pubkey: 'aa', name: 'rosa'))); - expect(find.text('R'), findsOneWidget); // uppercased first letter - }); - - testWidgets('falls back to a glyph when the name is unknown', ( + testWidgets('falls back to a generated pattern keyed by pubkey', ( tester, ) async { - await tester.pumpWidget(host(const PeerAvatar(pubkey: 'aa'))); - expect(find.byIcon(Icons.person_outline), findsOneWidget); + await tester.pumpWidget(host(PeerAvatar(pubkey: 'aa', name: 'rosa'))); + final generated = tester.widget(find.byType(BoringAvatar)); + expect(generated.name, 'aa'); // pubkey, not name → stable across renames expect(find.byType(Text), findsNothing); }); + testWidgets('generates it even when the name is unknown', (tester) async { + await tester.pumpWidget(host(const PeerAvatar(pubkey: 'aa'))); + expect(find.byType(BoringAvatar), findsOneWidget); + }); + testWidgets('renders a seed illustration when the avatar is a token', ( tester, ) async { @@ -74,19 +76,19 @@ void main() { expect(find.byType(SeedGlyph), findsOneWidget); }); - testWidgets('falls back to the initial when nothing is cached', ( + testWidgets('falls back to the generated pattern when nothing is cached', ( tester, ) async { final cache = ProfileCache(InMemorySecretStore()); await tester.pumpWidget(host( CachedAvatar(pubkey: 'peer2', name: 'Bea', cache: cache))); await tester.pumpAndSettle(); - expect(find.text('B'), findsOneWidget); + expect(find.byType(BoringAvatar), findsOneWidget); }); - testWidgets('no cache → the coloured-initial disc', (tester) async { + testWidgets('no cache → the generated pattern', (tester) async { await tester.pumpWidget(host(const CachedAvatar(pubkey: 'x', name: 'Bea'))); - expect(find.text('B'), findsOneWidget); + expect(find.byType(BoringAvatar), findsOneWidget); }); }); } diff --git a/pubspec.lock b/pubspec.lock index 85554da..e9a3972 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -414,6 +414,14 @@ packages: url: "https://pub.dev" source: hosted version: "9.1.1" + flutter_boring_avatars: + dependency: transitive + description: + name: flutter_boring_avatars + sha256: c4439a5e6753db58debd8d85a92e9747727e5f5c05cc1f3a252ba70d2c1df720 + url: "https://pub.dev" + source: hosted + version: "2.1.0" flutter_driver: dependency: transitive description: flutter