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: shared utilities

+164 -276
+5 -24
docs/tasks/testing.md
··· 2 2 3 3 ## M0 - Shared Utilities Extraction 4 4 5 - - [ ] Create `lib/shared/utils/format_utils.dart` with `formatInitials`, `formatCount`, `formatRelativeTime` 6 - - [ ] Replace `_initials` in: 7 - - `lib/features/feed/presentation/widgets/post_card.dart` 8 - - `lib/features/feed/presentation/widgets/grid_post_card.dart` 9 - - `lib/features/feed/presentation/widgets/post_embed_view.dart` 10 - - `lib/features/notifications/presentation/widgets/notification_list_item.dart` 11 - - `lib/features/notifications/presentation/widgets/grouped_notification_list_item.dart` 12 - - `lib/features/profile/presentation/widgets/suggested_follows_list.dart` 13 - - `lib/features/moderation/presentation/screens/labeler_detail_screen.dart` 14 - - `lib/features/moderation/presentation/screens/moderation_settings_screen.dart` 15 - - `lib/features/search/presentation/search_screen.dart` 16 - - `lib/features/search/presentation/hashtag_screen.dart` 17 - - [ ] Replace `_formatCount` in: 18 - - `lib/features/feed/presentation/widgets/post_action_bar.dart` 19 - - `lib/features/feed/presentation/widgets/post_card_footer.dart` 20 - - `lib/features/starter_packs/presentation/widgets/starter_pack_card.dart` 21 - - `lib/features/starter_packs/presentation/starter_pack_detail_screen.dart` 22 - - `lib/features/profile/presentation/profile_screen.dart` 23 - - [ ] Consolidate `_formatTime` with existing `formatPostTime` in `post_card_footer.dart`: 24 - - `lib/features/notifications/presentation/widgets/notification_list_item.dart` 25 - - `lib/features/notifications/presentation/widgets/grouped_notification_list_item.dart` 26 - - `lib/features/search/presentation/search_screen.dart` 27 - - `lib/features/search/presentation/hashtag_screen.dart` 28 - - [ ] Unit tests for all format functions (edge cases: empty string, zero, negative, boundary values) 5 + - [x] Create `lib/shared/utils/format_utils.dart` with `formatInitials`, `formatCount`, `formatRelativeTime` 6 + - [x] Replace `_initials` 7 + - [x] Replace `_formatCount` 8 + - [x] Consolidate `_formatTime` with existing `formatPostTime` in `post_card_footer.dart` 9 + - [x] Unit tests for all format functions (edge cases: empty string, zero, negative, boundary values) 29 10 30 11 ## M1 - Shared State Widgets 31 12
+2 -8
lib/features/feed/presentation/widgets/grid_post_card.dart
··· 13 13 import 'package:lazurite/features/moderation/presentation/widgets/moderated_avatar.dart'; 14 14 import 'package:lazurite/features/moderation/presentation/widgets/moderated_blur_overlay.dart'; 15 15 import 'package:lazurite/features/moderation/presentation/widgets/moderation_badge_row.dart'; 16 + import 'package:lazurite/shared/utils/format_utils.dart'; 16 17 17 18 const _greyscale = ColorFilter.matrix(<double>[ 18 19 0.2126, ··· 169 170 size: 40, 170 171 ui: avatarUi, 171 172 imageUrl: author.avatar, 172 - initials: _initials(author.displayName ?? author.handle), 173 + initials: formatInitials(author.displayName ?? author.handle), 173 174 shape: BoxShape.rectangle, 174 175 border: Border.all(color: colorScheme.outlineVariant), 175 176 placeholderTextStyle: Theme.of(context).textTheme.labelMedium, ··· 247 248 } catch (_) { 248 249 return null; 249 250 } 250 - } 251 - 252 - String _initials(String value) { 253 - final parts = value.trim().split(RegExp(r'\s+')); 254 - if (parts.isEmpty || parts.first.isEmpty) return '?'; 255 - if (parts.length == 1) return parts.first.substring(0, 1).toUpperCase(); 256 - return '${parts.first.substring(0, 1)}${parts.last.substring(0, 1)}'.toUpperCase(); 257 251 } 258 252 }
+2 -11
lib/features/feed/presentation/widgets/post_action_bar.dart
··· 2 2 import 'package:flutter/services.dart'; 3 3 import 'package:lazurite/core/logging/app_logger.dart'; 4 4 import 'package:lazurite/features/connectivity/connectivity_helpers.dart'; 5 + import 'package:lazurite/shared/utils/format_utils.dart'; 5 6 import 'package:share_plus/share_plus.dart'; 6 7 7 8 class PostActionBar extends StatelessWidget { ··· 262 263 Icon(isActive ? activeIcon : icon, size: 18, color: iconColor), 263 264 if (count > 0) ...[ 264 265 const SizedBox(width: 4), 265 - Text(_formatCount(count), style: Theme.of(context).textTheme.bodySmall?.copyWith(color: iconColor)), 266 + Text(formatCount(count), style: Theme.of(context).textTheme.bodySmall?.copyWith(color: iconColor)), 266 267 ], 267 268 ], 268 269 ), ··· 282 283 } 283 284 284 285 return button; 285 - } 286 - 287 - String _formatCount(int count) { 288 - if (count >= 1000000) { 289 - return '${(count / 1000000).toStringAsFixed(1)}M'; 290 - } 291 - if (count >= 1000) { 292 - return '${(count / 1000).toStringAsFixed(1)}K'; 293 - } 294 - return '$count'; 295 286 } 296 287 }
+2 -8
lib/features/feed/presentation/widgets/post_card.dart
··· 12 12 import 'package:lazurite/features/moderation/presentation/widgets/moderated_avatar.dart'; 13 13 import 'package:lazurite/features/moderation/presentation/widgets/moderated_blur_overlay.dart'; 14 14 import 'package:lazurite/features/moderation/presentation/widgets/moderation_badge_row.dart'; 15 + import 'package:lazurite/shared/utils/format_utils.dart'; 15 16 16 17 class PostCard extends StatelessWidget { 17 18 const PostCard({ ··· 101 102 size: 40, 102 103 ui: avatarUi, 103 104 imageUrl: author.avatar, 104 - initials: _initials(author.displayName ?? author.handle), 105 + initials: formatInitials(author.displayName ?? author.handle), 105 106 shape: BoxShape.rectangle, 106 107 border: Border.all(color: colorScheme.outlineVariant), 107 108 ), ··· 160 161 } catch (_) { 161 162 return null; 162 163 } 163 - } 164 - 165 - String _initials(String value) { 166 - final parts = value.trim().split(RegExp(r'\s+')); 167 - if (parts.isEmpty || parts.first.isEmpty) return '?'; 168 - if (parts.length == 1) return parts.first.substring(0, 1).toUpperCase(); 169 - return '${parts.first.substring(0, 1)}${parts.last.substring(0, 1)}'.toUpperCase(); 170 164 } 171 165 }
+3 -20
lib/features/feed/presentation/widgets/post_card_footer.dart
··· 1 1 import 'package:flutter/material.dart'; 2 2 import 'package:flutter/services.dart'; 3 - import 'package:intl/intl.dart'; 4 3 import 'package:lazurite/features/connectivity/connectivity_helpers.dart'; 4 + import 'package:lazurite/shared/utils/format_utils.dart'; 5 5 6 6 /// Formats a post timestamp as a short, uppercase string. 7 7 String formatPostTime(DateTime time) { 8 - final now = DateTime.now(); 9 - final difference = now.difference(time); 10 - 11 - if (difference.inMinutes < 1) return 'NOW'; 12 - if (difference.inHours < 1) return '${difference.inMinutes}M'; 13 - if (difference.inDays < 1) return '${difference.inHours}H'; 14 - if (difference.inDays < 7) return '${difference.inDays}D'; 15 - return DateFormat('MMM d').format(time).toUpperCase(); 8 + return formatRelativeTime(time, nowLabel: 'NOW', uppercase: true); 16 9 } 17 10 18 11 /// Shared footer for post cards. Renders a top-bordered row with ··· 278 271 Icon(isActive ? activeIcon : icon, size: iconSize, color: iconColor), 279 272 if (showCount && count > 0) ...[ 280 273 const SizedBox(width: 4), 281 - Text(_formatCount(count), style: Theme.of(context).textTheme.bodySmall?.copyWith(color: iconColor)), 274 + Text(formatCount(count), style: Theme.of(context).textTheme.bodySmall?.copyWith(color: iconColor)), 282 275 ], 283 276 ], 284 277 ), ··· 290 283 } 291 284 292 285 return button; 293 - } 294 - 295 - String _formatCount(int count) { 296 - if (count >= 1000000) { 297 - return '${(count / 1000000).toStringAsFixed(1)}M'; 298 - } 299 - if (count >= 1000) { 300 - return '${(count / 1000).toStringAsFixed(1)}K'; 301 - } 302 - return '$count'; 303 286 } 304 287 }
+2 -8
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 20 import 'package:url_launcher/url_launcher.dart'; 20 21 21 22 /// Renders the appropriate embed widget for a post embed. ··· 277 278 ), 278 279 child: quoted.author.avatar != null 279 280 ? Image.network(quoted.author.avatar!, fit: BoxFit.cover) 280 - : Center(child: Text(_initials(quoted.author.displayName ?? quoted.author.handle))), 281 + : Center(child: Text(formatInitials(quoted.author.displayName ?? quoted.author.handle))), 281 282 ), 282 283 const SizedBox(width: 8), 283 284 Expanded( ··· 464 465 } catch (_) { 465 466 return null; 466 467 } 467 - } 468 - 469 - String _initials(String value) { 470 - final parts = value.trim().split(RegExp(r'\s+')); 471 - if (parts.isEmpty || parts.first.isEmpty) return '?'; 472 - if (parts.length == 1) return parts.first.substring(0, 1).toUpperCase(); 473 - return '${parts.first.substring(0, 1)}${parts.last.substring(0, 1)}'.toUpperCase(); 474 468 } 475 469 } 476 470
+2 -9
lib/features/moderation/presentation/screens/labeler_detail_screen.dart
··· 5 5 import 'package:lazurite/features/moderation/data/moderation_service.dart'; 6 6 import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; 7 7 import 'package:lazurite/features/moderation/presentation/widgets/moderated_avatar.dart'; 8 + import 'package:lazurite/shared/utils/format_utils.dart'; 8 9 9 10 class LabelerDetailScreen extends StatefulWidget { 10 11 const LabelerDetailScreen({super.key, required this.did}); ··· 136 137 ModeratedAvatar( 137 138 size: 64, 138 139 imageUrl: creator.avatar, 139 - initials: _initials(creator.displayName ?? creator.handle), 140 + initials: formatInitials(creator.displayName ?? creator.handle), 140 141 shape: BoxShape.circle, 141 142 ), 142 143 const SizedBox(width: 16), ··· 302 303 }, 303 304 ), 304 305 ); 305 - } 306 - 307 - String _initials(String value) { 308 - final parts = value.trim().split(RegExp(r'\s+')).where((part) => part.isNotEmpty).take(2).toList(); 309 - if (parts.isEmpty) { 310 - return '?'; 311 - } 312 - return parts.map((part) => part[0].toUpperCase()).join(); 313 306 } 314 307 } 315 308
+2 -9
lib/features/moderation/presentation/screens/moderation_settings_screen.dart
··· 6 6 import 'package:lazurite/features/moderation/data/moderation_service.dart'; 7 7 import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; 8 8 import 'package:lazurite/features/moderation/presentation/widgets/moderated_avatar.dart'; 9 + import 'package:lazurite/shared/utils/format_utils.dart'; 9 10 10 11 class ModerationSettingsScreen extends StatefulWidget { 11 12 const ModerationSettingsScreen({super.key}); ··· 406 407 ModeratedAvatar( 407 408 size: 52, 408 409 imageUrl: creator.avatar, 409 - initials: _initials(creator.displayName ?? creator.handle), 410 + initials: formatInitials(creator.displayName ?? creator.handle), 410 411 shape: BoxShape.circle, 411 412 ), 412 413 const SizedBox(width: 14), ··· 479 480 ), 480 481 ), 481 482 ); 482 - } 483 - 484 - String _initials(String value) { 485 - final parts = value.trim().split(RegExp(r'\s+')).where((part) => part.isNotEmpty).take(2).toList(); 486 - if (parts.isEmpty) { 487 - return '?'; 488 - } 489 - return parts.map((part) => part[0].toUpperCase()).join(); 490 483 } 491 484 } 492 485
+2 -20
lib/features/notifications/presentation/widgets/grouped_notification_list_item.dart
··· 6 6 import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; 7 7 import 'package:lazurite/features/moderation/presentation/widgets/moderated_avatar.dart'; 8 8 import 'package:lazurite/features/moderation/presentation/widgets/moderated_blur_overlay.dart'; 9 + import 'package:lazurite/shared/utils/format_utils.dart'; 9 10 10 11 class NotificationGroup { 11 12 const NotificationGroup({required this.notifications}); ··· 220 221 221 222 Widget _buildTime(ThemeData theme) { 222 223 return Text( 223 - _formatTime(group.latest.indexedAt), 224 + formatRelativeTime(group.latest.indexedAt, nowLabel: 'Just now', includeAgo: true), 224 225 style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), 225 226 ); 226 227 } ··· 248 249 } 249 250 250 251 return 'interacted with you'; 251 - } 252 - 253 - String _formatTime(DateTime time) { 254 - final now = DateTime.now(); 255 - final difference = now.difference(time); 256 - 257 - if (difference.inMinutes < 1) { 258 - return 'Just now'; 259 - } else if (difference.inMinutes < 60) { 260 - return '${difference.inMinutes}m ago'; 261 - } else if (difference.inHours < 24) { 262 - return '${difference.inHours}h ago'; 263 - } else if (difference.inDays < 7) { 264 - return '${difference.inDays}d ago'; 265 - } 266 - 267 - const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; 268 - 269 - return '${months[time.month - 1]} ${time.day}'; 270 252 } 271 253 272 254 bool _shouldShowPreview(bsky.Notification notification) {
+2 -19
lib/features/notifications/presentation/widgets/notification_list_item.dart
··· 2 2 import 'package:bluesky/moderation.dart' as bsky_moderation; 3 3 import 'package:flutter/material.dart' hide Notification; 4 4 import 'package:go_router/go_router.dart'; 5 - import 'package:intl/intl.dart'; 6 5 import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; 7 6 import 'package:lazurite/features/moderation/presentation/widgets/moderated_avatar.dart'; 8 7 import 'package:lazurite/features/moderation/presentation/widgets/moderated_blur_overlay.dart'; 9 8 import 'package:lazurite/features/moderation/presentation/widgets/moderation_badge_row.dart'; 9 + import 'package:lazurite/shared/utils/format_utils.dart'; 10 10 11 11 class NotificationListItem extends StatelessWidget { 12 12 const NotificationListItem({super.key, required this.notification}); ··· 193 193 194 194 Widget _buildTime(ThemeData theme) { 195 195 return Text( 196 - _formatTime(notification.indexedAt), 196 + formatRelativeTime(notification.indexedAt, nowLabel: 'Just now', includeAgo: true), 197 197 style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), 198 198 ); 199 - } 200 - 201 - String _formatTime(DateTime time) { 202 - final now = DateTime.now(); 203 - final difference = now.difference(time); 204 - 205 - if (difference.inMinutes < 1) { 206 - return 'Just now'; 207 - } else if (difference.inMinutes < 60) { 208 - return '${difference.inMinutes}m ago'; 209 - } else if (difference.inHours < 24) { 210 - return '${difference.inHours}h ago'; 211 - } else if (difference.inDays < 7) { 212 - return '${difference.inDays}d ago'; 213 - } else { 214 - return DateFormat('MMM d').format(time); 215 - } 216 199 } 217 200 218 201 bool get _shouldShowPreview {
+3 -18
lib/features/profile/presentation/profile_screen.dart
··· 35 35 import 'package:lazurite/features/starter_packs/cubit/actor_starter_packs_cubit.dart'; 36 36 import 'package:lazurite/features/starter_packs/data/starter_pack_repository.dart'; 37 37 import 'package:lazurite/features/starter_packs/presentation/widgets/starter_pack_card.dart'; 38 + import 'package:lazurite/shared/utils/format_utils.dart'; 38 39 import 'package:share_plus/share_plus.dart'; 39 40 import 'package:url_launcher/url_launcher.dart'; 40 41 ··· 317 318 size: size, 318 319 ui: avatarUi, 319 320 imageUrl: profile?.avatar, 320 - initials: _initials(profile?.displayName ?? profile?.handle ?? '?'), 321 + initials: formatInitials(profile?.displayName ?? profile?.handle ?? '?'), 321 322 shape: BoxShape.rectangle, 322 323 border: Border.all(color: colorScheme.surfaceContainerLowest, width: 4), 323 324 placeholderTextStyle: Theme.of(context).textTheme.headlineSmall, ··· 453 454 return Column( 454 455 crossAxisAlignment: CrossAxisAlignment.start, 455 456 children: [ 456 - Text( 457 - _formatCount(count), 458 - style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), 459 - ), 457 + Text(formatCount(count), style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)), 460 458 Text( 461 459 label.toUpperCase(), 462 460 style: TextStyle(fontSize: 11, letterSpacing: 1.1, color: colorScheme.onSurfaceVariant), ··· 882 880 case FeedFilter.postsWithMedia: 883 881 return 'No media posts yet'; 884 882 } 885 - } 886 - 887 - String _formatCount(int count) { 888 - if (count >= 1000000) return '${(count / 1000000).toStringAsFixed(1)}M'; 889 - if (count >= 1000) return '${(count / 1000).toStringAsFixed(1)}K'; 890 - return '$count'; 891 - } 892 - 893 - String _initials(String value) { 894 - final parts = value.trim().split(RegExp(r'\s+')); 895 - if (parts.isEmpty || parts.first.isEmpty) return '?'; 896 - if (parts.length == 1) return parts.first.substring(0, 1).toUpperCase(); 897 - return '${parts.first.substring(0, 1)}${parts.last.substring(0, 1)}'.toUpperCase(); 898 883 } 899 884 900 885 Future<void> _launchWebsite(String website) async {
+3 -2
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/utils/format_utils.dart'; 7 8 8 9 class SuggestedFollowsList extends StatelessWidget { 9 10 const SuggestedFollowsList({ ··· 124 125 return ListTile( 125 126 leading: CircleAvatar( 126 127 backgroundImage: profile.avatar != null ? NetworkImage(profile.avatar!) : null, 127 - child: profile.avatar == null ? Text(title.substring(0, 1).toUpperCase()) : null, 128 + child: profile.avatar == null ? Text(formatInitials(title)) : null, 128 129 ), 129 130 title: Text(title), 130 131 subtitle: Text('@${profile.handle}'), ··· 152 153 return ListTile( 153 154 leading: CircleAvatar( 154 155 backgroundImage: profile.avatar != null ? NetworkImage(profile.avatar!) : null, 155 - child: profile.avatar == null ? Text(title.substring(0, 1).toUpperCase()) : null, 156 + child: profile.avatar == null ? Text(formatInitials(title)) : null, 156 157 ), 157 158 title: Text(title), 158 159 subtitle: Text('@${profile.handle}'),
+3 -33
lib/features/search/presentation/hashtag_screen.dart
··· 5 5 import 'package:flutter/material.dart'; 6 6 import 'package:flutter_bloc/flutter_bloc.dart'; 7 7 import 'package:go_router/go_router.dart'; 8 - import 'package:intl/intl.dart'; 9 8 import 'package:lazurite/features/feed/presentation/widgets/facet_text.dart'; 10 9 import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; 11 10 import 'package:lazurite/features/moderation/presentation/widgets/moderated_avatar.dart'; ··· 13 12 import 'package:lazurite/features/moderation/presentation/widgets/moderation_badge_row.dart'; 14 13 import 'package:lazurite/features/search/cubit/hashtag_cubit.dart'; 15 14 import 'package:lazurite/features/search/data/hashtag_utils.dart'; 15 + import 'package:lazurite/shared/utils/format_utils.dart'; 16 16 17 17 class HashtagScreen extends StatefulWidget { 18 18 const HashtagScreen({super.key, required this.tag}); ··· 311 311 size: 44, 312 312 ui: avatarUi, 313 313 imageUrl: author.avatar, 314 - initials: _initials(author.displayName ?? author.handle), 314 + initials: formatInitials(author.displayName ?? author.handle), 315 315 shape: BoxShape.circle, 316 316 ), 317 317 const SizedBox(width: 12), ··· 327 327 ), 328 328 const SizedBox(height: 2), 329 329 Text( 330 - '@${author.handle} · ${_formatTime(createdAt)}', 330 + '@${author.handle} · ${formatRelativeTime(createdAt)}', 331 331 style: Theme.of( 332 332 context, 333 333 ).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), ··· 386 386 } catch (_) { 387 387 return null; 388 388 } 389 - } 390 - 391 - String _formatTime(DateTime time) { 392 - final now = DateTime.now(); 393 - final difference = now.difference(time); 394 - 395 - if (difference.inMinutes < 1) { 396 - return 'now'; 397 - } 398 - if (difference.inHours < 1) { 399 - return '${difference.inMinutes}m'; 400 - } 401 - if (difference.inDays < 1) { 402 - return '${difference.inHours}h'; 403 - } 404 - if (difference.inDays < 7) { 405 - return '${difference.inDays}d'; 406 - } 407 - return DateFormat('MMM d').format(time); 408 - } 409 - 410 - String _initials(String value) { 411 - final parts = value.trim().split(RegExp(r'\s+')); 412 - if (parts.isEmpty || parts.first.isEmpty) { 413 - return '?'; 414 - } 415 - if (parts.length == 1) { 416 - return parts.first.substring(0, 1).toUpperCase(); 417 - } 418 - return '${parts.first.substring(0, 1)}${parts.last.substring(0, 1)}'.toUpperCase(); 419 389 } 420 390 }
+6 -73
lib/features/search/presentation/search_screen.dart
··· 5 5 import 'package:flutter/material.dart'; 6 6 import 'package:flutter_bloc/flutter_bloc.dart'; 7 7 import 'package:go_router/go_router.dart'; 8 - import 'package:intl/intl.dart'; 9 8 import 'package:lazurite/core/router/app_shell.dart'; 10 9 import 'package:lazurite/features/connectivity/connectivity_helpers.dart'; 11 10 import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; ··· 17 16 import 'package:lazurite/features/moderation/presentation/widgets/moderation_badge_row.dart'; 18 17 import 'package:lazurite/features/search/bloc/search_bloc.dart'; 19 18 import 'package:lazurite/features/starter_packs/presentation/widgets/starter_pack_card.dart'; 19 + import 'package:lazurite/shared/utils/format_utils.dart'; 20 20 21 21 class SearchScreen extends StatefulWidget { 22 22 const SearchScreen({super.key}); ··· 615 615 } 616 616 617 617 String _formatHistoryTime(DateTime time) { 618 - final now = DateTime.now(); 619 - final difference = now.difference(time); 620 - 621 - if (difference.inMinutes < 1) { 622 - return 'Just now'; 623 - } 624 - if (difference.inHours < 1) { 625 - return '${difference.inMinutes}m ago'; 626 - } 627 - if (difference.inDays < 1) { 628 - return '${difference.inHours}h ago'; 629 - } 630 - if (difference.inDays < 7) { 631 - return '${difference.inDays}d ago'; 632 - } 633 - return DateFormat('MMM d').format(time); 618 + return formatRelativeTime(time, nowLabel: 'Just now', includeAgo: true); 634 619 } 635 620 } 636 621 ··· 688 673 size: 44, 689 674 ui: avatarUi, 690 675 imageUrl: author.avatar, 691 - initials: _initials(author.displayName ?? author.handle), 676 + initials: formatInitials(author.displayName ?? author.handle), 692 677 shape: BoxShape.circle, 693 678 ), 694 679 const SizedBox(width: 12), ··· 704 689 ), 705 690 const SizedBox(height: 2), 706 691 Text( 707 - '@${author.handle} · ${_formatTime(createdAt)}', 692 + '@${author.handle} · ${formatRelativeTime(createdAt)}', 708 693 style: Theme.of( 709 694 context, 710 695 ).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), ··· 764 749 return null; 765 750 } 766 751 } 767 - 768 - String _formatTime(DateTime time) { 769 - final now = DateTime.now(); 770 - final difference = now.difference(time); 771 - 772 - if (difference.inMinutes < 1) { 773 - return 'now'; 774 - } 775 - if (difference.inHours < 1) { 776 - return '${difference.inMinutes}m'; 777 - } 778 - if (difference.inDays < 1) { 779 - return '${difference.inHours}h'; 780 - } 781 - if (difference.inDays < 7) { 782 - return '${difference.inDays}d'; 783 - } 784 - return DateFormat('MMM d').format(time); 785 - } 786 - 787 - String _initials(String value) { 788 - final parts = value.trim().split(RegExp(r'\s+')); 789 - if (parts.isEmpty || parts.first.isEmpty) { 790 - return '?'; 791 - } 792 - if (parts.length == 1) { 793 - return parts.first.substring(0, 1).toUpperCase(); 794 - } 795 - return '${parts.first.substring(0, 1)}${parts.last.substring(0, 1)}'.toUpperCase(); 796 - } 797 752 } 798 753 799 754 class _ActorResultTile extends StatelessWidget { ··· 824 779 size: 48, 825 780 ui: avatarUi, 826 781 imageUrl: actor.avatar, 827 - initials: _initials(actor.displayName ?? actor.handle), 782 + initials: formatInitials(actor.displayName ?? actor.handle), 828 783 shape: BoxShape.circle, 829 784 ), 830 785 const SizedBox(width: 12), ··· 876 831 router.push('/profile/view?actor=${Uri.encodeQueryComponent(did)}'); 877 832 } 878 833 } 879 - 880 - String _initials(String value) { 881 - final parts = value.trim().split(RegExp(r'\s+')); 882 - if (parts.isEmpty || parts.first.isEmpty) { 883 - return '?'; 884 - } 885 - if (parts.length == 1) { 886 - return parts.first.substring(0, 1).toUpperCase(); 887 - } 888 - return '${parts.first.substring(0, 1)}${parts.last.substring(0, 1)}'.toUpperCase(); 889 - } 890 834 } 891 835 892 836 class _ActorListTile extends StatelessWidget { ··· 915 859 size: 40, 916 860 ui: avatarUi, 917 861 imageUrl: actor.avatar, 918 - initials: _initials(actor.displayName ?? actor.handle), 862 + initials: formatInitials(actor.displayName ?? actor.handle), 919 863 shape: BoxShape.circle, 920 864 placeholderTextStyle: Theme.of(context).textTheme.labelMedium, 921 865 ), ··· 947 891 ), 948 892 ), 949 893 ); 950 - } 951 - 952 - String _initials(String value) { 953 - final parts = value.trim().split(RegExp(r'\s+')); 954 - if (parts.isEmpty || parts.first.isEmpty) { 955 - return '?'; 956 - } 957 - if (parts.length == 1) { 958 - return parts.first.substring(0, 1).toUpperCase(); 959 - } 960 - return '${parts.first.substring(0, 1)}${parts.last.substring(0, 1)}'.toUpperCase(); 961 894 } 962 895 } 963 896
+2 -7
lib/features/starter_packs/presentation/starter_pack_detail_screen.dart
··· 7 7 import 'package:go_router/go_router.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 + import 'package:lazurite/shared/utils/format_utils.dart'; 10 11 11 12 class StarterPackDetailScreen extends StatelessWidget { 12 13 const StarterPackDetailScreen({super.key, required this.packUri}); ··· 495 496 decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8)), 496 497 child: Column( 497 498 children: [ 498 - Text(_formatCount(count), style: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)), 499 + Text(formatCount(count), style: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)), 499 500 Text(label, style: textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant)), 500 501 ], 501 502 ), ··· 617 618 ), 618 619 ], 619 620 ); 620 - } 621 - 622 - String _formatCount(int count) { 623 - if (count >= 1000000) return '${(count / 1000000).toStringAsFixed(1)}M'; 624 - if (count >= 1000) return '${(count / 1000).toStringAsFixed(1)}K'; 625 - return '$count'; 626 621 } 627 622 }
+2 -7
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/utils/format_utils.dart'; 3 4 4 5 class StarterPackCard extends StatelessWidget { 5 6 const StarterPackCard({super.key, required this.pack, this.onTap}); ··· 75 76 return Column( 76 77 crossAxisAlignment: CrossAxisAlignment.start, 77 78 children: [ 78 - Text(_formatCount(count), style: Theme.of(context).textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w700)), 79 + Text(formatCount(count), style: Theme.of(context).textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w700)), 79 80 Text( 80 81 label, 81 82 style: Theme.of( ··· 84 85 ), 85 86 ], 86 87 ); 87 - } 88 - 89 - String _formatCount(int count) { 90 - if (count >= 1000000) return '${(count / 1000000).toStringAsFixed(1)}M'; 91 - if (count >= 1000) return '${(count / 1000).toStringAsFixed(1)}K'; 92 - return '$count'; 93 88 } 94 89 }
+52
lib/shared/utils/format_utils.dart
··· 1 + import 'package:intl/intl.dart'; 2 + 3 + /// Returns up to two initials from a display value. 4 + String formatInitials(String value) { 5 + final parts = value.trim().split(RegExp(r'\s+')).where((part) => part.isNotEmpty).toList(); 6 + if (parts.isEmpty) { 7 + return '?'; 8 + } 9 + if (parts.length == 1) { 10 + return parts.first.substring(0, 1).toUpperCase(); 11 + } 12 + return '${parts.first.substring(0, 1)}${parts.last.substring(0, 1)}'.toUpperCase(); 13 + } 14 + 15 + /// Formats counts using compact K/M suffixes. 16 + String formatCount(int count) { 17 + final absoluteCount = count.abs(); 18 + final sign = count < 0 ? '-' : ''; 19 + if (absoluteCount >= 1000000) { 20 + return '$sign${(absoluteCount / 1000000).toStringAsFixed(1)}M'; 21 + } 22 + if (absoluteCount >= 1000) { 23 + return '$sign${(absoluteCount / 1000).toStringAsFixed(1)}K'; 24 + } 25 + return '$count'; 26 + } 27 + 28 + /// Formats a relative timestamp using short units with optional suffix/casing. 29 + String formatRelativeTime( 30 + DateTime time, { 31 + DateTime? now, 32 + String nowLabel = 'now', 33 + bool includeAgo = false, 34 + bool uppercase = false, 35 + }) { 36 + final current = now ?? DateTime.now(); 37 + var difference = current.difference(time); 38 + if (difference.isNegative) { 39 + difference = Duration.zero; 40 + } 41 + 42 + final agoSuffix = includeAgo ? ' ago' : ''; 43 + final formatted = switch (difference) { 44 + final d when d.inMinutes < 1 => nowLabel, 45 + final d when d.inHours < 1 => '${d.inMinutes}m$agoSuffix', 46 + final d when d.inDays < 1 => '${d.inHours}h$agoSuffix', 47 + final d when d.inDays < 7 => '${d.inDays}d$agoSuffix', 48 + _ => DateFormat('MMM d').format(time), 49 + }; 50 + 51 + return uppercase ? formatted.toUpperCase() : formatted; 52 + }
+69
test/shared/utils/format_utils_test.dart
··· 1 + import 'package:flutter_test/flutter_test.dart'; 2 + import 'package:intl/intl.dart'; 3 + import 'package:lazurite/shared/utils/format_utils.dart'; 4 + 5 + void main() { 6 + setUp(() { 7 + Intl.defaultLocale = 'en_US'; 8 + }); 9 + 10 + group('formatInitials', () { 11 + test('returns question mark for empty values', () { 12 + expect(formatInitials(''), '?'); 13 + expect(formatInitials(' '), '?'); 14 + }); 15 + 16 + test('formats single and multi-part names', () { 17 + expect(formatInitials('alice'), 'A'); 18 + expect(formatInitials('alice bob'), 'AB'); 19 + expect(formatInitials('alice bob carol'), 'AC'); 20 + }); 21 + }); 22 + 23 + group('formatCount', () { 24 + test('formats zero and negative values', () { 25 + expect(formatCount(0), '0'); 26 + expect(formatCount(-12), '-12'); 27 + expect(formatCount(-1200), '-1.2K'); 28 + }); 29 + 30 + test('formats boundary values', () { 31 + expect(formatCount(999), '999'); 32 + expect(formatCount(1000), '1.0K'); 33 + expect(formatCount(999999), '1000.0K'); 34 + expect(formatCount(1000000), '1.0M'); 35 + }); 36 + }); 37 + 38 + group('formatRelativeTime', () { 39 + final now = DateTime(2026, 4, 25, 12, 0); 40 + 41 + test('formats core minute/hour/day boundaries', () { 42 + expect(formatRelativeTime(now, now: now), 'now'); 43 + expect(formatRelativeTime(now.subtract(const Duration(seconds: 59)), now: now), 'now'); 44 + expect(formatRelativeTime(now.subtract(const Duration(minutes: 1)), now: now), '1m'); 45 + expect(formatRelativeTime(now.subtract(const Duration(minutes: 59)), now: now), '59m'); 46 + expect(formatRelativeTime(now.subtract(const Duration(hours: 1)), now: now), '1h'); 47 + expect(formatRelativeTime(now.subtract(const Duration(hours: 23)), now: now), '23h'); 48 + expect(formatRelativeTime(now.subtract(const Duration(days: 1)), now: now), '1d'); 49 + expect(formatRelativeTime(now.subtract(const Duration(days: 6)), now: now), '6d'); 50 + }); 51 + 52 + test('formats older timestamps as dates and supports uppercase', () { 53 + final formatted = formatRelativeTime(now.subtract(const Duration(days: 7)), now: now); 54 + final formattedUpper = formatRelativeTime(now.subtract(const Duration(days: 7)), now: now, uppercase: true); 55 + 56 + expect(formatted, matches(RegExp(r'^[A-Z][a-z]{2} \d{1,2}$'))); 57 + expect(formattedUpper, matches(RegExp(r'^[A-Z]{3} \d{1,2}$'))); 58 + }); 59 + 60 + test('supports custom labels and suffixes', () { 61 + expect(formatRelativeTime(now, now: now, nowLabel: 'Just now', includeAgo: true), 'Just now'); 62 + expect(formatRelativeTime(now.subtract(const Duration(minutes: 2)), now: now, includeAgo: true), '2m ago'); 63 + }); 64 + 65 + test('clamps future timestamps to now label', () { 66 + expect(formatRelativeTime(now.add(const Duration(minutes: 5)), now: now), 'now'); 67 + }); 68 + }); 69 + }