Files
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

28 KiB

Feature Landscape: Flutter GPS Speedometer Restructure

Domain: Flutter GPS/Speedometer App Architecture Researched: 2026-02-09 Confidence: HIGH (verified with official Flutter docs + Android developer guides)

Executive Summary

This research focuses on HOW features should be structured in a well-architected Flutter GPS speedometer app, not what new features to add. The current app suffers from monolithic architecture (1,133-line main.dart with raw setState), buggy overlay data synchronization, and aggressive power usage. Research reveals that modern 2026 Flutter architecture emphasizes MVVM/feature-first organization, proper state management with Provider (or migration to Riverpod), and adaptive GPS precision strategies.


Table Stakes

Features/patterns users expect from a well-structured GPS speedometer app. Missing = restructure is pointless.

Pattern Why Expected Complexity Notes
MVVM Layer Separation Standard 2026 Flutter architecture - UI/Logic/Data layers must be distinct Medium View ↔ ViewModel ↔ Repository ↔ Service. Official Flutter recommendation. Prevents spaghetti code.
Feature-First Folder Structure Scales better than layer-first for multi-feature apps Low lib/features/speedometer/, lib/features/overlay/, lib/core/. Each feature self-contained.
Repository Pattern Single source of truth for GPS data - prevents state drift Medium GpsDataManager → Repository → Service pattern. Already partially implemented.
Provider Integration Move from raw setState to proper reactive state management Medium Consumer widgets + ChangeNotifier. Current app has Provider in dependencies but doesn't fully use it.
Speed-Adaptive GPS Precision Critical for battery life - high accuracy only when moving fast High Switch PRIORITY_HIGH_ACCURACY ↔ PRIORITY_BALANCED_POWER_ACCURACY based on speed thresholds (e.g., <5 km/h = balanced, >5 km/h = high).
Isolate-Aware Overlay Communication Overlay runs in separate isolate - needs proper message protocol High Use FlutterOverlayWindow.shareData() with structured messages. Current stale data issue stems from one-time sync instead of continuous updates.
Unidirectional Data Flow State flows downward: Data Layer → Logic Layer → UI Layer Medium Prevents circular dependencies. Events flow upward via commands/callbacks.
Service Layer Isolation GPS hardware access isolated to service layer only Low Already have gps_service.dart. Must ensure ViewModels never call Geolocator directly.
Lifecycle-Aware State Management Properly handle app backgrounding, overlay launch/close, GPS stream lifecycle Medium Use WidgetsBindingObserver + StreamSubscription cleanup. Partially implemented but needs formalization.

Differentiators

Patterns that make the architecture notably better than standard implementations.

Pattern Value Proposition Complexity Notes
Hybrid GPS Strategy Better battery life than continuous HIGH_ACCURACY - adaptively switches precision based on movement context High Combine speed threshold + WiFi connection state + motion sensors. Example: stationary + WiFi connected = PASSIVE mode, slow movement = BALANCED, fast movement = HIGH_ACCURACY.
Message Queue for Overlay Sync Prevents data staleness - overlay receives continuous updates via message stream Medium Implement periodic broadcast (e.g., every 500ms) with debouncing. Queue messages if overlay isn't ready. Better than current one-shot messaging.
WorkManager Background Tasks Modern alternative to wake locks - system handles power management Medium Use WorkManager for periodic location checks when app backgrounded. Replaces current aggressive wake lock + heartbeat timer pattern. Reduces Google Play Store policy violations.
ViewModel Command Pattern Cleaner event handling - views call commands, ViewModels never expose widgets Low viewModel.onThemeToggle() instead of callbacks. Makes testing easier and separates concerns.
Multi-Repository ViewModel Speedometer feature needs GPS + Settings + Overlay state - composing multiple repositories prevents monolithic ViewModels Medium SpeedometerViewModel depends on GpsRepository + SettingsRepository + OverlayRepository. Each repository focused on single domain.
Duty-Cycle Awareness Android 9+ duty-cycles GNSS hardware - app should detect and adapt update frequency expectations Medium Monitor GPS timestamp deltas. If duty-cycling detected (irregular intervals), adjust UI update strategy to interpolate between readings.
Scoped Provider Instances Overlay and main app have separate Provider scopes - prevents state collision Medium Each isolate (main app vs overlay) has its own Provider tree. Use message passing for synchronization, not shared state. Critical for fixing current stale data bug.
Foreground Service Architecture Proper Android foreground service with notification for GPS tracking - prevents system kill Low Already have SYSTEM_ALERT_WINDOW permission. Add android:foregroundServiceType="location" and show persistent notification while tracking active.

