Files
gps-speedo/.planning/phases/02-provider-migration/02-02-PLAN.md
T
Mikkeli Matlock 630cf1c578 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
2026-02-10 11:13:29 +09:00

21 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, must_haves
phase plan type wave depends_on files_modified autonomous must_haves
02-provider-migration 02 execute 2
02-01
lib/main.dart
lib/screens/speedometer_screen.dart
true
truths artifacts key_links
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
path provides contains
lib/main.dart MultiProvider wiring with pre-initialized SharedPreferences MultiProvider
path provides contains
lib/screens/speedometer_screen.dart Provider-consuming speedometer screen with Consumer/Selector widgets Selector<GpsDataManager
from to via pattern
lib/main.dart lib/services/gps_data_manager.dart ChangeNotifierProvider create callback ChangeNotifierProvider
from to via pattern
lib/main.dart lib/providers/settings_provider.dart ChangeNotifierProvider with SharedPreferences injection SettingsProvider
from to via pattern
lib/main.dart lib/providers/overlay_provider.dart ChangeNotifierProxyProvider2 ChangeNotifierProxyProvider2
from to via pattern
lib/screens/speedometer_screen.dart lib/services/gps_data_manager.dart Selector widget for speed/heading data Selector<GpsDataManager
from to via pattern
lib/screens/speedometer_screen.dart lib/providers/settings_provider.dart context.read in event handlers, Consumer in build context.read<SettingsProvider>
from to via pattern
lib/screens/speedometer_screen.dart lib/providers/overlay_provider.dart context.read in event handlers, Selector for isOverlayActive context.read<OverlayProvider>
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.

<execution_context> @C:\Users\SKSup.claude/get-shit-done/workflows/execute-plan.md @C:\Users\SKSup.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/02-provider-migration/02-CONTEXT.md @.planning/phases/02-provider-migration/02-RESEARCH.md @.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):

MultiProvider(
  providers: [
    // 1. Raw dependencies (no ChangeNotifier)
    Provider<SharedPreferences>.value(value: prefs),

    // 2. Independent ChangeNotifiers
    ChangeNotifierProvider(
      create: (context) => SettingsProvider(context.read<SharedPreferences>()),
    ),
    ChangeNotifierProvider(
      create: (_) => GpsDataManager(),
    ),

    // 3. Dependent ChangeNotifier (depends on GPS + Settings)
    ChangeNotifierProxyProvider2<GpsDataManager, SettingsProvider, OverlayProvider>(
      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:

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:

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<SharedPreferences>.value(value: prefs),
        ChangeNotifierProvider(
          create: (context) => SettingsProvider(context.read<SharedPreferences>()),
        ),
        ChangeNotifierProvider(
          create: (_) => GpsDataManager(),
        ),
        ChangeNotifierProxyProvider2<GpsDataManager, SettingsProvider, OverlayProvider>(
          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<GpsDataManager>().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<SettingsProvider>().cycleUnit()
  • _cycleTheme() — replaced by context.read<SettingsProvider>().cycleTheme()
  • _pushDataToOverlay() — now in OverlayProvider
  • _showFloatingWindow() — replaced by context.read<OverlayProvider>().showOverlay()
  • _closeFloatingWindow() — replaced by context.read<OverlayProvider>().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<GpsDataManager>().initialize(), handles error with local _errorMessage setState
  • _enableWakelock() — platform service, not state
  • _ensureOverlayPermission() — platform service, not state
  • _startBackgroundHeartbeat() — local timer, uses context.read<OverlayProvider>().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:

@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:

@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:

Future<void> _initializeGpsManager() async {
  Logger.info('Starting GPS manager initialization...', 'Main');
  try {
    await context.read<GpsDataManager>().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:

void _startBackgroundHeartbeat() {
  _backgroundHeartbeatTimer = Timer.periodic(TimingConfig.HEARTBEAT_INTERVAL, (timer) {
    if (!mounted) return;
    final overlay = context.read<OverlayProvider>();
    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):

@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:

void _handleBackgroundTransition() {
  _enableWakelock();
  _requestBatteryOptimizationExemption();
  if (context.read<OverlayProvider>().isOverlayActive) {
    context.read<OverlayProvider>().pushCurrentData();
  }
}

build() method — the key migration: Use nested Selector/Consumer widgets for granular rebuilds. Structure:

@override
Widget build(BuildContext context) {
  return Selector<SettingsProvider, ColorTheme>(
    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:

Selector<GpsDataManager, (double speed, String displaySpeed)>(
  selector: (_, gps) => (gps.currentData.speed, gps.currentData.displaySpeed),
  builder: (context, data, _) {
    final settings = context.read<SettingsProvider>();
    final displaySpeed = settings.currentUnit.convert(data.$1);
    final speedText = _getSpeedDisplayText(displaySpeed);
    return GestureDetector(
      onTap: () => context.read<SettingsProvider>().cycleTheme(),
      child: FittedBox(
        fit: BoxFit.contain,
        child: _buildSpeedDisplay(speedText, currentTheme, 200),
      ),
    );
  },
)

For the unit label:

Selector<SettingsProvider, String>(
  selector: (_, settings) => settings.currentUnit.label,
  builder: (context, unitLabel, _) {
    return GestureDetector(
      onTap: () => context.read<SettingsProvider>().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:

Selector<GpsDataManager, (double heading, String displayHeading)>(
  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):

GestureDetector(
  onTap: () {
    HapticFeedback.lightImpact();
    context.read<OverlayProvider>().toggleOverlay();
  },
  onLongPress: () {
    context.read<OverlayProvider>().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<SettingsProvider>() 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<GpsDataManager, ...> exists for speed display. Verify Selector<SettingsProvider, ...> exists for unit label. Verify Selector<GpsDataManager, ...> 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)

<success_criteria>

  • 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 </success_criteria>
After completion, create `.planning/phases/02-provider-migration/02-02-SUMMARY.md`