Files
gps-speedo/.planning/research/PITFALLS.md
T
Mikkeli Matlock 3c4231d307 docs: complete project research
Files:
- STACK.md (recommended Provider + existing packages, no new dependencies)
- FEATURES.md (restructure patterns, MVVM architecture requirements)
- ARCHITECTURE.md (layer separation, overlay communication, data flow)
- PITFALLS.md (async lifecycle issues, GPS leaks, battery drain)
- SUMMARY.md (synthesis with 4-phase roadmap recommendations)

Key findings:
- Stack: Provider 6.1.x + existing geolocator/overlay packages (no additions needed)
- Architecture: MVVM with layer-first structure, isolate-aware overlay messaging
- Critical pitfall: setState after dispose during Provider migration (audit async patterns first)

Overall confidence: HIGH
Estimated effort: 52-70 hours
Highest risk: Phase 2 (Provider migration) and Phase 3 (Overlay refactor)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 19:28:28 +09:00

40 KiB

Domain Pitfalls: Flutter GPS Speedometer Restructure

Project: GPS Speedometer — Monolith to Modular Restructure Domain: Flutter app restructuring, Android overlay windows, GPS location tracking Researched: 2026-02-09 Confidence: HIGH (based on Context7, official docs, recent 2025-2026 sources)

Executive Summary

Restructuring a working Flutter GPS app from monolith to modular architecture carries high regression risk when the app has no automated tests. The critical pitfalls cluster around four areas: (1) Provider migration breaking async lifecycle patterns, (2) overlay communication becoming unreliable after modularization, (3) GPS stream subscription memory leaks, and (4) excessive battery drain from unconditional wake locks and timers. Each pitfall has early warning signs and specific prevention strategies.

Critical Pitfalls

Mistakes that cause rewrites, regressions, or production failures.


Pitfall 1: setState After Dispose During Provider Migration

What goes wrong:

When migrating from setState() to Provider's ChangeNotifier, async operations (Timers, Stream subscriptions, Future callbacks) that previously called setState() continue running after widget disposal, causing runtime crashes with "setState() called after dispose()" or "A ChangeNotifier was used after being disposed()".

Why it happens:

The monolithic code has multiple async patterns: GPS stream subscriptions, background heartbeat timer (5s), overlay status polling timer (1s), tap-close timer (500ms), and stale data timer (4s). These timers reference this or call setState() in callbacks. During restructuring, these get moved into ChangeNotifiers or Services without proper lifecycle management, causing disposal race conditions.

Root cause from research:

89% of Flutter performance issues stem from poor state management decisions made in early development phases. When migrating setState to Provider, developers often forget to check mounted property before calling setState or fail to cancel async operations in dispose().

Consequences:

  • Silent failures in release mode (errors swallowed)
  • Intermittent crashes during navigation/back button
  • Memory leaks from uncanceled subscriptions holding references to disposed widgets
  • Overlay state becoming unreliable after main app backgrounds/foregrounds

Prevention strategies:

  1. Audit all async patterns before restructuring:

    // Current codebase has these async patterns to audit:
    // - _backgroundHeartbeatTimer (5s periodic)
    // - _overlayStatusCheckTimer (1s periodic)
    // - _tapCloseTimer (500ms one-shot)
    // - GpsDataManager._staleDataTimer (4s one-shot)
    // - _gpsSubscription (stream)
    // - _overlaySubscription (stream)
    
  2. Always check mounted before setState() during migration:

    // BAD (current pattern in monolith):
    Timer.periodic(Duration(seconds: 5), (timer) {
      setState(() { /* update state */ });
    });
    
    // GOOD (migration-safe pattern):
    Timer.periodic(Duration(seconds: 5), (timer) {
      if (mounted) {
        setState(() { /* update state */ });
      }
    });
    
  3. Cancel ALL timers/subscriptions in dispose():

    @override
    void dispose() {
      _gpsSubscription?.cancel();
      _overlaySubscription?.cancel();
      _backgroundHeartbeatTimer?.cancel();
      _overlayStatusCheckTimer?.cancel();
      _tapCloseTimer?.cancel();
      super.dispose(); // MUST call super.dispose()
    }
    
  4. For ChangeNotifier migration, override notifyListeners with disposal check:

    class SafeNotifier extends ChangeNotifier {
      bool _disposed = false;
    
      @override
      void notifyListeners() {
        if (!_disposed) {
          super.notifyListeners();
        }
      }
    
      @override
      void dispose() {
        _disposed = true;
        super.dispose();
      }
    }
    
  5. Use ChangeNotifierProvider.value for existing instances:

    • GpsDataManager is a singleton that survives widget rebuilds
    • Use ChangeNotifierProvider.value(value: GpsDataManager.instance) NOT ChangeNotifierProvider(create:)
    • Provider will NOT auto-dispose singletons when using .value constructor