Anti-Features

Things to deliberately NOT do during restructuring. Common mistakes in this domain.

Anti-Pattern Why Avoid What to Do Instead
Monolithic ViewModel/Widget Current 1,133-line main.dart is technical debt. Difficult to test, reason about, or modify. Split into feature-specific ViewModels: SpeedometerViewModel, OverlayViewModel, SettingsViewModel. Each <200 lines.
Shared State Between Isolates Flutter overlay runs in separate isolate - cannot share memory. Current stale data bug stems from assuming shared state. Use message passing only. Send serialized GPS data via shareData(). No global singletons accessible from both isolates.
Continuous HIGH_ACCURACY GPS Current app uses HIGH_ACCURACY + wake locks continuously. Drains 7-12% battery per hour. Violates 2026 Google Play Store excessive wake lock policy. Implement speed-adaptive precision. Switch to BALANCED_POWER_ACCURACY when stationary or slow (<5 km/h). Use PASSIVE mode when WiFi connected + stationary.
Wake Lock Without Justification Current permanent wake lock prevents system power management. Google penalizes apps exceeding 2 hours wake lock in 24 hours. Replace with WorkManager for background operation. Only use wake lock during active navigation/overlay display. Release wake lock when app backgrounded without overlay.
setState for Cross-Widget State Using setState in main.dart forces entire screen rebuilds. Doesn't scale to overlay sync. Migrate to Provider pattern fully. Use Consumer widgets at leaf nodes. Only rebuild widgets that depend on changed state.
Synchronous Overlay Data Push Current "push once on overlay launch" approach causes stale data. Overlay has no way to request updates. Implement continuous broadcast stream. Overlay subscribes to message stream and updates reactively. Debounce to 2-4 updates/second max.
Ignoring Duty-Cycling Android duty-cycles GPS after Android 9 to save battery. Expecting continuous updates leads to UI jank when updates arrive in bursts. Buffer GPS readings and interpolate between updates for smooth UI. Accept that <1s update frequency may not be achievable on battery-optimized devices.
Repository Coupling Having GpsDataManager directly call overlay messaging violates separation of concerns. GpsRepository emits data streams. Separate OverlayService subscribes to GPS stream and handles messaging. Repositories unaware of each other.
Missing Error State Handling Current error messages displayed directly in UI. No way to recover from GPS permission denial or service disabled. ViewModels expose error state objects. Views render error UI with action buttons (e.g., "Enable GPS" button opens system settings via SettingsClient).
Over-Engineering with BLoC For this app's complexity, BLoC would add boilerplate without benefit. Current team already using Provider. Stick with Provider + MVVM. Riverpod migration optional but not required. BLoC overkill for single-developer GPS app.

Feature Dependencies

Patterns have ordering dependencies - some must be implemented before others.

Foundation Layer (implement first):
├── Feature-First Folder Structure
├── Service Layer Isolation (GPS, Settings, Overlay)
└── Repository Pattern (single source of truth)
    ↓
State Management Layer (depends on repositories):
├── Provider Integration (ChangeNotifier ViewModels)
├── Unidirectional Data Flow
└── Lifecycle-Aware State Management
    ↓
GPS Optimization Layer (depends on state management):
├── Speed-Adaptive GPS Precision
├── Duty-Cycle Awareness
└── WorkManager Background Tasks (replaces wake locks)
    ↓
