--- 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(): Grep for `ChangeNotifierProxyProvider2` in lib/main.dart and confirm it calls `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." **Trip tracking code removal:** Not needed — grep confirms no trip-related state, methods, or UI exist in the codebase. The context decision "remove trip tracking entirely" is satisfied by absence. **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`