From 630cf1c57886a857d9cf1ec29019ab6b742e8cf0 Mon Sep 17 00:00:00 2001 From: Mikkeli Matlock Date: Tue, 10 Feb 2026 11:13:29 +0900 Subject: [PATCH] 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 --- .planning/ROADMAP.md | 5 +- .../02-provider-migration/02-01-PLAN.md | 539 +++++++++++++++++ .../02-provider-migration/02-02-PLAN.md | 567 ++++++++++++++++++ 3 files changed, 1109 insertions(+), 2 deletions(-) create mode 100644 .planning/phases/02-provider-migration/02-01-PLAN.md create mode 100644 .planning/phases/02-provider-migration/02-02-PLAN.md 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 + + + +After completion, create `.planning/phases/02-provider-migration/02-01-SUMMARY.md` + 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 + + + +After completion, create `.planning/phases/02-provider-migration/02-02-SUMMARY.md` +