Overlay Communication Layer (depends on all above):
├── Isolate-Aware Messaging Protocol
├── Message Queue for Continuous Sync
└── Scoped Provider Instances (separate isolate contexts)

Critical path: Cannot fix overlay stale data issue until Provider properly integrated + message queue implemented + scoped instances established.


MVP Recommendation

For restructure MVP, prioritize these patterns (in order):

Phase 1: Foundation (Must Have)

  1. Feature-First Folder Structure - Extract features from monolithic main.dart
  2. MVVM Layer Separation - Create View/ViewModel/Repository/Service layers
  3. Provider Integration - Replace setState with ChangeNotifier + Consumer

Rationale: These establish architectural foundation. Without these, remaining patterns cannot be properly implemented.

Phase 2: GPS Optimization (Should Have)

  1. Speed-Adaptive GPS Precision - Implement BALANCED ↔ HIGH_ACCURACY switching
  2. Repository Pattern - Formalize GpsDataManager as proper Repository
  3. WorkManager Background Tasks - Replace aggressive wake lock usage

Rationale: Addresses current battery drain and Google Play Store policy compliance. Can be implemented once foundation exists.

Phase 3: Overlay Fix (Must Have for Feature Parity)

  1. Isolate-Aware Overlay Communication - Implement proper message protocol
  2. Message Queue for Continuous Sync - Fix stale data bug with stream-based updates
  3. Scoped Provider Instances - Separate state management per isolate

Rationale: Fixes critical overlay bug. Blocked by Phase 1 Provider integration.

Defer to Post-Restructure:

  • Duty-Cycle Awareness: Nice-to-have optimization, not critical for functionality
  • Multi-Repository ViewModel: Current app simple enough for single GPS repository
  • Hybrid GPS Strategy: Advanced optimization - basic speed-adaptive sufficient for MVP
  • Foreground Service Architecture: Already working, no immediate need to refactor

Architecture Pattern Examples

Current Problematic Pattern

// main.dart - 1,133 lines, monolithic
class _SpeedometerScreenState extends State<SpeedometerScreen> {
  SpeedUnit _currentUnit = SpeedUnit.kmh;           // Settings state
  int _currentThemeIndex = 0;                        // Theme state
  ProcessedGpsData _currentGpsData = ...;            // GPS state
  bool _isOverlayActive = false;                     // Overlay state
  Timer? _backgroundHeartbeatTimer;                  // Background state

  // All business logic mixed with UI logic
  void _startGPS() { ... }
  void _updateOverlay() { ... }
  Widget build(BuildContext context) { /* 800+ lines */ }
}

Problems:

  • Single file responsible for 5+ concerns
  • No testability (widget tests required for business logic)
  • setState forces entire screen rebuild
  • Overlay state coupled to main screen state
lib/
├── features/
│   ├── speedometer/
│   │   ├── presentation/
│   │   │   ├── speedometer_screen.dart          // View (UI only)
│   │   │   └── widgets/
│   │   │       ├── digital_display.dart
│   │   │       └── analog_gauge.dart
│   │   ├── viewmodels/
│   │   │   └── speedometer_viewmodel.dart       // ViewModel (UI logic)
│   │   └── data/
│   │       └── repositories/
│   │           └── gps_repository.dart          // Repository (data logic)
│   │
│   ├── overlay/
│   │   ├── presentation/
│   │   │   └── overlay_screen.dart              // Separate isolate UI
│   │   ├── viewmodels/
│   │   │   └── overlay_viewmodel.dart           // Overlay-specific logic
│   │   └── services/
│   │       └── overlay_messaging_service.dart   // Message protocol
│   │
│   └── settings/
│       ├── presentation/
│       │   └── settings_screen.dart
│       ├── viewmodels/
│       │   └── settings_viewmodel.dart
│       └── data/
│           └── repositories/
│               └── settings_repository.dart     // SharedPreferences wrapper
│
├── core/
│   ├── services/
│   │   ├── gps_service.dart                     // Hardware abstraction
│   │   └── permission_service.dart
│   └── models/
│       ├── processed_gps_data.dart
│       └── speed_unit.dart
│
└── main.dart                                    // App entry point only

