fix: refine undo log and remove delete confirmations (Issue #12)

- Remove delete confirmation dialogs in list and detail screens.
- Style Undo SnackBar action button with red text color.
- Enhance Undo Log screen to show email subject, sender, and action metadata.
- Add Undo support for Snooze action.
- Fix restoreEmails to preserve snooze metadata.
- Add unit test for Undo snooze logic.
This commit is contained in:
Thomas SharedInbox
2026-05-11 07:33:22 +02:00
parent e80a7c7a0e
commit 568b63de55
8 changed files with 98 additions and 69 deletions
+3 -1
View File
@@ -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';
}
+2 -1
View File
@@ -53,7 +53,8 @@ class UndoService extends StateNotifier<List<UndoAction>> {
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
@@ -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);
+11 -21
View File
@@ -120,26 +120,6 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
icon: const Icon(Icons.delete),
tooltip: 'Delete',
onPressed: () async {
final confirmed = await showDialog<bool>(
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<EmailDetailScreen> {
);
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(
+17 -41
View File
@@ -342,25 +342,6 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
Future<void> _batchDelete() async {
final ids = _selectedEmailIds;
final count = ids.length;
final confirmed = await showDialog<bool>(
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<EmailListScreen> {
_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<Email>()
.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<EmailListScreen> {
icon: Icons.delete,
label: 'Delete',
),
confirmDismiss: (direction) async {
if (direction == DismissDirection.endToStart) {
return showDialog<bool>(
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
+25 -4
View File
@@ -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
+1
View File
@@ -36,6 +36,7 @@ class UndoShell extends ConsumerWidget {
),
action: SnackBarAction(
label: 'Undo',
textColor: Colors.redAccent,
onPressed: () => ref.read(undoServiceProvider.notifier).undo(),
),
),
+32
View File
@@ -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);
});
}