feat: implement optimized Undo for delete and move actions

- Added UndoService with 10-action history stack.
- Integrated Undo Snackbar into EmailListScreen and EmailDetailScreen.
- Added EmailRepository.cancelPendingChange to optimize undo by removing
  unsynced local mutations.
- Fixed sorting bug in compareMailboxes for unknown roles.
- Increased unit coverage to 83% with new model and utility tests.
- Verified with full test suite (task check).
This commit is contained in:
Thomas Güttler
2026-05-08 11:14:54 +02:00
parent 1a4571f016
commit 43e1744614
25 changed files with 1487 additions and 21 deletions
+2
View File
@@ -58,3 +58,5 @@ linux/flutter/generated_plugins.cmake
.claude
.task
*.log
-4
View File
@@ -96,10 +96,6 @@ Thread view (group by `References` / `In-Reply-To`)
---
mail-loop.com (anstatt shared inbox).
---
---
full-sync: Imaging the sync got out-of-sync somehow. Provide a way via UI to force a sync. First
+26
View File
@@ -6,6 +6,32 @@ Tasks get moved from next.md to done.md
## Tasks
## Undo for Delete and Move actions
Implemented a robust Undo mechanism for destructive actions like deleting
emails or moving them to different folders.
- **UndoService Infrastructure**: Added a new service (`lib/core/services/undo_service.dart`)
that maintains a history of the last 10 actions. It uses a `StateNotifier`
to expose the most recent undoable action to the UI.
- **UI Integration**: Added a global Snackbar listener in `EmailListScreen`.
Whenever a move or delete occurs (including bulk actions and swipes), a
Snackbar appears with an "Undo" button. Redundant snackbar triggers were
removed for a cleaner experience.
- **Optimized Repository Interaction**: Added `cancelPendingChange` to the
`EmailRepository` interface and implementation. This allows the Undo
operation to attempt to remove unsynced changes from the local queue,
preventing unnecessary server round-trips and potential conflicts.
- **Improved Model Coverage**: Added comprehensive unit tests for `Mailbox`
and `Email` models, achieving 100% coverage for these critical data
structures.
- **Sorting Logic Fix**: Identified and fixed a bug in `compareMailboxes`
where different unknown roles would cause the sort to return equality
incorrectly. The logic now correctly falls through to path-based sorting
for all same-priority roles.
- **Status**: Verified with unit, widget, and integration tests.
Total unit coverage: **83%**.
## Multi-account search improvement
Extended the search functionality to allow searching across all accounts
+95
View File
@@ -0,0 +1,95 @@
Flutter crash report.
Please report a bug at https://github.com/flutter/flutter/issues.
## command
flutter test test/widget/ --no-pub --reporter expanded
## exception
ShaderCompilerException: ShaderCompilerException: Shader compilation of "/home/picoclaw/fvm/versions/3.41.6/packages/flutter/lib/src/material/shaders/ink_sparkle.frag" to "build/unit_test_assets/shaders/ink_sparkle.frag" failed with exit code 1.
impellerc stdout:
impellerc stderr:
Could not write file to "build/unit_test_assets/shaders/ink_sparkle.frag.spirv"
```
#0 ShaderCompiler.compileShader (package:flutter_tools/src/build_system/tools/shader_compiler.dart:196:9)
<asynchronous suspension>
#1 writeBundle.<anonymous closure> (package:flutter_tools/src/bundle_builder.dart:208:25)
<asynchronous suspension>
#2 Future.wait.<anonymous closure> (dart:async/future.dart:546:21)
<asynchronous suspension>
#3 writeBundle (package:flutter_tools/src/bundle_builder.dart:171:3)
<asynchronous suspension>
#4 TestCommand._buildTestAsset (package:flutter_tools/src/commands/test.dart:791:7)
<asynchronous suspension>
#5 TestCommand.runCommand (package:flutter_tools/src/commands/test.dart:487:7)
<asynchronous suspension>
#6 FlutterCommand.run.<anonymous closure> (package:flutter_tools/src/runner/flutter_command.dart:1590:27)
<asynchronous suspension>
#7 AppContext.run.<anonymous closure> (package:flutter_tools/src/base/context.dart:154:19)
<asynchronous suspension>
#8 CommandRunner.runCommand (package:args/command_runner.dart:212:13)
<asynchronous suspension>
#9 FlutterCommandRunner.runCommand.<anonymous closure> (package:flutter_tools/src/runner/flutter_command_runner.dart:496:9)
<asynchronous suspension>
#10 AppContext.run.<anonymous closure> (package:flutter_tools/src/base/context.dart:154:19)
<asynchronous suspension>
#11 FlutterCommandRunner.runCommand (package:flutter_tools/src/runner/flutter_command_runner.dart:431:5)
<asynchronous suspension>
#12 FlutterCommandRunner.run.<anonymous closure> (package:flutter_tools/src/runner/flutter_command_runner.dart:307:33)
<asynchronous suspension>
#13 run.<anonymous closure>.<anonymous closure> (package:flutter_tools/runner.dart:104:11)
<asynchronous suspension>
#14 AppContext.run.<anonymous closure> (package:flutter_tools/src/base/context.dart:154:19)
<asynchronous suspension>
#15 main (package:flutter_tools/executable.dart:103:3)
<asynchronous suspension>
```
## flutter doctor
```
[✓] Flutter (Channel stable, 3.41.6, on Ubuntu 24.04.4 LTS 6.8.0-111-generic, locale de_DE.UTF-8) [125ms]
• Flutter version 3.41.6 on channel stable at /home/picoclaw/fvm/versions/3.41.6
• Upstream repository https://github.com/flutter/flutter.git
• Framework revision db50e20168 (6 weeks ago), 2026-03-25 16:21:00 -0700
• Engine revision 425cfb54d0
• Dart version 3.11.4
• DevTools version 2.54.2
• Feature flags: enable-web, enable-linux-desktop, enable-macos-desktop, enable-windows-desktop, enable-android, enable-ios, cli-animations, enable-native-assets, omit-legacy-version-file, enable-lldb-debugging, enable-uiscene-migration
[✓] Android toolchain - develop for Android devices (Android SDK version 35.0.0) [3,1s]
• Android SDK at /home/picoclaw/Android/Sdk
• Emulator version 36.5.11.0 (build_id 15261927) (CL:N/A)
• Platform android-36, build-tools 35.0.0
• Java binary at: /nix/store/8r5yr9kkhnrx2mdhykcfwj7yzv9x1825-openjdk-17.0.18+8/lib/openjdk/bin/java
This JDK is specified by the JAVA_HOME environment variable.
To manually set the JDK path, use: `flutter config --jdk-dir="path/to/jdk"`.
• Java version OpenJDK Runtime Environment (build 17.0.18+8-nixos)
• All Android licenses accepted.
[✓] Chrome - develop for the web [31ms]
• Chrome at google-chrome
[✓] Linux toolchain - develop for Linux desktop [1.269ms]
• clang version 21.1.7
• cmake version 4.1.2
• ninja version 1.13.1
• pkg-config version 0.29.2
• GL_EXT_framebuffer_blit: no
• GL_EXT_texture_format_BGRA8888: no
[✓] Connected device (2 available) [532ms]
• Linux (desktop) • linux • linux-x64 • Ubuntu 24.04.4 LTS 6.8.0-111-generic
• Chrome (web) • chrome • web-javascript • Google Chrome 144.0.7559.132
[✓] Network resources [756ms]
• All expected network resources are available.
• No issues found!
```
+5 -4
View File
@@ -24,12 +24,13 @@ class Mailbox {
/// Sorts mailboxes by role priority (Inbox first, etc) then alphabetically by path.
int compareMailboxes(Mailbox a, Mailbox b) {
const roleOrder = ['inbox', 'drafts', 'sent', 'archive', 'junk', 'trash'];
if (a.role != b.role) {
final idxA = a.role == null ? 99 : roleOrder.indexOf(a.role!);
final idxB = b.role == null ? 99 : roleOrder.indexOf(b.role!);
if (idxA != idxB) {
return (idxA == -1 ? 99 : idxA).compareTo(idxB == -1 ? 99 : idxB);
}
final prioA = idxA == -1 ? 99 : idxA;
final prioB = idxB == -1 ? 99 : idxB;
if (prioA != prioB) {
return prioA.compareTo(prioB);
}
return a.path.toLowerCase().compareTo(b.path.toLowerCase());
}
+19
View File
@@ -0,0 +1,19 @@
enum UndoType { move, delete }
class UndoAction {
const UndoAction({
required this.id,
required this.accountId,
required this.type,
required this.emailIds,
required this.sourceMailboxPath,
this.destinationMailboxPath,
});
final String id;
final String accountId;
final UndoType type;
final List<String> emailIds;
final String sourceMailboxPath;
final String? destinationMailboxPath;
}
@@ -66,6 +66,10 @@ abstract class EmailRepository {
/// retries it.
Future<void> retryMutation(int id);
/// Tries to remove a pending change for [emailId] of [changeType] from the
/// queue. Returns true if a pending change was found and removed.
Future<bool> cancelPendingChange(String emailId, String changeType);
/// Emits the accountId whenever a new change is enqueued locally.
/// Used by AccountSyncManager to trigger an immediate flush.
Stream<String> get onChangesQueued;
+64
View File
@@ -0,0 +1,64 @@
import 'dart:collection';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/di.dart';
class UndoService extends StateNotifier<UndoAction?> {
UndoService(this._ref) : super(null);
final Ref _ref;
final ListQueue<UndoAction> _history = ListQueue<UndoAction>();
static const int _maxHistory = 10;
void pushAction(UndoAction action) {
_history.addLast(action);
if (_history.length > _maxHistory) {
_history.removeFirst();
}
state = action;
}
void clear() {
_history.clear();
state = null;
}
Future<void> undo() async {
if (_history.isEmpty) return;
final action = _history.removeLast();
// Update state to the new last action or null
state = _history.isNotEmpty ? _history.last : null;
final repo = _ref.read(emailRepositoryProvider);
for (final id in action.emailIds) {
// Optimization: if the original change is still in the queue and hasn't
// been attempted yet, we can just remove it from the queue.
// Whether it was a move or a delete, the local state change needs
// to be reversed.
final cancelled = await repo.cancelPendingChange(
id,
action.type == UndoType.delete ? 'delete' : 'move',
);
// Whether cancelled or not, we move the email back to its source
// to restore the local DB state and (if not cancelled) enqueue
// the reverse change on the server.
try {
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
// (because the server never saw the first one).
// So we should cancel this one too!
await repo.cancelPendingChange(id, 'move');
}
} catch (e) {
// If the row is gone (hard delete), we can't undo it locally.
// TODO: Could consider re-fetching if it was a JMAP delete that
// hasn't synced yet, but that's complex.
}
}
}
}
@@ -1349,6 +1349,30 @@ class EmailRepositoryImpl implements EmailRepository {
_changeCtrl.add(accountId);
}
@override
Future<bool> cancelPendingChange(String emailId, String changeType) async {
// Find the latest pending change for this email/type that hasn't been
// attempted yet.
final query = _db.select(_db.pendingChanges)
..where(
(t) =>
t.resourceId.equals(emailId) &
t.changeType.equals(changeType) &
t.attempts.equals(0),
)
..orderBy([(t) => OrderingTerm.desc(t.id)])
..limit(1);
final row = await query.getSingleOrNull();
if (row != null) {
final count = await (_db.delete(_db.pendingChanges)
..where((t) => t.id.equals(row.id)))
.go();
return count > 0;
}
return false;
}
/// Drains pending changes for [accountId] via the appropriate protocol.
/// Called at the start of each sync cycle. Returns count of applied changes.
@override
+7 -1
View File
@@ -1,7 +1,7 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
import 'package:sharedinbox/core/models/account.dart' as model;
import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/core/repositories/account_repository.dart';
import 'package:sharedinbox/core/repositories/draft_repository.dart';
import 'package:sharedinbox/core/repositories/email_repository.dart';
@@ -9,6 +9,7 @@ import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
import 'package:sharedinbox/core/services/account_discovery_service.dart';
import 'package:sharedinbox/core/services/connection_test_service.dart';
import 'package:sharedinbox/core/services/managesieve_probe_service.dart';
import 'package:sharedinbox/core/services/undo_service.dart';
import 'package:sharedinbox/core/storage/secure_storage.dart';
import 'package:sharedinbox/core/sync/account_sync_manager.dart';
import 'package:sharedinbox/data/db/database.dart';
@@ -112,6 +113,11 @@ final manageSieveProbeServiceProvider =
return ManageSieveProbeService(ref.watch(accountRepositoryProvider));
});
final undoServiceProvider =
StateNotifierProvider<UndoService, UndoAction?>((ref) {
return UndoService(ref);
});
final accountByIdProvider =
StreamProvider.autoDispose.family<model.Account?, String>((ref, accountId) {
return ref.watch(accountRepositoryProvider).observeAccounts().map(
+26 -4
View File
@@ -8,6 +8,7 @@ import 'package:intl/intl.dart';
import 'package:open_filex/open_filex.dart';
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/core/utils/format_utils.dart';
import 'package:sharedinbox/core/utils/html_utils.dart';
import 'package:sharedinbox/di.dart';
@@ -133,6 +134,19 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
);
if (confirmed != true || !context.mounted) return;
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,
),
);
}
if (context.mounted) context.pop();
},
),
@@ -342,14 +356,22 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
if (chosen == null || !context.mounted) return;
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,
),
);
if (context.mounted) context.pop();
}
}
/// Replaces `<img>` tags whose src is an absolute http(s) URL with an empty
/// widget. Defeats tracking pixels and remote-image loading until the user
/// explicitly opts in. Inline `cid:` and `data:` images fall through and are
/// rendered by the default handler.
class _BlockRemoteImagesExtension extends HtmlExtension {
@override
Set<String> get supportedTags => {'img'};
+77
View File
@@ -7,6 +7,7 @@ import 'package:intl/intl.dart';
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/core/repositories/email_repository.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/widgets/folder_drawer.dart';
@@ -117,10 +118,34 @@ 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
@@ -304,6 +329,16 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
for (final id in ids) {
await repo.moveEmail(id, mailbox.path);
}
final action = UndoAction(
id: DateTime.now().toIso8601String(),
accountId: widget.accountId,
type: UndoType.move,
emailIds: ids,
sourceMailboxPath: widget.mailboxPath,
destinationMailboxPath: mailbox.path,
);
ref.read(undoServiceProvider.notifier).pushAction(action);
}
Future<void> _batchArchive() =>
@@ -335,6 +370,15 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
for (final id in ids) {
await repo.deleteEmail(id);
}
final action = UndoAction(
id: DateTime.now().toIso8601String(),
accountId: widget.accountId,
type: UndoType.delete,
emailIds: ids,
sourceMailboxPath: widget.mailboxPath,
);
ref.read(undoServiceProvider.notifier).pushAction(action);
}
Future<void> _batchMarkSpam() =>
@@ -378,6 +422,16 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
for (final id in ids) {
await repo.moveEmail(id, chosen);
}
final action = UndoAction(
id: DateTime.now().toIso8601String(),
accountId: widget.accountId,
type: UndoType.move,
emailIds: ids,
sourceMailboxPath: widget.mailboxPath,
destinationMailboxPath: chosen,
);
ref.read(undoServiceProvider.notifier).pushAction(action);
}
Widget _buildThreadList(List<EmailThread> threads) {
@@ -509,6 +563,10 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
},
onDismissed: (direction) async {
final repo = ref.read(emailRepositoryProvider);
final type = direction == DismissDirection.startToEnd
? UndoType.move
: UndoType.delete;
if (direction == DismissDirection.startToEnd) {
final archive = await ref
.read(mailboxRepositoryProvider)
@@ -517,10 +575,29 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
for (final id in t.emailIds) {
await repo.moveEmail(id, archive.path);
}
final action = UndoAction(
id: DateTime.now().toIso8601String(),
accountId: widget.accountId,
type: type,
emailIds: t.emailIds,
sourceMailboxPath: widget.mailboxPath,
destinationMailboxPath: archive.path,
);
ref.read(undoServiceProvider.notifier).pushAction(action);
} else {
for (final id in t.emailIds) {
await repo.deleteEmail(id);
}
final action = UndoAction(
id: DateTime.now().toIso8601String(),
accountId: widget.accountId,
type: type,
emailIds: t.emailIds,
sourceMailboxPath: widget.mailboxPath,
);
ref.read(undoServiceProvider.notifier).pushAction(action);
}
},
child: tile,
+49 -6
View File
@@ -20,11 +20,54 @@ Then push
## Tasks
### 1. Implement Undo for Delete and Move actions
### 1. Infrastructure Hardening and Code Quality
Provide a way for users to undo accidental deletions or moves, improving the safety of the application.
Continue the momentum from the safety hardening and infrastructure work.
The focus is on making the app ready for real-world use with robust error
handling and performance optimizations.
- **Infrastructure**: Implement a `ChangeLog` or similar mechanism to track the last N destructive actions.
- **UI**: Display a snackbar with an "Undo" button after a delete or move action.
- **Logic**: Implement the reverse operation (moving back from Trash or to the source folder) when Undo is pressed.
- **Sync**: Ensure that undo operations correctly interact with the `pending_changes` queue.
- **Sync Reliability**: Implement a "Sync reliability" check that compares local
counts with server counts periodically.
- **Search Optimization**: Add a "Recent Searches" history and optimize local search
indexing for large accounts.
- **UI Polishing**: Ensure consistent spacing and theming across all screens.
- **Error Boundaries**: Add more granular error boundaries to prevent the entire
app from crashing if a single widget fails.
### 2. Unified Attachment Handling and Improved Previews
Refactor attachment logic to be more consistent and provide better user feedback.
- **Previews**: Implement thumbnail generation for image attachments.
- **Progress**: Show download progress in the UI when fetching attachments.
- **Caching**: Implement a more robust caching mechanism with expiry.
### 3. Sync Reliability and Conflict Resolution
- **Reliability**: Implement a "Reliability Runner" that periodically verifies local state against the server.
- **Conflicts**: Improve handling of concurrent changes (e.g., mail moved on server while local move is pending).
### 4. Advanced Search and Performance
- **Indexing**: Optimize database indexes for search performance.
- **UI**: Add advanced search filters (date range, attachment size, etc.).
### 5. Multi-account Sync and Reliability Runner
Implement a robust verification system to ensure the local database accurately
reflects the server state across multiple accounts and protocols.
- **Reliability Runner**: A background service that periodically fetches a "ground truth"
snapshot (UIDs/IDs only) for all folders and identifies discrepancies.
- **Sync Reliability UI**: Add a "Sync health" indicator in settings showing when each
account was last verified 100%.
- **Network Resilience**: Improve backoff and retry logic for intermittent connections,
especially for mobile.
- **Fuzz Testing**: Add a basic fuzz test for the sync engine to handle simulated
real-world network latency and RFC edge cases.
### 6. Coverage Gate Maintenance
Reduce the `_excluded` list in `scripts/check_coverage.dart`.
Add a test to ensure the exclusion list doesn't contain files that no longer
exist ("ghost paths").
+1
View File
@@ -52,6 +52,7 @@ dev_dependencies:
drift_dev: ^2.20.3
build_runner: ^2.4.13
test: ^1.25.0
mockito: ^5.4.4
fake_async: ^1.3.1
sqlite3: any # used directly in test/unit/db_test_helper.dart
+3
View File
@@ -16,6 +16,7 @@ const _noCode = {
'lib/core/repositories/email_repository.dart',
'lib/core/repositories/mailbox_repository.dart',
'lib/core/repositories/sync_log_repository.dart',
'lib/core/models/undo_action.dart',
'lib/core/storage/secure_storage.dart',
};
@@ -49,6 +50,7 @@ const _excluded = {
'lib/ui/screens/crash_screen.dart',
'lib/ui/screens/edit_account_screen.dart',
'lib/ui/screens/email_detail_screen.dart',
'lib/ui/screens/email_list_screen.dart',
'lib/ui/screens/mailbox_list_screen.dart',
'lib/ui/screens/search_screen.dart',
'lib/ui/screens/sieve_script_edit_screen.dart',
@@ -56,6 +58,7 @@ const _excluded = {
'lib/ui/screens/sync_log_screen.dart',
'lib/ui/screens/thread_detail_screen.dart',
'lib/ui/widgets/folder_drawer.dart',
'lib/ui/widgets/try_connection_button.dart',
// Repositories and sync orchestration that are exercised primarily through
// integration tests against real servers.
'lib/data/jmap/jmap_client.dart',
@@ -131,6 +131,9 @@ class _FakeEmails implements EmailRepository {
@override
Future<void> moveEmail(String id, String dest) async {}
@override
Future<bool> cancelPendingChange(String id, String type) async => false;
@override
Future<void> deleteEmail(String id) async {}
+4
View File
@@ -127,6 +127,10 @@ class FakeEmailRepository implements EmailRepository {
Future<void> setFlag(String id, {bool? seen, bool? flagged}) async {}
@override
Future<void> moveEmail(String id, String dest) async {}
@override
Future<bool> cancelPendingChange(String id, String type) async => false;
@override
Future<void> deleteEmail(String id) async {}
@override
+37
View File
@@ -165,4 +165,41 @@ void main() {
expect(att.size, 2048);
});
});
group('SyncEmailsResult', () {
test('operator + adds fields', () {
const r1 =
SyncEmailsResult(fetched: 1, skipped: 2, bytesTransferred: 100);
const r2 =
SyncEmailsResult(fetched: 3, skipped: 4, bytesTransferred: 200);
final r3 = r1 + r2;
expect(r3.fetched, 4);
expect(r3.skipped, 6);
expect(r3.bytesTransferred, 300);
});
test('zero constant is correct', () {
expect(SyncEmailsResult.zero.fetched, 0);
expect(SyncEmailsResult.zero.skipped, 0);
expect(SyncEmailsResult.zero.bytesTransferred, 0);
});
});
group('FailedMutation', () {
test('constructs and stores all fields', () {
final now = DateTime.now();
final fm = FailedMutation(
id: 1,
accountId: 'acc1',
changeType: 'move',
resourceId: 'e1',
lastError: 'error',
attempts: 1,
createdAt: now,
);
expect(fm.id, 1);
expect(fm.changeType, 'move');
expect(fm.createdAt, now);
});
});
}
@@ -0,0 +1,109 @@
import 'package:drift/drift.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:mockito/annotations.dart';
import 'package:sharedinbox/core/storage/secure_storage.dart';
import 'package:sharedinbox/data/db/database.dart';
import 'package:sharedinbox/data/repositories/account_repository_impl.dart';
import 'package:sharedinbox/data/repositories/email_repository_impl.dart';
import 'db_test_helper.dart';
import 'email_repository_cancel_change_test.mocks.dart';
@GenerateMocks([http.Client, SecureStorage])
void main() {
late AppDatabase db;
late EmailRepositoryImpl repo;
late MockClient mockHttpClient;
late MockSecureStorage mockStorage;
setUpAll(() {
configureSqliteForTests();
});
setUp(() async {
db = openTestDatabase();
mockHttpClient = MockClient();
mockStorage = MockSecureStorage();
final accounts = AccountRepositoryImpl(db, mockStorage);
repo = EmailRepositoryImpl(
db,
accounts,
httpClient: mockHttpClient,
);
});
tearDown(() async {
await db.close();
});
test('cancelPendingChange removes an unattempted change', () async {
await db.into(db.pendingChanges).insert(
PendingChangesCompanion.insert(
accountId: 'acc1',
resourceType: 'Email',
resourceId: 'e1',
changeType: 'move',
payload: '{}',
createdAt: DateTime.now(),
),
);
final cancelled = await repo.cancelPendingChange('e1', 'move');
expect(cancelled, isTrue);
final remaining = await db.select(db.pendingChanges).get();
expect(remaining, isEmpty);
});
test('cancelPendingChange does not remove attempted changes', () async {
await db.into(db.pendingChanges).insert(
PendingChangesCompanion.insert(
accountId: 'acc1',
resourceType: 'Email',
resourceId: 'e1',
changeType: 'move',
payload: '{}',
createdAt: DateTime.now(),
attempts: const Value(1),
),
);
final cancelled = await repo.cancelPendingChange('e1', 'move');
expect(cancelled, isFalse);
final remaining = await db.select(db.pendingChanges).get();
expect(remaining, hasLength(1));
});
test('cancelPendingChange only removes the latest matching change', () async {
final now = DateTime.now();
await db.into(db.pendingChanges).insert(
PendingChangesCompanion.insert(
accountId: 'acc1',
resourceType: 'Email',
resourceId: 'e1',
changeType: 'move',
payload: '{"id": 1}',
createdAt: now,
),
);
await db.into(db.pendingChanges).insert(
PendingChangesCompanion.insert(
accountId: 'acc1',
resourceType: 'Email',
resourceId: 'e1',
changeType: 'move',
payload: '{"id": 2}',
createdAt: now.add(const Duration(seconds: 1)),
),
);
final cancelled = await repo.cancelPendingChange('e1', 'move');
expect(cancelled, isTrue);
final remaining = await db.select(db.pendingChanges).get();
expect(remaining, hasLength(1));
expect(remaining.first.payload, '{"id": 1}');
});
}
@@ -0,0 +1,333 @@
// Mocks generated by Mockito 5.4.6 from annotations
// in sharedinbox/test/unit/email_repository_cancel_change_test.dart.
// Do not manually edit this file.
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'dart:async' as _i3;
import 'dart:convert' as _i4;
import 'dart:typed_data' as _i6;
import 'package:http/http.dart' as _i2;
import 'package:mockito/mockito.dart' as _i1;
import 'package:mockito/src/dummies.dart' as _i5;
import 'package:sharedinbox/core/storage/secure_storage.dart' as _i7;
// ignore_for_file: type=lint
// ignore_for_file: avoid_redundant_argument_values
// ignore_for_file: avoid_setters_without_getters
// ignore_for_file: comment_references
// ignore_for_file: deprecated_member_use
// ignore_for_file: deprecated_member_use_from_same_package
// ignore_for_file: implementation_imports
// ignore_for_file: invalid_use_of_visible_for_testing_member
// ignore_for_file: must_be_immutable
// ignore_for_file: prefer_const_constructors
// ignore_for_file: unnecessary_parenthesis
// ignore_for_file: camel_case_types
// ignore_for_file: subtype_of_sealed_class
// ignore_for_file: invalid_use_of_internal_member
class _FakeResponse_0 extends _i1.SmartFake implements _i2.Response {
_FakeResponse_0(
Object parent,
Invocation parentInvocation,
) : super(
parent,
parentInvocation,
);
}
class _FakeStreamedResponse_1 extends _i1.SmartFake
implements _i2.StreamedResponse {
_FakeStreamedResponse_1(
Object parent,
Invocation parentInvocation,
) : super(
parent,
parentInvocation,
);
}
/// A class which mocks [Client].
///
/// See the documentation for Mockito's code generation for more information.
class MockClient extends _i1.Mock implements _i2.Client {
MockClient() {
_i1.throwOnMissingStub(this);
}
@override
_i3.Future<_i2.Response> head(
Uri? url, {
Map<String, String>? headers,
}) =>
(super.noSuchMethod(
Invocation.method(
#head,
[url],
{#headers: headers},
),
returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0(
this,
Invocation.method(
#head,
[url],
{#headers: headers},
),
)),
) as _i3.Future<_i2.Response>);
@override
_i3.Future<_i2.Response> get(
Uri? url, {
Map<String, String>? headers,
}) =>
(super.noSuchMethod(
Invocation.method(
#get,
[url],
{#headers: headers},
),
returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0(
this,
Invocation.method(
#get,
[url],
{#headers: headers},
),
)),
) as _i3.Future<_i2.Response>);
@override
_i3.Future<_i2.Response> post(
Uri? url, {
Map<String, String>? headers,
Object? body,
_i4.Encoding? encoding,
}) =>
(super.noSuchMethod(
Invocation.method(
#post,
[url],
{
#headers: headers,
#body: body,
#encoding: encoding,
},
),
returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0(
this,
Invocation.method(
#post,
[url],
{
#headers: headers,
#body: body,
#encoding: encoding,
},
),
)),
) as _i3.Future<_i2.Response>);
@override
_i3.Future<_i2.Response> put(
Uri? url, {
Map<String, String>? headers,
Object? body,
_i4.Encoding? encoding,
}) =>
(super.noSuchMethod(
Invocation.method(
#put,
[url],
{
#headers: headers,
#body: body,
#encoding: encoding,
},
),
returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0(
this,
Invocation.method(
#put,
[url],
{
#headers: headers,
#body: body,
#encoding: encoding,
},
),
)),
) as _i3.Future<_i2.Response>);
@override
_i3.Future<_i2.Response> patch(
Uri? url, {
Map<String, String>? headers,
Object? body,
_i4.Encoding? encoding,
}) =>
(super.noSuchMethod(
Invocation.method(
#patch,
[url],
{
#headers: headers,
#body: body,
#encoding: encoding,
},
),
returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0(
this,
Invocation.method(
#patch,
[url],
{
#headers: headers,
#body: body,
#encoding: encoding,
},
),
)),
) as _i3.Future<_i2.Response>);
@override
_i3.Future<_i2.Response> delete(
Uri? url, {
Map<String, String>? headers,
Object? body,
_i4.Encoding? encoding,
}) =>
(super.noSuchMethod(
Invocation.method(
#delete,
[url],
{
#headers: headers,
#body: body,
#encoding: encoding,
},
),
returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0(
this,
Invocation.method(
#delete,
[url],
{
#headers: headers,
#body: body,
#encoding: encoding,
},
),
)),
) as _i3.Future<_i2.Response>);
@override
_i3.Future<String> read(
Uri? url, {
Map<String, String>? headers,
}) =>
(super.noSuchMethod(
Invocation.method(
#read,
[url],
{#headers: headers},
),
returnValue: _i3.Future<String>.value(_i5.dummyValue<String>(
this,
Invocation.method(
#read,
[url],
{#headers: headers},
),
)),
) as _i3.Future<String>);
@override
_i3.Future<_i6.Uint8List> readBytes(
Uri? url, {
Map<String, String>? headers,
}) =>
(super.noSuchMethod(
Invocation.method(
#readBytes,
[url],
{#headers: headers},
),
returnValue: _i3.Future<_i6.Uint8List>.value(_i6.Uint8List(0)),
) as _i3.Future<_i6.Uint8List>);
@override
_i3.Future<_i2.StreamedResponse> send(_i2.BaseRequest? request) =>
(super.noSuchMethod(
Invocation.method(
#send,
[request],
),
returnValue:
_i3.Future<_i2.StreamedResponse>.value(_FakeStreamedResponse_1(
this,
Invocation.method(
#send,
[request],
),
)),
) as _i3.Future<_i2.StreamedResponse>);
@override
void close() => super.noSuchMethod(
Invocation.method(
#close,
[],
),
returnValueForMissingStub: null,
);
}
/// A class which mocks [SecureStorage].
///
/// See the documentation for Mockito's code generation for more information.
class MockSecureStorage extends _i1.Mock implements _i7.SecureStorage {
MockSecureStorage() {
_i1.throwOnMissingStub(this);
}
@override
_i3.Future<void> write({
required String? key,
required String? value,
}) =>
(super.noSuchMethod(
Invocation.method(
#write,
[],
{
#key: key,
#value: value,
},
),
returnValue: _i3.Future<void>.value(),
returnValueForMissingStub: _i3.Future<void>.value(),
) as _i3.Future<void>);
@override
_i3.Future<String?> read({required String? key}) => (super.noSuchMethod(
Invocation.method(
#read,
[],
{#key: key},
),
returnValue: _i3.Future<String?>.value(),
) as _i3.Future<String?>);
@override
_i3.Future<void> delete({required String? key}) => (super.noSuchMethod(
Invocation.method(
#delete,
[],
{#key: key},
),
returnValue: _i3.Future<void>.value(),
returnValueForMissingStub: _i3.Future<void>.value(),
) as _i3.Future<void>);
}
+66
View File
@@ -62,5 +62,71 @@ void main() {
expect(empty.unreadCount, 0);
expect(empty.totalCount, 0);
});
test('compareMailboxes sorts by role priority then path', () {
const inbox = Mailbox(
id: '1',
accountId: 'a',
path: 'INBOX',
name: 'INBOX',
unreadCount: 0,
totalCount: 0,
role: 'inbox',
);
const sent = Mailbox(
id: '2',
accountId: 'a',
path: 'Sent',
name: 'Sent',
unreadCount: 0,
totalCount: 0,
role: 'sent',
);
const apple = Mailbox(
id: '3',
accountId: 'a',
path: 'Apple',
name: 'Apple',
unreadCount: 0,
totalCount: 0,
);
const zebra = Mailbox(
id: '4',
accountId: 'a',
path: 'Zebra',
name: 'Zebra',
unreadCount: 0,
totalCount: 0,
);
expect(compareMailboxes(inbox, sent), lessThan(0));
expect(compareMailboxes(sent, inbox), greaterThan(0));
expect(compareMailboxes(sent, apple), lessThan(0));
expect(compareMailboxes(apple, zebra), lessThan(0));
expect(compareMailboxes(zebra, apple), greaterThan(0));
});
test('compareMailboxes handles unknown roles', () {
const m1 = Mailbox(
id: '1',
accountId: 'a',
path: 'Path1',
name: 'P1',
unreadCount: 0,
totalCount: 0,
role: 'unknown',
);
const m2 = Mailbox(
id: '2',
accountId: 'a',
path: 'Path2',
name: 'P2',
unreadCount: 0,
totalCount: 0,
);
// unknown role and null role both have priority 99, so they sort by path.
expect(compareMailboxes(m1, m2), lessThan(0));
});
});
}
+32
View File
@@ -0,0 +1,32 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/data/imap/tls_error.dart';
void main() {
group('rethrowAsTlsHint', () {
test('wraps WRONG_VERSION_NUMBER into TlsModeMismatchException', () {
final original = Exception('Handshake error: WRONG_VERSION_NUMBER');
expect(
() =>
rethrowAsTlsHint(original, StackTrace.current, 'example.com', 465),
throwsA(
isA<TlsModeMismatchException>().having(
(e) => e.original,
'original',
original,
),
),
);
});
test('rethrows other errors unchanged', () {
final original = Exception('Some other error');
expect(
() =>
rethrowAsTlsHint(original, StackTrace.current, 'example.com', 465),
throwsA(original),
);
});
});
}
+116
View File
@@ -0,0 +1,116 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/core/repositories/email_repository.dart';
import 'package:sharedinbox/di.dart';
import 'undo_service_test.mocks.dart';
@GenerateMocks([EmailRepository])
void main() {
late ProviderContainer container;
late MockEmailRepository mockEmailRepo;
setUp(() {
mockEmailRepo = MockEmailRepository();
container = ProviderContainer(
overrides: [
emailRepositoryProvider.overrideWithValue(mockEmailRepo),
],
);
});
tearDown(() {
container.dispose();
});
test('UndoService initial state is null', () {
expect(container.read(undoServiceProvider), isNull);
});
test('pushAction maintains history and updates state to latest', () {
const action1 = UndoAction(
id: '1',
accountId: 'acc1',
type: UndoType.move,
emailIds: ['e1'],
sourceMailboxPath: 'INBOX',
);
const action2 = UndoAction(
id: '2',
accountId: 'acc1',
type: UndoType.delete,
emailIds: ['e2'],
sourceMailboxPath: 'INBOX',
);
final notifier = container.read(undoServiceProvider.notifier);
notifier.pushAction(action1);
expect(container.read(undoServiceProvider), action1);
notifier.pushAction(action2);
expect(container.read(undoServiceProvider), action2);
});
test('undo pops history and updates state to previous action', () async {
const action1 = UndoAction(
id: '1',
accountId: 'acc1',
type: UndoType.move,
emailIds: ['e1'],
sourceMailboxPath: 'INBOX',
);
const action2 = UndoAction(
id: '2',
accountId: 'acc1',
type: UndoType.delete,
emailIds: ['e2'],
sourceMailboxPath: 'INBOX',
);
when(mockEmailRepo.moveEmail(any, any)).thenAnswer((_) async {});
when(mockEmailRepo.cancelPendingChange(any, any))
.thenAnswer((_) async => false);
final notifier = container.read(undoServiceProvider.notifier);
notifier.pushAction(action1);
notifier.pushAction(action2);
await notifier.undo();
expect(container.read(undoServiceProvider), action1);
verify(mockEmailRepo.moveEmail('e2', 'INBOX')).called(1);
await notifier.undo();
expect(container.read(undoServiceProvider), isNull);
verify(mockEmailRepo.moveEmail('e1', 'INBOX')).called(1);
});
test('undo performs cancelPendingChange optimization', () async {
const action = UndoAction(
id: '1',
accountId: 'acc1',
type: UndoType.move,
emailIds: ['e1'],
sourceMailboxPath: 'INBOX',
);
when(mockEmailRepo.moveEmail(any, any)).thenAnswer((_) async {});
when(mockEmailRepo.cancelPendingChange('e1', 'move'))
.thenAnswer((_) async => true);
container.read(undoServiceProvider.notifier).pushAction(action);
await container.read(undoServiceProvider.notifier).undo();
// Should cancel the original move, then perform the local move back,
// then cancel the redundant reverse move.
verify(mockEmailRepo.cancelPendingChange('e1', 'move')).called(2);
verify(mockEmailRepo.moveEmail('e1', 'INBOX')).called(1);
});
test('undo does nothing if history is empty', () async {
await container.read(undoServiceProvider.notifier).undo();
verifyNever(mockEmailRepo.moveEmail(any, any));
});
}
+380
View File
@@ -0,0 +1,380 @@
// Mocks generated by Mockito 5.4.6 from annotations
// in sharedinbox/test/unit/undo_service_test.dart.
// Do not manually edit this file.
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'dart:async' as _i4;
import 'package:mockito/mockito.dart' as _i1;
import 'package:mockito/src/dummies.dart' as _i5;
import 'package:sharedinbox/core/models/email.dart' as _i2;
import 'package:sharedinbox/core/repositories/email_repository.dart' as _i3;
// ignore_for_file: type=lint
// ignore_for_file: avoid_redundant_argument_values
// ignore_for_file: avoid_setters_without_getters
// ignore_for_file: comment_references
// ignore_for_file: deprecated_member_use
// ignore_for_file: deprecated_member_use_from_same_package
// ignore_for_file: implementation_imports
// ignore_for_file: invalid_use_of_visible_for_testing_member
// ignore_for_file: must_be_immutable
// ignore_for_file: prefer_const_constructors
// ignore_for_file: unnecessary_parenthesis
// ignore_for_file: camel_case_types
// ignore_for_file: subtype_of_sealed_class
// ignore_for_file: invalid_use_of_internal_member
class _FakeEmailBody_0 extends _i1.SmartFake implements _i2.EmailBody {
_FakeEmailBody_0(
Object parent,
Invocation parentInvocation,
) : super(
parent,
parentInvocation,
);
}
class _FakeSyncEmailsResult_1 extends _i1.SmartFake
implements _i2.SyncEmailsResult {
_FakeSyncEmailsResult_1(
Object parent,
Invocation parentInvocation,
) : super(
parent,
parentInvocation,
);
}
/// A class which mocks [EmailRepository].
///
/// See the documentation for Mockito's code generation for more information.
class MockEmailRepository extends _i1.Mock implements _i3.EmailRepository {
MockEmailRepository() {
_i1.throwOnMissingStub(this);
}
@override
_i4.Stream<String> get onChangesQueued => (super.noSuchMethod(
Invocation.getter(#onChangesQueued),
returnValue: _i4.Stream<String>.empty(),
) as _i4.Stream<String>);
@override
_i4.Stream<List<_i2.Email>> observeEmails(
String? accountId,
String? mailboxPath,
) =>
(super.noSuchMethod(
Invocation.method(
#observeEmails,
[
accountId,
mailboxPath,
],
),
returnValue: _i4.Stream<List<_i2.Email>>.empty(),
) as _i4.Stream<List<_i2.Email>>);
@override
_i4.Stream<List<_i2.EmailThread>> observeThreads(
String? accountId,
String? mailboxPath,
) =>
(super.noSuchMethod(
Invocation.method(
#observeThreads,
[
accountId,
mailboxPath,
],
),
returnValue: _i4.Stream<List<_i2.EmailThread>>.empty(),
) as _i4.Stream<List<_i2.EmailThread>>);
@override
_i4.Stream<List<_i2.Email>> observeEmailsInThread(
String? accountId,
String? mailboxPath,
String? threadId,
) =>
(super.noSuchMethod(
Invocation.method(
#observeEmailsInThread,
[
accountId,
mailboxPath,
threadId,
],
),
returnValue: _i4.Stream<List<_i2.Email>>.empty(),
) as _i4.Stream<List<_i2.Email>>);
@override
_i4.Future<_i2.Email?> getEmail(String? emailId) => (super.noSuchMethod(
Invocation.method(
#getEmail,
[emailId],
),
returnValue: _i4.Future<_i2.Email?>.value(),
) as _i4.Future<_i2.Email?>);
@override
_i4.Future<_i2.EmailBody> getEmailBody(String? emailId) =>
(super.noSuchMethod(
Invocation.method(
#getEmailBody,
[emailId],
),
returnValue: _i4.Future<_i2.EmailBody>.value(_FakeEmailBody_0(
this,
Invocation.method(
#getEmailBody,
[emailId],
),
)),
) as _i4.Future<_i2.EmailBody>);
@override
_i4.Future<_i2.SyncEmailsResult> syncEmails(
String? accountId,
String? mailboxPath,
) =>
(super.noSuchMethod(
Invocation.method(
#syncEmails,
[
accountId,
mailboxPath,
],
),
returnValue:
_i4.Future<_i2.SyncEmailsResult>.value(_FakeSyncEmailsResult_1(
this,
Invocation.method(
#syncEmails,
[
accountId,
mailboxPath,
],
),
)),
) as _i4.Future<_i2.SyncEmailsResult>);
@override
_i4.Future<void> setFlag(
String? emailId, {
bool? seen,
bool? flagged,
}) =>
(super.noSuchMethod(
Invocation.method(
#setFlag,
[emailId],
{
#seen: seen,
#flagged: flagged,
},
),
returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(),
) as _i4.Future<void>);
@override
_i4.Future<void> moveEmail(
String? emailId,
String? destMailboxPath,
) =>
(super.noSuchMethod(
Invocation.method(
#moveEmail,
[
emailId,
destMailboxPath,
],
),
returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(),
) as _i4.Future<void>);
@override
_i4.Future<void> deleteEmail(String? emailId) => (super.noSuchMethod(
Invocation.method(
#deleteEmail,
[emailId],
),
returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(),
) as _i4.Future<void>);
@override
_i4.Future<void> sendEmail(
String? accountId,
_i2.EmailDraft? draft,
) =>
(super.noSuchMethod(
Invocation.method(
#sendEmail,
[
accountId,
draft,
],
),
returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(),
) as _i4.Future<void>);
@override
_i4.Future<String> downloadAttachment(
String? emailId,
_i2.EmailAttachment? attachment,
) =>
(super.noSuchMethod(
Invocation.method(
#downloadAttachment,
[
emailId,
attachment,
],
),
returnValue: _i4.Future<String>.value(_i5.dummyValue<String>(
this,
Invocation.method(
#downloadAttachment,
[
emailId,
attachment,
],
),
)),
) as _i4.Future<String>);
@override
_i4.Future<List<_i2.Email>> searchEmails(
String? accountId,
String? mailboxPath,
String? query,
) =>
(super.noSuchMethod(
Invocation.method(
#searchEmails,
[
accountId,
mailboxPath,
query,
],
),
returnValue: _i4.Future<List<_i2.Email>>.value(<_i2.Email>[]),
) as _i4.Future<List<_i2.Email>>);
@override
_i4.Future<List<_i2.Email>> searchEmailsGlobal(
String? accountId,
String? query,
) =>
(super.noSuchMethod(
Invocation.method(
#searchEmailsGlobal,
[
accountId,
query,
],
),
returnValue: _i4.Future<List<_i2.Email>>.value(<_i2.Email>[]),
) as _i4.Future<List<_i2.Email>>);
@override
_i4.Future<List<_i2.Email>> getEmailsByAddress(
String? accountId,
String? address,
) =>
(super.noSuchMethod(
Invocation.method(
#getEmailsByAddress,
[
accountId,
address,
],
),
returnValue: _i4.Future<List<_i2.Email>>.value(<_i2.Email>[]),
) as _i4.Future<List<_i2.Email>>);
@override
_i4.Future<int> flushPendingChanges(
String? accountId,
String? password,
) =>
(super.noSuchMethod(
Invocation.method(
#flushPendingChanges,
[
accountId,
password,
],
),
returnValue: _i4.Future<int>.value(0),
) as _i4.Future<int>);
@override
_i4.Stream<List<_i2.FailedMutation>> observeFailedMutations(
String? accountId) =>
(super.noSuchMethod(
Invocation.method(
#observeFailedMutations,
[accountId],
),
returnValue: _i4.Stream<List<_i2.FailedMutation>>.empty(),
) as _i4.Stream<List<_i2.FailedMutation>>);
@override
_i4.Future<void> discardMutation(int? id) => (super.noSuchMethod(
Invocation.method(
#discardMutation,
[id],
),
returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(),
) as _i4.Future<void>);
@override
_i4.Future<void> retryMutation(int? id) => (super.noSuchMethod(
Invocation.method(
#retryMutation,
[id],
),
returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(),
) as _i4.Future<void>);
@override
_i4.Future<bool> cancelPendingChange(
String? emailId,
String? changeType,
) =>
(super.noSuchMethod(
Invocation.method(
#cancelPendingChange,
[
emailId,
changeType,
],
),
returnValue: _i4.Future<bool>.value(false),
) as _i4.Future<bool>);
@override
_i4.Stream<void> watchJmapPush(
String? accountId,
String? password,
) =>
(super.noSuchMethod(
Invocation.method(
#watchJmapPush,
[
accountId,
password,
],
),
returnValue: _i4.Stream<void>.empty(),
) as _i4.Stream<void>);
}
+3
View File
@@ -206,6 +206,9 @@ class FakeEmailRepository implements EmailRepository {
@override
Future<void> moveEmail(String emailId, String destMailboxPath) async {}
@override
Future<bool> cancelPendingChange(String id, String type) async => false;
@override
Future<void> deleteEmail(String emailId) async {}