Detection (warning signs):

  • Console spam with setState() called after dispose() during testing
  • App crashes when navigating back from speedometer screen
  • Overlay stops updating after backgrounding main app
  • Memory usage climbing during repeated navigation cycles
  • Timers continuing to fire after screen disposal (check debug prints)

Phase mapping:

  • Phase 1 (Code Organization): Low risk — just moving code, not changing lifecycle
  • Phase 2 (Provider Migration): CRITICAL RISK — all async patterns must be audited
  • Phase 3 (Overlay Refactor): High risk — overlay timers/subscriptions change
  • Phase 4 (GPS Optimization): Medium risk — new adaptive precision logic adds async complexity

Sources:


Pitfall 2: GPS Stream Subscription Memory Leaks

What goes wrong:

GPS stream subscriptions from geolocator package remain active after widget disposal, causing memory leaks, battery drain, and stale location requests running indefinitely in background.

Why it happens:

GpsDataManager creates a GPS stream subscription in initialize() but the subscription lifecycle is separate from widget lifecycle. Current code has dispose() method on GpsDataManager but nothing calls it — the singleton lives forever. During restructuring, if GPS subscriptions move into widget-scoped providers, forgetting to cancel them in dispose() causes leaks.

Root cause from research:

Disposable objects like StreamSubscriptions have a dispose() method. If not disposed when no longer needed, they remain in memory causing leaks because GC cannot automatically dispose them. Historical geolocator bugs on Android caused memory leaks when stopping position streams.

Consequences:

  • Memory usage grows with each navigation cycle
  • GPS hardware continues polling even when screen is off and no overlay
  • Battery drain from continuous HIGH_ACCURACY location requests
  • Location service icon persists in status bar even when app backgrounded
  • Android vitals flags app for excessive wake lock usage (>2 hours in 24h)

Prevention strategies:

  1. Cancel stream subscriptions in dispose():

    StreamSubscription<Position>? _gpsSubscription;
    
    @override
    void dispose() {
      _gpsSubscription?.cancel(); // CRITICAL
      _gpsSubscription = null;
      super.dispose();
    }
    
  2. Store subscriptions when creating them (initState pattern):

    @override
    void initState() {
      super.initState();
      _mySubscription = myStream.listen((data) {
        // handle data
      });
    }
    
  3. For singleton GpsDataManager, implement proper lifecycle:

    // Option A: Reference counting
    class GpsDataManager {
      int _refCount = 0;
    
      void addRef() {
        _refCount++;
        if (_refCount == 1) initialize();
      }
    
      void removeRef() {
        _refCount--;
        if (_refCount == 0) dispose();
      }
    }
    
    // Option B: Provider-managed lifecycle
    // Use ChangeNotifierProvider with create: (context) => GpsDataManager()
    // Provider will auto-dispose when no longer needed
    
  4. Stop GPS when not needed (smart background GPS):

    @override
    void didChangeAppLifecycleState(AppLifecycleState state) {
      if (state == AppLifecycleState.paused && !_isOverlayActive) {
        _gpsSubscription?.pause(); // or cancel()
      } else if (state == AppLifecycleState.resumed) {
        _gpsSubscription?.resume(); // or re-subscribe
      }
    }
    
  5. Use Leak Tracker during development:

    • Flutter's Leak Tracker (built-in since 2024) assists in preventing undisposed StreamSubscriptions, Timers, Controllers
    • Run app with --track-widget-creation flag for leak detection

Detection (warning signs):

  • Memory profiler shows increasing Position objects over time
  • GPS icon in Android status bar never disappears
  • Battery usage shows high "Location" percentage
  • Console logs show GPS updates continuing after screen navigation
  • Android vitals dashboard flags excessive wake lock usage
  • App backgrounded but location service notification persists