Benefits:

  • Each feature self-contained
  • Business logic testable without Flutter
  • Clear responsibility boundaries
  • Easy to locate code by feature
// speedometer_viewmodel.dart
class SpeedometerViewModel extends ChangeNotifier {
  final GpsRepository _gpsRepository;
  final SettingsRepository _settingsRepository;

  // UI State (derived from repositories)
  ProcessedGpsData? _currentGpsData;
  bool _isLoading = true;
  String? _errorMessage;

  SpeedometerViewModel({
    required GpsRepository gpsRepository,
    required SettingsRepository settingsRepository,
  }) : _gpsRepository = gpsRepository,
       _settingsRepository = settingsRepository {
    _initialize();
  }

  // Getters (read-only state for views)
  ProcessedGpsData? get gpsData => _currentGpsData;
  bool get isLoading => _isLoading;
  String? get errorMessage => _errorMessage;
  SpeedUnit get currentUnit => _settingsRepository.speedUnit;

  // Commands (view actions)
  Future<void> refreshGPS() async {
    // Business logic here
    notifyListeners();
  }

  void toggleSpeedUnit() {
    _settingsRepository.toggleSpeedUnit();
    notifyListeners();
  }

  void _initialize() {
    // Subscribe to GPS stream
    _gpsRepository.gpsStream.listen(
      (data) {
        _currentGpsData = data;
        _isLoading = false;
        _errorMessage = null;
        notifyListeners();
      },
      onError: (error) {
        _errorMessage = error.toString();
        _isLoading = false;
        notifyListeners();
      },
    );
  }

  @override
  void dispose() {
    // Cleanup subscriptions
    super.dispose();
  }
}

Benefits:

  • Testable without widgets (mock repositories)
  • Clear command pattern (toggleSpeedUnit vs callbacks)
  • State changes trigger only necessary rebuilds
  • Repository dependencies injected (testable, swappable)
// speedometer_screen.dart (View only)
class SpeedometerScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Column(
        children: [
          // Only rebuild speed display when GPS data changes
          Consumer<SpeedometerViewModel>(
            builder: (context, viewModel, child) {
              if (viewModel.isLoading) {
                return CircularProgressIndicator();
              }

              if (viewModel.errorMessage != null) {
                return ErrorWidget(
                  message: viewModel.errorMessage!,
                  onRetry: viewModel.refreshGPS,
                );
              }

              return DigitalSpeedDisplay(
                speed: viewModel.gpsData?.displaySpeed ?? '--',
                unit: viewModel.currentUnit,
              );
            },
          ),

          // Static child (doesn't rebuild on every GPS update)
          Consumer<SpeedometerViewModel>(
            builder: (context, viewModel, child) {
              return SpeedUnitToggle(
                currentUnit: viewModel.currentUnit,
                onToggle: viewModel.toggleSpeedUnit,
              );
            },
          ),
        ],
      ),
    );
  }
}

Benefits:

  • View is "dumb" - no business logic
  • Consumer scoped to minimize rebuilds
  • Clear separation: view renders, viewModel decides what to render
  • Testable: mock ViewModel, verify UI rendering
// gps_repository.dart
class GpsRepository {
  static const double STATIONARY_THRESHOLD_MPS = 1.4; // ~5 km/h
  static const double MOVING_THRESHOLD_MPS = 8.3;     // ~30 km/h

  LocationAccuracy _currentAccuracy = LocationAccuracy.balanced;
  double _lastSpeed = 0.0;

  void _updateAccuracyBasedOnSpeed(double speedMps) {
    final previousAccuracy = _currentAccuracy;

    // State machine for accuracy switching
    if (speedMps < STATIONARY_THRESHOLD_MPS) {
      _currentAccuracy = LocationAccuracy.low; // PRIORITY_BALANCED_POWER_ACCURACY
    } else if (speedMps > MOVING_THRESHOLD_MPS) {
      _currentAccuracy = LocationAccuracy.best; // PRIORITY_HIGH_ACCURACY
    }
    // Hysteresis: stay in current mode if between thresholds

    // Only reconfigure GPS if accuracy changed
    if (previousAccuracy != _currentAccuracy) {
      _reconfigureGpsStream(_currentAccuracy);
      debugPrint('GPS accuracy changed: $previousAccuracy → $_currentAccuracy');
    }

    _lastSpeed = speedMps;
  }

