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 state widgets (error/loading/empty) across the app

+310 -198
+5 -13
docs/tasks/testing.md
··· 10 10 11 11 ## M1 - Shared State Widgets 12 12 13 - - [ ] Create `lib/shared/presentation/widgets/loading_state.dart` 14 - - [ ] Create `lib/shared/presentation/widgets/error_state.dart` - `ErrorState(message, onRetry)` 15 - - [ ] Create `lib/shared/presentation/widgets/empty_state.dart` - `EmptyState(message, icon, action)` 16 - - [ ] Replace loading/error/empty states in: 17 - - `lib/features/feed/presentation/home_feed_screen.dart` 18 - - `lib/features/feed/presentation/feed_management_screen.dart` 19 - - `lib/features/feed/presentation/saved_posts_screen.dart` 20 - - `lib/features/lists/presentation/my_lists_screen.dart` 21 - - `lib/features/profile/presentation/widgets/suggested_follows_list.dart` 22 - - `lib/features/logs/presentation/logs_screen.dart` 23 - - `lib/features/messages/presentation/messages_screen.dart` 24 - - `lib/features/notifications/presentation/notifications_screen.dart` 25 - - [ ] Widget tests for each state widget 13 + - [x] Create `lib/shared/presentation/widgets/loading_state.dart` 14 + - [x] Create `lib/shared/presentation/widgets/error_state.dart` - `ErrorState(message, onRetry)` 15 + - [x] Create `lib/shared/presentation/widgets/empty_state.dart` - `EmptyState(message, icon, action)` 16 + - [x] Replace loading/error/empty states across the app with new widgets 17 + - [x] Widget tests for each state widget 26 18 27 19 ## M2 - Dialog & Sheet Consolidation 28 20
+9 -22
lib/features/feed/presentation/feed_management_screen.dart
··· 4 4 import 'package:flutter_bloc/flutter_bloc.dart'; 5 5 import 'package:lazurite/features/feed/cubit/feed_preferences_cubit.dart'; 6 6 import 'package:lazurite/features/feed/data/feed_repository.dart'; 7 + import 'package:lazurite/shared/presentation/widgets/empty_state.dart'; 8 + import 'package:lazurite/shared/presentation/widgets/loading_state.dart'; 7 9 8 10 class FeedManagementScreen extends StatefulWidget { 9 11 const FeedManagementScreen({super.key}); ··· 61 63 }, 62 64 builder: (context, state) { 63 65 if (state.status == FeedPreferencesStatus.loading) { 64 - return const Center(child: CircularProgressIndicator()); 66 + return const LoadingState(); 65 67 } 66 68 67 69 return ListView( ··· 80 82 const SizedBox(height: 16), 81 83 _buildSectionHeader(context, 'Saved Feeds'), 82 84 if (state.unpinnedFeeds.isEmpty) 83 - Padding( 84 - padding: const EdgeInsets.all(16), 85 - child: Text( 86 - 'No saved feeds', 87 - style: Theme.of( 88 - context, 89 - ).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), 90 - ), 91 - ) 85 + const EmptyState(message: 'No saved feeds', icon: Icons.bookmark_border, padding: EdgeInsets.all(16)) 92 86 else 93 87 ...state.unpinnedFeeds.map((feed) => _buildSavedFeedItem(context, feed)), 94 88 const SizedBox(height: 16), ··· 200 194 201 195 Widget _buildDiscoverSection(BuildContext context) { 202 196 if (_isLoadingSuggestions) { 203 - return const Padding( 204 - padding: EdgeInsets.all(32), 205 - child: Center(child: CircularProgressIndicator()), 206 - ); 197 + return const LoadingState(padding: EdgeInsets.all(32)); 207 198 } 208 199 209 200 if (_suggestedFeeds == null || _suggestedFeeds!.isEmpty) { 210 - return Padding( 211 - padding: const EdgeInsets.all(16), 212 - child: Text( 213 - 'No suggested feeds available', 214 - style: Theme.of( 215 - context, 216 - ).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), 217 - ), 201 + return const EmptyState( 202 + message: 'No suggested feeds available', 203 + icon: Icons.travel_explore_outlined, 204 + padding: EdgeInsets.all(16), 218 205 ); 219 206 } 220 207
+20 -50
lib/features/feed/presentation/home_feed_screen.dart
··· 12 12 import 'package:lazurite/features/feed/data/feed_repository.dart'; 13 13 import 'package:lazurite/features/feed/presentation/widgets/feed_layout_view.dart'; 14 14 import 'package:lazurite/features/feed/presentation/widgets/post_card_with_actions.dart'; 15 + import 'package:lazurite/shared/presentation/widgets/empty_state.dart'; 16 + import 'package:lazurite/shared/presentation/widgets/error_state.dart'; 17 + import 'package:lazurite/shared/presentation/widgets/loading_state.dart'; 15 18 16 19 /// Returns the number of grid columns for [width] per the responsive 17 20 /// breakpoints defined in the UI spec. ··· 50 53 return BlocBuilder<FeedPreferencesCubit, FeedPreferencesState>( 51 54 builder: (context, prefsState) { 52 55 if (prefsState.status == FeedPreferencesStatus.initial || prefsState.status == FeedPreferencesStatus.loading) { 53 - return const Scaffold(body: Center(child: CircularProgressIndicator())); 56 + return const Scaffold(body: LoadingState()); 54 57 } 55 58 56 59 if (prefsState.status == FeedPreferencesStatus.error) { 57 60 return Scaffold( 58 61 appBar: const LazuriteAppBar(sectionLabel: 'Home'), 59 - body: Center( 60 - child: Column( 61 - mainAxisAlignment: MainAxisAlignment.center, 62 - children: [ 63 - Text('Failed to load feeds', style: Theme.of(context).textTheme.titleMedium), 64 - const SizedBox(height: 8), 65 - Text(prefsState.message ?? 'Unknown error', textAlign: TextAlign.center), 66 - const SizedBox(height: 16), 67 - FilledButton( 68 - onPressed: () => context.read<FeedPreferencesCubit>().loadPreferences(), 69 - child: const Text('Retry'), 70 - ), 71 - ], 72 - ), 62 + body: ErrorState( 63 + title: 'Failed to load feeds', 64 + message: prefsState.message ?? 'Unknown error', 65 + onRetry: () => context.read<FeedPreferencesCubit>().loadPreferences(), 73 66 ), 74 67 ); 75 68 } ··· 80 73 if (pinnedFeeds.isEmpty) { 81 74 return Scaffold( 82 75 appBar: const LazuriteAppBar(sectionLabel: 'Home'), 83 - body: Center( 84 - child: Padding( 85 - padding: const EdgeInsets.all(24), 86 - child: Column( 87 - mainAxisAlignment: MainAxisAlignment.center, 88 - children: [ 89 - Text('No feeds pinned', style: Theme.of(context).textTheme.titleMedium), 90 - const SizedBox(height: 8), 91 - Text( 92 - 'Pin a timeline or custom feed to build your home tabs.', 93 - style: Theme.of(context).textTheme.bodyMedium, 94 - textAlign: TextAlign.center, 95 - ), 96 - const SizedBox(height: 16), 97 - FilledButton(onPressed: () => context.push('/feeds'), child: const Text('Manage Feeds')), 98 - ], 99 - ), 100 - ), 76 + body: EmptyState( 77 + message: 'No feeds pinned', 78 + icon: Icons.rss_feed_outlined, 79 + subtitle: 'Pin a timeline or custom feed to build your home tabs.', 80 + action: FilledButton(onPressed: () => context.push('/feeds'), child: const Text('Manage Feeds')), 101 81 ), 102 82 ); 103 83 } ··· 392 372 super.build(context); 393 373 394 374 if (_showInitialLoading) { 395 - return const Center(child: CircularProgressIndicator()); 375 + return const LoadingState(); 396 376 } 397 377 398 378 if (_hasError) { 399 - return Center( 400 - child: Column( 401 - mainAxisAlignment: MainAxisAlignment.center, 402 - children: [ 403 - Text('Failed to load feed', style: Theme.of(context).textTheme.bodyLarge), 404 - const SizedBox(height: 8), 405 - Text( 406 - _errorMessage ?? 'Unknown error', 407 - style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.error), 408 - textAlign: TextAlign.center, 409 - ), 410 - const SizedBox(height: 16), 411 - FilledButton(onPressed: _loadFeed, child: const Text('Retry')), 412 - ], 413 - ), 379 + return ErrorState( 380 + title: 'Failed to load feed', 381 + message: _errorMessage ?? 'Unknown error', 382 + onRetry: _loadFeed, 383 + icon: Icons.sync_problem_outlined, 414 384 ); 415 385 } 416 386 417 387 if (_posts.isEmpty) { 418 - return Center(child: Text('No posts yet', style: Theme.of(context).textTheme.bodyLarge)); 388 + return const EmptyState(message: 'No posts yet', icon: Icons.article_outlined); 419 389 } 420 390 421 391 final accountDid = context.read<AuthBloc>().state.tokens?.did ?? '';
+12 -36
lib/features/feed/presentation/saved_posts_screen.dart
··· 10 10 import 'package:lazurite/features/feed/data/post_action_repository.dart'; 11 11 import 'package:lazurite/features/feed/presentation/widgets/post_card_with_actions.dart'; 12 12 import 'package:lazurite/features/search/presentation/semantic_search_tab.dart'; 13 + import 'package:lazurite/shared/presentation/widgets/empty_state.dart'; 14 + import 'package:lazurite/shared/presentation/widgets/error_state.dart'; 15 + import 'package:lazurite/shared/presentation/widgets/loading_state.dart'; 13 16 import 'package:share_plus/share_plus.dart'; 14 17 15 18 class SavedPostsScreen extends StatelessWidget { ··· 114 117 return BlocBuilder<SavedPostsCubit, SavedPostsState>( 115 118 builder: (context, state) { 116 119 if (state.status == SavedPostsStatus.loading) { 117 - return const Center(child: CircularProgressIndicator()); 120 + return const LoadingState(); 118 121 } 119 122 120 123 if (state.status == SavedPostsStatus.error) { 121 - return Center( 122 - child: Column( 123 - mainAxisAlignment: MainAxisAlignment.center, 124 - children: [ 125 - const Icon(Icons.error_outline, size: 48, color: Colors.grey), 126 - const SizedBox(height: 16), 127 - Text(state.error ?? 'Failed to load saved posts'), 128 - const SizedBox(height: 16), 129 - FilledButton( 130 - onPressed: () => context.read<SavedPostsCubit>().loadSavedPosts(), 131 - child: const Text('Retry'), 132 - ), 133 - ], 134 - ), 124 + return ErrorState( 125 + title: 'Failed to load saved posts', 126 + message: state.error ?? 'Unknown error', 127 + onRetry: () => context.read<SavedPostsCubit>().loadSavedPosts(), 135 128 ); 136 129 } 137 130 138 131 if (state.savedPosts.isEmpty) { 139 - return Center( 140 - child: Column( 141 - mainAxisAlignment: MainAxisAlignment.center, 142 - children: [ 143 - Icon(Icons.bookmark_outline, size: 64, color: Theme.of(context).colorScheme.outline), 144 - const SizedBox(height: 16), 145 - Text( 146 - 'No saved posts', 147 - style: Theme.of( 148 - context, 149 - ).textTheme.headlineSmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), 150 - ), 151 - const SizedBox(height: 8), 152 - Text( 153 - 'Posts you save will appear here', 154 - style: Theme.of( 155 - context, 156 - ).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), 157 - ), 158 - ], 159 - ), 132 + return const EmptyState( 133 + message: 'No saved posts', 134 + subtitle: 'Posts you save will appear here', 135 + icon: Icons.bookmark_outline, 160 136 ); 161 137 } 162 138
+9 -11
lib/features/lists/presentation/my_lists_screen.dart
··· 7 7 import 'package:lazurite/features/lists/data/list_repository.dart'; 8 8 import 'package:lazurite/features/lists/presentation/widgets/create_edit_list_dialog.dart'; 9 9 import 'package:lazurite/features/lists/presentation/widgets/list_row_tile.dart'; 10 + import 'package:lazurite/shared/presentation/widgets/empty_state.dart'; 11 + import 'package:lazurite/shared/presentation/widgets/error_state.dart'; 12 + import 'package:lazurite/shared/presentation/widgets/loading_state.dart'; 10 13 11 14 class MyListsScreen extends StatelessWidget { 12 15 const MyListsScreen({super.key}); ··· 88 91 body: BlocBuilder<MyListsCubit, MyListsState>( 89 92 builder: (context, state) { 90 93 if (state.status == MyListsStatus.loading) { 91 - return const Center(child: CircularProgressIndicator()); 94 + return const LoadingState(); 92 95 } 93 96 94 97 if (state.status == MyListsStatus.error) { 95 - return Center( 96 - child: Column( 97 - mainAxisSize: MainAxisSize.min, 98 - children: [ 99 - Text(state.errorMessage ?? 'Failed to load lists'), 100 - const SizedBox(height: 12), 101 - FilledButton(onPressed: () => context.read<MyListsCubit>().refresh(), child: const Text('Retry')), 102 - ], 103 - ), 98 + return ErrorState( 99 + title: 'Failed to load lists', 100 + message: state.errorMessage ?? 'Unknown error', 101 + onRetry: () => context.read<MyListsCubit>().refresh(), 104 102 ); 105 103 } 106 104 ··· 120 118 121 119 Widget _buildListTab(BuildContext context, List<bsky_graph.ListView> lists) { 122 120 if (lists.isEmpty) { 123 - return const Center(child: Text('No lists yet')); 121 + return const EmptyState(message: 'No lists yet', icon: Icons.list_alt_outlined); 124 122 } 125 123 126 124 return RefreshIndicator(
+13 -13
lib/features/logs/presentation/logs_screen.dart
··· 3 3 import 'package:logger/logger.dart'; 4 4 import 'package:lazurite/features/logs/cubit/log_viewer_cubit.dart'; 5 5 import 'package:lazurite/features/logs/data/log_entry.dart'; 6 + import 'package:lazurite/shared/presentation/widgets/empty_state.dart'; 7 + import 'package:lazurite/shared/presentation/widgets/error_state.dart'; 8 + import 'package:lazurite/shared/presentation/widgets/loading_state.dart'; 6 9 import 'package:share_plus/share_plus.dart'; 7 10 8 11 class LogsScreen extends StatelessWidget { ··· 267 270 return BlocBuilder<LogViewerCubit, LogViewerState>( 268 271 builder: (context, state) { 269 272 if (state.status == LogViewerStatus.loading) { 270 - return const Center(child: CircularProgressIndicator()); 273 + return const LoadingState(); 271 274 } 272 275 273 276 if (state.status == LogViewerStatus.error) { 274 - return Center(child: Text('Error: ${state.errorMessage}')); 277 + return ErrorState( 278 + title: 'Failed to load logs', 279 + message: state.errorMessage ?? 'Unknown error', 280 + onRetry: () => context.read<LogViewerCubit>().loadLogs(), 281 + ); 275 282 } 276 283 277 284 if (state.filteredEntries.isEmpty) { 278 - return Center( 279 - child: Column( 280 - mainAxisSize: MainAxisSize.min, 281 - children: [ 282 - Icon(Icons.description_outlined, size: 48, color: Theme.of(context).colorScheme.outline), 283 - const SizedBox(height: 16), 284 - Text('No logs yet', style: Theme.of(context).textTheme.titleMedium), 285 - const SizedBox(height: 4), 286 - Text('Log entries will appear here', style: Theme.of(context).textTheme.bodySmall), 287 - ], 288 - ), 285 + return const EmptyState( 286 + message: 'No logs yet', 287 + subtitle: 'Log entries will appear here', 288 + icon: Icons.description_outlined, 289 289 ); 290 290 } 291 291
+11 -20
lib/features/messages/presentation/widgets/convo_list_pane.dart
··· 7 7 import 'package:lazurite/features/messages/bloc/convo_list_bloc.dart'; 8 8 import 'package:lazurite/features/messages/presentation/message_thread_route_args.dart'; 9 9 import 'package:lazurite/features/messages/presentation/widgets/convo_list_item.dart'; 10 + import 'package:lazurite/shared/presentation/widgets/empty_state.dart'; 11 + import 'package:lazurite/shared/presentation/widgets/error_state.dart'; 12 + import 'package:lazurite/shared/presentation/widgets/loading_state.dart'; 10 13 11 14 class ConvoListPane extends StatefulWidget { 12 15 const ConvoListPane({super.key, required this.tab}); ··· 78 81 if (isOffline) { 79 82 return const _OfflineConvoState(); 80 83 } 81 - return const Center(child: CircularProgressIndicator()); 84 + return const LoadingState(); 82 85 } 83 86 84 87 if (state.status == ConvoListStatus.error && state.convos.isEmpty) { 85 88 if (isOffline) { 86 89 return const _OfflineConvoState(); 87 90 } 88 - return Center( 89 - child: Column( 90 - mainAxisAlignment: MainAxisAlignment.center, 91 - children: [ 92 - Text('Failed to load messages', style: Theme.of(context).textTheme.titleMedium), 93 - const SizedBox(height: 8), 94 - Text(state.errorMessage ?? 'Unknown error', textAlign: TextAlign.center), 95 - const SizedBox(height: 16), 96 - FilledButton( 97 - onPressed: () => context.read<ConvoListBloc>().add(const ConvosRequested()), 98 - child: const Text('Retry'), 99 - ), 100 - ], 101 - ), 91 + return ErrorState( 92 + title: 'Failed to load messages', 93 + message: state.errorMessage ?? 'Unknown error', 94 + onRetry: () => context.read<ConvoListBloc>().add(const ConvosRequested()), 102 95 ); 103 96 } 104 97 ··· 115 108 children: [ 116 109 SizedBox( 117 110 height: MediaQuery.of(context).size.height * 0.5, 118 - child: Center( 119 - child: Text( 120 - widget.tab == ConvoTab.primary ? 'No conversations yet' : 'No message requests', 121 - style: Theme.of(context).textTheme.bodyLarge, 122 - ), 111 + child: EmptyState( 112 + message: widget.tab == ConvoTab.primary ? 'No conversations yet' : 'No message requests', 113 + icon: Icons.forum_outlined, 123 114 ), 124 115 ), 125 116 ],
+9 -16
lib/features/notifications/presentation/widgets/notifications_pane.dart
··· 6 6 import 'package:lazurite/features/notifications/cubit/unread_count_cubit.dart'; 7 7 import 'package:lazurite/features/notifications/presentation/widgets/grouped_notification_list_item.dart'; 8 8 import 'package:lazurite/features/notifications/presentation/widgets/notification_list_item.dart'; 9 + import 'package:lazurite/shared/presentation/widgets/empty_state.dart'; 10 + import 'package:lazurite/shared/presentation/widgets/error_state.dart'; 11 + import 'package:lazurite/shared/presentation/widgets/loading_state.dart'; 9 12 10 13 class NotificationsPane extends StatefulWidget { 11 14 const NotificationsPane({super.key}); ··· 58 61 if (isOffline) { 59 62 return const _OfflineNotificationsState(); 60 63 } 61 - return const Center(child: CircularProgressIndicator()); 64 + return const LoadingState(); 62 65 } 63 66 64 67 if (state.status == NotificationStatus.error && state.notifications.isEmpty) { 65 68 if (isOffline) { 66 69 return const _OfflineNotificationsState(); 67 70 } 68 - return Center( 69 - child: Column( 70 - mainAxisAlignment: MainAxisAlignment.center, 71 - children: [ 72 - Text('Failed to load notifications', style: Theme.of(context).textTheme.titleMedium), 73 - const SizedBox(height: 8), 74 - Text(state.errorMessage ?? 'Unknown error', textAlign: TextAlign.center), 75 - const SizedBox(height: 16), 76 - FilledButton( 77 - onPressed: () => context.read<NotificationBloc>().add(const NotificationsRequested()), 78 - child: const Text('Retry'), 79 - ), 80 - ], 81 - ), 71 + return ErrorState( 72 + title: 'Failed to load notifications', 73 + message: state.errorMessage ?? 'Unknown error', 74 + onRetry: () => context.read<NotificationBloc>().add(const NotificationsRequested()), 82 75 ); 83 76 } 84 77 ··· 86 79 if (isOffline) { 87 80 return const _OfflineNotificationsState(); 88 81 } 89 - return Center(child: Text('No notifications yet', style: Theme.of(context).textTheme.bodyLarge)); 82 + return const EmptyState(message: 'No notifications yet', icon: Icons.notifications_none_outlined); 90 83 } 91 84 92 85 final groupedNotifications = _groupNotificationsByDay(state.notifications);
+9 -17
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/empty_state.dart'; 8 + import 'package:lazurite/shared/presentation/widgets/error_state.dart'; 9 + import 'package:lazurite/shared/presentation/widgets/loading_state.dart'; 7 10 import 'package:lazurite/shared/utils/format_utils.dart'; 8 11 9 12 class SuggestedFollowsList extends StatelessWidget { ··· 27 30 return BlocBuilder<SuggestedFollowsCubit, SuggestedFollowsState>( 28 31 builder: (context, state) { 29 32 if (state.isLoading) { 30 - return const Center(child: CircularProgressIndicator()); 33 + return const LoadingState(); 31 34 } 32 35 33 36 if (state.hasError) { 34 - return Center( 35 - child: Padding( 36 - padding: const EdgeInsets.all(24), 37 - child: Column( 38 - mainAxisSize: MainAxisSize.min, 39 - children: [ 40 - Text(state.errorMessage ?? 'Failed to load suggestions', textAlign: TextAlign.center), 41 - const SizedBox(height: 12), 42 - FilledButton( 43 - onPressed: () => context.read<SuggestedFollowsCubit>().load(actor), 44 - child: const Text('Retry'), 45 - ), 46 - ], 47 - ), 48 - ), 37 + return ErrorState( 38 + title: 'Failed to load suggestions', 39 + message: state.errorMessage ?? 'Unknown error', 40 + onRetry: () => context.read<SuggestedFollowsCubit>().load(actor), 49 41 ); 50 42 } 51 43 52 44 if (state.isEmpty) { 53 - return Center(child: Text(emptyMessage)); 45 + return EmptyState(message: emptyMessage, icon: Icons.person_search_outlined); 54 46 } 55 47 56 48 return ListView.builder(
+57
lib/shared/presentation/widgets/empty_state.dart
··· 1 + import 'package:flutter/material.dart'; 2 + 3 + class EmptyState extends StatelessWidget { 4 + const EmptyState({ 5 + super.key, 6 + required this.message, 7 + this.icon = Icons.inbox_outlined, 8 + this.action, 9 + this.subtitle, 10 + this.padding = const EdgeInsets.all(24), 11 + }); 12 + 13 + final String message; 14 + final IconData icon; 15 + final Widget? action; 16 + final String? subtitle; 17 + final EdgeInsetsGeometry padding; 18 + 19 + @override 20 + Widget build(BuildContext context) { 21 + final colorScheme = Theme.of(context).colorScheme; 22 + final textTheme = Theme.of(context).textTheme; 23 + 24 + return Center( 25 + child: Padding( 26 + padding: padding, 27 + child: Column( 28 + mainAxisSize: MainAxisSize.min, 29 + children: [ 30 + Icon(icon, size: 48, color: colorScheme.outline), 31 + const SizedBox(height: 12), 32 + Text( 33 + message, 34 + textAlign: TextAlign.center, 35 + style: textTheme.titleMedium?.copyWith(color: colorScheme.onSurfaceVariant), 36 + ), 37 + ..._subtitle(subtitle, textTheme, colorScheme), 38 + ..._action(action), 39 + ], 40 + ), 41 + ), 42 + ); 43 + } 44 + 45 + List<Widget> _action(Widget? action) => (action == null) ? [] : [const SizedBox(height: 16), action]; 46 + 47 + List<Widget> _subtitle(String? subtitle, TextTheme textTheme, ColorScheme colorScheme) => (subtitle == null) 48 + ? [] 49 + : [ 50 + const SizedBox(height: 8), 51 + Text( 52 + subtitle, 53 + textAlign: TextAlign.center, 54 + style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant), 55 + ), 56 + ]; 57 + }
+40
lib/shared/presentation/widgets/error_state.dart
··· 1 + import 'package:flutter/material.dart'; 2 + 3 + class ErrorState extends StatelessWidget { 4 + const ErrorState({ 5 + super.key, 6 + required this.message, 7 + required this.onRetry, 8 + this.title = 'Something went wrong', 9 + this.retryLabel = 'Retry', 10 + this.icon = Icons.error_outline, 11 + }); 12 + 13 + final String title; 14 + final String message; 15 + final VoidCallback onRetry; 16 + final String retryLabel; 17 + final IconData icon; 18 + 19 + @override 20 + Widget build(BuildContext context) { 21 + final theme = Theme.of(context); 22 + return Center( 23 + child: Padding( 24 + padding: const EdgeInsets.all(24), 25 + child: Column( 26 + mainAxisSize: MainAxisSize.min, 27 + children: [ 28 + Icon(icon, size: 48, color: theme.colorScheme.outline), 29 + const SizedBox(height: 12), 30 + Text(title, style: theme.textTheme.titleMedium, textAlign: TextAlign.center), 31 + const SizedBox(height: 8), 32 + Text(message, textAlign: TextAlign.center), 33 + const SizedBox(height: 16), 34 + FilledButton(onPressed: onRetry, child: Text(retryLabel)), 35 + ], 36 + ), 37 + ), 38 + ); 39 + } 40 + }
+33
lib/shared/presentation/widgets/loading_state.dart
··· 1 + import 'package:flutter/material.dart'; 2 + 3 + class LoadingState extends StatelessWidget { 4 + const LoadingState({super.key, this.message, this.padding = const EdgeInsets.all(24)}); 5 + 6 + final String? message; 7 + final EdgeInsetsGeometry padding; 8 + 9 + @override 10 + Widget build(BuildContext context) { 11 + final theme = Theme.of(context); 12 + return Center( 13 + child: Padding( 14 + padding: padding, 15 + child: Column( 16 + mainAxisSize: MainAxisSize.min, 17 + children: [const CircularProgressIndicator(), ..._message(message, theme.textTheme, theme.colorScheme)], 18 + ), 19 + ), 20 + ); 21 + } 22 + 23 + List<Widget> _message(String? message, TextTheme textTheme, ColorScheme colorScheme) => (message == null) 24 + ? [] 25 + : [ 26 + const SizedBox(height: 12), 27 + Text( 28 + message, 29 + textAlign: TextAlign.center, 30 + style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant), 31 + ), 32 + ]; 33 + }
+32
test/shared/presentation/widgets/empty_state_test.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:flutter_test/flutter_test.dart'; 3 + import 'package:lazurite/shared/presentation/widgets/empty_state.dart'; 4 + 5 + void main() { 6 + Widget buildSubject(Widget child) { 7 + return MaterialApp(home: Scaffold(body: child)); 8 + } 9 + 10 + testWidgets('renders message and icon', (tester) async { 11 + await tester.pumpWidget(buildSubject(const EmptyState(message: 'No items', icon: Icons.search_off_outlined))); 12 + 13 + expect(find.text('No items'), findsOneWidget); 14 + expect(find.byIcon(Icons.search_off_outlined), findsOneWidget); 15 + }); 16 + 17 + testWidgets('renders subtitle and action', (tester) async { 18 + await tester.pumpWidget( 19 + buildSubject( 20 + EmptyState( 21 + message: 'No feeds pinned', 22 + subtitle: 'Add feeds to continue.', 23 + action: FilledButton(onPressed: () {}, child: const Text('Manage Feeds')), 24 + ), 25 + ), 26 + ); 27 + 28 + expect(find.text('No feeds pinned'), findsOneWidget); 29 + expect(find.text('Add feeds to continue.'), findsOneWidget); 30 + expect(find.text('Manage Feeds'), findsOneWidget); 31 + }); 32 + }
+30
test/shared/presentation/widgets/error_state_test.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:flutter_test/flutter_test.dart'; 3 + import 'package:lazurite/shared/presentation/widgets/error_state.dart'; 4 + 5 + void main() { 6 + Widget buildSubject(Widget child) { 7 + return MaterialApp(home: Scaffold(body: child)); 8 + } 9 + 10 + testWidgets('renders title and message', (tester) async { 11 + await tester.pumpWidget( 12 + buildSubject(ErrorState(title: 'Failed to load', message: 'Request timed out', onRetry: () {})), 13 + ); 14 + 15 + expect(find.text('Failed to load'), findsOneWidget); 16 + expect(find.text('Request timed out'), findsOneWidget); 17 + expect(find.text('Retry'), findsOneWidget); 18 + }); 19 + 20 + testWidgets('invokes retry callback', (tester) async { 21 + var retried = false; 22 + 23 + await tester.pumpWidget(buildSubject(ErrorState(message: 'Failed', onRetry: () => retried = true))); 24 + 25 + await tester.tap(find.text('Retry')); 26 + await tester.pump(); 27 + 28 + expect(retried, isTrue); 29 + }); 30 + }
+21
test/shared/presentation/widgets/loading_state_test.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:flutter_test/flutter_test.dart'; 3 + import 'package:lazurite/shared/presentation/widgets/loading_state.dart'; 4 + 5 + void main() { 6 + Widget buildSubject(Widget child) { 7 + return MaterialApp(home: Scaffold(body: child)); 8 + } 9 + 10 + testWidgets('renders progress indicator', (tester) async { 11 + await tester.pumpWidget(buildSubject(const LoadingState())); 12 + 13 + expect(find.byType(CircularProgressIndicator), findsOneWidget); 14 + }); 15 + 16 + testWidgets('renders optional message', (tester) async { 17 + await tester.pumpWidget(buildSubject(const LoadingState(message: 'Loading items...'))); 18 + 19 + expect(find.text('Loading items...'), findsOneWidget); 20 + }); 21 + }