Phase mapping:

  • Phase 1 (Code Organization): Low risk — GpsDataManager already exists and has dispose()
  • Phase 2 (Provider Migration): HIGH RISK — must ensure GpsDataManager.dispose() gets called
  • Phase 3 (Overlay Refactor): Medium risk — overlay closure should trigger GPS stop
  • Phase 4 (GPS Optimization): HIGH RISK — adaptive precision adds pause/resume logic

Sources:


Pitfall 3: Overlay Communication Breaking After Modularization

What goes wrong:

After extracting overlay logic from SpeedometerScreen into separate service/provider, overlay↔main communication becomes unreliable: data stops flowing, overlay shows stale speed, overlay ignores close commands, or overlay crashes silently.

Why it happens:

flutter_overlay_window 0.5.0 has known issues with bidirectional communication — specifically, shareData from overlay→main is "fundamentally broken" according to project context. The overlay runs in a separate isolate with separate memory space. Current workaround uses polling (1s timer checking overlay status) instead of message-based detection. During restructuring, moving message-passing logic breaks these fragile workarounds.

Root cause from research:

Different engines run on different threads and cannot communicate directly — sharing data is the only way to communicate between window engines. Isolates have isolated memory and do not share state, only communicate by message passing. Data being shared must be serializable. Current implementation uses unvalidated Map data with no type safety.

Consequences:

  • Overlay shows "0.0" speed while main app shows real speed
  • Overlay ignores close button taps (HapticFeedback hangs in overlay context)
  • Long-press overlay close unreliable on subsequent launches
  • Main app thinks overlay is closed but it's still visible
  • Theme/unit sync stops working between main↔overlay
  • Silent failures with no user feedback (errors caught and swallowed)

Prevention strategies:

  1. Define typed message schema BEFORE refactoring:

    // Replace unvalidated Map with typed messages
    class OverlayMessage {
      final String type; // 'speed_update' | 'close_request' | 'settings_sync'
      final Map<String, dynamic> payload;
    
      OverlayMessage(this.type, this.payload);
    
      Map<String, dynamic> toJson() => {'type': type, 'payload': payload};
      factory OverlayMessage.fromJson(Map<String, dynamic> json) =>
        OverlayMessage(json['type'], json['payload']);
    }
    
  2. Validate ALL data at boundaries:

    // BAD (current pattern):
    FlutterOverlayWindow.shareData(data); // unvalidated Map
    
    // GOOD (migration pattern):
    void shareDataSafe(OverlayMessage message) {
      try {
        final json = message.toJson();
        FlutterOverlayWindow.shareData(json);
      } catch (e) {
        print('[Overlay] Failed to share data: $e');
        // Show user-visible error
      }
    }
    
  3. Surface overlay errors to user (don't swallow):

    // BAD (current pattern):
    try {
      await FlutterOverlayWindow.showOverlay(...);
    } catch (e) {
      print('Error: $e'); // Silent failure
    }
    
    // GOOD (restructure pattern):
    try {
      await FlutterOverlayWindow.showOverlay(...);
    } catch (e) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('Overlay failed: $e'))
      );
    }
    
  4. Verify overlay creation succeeded before setting flags:

    // BAD (current pattern):
    await FlutterOverlayWindow.showOverlay(...);
    _isOverlayActive = true; // Assumes success
    
    // GOOD (restructure pattern):
    final success = await FlutterOverlayWindow.showOverlay(...);
    if (success == true) {
      _isOverlayActive = true;
    } else {
      // Show error, don't set flag
    }
    
  5. Avoid HapticFeedback in overlay context:

    // HapticFeedback.lightImpact() hangs indefinitely in overlay
    // Move haptics to main app only, or use try-timeout pattern
    
  6. Consider flutter_overlay_window_plus (fork with fixes):

    • Released July 2025, addresses communication reliability issues
    • Better isolate message passing implementation
    • Still requires serializable data

Detection (warning signs):

  • Overlay speed freezes at last value while moving
  • Console shows [Overlay] shareData called but no corresponding receive
  • Overlay close button does nothing
  • Main app shows overlay closed but overlay persists on screen
  • Theme changes in main app don't reflect in overlay
  • Overlay relaunch shows old/cached data instead of current

