Compare commits

...
Author SHA1 Message Date
Thomas SharedInboxandClaude Sonnet 4.6 cf6e9c9af3 feat(R4): show dismissible sync error banner in email list
When the most recent sync for an account fails, a MaterialBanner now
appears at the top of EmailListScreen showing the error message with
Retry and Dismiss actions. The banner disappears automatically once a
subsequent sync succeeds.

- SyncLogRepository.observeLastError() — new lightweight stream that
  emits the last error message (or null on success / no history).
- syncLastErrorProvider — Riverpod StreamProvider.family wiring it up.
- EmailListScreen tracks dismissed errors per-session to avoid re-showing
  the same message the user already acknowledged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 23:10:18 +02:00
Bot of Thomas Güttler beae8d8843 feat(R2): force full sync escape hatch in account edit screen (#22) 2026-05-13 22:57:36 +02:00
Bot of Thomas Güttler eddcc17c41 fix(R1): persist undo history across restarts (#20) 2026-05-13 22:35:08 +02:00
19 changed files with 371 additions and 60 deletions
@@ -99,4 +99,9 @@ abstract class EmailRepository {
String accountId,
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.
Future<Mailbox?> findMailboxByRole(String accountId, String role);
/// Deletes all locally-cached mailbox rows for [accountId].
Future<void> clearForResync(String accountId);
}
@@ -65,6 +65,10 @@ abstract class SyncLogRepository {
});
Stream<List<SyncLogEntry>> observeSyncLogs(String accountId);
/// Emits the error message of the most recent sync attempt for [accountId],
/// or null when the last sync succeeded (or no syncs have run yet).
Stream<String?> observeLastError(String accountId);
}
class NoOpSyncLogRepository implements SyncLogRepository {
@@ -90,4 +94,7 @@ class NoOpSyncLogRepository implements SyncLogRepository {
@override
Stream<List<SyncLogEntry>> observeSyncLogs(String accountId) =>
Stream.value([]);
@override
Stream<String?> observeLastError(String accountId) => Stream.value(null);
}
+13 -4
View File
@@ -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;
+37
View File
@@ -88,6 +88,43 @@ class AccountSyncManager {
void syncNow(String accountId) {
_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 ──────────────────────────────────────────────────────────
@@ -2739,4 +2739,27 @@ class EmailRepositoryImpl implements EmailRepository {
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';
return null;
}
@override
Future<void> clearForResync(String accountId) async {
await (_db.delete(_db.mailboxes)
..where((t) => t.accountId.equals(accountId)))
.go();
}
}
@@ -99,4 +99,14 @@ class SyncLogRepositoryImpl implements SyncLogRepository {
return entries;
});
}
@override
Stream<String?> observeLastError(String accountId) {
return (_db.select(_db.syncLogs)
..where((t) => t.accountId.equals(accountId))
..orderBy([(t) => OrderingTerm.desc(t.startedAt)])
..limit(1))
.watchSingleOrNull()
.map((row) => (row?.result == 'error') ? row?.errorMessage : null);
}
}
+5
View File
@@ -85,6 +85,11 @@ final syncLogRepositoryProvider = Provider((ref) {
return SyncLogRepositoryImpl(ref.watch(dbProvider));
});
final syncLastErrorProvider =
StreamProvider.autoDispose.family<String?, String>((ref, accountId) {
return ref.watch(syncLogRepositoryProvider).observeLastError(accountId);
});
final reliabilityRunnerProvider = Provider<ReliabilityRunner>((ref) {
final runner = ReliabilityRunner(
ref.watch(dbProvider),
+49 -4
View File
@@ -43,6 +43,7 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
bool _tryTesting = false;
String? _tryOk;
String? _tryErr;
bool _resyncing = false;
@override
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 {
if (!_formKey.currentState!.validate()) return;
final password = _passwordCtrl.text.isNotEmpty ? _passwordCtrl.text : null;
@@ -230,11 +268,9 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Edit account')),
body: _loading
body: _loading || _saving || _resyncing
? const Center(child: CircularProgressIndicator())
: _saving
? const Center(child: CircularProgressIndicator())
: _buildForm(),
: _buildForm(),
);
}
@@ -350,6 +386,15 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
),
const SizedBox(height: 8),
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,
),
],
),
),
+24 -20
View File
@@ -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) {
+56 -9
View File
@@ -35,6 +35,9 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
bool _searchLoading = false;
bool get _searching => _searchController.text.isNotEmpty;
// Error banner — tracks the last error message that the user dismissed.
String? _dismissedError;
// Thread-level selection (key = threadId).
final Set<String> _selectedThreadIds = {};
// Last-emitted thread list, used to resolve emailIds for batch operations.
@@ -131,9 +134,16 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
currentMailboxPath: widget.mailboxPath,
),
bottomNavigationBar: _selecting ? _selectionBottomBar() : null,
body: (_searchResults != null || _searchLoading)
? _buildSearchBody()
: _buildStreamBody(repo),
body: Column(
children: [
_buildSyncErrorBanner(),
Expanded(
child: (_searchResults != null || _searchLoading)
? _buildSearchBody()
: _buildStreamBody(repo),
),
],
),
);
}
@@ -267,6 +277,39 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
return _buildEmailList(_searchResults!);
}
Widget _buildSyncErrorBanner() {
final errorAsync = ref.watch(syncLastErrorProvider(widget.accountId));
final error = errorAsync.valueOrNull;
if (error == null || error == _dismissedError) {
return const SizedBox.shrink();
}
return MaterialBanner(
padding: const EdgeInsets.fromLTRB(16, 8, 8, 8),
content: Text(
error,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
leading: Icon(
Icons.sync_problem,
color: Theme.of(context).colorScheme.error,
),
backgroundColor: Theme.of(context).colorScheme.errorContainer,
actions: [
TextButton(
onPressed: () {
ref.read(syncManagerProvider).syncNow(widget.accountId);
},
child: const Text('Retry'),
),
TextButton(
onPressed: () => setState(() => _dismissedError = error),
child: const Text('Dismiss'),
),
],
);
}
Widget _buildStreamBody(EmailRepository emailRepo) {
return RefreshIndicator(
onRefresh: () async {
@@ -331,7 +374,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 +407,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 +469,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 +501,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 +652,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 +670,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,
+12 -10
View File
@@ -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],
),
),
);
);
}
}
}
+4 -1
View File
@@ -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()),
),
],
),
@@ -96,6 +96,9 @@ class _FakeMailboxes implements MailboxRepository {
@override
Future<Mailbox?> findMailboxByRole(String accountId, String role) async =>
null;
@override
Future<void> clearForResync(String accountId) async {}
}
class _FakeEmails implements EmailRepository {
@@ -191,6 +194,9 @@ class _FakeEmails implements EmailRepository {
@override
Future<void> retryMutation(int id) async {}
@override
Future<void> clearForResync(String accountId) async {}
}
class _FakeLogs implements SyncLogRepository {
@@ -214,4 +220,7 @@ class _FakeLogs implements SyncLogRepository {
@override
Stream<List<SyncLogEntry>> observeSyncLogs(String accountId) =>
Stream.value([]);
@override
Stream<String?> observeLastError(String accountId) => Stream.value(null);
}
+8
View File
@@ -98,6 +98,9 @@ class FakeEmailRepository implements EmailRepository {
String mailboxPath,
) async =>
ReliabilityResult.healthy;
@override
Future<void> clearForResync(String accountId) async {}
}
class _Log {
@@ -129,6 +132,9 @@ class FakeSyncLogRepository implements SyncLogRepository {
@override
Stream<List<SyncLogEntry>> observeSyncLogs(String accountId) =>
Stream.value([]);
@override
Stream<String?> observeLastError(String accountId) => Stream.value(null);
}
class FakeMailboxRepositoryWithInbox implements MailboxRepository {
@@ -148,4 +154,6 @@ class FakeMailboxRepositoryWithInbox implements MailboxRepository {
Future<int> syncMailboxes(String id) async => 1;
@override
Future<Mailbox?> findMailboxByRole(String id, String role) async => null;
@override
Future<void> clearForResync(String accountId) async {}
}
+4 -4
View File
@@ -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
+90 -8
View File
@@ -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]);
});
}
+5
View File
@@ -132,6 +132,8 @@ class FakeMailboxRepository implements MailboxRepository {
@override
Future<Mailbox?> findMailboxByRole(String accountId, String role) async =>
_mailboxes.where((m) => m.role == role).firstOrNull;
@override
Future<void> clearForResync(String accountId) async {}
}
class FakeEmailRepository implements EmailRepository {
@@ -279,6 +281,9 @@ class FakeEmailRepository implements EmailRepository {
@override
Future<void> retryMutation(int id) async {}
@override
Future<void> clearForResync(String accountId) async {}
}
// ---------------------------------------------------------------------------