mobile bluesky app made with flutter lazurite.stormlightlabs.org/
mobile bluesky flutter
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor: avatar & notification widgets

+441 -289
+7 -27
docs/tasks/testing.md
··· 36 36 37 37 ## M4 - Widget Extraction 38 38 39 - - [ ] Create `lib/shared/presentation/widgets/profile_avatar.dart` (configurable size, shape, fallback) 40 - - [ ] Create `lib/shared/presentation/widgets/actor_name_widget.dart` (displayName + handle) 41 - - [ ] Create `lib/shared/presentation/helpers/notification_icon_mapper.dart` 42 - - [ ] Replace avatar patterns in: 43 - - `lib/features/messages/presentation/widgets/convo_list_item.dart` 44 - - `lib/features/lists/presentation/widgets/list_row_tile.dart` 45 - - `lib/features/settings/presentation/settings_screen.dart` 46 - - `lib/features/starter_packs/presentation/widgets/starter_pack_card.dart` 47 - - `lib/features/starter_packs/presentation/create_edit_starter_pack_screen.dart` 48 - - `lib/features/account/presentation/account_switcher_sheet.dart` 49 - - `lib/features/profile/presentation/widgets/suggested_follows_list.dart` 50 - - `lib/features/feed/presentation/widgets/post_card.dart` 51 - - `lib/features/feed/presentation/widgets/grid_post_card.dart` 52 - - `lib/features/feed/presentation/widgets/post_embed_view.dart` 53 - - `lib/features/notifications/presentation/widgets/notification_list_item.dart` 54 - - `lib/features/notifications/presentation/widgets/grouped_notification_list_item.dart` 55 - - `lib/features/search/presentation/search_screen.dart` 56 - - `lib/features/search/presentation/hashtag_screen.dart` 57 - - [ ] Replace author name patterns in: 58 - - `lib/features/feed/presentation/widgets/post_card.dart` 59 - - `lib/features/feed/presentation/widgets/grid_post_card.dart` 60 - - `lib/features/feed/presentation/widgets/post_embed_view.dart` 61 - - `lib/features/messages/presentation/widgets/convo_list_item.dart` 62 - - [ ] Replace notification icon switch in: 63 - - `lib/features/notifications/presentation/widgets/notification_list_item.dart` 64 - - `lib/features/notifications/presentation/widgets/grouped_notification_list_item.dart` 65 - - [ ] Widget tests for extracted widgets 39 + - [x] Create `lib/shared/presentation/widgets/profile_avatar.dart` (configurable size, shape, fallback) 40 + - [x] Create `lib/shared/presentation/widgets/actor_name_widget.dart` (displayName + handle) 41 + - [x] Create `lib/shared/presentation/helpers/notification_icon_mapper.dart` 42 + - [x] Replace avatar patterns 43 + - [x] Replace author name patterns 44 + - [x] Replace notification icon switch 45 + - [x] Widget tests for extracted widgets 66 46 67 47 ## M5 - Navigation & Haptics Helpers 68 48
+2 -1
lib/features/account/presentation/account_switcher_sheet.dart
··· 3 3 import 'package:lazurite/features/account/cubit/account_switcher_cubit.dart'; 4 4 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 5 5 import 'package:lazurite/shared/presentation/helpers/snackbar_helper.dart'; 6 + import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; 6 7 import 'package:lazurite/shared/presentation/widgets/confirmation_dialog.dart'; 7 8 import 'package:lazurite/shared/presentation/widgets/options_sheet.dart'; 8 9 ··· 60 61 final label = account.displayName ?? account.handle; 61 62 62 63 return ListTile( 63 - leading: CircleAvatar(child: Text(label.substring(0, 1).toUpperCase())), 64 + leading: ProfileAvatar(size: 40, fallbackText: label), 64 65 title: Text(label), 65 66 subtitle: Text('@${account.handle}'), 66 67 trailing: isActive ? const Icon(Icons.check) : null,
+15 -26
lib/features/feed/presentation/widgets/grid_post_card.dart
··· 13 13 import 'package:lazurite/features/feed/presentation/widgets/post_embed_view.dart'; 14 14 import 'package:lazurite/features/feed/presentation/widgets/post_text_styles.dart'; 15 15 import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; 16 - import 'package:lazurite/features/moderation/presentation/widgets/moderated_avatar.dart'; 17 16 import 'package:lazurite/features/moderation/presentation/widgets/moderated_blur_overlay.dart'; 18 17 import 'package:lazurite/features/moderation/presentation/widgets/moderation_badge_row.dart'; 19 - import 'package:lazurite/shared/utils/format_utils.dart'; 18 + import 'package:lazurite/shared/presentation/widgets/actor_name_widget.dart'; 19 + import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; 20 20 21 21 const double _gridEmbedPreviewMaxHeight = 240; 22 22 ··· 147 147 GestureDetector( 148 148 key: const ValueKey('grid_post_card_avatar'), 149 149 onTap: () => GoRouter.maybeOf(context)?.push('/profile/view?actor=${Uri.encodeQueryComponent(author.did)}'), 150 - child: ModeratedAvatar( 150 + child: ProfileAvatar( 151 151 size: 40, 152 - ui: avatarUi, 152 + moderationUi: avatarUi, 153 153 imageUrl: author.avatar, 154 - initials: formatInitials(author.displayName ?? author.handle), 154 + fallbackText: author.displayName ?? author.handle, 155 155 shape: BoxShape.rectangle, 156 156 border: Border.all(color: colorScheme.outlineVariant), 157 157 placeholderTextStyle: context.textTheme.labelMedium, ··· 159 159 ), 160 160 const SizedBox(width: AppSpacing.xs), 161 161 Expanded( 162 - child: Column( 163 - crossAxisAlignment: CrossAxisAlignment.start, 164 - children: [ 165 - if (author.displayName != null && author.displayName!.isNotEmpty) 166 - Text( 167 - author.displayName!, 168 - style: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w700), 169 - maxLines: 1, 170 - overflow: TextOverflow.ellipsis, 171 - ), 172 - Text( 173 - '@${author.handle}'.toUpperCase(), 174 - style: context.textTheme.labelSmall?.copyWith( 175 - fontWeight: FontWeight.w700, 176 - letterSpacing: 1.5, 177 - color: colorScheme.onSurfaceVariant, 178 - ), 179 - maxLines: 1, 180 - overflow: TextOverflow.ellipsis, 181 - ), 182 - ], 162 + child: ActorNameWidget( 163 + displayName: author.displayName, 164 + handle: author.handle, 165 + showDisplayNameOnlyWhenPresent: true, 166 + displayNameStyle: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w700), 167 + handleStyle: context.textTheme.labelSmall?.copyWith( 168 + fontWeight: FontWeight.w700, 169 + letterSpacing: 1.5, 170 + color: colorScheme.onSurfaceVariant, 171 + ), 183 172 ), 184 173 ), 185 174 ],
+14 -26
lib/features/feed/presentation/widgets/post_card.dart
··· 9 9 import 'package:lazurite/features/feed/presentation/widgets/post_embed_view.dart'; 10 10 import 'package:lazurite/features/feed/presentation/widgets/post_text_styles.dart'; 11 11 import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; 12 - import 'package:lazurite/features/moderation/presentation/widgets/moderated_avatar.dart'; 13 12 import 'package:lazurite/features/moderation/presentation/widgets/moderated_blur_overlay.dart'; 14 13 import 'package:lazurite/features/moderation/presentation/widgets/moderation_badge_row.dart'; 15 - import 'package:lazurite/shared/utils/format_utils.dart'; 14 + import 'package:lazurite/shared/presentation/widgets/actor_name_widget.dart'; 15 + import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; 16 16 import 'package:lazurite/core/theme/theme_extensions.dart'; 17 17 18 18 class PostCard extends StatelessWidget { ··· 99 99 GestureDetector( 100 100 key: const ValueKey('post_card_avatar'), 101 101 onTap: () => GoRouter.maybeOf(context)?.push('/profile/view?actor=${Uri.encodeQueryComponent(author.did)}'), 102 - child: ModeratedAvatar( 102 + child: ProfileAvatar( 103 103 size: 40, 104 - ui: avatarUi, 104 + moderationUi: avatarUi, 105 105 imageUrl: author.avatar, 106 - initials: formatInitials(author.displayName ?? author.handle), 106 + fallbackText: author.displayName ?? author.handle, 107 107 shape: BoxShape.rectangle, 108 108 border: Border.all(color: colorScheme.outlineVariant), 109 109 ), 110 110 ), 111 111 const SizedBox(width: 12), 112 112 Expanded( 113 - child: Column( 114 - crossAxisAlignment: CrossAxisAlignment.start, 115 - children: [ 116 - Text( 117 - author.displayName ?? author.handle, 118 - style: context.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w700), 119 - maxLines: 1, 120 - overflow: TextOverflow.ellipsis, 121 - ), 122 - const SizedBox(height: 2), 123 - Text( 124 - '@${author.handle}'.toUpperCase(), 125 - style: context.textTheme.labelSmall?.copyWith( 126 - color: colorScheme.onSurfaceVariant, 127 - fontWeight: FontWeight.w700, 128 - letterSpacing: 1.5, 129 - ), 130 - maxLines: 1, 131 - overflow: TextOverflow.ellipsis, 132 - ), 133 - ], 113 + child: ActorNameWidget( 114 + displayName: author.displayName, 115 + handle: author.handle, 116 + displayNameStyle: context.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w700), 117 + handleStyle: context.textTheme.labelSmall?.copyWith( 118 + color: colorScheme.onSurfaceVariant, 119 + fontWeight: FontWeight.w700, 120 + letterSpacing: 1.5, 121 + ), 134 122 ), 135 123 ), 136 124 ],
+15 -16
lib/features/feed/presentation/widgets/post_embed_view.dart
··· 16 16 import 'package:lazurite/features/feed/presentation/widgets/post_text_styles.dart'; 17 17 import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; 18 18 import 'package:lazurite/features/moderation/presentation/widgets/moderated_blur_overlay.dart'; 19 - import 'package:lazurite/shared/utils/format_utils.dart'; 19 + import 'package:lazurite/shared/presentation/widgets/actor_name_widget.dart'; 20 + import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; 20 21 import 'package:url_launcher/url_launcher.dart'; 21 22 import 'package:lazurite/core/theme/theme_extensions.dart'; 22 23 ··· 266 267 crossAxisAlignment: CrossAxisAlignment.start, 267 268 children: [ 268 269 Row( 270 + crossAxisAlignment: CrossAxisAlignment.start, 269 271 children: [ 270 - Container( 271 - width: 28, 272 - height: 28, 273 - decoration: BoxDecoration( 274 - color: context.colorScheme.surfaceContainerHighest, 275 - border: Border.all(color: context.colorScheme.outlineVariant), 276 - ), 277 - child: quoted.author.avatar != null 278 - ? Image.network(quoted.author.avatar!, fit: BoxFit.cover) 279 - : Center(child: Text(formatInitials(quoted.author.displayName ?? quoted.author.handle))), 272 + ProfileAvatar( 273 + size: 28, 274 + imageUrl: quoted.author.avatar, 275 + fallbackText: quoted.author.displayName ?? quoted.author.handle, 276 + shape: BoxShape.rectangle, 277 + border: Border.all(color: context.colorScheme.outlineVariant), 280 278 ), 281 279 const SizedBox(width: 8), 282 280 Expanded( 283 - child: Text( 284 - '${quoted.author.displayName ?? quoted.author.handle} @${quoted.author.handle}', 285 - maxLines: 1, 286 - overflow: TextOverflow.ellipsis, 287 - style: theme.textTheme.bodyMedium?.copyWith( 281 + child: ActorNameWidget( 282 + displayName: quoted.author.displayName, 283 + handle: quoted.author.handle, 284 + displayNameStyle: theme.textTheme.bodyMedium?.copyWith( 288 285 fontWeight: FontWeight.w600, 289 286 color: colorScheme.onSurface, 290 287 ), 288 + handleStyle: theme.textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), 289 + uppercaseHandle: false, 291 290 ), 292 291 ), 293 292 ],
+6 -4
lib/features/lists/presentation/widgets/list_row_tile.dart
··· 1 1 import 'package:bluesky/app_bsky_graph_defs.dart' as bsky_graph; 2 2 import 'package:flutter/material.dart'; 3 3 import 'package:lazurite/core/theme/theme_extensions.dart'; 4 + import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; 4 5 5 6 /// A reusable tile for a single [bsky_graph.ListView] entry. 6 7 class ListRowTile extends StatelessWidget { ··· 19 20 20 21 return ListTile( 21 22 key: key, 22 - leading: CircleAvatar( 23 - backgroundImage: list.avatar != null ? NetworkImage(list.avatar!) : null, 24 - backgroundColor: colorScheme.surfaceContainerHighest, 25 - child: list.avatar == null ? Icon(Icons.list, color: colorScheme.onSurfaceVariant) : null, 23 + leading: ProfileAvatar( 24 + size: 40, 25 + imageUrl: list.avatar, 26 + fallbackText: list.name, 27 + fallbackBuilder: (_) => Icon(Icons.list, color: colorScheme.onSurfaceVariant), 26 28 ), 27 29 title: Text(list.name, maxLines: 1, overflow: TextOverflow.ellipsis), 28 30 subtitle: Text('${list.listItemCount ?? 0} members', style: TextStyle(color: colorScheme.onSurfaceVariant)),
+20 -14
lib/features/messages/presentation/widgets/convo_list_item.dart
··· 1 1 import 'package:bluesky/chat_bsky_convo_defs.dart'; 2 2 import 'package:flutter/material.dart'; 3 + import 'package:lazurite/core/theme/theme_extensions.dart'; 4 + import 'package:lazurite/shared/presentation/widgets/actor_name_widget.dart'; 5 + import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; 3 6 4 7 class ConvoListItem extends StatelessWidget { 5 8 const ConvoListItem({ ··· 19 22 Widget build(BuildContext context) { 20 23 final theme = Theme.of(context); 21 24 final other = convo.members.where((m) => m.did != currentUserDid).firstOrNull; 22 - final displayName = other?.displayName ?? other?.handle ?? 'Unknown'; 25 + final displayName = other?.displayName; 26 + final handle = other?.handle ?? 'unknown'; 27 + final fallbackName = displayName ?? handle; 23 28 final lastMessageText = _lastMessageText(); 24 29 25 30 return ListTile( 26 31 onTap: onTap, 27 - leading: _buildAvatar(context, other?.avatar), 32 + leading: _buildAvatar(other?.avatar, fallbackName), 28 33 title: Row( 34 + crossAxisAlignment: CrossAxisAlignment.start, 29 35 children: [ 30 36 Expanded( 31 - child: Text( 32 - displayName, 33 - style: theme.textTheme.bodyLarge?.copyWith( 37 + child: ActorNameWidget( 38 + displayName: displayName, 39 + handle: handle, 40 + displayNameStyle: theme.textTheme.bodyLarge?.copyWith( 34 41 fontWeight: convo.unreadCount > 0 ? FontWeight.w700 : FontWeight.normal, 35 42 ), 36 - maxLines: 1, 37 - overflow: TextOverflow.ellipsis, 43 + handleStyle: theme.textTheme.bodySmall?.copyWith(color: context.colorScheme.onSurfaceVariant), 44 + uppercaseHandle: false, 38 45 ), 39 46 ), 40 47 if (convo.muted) ··· 79 86 ); 80 87 } 81 88 82 - Widget _buildAvatar(BuildContext context, String? avatarUrl) { 83 - final theme = Theme.of(context); 84 - return CircleAvatar( 85 - radius: 24, 86 - backgroundColor: theme.colorScheme.surfaceContainerHighest, 87 - backgroundImage: avatarUrl != null ? NetworkImage(avatarUrl) : null, 88 - child: avatarUrl == null ? const Icon(Icons.person) : null, 89 + Widget _buildAvatar(String? avatarUrl, String fallbackText) { 90 + return ProfileAvatar( 91 + size: 48, 92 + imageUrl: avatarUrl, 93 + fallbackText: fallbackText, 94 + fallbackBuilder: (_) => const Icon(Icons.person), 89 95 ); 90 96 } 91 97
+8 -57
lib/features/notifications/presentation/widgets/grouped_notification_list_item.dart
··· 4 4 import 'package:flutter/material.dart' hide Notification; 5 5 import 'package:go_router/go_router.dart'; 6 6 import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; 7 - import 'package:lazurite/features/moderation/presentation/widgets/moderated_avatar.dart'; 8 7 import 'package:lazurite/features/moderation/presentation/widgets/moderated_blur_overlay.dart'; 8 + import 'package:lazurite/shared/presentation/helpers/notification_icon_mapper.dart'; 9 + import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; 9 10 import 'package:lazurite/shared/utils/format_utils.dart'; 10 11 import 'package:lazurite/core/theme/theme_extensions.dart'; 11 12 ··· 119 120 shape: BoxShape.circle, 120 121 border: Border.all(color: context.colorScheme.surface, width: 2), 121 122 ), 122 - child: ModeratedAvatar( 123 + child: ProfileAvatar( 123 124 size: 28, 124 - ui: avatarUi, 125 + moderationUi: avatarUi, 125 126 imageUrl: author.avatar, 126 - initials: _getInitials(author.displayName ?? author.handle), 127 + fallbackText: author.displayName ?? author.handle, 127 128 shape: BoxShape.circle, 128 129 placeholderTextStyle: const TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: Colors.black54), 129 130 ), ··· 131 132 } 132 133 133 134 Widget _buildReasonIcon(ThemeData theme, bsky.Notification notification) { 134 - final reason = notification.reason; 135 - final colorScheme = theme.colorScheme; 136 - 137 - Color backgroundColor; 138 - Color iconColor; 139 - IconData iconData; 140 - 141 - if (reason.isKnownValue) { 142 - switch (reason.knownValue) { 143 - case bsky.KnownNotificationReason.like: 144 - backgroundColor = colorScheme.error.withValues(alpha: 0.1); 145 - iconColor = colorScheme.error; 146 - iconData = Icons.favorite; 147 - case bsky.KnownNotificationReason.repost: 148 - backgroundColor = Colors.green.withValues(alpha: 0.1); 149 - iconColor = Colors.green; 150 - iconData = Icons.repeat; 151 - case bsky.KnownNotificationReason.follow: 152 - backgroundColor = colorScheme.primary.withValues(alpha: 0.1); 153 - iconColor = colorScheme.primary; 154 - iconData = Icons.person_add; 155 - case bsky.KnownNotificationReason.reply: 156 - backgroundColor = colorScheme.secondary.withValues(alpha: 0.1); 157 - iconColor = colorScheme.secondary; 158 - iconData = Icons.chat_bubble; 159 - case bsky.KnownNotificationReason.mention: 160 - backgroundColor = colorScheme.primary.withValues(alpha: 0.1); 161 - iconColor = colorScheme.primary; 162 - iconData = Icons.alternate_email; 163 - case bsky.KnownNotificationReason.quote: 164 - backgroundColor = Colors.purple.withValues(alpha: 0.1); 165 - iconColor = Colors.purple; 166 - iconData = Icons.format_quote; 167 - default: 168 - backgroundColor = colorScheme.surfaceContainerHighest; 169 - iconColor = colorScheme.onSurfaceVariant; 170 - iconData = Icons.notifications; 171 - } 172 - } else { 173 - backgroundColor = colorScheme.surfaceContainerHighest; 174 - iconColor = colorScheme.onSurfaceVariant; 175 - iconData = Icons.notifications; 176 - } 135 + final iconStyle = NotificationIconMapper.map(reason: notification.reason, colorScheme: theme.colorScheme); 177 136 178 137 return Container( 179 138 width: 32, 180 139 height: 32, 181 - decoration: BoxDecoration(color: backgroundColor, shape: BoxShape.circle), 182 - child: Icon(iconData, size: 16, color: iconColor), 140 + decoration: BoxDecoration(color: iconStyle.backgroundColor, shape: BoxShape.circle), 141 + child: Icon(iconStyle.icon, size: 16, color: iconStyle.iconColor), 183 142 ); 184 143 } 185 144 ··· 286 245 ), 287 246 ), 288 247 ); 289 - } 290 - 291 - String _getInitials(String text) { 292 - final parts = text.split(' '); 293 - if (parts.length >= 2) { 294 - return '${parts[0][0]}${parts[1][0]}'.toUpperCase(); 295 - } 296 - return text.substring(0, text.length >= 2 ? 2 : 1).toUpperCase(); 297 248 } 298 249 299 250 void _onTap(BuildContext context) {
+8 -57
lib/features/notifications/presentation/widgets/notification_list_item.dart
··· 3 3 import 'package:flutter/material.dart' hide Notification; 4 4 import 'package:go_router/go_router.dart'; 5 5 import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; 6 - import 'package:lazurite/features/moderation/presentation/widgets/moderated_avatar.dart'; 7 6 import 'package:lazurite/features/moderation/presentation/widgets/moderated_blur_overlay.dart'; 8 7 import 'package:lazurite/features/moderation/presentation/widgets/moderation_badge_row.dart'; 8 + import 'package:lazurite/shared/presentation/helpers/notification_icon_mapper.dart'; 9 + import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; 9 10 import 'package:lazurite/shared/utils/format_utils.dart'; 10 11 11 12 class NotificationListItem extends StatelessWidget { ··· 61 62 } 62 63 63 64 Widget _buildReasonIcon(ThemeData theme) { 64 - final reason = notification.reason; 65 - final colorScheme = theme.colorScheme; 66 - 67 - Color backgroundColor; 68 - Color iconColor; 69 - IconData iconData; 70 - 71 - if (reason.isKnownValue) { 72 - switch (reason.knownValue) { 73 - case bsky.KnownNotificationReason.like: 74 - backgroundColor = colorScheme.error.withValues(alpha: 0.1); 75 - iconColor = colorScheme.error; 76 - iconData = Icons.favorite; 77 - case bsky.KnownNotificationReason.repost: 78 - backgroundColor = Colors.green.withValues(alpha: 0.1); 79 - iconColor = Colors.green; 80 - iconData = Icons.repeat; 81 - case bsky.KnownNotificationReason.follow: 82 - backgroundColor = colorScheme.primary.withValues(alpha: 0.1); 83 - iconColor = colorScheme.primary; 84 - iconData = Icons.person_add; 85 - case bsky.KnownNotificationReason.reply: 86 - backgroundColor = colorScheme.secondary.withValues(alpha: 0.1); 87 - iconColor = colorScheme.secondary; 88 - iconData = Icons.chat_bubble; 89 - case bsky.KnownNotificationReason.mention: 90 - backgroundColor = colorScheme.primary.withValues(alpha: 0.1); 91 - iconColor = colorScheme.primary; 92 - iconData = Icons.alternate_email; 93 - case bsky.KnownNotificationReason.quote: 94 - backgroundColor = Colors.purple.withValues(alpha: 0.1); 95 - iconColor = Colors.purple; 96 - iconData = Icons.format_quote; 97 - default: 98 - backgroundColor = colorScheme.surfaceContainerHighest; 99 - iconColor = colorScheme.onSurfaceVariant; 100 - iconData = Icons.notifications; 101 - } 102 - } else { 103 - backgroundColor = colorScheme.surfaceContainerHighest; 104 - iconColor = colorScheme.onSurfaceVariant; 105 - iconData = Icons.notifications; 106 - } 65 + final iconStyle = NotificationIconMapper.map(reason: notification.reason, colorScheme: theme.colorScheme); 107 66 108 67 return Container( 109 68 width: 32, 110 69 height: 32, 111 - decoration: BoxDecoration(color: backgroundColor, shape: BoxShape.circle), 112 - child: Icon(iconData, size: 16, color: iconColor), 70 + decoration: BoxDecoration(color: iconStyle.backgroundColor, shape: BoxShape.circle), 71 + child: Icon(iconStyle.icon, size: 16, color: iconStyle.iconColor), 113 72 ); 114 73 } 115 74 ··· 122 81 123 82 return Row( 124 83 children: [ 125 - ModeratedAvatar( 84 + ProfileAvatar( 126 85 size: 28, 127 - ui: avatarUi, 86 + moderationUi: avatarUi, 128 87 imageUrl: author.avatar, 129 - initials: _getInitials(author.displayName ?? author.handle), 88 + fallbackText: author.displayName ?? author.handle, 130 89 shape: BoxShape.circle, 131 90 placeholderTextStyle: const TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: Colors.black54), 132 91 ), 133 92 ], 134 93 ); 135 - } 136 - 137 - String _getInitials(String text) { 138 - final parts = text.split(' '); 139 - if (parts.length >= 2) { 140 - return '${parts[0][0]}${parts[1][0]}'.toUpperCase(); 141 - } 142 - return text.substring(0, text.length >= 2 ? 2 : 1).toUpperCase(); 143 94 } 144 95 145 96 Widget _buildSummary(ThemeData theme) {
+3 -9
lib/features/profile/presentation/widgets/suggested_follows_list.dart
··· 4 4 import 'package:lazurite/features/profile/cubit/profile_action_cubit.dart'; 5 5 import 'package:lazurite/features/profile/cubit/suggested_follows_cubit.dart'; 6 6 import 'package:lazurite/features/profile/data/profile_action_repository.dart'; 7 + import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; 7 8 import 'package:lazurite/shared/presentation/widgets/empty_state.dart'; 8 9 import 'package:lazurite/shared/presentation/widgets/error_state.dart'; 9 10 import 'package:lazurite/shared/presentation/widgets/loading_state.dart'; 10 - import 'package:lazurite/shared/utils/format_utils.dart'; 11 11 12 12 class SuggestedFollowsList extends StatelessWidget { 13 13 const SuggestedFollowsList({ ··· 115 115 }, 116 116 builder: (context, state) { 117 117 return ListTile( 118 - leading: CircleAvatar( 119 - backgroundImage: profile.avatar != null ? NetworkImage(profile.avatar!) : null, 120 - child: profile.avatar == null ? Text(formatInitials(title)) : null, 121 - ), 118 + leading: ProfileAvatar(size: 40, imageUrl: profile.avatar, fallbackText: title), 122 119 title: Text(title), 123 120 subtitle: Text('@${profile.handle}'), 124 121 trailing: _FollowButton( ··· 143 140 Widget build(BuildContext context) { 144 141 final title = profile.displayName?.isNotEmpty == true ? profile.displayName! : profile.handle; 145 142 return ListTile( 146 - leading: CircleAvatar( 147 - backgroundImage: profile.avatar != null ? NetworkImage(profile.avatar!) : null, 148 - child: profile.avatar == null ? Text(formatInitials(title)) : null, 149 - ), 143 + leading: ProfileAvatar(size: 40, imageUrl: profile.avatar, fallbackText: title), 150 144 title: Text(title), 151 145 subtitle: Text('@${profile.handle}'), 152 146 onTap: onTap == null ? null : () => onTap!(profile),
+4 -4
lib/features/search/presentation/hashtag_screen.dart
··· 7 7 import 'package:go_router/go_router.dart'; 8 8 import 'package:lazurite/features/feed/presentation/widgets/facet_text.dart'; 9 9 import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; 10 - import 'package:lazurite/features/moderation/presentation/widgets/moderated_avatar.dart'; 11 10 import 'package:lazurite/features/moderation/presentation/widgets/moderated_blur_overlay.dart'; 12 11 import 'package:lazurite/features/moderation/presentation/widgets/moderation_badge_row.dart'; 13 12 import 'package:lazurite/features/search/cubit/hashtag_cubit.dart'; 14 13 import 'package:lazurite/features/search/data/hashtag_utils.dart'; 15 14 import 'package:lazurite/shared/presentation/widgets/options_sheet.dart'; 15 + import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; 16 16 import 'package:lazurite/shared/utils/format_utils.dart'; 17 17 import 'package:lazurite/core/theme/theme_extensions.dart'; 18 18 ··· 309 309 child: Row( 310 310 crossAxisAlignment: CrossAxisAlignment.start, 311 311 children: [ 312 - ModeratedAvatar( 312 + ProfileAvatar( 313 313 size: 44, 314 - ui: avatarUi, 314 + moderationUi: avatarUi, 315 315 imageUrl: author.avatar, 316 - initials: formatInitials(author.displayName ?? author.handle), 316 + fallbackText: author.displayName ?? author.handle, 317 317 shape: BoxShape.circle, 318 318 ), 319 319 const SizedBox(width: 12),
+10 -10
lib/features/search/presentation/search_screen.dart
··· 11 11 import 'package:lazurite/features/feed/cubit/feed_preferences_cubit.dart'; 12 12 import 'package:lazurite/features/feed/presentation/widgets/facet_text.dart'; 13 13 import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; 14 - import 'package:lazurite/features/moderation/presentation/widgets/moderated_avatar.dart'; 15 14 import 'package:lazurite/features/moderation/presentation/widgets/moderated_blur_overlay.dart'; 16 15 import 'package:lazurite/features/moderation/presentation/widgets/moderation_badge_row.dart'; 17 16 import 'package:lazurite/features/search/bloc/search_bloc.dart'; 18 17 import 'package:lazurite/features/starter_packs/presentation/widgets/starter_pack_card.dart'; 19 18 import 'package:lazurite/shared/presentation/helpers/snackbar_helper.dart'; 20 19 import 'package:lazurite/shared/presentation/widgets/confirmation_dialog.dart'; 20 + import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; 21 21 import 'package:lazurite/shared/utils/format_utils.dart'; 22 22 import 'package:lazurite/core/theme/theme_extensions.dart'; 23 23 ··· 651 651 child: Row( 652 652 crossAxisAlignment: CrossAxisAlignment.start, 653 653 children: [ 654 - ModeratedAvatar( 654 + ProfileAvatar( 655 655 size: 44, 656 - ui: avatarUi, 656 + moderationUi: avatarUi, 657 657 imageUrl: author.avatar, 658 - initials: formatInitials(author.displayName ?? author.handle), 658 + fallbackText: author.displayName ?? author.handle, 659 659 shape: BoxShape.circle, 660 660 ), 661 661 const SizedBox(width: 12), ··· 755 755 ), 756 756 child: Row( 757 757 children: [ 758 - ModeratedAvatar( 758 + ProfileAvatar( 759 759 size: 48, 760 - ui: avatarUi, 760 + moderationUi: avatarUi, 761 761 imageUrl: actor.avatar, 762 - initials: formatInitials(actor.displayName ?? actor.handle), 762 + fallbackText: actor.displayName ?? actor.handle, 763 763 shape: BoxShape.circle, 764 764 ), 765 765 const SizedBox(width: 12), ··· 831 831 padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), 832 832 child: Row( 833 833 children: [ 834 - ModeratedAvatar( 834 + ProfileAvatar( 835 835 size: 40, 836 - ui: avatarUi, 836 + moderationUi: avatarUi, 837 837 imageUrl: actor.avatar, 838 - initials: formatInitials(actor.displayName ?? actor.handle), 838 + fallbackText: actor.displayName ?? actor.handle, 839 839 shape: BoxShape.circle, 840 840 placeholderTextStyle: context.textTheme.labelMedium, 841 841 ),
+2 -3
lib/features/settings/presentation/settings_screen.dart
··· 19 19 import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 20 20 import 'package:lazurite/features/settings/bloc/settings_state.dart'; 21 21 import 'package:lazurite/shared/presentation/helpers/snackbar_helper.dart'; 22 + import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; 22 23 23 24 class SettingsScreen extends StatelessWidget { 24 25 const SettingsScreen({super.key}); ··· 55 56 : '@${tokens.handle}'; 56 57 57 58 return ListTile( 58 - leading: CircleAvatar( 59 - child: Text((tokens.displayName ?? tokens.handle).substring(0, 1).toUpperCase()), 60 - ), 59 + leading: ProfileAvatar(size: 40, fallbackText: tokens.displayName ?? tokens.handle), 61 60 title: Text(tokens.displayName ?? tokens.handle), 62 61 subtitle: Text(subtitle), 63 62 trailing: const Icon(Icons.chevron_right),
+21 -32
lib/features/starter_packs/presentation/create_edit_starter_pack_screen.dart
··· 8 8 import 'package:lazurite/features/starter_packs/bloc/starter_pack_bloc.dart'; 9 9 import 'package:lazurite/features/starter_packs/data/starter_pack_repository.dart'; 10 10 import 'package:lazurite/core/theme/theme_extensions.dart'; 11 + import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; 11 12 12 13 /// Full-screen form for creating a new starter pack. 13 14 /// ··· 225 226 final isAlreadyAdded = _selectedMembers.any((m) => m.did == profile.did); 226 227 return ListTile( 227 228 dense: true, 228 - leading: CircleAvatar( 229 - radius: 16, 230 - backgroundColor: colorScheme.surfaceContainerHighest, 231 - backgroundImage: profile.avatar != null ? NetworkImage(profile.avatar!) : null, 232 - child: profile.avatar == null 233 - ? Text( 234 - (profile.displayName?.isNotEmpty == true ? profile.displayName! : profile.handle) 235 - .substring(0, 1) 236 - .toUpperCase(), 237 - style: const TextStyle(fontSize: 12), 238 - ) 239 - : null, 229 + leading: ProfileAvatar( 230 + size: 32, 231 + imageUrl: profile.avatar, 232 + fallbackText: profile.displayName?.isNotEmpty == true ? profile.displayName! : profile.handle, 233 + placeholderTextStyle: const TextStyle(fontSize: 12), 240 234 ), 241 235 title: Text(profile.displayName ?? profile.handle, maxLines: 1, overflow: TextOverflow.ellipsis), 242 236 subtitle: Text('@${profile.handle}', style: TextStyle(color: colorScheme.onSurfaceVariant)), ··· 255 249 runSpacing: 8, 256 250 children: _selectedMembers.map((member) { 257 251 return Chip( 258 - avatar: CircleAvatar( 259 - backgroundImage: member.avatar != null ? NetworkImage(member.avatar!) : null, 260 - backgroundColor: colorScheme.surfaceContainerHighest, 261 - child: member.avatar == null 262 - ? Text( 263 - (member.displayName?.isNotEmpty == true ? member.displayName! : member.handle) 264 - .substring(0, 1) 265 - .toUpperCase(), 266 - style: const TextStyle(fontSize: 10), 267 - ) 268 - : null, 252 + avatar: ProfileAvatar( 253 + size: 24, 254 + imageUrl: member.avatar, 255 + fallbackText: member.displayName?.isNotEmpty == true ? member.displayName! : member.handle, 256 + placeholderTextStyle: const TextStyle(fontSize: 10), 269 257 ), 270 258 label: Text(member.displayName ?? member.handle, maxLines: 1, overflow: TextOverflow.ellipsis), 271 259 onDeleted: isCreating ? null : () => _removeMember(member.did), ··· 295 283 for (final feed in _selectedFeeds) 296 284 ListTile( 297 285 contentPadding: EdgeInsets.zero, 298 - leading: CircleAvatar( 299 - radius: 20, 300 - backgroundColor: colorScheme.surfaceContainerHighest, 301 - backgroundImage: feed.avatar != null ? NetworkImage(feed.avatar!) : null, 302 - child: feed.avatar == null ? const Icon(Icons.rss_feed) : null, 286 + leading: ProfileAvatar( 287 + size: 40, 288 + imageUrl: feed.avatar, 289 + fallbackText: feed.displayName, 290 + fallbackBuilder: (_) => const Icon(Icons.rss_feed), 303 291 ), 304 292 title: Text(feed.displayName, maxLines: 1, overflow: TextOverflow.ellipsis), 305 293 subtitle: feed.description != null ··· 388 376 final isSelected = widget.alreadySelected.contains(feed.uri); 389 377 390 378 return ListTile( 391 - leading: CircleAvatar( 392 - backgroundColor: colorScheme.surfaceContainerHighest, 393 - backgroundImage: feed.avatar != null ? NetworkImage(feed.avatar!) : null, 394 - child: feed.avatar == null ? const Icon(Icons.rss_feed) : null, 379 + leading: ProfileAvatar( 380 + size: 40, 381 + imageUrl: feed.avatar, 382 + fallbackText: feed.displayName, 383 + fallbackBuilder: (_) => const Icon(Icons.rss_feed), 395 384 ), 396 385 title: Text(feed.displayName, maxLines: 1, overflow: TextOverflow.ellipsis), 397 386 subtitle: feed.description != null
+5 -3
lib/features/starter_packs/presentation/widgets/starter_pack_card.dart
··· 1 1 import 'package:bluesky/app_bsky_graph_defs.dart'; 2 2 import 'package:flutter/material.dart'; 3 + import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; 3 4 import 'package:lazurite/shared/utils/format_utils.dart'; 4 5 import 'package:lazurite/core/theme/theme_extensions.dart'; 5 6 ··· 31 32 children: [ 32 33 Row( 33 34 children: [ 34 - CircleAvatar( 35 - radius: 20, 35 + ProfileAvatar( 36 + size: 40, 37 + fallbackText: name, 36 38 backgroundColor: colorScheme.primaryContainer, 37 - child: Icon(Icons.group_outlined, color: colorScheme.onPrimaryContainer, size: 20), 39 + fallbackBuilder: (_) => Icon(Icons.group_outlined, color: colorScheme.onPrimaryContainer, size: 20), 38 40 ), 39 41 const SizedBox(width: 12), 40 42 Expanded(
+67
lib/shared/presentation/helpers/notification_icon_mapper.dart
··· 1 + import 'package:bluesky/app_bsky_notification_listnotifications.dart' as bsky; 2 + import 'package:flutter/material.dart'; 3 + 4 + class NotificationIconStyle { 5 + const NotificationIconStyle({required this.backgroundColor, required this.iconColor, required this.icon}); 6 + 7 + final Color backgroundColor; 8 + final Color iconColor; 9 + final IconData icon; 10 + } 11 + 12 + abstract final class NotificationIconMapper { 13 + static NotificationIconStyle map({required bsky.NotificationReason reason, required ColorScheme colorScheme}) { 14 + if (!reason.isKnownValue) { 15 + return NotificationIconStyle( 16 + backgroundColor: colorScheme.surfaceContainerHighest, 17 + iconColor: colorScheme.onSurfaceVariant, 18 + icon: Icons.notifications, 19 + ); 20 + } 21 + 22 + switch (reason.knownValue) { 23 + case bsky.KnownNotificationReason.like: 24 + return NotificationIconStyle( 25 + backgroundColor: colorScheme.error.withValues(alpha: 0.1), 26 + iconColor: colorScheme.error, 27 + icon: Icons.favorite, 28 + ); 29 + case bsky.KnownNotificationReason.repost: 30 + return NotificationIconStyle( 31 + backgroundColor: Colors.green.withValues(alpha: 0.1), 32 + iconColor: Colors.green, 33 + icon: Icons.repeat, 34 + ); 35 + case bsky.KnownNotificationReason.follow: 36 + return NotificationIconStyle( 37 + backgroundColor: colorScheme.primary.withValues(alpha: 0.1), 38 + iconColor: colorScheme.primary, 39 + icon: Icons.person_add, 40 + ); 41 + case bsky.KnownNotificationReason.reply: 42 + return NotificationIconStyle( 43 + backgroundColor: colorScheme.secondary.withValues(alpha: 0.1), 44 + iconColor: colorScheme.secondary, 45 + icon: Icons.chat_bubble, 46 + ); 47 + case bsky.KnownNotificationReason.mention: 48 + return NotificationIconStyle( 49 + backgroundColor: colorScheme.primary.withValues(alpha: 0.1), 50 + iconColor: colorScheme.primary, 51 + icon: Icons.alternate_email, 52 + ); 53 + case bsky.KnownNotificationReason.quote: 54 + return NotificationIconStyle( 55 + backgroundColor: Colors.purple.withValues(alpha: 0.1), 56 + iconColor: Colors.purple, 57 + icon: Icons.format_quote, 58 + ); 59 + default: 60 + return NotificationIconStyle( 61 + backgroundColor: colorScheme.surfaceContainerHighest, 62 + iconColor: colorScheme.onSurfaceVariant, 63 + icon: Icons.notifications, 64 + ); 65 + } 66 + } 67 + }
+50
lib/shared/presentation/widgets/actor_name_widget.dart
··· 1 + import 'package:flutter/material.dart'; 2 + 3 + class ActorNameWidget extends StatelessWidget { 4 + const ActorNameWidget({ 5 + super.key, 6 + required this.handle, 7 + this.displayName, 8 + this.displayNameStyle, 9 + this.handleStyle, 10 + this.maxLines = 1, 11 + this.overflow = TextOverflow.ellipsis, 12 + this.uppercaseHandle = true, 13 + this.showHandle = true, 14 + this.showDisplayNameOnlyWhenPresent = false, 15 + this.handlePrefix = '@', 16 + this.gap = 2, 17 + }); 18 + 19 + final String handle; 20 + final String? displayName; 21 + final TextStyle? displayNameStyle; 22 + final TextStyle? handleStyle; 23 + final int maxLines; 24 + final TextOverflow overflow; 25 + final bool uppercaseHandle; 26 + final bool showHandle; 27 + final bool showDisplayNameOnlyWhenPresent; 28 + final String handlePrefix; 29 + final double gap; 30 + 31 + @override 32 + Widget build(BuildContext context) { 33 + final normalizedDisplayName = displayName?.trim(); 34 + final hasDisplayName = normalizedDisplayName != null && normalizedDisplayName.isNotEmpty; 35 + final shouldShowDisplayName = hasDisplayName || !showDisplayNameOnlyWhenPresent; 36 + final displayText = hasDisplayName ? normalizedDisplayName : handle; 37 + final handleText = '$handlePrefix${uppercaseHandle ? handle.toUpperCase() : handle}'; 38 + 39 + return Column( 40 + crossAxisAlignment: CrossAxisAlignment.start, 41 + children: [ 42 + if (shouldShowDisplayName) Text(displayText, style: displayNameStyle, maxLines: maxLines, overflow: overflow), 43 + if (showHandle) ...[ 44 + if (shouldShowDisplayName) SizedBox(height: gap), 45 + Text(handleText, style: handleStyle, maxLines: maxLines, overflow: overflow), 46 + ], 47 + ], 48 + ); 49 + } 50 + }
+69
lib/shared/presentation/widgets/profile_avatar.dart
··· 1 + import 'package:bluesky/moderation.dart' as bsky_moderation; 2 + import 'package:flutter/material.dart'; 3 + import 'package:lazurite/core/theme/theme_extensions.dart'; 4 + import 'package:lazurite/shared/utils/format_utils.dart'; 5 + 6 + class ProfileAvatar extends StatelessWidget { 7 + const ProfileAvatar({ 8 + super.key, 9 + required this.size, 10 + required this.fallbackText, 11 + this.imageUrl, 12 + this.moderationUi, 13 + this.shape = BoxShape.circle, 14 + this.borderRadius, 15 + this.border, 16 + this.placeholderTextStyle, 17 + this.fallbackBuilder, 18 + this.backgroundColor, 19 + }); 20 + 21 + final double size; 22 + final String fallbackText; 23 + final String? imageUrl; 24 + final bsky_moderation.ModerationUI? moderationUi; 25 + final BoxShape shape; 26 + final BorderRadius? borderRadius; 27 + final Border? border; 28 + final TextStyle? placeholderTextStyle; 29 + final WidgetBuilder? fallbackBuilder; 30 + final Color? backgroundColor; 31 + 32 + @override 33 + Widget build(BuildContext context) { 34 + final colorScheme = context.colorScheme; 35 + final shouldMask = moderationUi?.blur ?? false; 36 + final containerColor = backgroundColor ?? colorScheme.surfaceContainerHighest; 37 + 38 + return Container( 39 + width: size, 40 + height: size, 41 + decoration: BoxDecoration( 42 + color: containerColor, 43 + shape: shape, 44 + borderRadius: shape == BoxShape.rectangle ? borderRadius : null, 45 + border: border, 46 + ), 47 + clipBehavior: Clip.antiAlias, 48 + child: shouldMask 49 + ? Icon(Icons.shield_outlined, color: colorScheme.onSurfaceVariant, size: size * 0.44) 50 + : imageUrl != null 51 + ? Image.network( 52 + imageUrl!, 53 + fit: BoxFit.cover, 54 + errorBuilder: (_, _, _) => _buildFallback(context, containerColor), 55 + ) 56 + : _buildFallback(context, containerColor), 57 + ); 58 + } 59 + 60 + Widget _buildFallback(BuildContext context, Color backgroundColor) { 61 + final resolvedFallback = fallbackBuilder?.call(context); 62 + final textStyle = placeholderTextStyle ?? context.textTheme.labelLarge; 63 + 64 + return ColoredBox( 65 + color: backgroundColor, 66 + child: Center(child: resolvedFallback ?? Text(formatInitials(fallbackText), style: textStyle)), 67 + ); 68 + } 69 + }
+40
test/shared/presentation/helpers/notification_icon_mapper_test.dart
··· 1 + import 'package:bluesky/app_bsky_notification_listnotifications.dart' as bsky; 2 + import 'package:flutter/material.dart'; 3 + import 'package:flutter_test/flutter_test.dart'; 4 + import 'package:lazurite/shared/presentation/helpers/notification_icon_mapper.dart'; 5 + 6 + void main() { 7 + group('NotificationIconMapper', () { 8 + final colorScheme = ColorScheme.fromSeed(seedColor: const Color(0xFF1565C0)); 9 + 10 + test('maps like reason to favorite icon and error color', () { 11 + final style = NotificationIconMapper.map( 12 + reason: const bsky.NotificationReason.knownValue(data: bsky.KnownNotificationReason.like), 13 + colorScheme: colorScheme, 14 + ); 15 + 16 + expect(style.icon, Icons.favorite); 17 + expect(style.iconColor, colorScheme.error); 18 + }); 19 + 20 + test('maps follow reason to person_add icon and primary color', () { 21 + final style = NotificationIconMapper.map( 22 + reason: const bsky.NotificationReason.knownValue(data: bsky.KnownNotificationReason.follow), 23 + colorScheme: colorScheme, 24 + ); 25 + 26 + expect(style.icon, Icons.person_add); 27 + expect(style.iconColor, colorScheme.primary); 28 + }); 29 + 30 + test('maps quote reason to quote icon and purple color', () { 31 + final style = NotificationIconMapper.map( 32 + reason: const bsky.NotificationReason.knownValue(data: bsky.KnownNotificationReason.quote), 33 + colorScheme: colorScheme, 34 + ); 35 + 36 + expect(style.icon, Icons.format_quote); 37 + expect(style.iconColor, Colors.purple); 38 + }); 39 + }); 40 + }
+39
test/shared/presentation/widgets/actor_name_widget_test.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:flutter_test/flutter_test.dart'; 3 + import 'package:lazurite/shared/presentation/widgets/actor_name_widget.dart'; 4 + 5 + void main() { 6 + Widget buildSubject(Widget child) { 7 + return MaterialApp( 8 + home: Scaffold(body: Center(child: child)), 9 + ); 10 + } 11 + 12 + testWidgets('renders display name and uppercase handle by default', (tester) async { 13 + await tester.pumpWidget( 14 + buildSubject(const ActorNameWidget(displayName: 'Alice Smith', handle: 'alice.bsky.social')), 15 + ); 16 + 17 + expect(find.text('Alice Smith'), findsOneWidget); 18 + expect(find.text('@ALICE.BSKY.SOCIAL'), findsOneWidget); 19 + }); 20 + 21 + testWidgets('renders only handle line when configured and displayName is missing', (tester) async { 22 + await tester.pumpWidget( 23 + buildSubject(const ActorNameWidget(handle: 'alice.bsky.social', showDisplayNameOnlyWhenPresent: true)), 24 + ); 25 + 26 + expect(find.text('alice.bsky.social'), findsNothing); 27 + expect(find.text('@ALICE.BSKY.SOCIAL'), findsOneWidget); 28 + }); 29 + 30 + testWidgets('can preserve original handle case', (tester) async { 31 + await tester.pumpWidget( 32 + buildSubject( 33 + const ActorNameWidget(displayName: 'Alice Smith', handle: 'alice.bsky.social', uppercaseHandle: false), 34 + ), 35 + ); 36 + 37 + expect(find.text('@alice.bsky.social'), findsOneWidget); 38 + }); 39 + }
+36
test/shared/presentation/widgets/profile_avatar_test.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:flutter_test/flutter_test.dart'; 3 + import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; 4 + 5 + void main() { 6 + Widget buildSubject(Widget child) { 7 + return MaterialApp( 8 + home: Scaffold(body: Center(child: child)), 9 + ); 10 + } 11 + 12 + testWidgets('renders initials fallback when image is missing', (tester) async { 13 + await tester.pumpWidget(buildSubject(const ProfileAvatar(size: 40, fallbackText: 'Alice Smith'))); 14 + 15 + expect(find.text('AS'), findsOneWidget); 16 + }); 17 + 18 + testWidgets('uses custom fallback builder when provided', (tester) async { 19 + await tester.pumpWidget( 20 + buildSubject( 21 + ProfileAvatar(size: 40, fallbackText: 'Alice Smith', fallbackBuilder: (_) => const Icon(Icons.person_outline)), 22 + ), 23 + ); 24 + 25 + expect(find.byIcon(Icons.person_outline), findsOneWidget); 26 + expect(find.text('AS'), findsNothing); 27 + }); 28 + 29 + testWidgets('respects the requested avatar size', (tester) async { 30 + await tester.pumpWidget(buildSubject(const ProfileAvatar(size: 52, fallbackText: 'Alice Smith'))); 31 + 32 + final avatarSize = tester.getSize(find.byType(ProfileAvatar)); 33 + expect(avatarSize.width, 52); 34 + expect(avatarSize.height, 52); 35 + }); 36 + }