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,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"
---
<objective>
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.
</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
@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
</context>
<tasks>
<task type="auto">
<name>Task 1: Add dependencies and refactor GpsDataManager to ChangeNotifier</name>
<files>pubspec.yaml, lib/services/gps_data_manager.dart</files>
<action>
**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.
</action>
<verify>
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.
</verify>
<done>
GpsDataManager is a proper ChangeNotifier with public constructor, no singleton, _isDisposed safety guard, and notifyListeners() on every data update. Dependencies installed.
</done>
</task>
<task type="auto">
<name>Task 2: Create SettingsProvider for theme and unit state</name>
<files>lib/providers/settings_provider.dart</files>
<action>
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
</action>
<verify>
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().
</verify>
<done>
SettingsProvider manages theme and unit state with SharedPreferences persistence, haptic feedback on changes, and ChangeNotifier notifications.
</done>
</task>
<task type="auto">
<name>Task 3: Create OverlayProvider for overlay lifecycle and messaging</name>
<files>lib/providers/overlay_provider.dart</files>
<action>
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<ProcessedGpsData>? _gpsSubscription;
StreamSubscription<dynamic>? _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<void> 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<void> 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<void> toggleOverlay() async {
if (_isOverlayActive) {
await closeOverlay();
} else {
await showOverlay();
}
}
Map<String, int> _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
</action>
<verify>
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.
</verify>
<done>
OverlayProvider centralizes all overlay lifecycle management with GPS stream listening, overlay messaging, status checking, and proper disposal. Ready for MultiProvider wiring.
</done>
</task>
</tasks>
<verification>
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
</verification>
<success_criteria>
- 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
</success_criteria>
<output>
After completion, create `.planning/phases/02-provider-migration/02-01-SUMMARY.md`
</output>
@@ -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>