From fc592c475f13c7d3f76040d837cd08eb303c5d24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bot=20of=20Thomas=20G=C3=BCttler?= Date: Wed, 13 May 2026 23:14:44 +0200 Subject: [PATCH] feat(R4): dismissible sync error banner in email list (#23) --- .../repositories/sync_log_repository.dart | 7 +++ .../sync_log_repository_impl.dart | 10 ++++ lib/di.dart | 5 ++ lib/ui/screens/email_list_screen.dart | 49 +++++++++++++++++-- .../account_sync_manager_test.dart | 3 ++ test/unit/account_sync_manager_test.dart | 3 ++ 6 files changed, 74 insertions(+), 3 deletions(-) diff --git a/lib/core/repositories/sync_log_repository.dart b/lib/core/repositories/sync_log_repository.dart index 15efd4a..aeea970 100644 --- a/lib/core/repositories/sync_log_repository.dart +++ b/lib/core/repositories/sync_log_repository.dart @@ -65,6 +65,10 @@ abstract class SyncLogRepository { }); Stream> 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 observeLastError(String accountId); } class NoOpSyncLogRepository implements SyncLogRepository { @@ -90,4 +94,7 @@ class NoOpSyncLogRepository implements SyncLogRepository { @override Stream> observeSyncLogs(String accountId) => Stream.value([]); + + @override + Stream observeLastError(String accountId) => Stream.value(null); } diff --git a/lib/data/repositories/sync_log_repository_impl.dart b/lib/data/repositories/sync_log_repository_impl.dart index 92fa44d..582f553 100644 --- a/lib/data/repositories/sync_log_repository_impl.dart +++ b/lib/data/repositories/sync_log_repository_impl.dart @@ -99,4 +99,14 @@ class SyncLogRepositoryImpl implements SyncLogRepository { return entries; }); } + + @override + Stream 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); + } } diff --git a/lib/di.dart b/lib/di.dart index 3f8913b..8082e90 100644 --- a/lib/di.dart +++ b/lib/di.dart @@ -85,6 +85,11 @@ final syncLogRepositoryProvider = Provider((ref) { return SyncLogRepositoryImpl(ref.watch(dbProvider)); }); +final syncLastErrorProvider = + StreamProvider.autoDispose.family((ref, accountId) { + return ref.watch(syncLogRepositoryProvider).observeLastError(accountId); +}); + final reliabilityRunnerProvider = Provider((ref) { final runner = ReliabilityRunner( ref.watch(dbProvider), diff --git a/lib/ui/screens/email_list_screen.dart b/lib/ui/screens/email_list_screen.dart index a2b65bb..6fe2a66 100644 --- a/lib/ui/screens/email_list_screen.dart +++ b/lib/ui/screens/email_list_screen.dart @@ -35,6 +35,9 @@ class _EmailListScreenState extends ConsumerState { 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 _selectedThreadIds = {}; // Last-emitted thread list, used to resolve emailIds for batch operations. @@ -131,9 +134,16 @@ class _EmailListScreenState extends ConsumerState { 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 { 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 { diff --git a/test/integration/account_sync_manager_test.dart b/test/integration/account_sync_manager_test.dart index efa859c..e78ff18 100644 --- a/test/integration/account_sync_manager_test.dart +++ b/test/integration/account_sync_manager_test.dart @@ -220,4 +220,7 @@ class _FakeLogs implements SyncLogRepository { @override Stream> observeSyncLogs(String accountId) => Stream.value([]); + + @override + Stream observeLastError(String accountId) => Stream.value(null); } diff --git a/test/unit/account_sync_manager_test.dart b/test/unit/account_sync_manager_test.dart index c7981b7..63041e5 100644 --- a/test/unit/account_sync_manager_test.dart +++ b/test/unit/account_sync_manager_test.dart @@ -132,6 +132,9 @@ class FakeSyncLogRepository implements SyncLogRepository { @override Stream> observeSyncLogs(String accountId) => Stream.value([]); + + @override + Stream observeLastError(String accountId) => Stream.value(null); } class FakeMailboxRepositoryWithInbox implements MailboxRepository {