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

Configure Feed

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

refactor: shared dialog/sheets and snackbar handling

+801 -708
+7 -28
docs/tasks/testing.md
··· 18 18 19 19 ## M2 - Dialog & Sheet Consolidation 20 20 21 - - [ ] Create `lib/shared/presentation/widgets/confirmation_dialog.dart` 22 - - [ ] Create `lib/shared/presentation/widgets/options_sheet.dart` 23 - - [ ] Create `lib/shared/presentation/helpers/snackbar_helper.dart` - `showAppSnackBar` 24 - - [ ] Replace confirmation dialogs in: 25 - - `lib/features/profile/presentation/widgets/profile_action_buttons.dart` (5 dialogs) 26 - - `lib/features/compose/presentation/compose_screen.dart` (6 dialogs) 27 - - `lib/features/search/presentation/search_screen.dart` (2 dialogs) 28 - - `lib/features/search/presentation/hashtag_screen.dart` 29 - - `lib/features/feed/presentation/post_thread_screen.dart` 30 - - `lib/features/feed/presentation/feed_management_screen.dart` 31 - - `lib/features/lists/presentation/list_detail_screen.dart` 32 - - `lib/features/settings/presentation/settings_screen.dart` 33 - - `lib/features/moderation/presentation/screens/moderation_settings_screen.dart` 34 - - `lib/features/account/presentation/account_switcher_sheet.dart` 35 - - [ ] Replace modal bottom sheets in: 36 - - `lib/features/feed/presentation/widgets/post_action_bar.dart` 37 - - `lib/features/feed/presentation/widgets/post_card_footer.dart` 38 - - `lib/features/feed/presentation/post_thread_screen.dart` 39 - - `lib/features/search/presentation/hashtag_screen.dart` 40 - - `lib/features/profile/presentation/profile_screen.dart` 41 - - [ ] Replace SnackBar patterns in: 42 - - `lib/features/feed/presentation/widgets/post_card_with_actions.dart` 43 - - `lib/features/feed/presentation/feed_management_screen.dart` 44 - - `lib/features/compose/presentation/compose_screen.dart` 45 - - `lib/features/profile/presentation/profile_screen.dart` 46 - - `lib/features/lists/presentation/list_detail_screen.dart` 47 - - `lib/features/settings/presentation/settings_screen.dart` 48 - - [ ] Tests for dialog/sheet/snackbar helpers 21 + - [x] Create `lib/shared/presentation/widgets/confirmation_dialog.dart` 22 + - [x] Create `lib/shared/presentation/widgets/options_sheet.dart` 23 + - [x] Create `lib/shared/presentation/helpers/snackbar_helper.dart` - `showAppSnackBar` 24 + - [x] Replace confirmation dialogs 25 + - [x] Replace modal bottom sheets 26 + - [x] Replace SnackBar patterns 27 + - [x] Tests for dialog/sheet/snackbar helpers 49 28 50 29 ## M3 - Theme & Spacing Constants 51 30
+22 -24
lib/features/account/presentation/account_switcher_sheet.dart
··· 2 2 import 'package:flutter_bloc/flutter_bloc.dart'; 3 3 import 'package:lazurite/features/account/cubit/account_switcher_cubit.dart'; 4 4 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 5 + import 'package:lazurite/shared/presentation/helpers/snackbar_helper.dart'; 6 + import 'package:lazurite/shared/presentation/widgets/confirmation_dialog.dart'; 7 + import 'package:lazurite/shared/presentation/widgets/options_sheet.dart'; 5 8 6 9 void showAccountSwitcherSheet(BuildContext context) { 7 10 final cubit = context.read<AccountSwitcherCubit>(); 8 11 final authBloc = context.read<AuthBloc>(); 9 12 10 - showModalBottomSheet<void>( 13 + showAppBottomSheet<void>( 11 14 context: context, 12 15 builder: (sheetContext) => BlocProvider.value( 13 16 value: cubit, ··· 97 100 } 98 101 99 102 Future<void> _onSwitchAccount(BuildContext context, String did) async { 100 - final messenger = ScaffoldMessenger.of(context); 101 103 final cubit = context.read<AccountSwitcherCubit>(); 102 104 Navigator.pop(context); 103 105 final tokens = await cubit.switchAccount(did); ··· 106 108 return; 107 109 } 108 110 109 - messenger.showSnackBar(const SnackBar(content: Text('Unable to switch accounts. Sign in again for that account.'))); 111 + if (context.mounted) { 112 + showAppSnackBar(context, 'Unable to switch accounts. Sign in again for that account.'); 113 + } 110 114 } 111 115 112 116 Future<void> _onAddAccount(BuildContext context) async { 113 - final messenger = ScaffoldMessenger.of(context); 114 117 final cubit = context.read<AccountSwitcherCubit>(); 115 118 Navigator.pop(context); 116 119 120 + final controller = TextEditingController(); 117 121 final handle = await showDialog<String>( 118 122 context: context, 119 - builder: (dialogContext) { 120 - final controller = TextEditingController(); 121 - return AlertDialog( 122 - title: const Text('Add Account'), 123 - content: TextField( 124 - controller: controller, 125 - decoration: const InputDecoration(labelText: 'Handle or DID'), 126 - autofocus: true, 127 - ), 128 - actions: [ 129 - TextButton(onPressed: () => Navigator.pop(dialogContext), child: const Text('Cancel')), 130 - TextButton( 131 - onPressed: () => Navigator.pop(dialogContext, controller.text.trim()), 132 - child: const Text('Continue'), 133 - ), 134 - ], 135 - ); 136 - }, 123 + builder: (dialogContext) => ConfirmationDialog( 124 + title: const Text('Add Account'), 125 + content: TextField( 126 + controller: controller, 127 + decoration: const InputDecoration(labelText: 'Handle or DID'), 128 + autofocus: true, 129 + ), 130 + confirmLabel: 'Continue', 131 + onCancel: () => Navigator.pop(dialogContext), 132 + onConfirm: () => Navigator.pop(dialogContext, controller.text.trim()), 133 + ), 137 134 ); 135 + controller.dispose(); 138 136 139 137 if (handle == null || handle.isEmpty) return; 140 138 141 139 final tokens = await cubit.addAccountWithOAuth(handle); 142 140 if (tokens != null) { 143 141 authBloc.add(SessionRestored(tokens: tokens)); 144 - } else { 145 - messenger.showSnackBar(const SnackBar(content: Text('Failed to add account'))); 142 + } else if (context.mounted) { 143 + showAppSnackBar(context, 'Failed to add account', isError: true); 146 144 } 147 145 } 148 146 }
+91 -140
lib/features/compose/presentation/compose_screen.dart
··· 10 10 import 'package:lazurite/features/compose/bloc/compose_bloc.dart'; 11 11 import 'package:lazurite/features/connectivity/connectivity_helpers.dart'; 12 12 import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; 13 + import 'package:lazurite/shared/presentation/helpers/snackbar_helper.dart'; 14 + import 'package:lazurite/shared/presentation/widgets/confirmation_dialog.dart'; 13 15 14 16 class ComposeScreen extends StatefulWidget { 15 17 const ComposeScreen({ ··· 110 112 111 113 Future<void> _pickImage() async { 112 114 final state = context.read<ComposeBloc>().state; 113 - final theme = Theme.of(context); 114 115 if (!state.canAddMoreMedia) { 115 116 if (mounted) { 116 - ScaffoldMessenger.of(context).showSnackBar( 117 - SnackBar( 118 - content: Text('Maximum 4 images allowed', style: TextStyle(color: theme.colorScheme.error)), 119 - ), 120 - ); 117 + showAppSnackBar(context, 'Maximum 4 images allowed', isError: true); 121 118 } 122 119 return; 123 120 } ··· 136 133 const maxSize = 1 * 1024 * 1024; 137 134 if (fileSize > maxSize) { 138 135 if (mounted) { 139 - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Image must be smaller than 1MB'))); 136 + showAppSnackBar(context, 'Image must be smaller than 1MB', isError: true); 140 137 } 141 138 return; 142 139 } ··· 145 142 const validExtensions = ['jpg', 'jpeg', 'png', 'webp']; 146 143 if (!validExtensions.contains(extension)) { 147 144 if (mounted) { 148 - ScaffoldMessenger.of( 149 - context, 150 - ).showSnackBar(const SnackBar(content: Text('Image must be JPEG, PNG, or WebP'))); 145 + showAppSnackBar(context, 'Image must be JPEG, PNG, or WebP', isError: true); 151 146 } 152 147 return; 153 148 } ··· 164 159 } 165 160 } catch (e) { 166 161 if (mounted) { 167 - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to pick image: $e'))); 162 + showAppSnackBar(context, 'Failed to pick image: $e', isError: true); 168 163 } 169 164 } 170 165 } ··· 173 168 final state = context.read<ComposeBloc>().state; 174 169 if (!state.canAddVideo) { 175 170 if (mounted) { 176 - ScaffoldMessenger.of( 177 - context, 178 - ).showSnackBar(const SnackBar(content: Text('Remove existing media before adding a video'))); 171 + showAppSnackBar(context, 'Remove existing media before adding a video', isError: true); 179 172 } 180 173 return; 181 174 } ··· 187 180 } 188 181 } catch (e) { 189 182 if (mounted) { 190 - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to pick video: $e'))); 183 + showAppSnackBar(context, 'Failed to pick video: $e', isError: true); 191 184 } 192 185 } 193 186 } ··· 197 190 198 191 final result = await showDialog<String>( 199 192 context: context, 200 - builder: (context) => AlertDialog( 193 + builder: (dialogContext) => ConfirmationDialog( 201 194 title: const Text('Add video alt text'), 202 195 content: TextField( 203 196 controller: altController, ··· 208 201 border: OutlineInputBorder(), 209 202 ), 210 203 ), 211 - actions: [ 212 - TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')), 213 - TextButton(onPressed: () => Navigator.pop(context, altController.text), child: const Text('Save')), 214 - ], 204 + confirmLabel: 'Save', 205 + onCancel: () => Navigator.pop(dialogContext), 206 + onConfirm: () => Navigator.pop(dialogContext, altController.text), 215 207 ), 216 208 ); 217 209 ··· 227 219 228 220 final result = await showDialog<String>( 229 221 context: context, 230 - builder: (context) => AlertDialog( 222 + builder: (dialogContext) => ConfirmationDialog( 231 223 title: const Text('Add alt text'), 232 224 content: TextField( 233 225 controller: altController, ··· 238 230 border: OutlineInputBorder(), 239 231 ), 240 232 ), 241 - actions: [ 242 - TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')), 243 - TextButton(onPressed: () => Navigator.pop(context, altController.text), child: const Text('Save')), 244 - ], 233 + confirmLabel: 'Save', 234 + onCancel: () => Navigator.pop(dialogContext), 235 + onConfirm: () => Navigator.pop(dialogContext, altController.text), 245 236 ), 246 237 ); 247 238 ··· 325 316 return Container( 326 317 constraints: const BoxConstraints(maxHeight: 280), 327 318 decoration: BoxDecoration( 328 - border: Border(top: BorderSide(color: _theme.dividerColor)), 319 + border: Border(top: BorderSide(color: theme.dividerColor)), 329 320 ), 330 321 child: Column( 331 322 mainAxisSize: MainAxisSize.min, ··· 335 326 child: Row( 336 327 mainAxisAlignment: MainAxisAlignment.spaceBetween, 337 328 children: [ 338 - Text('Drafts', style: _theme.textTheme.titleMedium), 329 + Text('Drafts', style: theme.textTheme.titleMedium), 339 330 if (state.drafts.isNotEmpty) 340 331 Text( 341 332 '${state.drafts.length} draft${state.drafts.length != 1 ? 's' : ''}', 342 - style: _theme.textTheme.bodySmall?.copyWith(color: _theme.colorScheme.onSurfaceVariant), 333 + style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), 343 334 ), 344 335 ], 345 336 ), ··· 355 346 child: Center( 356 347 child: Text( 357 348 'No drafts saved', 358 - style: _theme.textTheme.bodyMedium?.copyWith(color: _theme.colorScheme.onSurfaceVariant), 349 + style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant), 359 350 ), 360 351 ), 361 352 ) ··· 375 366 ), 376 367 subtitle: Row( 377 368 children: [ 378 - Text(_formatDraftTime(draft.updatedAt), style: _theme.textTheme.bodySmall), 369 + Text(_formatDraftTime(draft.updatedAt), style: theme.textTheme.bodySmall), 379 370 if (draft.scheduledAt != null) ...[ 380 371 const SizedBox(width: 8), 381 372 Container( 382 373 padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), 383 374 decoration: BoxDecoration( 384 - color: _theme.colorScheme.primaryContainer, 375 + color: theme.colorScheme.primaryContainer, 385 376 borderRadius: BorderRadius.circular(4), 386 377 ), 387 378 child: Text( 388 379 'Scheduled', 389 - style: _theme.textTheme.bodySmall?.copyWith( 390 - color: _theme.colorScheme.onPrimaryContainer, 380 + style: theme.textTheme.bodySmall?.copyWith( 381 + color: theme.colorScheme.onPrimaryContainer, 391 382 ), 392 383 ), 393 384 ), ··· 395 386 ], 396 387 ), 397 388 trailing: IconButton( 398 - icon: Icon(Icons.delete_outline, color: _theme.colorScheme.error), 389 + icon: Icon(Icons.delete_outline, color: theme.colorScheme.error), 399 390 onPressed: () { 400 391 final bloc = context.read<ComposeBloc>(); 401 - final theme = _theme; 402 - showDialog<bool>( 392 + showConfirmationDialog( 403 393 context: context, 404 - builder: (dialogContext) => AlertDialog( 405 - title: const Text('Delete Draft?'), 406 - content: const Text('This action cannot be undone.'), 407 - actions: [ 408 - TextButton( 409 - onPressed: () => Navigator.of(dialogContext).pop(false), 410 - child: const Text('Cancel'), 411 - ), 412 - TextButton( 413 - onPressed: () => Navigator.of(dialogContext).pop(true), 414 - child: Text('Delete', style: TextStyle(color: theme.colorScheme.error)), 415 - ), 416 - ], 417 - ), 394 + title: const Text('Delete Draft?'), 395 + content: const Text('This action cannot be undone.'), 396 + confirmLabel: 'Delete', 397 + confirmDestructive: true, 418 398 ).then((confirmed) { 419 - if (confirmed == true && mounted) { 399 + if (confirmed && mounted) { 420 400 bloc.add(DraftDeleted(draft.id)); 421 401 } 422 402 }); ··· 437 417 ); 438 418 } 439 419 440 - ThemeData get _theme => Theme.of(context); 420 + ThemeData get theme => Theme.of(context); 441 421 442 422 void _submitPost() { 443 423 context.read<ComposeBloc>().add(const PostSubmitted()); ··· 447 427 if (context.read<ComposeBloc>().state.isEditing) return; 448 428 context.read<ComposeBloc>().add(const DraftSaved()); 449 429 if (mounted) { 450 - ScaffoldMessenger.of(context).showSnackBar( 451 - SnackBar( 452 - content: Text('Draft saved', style: TextStyle(color: _theme.colorScheme.onPrimary)), 453 - backgroundColor: _theme.colorScheme.primary, 454 - ), 455 - ); 430 + showAppSnackBar(context, 'Draft saved'); 456 431 } 457 432 } 458 433 459 - void _showEditAlgorithmInfo() { 460 - showDialog<void>( 434 + Future<void> _showEditAlgorithmInfo() async { 435 + await showConfirmationDialog( 461 436 context: context, 462 - builder: (dialogContext) => AlertDialog( 463 - title: const Text('How Post Editing Works'), 464 - content: const Text( 465 - 'Lazurite saves edits by deleting and recreating the post record with the same URI. During re-indexing, ' 466 - 'ranking, counters, and search visibility can shift, and updates may take time to appear everywhere.', 467 - ), 468 - actions: [TextButton(onPressed: () => Navigator.pop(dialogContext), child: const Text('OK'))], 437 + title: const Text('How Post Editing Works'), 438 + content: const Text( 439 + 'Lazurite saves edits by deleting and recreating the post record with the same URI. During re-indexing, ' 440 + 'ranking, counters, and search visibility can shift, and updates may take time to appear everywhere.', 469 441 ), 442 + confirmLabel: 'OK', 443 + showCancel: false, 470 444 ); 471 445 } 472 446 ··· 478 452 479 453 if (state.isEditing) { 480 454 if (state.isDraftDirty) { 481 - showDialog<bool>( 455 + showConfirmationDialog( 482 456 context: context, 483 - builder: (dialogContext) => AlertDialog( 484 - title: const Text('Discard Changes?'), 485 - content: const Text('You have unsaved edits. Discard them and leave?'), 486 - actions: [ 487 - TextButton(onPressed: () => Navigator.of(dialogContext).pop(false), child: const Text('Cancel')), 488 - TextButton(onPressed: () => Navigator.of(dialogContext).pop(true), child: const Text('Discard')), 489 - ], 490 - ), 457 + title: const Text('Discard Changes?'), 458 + content: const Text('You have unsaved edits. Discard them and leave?'), 459 + confirmLabel: 'Discard', 491 460 ).then((shouldDiscard) { 492 - if (shouldDiscard == true && mounted) { 461 + if (shouldDiscard && mounted) { 493 462 navigator.pop(false); 494 463 } 495 464 }); ··· 500 469 } 501 470 502 471 if (hasContent && state.isDraftDirty) { 503 - showDialog<bool>( 472 + showConfirmationDialog( 504 473 context: context, 505 - builder: (dialogContext) => AlertDialog( 506 - title: const Text('Save Draft?'), 507 - content: const Text('You have unsaved content. Would you like to save it as a draft?'), 508 - actions: [ 509 - TextButton( 510 - onPressed: () { 511 - Navigator.of(dialogContext).pop(false); 512 - }, 513 - child: const Text('Discard'), 514 - ), 515 - TextButton( 516 - onPressed: () { 517 - Navigator.of(dialogContext).pop(true); 518 - }, 519 - child: const Text('Save'), 520 - ), 521 - ], 522 - ), 474 + title: const Text('Save Draft?'), 475 + content: const Text('You have unsaved content. Would you like to save it as a draft?'), 476 + cancelLabel: 'Discard', 477 + confirmLabel: 'Save', 523 478 ).then((shouldSave) { 524 - if (shouldSave == true) { 479 + if (shouldSave) { 525 480 _saveDraft(); 526 481 } 527 482 if (mounted) { ··· 544 499 545 500 if (state.isSuccess) { 546 501 if (state.isEditing) { 547 - ScaffoldMessenger.of( 548 - context, 549 - ).showSnackBar(const SnackBar(content: Text('Changes saved.'), behavior: SnackBarBehavior.floating)); 502 + showAppSnackBar(context, 'Changes saved.', behavior: SnackBarBehavior.floating); 550 503 } 551 504 Navigator.of(context).pop(state.isEditing ? {'editedText': state.text} : null); 552 505 } 553 506 554 507 if (state.hasError && state.errorMessage != null) { 555 - ScaffoldMessenger.of( 556 - context, 557 - ).showSnackBar(SnackBar(content: Text(state.errorMessage!), behavior: SnackBarBehavior.floating)); 508 + showAppSnackBar(context, state.errorMessage!, behavior: SnackBarBehavior.floating, isError: true); 558 509 } 559 510 }, 560 511 child: PopScope( ··· 607 558 margin: const EdgeInsets.fromLTRB(16, 12, 16, 0), 608 559 padding: const EdgeInsets.all(12), 609 560 decoration: BoxDecoration( 610 - color: _theme.colorScheme.surfaceContainerHighest, 611 - border: Border.all(color: _theme.colorScheme.outlineVariant), 561 + color: theme.colorScheme.surfaceContainerHighest, 562 + border: Border.all(color: theme.colorScheme.outlineVariant), 612 563 ), 613 564 child: Row( 614 565 crossAxisAlignment: CrossAxisAlignment.start, 615 566 children: [ 616 - Icon(Icons.info_outline, color: _theme.colorScheme.onSurfaceVariant, size: 20), 567 + Icon(Icons.info_outline, color: theme.colorScheme.onSurfaceVariant, size: 20), 617 568 const SizedBox(width: 12), 618 569 Expanded( 619 570 child: Text( 620 571 'Edits are saved by replacing the record while keeping this post URI. Ranking, ' 621 572 'counts, and visibility may shift while networks re-index.', 622 - style: _theme.textTheme.bodySmall?.copyWith(color: _theme.colorScheme.onSurfaceVariant), 573 + style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), 623 574 ), 624 575 ), 625 576 IconButton( ··· 638 589 return Container( 639 590 padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), 640 591 decoration: BoxDecoration( 641 - color: _theme.colorScheme.surfaceContainerHighest, 642 - border: Border(bottom: BorderSide(color: _theme.dividerColor)), 592 + color: theme.colorScheme.surfaceContainerHighest, 593 + border: Border(bottom: BorderSide(color: theme.dividerColor)), 643 594 ), 644 595 child: Row( 645 596 children: [ 646 - Icon(Icons.reply, size: 16, color: _theme.colorScheme.onSurfaceVariant), 597 + Icon(Icons.reply, size: 16, color: theme.colorScheme.onSurfaceVariant), 647 598 const SizedBox(width: 8), 648 599 Text( 649 600 'Replying to ', 650 601 style: Theme.of( 651 602 context, 652 - ).textTheme.bodySmall?.copyWith(color: _theme.colorScheme.onSurfaceVariant), 603 + ).textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), 653 604 ), 654 605 Text( 655 606 '@${widget.replyAuthorHandle}', 656 - style: _theme.textTheme.bodySmall?.copyWith( 657 - color: _theme.colorScheme.primary, 607 + style: theme.textTheme.bodySmall?.copyWith( 608 + color: theme.colorScheme.primary, 658 609 fontWeight: FontWeight.w500, 659 610 ), 660 611 ), ··· 676 627 border: InputBorder.none, 677 628 contentPadding: EdgeInsets.zero, 678 629 ), 679 - style: _theme.textTheme.bodyLarge?.copyWith(height: 1.5), 630 + style: theme.textTheme.bodyLarge?.copyWith(height: 1.5), 680 631 ), 681 632 ), 682 633 ), ··· 688 639 margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), 689 640 padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), 690 641 decoration: BoxDecoration( 691 - color: _theme.colorScheme.primaryContainer, 642 + color: theme.colorScheme.primaryContainer, 692 643 borderRadius: BorderRadius.circular(20), 693 644 ), 694 645 child: Row( 695 646 mainAxisSize: MainAxisSize.min, 696 647 children: [ 697 - Icon(Icons.schedule, size: 16, color: _theme.colorScheme.onPrimaryContainer), 648 + Icon(Icons.schedule, size: 16, color: theme.colorScheme.onPrimaryContainer), 698 649 const SizedBox(width: 8), 699 650 Text( 700 651 'Scheduled for ${DateFormat('MMM d, h:mm a').format(state.scheduledAt!)}', 701 652 style: Theme.of( 702 653 context, 703 - ).textTheme.bodySmall?.copyWith(color: _theme.colorScheme.onPrimaryContainer), 654 + ).textTheme.bodySmall?.copyWith(color: theme.colorScheme.onPrimaryContainer), 704 655 ), 705 656 const SizedBox(width: 8), 706 657 GestureDetector( 707 658 onTap: () { 708 659 context.read<ComposeBloc>().add(const ScheduleCleared()); 709 660 }, 710 - child: Icon(Icons.close, size: 16, color: _theme.colorScheme.onPrimaryContainer), 661 + child: Icon(Icons.close, size: 16, color: theme.colorScheme.onPrimaryContainer), 711 662 ), 712 663 ], 713 664 ), ··· 752 703 padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), 753 704 decoration: BoxDecoration( 754 705 color: attachment.altText.isNotEmpty 755 - ? _theme.colorScheme.primary 706 + ? theme.colorScheme.primary 756 707 : Colors.black54, 757 708 borderRadius: BorderRadius.circular(4), 758 709 ), 759 710 child: Text( 760 711 'ALT', 761 - style: _theme.textTheme.labelSmall?.copyWith( 712 + style: theme.textTheme.labelSmall?.copyWith( 762 713 color: attachment.altText.isNotEmpty 763 - ? _theme.colorScheme.onPrimary 714 + ? theme.colorScheme.onPrimary 764 715 : Colors.white, 765 716 fontWeight: FontWeight.bold, 766 717 ), ··· 802 753 margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), 803 754 padding: const EdgeInsets.all(12), 804 755 decoration: BoxDecoration( 805 - color: _theme.colorScheme.surfaceContainerHighest, 756 + color: theme.colorScheme.surfaceContainerHighest, 806 757 borderRadius: BorderRadius.circular(12), 807 - border: Border.all(color: video.hasError ? _theme.colorScheme.error : _theme.dividerColor), 758 + border: Border.all(color: video.hasError ? theme.colorScheme.error : theme.dividerColor), 808 759 ), 809 760 child: Row( 810 761 children: [ ··· 812 763 width: 48, 813 764 height: 48, 814 765 decoration: BoxDecoration( 815 - color: _theme.colorScheme.primaryContainer, 766 + color: theme.colorScheme.primaryContainer, 816 767 borderRadius: BorderRadius.circular(8), 817 768 ), 818 769 child: video.isActive ··· 828 779 : Icon( 829 780 video.hasError ? Icons.error_outline : Icons.videocam_outlined, 830 781 color: video.hasError 831 - ? _theme.colorScheme.error 832 - : _theme.colorScheme.onPrimaryContainer, 782 + ? theme.colorScheme.error 783 + : theme.colorScheme.onPrimaryContainer, 833 784 ), 834 785 ), 835 786 const SizedBox(width: 12), ··· 839 790 children: [ 840 791 Text( 841 792 video.localPath.split('/').last, 842 - style: _theme.textTheme.bodyMedium, 793 + style: theme.textTheme.bodyMedium, 843 794 maxLines: 1, 844 795 overflow: TextOverflow.ellipsis, 845 796 ), 846 797 const SizedBox(height: 2), 847 798 Text( 848 799 _videoStatusLabel(video), 849 - style: _theme.textTheme.bodySmall?.copyWith( 800 + style: theme.textTheme.bodySmall?.copyWith( 850 801 color: video.hasError 851 - ? _theme.colorScheme.error 852 - : _theme.colorScheme.onSurfaceVariant, 802 + ? theme.colorScheme.error 803 + : theme.colorScheme.onSurfaceVariant, 853 804 ), 854 805 ), 855 806 if (video.isActive && video.uploadProgress > 0) ...[ ··· 868 819 tooltip: 'Add alt text', 869 820 onPressed: () => _showVideoAltTextDialog(video.altText), 870 821 color: video.altText.isNotEmpty 871 - ? _theme.colorScheme.primary 872 - : _theme.colorScheme.onSurfaceVariant, 822 + ? theme.colorScheme.primary 823 + : theme.colorScheme.onSurfaceVariant, 873 824 ), 874 825 ], 875 826 IconButton( 876 827 icon: const Icon(Icons.close), 877 828 onPressed: () => context.read<ComposeBloc>().add(const VideoRemoved()), 878 - color: _theme.colorScheme.onSurfaceVariant, 829 + color: theme.colorScheme.onSurfaceVariant, 879 830 ), 880 831 ], 881 832 ), ··· 892 843 const SizedBox(height: 8), 893 844 Container( 894 845 decoration: BoxDecoration( 895 - border: Border(top: BorderSide(color: _theme.dividerColor)), 846 + border: Border(top: BorderSide(color: theme.dividerColor)), 896 847 ), 897 848 padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), 898 849 child: SafeArea( ··· 906 857 icon: Icon( 907 858 Icons.image_outlined, 908 859 color: state.canAddMoreMedia 909 - ? _theme.colorScheme.primary 910 - : _theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.5), 860 + ? theme.colorScheme.primary 861 + : theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.5), 911 862 ), 912 863 tooltip: 'Add image', 913 864 ); ··· 921 872 icon: Icon( 922 873 Icons.videocam_outlined, 923 874 color: state.canAddVideo 924 - ? _theme.colorScheme.primary 925 - : _theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.5), 875 + ? theme.colorScheme.primary 876 + : theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.5), 926 877 ), 927 878 tooltip: 'Add video', 928 879 ); ··· 933 884 if (state.isEditing) return const SizedBox.shrink(); 934 885 return IconButton( 935 886 onPressed: _toggleDrafts, 936 - icon: Icon(Icons.drive_file_rename_outline, color: _theme.colorScheme.primary), 887 + icon: Icon(Icons.drive_file_rename_outline, color: theme.colorScheme.primary), 937 888 tooltip: 'Drafts', 938 889 ); 939 890 }, ··· 943 894 if (state.isEditing) return const SizedBox.shrink(); 944 895 return IconButton( 945 896 onPressed: _showSchedulePicker, 946 - icon: Icon(Icons.schedule, color: _theme.colorScheme.primary), 897 + icon: Icon(Icons.schedule, color: theme.colorScheme.primary), 947 898 tooltip: 'Schedule', 948 899 ); 949 900 },
+14 -24
lib/features/feed/presentation/feed_management_screen.dart
··· 4 4 import 'package:flutter_bloc/flutter_bloc.dart'; 5 5 import 'package:lazurite/features/feed/cubit/feed_preferences_cubit.dart'; 6 6 import 'package:lazurite/features/feed/data/feed_repository.dart'; 7 + import 'package:lazurite/shared/presentation/helpers/snackbar_helper.dart'; 8 + import 'package:lazurite/shared/presentation/widgets/confirmation_dialog.dart'; 7 9 import 'package:lazurite/shared/presentation/widgets/empty_state.dart'; 8 10 import 'package:lazurite/shared/presentation/widgets/loading_state.dart'; 9 11 ··· 50 52 body: BlocConsumer<FeedPreferencesCubit, FeedPreferencesState>( 51 53 listener: (context, state) { 52 54 if (state.status == FeedPreferencesStatus.saveError) { 53 - ScaffoldMessenger.of(context).showSnackBar( 54 - SnackBar( 55 - content: Text('Failed to sync: ${state.message}'), 56 - action: SnackBarAction( 57 - label: 'Dismiss', 58 - onPressed: () => context.read<FeedPreferencesCubit>().clearError(), 59 - ), 60 - ), 55 + showAppSnackBar( 56 + context, 57 + 'Failed to sync: ${state.message}', 58 + actionLabel: 'Dismiss', 59 + onAction: () => context.read<FeedPreferencesCubit>().clearError(), 61 60 ); 62 61 } 63 62 }, ··· 334 333 return feed.uri.rkey; 335 334 } 336 335 337 - void _confirmRemoveFeed(BuildContext context, String feedId) { 338 - showDialog<void>( 336 + Future<void> _confirmRemoveFeed(BuildContext context, String feedId) async { 337 + await showConfirmationDialog( 339 338 context: context, 340 - builder: (context) => AlertDialog( 341 - title: const Text('Remove Feed'), 342 - content: const Text('Are you sure you want to remove this feed from your saved feeds?'), 343 - actions: [ 344 - TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel')), 345 - TextButton( 346 - onPressed: () { 347 - context.read<FeedPreferencesCubit>().removeFeed(feedId); 348 - Navigator.of(context).pop(); 349 - }, 350 - child: Text('Remove', style: TextStyle(color: Theme.of(context).colorScheme.error)), 351 - ), 352 - ], 353 - ), 339 + title: const Text('Remove Feed'), 340 + content: const Text('Are you sure you want to remove this feed from your saved feeds?'), 341 + confirmLabel: 'Remove', 342 + confirmDestructive: true, 343 + onConfirmed: () => context.read<FeedPreferencesCubit>().removeFeed(feedId), 354 344 ); 355 345 } 356 346 }
+43 -81
lib/features/feed/presentation/post_thread_screen.dart
··· 28 28 import 'package:lazurite/features/profile/data/profile_action_repository.dart'; 29 29 import 'package:lazurite/features/profile/presentation/widgets/report_dialog.dart'; 30 30 import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 31 + import 'package:lazurite/shared/presentation/helpers/snackbar_helper.dart'; 32 + import 'package:lazurite/shared/presentation/widgets/confirmation_dialog.dart'; 33 + import 'package:lazurite/shared/presentation/widgets/options_sheet.dart'; 31 34 32 35 class PostThreadScreen extends StatelessWidget { 33 36 const PostThreadScreen({super.key, required this.postUri}); ··· 635 638 child: BlocListener<PostActionCubit, PostActionState>( 636 639 listenWhen: (previous, current) => previous.error != current.error && current.error != null, 637 640 listener: (context, state) { 638 - ScaffoldMessenger.of( 639 - context, 640 - ).showSnackBar(SnackBar(content: Text(state.error!), behavior: SnackBarBehavior.floating)); 641 + showAppSnackBar(context, state.error!, behavior: SnackBarBehavior.floating); 641 642 context.read<PostActionCubit>().clearError(); 642 643 }, 643 644 child: _FocusedPostContent(thread: thread, accountDid: accountDid), ··· 722 723 723 724 void _showInteractions(BuildContext context, PostView post, {required bool showLikes}) { 724 725 final repository = context.read<PostActionRepository>(); 725 - showModalBottomSheet<void>( 726 + showAppBottomSheet<void>( 726 727 context: context, 727 728 isScrollControlled: true, 728 729 builder: (_) => PostInteractionsSheet( ··· 836 837 final postUri = post.uri.toString(); 837 838 final bskyUrl = _convertAtUriToBskyUrl(postUri); 838 839 839 - showModalBottomSheet<void>( 840 + showOptionsSheet<void>( 840 841 context: context, 841 - builder: (sheetContext) => SafeArea( 842 - child: Column( 843 - mainAxisSize: MainAxisSize.min, 844 - children: [ 845 - ListTile( 846 - leading: const Icon(Icons.copy), 847 - title: const Text('Copy Link'), 848 - onTap: () { 849 - Navigator.pop(sheetContext); 850 - _copyToClipboard(context, bskyUrl); 851 - }, 852 - ), 853 - ListTile( 854 - leading: const Icon(Icons.person_outline), 855 - title: Text('View @${post.author.handle}'), 856 - onTap: () { 857 - Navigator.pop(sheetContext); 858 - context.push('/profile/view?actor=${Uri.encodeQueryComponent(post.author.did)}'); 859 - }, 860 - ), 861 - ListTile( 862 - leading: const Icon(Icons.report_outlined, color: Colors.orange), 863 - title: const Text('Report Post', style: TextStyle(color: Colors.orange)), 864 - onTap: () { 865 - Navigator.pop(sheetContext); 866 - _showReportDialog(context); 867 - }, 868 - ), 869 - if (post.author.did == accountDid) 870 - ListTile( 871 - leading: const Icon(Icons.edit_outlined), 872 - title: const Text('Edit Post'), 873 - onTap: () { 874 - Navigator.pop(sheetContext); 875 - unawaited(_onEdit(context)); 876 - }, 877 - ), 878 - if (post.author.did == accountDid) 879 - ListTile( 880 - leading: Icon(Icons.delete_outline, color: Theme.of(context).colorScheme.error), 881 - title: Text('Delete Post', style: TextStyle(color: Theme.of(context).colorScheme.error)), 882 - onTap: () { 883 - Navigator.pop(sheetContext); 884 - _confirmDelete(context); 885 - }, 886 - ), 887 - ], 842 + items: [ 843 + OptionsSheetItem( 844 + leading: const Icon(Icons.copy), 845 + title: 'Copy Link', 846 + onTap: () => _copyToClipboard(context, bskyUrl), 847 + ), 848 + OptionsSheetItem( 849 + leading: const Icon(Icons.person_outline), 850 + title: 'View @${post.author.handle}', 851 + onTap: () => context.push('/profile/view?actor=${Uri.encodeQueryComponent(post.author.did)}'), 852 + ), 853 + OptionsSheetItem( 854 + leading: const Icon(Icons.report_outlined, color: Colors.orange), 855 + title: 'Report Post', 856 + onTap: () => _showReportDialog(context), 888 857 ), 889 - ), 858 + if (post.author.did == accountDid) 859 + OptionsSheetItem(leading: const Icon(Icons.edit_outlined), title: 'Edit Post', onTap: () => _onEdit(context)), 860 + if (post.author.did == accountDid) 861 + OptionsSheetItem( 862 + leading: Icon(Icons.delete_outline, color: Theme.of(context).colorScheme.error), 863 + title: 'Delete Post', 864 + isDestructive: true, 865 + onTap: () => _confirmDelete(context), 866 + ), 867 + ], 890 868 ); 891 869 } 892 870 ··· 956 934 } 957 935 958 936 if (context.mounted && expectedText != null) { 959 - ScaffoldMessenger.of(context).showSnackBar( 960 - const SnackBar( 961 - content: Text('Edit saved. Your updates may take a moment to appear across feeds.'), 962 - behavior: SnackBarBehavior.floating, 963 - ), 937 + showAppSnackBar( 938 + context, 939 + 'Edit saved. Your updates may take a moment to appear across feeds.', 940 + behavior: SnackBarBehavior.floating, 964 941 ); 965 942 } 966 943 } ··· 980 957 ); 981 958 } 982 959 983 - void _confirmDelete(BuildContext context) { 984 - showDialog<void>( 960 + Future<void> _confirmDelete(BuildContext context) async { 961 + await showConfirmationDialog( 985 962 context: context, 986 - builder: (dialogContext) => AlertDialog( 987 - title: const Text('Delete Post?'), 988 - content: const Text('This action cannot be undone.'), 989 - actions: [ 990 - TextButton(onPressed: () => Navigator.pop(dialogContext), child: const Text('Cancel')), 991 - FilledButton( 992 - onPressed: () { 993 - Navigator.pop(dialogContext); 994 - context.read<PostActionCubit>().deletePost(); 995 - }, 996 - style: FilledButton.styleFrom( 997 - backgroundColor: Theme.of(context).colorScheme.error, 998 - foregroundColor: Theme.of(context).colorScheme.onError, 999 - ), 1000 - child: const Text('Delete'), 1001 - ), 1002 - ], 1003 - ), 963 + title: const Text('Delete Post?'), 964 + content: const Text('This action cannot be undone.'), 965 + confirmLabel: 'Delete', 966 + confirmDestructive: true, 967 + onConfirmed: () => context.read<PostActionCubit>().deletePost(), 1004 968 ); 1005 969 } 1006 970 1007 971 void _copyToClipboard(BuildContext context, String text) { 1008 972 Clipboard.setData(ClipboardData(text: text)); 1009 - ScaffoldMessenger.of( 1010 - context, 1011 - ).showSnackBar(const SnackBar(content: Text('Link copied to clipboard'), behavior: SnackBarBehavior.floating)); 973 + showAppSnackBar(context, 'Link copied to clipboard', behavior: SnackBarBehavior.floating); 1012 974 } 1013 975 1014 976 (String, String) _findRoot() {
+34 -59
lib/features/feed/presentation/widgets/post_action_bar.dart
··· 2 2 import 'package:flutter/services.dart'; 3 3 import 'package:lazurite/core/logging/app_logger.dart'; 4 4 import 'package:lazurite/features/connectivity/connectivity_helpers.dart'; 5 + import 'package:lazurite/shared/presentation/widgets/options_sheet.dart'; 5 6 import 'package:lazurite/shared/utils/format_utils.dart'; 6 7 import 'package:share_plus/share_plus.dart'; 7 8 ··· 124 125 125 126 void _showRepostOptions(BuildContext context) { 126 127 HapticFeedback.mediumImpact(); 127 - showModalBottomSheet<void>( 128 + showOptionsSheet<void>( 128 129 context: context, 129 - builder: (context) => SafeArea( 130 - child: Column( 131 - mainAxisSize: MainAxisSize.min, 132 - children: [ 133 - ListTile( 134 - leading: Icon(isReposted ? Icons.repeat : Icons.repeat, color: isReposted ? Colors.green : null), 135 - title: Text(isReposted ? 'Unrepost' : 'Repost'), 136 - subtitle: Text(isReposted ? 'Remove this repost' : 'Share this post'), 137 - onTap: () { 138 - Navigator.pop(context); 139 - onRepost?.call(); 140 - }, 141 - ), 142 - if (!isReposted) 143 - ListTile( 144 - leading: const Icon(Icons.format_quote), 145 - title: const Text('Quote Post'), 146 - subtitle: const Text('Quote this post with your own text'), 147 - onTap: () { 148 - Navigator.pop(context); 149 - onQuote?.call(); 150 - }, 151 - ), 152 - ], 130 + items: [ 131 + OptionsSheetItem( 132 + leading: Icon(Icons.repeat, color: isReposted ? Colors.green : null), 133 + title: isReposted ? 'Unrepost' : 'Repost', 134 + subtitle: isReposted ? 'Remove this repost' : 'Share this post', 135 + onTap: onRepost, 153 136 ), 154 - ), 137 + if (!isReposted) 138 + OptionsSheetItem( 139 + leading: const Icon(Icons.format_quote), 140 + title: 'Quote Post', 141 + subtitle: 'Quote this post with your own text', 142 + onTap: onQuote, 143 + ), 144 + ], 155 145 ); 156 146 } 157 147 ··· 159 149 HapticFeedback.mediumImpact(); 160 150 final isLocalSaved = isSaved && (saveType == 'local' || saveType == 'both'); 161 151 final isCloudSaved = saveType == 'cloud' || saveType == 'both'; 162 - showModalBottomSheet<void>( 152 + showOptionsSheet<void>( 163 153 context: context, 164 - builder: (context) => SafeArea( 165 - child: Column( 166 - mainAxisSize: MainAxisSize.min, 167 - children: [ 168 - ListTile( 169 - leading: Icon( 170 - isLocalSaved ? Icons.bookmark_remove_outlined : Icons.bookmark_add_outlined, 171 - color: Colors.amber, 172 - ), 173 - title: Text(isLocalSaved ? 'Remove local save' : 'Save locally'), 174 - onTap: () { 175 - Navigator.pop(context); 176 - onSave?.call(); 177 - }, 178 - ), 179 - ListTile( 180 - leading: Icon( 181 - isCloudSaved ? Icons.cloud_off_outlined : Icons.cloud_outlined, 182 - color: Theme.of(context).colorScheme.primary, 183 - ), 184 - title: Text(isCloudSaved ? 'Remove from Bluesky' : 'Save to Bluesky'), 185 - onTap: () { 186 - Navigator.pop(context); 187 - if (isCloudSaved) { 188 - onCloudUnsave?.call(); 189 - } else { 190 - onCloudSave?.call(); 191 - } 192 - }, 193 - ), 194 - ], 154 + items: [ 155 + OptionsSheetItem( 156 + leading: Icon( 157 + isLocalSaved ? Icons.bookmark_remove_outlined : Icons.bookmark_add_outlined, 158 + color: Colors.amber, 159 + ), 160 + title: isLocalSaved ? 'Remove local save' : 'Save locally', 161 + onTap: onSave, 162 + ), 163 + OptionsSheetItem( 164 + leading: Icon( 165 + isCloudSaved ? Icons.cloud_off_outlined : Icons.cloud_outlined, 166 + color: Theme.of(context).colorScheme.primary, 167 + ), 168 + title: isCloudSaved ? 'Remove from Bluesky' : 'Save to Bluesky', 169 + onTap: isCloudSaved ? onCloudUnsave : onCloudSave, 195 170 ), 196 - ), 171 + ], 197 172 ); 198 173 } 199 174
+19 -33
lib/features/feed/presentation/widgets/post_card_footer.dart
··· 1 1 import 'package:flutter/material.dart'; 2 2 import 'package:flutter/services.dart'; 3 3 import 'package:lazurite/features/connectivity/connectivity_helpers.dart'; 4 + import 'package:lazurite/shared/presentation/widgets/options_sheet.dart'; 4 5 import 'package:lazurite/shared/utils/format_utils.dart'; 5 6 6 7 /// Formats a post timestamp as a short, uppercase string. ··· 177 178 final isLocalSaved = isSaved && (saveType == 'local' || saveType == 'both'); 178 179 final isCloudSaved = saveType == 'cloud' || saveType == 'both'; 179 180 180 - showModalBottomSheet<void>( 181 + showOptionsSheet<void>( 181 182 context: context, 182 - builder: (context) => SafeArea( 183 - child: Column( 184 - mainAxisSize: MainAxisSize.min, 185 - children: [ 186 - ListTile( 187 - leading: Icon( 188 - isLocalSaved ? Icons.bookmark_remove_outlined : Icons.bookmark_add_outlined, 189 - color: Colors.amber, 190 - ), 191 - title: Text(isLocalSaved ? 'Remove local save' : 'Save locally'), 192 - onTap: () { 193 - Navigator.pop(context); 194 - onSave?.call(); 195 - }, 196 - ), 197 - ListTile( 198 - leading: Icon( 199 - isCloudSaved ? Icons.cloud_off_outlined : Icons.cloud_outlined, 200 - color: Theme.of(context).colorScheme.primary, 201 - ), 202 - title: Text(isCloudSaved ? 'Remove from Bluesky' : 'Save to Bluesky'), 203 - onTap: () { 204 - Navigator.pop(context); 205 - if (isCloudSaved) { 206 - onCloudUnsave?.call(); 207 - } else { 208 - onCloudSave?.call(); 209 - } 210 - }, 211 - ), 212 - ], 183 + items: [ 184 + OptionsSheetItem( 185 + leading: Icon( 186 + isLocalSaved ? Icons.bookmark_remove_outlined : Icons.bookmark_add_outlined, 187 + color: Colors.amber, 188 + ), 189 + title: isLocalSaved ? 'Remove local save' : 'Save locally', 190 + onTap: onSave, 191 + ), 192 + OptionsSheetItem( 193 + leading: Icon( 194 + isCloudSaved ? Icons.cloud_off_outlined : Icons.cloud_outlined, 195 + color: Theme.of(context).colorScheme.primary, 196 + ), 197 + title: isCloudSaved ? 'Remove from Bluesky' : 'Save to Bluesky', 198 + onTap: isCloudSaved ? onCloudUnsave : onCloudSave, 213 199 ), 214 - ), 200 + ], 215 201 ); 216 202 } 217 203 }
+16 -20
lib/features/feed/presentation/widgets/post_card_with_actions.dart
··· 16 16 import 'package:lazurite/features/feed/presentation/widgets/grid_post_card.dart'; 17 17 import 'package:lazurite/features/feed/presentation/widgets/post_card.dart'; 18 18 import 'package:lazurite/features/feed/presentation/widgets/post_card_footer.dart'; 19 + import 'package:lazurite/shared/presentation/helpers/snackbar_helper.dart'; 19 20 20 21 /// Controls which card layout variant is rendered by [PostCardWithActions]. 21 22 enum PostCardVariant { linear, grid } ··· 87 88 (previous.error != current.error && current.error != null) || (!previous.isDeleted && current.isDeleted), 88 89 listener: (context, state) { 89 90 if (state.isDeleted) { 90 - ScaffoldMessenger.of( 91 - context, 92 - ).showSnackBar(const SnackBar(content: Text('Post deleted'), behavior: SnackBarBehavior.floating)); 91 + showAppSnackBar(context, 'Post deleted', behavior: SnackBarBehavior.floating); 93 92 onDeleted?.call(); 94 93 return; 95 94 } 96 95 if (state.error != null) { 97 96 final cubit = context.read<PostActionCubit>(); 98 97 final error = state.error!; 99 - ScaffoldMessenger.of(context).showSnackBar( 100 - SnackBar( 101 - content: Text(error), 102 - behavior: SnackBarBehavior.floating, 103 - action: SnackBarAction( 104 - label: 'Retry', 105 - onPressed: () { 106 - if (error.contains('like')) { 107 - cubit.toggleLike(); 108 - } else if (error.contains('repost')) { 109 - cubit.toggleRepost(); 110 - } else if (error.contains('delete')) { 111 - cubit.deletePost(); 112 - } 113 - }, 114 - ), 115 - ), 98 + showAppSnackBar( 99 + context, 100 + error, 101 + behavior: SnackBarBehavior.floating, 102 + actionLabel: 'Retry', 103 + onAction: () { 104 + if (error.contains('like')) { 105 + cubit.toggleLike(); 106 + } else if (error.contains('repost')) { 107 + cubit.toggleRepost(); 108 + } else if (error.contains('delete')) { 109 + cubit.deletePost(); 110 + } 111 + }, 116 112 ); 117 113 cubit.clearError(); 118 114 }
+46 -71
lib/features/lists/presentation/list_detail_screen.dart
··· 11 11 import 'package:lazurite/features/lists/bloc/list_feed_bloc.dart'; 12 12 import 'package:lazurite/features/lists/data/list_repository.dart'; 13 13 import 'package:lazurite/features/lists/presentation/widgets/create_edit_list_dialog.dart'; 14 + import 'package:lazurite/shared/presentation/widgets/confirmation_dialog.dart'; 15 + import 'package:lazurite/shared/presentation/widgets/options_sheet.dart'; 14 16 15 17 class ListDetailScreen extends StatelessWidget { 16 18 const ListDetailScreen({super.key, required this.listUri}); ··· 93 95 } 94 96 95 97 Future<void> _confirmDelete(BuildContext context) async { 96 - final confirmed = await showDialog<bool>( 98 + final confirmed = await showConfirmationDialog( 97 99 context: context, 98 - builder: (dialogContext) => AlertDialog( 99 - title: const Text('Delete list?'), 100 - content: const Text('This action cannot be undone.'), 101 - actions: [ 102 - TextButton(onPressed: () => Navigator.pop(dialogContext, false), child: const Text('Cancel')), 103 - FilledButton( 104 - style: FilledButton.styleFrom( 105 - backgroundColor: Theme.of(dialogContext).colorScheme.error, 106 - foregroundColor: Theme.of(dialogContext).colorScheme.onError, 107 - ), 108 - onPressed: () => Navigator.pop(dialogContext, true), 109 - child: const Text('Delete'), 110 - ), 111 - ], 112 - ), 100 + title: const Text('Delete list?'), 101 + content: const Text('This action cannot be undone.'), 102 + confirmLabel: 'Delete', 103 + confirmDestructive: true, 113 104 ); 114 105 115 - if (confirmed == true && context.mounted) { 106 + if (confirmed && context.mounted) { 116 107 final userDid = context.read<AuthBloc>().state.tokens?.did; 117 108 if (userDid != null) { 118 109 context.read<ListBloc>().add(ListDeleted(userDid: userDid)); ··· 128 119 final isBlocked = list.viewer?.hasBlocked ?? false; 129 120 final isOwn = _isOwnList(context, list); 130 121 131 - showModalBottomSheet<void>( 122 + showOptionsSheet<void>( 132 123 context: context, 133 - builder: (sheetContext) => SafeArea( 134 - child: Column( 135 - mainAxisSize: MainAxisSize.min, 136 - children: [ 137 - if (isOwn) ...[ 138 - ListTile( 139 - leading: const Icon(Icons.edit_outlined), 140 - title: const Text('Edit list'), 141 - onTap: () { 142 - Navigator.pop(sheetContext); 143 - _showEditDialog(context, list); 144 - }, 145 - ), 146 - ListTile( 147 - leading: const Icon(Icons.person_add_outlined), 148 - title: const Text('Add members'), 149 - onTap: () async { 150 - final listUriStr = Uri.encodeComponent(list.uri.toString()); 151 - Navigator.pop(sheetContext); 152 - await context.push('/list/members?uri=$listUriStr'); 153 - if (context.mounted) { 154 - context.read<ListBloc>().add(const ListRefreshed()); 155 - } 156 - }, 157 - ), 158 - ListTile( 159 - leading: Icon(Icons.delete_outline, color: Theme.of(context).colorScheme.error), 160 - title: Text('Delete list', style: TextStyle(color: Theme.of(context).colorScheme.error)), 161 - onTap: () { 162 - Navigator.pop(sheetContext); 163 - _confirmDelete(context); 164 - }, 165 - ), 166 - ], 167 - ListTile( 168 - leading: Icon(isMuted ? Icons.volume_up_outlined : Icons.volume_off_outlined), 169 - title: Text(isMuted ? 'Unmute list' : 'Mute list'), 170 - onTap: () { 171 - Navigator.pop(sheetContext); 172 - context.read<ListBloc>().add(isMuted ? const ListUnmuted() : const ListMuted()); 173 - }, 174 - ), 175 - if (list.purpose.knownValue == bsky_graph.KnownListPurpose.appBskyGraphDefsModlist) 176 - ListTile( 177 - leading: Icon(isBlocked ? Icons.block_flipped : Icons.block_outlined), 178 - title: Text(isBlocked ? 'Unblock via list' : 'Block via list'), 179 - onTap: () { 180 - Navigator.pop(sheetContext); 181 - context.read<ListBloc>().add(isBlocked ? const ListUnblocked() : const ListBlocked()); 182 - }, 183 - ), 184 - ], 124 + items: [ 125 + if (isOwn) 126 + OptionsSheetItem( 127 + leading: const Icon(Icons.edit_outlined), 128 + title: 'Edit list', 129 + onTap: () => _showEditDialog(context, list), 130 + ), 131 + if (isOwn) 132 + OptionsSheetItem( 133 + leading: const Icon(Icons.person_add_outlined), 134 + title: 'Add members', 135 + onTap: () async { 136 + final listUriStr = Uri.encodeComponent(list.uri.toString()); 137 + await context.push('/list/members?uri=$listUriStr'); 138 + if (context.mounted) { 139 + context.read<ListBloc>().add(const ListRefreshed()); 140 + } 141 + }, 142 + ), 143 + if (isOwn) 144 + OptionsSheetItem( 145 + leading: Icon(Icons.delete_outline, color: Theme.of(context).colorScheme.error), 146 + title: 'Delete list', 147 + isDestructive: true, 148 + onTap: () => _confirmDelete(context), 149 + ), 150 + OptionsSheetItem( 151 + leading: Icon(isMuted ? Icons.volume_up_outlined : Icons.volume_off_outlined), 152 + title: isMuted ? 'Unmute list' : 'Mute list', 153 + onTap: () => context.read<ListBloc>().add(isMuted ? const ListUnmuted() : const ListMuted()), 185 154 ), 186 - ), 155 + if (list.purpose.knownValue == bsky_graph.KnownListPurpose.appBskyGraphDefsModlist) 156 + OptionsSheetItem( 157 + leading: Icon(isBlocked ? Icons.block_flipped : Icons.block_outlined), 158 + title: isBlocked ? 'Unblock via list' : 'Block via list', 159 + onTap: () => context.read<ListBloc>().add(isBlocked ? const ListUnblocked() : const ListBlocked()), 160 + ), 161 + ], 187 162 ); 188 163 } 189 164
+10 -11
lib/features/moderation/presentation/screens/moderation_settings_screen.dart
··· 6 6 import 'package:lazurite/features/moderation/data/moderation_service.dart'; 7 7 import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; 8 8 import 'package:lazurite/features/moderation/presentation/widgets/moderated_avatar.dart'; 9 + import 'package:lazurite/shared/presentation/helpers/snackbar_helper.dart'; 10 + import 'package:lazurite/shared/presentation/widgets/confirmation_dialog.dart'; 9 11 import 'package:lazurite/shared/utils/format_utils.dart'; 10 12 11 13 class ModerationSettingsScreen extends StatefulWidget { ··· 61 63 _reload(); 62 64 } catch (error) { 63 65 if (mounted) { 64 - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to update adult content: $error'))); 66 + showAppSnackBar(context, 'Failed to update adult content: $error', isError: true); 65 67 } 66 68 } finally { 67 69 if (mounted) { ··· 76 78 _reload(); 77 79 } catch (error) { 78 80 if (mounted) { 79 - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to unsubscribe: $error'))); 81 + showAppSnackBar(context, 'Failed to unsubscribe: $error', isError: true); 80 82 } 81 83 } 82 84 } ··· 119 121 120 122 if (context.mounted) { 121 123 final name = details.creator.displayName ?? details.creator.handle; 122 - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Subscribed to $name'))); 124 + showAppSnackBar(context, 'Subscribed to $name'); 123 125 } 124 126 } 125 127 } catch (error) { ··· 129 131 } 130 132 } 131 133 132 - return AlertDialog( 134 + return ConfirmationDialog( 133 135 title: const Text('Add labeler'), 134 136 content: SizedBox( 135 137 width: 420, ··· 155 157 ], 156 158 ), 157 159 ), 158 - actions: [ 159 - TextButton( 160 - onPressed: isSubmitting ? null : () => Navigator.of(dialogContext).pop(), 161 - child: const Text('Cancel'), 162 - ), 163 - FilledButton(onPressed: isSubmitting ? null : submit, child: Text(isSubmitting ? 'Adding...' : 'Add')), 164 - ], 160 + confirmLabel: isSubmitting ? 'Adding...' : 'Add', 161 + confirmEnabled: !isSubmitting, 162 + onCancel: isSubmitting ? null : () => Navigator.of(dialogContext).pop(), 163 + onConfirm: submit, 165 164 ); 166 165 }, 167 166 );
+51 -83
lib/features/profile/presentation/profile_screen.dart
··· 35 35 import 'package:lazurite/features/starter_packs/cubit/actor_starter_packs_cubit.dart'; 36 36 import 'package:lazurite/features/starter_packs/data/starter_pack_repository.dart'; 37 37 import 'package:lazurite/features/starter_packs/presentation/widgets/starter_pack_card.dart'; 38 + import 'package:lazurite/shared/presentation/helpers/snackbar_helper.dart'; 39 + import 'package:lazurite/shared/presentation/widgets/options_sheet.dart'; 38 40 import 'package:lazurite/shared/utils/format_utils.dart'; 39 41 import 'package:share_plus/share_plus.dart'; 40 42 import 'package:url_launcher/url_launcher.dart'; ··· 481 483 child: BlocConsumer<ProfileActionCubit, ProfileActionState>( 482 484 listener: (context, state) { 483 485 if (state.error != null) { 484 - ScaffoldMessenger.of( 485 - context, 486 - ).showSnackBar(SnackBar(content: Text(state.error!), behavior: SnackBarBehavior.floating)); 486 + showAppSnackBar(context, state.error!, behavior: SnackBarBehavior.floating); 487 487 context.read<ProfileActionCubit>().clearError(); 488 488 } 489 489 }, ··· 510 510 } 511 511 512 512 void _showOwnProfileMoreOptions(BuildContext context, ProfileViewDetailed profile) { 513 - showModalBottomSheet<void>( 513 + showOptionsSheet<void>( 514 514 context: context, 515 - builder: (sheetContext) => SafeArea( 516 - child: Column( 517 - mainAxisSize: MainAxisSize.min, 518 - children: [ 519 - ListTile( 520 - leading: const Icon(Icons.hub_outlined), 521 - title: const Text('Profile Context'), 522 - onTap: () { 523 - Navigator.pop(sheetContext); 524 - context.push( 525 - '/profile-context?did=${Uri.encodeComponent(profile.did)}&handle=${Uri.encodeComponent(profile.handle)}', 526 - ); 527 - }, 528 - ), 529 - ListTile( 530 - leading: const Icon(Icons.cleaning_services_outlined), 531 - title: const Text('Clean Follows'), 532 - onTap: () { 533 - Navigator.pop(sheetContext); 534 - context.push('/settings/clean-follows'); 535 - }, 536 - ), 537 - ], 515 + items: [ 516 + OptionsSheetItem( 517 + leading: const Icon(Icons.hub_outlined), 518 + title: 'Profile Context', 519 + onTap: () => context.push( 520 + '/profile-context?did=${Uri.encodeComponent(profile.did)}&handle=${Uri.encodeComponent(profile.handle)}', 521 + ), 538 522 ), 539 - ), 523 + OptionsSheetItem( 524 + leading: const Icon(Icons.cleaning_services_outlined), 525 + title: 'Clean Follows', 526 + onTap: () => context.push('/settings/clean-follows'), 527 + ), 528 + ], 540 529 ); 541 530 } 542 531 543 532 void _showProfileMoreOptions(BuildContext context, ProfileViewDetailed profile) { 544 - showModalBottomSheet<void>( 533 + showOptionsSheet<void>( 545 534 context: context, 546 - builder: (sheetContext) => SafeArea( 547 - child: Column( 548 - mainAxisSize: MainAxisSize.min, 549 - children: [ 550 - ListTile( 551 - leading: const Icon(Icons.copy), 552 - title: const Text('Copy DID'), 553 - onTap: () { 554 - Clipboard.setData(ClipboardData(text: profile.did)); 555 - Navigator.pop(sheetContext); 556 - ScaffoldMessenger.of(context).showSnackBar( 557 - const SnackBar(content: Text('DID copied to clipboard'), behavior: SnackBarBehavior.floating), 558 - ); 559 - }, 560 - ), 561 - ListTile( 562 - leading: const Icon(Icons.share_outlined), 563 - title: const Text('Share Profile'), 564 - onTap: () { 565 - Navigator.pop(sheetContext); 566 - final url = 'https://bsky.app/profile/${profile.handle}'; 567 - Share.share(url); 568 - }, 569 - ), 570 - ListTile( 571 - leading: const Icon(Icons.playlist_add_outlined), 572 - title: const Text('Add to list'), 573 - onTap: () { 574 - Navigator.pop(sheetContext); 575 - _showAddToList(context, profile); 576 - }, 577 - ), 578 - ListTile( 579 - leading: const Icon(Icons.people_outline), 580 - title: const Text('Suggested Follows'), 581 - onTap: () { 582 - Navigator.pop(sheetContext); 583 - _showSuggestedFollows(context, profile); 584 - }, 585 - ), 586 - ListTile( 587 - leading: const Icon(Icons.hub_outlined), 588 - title: const Text('Profile Context'), 589 - onTap: () { 590 - Navigator.pop(sheetContext); 591 - context.push( 592 - '/profile-context?did=${Uri.encodeComponent(profile.did)}&handle=${Uri.encodeComponent(profile.handle)}', 593 - ); 594 - }, 595 - ), 596 - ], 535 + items: [ 536 + OptionsSheetItem( 537 + leading: const Icon(Icons.copy), 538 + title: 'Copy DID', 539 + onTap: () { 540 + Clipboard.setData(ClipboardData(text: profile.did)); 541 + showAppSnackBar(context, 'DID copied to clipboard', behavior: SnackBarBehavior.floating); 542 + }, 597 543 ), 598 - ), 544 + OptionsSheetItem( 545 + leading: const Icon(Icons.share_outlined), 546 + title: 'Share Profile', 547 + onTap: () => Share.share('https://bsky.app/profile/${profile.handle}'), 548 + ), 549 + OptionsSheetItem( 550 + leading: const Icon(Icons.playlist_add_outlined), 551 + title: 'Add to list', 552 + onTap: () => _showAddToList(context, profile), 553 + ), 554 + OptionsSheetItem( 555 + leading: const Icon(Icons.people_outline), 556 + title: 'Suggested Follows', 557 + onTap: () => _showSuggestedFollows(context, profile), 558 + ), 559 + OptionsSheetItem( 560 + leading: const Icon(Icons.hub_outlined), 561 + title: 'Profile Context', 562 + onTap: () => context.push( 563 + '/profile-context?did=${Uri.encodeComponent(profile.did)}&handle=${Uri.encodeComponent(profile.handle)}', 564 + ), 565 + ), 566 + ], 599 567 ); 600 568 } 601 569 ··· 611 579 final cubit = AddToListCubit(listRepository: listRepository, currentUserDid: currentUserDid) 612 580 ..load(targetDid: profile.did); 613 581 614 - showModalBottomSheet<void>( 582 + showAppBottomSheet<void>( 615 583 context: context, 616 584 isScrollControlled: true, 617 585 builder: (sheetContext) => BlocProvider.value( ··· 688 656 689 657 final cubit = SuggestedFollowsCubit(repository: profileRepository)..load(profile.did); 690 658 691 - showModalBottomSheet<void>( 659 + showAppBottomSheet<void>( 692 660 context: context, 693 661 isScrollControlled: true, 694 662 builder: (sheetContext) => BlocProvider.value(
+45 -95
lib/features/profile/presentation/widgets/profile_action_buttons.dart
··· 1 + import 'dart:async'; 2 + 1 3 import 'package:flutter/material.dart'; 2 4 import 'package:flutter/services.dart'; 3 5 import 'package:lazurite/features/connectivity/connectivity_helpers.dart'; 6 + import 'package:lazurite/shared/presentation/widgets/confirmation_dialog.dart'; 4 7 5 8 class ProfileActionButtons extends StatelessWidget { 6 9 const ProfileActionButtons({ ··· 150 153 return button; 151 154 } 152 155 153 - void _confirmUnfollow(BuildContext context) { 154 - HapticFeedback.mediumImpact(); 155 - showDialog<void>( 156 + Future<void> _confirmUnfollow(BuildContext context) async { 157 + unawaited(HapticFeedback.mediumImpact()); 158 + await showConfirmationDialog( 156 159 context: context, 157 - builder: (context) => AlertDialog( 158 - title: const Text('Unfollow?'), 159 - content: const Text('You will no longer see their posts in your feed.'), 160 - actions: [ 161 - TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')), 162 - FilledButton( 163 - onPressed: () { 164 - Navigator.pop(context); 165 - onUnfollow?.call(); 166 - }, 167 - child: const Text('Unfollow'), 168 - ), 169 - ], 170 - ), 160 + title: const Text('Unfollow?'), 161 + content: const Text('You will no longer see their posts in your feed.'), 162 + confirmLabel: 'Unfollow', 163 + onConfirmed: onUnfollow, 171 164 ); 172 165 } 173 166 174 - void _confirmMute(BuildContext context) { 175 - HapticFeedback.mediumImpact(); 176 - showDialog<void>( 167 + Future<void> _confirmMute(BuildContext context) async { 168 + unawaited(HapticFeedback.mediumImpact()); 169 + await showConfirmationDialog( 177 170 context: context, 178 - builder: (context) => AlertDialog( 179 - title: const Text('Mute Account?'), 180 - content: const Text('You will no longer see their posts or receive notifications from them.'), 181 - actions: [ 182 - TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')), 183 - FilledButton( 184 - onPressed: () { 185 - Navigator.pop(context); 186 - onMute?.call(); 187 - }, 188 - child: const Text('Mute'), 189 - ), 190 - ], 191 - ), 171 + title: const Text('Mute Account?'), 172 + content: const Text('You will no longer see their posts or receive notifications from them.'), 173 + confirmLabel: 'Mute', 174 + onConfirmed: onMute, 192 175 ); 193 176 } 194 177 195 - void _confirmUnmute(BuildContext context) { 196 - HapticFeedback.mediumImpact(); 197 - showDialog<void>( 178 + Future<void> _confirmUnmute(BuildContext context) async { 179 + unawaited(HapticFeedback.mediumImpact()); 180 + await showConfirmationDialog( 198 181 context: context, 199 - builder: (context) => AlertDialog( 200 - title: const Text('Unmute Account?'), 201 - content: const Text('You will see their posts and receive notifications again.'), 202 - actions: [ 203 - TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')), 204 - FilledButton( 205 - onPressed: () { 206 - Navigator.pop(context); 207 - onUnmute?.call(); 208 - }, 209 - child: const Text('Unmute'), 210 - ), 211 - ], 212 - ), 182 + title: const Text('Unmute Account?'), 183 + content: const Text('You will see their posts and receive notifications again.'), 184 + confirmLabel: 'Unmute', 185 + onConfirmed: onUnmute, 213 186 ); 214 187 } 215 188 216 - void _confirmBlock(BuildContext context) { 217 - HapticFeedback.heavyImpact(); 218 - showDialog<void>( 189 + Future<void> _confirmBlock(BuildContext context) async { 190 + unawaited(HapticFeedback.heavyImpact()); 191 + await showConfirmationDialog( 219 192 context: context, 220 - builder: (context) => AlertDialog( 221 - title: Row( 222 - children: [ 223 - Icon(Icons.block, color: Theme.of(context).colorScheme.error), 224 - const SizedBox(width: 8), 225 - const Text('Block Account?'), 226 - ], 227 - ), 228 - content: const Text( 229 - 'They will not be able to see your posts or interact with you. They will not be notified that you blocked them.', 230 - ), 231 - actions: [ 232 - TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')), 233 - FilledButton( 234 - onPressed: () { 235 - Navigator.pop(context); 236 - onBlock?.call(); 237 - }, 238 - style: FilledButton.styleFrom( 239 - backgroundColor: Theme.of(context).colorScheme.error, 240 - foregroundColor: Theme.of(context).colorScheme.onError, 241 - ), 242 - child: const Text('Block'), 243 - ), 193 + title: Row( 194 + children: [ 195 + Icon(Icons.block, color: Theme.of(context).colorScheme.error), 196 + const SizedBox(width: 8), 197 + const Text('Block Account?'), 244 198 ], 245 199 ), 200 + content: const Text( 201 + 'They will not be able to see your posts or interact with you. They will not be notified that you blocked them.', 202 + ), 203 + confirmLabel: 'Block', 204 + confirmDestructive: true, 205 + onConfirmed: onBlock, 246 206 ); 247 207 } 248 208 249 - void _confirmUnblock(BuildContext context) { 250 - HapticFeedback.mediumImpact(); 251 - showDialog<void>( 209 + Future<void> _confirmUnblock(BuildContext context) async { 210 + unawaited(HapticFeedback.mediumImpact()); 211 + await showConfirmationDialog( 252 212 context: context, 253 - builder: (context) => AlertDialog( 254 - title: const Text('Unblock Account?'), 255 - content: const Text('They will be able to see your posts and interact with you again.'), 256 - actions: [ 257 - TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')), 258 - FilledButton( 259 - onPressed: () { 260 - Navigator.pop(context); 261 - onUnblock?.call(); 262 - }, 263 - child: const Text('Unblock'), 264 - ), 265 - ], 266 - ), 213 + title: const Text('Unblock Account?'), 214 + content: const Text('They will be able to see your posts and interact with you again.'), 215 + confirmLabel: 'Unblock', 216 + onConfirmed: onUnblock, 267 217 ); 268 218 } 269 219 }
+2 -1
lib/features/search/presentation/hashtag_screen.dart
··· 12 12 import 'package:lazurite/features/moderation/presentation/widgets/moderation_badge_row.dart'; 13 13 import 'package:lazurite/features/search/cubit/hashtag_cubit.dart'; 14 14 import 'package:lazurite/features/search/data/hashtag_utils.dart'; 15 + import 'package:lazurite/shared/presentation/widgets/options_sheet.dart'; 15 16 import 'package:lazurite/shared/utils/format_utils.dart'; 16 17 17 18 class HashtagScreen extends StatefulWidget { ··· 72 73 context.go('/hashtag?tag=${Uri.encodeQueryComponent(normalized)}'); 73 74 } 74 75 75 - showModalBottomSheet<void>( 76 + showAppBottomSheet<void>( 76 77 context: context, 77 78 isScrollControlled: true, 78 79 builder: (sheetContext) {
+21 -37
lib/features/search/presentation/search_screen.dart
··· 16 16 import 'package:lazurite/features/moderation/presentation/widgets/moderation_badge_row.dart'; 17 17 import 'package:lazurite/features/search/bloc/search_bloc.dart'; 18 18 import 'package:lazurite/features/starter_packs/presentation/widgets/starter_pack_card.dart'; 19 + import 'package:lazurite/shared/presentation/helpers/snackbar_helper.dart'; 20 + import 'package:lazurite/shared/presentation/widgets/confirmation_dialog.dart'; 19 21 import 'package:lazurite/shared/utils/format_utils.dart'; 20 22 21 23 class SearchScreen extends StatefulWidget { ··· 93 95 context.read<SearchBloc>().add(HistoryEntryDeleted(id: id)); 94 96 } 95 97 96 - void _onClearHistory() { 97 - showDialog( 98 + Future<void> _onClearHistory() async { 99 + await showConfirmationDialog( 98 100 context: context, 99 - builder: (context) => AlertDialog( 100 - title: const Text('Clear search history?'), 101 - content: const Text('This will delete all your recent searches.'), 102 - actions: [ 103 - TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')), 104 - TextButton( 105 - onPressed: () { 106 - Navigator.pop(context); 107 - context.read<SearchBloc>().add(const HistoryCleared()); 108 - }, 109 - child: const Text('Clear'), 110 - ), 111 - ], 112 - ), 101 + title: const Text('Clear search history?'), 102 + content: const Text('This will delete all your recent searches.'), 103 + confirmLabel: 'Clear', 104 + onConfirmed: () => context.read<SearchBloc>().add(const HistoryCleared()), 113 105 ); 114 106 } 115 107 ··· 140 132 }); 141 133 } 142 134 143 - return AlertDialog( 135 + return ConfirmationDialog( 144 136 title: const Text('Jump to profile'), 145 137 content: SizedBox( 146 138 width: 420, ··· 209 201 }, 210 202 ), 211 203 ), 212 - actions: [ 213 - TextButton( 214 - onPressed: () { 215 - searchBloc.add(const TypeaheadRequested(query: '')); 216 - Navigator.of(dialogContext).pop(); 217 - }, 218 - child: const Text('Cancel'), 219 - ), 220 - FilledButton( 221 - onPressed: controller.text.trim().isEmpty ? null : submitHandle, 222 - child: const Text('Open'), 223 - ), 224 - ], 204 + confirmLabel: 'Open', 205 + confirmEnabled: controller.text.trim().isNotEmpty, 206 + onCancel: () { 207 + searchBloc.add(const TypeaheadRequested(query: '')); 208 + Navigator.of(dialogContext).pop(); 209 + }, 210 + onConfirm: submitHandle, 225 211 ); 226 212 }, 227 213 ), ··· 571 557 return _FeedResultTile( 572 558 feed: feed, 573 559 onAdded: (displayName) { 574 - final messenger = ScaffoldMessenger.of(context); 575 - messenger.hideCurrentSnackBar(); 576 - messenger.showSnackBar( 577 - SnackBar( 578 - content: Text('Added $displayName to your saved feeds'), 579 - action: SnackBarAction(label: 'Manage', onPressed: () => GoRouter.maybeOf(context)?.push('/feeds')), 580 - ), 560 + showAppSnackBar( 561 + context, 562 + 'Added $displayName to your saved feeds', 563 + actionLabel: 'Manage', 564 + onAction: () => GoRouter.maybeOf(context)?.push('/feeds'), 581 565 ); 582 566 }, 583 567 );
+2 -1
lib/features/settings/presentation/settings_screen.dart
··· 17 17 import 'package:lazurite/features/search/cubit/semantic_search_cubit.dart'; 18 18 import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 19 19 import 'package:lazurite/features/settings/bloc/settings_state.dart'; 20 + import 'package:lazurite/shared/presentation/helpers/snackbar_helper.dart'; 20 21 21 22 class SettingsScreen extends StatelessWidget { 22 23 const SettingsScreen({super.key}); ··· 427 428 } 428 429 } catch (error) { 429 430 if (mounted) { 430 - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to update adult content: $error'))); 431 + showAppSnackBar(context, 'Failed to update adult content: $error', isError: true); 431 432 } 432 433 } finally { 433 434 if (mounted) {
+29
lib/shared/presentation/helpers/snackbar_helper.dart
··· 1 + import 'package:flutter/material.dart'; 2 + 3 + ScaffoldFeatureController<SnackBar, SnackBarClosedReason> showAppSnackBar( 4 + BuildContext context, 5 + String message, { 6 + bool hideCurrent = true, 7 + bool isError = false, 8 + SnackBarBehavior? behavior, 9 + Duration? duration, 10 + String? actionLabel, 11 + VoidCallback? onAction, 12 + }) { 13 + final messenger = ScaffoldMessenger.of(context); 14 + final colorScheme = Theme.of(context).colorScheme; 15 + 16 + if (hideCurrent) { 17 + messenger.hideCurrentSnackBar(); 18 + } 19 + 20 + return messenger.showSnackBar( 21 + SnackBar( 22 + content: Text(message), 23 + behavior: behavior, 24 + duration: duration ?? const Duration(seconds: 4), 25 + backgroundColor: isError ? colorScheme.error : null, 26 + action: actionLabel == null ? null : SnackBarAction(label: actionLabel, onPressed: onAction ?? () {}), 27 + ), 28 + ); 29 + }
+82
lib/shared/presentation/widgets/confirmation_dialog.dart
··· 1 + import 'dart:async'; 2 + 3 + import 'package:flutter/material.dart'; 4 + 5 + class ConfirmationDialog extends StatelessWidget { 6 + const ConfirmationDialog({ 7 + super.key, 8 + required this.title, 9 + required this.content, 10 + required this.confirmLabel, 11 + required this.onConfirm, 12 + this.cancelLabel = 'Cancel', 13 + this.onCancel, 14 + this.confirmDestructive = false, 15 + this.showCancel = true, 16 + this.confirmEnabled = true, 17 + }); 18 + 19 + final Widget title; 20 + final Widget content; 21 + final String confirmLabel; 22 + final VoidCallback onConfirm; 23 + final String cancelLabel; 24 + final VoidCallback? onCancel; 25 + final bool confirmDestructive; 26 + final bool showCancel; 27 + final bool confirmEnabled; 28 + 29 + @override 30 + Widget build(BuildContext context) { 31 + return AlertDialog( 32 + title: title, 33 + content: content, 34 + actions: [ 35 + if (showCancel) 36 + TextButton(onPressed: onCancel ?? () => Navigator.of(context).pop(false), child: Text(cancelLabel)), 37 + FilledButton( 38 + onPressed: confirmEnabled ? onConfirm : null, 39 + style: confirmDestructive 40 + ? FilledButton.styleFrom( 41 + backgroundColor: Theme.of(context).colorScheme.error, 42 + foregroundColor: Theme.of(context).colorScheme.onError, 43 + ) 44 + : null, 45 + child: Text(confirmLabel), 46 + ), 47 + ], 48 + ); 49 + } 50 + } 51 + 52 + Future<bool> showConfirmationDialog({ 53 + required BuildContext context, 54 + required Widget title, 55 + required Widget content, 56 + required String confirmLabel, 57 + String cancelLabel = 'Cancel', 58 + bool confirmDestructive = false, 59 + bool showCancel = true, 60 + bool barrierDismissible = true, 61 + FutureOr<void> Function()? onConfirmed, 62 + }) async { 63 + final confirmed = await showDialog<bool>( 64 + context: context, 65 + barrierDismissible: barrierDismissible, 66 + builder: (dialogContext) => ConfirmationDialog( 67 + title: title, 68 + content: content, 69 + confirmLabel: confirmLabel, 70 + cancelLabel: cancelLabel, 71 + confirmDestructive: confirmDestructive, 72 + showCancel: showCancel, 73 + onConfirm: () => Navigator.of(dialogContext).pop(true), 74 + onCancel: () => Navigator.of(dialogContext).pop(false), 75 + ), 76 + ); 77 + 78 + if (confirmed == true && onConfirmed != null) { 79 + await onConfirmed(); 80 + } 81 + return confirmed ?? false; 82 + }
+85
lib/shared/presentation/widgets/options_sheet.dart
··· 1 + import 'dart:async'; 2 + 3 + import 'package:flutter/material.dart'; 4 + 5 + class OptionsSheetItem { 6 + const OptionsSheetItem({ 7 + required this.title, 8 + this.subtitle, 9 + this.leading, 10 + this.trailing, 11 + this.isDestructive = false, 12 + this.enabled = true, 13 + this.dismissOnTap = true, 14 + this.onTap, 15 + }); 16 + 17 + final String title; 18 + final String? subtitle; 19 + final Widget? leading; 20 + final Widget? trailing; 21 + final bool isDestructive; 22 + final bool enabled; 23 + final bool dismissOnTap; 24 + final FutureOr<void> Function()? onTap; 25 + } 26 + 27 + class OptionsSheet extends StatelessWidget { 28 + const OptionsSheet({super.key, required this.items, this.header}); 29 + 30 + final Widget? header; 31 + final List<OptionsSheetItem> items; 32 + 33 + @override 34 + Widget build(BuildContext context) { 35 + final colorScheme = Theme.of(context).colorScheme; 36 + return SafeArea( 37 + child: Column( 38 + mainAxisSize: MainAxisSize.min, 39 + children: [ 40 + ?header, 41 + for (final item in items) 42 + ListTile( 43 + enabled: item.enabled, 44 + leading: item.leading, 45 + trailing: item.trailing, 46 + title: Text(item.title, style: item.isDestructive ? TextStyle(color: colorScheme.error) : null), 47 + subtitle: item.subtitle == null ? null : Text(item.subtitle!), 48 + onTap: item.onTap == null 49 + ? null 50 + : () { 51 + if (item.dismissOnTap) { 52 + Navigator.of(context).pop(); 53 + } 54 + final result = item.onTap!.call(); 55 + if (result is Future) { 56 + unawaited(result.then((_) {})); 57 + } 58 + }, 59 + ), 60 + ], 61 + ), 62 + ); 63 + } 64 + } 65 + 66 + Future<T?> showAppBottomSheet<T>({ 67 + required BuildContext context, 68 + required WidgetBuilder builder, 69 + bool isScrollControlled = false, 70 + }) { 71 + return showModalBottomSheet<T>(context: context, isScrollControlled: isScrollControlled, builder: builder); 72 + } 73 + 74 + Future<T?> showOptionsSheet<T>({ 75 + required BuildContext context, 76 + required List<OptionsSheetItem> items, 77 + Widget? header, 78 + bool isScrollControlled = false, 79 + }) { 80 + return showAppBottomSheet<T>( 81 + context: context, 82 + isScrollControlled: isScrollControlled, 83 + builder: (_) => OptionsSheet(items: items, header: header), 84 + ); 85 + }
+43
test/shared/presentation/helpers/snackbar_helper_test.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:flutter_test/flutter_test.dart'; 3 + import 'package:lazurite/shared/presentation/helpers/snackbar_helper.dart'; 4 + 5 + void main() { 6 + Widget buildSubject(void Function(BuildContext context) onPressed) => MaterialApp( 7 + home: Scaffold( 8 + body: Builder( 9 + builder: (context) => Center( 10 + child: FilledButton(onPressed: () => onPressed(context), child: const Text('show')), 11 + ), 12 + ), 13 + ), 14 + ); 15 + 16 + testWidgets('shows message', (tester) async { 17 + await tester.pumpWidget(buildSubject((context) => showAppSnackBar(context, 'Saved'))); 18 + 19 + await tester.tap(find.text('show')); 20 + await tester.pump(); 21 + 22 + expect(find.text('Saved'), findsOneWidget); 23 + }); 24 + 25 + testWidgets('shows action and invokes callback', (tester) async { 26 + var retried = false; 27 + 28 + await tester.pumpWidget( 29 + buildSubject( 30 + (context) => showAppSnackBar(context, 'Failed', actionLabel: 'Retry', onAction: () => retried = true), 31 + ), 32 + ); 33 + 34 + await tester.tap(find.text('show')); 35 + await tester.pump(); 36 + expect(find.text('Retry'), findsOneWidget); 37 + 38 + final action = tester.widget<SnackBarAction>(find.byType(SnackBarAction)); 39 + action.onPressed.call(); 40 + 41 + expect(retried, isTrue); 42 + }); 43 + }
+69
test/shared/presentation/widgets/confirmation_dialog_test.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:flutter_test/flutter_test.dart'; 3 + import 'package:lazurite/shared/presentation/widgets/confirmation_dialog.dart'; 4 + 5 + void main() { 6 + Widget buildSubject({required Future<void> Function(BuildContext context) onPressed}) { 7 + return MaterialApp( 8 + home: Scaffold( 9 + body: Builder( 10 + builder: (context) { 11 + return Center( 12 + child: FilledButton(onPressed: () => onPressed(context), child: const Text('open')), 13 + ); 14 + }, 15 + ), 16 + ), 17 + ); 18 + } 19 + 20 + testWidgets('returns true when confirmed', (tester) async { 21 + var confirmed = false; 22 + 23 + await tester.pumpWidget( 24 + buildSubject( 25 + onPressed: (context) async { 26 + confirmed = await showConfirmationDialog( 27 + context: context, 28 + title: const Text('Delete post?'), 29 + content: const Text('This cannot be undone.'), 30 + confirmLabel: 'Delete', 31 + ); 32 + }, 33 + ), 34 + ); 35 + 36 + await tester.tap(find.text('open')); 37 + await tester.pumpAndSettle(); 38 + expect(find.text('Delete post?'), findsOneWidget); 39 + 40 + await tester.tap(find.text('Delete')); 41 + await tester.pumpAndSettle(); 42 + 43 + expect(confirmed, isTrue); 44 + }); 45 + 46 + testWidgets('returns false when cancelled', (tester) async { 47 + var confirmed = true; 48 + 49 + await tester.pumpWidget( 50 + buildSubject( 51 + onPressed: (context) async { 52 + confirmed = await showConfirmationDialog( 53 + context: context, 54 + title: const Text('Discard changes?'), 55 + content: const Text('Unsaved changes will be lost.'), 56 + confirmLabel: 'Discard', 57 + ); 58 + }, 59 + ), 60 + ); 61 + 62 + await tester.tap(find.text('open')); 63 + await tester.pumpAndSettle(); 64 + await tester.tap(find.text('Cancel')); 65 + await tester.pumpAndSettle(); 66 + 67 + expect(confirmed, isFalse); 68 + }); 69 + }
+70
test/shared/presentation/widgets/options_sheet_test.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:flutter_test/flutter_test.dart'; 3 + import 'package:lazurite/shared/presentation/widgets/options_sheet.dart'; 4 + 5 + void main() { 6 + Widget buildSubject({ 7 + required void Function(BuildContext context) onOpenOptions, 8 + required void Function(BuildContext context) onOpenCustom, 9 + }) { 10 + return MaterialApp( 11 + home: Scaffold( 12 + body: Builder( 13 + builder: (context) { 14 + return Column( 15 + children: [ 16 + FilledButton(onPressed: () => onOpenOptions(context), child: const Text('open-options')), 17 + FilledButton(onPressed: () => onOpenCustom(context), child: const Text('open-custom')), 18 + ], 19 + ); 20 + }, 21 + ), 22 + ), 23 + ); 24 + } 25 + 26 + testWidgets('shows options sheet items and triggers callback', (tester) async { 27 + var tapped = false; 28 + 29 + await tester.pumpWidget( 30 + buildSubject( 31 + onOpenOptions: (context) { 32 + showOptionsSheet<void>( 33 + context: context, 34 + items: [OptionsSheetItem(title: 'Copy link', leading: const Icon(Icons.copy), onTap: () => tapped = true)], 35 + ); 36 + }, 37 + onOpenCustom: (_) {}, 38 + ), 39 + ); 40 + 41 + await tester.tap(find.text('open-options')); 42 + await tester.pumpAndSettle(); 43 + 44 + expect(find.text('Copy link'), findsOneWidget); 45 + await tester.tap(find.text('Copy link')); 46 + await tester.pumpAndSettle(); 47 + expect(tapped, isTrue); 48 + }); 49 + 50 + testWidgets('shows custom bottom sheet via helper', (tester) async { 51 + await tester.pumpWidget( 52 + buildSubject( 53 + onOpenOptions: (_) {}, 54 + onOpenCustom: (context) { 55 + showAppBottomSheet<void>( 56 + context: context, 57 + builder: (_) => const SafeArea( 58 + child: SizedBox(height: 80, child: Center(child: Text('custom'))), 59 + ), 60 + ); 61 + }, 62 + ), 63 + ); 64 + 65 + await tester.tap(find.text('open-custom')); 66 + await tester.pumpAndSettle(); 67 + 68 + expect(find.text('custom'), findsOneWidget); 69 + }); 70 + }