diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index ebd3f3e77a..2f67c84748 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 e983039951..3f17f01720 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 340210b14e..720d87ede6 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/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index 2ffb046b6c..ce4394e178 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 902cb76aae..32f70c8b4f 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 8bd45090e2..ef7039e0d9 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/channels/compose_bar.dart b/mobile/lib/features/channels/compose_bar.dart index 4ba08a05c2..5eed3c2af3 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 867242cc10..9677c7cfe4 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 abb488ced4..b17d9f4823 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 87a5404aed..a721ef1988 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 374a98a12d..44b90ba45b 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 fb69d9feaf..8c2e0a80b6 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/forum/forum_post_card.dart b/mobile/lib/features/forum/forum_post_card.dart index d84ae846d7..7c1851ba54 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 631735d54e..9d04d4e46c 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/profile/profile_avatar.dart b/mobile/lib/features/profile/profile_avatar.dart index a3affa39fa..38cc3e6138 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/profile/user_profile_sheet.dart b/mobile/lib/features/profile/user_profile_sheet.dart index 7abc4d7352..bce99dc840 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/features/pulse/agent_activity_card.dart b/mobile/lib/features/pulse/agent_activity_card.dart index cbaed133de..a204140e80 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, diff --git a/mobile/lib/features/pulse/compose_note_page.dart b/mobile/lib/features/pulse/compose_note_page.dart index f6bfe42cdd..2c514d6a71 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 43a93d2214..c843de7010 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 5b99ad7e99..b7040ed1c8 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( diff --git a/mobile/lib/shared/widgets/avatar_image.dart b/mobile/lib/shared/widgets/avatar_image.dart new file mode 100644 index 0000000000..f0c9a26f50 --- /dev/null +++ b/mobile/lib/shared/widgets/avatar_image.dart @@ -0,0 +1,174 @@ +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 StatefulWidget { + final String? imageUrl; + final Widget fallback; + final BoxFit fit; + + const AvatarImageContent({ + super.key, + required this.imageUrl, + required this.fallback, + 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 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, + placeholderBuilder: (_) => centeredFallback, + errorBuilder: (_, _, _) => centeredFallback, + ), + _RasterDataAvatarSource(:final bytes) => Image.memory( + bytes, + fit: widget.fit, + errorBuilder: (_, _, _) => centeredFallback, + ), + _NetworkAvatarSource(:final url) => Image.network( + url, + fit: widget.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(); + final svg = utf8.decode(bytes); + return _parseEmojiAvatar(svg) ?? _SvgAvatarSource(svg); + } + return _RasterDataAvatarSource(data.contentAsBytes()); + } on FormatException { + return null; + } + } +} + +_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); +} + +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 a492e175a8..260de02c11 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 9af3dca66b..11d1d7888c 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 0000000000..79bb03b6b5 --- /dev/null +++ b/mobile/test/shared/widgets/avatar_image_test.dart @@ -0,0 +1,74 @@ +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(); + + 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); + }); + + 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); + }); + + 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)); + }); +}