From 4f6f1d9437d2399b1339b33ab9b1f872f3bac985 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bot=20of=20Thomas=20G=C3=BCttler?= Date: Sat, 23 May 2026 19:50:11 +0200 Subject: [PATCH] fix: migrate to Riverpod 3.x and update dependencies (#175) (#190) --- lib/core/services/undo_service.dart | 31 ++++++++++--------- lib/di.dart | 23 +++++++------- lib/main.dart | 1 + lib/ui/screens/email_detail_screen.dart | 6 ++-- lib/ui/screens/email_list_screen.dart | 6 ++-- pubspec.lock | 8 ++--- pubspec.yaml | 2 +- test/widget/compose_screen_test.dart | 1 + test/widget/email_detail_screen_test.dart | 2 +- .../widget/email_list_screen_golden_test.dart | 2 +- test/widget/helpers.dart | 16 ++++++++-- 11 files changed, 55 insertions(+), 43 deletions(-) diff --git a/lib/core/services/undo_service.dart b/lib/core/services/undo_service.dart index d4c7ead..ff43661 100644 --- a/lib/core/services/undo_service.dart +++ b/lib/core/services/undo_service.dart @@ -4,38 +4,39 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:sharedinbox/core/models/undo_action.dart'; import 'package:sharedinbox/di.dart'; -class UndoService extends StateNotifier> { - UndoService(this._ref) : super([]); - - final Ref _ref; +class UndoService extends Notifier> { 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 _ready = Future.value(); + // Resolves once build() has loaded persisted history. + late Future _ready; - Future init() async { - _ready = _ref.read(undoRepositoryProvider).getHistory().then((history) { - if (mounted) state = history; + @override + List build() { + _ready = ref.read(undoRepositoryProvider).getHistory().then((history) { + if (ref.mounted) state = history; }); - await _ready; + return []; } + /// Waits for the persisted history to finish loading. Called by tests to + /// ensure the provider is ready before asserting state. + Future init() => _ready; + Future pushAction(UndoAction action) async { await _ready; final newList = [...state, action]; if (newList.length > _maxHistory) { final removed = newList.removeAt(0); - await _ref.read(undoRepositoryProvider).deleteAction(removed.id); + await ref.read(undoRepositoryProvider).deleteAction(removed.id); } state = newList; - await _ref.read(undoRepositoryProvider).saveAction(action); + await ref.read(undoRepositoryProvider).saveAction(action); } Future clear() async { await _ready; state = []; - unawaited(_ref.read(undoRepositoryProvider).clearHistory()); + unawaited(ref.read(undoRepositoryProvider).clearHistory()); } Future undo({String? actionId}) async { @@ -57,7 +58,7 @@ class UndoService extends StateNotifier> { // happened and retry if the undo failed (e.g. after an IMAP sync reverted // the local change). The inverse action added below allows undoing the undo. - final repo = _ref.read(emailRepositoryProvider); + final repo = ref.read(emailRepositoryProvider); for (final id in action.emailIds) { // 1. Try to cancel the original change (if not started yet). diff --git a/lib/di.dart b/lib/di.dart index 6d89106..4795cb3 100644 --- a/lib/di.dart +++ b/lib/di.dart @@ -11,6 +11,7 @@ import 'package:sharedinbox/core/repositories/email_repository.dart'; import 'package:sharedinbox/core/repositories/mailbox_repository.dart'; import 'package:sharedinbox/core/repositories/search_history_repository.dart'; import 'package:sharedinbox/core/repositories/share_key_repository.dart'; +import 'package:sharedinbox/core/repositories/sync_log_repository.dart'; import 'package:sharedinbox/core/repositories/undo_repository.dart'; import 'package:sharedinbox/core/services/account_discovery_service.dart'; import 'package:sharedinbox/core/services/connection_test_service.dart'; @@ -101,7 +102,7 @@ final searchHistoryRepositoryProvider = return SearchHistoryRepositoryImpl(ref.watch(dbProvider)); }); -final syncLogRepositoryProvider = Provider((ref) { +final syncLogRepositoryProvider = Provider((ref) { return SyncLogRepositoryImpl(ref.watch(dbProvider)); }); @@ -181,11 +182,7 @@ final manageSieveProbeServiceProvider = Provider(( }); final undoServiceProvider = - StateNotifierProvider>((ref) { - final service = UndoService(ref); - unawaited(service.init()); - return service; -}); + NotifierProvider>(UndoService.new); /// Loads email header + body and marks the email as seen. /// Owned by [EmailDetailScreen]; decouples data loading from the widget tree. @@ -194,16 +191,18 @@ final emailDetailProvider = AsyncNotifierProvider.autoDispose EmailDetailNotifier.new, ); -class EmailDetailNotifier - extends AutoDisposeFamilyAsyncNotifier<(Email?, EmailBody), String> { +class EmailDetailNotifier extends AsyncNotifier<(Email?, EmailBody)> { + EmailDetailNotifier(this._emailId); + final String _emailId; + @override - Future<(Email?, EmailBody)> build(String emailId) async { + Future<(Email?, EmailBody)> build() async { final repo = ref.read(emailRepositoryProvider); final results = await Future.wait([ - repo.getEmail(emailId), - repo.getEmailBody(emailId), + repo.getEmail(_emailId), + repo.getEmailBody(_emailId), ]); - unawaited(repo.setFlag(emailId, seen: true)); + unawaited(repo.setFlag(_emailId, seen: true)); return (results[0] as Email?, results[1] as EmailBody); } } diff --git a/lib/main.dart b/lib/main.dart index f5008a3..66bf511 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/misc.dart' show Override; import 'package:sharedinbox/core/services/notification_service.dart'; import 'package:sharedinbox/core/sync/background_sync.dart'; diff --git a/lib/ui/screens/email_detail_screen.dart b/lib/ui/screens/email_detail_screen.dart index a30a7b3..a45a603 100644 --- a/lib/ui/screens/email_detail_screen.dart +++ b/lib/ui/screens/email_detail_screen.dart @@ -43,15 +43,15 @@ class _EmailDetailScreenState extends ConsumerState { ref.listen>( emailDetailProvider(widget.emailId), (_, next) { - final email = next.valueOrNull?.$1; + final email = next.value?.$1; if (email != null && mounted) { setState(() => _isFlagged = email.isFlagged); } }, ); - final header = detail.valueOrNull?.$1; - final body = detail.valueOrNull?.$2; + final header = detail.value?.$1; + final body = detail.value?.$2; final isMobile = defaultTargetPlatform == TargetPlatform.android || defaultTargetPlatform == TargetPlatform.iOS; diff --git a/lib/ui/screens/email_list_screen.dart b/lib/ui/screens/email_list_screen.dart index dc18123..f6688c2 100644 --- a/lib/ui/screens/email_list_screen.dart +++ b/lib/ui/screens/email_list_screen.dart @@ -261,9 +261,9 @@ class _EmailListScreenState extends ConsumerState { Widget _buildSyncButton(EmailRepository emailRepo) { final isSyncing = - ref.watch(isSyncingProvider(widget.accountId)).valueOrNull ?? false; + ref.watch(isSyncingProvider(widget.accountId)).value ?? false; final hasError = - ref.watch(syncLastErrorProvider(widget.accountId)).valueOrNull != null; + ref.watch(syncLastErrorProvider(widget.accountId)).value != null; return IconButton( tooltip: isSyncing ? 'Syncing…' @@ -350,7 +350,7 @@ class _EmailListScreenState extends ConsumerState { Widget _buildSyncErrorBanner() { final errorAsync = ref.watch(syncLastErrorProvider(widget.accountId)); - final error = errorAsync.valueOrNull; + final error = errorAsync.value; if (error == null || error == _dismissedError) { return const SizedBox.shrink(); } diff --git a/pubspec.lock b/pubspec.lock index dc56408..7edea8a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -415,10 +415,10 @@ packages: dependency: "direct main" description: name: flutter_riverpod - sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1" + sha256: "4e166be88e1dbbaa34a280bdb744aeae73b7ef25fdf8db7a3bb776760a3648e2" url: "https://pub.dev" source: hosted - version: "2.6.1" + version: "3.3.1" flutter_secure_storage: dependency: "direct main" description: @@ -891,10 +891,10 @@ packages: dependency: transitive description: name: riverpod - sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959" + sha256: "8c22216be8ad3ef2b44af3a329693558c98eca7b8bd4ef495c92db0bba279f83" url: "https://pub.dev" source: hosted - version: "2.6.1" + version: "3.2.1" share_plus: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 1c1a2dd..17ecabb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -24,7 +24,7 @@ dependencies: path: ^1.9.1 # State management - flutter_riverpod: ^2.6.1 + flutter_riverpod: ^3.0.0 # Navigation go_router: ^17.2.3 diff --git a/test/widget/compose_screen_test.dart b/test/widget/compose_screen_test.dart index e2abfe2..de00e26 100644 --- a/test/widget/compose_screen_test.dart +++ b/test/widget/compose_screen_test.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/misc.dart' show Override; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; diff --git a/test/widget/email_detail_screen_test.dart b/test/widget/email_detail_screen_test.dart index 1764602..494eaff 100644 --- a/test/widget/email_detail_screen_test.dart +++ b/test/widget/email_detail_screen_test.dart @@ -3,7 +3,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/misc.dart' show Override; import 'package:flutter_test/flutter_test.dart'; import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; diff --git a/test/widget/email_list_screen_golden_test.dart b/test/widget/email_list_screen_golden_test.dart index fbdc711..5ac9051 100644 --- a/test/widget/email_list_screen_golden_test.dart +++ b/test/widget/email_list_screen_golden_test.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/misc.dart' show Override; import 'package:flutter_test/flutter_test.dart'; import 'package:sharedinbox/core/models/email.dart'; diff --git a/test/widget/helpers.dart b/test/widget/helpers.dart index e29cd19..89da3d4 100644 --- a/test/widget/helpers.dart +++ b/test/widget/helpers.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/misc.dart' show Override; import 'package:go_router/go_router.dart'; import 'package:sharedinbox/core/models/account.dart'; @@ -19,6 +20,7 @@ import 'package:sharedinbox/core/repositories/email_repository.dart'; import 'package:sharedinbox/core/repositories/mailbox_repository.dart'; import 'package:sharedinbox/core/repositories/search_history_repository.dart'; import 'package:sharedinbox/core/repositories/share_key_repository.dart'; +import 'package:sharedinbox/core/repositories/sync_log_repository.dart'; import 'package:sharedinbox/core/services/account_discovery_service.dart'; import 'package:sharedinbox/core/services/connection_test_service.dart'; import 'package:sharedinbox/core/services/managesieve_probe_service.dart'; @@ -473,10 +475,18 @@ Widget buildApp({ ); return ProviderScope( - // Always neutralise the ManageSieve probe so widget tests never open a - // real socket. Tests that need to assert on probe behaviour should supply - // their own override before this default in [overrides]. + // Defaults come first so tests can override them via [overrides]. + // + // syncHealthProvider and syncLogRepositoryProvider are backed by Drift + // StreamQueries. When a StreamProvider that wraps a Drift query is disposed, + // Drift schedules a Timer.run() for cache debouncing. Flutter's test + // framework then fails the test with "A Timer is still pending". Replacing + // these with simple synchronous streams avoids the pending-timer assertion. overrides: [ + syncHealthProvider.overrideWith((ref, _) => Stream.value(null)), + syncLogRepositoryProvider.overrideWithValue( + const NoOpSyncLogRepository(), + ), ...overrides, manageSieveProbeServiceProvider.overrideWith( (ref) => _NoOpManageSieveProbeService(),