Phase mapping:

  • Phase 1 (Code Organization): Low risk — overlay code stays in place
  • Phase 2 (Provider Migration): Medium risk — message passing logic moves
  • Phase 3 (Overlay Refactor): CRITICAL RISK — all communication patterns change
  • Phase 4 (GPS Optimization): Low risk — GPS data already flows through manager

Sources:


Pitfall 4: Excessive Battery Drain From Unconditional Background Timers

What goes wrong:

Background heartbeat timer fires every 5 seconds unconditionally, overlay status polling timer fires every 1 second when overlay active, and GPS requests continuous HIGH_ACCURACY location — all causing Android vitals to flag app for excessive battery drain, potentially removing app from Play Store prominent placements.

Why it happens:

Current implementation starts timers/GPS without conditional logic for when they're needed:

  • Heartbeat timer runs even when app in foreground with no overlay (unnecessary)
  • Overlay polling runs even when overlay definitely active (workaround overkill)
  • HIGH_ACCURACY GPS runs even when stationary (<1 km/h)
  • Wake locks held continuously instead of only when tracking with overlay

Root cause from research:

Android introduced excessive partial wake locks metric in 2025, with threshold of 2+ cumulative hours in 24h period. Starting March 2026, apps exceeding 5% bad sessions may be excluded from Play Store recommendations with battery drain warning on listing. Flutter apps commonly trigger this by holding wake locks during foreground services unnecessarily.

Consequences:

  • Android vitals flags app for excessive battery usage
  • Play Store displays battery drain warning on app listing
  • Users complain about battery percentage dropping rapidly
  • App removed from "Editor's Choice" / "Recommended" sections
  • Location service wake lock persists 24/7 even when not tracking
  • Unnecessary CPU wake-ups every 1-5 seconds

Prevention strategies:

  1. Stop heartbeat timer when not needed:

    // ONLY run heartbeat when: (backgrounded AND overlay active)
    void _updateHeartbeatTimer() {
      final needsHeartbeat = _isInBackground && _isOverlayActive;
    
      if (needsHeartbeat && _backgroundHeartbeatTimer == null) {
        _backgroundHeartbeatTimer = Timer.periodic(
          Duration(seconds: 5),
          _handleHeartbeat
        );
      } else if (!needsHeartbeat && _backgroundHeartbeatTimer != null) {
        _backgroundHeartbeatTimer?.cancel();
        _backgroundHeartbeatTimer = null;
      }
    }
    
  2. Replace overlay polling with event-based detection:

    // Current pattern: 1s timer checking FlutterOverlayWindow.isActive()
    // Better pattern: Subscribe to overlay lifecycle events if available
    // Or: Accept that polling is workaround, but make it 5s not 1s
    
  3. Speed-adaptive GPS precision (8/12 km/h hysteresis):

    void _updateGpsAccuracy(double currentSpeed) {
      final shouldUseHighAccuracy = currentSpeed > 12.0; // km/h
      final shouldUseLowAccuracy = currentSpeed < 8.0;   // hysteresis
    
      if (shouldUseHighAccuracy && _currentAccuracy != LocationAccuracy.best) {
        _switchToHighAccuracy();
      } else if (shouldUseLowAccuracy && _currentAccuracy != LocationAccuracy.balanced) {
        _switchToBalancedPower();
      }
    }
    
  4. Stop GPS completely when overlay closed:

    void _handleOverlayClosed() {
      _isOverlayActive = false;
      _gpsSubscription?.pause(); // or cancel()
      WakelockPlus.disable(); // Release wake lock
      _backgroundHeartbeatTimer?.cancel();
    }
    
  5. Cache overlay size calculation:

    // BAD (current pattern):
    Map<String, int> _getOverlaySize() {
      final window = WidgetsBinding.instance.platformDispatcher.views.first;
      // ... calculation on every call
    }
    
    // GOOD (restructure pattern):
    Map<String, int>? _cachedOverlaySize;
    Map<String, int> _getOverlaySize() {
      return _cachedOverlaySize ??= _calculateOverlaySize();
    }
    

Detection (warning signs):

  • Android vitals dashboard shows >5% excessive wake lock sessions
  • Battery historian shows continuous wake locks during screen-off
  • Location service notification persists even when not actively navigating
  • App consumes >5% battery in 1-hour idle period
  • ADB logcat shows timer wake-ups every 1-5 seconds
  • GPS icon in status bar never disappears

