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
6 changed files with 74 additions and 3 deletions
@@ -65,6 +65,10 @@ abstract class SyncLogRepository {
}); });
Stream<List<SyncLogEntry>> observeSyncLogs(String accountId); 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 { class NoOpSyncLogRepository implements SyncLogRepository {
@@ -90,4 +94,7 @@ class NoOpSyncLogRepository implements SyncLogRepository {
@override @override
Stream<List<SyncLogEntry>> observeSyncLogs(String accountId) => Stream<List<SyncLogEntry>> observeSyncLogs(String accountId) =>
Stream.value([]); Stream.value([]);
@override
Stream<String?> observeLastError(String accountId) => Stream.value(null);
} }
@@ -99,4 +99,14 @@ class SyncLogRepositoryImpl implements SyncLogRepository {
return entries; 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)); 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 reliabilityRunnerProvider = Provider<ReliabilityRunner>((ref) {
final runner = ReliabilityRunner( final runner = ReliabilityRunner(
ref.watch(dbProvider), ref.watch(dbProvider),
+44 -1
View File
@@ -35,6 +35,9 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
bool _searchLoading = false; bool _searchLoading = false;
bool get _searching => _searchController.text.isNotEmpty; bool get _searching => _searchController.text.isNotEmpty;
// Error banner — tracks the last error message that the user dismissed.
String? _dismissedError;
// Thread-level selection (key = threadId). // Thread-level selection (key = threadId).
final Set<String> _selectedThreadIds = {}; final Set<String> _selectedThreadIds = {};
// Last-emitted thread list, used to resolve emailIds for batch operations. // Last-emitted thread list, used to resolve emailIds for batch operations.
@@ -131,9 +134,16 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
currentMailboxPath: widget.mailboxPath, currentMailboxPath: widget.mailboxPath,
), ),
bottomNavigationBar: _selecting ? _selectionBottomBar() : null, bottomNavigationBar: _selecting ? _selectionBottomBar() : null,
body: (_searchResults != null || _searchLoading) body: Column(
children: [
_buildSyncErrorBanner(),
Expanded(
child: (_searchResults != null || _searchLoading)
? _buildSearchBody() ? _buildSearchBody()
: _buildStreamBody(repo), : _buildStreamBody(repo),
),
],
),
); );
} }
@@ -267,6 +277,39 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
return _buildEmailList(_searchResults!); 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) { Widget _buildStreamBody(EmailRepository emailRepo) {
return RefreshIndicator( return RefreshIndicator(
onRefresh: () async { onRefresh: () async {
@@ -220,4 +220,7 @@ class _FakeLogs implements SyncLogRepository {
@override @override
Stream<List<SyncLogEntry>> observeSyncLogs(String accountId) => Stream<List<SyncLogEntry>> observeSyncLogs(String accountId) =>
Stream.value([]); Stream.value([]);
@override
Stream<String?> observeLastError(String accountId) => Stream.value(null);
} }
+3
View File
@@ -132,6 +132,9 @@ class FakeSyncLogRepository implements SyncLogRepository {
@override @override
Stream<List<SyncLogEntry>> observeSyncLogs(String accountId) => Stream<List<SyncLogEntry>> observeSyncLogs(String accountId) =>
Stream.value([]); Stream.value([]);
@override
Stream<String?> observeLastError(String accountId) => Stream.value(null);
} }
class FakeMailboxRepositoryWithInbox implements MailboxRepository { class FakeMailboxRepositoryWithInbox implements MailboxRepository {