diff --git a/apps/app_seeds/lib/ui/chat_list_screen.dart b/apps/app_seeds/lib/ui/chat_list_screen.dart index 4754c3e..ea4fdc6 100644 --- a/apps/app_seeds/lib/ui/chat_list_screen.dart +++ b/apps/app_seeds/lib/ui/chat_list_screen.dart @@ -7,6 +7,7 @@ import '../i18n/strings.g.dart'; import '../services/inbox_service.dart'; import '../services/message_store.dart'; import '../services/profile_cache.dart'; +import 'peer_avatar.dart'; import '../services/social_connection.dart'; import 'theme.dart'; import 'unread_badge.dart'; @@ -119,9 +120,11 @@ class _ChatListScreenState extends State { return ListTile( leading: UnreadBadge( peer: c.peerPubkey, - child: const CircleAvatar( - backgroundColor: seedAvatar, - child: Icon(Icons.person, color: seedOnAvatar), + child: CachedAvatar( + pubkey: c.peerPubkey, + name: _names[c.peerPubkey], + cache: widget.profileCache, + radius: 20, ), ), title: Text(_names[c.peerPubkey] ?? diff --git a/apps/app_seeds/lib/ui/chat_screen.dart b/apps/app_seeds/lib/ui/chat_screen.dart index 541765e..d71f209 100644 --- a/apps/app_seeds/lib/ui/chat_screen.dart +++ b/apps/app_seeds/lib/ui/chat_screen.dart @@ -60,6 +60,8 @@ class _ChatScreenState extends State { bool _loading = true; String? _peerName; String? _selfName; + String? _peerPicture; + String? _selfPicture; String? _peerG1; final _input = TextEditingController(); @@ -86,10 +88,13 @@ class _ChatScreenState extends State { // connected / unreachable) → null transports and the screen degrades. final session = await widget.connection.session(); final cachedName = await widget.profileCache?.name(widget.peerPubkey); - // My own display name, so my avatar shows my initial (not a bare glyph). - final selfName = getIt.isRegistered() - ? await getIt().name() - : ''; + final cachedPicture = await widget.profileCache?.picture(widget.peerPubkey); + // My own name + avatar, so my side shows me (not a bare glyph). + final selfStore = getIt.isRegistered() + ? getIt() + : null; + final selfName = selfStore == null ? '' : await selfStore.name(); + final selfAvatar = selfStore == null ? '' : await selfStore.avatar(); if (!mounted) return; // shared session is owned by the connection, not us final self = widget.social.publicKeyHex; final messages = MessagesCubit( @@ -117,6 +122,8 @@ class _ChatScreenState extends State { _rating = rating; _peerName = cachedName; _selfName = selfName.isEmpty ? null : selfName; + _peerPicture = cachedPicture; + _selfPicture = selfAvatar.isEmpty ? null : selfAvatar; _loading = false; }); @@ -133,8 +140,15 @@ class _ChatScreenState extends State { profile.name, ); } + if (profile.picture.isNotEmpty) { + await widget.profileCache!.setPicture( + widget.peerPubkey, + profile.picture, + ); + } setState(() { if (profile.name.isNotEmpty) _peerName = profile.name; + if (profile.picture.isNotEmpty) _peerPicture = profile.picture; if (profile.g1.isNotEmpty) _peerG1 = profile.g1; }); } @@ -230,6 +244,8 @@ class _ChatScreenState extends State { onSend: _send, peerName: _peerName, selfName: _selfName, + peerPicture: _peerPicture, + selfPicture: _selfPicture, ), ), ); @@ -242,12 +258,16 @@ class _ChatBody extends StatelessWidget { required this.onSend, this.peerName, this.selfName, + this.peerPicture, + this.selfPicture, }); final TextEditingController controller; final Future Function() onSend; final String? peerName; final String? selfName; + final String? peerPicture; + final String? selfPicture; @override Widget build(BuildContext context) { @@ -278,7 +298,12 @@ class _ChatBody extends StatelessWidget { const _TrustBanner(), const _RatingStrip(), Expanded( - child: ChatMessageList(peerName: peerName, selfName: selfName), + child: ChatMessageList( + peerName: peerName, + selfName: selfName, + peerPicture: peerPicture, + selfPicture: selfPicture, + ), ), _Composer(controller: controller, onSend: onSend), ], @@ -292,7 +317,13 @@ class _ChatBody extends StatelessWidget { /// in place instead of landing below the fold. Reads the [MessagesCubit] from /// context. class ChatMessageList extends StatelessWidget { - const ChatMessageList({this.peerName, this.selfName, super.key}); + const ChatMessageList({ + this.peerName, + this.selfName, + this.peerPicture, + this.selfPicture, + super.key, + }); /// The peer's display name, for their avatar's initial (null → a glyph). final String? peerName; @@ -300,6 +331,10 @@ class ChatMessageList extends StatelessWidget { /// My own display name, for my avatar's initial (null → a glyph). final String? selfName; + /// The peer's / my published avatar (null → the coloured-initial disc). + final String? peerPicture; + final String? selfPicture; + @override Widget build(BuildContext context) { final t = context.t; @@ -329,6 +364,8 @@ class ChatMessageList extends StatelessWidget { mine: cubit.isMine(message), peerName: peerName, selfName: selfName, + peerPicture: peerPicture, + selfPicture: selfPicture, selfPubkey: cubit.selfPubkey, ), }; @@ -494,6 +531,8 @@ class _MessageRow extends StatelessWidget { required this.mine, this.peerName, this.selfName, + this.peerPicture, + this.selfPicture, this.selfPubkey, }); @@ -501,17 +540,27 @@ class _MessageRow extends StatelessWidget { final bool mine; final String? peerName; final String? selfName; + final String? peerPicture; + final String? selfPicture; /// My own pubkey, so my messages carry my (distinctly coloured) avatar. final String? selfPubkey; @override Widget build(BuildContext context) { - // Each avatar shows its owner's initial (falling back to a glyph when no - // name is known) on a colour derived from their pubkey. + // Each avatar shows its owner's photo/illustration when set, else their + // initial (or a glyph) on a colour derived from their pubkey. final avatar = mine - ? PeerAvatar(pubkey: selfPubkey ?? message.fromPubkey, name: selfName) - : PeerAvatar(pubkey: message.fromPubkey, name: peerName); + ? PeerAvatar( + pubkey: selfPubkey ?? message.fromPubkey, + name: selfName, + picture: selfPicture, + ) + : PeerAvatar( + pubkey: message.fromPubkey, + name: peerName, + picture: peerPicture, + ); final bubble = Flexible( child: _Bubble(message: message, mine: mine), ); diff --git a/apps/app_seeds/lib/ui/peer_avatar.dart b/apps/app_seeds/lib/ui/peer_avatar.dart index 702395f..3dcce9b 100644 --- a/apps/app_seeds/lib/ui/peer_avatar.dart +++ b/apps/app_seeds/lib/ui/peer_avatar.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../services/offer_thumbnail.dart' show decodeDataUri; +import '../services/profile_cache.dart'; import 'avatar.dart'; import 'seed_glyph.dart'; @@ -71,6 +72,43 @@ class PeerAvatar extends StatelessWidget { } } +/// 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 +/// 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 { + const CachedAvatar({ + required this.pubkey, + this.name, + this.cache, + this.radius = 14, + super.key, + }); + + final String pubkey; + final String? name; + final ProfileCache? cache; + final double radius; + + @override + Widget build(BuildContext context) { + final c = cache; + if (c == null) { + return PeerAvatar(pubkey: pubkey, name: name, radius: radius); + } + return FutureBuilder( + future: c.picture(pubkey), + builder: (context, snap) => PeerAvatar( + pubkey: pubkey, + name: name, + picture: snap.data, + radius: radius, + ), + ); + } +} + /// A deterministic, readable avatar colour derived from [pubkey]. Hashes the /// key to a hue, then fixes saturation/lightness so white text stays legible on /// top. Pure and stable — exposed for testing. diff --git a/apps/app_seeds/lib/ui/your_people_screen.dart b/apps/app_seeds/lib/ui/your_people_screen.dart index 8ca1f0b..00eb32a 100644 --- a/apps/app_seeds/lib/ui/your_people_screen.dart +++ b/apps/app_seeds/lib/ui/your_people_screen.dart @@ -6,6 +6,7 @@ import 'package:go_router/go_router.dart'; import '../i18n/strings.g.dart'; import '../services/profile_cache.dart'; +import 'peer_avatar.dart'; import '../services/social_connection.dart'; import '../services/social_service.dart'; import 'theme.dart'; @@ -151,7 +152,9 @@ class _YourPeopleScreenState extends State { for (final peer in _youVouchFor) _PersonTile( key: Key('yourPeople.given.$peer'), + pubkey: peer, name: _names[peer] ?? shortPubkey(peer), + cache: widget.profileCache, onOpen: () => context.push('/chat/$peer'), trailing: TextButton( onPressed: _busy ? null : () => _revoke(peer), @@ -166,7 +169,9 @@ class _YourPeopleScreenState extends State { for (final peer in _vouchForYou) _PersonTile( key: Key('yourPeople.received.$peer'), + pubkey: peer, name: _names[peer] ?? shortPubkey(peer), + cache: widget.profileCache, onOpen: () => context.push('/chat/$peer'), ), ], @@ -217,20 +222,26 @@ class _PersonTile extends StatelessWidget { const _PersonTile({ required this.name, required this.onOpen, + required this.pubkey, + this.cache, this.trailing, super.key, }); + final String pubkey; final String name; + final ProfileCache? cache; final VoidCallback onOpen; final Widget? trailing; @override Widget build(BuildContext context) { return ListTile( - leading: const CircleAvatar( - backgroundColor: seedAvatar, - child: Icon(Icons.person, color: seedOnAvatar), + leading: CachedAvatar( + pubkey: pubkey, + name: name, + cache: cache, + radius: 20, ), title: Text(name), trailing: trailing, diff --git a/apps/app_seeds/test/ui/peer_avatar_test.dart b/apps/app_seeds/test/ui/peer_avatar_test.dart index c56a2a9..7dc0485 100644 --- a/apps/app_seeds/test/ui/peer_avatar_test.dart +++ b/apps/app_seeds/test/ui/peer_avatar_test.dart @@ -1,9 +1,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/services/profile_cache.dart'; import 'package:tane/ui/avatar.dart'; import 'package:tane/ui/peer_avatar.dart'; import 'package:tane/ui/seed_glyph.dart'; +import '../support/test_support.dart'; + void main() { group('avatar value scheme', () { test('every offered illustration maps to a glyph; unknown → null', () { @@ -56,4 +59,34 @@ void main() { expect(find.text('R'), findsNothing); // the initial isn't used }); }); + + group('CachedAvatar', () { + Widget host(Widget child) => MaterialApp( + home: Scaffold(body: Center(child: child)), + ); + + testWidgets('paints the cached illustration for the peer', (tester) async { + final cache = ProfileCache(InMemorySecretStore()); + await cache.setPicture('peer1', 'tane:seed:sack'); + await tester.pumpWidget(host( + CachedAvatar(pubkey: 'peer1', name: 'Bea', cache: cache))); + await tester.pumpAndSettle(); + expect(find.byType(SeedGlyph), findsOneWidget); + }); + + testWidgets('falls back to the initial 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); + }); + + testWidgets('no cache → the coloured-initial disc', (tester) async { + await tester.pumpWidget(host(const CachedAvatar(pubkey: 'x', name: 'Bea'))); + expect(find.text('B'), findsOneWidget); + }); + }); }