Phase mapping:

  • Phase 1 (Code Organization): Low risk — timers stay as-is
  • Phase 2 (Provider Migration): Low risk — timer logic doesn't change
  • Phase 3 (Overlay Refactor): HIGH RISK — must add conditional timer logic
  • Phase 4 (GPS Optimization): CRITICAL RISK — adaptive precision core feature

Sources:


Pitfall 5: Singleton Thread Safety Assumptions Breaking With Isolates

What goes wrong:

GpsDataManager singleton uses lazy initialization (_instance ??= GpsDataManager._internal()) which is NOT thread-safe if accessed from multiple isolates. Overlay runs in separate isolate — if overlay tries to access singleton directly, either gets separate instance (data desync) or race condition on initialization.

Why it happens:

Dart's single-threaded nature makes basic singleton implementation safe for single-isolate apps. But overlay window runs in separate isolate with separate heap. Current implementation assumes single isolate but doesn't document or enforce this constraint.

Consequences:

  • Overlay creates separate GpsDataManager instance with no GPS data
  • Race condition if main + overlay both call GpsDataManager.instance during startup
  • Data changes in main app's singleton don't reflect in overlay's separate instance
  • Two GPS stream subscriptions running (one per isolate) doubling battery drain
  • Confusing debug logs showing duplicate initialization

Prevention strategies:

  1. Document single-isolate constraint:

    /// GpsDataManager singleton.
    ///
    /// WARNING: This singleton is NOT thread-safe across isolates.
    /// Each isolate gets its own instance. Do NOT access directly from
    /// overlay isolate — use message passing instead.
    class GpsDataManager { ... }
    
  2. If multi-isolate access needed, use synchronized package:

    import 'package:synchronized/synchronized.dart';
    
    class GpsDataManager {
      static GpsDataManager? _instance;
      static final _lock = Lock();
    
      static Future<GpsDataManager> get instance async {
        return await _lock.synchronized(() {
          return _instance ??= GpsDataManager._internal();
        });
      }
    }
    
  3. Better: Enforce single-isolate architecture:

    // GPS lives in main isolate only
    // Overlay receives GPS data via message passing
    // Overlay NEVER accesses GpsDataManager directly
    
  4. Add runtime isolate check:

    class GpsDataManager {
      final String _isolateId = Isolate.current.debugName ?? 'main';
    
      GpsDataManager._internal() {
        if (_isolateId != 'main') {
          throw StateError(
            'GpsDataManager should only be created in main isolate. '
            'Current: $_isolateId'
          );
        }
      }
    }
    

Detection (warning signs):

  • Console shows multiple "GPS manager initialized" logs
  • Overlay shows no GPS data despite main app showing speed
  • GPS battery usage doubles unexpectedly
  • Two location service notifications appear
  • Debug logs show different instance hashCodes

Phase mapping:

  • Phase 1 (Code Organization): Low risk — singleton stays in main isolate
  • Phase 2 (Provider Migration): Low risk — Provider enforces widget tree scope
  • Phase 3 (Overlay Refactor): MEDIUM RISK — must clarify isolate boundaries
  • Phase 4 (GPS Optimization): Low risk — GPS stays in main isolate

Sources:


Moderate Pitfalls

Mistakes that cause delays or technical debt but are fixable.


Pitfall 6: ChangeNotifier Excessive Rebuilds After Provider Migration

What goes wrong:

After migrating to Provider, every notifyListeners() call rebuilds ALL Consumer widgets, even if the specific data they depend on hasn't changed. This causes UI jank, dropped frames, and poor performance — especially problematic for GPS updates at 1Hz+ refresh rate.

Why it happens:

ChangeNotifier broadcasts to all listeners indiscriminately. If SpeedometerProvider has speed, heading, unit, theme, overlayActive as properties, changing theme triggers rebuilds of speed display widgets unnecessarily.

Consequences:

  • Dropped frames during GPS updates (FPS drops below 60)
  • UI lag when cycling theme/unit
  • Battery drain from excessive widget rebuilds
  • Analog gauge animation stutters
  • Poor user experience despite correct data

