feat(U6): show sync status indicator in email list app bar

Replaces the static sync icon with a live status widget:
- Spinner (CircularProgressIndicator) while a sync cycle is active
- Red sync_problem icon when the last sync recorded an error
- Regular sync icon when idle (tapping still triggers a manual sync)

AccountSyncManager now exposes watchSyncing(accountId) — a broadcast
stream that emits true/false as each sync loop starts and ends.
The isSyncingProvider in di.dart bridges this to Riverpod.
This commit is contained in:
Thomas SharedInbox
2026-05-14 04:19:48 +02:00
parent 2715c1613f
commit db3bf5937d
3 changed files with 84 additions and 20 deletions
+40 -4
View File
@@ -44,6 +44,16 @@ class AccountSyncManager {
StreamSubscription<List<Account>>? _accountsSub;
StreamSubscription<String>? _onChangesSub;
final _syncPhaseCtrl = StreamController<(String, bool)>.broadcast();
/// Emits `true` when [accountId] starts syncing, `false` when it stops.
Stream<bool> watchSyncing(String accountId) =>
_syncPhaseCtrl.stream.where((e) => e.$1 == accountId).map((e) => e.$2);
void _emitSyncing(String accountId, {required bool syncing}) {
if (!_syncPhaseCtrl.isClosed) _syncPhaseCtrl.add((accountId, syncing));
}
void start() {
_onChangesSub = _emails.onChangesQueued.listen((accountId) {
_active[accountId]?.kick();
@@ -54,6 +64,7 @@ class AccountSyncManager {
for (final account in accounts) {
if (_active.containsKey(account.id)) continue;
final id = account.id;
final loop = switch (account.type) {
AccountType.imap => _AccountSync(
account,
@@ -64,6 +75,8 @@ class AccountSyncManager {
_syncLog,
_drafts,
_onNewMail,
onSyncStart: () => _emitSyncing(id, syncing: true),
onSyncEnd: () => _emitSyncing(id, syncing: false),
),
AccountType.jmap => _JmapAccountSync(
account,
@@ -71,6 +84,8 @@ class AccountSyncManager {
_emails,
_accounts,
_syncLog,
onSyncStart: () => _emitSyncing(id, syncing: true),
onSyncEnd: () => _emitSyncing(id, syncing: false),
),
};
_active[account.id] = loop;
@@ -92,6 +107,7 @@ class AccountSyncManager {
s.stop();
}
_active.clear();
unawaited(_syncPhaseCtrl.close());
}
/// Wakes the idle/wait phase of the given account's sync loop so a new
@@ -126,6 +142,8 @@ class AccountSyncManager {
_syncLog,
_drafts,
_onNewMail,
onSyncStart: () => _emitSyncing(accountId, syncing: true),
onSyncEnd: () => _emitSyncing(accountId, syncing: false),
),
AccountType.jmap => _JmapAccountSync(
account,
@@ -133,6 +151,8 @@ class AccountSyncManager {
_emails,
_accounts,
_syncLog,
onSyncStart: () => _emitSyncing(accountId, syncing: true),
onSyncEnd: () => _emitSyncing(accountId, syncing: false),
),
};
_active[accountId] = loop;
@@ -159,8 +179,11 @@ class _AccountSync implements _SyncLoop {
this._imapConnect,
this._syncLog,
this._drafts,
this._onNewMail,
);
this._onNewMail, {
void Function()? onSyncStart,
void Function()? onSyncEnd,
}) : _onSyncStart = onSyncStart,
_onSyncEnd = onSyncEnd;
final Account account;
final AccountRepository _accounts;
@@ -170,6 +193,8 @@ class _AccountSync implements _SyncLoop {
final SyncLogRepository _syncLog;
final DraftRepository? _drafts;
final OnNewMailCallback? _onNewMail;
final void Function()? _onSyncStart;
final void Function()? _onSyncEnd;
imap.ImapClient? _idleClient;
bool _running = false;
@@ -202,6 +227,7 @@ class _AccountSync implements _SyncLoop {
Future<void> _loop() async {
while (_running) {
final startedAt = DateTime.now();
_onSyncStart?.call();
try {
final (_SyncStats stats, String? capturedLog) = await _runSync(
account.verbose,
@@ -221,8 +247,10 @@ class _AccountSync implements _SyncLoop {
protocolLog: capturedLog,
);
_backoffSeconds = 5;
_onSyncEnd?.call();
await _idle();
} catch (e, st) {
_onSyncEnd?.call();
final isPermanent = _isPermanentError(e);
try {
await _syncLog.log(
@@ -392,14 +420,19 @@ class _JmapAccountSync implements _SyncLoop {
this._mailboxes,
this._emails,
this._accounts,
this._syncLog,
);
this._syncLog, {
void Function()? onSyncStart,
void Function()? onSyncEnd,
}) : _onSyncStart = onSyncStart,
_onSyncEnd = onSyncEnd;
final Account account;
final MailboxRepository _mailboxes;
final EmailRepository _emails;
final AccountRepository _accounts;
final SyncLogRepository _syncLog;
final void Function()? _onSyncStart;
final void Function()? _onSyncEnd;
bool _running = false;
int _backoffSeconds = 5;
@@ -431,6 +464,7 @@ class _JmapAccountSync implements _SyncLoop {
Future<void> _loop() async {
while (_running) {
final startedAt = DateTime.now();
_onSyncStart?.call();
try {
final (_SyncStats stats, String? capturedLog) = await _runSync(
account.verbose,
@@ -450,8 +484,10 @@ class _JmapAccountSync implements _SyncLoop {
protocolLog: capturedLog,
);
_backoffSeconds = 5;
_onSyncEnd?.call();
await _wait();
} catch (e, st) {
_onSyncEnd?.call();
final isPermanent = _isPermanentError(e);
try {
await _syncLog.log(
+5
View File
@@ -115,6 +115,11 @@ final syncHealthProvider =
.watchSingleOrNull();
});
final isSyncingProvider =
StreamProvider.autoDispose.family<bool, String>((ref, accountId) {
return ref.watch(syncManagerProvider).watchSyncing(accountId);
});
final syncManagerProvider = Provider<AccountSyncManager>((ref) {
final manager = AccountSyncManager(
ref.watch(accountRepositoryProvider),
+39 -16
View File
@@ -180,22 +180,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
),
),
),
IconButton(
icon: const Icon(Icons.sync),
onPressed: () async {
try {
await emailRepo.syncEmails(
widget.accountId,
widget.mailboxPath,
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Sync failed: $e')));
}
},
),
_buildSyncButton(emailRepo),
IconButton(
icon: const Icon(Icons.edit),
onPressed: () => context.push(
@@ -229,6 +214,44 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
);
}
Widget _buildSyncButton(EmailRepository emailRepo) {
final isSyncing =
ref.watch(isSyncingProvider(widget.accountId)).valueOrNull ?? false;
final hasError =
ref.watch(syncLastErrorProvider(widget.accountId)).valueOrNull != null;
return IconButton(
tooltip: isSyncing
? 'Syncing…'
: hasError
? 'Sync error'
: 'Sync',
icon: isSyncing
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: hasError
? const Icon(Icons.sync_problem, color: Colors.red)
: const Icon(Icons.sync),
onPressed: isSyncing
? null
: () async {
try {
await emailRepo.syncEmails(
widget.accountId,
widget.mailboxPath,
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Sync failed: $e')),
);
}
},
);
}
Widget _selectionBottomBar() {
return BottomAppBar(
child: Row(