diff --git a/docs/tasks/testing.md b/docs/tasks/testing.md index 1c9c6bc..2bb11b6 100644 --- a/docs/tasks/testing.md +++ b/docs/tasks/testing.md @@ -18,34 +18,13 @@ ## M2 - Dialog & Sheet Consolidation -- [ ] Create `lib/shared/presentation/widgets/confirmation_dialog.dart` -- [ ] Create `lib/shared/presentation/widgets/options_sheet.dart` -- [ ] Create `lib/shared/presentation/helpers/snackbar_helper.dart` - `showAppSnackBar` -- [ ] Replace confirmation dialogs in: - - `lib/features/profile/presentation/widgets/profile_action_buttons.dart` (5 dialogs) - - `lib/features/compose/presentation/compose_screen.dart` (6 dialogs) - - `lib/features/search/presentation/search_screen.dart` (2 dialogs) - - `lib/features/search/presentation/hashtag_screen.dart` - - `lib/features/feed/presentation/post_thread_screen.dart` - - `lib/features/feed/presentation/feed_management_screen.dart` - - `lib/features/lists/presentation/list_detail_screen.dart` - - `lib/features/settings/presentation/settings_screen.dart` - - `lib/features/moderation/presentation/screens/moderation_settings_screen.dart` - - `lib/features/account/presentation/account_switcher_sheet.dart` -- [ ] Replace modal bottom sheets in: - - `lib/features/feed/presentation/widgets/post_action_bar.dart` - - `lib/features/feed/presentation/widgets/post_card_footer.dart` - - `lib/features/feed/presentation/post_thread_screen.dart` - - `lib/features/search/presentation/hashtag_screen.dart` - - `lib/features/profile/presentation/profile_screen.dart` -- [ ] Replace SnackBar patterns in: - - `lib/features/feed/presentation/widgets/post_card_with_actions.dart` - - `lib/features/feed/presentation/feed_management_screen.dart` - - `lib/features/compose/presentation/compose_screen.dart` - - `lib/features/profile/presentation/profile_screen.dart` - - `lib/features/lists/presentation/list_detail_screen.dart` - - `lib/features/settings/presentation/settings_screen.dart` -- [ ] Tests for dialog/sheet/snackbar helpers +- [x] Create `lib/shared/presentation/widgets/confirmation_dialog.dart` +- [x] Create `lib/shared/presentation/widgets/options_sheet.dart` +- [x] Create `lib/shared/presentation/helpers/snackbar_helper.dart` - `showAppSnackBar` +- [x] Replace confirmation dialogs +- [x] Replace modal bottom sheets +- [x] Replace SnackBar patterns +- [x] Tests for dialog/sheet/snackbar helpers ## M3 - Theme & Spacing Constants diff --git a/lib/features/account/presentation/account_switcher_sheet.dart b/lib/features/account/presentation/account_switcher_sheet.dart index 484e302..4416040 100644 --- a/lib/features/account/presentation/account_switcher_sheet.dart +++ b/lib/features/account/presentation/account_switcher_sheet.dart @@ -2,12 +2,15 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:lazurite/features/account/cubit/account_switcher_cubit.dart'; import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; +import 'package:lazurite/shared/presentation/helpers/snackbar_helper.dart'; +import 'package:lazurite/shared/presentation/widgets/confirmation_dialog.dart'; +import 'package:lazurite/shared/presentation/widgets/options_sheet.dart'; void showAccountSwitcherSheet(BuildContext context) { final cubit = context.read(); final authBloc = context.read(); - showModalBottomSheet( + showAppBottomSheet( context: context, builder: (sheetContext) => BlocProvider.value( value: cubit, @@ -97,7 +100,6 @@ class _AccountSwitcherSheet extends StatelessWidget { } Future _onSwitchAccount(BuildContext context, String did) async { - final messenger = ScaffoldMessenger.of(context); final cubit = context.read(); Navigator.pop(context); final tokens = await cubit.switchAccount(did); @@ -106,43 +108,39 @@ class _AccountSwitcherSheet extends StatelessWidget { return; } - messenger.showSnackBar(const SnackBar(content: Text('Unable to switch accounts. Sign in again for that account.'))); + if (context.mounted) { + showAppSnackBar(context, 'Unable to switch accounts. Sign in again for that account.'); + } } Future _onAddAccount(BuildContext context) async { - final messenger = ScaffoldMessenger.of(context); final cubit = context.read(); Navigator.pop(context); + final controller = TextEditingController(); final handle = await showDialog( context: context, - builder: (dialogContext) { - final controller = TextEditingController(); - return AlertDialog( - title: const Text('Add Account'), - content: TextField( - controller: controller, - decoration: const InputDecoration(labelText: 'Handle or DID'), - autofocus: true, - ), - actions: [ - TextButton(onPressed: () => Navigator.pop(dialogContext), child: const Text('Cancel')), - TextButton( - onPressed: () => Navigator.pop(dialogContext, controller.text.trim()), - child: const Text('Continue'), - ), - ], - ); - }, + builder: (dialogContext) => ConfirmationDialog( + title: const Text('Add Account'), + content: TextField( + controller: controller, + decoration: const InputDecoration(labelText: 'Handle or DID'), + autofocus: true, + ), + confirmLabel: 'Continue', + onCancel: () => Navigator.pop(dialogContext), + onConfirm: () => Navigator.pop(dialogContext, controller.text.trim()), + ), ); + controller.dispose(); if (handle == null || handle.isEmpty) return; final tokens = await cubit.addAccountWithOAuth(handle); if (tokens != null) { authBloc.add(SessionRestored(tokens: tokens)); - } else { - messenger.showSnackBar(const SnackBar(content: Text('Failed to add account'))); + } else if (context.mounted) { + showAppSnackBar(context, 'Failed to add account', isError: true); } } } diff --git a/lib/features/compose/presentation/compose_screen.dart b/lib/features/compose/presentation/compose_screen.dart index 6d3b019..b1cac6f 100644 --- a/lib/features/compose/presentation/compose_screen.dart +++ b/lib/features/compose/presentation/compose_screen.dart @@ -10,6 +10,8 @@ import 'package:intl/intl.dart'; import 'package:lazurite/features/compose/bloc/compose_bloc.dart'; import 'package:lazurite/features/connectivity/connectivity_helpers.dart'; import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; +import 'package:lazurite/shared/presentation/helpers/snackbar_helper.dart'; +import 'package:lazurite/shared/presentation/widgets/confirmation_dialog.dart'; class ComposeScreen extends StatefulWidget { const ComposeScreen({ @@ -110,14 +112,9 @@ class _ComposeScreenState extends State { Future _pickImage() async { final state = context.read().state; - final theme = Theme.of(context); if (!state.canAddMoreMedia) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Maximum 4 images allowed', style: TextStyle(color: theme.colorScheme.error)), - ), - ); + showAppSnackBar(context, 'Maximum 4 images allowed', isError: true); } return; } @@ -136,7 +133,7 @@ class _ComposeScreenState extends State { const maxSize = 1 * 1024 * 1024; if (fileSize > maxSize) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Image must be smaller than 1MB'))); + showAppSnackBar(context, 'Image must be smaller than 1MB', isError: true); } return; } @@ -145,9 +142,7 @@ class _ComposeScreenState extends State { const validExtensions = ['jpg', 'jpeg', 'png', 'webp']; if (!validExtensions.contains(extension)) { if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('Image must be JPEG, PNG, or WebP'))); + showAppSnackBar(context, 'Image must be JPEG, PNG, or WebP', isError: true); } return; } @@ -164,7 +159,7 @@ class _ComposeScreenState extends State { } } catch (e) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to pick image: $e'))); + showAppSnackBar(context, 'Failed to pick image: $e', isError: true); } } } @@ -173,9 +168,7 @@ class _ComposeScreenState extends State { final state = context.read().state; if (!state.canAddVideo) { if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('Remove existing media before adding a video'))); + showAppSnackBar(context, 'Remove existing media before adding a video', isError: true); } return; } @@ -187,7 +180,7 @@ class _ComposeScreenState extends State { } } catch (e) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to pick video: $e'))); + showAppSnackBar(context, 'Failed to pick video: $e', isError: true); } } } @@ -197,7 +190,7 @@ class _ComposeScreenState extends State { final result = await showDialog( context: context, - builder: (context) => AlertDialog( + builder: (dialogContext) => ConfirmationDialog( title: const Text('Add video alt text'), content: TextField( controller: altController, @@ -208,10 +201,9 @@ class _ComposeScreenState extends State { border: OutlineInputBorder(), ), ), - actions: [ - TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')), - TextButton(onPressed: () => Navigator.pop(context, altController.text), child: const Text('Save')), - ], + confirmLabel: 'Save', + onCancel: () => Navigator.pop(dialogContext), + onConfirm: () => Navigator.pop(dialogContext, altController.text), ), ); @@ -227,7 +219,7 @@ class _ComposeScreenState extends State { final result = await showDialog( context: context, - builder: (context) => AlertDialog( + builder: (dialogContext) => ConfirmationDialog( title: const Text('Add alt text'), content: TextField( controller: altController, @@ -238,10 +230,9 @@ class _ComposeScreenState extends State { border: OutlineInputBorder(), ), ), - actions: [ - TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')), - TextButton(onPressed: () => Navigator.pop(context, altController.text), child: const Text('Save')), - ], + confirmLabel: 'Save', + onCancel: () => Navigator.pop(dialogContext), + onConfirm: () => Navigator.pop(dialogContext, altController.text), ), ); @@ -325,7 +316,7 @@ class _ComposeScreenState extends State { return Container( constraints: const BoxConstraints(maxHeight: 280), decoration: BoxDecoration( - border: Border(top: BorderSide(color: _theme.dividerColor)), + border: Border(top: BorderSide(color: theme.dividerColor)), ), child: Column( mainAxisSize: MainAxisSize.min, @@ -335,11 +326,11 @@ class _ComposeScreenState extends State { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text('Drafts', style: _theme.textTheme.titleMedium), + Text('Drafts', style: theme.textTheme.titleMedium), if (state.drafts.isNotEmpty) Text( '${state.drafts.length} draft${state.drafts.length != 1 ? 's' : ''}', - style: _theme.textTheme.bodySmall?.copyWith(color: _theme.colorScheme.onSurfaceVariant), + style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), ), ], ), @@ -355,7 +346,7 @@ class _ComposeScreenState extends State { child: Center( child: Text( 'No drafts saved', - style: _theme.textTheme.bodyMedium?.copyWith(color: _theme.colorScheme.onSurfaceVariant), + style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant), ), ), ) @@ -375,19 +366,19 @@ class _ComposeScreenState extends State { ), subtitle: Row( children: [ - Text(_formatDraftTime(draft.updatedAt), style: _theme.textTheme.bodySmall), + Text(_formatDraftTime(draft.updatedAt), style: theme.textTheme.bodySmall), if (draft.scheduledAt != null) ...[ const SizedBox(width: 8), Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( - color: _theme.colorScheme.primaryContainer, + color: theme.colorScheme.primaryContainer, borderRadius: BorderRadius.circular(4), ), child: Text( 'Scheduled', - style: _theme.textTheme.bodySmall?.copyWith( - color: _theme.colorScheme.onPrimaryContainer, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onPrimaryContainer, ), ), ), @@ -395,28 +386,17 @@ class _ComposeScreenState extends State { ], ), trailing: IconButton( - icon: Icon(Icons.delete_outline, color: _theme.colorScheme.error), + icon: Icon(Icons.delete_outline, color: theme.colorScheme.error), onPressed: () { final bloc = context.read(); - final theme = _theme; - showDialog( + showConfirmationDialog( context: context, - builder: (dialogContext) => AlertDialog( - title: const Text('Delete Draft?'), - content: const Text('This action cannot be undone.'), - actions: [ - TextButton( - onPressed: () => Navigator.of(dialogContext).pop(false), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () => Navigator.of(dialogContext).pop(true), - child: Text('Delete', style: TextStyle(color: theme.colorScheme.error)), - ), - ], - ), + title: const Text('Delete Draft?'), + content: const Text('This action cannot be undone.'), + confirmLabel: 'Delete', + confirmDestructive: true, ).then((confirmed) { - if (confirmed == true && mounted) { + if (confirmed && mounted) { bloc.add(DraftDeleted(draft.id)); } }); @@ -437,7 +417,7 @@ class _ComposeScreenState extends State { ); } - ThemeData get _theme => Theme.of(context); + ThemeData get theme => Theme.of(context); void _submitPost() { context.read().add(const PostSubmitted()); @@ -447,26 +427,20 @@ class _ComposeScreenState extends State { if (context.read().state.isEditing) return; context.read().add(const DraftSaved()); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Draft saved', style: TextStyle(color: _theme.colorScheme.onPrimary)), - backgroundColor: _theme.colorScheme.primary, - ), - ); + showAppSnackBar(context, 'Draft saved'); } } - void _showEditAlgorithmInfo() { - showDialog( + Future _showEditAlgorithmInfo() async { + await showConfirmationDialog( context: context, - builder: (dialogContext) => AlertDialog( - title: const Text('How Post Editing Works'), - content: const Text( - 'Lazurite saves edits by deleting and recreating the post record with the same URI. During re-indexing, ' - 'ranking, counters, and search visibility can shift, and updates may take time to appear everywhere.', - ), - actions: [TextButton(onPressed: () => Navigator.pop(dialogContext), child: const Text('OK'))], + title: const Text('How Post Editing Works'), + content: const Text( + 'Lazurite saves edits by deleting and recreating the post record with the same URI. During re-indexing, ' + 'ranking, counters, and search visibility can shift, and updates may take time to appear everywhere.', ), + confirmLabel: 'OK', + showCancel: false, ); } @@ -478,18 +452,13 @@ class _ComposeScreenState extends State { if (state.isEditing) { if (state.isDraftDirty) { - showDialog( + showConfirmationDialog( context: context, - builder: (dialogContext) => AlertDialog( - title: const Text('Discard Changes?'), - content: const Text('You have unsaved edits. Discard them and leave?'), - actions: [ - TextButton(onPressed: () => Navigator.of(dialogContext).pop(false), child: const Text('Cancel')), - TextButton(onPressed: () => Navigator.of(dialogContext).pop(true), child: const Text('Discard')), - ], - ), + title: const Text('Discard Changes?'), + content: const Text('You have unsaved edits. Discard them and leave?'), + confirmLabel: 'Discard', ).then((shouldDiscard) { - if (shouldDiscard == true && mounted) { + if (shouldDiscard && mounted) { navigator.pop(false); } }); @@ -500,28 +469,14 @@ class _ComposeScreenState extends State { } if (hasContent && state.isDraftDirty) { - showDialog( + showConfirmationDialog( context: context, - builder: (dialogContext) => AlertDialog( - title: const Text('Save Draft?'), - content: const Text('You have unsaved content. Would you like to save it as a draft?'), - actions: [ - TextButton( - onPressed: () { - Navigator.of(dialogContext).pop(false); - }, - child: const Text('Discard'), - ), - TextButton( - onPressed: () { - Navigator.of(dialogContext).pop(true); - }, - child: const Text('Save'), - ), - ], - ), + title: const Text('Save Draft?'), + content: const Text('You have unsaved content. Would you like to save it as a draft?'), + cancelLabel: 'Discard', + confirmLabel: 'Save', ).then((shouldSave) { - if (shouldSave == true) { + if (shouldSave) { _saveDraft(); } if (mounted) { @@ -544,17 +499,13 @@ class _ComposeScreenState extends State { if (state.isSuccess) { if (state.isEditing) { - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('Changes saved.'), behavior: SnackBarBehavior.floating)); + showAppSnackBar(context, 'Changes saved.', behavior: SnackBarBehavior.floating); } Navigator.of(context).pop(state.isEditing ? {'editedText': state.text} : null); } if (state.hasError && state.errorMessage != null) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(state.errorMessage!), behavior: SnackBarBehavior.floating)); + showAppSnackBar(context, state.errorMessage!, behavior: SnackBarBehavior.floating, isError: true); } }, child: PopScope( @@ -607,19 +558,19 @@ class _ComposeScreenState extends State { margin: const EdgeInsets.fromLTRB(16, 12, 16, 0), padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: _theme.colorScheme.surfaceContainerHighest, - border: Border.all(color: _theme.colorScheme.outlineVariant), + color: theme.colorScheme.surfaceContainerHighest, + border: Border.all(color: theme.colorScheme.outlineVariant), ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(Icons.info_outline, color: _theme.colorScheme.onSurfaceVariant, size: 20), + Icon(Icons.info_outline, color: theme.colorScheme.onSurfaceVariant, size: 20), const SizedBox(width: 12), Expanded( child: Text( 'Edits are saved by replacing the record while keeping this post URI. Ranking, ' 'counts, and visibility may shift while networks re-index.', - style: _theme.textTheme.bodySmall?.copyWith(color: _theme.colorScheme.onSurfaceVariant), + style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), ), ), IconButton( @@ -638,23 +589,23 @@ class _ComposeScreenState extends State { return Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration( - color: _theme.colorScheme.surfaceContainerHighest, - border: Border(bottom: BorderSide(color: _theme.dividerColor)), + color: theme.colorScheme.surfaceContainerHighest, + border: Border(bottom: BorderSide(color: theme.dividerColor)), ), child: Row( children: [ - Icon(Icons.reply, size: 16, color: _theme.colorScheme.onSurfaceVariant), + Icon(Icons.reply, size: 16, color: theme.colorScheme.onSurfaceVariant), const SizedBox(width: 8), Text( 'Replying to ', style: Theme.of( context, - ).textTheme.bodySmall?.copyWith(color: _theme.colorScheme.onSurfaceVariant), + ).textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), ), Text( '@${widget.replyAuthorHandle}', - style: _theme.textTheme.bodySmall?.copyWith( - color: _theme.colorScheme.primary, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.primary, fontWeight: FontWeight.w500, ), ), @@ -676,7 +627,7 @@ class _ComposeScreenState extends State { border: InputBorder.none, contentPadding: EdgeInsets.zero, ), - style: _theme.textTheme.bodyLarge?.copyWith(height: 1.5), + style: theme.textTheme.bodyLarge?.copyWith(height: 1.5), ), ), ), @@ -688,26 +639,26 @@ class _ComposeScreenState extends State { margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( - color: _theme.colorScheme.primaryContainer, + color: theme.colorScheme.primaryContainer, borderRadius: BorderRadius.circular(20), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.schedule, size: 16, color: _theme.colorScheme.onPrimaryContainer), + Icon(Icons.schedule, size: 16, color: theme.colorScheme.onPrimaryContainer), const SizedBox(width: 8), Text( 'Scheduled for ${DateFormat('MMM d, h:mm a').format(state.scheduledAt!)}', style: Theme.of( context, - ).textTheme.bodySmall?.copyWith(color: _theme.colorScheme.onPrimaryContainer), + ).textTheme.bodySmall?.copyWith(color: theme.colorScheme.onPrimaryContainer), ), const SizedBox(width: 8), GestureDetector( onTap: () { context.read().add(const ScheduleCleared()); }, - child: Icon(Icons.close, size: 16, color: _theme.colorScheme.onPrimaryContainer), + child: Icon(Icons.close, size: 16, color: theme.colorScheme.onPrimaryContainer), ), ], ), @@ -752,15 +703,15 @@ class _ComposeScreenState extends State { padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( color: attachment.altText.isNotEmpty - ? _theme.colorScheme.primary + ? theme.colorScheme.primary : Colors.black54, borderRadius: BorderRadius.circular(4), ), child: Text( 'ALT', - style: _theme.textTheme.labelSmall?.copyWith( + style: theme.textTheme.labelSmall?.copyWith( color: attachment.altText.isNotEmpty - ? _theme.colorScheme.onPrimary + ? theme.colorScheme.onPrimary : Colors.white, fontWeight: FontWeight.bold, ), @@ -802,9 +753,9 @@ class _ComposeScreenState extends State { margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: _theme.colorScheme.surfaceContainerHighest, + color: theme.colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(12), - border: Border.all(color: video.hasError ? _theme.colorScheme.error : _theme.dividerColor), + border: Border.all(color: video.hasError ? theme.colorScheme.error : theme.dividerColor), ), child: Row( children: [ @@ -812,7 +763,7 @@ class _ComposeScreenState extends State { width: 48, height: 48, decoration: BoxDecoration( - color: _theme.colorScheme.primaryContainer, + color: theme.colorScheme.primaryContainer, borderRadius: BorderRadius.circular(8), ), child: video.isActive @@ -828,8 +779,8 @@ class _ComposeScreenState extends State { : Icon( video.hasError ? Icons.error_outline : Icons.videocam_outlined, color: video.hasError - ? _theme.colorScheme.error - : _theme.colorScheme.onPrimaryContainer, + ? theme.colorScheme.error + : theme.colorScheme.onPrimaryContainer, ), ), const SizedBox(width: 12), @@ -839,17 +790,17 @@ class _ComposeScreenState extends State { children: [ Text( video.localPath.split('/').last, - style: _theme.textTheme.bodyMedium, + style: theme.textTheme.bodyMedium, maxLines: 1, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 2), Text( _videoStatusLabel(video), - style: _theme.textTheme.bodySmall?.copyWith( + style: theme.textTheme.bodySmall?.copyWith( color: video.hasError - ? _theme.colorScheme.error - : _theme.colorScheme.onSurfaceVariant, + ? theme.colorScheme.error + : theme.colorScheme.onSurfaceVariant, ), ), if (video.isActive && video.uploadProgress > 0) ...[ @@ -868,14 +819,14 @@ class _ComposeScreenState extends State { tooltip: 'Add alt text', onPressed: () => _showVideoAltTextDialog(video.altText), color: video.altText.isNotEmpty - ? _theme.colorScheme.primary - : _theme.colorScheme.onSurfaceVariant, + ? theme.colorScheme.primary + : theme.colorScheme.onSurfaceVariant, ), ], IconButton( icon: const Icon(Icons.close), onPressed: () => context.read().add(const VideoRemoved()), - color: _theme.colorScheme.onSurfaceVariant, + color: theme.colorScheme.onSurfaceVariant, ), ], ), @@ -892,7 +843,7 @@ class _ComposeScreenState extends State { const SizedBox(height: 8), Container( decoration: BoxDecoration( - border: Border(top: BorderSide(color: _theme.dividerColor)), + border: Border(top: BorderSide(color: theme.dividerColor)), ), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), child: SafeArea( @@ -906,8 +857,8 @@ class _ComposeScreenState extends State { icon: Icon( Icons.image_outlined, color: state.canAddMoreMedia - ? _theme.colorScheme.primary - : _theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.5), + ? theme.colorScheme.primary + : theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.5), ), tooltip: 'Add image', ); @@ -921,8 +872,8 @@ class _ComposeScreenState extends State { icon: Icon( Icons.videocam_outlined, color: state.canAddVideo - ? _theme.colorScheme.primary - : _theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.5), + ? theme.colorScheme.primary + : theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.5), ), tooltip: 'Add video', ); @@ -933,7 +884,7 @@ class _ComposeScreenState extends State { if (state.isEditing) return const SizedBox.shrink(); return IconButton( onPressed: _toggleDrafts, - icon: Icon(Icons.drive_file_rename_outline, color: _theme.colorScheme.primary), + icon: Icon(Icons.drive_file_rename_outline, color: theme.colorScheme.primary), tooltip: 'Drafts', ); }, @@ -943,7 +894,7 @@ class _ComposeScreenState extends State { if (state.isEditing) return const SizedBox.shrink(); return IconButton( onPressed: _showSchedulePicker, - icon: Icon(Icons.schedule, color: _theme.colorScheme.primary), + icon: Icon(Icons.schedule, color: theme.colorScheme.primary), tooltip: 'Schedule', ); }, diff --git a/lib/features/feed/presentation/feed_management_screen.dart b/lib/features/feed/presentation/feed_management_screen.dart index 68150dd..a5acaac 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/helpers/snackbar_helper.dart'; +import 'package:lazurite/shared/presentation/widgets/confirmation_dialog.dart'; import 'package:lazurite/shared/presentation/widgets/empty_state.dart'; import 'package:lazurite/shared/presentation/widgets/loading_state.dart'; @@ -50,14 +52,11 @@ class _FeedManagementScreenState extends State { body: BlocConsumer( listener: (context, state) { if (state.status == FeedPreferencesStatus.saveError) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Failed to sync: ${state.message}'), - action: SnackBarAction( - label: 'Dismiss', - onPressed: () => context.read().clearError(), - ), - ), + showAppSnackBar( + context, + 'Failed to sync: ${state.message}', + actionLabel: 'Dismiss', + onAction: () => context.read().clearError(), ); } }, @@ -334,23 +333,14 @@ class _FeedManagementScreenState extends State { return feed.uri.rkey; } - void _confirmRemoveFeed(BuildContext context, String feedId) { - showDialog( + Future _confirmRemoveFeed(BuildContext context, String feedId) async { + await showConfirmationDialog( context: context, - builder: (context) => AlertDialog( - title: const Text('Remove Feed'), - content: const Text('Are you sure you want to remove this feed from your saved feeds?'), - actions: [ - TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel')), - TextButton( - onPressed: () { - context.read().removeFeed(feedId); - Navigator.of(context).pop(); - }, - child: Text('Remove', style: TextStyle(color: Theme.of(context).colorScheme.error)), - ), - ], - ), + title: const Text('Remove Feed'), + content: const Text('Are you sure you want to remove this feed from your saved feeds?'), + confirmLabel: 'Remove', + confirmDestructive: true, + onConfirmed: () => context.read().removeFeed(feedId), ); } } diff --git a/lib/features/feed/presentation/post_thread_screen.dart b/lib/features/feed/presentation/post_thread_screen.dart index 70b4ca1..316c764 100644 --- a/lib/features/feed/presentation/post_thread_screen.dart +++ b/lib/features/feed/presentation/post_thread_screen.dart @@ -28,6 +28,9 @@ import 'package:lazurite/features/profile/cubit/profile_action_cubit.dart'; import 'package:lazurite/features/profile/data/profile_action_repository.dart'; import 'package:lazurite/features/profile/presentation/widgets/report_dialog.dart'; import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; +import 'package:lazurite/shared/presentation/helpers/snackbar_helper.dart'; +import 'package:lazurite/shared/presentation/widgets/confirmation_dialog.dart'; +import 'package:lazurite/shared/presentation/widgets/options_sheet.dart'; class PostThreadScreen extends StatelessWidget { const PostThreadScreen({super.key, required this.postUri}); @@ -635,9 +638,7 @@ class _FocusedPostWithActions extends StatelessWidget { child: BlocListener( listenWhen: (previous, current) => previous.error != current.error && current.error != null, listener: (context, state) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(state.error!), behavior: SnackBarBehavior.floating)); + showAppSnackBar(context, state.error!, behavior: SnackBarBehavior.floating); context.read().clearError(); }, child: _FocusedPostContent(thread: thread, accountDid: accountDid), @@ -722,7 +723,7 @@ class _FocusedPostContent extends StatelessWidget { void _showInteractions(BuildContext context, PostView post, {required bool showLikes}) { final repository = context.read(); - showModalBottomSheet( + showAppBottomSheet( context: context, isScrollControlled: true, builder: (_) => PostInteractionsSheet( @@ -836,57 +837,34 @@ class _FocusedPostContent extends StatelessWidget { final postUri = post.uri.toString(); final bskyUrl = _convertAtUriToBskyUrl(postUri); - showModalBottomSheet( + showOptionsSheet( context: context, - builder: (sheetContext) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: const Icon(Icons.copy), - title: const Text('Copy Link'), - onTap: () { - Navigator.pop(sheetContext); - _copyToClipboard(context, bskyUrl); - }, - ), - ListTile( - leading: const Icon(Icons.person_outline), - title: Text('View @${post.author.handle}'), - onTap: () { - Navigator.pop(sheetContext); - context.push('/profile/view?actor=${Uri.encodeQueryComponent(post.author.did)}'); - }, - ), - ListTile( - leading: const Icon(Icons.report_outlined, color: Colors.orange), - title: const Text('Report Post', style: TextStyle(color: Colors.orange)), - onTap: () { - Navigator.pop(sheetContext); - _showReportDialog(context); - }, - ), - if (post.author.did == accountDid) - ListTile( - leading: const Icon(Icons.edit_outlined), - title: const Text('Edit Post'), - onTap: () { - Navigator.pop(sheetContext); - unawaited(_onEdit(context)); - }, - ), - if (post.author.did == accountDid) - ListTile( - leading: Icon(Icons.delete_outline, color: Theme.of(context).colorScheme.error), - title: Text('Delete Post', style: TextStyle(color: Theme.of(context).colorScheme.error)), - onTap: () { - Navigator.pop(sheetContext); - _confirmDelete(context); - }, - ), - ], + items: [ + OptionsSheetItem( + leading: const Icon(Icons.copy), + title: 'Copy Link', + onTap: () => _copyToClipboard(context, bskyUrl), ), - ), + OptionsSheetItem( + leading: const Icon(Icons.person_outline), + title: 'View @${post.author.handle}', + onTap: () => context.push('/profile/view?actor=${Uri.encodeQueryComponent(post.author.did)}'), + ), + OptionsSheetItem( + leading: const Icon(Icons.report_outlined, color: Colors.orange), + title: 'Report Post', + onTap: () => _showReportDialog(context), + ), + if (post.author.did == accountDid) + OptionsSheetItem(leading: const Icon(Icons.edit_outlined), title: 'Edit Post', onTap: () => _onEdit(context)), + if (post.author.did == accountDid) + OptionsSheetItem( + leading: Icon(Icons.delete_outline, color: Theme.of(context).colorScheme.error), + title: 'Delete Post', + isDestructive: true, + onTap: () => _confirmDelete(context), + ), + ], ); } @@ -956,11 +934,10 @@ class _FocusedPostContent extends StatelessWidget { } if (context.mounted && expectedText != null) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Edit saved. Your updates may take a moment to appear across feeds.'), - behavior: SnackBarBehavior.floating, - ), + showAppSnackBar( + context, + 'Edit saved. Your updates may take a moment to appear across feeds.', + behavior: SnackBarBehavior.floating, ); } } @@ -980,35 +957,20 @@ class _FocusedPostContent extends StatelessWidget { ); } - void _confirmDelete(BuildContext context) { - showDialog( + Future _confirmDelete(BuildContext context) async { + await showConfirmationDialog( context: context, - builder: (dialogContext) => AlertDialog( - title: const Text('Delete Post?'), - content: const Text('This action cannot be undone.'), - actions: [ - TextButton(onPressed: () => Navigator.pop(dialogContext), child: const Text('Cancel')), - FilledButton( - onPressed: () { - Navigator.pop(dialogContext); - context.read().deletePost(); - }, - style: FilledButton.styleFrom( - backgroundColor: Theme.of(context).colorScheme.error, - foregroundColor: Theme.of(context).colorScheme.onError, - ), - child: const Text('Delete'), - ), - ], - ), + title: const Text('Delete Post?'), + content: const Text('This action cannot be undone.'), + confirmLabel: 'Delete', + confirmDestructive: true, + onConfirmed: () => context.read().deletePost(), ); } void _copyToClipboard(BuildContext context, String text) { Clipboard.setData(ClipboardData(text: text)); - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('Link copied to clipboard'), behavior: SnackBarBehavior.floating)); + showAppSnackBar(context, 'Link copied to clipboard', behavior: SnackBarBehavior.floating); } (String, String) _findRoot() { diff --git a/lib/features/feed/presentation/widgets/post_action_bar.dart b/lib/features/feed/presentation/widgets/post_action_bar.dart index 5d7e7b9..8e9bf59 100644 --- a/lib/features/feed/presentation/widgets/post_action_bar.dart +++ b/lib/features/feed/presentation/widgets/post_action_bar.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:lazurite/core/logging/app_logger.dart'; import 'package:lazurite/features/connectivity/connectivity_helpers.dart'; +import 'package:lazurite/shared/presentation/widgets/options_sheet.dart'; import 'package:lazurite/shared/utils/format_utils.dart'; import 'package:share_plus/share_plus.dart'; @@ -124,34 +125,23 @@ class PostActionBar extends StatelessWidget { void _showRepostOptions(BuildContext context) { HapticFeedback.mediumImpact(); - showModalBottomSheet( + showOptionsSheet( context: context, - builder: (context) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: Icon(isReposted ? Icons.repeat : Icons.repeat, color: isReposted ? Colors.green : null), - title: Text(isReposted ? 'Unrepost' : 'Repost'), - subtitle: Text(isReposted ? 'Remove this repost' : 'Share this post'), - onTap: () { - Navigator.pop(context); - onRepost?.call(); - }, - ), - if (!isReposted) - ListTile( - leading: const Icon(Icons.format_quote), - title: const Text('Quote Post'), - subtitle: const Text('Quote this post with your own text'), - onTap: () { - Navigator.pop(context); - onQuote?.call(); - }, - ), - ], + items: [ + OptionsSheetItem( + leading: Icon(Icons.repeat, color: isReposted ? Colors.green : null), + title: isReposted ? 'Unrepost' : 'Repost', + subtitle: isReposted ? 'Remove this repost' : 'Share this post', + onTap: onRepost, ), - ), + if (!isReposted) + OptionsSheetItem( + leading: const Icon(Icons.format_quote), + title: 'Quote Post', + subtitle: 'Quote this post with your own text', + onTap: onQuote, + ), + ], ); } @@ -159,41 +149,26 @@ class PostActionBar extends StatelessWidget { HapticFeedback.mediumImpact(); final isLocalSaved = isSaved && (saveType == 'local' || saveType == 'both'); final isCloudSaved = saveType == 'cloud' || saveType == 'both'; - showModalBottomSheet( + showOptionsSheet( context: context, - builder: (context) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: Icon( - isLocalSaved ? Icons.bookmark_remove_outlined : Icons.bookmark_add_outlined, - color: Colors.amber, - ), - title: Text(isLocalSaved ? 'Remove local save' : 'Save locally'), - onTap: () { - Navigator.pop(context); - onSave?.call(); - }, - ), - ListTile( - leading: Icon( - isCloudSaved ? Icons.cloud_off_outlined : Icons.cloud_outlined, - color: Theme.of(context).colorScheme.primary, - ), - title: Text(isCloudSaved ? 'Remove from Bluesky' : 'Save to Bluesky'), - onTap: () { - Navigator.pop(context); - if (isCloudSaved) { - onCloudUnsave?.call(); - } else { - onCloudSave?.call(); - } - }, - ), - ], + items: [ + OptionsSheetItem( + leading: Icon( + isLocalSaved ? Icons.bookmark_remove_outlined : Icons.bookmark_add_outlined, + color: Colors.amber, + ), + title: isLocalSaved ? 'Remove local save' : 'Save locally', + onTap: onSave, ), - ), + OptionsSheetItem( + leading: Icon( + isCloudSaved ? Icons.cloud_off_outlined : Icons.cloud_outlined, + color: Theme.of(context).colorScheme.primary, + ), + title: isCloudSaved ? 'Remove from Bluesky' : 'Save to Bluesky', + onTap: isCloudSaved ? onCloudUnsave : onCloudSave, + ), + ], ); } diff --git a/lib/features/feed/presentation/widgets/post_card_footer.dart b/lib/features/feed/presentation/widgets/post_card_footer.dart index 27153d3..50ff9db 100644 --- a/lib/features/feed/presentation/widgets/post_card_footer.dart +++ b/lib/features/feed/presentation/widgets/post_card_footer.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:lazurite/features/connectivity/connectivity_helpers.dart'; +import 'package:lazurite/shared/presentation/widgets/options_sheet.dart'; import 'package:lazurite/shared/utils/format_utils.dart'; /// Formats a post timestamp as a short, uppercase string. @@ -177,41 +178,26 @@ class PostCardFooter extends StatelessWidget { final isLocalSaved = isSaved && (saveType == 'local' || saveType == 'both'); final isCloudSaved = saveType == 'cloud' || saveType == 'both'; - showModalBottomSheet( + showOptionsSheet( context: context, - builder: (context) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: Icon( - isLocalSaved ? Icons.bookmark_remove_outlined : Icons.bookmark_add_outlined, - color: Colors.amber, - ), - title: Text(isLocalSaved ? 'Remove local save' : 'Save locally'), - onTap: () { - Navigator.pop(context); - onSave?.call(); - }, - ), - ListTile( - leading: Icon( - isCloudSaved ? Icons.cloud_off_outlined : Icons.cloud_outlined, - color: Theme.of(context).colorScheme.primary, - ), - title: Text(isCloudSaved ? 'Remove from Bluesky' : 'Save to Bluesky'), - onTap: () { - Navigator.pop(context); - if (isCloudSaved) { - onCloudUnsave?.call(); - } else { - onCloudSave?.call(); - } - }, - ), - ], + items: [ + OptionsSheetItem( + leading: Icon( + isLocalSaved ? Icons.bookmark_remove_outlined : Icons.bookmark_add_outlined, + color: Colors.amber, + ), + title: isLocalSaved ? 'Remove local save' : 'Save locally', + onTap: onSave, ), - ), + OptionsSheetItem( + leading: Icon( + isCloudSaved ? Icons.cloud_off_outlined : Icons.cloud_outlined, + color: Theme.of(context).colorScheme.primary, + ), + title: isCloudSaved ? 'Remove from Bluesky' : 'Save to Bluesky', + onTap: isCloudSaved ? onCloudUnsave : onCloudSave, + ), + ], ); } } diff --git a/lib/features/feed/presentation/widgets/post_card_with_actions.dart b/lib/features/feed/presentation/widgets/post_card_with_actions.dart index 434a330..6f774d9 100644 --- a/lib/features/feed/presentation/widgets/post_card_with_actions.dart +++ b/lib/features/feed/presentation/widgets/post_card_with_actions.dart @@ -16,6 +16,7 @@ import 'package:lazurite/features/feed/data/post_action_repository.dart'; import 'package:lazurite/features/feed/presentation/widgets/grid_post_card.dart'; import 'package:lazurite/features/feed/presentation/widgets/post_card.dart'; import 'package:lazurite/features/feed/presentation/widgets/post_card_footer.dart'; +import 'package:lazurite/shared/presentation/helpers/snackbar_helper.dart'; /// Controls which card layout variant is rendered by [PostCardWithActions]. enum PostCardVariant { linear, grid } @@ -87,32 +88,27 @@ class _PostCardWithActionsContent extends StatelessWidget { (previous.error != current.error && current.error != null) || (!previous.isDeleted && current.isDeleted), listener: (context, state) { if (state.isDeleted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('Post deleted'), behavior: SnackBarBehavior.floating)); + showAppSnackBar(context, 'Post deleted', behavior: SnackBarBehavior.floating); onDeleted?.call(); return; } if (state.error != null) { final cubit = context.read(); final error = state.error!; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(error), - behavior: SnackBarBehavior.floating, - action: SnackBarAction( - label: 'Retry', - onPressed: () { - if (error.contains('like')) { - cubit.toggleLike(); - } else if (error.contains('repost')) { - cubit.toggleRepost(); - } else if (error.contains('delete')) { - cubit.deletePost(); - } - }, - ), - ), + showAppSnackBar( + context, + error, + behavior: SnackBarBehavior.floating, + actionLabel: 'Retry', + onAction: () { + if (error.contains('like')) { + cubit.toggleLike(); + } else if (error.contains('repost')) { + cubit.toggleRepost(); + } else if (error.contains('delete')) { + cubit.deletePost(); + } + }, ); cubit.clearError(); } diff --git a/lib/features/lists/presentation/list_detail_screen.dart b/lib/features/lists/presentation/list_detail_screen.dart index 072a386..028fb07 100644 --- a/lib/features/lists/presentation/list_detail_screen.dart +++ b/lib/features/lists/presentation/list_detail_screen.dart @@ -11,6 +11,8 @@ import 'package:lazurite/features/lists/bloc/list_bloc.dart'; import 'package:lazurite/features/lists/bloc/list_feed_bloc.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/shared/presentation/widgets/confirmation_dialog.dart'; +import 'package:lazurite/shared/presentation/widgets/options_sheet.dart'; class ListDetailScreen extends StatelessWidget { const ListDetailScreen({super.key, required this.listUri}); @@ -93,26 +95,15 @@ class _ListDetailViewState extends State<_ListDetailView> with SingleTickerProvi } Future _confirmDelete(BuildContext context) async { - final confirmed = await showDialog( + final confirmed = await showConfirmationDialog( context: context, - builder: (dialogContext) => AlertDialog( - title: const Text('Delete list?'), - content: const Text('This action cannot be undone.'), - actions: [ - TextButton(onPressed: () => Navigator.pop(dialogContext, false), child: const Text('Cancel')), - FilledButton( - style: FilledButton.styleFrom( - backgroundColor: Theme.of(dialogContext).colorScheme.error, - foregroundColor: Theme.of(dialogContext).colorScheme.onError, - ), - onPressed: () => Navigator.pop(dialogContext, true), - child: const Text('Delete'), - ), - ], - ), + title: const Text('Delete list?'), + content: const Text('This action cannot be undone.'), + confirmLabel: 'Delete', + confirmDestructive: true, ); - if (confirmed == true && context.mounted) { + if (confirmed && context.mounted) { final userDid = context.read().state.tokens?.did; if (userDid != null) { context.read().add(ListDeleted(userDid: userDid)); @@ -128,62 +119,46 @@ class _ListDetailViewState extends State<_ListDetailView> with SingleTickerProvi final isBlocked = list.viewer?.hasBlocked ?? false; final isOwn = _isOwnList(context, list); - showModalBottomSheet( + showOptionsSheet( context: context, - builder: (sheetContext) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (isOwn) ...[ - ListTile( - leading: const Icon(Icons.edit_outlined), - title: const Text('Edit list'), - onTap: () { - Navigator.pop(sheetContext); - _showEditDialog(context, list); - }, - ), - ListTile( - leading: const Icon(Icons.person_add_outlined), - title: const Text('Add members'), - onTap: () async { - final listUriStr = Uri.encodeComponent(list.uri.toString()); - Navigator.pop(sheetContext); - await context.push('/list/members?uri=$listUriStr'); - if (context.mounted) { - context.read().add(const ListRefreshed()); - } - }, - ), - ListTile( - leading: Icon(Icons.delete_outline, color: Theme.of(context).colorScheme.error), - title: Text('Delete list', style: TextStyle(color: Theme.of(context).colorScheme.error)), - onTap: () { - Navigator.pop(sheetContext); - _confirmDelete(context); - }, - ), - ], - ListTile( - leading: Icon(isMuted ? Icons.volume_up_outlined : Icons.volume_off_outlined), - title: Text(isMuted ? 'Unmute list' : 'Mute list'), - onTap: () { - Navigator.pop(sheetContext); - context.read().add(isMuted ? const ListUnmuted() : const ListMuted()); - }, - ), - if (list.purpose.knownValue == bsky_graph.KnownListPurpose.appBskyGraphDefsModlist) - ListTile( - leading: Icon(isBlocked ? Icons.block_flipped : Icons.block_outlined), - title: Text(isBlocked ? 'Unblock via list' : 'Block via list'), - onTap: () { - Navigator.pop(sheetContext); - context.read().add(isBlocked ? const ListUnblocked() : const ListBlocked()); - }, - ), - ], + items: [ + if (isOwn) + OptionsSheetItem( + leading: const Icon(Icons.edit_outlined), + title: 'Edit list', + onTap: () => _showEditDialog(context, list), + ), + if (isOwn) + OptionsSheetItem( + leading: const Icon(Icons.person_add_outlined), + title: 'Add members', + onTap: () async { + final listUriStr = Uri.encodeComponent(list.uri.toString()); + await context.push('/list/members?uri=$listUriStr'); + if (context.mounted) { + context.read().add(const ListRefreshed()); + } + }, + ), + if (isOwn) + OptionsSheetItem( + leading: Icon(Icons.delete_outline, color: Theme.of(context).colorScheme.error), + title: 'Delete list', + isDestructive: true, + onTap: () => _confirmDelete(context), + ), + OptionsSheetItem( + leading: Icon(isMuted ? Icons.volume_up_outlined : Icons.volume_off_outlined), + title: isMuted ? 'Unmute list' : 'Mute list', + onTap: () => context.read().add(isMuted ? const ListUnmuted() : const ListMuted()), ), - ), + if (list.purpose.knownValue == bsky_graph.KnownListPurpose.appBskyGraphDefsModlist) + OptionsSheetItem( + leading: Icon(isBlocked ? Icons.block_flipped : Icons.block_outlined), + title: isBlocked ? 'Unblock via list' : 'Block via list', + onTap: () => context.read().add(isBlocked ? const ListUnblocked() : const ListBlocked()), + ), + ], ); } diff --git a/lib/features/moderation/presentation/screens/moderation_settings_screen.dart b/lib/features/moderation/presentation/screens/moderation_settings_screen.dart index 58607de..34c079f 100644 --- a/lib/features/moderation/presentation/screens/moderation_settings_screen.dart +++ b/lib/features/moderation/presentation/screens/moderation_settings_screen.dart @@ -6,6 +6,8 @@ import 'package:go_router/go_router.dart'; import 'package:lazurite/features/moderation/data/moderation_service.dart'; import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; import 'package:lazurite/features/moderation/presentation/widgets/moderated_avatar.dart'; +import 'package:lazurite/shared/presentation/helpers/snackbar_helper.dart'; +import 'package:lazurite/shared/presentation/widgets/confirmation_dialog.dart'; import 'package:lazurite/shared/utils/format_utils.dart'; class ModerationSettingsScreen extends StatefulWidget { @@ -61,7 +63,7 @@ class _ModerationSettingsScreenState extends State { _reload(); } catch (error) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to update adult content: $error'))); + showAppSnackBar(context, 'Failed to update adult content: $error', isError: true); } } finally { if (mounted) { @@ -76,7 +78,7 @@ class _ModerationSettingsScreenState extends State { _reload(); } catch (error) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to unsubscribe: $error'))); + showAppSnackBar(context, 'Failed to unsubscribe: $error', isError: true); } } } @@ -119,7 +121,7 @@ class _ModerationSettingsScreenState extends State { if (context.mounted) { final name = details.creator.displayName ?? details.creator.handle; - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Subscribed to $name'))); + showAppSnackBar(context, 'Subscribed to $name'); } } } catch (error) { @@ -129,7 +131,7 @@ class _ModerationSettingsScreenState extends State { } } - return AlertDialog( + return ConfirmationDialog( title: const Text('Add labeler'), content: SizedBox( width: 420, @@ -155,13 +157,10 @@ class _ModerationSettingsScreenState extends State { ], ), ), - actions: [ - TextButton( - onPressed: isSubmitting ? null : () => Navigator.of(dialogContext).pop(), - child: const Text('Cancel'), - ), - FilledButton(onPressed: isSubmitting ? null : submit, child: Text(isSubmitting ? 'Adding...' : 'Add')), - ], + confirmLabel: isSubmitting ? 'Adding...' : 'Add', + confirmEnabled: !isSubmitting, + onCancel: isSubmitting ? null : () => Navigator.of(dialogContext).pop(), + onConfirm: submit, ); }, ); diff --git a/lib/features/profile/presentation/profile_screen.dart b/lib/features/profile/presentation/profile_screen.dart index 6b48234..231c6f8 100644 --- a/lib/features/profile/presentation/profile_screen.dart +++ b/lib/features/profile/presentation/profile_screen.dart @@ -35,6 +35,8 @@ import 'package:lazurite/features/settings/bloc/settings_state.dart'; import 'package:lazurite/features/starter_packs/cubit/actor_starter_packs_cubit.dart'; import 'package:lazurite/features/starter_packs/data/starter_pack_repository.dart'; import 'package:lazurite/features/starter_packs/presentation/widgets/starter_pack_card.dart'; +import 'package:lazurite/shared/presentation/helpers/snackbar_helper.dart'; +import 'package:lazurite/shared/presentation/widgets/options_sheet.dart'; import 'package:lazurite/shared/utils/format_utils.dart'; import 'package:share_plus/share_plus.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -481,9 +483,7 @@ class _ProfileScreenState extends State with TickerProviderStateM child: BlocConsumer( listener: (context, state) { if (state.error != null) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(state.error!), behavior: SnackBarBehavior.floating)); + showAppSnackBar(context, state.error!, behavior: SnackBarBehavior.floating); context.read().clearError(); } }, @@ -510,92 +510,60 @@ class _ProfileScreenState extends State with TickerProviderStateM } void _showOwnProfileMoreOptions(BuildContext context, ProfileViewDetailed profile) { - showModalBottomSheet( + showOptionsSheet( context: context, - builder: (sheetContext) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: const Icon(Icons.hub_outlined), - title: const Text('Profile Context'), - onTap: () { - Navigator.pop(sheetContext); - context.push( - '/profile-context?did=${Uri.encodeComponent(profile.did)}&handle=${Uri.encodeComponent(profile.handle)}', - ); - }, - ), - ListTile( - leading: const Icon(Icons.cleaning_services_outlined), - title: const Text('Clean Follows'), - onTap: () { - Navigator.pop(sheetContext); - context.push('/settings/clean-follows'); - }, - ), - ], + items: [ + OptionsSheetItem( + leading: const Icon(Icons.hub_outlined), + title: 'Profile Context', + onTap: () => context.push( + '/profile-context?did=${Uri.encodeComponent(profile.did)}&handle=${Uri.encodeComponent(profile.handle)}', + ), ), - ), + OptionsSheetItem( + leading: const Icon(Icons.cleaning_services_outlined), + title: 'Clean Follows', + onTap: () => context.push('/settings/clean-follows'), + ), + ], ); } void _showProfileMoreOptions(BuildContext context, ProfileViewDetailed profile) { - showModalBottomSheet( + showOptionsSheet( context: context, - builder: (sheetContext) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: const Icon(Icons.copy), - title: const Text('Copy DID'), - onTap: () { - Clipboard.setData(ClipboardData(text: profile.did)); - Navigator.pop(sheetContext); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('DID copied to clipboard'), behavior: SnackBarBehavior.floating), - ); - }, - ), - ListTile( - leading: const Icon(Icons.share_outlined), - title: const Text('Share Profile'), - onTap: () { - Navigator.pop(sheetContext); - final url = 'https://bsky.app/profile/${profile.handle}'; - Share.share(url); - }, - ), - ListTile( - leading: const Icon(Icons.playlist_add_outlined), - title: const Text('Add to list'), - onTap: () { - Navigator.pop(sheetContext); - _showAddToList(context, profile); - }, - ), - ListTile( - leading: const Icon(Icons.people_outline), - title: const Text('Suggested Follows'), - onTap: () { - Navigator.pop(sheetContext); - _showSuggestedFollows(context, profile); - }, - ), - ListTile( - leading: const Icon(Icons.hub_outlined), - title: const Text('Profile Context'), - onTap: () { - Navigator.pop(sheetContext); - context.push( - '/profile-context?did=${Uri.encodeComponent(profile.did)}&handle=${Uri.encodeComponent(profile.handle)}', - ); - }, - ), - ], + items: [ + OptionsSheetItem( + leading: const Icon(Icons.copy), + title: 'Copy DID', + onTap: () { + Clipboard.setData(ClipboardData(text: profile.did)); + showAppSnackBar(context, 'DID copied to clipboard', behavior: SnackBarBehavior.floating); + }, ), - ), + OptionsSheetItem( + leading: const Icon(Icons.share_outlined), + title: 'Share Profile', + onTap: () => Share.share('https://bsky.app/profile/${profile.handle}'), + ), + OptionsSheetItem( + leading: const Icon(Icons.playlist_add_outlined), + title: 'Add to list', + onTap: () => _showAddToList(context, profile), + ), + OptionsSheetItem( + leading: const Icon(Icons.people_outline), + title: 'Suggested Follows', + onTap: () => _showSuggestedFollows(context, profile), + ), + OptionsSheetItem( + leading: const Icon(Icons.hub_outlined), + title: 'Profile Context', + onTap: () => context.push( + '/profile-context?did=${Uri.encodeComponent(profile.did)}&handle=${Uri.encodeComponent(profile.handle)}', + ), + ), + ], ); } @@ -611,7 +579,7 @@ class _ProfileScreenState extends State with TickerProviderStateM final cubit = AddToListCubit(listRepository: listRepository, currentUserDid: currentUserDid) ..load(targetDid: profile.did); - showModalBottomSheet( + showAppBottomSheet( context: context, isScrollControlled: true, builder: (sheetContext) => BlocProvider.value( @@ -688,7 +656,7 @@ class _ProfileScreenState extends State with TickerProviderStateM final cubit = SuggestedFollowsCubit(repository: profileRepository)..load(profile.did); - showModalBottomSheet( + showAppBottomSheet( context: context, isScrollControlled: true, builder: (sheetContext) => BlocProvider.value( diff --git a/lib/features/profile/presentation/widgets/profile_action_buttons.dart b/lib/features/profile/presentation/widgets/profile_action_buttons.dart index f7f0152..b99e002 100644 --- a/lib/features/profile/presentation/widgets/profile_action_buttons.dart +++ b/lib/features/profile/presentation/widgets/profile_action_buttons.dart @@ -1,6 +1,9 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:lazurite/features/connectivity/connectivity_helpers.dart'; +import 'package:lazurite/shared/presentation/widgets/confirmation_dialog.dart'; class ProfileActionButtons extends StatelessWidget { const ProfileActionButtons({ @@ -150,120 +153,67 @@ class ProfileActionButtons extends StatelessWidget { return button; } - void _confirmUnfollow(BuildContext context) { - HapticFeedback.mediumImpact(); - showDialog( + Future _confirmUnfollow(BuildContext context) async { + unawaited(HapticFeedback.mediumImpact()); + await showConfirmationDialog( context: context, - builder: (context) => AlertDialog( - title: const Text('Unfollow?'), - content: const Text('You will no longer see their posts in your feed.'), - actions: [ - TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')), - FilledButton( - onPressed: () { - Navigator.pop(context); - onUnfollow?.call(); - }, - child: const Text('Unfollow'), - ), - ], - ), + title: const Text('Unfollow?'), + content: const Text('You will no longer see their posts in your feed.'), + confirmLabel: 'Unfollow', + onConfirmed: onUnfollow, ); } - void _confirmMute(BuildContext context) { - HapticFeedback.mediumImpact(); - showDialog( + Future _confirmMute(BuildContext context) async { + unawaited(HapticFeedback.mediumImpact()); + await showConfirmationDialog( context: context, - builder: (context) => AlertDialog( - title: const Text('Mute Account?'), - content: const Text('You will no longer see their posts or receive notifications from them.'), - actions: [ - TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')), - FilledButton( - onPressed: () { - Navigator.pop(context); - onMute?.call(); - }, - child: const Text('Mute'), - ), - ], - ), + title: const Text('Mute Account?'), + content: const Text('You will no longer see their posts or receive notifications from them.'), + confirmLabel: 'Mute', + onConfirmed: onMute, ); } - void _confirmUnmute(BuildContext context) { - HapticFeedback.mediumImpact(); - showDialog( + Future _confirmUnmute(BuildContext context) async { + unawaited(HapticFeedback.mediumImpact()); + await showConfirmationDialog( context: context, - builder: (context) => AlertDialog( - title: const Text('Unmute Account?'), - content: const Text('You will see their posts and receive notifications again.'), - actions: [ - TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')), - FilledButton( - onPressed: () { - Navigator.pop(context); - onUnmute?.call(); - }, - child: const Text('Unmute'), - ), - ], - ), + title: const Text('Unmute Account?'), + content: const Text('You will see their posts and receive notifications again.'), + confirmLabel: 'Unmute', + onConfirmed: onUnmute, ); } - void _confirmBlock(BuildContext context) { - HapticFeedback.heavyImpact(); - showDialog( + Future _confirmBlock(BuildContext context) async { + unawaited(HapticFeedback.heavyImpact()); + await showConfirmationDialog( context: context, - builder: (context) => AlertDialog( - title: Row( - children: [ - Icon(Icons.block, color: Theme.of(context).colorScheme.error), - const SizedBox(width: 8), - const Text('Block Account?'), - ], - ), - content: const Text( - 'They will not be able to see your posts or interact with you. They will not be notified that you blocked them.', - ), - actions: [ - TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')), - FilledButton( - onPressed: () { - Navigator.pop(context); - onBlock?.call(); - }, - style: FilledButton.styleFrom( - backgroundColor: Theme.of(context).colorScheme.error, - foregroundColor: Theme.of(context).colorScheme.onError, - ), - child: const Text('Block'), - ), + title: Row( + children: [ + Icon(Icons.block, color: Theme.of(context).colorScheme.error), + const SizedBox(width: 8), + const Text('Block Account?'), ], ), + content: const Text( + 'They will not be able to see your posts or interact with you. They will not be notified that you blocked them.', + ), + confirmLabel: 'Block', + confirmDestructive: true, + onConfirmed: onBlock, ); } - void _confirmUnblock(BuildContext context) { - HapticFeedback.mediumImpact(); - showDialog( + Future _confirmUnblock(BuildContext context) async { + unawaited(HapticFeedback.mediumImpact()); + await showConfirmationDialog( context: context, - builder: (context) => AlertDialog( - title: const Text('Unblock Account?'), - content: const Text('They will be able to see your posts and interact with you again.'), - actions: [ - TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')), - FilledButton( - onPressed: () { - Navigator.pop(context); - onUnblock?.call(); - }, - child: const Text('Unblock'), - ), - ], - ), + title: const Text('Unblock Account?'), + content: const Text('They will be able to see your posts and interact with you again.'), + confirmLabel: 'Unblock', + onConfirmed: onUnblock, ); } } diff --git a/lib/features/search/presentation/hashtag_screen.dart b/lib/features/search/presentation/hashtag_screen.dart index 1247d02..98c5c76 100644 --- a/lib/features/search/presentation/hashtag_screen.dart +++ b/lib/features/search/presentation/hashtag_screen.dart @@ -12,6 +12,7 @@ import 'package:lazurite/features/moderation/presentation/widgets/moderated_blur import 'package:lazurite/features/moderation/presentation/widgets/moderation_badge_row.dart'; import 'package:lazurite/features/search/cubit/hashtag_cubit.dart'; import 'package:lazurite/features/search/data/hashtag_utils.dart'; +import 'package:lazurite/shared/presentation/widgets/options_sheet.dart'; import 'package:lazurite/shared/utils/format_utils.dart'; class HashtagScreen extends StatefulWidget { @@ -72,7 +73,7 @@ class _HashtagScreenState extends State { context.go('/hashtag?tag=${Uri.encodeQueryComponent(normalized)}'); } - showModalBottomSheet( + showAppBottomSheet( context: context, isScrollControlled: true, builder: (sheetContext) { diff --git a/lib/features/search/presentation/search_screen.dart b/lib/features/search/presentation/search_screen.dart index 9194c48..73d878b 100644 --- a/lib/features/search/presentation/search_screen.dart +++ b/lib/features/search/presentation/search_screen.dart @@ -16,6 +16,8 @@ import 'package:lazurite/features/moderation/presentation/widgets/moderated_blur import 'package:lazurite/features/moderation/presentation/widgets/moderation_badge_row.dart'; import 'package:lazurite/features/search/bloc/search_bloc.dart'; import 'package:lazurite/features/starter_packs/presentation/widgets/starter_pack_card.dart'; +import 'package:lazurite/shared/presentation/helpers/snackbar_helper.dart'; +import 'package:lazurite/shared/presentation/widgets/confirmation_dialog.dart'; import 'package:lazurite/shared/utils/format_utils.dart'; class SearchScreen extends StatefulWidget { @@ -93,23 +95,13 @@ class _SearchScreenState extends State { context.read().add(HistoryEntryDeleted(id: id)); } - void _onClearHistory() { - showDialog( + Future _onClearHistory() async { + await showConfirmationDialog( context: context, - builder: (context) => AlertDialog( - title: const Text('Clear search history?'), - content: const Text('This will delete all your recent searches.'), - actions: [ - TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')), - TextButton( - onPressed: () { - Navigator.pop(context); - context.read().add(const HistoryCleared()); - }, - child: const Text('Clear'), - ), - ], - ), + title: const Text('Clear search history?'), + content: const Text('This will delete all your recent searches.'), + confirmLabel: 'Clear', + onConfirmed: () => context.read().add(const HistoryCleared()), ); } @@ -140,7 +132,7 @@ class _SearchScreenState extends State { }); } - return AlertDialog( + return ConfirmationDialog( title: const Text('Jump to profile'), content: SizedBox( width: 420, @@ -209,19 +201,13 @@ class _SearchScreenState extends State { }, ), ), - actions: [ - TextButton( - onPressed: () { - searchBloc.add(const TypeaheadRequested(query: '')); - Navigator.of(dialogContext).pop(); - }, - child: const Text('Cancel'), - ), - FilledButton( - onPressed: controller.text.trim().isEmpty ? null : submitHandle, - child: const Text('Open'), - ), - ], + confirmLabel: 'Open', + confirmEnabled: controller.text.trim().isNotEmpty, + onCancel: () { + searchBloc.add(const TypeaheadRequested(query: '')); + Navigator.of(dialogContext).pop(); + }, + onConfirm: submitHandle, ); }, ), @@ -571,13 +557,11 @@ class _SearchScreenState extends State { return _FeedResultTile( feed: feed, onAdded: (displayName) { - final messenger = ScaffoldMessenger.of(context); - messenger.hideCurrentSnackBar(); - messenger.showSnackBar( - SnackBar( - content: Text('Added $displayName to your saved feeds'), - action: SnackBarAction(label: 'Manage', onPressed: () => GoRouter.maybeOf(context)?.push('/feeds')), - ), + showAppSnackBar( + context, + 'Added $displayName to your saved feeds', + actionLabel: 'Manage', + onAction: () => GoRouter.maybeOf(context)?.push('/feeds'), ); }, ); diff --git a/lib/features/settings/presentation/settings_screen.dart b/lib/features/settings/presentation/settings_screen.dart index ec983ca..39a7c30 100644 --- a/lib/features/settings/presentation/settings_screen.dart +++ b/lib/features/settings/presentation/settings_screen.dart @@ -17,6 +17,7 @@ import 'package:lazurite/features/search/cubit/semantic_index_cubit.dart'; import 'package:lazurite/features/search/cubit/semantic_search_cubit.dart'; import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; import 'package:lazurite/features/settings/bloc/settings_state.dart'; +import 'package:lazurite/shared/presentation/helpers/snackbar_helper.dart'; class SettingsScreen extends StatelessWidget { const SettingsScreen({super.key}); @@ -427,7 +428,7 @@ class _ModerationSettingsPreviewState extends State<_ModerationSettingsPreview> } } catch (error) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to update adult content: $error'))); + showAppSnackBar(context, 'Failed to update adult content: $error', isError: true); } } finally { if (mounted) { diff --git a/lib/shared/presentation/helpers/snackbar_helper.dart b/lib/shared/presentation/helpers/snackbar_helper.dart new file mode 100644 index 0000000..0c3fc79 --- /dev/null +++ b/lib/shared/presentation/helpers/snackbar_helper.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; + +ScaffoldFeatureController showAppSnackBar( + BuildContext context, + String message, { + bool hideCurrent = true, + bool isError = false, + SnackBarBehavior? behavior, + Duration? duration, + String? actionLabel, + VoidCallback? onAction, +}) { + final messenger = ScaffoldMessenger.of(context); + final colorScheme = Theme.of(context).colorScheme; + + if (hideCurrent) { + messenger.hideCurrentSnackBar(); + } + + return messenger.showSnackBar( + SnackBar( + content: Text(message), + behavior: behavior, + duration: duration ?? const Duration(seconds: 4), + backgroundColor: isError ? colorScheme.error : null, + action: actionLabel == null ? null : SnackBarAction(label: actionLabel, onPressed: onAction ?? () {}), + ), + ); +} diff --git a/lib/shared/presentation/widgets/confirmation_dialog.dart b/lib/shared/presentation/widgets/confirmation_dialog.dart new file mode 100644 index 0000000..c335e87 --- /dev/null +++ b/lib/shared/presentation/widgets/confirmation_dialog.dart @@ -0,0 +1,82 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +class ConfirmationDialog extends StatelessWidget { + const ConfirmationDialog({ + super.key, + required this.title, + required this.content, + required this.confirmLabel, + required this.onConfirm, + this.cancelLabel = 'Cancel', + this.onCancel, + this.confirmDestructive = false, + this.showCancel = true, + this.confirmEnabled = true, + }); + + final Widget title; + final Widget content; + final String confirmLabel; + final VoidCallback onConfirm; + final String cancelLabel; + final VoidCallback? onCancel; + final bool confirmDestructive; + final bool showCancel; + final bool confirmEnabled; + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: title, + content: content, + actions: [ + if (showCancel) + TextButton(onPressed: onCancel ?? () => Navigator.of(context).pop(false), child: Text(cancelLabel)), + FilledButton( + onPressed: confirmEnabled ? onConfirm : null, + style: confirmDestructive + ? FilledButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.error, + foregroundColor: Theme.of(context).colorScheme.onError, + ) + : null, + child: Text(confirmLabel), + ), + ], + ); + } +} + +Future showConfirmationDialog({ + required BuildContext context, + required Widget title, + required Widget content, + required String confirmLabel, + String cancelLabel = 'Cancel', + bool confirmDestructive = false, + bool showCancel = true, + bool barrierDismissible = true, + FutureOr Function()? onConfirmed, +}) async { + final confirmed = await showDialog( + context: context, + barrierDismissible: barrierDismissible, + builder: (dialogContext) => ConfirmationDialog( + title: title, + content: content, + confirmLabel: confirmLabel, + cancelLabel: cancelLabel, + confirmDestructive: confirmDestructive, + showCancel: showCancel, + onConfirm: () => Navigator.of(dialogContext).pop(true), + onCancel: () => Navigator.of(dialogContext).pop(false), + ), + ); + + if (confirmed == true && onConfirmed != null) { + await onConfirmed(); + } + return confirmed ?? false; +} diff --git a/lib/shared/presentation/widgets/options_sheet.dart b/lib/shared/presentation/widgets/options_sheet.dart new file mode 100644 index 0000000..12f607a --- /dev/null +++ b/lib/shared/presentation/widgets/options_sheet.dart @@ -0,0 +1,85 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +class OptionsSheetItem { + const OptionsSheetItem({ + required this.title, + this.subtitle, + this.leading, + this.trailing, + this.isDestructive = false, + this.enabled = true, + this.dismissOnTap = true, + this.onTap, + }); + + final String title; + final String? subtitle; + final Widget? leading; + final Widget? trailing; + final bool isDestructive; + final bool enabled; + final bool dismissOnTap; + final FutureOr Function()? onTap; +} + +class OptionsSheet extends StatelessWidget { + const OptionsSheet({super.key, required this.items, this.header}); + + final Widget? header; + final List items; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ?header, + for (final item in items) + ListTile( + enabled: item.enabled, + leading: item.leading, + trailing: item.trailing, + title: Text(item.title, style: item.isDestructive ? TextStyle(color: colorScheme.error) : null), + subtitle: item.subtitle == null ? null : Text(item.subtitle!), + onTap: item.onTap == null + ? null + : () { + if (item.dismissOnTap) { + Navigator.of(context).pop(); + } + final result = item.onTap!.call(); + if (result is Future) { + unawaited(result.then((_) {})); + } + }, + ), + ], + ), + ); + } +} + +Future showAppBottomSheet({ + required BuildContext context, + required WidgetBuilder builder, + bool isScrollControlled = false, +}) { + return showModalBottomSheet(context: context, isScrollControlled: isScrollControlled, builder: builder); +} + +Future showOptionsSheet({ + required BuildContext context, + required List items, + Widget? header, + bool isScrollControlled = false, +}) { + return showAppBottomSheet( + context: context, + isScrollControlled: isScrollControlled, + builder: (_) => OptionsSheet(items: items, header: header), + ); +} diff --git a/test/shared/presentation/helpers/snackbar_helper_test.dart b/test/shared/presentation/helpers/snackbar_helper_test.dart new file mode 100644 index 0000000..949df3d --- /dev/null +++ b/test/shared/presentation/helpers/snackbar_helper_test.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/shared/presentation/helpers/snackbar_helper.dart'; + +void main() { + Widget buildSubject(void Function(BuildContext context) onPressed) => MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => Center( + child: FilledButton(onPressed: () => onPressed(context), child: const Text('show')), + ), + ), + ), + ); + + testWidgets('shows message', (tester) async { + await tester.pumpWidget(buildSubject((context) => showAppSnackBar(context, 'Saved'))); + + await tester.tap(find.text('show')); + await tester.pump(); + + expect(find.text('Saved'), findsOneWidget); + }); + + testWidgets('shows action and invokes callback', (tester) async { + var retried = false; + + await tester.pumpWidget( + buildSubject( + (context) => showAppSnackBar(context, 'Failed', actionLabel: 'Retry', onAction: () => retried = true), + ), + ); + + await tester.tap(find.text('show')); + await tester.pump(); + expect(find.text('Retry'), findsOneWidget); + + final action = tester.widget(find.byType(SnackBarAction)); + action.onPressed.call(); + + expect(retried, isTrue); + }); +} diff --git a/test/shared/presentation/widgets/confirmation_dialog_test.dart b/test/shared/presentation/widgets/confirmation_dialog_test.dart new file mode 100644 index 0000000..d724ad3 --- /dev/null +++ b/test/shared/presentation/widgets/confirmation_dialog_test.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/shared/presentation/widgets/confirmation_dialog.dart'; + +void main() { + Widget buildSubject({required Future Function(BuildContext context) onPressed}) { + return MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) { + return Center( + child: FilledButton(onPressed: () => onPressed(context), child: const Text('open')), + ); + }, + ), + ), + ); + } + + testWidgets('returns true when confirmed', (tester) async { + var confirmed = false; + + await tester.pumpWidget( + buildSubject( + onPressed: (context) async { + confirmed = await showConfirmationDialog( + context: context, + title: const Text('Delete post?'), + content: const Text('This cannot be undone.'), + confirmLabel: 'Delete', + ); + }, + ), + ); + + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + expect(find.text('Delete post?'), findsOneWidget); + + await tester.tap(find.text('Delete')); + await tester.pumpAndSettle(); + + expect(confirmed, isTrue); + }); + + testWidgets('returns false when cancelled', (tester) async { + var confirmed = true; + + await tester.pumpWidget( + buildSubject( + onPressed: (context) async { + confirmed = await showConfirmationDialog( + context: context, + title: const Text('Discard changes?'), + content: const Text('Unsaved changes will be lost.'), + confirmLabel: 'Discard', + ); + }, + ), + ); + + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(confirmed, isFalse); + }); +} diff --git a/test/shared/presentation/widgets/options_sheet_test.dart b/test/shared/presentation/widgets/options_sheet_test.dart new file mode 100644 index 0000000..31b5321 --- /dev/null +++ b/test/shared/presentation/widgets/options_sheet_test.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/shared/presentation/widgets/options_sheet.dart'; + +void main() { + Widget buildSubject({ + required void Function(BuildContext context) onOpenOptions, + required void Function(BuildContext context) onOpenCustom, + }) { + return MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) { + return Column( + children: [ + FilledButton(onPressed: () => onOpenOptions(context), child: const Text('open-options')), + FilledButton(onPressed: () => onOpenCustom(context), child: const Text('open-custom')), + ], + ); + }, + ), + ), + ); + } + + testWidgets('shows options sheet items and triggers callback', (tester) async { + var tapped = false; + + await tester.pumpWidget( + buildSubject( + onOpenOptions: (context) { + showOptionsSheet( + context: context, + items: [OptionsSheetItem(title: 'Copy link', leading: const Icon(Icons.copy), onTap: () => tapped = true)], + ); + }, + onOpenCustom: (_) {}, + ), + ); + + await tester.tap(find.text('open-options')); + await tester.pumpAndSettle(); + + expect(find.text('Copy link'), findsOneWidget); + await tester.tap(find.text('Copy link')); + await tester.pumpAndSettle(); + expect(tapped, isTrue); + }); + + testWidgets('shows custom bottom sheet via helper', (tester) async { + await tester.pumpWidget( + buildSubject( + onOpenOptions: (_) {}, + onOpenCustom: (context) { + showAppBottomSheet( + context: context, + builder: (_) => const SafeArea( + child: SizedBox(height: 80, child: Center(child: Text('custom'))), + ), + ); + }, + ), + ); + + await tester.tap(find.text('open-custom')); + await tester.pumpAndSettle(); + + expect(find.text('custom'), findsOneWidget); + }); +}