|
| 1 | +# 85. Riverpod State Management Pilot — Local Settings & Collapsed Thread in Search |
| 2 | + |
| 3 | +Date: 2026-04-23 |
| 4 | + |
| 5 | +## Status |
| 6 | + |
| 7 | +Accepted |
| 8 | + |
| 9 | +## Related ADRs |
| 10 | + |
| 11 | +- [ADR-0071](./0071-collapse-threads-in-email-query.md) — collapseThreads in Email/query |
| 12 | + |
| 13 | +## Context |
| 14 | + |
| 15 | +As part of implementing the collapsed thread feature in search email (TF-4363), two controllers need to read the `collapseThreads` local setting: |
| 16 | + |
| 17 | +- `ThreadController` — must react when the setting changes to re-trigger search |
| 18 | +- `SearchEmailController` — must read the current value at query time |
| 19 | + |
| 20 | +The setting is written by `PreferencesController` via `UpdateLocalSettingsInteractor` when the user toggles it in `PreferencesView`, and must also be reset on logout to prevent stale cross-account data. |
| 21 | + |
| 22 | +Rather than routing this through a GetX-based `LocalSettingsService` with a reactive `Rx` field and `ever(...)` listeners — which would require manual equality tracking and expose mutable state broadly — we chose to use Riverpod's `StateNotifier` as the single source of truth from the start. |
| 23 | + |
| 24 | +This is also a **pilot** to validate the GetX + Riverpod coexistence pattern before applying it more broadly across the codebase. |
| 25 | + |
| 26 | +## Decision |
| 27 | + |
| 28 | +Introduce `localSettingsNotifierProvider` (`StateNotifier<PreferencesSetting>`) backed by a global `ProviderContainer` (`appProviderContainer`). All reads and writes go through this provider directly. |
| 29 | + |
| 30 | +### Architecture |
| 31 | + |
| 32 | +```text |
| 33 | +┌─────────────────────────────────────────────────────┐ |
| 34 | +│ appProviderContainer (global ProviderContainer) │ |
| 35 | +│ │ |
| 36 | +│ localSettingsNotifierProvider │ |
| 37 | +│ StateNotifier<PreferencesSetting> │ |
| 38 | +│ ▲ write ▲ write read ▼ │ |
| 39 | +│ │ │ │ │ |
| 40 | +│ LocalSettings Preferences Thread / │ |
| 41 | +│ Service Controller Search │ |
| 42 | +│ (initial load) (on toggle) Controllers │ |
| 43 | +└─────────────────────────────────────────────────────┘ |
| 44 | +``` |
| 45 | + |
| 46 | +### Write paths |
| 47 | + |
| 48 | +**On app start / settings route reload:** |
| 49 | +`LocalSettingsService.onInit()` → `_loadInitialSettings()` → cache read → `localSettingsNotifierProvider.notifier.update()` |
| 50 | + |
| 51 | +**On user toggle in PreferencesView:** |
| 52 | +`PreferencesController.handleSuccessViewState(UpdateLocalSettingsSuccess)` → `localSettingsNotifierProvider.notifier.update()` |
| 53 | + |
| 54 | +**On logout:** |
| 55 | +`BaseController.clearAllData()` → `localSettingsNotifierProvider.notifier.update(PreferencesSetting.initial())` |
| 56 | + |
| 57 | +### Read paths |
| 58 | + |
| 59 | +**`ThreadController`** — subscribes via `appProviderContainer.listen(localSettingsNotifierProvider, ...)`. Riverpod skips notification when `PreferencesSetting` equality is unchanged (`EquatableMixin`), so no manual dedup is needed. |
| 60 | + |
| 61 | +**`SearchEmailController`** — reads on demand via `appProviderContainer.read(localSettingsNotifierProvider)`. |
| 62 | + |
| 63 | +### `LocalSettingsService` role |
| 64 | + |
| 65 | +`LocalSettingsService` is a `GetxService` acting as a **temporary bridge** between the GetX DI graph and Riverpod. It remains a `GetxService` because all DI wiring in the project uses GetX bindings — introducing a plain class would be inconsistent with the existing layer. Its sole responsibility is loading the initial value from cache into the provider on startup via `onInit`. |
| 66 | + |
| 67 | +It will be removed entirely once `ManageAccountRepository` and its datasources are migrated to Riverpod providers, at which point `LocalSettingsNotifier` can inject and call `GetLocalSettingsInteractor` directly. |
| 68 | + |
| 69 | +### Settings route reload on web |
| 70 | + |
| 71 | +When the user reloads the browser directly on the settings route, `MailboxDashBoardBindings` does not run. `ManageAccountDashBoardBindings` guards against this: |
| 72 | + |
| 73 | +```dart |
| 74 | +if (!Get.isRegistered<LocalSettingsService>()) { |
| 75 | + Get.put(LocalSettingsService(Get.find<GetLocalSettingsInteractor>())); |
| 76 | +} |
| 77 | +``` |
| 78 | + |
| 79 | +## Consequences |
| 80 | + |
| 81 | +**Positive** |
| 82 | +- No redundant cache round-trip — provider is updated directly from `UpdateLocalSettingsSuccess` result |
| 83 | +- No manual equality tracking needed — Riverpod + `EquatableMixin` handles it |
| 84 | +- Single write surface for `PreferencesSetting`; no mutable `Rx` exposed across the codebase |
| 85 | +- Validates the GetX + Riverpod coexistence pattern for future migrations |
| 86 | + |
| 87 | +**Negative** |
| 88 | +- `appProviderContainer` is a global singleton — controllers depend directly on a concrete implementation rather than an abstraction, which is a known DIP violation. The correct solution is to wrap it behind an interface (e.g. `LocalSettingsRepository`), but doing so now would add complexity before the pattern is proven at scale. This should be addressed when the migration reaches the repository layer. |
| 89 | +- Two DI systems (GetX + Riverpod) coexist during the migration period; developers must know which layer owns which state |
| 90 | +- `LocalSettingsService` is a temporary class that must be removed when the migration reaches the repository layer |
0 commit comments