diff --git a/docs/tasks/testing.md b/docs/tasks/testing.md index 81f5ba6..1749aba 100644 --- a/docs/tasks/testing.md +++ b/docs/tasks/testing.md @@ -36,33 +36,13 @@ ## M4 - Widget Extraction -- [ ] Create `lib/shared/presentation/widgets/profile_avatar.dart` (configurable size, shape, fallback) -- [ ] Create `lib/shared/presentation/widgets/actor_name_widget.dart` (displayName + handle) -- [ ] Create `lib/shared/presentation/helpers/notification_icon_mapper.dart` -- [ ] Replace avatar patterns in: - - `lib/features/messages/presentation/widgets/convo_list_item.dart` - - `lib/features/lists/presentation/widgets/list_row_tile.dart` - - `lib/features/settings/presentation/settings_screen.dart` - - `lib/features/starter_packs/presentation/widgets/starter_pack_card.dart` - - `lib/features/starter_packs/presentation/create_edit_starter_pack_screen.dart` - - `lib/features/account/presentation/account_switcher_sheet.dart` - - `lib/features/profile/presentation/widgets/suggested_follows_list.dart` - - `lib/features/feed/presentation/widgets/post_card.dart` - - `lib/features/feed/presentation/widgets/grid_post_card.dart` - - `lib/features/feed/presentation/widgets/post_embed_view.dart` - - `lib/features/notifications/presentation/widgets/notification_list_item.dart` - - `lib/features/notifications/presentation/widgets/grouped_notification_list_item.dart` - - `lib/features/search/presentation/search_screen.dart` - - `lib/features/search/presentation/hashtag_screen.dart` -- [ ] Replace author name patterns in: - - `lib/features/feed/presentation/widgets/post_card.dart` - - `lib/features/feed/presentation/widgets/grid_post_card.dart` - - `lib/features/feed/presentation/widgets/post_embed_view.dart` - - `lib/features/messages/presentation/widgets/convo_list_item.dart` -- [ ] Replace notification icon switch in: - - `lib/features/notifications/presentation/widgets/notification_list_item.dart` - - `lib/features/notifications/presentation/widgets/grouped_notification_list_item.dart` -- [ ] Widget tests for extracted widgets +- [x] Create `lib/shared/presentation/widgets/profile_avatar.dart` (configurable size, shape, fallback) +- [x] Create `lib/shared/presentation/widgets/actor_name_widget.dart` (displayName + handle) +- [x] Create `lib/shared/presentation/helpers/notification_icon_mapper.dart` +- [x] Replace avatar patterns +- [x] Replace author name patterns +- [x] Replace notification icon switch +- [x] Widget tests for extracted widgets ## M5 - Navigation & Haptics Helpers diff --git a/lib/features/account/presentation/account_switcher_sheet.dart b/lib/features/account/presentation/account_switcher_sheet.dart index 4416040..a38d518 100644 --- a/lib/features/account/presentation/account_switcher_sheet.dart +++ b/lib/features/account/presentation/account_switcher_sheet.dart @@ -3,6 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:lazurite/features/account/cubit/account_switcher_cubit.dart'; import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; import 'package:lazurite/shared/presentation/helpers/snackbar_helper.dart'; +import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; import 'package:lazurite/shared/presentation/widgets/confirmation_dialog.dart'; import 'package:lazurite/shared/presentation/widgets/options_sheet.dart'; @@ -60,7 +61,7 @@ class _AccountSwitcherSheet extends StatelessWidget { final label = account.displayName ?? account.handle; return ListTile( - leading: CircleAvatar(child: Text(label.substring(0, 1).toUpperCase())), + leading: ProfileAvatar(size: 40, fallbackText: label), title: Text(label), subtitle: Text('@${account.handle}'), trailing: isActive ? const Icon(Icons.check) : null, diff --git a/lib/features/feed/presentation/widgets/grid_post_card.dart b/lib/features/feed/presentation/widgets/grid_post_card.dart index e443614..5cfcc19 100644 --- a/lib/features/feed/presentation/widgets/grid_post_card.dart +++ b/lib/features/feed/presentation/widgets/grid_post_card.dart @@ -13,10 +13,10 @@ import 'package:lazurite/features/feed/presentation/widgets/post_card_footer.dar import 'package:lazurite/features/feed/presentation/widgets/post_embed_view.dart'; import 'package:lazurite/features/feed/presentation/widgets/post_text_styles.dart'; import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; -import 'package:lazurite/features/moderation/presentation/widgets/moderated_avatar.dart'; import 'package:lazurite/features/moderation/presentation/widgets/moderated_blur_overlay.dart'; import 'package:lazurite/features/moderation/presentation/widgets/moderation_badge_row.dart'; -import 'package:lazurite/shared/utils/format_utils.dart'; +import 'package:lazurite/shared/presentation/widgets/actor_name_widget.dart'; +import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; const double _gridEmbedPreviewMaxHeight = 240; @@ -147,11 +147,11 @@ class GridPostCard extends StatelessWidget { GestureDetector( key: const ValueKey('grid_post_card_avatar'), onTap: () => GoRouter.maybeOf(context)?.push('/profile/view?actor=${Uri.encodeQueryComponent(author.did)}'), - child: ModeratedAvatar( + child: ProfileAvatar( size: 40, - ui: avatarUi, + moderationUi: avatarUi, imageUrl: author.avatar, - initials: formatInitials(author.displayName ?? author.handle), + fallbackText: author.displayName ?? author.handle, shape: BoxShape.rectangle, border: Border.all(color: colorScheme.outlineVariant), placeholderTextStyle: context.textTheme.labelMedium, @@ -159,27 +159,16 @@ class GridPostCard extends StatelessWidget { ), const SizedBox(width: AppSpacing.xs), Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (author.displayName != null && author.displayName!.isNotEmpty) - Text( - author.displayName!, - style: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w700), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - Text( - '@${author.handle}'.toUpperCase(), - style: context.textTheme.labelSmall?.copyWith( - fontWeight: FontWeight.w700, - letterSpacing: 1.5, - color: colorScheme.onSurfaceVariant, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], + child: ActorNameWidget( + displayName: author.displayName, + handle: author.handle, + showDisplayNameOnlyWhenPresent: true, + displayNameStyle: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w700), + handleStyle: context.textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.w700, + letterSpacing: 1.5, + color: colorScheme.onSurfaceVariant, + ), ), ), ], diff --git a/lib/features/feed/presentation/widgets/post_card.dart b/lib/features/feed/presentation/widgets/post_card.dart index 4d9e47d..ca21ee2 100644 --- a/lib/features/feed/presentation/widgets/post_card.dart +++ b/lib/features/feed/presentation/widgets/post_card.dart @@ -9,10 +9,10 @@ import 'package:lazurite/features/feed/presentation/widgets/post_card_footer.dar import 'package:lazurite/features/feed/presentation/widgets/post_embed_view.dart'; import 'package:lazurite/features/feed/presentation/widgets/post_text_styles.dart'; import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; -import 'package:lazurite/features/moderation/presentation/widgets/moderated_avatar.dart'; import 'package:lazurite/features/moderation/presentation/widgets/moderated_blur_overlay.dart'; import 'package:lazurite/features/moderation/presentation/widgets/moderation_badge_row.dart'; -import 'package:lazurite/shared/utils/format_utils.dart'; +import 'package:lazurite/shared/presentation/widgets/actor_name_widget.dart'; +import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; import 'package:lazurite/core/theme/theme_extensions.dart'; class PostCard extends StatelessWidget { @@ -99,38 +99,26 @@ class PostCard extends StatelessWidget { GestureDetector( key: const ValueKey('post_card_avatar'), onTap: () => GoRouter.maybeOf(context)?.push('/profile/view?actor=${Uri.encodeQueryComponent(author.did)}'), - child: ModeratedAvatar( + child: ProfileAvatar( size: 40, - ui: avatarUi, + moderationUi: avatarUi, imageUrl: author.avatar, - initials: formatInitials(author.displayName ?? author.handle), + fallbackText: author.displayName ?? author.handle, shape: BoxShape.rectangle, border: Border.all(color: colorScheme.outlineVariant), ), ), const SizedBox(width: 12), Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - author.displayName ?? author.handle, - style: context.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w700), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 2), - Text( - '@${author.handle}'.toUpperCase(), - style: context.textTheme.labelSmall?.copyWith( - color: colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w700, - letterSpacing: 1.5, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], + child: ActorNameWidget( + displayName: author.displayName, + handle: author.handle, + displayNameStyle: context.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w700), + handleStyle: context.textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w700, + letterSpacing: 1.5, + ), ), ), ], diff --git a/lib/features/feed/presentation/widgets/post_embed_view.dart b/lib/features/feed/presentation/widgets/post_embed_view.dart index 23f1557..c07faab 100644 --- a/lib/features/feed/presentation/widgets/post_embed_view.dart +++ b/lib/features/feed/presentation/widgets/post_embed_view.dart @@ -16,7 +16,8 @@ import 'package:lazurite/features/feed/presentation/widgets/facet_text.dart'; import 'package:lazurite/features/feed/presentation/widgets/post_text_styles.dart'; import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; import 'package:lazurite/features/moderation/presentation/widgets/moderated_blur_overlay.dart'; -import 'package:lazurite/shared/utils/format_utils.dart'; +import 'package:lazurite/shared/presentation/widgets/actor_name_widget.dart'; +import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:lazurite/core/theme/theme_extensions.dart'; @@ -266,28 +267,26 @@ class PostEmbedView extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - width: 28, - height: 28, - decoration: BoxDecoration( - color: context.colorScheme.surfaceContainerHighest, - border: Border.all(color: context.colorScheme.outlineVariant), - ), - child: quoted.author.avatar != null - ? Image.network(quoted.author.avatar!, fit: BoxFit.cover) - : Center(child: Text(formatInitials(quoted.author.displayName ?? quoted.author.handle))), + ProfileAvatar( + size: 28, + imageUrl: quoted.author.avatar, + fallbackText: quoted.author.displayName ?? quoted.author.handle, + shape: BoxShape.rectangle, + border: Border.all(color: context.colorScheme.outlineVariant), ), const SizedBox(width: 8), Expanded( - child: Text( - '${quoted.author.displayName ?? quoted.author.handle} @${quoted.author.handle}', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodyMedium?.copyWith( + child: ActorNameWidget( + displayName: quoted.author.displayName, + handle: quoted.author.handle, + displayNameStyle: theme.textTheme.bodyMedium?.copyWith( fontWeight: FontWeight.w600, color: colorScheme.onSurface, ), + handleStyle: theme.textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), + uppercaseHandle: false, ), ), ], diff --git a/lib/features/lists/presentation/widgets/list_row_tile.dart b/lib/features/lists/presentation/widgets/list_row_tile.dart index 5595da2..9809eba 100644 --- a/lib/features/lists/presentation/widgets/list_row_tile.dart +++ b/lib/features/lists/presentation/widgets/list_row_tile.dart @@ -1,6 +1,7 @@ import 'package:bluesky/app_bsky_graph_defs.dart' as bsky_graph; import 'package:flutter/material.dart'; import 'package:lazurite/core/theme/theme_extensions.dart'; +import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; /// A reusable tile for a single [bsky_graph.ListView] entry. class ListRowTile extends StatelessWidget { @@ -19,10 +20,11 @@ class ListRowTile extends StatelessWidget { return ListTile( key: key, - leading: CircleAvatar( - backgroundImage: list.avatar != null ? NetworkImage(list.avatar!) : null, - backgroundColor: colorScheme.surfaceContainerHighest, - child: list.avatar == null ? Icon(Icons.list, color: colorScheme.onSurfaceVariant) : null, + leading: ProfileAvatar( + size: 40, + imageUrl: list.avatar, + fallbackText: list.name, + fallbackBuilder: (_) => Icon(Icons.list, color: colorScheme.onSurfaceVariant), ), title: Text(list.name, maxLines: 1, overflow: TextOverflow.ellipsis), subtitle: Text('${list.listItemCount ?? 0} members', style: TextStyle(color: colorScheme.onSurfaceVariant)), diff --git a/lib/features/messages/presentation/widgets/convo_list_item.dart b/lib/features/messages/presentation/widgets/convo_list_item.dart index 564553e..b53c027 100644 --- a/lib/features/messages/presentation/widgets/convo_list_item.dart +++ b/lib/features/messages/presentation/widgets/convo_list_item.dart @@ -1,5 +1,8 @@ import 'package:bluesky/chat_bsky_convo_defs.dart'; import 'package:flutter/material.dart'; +import 'package:lazurite/core/theme/theme_extensions.dart'; +import 'package:lazurite/shared/presentation/widgets/actor_name_widget.dart'; +import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; class ConvoListItem extends StatelessWidget { const ConvoListItem({ @@ -19,22 +22,26 @@ class ConvoListItem extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); final other = convo.members.where((m) => m.did != currentUserDid).firstOrNull; - final displayName = other?.displayName ?? other?.handle ?? 'Unknown'; + final displayName = other?.displayName; + final handle = other?.handle ?? 'unknown'; + final fallbackName = displayName ?? handle; final lastMessageText = _lastMessageText(); return ListTile( onTap: onTap, - leading: _buildAvatar(context, other?.avatar), + leading: _buildAvatar(other?.avatar, fallbackName), title: Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( - child: Text( - displayName, - style: theme.textTheme.bodyLarge?.copyWith( + child: ActorNameWidget( + displayName: displayName, + handle: handle, + displayNameStyle: theme.textTheme.bodyLarge?.copyWith( fontWeight: convo.unreadCount > 0 ? FontWeight.w700 : FontWeight.normal, ), - maxLines: 1, - overflow: TextOverflow.ellipsis, + handleStyle: theme.textTheme.bodySmall?.copyWith(color: context.colorScheme.onSurfaceVariant), + uppercaseHandle: false, ), ), if (convo.muted) @@ -79,13 +86,12 @@ class ConvoListItem extends StatelessWidget { ); } - Widget _buildAvatar(BuildContext context, String? avatarUrl) { - final theme = Theme.of(context); - return CircleAvatar( - radius: 24, - backgroundColor: theme.colorScheme.surfaceContainerHighest, - backgroundImage: avatarUrl != null ? NetworkImage(avatarUrl) : null, - child: avatarUrl == null ? const Icon(Icons.person) : null, + Widget _buildAvatar(String? avatarUrl, String fallbackText) { + return ProfileAvatar( + size: 48, + imageUrl: avatarUrl, + fallbackText: fallbackText, + fallbackBuilder: (_) => const Icon(Icons.person), ); } diff --git a/lib/features/notifications/presentation/widgets/grouped_notification_list_item.dart b/lib/features/notifications/presentation/widgets/grouped_notification_list_item.dart index 7b31876..4577e17 100644 --- a/lib/features/notifications/presentation/widgets/grouped_notification_list_item.dart +++ b/lib/features/notifications/presentation/widgets/grouped_notification_list_item.dart @@ -4,8 +4,9 @@ import 'package:bluesky/moderation.dart' as bsky_moderation; import 'package:flutter/material.dart' hide Notification; import 'package:go_router/go_router.dart'; import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; -import 'package:lazurite/features/moderation/presentation/widgets/moderated_avatar.dart'; import 'package:lazurite/features/moderation/presentation/widgets/moderated_blur_overlay.dart'; +import 'package:lazurite/shared/presentation/helpers/notification_icon_mapper.dart'; +import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; import 'package:lazurite/shared/utils/format_utils.dart'; import 'package:lazurite/core/theme/theme_extensions.dart'; @@ -119,11 +120,11 @@ class GroupedNotificationListItem extends StatelessWidget { shape: BoxShape.circle, border: Border.all(color: context.colorScheme.surface, width: 2), ), - child: ModeratedAvatar( + child: ProfileAvatar( size: 28, - ui: avatarUi, + moderationUi: avatarUi, imageUrl: author.avatar, - initials: _getInitials(author.displayName ?? author.handle), + fallbackText: author.displayName ?? author.handle, shape: BoxShape.circle, placeholderTextStyle: const TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: Colors.black54), ), @@ -131,55 +132,13 @@ class GroupedNotificationListItem extends StatelessWidget { } Widget _buildReasonIcon(ThemeData theme, bsky.Notification notification) { - final reason = notification.reason; - final colorScheme = theme.colorScheme; - - Color backgroundColor; - Color iconColor; - IconData iconData; - - if (reason.isKnownValue) { - switch (reason.knownValue) { - case bsky.KnownNotificationReason.like: - backgroundColor = colorScheme.error.withValues(alpha: 0.1); - iconColor = colorScheme.error; - iconData = Icons.favorite; - case bsky.KnownNotificationReason.repost: - backgroundColor = Colors.green.withValues(alpha: 0.1); - iconColor = Colors.green; - iconData = Icons.repeat; - case bsky.KnownNotificationReason.follow: - backgroundColor = colorScheme.primary.withValues(alpha: 0.1); - iconColor = colorScheme.primary; - iconData = Icons.person_add; - case bsky.KnownNotificationReason.reply: - backgroundColor = colorScheme.secondary.withValues(alpha: 0.1); - iconColor = colorScheme.secondary; - iconData = Icons.chat_bubble; - case bsky.KnownNotificationReason.mention: - backgroundColor = colorScheme.primary.withValues(alpha: 0.1); - iconColor = colorScheme.primary; - iconData = Icons.alternate_email; - case bsky.KnownNotificationReason.quote: - backgroundColor = Colors.purple.withValues(alpha: 0.1); - iconColor = Colors.purple; - iconData = Icons.format_quote; - default: - backgroundColor = colorScheme.surfaceContainerHighest; - iconColor = colorScheme.onSurfaceVariant; - iconData = Icons.notifications; - } - } else { - backgroundColor = colorScheme.surfaceContainerHighest; - iconColor = colorScheme.onSurfaceVariant; - iconData = Icons.notifications; - } + final iconStyle = NotificationIconMapper.map(reason: notification.reason, colorScheme: theme.colorScheme); return Container( width: 32, height: 32, - decoration: BoxDecoration(color: backgroundColor, shape: BoxShape.circle), - child: Icon(iconData, size: 16, color: iconColor), + decoration: BoxDecoration(color: iconStyle.backgroundColor, shape: BoxShape.circle), + child: Icon(iconStyle.icon, size: 16, color: iconStyle.iconColor), ); } @@ -288,14 +247,6 @@ class GroupedNotificationListItem extends StatelessWidget { ); } - String _getInitials(String text) { - final parts = text.split(' '); - if (parts.length >= 2) { - return '${parts[0][0]}${parts[1][0]}'.toUpperCase(); - } - return text.substring(0, text.length >= 2 ? 2 : 1).toUpperCase(); - } - void _onTap(BuildContext context) { final notification = group.latest; final reason = notification.reason; diff --git a/lib/features/notifications/presentation/widgets/notification_list_item.dart b/lib/features/notifications/presentation/widgets/notification_list_item.dart index 5415a02..1fc81a7 100644 --- a/lib/features/notifications/presentation/widgets/notification_list_item.dart +++ b/lib/features/notifications/presentation/widgets/notification_list_item.dart @@ -3,9 +3,10 @@ import 'package:bluesky/moderation.dart' as bsky_moderation; import 'package:flutter/material.dart' hide Notification; import 'package:go_router/go_router.dart'; import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; -import 'package:lazurite/features/moderation/presentation/widgets/moderated_avatar.dart'; import 'package:lazurite/features/moderation/presentation/widgets/moderated_blur_overlay.dart'; import 'package:lazurite/features/moderation/presentation/widgets/moderation_badge_row.dart'; +import 'package:lazurite/shared/presentation/helpers/notification_icon_mapper.dart'; +import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; import 'package:lazurite/shared/utils/format_utils.dart'; class NotificationListItem extends StatelessWidget { @@ -61,55 +62,13 @@ class NotificationListItem extends StatelessWidget { } Widget _buildReasonIcon(ThemeData theme) { - final reason = notification.reason; - final colorScheme = theme.colorScheme; - - Color backgroundColor; - Color iconColor; - IconData iconData; - - if (reason.isKnownValue) { - switch (reason.knownValue) { - case bsky.KnownNotificationReason.like: - backgroundColor = colorScheme.error.withValues(alpha: 0.1); - iconColor = colorScheme.error; - iconData = Icons.favorite; - case bsky.KnownNotificationReason.repost: - backgroundColor = Colors.green.withValues(alpha: 0.1); - iconColor = Colors.green; - iconData = Icons.repeat; - case bsky.KnownNotificationReason.follow: - backgroundColor = colorScheme.primary.withValues(alpha: 0.1); - iconColor = colorScheme.primary; - iconData = Icons.person_add; - case bsky.KnownNotificationReason.reply: - backgroundColor = colorScheme.secondary.withValues(alpha: 0.1); - iconColor = colorScheme.secondary; - iconData = Icons.chat_bubble; - case bsky.KnownNotificationReason.mention: - backgroundColor = colorScheme.primary.withValues(alpha: 0.1); - iconColor = colorScheme.primary; - iconData = Icons.alternate_email; - case bsky.KnownNotificationReason.quote: - backgroundColor = Colors.purple.withValues(alpha: 0.1); - iconColor = Colors.purple; - iconData = Icons.format_quote; - default: - backgroundColor = colorScheme.surfaceContainerHighest; - iconColor = colorScheme.onSurfaceVariant; - iconData = Icons.notifications; - } - } else { - backgroundColor = colorScheme.surfaceContainerHighest; - iconColor = colorScheme.onSurfaceVariant; - iconData = Icons.notifications; - } + final iconStyle = NotificationIconMapper.map(reason: notification.reason, colorScheme: theme.colorScheme); return Container( width: 32, height: 32, - decoration: BoxDecoration(color: backgroundColor, shape: BoxShape.circle), - child: Icon(iconData, size: 16, color: iconColor), + decoration: BoxDecoration(color: iconStyle.backgroundColor, shape: BoxShape.circle), + child: Icon(iconStyle.icon, size: 16, color: iconStyle.iconColor), ); } @@ -122,11 +81,11 @@ class NotificationListItem extends StatelessWidget { return Row( children: [ - ModeratedAvatar( + ProfileAvatar( size: 28, - ui: avatarUi, + moderationUi: avatarUi, imageUrl: author.avatar, - initials: _getInitials(author.displayName ?? author.handle), + fallbackText: author.displayName ?? author.handle, shape: BoxShape.circle, placeholderTextStyle: const TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: Colors.black54), ), @@ -134,14 +93,6 @@ class NotificationListItem extends StatelessWidget { ); } - String _getInitials(String text) { - final parts = text.split(' '); - if (parts.length >= 2) { - return '${parts[0][0]}${parts[1][0]}'.toUpperCase(); - } - return text.substring(0, text.length >= 2 ? 2 : 1).toUpperCase(); - } - Widget _buildSummary(ThemeData theme) { final author = notification.author; final displayName = author.displayName ?? author.handle; diff --git a/lib/features/profile/presentation/widgets/suggested_follows_list.dart b/lib/features/profile/presentation/widgets/suggested_follows_list.dart index a79f89e..de59a60 100644 --- a/lib/features/profile/presentation/widgets/suggested_follows_list.dart +++ b/lib/features/profile/presentation/widgets/suggested_follows_list.dart @@ -4,10 +4,10 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:lazurite/features/profile/cubit/profile_action_cubit.dart'; import 'package:lazurite/features/profile/cubit/suggested_follows_cubit.dart'; import 'package:lazurite/features/profile/data/profile_action_repository.dart'; +import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; import 'package:lazurite/shared/presentation/widgets/empty_state.dart'; import 'package:lazurite/shared/presentation/widgets/error_state.dart'; import 'package:lazurite/shared/presentation/widgets/loading_state.dart'; -import 'package:lazurite/shared/utils/format_utils.dart'; class SuggestedFollowsList extends StatelessWidget { const SuggestedFollowsList({ @@ -115,10 +115,7 @@ class _SuggestedProfileTileBody extends StatelessWidget { }, builder: (context, state) { return ListTile( - leading: CircleAvatar( - backgroundImage: profile.avatar != null ? NetworkImage(profile.avatar!) : null, - child: profile.avatar == null ? Text(formatInitials(title)) : null, - ), + leading: ProfileAvatar(size: 40, imageUrl: profile.avatar, fallbackText: title), title: Text(title), subtitle: Text('@${profile.handle}'), trailing: _FollowButton( @@ -143,10 +140,7 @@ class _StaticSuggestedProfileTile extends StatelessWidget { Widget build(BuildContext context) { final title = profile.displayName?.isNotEmpty == true ? profile.displayName! : profile.handle; return ListTile( - leading: CircleAvatar( - backgroundImage: profile.avatar != null ? NetworkImage(profile.avatar!) : null, - child: profile.avatar == null ? Text(formatInitials(title)) : null, - ), + leading: ProfileAvatar(size: 40, imageUrl: profile.avatar, fallbackText: title), title: Text(title), subtitle: Text('@${profile.handle}'), onTap: onTap == null ? null : () => onTap!(profile), diff --git a/lib/features/search/presentation/hashtag_screen.dart b/lib/features/search/presentation/hashtag_screen.dart index 54de0b2..53db7d5 100644 --- a/lib/features/search/presentation/hashtag_screen.dart +++ b/lib/features/search/presentation/hashtag_screen.dart @@ -7,12 +7,12 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:lazurite/features/feed/presentation/widgets/facet_text.dart'; import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; -import 'package:lazurite/features/moderation/presentation/widgets/moderated_avatar.dart'; import 'package:lazurite/features/moderation/presentation/widgets/moderated_blur_overlay.dart'; import 'package:lazurite/features/moderation/presentation/widgets/moderation_badge_row.dart'; import 'package:lazurite/features/search/cubit/hashtag_cubit.dart'; import 'package:lazurite/features/search/data/hashtag_utils.dart'; import 'package:lazurite/shared/presentation/widgets/options_sheet.dart'; +import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; import 'package:lazurite/shared/utils/format_utils.dart'; import 'package:lazurite/core/theme/theme_extensions.dart'; @@ -309,11 +309,11 @@ class _HashtagPostCard extends StatelessWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - ModeratedAvatar( + ProfileAvatar( size: 44, - ui: avatarUi, + moderationUi: avatarUi, imageUrl: author.avatar, - initials: formatInitials(author.displayName ?? author.handle), + fallbackText: author.displayName ?? author.handle, shape: BoxShape.circle, ), const SizedBox(width: 12), diff --git a/lib/features/search/presentation/search_screen.dart b/lib/features/search/presentation/search_screen.dart index 16c2133..cee8b90 100644 --- a/lib/features/search/presentation/search_screen.dart +++ b/lib/features/search/presentation/search_screen.dart @@ -11,13 +11,13 @@ import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; import 'package:lazurite/features/feed/cubit/feed_preferences_cubit.dart'; import 'package:lazurite/features/feed/presentation/widgets/facet_text.dart'; import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; -import 'package:lazurite/features/moderation/presentation/widgets/moderated_avatar.dart'; import 'package:lazurite/features/moderation/presentation/widgets/moderated_blur_overlay.dart'; import 'package:lazurite/features/moderation/presentation/widgets/moderation_badge_row.dart'; import 'package:lazurite/features/search/bloc/search_bloc.dart'; import 'package:lazurite/features/starter_packs/presentation/widgets/starter_pack_card.dart'; import 'package:lazurite/shared/presentation/helpers/snackbar_helper.dart'; import 'package:lazurite/shared/presentation/widgets/confirmation_dialog.dart'; +import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; import 'package:lazurite/shared/utils/format_utils.dart'; import 'package:lazurite/core/theme/theme_extensions.dart'; @@ -651,11 +651,11 @@ class _PostViewCard extends StatelessWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - ModeratedAvatar( + ProfileAvatar( size: 44, - ui: avatarUi, + moderationUi: avatarUi, imageUrl: author.avatar, - initials: formatInitials(author.displayName ?? author.handle), + fallbackText: author.displayName ?? author.handle, shape: BoxShape.circle, ), const SizedBox(width: 12), @@ -755,11 +755,11 @@ class _ActorResultTile extends StatelessWidget { ), child: Row( children: [ - ModeratedAvatar( + ProfileAvatar( size: 48, - ui: avatarUi, + moderationUi: avatarUi, imageUrl: actor.avatar, - initials: formatInitials(actor.displayName ?? actor.handle), + fallbackText: actor.displayName ?? actor.handle, shape: BoxShape.circle, ), const SizedBox(width: 12), @@ -831,11 +831,11 @@ class _ActorListTile extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row( children: [ - ModeratedAvatar( + ProfileAvatar( size: 40, - ui: avatarUi, + moderationUi: avatarUi, imageUrl: actor.avatar, - initials: formatInitials(actor.displayName ?? actor.handle), + fallbackText: actor.displayName ?? actor.handle, shape: BoxShape.circle, placeholderTextStyle: context.textTheme.labelMedium, ), diff --git a/lib/features/settings/presentation/settings_screen.dart b/lib/features/settings/presentation/settings_screen.dart index 43b16c2..48a7527 100644 --- a/lib/features/settings/presentation/settings_screen.dart +++ b/lib/features/settings/presentation/settings_screen.dart @@ -19,6 +19,7 @@ import 'package:lazurite/features/search/cubit/semantic_search_cubit.dart'; import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; import 'package:lazurite/features/settings/bloc/settings_state.dart'; import 'package:lazurite/shared/presentation/helpers/snackbar_helper.dart'; +import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; class SettingsScreen extends StatelessWidget { const SettingsScreen({super.key}); @@ -55,9 +56,7 @@ class SettingsScreen extends StatelessWidget { : '@${tokens.handle}'; return ListTile( - leading: CircleAvatar( - child: Text((tokens.displayName ?? tokens.handle).substring(0, 1).toUpperCase()), - ), + leading: ProfileAvatar(size: 40, fallbackText: tokens.displayName ?? tokens.handle), title: Text(tokens.displayName ?? tokens.handle), subtitle: Text(subtitle), trailing: const Icon(Icons.chevron_right), diff --git a/lib/features/starter_packs/presentation/create_edit_starter_pack_screen.dart b/lib/features/starter_packs/presentation/create_edit_starter_pack_screen.dart index ee31694..f494627 100644 --- a/lib/features/starter_packs/presentation/create_edit_starter_pack_screen.dart +++ b/lib/features/starter_packs/presentation/create_edit_starter_pack_screen.dart @@ -8,6 +8,7 @@ import 'package:lazurite/features/lists/data/list_repository.dart'; import 'package:lazurite/features/starter_packs/bloc/starter_pack_bloc.dart'; import 'package:lazurite/features/starter_packs/data/starter_pack_repository.dart'; import 'package:lazurite/core/theme/theme_extensions.dart'; +import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; /// Full-screen form for creating a new starter pack. /// @@ -225,18 +226,11 @@ class _CreateStarterPackScreenState extends State { final isAlreadyAdded = _selectedMembers.any((m) => m.did == profile.did); return ListTile( dense: true, - leading: CircleAvatar( - radius: 16, - backgroundColor: colorScheme.surfaceContainerHighest, - backgroundImage: profile.avatar != null ? NetworkImage(profile.avatar!) : null, - child: profile.avatar == null - ? Text( - (profile.displayName?.isNotEmpty == true ? profile.displayName! : profile.handle) - .substring(0, 1) - .toUpperCase(), - style: const TextStyle(fontSize: 12), - ) - : null, + leading: ProfileAvatar( + size: 32, + imageUrl: profile.avatar, + fallbackText: profile.displayName?.isNotEmpty == true ? profile.displayName! : profile.handle, + placeholderTextStyle: const TextStyle(fontSize: 12), ), title: Text(profile.displayName ?? profile.handle, maxLines: 1, overflow: TextOverflow.ellipsis), subtitle: Text('@${profile.handle}', style: TextStyle(color: colorScheme.onSurfaceVariant)), @@ -255,17 +249,11 @@ class _CreateStarterPackScreenState extends State { runSpacing: 8, children: _selectedMembers.map((member) { return Chip( - avatar: CircleAvatar( - backgroundImage: member.avatar != null ? NetworkImage(member.avatar!) : null, - backgroundColor: colorScheme.surfaceContainerHighest, - child: member.avatar == null - ? Text( - (member.displayName?.isNotEmpty == true ? member.displayName! : member.handle) - .substring(0, 1) - .toUpperCase(), - style: const TextStyle(fontSize: 10), - ) - : null, + avatar: ProfileAvatar( + size: 24, + imageUrl: member.avatar, + fallbackText: member.displayName?.isNotEmpty == true ? member.displayName! : member.handle, + placeholderTextStyle: const TextStyle(fontSize: 10), ), label: Text(member.displayName ?? member.handle, maxLines: 1, overflow: TextOverflow.ellipsis), onDeleted: isCreating ? null : () => _removeMember(member.did), @@ -295,11 +283,11 @@ class _CreateStarterPackScreenState extends State { for (final feed in _selectedFeeds) ListTile( contentPadding: EdgeInsets.zero, - leading: CircleAvatar( - radius: 20, - backgroundColor: colorScheme.surfaceContainerHighest, - backgroundImage: feed.avatar != null ? NetworkImage(feed.avatar!) : null, - child: feed.avatar == null ? const Icon(Icons.rss_feed) : null, + leading: ProfileAvatar( + size: 40, + imageUrl: feed.avatar, + fallbackText: feed.displayName, + fallbackBuilder: (_) => const Icon(Icons.rss_feed), ), title: Text(feed.displayName, maxLines: 1, overflow: TextOverflow.ellipsis), subtitle: feed.description != null @@ -388,10 +376,11 @@ class _FeedPickerSheetState extends State<_FeedPickerSheet> { final isSelected = widget.alreadySelected.contains(feed.uri); return ListTile( - leading: CircleAvatar( - backgroundColor: colorScheme.surfaceContainerHighest, - backgroundImage: feed.avatar != null ? NetworkImage(feed.avatar!) : null, - child: feed.avatar == null ? const Icon(Icons.rss_feed) : null, + leading: ProfileAvatar( + size: 40, + imageUrl: feed.avatar, + fallbackText: feed.displayName, + fallbackBuilder: (_) => const Icon(Icons.rss_feed), ), title: Text(feed.displayName, maxLines: 1, overflow: TextOverflow.ellipsis), subtitle: feed.description != null diff --git a/lib/features/starter_packs/presentation/widgets/starter_pack_card.dart b/lib/features/starter_packs/presentation/widgets/starter_pack_card.dart index 7fc20f6..0040453 100644 --- a/lib/features/starter_packs/presentation/widgets/starter_pack_card.dart +++ b/lib/features/starter_packs/presentation/widgets/starter_pack_card.dart @@ -1,5 +1,6 @@ import 'package:bluesky/app_bsky_graph_defs.dart'; import 'package:flutter/material.dart'; +import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; import 'package:lazurite/shared/utils/format_utils.dart'; import 'package:lazurite/core/theme/theme_extensions.dart'; @@ -31,10 +32,11 @@ class StarterPackCard extends StatelessWidget { children: [ Row( children: [ - CircleAvatar( - radius: 20, + ProfileAvatar( + size: 40, + fallbackText: name, backgroundColor: colorScheme.primaryContainer, - child: Icon(Icons.group_outlined, color: colorScheme.onPrimaryContainer, size: 20), + fallbackBuilder: (_) => Icon(Icons.group_outlined, color: colorScheme.onPrimaryContainer, size: 20), ), const SizedBox(width: 12), Expanded( diff --git a/lib/shared/presentation/helpers/notification_icon_mapper.dart b/lib/shared/presentation/helpers/notification_icon_mapper.dart new file mode 100644 index 0000000..e7e3a3d --- /dev/null +++ b/lib/shared/presentation/helpers/notification_icon_mapper.dart @@ -0,0 +1,67 @@ +import 'package:bluesky/app_bsky_notification_listnotifications.dart' as bsky; +import 'package:flutter/material.dart'; + +class NotificationIconStyle { + const NotificationIconStyle({required this.backgroundColor, required this.iconColor, required this.icon}); + + final Color backgroundColor; + final Color iconColor; + final IconData icon; +} + +abstract final class NotificationIconMapper { + static NotificationIconStyle map({required bsky.NotificationReason reason, required ColorScheme colorScheme}) { + if (!reason.isKnownValue) { + return NotificationIconStyle( + backgroundColor: colorScheme.surfaceContainerHighest, + iconColor: colorScheme.onSurfaceVariant, + icon: Icons.notifications, + ); + } + + switch (reason.knownValue) { + case bsky.KnownNotificationReason.like: + return NotificationIconStyle( + backgroundColor: colorScheme.error.withValues(alpha: 0.1), + iconColor: colorScheme.error, + icon: Icons.favorite, + ); + case bsky.KnownNotificationReason.repost: + return NotificationIconStyle( + backgroundColor: Colors.green.withValues(alpha: 0.1), + iconColor: Colors.green, + icon: Icons.repeat, + ); + case bsky.KnownNotificationReason.follow: + return NotificationIconStyle( + backgroundColor: colorScheme.primary.withValues(alpha: 0.1), + iconColor: colorScheme.primary, + icon: Icons.person_add, + ); + case bsky.KnownNotificationReason.reply: + return NotificationIconStyle( + backgroundColor: colorScheme.secondary.withValues(alpha: 0.1), + iconColor: colorScheme.secondary, + icon: Icons.chat_bubble, + ); + case bsky.KnownNotificationReason.mention: + return NotificationIconStyle( + backgroundColor: colorScheme.primary.withValues(alpha: 0.1), + iconColor: colorScheme.primary, + icon: Icons.alternate_email, + ); + case bsky.KnownNotificationReason.quote: + return NotificationIconStyle( + backgroundColor: Colors.purple.withValues(alpha: 0.1), + iconColor: Colors.purple, + icon: Icons.format_quote, + ); + default: + return NotificationIconStyle( + backgroundColor: colorScheme.surfaceContainerHighest, + iconColor: colorScheme.onSurfaceVariant, + icon: Icons.notifications, + ); + } + } +} diff --git a/lib/shared/presentation/widgets/actor_name_widget.dart b/lib/shared/presentation/widgets/actor_name_widget.dart new file mode 100644 index 0000000..6590b3f --- /dev/null +++ b/lib/shared/presentation/widgets/actor_name_widget.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; + +class ActorNameWidget extends StatelessWidget { + const ActorNameWidget({ + super.key, + required this.handle, + this.displayName, + this.displayNameStyle, + this.handleStyle, + this.maxLines = 1, + this.overflow = TextOverflow.ellipsis, + this.uppercaseHandle = true, + this.showHandle = true, + this.showDisplayNameOnlyWhenPresent = false, + this.handlePrefix = '@', + this.gap = 2, + }); + + final String handle; + final String? displayName; + final TextStyle? displayNameStyle; + final TextStyle? handleStyle; + final int maxLines; + final TextOverflow overflow; + final bool uppercaseHandle; + final bool showHandle; + final bool showDisplayNameOnlyWhenPresent; + final String handlePrefix; + final double gap; + + @override + Widget build(BuildContext context) { + final normalizedDisplayName = displayName?.trim(); + final hasDisplayName = normalizedDisplayName != null && normalizedDisplayName.isNotEmpty; + final shouldShowDisplayName = hasDisplayName || !showDisplayNameOnlyWhenPresent; + final displayText = hasDisplayName ? normalizedDisplayName : handle; + final handleText = '$handlePrefix${uppercaseHandle ? handle.toUpperCase() : handle}'; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (shouldShowDisplayName) Text(displayText, style: displayNameStyle, maxLines: maxLines, overflow: overflow), + if (showHandle) ...[ + if (shouldShowDisplayName) SizedBox(height: gap), + Text(handleText, style: handleStyle, maxLines: maxLines, overflow: overflow), + ], + ], + ); + } +} diff --git a/lib/shared/presentation/widgets/profile_avatar.dart b/lib/shared/presentation/widgets/profile_avatar.dart new file mode 100644 index 0000000..0d9c4f5 --- /dev/null +++ b/lib/shared/presentation/widgets/profile_avatar.dart @@ -0,0 +1,69 @@ +import 'package:bluesky/moderation.dart' as bsky_moderation; +import 'package:flutter/material.dart'; +import 'package:lazurite/core/theme/theme_extensions.dart'; +import 'package:lazurite/shared/utils/format_utils.dart'; + +class ProfileAvatar extends StatelessWidget { + const ProfileAvatar({ + super.key, + required this.size, + required this.fallbackText, + this.imageUrl, + this.moderationUi, + this.shape = BoxShape.circle, + this.borderRadius, + this.border, + this.placeholderTextStyle, + this.fallbackBuilder, + this.backgroundColor, + }); + + final double size; + final String fallbackText; + final String? imageUrl; + final bsky_moderation.ModerationUI? moderationUi; + final BoxShape shape; + final BorderRadius? borderRadius; + final Border? border; + final TextStyle? placeholderTextStyle; + final WidgetBuilder? fallbackBuilder; + final Color? backgroundColor; + + @override + Widget build(BuildContext context) { + final colorScheme = context.colorScheme; + final shouldMask = moderationUi?.blur ?? false; + final containerColor = backgroundColor ?? colorScheme.surfaceContainerHighest; + + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: containerColor, + shape: shape, + borderRadius: shape == BoxShape.rectangle ? borderRadius : null, + border: border, + ), + clipBehavior: Clip.antiAlias, + child: shouldMask + ? Icon(Icons.shield_outlined, color: colorScheme.onSurfaceVariant, size: size * 0.44) + : imageUrl != null + ? Image.network( + imageUrl!, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => _buildFallback(context, containerColor), + ) + : _buildFallback(context, containerColor), + ); + } + + Widget _buildFallback(BuildContext context, Color backgroundColor) { + final resolvedFallback = fallbackBuilder?.call(context); + final textStyle = placeholderTextStyle ?? context.textTheme.labelLarge; + + return ColoredBox( + color: backgroundColor, + child: Center(child: resolvedFallback ?? Text(formatInitials(fallbackText), style: textStyle)), + ); + } +} diff --git a/test/shared/presentation/helpers/notification_icon_mapper_test.dart b/test/shared/presentation/helpers/notification_icon_mapper_test.dart new file mode 100644 index 0000000..137bf92 --- /dev/null +++ b/test/shared/presentation/helpers/notification_icon_mapper_test.dart @@ -0,0 +1,40 @@ +import 'package:bluesky/app_bsky_notification_listnotifications.dart' as bsky; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/shared/presentation/helpers/notification_icon_mapper.dart'; + +void main() { + group('NotificationIconMapper', () { + final colorScheme = ColorScheme.fromSeed(seedColor: const Color(0xFF1565C0)); + + test('maps like reason to favorite icon and error color', () { + final style = NotificationIconMapper.map( + reason: const bsky.NotificationReason.knownValue(data: bsky.KnownNotificationReason.like), + colorScheme: colorScheme, + ); + + expect(style.icon, Icons.favorite); + expect(style.iconColor, colorScheme.error); + }); + + test('maps follow reason to person_add icon and primary color', () { + final style = NotificationIconMapper.map( + reason: const bsky.NotificationReason.knownValue(data: bsky.KnownNotificationReason.follow), + colorScheme: colorScheme, + ); + + expect(style.icon, Icons.person_add); + expect(style.iconColor, colorScheme.primary); + }); + + test('maps quote reason to quote icon and purple color', () { + final style = NotificationIconMapper.map( + reason: const bsky.NotificationReason.knownValue(data: bsky.KnownNotificationReason.quote), + colorScheme: colorScheme, + ); + + expect(style.icon, Icons.format_quote); + expect(style.iconColor, Colors.purple); + }); + }); +} diff --git a/test/shared/presentation/widgets/actor_name_widget_test.dart b/test/shared/presentation/widgets/actor_name_widget_test.dart new file mode 100644 index 0000000..cc4993e --- /dev/null +++ b/test/shared/presentation/widgets/actor_name_widget_test.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/shared/presentation/widgets/actor_name_widget.dart'; + +void main() { + Widget buildSubject(Widget child) { + return MaterialApp( + home: Scaffold(body: Center(child: child)), + ); + } + + testWidgets('renders display name and uppercase handle by default', (tester) async { + await tester.pumpWidget( + buildSubject(const ActorNameWidget(displayName: 'Alice Smith', handle: 'alice.bsky.social')), + ); + + expect(find.text('Alice Smith'), findsOneWidget); + expect(find.text('@ALICE.BSKY.SOCIAL'), findsOneWidget); + }); + + testWidgets('renders only handle line when configured and displayName is missing', (tester) async { + await tester.pumpWidget( + buildSubject(const ActorNameWidget(handle: 'alice.bsky.social', showDisplayNameOnlyWhenPresent: true)), + ); + + expect(find.text('alice.bsky.social'), findsNothing); + expect(find.text('@ALICE.BSKY.SOCIAL'), findsOneWidget); + }); + + testWidgets('can preserve original handle case', (tester) async { + await tester.pumpWidget( + buildSubject( + const ActorNameWidget(displayName: 'Alice Smith', handle: 'alice.bsky.social', uppercaseHandle: false), + ), + ); + + expect(find.text('@alice.bsky.social'), findsOneWidget); + }); +} diff --git a/test/shared/presentation/widgets/profile_avatar_test.dart b/test/shared/presentation/widgets/profile_avatar_test.dart new file mode 100644 index 0000000..415ead7 --- /dev/null +++ b/test/shared/presentation/widgets/profile_avatar_test.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; + +void main() { + Widget buildSubject(Widget child) { + return MaterialApp( + home: Scaffold(body: Center(child: child)), + ); + } + + testWidgets('renders initials fallback when image is missing', (tester) async { + await tester.pumpWidget(buildSubject(const ProfileAvatar(size: 40, fallbackText: 'Alice Smith'))); + + expect(find.text('AS'), findsOneWidget); + }); + + testWidgets('uses custom fallback builder when provided', (tester) async { + await tester.pumpWidget( + buildSubject( + ProfileAvatar(size: 40, fallbackText: 'Alice Smith', fallbackBuilder: (_) => const Icon(Icons.person_outline)), + ), + ); + + expect(find.byIcon(Icons.person_outline), findsOneWidget); + expect(find.text('AS'), findsNothing); + }); + + testWidgets('respects the requested avatar size', (tester) async { + await tester.pumpWidget(buildSubject(const ProfileAvatar(size: 52, fallbackText: 'Alice Smith'))); + + final avatarSize = tester.getSize(find.byType(ProfileAvatar)); + expect(avatarSize.width, 52); + expect(avatarSize.height, 52); + }); +}