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
21 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 02-provider-migration | 01 | execute | 1 |
|
true |
|
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.
<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>
@.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 Task 1: Add dependencies and refactor GpsDataManager to ChangeNotifier pubspec.yaml, lib/services/gps_data_manager.dart **pubspec.yaml:** Add two dependencies under the existing dependencies section: ```yaml # State management provider: ^6.1.2Persistent 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.
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.
GpsDataManager is a proper ChangeNotifier with public constructor, no singleton, _isDisposed safety guard, and notifyListeners() on every data update. Dependencies installed.
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.
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)
currentThemeconvenience getter avoids repeatedColorThemes.getTheme()calls in UIunitIndex.clamp()prevents crash if persisted value is out of range- No dispose override needed — no streams or timers to clean up
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(). SettingsProvider manages theme and unit state with SharedPreferences persistence, haptic feedback on changes, and ChangeNotifier notifications.
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).
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)
_isDisposedguard 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 statepushCurrentData()exposed for background heartbeat timer (stays in SpeedometerScreen as local widget concern)toggleOverlay()convenience method for the compass area tap handler Runflutter 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_isOverlayActivebefore sending. OverlayProvider centralizes all overlay lifecycle management with GPS stream listening, overlay messaging, status checking, and proper disposal. Ready for MultiProvider wiring.
<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>