Prevention strategies:

  1. Use Selector instead of Consumer for targeted rebuilds:

    // BAD: Rebuilds on ANY notifyListeners()
    Consumer<SpeedometerProvider>(
      builder: (context, provider, child) => Text('${provider.speed}')
    )
    
    // GOOD: Rebuilds only when speed changes
    Selector<SpeedometerProvider, double>(
      selector: (context, provider) => provider.speed,
      builder: (context, speed, child) => Text('$speed')
    )
    
  2. Place Consumer widgets deep in tree:

    // Wrap only the Text widget that needs updates, not entire Column
    Column(
      children: [
        SomeStaticHeader(),
        Consumer<SpeedometerProvider>(
          builder: (context, provider, child) => Text('${provider.speed}')
        ),
        SomeStaticFooter(),
      ]
    )
    
  3. Split large ChangeNotifiers:

    // Instead of one SpeedometerProvider with everything:
    class GpsDataProvider extends ChangeNotifier { /* speed, heading */ }
    class SettingsProvider extends ChangeNotifier { /* theme, unit */ }
    class OverlayProvider extends ChangeNotifier { /* overlayActive */ }
    
  4. Check before notifying:

    void updateSpeed(double newSpeed) {
      if (_speed == newSpeed) return; // Skip notification
      _speed = newSpeed;
      notifyListeners();
    }
    
  5. Use child parameter for static subtrees:

    Consumer<SpeedometerProvider>(
      builder: (context, provider, child) {
        return Column(
          children: [
            Text('${provider.speed}'), // Dynamic
            child!, // Static subtree never rebuilds
          ]
        );
      },
      child: ExpensiveStaticWidget(),
    )
    

Detection (warning signs):

  • Flutter DevTools Performance tab shows frequent widget rebuilds
  • Timeline shows jank during GPS updates
  • FPS counter drops below 60 during speed changes
  • Debug prints show rebuilds when unrelated state changes

Phase mapping:

  • Phase 2 (Provider Migration): HIGH RISK — initial naive Consumer use
  • Phase 3 (Overlay Refactor): Medium risk — overlay state changes trigger rebuilds

Sources:


Pitfall 7: Manual Regression Testing Missing Edge Cases

What goes wrong:

Without automated tests, restructuring relies on manual regression testing. Manual testing misses edge cases like "background app → revoke overlay permission → foreground app" or "GPS disabled → enable → race condition in subscription restart".

Why it happens:

App has zero unit/widget/integration tests. Project context explicitly states "no tests, regressions are manual-detection-only". Manual testing follows happy path (start app → see speed → close app) and misses error paths, lifecycle transitions, permission changes, and race conditions.

Consequences:

  • Regressions ship to production
  • User reports bugs that "never happened during testing"
  • Permission errors silently swallowed
  • Overlay gets stuck on screen with no close mechanism
  • App crashes only on specific Android versions/devices
  • Background service leaks discovered months later

Prevention strategies:

  1. Create manual test checklist BEFORE restructuring:

    Functional Tests:
    - [ ] Start app with GPS disabled → enable GPS → see speed
    - [ ] Start app with location permission denied → grant → see speed
    - [ ] Start app with overlay permission denied → grant → tap nav icon
    - [ ] Open overlay → minimize app → overlay stays visible
    - [ ] Open overlay → long-press nav area → overlay closes
    - [ ] Open overlay → tap overlay close button → overlay closes
    - [ ] Change theme while overlay open → theme syncs
    - [ ] Change unit while overlay open → unit syncs
    - [ ] Open overlay → revoke overlay permission → no crash
    - [ ] GPS fix → move to indoor → stale data timeout → shows "--"
    - [ ] Stop moving → speed shows "--" (low speed threshold)
    - [ ] Rotate device → layout adjusts
    - [ ] Background app → foreground → GPS reconnects
    - [ ] Kill app → restart → settings persist (wait, they don't — out of scope)
    
    Performance Tests:
    - [ ] Leave app running 30 min → no memory leak
    - [ ] Leave overlay running 30 min → no battery drain warning
    - [ ] Check Android vitals → no excessive wake lock flags
    - [ ] Navigate away/back 20 times → no memory increase
    
    Error Tests:
    - [ ] Disable GPS during active tracking → error message
    - [ ] Revoke location permission during tracking → error message
    - [ ] Force-stop location service → graceful degradation
    
  2. Add defensive error surfacing:

    // Catch and DISPLAY errors, don't swallow them
    void _handleError(String context, dynamic error) {
      print('[$context] Error: $error');
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(
          content: Text('$context failed: $error'),
          duration: Duration(seconds: 5),
        )
      );
    }
    
  3. Use debug assertions liberally:

    assert(_gpsSubscription != null, 'GPS subscription null during update');
    assert(!_disposed, 'Method called after dispose');
    
  4. Test on MULTIPLE devices:

    • Different Android versions (11, 12, 13, 14, 15)
    • Different OEMs (Samsung, Xiaomi, OnePlus — aggressive battery optimization)
    • Different screen densities
    • Different GPS hardware quality
  5. Consider adding smoke test suite post-restructure:

    // Even basic widget tests catch regressions
    testWidgets('Speed displays when GPS active', (tester) async {
      await tester.pumpWidget(SpeedoApp());
      expect(find.text('--'), findsOneWidget); // Initial state
      // Inject mock GPS data
      expect(find.textContaining('km/h'), findsOneWidget);
    });
    

