Files
gps-speedo/.planning/phases/02-provider-migration/02-01-PLAN.md
T
Mikkeli Matlock 630cf1c578 docs(02): create phase plan
Phase 02: Provider Migration
- 2 plan(s) in 2 wave(s)
- Wave 1: Create providers (GpsDataManager + SettingsProvider + OverlayProvider)
- Wave 2: Wire MultiProvider and migrate SpeedometerScreen
- Ready for execution
2026-02-10 11:13:29 +09:00

21 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, must_haves
phase plan type wave depends_on files_modified autonomous must_haves
02-provider-migration 01 execute 1
pubspec.yaml
lib/services/gps_data_manager.dart
lib/providers/settings_provider.dart
lib/providers/overlay_provider.dart
true
truths artifacts key_links
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
path provides contains
lib/services/gps_data_manager.dart GPS data ChangeNotifier with _isDisposed guard extends ChangeNotifier
path provides contains
lib/providers/settings_provider.dart Theme and unit state management with persistence class SettingsProvider extends ChangeNotifier
path provides contains
lib/providers/overlay_provider.dart Overlay lifecycle and messaging centralization class OverlayProvider extends ChangeNotifier
path provides contains
pubspec.yaml Updated dependencies provider:
from to via pattern
lib/services/gps_data_manager.dart ChangeNotifier extends mixin, notifyListeners() in _updateData() notifyListeners
from to via pattern
lib/providers/settings_provider.dart SharedPreferences constructor injection, sync load SharedPreferences
from to via pattern
lib/providers/overlay_provider.dart FlutterOverlayWindow.shareData _pushToOverlay method shareData
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.

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

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/02-provider-migration/02-CONTEXT.md @.planning/phases/02-provider-migration/02-RESEARCH.md @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 Task 1: Add dependencies and refactor GpsDataManager to ChangeNotifier pubspec.yaml, lib/services/gps_data_manager.dart **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. 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. GpsDataManager is a proper ChangeNotifier with public constructor, no singleton, _isDisposed safety guard, and notifyListeners() on every data update. Dependencies installed.

Task 2: Create SettingsProvider for theme and unit state lib/providers/settings_provider.dart 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.

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 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(). SettingsProvider manages theme and unit state with SharedPreferences persistence, haptic feedback on changes, and ChangeNotifier notifications.
Task 3: Create OverlayProvider for overlay lifecycle and messaging lib/providers/overlay_provider.dart 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).

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 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. OverlayProvider centralizes all overlay lifecycle management with GPS stream listening, overlay messaging, status checking, and proper disposal. Ready for MultiProvider wiring.
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

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