feat(profile): show other people's avatars in chat, chat list, your people
Follow-up to the avatar feature: peers' published photos/illustrations now actually render where people appear. - CachedAvatar: a PeerAvatar that resolves the person's picture from the ProfileCache (FutureBuilder), falling back to the coloured-initial disc. For list/row sites — one avatar each. - Chat bubbles: the peer's and my own avatar are resolved once into state (_peerPicture/_selfPicture — from the cache / ProfileStore, freshened from the peer's published profile) and threaded to each bubble, so many bubbles of two people don't each hit the cache. - Chat list + your-people rows: swapped the static person-icon disc for CachedAvatar, so photos/illustrations show there too. - Tests: CachedAvatar render modes (cached illustration / initial / no cache). analyze clean; chat + your-people suites green. Market offers show no author avatar today (would be a new element, not a swap) — left out of scope.
This commit is contained in:
parent
ec56819fbf
commit
51d6924464
5 changed files with 150 additions and 16 deletions
|
|
@ -7,6 +7,7 @@ import '../i18n/strings.g.dart';
|
||||||
import '../services/inbox_service.dart';
|
import '../services/inbox_service.dart';
|
||||||
import '../services/message_store.dart';
|
import '../services/message_store.dart';
|
||||||
import '../services/profile_cache.dart';
|
import '../services/profile_cache.dart';
|
||||||
|
import 'peer_avatar.dart';
|
||||||
import '../services/social_connection.dart';
|
import '../services/social_connection.dart';
|
||||||
import 'theme.dart';
|
import 'theme.dart';
|
||||||
import 'unread_badge.dart';
|
import 'unread_badge.dart';
|
||||||
|
|
@ -119,9 +120,11 @@ class _ChatListScreenState extends State<ChatListScreen> {
|
||||||
return ListTile(
|
return ListTile(
|
||||||
leading: UnreadBadge(
|
leading: UnreadBadge(
|
||||||
peer: c.peerPubkey,
|
peer: c.peerPubkey,
|
||||||
child: const CircleAvatar(
|
child: CachedAvatar(
|
||||||
backgroundColor: seedAvatar,
|
pubkey: c.peerPubkey,
|
||||||
child: Icon(Icons.person, color: seedOnAvatar),
|
name: _names[c.peerPubkey],
|
||||||
|
cache: widget.profileCache,
|
||||||
|
radius: 20,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
title: Text(_names[c.peerPubkey] ??
|
title: Text(_names[c.peerPubkey] ??
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,8 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
bool _loading = true;
|
bool _loading = true;
|
||||||
String? _peerName;
|
String? _peerName;
|
||||||
String? _selfName;
|
String? _selfName;
|
||||||
|
String? _peerPicture;
|
||||||
|
String? _selfPicture;
|
||||||
String? _peerG1;
|
String? _peerG1;
|
||||||
final _input = TextEditingController();
|
final _input = TextEditingController();
|
||||||
|
|
||||||
|
|
@ -86,10 +88,13 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
// connected / unreachable) → null transports and the screen degrades.
|
// connected / unreachable) → null transports and the screen degrades.
|
||||||
final session = await widget.connection.session();
|
final session = await widget.connection.session();
|
||||||
final cachedName = await widget.profileCache?.name(widget.peerPubkey);
|
final cachedName = await widget.profileCache?.name(widget.peerPubkey);
|
||||||
// My own display name, so my avatar shows my initial (not a bare glyph).
|
final cachedPicture = await widget.profileCache?.picture(widget.peerPubkey);
|
||||||
final selfName = getIt.isRegistered<ProfileStore>()
|
// My own name + avatar, so my side shows me (not a bare glyph).
|
||||||
? await getIt<ProfileStore>().name()
|
final selfStore = getIt.isRegistered<ProfileStore>()
|
||||||
: '';
|
? getIt<ProfileStore>()
|
||||||
|
: 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
|
if (!mounted) return; // shared session is owned by the connection, not us
|
||||||
final self = widget.social.publicKeyHex;
|
final self = widget.social.publicKeyHex;
|
||||||
final messages = MessagesCubit(
|
final messages = MessagesCubit(
|
||||||
|
|
@ -117,6 +122,8 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
_rating = rating;
|
_rating = rating;
|
||||||
_peerName = cachedName;
|
_peerName = cachedName;
|
||||||
_selfName = selfName.isEmpty ? null : selfName;
|
_selfName = selfName.isEmpty ? null : selfName;
|
||||||
|
_peerPicture = cachedPicture;
|
||||||
|
_selfPicture = selfAvatar.isEmpty ? null : selfAvatar;
|
||||||
_loading = false;
|
_loading = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -133,8 +140,15 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
profile.name,
|
profile.name,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (profile.picture.isNotEmpty) {
|
||||||
|
await widget.profileCache!.setPicture(
|
||||||
|
widget.peerPubkey,
|
||||||
|
profile.picture,
|
||||||
|
);
|
||||||
|
}
|
||||||
setState(() {
|
setState(() {
|
||||||
if (profile.name.isNotEmpty) _peerName = profile.name;
|
if (profile.name.isNotEmpty) _peerName = profile.name;
|
||||||
|
if (profile.picture.isNotEmpty) _peerPicture = profile.picture;
|
||||||
if (profile.g1.isNotEmpty) _peerG1 = profile.g1;
|
if (profile.g1.isNotEmpty) _peerG1 = profile.g1;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -230,6 +244,8 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
onSend: _send,
|
onSend: _send,
|
||||||
peerName: _peerName,
|
peerName: _peerName,
|
||||||
selfName: _selfName,
|
selfName: _selfName,
|
||||||
|
peerPicture: _peerPicture,
|
||||||
|
selfPicture: _selfPicture,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -242,12 +258,16 @@ class _ChatBody extends StatelessWidget {
|
||||||
required this.onSend,
|
required this.onSend,
|
||||||
this.peerName,
|
this.peerName,
|
||||||
this.selfName,
|
this.selfName,
|
||||||
|
this.peerPicture,
|
||||||
|
this.selfPicture,
|
||||||
});
|
});
|
||||||
|
|
||||||
final TextEditingController controller;
|
final TextEditingController controller;
|
||||||
final Future<void> Function() onSend;
|
final Future<void> Function() onSend;
|
||||||
final String? peerName;
|
final String? peerName;
|
||||||
final String? selfName;
|
final String? selfName;
|
||||||
|
final String? peerPicture;
|
||||||
|
final String? selfPicture;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
@ -278,7 +298,12 @@ class _ChatBody extends StatelessWidget {
|
||||||
const _TrustBanner(),
|
const _TrustBanner(),
|
||||||
const _RatingStrip(),
|
const _RatingStrip(),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ChatMessageList(peerName: peerName, selfName: selfName),
|
child: ChatMessageList(
|
||||||
|
peerName: peerName,
|
||||||
|
selfName: selfName,
|
||||||
|
peerPicture: peerPicture,
|
||||||
|
selfPicture: selfPicture,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
_Composer(controller: controller, onSend: onSend),
|
_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
|
/// in place instead of landing below the fold. Reads the [MessagesCubit] from
|
||||||
/// context.
|
/// context.
|
||||||
class ChatMessageList extends StatelessWidget {
|
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).
|
/// The peer's display name, for their avatar's initial (null → a glyph).
|
||||||
final String? peerName;
|
final String? peerName;
|
||||||
|
|
@ -300,6 +331,10 @@ class ChatMessageList extends StatelessWidget {
|
||||||
/// My own display name, for my avatar's initial (null → a glyph).
|
/// My own display name, for my avatar's initial (null → a glyph).
|
||||||
final String? selfName;
|
final String? selfName;
|
||||||
|
|
||||||
|
/// The peer's / my published avatar (null → the coloured-initial disc).
|
||||||
|
final String? peerPicture;
|
||||||
|
final String? selfPicture;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final t = context.t;
|
final t = context.t;
|
||||||
|
|
@ -329,6 +364,8 @@ class ChatMessageList extends StatelessWidget {
|
||||||
mine: cubit.isMine(message),
|
mine: cubit.isMine(message),
|
||||||
peerName: peerName,
|
peerName: peerName,
|
||||||
selfName: selfName,
|
selfName: selfName,
|
||||||
|
peerPicture: peerPicture,
|
||||||
|
selfPicture: selfPicture,
|
||||||
selfPubkey: cubit.selfPubkey,
|
selfPubkey: cubit.selfPubkey,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
@ -494,6 +531,8 @@ class _MessageRow extends StatelessWidget {
|
||||||
required this.mine,
|
required this.mine,
|
||||||
this.peerName,
|
this.peerName,
|
||||||
this.selfName,
|
this.selfName,
|
||||||
|
this.peerPicture,
|
||||||
|
this.selfPicture,
|
||||||
this.selfPubkey,
|
this.selfPubkey,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -501,17 +540,27 @@ class _MessageRow extends StatelessWidget {
|
||||||
final bool mine;
|
final bool mine;
|
||||||
final String? peerName;
|
final String? peerName;
|
||||||
final String? selfName;
|
final String? selfName;
|
||||||
|
final String? peerPicture;
|
||||||
|
final String? selfPicture;
|
||||||
|
|
||||||
/// My own pubkey, so my messages carry my (distinctly coloured) avatar.
|
/// My own pubkey, so my messages carry my (distinctly coloured) avatar.
|
||||||
final String? selfPubkey;
|
final String? selfPubkey;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// Each avatar shows its owner's initial (falling back to a glyph when no
|
// Each avatar shows its owner's photo/illustration when set, else their
|
||||||
// name is known) on a colour derived from their pubkey.
|
// initial (or a glyph) on a colour derived from their pubkey.
|
||||||
final avatar = mine
|
final avatar = mine
|
||||||
? PeerAvatar(pubkey: selfPubkey ?? message.fromPubkey, name: selfName)
|
? PeerAvatar(
|
||||||
: PeerAvatar(pubkey: message.fromPubkey, name: peerName);
|
pubkey: selfPubkey ?? message.fromPubkey,
|
||||||
|
name: selfName,
|
||||||
|
picture: selfPicture,
|
||||||
|
)
|
||||||
|
: PeerAvatar(
|
||||||
|
pubkey: message.fromPubkey,
|
||||||
|
name: peerName,
|
||||||
|
picture: peerPicture,
|
||||||
|
);
|
||||||
final bubble = Flexible(
|
final bubble = Flexible(
|
||||||
child: _Bubble(message: message, mine: mine),
|
child: _Bubble(message: message, mine: mine),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../services/offer_thumbnail.dart' show decodeDataUri;
|
import '../services/offer_thumbnail.dart' show decodeDataUri;
|
||||||
|
import '../services/profile_cache.dart';
|
||||||
import 'avatar.dart';
|
import 'avatar.dart';
|
||||||
import 'seed_glyph.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<String?>(
|
||||||
|
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
|
/// A deterministic, readable avatar colour derived from [pubkey]. Hashes the
|
||||||
/// key to a hue, then fixes saturation/lightness so white text stays legible on
|
/// key to a hue, then fixes saturation/lightness so white text stays legible on
|
||||||
/// top. Pure and stable — exposed for testing.
|
/// top. Pure and stable — exposed for testing.
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../i18n/strings.g.dart';
|
import '../i18n/strings.g.dart';
|
||||||
import '../services/profile_cache.dart';
|
import '../services/profile_cache.dart';
|
||||||
|
import 'peer_avatar.dart';
|
||||||
import '../services/social_connection.dart';
|
import '../services/social_connection.dart';
|
||||||
import '../services/social_service.dart';
|
import '../services/social_service.dart';
|
||||||
import 'theme.dart';
|
import 'theme.dart';
|
||||||
|
|
@ -151,7 +152,9 @@ class _YourPeopleScreenState extends State<YourPeopleScreen> {
|
||||||
for (final peer in _youVouchFor)
|
for (final peer in _youVouchFor)
|
||||||
_PersonTile(
|
_PersonTile(
|
||||||
key: Key('yourPeople.given.$peer'),
|
key: Key('yourPeople.given.$peer'),
|
||||||
|
pubkey: peer,
|
||||||
name: _names[peer] ?? shortPubkey(peer),
|
name: _names[peer] ?? shortPubkey(peer),
|
||||||
|
cache: widget.profileCache,
|
||||||
onOpen: () => context.push('/chat/$peer'),
|
onOpen: () => context.push('/chat/$peer'),
|
||||||
trailing: TextButton(
|
trailing: TextButton(
|
||||||
onPressed: _busy ? null : () => _revoke(peer),
|
onPressed: _busy ? null : () => _revoke(peer),
|
||||||
|
|
@ -166,7 +169,9 @@ class _YourPeopleScreenState extends State<YourPeopleScreen> {
|
||||||
for (final peer in _vouchForYou)
|
for (final peer in _vouchForYou)
|
||||||
_PersonTile(
|
_PersonTile(
|
||||||
key: Key('yourPeople.received.$peer'),
|
key: Key('yourPeople.received.$peer'),
|
||||||
|
pubkey: peer,
|
||||||
name: _names[peer] ?? shortPubkey(peer),
|
name: _names[peer] ?? shortPubkey(peer),
|
||||||
|
cache: widget.profileCache,
|
||||||
onOpen: () => context.push('/chat/$peer'),
|
onOpen: () => context.push('/chat/$peer'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -217,20 +222,26 @@ class _PersonTile extends StatelessWidget {
|
||||||
const _PersonTile({
|
const _PersonTile({
|
||||||
required this.name,
|
required this.name,
|
||||||
required this.onOpen,
|
required this.onOpen,
|
||||||
|
required this.pubkey,
|
||||||
|
this.cache,
|
||||||
this.trailing,
|
this.trailing,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final String pubkey;
|
||||||
final String name;
|
final String name;
|
||||||
|
final ProfileCache? cache;
|
||||||
final VoidCallback onOpen;
|
final VoidCallback onOpen;
|
||||||
final Widget? trailing;
|
final Widget? trailing;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ListTile(
|
return ListTile(
|
||||||
leading: const CircleAvatar(
|
leading: CachedAvatar(
|
||||||
backgroundColor: seedAvatar,
|
pubkey: pubkey,
|
||||||
child: Icon(Icons.person, color: seedOnAvatar),
|
name: name,
|
||||||
|
cache: cache,
|
||||||
|
radius: 20,
|
||||||
),
|
),
|
||||||
title: Text(name),
|
title: Text(name),
|
||||||
trailing: trailing,
|
trailing: trailing,
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.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/avatar.dart';
|
||||||
import 'package:tane/ui/peer_avatar.dart';
|
import 'package:tane/ui/peer_avatar.dart';
|
||||||
import 'package:tane/ui/seed_glyph.dart';
|
import 'package:tane/ui/seed_glyph.dart';
|
||||||
|
|
||||||
|
import '../support/test_support.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('avatar value scheme', () {
|
group('avatar value scheme', () {
|
||||||
test('every offered illustration maps to a glyph; unknown → null', () {
|
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
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue