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
This commit is contained in:
Mikkeli Matlock
2026-02-10 11:13:29 +09:00
parent 8ff74b36e1
commit 630cf1c578
3 changed files with 1109 additions and 2 deletions
@@ -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<GpsDataManager"
key_links:
- from: "lib/main.dart"
to: "lib/services/gps_data_manager.dart"
via: "ChangeNotifierProvider create callback"
pattern: "ChangeNotifierProvider"
- from: "lib/main.dart"
to: "lib/providers/settings_provider.dart"
via: "ChangeNotifierProvider with SharedPreferences injection"
pattern: "SettingsProvider"
- from: "lib/main.dart"
to: "lib/providers/overlay_provider.dart"
via: "ChangeNotifierProxyProvider2"
pattern: "ChangeNotifierProxyProvider2"
- from: "lib/screens/speedometer_screen.dart"
to: "lib/services/gps_data_manager.dart"
via: "Selector widget for speed/heading data"
pattern: "Selector<GpsDataManager"
- from: "lib/screens/speedometer_screen.dart"
to: "lib/providers/settings_provider.dart"
via: "context.read in event handlers, Consumer in build"
pattern: "context.read<SettingsProvider>"
- from: "lib/screens/speedometer_screen.dart"
to: "lib/providers/overlay_provider.dart"
via: "context.read in event handlers, Selector for isOverlayActive"
pattern: "context.read<OverlayProvider>"
---
<objective>
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.
</objective>
<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>
<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
</context>
<tasks>
<task type="auto">
<name>Task 1: Wire MultiProvider in main.dart</name>
<files>lib/main.dart</files>
<action>
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<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:**
```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<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(),
);
}
}
```
</action>
<verify>
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.
</verify>
<done>
main.dart wires MultiProvider with pre-initialized SharedPreferences, three ChangeNotifier providers in correct dependency order, and OverlayProvider receiving proxy updates from GPS and Settings.
</done>
</task>
<task type="auto">
<name>Task 2: Migrate SpeedometerScreen from setState to Provider</name>
<files>lib/screens/speedometer_screen.dart</files>
<action>
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:**
```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<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:**
```dart
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):
```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<OverlayProvider>().isOverlayActive) {
context.read<OverlayProvider>().pushCurrentData();
}
}
```
**build() method — the key migration:**
Use nested Selector/Consumer widgets for granular rebuilds. Structure:
```dart
@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:
```dart
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:
```dart
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:
```dart
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):
```dart
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)
</action>
<verify>
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<SettingsProvider>() used for cycleTheme/cycleUnit.
Verify context.read<OverlayProvider>() used for toggleOverlay/closeOverlay.
Verify no `_currentUnit`, `_currentThemeIndex`, `_isOverlayActive`, `_currentGpsData` state variables exist.
Verify dispose() only cancels _backgroundHeartbeatTimer and disables WakelockPlus.
</verify>
<done>
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.
</done>
</task>
</tasks>
<verification>
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)
</verification>
<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>
<output>
After completion, create `.planning/phases/02-provider-migration/02-02-SUMMARY.md`
</output>