Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4eca1b5ac8 |
@@ -10,12 +10,19 @@ class UndoService extends StateNotifier<List<UndoAction>> {
|
||||
final Ref _ref;
|
||||
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 {
|
||||
final repo = _ref.read(undoRepositoryProvider);
|
||||
state = await repo.getHistory();
|
||||
_ready = _ref.read(undoRepositoryProvider).getHistory().then((history) {
|
||||
if (mounted) state = history;
|
||||
});
|
||||
await _ready;
|
||||
}
|
||||
|
||||
void pushAction(UndoAction action) {
|
||||
Future<void> pushAction(UndoAction action) async {
|
||||
await _ready;
|
||||
final newList = [...state, action];
|
||||
if (newList.length > _maxHistory) {
|
||||
final removed = newList.removeAt(0);
|
||||
@@ -25,12 +32,14 @@ class UndoService extends StateNotifier<List<UndoAction>> {
|
||||
unawaited(_ref.read(undoRepositoryProvider).saveAction(action));
|
||||
}
|
||||
|
||||
void clear() {
|
||||
Future<void> clear() async {
|
||||
await _ready;
|
||||
state = [];
|
||||
unawaited(_ref.read(undoRepositoryProvider).clearHistory());
|
||||
}
|
||||
|
||||
Future<void> undo({String? actionId}) async {
|
||||
await _ready;
|
||||
if (state.isEmpty) return;
|
||||
|
||||
final UndoAction action;
|
||||
|
||||
@@ -123,17 +123,19 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
final destPath = await repo.deleteEmail(widget.emailId);
|
||||
|
||||
if (header != null) {
|
||||
ref.read(undoServiceProvider.notifier).pushAction(
|
||||
UndoAction(
|
||||
id: DateTime.now().toIso8601String(),
|
||||
accountId: header.accountId,
|
||||
type: UndoType.delete,
|
||||
emailIds: [widget.emailId],
|
||||
sourceMailboxPath: header.mailboxPath,
|
||||
destinationMailboxPath: destPath,
|
||||
originalEmails: [header],
|
||||
unawaited(
|
||||
ref.read(undoServiceProvider.notifier).pushAction(
|
||||
UndoAction(
|
||||
id: DateTime.now().toIso8601String(),
|
||||
accountId: header.accountId,
|
||||
type: UndoType.delete,
|
||||
emailIds: [widget.emailId],
|
||||
sourceMailboxPath: header.mailboxPath,
|
||||
destinationMailboxPath: destPath,
|
||||
originalEmails: [header],
|
||||
),
|
||||
),
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
if (context.mounted) context.pop();
|
||||
@@ -354,16 +356,18 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
|
||||
await ref.read(emailRepositoryProvider).moveEmail(widget.emailId, chosen);
|
||||
|
||||
ref.read(undoServiceProvider.notifier).pushAction(
|
||||
UndoAction(
|
||||
id: DateTime.now().toIso8601String(),
|
||||
accountId: header.accountId,
|
||||
type: UndoType.move,
|
||||
emailIds: [widget.emailId],
|
||||
sourceMailboxPath: header.mailboxPath,
|
||||
destinationMailboxPath: chosen,
|
||||
unawaited(
|
||||
ref.read(undoServiceProvider.notifier).pushAction(
|
||||
UndoAction(
|
||||
id: DateTime.now().toIso8601String(),
|
||||
accountId: header.accountId,
|
||||
type: UndoType.move,
|
||||
emailIds: [widget.emailId],
|
||||
sourceMailboxPath: header.mailboxPath,
|
||||
destinationMailboxPath: chosen,
|
||||
),
|
||||
),
|
||||
);
|
||||
);
|
||||
|
||||
if (context.mounted) context.pop();
|
||||
}
|
||||
@@ -384,7 +388,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
sourceMailboxPath: header.mailboxPath,
|
||||
originalEmails: [header],
|
||||
);
|
||||
ref.read(undoServiceProvider.notifier).pushAction(action);
|
||||
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
|
||||
await repo.snoozeEmail(widget.emailId, until);
|
||||
|
||||
if (context.mounted) {
|
||||
|
||||
@@ -331,7 +331,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
destinationMailboxPath: mailbox.path,
|
||||
originalEmails: originalEmails,
|
||||
);
|
||||
ref.read(undoServiceProvider.notifier).pushAction(action);
|
||||
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
|
||||
}
|
||||
|
||||
Future<void> _batchArchive() =>
|
||||
@@ -364,7 +364,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
destinationMailboxPath: lastDestPath,
|
||||
originalEmails: originalEmails,
|
||||
);
|
||||
ref.read(undoServiceProvider.notifier).pushAction(action);
|
||||
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
|
||||
}
|
||||
|
||||
Future<void> _batchMarkSpam() =>
|
||||
@@ -426,7 +426,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
destinationMailboxPath: chosen,
|
||||
originalEmails: originalEmails,
|
||||
);
|
||||
ref.read(undoServiceProvider.notifier).pushAction(action);
|
||||
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
|
||||
}
|
||||
|
||||
Future<void> _batchSnooze() async {
|
||||
@@ -458,7 +458,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
sourceMailboxPath: widget.mailboxPath,
|
||||
originalEmails: originalEmails,
|
||||
);
|
||||
ref.read(undoServiceProvider.notifier).pushAction(action);
|
||||
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
@@ -609,7 +609,9 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
destinationMailboxPath: archive.path,
|
||||
originalEmails: originalEmails,
|
||||
);
|
||||
ref.read(undoServiceProvider.notifier).pushAction(action);
|
||||
unawaited(
|
||||
ref.read(undoServiceProvider.notifier).pushAction(action),
|
||||
);
|
||||
} else {
|
||||
String? lastDestPath;
|
||||
for (final id in t.emailIds) {
|
||||
@@ -625,7 +627,9 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
destinationMailboxPath: lastDestPath,
|
||||
originalEmails: originalEmails,
|
||||
);
|
||||
ref.read(undoServiceProvider.notifier).pushAction(action);
|
||||
unawaited(
|
||||
ref.read(undoServiceProvider.notifier).pushAction(action),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: tile,
|
||||
|
||||
@@ -256,17 +256,19 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
|
||||
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],
|
||||
unawaited(
|
||||
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],
|
||||
),
|
||||
),
|
||||
);
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
@@ -22,7 +24,8 @@ class UndoLogScreen extends ConsumerWidget {
|
||||
tooltip: 'Clear history',
|
||||
onPressed: history.isEmpty
|
||||
? null
|
||||
: () => ref.read(undoServiceProvider.notifier).clear(),
|
||||
: () =>
|
||||
unawaited(ref.read(undoServiceProvider.notifier).clear()),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -109,7 +109,7 @@ void main() {
|
||||
sourceMailboxPath: 'INBOX',
|
||||
originalEmails: [original!],
|
||||
);
|
||||
container.read(undoServiceProvider.notifier).pushAction(action);
|
||||
await container.read(undoServiceProvider.notifier).pushAction(action);
|
||||
await container.read(undoServiceProvider.notifier).undo();
|
||||
|
||||
// 3. Verify it is back in Inbox
|
||||
@@ -190,7 +190,7 @@ void main() {
|
||||
emailIds: [emailId],
|
||||
sourceMailboxPath: 'INBOX',
|
||||
);
|
||||
container.read(undoServiceProvider.notifier).pushAction(action);
|
||||
await container.read(undoServiceProvider.notifier).pushAction(action);
|
||||
await container.read(undoServiceProvider.notifier).undo();
|
||||
|
||||
// 3. Verify it is back in Inbox
|
||||
@@ -230,7 +230,7 @@ void main() {
|
||||
destinationMailboxPath: destPath,
|
||||
originalEmails: [original!],
|
||||
);
|
||||
container.read(undoServiceProvider.notifier).pushAction(action);
|
||||
await container.read(undoServiceProvider.notifier).pushAction(action);
|
||||
await container.read(undoServiceProvider.notifier).undo();
|
||||
|
||||
// 4. Verify local state
|
||||
@@ -273,7 +273,7 @@ void main() {
|
||||
sourceMailboxPath: 'INBOX',
|
||||
originalEmails: [original!],
|
||||
);
|
||||
container.read(undoServiceProvider.notifier).pushAction(action);
|
||||
await container.read(undoServiceProvider.notifier).pushAction(action);
|
||||
await container.read(undoServiceProvider.notifier).undo();
|
||||
|
||||
// 3. Verify it is back in Inbox and metadata is cleared
|
||||
|
||||
@@ -61,10 +61,10 @@ void main() {
|
||||
final notifier = container.read(undoServiceProvider.notifier);
|
||||
await notifier.init(); // Wait for persistent load
|
||||
|
||||
notifier.pushAction(action1);
|
||||
await notifier.pushAction(action1);
|
||||
expect(container.read(undoServiceProvider), [action1]);
|
||||
|
||||
notifier.pushAction(action2);
|
||||
await notifier.pushAction(action2);
|
||||
expect(container.read(undoServiceProvider), [action1, action2]);
|
||||
});
|
||||
|
||||
@@ -91,8 +91,8 @@ void main() {
|
||||
|
||||
final notifier = container.read(undoServiceProvider.notifier);
|
||||
await notifier.init();
|
||||
notifier.pushAction(action1);
|
||||
notifier.pushAction(action2);
|
||||
await notifier.pushAction(action1);
|
||||
await notifier.pushAction(action2);
|
||||
|
||||
await notifier.undo();
|
||||
expect(container.read(undoServiceProvider), [action1]);
|
||||
@@ -126,8 +126,8 @@ void main() {
|
||||
|
||||
final notifier = container.read(undoServiceProvider.notifier);
|
||||
await notifier.init();
|
||||
notifier.pushAction(action1);
|
||||
notifier.pushAction(action2);
|
||||
await notifier.pushAction(action1);
|
||||
await notifier.pushAction(action2);
|
||||
|
||||
await notifier.undo(actionId: '1');
|
||||
expect(container.read(undoServiceProvider), [action2]);
|
||||
@@ -154,7 +154,7 @@ void main() {
|
||||
|
||||
final notifier = container.read(undoServiceProvider.notifier);
|
||||
await notifier.init();
|
||||
notifier.pushAction(action);
|
||||
await notifier.pushAction(action);
|
||||
|
||||
await notifier.undo();
|
||||
verify(mockEmailRepo.moveEmail('e1', 'INBOX')).called(1);
|
||||
@@ -193,11 +193,93 @@ void main() {
|
||||
|
||||
final notifier = container.read(undoServiceProvider.notifier);
|
||||
await notifier.init();
|
||||
notifier.pushAction(action);
|
||||
await notifier.pushAction(action);
|
||||
|
||||
await notifier.undo();
|
||||
|
||||
verify(mockEmailRepo.restoreEmails(any)).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]);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user