fix: implement global undo UI and optimistic IMAP moves for better UX
This commit is contained in:
@@ -6,6 +6,15 @@ Tasks get moved from next.md to done.md
|
|||||||
|
|
||||||
## Tasks
|
## Tasks
|
||||||
|
|
||||||
|
- **Optimistic UI**: Both IMAP and JMAP `moveEmail` operations are now optimistic,
|
||||||
|
updating the local database immediately instead of waiting for sync. This
|
||||||
|
provides instant feedback and ensures rows are available for Undo actions.
|
||||||
|
- **Global Undo Support**: Introduced `UndoShell` and `ShellRoute` to provide a
|
||||||
|
consistent "Undo" experience across all screens, automatically surfacing the
|
||||||
|
Undo SnackBar whenever a destructive action is performed.
|
||||||
|
- **Improved Thread Support**: Fixed a bug where deleting emails from the
|
||||||
|
`ThreadDetailScreen` lacked Undo logic.
|
||||||
|
|
||||||
## Undo Feature Fix (IMAP)
|
## Undo Feature Fix (IMAP)
|
||||||
|
|
||||||
Fixed a bug where undoing an email deletion or move would fail for IMAP accounts
|
Fixed a bug where undoing an email deletion or move would fail for IMAP accounts
|
||||||
|
|||||||
@@ -40,6 +40,48 @@ class Email {
|
|||||||
this.inReplyTo,
|
this.inReplyTo,
|
||||||
this.references,
|
this.references,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Email copyWith({
|
||||||
|
String? id,
|
||||||
|
String? accountId,
|
||||||
|
String? mailboxPath,
|
||||||
|
int? uid,
|
||||||
|
String? subject,
|
||||||
|
DateTime? sentAt,
|
||||||
|
DateTime? receivedAt,
|
||||||
|
List<EmailAddress>? from,
|
||||||
|
List<EmailAddress>? to,
|
||||||
|
List<EmailAddress>? cc,
|
||||||
|
String? preview,
|
||||||
|
bool? isSeen,
|
||||||
|
bool? isFlagged,
|
||||||
|
bool? hasAttachment,
|
||||||
|
String? threadId,
|
||||||
|
String? messageId,
|
||||||
|
String? inReplyTo,
|
||||||
|
String? references,
|
||||||
|
}) {
|
||||||
|
return Email(
|
||||||
|
id: id ?? this.id,
|
||||||
|
accountId: accountId ?? this.accountId,
|
||||||
|
mailboxPath: mailboxPath ?? this.mailboxPath,
|
||||||
|
uid: uid ?? this.uid,
|
||||||
|
subject: subject ?? this.subject,
|
||||||
|
sentAt: sentAt ?? this.sentAt,
|
||||||
|
receivedAt: receivedAt ?? this.receivedAt,
|
||||||
|
from: from ?? this.from,
|
||||||
|
to: to ?? this.to,
|
||||||
|
cc: cc ?? this.cc,
|
||||||
|
preview: preview ?? this.preview,
|
||||||
|
isSeen: isSeen ?? this.isSeen,
|
||||||
|
isFlagged: isFlagged ?? this.isFlagged,
|
||||||
|
hasAttachment: hasAttachment ?? this.hasAttachment,
|
||||||
|
threadId: threadId ?? this.threadId,
|
||||||
|
messageId: messageId ?? this.messageId,
|
||||||
|
inReplyTo: inReplyTo ?? this.inReplyTo,
|
||||||
|
references: references ?? this.references,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A group of related emails sharing the same thread.
|
/// A group of related emails sharing the same thread.
|
||||||
|
|||||||
@@ -27,7 +27,11 @@ abstract class EmailRepository {
|
|||||||
bool? flagged,
|
bool? flagged,
|
||||||
});
|
});
|
||||||
Future<void> moveEmail(String emailId, String destMailboxPath);
|
Future<void> moveEmail(String emailId, String destMailboxPath);
|
||||||
Future<void> deleteEmail(String emailId);
|
|
||||||
|
/// Deletes the email. Returns the path of the mailbox it was moved to
|
||||||
|
/// (e.g. Trash) if it was a soft-delete, or null if it was hard-deleted.
|
||||||
|
Future<String?> deleteEmail(String emailId);
|
||||||
|
|
||||||
Future<void> sendEmail(String accountId, EmailDraft draft);
|
Future<void> sendEmail(String accountId, EmailDraft draft);
|
||||||
|
|
||||||
/// Downloads [attachment] bytes from the server (or local cache) and returns
|
/// Downloads [attachment] bytes from the server (or local cache) and returns
|
||||||
|
|||||||
@@ -31,30 +31,39 @@ class UndoService extends StateNotifier<UndoAction?> {
|
|||||||
state = _history.isNotEmpty ? _history.last : null;
|
state = _history.isNotEmpty ? _history.last : null;
|
||||||
|
|
||||||
final repo = _ref.read(emailRepositoryProvider);
|
final repo = _ref.read(emailRepositoryProvider);
|
||||||
|
|
||||||
// For IMAP, the rows were hard-deleted, so we must restore them first.
|
|
||||||
if (action.originalEmails.isNotEmpty) {
|
|
||||||
await repo.restoreEmails(action.originalEmails);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (final id in action.emailIds) {
|
for (final id in action.emailIds) {
|
||||||
// Try to cancel the original change.
|
// 1. Try to cancel the original change (if not started yet).
|
||||||
// Deletes might have been implemented as moves to Trash, so try both.
|
|
||||||
final cancelled = await repo.cancelPendingChange(id, 'delete') ||
|
final cancelled = await repo.cancelPendingChange(id, 'delete') ||
|
||||||
await repo.cancelPendingChange(id, 'move');
|
await repo.cancelPendingChange(id, 'move');
|
||||||
|
|
||||||
// Move the email back to its source to reverse local DB state and
|
|
||||||
// (if not cancelled) enqueue the reverse change on the server.
|
|
||||||
try {
|
try {
|
||||||
|
final original = action.originalEmails.isEmpty
|
||||||
|
? null
|
||||||
|
: action.originalEmails.where((e) => e.id == id).firstOrNull;
|
||||||
|
|
||||||
|
// 2. If row is missing (hard delete), restore it first.
|
||||||
|
// We restore it at its CURRENT state (where it is on the server,
|
||||||
|
// or where it was moving to).
|
||||||
|
if (original != null) {
|
||||||
|
final currentPath = cancelled
|
||||||
|
? action.sourceMailboxPath
|
||||||
|
: (action.destinationMailboxPath ?? action.sourceMailboxPath);
|
||||||
|
await repo.restoreEmails([original.copyWith(mailboxPath: currentPath)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Move it back to source.
|
||||||
|
// This updates local DB optimistically and (if not cancelled) enqueues
|
||||||
|
// a reverse move on the server.
|
||||||
await repo.moveEmail(id, action.sourceMailboxPath);
|
await repo.moveEmail(id, action.sourceMailboxPath);
|
||||||
|
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
// If we cancelled the original change, and then moved it back,
|
// 4. If we successfully cancelled the original, the reverse move
|
||||||
// we've just enqueued a NEW 'move' change that is redundant.
|
// we just enqueued is redundant.
|
||||||
await repo.cancelPendingChange(id, 'move');
|
await repo.cancelPendingChange(id, 'move');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// If it still fails, nothing more we can do locally.
|
// Best effort.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1478,17 +1478,25 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
'dest': destMailboxPath,
|
'dest': destMailboxPath,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await (_db.delete(_db.emails)..where((t) => t.id.equals(emailId))).go();
|
// 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)),
|
||||||
|
);
|
||||||
await _updateThread(
|
await _updateThread(
|
||||||
row.accountId,
|
row.accountId,
|
||||||
row.mailboxPath,
|
row.mailboxPath,
|
||||||
row.threadId ?? emailId,
|
row.threadId ?? emailId,
|
||||||
);
|
);
|
||||||
// Destination will be updated when synced (IMAP move is a delete + copy).
|
await _updateThread(
|
||||||
|
row.accountId,
|
||||||
|
destMailboxPath,
|
||||||
|
row.threadId ?? emailId,
|
||||||
|
);
|
||||||
|
// Destination UID will be updated when synced (IMAP move is a delete + copy).
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> deleteEmail(String emailId) async {
|
Future<String?> deleteEmail(String emailId) async {
|
||||||
final row = await (_db.select(_db.emails)
|
final row = await (_db.select(_db.emails)
|
||||||
..where((t) => t.id.equals(emailId)))
|
..where((t) => t.id.equals(emailId)))
|
||||||
.getSingle();
|
.getSingle();
|
||||||
@@ -1503,7 +1511,8 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
.getSingleOrNull();
|
.getSingleOrNull();
|
||||||
|
|
||||||
if (trashRow != null && trashRow.path != row.mailboxPath) {
|
if (trashRow != null && trashRow.path != row.mailboxPath) {
|
||||||
return moveEmail(emailId, trashRow.path);
|
await moveEmail(emailId, trashRow.path);
|
||||||
|
return trashRow.path;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Already in Trash or no Trash folder — hard delete.
|
// Already in Trash or no Trash folder — hard delete.
|
||||||
@@ -1520,7 +1529,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
row.mailboxPath,
|
row.mailboxPath,
|
||||||
row.threadId ?? emailId,
|
row.threadId ?? emailId,
|
||||||
);
|
);
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
await _enqueueChange(
|
await _enqueueChange(
|
||||||
@@ -1535,6 +1544,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
row.mailboxPath,
|
row.mailboxPath,
|
||||||
row.threadId ?? emailId,
|
row.threadId ?? emailId,
|
||||||
);
|
);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── pending_changes queue ──────────────────────────────────────────────────
|
// ── pending_changes queue ──────────────────────────────────────────────────
|
||||||
|
|||||||
+90
-84
@@ -15,109 +15,115 @@ import 'package:sharedinbox/ui/screens/sieve_script_edit_screen.dart';
|
|||||||
import 'package:sharedinbox/ui/screens/sieve_scripts_screen.dart';
|
import 'package:sharedinbox/ui/screens/sieve_scripts_screen.dart';
|
||||||
import 'package:sharedinbox/ui/screens/sync_log_screen.dart';
|
import 'package:sharedinbox/ui/screens/sync_log_screen.dart';
|
||||||
import 'package:sharedinbox/ui/screens/thread_detail_screen.dart';
|
import 'package:sharedinbox/ui/screens/thread_detail_screen.dart';
|
||||||
|
import 'package:sharedinbox/ui/widgets/undo_shell.dart';
|
||||||
|
|
||||||
final router = GoRouter(
|
final router = GoRouter(
|
||||||
initialLocation: '/accounts',
|
initialLocation: '/accounts',
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
ShellRoute(
|
||||||
path: '/accounts',
|
builder: (ctx, state, child) => UndoShell(child: child),
|
||||||
builder: (ctx, state) => const AccountListScreen(),
|
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'add',
|
path: '/accounts',
|
||||||
builder: (ctx, state) => const AddAccountScreen(),
|
builder: (ctx, state) => const AccountListScreen(),
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: ':accountId/edit',
|
|
||||||
builder: (ctx, state) => EditAccountScreen(
|
|
||||||
accountId: state.pathParameters['accountId']!,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: ':accountId/sync-log',
|
|
||||||
builder: (ctx, state) => SyncLogScreen(
|
|
||||||
accountId: state.pathParameters['accountId']!,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: ':accountId/sieve',
|
|
||||||
builder: (ctx, state) => SieveScriptsScreen(
|
|
||||||
accountId: state.pathParameters['accountId']!,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: ':accountId/sieve/edit',
|
|
||||||
builder: (ctx, state) => SieveScriptEditScreen(
|
|
||||||
accountId: state.pathParameters['accountId']!,
|
|
||||||
script: state.extra as SieveScript?,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: ':accountId/search',
|
|
||||||
builder: (ctx, state) => SearchScreen(
|
|
||||||
accountId: state.pathParameters['accountId']!,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: ':accountId/emails/by-address/:address',
|
|
||||||
builder: (ctx, state) => AddressEmailsScreen(
|
|
||||||
accountId: state.pathParameters['accountId']!,
|
|
||||||
address: state.pathParameters['address']!,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: ':accountId/mailboxes',
|
|
||||||
builder: (ctx, state) =>
|
|
||||||
MailboxListScreen(accountId: state.pathParameters['accountId']!),
|
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: ':mailboxPath/emails',
|
path: 'add',
|
||||||
builder: (ctx, state) => EmailListScreen(
|
builder: (ctx, state) => const AddAccountScreen(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: ':accountId/edit',
|
||||||
|
builder: (ctx, state) => EditAccountScreen(
|
||||||
accountId: state.pathParameters['accountId']!,
|
accountId: state.pathParameters['accountId']!,
|
||||||
mailboxPath: state.pathParameters['mailboxPath']!,
|
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: ':accountId/sync-log',
|
||||||
|
builder: (ctx, state) => SyncLogScreen(
|
||||||
|
accountId: state.pathParameters['accountId']!,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: ':accountId/sieve',
|
||||||
|
builder: (ctx, state) => SieveScriptsScreen(
|
||||||
|
accountId: state.pathParameters['accountId']!,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: ':accountId/sieve/edit',
|
||||||
|
builder: (ctx, state) => SieveScriptEditScreen(
|
||||||
|
accountId: state.pathParameters['accountId']!,
|
||||||
|
script: state.extra as SieveScript?,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: ':accountId/search',
|
||||||
|
builder: (ctx, state) => SearchScreen(
|
||||||
|
accountId: state.pathParameters['accountId']!,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: ':accountId/emails/by-address/:address',
|
||||||
|
builder: (ctx, state) => AddressEmailsScreen(
|
||||||
|
accountId: state.pathParameters['accountId']!,
|
||||||
|
address: state.pathParameters['address']!,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: ':accountId/mailboxes',
|
||||||
|
builder: (ctx, state) => MailboxListScreen(
|
||||||
|
accountId: state.pathParameters['accountId']!,),
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: ':emailId',
|
path: ':mailboxPath/emails',
|
||||||
builder: (ctx, state) => EmailDetailScreen(
|
builder: (ctx, state) => EmailListScreen(
|
||||||
emailId: state.pathParameters['emailId']!,
|
accountId: state.pathParameters['accountId']!,
|
||||||
|
mailboxPath: state.pathParameters['mailboxPath']!,
|
||||||
|
),
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: ':emailId',
|
||||||
|
builder: (ctx, state) => EmailDetailScreen(
|
||||||
|
emailId: state.pathParameters['emailId']!,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: ':mailboxPath/threads/:threadId',
|
||||||
|
builder: (ctx, state) => ThreadDetailScreen(
|
||||||
|
accountId: state.pathParameters['accountId']!,
|
||||||
|
mailboxPath: Uri.decodeComponent(
|
||||||
|
state.pathParameters['mailboxPath']!,
|
||||||
|
),
|
||||||
|
threadId: Uri.decodeComponent(
|
||||||
|
state.pathParameters['threadId']!,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
GoRoute(
|
|
||||||
path: ':mailboxPath/threads/:threadId',
|
|
||||||
builder: (ctx, state) => ThreadDetailScreen(
|
|
||||||
accountId: state.pathParameters['accountId']!,
|
|
||||||
mailboxPath: Uri.decodeComponent(
|
|
||||||
state.pathParameters['mailboxPath']!,
|
|
||||||
),
|
|
||||||
threadId: Uri.decodeComponent(
|
|
||||||
state.pathParameters['threadId']!,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/search',
|
||||||
|
builder: (ctx, state) => const SearchScreen(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/compose',
|
||||||
|
builder: (ctx, state) {
|
||||||
|
final extra = state.extra as Map<String, dynamic>?;
|
||||||
|
return ComposeScreen(
|
||||||
|
accountId: extra?['accountId'] as String?,
|
||||||
|
replyToEmailId: extra?['replyToEmailId'] as String?,
|
||||||
|
prefillTo: extra?['prefillTo'] as String?,
|
||||||
|
prefillCc: extra?['prefillCc'] as String?,
|
||||||
|
prefillSubject: extra?['prefillSubject'] as String?,
|
||||||
|
prefillBody: extra?['prefillBody'] as String?,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
GoRoute(
|
|
||||||
path: '/search',
|
|
||||||
builder: (ctx, state) => const SearchScreen(),
|
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: '/compose',
|
|
||||||
builder: (ctx, state) {
|
|
||||||
final extra = state.extra as Map<String, dynamic>?;
|
|
||||||
return ComposeScreen(
|
|
||||||
accountId: extra?['accountId'] as String?,
|
|
||||||
replyToEmailId: extra?['replyToEmailId'] as String?,
|
|
||||||
prefillTo: extra?['prefillTo'] as String?,
|
|
||||||
prefillCc: extra?['prefillCc'] as String?,
|
|
||||||
prefillSubject: extra?['prefillSubject'] as String?,
|
|
||||||
prefillBody: extra?['prefillBody'] as String?,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (confirmed != true || !context.mounted) return;
|
if (confirmed != true || !context.mounted) return;
|
||||||
await repo.deleteEmail(widget.emailId);
|
final destPath = await repo.deleteEmail(widget.emailId);
|
||||||
|
|
||||||
if (header != null) {
|
if (header != null) {
|
||||||
ref.read(undoServiceProvider.notifier).pushAction(
|
ref.read(undoServiceProvider.notifier).pushAction(
|
||||||
@@ -143,6 +143,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
type: UndoType.delete,
|
type: UndoType.delete,
|
||||||
emailIds: [widget.emailId],
|
emailIds: [widget.emailId],
|
||||||
sourceMailboxPath: header.mailboxPath,
|
sourceMailboxPath: header.mailboxPath,
|
||||||
|
destinationMailboxPath: destPath,
|
||||||
originalEmails: [header],
|
originalEmails: [header],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -118,34 +118,11 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
if (value.trim().isNotEmpty) unawaited(_runSearch(value.trim()));
|
if (value.trim().isNotEmpty) unawaited(_runSearch(value.trim()));
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showUndoSnackbar(UndoAction action) {
|
|
||||||
ScaffoldMessenger.of(context).clearSnackBars();
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(
|
|
||||||
action.type == UndoType.delete
|
|
||||||
? '${action.emailIds.length} email(s) moved to Trash'
|
|
||||||
: '${action.emailIds.length} email(s) moved',
|
|
||||||
),
|
|
||||||
action: SnackBarAction(
|
|
||||||
label: 'Undo',
|
|
||||||
onPressed: () => ref.read(undoServiceProvider.notifier).undo(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final repo = ref.watch(emailRepositoryProvider);
|
final repo = ref.watch(emailRepositoryProvider);
|
||||||
final accountAsync = ref.watch(accountByIdProvider(widget.accountId));
|
final accountAsync = ref.watch(accountByIdProvider(widget.accountId));
|
||||||
|
|
||||||
ref.listen<UndoAction?>(undoServiceProvider, (previous, next) {
|
|
||||||
if (next != null && previous?.id != next.id) {
|
|
||||||
_showUndoSnackbar(next);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: _selecting ? _selectionBar() : _normalBar(repo, accountAsync),
|
appBar: _selecting ? _selectionBar() : _normalBar(repo, accountAsync),
|
||||||
drawer: _selecting
|
drawer: _selecting
|
||||||
@@ -389,8 +366,9 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
.whereType<Email>()
|
.whereType<Email>()
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
|
String? lastDestPath;
|
||||||
for (final id in ids) {
|
for (final id in ids) {
|
||||||
await repo.deleteEmail(id);
|
lastDestPath = await repo.deleteEmail(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
final action = UndoAction(
|
final action = UndoAction(
|
||||||
@@ -399,6 +377,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
type: UndoType.delete,
|
type: UndoType.delete,
|
||||||
emailIds: ids,
|
emailIds: ids,
|
||||||
sourceMailboxPath: widget.mailboxPath,
|
sourceMailboxPath: widget.mailboxPath,
|
||||||
|
destinationMailboxPath: lastDestPath,
|
||||||
originalEmails: originalEmails,
|
originalEmails: originalEmails,
|
||||||
);
|
);
|
||||||
ref.read(undoServiceProvider.notifier).pushAction(action);
|
ref.read(undoServiceProvider.notifier).pushAction(action);
|
||||||
@@ -626,8 +605,9 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
);
|
);
|
||||||
ref.read(undoServiceProvider.notifier).pushAction(action);
|
ref.read(undoServiceProvider.notifier).pushAction(action);
|
||||||
} else {
|
} else {
|
||||||
|
String? lastDestPath;
|
||||||
for (final id in t.emailIds) {
|
for (final id in t.emailIds) {
|
||||||
await repo.deleteEmail(id);
|
lastDestPath = await repo.deleteEmail(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
final action = UndoAction(
|
final action = UndoAction(
|
||||||
@@ -636,6 +616,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
type: type,
|
type: type,
|
||||||
emailIds: t.emailIds,
|
emailIds: t.emailIds,
|
||||||
sourceMailboxPath: widget.mailboxPath,
|
sourceMailboxPath: widget.mailboxPath,
|
||||||
|
destinationMailboxPath: lastDestPath,
|
||||||
originalEmails: originalEmails,
|
originalEmails: originalEmails,
|
||||||
);
|
);
|
||||||
ref.read(undoServiceProvider.notifier).pushAction(action);
|
ref.read(undoServiceProvider.notifier).pushAction(action);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'package:go_router/go_router.dart';
|
|||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
import 'package:sharedinbox/core/models/email.dart';
|
import 'package:sharedinbox/core/models/email.dart';
|
||||||
|
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||||
import 'package:sharedinbox/core/utils/html_utils.dart';
|
import 'package:sharedinbox/core/utils/html_utils.dart';
|
||||||
import 'package:sharedinbox/di.dart';
|
import 'package:sharedinbox/di.dart';
|
||||||
|
|
||||||
@@ -250,7 +251,25 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (confirmed == true) {
|
if (confirmed == true) {
|
||||||
unawaited(ref.read(emailRepositoryProvider).deleteEmail(widget.email.id));
|
final repo = ref.read(emailRepositoryProvider);
|
||||||
|
// Fetch data first for IMAP undo support
|
||||||
|
final original = await repo.getEmail(widget.email.id);
|
||||||
|
|
||||||
|
final destPath = await repo.deleteEmail(widget.email.id);
|
||||||
|
|
||||||
|
if (original != null) {
|
||||||
|
ref.read(undoServiceProvider.notifier).pushAction(
|
||||||
|
UndoAction(
|
||||||
|
id: DateTime.now().toIso8601String(),
|
||||||
|
accountId: widget.email.accountId,
|
||||||
|
type: UndoType.delete,
|
||||||
|
emailIds: [widget.email.id],
|
||||||
|
sourceMailboxPath: widget.email.mailboxPath,
|
||||||
|
destinationMailboxPath: destPath,
|
||||||
|
originalEmails: [original],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||||
|
import 'package:sharedinbox/di.dart';
|
||||||
|
|
||||||
|
class UndoShell extends ConsumerWidget {
|
||||||
|
const UndoShell({super.key, required this.child});
|
||||||
|
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
ref.listen<UndoAction?>(undoServiceProvider, (previous, next) {
|
||||||
|
if (next != null && previous?.id != next.id) {
|
||||||
|
_showUndoSnackbar(context, ref, next);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showUndoSnackbar(BuildContext context, WidgetRef ref, UndoAction action) {
|
||||||
|
final scaffoldMessenger = ScaffoldMessenger.of(context);
|
||||||
|
scaffoldMessenger.clearSnackBars();
|
||||||
|
scaffoldMessenger.showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
action.type == UndoType.delete
|
||||||
|
? '${action.emailIds.length} email(s) moved to Trash'
|
||||||
|
: '${action.emailIds.length} email(s) moved',
|
||||||
|
),
|
||||||
|
action: SnackBarAction(
|
||||||
|
label: 'Undo',
|
||||||
|
onPressed: () => ref.read(undoServiceProvider.notifier).undo(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -138,7 +138,7 @@ class _FakeEmails implements EmailRepository {
|
|||||||
Future<void> restoreEmails(List<Email> emails) async {}
|
Future<void> restoreEmails(List<Email> emails) async {}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> deleteEmail(String id) async {}
|
Future<String?> deleteEmail(String id) async => null;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Stream<String> get onChangesQueued => const Stream.empty();
|
Stream<String> get onChangesQueued => const Stream.empty();
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ class FakeEmailRepository implements EmailRepository {
|
|||||||
Future<void> restoreEmails(List<Email> emails) async {}
|
Future<void> restoreEmails(List<Email> emails) async {}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> deleteEmail(String id) async {}
|
Future<String?> deleteEmail(String id) async => null;
|
||||||
@override
|
@override
|
||||||
Stream<String> get onChangesQueued => const Stream.empty();
|
Stream<String> get onChangesQueued => const Stream.empty();
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -341,14 +341,13 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
|||||||
) as _i4.Future<void>);
|
) as _i4.Future<void>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i4.Future<void> deleteEmail(String? emailId) => (super.noSuchMethod(
|
_i4.Future<String?> deleteEmail(String? emailId) => (super.noSuchMethod(
|
||||||
Invocation.method(
|
Invocation.method(
|
||||||
#deleteEmail,
|
#deleteEmail,
|
||||||
[emailId],
|
[emailId],
|
||||||
),
|
),
|
||||||
returnValue: _i4.Future<void>.value(),
|
returnValue: _i4.Future<String?>.value(),
|
||||||
returnValueForMissingStub: _i4.Future<void>.value(),
|
) as _i4.Future<String?>);
|
||||||
) as _i4.Future<void>);
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i4.Future<void> sendEmail(
|
_i4.Future<void> sendEmail(
|
||||||
|
|||||||
@@ -513,7 +513,7 @@ void main() {
|
|||||||
expect(changes.first.payload, contains('"flagged":false'));
|
expect(changes.first.payload, contains('"flagged":false'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('moveEmail enqueues move change and removes email from local DB',
|
test('moveEmail enqueues move change and updates local mailboxPath (optimistic)',
|
||||||
() async {
|
() async {
|
||||||
final r = _makeRepos();
|
final r = _makeRepos();
|
||||||
await r.accounts.addAccount(_account, 'pw');
|
await r.accounts.addAccount(_account, 'pw');
|
||||||
@@ -532,7 +532,10 @@ void main() {
|
|||||||
final changes = await r.db.select(r.db.pendingChanges).get();
|
final changes = await r.db.select(r.db.pendingChanges).get();
|
||||||
expect(changes.first.changeType, 'move');
|
expect(changes.first.changeType, 'move');
|
||||||
expect(changes.first.payload, contains('Archive'));
|
expect(changes.first.payload, contains('Archive'));
|
||||||
expect(await r.emails.getEmail('acc-1:5'), isNull);
|
|
||||||
|
final email = await r.emails.getEmail('acc-1:5');
|
||||||
|
expect(email, isNotNull);
|
||||||
|
expect(email!.mailboxPath, 'Archive');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('deleteEmail enqueues delete change and removes email from local DB',
|
test('deleteEmail enqueues delete change and removes email from local DB',
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:convert';
|
||||||
import 'package:drift/drift.dart' show Value;
|
import 'package:drift/drift.dart' show Value;
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
@@ -181,4 +182,45 @@ void main() {
|
|||||||
.get();
|
.get();
|
||||||
expect(restored, isNotEmpty, reason: 'JMAP email should be restored to Inbox after undo');
|
expect(restored, isNotEmpty, reason: 'JMAP email should be restored to Inbox after undo');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Undo deletion for IMAP enqueues reverse move if cancel fails', () async {
|
||||||
|
const emailId = 'acc1:101';
|
||||||
|
final original = await repo.getEmail(emailId);
|
||||||
|
|
||||||
|
// 1. Delete
|
||||||
|
final destPath = await repo.deleteEmail(emailId);
|
||||||
|
expect(destPath, 'Trash');
|
||||||
|
|
||||||
|
// 2. Mark the pending change as "attempted" so it cannot be cancelled
|
||||||
|
await (db.update(db.pendingChanges)..where((t) => t.resourceId.equals(emailId))).write(
|
||||||
|
const PendingChangesCompanion(attempts: Value(1)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Undo
|
||||||
|
final action = UndoAction(
|
||||||
|
id: 'undo3',
|
||||||
|
accountId: 'acc1',
|
||||||
|
type: UndoType.delete,
|
||||||
|
emailIds: [emailId],
|
||||||
|
sourceMailboxPath: 'INBOX',
|
||||||
|
destinationMailboxPath: destPath,
|
||||||
|
originalEmails: [original!],
|
||||||
|
);
|
||||||
|
container.read(undoServiceProvider.notifier).pushAction(action);
|
||||||
|
await container.read(undoServiceProvider.notifier).undo();
|
||||||
|
|
||||||
|
// 4. Verify local state
|
||||||
|
final restored = await (db.select(db.emails)
|
||||||
|
..where((t) => t.id.equals(emailId))
|
||||||
|
..where((t) => t.mailboxPath.equals('INBOX')))
|
||||||
|
.get();
|
||||||
|
expect(restored, isNotEmpty);
|
||||||
|
|
||||||
|
// 5. Verify a NEW pending change was enqueued (Trash -> INBOX)
|
||||||
|
final changes = await db.select(db.pendingChanges).get();
|
||||||
|
final reverseMove = changes.firstWhere((c) => c.changeType == 'move' && c.attempts == 0);
|
||||||
|
final payload = jsonDecode(reverseMove.payload) as Map<String, dynamic>;
|
||||||
|
expect(payload['mailboxPath'], 'Trash');
|
||||||
|
expect(payload['dest'], 'INBOX');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
@@ -143,7 +143,7 @@ void main() {
|
|||||||
container.read(undoServiceProvider.notifier).pushAction(action);
|
container.read(undoServiceProvider.notifier).pushAction(action);
|
||||||
await container.read(undoServiceProvider.notifier).undo();
|
await container.read(undoServiceProvider.notifier).undo();
|
||||||
|
|
||||||
verify(mockEmailRepo.restoreEmails([email])).called(1);
|
verify(mockEmailRepo.restoreEmails(any)).called(1);
|
||||||
verify(mockEmailRepo.moveEmail('e1', 'INBOX')).called(1);
|
verify(mockEmailRepo.moveEmail('e1', 'INBOX')).called(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -209,14 +209,13 @@ class MockEmailRepository extends _i1.Mock implements _i3.EmailRepository {
|
|||||||
) as _i4.Future<void>);
|
) as _i4.Future<void>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i4.Future<void> deleteEmail(String? emailId) => (super.noSuchMethod(
|
_i4.Future<String?> deleteEmail(String? emailId) => (super.noSuchMethod(
|
||||||
Invocation.method(
|
Invocation.method(
|
||||||
#deleteEmail,
|
#deleteEmail,
|
||||||
[emailId],
|
[emailId],
|
||||||
),
|
),
|
||||||
returnValue: _i4.Future<void>.value(),
|
returnValue: _i4.Future<String?>.value(),
|
||||||
returnValueForMissingStub: _i4.Future<void>.value(),
|
) as _i4.Future<String?>);
|
||||||
) as _i4.Future<void>);
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i4.Future<void> sendEmail(
|
_i4.Future<void> sendEmail(
|
||||||
|
|||||||
@@ -213,7 +213,7 @@ class FakeEmailRepository implements EmailRepository {
|
|||||||
Future<void> restoreEmails(List<Email> emails) async {}
|
Future<void> restoreEmails(List<Email> emails) async {}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> deleteEmail(String emailId) async {}
|
Future<String?> deleteEmail(String emailId) async => null;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Stream<String> get onChangesQueued => const Stream.empty();
|
Stream<String> get onChangesQueued => const Stream.empty();
|
||||||
|
|||||||
Reference in New Issue
Block a user