docs(02-01): complete provider infrastructure plan

Tasks completed: 3/3
- Task 1: Add provider dependencies and refactor GpsDataManager to ChangeNotifier
- Task 2: Create SettingsProvider for theme and unit state
- Task 3: Create OverlayProvider for overlay lifecycle and messaging

SUMMARY: .planning/phases/02-provider-migration/02-01-SUMMARY.md
This commit is contained in:
Mikkeli Matlock
2026-02-10 22:11:49 +09:00
parent 6b8bba8686
commit 3c5ce89ded
3 changed files with 370 additions and 25 deletions
@@ -0,0 +1,339 @@
---
phase: 02-provider-migration
plan: 01
subsystem: state-management
tags: [provider, changenotifier, dependency-injection, settings-persistence, overlay-management]
requires:
- phase: 01
outputs: [Logger, ProcessedGpsData, OverlayMessage, config constants, modular file structure]
provides:
infrastructure:
- GpsDataManager as ChangeNotifier (reactive GPS data)
- SettingsProvider (theme + unit persistence)
- OverlayProvider (centralized overlay lifecycle)
- provider ^6.1.2 and shared_preferences ^2.3.4 packages
affects:
- phase: 02
plan: 02
why: Plan 02-02 will wire these providers via MultiProvider and migrate SpeedometerScreen
tech-stack:
added:
- provider: ^6.1.2
- shared_preferences: ^2.3.4
patterns:
- ChangeNotifier for reactive state
- _isDisposed guards for async safety
- Constructor injection for SharedPreferences
- Stream + ChangeNotifier dual access pattern
key-files:
created:
- lib/providers/settings_provider.dart
- lib/providers/overlay_provider.dart
modified:
- lib/services/gps_data_manager.dart
- pubspec.yaml
decisions:
- decision: GpsDataManager singleton removed, replaced with public constructor
rationale: Provider manages lifecycle via dependency injection
impact: All GpsDataManager.instance calls must migrate to context.read/watch
alternatives: Keep singleton, wrap with separate provider class
confidence: HIGH
- decision: SettingsProvider combines theme + units in single provider
rationale: Settings always change together in user flow, no performance benefit to splitting
impact: Single provider subscription for all settings UI
alternatives: Separate ThemeProvider and UnitProvider
confidence: MEDIUM
- decision: OverlayProvider listens to GPS stream continuously, skips sends when inactive
rationale: Simpler than subscribe/unsubscribe on activate/deactivate, avoids subscription lifecycle bugs
impact: GPS stream always has one listener even when overlay closed
alternatives: Subscribe on activate, unsubscribe on deactivate
confidence: MEDIUM
- decision: SharedPreferences loaded eagerly in constructor, not async
rationale: Pre-initialization in main() before runApp() eliminates flash of defaults
impact: main() must be async and call SharedPreferences.getInstance() before MultiProvider
alternatives: FutureProvider with async loading, show splash during load
confidence: HIGH
metrics:
duration: 7.7 min
completed: 2026-02-10
---
# Phase 02 Plan 01: Provider Infrastructure Summary
**One-liner:** Provider pattern foundation with ChangeNotifier-based GpsDataManager, SettingsProvider (theme+units), and OverlayProvider (lifecycle+messaging)
## What Was Built
Created the three core Provider classes that will replace all setState() calls in SpeedometerScreen:
### 1. GpsDataManager Refactored (Task 1)
- **Removed singleton pattern** — deleted `static _instance` and `static get instance`
- **Changed to public constructor** — `GpsDataManager._internal()` → `GpsDataManager()`
- **Extended ChangeNotifier** — added `import 'package:flutter/foundation.dart'`
- **Added reactive notifications** — `notifyListeners()` called in `_updateData()` after stream emission
- **Async safety via _isDisposed guard** — prevents "ChangeNotifier after dispose" errors
- Guard added to: `_updateData()`, `_onGpsError()`, `_handleStaleDataTimeout()`
- **Proper disposal** — `@override dispose()` sets `_isDisposed = true`, cleans up subscriptions/timers, calls `super.dispose()` last
- **Dual access pattern** — Stream (for OverlayProvider) + ChangeNotifier (for UI widgets) coexist
### 2. SettingsProvider Created (Task 2)
- **Combines theme + speed unit** — single provider for all user settings
- **Constructor injection** — accepts pre-initialized `SharedPreferences` instance
- **Synchronous load** — `_loadSettings()` reads from SharedPreferences in constructor
- No `notifyListeners()` in constructor (runs before first build)
- **Persistence on change** — `cycleTheme()` and `cycleUnit()` both:
- Trigger haptic feedback via `HapticFeedback.lightImpact()`
- Persist to SharedPreferences via `_prefs.setInt()`
- Call `notifyListeners()` for reactive UI updates
- **Computed getter** — `currentTheme` delegates to `ColorThemes.getTheme(_currentThemeIndex)`
### 3. OverlayProvider Created (Task 3)
- **Centralizes ALL overlay operations** — show, close, toggle, status polling, IPC messaging
- **Dependency injection via updateDependencies()** — called by `ChangeNotifierProxyProvider2`
- Accepts `GpsDataManager`, `SpeedUnit`, `int themeIndex`
- Subscribes to GPS stream on first call (persists for lifetime)
- Pushes updated settings to overlay when active
- **Conditional messaging** — `if (_isOverlayActive && !_isDisposed)` guards overlay sends
- **Stream subscription lifecycle** — GPS stream subscribed once, never unsubscribed (just skip sends)
- **Overlay lifecycle management:**
- `showOverlay()` — calculates size, sends initial data with dimensions, launches overlay
- `closeOverlay()` — closes overlay window via platform API
- `toggleOverlay()` — convenience wrapper
- `_handleOverlayClose()` — cleanup, optional bring-to-front
- **IPC handling** — `_startListeningToOverlayMessages()` processes overlay→main messages
- **Status polling** — 1-second timer checks `FlutterOverlayWindow.isActive()` for close detection
- **Public interface** — `pushCurrentData()` for background heartbeat usage
- **Async safety** — `_isDisposed` guards on all methods, disposes 4 subscriptions/timers
### 4. Dependencies Added
- **provider: ^6.1.2** — state management via ChangeNotifier/Consumer/Selector
- **shared_preferences: ^2.3.4** — persistent settings storage
## Commits
| Hash | Type | Message |
|---------|-------|-----------------------------------------------------------------|
| f458b62 | chore | add provider dependencies and refactor GpsDataManager |
| 1961651 | feat | create SettingsProvider for theme and unit state |
| 6b8bba8 | feat | create OverlayProvider for overlay lifecycle and messaging |
## Deviations from Plan
None — plan executed exactly as written.
All three providers created with proper ChangeNotifier implementation, _isDisposed guards, and super.dispose() calls. Dependencies installed successfully.
## Key Decisions Made
### GpsDataManager Singleton Removal
**Decision:** Remove singleton pattern entirely, use Provider-managed lifecycle
**Why:** Provider's dependency injection handles instance lifecycle, disposal, and testing overrides automatically. Singleton creates hidden global state that bypasses Provider's benefits.
**Implementation:**
- Deleted `static GpsDataManager? _instance;`
- Deleted `static GpsDataManager get instance => _instance ??= GpsDataManager._internal();`
- Changed `GpsDataManager._internal()` to public `GpsDataManager()`
- Provider creates instance via `ChangeNotifierProvider(create: (_) => GpsDataManager())`
**Impact:** All existing `GpsDataManager.instance` calls in SpeedometerScreen will error — must migrate to `context.read<GpsDataManager>()` in Plan 02-02.
**Verification:** `grep -E "static.*instance" lib/services/gps_data_manager.dart` returns empty (singleton removed)
### Settings Provider Consolidation
**Decision:** Combine theme and speed unit into single SettingsProvider
**Why:**
1. Settings always change together in user flow (theme button, unit button side-by-side)
2. No performance benefit to splitting — both are low-frequency updates
3. Simpler dependency graph — one provider instead of two
**Alternatives considered:**
- Separate ThemeProvider and UnitProvider — rejected due to unnecessary complexity
- Single AppStateProvider for all state — rejected per context decision (granular providers)
**Impact:** UI widgets subscribe to one provider for both theme and unit data.
### Overlay Stream Subscription Strategy
**Decision:** Subscribe to GPS stream once in updateDependencies(), skip sends when inactive via if-check
**Why:**
1. Simpler lifecycle — no subscribe/unsubscribe dance
2. Avoids "bad state: stream already listened to" errors
3. Negligible performance impact (if-check vs subscription churn)
**Alternatives considered:**
- Subscribe on activate, unsubscribe on deactivate — rejected due to async subscription timing bugs
- Pause/resume stream subscription — rejected as StreamSubscription.pause() doesn't exist for broadcast streams
**Implementation:**
```dart
void updateDependencies({...}) {
if (_gpsManager == null) {
_gpsManager = gpsManager;
_gpsSubscription = gpsManager.dataStream.listen((data) {
if (_isOverlayActive && !_isDisposed) { // Skip sends when inactive
_pushDataToOverlay(data);
}
});
}
}
```
### SharedPreferences Pre-Initialization
**Decision:** Load SharedPreferences in main() before runApp(), inject into SettingsProvider constructor
**Why:**
1. Eliminates "flash of defaults" on first app launch
2. Makes SettingsProvider constructor synchronous (no async create callback)
3. Matches Flutter best practice per provider research
**Implementation pattern:**
```dart
void main() async {
WidgetsFlutterBinding.ensureInitialized();
final prefs = await SharedPreferences.getInstance();
runApp(
MultiProvider(
providers: [
Provider.value(value: prefs),
ChangeNotifierProvider(create: (ctx) => SettingsProvider(ctx.read<SharedPreferences>())),
],
child: SpeedoApp(),
),
);
}
```
**Impact:** Plan 02-02 must implement this async main() pattern.
## Testing Notes
### Verification Performed
1. ✅ `flutter pub get` — dependencies resolve without conflicts
2. ✅ `flutter analyze` — no errors in provider files (only expected SpeedometerScreen errors from removed singleton)
3. ✅ GpsDataManager extends ChangeNotifier — verified via grep
4. ✅ GpsDataManager calls notifyListeners() in _updateData() — verified via code inspection
5. ✅ No singleton pattern in GpsDataManager — verified via grep (no static instance)
6. ✅ SettingsProvider loads from SharedPreferences — constructor calls _loadSettings()
7. ✅ SettingsProvider persists on change — cycleTheme/cycleUnit call _prefs.setInt()
8. ✅ OverlayProvider has updateDependencies() — verified via grep
9. ✅ OverlayProvider skips sends when inactive — `if (_isOverlayActive && !_isDisposed)` guards
10. ✅ All providers have proper dispose() — @override, _isDisposed guard, super.dispose() last
### Expected Errors (Will Fix in Plan 02-02)
```
error - The getter 'instance' isn't defined for the type 'GpsDataManager'
lib\screens\speedometer_screen.dart:302:28
lib\screens\speedometer_screen.dart:307:45
lib\screens\speedometer_screen.dart:354:38
lib\screens\speedometer_screen.dart:398:40
```
These are intentional — singleton removal breaks old code. Plan 02-02 will migrate all references to Provider-based access.
### Manual Testing Required (Deferred to Plan 02-02)
- [ ] App compiles after MultiProvider wiring
- [ ] GPS data updates trigger UI rebuilds via Consumer/Selector
- [ ] Theme/unit cycling persists across app restarts
- [ ] Overlay launches and receives GPS data
- [ ] No "ChangeNotifier after dispose" errors on hot reload
- [ ] No "setState after dispose" errors on navigation
## Integration Points
### For Plan 02-02 (Screen Migration)
**What's ready:**
- Three ChangeNotifier providers ready for MultiProvider wiring
- GpsDataManager replaces direct GPS stream subscription
- SettingsProvider replaces setState for theme/unit
- OverlayProvider replaces inline overlay management code
**What Plan 02-02 must do:**
1. Implement async main() with SharedPreferences.getInstance()
2. Wire MultiProvider with dependency order:
- Provider.value(prefs)
- ChangeNotifierProvider(SettingsProvider)
- ChangeNotifierProvider(GpsDataManager)
- ChangeNotifierProxyProvider2(OverlayProvider) — depends on GPS + Settings
3. Replace all `GpsDataManager.instance` calls with `context.read/watch`
4. Replace setState for theme/unit with `context.read<SettingsProvider>().cycleTheme/Unit()`
5. Replace inline overlay code with `context.read<OverlayProvider>().showOverlay/closeOverlay()`
6. Wrap speed display with `Selector<GpsDataManager, String>` for granular rebuilds
7. Wrap theme controls with `Consumer<SettingsProvider>` for reactive updates
8. Call `context.read<GpsDataManager>().initialize()` in screen initState
**Expected file changes in Plan 02-02:**
- lib/main.dart — async main(), MultiProvider setup
- lib/screens/speedometer_screen.dart — remove setState, add Consumer/Selector, remove inline overlay code
## Architecture Notes
### ChangeNotifier Lifecycle Pattern
All three providers follow the same disposal pattern:
```dart
@override
void dispose() {
_isDisposed = true; // FIRST: Set guard flag
// ... cancel all async operations (streams, timers, listeners)
super.dispose(); // LAST: Call ChangeNotifier cleanup
}
```
**Why _isDisposed guard:** Async operations (GPS stream, overlay listeners, timers) may complete after dispose() called. Guard prevents "ChangeNotifier after dispose" assertion by short-circuiting methods that call notifyListeners().
### Dual Access Pattern (GpsDataManager)
GpsDataManager provides two access patterns:
1. **Stream-based** — `dataStream` getter for OverlayProvider's continuous subscription
2. **ChangeNotifier-based** — `currentData` getter + `notifyListeners()` for UI widgets
**Why both:** OverlayProvider needs stream subscription to push data to overlay isolate. UI widgets prefer ChangeNotifier for reactive rebuilds. Both patterns share the same `_currentData` state and update simultaneously in `_updateData()`.
### Provider Dependency Graph
```
SharedPreferences (pre-initialized)
└─> SettingsProvider (theme + unit)
GpsDataManager (independent, no deps)
OverlayProvider
├─> GpsDataManager (GPS data stream)
└─> SettingsProvider (theme + unit for overlay)
```
Plan 02-02 will wire this via ChangeNotifierProxyProvider2 for OverlayProvider.
## Next Phase Readiness
### Blockers: NONE
All deliverables complete. Plan 02-02 can proceed immediately.
### Concerns: LOW
**Async main() timing:** SharedPreferences.getInstance() adds ~50-100ms startup delay. Acceptable per research (eliminates flash of defaults).
**Overlay stream persistence:** GPS stream stays subscribed even when overlay closed. Negligible performance impact (if-check is O(1), subscription churn avoided).
### Preparation for Plan 02-02
- [x] Provider classes created and verified
- [x] Dependencies installed and resolving
- [x] No circular dependencies between providers
- [x] All providers have proper dispose() implementations
- [x] _isDisposed guards prevent async disposal errors
**Ready for execution:** Plan 02-02 can wire providers and migrate screen without blockers.
---
**Plan 02-01 complete.** Three ChangeNotifier providers created with proper lifecycle management, async safety guards, and reactive notifications. Dependencies installed. Ready for MultiProvider wiring in Plan 02-02.