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
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 |
|
|
true |
|
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():
- Make main() async
- Call
WidgetsFlutterBinding.ensureInitialized()before any async work - Pre-initialize SharedPreferences:
final prefs = await SharedPreferences.getInstance(); - Wrap
SpeedoAppinMultiProviderwith 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
isOverlayModeparameter — 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: trueand 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(),
);
}
}
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 viacontext.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 bycontext.read<SettingsProvider>().cycleUnit()_cycleTheme()— replaced bycontext.read<SettingsProvider>().cycleTheme()_pushDataToOverlay()— now in OverlayProvider_showFloatingWindow()— replaced bycontext.read<OverlayProvider>().showOverlay()_closeFloatingWindow()— replaced bycontext.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: callscontext.read<GpsDataManager>().initialize(), handles error with local_errorMessagesetState_enableWakelock()— platform service, not state_ensureOverlayPermission()— platform service, not state_startBackgroundHeartbeat()— local timer, usescontext.read<OverlayProvider>().pushCurrentData()_handleBackgroundTransition()— local lifecycle_requestBatteryOptimizationExemption()— platform servicedidChangeAppLifecycleState()— 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.
<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>