Compare commits

...
Author SHA1 Message Date
Thomas SharedInbox db3bf5937d 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.
2026-05-14 04:19:48 +02:00
3 changed files with 84 additions and 20 deletions
+40 -4
View File
@@ -44,6 +44,16 @@ class AccountSyncManager {
StreamSubscription<List<Account>>? _accountsSub; StreamSubscription<List<Account>>? _accountsSub;
StreamSubscription<String>? _onChangesSub; 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() { void start() {
_onChangesSub = _emails.onChangesQueued.listen((accountId) { _onChangesSub = _emails.onChangesQueued.listen((accountId) {
_active[accountId]?.kick(); _active[accountId]?.kick();
@@ -54,6 +64,7 @@ class AccountSyncManager {
for (final account in accounts) { for (final account in accounts) {
if (_active.containsKey(account.id)) continue; if (_active.containsKey(account.id)) continue;
final id = account.id;
final loop = switch (account.type) { final loop = switch (account.type) {
AccountType.imap => _AccountSync( AccountType.imap => _AccountSync(
account, account,
@@ -64,6 +75,8 @@ class AccountSyncManager {
_syncLog, _syncLog,
_drafts, _drafts,
_onNewMail, _onNewMail,
onSyncStart: () => _emitSyncing(id, syncing: true),
onSyncEnd: () => _emitSyncing(id, syncing: false),
), ),
AccountType.jmap => _JmapAccountSync( AccountType.jmap => _JmapAccountSync(
account, account,
@@ -71,6 +84,8 @@ class AccountSyncManager {
_emails, _emails,
_accounts, _accounts,
_syncLog, _syncLog,
onSyncStart: () => _emitSyncing(id, syncing: true),
onSyncEnd: () => _emitSyncing(id, syncing: false),
), ),
}; };
_active[account.id] = loop; _active[account.id] = loop;
@@ -92,6 +107,7 @@ class AccountSyncManager {
s.stop(); s.stop();
} }
_active.clear(); _active.clear();
unawaited(_syncPhaseCtrl.close());
} }
/// Wakes the idle/wait phase of the given account's sync loop so a new /// Wakes the idle/wait phase of the given account's sync loop so a new
@@ -126,6 +142,8 @@ class AccountSyncManager {
_syncLog, _syncLog,
_drafts, _drafts,
_onNewMail, _onNewMail,
onSyncStart: () => _emitSyncing(accountId, syncing: true),
onSyncEnd: () => _emitSyncing(accountId, syncing: false),
), ),
AccountType.jmap => _JmapAccountSync( AccountType.jmap => _JmapAccountSync(
account, account,
@@ -133,6 +151,8 @@ class AccountSyncManager {
_emails, _emails,
_accounts, _accounts,
_syncLog, _syncLog,
onSyncStart: () => _emitSyncing(accountId, syncing: true),
onSyncEnd: () => _emitSyncing(accountId, syncing: false),
), ),
}; };
_active[accountId] = loop; _active[accountId] = loop;
@@ -159,8 +179,11 @@ class _AccountSync implements _SyncLoop {
this._imapConnect, this._imapConnect,
this._syncLog, this._syncLog,
this._drafts, this._drafts,
this._onNewMail, this._onNewMail, {
); void Function()? onSyncStart,
void Function()? onSyncEnd,
}) : _onSyncStart = onSyncStart,
_onSyncEnd = onSyncEnd;
final Account account; final Account account;
final AccountRepository _accounts; final AccountRepository _accounts;
@@ -170,6 +193,8 @@ class _AccountSync implements _SyncLoop {
final SyncLogRepository _syncLog; final SyncLogRepository _syncLog;
final DraftRepository? _drafts; final DraftRepository? _drafts;
final OnNewMailCallback? _onNewMail; final OnNewMailCallback? _onNewMail;
final void Function()? _onSyncStart;
final void Function()? _onSyncEnd;
imap.ImapClient? _idleClient; imap.ImapClient? _idleClient;
bool _running = false; bool _running = false;
@@ -202,6 +227,7 @@ class _AccountSync implements _SyncLoop {
Future<void> _loop() async { Future<void> _loop() async {
while (_running) { while (_running) {
final startedAt = DateTime.now(); final startedAt = DateTime.now();
_onSyncStart?.call();
try { try {
final (_SyncStats stats, String? capturedLog) = await _runSync( final (_SyncStats stats, String? capturedLog) = await _runSync(
account.verbose, account.verbose,
@@ -221,8 +247,10 @@ class _AccountSync implements _SyncLoop {
protocolLog: capturedLog, protocolLog: capturedLog,
); );
_backoffSeconds = 5; _backoffSeconds = 5;
_onSyncEnd?.call();
await _idle(); await _idle();
} catch (e, st) { } catch (e, st) {
_onSyncEnd?.call();
final isPermanent = _isPermanentError(e); final isPermanent = _isPermanentError(e);
try { try {
await _syncLog.log( await _syncLog.log(
@@ -392,14 +420,19 @@ class _JmapAccountSync implements _SyncLoop {
this._mailboxes, this._mailboxes,
this._emails, this._emails,
this._accounts, this._accounts,
this._syncLog, this._syncLog, {
); void Function()? onSyncStart,
void Function()? onSyncEnd,
}) : _onSyncStart = onSyncStart,
_onSyncEnd = onSyncEnd;
final Account account; final Account account;
final MailboxRepository _mailboxes; final MailboxRepository _mailboxes;
final EmailRepository _emails; final EmailRepository _emails;
final AccountRepository _accounts; final AccountRepository _accounts;
final SyncLogRepository _syncLog; final SyncLogRepository _syncLog;
final void Function()? _onSyncStart;
final void Function()? _onSyncEnd;
bool _running = false; bool _running = false;
int _backoffSeconds = 5; int _backoffSeconds = 5;
@@ -431,6 +464,7 @@ class _JmapAccountSync implements _SyncLoop {
Future<void> _loop() async { Future<void> _loop() async {
while (_running) { while (_running) {
final startedAt = DateTime.now(); final startedAt = DateTime.now();
_onSyncStart?.call();
try { try {
final (_SyncStats stats, String? capturedLog) = await _runSync( final (_SyncStats stats, String? capturedLog) = await _runSync(
account.verbose, account.verbose,
@@ -450,8 +484,10 @@ class _JmapAccountSync implements _SyncLoop {
protocolLog: capturedLog, protocolLog: capturedLog,
); );
_backoffSeconds = 5; _backoffSeconds = 5;
_onSyncEnd?.call();
await _wait(); await _wait();
} catch (e, st) { } catch (e, st) {
_onSyncEnd?.call();
final isPermanent = _isPermanentError(e); final isPermanent = _isPermanentError(e);
try { try {
await _syncLog.log( await _syncLog.log(
+5
View File
@@ -115,6 +115,11 @@ final syncHealthProvider =
.watchSingleOrNull(); .watchSingleOrNull();
}); });
final isSyncingProvider =
StreamProvider.autoDispose.family<bool, String>((ref, accountId) {
return ref.watch(syncManagerProvider).watchSyncing(accountId);
});
final syncManagerProvider = Provider<AccountSyncManager>((ref) { final syncManagerProvider = Provider<AccountSyncManager>((ref) {
final manager = AccountSyncManager( final manager = AccountSyncManager(
ref.watch(accountRepositoryProvider), ref.watch(accountRepositoryProvider),
+39 -16
View File
@@ -180,22 +180,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
), ),
), ),
), ),
IconButton( _buildSyncButton(emailRepo),
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')));
}
},
),
IconButton( IconButton(
icon: const Icon(Icons.edit), icon: const Icon(Icons.edit),
onPressed: () => context.push( 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() { Widget _selectionBottomBar() {
return BottomAppBar( return BottomAppBar(
child: Row( child: Row(