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
|
||||
|
||||
- **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)
|
||||
|
||||
Fixed a bug where undoing an email deletion or move would fail for IMAP accounts
|
||||
|
||||
@@ -40,6 +40,48 @@ class Email {
|
||||
this.inReplyTo,
|
||||
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.
|
||||
|
||||
@@ -27,7 +27,11 @@ abstract class EmailRepository {
|
||||
bool? flagged,
|
||||
});
|
||||
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);
|
||||
|
||||
/// 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;
|
||||
|
||||
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) {
|
||||
// Try to cancel the original change.
|
||||
// Deletes might have been implemented as moves to Trash, so try both.
|
||||
// 1. Try to cancel the original change (if not started yet).
|
||||
final cancelled = await repo.cancelPendingChange(id, 'delete') ||
|
||||
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 {
|
||||
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);
|
||||
|
||||
if (cancelled) {
|
||||
// If we cancelled the original change, and then moved it back,
|
||||
// we've just enqueued a NEW 'move' change that is redundant.
|
||||
// 4. If we successfully cancelled the original, the reverse move
|
||||
// we just enqueued is redundant.
|
||||
await repo.cancelPendingChange(id, 'move');
|
||||
}
|
||||
} catch (e) {
|
||||
// If it still fails, nothing more we can do locally.
|
||||
// Best effort.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1478,17 +1478,25 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
'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(
|
||||
row.accountId,
|
||||
row.mailboxPath,
|
||||
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
|
||||
Future<void> deleteEmail(String emailId) async {
|
||||
Future<String?> deleteEmail(String emailId) async {
|
||||
final row = await (_db.select(_db.emails)
|
||||
..where((t) => t.id.equals(emailId)))
|
||||
.getSingle();
|
||||
@@ -1503,7 +1511,8 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
.getSingleOrNull();
|
||||
|
||||
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.
|
||||
@@ -1520,7 +1529,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
row.mailboxPath,
|
||||
row.threadId ?? emailId,
|
||||
);
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
await _enqueueChange(
|
||||
@@ -1535,6 +1544,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
row.mailboxPath,
|
||||
row.threadId ?? emailId,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── 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/sync_log_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/thread_detail_screen.dart';
|
||||
import 'package:sharedinbox/ui/widgets/undo_shell.dart';
|
||||
|
||||
final router = GoRouter(
|
||||
initialLocation: '/accounts',
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/accounts',
|
||||
builder: (ctx, state) => const AccountListScreen(),
|
||||
ShellRoute(
|
||||
builder: (ctx, state, child) => UndoShell(child: child),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: 'add',
|
||||
builder: (ctx, state) => const AddAccountScreen(),
|
||||
),
|
||||
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']!),
|
||||
path: '/accounts',
|
||||
builder: (ctx, state) => const AccountListScreen(),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: ':mailboxPath/emails',
|
||||
builder: (ctx, state) => EmailListScreen(
|
||||
path: 'add',
|
||||
builder: (ctx, state) => const AddAccountScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: ':accountId/edit',
|
||||
builder: (ctx, state) => EditAccountScreen(
|
||||
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: [
|
||||
GoRoute(
|
||||
path: ':emailId',
|
||||
builder: (ctx, state) => EmailDetailScreen(
|
||||
emailId: state.pathParameters['emailId']!,
|
||||
path: ':mailboxPath/emails',
|
||||
builder: (ctx, state) => EmailListScreen(
|
||||
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;
|
||||
await repo.deleteEmail(widget.emailId);
|
||||
final destPath = await repo.deleteEmail(widget.emailId);
|
||||
|
||||
if (header != null) {
|
||||
ref.read(undoServiceProvider.notifier).pushAction(
|
||||
@@ -143,6 +143,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
type: UndoType.delete,
|
||||
emailIds: [widget.emailId],
|
||||
sourceMailboxPath: header.mailboxPath,
|
||||
destinationMailboxPath: destPath,
|
||||
originalEmails: [header],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -118,34 +118,11 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
final repo = ref.watch(emailRepositoryProvider);
|
||||
final accountAsync = ref.watch(accountByIdProvider(widget.accountId));
|
||||
|
||||
ref.listen<UndoAction?>(undoServiceProvider, (previous, next) {
|
||||
if (next != null && previous?.id != next.id) {
|
||||
_showUndoSnackbar(next);
|
||||
}
|
||||
});
|
||||
|
||||
return Scaffold(
|
||||
appBar: _selecting ? _selectionBar() : _normalBar(repo, accountAsync),
|
||||
drawer: _selecting
|
||||
@@ -389,8 +366,9 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
.whereType<Email>()
|
||||
.toList();
|
||||
|
||||
String? lastDestPath;
|
||||
for (final id in ids) {
|
||||
await repo.deleteEmail(id);
|
||||
lastDestPath = await repo.deleteEmail(id);
|
||||
}
|
||||
|
||||
final action = UndoAction(
|
||||
@@ -399,6 +377,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
type: UndoType.delete,
|
||||
emailIds: ids,
|
||||
sourceMailboxPath: widget.mailboxPath,
|
||||
destinationMailboxPath: lastDestPath,
|
||||
originalEmails: originalEmails,
|
||||
);
|
||||
ref.read(undoServiceProvider.notifier).pushAction(action);
|
||||
@@ -626,8 +605,9 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
);
|
||||
ref.read(undoServiceProvider.notifier).pushAction(action);
|
||||
} else {
|
||||
String? lastDestPath;
|
||||
for (final id in t.emailIds) {
|
||||
await repo.deleteEmail(id);
|
||||
lastDestPath = await repo.deleteEmail(id);
|
||||
}
|
||||
|
||||
final action = UndoAction(
|
||||
@@ -636,6 +616,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
type: type,
|
||||
emailIds: t.emailIds,
|
||||
sourceMailboxPath: widget.mailboxPath,
|
||||
destinationMailboxPath: lastDestPath,
|
||||
originalEmails: originalEmails,
|
||||
);
|
||||
ref.read(undoServiceProvider.notifier).pushAction(action);
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.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/di.dart';
|
||||
|
||||
@@ -250,7 +251,25 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
|
||||
),
|
||||
);
|
||||
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 {}
|
||||
|
||||
@override
|
||||
Future<void> deleteEmail(String id) async {}
|
||||
Future<String?> deleteEmail(String id) async => null;
|
||||
|
||||
@override
|
||||
Stream<String> get onChangesQueued => const Stream.empty();
|
||||
|
||||
@@ -61,7 +61,7 @@ class FakeEmailRepository implements EmailRepository {
|
||||
Future<void> restoreEmails(List<Email> emails) async {}
|
||||
|
||||
@override
|
||||
Future<void> deleteEmail(String id) async {}
|
||||
Future<String?> deleteEmail(String id) async => null;
|
||||
@override
|
||||
Stream<String> get onChangesQueued => const Stream.empty();
|
||||
@override
|
||||
|
||||
@@ -341,14 +341,13 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
||||
) as _i4.Future<void>);
|
||||
|
||||
@override
|
||||
_i4.Future<void> deleteEmail(String? emailId) => (super.noSuchMethod(
|
||||
_i4.Future<String?> deleteEmail(String? emailId) => (super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#deleteEmail,
|
||||
[emailId],
|
||||
),
|
||||
returnValue: _i4.Future<void>.value(),
|
||||
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||
) as _i4.Future<void>);
|
||||
returnValue: _i4.Future<String?>.value(),
|
||||
) as _i4.Future<String?>);
|
||||
|
||||
@override
|
||||
_i4.Future<void> sendEmail(
|
||||
|
||||
@@ -513,7 +513,7 @@ void main() {
|
||||
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 {
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
@@ -532,7 +532,10 @@ void main() {
|
||||
final changes = await r.db.select(r.db.pendingChanges).get();
|
||||
expect(changes.first.changeType, 'move');
|
||||
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',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:convert';
|
||||
import 'package:drift/drift.dart' show Value;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
@@ -181,4 +182,45 @@ void main() {
|
||||
.get();
|
||||
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);
|
||||
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);
|
||||
});
|
||||
|
||||
|
||||
@@ -209,14 +209,13 @@ class MockEmailRepository extends _i1.Mock implements _i3.EmailRepository {
|
||||
) as _i4.Future<void>);
|
||||
|
||||
@override
|
||||
_i4.Future<void> deleteEmail(String? emailId) => (super.noSuchMethod(
|
||||
_i4.Future<String?> deleteEmail(String? emailId) => (super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#deleteEmail,
|
||||
[emailId],
|
||||
),
|
||||
returnValue: _i4.Future<void>.value(),
|
||||
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||
) as _i4.Future<void>);
|
||||
returnValue: _i4.Future<String?>.value(),
|
||||
) as _i4.Future<String?>);
|
||||
|
||||
@override
|
||||
_i4.Future<void> sendEmail(
|
||||
|
||||
@@ -213,7 +213,7 @@ class FakeEmailRepository implements EmailRepository {
|
||||
Future<void> restoreEmails(List<Email> emails) async {}
|
||||
|
||||
@override
|
||||
Future<void> deleteEmail(String emailId) async {}
|
||||
Future<String?> deleteEmail(String emailId) async => null;
|
||||
|
||||
@override
|
||||
Stream<String> get onChangesQueued => const Stream.empty();
|
||||
|
||||
Reference in New Issue
Block a user