  void _reconfigureGpsStream(LocationAccuracy accuracy) {
    // Cancel existing stream
    _gpsSubscription?.cancel();

    // Create new stream with updated accuracy
    final locationSettings = LocationSettings(
      accuracy: accuracy,
      distanceFilter: accuracy == LocationAccuracy.best ? 5 : 50,
    );

    _gpsSubscription = Geolocator.getPositionStream(
      locationSettings: locationSettings,
    ).listen(_handleGpsUpdate);
  }
}

Benefits:

  • Automatic switching based on movement
  • Hysteresis prevents rapid toggling
  • Clear thresholds (can be tuned)
  • Logs accuracy changes for debugging
// overlay_messaging_service.dart
class OverlayMessagingService {
  static const Duration UPDATE_INTERVAL = Duration(milliseconds: 500);
  Timer? _broadcastTimer;

  void startBroadcasting(Stream<ProcessedGpsData> gpsStream) {
    // Debounced broadcast to prevent message flooding
    _broadcastTimer = Timer.periodic(UPDATE_INTERVAL, (_) async {
      final latestData = _latestGpsData;
      if (latestData != null) {
        await _sendToOverlay(latestData);
      }
    });

    // Subscribe to GPS stream
    gpsStream.listen((data) {
      _latestGpsData = data;
    });
  }

  Future<void> _sendToOverlay(ProcessedGpsData data) async {
    // Structured message format
    final message = jsonEncode({
      'type': 'gps_update',
      'timestamp': DateTime.now().millisecondsSinceEpoch,
      'data': {
        'displaySpeed': data.displaySpeed,
        'displayHeading': data.displayHeading,
        'isSpeedValid': data.isSpeedValid,
        'isHeadingValid': data.isHeadingValid,
      },
    });

    try {
      await FlutterOverlayWindow.shareData(message);
    } catch (e) {
      debugPrint('Failed to send overlay message: $e');
      // Don't crash main app if overlay messaging fails
    }
  }

  void stopBroadcasting() {
    _broadcastTimer?.cancel();
    _broadcastTimer = null;
  }
}

Benefits:

  • Continuous updates (fixes stale data bug)
  • Debounced to prevent message flooding (500ms = 2 updates/second)
  • Structured JSON messages (extensible)
  • Graceful failure handling (overlay may not be active)
  • Timestamp allows overlay to detect stale messages
// overlay_main.dart (separate entry point)
@pragma("vm:entry-point")
void overlayMain() {
  runApp(
    MultiProvider(
      providers: [
        ChangeNotifierProvider(
          create: (_) => OverlayViewModel(), // Separate instance from main app
        ),
      ],
      child: MaterialApp(
        home: OverlayScreen(),
      ),
    ),
  );
}

// overlay_viewmodel.dart
class OverlayViewModel extends ChangeNotifier {
  ProcessedGpsData? _currentGpsData;
  DateTime? _lastUpdate;

  OverlayViewModel() {
    _startListening();
  }

  void _startListening() {
    // Listen for messages from main app
    FlutterOverlayWindow.overlayListener.listen((rawMessage) {
      try {
        final message = jsonDecode(rawMessage);

        if (message['type'] == 'gps_update') {
          _currentGpsData = ProcessedGpsData.fromJson(message['data']);
          _lastUpdate = DateTime.fromMillisecondsSinceEpoch(
            message['timestamp'],
          );
          notifyListeners();
        }
      } catch (e) {
        debugPrint('Failed to parse overlay message: $e');
      }
    });
  }

  // Check if data is stale (no updates for 2+ seconds)
  bool get isDataStale {
    if (_lastUpdate == null) return true;
    return DateTime.now().difference(_lastUpdate!) > Duration(seconds: 2);
  }

