Files
gps-speedo/.planning/phases/04-gps-power-optimization/04-02-PLAN.md
T
Mikkeli Matlock 40c74677be docs(04): create phase plan for GPS & power optimization
2 plans in 2 waves: core GPS engine (precision switching, lifecycle,
wake lock) then UI integration (main app staleness, screen cleanup,
provider wiring).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 08:50:46 +09:00

14 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, must_haves
phase plan type wave depends_on files_modified autonomous must_haves
04-gps-power-optimization 02 execute 2
04-01
lib/screens/speedometer_screen.dart
lib/providers/overlay_provider.dart
lib/main.dart
true
truths artifacts key_links
Main app speed display dims at 3s of no GPS data (0.5 opacity)
Main app speed display shows dashes at 10s of no GPS data
Main app speed display snaps back to full opacity immediately on fresh data
Staleness behavior is identical between main app and overlay
Wake lock management is solely in GpsDataManager (removed from SpeedometerScreen)
OverlayProvider grace period placeholder (30s) is removed
GpsDataManager receives overlay provider reference for lifecycle decisions
Heartbeat and overlay status polling only run when overlay active
All timers and subscriptions properly canceled in dispose()
path provides contains
lib/screens/speedometer_screen.dart Main screen with staleness detection and cleaned-up lifecycle STALENESS_DIM_THRESHOLD
path provides
lib/providers/overlay_provider.dart Overlay provider without Phase 3 grace period placeholder
path provides
lib/main.dart Provider wiring connecting GpsDataManager to OverlayProvider
from to via pattern
lib/screens/speedometer_screen.dart lib/config/overlay_constants.dart staleness threshold constants OverlayConfig.STALENESS
from to via pattern
lib/main.dart lib/services/gps_data_manager.dart setOverlayProvider wiring setOverlayProvider
Wire the lifecycle-aware GPS engine into the UI layer: add staleness detection to the main speed display, clean up wake lock and lifecycle handling that moved to GpsDataManager, remove OverlayProvider's placeholder grace period, and wire the overlay provider reference into GpsDataManager.

Purpose: Plan 01 built the engine. This plan connects it to the user-visible surfaces. After this plan, the main app and overlay behave identically -- same staleness rules, same data source, consistent behavior. Power management is centralized in GpsDataManager rather than scattered across screens and providers.

Output: A fully integrated app where GPS precision, lifecycle, and power are managed centrally, and both UI surfaces (main + overlay) show consistent staleness behavior.

<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/04-gps-power-optimization/04-CONTEXT.md @.planning/phases/04-gps-power-optimization/04-RESEARCH.md @.planning/phases/04-gps-power-optimization/04-01-SUMMARY.md

Source files to modify: @lib/screens/speedometer_screen.dart @lib/providers/overlay_provider.dart @lib/main.dart

Reference files (read-only context): @lib/services/gps_data_manager.dart @lib/config/overlay_constants.dart @lib/config/timing_constants.dart @lib/screens/overlay_screen.dart

Task 1: Add staleness detection to main speed display and clean up SpeedometerScreen lib/screens/speedometer_screen.dart **SpeedometerScreen** (`lib/screens/speedometer_screen.dart`):

Remove wake lock management from SpeedometerScreen:

  • Remove import 'package:wakelock_plus/wakelock_plus.dart';
  • Remove _enableWakelock() method entirely
  • Remove WakelockPlus.disable() from dispose()
  • Remove _enableWakelock() call from initState()
  • Remove _enableWakelock() call from _handleBackgroundTransition()
  • Wake lock is now managed by GpsDataManager (Plan 01)

Remove WidgetsBindingObserver from SpeedometerScreen:

  • Remove with WidgetsBindingObserver from class declaration
  • Remove WidgetsBinding.instance.addObserver(this) from initState()
  • Remove WidgetsBinding.instance.removeObserver(this) from dispose()
  • Remove didChangeAppLifecycleState() override entirely
  • Remove _isInBackground field
  • Remove _handleBackgroundTransition() method
  • Remove _requestBatteryOptimizationExemption() method (empty stub)
  • Lifecycle management is now in GpsDataManager (Plan 01)

Keep background heartbeat but simplify:

  • Keep _backgroundHeartbeatTimer and _startBackgroundHeartbeat()
  • The heartbeat pushes data to overlay during background -- this still needs to run from the screen because it uses context.read<OverlayProvider>(). However, it should be simplified:
    • Remove the _isInBackground check (that field is removed)
    • The heartbeat should always push if overlay is active -- it's cheap and the overlay needs regular data
    • Actually, reconsider: the heartbeat is needed because the screen's context is the only way to access OverlayProvider.pushCurrentData(). Keep it running periodically, checking only overlay.isOverlayActive.

