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:
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -36,6 +36,7 @@ class UndoShell extends ConsumerWidget {
|
||||
),
|
||||
action: SnackBarAction(
|
||||
label: 'Undo',
|
||||
textColor: Colors.redAccent,
|
||||
onPressed: () => ref.read(undoServiceProvider.notifier).undo(),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user