--- 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" --- 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. @C:\Users\SKSup\.claude/get-shit-done/workflows/execute-plan.md @C:\Users\SKSup\.claude/get-shit-done/templates/summary.md @.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.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. 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. Task 2: Create SettingsProvider for theme and unit state lib/providers/settings_provider.dart 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 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. Task 3: Create OverlayProvider for overlay lifecycle and messaging lib/providers/overlay_provider.dart 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? _gpsSubscription; StreamSubscription? _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 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 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 toggleOverlay() async { if (_isOverlayActive) { await closeOverlay(); } else { await showOverlay(); } } Map _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 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. OverlayProvider centralizes all overlay lifecycle management with GPS stream listening, overlay messaging, status checking, and proper disposal. Ready for MultiProvider wiring. 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 - 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 After completion, create `.planning/phases/02-provider-migration/02-01-SUMMARY.md`