Detection (warning signs):

  • User reports bugs not seen during development
  • Different behavior on physical device vs emulator
  • Issues only reproducible on specific Android versions
  • Crashes in release build but not debug build
  • Battery drain reports after release

Phase mapping:

  • ALL PHASES: Continuous risk — manual testing after each phase
  • Phase 2 (Provider Migration): High risk — many state transitions
  • Phase 3 (Overlay Refactor): High risk — complex lifecycle interactions

Sources:


Pitfall 8: Permission State Changes Not Handled At Runtime

What goes wrong:

User grants overlay permission → opens overlay → backgrounds app → revokes overlay permission in Settings → foregrounds app → app crashes or overlay gets stuck because permission state change not handled.

Why it happens:

Permission checks happen once at overlay creation. App assumes permission persists for entire session. Android 11+ allows runtime permission revocation even for previously-granted permissions.

Consequences:

  • App crashes when trying to update overlay after permission revoked
  • Overlay remains visible even after permission revoked (zombie overlay)
  • No user feedback explaining why overlay stopped working
  • SecurityException crashes in production

Prevention strategies:

  1. Check permission before EVERY overlay operation:

    Future<void> updateOverlay(String data) async {
      final hasPermission = await Permission.systemAlertWindow.isGranted;
      if (!hasPermission) {
        _handlePermissionRevoked();
        return;
      }
    
      try {
        await FlutterOverlayWindow.shareData(data);
      } catch (e) {
        if (e.toString().contains('permission')) {
          _handlePermissionRevoked();
        }
      }
    }
    
  2. Handle permission revocation gracefully:

    void _handlePermissionRevoked() {
      setState(() {
        _isOverlayActive = false;
      });
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('Overlay permission revoked. Please re-enable in Settings.'))
      );
    }
    
  3. Re-check permission on app resume:

    @override
    void didChangeAppLifecycleState(AppLifecycleState state) {
      if (state == AppLifecycleState.resumed && _isOverlayActive) {
        _verifyOverlayPermission();
      }
    }
    
  4. Wrap all overlay calls in try-catch:

    try {
      await FlutterOverlayWindow.showOverlay(...);
    } on PlatformException catch (e) {
      if (e.code == 'PERMISSION_DENIED') {
        _handlePermissionRevoked();
      } else {
        rethrow;
      }
    }
    

Detection (warning signs):

  • Crashes with "SecurityException: Permission Denial"
  • Overlay stops updating but no error message shown
  • Console shows permission errors but UI shows no feedback

Phase mapping:

  • Phase 3 (Overlay Refactor): MEDIUM RISK — overlay service needs checks
  • Phase 4 (GPS Optimization): Low risk — GPS permission separate concern

Sources:


Minor Pitfalls

Mistakes that cause annoyance but are easily fixable.


Pitfall 9: Debug Print Statements Polluting Production Logs

What goes wrong:

After restructuring, excessive debug prints make production logs unreadable, hide real errors, and cause performance overhead from string formatting.

Why it happens:

Current codebase has heavy debug logging (every GPS update, every stream broadcast, every timer fire). During restructuring, more debug prints get added to track flow. No cleanup before release.

Prevention:

  • Use customDebugPrint() helper (already exists) — only prints in debug mode
  • Replace print() with debugPrint() for auto-stripping in release
  • Add log levels: [DEBUG], [INFO], [ERROR]
  • Remove or comment verbose logs before production build