  ProcessedGpsData? get gpsData => isDataStale ? null : _currentGpsData;
}

Benefits:

  • Overlay has its own Provider scope (separate isolate)
  • Reactive updates via overlayListener
  • Stale data detection (shows "--" if no recent updates)
  • No shared state between main app and overlay

Complexity Assessment

Pattern Category Overall Complexity Implementation Time Risk
MVVM Layer Separation Medium 8-12 hours Low - well-documented pattern
Feature-First Structure Low 4-6 hours Low - file reorganization
Provider Integration Medium 6-8 hours Medium - requires careful migration from setState
Speed-Adaptive GPS High 12-16 hours Medium - requires testing on real devices
Overlay Communication High 16-20 hours High - isolate debugging difficult
WorkManager Background Medium 6-8 hours Medium - Android platform-specific

Total restructure estimate: 52-70 hours (1.5-2 weeks full-time)

Highest risk areas:

  1. Overlay communication - isolates cannot be debugged easily, requires extensive physical device testing
  2. Speed-adaptive GPS - thresholds need real-world tuning, different Android versions behave differently
  3. Provider migration - easy to miss setState calls, requires thorough testing

Testing Strategy Implications

Well-structured architecture enables better testing:

Unit Tests (Business Logic)

// Test ViewModel without Flutter framework
test('SpeedometerViewModel toggles speed unit', () {
  final mockGpsRepo = MockGpsRepository();
  final mockSettingsRepo = MockSettingsRepository();

  final viewModel = SpeedometerViewModel(
    gpsRepository: mockGpsRepo,
    settingsRepository: mockSettingsRepo,
  );

  viewModel.toggleSpeedUnit();

  verify(mockSettingsRepo.toggleSpeedUnit()).called(1);
});

Widget Tests (UI)

// Test View rendering without business logic
testWidgets('SpeedometerScreen displays speed from ViewModel', (tester) async {
  final mockViewModel = MockSpeedometerViewModel();
  when(mockViewModel.gpsData).thenReturn(
    ProcessedGpsData(displaySpeed: '120', ...),
  );

  await tester.pumpWidget(
    ChangeNotifierProvider<SpeedometerViewModel>.value(
      value: mockViewModel,
      child: SpeedometerScreen(),
    ),
  );

  expect(find.text('120'), findsOneWidget);
});

Integration Tests (End-to-End)

// Test GPS → ViewModel → View flow
testWidgets('GPS updates trigger UI updates', (tester) async {
  final realGpsRepo = GpsRepository(gpsService: FakeGpsService());

  await tester.pumpWidget(MyApp(gpsRepository: realGpsRepo));

  // Simulate GPS update
  realGpsRepo.injectFakePosition(speed: 50.0);
  await tester.pump();

  expect(find.text('50'), findsOneWidget);
});

Current app: Cannot unit test business logic (requires widget tests for everything). Cannot mock GPS (tightly coupled to hardware).

Restructured app: Business logic testable in isolation. Views testable with mocked ViewModels. Integration tests verify end-to-end flow.


Conclusion

The restructure focuses on architectural patterns (HOW to structure), not new features (WHAT to build). Priority order:

  1. Foundation (Feature-first + MVVM + Provider) - enables everything else
  2. GPS Optimization (Speed-adaptive + WorkManager) - addresses battery drain
  3. Overlay Fix (Message queue + Scoped providers) - resolves stale data bug

The current 1,133-line monolithic main.dart with raw setState and aggressive wake locks represents 2018-era Flutter development. The 2026 standard is MVVM with Provider/Riverpod, feature-first organization, adaptive GPS precision, and proper Android lifecycle management.

Key success metric: Main screen logic reduced from 1,133 lines to <200 lines per ViewModel. Business logic testable without Flutter framework. Overlay data stays fresh within 500ms of main app.


Sources

Flutter Architecture

State Management

Android Overlay Windows

GPS & Location Services

Background Work & Battery Optimization

Flutter Best Practices 2026