diff --git a/lib/ui/screens/email_action_helpers.dart b/lib/ui/screens/email_action_helpers.dart new file mode 100644 index 0000000..91288fa --- /dev/null +++ b/lib/ui/screens/email_action_helpers.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; + +import 'package:sharedinbox/core/models/mailbox.dart'; +import 'package:sharedinbox/core/repositories/mailbox_repository.dart'; + +enum _MissingFolderChoice { chooseExisting, createNew } + +/// Resolves a mailbox by role, prompting the user to choose or create one when +/// the role is not found. Returns the target [Mailbox], or null if cancelled. +Future resolveMailboxByRole( + BuildContext context, + MailboxRepository mailboxRepo, + String accountId, + String currentMailboxPath, + String role, { + required String dialogTitle, + required String createFolderName, +}) async { + Mailbox? mailbox = await mailboxRepo.findMailboxByRole(accountId, role); + if (!context.mounted) return null; + if (mailbox != null) return mailbox; + + final choice = await showDialog<_MissingFolderChoice>( + context: context, + builder: (ctx) => AlertDialog( + title: Text(dialogTitle), + actions: [ + TextButton( + onPressed: () => + Navigator.pop(ctx, _MissingFolderChoice.chooseExisting), + child: const Text('Choose existing folder'), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, _MissingFolderChoice.createNew), + child: Text('Create "$createFolderName"'), + ), + ], + ), + ); + if (!context.mounted || choice == null) return null; + + switch (choice) { + case _MissingFolderChoice.chooseExisting: + final mailboxes = await mailboxRepo.observeMailboxes(accountId).first; + if (!context.mounted) return null; + final chosen = await showModalBottomSheet( + context: context, + builder: (ctx) => ListView( + shrinkWrap: true, + children: [ + const ListTile( + title: Text( + 'Move to…', + style: TextStyle(fontWeight: FontWeight.bold), + ), + ), + for (final m + in mailboxes.where((m) => m.path != currentMailboxPath)) + ListTile( + leading: const Icon(Icons.folder_outlined), + title: Text(m.name), + onTap: () => Navigator.pop(ctx, m.path), + ), + ], + ), + ); + if (chosen == null || !context.mounted) return null; + mailbox = mailboxes.firstWhere((m) => m.path == chosen); + case _MissingFolderChoice.createNew: + mailbox = await mailboxRepo.createMailboxWithRole( + accountId, + createFolderName, + role, + ); + if (!context.mounted) return null; + } + + return mailbox; +} diff --git a/lib/ui/screens/email_detail_screen.dart b/lib/ui/screens/email_detail_screen.dart index 1184835..1baeb77 100644 --- a/lib/ui/screens/email_detail_screen.dart +++ b/lib/ui/screens/email_detail_screen.dart @@ -16,6 +16,7 @@ import 'package:sharedinbox/core/models/undo_action.dart'; import 'package:sharedinbox/core/utils/format_utils.dart'; import 'package:sharedinbox/core/utils/html_utils.dart'; import 'package:sharedinbox/di.dart'; +import 'package:sharedinbox/ui/screens/email_action_helpers.dart'; import 'package:sharedinbox/ui/widgets/secure_email_webview.dart'; import 'package:sharedinbox/ui/widgets/snooze_picker.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -85,42 +86,12 @@ class _EmailDetailScreenState extends ConsumerState { }, ), IconButton( - icon: const Icon(Icons.mark_email_unread_outlined), - tooltip: 'Mark as unread', - onPressed: () async { - await repo.setFlag(widget.emailId, seen: false); - if (context.mounted) context.pop(); - }, - ), - IconButton( - icon: Icon( - _isFlagged ? Icons.star : Icons.star_border, - color: _isFlagged ? Colors.amber : null, - ), - tooltip: _isFlagged ? 'Unflag' : 'Flag', - onPressed: () async { - final next = !_isFlagged; - await repo.setFlag(widget.emailId, flagged: next); - if (mounted) setState(() => _isFlagged = next); - }, - ), - IconButton( - icon: const Icon(Icons.drive_file_move_outline), - tooltip: 'Move to folder', - onPressed: header == null ? null : () => _moveTo(context, header), - ), - IconButton( - icon: const Icon(Icons.access_time), - tooltip: 'Snooze', - onPressed: header == null ? null : () => _snooze(context, header), - ), - IconButton( - icon: const Icon(Icons.report_outlined), - tooltip: 'Mark as spam', + icon: const Icon(Icons.archive), + tooltip: 'Archive', onPressed: header == null ? null : () { - unawaited(_markAsSpam(context, header)); + unawaited(_archive(context, header)); }, ), IconButton( @@ -148,8 +119,43 @@ class _EmailDetailScreenState extends ConsumerState { if (context.mounted) context.pop(); }, ), + IconButton( + icon: const Icon(Icons.report_outlined), + tooltip: 'Mark as spam', + onPressed: header == null + ? null + : () { + unawaited(_markAsSpam(context, header)); + }, + ), + IconButton( + icon: const Icon(Icons.drive_file_move_outline), + tooltip: 'Move to folder', + onPressed: header == null ? null : () => _moveTo(context, header), + ), + IconButton( + icon: const Icon(Icons.access_time), + tooltip: 'Snooze', + onPressed: header == null ? null : () => _snooze(context, header), + ), + IconButton( + icon: Icon( + _isFlagged ? Icons.star : Icons.star_border, + color: _isFlagged ? Colors.amber : null, + ), + tooltip: _isFlagged ? 'Unflag' : 'Flag', + onPressed: () async { + final next = !_isFlagged; + await repo.setFlag(widget.emailId, flagged: next); + if (mounted) setState(() => _isFlagged = next); + }, + ), PopupMenuButton( itemBuilder: (ctx) => [ + const PopupMenuItem( + value: 'mark_unread', + child: Text('Mark as unread'), + ), const PopupMenuItem( value: 'headers', child: Text('Show Mail Headers'), @@ -163,8 +169,11 @@ class _EmailDetailScreenState extends ConsumerState { child: Text('Show Raw Email'), ), ], - onSelected: (value) { - if (value == 'headers' && body != null) { + onSelected: (value) async { + if (value == 'mark_unread') { + await repo.setFlag(widget.emailId, seen: false); + if (context.mounted) context.pop(); + } else if (value == 'headers' && body != null) { _showHeaders(context, body); } else if (value == 'structure' && body != null) { _showStructure(context, body); @@ -393,21 +402,22 @@ class _EmailDetailScreenState extends ConsumerState { ); } - Future _markAsSpam(BuildContext context, Email header) async { - final mailboxRepo = ref.read(mailboxRepositoryProvider); - final junk = await mailboxRepo.findMailboxByRole(header.accountId, 'junk'); + Future _archive(BuildContext context, Email header) async { + final mailbox = await resolveMailboxByRole( + context, + ref.read(mailboxRepositoryProvider), + header.accountId, + header.mailboxPath, + 'archive', + dialogTitle: 'No archive folder found', + createFolderName: 'Archive', + ); - if (junk == null) { - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('No Junk folder found')), - ); - return; - } + if (mailbox == null || !context.mounted) return; await ref .read(emailRepositoryProvider) - .moveEmail(widget.emailId, junk.path); + .moveEmail(widget.emailId, mailbox.path); unawaited( ref.read(undoServiceProvider.notifier).pushAction( @@ -417,7 +427,40 @@ class _EmailDetailScreenState extends ConsumerState { type: UndoType.move, emailIds: [widget.emailId], sourceMailboxPath: header.mailboxPath, - destinationMailboxPath: junk.path, + destinationMailboxPath: mailbox.path, + ), + ), + ); + + if (context.mounted) context.pop(); + } + + Future _markAsSpam(BuildContext context, Email header) async { + final mailbox = await resolveMailboxByRole( + context, + ref.read(mailboxRepositoryProvider), + header.accountId, + header.mailboxPath, + 'junk', + dialogTitle: 'No spam folder found', + createFolderName: 'Junk', + ); + + if (mailbox == null || !context.mounted) return; + + await ref + .read(emailRepositoryProvider) + .moveEmail(widget.emailId, mailbox.path); + + unawaited( + ref.read(undoServiceProvider.notifier).pushAction( + UndoAction( + id: DateTime.now().toIso8601String(), + accountId: header.accountId, + type: UndoType.move, + emailIds: [widget.emailId], + sourceMailboxPath: header.mailboxPath, + destinationMailboxPath: mailbox.path, ), ), ); diff --git a/lib/ui/screens/email_list_screen.dart b/lib/ui/screens/email_list_screen.dart index 485e1a0..74bd989 100644 --- a/lib/ui/screens/email_list_screen.dart +++ b/lib/ui/screens/email_list_screen.dart @@ -7,10 +7,10 @@ import 'package:intl/intl.dart'; import 'package:sharedinbox/core/models/account.dart'; import 'package:sharedinbox/core/models/email.dart'; -import 'package:sharedinbox/core/models/mailbox.dart'; import 'package:sharedinbox/core/models/undo_action.dart'; import 'package:sharedinbox/core/repositories/email_repository.dart'; import 'package:sharedinbox/di.dart'; +import 'package:sharedinbox/ui/screens/email_action_helpers.dart'; import 'package:sharedinbox/ui/widgets/email_tile.dart'; import 'package:sharedinbox/ui/widgets/folder_drawer.dart'; import 'package:sharedinbox/ui/widgets/snooze_picker.dart'; @@ -25,8 +25,6 @@ int _dayKey(DateTime dt) => dt.year * 10000 + dt.month * 100 + dt.day; String _fmtDate(DateTime dt) => _formattedDates[_dayKey(dt)] ??= _dateFmt.format(dt); -enum _MissingFolderChoice { chooseExisting, createNew } - class EmailListScreen extends ConsumerStatefulWidget { const EmailListScreen({ super.key, @@ -431,70 +429,17 @@ class _EmailListScreenState extends ConsumerState { final ids = _selectedEmailIds; _clearSelection(); - final mailboxRepo = ref.read(mailboxRepositoryProvider); - Mailbox? mailbox = - await mailboxRepo.findMailboxByRole(widget.accountId, role); + final mailbox = await resolveMailboxByRole( + context, + ref.read(mailboxRepositoryProvider), + widget.accountId, + widget.mailboxPath, + role, + dialogTitle: dialogTitle, + createFolderName: createFolderName, + ); - if (!mounted) return; - - if (mailbox == null) { - final choice = await showDialog<_MissingFolderChoice>( - context: context, - builder: (ctx) => AlertDialog( - title: Text(dialogTitle), - actions: [ - TextButton( - onPressed: () => - Navigator.pop(ctx, _MissingFolderChoice.chooseExisting), - child: const Text('Choose existing folder'), - ), - FilledButton( - onPressed: () => - Navigator.pop(ctx, _MissingFolderChoice.createNew), - child: Text('Create "$createFolderName"'), - ), - ], - ), - ); - if (!mounted || choice == null) return; - - switch (choice) { - case _MissingFolderChoice.chooseExisting: - final mailboxes = - await mailboxRepo.observeMailboxes(widget.accountId).first; - if (!mounted) return; - final chosen = await showModalBottomSheet( - context: context, - builder: (ctx) => ListView( - shrinkWrap: true, - children: [ - const ListTile( - title: Text( - 'Move to…', - style: TextStyle(fontWeight: FontWeight.bold), - ), - ), - for (final m - in mailboxes.where((m) => m.path != widget.mailboxPath)) - ListTile( - leading: const Icon(Icons.folder_outlined), - title: Text(m.name), - onTap: () => Navigator.pop(ctx, m.path), - ), - ], - ), - ); - if (chosen == null || !mounted) return; - mailbox = mailboxes.firstWhere((m) => m.path == chosen); - case _MissingFolderChoice.createNew: - mailbox = await mailboxRepo.createMailboxWithRole( - widget.accountId, - createFolderName, - role, - ); - if (!mounted) return; - } - } + if (!mounted || mailbox == null) return; final repo = ref.read(emailRepositoryProvider); diff --git a/test/widget/email_detail_screen_test.dart b/test/widget/email_detail_screen_test.dart index d1368bb..92b63ad 100644 --- a/test/widget/email_detail_screen_test.dart +++ b/test/widget/email_detail_screen_test.dart @@ -290,11 +290,10 @@ void main() { ); }); - testWidgets( - 'Mark as spam moves email to junk and shows snackbar when no junk folder', + testWidgets('Mark as spam shows dialog when no junk folder', (tester) async { // FakeMailboxRepository has no mailboxes by default → findMailboxByRole - // returns null → snackbar shown. + // returns null → dialog shown. await tester.pumpWidget( buildApp( initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42', @@ -312,7 +311,76 @@ void main() { ); await tester.pumpAndSettle(); - expect(find.text('No Junk folder found'), findsOneWidget); + expect(find.text('No spam folder found'), findsOneWidget); + }); + + testWidgets('Archive button is present in app bar', (tester) async { + await tester.pumpWidget( + buildApp( + initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42', + overrides: _overrides( + body: const EmailBody(emailId: 'acc-1:42', attachments: []), + ), + ), + ); + await tester.pumpAndSettle(); + + expect( + find.byWidgetPredicate( + (w) => w is Tooltip && w.message == 'Archive', + ), + findsOneWidget, + ); + }); + + testWidgets('Archive shows dialog when no archive folder', (tester) async { + // FakeMailboxRepository has no mailboxes by default → findMailboxByRole + // returns null → dialog shown. + await tester.pumpWidget( + buildApp( + initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42', + overrides: _overrides( + body: const EmailBody(emailId: 'acc-1:42', attachments: []), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap( + find.byWidgetPredicate( + (w) => w is Tooltip && w.message == 'Archive', + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('No archive folder found'), findsOneWidget); + }); + + testWidgets('Mark as unread is in popup menu, not a standalone button', + (tester) async { + await tester.pumpWidget( + buildApp( + initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42', + overrides: _overrides( + body: const EmailBody(emailId: 'acc-1:42', attachments: []), + ), + ), + ); + await tester.pumpAndSettle(); + + // No standalone icon button for mark as unread. + expect( + find.byWidgetPredicate( + (w) => w is Tooltip && w.message == 'Mark as unread', + ), + findsNothing, + ); + + // It appears in the popup menu. + await tester.tap(find.byType(PopupMenuButton)); + await tester.pumpAndSettle(); + + expect(find.text('Mark as unread'), findsOneWidget); }); testWidgets('Show Raw Email dialog shows size of email', (tester) async {