diff --git a/docs/tasks/testing.md b/docs/tasks/testing.md index 7966f2d..1c9c6bc 100644 --- a/docs/tasks/testing.md +++ b/docs/tasks/testing.md @@ -10,19 +10,11 @@ ## M1 - Shared State Widgets -- [ ] Create `lib/shared/presentation/widgets/loading_state.dart` -- [ ] Create `lib/shared/presentation/widgets/error_state.dart` - `ErrorState(message, onRetry)` -- [ ] Create `lib/shared/presentation/widgets/empty_state.dart` - `EmptyState(message, icon, action)` -- [ ] Replace loading/error/empty states in: - - `lib/features/feed/presentation/home_feed_screen.dart` - - `lib/features/feed/presentation/feed_management_screen.dart` - - `lib/features/feed/presentation/saved_posts_screen.dart` - - `lib/features/lists/presentation/my_lists_screen.dart` - - `lib/features/profile/presentation/widgets/suggested_follows_list.dart` - - `lib/features/logs/presentation/logs_screen.dart` - - `lib/features/messages/presentation/messages_screen.dart` - - `lib/features/notifications/presentation/notifications_screen.dart` -- [ ] Widget tests for each state widget +- [x] Create `lib/shared/presentation/widgets/loading_state.dart` +- [x] Create `lib/shared/presentation/widgets/error_state.dart` - `ErrorState(message, onRetry)` +- [x] Create `lib/shared/presentation/widgets/empty_state.dart` - `EmptyState(message, icon, action)` +- [x] Replace loading/error/empty states across the app with new widgets +- [x] Widget tests for each state widget ## M2 - Dialog & Sheet Consolidation diff --git a/lib/features/feed/presentation/feed_management_screen.dart b/lib/features/feed/presentation/feed_management_screen.dart index cbb9bd4..68150dd 100644 --- a/lib/features/feed/presentation/feed_management_screen.dart +++ b/lib/features/feed/presentation/feed_management_screen.dart @@ -4,6 +4,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:lazurite/features/feed/cubit/feed_preferences_cubit.dart'; import 'package:lazurite/features/feed/data/feed_repository.dart'; +import 'package:lazurite/shared/presentation/widgets/empty_state.dart'; +import 'package:lazurite/shared/presentation/widgets/loading_state.dart'; class FeedManagementScreen extends StatefulWidget { const FeedManagementScreen({super.key}); @@ -61,7 +63,7 @@ class _FeedManagementScreenState extends State { }, builder: (context, state) { if (state.status == FeedPreferencesStatus.loading) { - return const Center(child: CircularProgressIndicator()); + return const LoadingState(); } return ListView( @@ -80,15 +82,7 @@ class _FeedManagementScreenState extends State { const SizedBox(height: 16), _buildSectionHeader(context, 'Saved Feeds'), if (state.unpinnedFeeds.isEmpty) - Padding( - padding: const EdgeInsets.all(16), - child: Text( - 'No saved feeds', - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), - ), - ) + const EmptyState(message: 'No saved feeds', icon: Icons.bookmark_border, padding: EdgeInsets.all(16)) else ...state.unpinnedFeeds.map((feed) => _buildSavedFeedItem(context, feed)), const SizedBox(height: 16), @@ -200,21 +194,14 @@ class _FeedManagementScreenState extends State { Widget _buildDiscoverSection(BuildContext context) { if (_isLoadingSuggestions) { - return const Padding( - padding: EdgeInsets.all(32), - child: Center(child: CircularProgressIndicator()), - ); + return const LoadingState(padding: EdgeInsets.all(32)); } if (_suggestedFeeds == null || _suggestedFeeds!.isEmpty) { - return Padding( - padding: const EdgeInsets.all(16), - child: Text( - 'No suggested feeds available', - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), - ), + return const EmptyState( + message: 'No suggested feeds available', + icon: Icons.travel_explore_outlined, + padding: EdgeInsets.all(16), ); } diff --git a/lib/features/feed/presentation/home_feed_screen.dart b/lib/features/feed/presentation/home_feed_screen.dart index 1493568..d404c57 100644 --- a/lib/features/feed/presentation/home_feed_screen.dart +++ b/lib/features/feed/presentation/home_feed_screen.dart @@ -12,6 +12,9 @@ import 'package:lazurite/features/feed/cubit/feed_preferences_cubit.dart'; import 'package:lazurite/features/feed/data/feed_repository.dart'; import 'package:lazurite/features/feed/presentation/widgets/feed_layout_view.dart'; import 'package:lazurite/features/feed/presentation/widgets/post_card_with_actions.dart'; +import 'package:lazurite/shared/presentation/widgets/empty_state.dart'; +import 'package:lazurite/shared/presentation/widgets/error_state.dart'; +import 'package:lazurite/shared/presentation/widgets/loading_state.dart'; /// Returns the number of grid columns for [width] per the responsive /// breakpoints defined in the UI spec. @@ -50,26 +53,16 @@ class _HomeFeedScreenState extends State { return BlocBuilder( builder: (context, prefsState) { if (prefsState.status == FeedPreferencesStatus.initial || prefsState.status == FeedPreferencesStatus.loading) { - return const Scaffold(body: Center(child: CircularProgressIndicator())); + return const Scaffold(body: LoadingState()); } if (prefsState.status == FeedPreferencesStatus.error) { return Scaffold( appBar: const LazuriteAppBar(sectionLabel: 'Home'), - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text('Failed to load feeds', style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: 8), - Text(prefsState.message ?? 'Unknown error', textAlign: TextAlign.center), - const SizedBox(height: 16), - FilledButton( - onPressed: () => context.read().loadPreferences(), - child: const Text('Retry'), - ), - ], - ), + body: ErrorState( + title: 'Failed to load feeds', + message: prefsState.message ?? 'Unknown error', + onRetry: () => context.read().loadPreferences(), ), ); } @@ -80,24 +73,11 @@ class _HomeFeedScreenState extends State { if (pinnedFeeds.isEmpty) { return Scaffold( appBar: const LazuriteAppBar(sectionLabel: 'Home'), - body: Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text('No feeds pinned', style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: 8), - Text( - 'Pin a timeline or custom feed to build your home tabs.', - style: Theme.of(context).textTheme.bodyMedium, - textAlign: TextAlign.center, - ), - const SizedBox(height: 16), - FilledButton(onPressed: () => context.push('/feeds'), child: const Text('Manage Feeds')), - ], - ), - ), + body: EmptyState( + message: 'No feeds pinned', + icon: Icons.rss_feed_outlined, + subtitle: 'Pin a timeline or custom feed to build your home tabs.', + action: FilledButton(onPressed: () => context.push('/feeds'), child: const Text('Manage Feeds')), ), ); } @@ -392,30 +372,20 @@ class _FeedListViewState extends State<_FeedListView> with AutomaticKeepAliveCli super.build(context); if (_showInitialLoading) { - return const Center(child: CircularProgressIndicator()); + return const LoadingState(); } if (_hasError) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text('Failed to load feed', style: Theme.of(context).textTheme.bodyLarge), - const SizedBox(height: 8), - Text( - _errorMessage ?? 'Unknown error', - style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.error), - textAlign: TextAlign.center, - ), - const SizedBox(height: 16), - FilledButton(onPressed: _loadFeed, child: const Text('Retry')), - ], - ), + return ErrorState( + title: 'Failed to load feed', + message: _errorMessage ?? 'Unknown error', + onRetry: _loadFeed, + icon: Icons.sync_problem_outlined, ); } if (_posts.isEmpty) { - return Center(child: Text('No posts yet', style: Theme.of(context).textTheme.bodyLarge)); + return const EmptyState(message: 'No posts yet', icon: Icons.article_outlined); } final accountDid = context.read().state.tokens?.did ?? ''; diff --git a/lib/features/feed/presentation/saved_posts_screen.dart b/lib/features/feed/presentation/saved_posts_screen.dart index e94006f..da08c2c 100644 --- a/lib/features/feed/presentation/saved_posts_screen.dart +++ b/lib/features/feed/presentation/saved_posts_screen.dart @@ -10,6 +10,9 @@ import 'package:lazurite/features/feed/cubit/saved_posts_cubit.dart'; import 'package:lazurite/features/feed/data/post_action_repository.dart'; import 'package:lazurite/features/feed/presentation/widgets/post_card_with_actions.dart'; import 'package:lazurite/features/search/presentation/semantic_search_tab.dart'; +import 'package:lazurite/shared/presentation/widgets/empty_state.dart'; +import 'package:lazurite/shared/presentation/widgets/error_state.dart'; +import 'package:lazurite/shared/presentation/widgets/loading_state.dart'; import 'package:share_plus/share_plus.dart'; class SavedPostsScreen extends StatelessWidget { @@ -114,49 +117,22 @@ class _AllSavedTab extends StatelessWidget { return BlocBuilder( builder: (context, state) { if (state.status == SavedPostsStatus.loading) { - return const Center(child: CircularProgressIndicator()); + return const LoadingState(); } if (state.status == SavedPostsStatus.error) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.error_outline, size: 48, color: Colors.grey), - const SizedBox(height: 16), - Text(state.error ?? 'Failed to load saved posts'), - const SizedBox(height: 16), - FilledButton( - onPressed: () => context.read().loadSavedPosts(), - child: const Text('Retry'), - ), - ], - ), + return ErrorState( + title: 'Failed to load saved posts', + message: state.error ?? 'Unknown error', + onRetry: () => context.read().loadSavedPosts(), ); } if (state.savedPosts.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.bookmark_outline, size: 64, color: Theme.of(context).colorScheme.outline), - const SizedBox(height: 16), - Text( - 'No saved posts', - style: Theme.of( - context, - ).textTheme.headlineSmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), - ), - const SizedBox(height: 8), - Text( - 'Posts you save will appear here', - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), - ), - ], - ), + return const EmptyState( + message: 'No saved posts', + subtitle: 'Posts you save will appear here', + icon: Icons.bookmark_outline, ); } diff --git a/lib/features/lists/presentation/my_lists_screen.dart b/lib/features/lists/presentation/my_lists_screen.dart index 6789b7e..f7cfe75 100644 --- a/lib/features/lists/presentation/my_lists_screen.dart +++ b/lib/features/lists/presentation/my_lists_screen.dart @@ -7,6 +7,9 @@ import 'package:lazurite/features/lists/cubit/my_lists_cubit.dart'; import 'package:lazurite/features/lists/data/list_repository.dart'; import 'package:lazurite/features/lists/presentation/widgets/create_edit_list_dialog.dart'; import 'package:lazurite/features/lists/presentation/widgets/list_row_tile.dart'; +import 'package:lazurite/shared/presentation/widgets/empty_state.dart'; +import 'package:lazurite/shared/presentation/widgets/error_state.dart'; +import 'package:lazurite/shared/presentation/widgets/loading_state.dart'; class MyListsScreen extends StatelessWidget { const MyListsScreen({super.key}); @@ -88,19 +91,14 @@ class _MyListsViewState extends State<_MyListsView> with SingleTickerProviderSta body: BlocBuilder( builder: (context, state) { if (state.status == MyListsStatus.loading) { - return const Center(child: CircularProgressIndicator()); + return const LoadingState(); } if (state.status == MyListsStatus.error) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text(state.errorMessage ?? 'Failed to load lists'), - const SizedBox(height: 12), - FilledButton(onPressed: () => context.read().refresh(), child: const Text('Retry')), - ], - ), + return ErrorState( + title: 'Failed to load lists', + message: state.errorMessage ?? 'Unknown error', + onRetry: () => context.read().refresh(), ); } @@ -120,7 +118,7 @@ class _MyListsViewState extends State<_MyListsView> with SingleTickerProviderSta Widget _buildListTab(BuildContext context, List lists) { if (lists.isEmpty) { - return const Center(child: Text('No lists yet')); + return const EmptyState(message: 'No lists yet', icon: Icons.list_alt_outlined); } return RefreshIndicator( diff --git a/lib/features/logs/presentation/logs_screen.dart b/lib/features/logs/presentation/logs_screen.dart index 73aab8a..c26f22c 100644 --- a/lib/features/logs/presentation/logs_screen.dart +++ b/lib/features/logs/presentation/logs_screen.dart @@ -3,6 +3,9 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:logger/logger.dart'; import 'package:lazurite/features/logs/cubit/log_viewer_cubit.dart'; import 'package:lazurite/features/logs/data/log_entry.dart'; +import 'package:lazurite/shared/presentation/widgets/empty_state.dart'; +import 'package:lazurite/shared/presentation/widgets/error_state.dart'; +import 'package:lazurite/shared/presentation/widgets/loading_state.dart'; import 'package:share_plus/share_plus.dart'; class LogsScreen extends StatelessWidget { @@ -267,25 +270,22 @@ class _LogList extends StatelessWidget { return BlocBuilder( builder: (context, state) { if (state.status == LogViewerStatus.loading) { - return const Center(child: CircularProgressIndicator()); + return const LoadingState(); } if (state.status == LogViewerStatus.error) { - return Center(child: Text('Error: ${state.errorMessage}')); + return ErrorState( + title: 'Failed to load logs', + message: state.errorMessage ?? 'Unknown error', + onRetry: () => context.read().loadLogs(), + ); } if (state.filteredEntries.isEmpty) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.description_outlined, size: 48, color: Theme.of(context).colorScheme.outline), - const SizedBox(height: 16), - Text('No logs yet', style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: 4), - Text('Log entries will appear here', style: Theme.of(context).textTheme.bodySmall), - ], - ), + return const EmptyState( + message: 'No logs yet', + subtitle: 'Log entries will appear here', + icon: Icons.description_outlined, ); } diff --git a/lib/features/messages/presentation/widgets/convo_list_pane.dart b/lib/features/messages/presentation/widgets/convo_list_pane.dart index 3e2b2f4..39e9397 100644 --- a/lib/features/messages/presentation/widgets/convo_list_pane.dart +++ b/lib/features/messages/presentation/widgets/convo_list_pane.dart @@ -7,6 +7,9 @@ import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; import 'package:lazurite/features/messages/bloc/convo_list_bloc.dart'; import 'package:lazurite/features/messages/presentation/message_thread_route_args.dart'; import 'package:lazurite/features/messages/presentation/widgets/convo_list_item.dart'; +import 'package:lazurite/shared/presentation/widgets/empty_state.dart'; +import 'package:lazurite/shared/presentation/widgets/error_state.dart'; +import 'package:lazurite/shared/presentation/widgets/loading_state.dart'; class ConvoListPane extends StatefulWidget { const ConvoListPane({super.key, required this.tab}); @@ -78,27 +81,17 @@ class _ConvoListPaneState extends State { if (isOffline) { return const _OfflineConvoState(); } - return const Center(child: CircularProgressIndicator()); + return const LoadingState(); } if (state.status == ConvoListStatus.error && state.convos.isEmpty) { if (isOffline) { return const _OfflineConvoState(); } - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text('Failed to load messages', style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: 8), - Text(state.errorMessage ?? 'Unknown error', textAlign: TextAlign.center), - const SizedBox(height: 16), - FilledButton( - onPressed: () => context.read().add(const ConvosRequested()), - child: const Text('Retry'), - ), - ], - ), + return ErrorState( + title: 'Failed to load messages', + message: state.errorMessage ?? 'Unknown error', + onRetry: () => context.read().add(const ConvosRequested()), ); } @@ -115,11 +108,9 @@ class _ConvoListPaneState extends State { children: [ SizedBox( height: MediaQuery.of(context).size.height * 0.5, - child: Center( - child: Text( - widget.tab == ConvoTab.primary ? 'No conversations yet' : 'No message requests', - style: Theme.of(context).textTheme.bodyLarge, - ), + child: EmptyState( + message: widget.tab == ConvoTab.primary ? 'No conversations yet' : 'No message requests', + icon: Icons.forum_outlined, ), ), ], diff --git a/lib/features/notifications/presentation/widgets/notifications_pane.dart b/lib/features/notifications/presentation/widgets/notifications_pane.dart index 455c861..16c41ea 100644 --- a/lib/features/notifications/presentation/widgets/notifications_pane.dart +++ b/lib/features/notifications/presentation/widgets/notifications_pane.dart @@ -6,6 +6,9 @@ import 'package:lazurite/features/notifications/bloc/notification_bloc.dart'; import 'package:lazurite/features/notifications/cubit/unread_count_cubit.dart'; import 'package:lazurite/features/notifications/presentation/widgets/grouped_notification_list_item.dart'; import 'package:lazurite/features/notifications/presentation/widgets/notification_list_item.dart'; +import 'package:lazurite/shared/presentation/widgets/empty_state.dart'; +import 'package:lazurite/shared/presentation/widgets/error_state.dart'; +import 'package:lazurite/shared/presentation/widgets/loading_state.dart'; class NotificationsPane extends StatefulWidget { const NotificationsPane({super.key}); @@ -58,27 +61,17 @@ class _NotificationsPaneState extends State { if (isOffline) { return const _OfflineNotificationsState(); } - return const Center(child: CircularProgressIndicator()); + return const LoadingState(); } if (state.status == NotificationStatus.error && state.notifications.isEmpty) { if (isOffline) { return const _OfflineNotificationsState(); } - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text('Failed to load notifications', style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: 8), - Text(state.errorMessage ?? 'Unknown error', textAlign: TextAlign.center), - const SizedBox(height: 16), - FilledButton( - onPressed: () => context.read().add(const NotificationsRequested()), - child: const Text('Retry'), - ), - ], - ), + return ErrorState( + title: 'Failed to load notifications', + message: state.errorMessage ?? 'Unknown error', + onRetry: () => context.read().add(const NotificationsRequested()), ); } @@ -86,7 +79,7 @@ class _NotificationsPaneState extends State { if (isOffline) { return const _OfflineNotificationsState(); } - return Center(child: Text('No notifications yet', style: Theme.of(context).textTheme.bodyLarge)); + return const EmptyState(message: 'No notifications yet', icon: Icons.notifications_none_outlined); } final groupedNotifications = _groupNotificationsByDay(state.notifications); diff --git a/lib/features/profile/presentation/widgets/suggested_follows_list.dart b/lib/features/profile/presentation/widgets/suggested_follows_list.dart index 3a5672a..a79f89e 100644 --- a/lib/features/profile/presentation/widgets/suggested_follows_list.dart +++ b/lib/features/profile/presentation/widgets/suggested_follows_list.dart @@ -4,6 +4,9 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:lazurite/features/profile/cubit/profile_action_cubit.dart'; import 'package:lazurite/features/profile/cubit/suggested_follows_cubit.dart'; import 'package:lazurite/features/profile/data/profile_action_repository.dart'; +import 'package:lazurite/shared/presentation/widgets/empty_state.dart'; +import 'package:lazurite/shared/presentation/widgets/error_state.dart'; +import 'package:lazurite/shared/presentation/widgets/loading_state.dart'; import 'package:lazurite/shared/utils/format_utils.dart'; class SuggestedFollowsList extends StatelessWidget { @@ -27,30 +30,19 @@ class SuggestedFollowsList extends StatelessWidget { return BlocBuilder( builder: (context, state) { if (state.isLoading) { - return const Center(child: CircularProgressIndicator()); + return const LoadingState(); } if (state.hasError) { - return Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text(state.errorMessage ?? 'Failed to load suggestions', textAlign: TextAlign.center), - const SizedBox(height: 12), - FilledButton( - onPressed: () => context.read().load(actor), - child: const Text('Retry'), - ), - ], - ), - ), + return ErrorState( + title: 'Failed to load suggestions', + message: state.errorMessage ?? 'Unknown error', + onRetry: () => context.read().load(actor), ); } if (state.isEmpty) { - return Center(child: Text(emptyMessage)); + return EmptyState(message: emptyMessage, icon: Icons.person_search_outlined); } return ListView.builder( diff --git a/lib/shared/presentation/widgets/empty_state.dart b/lib/shared/presentation/widgets/empty_state.dart new file mode 100644 index 0000000..dcb7fd2 --- /dev/null +++ b/lib/shared/presentation/widgets/empty_state.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; + +class EmptyState extends StatelessWidget { + const EmptyState({ + super.key, + required this.message, + this.icon = Icons.inbox_outlined, + this.action, + this.subtitle, + this.padding = const EdgeInsets.all(24), + }); + + final String message; + final IconData icon; + final Widget? action; + final String? subtitle; + final EdgeInsetsGeometry padding; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + + return Center( + child: Padding( + padding: padding, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 48, color: colorScheme.outline), + const SizedBox(height: 12), + Text( + message, + textAlign: TextAlign.center, + style: textTheme.titleMedium?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ..._subtitle(subtitle, textTheme, colorScheme), + ..._action(action), + ], + ), + ), + ); + } + + List _action(Widget? action) => (action == null) ? [] : [const SizedBox(height: 16), action]; + + List _subtitle(String? subtitle, TextTheme textTheme, ColorScheme colorScheme) => (subtitle == null) + ? [] + : [ + const SizedBox(height: 8), + Text( + subtitle, + textAlign: TextAlign.center, + style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ]; +} diff --git a/lib/shared/presentation/widgets/error_state.dart b/lib/shared/presentation/widgets/error_state.dart new file mode 100644 index 0000000..27b2c54 --- /dev/null +++ b/lib/shared/presentation/widgets/error_state.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; + +class ErrorState extends StatelessWidget { + const ErrorState({ + super.key, + required this.message, + required this.onRetry, + this.title = 'Something went wrong', + this.retryLabel = 'Retry', + this.icon = Icons.error_outline, + }); + + final String title; + final String message; + final VoidCallback onRetry; + final String retryLabel; + final IconData icon; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 48, color: theme.colorScheme.outline), + const SizedBox(height: 12), + Text(title, style: theme.textTheme.titleMedium, textAlign: TextAlign.center), + const SizedBox(height: 8), + Text(message, textAlign: TextAlign.center), + const SizedBox(height: 16), + FilledButton(onPressed: onRetry, child: Text(retryLabel)), + ], + ), + ), + ); + } +} diff --git a/lib/shared/presentation/widgets/loading_state.dart b/lib/shared/presentation/widgets/loading_state.dart new file mode 100644 index 0000000..bd4e29a --- /dev/null +++ b/lib/shared/presentation/widgets/loading_state.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; + +class LoadingState extends StatelessWidget { + const LoadingState({super.key, this.message, this.padding = const EdgeInsets.all(24)}); + + final String? message; + final EdgeInsetsGeometry padding; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Center( + child: Padding( + padding: padding, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [const CircularProgressIndicator(), ..._message(message, theme.textTheme, theme.colorScheme)], + ), + ), + ); + } + + List _message(String? message, TextTheme textTheme, ColorScheme colorScheme) => (message == null) + ? [] + : [ + const SizedBox(height: 12), + Text( + message, + textAlign: TextAlign.center, + style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ]; +} diff --git a/test/shared/presentation/widgets/empty_state_test.dart b/test/shared/presentation/widgets/empty_state_test.dart new file mode 100644 index 0000000..66abdf9 --- /dev/null +++ b/test/shared/presentation/widgets/empty_state_test.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/shared/presentation/widgets/empty_state.dart'; + +void main() { + Widget buildSubject(Widget child) { + return MaterialApp(home: Scaffold(body: child)); + } + + testWidgets('renders message and icon', (tester) async { + await tester.pumpWidget(buildSubject(const EmptyState(message: 'No items', icon: Icons.search_off_outlined))); + + expect(find.text('No items'), findsOneWidget); + expect(find.byIcon(Icons.search_off_outlined), findsOneWidget); + }); + + testWidgets('renders subtitle and action', (tester) async { + await tester.pumpWidget( + buildSubject( + EmptyState( + message: 'No feeds pinned', + subtitle: 'Add feeds to continue.', + action: FilledButton(onPressed: () {}, child: const Text('Manage Feeds')), + ), + ), + ); + + expect(find.text('No feeds pinned'), findsOneWidget); + expect(find.text('Add feeds to continue.'), findsOneWidget); + expect(find.text('Manage Feeds'), findsOneWidget); + }); +} diff --git a/test/shared/presentation/widgets/error_state_test.dart b/test/shared/presentation/widgets/error_state_test.dart new file mode 100644 index 0000000..d4607f6 --- /dev/null +++ b/test/shared/presentation/widgets/error_state_test.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/shared/presentation/widgets/error_state.dart'; + +void main() { + Widget buildSubject(Widget child) { + return MaterialApp(home: Scaffold(body: child)); + } + + testWidgets('renders title and message', (tester) async { + await tester.pumpWidget( + buildSubject(ErrorState(title: 'Failed to load', message: 'Request timed out', onRetry: () {})), + ); + + expect(find.text('Failed to load'), findsOneWidget); + expect(find.text('Request timed out'), findsOneWidget); + expect(find.text('Retry'), findsOneWidget); + }); + + testWidgets('invokes retry callback', (tester) async { + var retried = false; + + await tester.pumpWidget(buildSubject(ErrorState(message: 'Failed', onRetry: () => retried = true))); + + await tester.tap(find.text('Retry')); + await tester.pump(); + + expect(retried, isTrue); + }); +} diff --git a/test/shared/presentation/widgets/loading_state_test.dart b/test/shared/presentation/widgets/loading_state_test.dart new file mode 100644 index 0000000..e978d45 --- /dev/null +++ b/test/shared/presentation/widgets/loading_state_test.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/shared/presentation/widgets/loading_state.dart'; + +void main() { + Widget buildSubject(Widget child) { + return MaterialApp(home: Scaffold(body: child)); + } + + testWidgets('renders progress indicator', (tester) async { + await tester.pumpWidget(buildSubject(const LoadingState())); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); + + testWidgets('renders optional message', (tester) async { + await tester.pumpWidget(buildSubject(const LoadingState(message: 'Loading items...'))); + + expect(find.text('Loading items...'), findsOneWidget); + }); +}