Add staleness detection to speed display:

  • Import ../config/overlay_constants.dart for OverlayConfig.STALENESS_DIM_THRESHOLD, STALENESS_DASH_THRESHOLD, STALENESS_DIM_OPACITY
  • Add a Timer? _stalenessCheckTimer field
  • In initState(), start a periodic timer (1s interval, same as overlay's STALENESS_CHECK_INTERVAL):
    _stalenessCheckTimer = Timer.periodic(OverlayConfig.STALENESS_CHECK_INTERVAL, (_) {
      if (mounted) setState(() {}); // Trigger rebuild to re-evaluate staleness
    });
    
  • Cancel _stalenessCheckTimer in dispose()

Modify speed display builders to apply staleness:

  • In both _buildPortraitLayout and _buildLandscapeLayout, the speed Selector<GpsDataManager, double> needs to change:
    • Change selector to select the full ProcessedGpsData object (or at minimum speed + timestamp): Selector<GpsDataManager, ProcessedGpsData>
    • Selector: (_, gps) => gps.currentData
    • In the builder, calculate staleness:
      final age = DateTime.now().difference(data.timestamp);
      final isDimmed = age >= OverlayConfig.STALENESS_DIM_THRESHOLD;
      final showDashes = age >= OverlayConfig.STALENESS_DASH_THRESHOLD;
      final staleOpacity = isDimmed ? OverlayConfig.STALENESS_DIM_OPACITY : 1.0;
      
    • If showDashes: set speedText to '--'
    • Apply staleOpacity to speed text color using .withValues(alpha: staleOpacity) (same pattern as overlay, per Phase 3 decision -- no animation)
    • Apply same stale opacity to the heading display in the compass section
    • Import ProcessedGpsData model if not already imported

Apply staleness to heading/compass section too:

  • The compass Selector<GpsDataManager, (double heading, String displayHeading)> should also check staleness
  • Change to select ProcessedGpsData or add timestamp to the tuple
  • Apply staleOpacity to heading icon and text colors using .withValues(alpha: staleOpacity)

Remove _getSpeedDisplayText method:

  • This method is a pass-through that just calls toStringAsFixed(1). The staleness logic replaces it.
  • Speed formatting is already done in GpsDataManager. Use gpsManager.getFormattedSpeed(unit) which handles the formatting, or compute inline.

Important: Do NOT change these:

  • Error callback wiring (onError = _handleOverlayError)
  • GPS manager initialization (_initializeGpsManager)
  • Overlay toggle/close gesture handlers
  • Theme/unit selector tap handlers
  • Layout flex ratios and structure
  • _ensureOverlayPermission() (leave stub, harmless) Run cd J:/dev/gps_speedo && flutter analyze -- should pass with no new errors. Verify no references to WakelockPlus remain in speedometer_screen.dart. Verify no WidgetsBindingObserver in SpeedometerScreen. Verify staleness constants from OverlayConfig are used (not hardcoded values). Verify stalenessCheckTimer is canceled in dispose(). SpeedometerScreen has staleness detection matching overlay behavior (3s dim, 10s dashes, immediate snap-back). Wake lock management removed (now in GpsDataManager). Lifecycle observer removed (now in GpsDataManager). Staleness applies to both speed and heading displays. No new flutter analyze errors.
Task 2: Wire overlay provider into GpsDataManager and clean up OverlayProvider lib/providers/overlay_provider.dart, lib/main.dart **OverlayProvider** (`lib/providers/overlay_provider.dart`):
  • Remove the Phase 3 grace period placeholder:
    • Remove Timer? _gracePeriodTimer field
    • Remove _gracePeriodTimer?.cancel() from dispose()
    • Remove grace period logic from _handleOverlayClose():
      // DELETE these lines:
      _gracePeriodTimer?.cancel();
      _gracePeriodTimer = Timer(TimingConfig.GPS_GRACE_PERIOD, () {
        Logger.info('GPS grace period expired', 'OverlayProvider');
        _gracePeriodTimer = null;
      });
      
    • Remove _gracePeriodTimer?.cancel() from showOverlay() (the "cancel grace period if user reopens" line)
    • Grace period is now managed by GpsDataManager's background lifecycle handler
  • Remove import '../config/timing_constants.dart' IF TimingConfig is no longer referenced anywhere in this file (check TAP_CLOSE_DELAY -- that still uses it, so keep the import)
  • Everything else in OverlayProvider stays exactly the same

main.dart (lib/main.dart):

  • Wire GpsDataManager to know about OverlayProvider for lifecycle decisions.

  • The challenge: GpsDataManager is created before OverlayProvider in the MultiProvider tree. We need to pass the overlay reference after both are created.

  • Approach: Use ChangeNotifierProxyProvider2's update callback to also set the overlay reference on GpsDataManager. OR use a simpler approach:

    • Change GpsDataManager from ChangeNotifierProvider to ChangeNotifierProxyProvider<OverlayProvider, GpsDataManager>:
      ChangeNotifierProxyProvider<OverlayProvider, GpsDataManager>(
        create: (_) => GpsDataManager(),
        update: (_, overlay, gpsManager) => gpsManager!..setOverlayProvider(overlay),
      ),
      
    • WAIT: This creates a circular dependency! OverlayProvider depends on GpsDataManager (via ProxyProvider2), and now GpsDataManager would depend on OverlayProvider. Provider doesn't allow circular proxy dependencies.
  • Better approach: Keep GpsDataManager as a plain ChangeNotifierProvider. Wire the overlay reference from SpeedometerScreen's initState() (after post-frame callback, when both providers are available):

    WidgetsBinding.instance.addPostFrameCallback((_) {
      _initializeGpsManager();
      context.read<OverlayProvider>().onError = _handleOverlayError;
      // Wire overlay provider into GPS manager for lifecycle decisions
      context.read<GpsDataManager>().setOverlayProvider(context.read<OverlayProvider>());
    });
    

    This is already in initState() inside the post-frame callback, so just add the one line.

  • Actually, even better: Wire it in main.dart without circular deps by using a regular Provider and a post-creation callback. But the simplest reliable approach is SpeedometerScreen's initState, which already has the post-frame callback pattern. Go with that.

  • So main.dart changes are MINIMAL or NONE. The wiring happens in SpeedometerScreen's initState (already modified in Task 1).

  • Update SpeedometerScreen's initState (addendum to Task 1): Add this line in the existing addPostFrameCallback:

    context.read<GpsDataManager>().setOverlayProvider(context.read<OverlayProvider>());
    

TimingConfig cleanup (lib/config/timing_constants.dart):

  • If Plan 01's Task 1 left the old GPS_GRACE_PERIOD (30s) constant, check if anything still references it after the OverlayProvider cleanup above.
  • If nothing references it: remove GPS_GRACE_PERIOD from TimingConfig
  • If something still references it: leave it with a deprecation comment Run cd J:/dev/gps_speedo && flutter analyze -- should pass with no new errors. Verify OverlayProvider has no grace period timer. Verify GpsDataManager.setOverlayProvider() is called in SpeedometerScreen's initState. Verify no unused imports. Check that GPS_GRACE_PERIOD constant is either removed or still has a consumer. OverlayProvider's Phase 3 grace period placeholder removed. GpsDataManager receives overlay provider reference via SpeedometerScreen's initState wiring. TimingConfig cleaned of unused constants. Flutter analyze passes. The full GPS lifecycle chain works: GpsDataManager detects background -> checks overlay state -> decides GPS fate.
1. `cd J:/dev/gps_speedo && flutter analyze` passes with no new errors 2. Main speed display dims at 3s, shows dashes at 10s (same thresholds as overlay) 3. SpeedometerScreen has NO WakelockPlus references 4. SpeedometerScreen has NO WidgetsBindingObserver 5. OverlayProvider has NO grace period timer (30s placeholder removed) 6. GpsDataManager.setOverlayProvider() called in SpeedometerScreen initState 7. Staleness check timer runs periodically and is canceled in dispose() 8. Heading/compass also dims with staleness 9. All timers canceled in dispose() across all modified files 10. No circular provider dependencies

<success_criteria>

  • Flutter analyze passes with no new errors
  • Main app and overlay have identical staleness behavior (3s dim, 10s dash, immediate snap-back)
  • Wake lock management centralized in GpsDataManager (not in SpeedometerScreen)
  • Lifecycle management centralized in GpsDataManager (not in SpeedometerScreen)
  • OverlayProvider is clean -- no placeholder infrastructure from Phase 3
  • GpsDataManager has overlay provider reference for background decisions
  • No regressions in overlay toggle, theme/unit cycling, error handling </success_criteria>
After completion, create `.planning/phases/04-gps-power-optimization/04-02-SUMMARY.md`