Detection:

  • Release build logcat flooded with messages
  • Performance profiling shows time spent in print statements

Phase mapping:

  • All phases: Continuous cleanup task

Pitfall 10: Hardcoded Magic Numbers Scattered During Refactor

What goes wrong:

Timer durations (5s, 1s, 500ms, 4s), GPS thresholds (1.0 m/s, 10 km/h), overlay size percentages (45%, 60%) remain hardcoded magic numbers scattered across multiple files after modularization.

Prevention:

  • Create constants file during Phase 1:
    // lib/constants.dart
    class AppConstants {
      static const heartbeatInterval = Duration(seconds: 5);
      static const overlayPollInterval = Duration(seconds: 1);
      static const staleDataTimeout = Duration(seconds: 4);
      static const speedThresholdDisplay = 1.0; // m/s
      static const speedThresholdHighAccuracy = 12.0; // km/h
      static const speedThresholdLowAccuracy = 8.0; // km/h
      static const overlayWidthPercent = 0.45;
      static const overlayAspectRatio = 0.6; // height/width
    }
    

Detection:

  • During code review, spot numbers without context
  • Difficulty adjusting values requires multi-file search

Phase mapping:

  • Phase 1 (Code Organization): Create constants file immediately

Phase-Specific Risk Matrix

Phase Critical Risks Moderate Risks Mitigation Priority
Phase 1: Code Organization None Magic numbers (#10) Low — just moving code
Phase 2: Provider Migration setState after dispose (#1)
GPS stream leaks (#2)
Excessive rebuilds (#6)
Manual regression gaps (#7) CRITICAL — audit all async patterns first
Phase 3: Overlay Refactor Overlay communication breaks (#3)
Battery drain from timers (#4)
Permission runtime changes (#8) HIGH — define message schema first
Phase 4: GPS Optimization Battery drain from adaptive GPS (#4)
GPS stream leaks (#2)
None HIGH — test battery usage thoroughly

Confidence Assessment

Pitfall Category Confidence Level Rationale
Provider migration lifecycle HIGH Multiple 2025-2026 sources, official Flutter docs, established patterns
GPS stream memory leaks HIGH Geolocator package issues tracker, official Dart docs, DCM memory leak analysis
Overlay communication reliability MEDIUM flutter_overlay_window GitHub issues confirm problems, but solutions vary
Battery drain detection/prevention HIGH Android official blog posts (2025), vitals documentation, enforcement starting 2026
Singleton thread safety MEDIUM Dart isolate docs clear, but app-specific isolate usage needs validation
Manual testing gaps HIGH Industry consensus 2026, multiple testing framework sources
Permission runtime changes MEDIUM Android 11+ docs confirm, but Flutter-specific handling less documented

Research Gaps

Areas where research was inconclusive or needs phase-specific investigation:

  1. flutter_overlay_window 0.5.0 specific limitations:

    • Project context mentions "shareData fundamentally broken" but GitHub issues are vague
    • Need to test overlay→main communication thoroughly during Phase 3
    • Consider flutter_overlay_window_plus fork (2025) as alternative
  2. GpsDataManager singleton lifecycle:

    • Currently nothing calls dispose() on singleton
    • Need to decide: Reference counting? Provider-managed? App-global?
    • Research needed on cleanest Provider + Singleton pattern
  3. Optimal GPS accuracy switching thresholds:

    • Project spec says ~10 km/h with 8/12 hysteresis
    • Need field testing to validate battery vs responsiveness tradeoff
    • Android Fused Location Provider behavior varies by OEM
  4. Overlay status polling alternative:

    • Current 1s polling is workaround for broken message-based detection
    • Is there event-based alternative? Or accept polling at 5s instead?
    • Research flutter_overlay_window_plus event support

Sources Summary

Primary sources (HIGH confidence):

Secondary sources (MEDIUM confidence):

Supplementary sources:

  • Multiple Medium articles on Provider migration best practices
  • Stack Overflow discussions on setState after dispose patterns
  • Flutter Community blog posts on ChangeNotifier optimization

Research completed: 2026-02-09 Domain: Flutter restructuring, Android overlay windows, GPS location services Target audience: Roadmap creator, phase planners