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:
@@ -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>
|
||||
Reference in New Issue
Block a user