From 252d42a0c544df348cef026ece7bd4b23cd9076f Mon Sep 17 00:00:00 2001 From: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e Date: Mon, 13 Jul 2026 16:32:15 -0700 Subject: [PATCH 1/5] fix(mobile): render inline agent avatars Co-authored-by: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e Signed-off-by: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e --- .../channels/channel_detail_page.dart | 1 + .../channels/channel_detail_page/app_bar.dart | 22 ++-- .../channel_detail_page/message_bubble.dart | 20 ++- mobile/lib/features/channels/compose_bar.dart | 1 + .../channels/compose_bar/suggestions.dart | 20 ++- .../lib/features/channels/members_sheet.dart | 22 +--- .../lib/features/channels/reaction_row.dart | 22 +--- .../lib/features/channels/small_avatar.dart | 23 ++-- .../features/channels/thread_detail_page.dart | 21 ++-- .../features/profile/user_profile_sheet.dart | 27 +--- mobile/lib/shared/widgets/avatar_image.dart | 115 ++++++++++++++++++ mobile/pubspec.lock | 2 +- mobile/pubspec.yaml | 1 + .../shared/widgets/avatar_image_test.dart | 55 +++++++++ 14 files changed, 239 insertions(+), 113 deletions(-) create mode 100644 mobile/lib/shared/widgets/avatar_image.dart create mode 100644 mobile/test/shared/widgets/avatar_image_test.dart diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index ebd3f3e77..2f67c8474 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -7,6 +7,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; +import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/frosted_app_bar.dart'; import '../../shared/widgets/frosted_scaffold.dart'; import '../profile/presence_cache_provider.dart'; diff --git a/mobile/lib/features/channels/channel_detail_page/app_bar.dart b/mobile/lib/features/channels/channel_detail_page/app_bar.dart index e98303995..3f17f0172 100644 --- a/mobile/lib/features/channels/channel_detail_page/app_bar.dart +++ b/mobile/lib/features/channels/channel_detail_page/app_bar.dart @@ -168,21 +168,17 @@ class _DmAppBarTitle extends ConsumerWidget { child: Stack( clipBehavior: Clip.none, children: [ - CircleAvatar( + AvatarImage( + imageUrl: avatarUrl, radius: 14, backgroundColor: context.colors.primaryContainer, - backgroundImage: avatarUrl != null - ? NetworkImage(avatarUrl) - : null, - child: avatarUrl == null - ? Text( - initial, - style: context.textTheme.labelSmall?.copyWith( - color: context.colors.onPrimaryContainer, - fontWeight: FontWeight.w600, - ), - ) - : null, + fallback: Text( + initial, + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.onPrimaryContainer, + fontWeight: FontWeight.w600, + ), + ), ), Positioned( right: -1, diff --git a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart index 340210b14..720d87ede 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart @@ -150,19 +150,17 @@ class _UserAvatar extends StatelessWidget { profile?.initial ?? (pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?'); final avatarUrl = profile?.avatarUrl; - return CircleAvatar( + return AvatarImage( + imageUrl: avatarUrl, radius: 14, backgroundColor: context.colors.primaryContainer, - backgroundImage: avatarUrl != null ? NetworkImage(avatarUrl) : null, - child: avatarUrl == null - ? Text( - initial, - style: context.textTheme.labelSmall?.copyWith( - color: context.colors.onPrimaryContainer, - fontWeight: FontWeight.w600, - ), - ) - : null, + fallback: Text( + initial, + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.onPrimaryContainer, + fontWeight: FontWeight.w600, + ), + ), ); } } diff --git a/mobile/lib/features/channels/compose_bar.dart b/mobile/lib/features/channels/compose_bar.dart index 4ba08a05c..5eed3c2af 100644 --- a/mobile/lib/features/channels/compose_bar.dart +++ b/mobile/lib/features/channels/compose_bar.dart @@ -9,6 +9,7 @@ import 'package:nostr/nostr.dart' as nostr; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; +import '../../shared/widgets/avatar_image.dart'; import '../profile/user_cache_provider.dart'; import '../profile/user_profile.dart'; import '../custom_emoji/custom_emoji.dart'; diff --git a/mobile/lib/features/channels/compose_bar/suggestions.dart b/mobile/lib/features/channels/compose_bar/suggestions.dart index 867242cc1..9677c7cfe 100644 --- a/mobile/lib/features/channels/compose_bar/suggestions.dart +++ b/mobile/lib/features/channels/compose_bar/suggestions.dart @@ -47,20 +47,16 @@ class _MentionSuggestions extends StatelessWidget { return ListTile( dense: true, visualDensity: VisualDensity.compact, - leading: CircleAvatar( + leading: AvatarImage( + imageUrl: avatarUrl, radius: 14, backgroundColor: context.colors.primaryContainer, - backgroundImage: avatarUrl != null - ? NetworkImage(avatarUrl) - : null, - child: avatarUrl == null - ? Text( - name[0].toUpperCase(), - style: context.textTheme.labelSmall?.copyWith( - color: context.colors.onPrimaryContainer, - ), - ) - : null, + fallback: Text( + name[0].toUpperCase(), + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.onPrimaryContainer, + ), + ), ), title: Text(name, style: context.textTheme.bodyMedium), subtitle: _MentionSuggestionInfo.build( diff --git a/mobile/lib/features/channels/members_sheet.dart b/mobile/lib/features/channels/members_sheet.dart index abb488ced..b17d9f482 100644 --- a/mobile/lib/features/channels/members_sheet.dart +++ b/mobile/lib/features/channels/members_sheet.dart @@ -4,6 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/theme/theme.dart'; +import '../../shared/widgets/avatar_image.dart'; import '../profile/user_cache_provider.dart'; import '../profile/user_profile.dart'; import '../profile/user_status.dart'; @@ -449,7 +450,7 @@ class _RoleSelector extends StatelessWidget { } } -class _MemberAvatar extends HookWidget { +class _MemberAvatar extends StatelessWidget { final String? avatarUrl; final String initial; @@ -457,21 +458,10 @@ class _MemberAvatar extends HookWidget { @override Widget build(BuildContext context) { - final failed = useState(false); - - useEffect(() { - failed.value = false; - return null; - }, [avatarUrl]); - - final url = avatarUrl; - if (url == null || failed.value) { - return CircleAvatar(child: Text(initial)); - } - return CircleAvatar( - backgroundImage: NetworkImage(url), - onBackgroundImageError: (_, _) => failed.value = true, - child: null, + return AvatarImage( + imageUrl: avatarUrl, + radius: 20, + fallback: Text(initial), ); } } diff --git a/mobile/lib/features/channels/reaction_row.dart b/mobile/lib/features/channels/reaction_row.dart index 87a5404ae..a721ef198 100644 --- a/mobile/lib/features/channels/reaction_row.dart +++ b/mobile/lib/features/channels/reaction_row.dart @@ -3,6 +3,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/theme/theme.dart'; +import '../../shared/widgets/avatar_image.dart'; import '../custom_emoji/custom_emoji_render.dart'; import '../profile/user_cache_provider.dart'; import '../profile/user_profile.dart'; @@ -279,7 +280,7 @@ class _ReactorTile extends StatelessWidget { } } -class _ReactorAvatar extends HookWidget { +class _ReactorAvatar extends StatelessWidget { final String? avatarUrl; final String initial; @@ -287,21 +288,10 @@ class _ReactorAvatar extends HookWidget { @override Widget build(BuildContext context) { - final failed = useState(false); - - useEffect(() { - failed.value = false; - return null; - }, [avatarUrl]); - - final url = avatarUrl; - if (url == null || failed.value) { - return CircleAvatar(child: Text(initial)); - } - return CircleAvatar( - backgroundImage: NetworkImage(url), - onBackgroundImageError: (_, _) => failed.value = true, - child: null, + return AvatarImage( + imageUrl: avatarUrl, + radius: 20, + fallback: Text(initial), ); } } diff --git a/mobile/lib/features/channels/small_avatar.dart b/mobile/lib/features/channels/small_avatar.dart index 374a98a12..44b90ba45 100644 --- a/mobile/lib/features/channels/small_avatar.dart +++ b/mobile/lib/features/channels/small_avatar.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../../shared/theme/theme.dart'; +import '../../shared/widgets/avatar_image.dart'; import '../profile/user_profile.dart'; /// 20px circle avatar used in thread summary rows and other compact lists. @@ -24,20 +25,18 @@ class SmallAvatar extends StatelessWidget { shape: BoxShape.circle, border: Border.all(color: context.colors.surface, width: 1.5), ), - child: CircleAvatar( + child: AvatarImage( + imageUrl: avatarUrl, radius: 9, backgroundColor: context.colors.primaryContainer, - backgroundImage: avatarUrl != null ? NetworkImage(avatarUrl) : null, - child: avatarUrl == null - ? Text( - initial, - style: TextStyle( - fontSize: 8, - fontWeight: FontWeight.w600, - color: context.colors.onPrimaryContainer, - ), - ) - : null, + fallback: Text( + initial, + style: TextStyle( + fontSize: 8, + fontWeight: FontWeight.w600, + color: context.colors.onPrimaryContainer, + ), + ), ), ); } diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index fb69d9fea..8c2e0a80b 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -4,6 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/theme/theme.dart'; +import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/frosted_app_bar.dart'; import '../../shared/widgets/frosted_scaffold.dart'; import '../profile/user_cache_provider.dart'; @@ -569,19 +570,17 @@ class _Avatar extends StatelessWidget { profile?.initial ?? (pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?'); final avatarUrl = profile?.avatarUrl; - return CircleAvatar( + return AvatarImage( + imageUrl: avatarUrl, radius: 14, backgroundColor: context.colors.primaryContainer, - backgroundImage: avatarUrl != null ? NetworkImage(avatarUrl) : null, - child: avatarUrl == null - ? Text( - initial, - style: context.textTheme.labelSmall?.copyWith( - color: context.colors.onPrimaryContainer, - fontWeight: FontWeight.w600, - ), - ) - : null, + fallback: Text( + initial, + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.onPrimaryContainer, + fontWeight: FontWeight.w600, + ), + ), ); } } diff --git a/mobile/lib/features/profile/user_profile_sheet.dart b/mobile/lib/features/profile/user_profile_sheet.dart index 7abc4d735..bce99dc84 100644 --- a/mobile/lib/features/profile/user_profile_sheet.dart +++ b/mobile/lib/features/profile/user_profile_sheet.dart @@ -6,6 +6,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/clipboard_utils.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; +import '../../shared/widgets/avatar_image.dart'; import '../../shared/utils/string_utils.dart'; import '../channels/channel_detail_page.dart'; import '../channels/channel_management_provider.dart'; @@ -334,7 +335,7 @@ class _InfoRow extends StatelessWidget { } } -class _ProfileAvatar extends HookWidget { +class _ProfileAvatar extends StatelessWidget { final String? avatarUrl; final String initial; @@ -342,30 +343,14 @@ class _ProfileAvatar extends HookWidget { @override Widget build(BuildContext context) { - final failed = useState(false); - - useEffect(() { - failed.value = false; - return null; - }, [avatarUrl]); - - final url = avatarUrl; - final showImage = url != null && !failed.value; - return AspectRatio( aspectRatio: 1, child: ClipRRect( borderRadius: BorderRadius.circular(20), - child: showImage - ? Image.network( - url, - fit: BoxFit.cover, - errorBuilder: (_, _, _) { - failed.value = true; - return _AvatarFallback(initial: initial); - }, - ) - : _AvatarFallback(initial: initial), + child: AvatarImageContent( + imageUrl: avatarUrl, + fallback: _AvatarFallback(initial: initial), + ), ), ); } diff --git a/mobile/lib/shared/widgets/avatar_image.dart b/mobile/lib/shared/widgets/avatar_image.dart new file mode 100644 index 000000000..2e862bf66 --- /dev/null +++ b/mobile/lib/shared/widgets/avatar_image.dart @@ -0,0 +1,115 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +/// A circular avatar that supports both remote URLs and inline image data. +/// +/// Flutter's [NetworkImage] only loads network URLs, while desktop browsers also +/// accept `data:image/*` sources directly. Agent emoji avatars are inline SVGs, +/// so mobile must decode those before rendering them. +class AvatarImage extends StatelessWidget { + final String? imageUrl; + final double radius; + final Color? backgroundColor; + final Widget fallback; + + const AvatarImage({ + super.key, + required this.imageUrl, + required this.radius, + required this.fallback, + this.backgroundColor, + }); + + @override + Widget build(BuildContext context) { + return CircleAvatar( + radius: radius, + backgroundColor: backgroundColor, + child: ClipOval( + child: SizedBox.square( + dimension: radius * 2, + child: AvatarImageContent(imageUrl: imageUrl, fallback: fallback), + ), + ), + ); + } +} + +/// Image content for avatar surfaces whose shape is supplied by their parent. +class AvatarImageContent extends StatelessWidget { + final String? imageUrl; + final Widget fallback; + final BoxFit fit; + + const AvatarImageContent({ + super.key, + required this.imageUrl, + required this.fallback, + this.fit = BoxFit.cover, + }); + + @override + Widget build(BuildContext context) { + final source = _AvatarSource.parse(imageUrl); + final centeredFallback = Center(child: fallback); + + return switch (source) { + _SvgAvatarSource(:final svg) => SvgPicture.string( + svg, + fit: fit, + placeholderBuilder: (_) => centeredFallback, + errorBuilder: (_, _, _) => centeredFallback, + ), + _RasterDataAvatarSource(:final bytes) => Image.memory( + bytes, + fit: fit, + errorBuilder: (_, _, _) => centeredFallback, + ), + _NetworkAvatarSource(:final url) => Image.network( + url, + fit: fit, + errorBuilder: (_, _, _) => centeredFallback, + ), + null => centeredFallback, + }; + } +} + +sealed class _AvatarSource { + const _AvatarSource(); + + static _AvatarSource? parse(String? value) { + final url = value?.trim(); + if (url == null || url.isEmpty) return null; + if (!url.startsWith('data:image/')) return _NetworkAvatarSource(url); + + try { + final data = UriData.parse(url); + if (data.mimeType == 'image/svg+xml') { + final Uint8List bytes = data.contentAsBytes(); + return _SvgAvatarSource(utf8.decode(bytes)); + } + return _RasterDataAvatarSource(data.contentAsBytes()); + } on FormatException { + return null; + } + } +} + +class _SvgAvatarSource extends _AvatarSource { + final String svg; + const _SvgAvatarSource(this.svg); +} + +class _RasterDataAvatarSource extends _AvatarSource { + final Uint8List bytes; + const _RasterDataAvatarSource(this.bytes); +} + +class _NetworkAvatarSource extends _AvatarSource { + final String url; + const _NetworkAvatarSource(this.url); +} diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index a492e175a..260de02c1 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -431,7 +431,7 @@ packages: source: hosted version: "4.2.2" flutter_svg: - dependency: transitive + dependency: "direct main" description: name: flutter_svg sha256: "1ded017b39c8e15c8948ea855070a5ff8ff8b3d5e83f3446e02d6bb12add7ad9" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 9af3dca66..11d1d7888 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -14,6 +14,7 @@ dependencies: lucide_icons_flutter: ^3.1.0 http: ^1.4.0 flutter_secure_storage: ^10.0.0 + flutter_svg: ^2.2.4 mobile_scanner: ^7.0.0 shared_preferences: ^2.5.5 web_socket_channel: ^3.0.1 diff --git a/mobile/test/shared/widgets/avatar_image_test.dart b/mobile/test/shared/widgets/avatar_image_test.dart new file mode 100644 index 000000000..0a8410768 --- /dev/null +++ b/mobile/test/shared/widgets/avatar_image_test.dart @@ -0,0 +1,55 @@ +import 'dart:convert'; + +import 'package:buzz/shared/widgets/avatar_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const svg = + '' + '🦝'; + + Widget subject(String? imageUrl) => MaterialApp( + home: AvatarImage( + imageUrl: imageUrl, + radius: 16, + fallback: const Text('R'), + ), + ); + + testWidgets('renders raccoon percent-encoded SVG data avatar', ( + tester, + ) async { + const raccoonAvatar = + 'data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22512%22%20height%3D%22512%22%20viewBox%3D%220%200%20512%20512%22%3E%3Crect%20width%3D%22512%22%20height%3D%22512%22%20rx%3D%22256%22%20fill%3D%22%233399FF%22%2F%3E%3Ctext%20x%3D%2250%25%22%20y%3D%2256%25%22%20dominant-baseline%3D%22middle%22%20text-anchor%3D%22middle%22%20font-size%3D%22258%22%3E%F0%9F%A6%9D%3C%2Ftext%3E%3C%2Fsvg%3E'; + await tester.pumpWidget(subject(raccoonAvatar)); + await tester.pumpAndSettle(); + + expect(find.byType(SvgPicture), findsOneWidget); + expect(find.text('R'), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('renders base64 SVG data avatar', (tester) async { + await tester.pumpWidget( + subject('data:image/svg+xml;base64,${base64Encode(utf8.encode(svg))}'), + ); + + expect(find.byType(SvgPicture), findsOneWidget); + }); + + testWidgets('centers fallback when no avatar is configured', (tester) async { + await tester.pumpWidget(subject(null)); + + final avatarCenter = tester.getCenter(find.byType(CircleAvatar)); + final fallbackCenter = tester.getCenter(find.text('R')); + expect(fallbackCenter, avatarCenter); + }); + + testWidgets('uses fallback for malformed image data', (tester) async { + await tester.pumpWidget(subject('data:image/svg+xml;base64,%%%')); + + expect(find.text('R'), findsOneWidget); + }); +} From 381fdcc8d0a093a40f8eb0c1e4135d9c13b475e4 Mon Sep 17 00:00:00 2001 From: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e Date: Mon, 13 Jul 2026 16:38:06 -0700 Subject: [PATCH 2/5] fix(mobile): cover global agent avatars Co-authored-by: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e Signed-off-by: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e --- .../lib/features/profile/profile_avatar.dart | 21 ++++++++----------- .../features/pulse/agent_activity_card.dart | 11 ++++------ 2 files changed, 13 insertions(+), 19 deletions(-) diff --git a/mobile/lib/features/profile/profile_avatar.dart b/mobile/lib/features/profile/profile_avatar.dart index a3affa39f..38cc3e613 100644 --- a/mobile/lib/features/profile/profile_avatar.dart +++ b/mobile/lib/features/profile/profile_avatar.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/theme/theme.dart'; +import '../../shared/widgets/avatar_image.dart'; import 'profile_provider.dart'; import 'user_profile.dart'; @@ -45,20 +46,16 @@ class ProfileAvatar extends ConsumerWidget { onTap: onTap, child: Stack( children: [ - CircleAvatar( + AvatarImage( + imageUrl: profile?.avatarUrl, radius: 16, backgroundColor: context.colors.primaryContainer, - backgroundImage: profile?.avatarUrl != null - ? NetworkImage(profile!.avatarUrl!) - : null, - child: profile?.avatarUrl == null - ? Text( - profile?.initial ?? '?', - style: context.textTheme.labelMedium?.copyWith( - color: context.colors.onPrimaryContainer, - ), - ) - : null, + fallback: Text( + profile?.initial ?? '?', + style: context.textTheme.labelMedium?.copyWith( + color: context.colors.onPrimaryContainer, + ), + ), ), if (showPresence) Positioned( diff --git a/mobile/lib/features/pulse/agent_activity_card.dart b/mobile/lib/features/pulse/agent_activity_card.dart index cbaed133d..a204140e8 100644 --- a/mobile/lib/features/pulse/agent_activity_card.dart +++ b/mobile/lib/features/pulse/agent_activity_card.dart @@ -4,6 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/theme/theme.dart'; +import '../../shared/widgets/avatar_image.dart'; import '../profile/user_cache_provider.dart'; import 'note_card.dart'; import 'pulse_models.dart'; @@ -41,15 +42,11 @@ class AgentActivityCard extends HookConsumerWidget { children: [ Stack( children: [ - CircleAvatar( + AvatarImage( + imageUrl: profile?.avatarUrl, radius: 18, backgroundColor: context.colors.primaryContainer, - backgroundImage: profile?.avatarUrl != null - ? NetworkImage(profile!.avatarUrl!) - : null, - child: profile?.avatarUrl == null - ? const Icon(LucideIcons.bot, size: 18) - : null, + fallback: const Icon(LucideIcons.bot, size: 18), ), Positioned( right: 0, From 18dedf7b927473b2d09db7f34d889a00c6295cec Mon Sep 17 00:00:00 2001 From: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e Date: Mon, 13 Jul 2026 16:43:16 -0700 Subject: [PATCH 3/5] fix(mobile): migrate remaining avatar surfaces Co-authored-by: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e Signed-off-by: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e --- .../lib/features/channels/channels_page.dart | 1 + .../channels/channels_page/channel_tile.dart | 22 +++++----- .../channels/channels_page/sheets.dart | 13 +++--- .../lib/features/forum/forum_post_card.dart | 21 +++++----- .../lib/features/forum/forum_thread_page.dart | 23 +++++------ .../lib/features/pulse/compose_note_page.dart | 41 ++++++++----------- mobile/lib/features/pulse/note_card.dart | 21 ++++------ mobile/lib/features/search/search_page.dart | 12 +++--- 8 files changed, 69 insertions(+), 85 deletions(-) diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index 2ffb046b6..ce4394e17 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -9,6 +9,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/auth/auth.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; +import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/frosted_app_bar.dart'; import '../../shared/widgets/frosted_scaffold.dart'; import '../profile/profile_avatar.dart'; diff --git a/mobile/lib/features/channels/channels_page/channel_tile.dart b/mobile/lib/features/channels/channels_page/channel_tile.dart index 902cb76aa..32f70c8b4 100644 --- a/mobile/lib/features/channels/channels_page/channel_tile.dart +++ b/mobile/lib/features/channels/channels_page/channel_tile.dart @@ -390,20 +390,18 @@ class _DmAvatar extends ConsumerWidget { child: Stack( clipBehavior: Clip.none, children: [ - CircleAvatar( + AvatarImage( + imageUrl: avatarUrl, radius: 10, backgroundColor: context.colors.primaryContainer, - backgroundImage: avatarUrl != null ? NetworkImage(avatarUrl) : null, - child: avatarUrl == null - ? Text( - initial, - style: context.textTheme.labelSmall?.copyWith( - fontSize: 9, - color: context.colors.onPrimaryContainer, - fontWeight: FontWeight.w600, - ), - ) - : null, + fallback: Text( + initial, + style: context.textTheme.labelSmall?.copyWith( + fontSize: 9, + color: context.colors.onPrimaryContainer, + fontWeight: FontWeight.w600, + ), + ), ), Positioned( right: -1, diff --git a/mobile/lib/features/channels/channels_page/sheets.dart b/mobile/lib/features/channels/channels_page/sheets.dart index 8bd45090e..ef7039e0d 100644 --- a/mobile/lib/features/channels/channels_page/sheets.dart +++ b/mobile/lib/features/channels/channels_page/sheets.dart @@ -297,13 +297,12 @@ class _NewDirectMessageSheet extends HookConsumerWidget { children: [ for (final user in availableResults) ListTile( - leading: CircleAvatar( - backgroundImage: user.avatarUrl != null - ? NetworkImage(user.avatarUrl!) - : null, - child: user.avatarUrl == null - ? Text(user.label.substring(0, 1).toUpperCase()) - : null, + leading: AvatarImage( + imageUrl: user.avatarUrl, + radius: 20, + fallback: Text( + user.label.substring(0, 1).toUpperCase(), + ), ), title: Text(user.label), subtitle: Text(user.secondaryLabel), diff --git a/mobile/lib/features/forum/forum_post_card.dart b/mobile/lib/features/forum/forum_post_card.dart index d84ae846d..7c1851ba5 100644 --- a/mobile/lib/features/forum/forum_post_card.dart +++ b/mobile/lib/features/forum/forum_post_card.dart @@ -4,6 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/theme/theme.dart'; +import '../../shared/widgets/avatar_image.dart'; import '../channels/message_content.dart'; import '../profile/user_cache_provider.dart'; import '../profile/user_profile_sheet.dart'; @@ -260,19 +261,17 @@ class _PostAvatar extends StatelessWidget { profile?.initial ?? (pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?'); final avatarUrl = profile?.avatarUrl; - return CircleAvatar( + return AvatarImage( + imageUrl: avatarUrl, radius: 14, backgroundColor: context.colors.primaryContainer, - backgroundImage: avatarUrl != null ? NetworkImage(avatarUrl) : null, - child: avatarUrl == null - ? Text( - initial, - style: context.textTheme.labelSmall?.copyWith( - color: context.colors.onPrimaryContainer, - fontWeight: FontWeight.w600, - ), - ) - : null, + fallback: Text( + initial, + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.onPrimaryContainer, + fontWeight: FontWeight.w600, + ), + ), ); } } diff --git a/mobile/lib/features/forum/forum_thread_page.dart b/mobile/lib/features/forum/forum_thread_page.dart index 631735d54..9d04d4e46 100644 --- a/mobile/lib/features/forum/forum_thread_page.dart +++ b/mobile/lib/features/forum/forum_thread_page.dart @@ -7,6 +7,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/theme/theme.dart'; +import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/frosted_app_bar.dart'; import '../../shared/widgets/frosted_scaffold.dart'; import '../channels/compose_bar.dart'; @@ -561,20 +562,18 @@ class _Avatar extends StatelessWidget { profile?.initial ?? (pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?'); final avatarUrl = profile?.avatarUrl; - return CircleAvatar( + return AvatarImage( + imageUrl: avatarUrl, radius: radius, backgroundColor: context.colors.primaryContainer, - backgroundImage: avatarUrl != null ? NetworkImage(avatarUrl) : null, - child: avatarUrl == null - ? Text( - initial, - style: TextStyle( - fontSize: radius * 0.75, - fontWeight: FontWeight.w600, - color: context.colors.onPrimaryContainer, - ), - ) - : null, + fallback: Text( + initial, + style: TextStyle( + fontSize: radius * 0.75, + fontWeight: FontWeight.w600, + color: context.colors.onPrimaryContainer, + ), + ), ); } } diff --git a/mobile/lib/features/pulse/compose_note_page.dart b/mobile/lib/features/pulse/compose_note_page.dart index f6bfe42cd..2c514d6a7 100644 --- a/mobile/lib/features/pulse/compose_note_page.dart +++ b/mobile/lib/features/pulse/compose_note_page.dart @@ -3,6 +3,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/theme/theme.dart'; +import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/frosted_app_bar.dart'; import '../../shared/widgets/frosted_scaffold.dart'; import '../channels/message_content.dart'; @@ -98,20 +99,16 @@ class ComposeNotePage extends HookConsumerWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - CircleAvatar( + AvatarImage( + imageUrl: profile?.avatarUrl, radius: 18, backgroundColor: context.colors.primaryContainer, - backgroundImage: profile?.avatarUrl != null - ? NetworkImage(profile!.avatarUrl!) - : null, - child: profile?.avatarUrl == null - ? Text( - profile?.initial ?? '?', - style: context.textTheme.labelMedium?.copyWith( - color: context.colors.onPrimaryContainer, - ), - ) - : null, + fallback: Text( + profile?.initial ?? '?', + style: context.textTheme.labelMedium?.copyWith( + color: context.colors.onPrimaryContainer, + ), + ), ), const SizedBox(width: Grid.xxs), Expanded( @@ -183,20 +180,16 @@ class _ReplyContext extends ConsumerWidget { Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - CircleAvatar( + AvatarImage( + imageUrl: profile?.avatarUrl, radius: 18, backgroundColor: context.colors.primaryContainer, - backgroundImage: profile?.avatarUrl != null - ? NetworkImage(profile!.avatarUrl!) - : null, - child: profile?.avatarUrl == null - ? Text( - (profile?.initial ?? displayName[0]).toUpperCase(), - style: context.textTheme.labelMedium?.copyWith( - color: context.colors.onPrimaryContainer, - ), - ) - : null, + fallback: Text( + (profile?.initial ?? displayName[0]).toUpperCase(), + style: context.textTheme.labelMedium?.copyWith( + color: context.colors.onPrimaryContainer, + ), + ), ), const SizedBox(width: Grid.xs), Expanded( diff --git a/mobile/lib/features/pulse/note_card.dart b/mobile/lib/features/pulse/note_card.dart index 43a93d221..c843de701 100644 --- a/mobile/lib/features/pulse/note_card.dart +++ b/mobile/lib/features/pulse/note_card.dart @@ -6,6 +6,7 @@ import 'package:nostr/nostr.dart' as nostr; import '../../shared/clipboard_utils.dart'; import '../../shared/theme/theme.dart'; +import '../../shared/widgets/avatar_image.dart'; import '../channels/channel_detail_page.dart'; import '../channels/channel_management_provider.dart'; import '../channels/message_content.dart'; @@ -64,20 +65,16 @@ class NoteCard extends HookConsumerWidget { children: [ GestureDetector( onTap: () => showUserProfileSheet(context, note.pubkey), - child: CircleAvatar( + child: AvatarImage( + imageUrl: profile?.avatarUrl, radius: 18, backgroundColor: context.colors.primaryContainer, - backgroundImage: profile?.avatarUrl != null - ? NetworkImage(profile!.avatarUrl!) - : null, - child: profile?.avatarUrl == null - ? Text( - (profile?.initial ?? displayName[0]).toUpperCase(), - style: context.textTheme.labelMedium?.copyWith( - color: context.colors.onPrimaryContainer, - ), - ) - : null, + fallback: Text( + (profile?.initial ?? displayName[0]).toUpperCase(), + style: context.textTheme.labelMedium?.copyWith( + color: context.colors.onPrimaryContainer, + ), + ), ), ), const SizedBox(width: Grid.xs), diff --git a/mobile/lib/features/search/search_page.dart b/mobile/lib/features/search/search_page.dart index 5b99ad7e9..b7040ed1c 100644 --- a/mobile/lib/features/search/search_page.dart +++ b/mobile/lib/features/search/search_page.dart @@ -4,6 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/theme/theme.dart'; +import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/filter_chip_bar.dart'; import '../../shared/widgets/frosted_app_bar.dart'; import '../../shared/widgets/frosted_scaffold.dart'; @@ -237,13 +238,10 @@ class _PeopleSection extends ConsumerWidget { _SectionLabel(label: 'People'), for (final user in users) ListTile( - leading: CircleAvatar( - backgroundImage: user.avatarUrl != null - ? NetworkImage(user.avatarUrl!) - : null, - child: user.avatarUrl == null - ? Text(user.label.substring(0, 1).toUpperCase()) - : null, + leading: AvatarImage( + imageUrl: user.avatarUrl, + radius: 20, + fallback: Text(user.label.substring(0, 1).toUpperCase()), ), title: Text(user.label), subtitle: Text( From aa412e770ca57909e2e6e27f086aa4d9f41418aa Mon Sep 17 00:00:00 2001 From: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e Date: Mon, 13 Jul 2026 16:47:35 -0700 Subject: [PATCH 4/5] fix(mobile): retain parsed avatar data Co-authored-by: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e Signed-off-by: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e --- mobile/lib/shared/widgets/avatar_image.dart | 28 ++++++++++++++----- .../shared/widgets/avatar_image_test.dart | 17 +++++++++++ 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/mobile/lib/shared/widgets/avatar_image.dart b/mobile/lib/shared/widgets/avatar_image.dart index 2e862bf66..289049c81 100644 --- a/mobile/lib/shared/widgets/avatar_image.dart +++ b/mobile/lib/shared/widgets/avatar_image.dart @@ -39,7 +39,7 @@ class AvatarImage extends StatelessWidget { } /// Image content for avatar surfaces whose shape is supplied by their parent. -class AvatarImageContent extends StatelessWidget { +class AvatarImageContent extends StatefulWidget { final String? imageUrl; final Widget fallback; final BoxFit fit; @@ -51,26 +51,40 @@ class AvatarImageContent extends StatelessWidget { this.fit = BoxFit.cover, }); + @override + State createState() => _AvatarImageContentState(); +} + +class _AvatarImageContentState extends State { + late _AvatarSource? _source = _AvatarSource.parse(widget.imageUrl); + + @override + void didUpdateWidget(AvatarImageContent oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.imageUrl != oldWidget.imageUrl) { + _source = _AvatarSource.parse(widget.imageUrl); + } + } + @override Widget build(BuildContext context) { - final source = _AvatarSource.parse(imageUrl); - final centeredFallback = Center(child: fallback); + final centeredFallback = Center(child: widget.fallback); - return switch (source) { + return switch (_source) { _SvgAvatarSource(:final svg) => SvgPicture.string( svg, - fit: fit, + fit: widget.fit, placeholderBuilder: (_) => centeredFallback, errorBuilder: (_, _, _) => centeredFallback, ), _RasterDataAvatarSource(:final bytes) => Image.memory( bytes, - fit: fit, + fit: widget.fit, errorBuilder: (_, _, _) => centeredFallback, ), _NetworkAvatarSource(:final url) => Image.network( url, - fit: fit, + fit: widget.fit, errorBuilder: (_, _, _) => centeredFallback, ), null => centeredFallback, diff --git a/mobile/test/shared/widgets/avatar_image_test.dart b/mobile/test/shared/widgets/avatar_image_test.dart index 0a8410768..b107fc14b 100644 --- a/mobile/test/shared/widgets/avatar_image_test.dart +++ b/mobile/test/shared/widgets/avatar_image_test.dart @@ -52,4 +52,21 @@ void main() { expect(find.text('R'), findsOneWidget); }); + + testWidgets('reuses parsed raster bytes across parent rebuilds', ( + tester, + ) async { + const png = + 'data:image/png;base64,' + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII='; + await tester.pumpWidget(subject(png)); + final firstBytes = tester.widget(find.byType(Image)).image; + + await tester.pumpWidget(subject(png)); + final rebuiltBytes = tester.widget(find.byType(Image)).image; + + final firstMemory = firstBytes as MemoryImage; + final rebuiltMemory = rebuiltBytes as MemoryImage; + expect(rebuiltMemory.bytes, same(firstMemory.bytes)); + }); } From 1892b2e5270519c07b6f5878e211736e19b36180 Mon Sep 17 00:00:00 2001 From: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e Date: Mon, 13 Jul 2026 19:20:29 -0700 Subject: [PATCH 5/5] fix(mobile): center emoji avatar glyphs Co-authored-by: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e Signed-off-by: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e --- mobile/lib/shared/widgets/avatar_image.dart | 47 ++++++++++++++++++- .../shared/widgets/avatar_image_test.dart | 4 +- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/mobile/lib/shared/widgets/avatar_image.dart b/mobile/lib/shared/widgets/avatar_image.dart index 289049c81..f0c9a26f5 100644 --- a/mobile/lib/shared/widgets/avatar_image.dart +++ b/mobile/lib/shared/widgets/avatar_image.dart @@ -71,6 +71,21 @@ class _AvatarImageContentState extends State { final centeredFallback = Center(child: widget.fallback); return switch (_source) { + _EmojiAvatarSource(:final emoji, :final color) => ColoredBox( + color: color, + child: LayoutBuilder( + builder: (_, constraints) => Center( + child: Text( + emoji, + textScaler: TextScaler.noScaling, + style: TextStyle( + fontSize: constraints.biggest.shortestSide * 258 / 512, + height: 1, + ), + ), + ), + ), + ), _SvgAvatarSource(:final svg) => SvgPicture.string( svg, fit: widget.fit, @@ -104,7 +119,8 @@ sealed class _AvatarSource { final data = UriData.parse(url); if (data.mimeType == 'image/svg+xml') { final Uint8List bytes = data.contentAsBytes(); - return _SvgAvatarSource(utf8.decode(bytes)); + final svg = utf8.decode(bytes); + return _parseEmojiAvatar(svg) ?? _SvgAvatarSource(svg); } return _RasterDataAvatarSource(data.contentAsBytes()); } on FormatException { @@ -113,6 +129,35 @@ sealed class _AvatarSource { } } +_EmojiAvatarSource? _parseEmojiAvatar(String svg) { + final colorValue = RegExp( + r']*\sfill="([^"]+)"', + ).firstMatch(svg)?[1]; + final emojiValue = RegExp(r']*>(.*?)').firstMatch(svg)?[1]; + if (colorValue == null || emojiValue == null) return null; + + final color = _parseHexColor(colorValue); + if (color == null) return null; + final emoji = emojiValue + .replaceAll('>', '>') + .replaceAll('<', '<') + .replaceAll('&', '&'); + return _EmojiAvatarSource(emoji, color); +} + +Color? _parseHexColor(String value) { + final hex = value.startsWith('#') ? value.substring(1) : value; + if (!RegExp(r'^[0-9a-fA-F]{6}$').hasMatch(hex)) return null; + final rgb = int.tryParse(hex, radix: 16); + return rgb == null ? null : Color(0xFF000000 | rgb); +} + +class _EmojiAvatarSource extends _AvatarSource { + final String emoji; + final Color color; + const _EmojiAvatarSource(this.emoji, this.color); +} + class _SvgAvatarSource extends _AvatarSource { final String svg; const _SvgAvatarSource(this.svg); diff --git a/mobile/test/shared/widgets/avatar_image_test.dart b/mobile/test/shared/widgets/avatar_image_test.dart index b107fc14b..79bb03b6b 100644 --- a/mobile/test/shared/widgets/avatar_image_test.dart +++ b/mobile/test/shared/widgets/avatar_image_test.dart @@ -26,7 +26,9 @@ void main() { await tester.pumpWidget(subject(raccoonAvatar)); await tester.pumpAndSettle(); - expect(find.byType(SvgPicture), findsOneWidget); + final emoji = tester.widget(find.text('🦝')); + expect(emoji.style?.height, 1); + expect(find.byType(SvgPicture), findsNothing); expect(find.text('R'), findsNothing); expect(tester.takeException(), isNull); });