diff --git a/lib/core/models/undo_action.dart b/lib/core/models/undo_action.dart index bd41fa7..49ad2c5 100644 --- a/lib/core/models/undo_action.dart +++ b/lib/core/models/undo_action.dart @@ -1,6 +1,6 @@ import 'package:sharedinbox/core/models/email.dart'; -enum UndoType { move, delete } +enum UndoType { move, delete, snooze } class UndoAction { UndoAction({ @@ -58,6 +58,8 @@ class UndoAction { final s = count == 1 ? '' : 's'; if (type == UndoType.delete) { return 'Deleted $count email$s from $sourceMailboxPath'; + } else if (type == UndoType.snooze) { + return 'Snoozed $count email$s from $sourceMailboxPath'; } else { return 'Moved $count email$s from $sourceMailboxPath to $destinationMailboxPath'; } diff --git a/lib/core/services/undo_service.dart b/lib/core/services/undo_service.dart index 08bf98f..9684f9c 100644 --- a/lib/core/services/undo_service.dart +++ b/lib/core/services/undo_service.dart @@ -53,7 +53,8 @@ class UndoService extends StateNotifier> { for (final id in action.emailIds) { // 1. Try to cancel the original change (if not started yet). final cancelled = await repo.cancelPendingChange(id, 'delete') || - await repo.cancelPendingChange(id, 'move'); + await repo.cancelPendingChange(id, 'move') || + await repo.cancelPendingChange(id, 'snooze'); try { final original = action.originalEmails.isEmpty diff --git a/lib/data/repositories/email_repository_impl.dart b/lib/data/repositories/email_repository_impl.dart index 43df2d3..f83d9af 100644 --- a/lib/data/repositories/email_repository_impl.dart +++ b/lib/data/repositories/email_repository_impl.dart @@ -1529,7 +1529,11 @@ class EmailRepositoryImpl implements EmailRepository { ); // Optimistic: move the cached row locally instead of hard-deleting. await (_db.update(_db.emails)..where((t) => t.id.equals(emailId))).write( - EmailsCompanion(mailboxPath: Value(destMailboxPath)), + EmailsCompanion( + mailboxPath: Value(destMailboxPath), + snoozedUntil: const Value(null), + snoozedFromMailboxPath: const Value(null), + ), ); await _updateThread( row.accountId, @@ -1771,6 +1775,8 @@ class EmailRepositoryImpl implements EmailRepository { messageId: Value(e.messageId), inReplyTo: Value(e.inReplyTo), references: Value(e.references), + snoozedUntil: Value(e.snoozedUntil), + snoozedFromMailboxPath: Value(e.snoozedFromMailboxPath), ), ); await _updateThread(e.accountId, e.mailboxPath, e.threadId ?? e.id); diff --git a/lib/ui/screens/email_detail_screen.dart b/lib/ui/screens/email_detail_screen.dart index 3ea7f92..6af775d 100644 --- a/lib/ui/screens/email_detail_screen.dart +++ b/lib/ui/screens/email_detail_screen.dart @@ -120,26 +120,6 @@ class _EmailDetailScreenState extends ConsumerState { icon: const Icon(Icons.delete), tooltip: 'Delete', onPressed: () async { - final confirmed = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('Delete email'), - content: const Text( - 'Move this email to Trash?', - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () => Navigator.pop(ctx, true), - child: const Text('Delete'), - ), - ], - ), - ); - if (confirmed != true || !context.mounted) return; final destPath = await repo.deleteEmail(widget.emailId); if (header != null) { @@ -400,7 +380,17 @@ class _EmailDetailScreenState extends ConsumerState { ); if (until == null || !context.mounted) return; - await ref.read(emailRepositoryProvider).snoozeEmail(widget.emailId, until); + final repo = ref.read(emailRepositoryProvider); + final action = UndoAction( + id: DateTime.now().toIso8601String(), + accountId: header.accountId, + type: UndoType.snooze, + emailIds: [widget.emailId], + sourceMailboxPath: header.mailboxPath, + originalEmails: [header], + ); + ref.read(undoServiceProvider.notifier).pushAction(action); + await repo.snoozeEmail(widget.emailId, until); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( diff --git a/lib/ui/screens/email_list_screen.dart b/lib/ui/screens/email_list_screen.dart index 48f8376..fde0cfc 100644 --- a/lib/ui/screens/email_list_screen.dart +++ b/lib/ui/screens/email_list_screen.dart @@ -342,25 +342,6 @@ class _EmailListScreenState extends ConsumerState { Future _batchDelete() async { final ids = _selectedEmailIds; - final count = ids.length; - final confirmed = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('Delete emails'), - content: Text('Move $count email${count == 1 ? '' : 's'} to Trash?'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () => Navigator.pop(ctx, true), - child: const Text('Delete'), - ), - ], - ), - ); - if (confirmed != true) return; _clearSelection(); final repo = ref.read(emailRepositoryProvider); @@ -461,10 +442,27 @@ class _EmailListScreenState extends ConsumerState { _clearSelection(); final repo = ref.read(emailRepositoryProvider); + // Fetch full email data before snoozing so we can restore them if user clicks Undo. + final originalEmails = (await Future.wait( + ids.map((id) => repo.getEmail(id)), + )) + .whereType() + .toList(); + for (final id in ids) { await repo.snoozeEmail(id, until); } + final action = UndoAction( + id: DateTime.now().toIso8601String(), + accountId: widget.accountId, + type: UndoType.snooze, + emailIds: ids, + sourceMailboxPath: widget.mailboxPath, + originalEmails: originalEmails, + ); + ref.read(undoServiceProvider.notifier).pushAction(action); + if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( @@ -581,28 +579,6 @@ class _EmailListScreenState extends ConsumerState { icon: Icons.delete, label: 'Delete', ), - confirmDismiss: (direction) async { - if (direction == DismissDirection.endToStart) { - return showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('Delete email'), - content: const Text('Move this email to Trash?'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () => Navigator.pop(ctx, true), - child: const Text('Delete'), - ), - ], - ), - ); - } - return true; - }, onDismissed: (direction) async { final repo = ref.read(emailRepositoryProvider); final type = direction == DismissDirection.startToEnd diff --git a/lib/ui/screens/undo_log_screen.dart b/lib/ui/screens/undo_log_screen.dart index 37a6df1..8ae4269 100644 --- a/lib/ui/screens/undo_log_screen.dart +++ b/lib/ui/screens/undo_log_screen.dart @@ -43,17 +43,38 @@ class _UndoActionTile extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final firstEmail = action.originalEmails.firstOrNull; + final subject = firstEmail?.subject ?? '(No Subject)'; + final sender = firstEmail != null && firstEmail.from.isNotEmpty + ? (firstEmail.from.first.name ?? firstEmail.from.first.email) + : '(Unknown Sender)'; + final count = action.emailIds.length; + final extraCount = count > 1 ? ' (+${count - 1} more)' : ''; + return ListTile( leading: Icon( action.type == UndoType.delete ? Icons.delete_outline - : Icons.move_to_inbox, + : (action.type == UndoType.snooze + ? Icons.access_time + : Icons.move_to_inbox), color: action.type == UndoType.delete ? Colors.redAccent - : Colors.blueAccent, + : (action.type == UndoType.snooze + ? Colors.orangeAccent + : Colors.blueAccent), + ), + title: Text('$subject$extraCount'), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(sender, maxLines: 1, overflow: TextOverflow.ellipsis), + Text( + '${action.type.name.toUpperCase()} from ${action.sourceMailboxPath} • ${_timeFmt.format(action.timestamp.toLocal())}', + style: Theme.of(context).textTheme.bodySmall, + ), + ], ), - title: Text(action.description), - subtitle: Text(_timeFmt.format(action.timestamp.toLocal())), trailing: TextButton( onPressed: () async { await ref diff --git a/lib/ui/widgets/undo_shell.dart b/lib/ui/widgets/undo_shell.dart index 0c65b02..78f1ac3 100644 --- a/lib/ui/widgets/undo_shell.dart +++ b/lib/ui/widgets/undo_shell.dart @@ -36,6 +36,7 @@ class UndoShell extends ConsumerWidget { ), action: SnackBarAction( label: 'Undo', + textColor: Colors.redAccent, onPressed: () => ref.read(undoServiceProvider.notifier).undo(), ), ), diff --git a/test/unit/undo_logic_test.dart b/test/unit/undo_logic_test.dart index acf8587..ab62412 100644 --- a/test/unit/undo_logic_test.dart +++ b/test/unit/undo_logic_test.dart @@ -249,4 +249,36 @@ void main() { expect(payload['mailboxPath'], 'Trash'); expect(payload['dest'], 'INBOX'); }); + + test('Undo snooze clears snooze metadata and moves back', () async { + const emailId = 'acc1:101'; + final original = await repo.getEmail(emailId); + + // 1. Snooze + final until = DateTime(2026, 5, 10, 15); + await repo.snoozeEmail(emailId, until); + + // Verify it snoozed locally + var email = await repo.getEmail(emailId); + expect(email!.mailboxPath, 'Snoozed'); + expect(email.snoozedUntil, until); + + // 2. Undo + final action = UndoAction( + id: 'undo4', + accountId: 'acc1', + type: UndoType.snooze, + emailIds: [emailId], + sourceMailboxPath: 'INBOX', + originalEmails: [original!], + ); + container.read(undoServiceProvider.notifier).pushAction(action); + await container.read(undoServiceProvider.notifier).undo(); + + // 3. Verify it is back in Inbox and metadata is cleared + email = await repo.getEmail(emailId); + expect(email!.mailboxPath, 'INBOX'); + expect(email.snoozedUntil, isNull); + expect(email.snoozedFromMailboxPath, isNull); + }); }