diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index 6e10476..6b43073 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -56,10 +56,11 @@ Plans:
5. All timers and stream subscriptions properly canceled in dispose
6. App runs without "setState after dispose" or "ChangeNotifier after dispose" errors
-**Plans**: TBD (2-3 plans expected)
+**Plans**: 2 plans
Plans:
-- [ ] 02-01: [To be planned]
+- [ ] 02-01-PLAN.md — Create providers (GpsDataManager ChangeNotifier + SettingsProvider + OverlayProvider) and add dependencies
+- [ ] 02-02-PLAN.md — Wire MultiProvider in main.dart and migrate SpeedometerScreen to Consumer/Selector
### Phase 3: Overlay Refactor
diff --git a/.planning/phases/02-provider-migration/02-01-PLAN.md b/.planning/phases/02-provider-migration/02-01-PLAN.md
new file mode 100644
index 0000000..42b1caf
--- /dev/null
+++ b/.planning/phases/02-provider-migration/02-01-PLAN.md
@@ -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"
+---
+
+
+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
+
+
+
diff --git a/.planning/phases/02-provider-migration/02-02-PLAN.md b/.planning/phases/02-provider-migration/02-02-PLAN.md
new file mode 100644
index 0000000..4db2b6d
--- /dev/null
+++ b/.planning/phases/02-provider-migration/02-02-PLAN.md
@@ -0,0 +1,567 @@
+---
+phase: 02-provider-migration
+plan: 02
+type: execute
+wave: 2
+depends_on: ["02-01"]
+files_modified:
+ - lib/main.dart
+ - lib/screens/speedometer_screen.dart
+autonomous: true
+
+must_haves:
+ truths:
+ - "App launches with pre-initialized SharedPreferences — no flash of default values"
+ - "Speed display updates reactively from GpsDataManager via Selector without rebuilding theme or heading"
+ - "Tapping speed text cycles theme via SettingsProvider (not setState)"
+ - "Tapping unit label cycles unit via SettingsProvider (not setState)"
+ - "Tapping compass area toggles overlay via OverlayProvider (not setState)"
+ - "No setState calls remain for shared app state — only local widget state (if any)"
+ - "App compiles and runs with identical visual behavior to pre-migration"
+ - "Background heartbeat and lifecycle observer still function"
+ artifacts:
+ - path: "lib/main.dart"
+ provides: "MultiProvider wiring with pre-initialized SharedPreferences"
+ contains: "MultiProvider"
+ - path: "lib/screens/speedometer_screen.dart"
+ provides: "Provider-consuming speedometer screen with Consumer/Selector widgets"
+ contains: "Selector"
+ - from: "lib/screens/speedometer_screen.dart"
+ to: "lib/providers/overlay_provider.dart"
+ via: "context.read in event handlers, Selector for isOverlayActive"
+ pattern: "context.read"
+---
+
+
+Wire all three providers into the widget tree via MultiProvider in main.dart, then fully migrate SpeedometerScreen from setState to Consumer/Selector pattern. This is the "big bang" migration — all shared state moves to Provider in one pass.
+
+Purpose: Complete the setState-to-Provider migration so the app has reactive state management with granular rebuilds.
+Output: Working app with MultiProvider at root, SpeedometerScreen consuming providers via Consumer/Selector, zero setState calls for shared state.
+
+
+
+@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
+@.planning/phases/02-provider-migration/02-01-SUMMARY.md
+@lib/main.dart
+@lib/screens/speedometer_screen.dart
+@lib/services/gps_data_manager.dart
+@lib/providers/settings_provider.dart
+@lib/providers/overlay_provider.dart
+@lib/config/speed_unit.dart
+@lib/config/color_themes.dart
+
+
+
+
+
+ Task 1: Wire MultiProvider in main.dart
+ lib/main.dart
+
+Rewrite main.dart to set up MultiProvider with pre-initialized SharedPreferences. The file should remain a clean entry point (target ~50-60 lines).
+
+**Changes to main():**
+1. Make main() async
+2. Call `WidgetsFlutterBinding.ensureInitialized()` before any async work
+3. Pre-initialize SharedPreferences: `final prefs = await SharedPreferences.getInstance();`
+4. Wrap `SpeedoApp` in `MultiProvider` with providers in dependency order
+
+**Provider wiring order (dependencies first):**
+```dart
+MultiProvider(
+ providers: [
+ // 1. Raw dependencies (no ChangeNotifier)
+ Provider.value(value: prefs),
+
+ // 2. Independent ChangeNotifiers
+ ChangeNotifierProvider(
+ create: (context) => SettingsProvider(context.read()),
+ ),
+ ChangeNotifierProvider(
+ create: (_) => GpsDataManager(),
+ ),
+
+ // 3. Dependent ChangeNotifier (depends on GPS + Settings)
+ ChangeNotifierProxyProvider2(
+ create: (_) => OverlayProvider(),
+ update: (_, gpsManager, settings, overlay) => overlay!
+ ..updateDependencies(
+ gpsManager: gpsManager,
+ currentUnit: settings.currentUnit,
+ currentThemeIndex: settings.currentThemeIndex,
+ ),
+ ),
+ ],
+ child: const SpeedoApp(),
+)
+```
+
+**Changes to SpeedoApp:**
+- Remove `isOverlayMode` parameter — no longer needed (overlay runs in separate isolate with its own entry point)
+- Theme is now derived from SettingsProvider in the screen, not hardcoded in SpeedoApp
+- Keep the MaterialApp with `useMaterial3: true` and base dark theme as fallback
+
+**overlayMain() stays unchanged** — overlay runs in separate isolate and cannot access Provider.
+
+**Required imports:**
+```dart
+import 'package:provider/provider.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+import 'services/gps_data_manager.dart';
+import 'providers/settings_provider.dart';
+import 'providers/overlay_provider.dart';
+```
+
+**Full target main.dart structure:**
+```dart
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+import 'screens/speedometer_screen.dart';
+import 'screens/overlay_screen.dart';
+import 'services/gps_data_manager.dart';
+import 'providers/settings_provider.dart';
+import 'providers/overlay_provider.dart';
+import 'services/logger.dart';
+
+void main() async {
+ WidgetsFlutterBinding.ensureInitialized();
+ final prefs = await SharedPreferences.getInstance();
+
+ runApp(
+ MultiProvider(
+ providers: [
+ Provider.value(value: prefs),
+ ChangeNotifierProvider(
+ create: (context) => SettingsProvider(context.read()),
+ ),
+ ChangeNotifierProvider(
+ create: (_) => GpsDataManager(),
+ ),
+ ChangeNotifierProxyProvider2(
+ create: (_) => OverlayProvider(),
+ update: (_, gpsManager, settings, overlay) => overlay!
+ ..updateDependencies(
+ gpsManager: gpsManager,
+ currentUnit: settings.currentUnit,
+ currentThemeIndex: settings.currentThemeIndex,
+ ),
+ ),
+ ],
+ child: const SpeedoApp(),
+ ),
+ );
+}
+
+@pragma("vm:entry-point")
+void overlayMain() {
+ Logger.info('overlayMain() called', 'Overlay');
+ runApp(const MaterialApp(
+ debugShowCheckedModeBanner: false,
+ home: OverlaySpeedometer(),
+ ));
+}
+
+class SpeedoApp extends StatelessWidget {
+ const SpeedoApp({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return MaterialApp(
+ title: 'Speedo',
+ debugShowCheckedModeBanner: false,
+ theme: ThemeData(
+ useMaterial3: true,
+ brightness: Brightness.dark,
+ colorSchemeSeed: Colors.green,
+ ),
+ home: const SpeedometerScreen(),
+ );
+ }
+}
+```
+
+
+Run `flutter analyze` — no errors.
+Verify main() is async with WidgetsFlutterBinding.ensureInitialized().
+Verify MultiProvider wraps SpeedoApp with all 4 providers.
+Verify provider order: SharedPreferences first, then SettingsProvider, GpsDataManager, OverlayProvider.
+Verify ChangeNotifierProxyProvider2 passes dependencies to OverlayProvider.updateDependencies().
+Verify overlayMain() is unchanged.
+Verify SpeedoApp no longer has isOverlayMode parameter.
+
+
+main.dart wires MultiProvider with pre-initialized SharedPreferences, three ChangeNotifier providers in correct dependency order, and OverlayProvider receiving proxy updates from GPS and Settings.
+
+
+
+
+ Task 2: Migrate SpeedometerScreen from setState to Provider
+ lib/screens/speedometer_screen.dart
+
+Full rewrite of SpeedometerScreen to consume providers via Consumer/Selector. This is the core of the "big bang migration."
+
+**State variables to REMOVE (now in providers):**
+- `_currentUnit` — now in SettingsProvider
+- `_currentThemeIndex` — now in SettingsProvider
+- `_gpsDataSubscription` — GPS subscription now in GpsDataManager (Provider-managed lifecycle)
+- `_overlaySubscription` — now in OverlayProvider
+- `_currentGpsData` — accessed via `context.watch().currentData`
+- `_isOverlayActive` — now in OverlayProvider
+- `_tapCloseRequested` — now in OverlayProvider
+- `_tapCloseTimer` — now in OverlayProvider
+- `_overlayStatusCheckTimer` — now in OverlayProvider
+
+**State variables to KEEP (local widget state):**
+- `_errorMessage` — local UI state for GPS init errors
+- `_isInBackground` — local lifecycle tracking for heartbeat
+- `_backgroundHeartbeatTimer` — local timer for background keep-alive
+
+**Widget parameter to REMOVE:**
+- `isOverlayMode` — not used (overlay has separate entry point)
+
+**Methods to REMOVE (now in providers):**
+- `_cycleUnit()` — replaced by `context.read().cycleUnit()`
+- `_cycleTheme()` — replaced by `context.read().cycleTheme()`
+- `_pushDataToOverlay()` — now in OverlayProvider
+- `_showFloatingWindow()` — replaced by `context.read().showOverlay()`
+- `_closeFloatingWindow()` — replaced by `context.read().closeOverlay()`
+- `_startListeningToOverlayMessages()` — now in OverlayProvider
+- `_stopListeningToOverlayMessages()` — now in OverlayProvider
+- `_startOverlayStatusCheck()` — now in OverlayProvider
+- `_handleOverlayClose()` — now in OverlayProvider
+- `_bringAppToFront()` — now in OverlayProvider
+- `_getOverlaySize()` — now in OverlayProvider
+
+**Methods to KEEP (local concerns):**
+- `_initializeGpsManager()` — simplified: calls `context.read().initialize()`, handles error with local `_errorMessage` setState
+- `_enableWakelock()` — platform service, not state
+- `_ensureOverlayPermission()` — platform service, not state
+- `_startBackgroundHeartbeat()` — local timer, uses `context.read().pushCurrentData()`
+- `_handleBackgroundTransition()` — local lifecycle
+- `_requestBatteryOptimizationExemption()` — platform service
+- `didChangeAppLifecycleState()` — local lifecycle observer
+- `_getSpeedDisplayText()` — pure function
+- `_buildSpeedDisplay()` — pure UI builder
+- `_buildPortraitLayout()` — UI layout (now receives data from Provider)
+- `_buildLandscapeLayout()` — UI layout (now receives data from Provider)
+
+**initState changes:**
+```dart
+@override
+void initState() {
+ super.initState();
+ WidgetsBinding.instance.addObserver(this);
+ // Delay GPS init to after first frame so context is available
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ _initializeGpsManager();
+ });
+ _enableWakelock();
+ _ensureOverlayPermission();
+ _startBackgroundHeartbeat();
+}
+```
+
+**dispose changes:**
+```dart
+@override
+void dispose() {
+ WidgetsBinding.instance.removeObserver(this);
+ _backgroundHeartbeatTimer?.cancel();
+ WakelockPlus.disable();
+ Logger.info('SpeedometerScreen disposed', 'Main');
+ super.dispose();
+}
+```
+Note: Much simpler — GPS subscription, overlay subscription, overlay status timer, tap close timer all managed by providers now.
+
+**_initializeGpsManager changes:**
+```dart
+Future _initializeGpsManager() async {
+ Logger.info('Starting GPS manager initialization...', 'Main');
+ try {
+ await context.read().initialize();
+ Logger.info('GPS manager initialized', 'Main');
+ } catch (e) {
+ Logger.error('GPS manager initialization failed: $e', 'Main');
+ if (mounted) {
+ setState(() => _errorMessage = 'Failed to initialize GPS manager');
+ }
+ }
+}
+```
+Note: `_errorMessage` is the ONLY remaining setState — it's purely local error UI state.
+
+**_startBackgroundHeartbeat changes:**
+```dart
+void _startBackgroundHeartbeat() {
+ _backgroundHeartbeatTimer = Timer.periodic(TimingConfig.HEARTBEAT_INTERVAL, (timer) {
+ if (!mounted) return;
+ final overlay = context.read();
+ if (_isInBackground && overlay.isOverlayActive) {
+ overlay.pushCurrentData();
+ }
+ });
+}
+```
+Note: Simplified — no more fake setState to keep engine alive. Just push data to overlay.
+
+**didChangeAppLifecycleState changes:**
+Keep the same structure but remove setState for _isInBackground (it's purely local, not rendered, so direct assignment is fine):
+```dart
+@override
+void didChangeAppLifecycleState(AppLifecycleState state) {
+ super.didChangeAppLifecycleState(state);
+ switch (state) {
+ case AppLifecycleState.paused:
+ if (!_isInBackground) {
+ _isInBackground = true;
+ _handleBackgroundTransition();
+ }
+ break;
+ case AppLifecycleState.resumed:
+ _isInBackground = false;
+ break;
+ case AppLifecycleState.inactive:
+ case AppLifecycleState.detached:
+ case AppLifecycleState.hidden:
+ break;
+ }
+}
+```
+
+**_handleBackgroundTransition changes:**
+```dart
+void _handleBackgroundTransition() {
+ _enableWakelock();
+ _requestBatteryOptimizationExemption();
+ if (context.read().isOverlayActive) {
+ context.read().pushCurrentData();
+ }
+}
+```
+
+**build() method — the key migration:**
+Use nested Selector/Consumer widgets for granular rebuilds. Structure:
+
+```dart
+@override
+Widget build(BuildContext context) {
+ return Selector(
+ selector: (_, settings) => settings.currentTheme,
+ builder: (context, currentTheme, _) {
+ return Scaffold(
+ backgroundColor: currentTheme.background,
+ body: SafeArea(
+ child: LayoutBuilder(
+ builder: (context, constraints) {
+ final isLandscape = constraints.maxWidth > constraints.maxHeight;
+ if (isLandscape) {
+ return _buildLandscapeLayout(currentTheme);
+ } else {
+ return _buildPortraitLayout(currentTheme);
+ }
+ },
+ ),
+ ),
+ );
+ },
+ );
+}
+```
+
+**_buildPortraitLayout and _buildLandscapeLayout changes:**
+Remove `speedText` parameter — speed is now obtained via Selector inside the layout.
+
+The layout methods receive only `currentTheme` and use Selector widgets internally:
+
+For the speed display area:
+```dart
+Selector(
+ selector: (_, gps) => (gps.currentData.speed, gps.currentData.displaySpeed),
+ builder: (context, data, _) {
+ final settings = context.read();
+ final displaySpeed = settings.currentUnit.convert(data.$1);
+ final speedText = _getSpeedDisplayText(displaySpeed);
+ return GestureDetector(
+ onTap: () => context.read().cycleTheme(),
+ child: FittedBox(
+ fit: BoxFit.contain,
+ child: _buildSpeedDisplay(speedText, currentTheme, 200),
+ ),
+ );
+ },
+)
+```
+
+For the unit label:
+```dart
+Selector(
+ selector: (_, settings) => settings.currentUnit.label,
+ builder: (context, unitLabel, _) {
+ return GestureDetector(
+ onTap: () => context.read().cycleUnit(),
+ child: FittedBox(
+ fit: BoxFit.contain,
+ child: Text(
+ unitLabel,
+ style: TextStyle(
+ fontSize: 80,
+ color: currentTheme.unitText,
+ fontFamily: 'DIN1451Alt',
+ ),
+ ),
+ ),
+ );
+ },
+)
+```
+
+For the compass/navigation area heading display:
+```dart
+Selector(
+ selector: (_, gps) => (gps.currentData.heading, gps.currentData.displayHeading),
+ builder: (context, data, _) {
+ return Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Flexible(
+ flex: 3,
+ child: Transform.rotate(
+ angle: (data.$1 >= 0 && data.$1 < 360) ? (data.$1 * math.pi / 180.0) : 0,
+ child: FittedBox(
+ fit: BoxFit.contain,
+ child: Icon(Icons.navigation, size: 80, color: currentTheme.headingText),
+ ),
+ ),
+ ),
+ Flexible(
+ flex: 1,
+ child: FittedBox(
+ fit: BoxFit.contain,
+ child: Text(
+ data.$2,
+ style: TextStyle(fontSize: 50, color: currentTheme.headingText, fontFamily: 'DIN1451Alt'),
+ ),
+ ),
+ ),
+ ],
+ );
+ },
+)
+```
+
+For the compass area GestureDetector (overlay toggle):
+```dart
+GestureDetector(
+ onTap: () {
+ HapticFeedback.lightImpact();
+ context.read().toggleOverlay();
+ },
+ onLongPress: () {
+ context.read().closeOverlay();
+ },
+ child: Container(
+ // ... heading Selector inside
+ ),
+)
+```
+
+For the error message display, keep using the local `_errorMessage` state variable with a simple conditional check (no Provider needed — it's local).
+
+**Critical performance pattern:** The speed Selector depends on BOTH GpsDataManager (for raw speed) AND SettingsProvider (for unit conversion). Use `context.read()` inside the Selector builder (non-rebuilding read) since the speed Selector already rebuilds when GPS data changes, and the unit Selector handles unit label separately.
+
+**Import changes:**
+Add: `import 'package:provider/provider.dart';`
+Add: `import '../providers/settings_provider.dart';`
+Add: `import '../providers/overlay_provider.dart';`
+Remove: `import '../config/color_themes.dart';` (accessed via SettingsProvider.currentTheme)
+Remove: `import '../config/overlay_constants.dart';` (overlay sizing moved to OverlayProvider)
+Remove: `import '../models/overlay_message.dart';` (overlay messaging moved to OverlayProvider)
+Remove: `import 'package:flutter_overlay_window/flutter_overlay_window.dart';` (overlay interaction moved to OverlayProvider)
+Remove: `import 'package:bg_launcher/bg_launcher.dart';` (bring-to-front moved to OverlayProvider)
+Keep: `import '../config/speed_unit.dart';` (still needed for type reference in Selector)
+Keep: `import '../config/timing_constants.dart';` (for heartbeat timer)
+Keep: `import '../models/processed_gps_data.dart';` (for type in Selector)
+Keep: `import '../services/gps_data_manager.dart';` (for Provider type access)
+Keep: `import '../services/logger.dart';`
+Keep: `import 'dart:async';` (for Timer)
+Keep: `import 'dart:math' as math;` (for heading rotation)
+Keep: `import 'package:wakelock_plus/wakelock_plus.dart';`
+Keep: `import 'package:flutter/services.dart';` (for HapticFeedback in compass area)
+
+
+Run `flutter analyze` — no errors (SCREAMING_SNAKE warnings expected).
+Search for `setState` in speedometer_screen.dart — should appear ONLY for `_errorMessage` (1 occurrence).
+Search for `GpsDataManager.instance` — should appear ZERO times in the entire codebase.
+Verify Selector exists for speed display.
+Verify Selector exists for unit label.
+Verify Selector exists for heading display.
+Verify context.read() used for cycleTheme/cycleUnit.
+Verify context.read() used for toggleOverlay/closeOverlay.
+Verify no `_currentUnit`, `_currentThemeIndex`, `_isOverlayActive`, `_currentGpsData` state variables exist.
+Verify dispose() only cancels _backgroundHeartbeatTimer and disables WakelockPlus.
+
+
+SpeedometerScreen fully migrated to Provider pattern. All shared state accessed via Consumer/Selector. Only local state (_errorMessage, _isInBackground, _backgroundHeartbeatTimer) uses setState or direct assignment. Speed display rebuilds independently of theme changes. Theme controls rebuild independently of GPS updates.
+
+
+
+
+
+
+After both tasks complete:
+1. `flutter analyze` shows no errors
+2. `flutter build apk --debug` compiles successfully
+3. No `GpsDataManager.instance` anywhere in codebase (grep confirms)
+4. No `setState` for shared state in speedometer_screen.dart (only _errorMessage)
+5. MultiProvider correctly wired in main.dart with 4 providers
+6. SpeedometerScreen uses Selector for speed display (does not rebuild on theme change)
+7. SpeedometerScreen uses Selector for heading display (does not rebuild on unit change)
+8. Overlay toggle works via OverlayProvider.toggleOverlay()
+9. Background heartbeat still pushes data when overlay active
+10. All providers properly disposed when app shuts down (via ChangeNotifierProvider lifecycle)
+
+
+
+- App compiles and runs with visually identical behavior to pre-migration
+- Zero setState calls for shared state (theme, unit, GPS data, overlay status)
+- Speed display rebuilds ONLY when GPS speed changes (not on theme/unit changes)
+- Theme/unit cycling persists across app restarts via SharedPreferences
+- Overlay show/close managed entirely by OverlayProvider
+- Background heartbeat continues functioning for overlay data freshness
+- No "setState after dispose" or "ChangeNotifier after dispose" errors
+
+
+