Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0c1b0c2c0 | ||
|
|
eddcc17c41 |
@@ -99,4 +99,9 @@ abstract class EmailRepository {
|
|||||||
String accountId,
|
String accountId,
|
||||||
String mailboxPath,
|
String mailboxPath,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// Deletes all locally-cached email rows and pending changes for [accountId],
|
||||||
|
/// while preserving EmailBodies so already-downloaded content is not lost.
|
||||||
|
/// Also resets sync-state checkpoints so the next sync fetches everything fresh.
|
||||||
|
Future<void> clearForResync(String accountId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,4 +8,7 @@ abstract class MailboxRepository {
|
|||||||
|
|
||||||
/// Returns the first mailbox with the given [role] for [accountId], or null.
|
/// Returns the first mailbox with the given [role] for [accountId], or null.
|
||||||
Future<Mailbox?> findMailboxByRole(String accountId, String role);
|
Future<Mailbox?> findMailboxByRole(String accountId, String role);
|
||||||
|
|
||||||
|
/// Deletes all locally-cached mailbox rows for [accountId].
|
||||||
|
Future<void> clearForResync(String accountId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,12 +10,19 @@ class UndoService extends StateNotifier<List<UndoAction>> {
|
|||||||
final Ref _ref;
|
final Ref _ref;
|
||||||
static const int _maxHistory = 10;
|
static const int _maxHistory = 10;
|
||||||
|
|
||||||
|
// Resolves once init() has loaded persisted history. Default to an already-
|
||||||
|
// resolved future so operations are safe even if init() is never called.
|
||||||
|
Future<void> _ready = Future.value();
|
||||||
|
|
||||||
Future<void> init() async {
|
Future<void> init() async {
|
||||||
final repo = _ref.read(undoRepositoryProvider);
|
_ready = _ref.read(undoRepositoryProvider).getHistory().then((history) {
|
||||||
state = await repo.getHistory();
|
if (mounted) state = history;
|
||||||
|
});
|
||||||
|
await _ready;
|
||||||
}
|
}
|
||||||
|
|
||||||
void pushAction(UndoAction action) {
|
Future<void> pushAction(UndoAction action) async {
|
||||||
|
await _ready;
|
||||||
final newList = [...state, action];
|
final newList = [...state, action];
|
||||||
if (newList.length > _maxHistory) {
|
if (newList.length > _maxHistory) {
|
||||||
final removed = newList.removeAt(0);
|
final removed = newList.removeAt(0);
|
||||||
@@ -25,12 +32,14 @@ class UndoService extends StateNotifier<List<UndoAction>> {
|
|||||||
unawaited(_ref.read(undoRepositoryProvider).saveAction(action));
|
unawaited(_ref.read(undoRepositoryProvider).saveAction(action));
|
||||||
}
|
}
|
||||||
|
|
||||||
void clear() {
|
Future<void> clear() async {
|
||||||
|
await _ready;
|
||||||
state = [];
|
state = [];
|
||||||
unawaited(_ref.read(undoRepositoryProvider).clearHistory());
|
unawaited(_ref.read(undoRepositoryProvider).clearHistory());
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> undo({String? actionId}) async {
|
Future<void> undo({String? actionId}) async {
|
||||||
|
await _ready;
|
||||||
if (state.isEmpty) return;
|
if (state.isEmpty) return;
|
||||||
|
|
||||||
final UndoAction action;
|
final UndoAction action;
|
||||||
|
|||||||
@@ -88,6 +88,43 @@ class AccountSyncManager {
|
|||||||
void syncNow(String accountId) {
|
void syncNow(String accountId) {
|
||||||
_active[accountId]?.kick();
|
_active[accountId]?.kick();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Clears all locally-cached emails and mailboxes for [accountId], then
|
||||||
|
/// immediately starts a fresh sync cycle. Use this as an escape hatch when
|
||||||
|
/// the local DB is believed to be out of sync with the server.
|
||||||
|
Future<void> forceResync(String accountId) async {
|
||||||
|
_active.remove(accountId)?.stop();
|
||||||
|
|
||||||
|
await _emails.clearForResync(accountId);
|
||||||
|
await _mailboxes.clearForResync(accountId);
|
||||||
|
|
||||||
|
final accounts = await _accounts.observeAccounts().first;
|
||||||
|
final account = accounts.cast<Account?>().firstWhere(
|
||||||
|
(a) => a?.id == accountId,
|
||||||
|
orElse: () => null,
|
||||||
|
);
|
||||||
|
if (account == null) return;
|
||||||
|
|
||||||
|
final loop = switch (account.type) {
|
||||||
|
AccountType.imap => _AccountSync(
|
||||||
|
account,
|
||||||
|
_accounts,
|
||||||
|
_mailboxes,
|
||||||
|
_emails,
|
||||||
|
_imapConnect,
|
||||||
|
_syncLog,
|
||||||
|
),
|
||||||
|
AccountType.jmap => _JmapAccountSync(
|
||||||
|
account,
|
||||||
|
_mailboxes,
|
||||||
|
_emails,
|
||||||
|
_accounts,
|
||||||
|
_syncLog,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
_active[accountId] = loop;
|
||||||
|
loop.start();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Shared interface ──────────────────────────────────────────────────────────
|
// ── Shared interface ──────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -2739,4 +2739,27 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
const PendingChangesCompanion(attempts: Value(0), lastError: Value(null)),
|
const PendingChangesCompanion(attempts: Value(0), lastError: Value(null)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> clearForResync(String accountId) async {
|
||||||
|
// Disable FK constraints so EmailBodies rows survive the emails deletion.
|
||||||
|
// When emails are re-inserted after the next sync with the same IDs, the
|
||||||
|
// cached body content will be reused without a network round-trip.
|
||||||
|
await _db.customStatement('PRAGMA foreign_keys = OFF');
|
||||||
|
try {
|
||||||
|
await _db.transaction(() async {
|
||||||
|
await (_db.delete(_db.emails)
|
||||||
|
..where((t) => t.accountId.equals(accountId)))
|
||||||
|
.go();
|
||||||
|
await (_db.delete(_db.pendingChanges)
|
||||||
|
..where((t) => t.accountId.equals(accountId)))
|
||||||
|
.go();
|
||||||
|
await (_db.delete(_db.syncStates)
|
||||||
|
..where((t) => t.accountId.equals(accountId)))
|
||||||
|
.go();
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await _db.customStatement('PRAGMA foreign_keys = ON');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -303,4 +303,11 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
|||||||
if (mb.isJunk) return 'junk';
|
if (mb.isJunk) return 'junk';
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> clearForResync(String accountId) async {
|
||||||
|
await (_db.delete(_db.mailboxes)
|
||||||
|
..where((t) => t.accountId.equals(accountId)))
|
||||||
|
.go();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
|||||||
bool _tryTesting = false;
|
bool _tryTesting = false;
|
||||||
String? _tryOk;
|
String? _tryOk;
|
||||||
String? _tryErr;
|
String? _tryErr;
|
||||||
|
bool _resyncing = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -170,6 +171,43 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _forceResync() async {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('Force full sync?'),
|
||||||
|
content: const Text(
|
||||||
|
'This clears all locally-cached emails and mailboxes for this '
|
||||||
|
'account and immediately re-downloads everything from the server. '
|
||||||
|
'Previously viewed email content will not need to be re-downloaded.',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(false),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(true),
|
||||||
|
child: const Text('Force sync'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirmed != true || !mounted) return;
|
||||||
|
setState(() => _resyncing = true);
|
||||||
|
try {
|
||||||
|
await ref.read(syncManagerProvider).forceResync(widget.accountId);
|
||||||
|
if (mounted) context.pop();
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_resyncing = false;
|
||||||
|
_errorMessage = 'Force sync failed: $e';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _save() async {
|
Future<void> _save() async {
|
||||||
if (!_formKey.currentState!.validate()) return;
|
if (!_formKey.currentState!.validate()) return;
|
||||||
final password = _passwordCtrl.text.isNotEmpty ? _passwordCtrl.text : null;
|
final password = _passwordCtrl.text.isNotEmpty ? _passwordCtrl.text : null;
|
||||||
@@ -230,11 +268,9 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Edit account')),
|
appBar: AppBar(title: const Text('Edit account')),
|
||||||
body: _loading
|
body: _loading || _saving || _resyncing
|
||||||
? const Center(child: CircularProgressIndicator())
|
? const Center(child: CircularProgressIndicator())
|
||||||
: _saving
|
: _buildForm(),
|
||||||
? const Center(child: CircularProgressIndicator())
|
|
||||||
: _buildForm(),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,6 +386,15 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
FilledButton(onPressed: _save, child: const Text('Save')),
|
FilledButton(onPressed: _save, child: const Text('Save')),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
icon: const Icon(Icons.sync_problem),
|
||||||
|
label: const Text('Force full sync'),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
|
onPressed: _forceResync,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -123,17 +123,19 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
final destPath = await repo.deleteEmail(widget.emailId);
|
final destPath = await repo.deleteEmail(widget.emailId);
|
||||||
|
|
||||||
if (header != null) {
|
if (header != null) {
|
||||||
ref.read(undoServiceProvider.notifier).pushAction(
|
unawaited(
|
||||||
UndoAction(
|
ref.read(undoServiceProvider.notifier).pushAction(
|
||||||
id: DateTime.now().toIso8601String(),
|
UndoAction(
|
||||||
accountId: header.accountId,
|
id: DateTime.now().toIso8601String(),
|
||||||
type: UndoType.delete,
|
accountId: header.accountId,
|
||||||
emailIds: [widget.emailId],
|
type: UndoType.delete,
|
||||||
sourceMailboxPath: header.mailboxPath,
|
emailIds: [widget.emailId],
|
||||||
destinationMailboxPath: destPath,
|
sourceMailboxPath: header.mailboxPath,
|
||||||
originalEmails: [header],
|
destinationMailboxPath: destPath,
|
||||||
|
originalEmails: [header],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (context.mounted) context.pop();
|
if (context.mounted) context.pop();
|
||||||
@@ -354,16 +356,18 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
|
|
||||||
await ref.read(emailRepositoryProvider).moveEmail(widget.emailId, chosen);
|
await ref.read(emailRepositoryProvider).moveEmail(widget.emailId, chosen);
|
||||||
|
|
||||||
ref.read(undoServiceProvider.notifier).pushAction(
|
unawaited(
|
||||||
UndoAction(
|
ref.read(undoServiceProvider.notifier).pushAction(
|
||||||
id: DateTime.now().toIso8601String(),
|
UndoAction(
|
||||||
accountId: header.accountId,
|
id: DateTime.now().toIso8601String(),
|
||||||
type: UndoType.move,
|
accountId: header.accountId,
|
||||||
emailIds: [widget.emailId],
|
type: UndoType.move,
|
||||||
sourceMailboxPath: header.mailboxPath,
|
emailIds: [widget.emailId],
|
||||||
destinationMailboxPath: chosen,
|
sourceMailboxPath: header.mailboxPath,
|
||||||
|
destinationMailboxPath: chosen,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (context.mounted) context.pop();
|
if (context.mounted) context.pop();
|
||||||
}
|
}
|
||||||
@@ -384,7 +388,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
sourceMailboxPath: header.mailboxPath,
|
sourceMailboxPath: header.mailboxPath,
|
||||||
originalEmails: [header],
|
originalEmails: [header],
|
||||||
);
|
);
|
||||||
ref.read(undoServiceProvider.notifier).pushAction(action);
|
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
|
||||||
await repo.snoozeEmail(widget.emailId, until);
|
await repo.snoozeEmail(widget.emailId, until);
|
||||||
|
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
|
|||||||
@@ -331,7 +331,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
destinationMailboxPath: mailbox.path,
|
destinationMailboxPath: mailbox.path,
|
||||||
originalEmails: originalEmails,
|
originalEmails: originalEmails,
|
||||||
);
|
);
|
||||||
ref.read(undoServiceProvider.notifier).pushAction(action);
|
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _batchArchive() =>
|
Future<void> _batchArchive() =>
|
||||||
@@ -364,7 +364,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
destinationMailboxPath: lastDestPath,
|
destinationMailboxPath: lastDestPath,
|
||||||
originalEmails: originalEmails,
|
originalEmails: originalEmails,
|
||||||
);
|
);
|
||||||
ref.read(undoServiceProvider.notifier).pushAction(action);
|
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _batchMarkSpam() =>
|
Future<void> _batchMarkSpam() =>
|
||||||
@@ -426,7 +426,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
destinationMailboxPath: chosen,
|
destinationMailboxPath: chosen,
|
||||||
originalEmails: originalEmails,
|
originalEmails: originalEmails,
|
||||||
);
|
);
|
||||||
ref.read(undoServiceProvider.notifier).pushAction(action);
|
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _batchSnooze() async {
|
Future<void> _batchSnooze() async {
|
||||||
@@ -458,7 +458,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
sourceMailboxPath: widget.mailboxPath,
|
sourceMailboxPath: widget.mailboxPath,
|
||||||
originalEmails: originalEmails,
|
originalEmails: originalEmails,
|
||||||
);
|
);
|
||||||
ref.read(undoServiceProvider.notifier).pushAction(action);
|
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
@@ -609,7 +609,9 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
destinationMailboxPath: archive.path,
|
destinationMailboxPath: archive.path,
|
||||||
originalEmails: originalEmails,
|
originalEmails: originalEmails,
|
||||||
);
|
);
|
||||||
ref.read(undoServiceProvider.notifier).pushAction(action);
|
unawaited(
|
||||||
|
ref.read(undoServiceProvider.notifier).pushAction(action),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
String? lastDestPath;
|
String? lastDestPath;
|
||||||
for (final id in t.emailIds) {
|
for (final id in t.emailIds) {
|
||||||
@@ -625,7 +627,9 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
destinationMailboxPath: lastDestPath,
|
destinationMailboxPath: lastDestPath,
|
||||||
originalEmails: originalEmails,
|
originalEmails: originalEmails,
|
||||||
);
|
);
|
||||||
ref.read(undoServiceProvider.notifier).pushAction(action);
|
unawaited(
|
||||||
|
ref.read(undoServiceProvider.notifier).pushAction(action),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: tile,
|
child: tile,
|
||||||
|
|||||||
@@ -256,17 +256,19 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
|
|||||||
final destPath = await repo.deleteEmail(widget.email.id);
|
final destPath = await repo.deleteEmail(widget.email.id);
|
||||||
|
|
||||||
if (original != null) {
|
if (original != null) {
|
||||||
ref.read(undoServiceProvider.notifier).pushAction(
|
unawaited(
|
||||||
UndoAction(
|
ref.read(undoServiceProvider.notifier).pushAction(
|
||||||
id: DateTime.now().toIso8601String(),
|
UndoAction(
|
||||||
accountId: widget.email.accountId,
|
id: DateTime.now().toIso8601String(),
|
||||||
type: UndoType.delete,
|
accountId: widget.email.accountId,
|
||||||
emailIds: [widget.email.id],
|
type: UndoType.delete,
|
||||||
sourceMailboxPath: widget.email.mailboxPath,
|
emailIds: [widget.email.id],
|
||||||
destinationMailboxPath: destPath,
|
sourceMailboxPath: widget.email.mailboxPath,
|
||||||
originalEmails: [original],
|
destinationMailboxPath: destPath,
|
||||||
|
originalEmails: [original],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
@@ -22,7 +24,8 @@ class UndoLogScreen extends ConsumerWidget {
|
|||||||
tooltip: 'Clear history',
|
tooltip: 'Clear history',
|
||||||
onPressed: history.isEmpty
|
onPressed: history.isEmpty
|
||||||
? null
|
? null
|
||||||
: () => ref.read(undoServiceProvider.notifier).clear(),
|
: () =>
|
||||||
|
unawaited(ref.read(undoServiceProvider.notifier).clear()),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -96,6 +96,9 @@ class _FakeMailboxes implements MailboxRepository {
|
|||||||
@override
|
@override
|
||||||
Future<Mailbox?> findMailboxByRole(String accountId, String role) async =>
|
Future<Mailbox?> findMailboxByRole(String accountId, String role) async =>
|
||||||
null;
|
null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> clearForResync(String accountId) async {}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _FakeEmails implements EmailRepository {
|
class _FakeEmails implements EmailRepository {
|
||||||
@@ -191,6 +194,9 @@ class _FakeEmails implements EmailRepository {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> retryMutation(int id) async {}
|
Future<void> retryMutation(int id) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> clearForResync(String accountId) async {}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _FakeLogs implements SyncLogRepository {
|
class _FakeLogs implements SyncLogRepository {
|
||||||
|
|||||||
@@ -98,6 +98,9 @@ class FakeEmailRepository implements EmailRepository {
|
|||||||
String mailboxPath,
|
String mailboxPath,
|
||||||
) async =>
|
) async =>
|
||||||
ReliabilityResult.healthy;
|
ReliabilityResult.healthy;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> clearForResync(String accountId) async {}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _Log {
|
class _Log {
|
||||||
@@ -148,4 +151,6 @@ class FakeMailboxRepositoryWithInbox implements MailboxRepository {
|
|||||||
Future<int> syncMailboxes(String id) async => 1;
|
Future<int> syncMailboxes(String id) async => 1;
|
||||||
@override
|
@override
|
||||||
Future<Mailbox?> findMailboxByRole(String id, String role) async => null;
|
Future<Mailbox?> findMailboxByRole(String id, String role) async => null;
|
||||||
|
@override
|
||||||
|
Future<void> clearForResync(String accountId) async {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ void main() {
|
|||||||
sourceMailboxPath: 'INBOX',
|
sourceMailboxPath: 'INBOX',
|
||||||
originalEmails: [original!],
|
originalEmails: [original!],
|
||||||
);
|
);
|
||||||
container.read(undoServiceProvider.notifier).pushAction(action);
|
await container.read(undoServiceProvider.notifier).pushAction(action);
|
||||||
await container.read(undoServiceProvider.notifier).undo();
|
await container.read(undoServiceProvider.notifier).undo();
|
||||||
|
|
||||||
// 3. Verify it is back in Inbox
|
// 3. Verify it is back in Inbox
|
||||||
@@ -190,7 +190,7 @@ void main() {
|
|||||||
emailIds: [emailId],
|
emailIds: [emailId],
|
||||||
sourceMailboxPath: 'INBOX',
|
sourceMailboxPath: 'INBOX',
|
||||||
);
|
);
|
||||||
container.read(undoServiceProvider.notifier).pushAction(action);
|
await container.read(undoServiceProvider.notifier).pushAction(action);
|
||||||
await container.read(undoServiceProvider.notifier).undo();
|
await container.read(undoServiceProvider.notifier).undo();
|
||||||
|
|
||||||
// 3. Verify it is back in Inbox
|
// 3. Verify it is back in Inbox
|
||||||
@@ -230,7 +230,7 @@ void main() {
|
|||||||
destinationMailboxPath: destPath,
|
destinationMailboxPath: destPath,
|
||||||
originalEmails: [original!],
|
originalEmails: [original!],
|
||||||
);
|
);
|
||||||
container.read(undoServiceProvider.notifier).pushAction(action);
|
await container.read(undoServiceProvider.notifier).pushAction(action);
|
||||||
await container.read(undoServiceProvider.notifier).undo();
|
await container.read(undoServiceProvider.notifier).undo();
|
||||||
|
|
||||||
// 4. Verify local state
|
// 4. Verify local state
|
||||||
@@ -273,7 +273,7 @@ void main() {
|
|||||||
sourceMailboxPath: 'INBOX',
|
sourceMailboxPath: 'INBOX',
|
||||||
originalEmails: [original!],
|
originalEmails: [original!],
|
||||||
);
|
);
|
||||||
container.read(undoServiceProvider.notifier).pushAction(action);
|
await container.read(undoServiceProvider.notifier).pushAction(action);
|
||||||
await container.read(undoServiceProvider.notifier).undo();
|
await container.read(undoServiceProvider.notifier).undo();
|
||||||
|
|
||||||
// 3. Verify it is back in Inbox and metadata is cleared
|
// 3. Verify it is back in Inbox and metadata is cleared
|
||||||
|
|||||||
@@ -61,10 +61,10 @@ void main() {
|
|||||||
final notifier = container.read(undoServiceProvider.notifier);
|
final notifier = container.read(undoServiceProvider.notifier);
|
||||||
await notifier.init(); // Wait for persistent load
|
await notifier.init(); // Wait for persistent load
|
||||||
|
|
||||||
notifier.pushAction(action1);
|
await notifier.pushAction(action1);
|
||||||
expect(container.read(undoServiceProvider), [action1]);
|
expect(container.read(undoServiceProvider), [action1]);
|
||||||
|
|
||||||
notifier.pushAction(action2);
|
await notifier.pushAction(action2);
|
||||||
expect(container.read(undoServiceProvider), [action1, action2]);
|
expect(container.read(undoServiceProvider), [action1, action2]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -91,8 +91,8 @@ void main() {
|
|||||||
|
|
||||||
final notifier = container.read(undoServiceProvider.notifier);
|
final notifier = container.read(undoServiceProvider.notifier);
|
||||||
await notifier.init();
|
await notifier.init();
|
||||||
notifier.pushAction(action1);
|
await notifier.pushAction(action1);
|
||||||
notifier.pushAction(action2);
|
await notifier.pushAction(action2);
|
||||||
|
|
||||||
await notifier.undo();
|
await notifier.undo();
|
||||||
expect(container.read(undoServiceProvider), [action1]);
|
expect(container.read(undoServiceProvider), [action1]);
|
||||||
@@ -126,8 +126,8 @@ void main() {
|
|||||||
|
|
||||||
final notifier = container.read(undoServiceProvider.notifier);
|
final notifier = container.read(undoServiceProvider.notifier);
|
||||||
await notifier.init();
|
await notifier.init();
|
||||||
notifier.pushAction(action1);
|
await notifier.pushAction(action1);
|
||||||
notifier.pushAction(action2);
|
await notifier.pushAction(action2);
|
||||||
|
|
||||||
await notifier.undo(actionId: '1');
|
await notifier.undo(actionId: '1');
|
||||||
expect(container.read(undoServiceProvider), [action2]);
|
expect(container.read(undoServiceProvider), [action2]);
|
||||||
@@ -154,7 +154,7 @@ void main() {
|
|||||||
|
|
||||||
final notifier = container.read(undoServiceProvider.notifier);
|
final notifier = container.read(undoServiceProvider.notifier);
|
||||||
await notifier.init();
|
await notifier.init();
|
||||||
notifier.pushAction(action);
|
await notifier.pushAction(action);
|
||||||
|
|
||||||
await notifier.undo();
|
await notifier.undo();
|
||||||
verify(mockEmailRepo.moveEmail('e1', 'INBOX')).called(1);
|
verify(mockEmailRepo.moveEmail('e1', 'INBOX')).called(1);
|
||||||
@@ -193,11 +193,93 @@ void main() {
|
|||||||
|
|
||||||
final notifier = container.read(undoServiceProvider.notifier);
|
final notifier = container.read(undoServiceProvider.notifier);
|
||||||
await notifier.init();
|
await notifier.init();
|
||||||
notifier.pushAction(action);
|
await notifier.pushAction(action);
|
||||||
|
|
||||||
await notifier.undo();
|
await notifier.undo();
|
||||||
|
|
||||||
verify(mockEmailRepo.restoreEmails(any)).called(1);
|
verify(mockEmailRepo.restoreEmails(any)).called(1);
|
||||||
verify(mockEmailRepo.moveEmail('e1', 'INBOX')).called(1);
|
verify(mockEmailRepo.moveEmail('e1', 'INBOX')).called(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('init loads persisted history from repository', () async {
|
||||||
|
final persisted = UndoAction(
|
||||||
|
id: '99',
|
||||||
|
accountId: 'acc1',
|
||||||
|
type: UndoType.move,
|
||||||
|
emailIds: ['e99'],
|
||||||
|
sourceMailboxPath: 'INBOX',
|
||||||
|
);
|
||||||
|
|
||||||
|
when(
|
||||||
|
mockUndoRepo.getHistory(limit: anyNamed('limit')),
|
||||||
|
).thenAnswer((_) async => [persisted]);
|
||||||
|
|
||||||
|
final notifier = container.read(undoServiceProvider.notifier);
|
||||||
|
await notifier.init();
|
||||||
|
|
||||||
|
expect(container.read(undoServiceProvider), [persisted]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pushAction after restart appends to persisted history', () async {
|
||||||
|
final persisted = UndoAction(
|
||||||
|
id: '1',
|
||||||
|
accountId: 'acc1',
|
||||||
|
type: UndoType.move,
|
||||||
|
emailIds: ['e1'],
|
||||||
|
sourceMailboxPath: 'INBOX',
|
||||||
|
);
|
||||||
|
final newAction = UndoAction(
|
||||||
|
id: '2',
|
||||||
|
accountId: 'acc1',
|
||||||
|
type: UndoType.delete,
|
||||||
|
emailIds: ['e2'],
|
||||||
|
sourceMailboxPath: 'INBOX',
|
||||||
|
);
|
||||||
|
|
||||||
|
when(
|
||||||
|
mockUndoRepo.getHistory(limit: anyNamed('limit')),
|
||||||
|
).thenAnswer((_) async => [persisted]);
|
||||||
|
|
||||||
|
final notifier = container.read(undoServiceProvider.notifier);
|
||||||
|
await notifier.init();
|
||||||
|
await notifier.pushAction(newAction);
|
||||||
|
|
||||||
|
expect(container.read(undoServiceProvider), [persisted, newAction]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pushAction concurrent with init waits for init to complete', () async {
|
||||||
|
final persisted = UndoAction(
|
||||||
|
id: '1',
|
||||||
|
accountId: 'acc1',
|
||||||
|
type: UndoType.move,
|
||||||
|
emailIds: ['e1'],
|
||||||
|
sourceMailboxPath: 'INBOX',
|
||||||
|
);
|
||||||
|
final raced = UndoAction(
|
||||||
|
id: '2',
|
||||||
|
accountId: 'acc1',
|
||||||
|
type: UndoType.delete,
|
||||||
|
emailIds: ['e2'],
|
||||||
|
sourceMailboxPath: 'INBOX',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Simulate slow DB load
|
||||||
|
when(
|
||||||
|
mockUndoRepo.getHistory(limit: anyNamed('limit')),
|
||||||
|
).thenAnswer(
|
||||||
|
(_) => Future.delayed(
|
||||||
|
const Duration(milliseconds: 10),
|
||||||
|
() => [persisted],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final notifier = container.read(undoServiceProvider.notifier);
|
||||||
|
final initFuture = notifier.init();
|
||||||
|
// pushAction issued before init completes — it must still see persisted history
|
||||||
|
final pushFuture = notifier.pushAction(raced);
|
||||||
|
|
||||||
|
await Future.wait([initFuture, pushFuture]);
|
||||||
|
|
||||||
|
expect(container.read(undoServiceProvider), [persisted, raced]);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,6 +132,8 @@ class FakeMailboxRepository implements MailboxRepository {
|
|||||||
@override
|
@override
|
||||||
Future<Mailbox?> findMailboxByRole(String accountId, String role) async =>
|
Future<Mailbox?> findMailboxByRole(String accountId, String role) async =>
|
||||||
_mailboxes.where((m) => m.role == role).firstOrNull;
|
_mailboxes.where((m) => m.role == role).firstOrNull;
|
||||||
|
@override
|
||||||
|
Future<void> clearForResync(String accountId) async {}
|
||||||
}
|
}
|
||||||
|
|
||||||
class FakeEmailRepository implements EmailRepository {
|
class FakeEmailRepository implements EmailRepository {
|
||||||
@@ -279,6 +281,9 @@ class FakeEmailRepository implements EmailRepository {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> retryMutation(int id) async {}
|
Future<void> retryMutation(int id) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> clearForResync(String accountId) async {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user