docs(02): create phase plan

Phase 02: Provider Migration
- 2 plan(s) in 2 wave(s)
- Wave 1: Create providers (GpsDataManager + SettingsProvider + OverlayProvider)
- Wave 2: Wire MultiProvider and migrate SpeedometerScreen
- Ready for execution
This commit is contained in:
Mikkeli Matlock
2026-02-10 11:13:29 +09:00
parent 8ff74b36e1
commit 630cf1c578
3 changed files with 1109 additions and 2 deletions
@@ -0,0 +1,539 @@
---
phase: 02-provider-migration
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- pubspec.yaml
- lib/services/gps_data_manager.dart
- lib/providers/settings_provider.dart
- lib/providers/overlay_provider.dart
autonomous: true
must_haves:
truths:
- "GpsDataManager extends ChangeNotifier and calls notifyListeners() on GPS data updates"
- "GpsDataManager no longer has a singleton accessor — no static _instance or get instance"
- "SettingsProvider loads theme index and speed unit from SharedPreferences synchronously in constructor"
- "SettingsProvider persists theme and unit changes to SharedPreferences on every cycle"
- "OverlayProvider has activate/deactivate methods and skips overlay sends when inactive"
- "provider and shared_preferences packages are added to pubspec.yaml dependencies"
artifacts:
- path: "lib/services/gps_data_manager.dart"
provides: "GPS data ChangeNotifier with _isDisposed guard"
contains: "extends ChangeNotifier"
- path: "lib/providers/settings_provider.dart"
provides: "Theme and unit state management with persistence"
contains: "class SettingsProvider extends ChangeNotifier"
- path: "lib/providers/overlay_provider.dart"
provides: "Overlay lifecycle and messaging centralization"
contains: "class OverlayProvider extends ChangeNotifier"
- path: "pubspec.yaml"
provides: "Updated dependencies"
contains: "provider:"
key_links:
- from: "lib/services/gps_data_manager.dart"
to: "ChangeNotifier"
via: "extends mixin, notifyListeners() in _updateData()"
pattern: "notifyListeners"
- from: "lib/providers/settings_provider.dart"
to: "SharedPreferences"
via: "constructor injection, sync load"
pattern: "SharedPreferences"
- from: "lib/providers/overlay_provider.dart"
to: "FlutterOverlayWindow.shareData"
via: "_pushToOverlay method"
pattern: "shareData"
---
<objective>
Create the three Provider classes (GpsDataManager refactor, SettingsProvider, OverlayProvider) and add required dependencies. These providers encapsulate all shared app state that was previously managed by setState in SpeedometerScreen.
Purpose: Establish the Provider infrastructure so Plan 02 can wire them into the widget tree and migrate the screen.
Output: Three ChangeNotifier classes ready for MultiProvider wiring, plus updated pubspec.yaml with provider and shared_preferences packages.
</objective>
<execution_context>
@C:\Users\SKSup\.claude/get-shit-done/workflows/execute-plan.md
@C:\Users\SKSup\.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/02-provider-migration/02-CONTEXT.md
@.planning/phases/02-provider-migration/02-RESEARCH.md
@lib/services/gps_data_manager.dart
@lib/config/speed_unit.dart
@lib/config/color_themes.dart
@lib/config/overlay_constants.dart
@lib/config/timing_constants.dart
@lib/models/overlay_message.dart
@lib/models/processed_gps_data.dart
@lib/services/logger.dart
@pubspec.yaml
</context>
<tasks>
<task type="auto">
<name>Task 1: Add dependencies and refactor GpsDataManager to ChangeNotifier</name>
<files>pubspec.yaml, lib/services/gps_data_manager.dart</files>
<action>
**pubspec.yaml:**
Add two dependencies under the existing dependencies section:
```yaml
# State management
provider: ^6.1.2
# Persistent settings storage
shared_preferences: ^2.3.4
```
Run `flutter pub get` to install.
**lib/services/gps_data_manager.dart:**
Refactor the existing GpsDataManager class. The file stays in `services/` (it's a service that happens to be observable, not a pure provider).
Changes:
1. Add `import 'package:flutter/foundation.dart';` for ChangeNotifier
2. Change class declaration from `class GpsDataManager` to `class GpsDataManager extends ChangeNotifier`
3. **Remove the singleton pattern entirely:**
- Delete `static GpsDataManager? _instance;`
- Delete `static GpsDataManager get instance => _instance ??= GpsDataManager._internal();`
- Rename `GpsDataManager._internal()` to just `GpsDataManager()` (public constructor)
4. **Add _isDisposed guard** to prevent "ChangeNotifier after dispose" errors:
- Add field: `bool _isDisposed = false;`
- In `_updateData()`: Add `if (_isDisposed) return;` as the first line, then after updating `_currentData` and adding to stream, call `notifyListeners();`
- In `_onGpsError()`: Add `if (_isDisposed) return;` as the first line
- In `_handleStaleDataTimeout()`: Add `if (_isDisposed) return;` as the first line
5. **Update dispose()** to be a proper ChangeNotifier override:
- Add `@override` annotation
- Set `_isDisposed = true;` as the FIRST line
- Keep existing cleanup (cancel subscription, cancel timer, close stream controller)
- Call `super.dispose();` as the LAST line (replaces setting `_isInitialized = false`)
6. **Add notifyListeners() to _updateData():**
```dart
void _updateData(ProcessedGpsData newData) {
if (_isDisposed) return;
Logger.debug('Broadcasting data: ${newData.displaySpeed} ${newData.displayHeading}', 'GpsDataManager');
_currentData = newData;
_dataController.add(newData);
notifyListeners();
Logger.debug('Data broadcast complete', 'GpsDataManager');
}
```
Do NOT change: initialize(), _onPositionUpdate(), _scheduleStaleDataTimer(), getFormattedSpeed(), dataStream getter, currentData getter. The existing stream-based API stays alongside ChangeNotifier for OverlayProvider's stream subscription.
</action>
<verify>
Run `flutter pub get` — must succeed with no version conflicts.
Run `flutter analyze` — no errors (warnings about SCREAMING_SNAKE are expected and acceptable).
Verify GpsDataManager has no `static` instance or `_instance` field.
Verify `extends ChangeNotifier` is in the class declaration.
Verify `notifyListeners()` is called in `_updateData()`.
Verify `_isDisposed` guard exists in `_updateData`, `_onGpsError`, `_handleStaleDataTimeout`.
Verify `dispose()` calls `super.dispose()` as last line.
</verify>
<done>
GpsDataManager is a proper ChangeNotifier with public constructor, no singleton, _isDisposed safety guard, and notifyListeners() on every data update. Dependencies installed.
</done>
</task>
<task type="auto">
<name>Task 2: Create SettingsProvider for theme and unit state</name>
<files>lib/providers/settings_provider.dart</files>
<action>
Create the `lib/providers/` directory and create `settings_provider.dart`.
SettingsProvider manages theme index and speed unit, persisted to SharedPreferences. It replaces the `_currentUnit` and `_currentThemeIndex` state variables from SpeedometerScreen, plus the `_cycleUnit()` and `_cycleTheme()` methods.
```dart
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../config/speed_unit.dart';
import '../config/color_themes.dart';
import '../services/logger.dart';
class SettingsProvider extends ChangeNotifier {
final SharedPreferences _prefs;
int _currentThemeIndex = 0;
SpeedUnit _currentUnit = SpeedUnit.kmh;
// Getters for read access
int get currentThemeIndex => _currentThemeIndex;
SpeedUnit get currentUnit => _currentUnit;
ColorTheme get currentTheme => ColorThemes.getTheme(_currentThemeIndex);
/// Constructor loads settings synchronously from pre-initialized SharedPreferences.
/// SharedPreferences MUST be initialized before constructing this provider.
SettingsProvider(this._prefs) {
_loadSettings();
Logger.info('Settings loaded: theme=$_currentThemeIndex, unit=${_currentUnit.label}', 'SettingsProvider');
}
void _loadSettings() {
_currentThemeIndex = _prefs.getInt('themeIndex') ?? 0;
final unitIndex = _prefs.getInt('unitIndex') ?? 0;
_currentUnit = SpeedUnit.values[unitIndex.clamp(0, SpeedUnit.values.length - 1)];
// No notifyListeners() — constructor runs before first build
}
/// Cycle to next theme with haptic feedback and persistence
void cycleTheme() {
HapticFeedback.lightImpact();
_currentThemeIndex = ColorThemes.getNextThemeIndex(_currentThemeIndex);
_prefs.setInt('themeIndex', _currentThemeIndex);
Logger.debug('Theme cycled to index $_currentThemeIndex', 'SettingsProvider');
notifyListeners();
}
/// Cycle to next speed unit with haptic feedback and persistence
void cycleUnit() {
HapticFeedback.lightImpact();
_currentUnit = _currentUnit.next;
_prefs.setInt('unitIndex', _currentUnit.index);
Logger.debug('Unit cycled to ${_currentUnit.label}', 'SettingsProvider');
notifyListeners();
}
}
```
Key design decisions:
- SharedPreferences injected via constructor (pre-initialized in main())
- `_loadSettings()` is synchronous — reads from already-loaded SharedPreferences instance
- Haptic feedback included in cycle methods (moved from SpeedometerScreen)
- `currentTheme` convenience getter avoids repeated `ColorThemes.getTheme()` calls in UI
- `unitIndex.clamp()` prevents crash if persisted value is out of range
- No dispose override needed — no streams or timers to clean up
</action>
<verify>
Run `flutter analyze` — no errors in the new file.
Verify SettingsProvider extends ChangeNotifier.
Verify constructor takes SharedPreferences parameter.
Verify cycleTheme() and cycleUnit() both call notifyListeners() and persist to SharedPreferences.
Verify _loadSettings() does NOT call notifyListeners().
</verify>
<done>
SettingsProvider manages theme and unit state with SharedPreferences persistence, haptic feedback on changes, and ChangeNotifier notifications.
</done>
</task>
<task type="auto">
<name>Task 3: Create OverlayProvider for overlay lifecycle and messaging</name>
<files>lib/providers/overlay_provider.dart</files>
<action>
Create `lib/providers/overlay_provider.dart`.
OverlayProvider centralizes ALL overlay management that was scattered across SpeedometerScreen: overlay show/close, message listening, status checking, data pushing, and tap-close handling. It listens to GpsDataManager's stream continuously and skips sends when overlay is inactive (if-check, not unsubscribe — per context decision).
```dart
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_overlay_window/flutter_overlay_window.dart';
import 'package:bg_launcher/bg_launcher.dart';
import '../config/overlay_constants.dart';
import '../config/timing_constants.dart';
import '../config/speed_unit.dart';
import '../models/overlay_message.dart';
import '../models/processed_gps_data.dart';
import '../services/gps_data_manager.dart';
import '../services/logger.dart';
class OverlayProvider extends ChangeNotifier {
bool _isOverlayActive = false;
bool _isDisposed = false;
bool _tapCloseRequested = false;
StreamSubscription<ProcessedGpsData>? _gpsSubscription;
StreamSubscription<dynamic>? _overlayMessageSubscription;
Timer? _overlayStatusCheckTimer;
Timer? _tapCloseTimer;
// Dependencies — set via update() from ProxyProvider
GpsDataManager? _gpsManager;
SpeedUnit _currentUnit = SpeedUnit.kmh;
int _currentThemeIndex = 0;
bool get isOverlayActive => _isOverlayActive;
/// Called by ChangeNotifierProxyProvider2 when GpsDataManager or SettingsProvider changes.
/// Sets up GPS stream subscription on first call, updates settings references on subsequent calls.
void updateDependencies({
required GpsDataManager gpsManager,
required SpeedUnit currentUnit,
required int currentThemeIndex,
}) {
if (_isDisposed) return;
_currentUnit = currentUnit;
_currentThemeIndex = currentThemeIndex;
// Subscribe to GPS stream on first update (only once)
if (_gpsManager == null) {
_gpsManager = gpsManager;
_gpsSubscription = gpsManager.dataStream.listen((data) {
if (_isOverlayActive && !_isDisposed) {
_pushDataToOverlay(data);
}
});
Logger.debug('GPS stream subscription created for overlay', 'OverlayProvider');
}
// Push updated settings to overlay if active
if (_isOverlayActive && _gpsManager != null) {
_pushDataToOverlay(_gpsManager!.currentData);
}
}
/// Show the floating overlay window
Future<void> showOverlay() async {
if (_isDisposed) return;
try {
final overlaySize = _getOverlaySize();
final gpsData = _gpsManager?.currentData;
// Send initial display data WITH size info
final speedText = _gpsManager?.getFormattedSpeed(_currentUnit) ?? '--';
final headingText = gpsData?.displayHeading ?? 'N/A';
final unitText = _currentUnit.label;
await FlutterOverlayWindow.shareData(OverlayMessage.updateDisplay(
speedText: speedText,
unitText: unitText,
headingText: headingText,
heading: gpsData?.heading ?? -1.0,
unitIndex: _currentUnit.index,
themeIndex: _currentThemeIndex,
overlayWidth: overlaySize['width']!.toDouble(),
overlayHeight: overlaySize['height']!.toDouble(),
).toMap());
await FlutterOverlayWindow.showOverlay(
enableDrag: true,
overlayTitle: "Speedometer",
overlayContent: 'Speedo overlay active',
flag: OverlayFlag.defaultFlag,
visibility: NotificationVisibility.visibilityPublic,
positionGravity: PositionGravity.none,
width: overlaySize['width']!,
height: overlaySize['height']!,
);
Logger.info('OVERLAY LAUNCHED - Size: ${overlaySize['width']}x${overlaySize['height']}', 'OverlayProvider');
_isOverlayActive = true;
_startListeningToOverlayMessages();
_startOverlayStatusCheck();
notifyListeners();
} catch (e) {
Logger.error('Failed to show overlay: $e', 'OverlayProvider');
}
}
/// Close the floating overlay window
Future<void> closeOverlay() async {
if (!_isOverlayActive || _isDisposed) return;
try {
await FlutterOverlayWindow.closeOverlay();
Logger.info('OVERLAY CLOSED - Requested from main app', 'OverlayProvider');
_handleOverlayClose();
} catch (e) {
Logger.error('Error closing overlay: $e', 'OverlayProvider');
}
}
/// Toggle overlay state (show if hidden, close if active)
Future<void> toggleOverlay() async {
if (_isOverlayActive) {
await closeOverlay();
} else {
await showOverlay();
}
}
Map<String, int> _getOverlaySize() {
final window = WidgetsBinding.instance.platformDispatcher.views.first;
final physicalSize = window.physicalSize;
final overlayWidth = (physicalSize.width * OverlayConfig.WIDTH_PERCENTAGE).round();
final overlayHeight = (overlayWidth * OverlayConfig.ASPECT_RATIO).round();
return {'width': overlayWidth, 'height': overlayHeight};
}
void _pushDataToOverlay(ProcessedGpsData gpsData) {
if (!_isOverlayActive || _isDisposed) return;
final speedText = _gpsManager?.getFormattedSpeed(_currentUnit) ?? '--';
final unitText = _currentUnit.label;
final headingText = gpsData.displayHeading;
Logger.debug('Pushing to overlay: speed="$speedText", heading="$headingText"', 'OverlayProvider');
FlutterOverlayWindow.shareData(OverlayMessage.updateDisplay(
speedText: speedText,
unitText: unitText,
headingText: headingText,
heading: gpsData.heading,
unitIndex: _currentUnit.index,
themeIndex: _currentThemeIndex,
).toMap());
}
void _startListeningToOverlayMessages() {
if (_overlayMessageSubscription != null) return;
_overlayMessageSubscription = FlutterOverlayWindow.overlayListener.listen(
(data) {
try {
if (data is Map) {
final action = data['action'];
switch (action) {
case 'overlayClosed':
_handleOverlayClose();
break;
case 'longPressClose':
_tapCloseRequested = true;
_tapCloseTimer?.cancel();
_tapCloseTimer = Timer(TimingConfig.TAP_CLOSE_DELAY, () {
_tapCloseRequested = false;
});
break;
default:
Logger.warn('Unknown overlay action: "$action"', 'OverlayProvider');
}
}
} catch (e) {
Logger.error('Error processing overlay message: $e', 'OverlayProvider');
}
},
onError: (error) {
Logger.error('Overlay listener error: $error', 'OverlayProvider');
},
);
}
void _stopListeningToOverlayMessages() {
_overlayMessageSubscription?.cancel();
_overlayMessageSubscription = null;
}
void _startOverlayStatusCheck() {
_overlayStatusCheckTimer?.cancel();
_overlayStatusCheckTimer = Timer.periodic(TimingConfig.OVERLAY_STATUS_CHECK_INTERVAL, (timer) async {
if (_isOverlayActive && !_isDisposed) {
try {
final isActive = await FlutterOverlayWindow.isActive();
if (!isActive) {
timer.cancel();
_handleOverlayClose();
}
} catch (e) {
Logger.error('Overlay status check failed: $e', 'OverlayProvider');
}
} else {
timer.cancel();
}
});
Logger.debug('Started overlay status monitoring', 'OverlayProvider');
}
void _handleOverlayClose({bool bringToForeground = false}) {
if (_isDisposed) return;
final shouldBringToFront = bringToForeground || _tapCloseRequested;
_tapCloseTimer?.cancel();
_overlayStatusCheckTimer?.cancel();
_tapCloseRequested = false;
_isOverlayActive = false;
_stopListeningToOverlayMessages();
notifyListeners();
if (shouldBringToFront) {
_bringAppToFront();
}
}
void _bringAppToFront() {
try {
BgLauncher.bringAppToForeground();
} catch (e) {
Logger.error('BgLauncher failed to bring app to front: $e', 'OverlayProvider');
}
}
/// Push current data to overlay — used by background heartbeat
void pushCurrentData() {
if (_isOverlayActive && _gpsManager != null && !_isDisposed) {
_pushDataToOverlay(_gpsManager!.currentData);
}
}
@override
void dispose() {
_isDisposed = true;
_gpsSubscription?.cancel();
_overlayMessageSubscription?.cancel();
_overlayStatusCheckTimer?.cancel();
_tapCloseTimer?.cancel();
Logger.info('OverlayProvider disposed', 'OverlayProvider');
super.dispose();
}
}
```
Key design decisions per context:
- GPS stream subscription created once in updateDependencies(), never torn down (skips sends via if-check when inactive)
- `_isDisposed` guard on all public methods and async callbacks
- Heartbeat/polling stripped down to simple boolean `_isOverlayActive` — Phase 3 builds proper detection
- All overlay message handling, status checking, and close logic moved from SpeedometerScreen
- `_bringAppToFront()` simplified — no setState needed since Provider handles state
- `pushCurrentData()` exposed for background heartbeat timer (stays in SpeedometerScreen as local widget concern)
- `toggleOverlay()` convenience method for the compass area tap handler
</action>
<verify>
Run `flutter analyze` — no errors in the new file.
Verify OverlayProvider extends ChangeNotifier.
Verify _isDisposed guard exists in showOverlay, closeOverlay, updateDependencies, _pushDataToOverlay, _handleOverlayClose.
Verify dispose() cancels all 4 subscriptions/timers and calls super.dispose() last.
Verify _pushDataToOverlay checks `_isOverlayActive` before sending.
</verify>
<done>
OverlayProvider centralizes all overlay lifecycle management with GPS stream listening, overlay messaging, status checking, and proper disposal. Ready for MultiProvider wiring.
</done>
</task>
</tasks>
<verification>
After all 3 tasks complete:
1. `flutter pub get` succeeds — provider and shared_preferences installed
2. `flutter analyze` shows no errors (SCREAMING_SNAKE warnings expected)
3. GpsDataManager: no singleton, extends ChangeNotifier, has notifyListeners() in _updateData()
4. SettingsProvider: loads from SharedPreferences, persists on change, has cycleTheme/cycleUnit
5. OverlayProvider: manages overlay lifecycle, listens to GPS stream, skips sends when inactive
6. All three classes have proper dispose() with super.dispose() called last
7. All async-accessible methods guarded with _isDisposed check
</verification>
<success_criteria>
- Three ChangeNotifier classes exist and compile without errors
- GpsDataManager singleton pattern removed, replaced with public constructor
- SettingsProvider reads and writes SharedPreferences for theme and unit
- OverlayProvider encapsulates all overlay management from SpeedometerScreen
- Dependencies (provider, shared_preferences) added to pubspec.yaml and resolvable
- No circular dependencies between provider classes
</success_criteria>
<output>
After completion, create `.planning/phases/02-provider-migration/02-01-SUMMARY.md`
</output>