Files
gps-speedo/.planning/research/ARCHITECTURE.md
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

41 KiB

Architecture Patterns: Flutter GPS Speedometer

Domain: GPS speedometer mobile application Researched: 2026-02-09 Confidence: HIGH

Executive Summary

Flutter GPS speedometer apps require clean separation between UI, state management, GPS data processing, and overlay communication. The recommended architecture follows Flutter's MVVM pattern with Provider state management, modular widget extraction, and a dedicated overlay service for reliable system-level window communication.

Key architectural decision: Use Provider with ChangeNotifier for reactive state management, extract widgets by feature/responsibility, maintain GPS data flow through a centralized manager, and architect overlay communication with explicit message schemas to avoid data staleness.

Layer Structure (MVVM + Provider)

┌──────────────────────────────────────────────────────┐
│                   UI Layer (Views)                    │
│  - screens/                                           │
│    └── speedometer_screen.dart (StatelessWidget)     │
│  - widgets/                                           │
│    ├── speed_display.dart                            │
│    ├── heading_compass.dart                          │
│    ├── metrics_panel.dart                            │
│    ├── unit_selector.dart                            │
│    └── theme_selector.dart                           │
├──────────────────────────────────────────────────────┤
│            State Management (ViewModels)              │
│  - providers/                                         │
│    ├── speedometer_provider.dart (ChangeNotifier)    │
│    ├── theme_provider.dart (ChangeNotifier)          │
│    └── overlay_provider.dart (ChangeNotifier)        │
├──────────────────────────────────────────────────────┤
│            Services (Data/Logic Layer)                │
│  - services/                                          │
│    ├── gps_data_manager.dart (Singleton)             │
│    ├── gps_service.dart (GPS permissions/stream)     │
│    └── overlay_service.dart (Overlay lifecycle)      │
├──────────────────────────────────────────────────────┤
│            Models (Domain Objects)                    │
│  - models/                                            │
│    ├── processed_gps_data.dart                       │
│    ├── overlay_message.dart                          │
│    ├── speedometer_settings.dart                     │
│    └── trip_stats.dart                               │
├──────────────────────────────────────────────────────┤
│          Configuration (Constants/Themes)             │
│  - config/                                            │
│    ├── speed_units.dart                              │
│    └── color_themes.dart                             │
└──────────────────────────────────────────────────────┘

Data Flow Architecture

GPS Hardware
    ↓
[GpsDataManager (Singleton)]
    ↓ Broadcast Stream (ProcessedGpsData)
    ↓
[SpeedometerProvider (ChangeNotifier)]
    ↓ notifyListeners()
    ↓
[Consumer Widgets] → Speed Display, Compass, Metrics
    ↓
[OverlayProvider] → Structured OverlayMessage
    ↓
[OverlayService] → FlutterOverlayWindow.shareData()
    ↓
[Overlay Window (Separate Isolate)]

Key principle: Unidirectional data flow with clear boundaries. GPS data flows DOWN through managers → providers → widgets. User interactions flow UP through callbacks → provider methods → service calls.

Component Boundaries

1. Screens (UI Entry Points)

Location: lib/screens/

Responsibility:

  • Top-level widget compositions with Scaffold
  • Route handling and navigation
  • Provider setup for the screen scope
  • Layout structure (portrait/landscape)

What goes here:

  • speedometer_screen.dart: Main screen with speed display, compass, metrics panel
  • overlay_speedometer.dart: Overlay window entry point (separate isolate)

What does NOT go here:

  • Business logic (use providers)
  • Direct GPS access (use services)
  • Reusable UI components (use widgets/)
  • setState calls (use Consumer/Provider)

Example structure:

// speedometer_screen.dart
class SpeedometerScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Consumer<SpeedometerProvider>(
        builder: (context, speedometer, child) {
          return OrientationBuilder(
            builder: (context, orientation) {
              return orientation == Orientation.portrait
                ? _buildPortraitLayout(speedometer)
                : _buildLandscapeLayout(speedometer);
            },
          );
        },
      ),
    );
  }
}

2. Widgets (Reusable UI Components)

Location: lib/widgets/

Responsibility:

  • Self-contained, reusable UI components
  • Presentational logic only (conditionals, animations, layout)
  • Consume state via Consumer or context.watch
  • Expose callbacks for user interactions

Component breakdown:

Widget Purpose State Access Events Emitted
speed_display.dart Digital speed with unit, split decimal formatting SpeedometerProvider onTap (toggle unit)
heading_compass.dart Rotating compass icon with heading text SpeedometerProvider onTap (launch overlay), onLongPress (close overlay)
metrics_panel.dart Coordinates, distance, trip time, accuracy SpeedometerProvider onResetTrip
unit_selector.dart Quick unit selection buttons SpeedometerProvider onUnitSelected
theme_selector.dart Theme picker UI ThemeProvider onThemeSelected
analog_gauge.dart Circular speedometer gauge SpeedometerProvider onTap (switch mode)

Anti-pattern to avoid:

// ❌ DON'T: Widget with business logic
class SpeedDisplay extends StatefulWidget {
  @override
  State<SpeedDisplay> createState() => _SpeedDisplayState();
}

class _SpeedDisplayState extends State<SpeedDisplay> {
  StreamSubscription<ProcessedGpsData>? _subscription;

  @override
  void initState() {
    super.initState();
    _subscription = GpsDataManager.instance.dataStream.listen((data) {
      setState(() { /* update local state */ });
    });
  }
}

// ✅ DO: Widget consuming provider state
class SpeedDisplay extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Consumer<SpeedometerProvider>(
      builder: (context, speedometer, child) {
        return Text(speedometer.displaySpeed);
      },
    );
  }
}

3. Providers (State Management / ViewModels)

Location: lib/providers/

Responsibility:

  • Extend ChangeNotifier to manage UI state
  • Subscribe to service streams (GPS, overlay events)
  • Transform service data into UI-ready format
  • Expose commands (methods) for user interactions
  • Call notifyListeners() when state changes

Provider breakdown:

SpeedometerProvider (Primary State)

class SpeedometerProvider extends ChangeNotifier {
  final GpsDataManager _gpsManager;
  final OverlayService _overlayService;
  StreamSubscription<ProcessedGpsData>? _gpsSubscription;

  // UI-ready state
  String displaySpeed = '--';
  String displayHeading = 'N/A';
  double heading = -1.0;
  SpeedUnit currentUnit = SpeedUnit.kmh;
  bool isGpsActive = false;
  TripStats tripStats = TripStats.empty();

  SpeedometerProvider({
    required GpsDataManager gpsManager,
    required OverlayService overlayService,
  }) : _gpsManager = gpsManager,
       _overlayService = overlayService {
    _init();
  }

  Future<void> _init() async {
    await _gpsManager.initialize();
    _gpsSubscription = _gpsManager.dataStream.listen(_onGpsDataUpdate);
  }

  void _onGpsDataUpdate(ProcessedGpsData data) {
    displaySpeed = data.displaySpeed;
    displayHeading = data.displayHeading;
    heading = data.heading;
    isGpsActive = data.isSpeedValid;

    // Update trip stats
    tripStats = tripStats.update(data);

    notifyListeners(); // Triggers Consumer rebuilds

    // Push to overlay if active
    if (_overlayService.isActive) {
      _overlayService.sendUpdate(
        OverlayMessage.speedUpdate(
          speedText: displaySpeed,
          headingText: displayHeading,
          heading: heading,
          unitText: currentUnit.label,
          themeIndex: _themeProvider.currentIndex,
        ),
      );
    }
  }

  // Command methods (called by UI)
  void toggleUnit() {
    currentUnit = currentUnit.next;
    notifyListeners();
  }

  void resetTrip() {
    tripStats = TripStats.empty();
    notifyListeners();
  }

  @override
  void dispose() {
    _gpsSubscription?.cancel();
    super.dispose();
  }
}

ThemeProvider (Theme State)

class ThemeProvider extends ChangeNotifier {
  int _currentThemeIndex = 0;

  int get currentThemeIndex => _currentThemeIndex;
  ColorTheme get currentTheme => ColorThemes.getTheme(_currentThemeIndex);

  void nextTheme() {
    _currentThemeIndex = ColorThemes.getNextThemeIndex(_currentThemeIndex);
    notifyListeners();
  }
}

OverlayProvider (Overlay State)

class OverlayProvider extends ChangeNotifier {
  final OverlayService _overlayService;
  bool isOverlayActive = false;

  OverlayProvider(this._overlayService) {
    _overlayService.statusStream.listen((active) {
      isOverlayActive = active;
      notifyListeners();
    });
  }

  Future<void> showOverlay() async {
    await _overlayService.show();
  }

  Future<void> closeOverlay() async {
    await _overlayService.close();
  }
}

4. Services (Data/Logic Layer)

Location: lib/services/

Responsibility:

  • Encapsulate external APIs (GPS, platform channels, overlays)
  • Manage background processes and lifecycle
  • Provide streams or futures for data access
  • Handle error states and retries
  • Stateless where possible (except lifecycle management)

Service breakdown:

GpsDataManager (EXISTING - Keep as-is)

Status: Already implemented correctly as singleton Confidence: HIGH

class GpsDataManager {
  static GpsDataManager? _instance;
  static GpsDataManager get instance => _instance ??= GpsDataManager._internal();

  final StreamController<ProcessedGpsData> _dataController =
      StreamController<ProcessedGpsData>.broadcast();

  Stream<ProcessedGpsData> get dataStream => _dataController.stream;
  ProcessedGpsData get currentData => _currentData;

  Future<void> initialize() async { /* existing logic */ }
  void dispose() { /* existing logic */ }
}

Why it works:

  • Singleton pattern ensures single GPS stream
  • Broadcast stream allows multiple listeners (providers + overlay)
  • Centralized timeout and stale data handling
  • Already separates raw GPS → processed display logic

Change needed: None. Keep this service intact.

GpsService (EXISTING - Keep as static utility)

Status: Already correctly structured Confidence: HIGH

Static utility class for GPS permissions and stream creation. No changes needed.

OverlayService (NEW - Extract from main.dart)

Purpose: Manage overlay lifecycle and communication Confidence: MEDIUM (overlay communication has known reliability issues)

class OverlayService {
  final StreamController<bool> _statusController =
      StreamController<bool>.broadcast();
  Stream<bool> get statusStream => _statusController.stream;

  bool _isActive = false;
  bool get isActive => _isActive;

  Timer? _heartbeatTimer;

  Future<void> show({
    required int width,
    required int height,
  }) async {
    if (_isActive) return;

    final hasPermission = await FlutterOverlayWindow.requestPermission();
    if (!hasPermission) {
      throw OverlayPermissionException();
    }

    await FlutterOverlayWindow.showOverlay(
      width: width,
      height: height,
      enableDrag: true,
    );

    _isActive = true;
    _statusController.add(true);
    _startHeartbeat();
  }

  Future<void> close() async {
    _heartbeatTimer?.cancel();
    await FlutterOverlayWindow.closeOverlay();
    _isActive = false;
    _statusController.add(false);
  }

  void sendUpdate(OverlayMessage message) {
    if (!_isActive) return;

    FlutterOverlayWindow.shareData(message.toJson()).catchError((error) {
      print('[OverlayService] Failed to send update: $error');
      // Don't throw - overlay may have closed
    });
  }

  void _startHeartbeat() {
    // Periodic updates to keep overlay in sync
    _heartbeatTimer = Timer.periodic(
      const Duration(seconds: 5),
      (_) => _checkOverlayStatus(),
    );
  }

  Future<void> _checkOverlayStatus() async {
    final active = await FlutterOverlayWindow.isActive();
    if (active != _isActive) {
      _isActive = active;
      _statusController.add(active);
    }
  }

  void dispose() {
    _heartbeatTimer?.cancel();
    _statusController.close();
  }
}

Key design decisions:

  • Encapsulates all overlay platform channel calls
  • Status stream for reactive overlay state
  • Heartbeat for status polling (overlay runs in separate isolate)
  • Graceful error handling (overlay communication can fail)

5. Models (Domain Objects)

Location: lib/models/

Responsibility:

  • Define data structures with immutability
  • Provide copyWith, toJson, fromJson methods
  • No business logic (just data + serialization)

Model breakdown:

ProcessedGpsData (EXISTING - Move from service to models/)

class ProcessedGpsData {
  final double speed;
  final double heading;
  final String displaySpeed;
  final String displayHeading;
  final bool isSpeedValid;
  final bool isHeadingValid;

  const ProcessedGpsData({ /* ... */ });

  ProcessedGpsData copyWith({ /* ... */ });
}

OverlayMessage (NEW - Typed message schema)

class OverlayMessage {
  final String action;
  final Map<String, dynamic> payload;

  OverlayMessage({required this.action, required this.payload});

  factory OverlayMessage.speedUpdate({
    required String speedText,
    required String headingText,
    required double heading,
    required String unitText,
    required int themeIndex,
  }) {
    return OverlayMessage(
      action: 'updateDisplay',
      payload: {
        'speedText': speedText,
        'headingText': headingText,
        'heading': heading,
        'unitText': unitText,
        'themeIndex': themeIndex,
      },
    );
  }

  factory OverlayMessage.close() {
    return OverlayMessage(action: 'close', payload: {});
  }

  Map<String, dynamic> toJson() => {'action': action, ...payload};
}

Why typed messages?

  • Prevents typos in action strings
  • Centralizes message structure
  • Makes refactoring safer
  • Self-documenting

TripStats (NEW - Extract trip logic)

class TripStats {
  final double totalDistance;
  final Duration tripDuration;
  final DateTime? startTime;

  const TripStats({
    this.totalDistance = 0.0,
    this.tripDuration = Duration.zero,
    this.startTime,
  });

  factory TripStats.empty() => const TripStats();

  TripStats update(ProcessedGpsData data) {
    // Trip calculation logic
  }

  TripStats copyWith({ /* ... */ });
}

SpeedometerSettings (NEW - Persistent settings)

class SpeedometerSettings {
  final SpeedUnit unit;
  final int themeIndex;
  final bool showMetrics;

  const SpeedometerSettings({
    this.unit = SpeedUnit.kmh,
    this.themeIndex = 0,
    this.showMetrics = true,
  });

  Map<String, dynamic> toJson() { /* ... */ }
  factory SpeedometerSettings.fromJson(Map<String, dynamic> json) { /* ... */ }
}

6. Configuration (Constants/Enums/Themes)

Location: lib/config/

Files:

  • speed_units.dart (EXISTING - Move here)
  • color_themes.dart (EXISTING - Move here)

No architectural changes needed for these.

Data Flow Patterns

Pattern 1: GPS Data Flow (Reactive)

Hardware GPS
    ↓ Position updates
GpsService.positionStream
    ↓ Raw Position objects
GpsDataManager.initialize()
    ↓ Process & validate
    ↓ Format display strings
    ↓ Handle staleness
GpsDataManager._dataController.add()
    ↓ Broadcast Stream<ProcessedGpsData>
SpeedometerProvider._gpsSubscription
    ↓ Transform for UI state
    ↓ Update trip stats
SpeedometerProvider.notifyListeners()
    ↓
Consumer<SpeedometerProvider> widgets rebuild
    ↓
Speed Display, Compass, Metrics Panel render

Key characteristics:

  • Pull-based: UI subscribes to state changes
  • Single source of truth: GpsDataManager
  • Unidirectional: Data flows down, never up
  • Reactive: UI rebuilds automatically

Pattern 2: User Interaction Flow (Command)

User taps unit selector button
    ↓
UnitSelector.onTap() callback
    ↓
SpeedometerProvider.toggleUnit()
    ↓
currentUnit = currentUnit.next
    ↓
notifyListeners()
    ↓
Consumer rebuilds
    ↓
SpeedDisplay shows new unit

Key characteristics:

  • Push-based: UI calls provider methods
  • Synchronous: Immediate state change
  • Provider is command handler

Pattern 3: Overlay Communication (Push)

SpeedometerProvider receives GPS update
    ↓
Check if overlay active
    ↓
Create OverlayMessage.speedUpdate()
    ↓
OverlayService.sendUpdate(message)
    ↓
FlutterOverlayWindow.shareData(json)
    ↓ Platform channel (main isolate → overlay isolate)
Overlay: FlutterOverlayWindow.overlayListener
    ↓
Overlay: setState() with new data
    ↓
Overlay rebuilds

Key challenges:

  • Separate isolates: Main app and overlay don't share memory
  • One-way communication: Main → Overlay only (overlay can't call back reliably)
  • Message staleness: If overlay is busy, messages can be dropped
  • Status polling: Need heartbeat to detect overlay closure

Reliability improvements:

  1. Structured messages with typed OverlayMessage class
  2. Status stream for overlay lifecycle tracking
  3. Graceful failures - don't crash if overlay message fails
  4. Debounce updates - send overlay updates at 5Hz max, not every GPS tick

Architecture Patterns to Follow

Pattern 1: Provider Placement

void main() {
  runApp(
    MultiProvider(
      providers: [
        // Services (singletons)
        Provider<GpsDataManager>(
          create: (_) => GpsDataManager.instance,
          dispose: (_, manager) => manager.dispose(),
        ),
        Provider<OverlayService>(
          create: (_) => OverlayService(),
          dispose: (_, service) => service.dispose(),
        ),

        // State providers (ChangeNotifier)
        ChangeNotifierProvider<ThemeProvider>(
          create: (_) => ThemeProvider(),
        ),
        ChangeNotifierProvider<SpeedometerProvider>(
          create: (context) => SpeedometerProvider(
            gpsManager: context.read<GpsDataManager>(),
            overlayService: context.read<OverlayService>(),
          ),
        ),
        ChangeNotifierProvider<OverlayProvider>(
          create: (context) => OverlayProvider(
            context.read<OverlayService>(),
          ),
        ),
      ],
      child: const SpeedoApp(),
    ),
  );
}

Principles:

  • Services at top level (app-wide singletons)
  • Providers depend on services via constructor injection
  • Use context.read() in create to avoid rebuild loops

Pattern 2: Widget Extraction Priority

Extract in this order:

  1. Leaf widgets first (speed display, compass, unit selector)

    • No dependencies on other widgets
    • Self-contained Consumer patterns
    • Easiest to test
  2. Composite widgets (metrics panel with multiple displays)

    • Compose leaf widgets
    • Pass callbacks down
  3. Layout widgets (portrait/landscape containers)

    • Orchestrate composite widgets
    • Handle orientation logic
  4. Screen (top-level)

    • Final integration point
    • Minimal logic, mostly layout

Pattern 3: Consumer Optimization

// ❌ BAD: Entire screen rebuilds on any state change
Consumer<SpeedometerProvider>(
  builder: (context, speedometer, child) {
    return Column(
      children: [
        SpeedDisplay(speed: speedometer.displaySpeed),
        Compass(heading: speedometer.heading),
        MetricsPanel(stats: speedometer.tripStats),
      ],
    );
  },
)

// ✅ GOOD: Each component consumes only what it needs
Column(
  children: [
    // Only rebuilds when speed changes
    Consumer<SpeedometerProvider>(
      builder: (context, speedometer, child) {
        return SpeedDisplay(speed: speedometer.displaySpeed);
      },
    ),
    // Only rebuilds when heading changes
    Consumer<SpeedometerProvider>(
      builder: (context, speedometer, child) {
        return Compass(heading: speedometer.heading);
      },
    ),
    // Only rebuilds when trip stats change
    Consumer<SpeedometerProvider>(
      builder: (context, speedometer, child) {
        return MetricsPanel(stats: speedometer.tripStats);
      },
    ),
  ],
)

// ✅ BEST: Extract to widgets with internal Consumers
Column(
  children: [
    SpeedDisplayWidget(),  // Contains Consumer internally
    CompassWidget(),       // Contains Consumer internally
    MetricsPanelWidget(),  // Contains Consumer internally
  ],
)

Pattern 4: Overlay Communication Reliability

// ❌ PROBLEM: Push every GPS update to overlay (60+ per second)
void _onGpsDataUpdate(ProcessedGpsData data) {
  displaySpeed = data.displaySpeed;
  notifyListeners();

  // Floods overlay with messages
  _overlayService.sendUpdate(/* ... */);
}

// ✅ SOLUTION: Debounce overlay updates
Timer? _overlayUpdateTimer;

void _onGpsDataUpdate(ProcessedGpsData data) {
  displaySpeed = data.displaySpeed;
  notifyListeners(); // UI updates at full rate

  // Debounce overlay updates to 5 Hz
  _overlayUpdateTimer?.cancel();
  _overlayUpdateTimer = Timer(
    const Duration(milliseconds: 200),
    () => _sendOverlayUpdate(),
  );
}

void _sendOverlayUpdate() {
  if (!_overlayService.isActive) return;

  _overlayService.sendUpdate(
    OverlayMessage.speedUpdate(/* ... */),
  );
}

Why debounce?

  • Overlay runs in separate isolate with platform channel overhead
  • Message passing has serialization cost
  • Overlay only updates at screen refresh rate (60 FPS = 16ms)
  • 200ms = 5 Hz is plenty for human perception

Anti-Patterns to Avoid

Anti-Pattern 1: Business Logic in Widgets

// ❌ BAD: Widget directly subscribes to GPS stream
class SpeedDisplay extends StatefulWidget {
  @override
  State<SpeedDisplay> createState() => _SpeedDisplayState();
}

class _SpeedDisplayState extends State<SpeedDisplay> {
  late StreamSubscription _subscription;
  String _speed = '--';

  @override
  void initState() {
    super.initState();
    _subscription = GpsDataManager.instance.dataStream.listen((data) {
      setState(() { _speed = data.displaySpeed; });
    });
  }

  @override
  void dispose() {
    _subscription.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Text(_speed);
  }
}

Problems:

  • Widget has GPS knowledge (breaks separation of concerns)
  • setState causes widget-level rebuilds (not optimized)
  • Subscription management scattered across widgets
  • Hard to test (requires mocking GPS stream)
  • Duplicate subscriptions if multiple speed displays
// ✅ GOOD: Widget consumes provider state
class SpeedDisplay extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Consumer<SpeedometerProvider>(
      builder: (context, speedometer, child) {
        return Text(speedometer.displaySpeed);
      },
    );
  }
}

Anti-Pattern 2: Direct Overlay Communication from Widgets

// ❌ BAD: Widget pushes to overlay
class Compass extends StatelessWidget {
  final double heading;

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () {
        // Widget knows about overlay internals
        FlutterOverlayWindow.shareData({
          'action': 'updateHeading',
          'heading': heading,
        });
      },
      child: Icon(Icons.navigation),
    );
  }
}

Problems:

  • Widget has overlay platform channel knowledge
  • No type safety (typo in 'updateHeading')
  • No error handling
  • Can't track overlay state
// ✅ GOOD: Widget calls provider, provider handles overlay
class Compass extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Consumer<SpeedometerProvider>(
      builder: (context, speedometer, child) {
        return GestureDetector(
          onTap: () => speedometer.toggleOverlay(),
          child: Transform.rotate(
            angle: speedometer.heading * pi / 180,
            child: Icon(Icons.navigation),
          ),
        );
      },
    );
  }
}

// Provider handles overlay coordination
class SpeedometerProvider extends ChangeNotifier {
  void toggleOverlay() {
    if (_overlayService.isActive) {
      _overlayService.close();
    } else {
      _overlayService.show();
    }
  }
}

Anti-Pattern 3: Multiple Sources of Truth

// ❌ BAD: Both provider and widget have speed state
class SpeedometerProvider extends ChangeNotifier {
  String displaySpeed = '--';

  void updateSpeed(String speed) {
    displaySpeed = speed;
    notifyListeners();
  }
}

class SpeedDisplay extends StatefulWidget {
  @override
  State<SpeedDisplay> createState() => _SpeedDisplayState();
}

class _SpeedDisplayState extends State<SpeedDisplay> {
  String _localSpeed = '--'; // Duplicate state!

  @override
  Widget build(BuildContext context) {
    return Consumer<SpeedometerProvider>(
      builder: (context, speedometer, child) {
        // Which is correct? Provider or widget state?
        return Text(_localSpeed ?? speedometer.displaySpeed);
      },
    );
  }
}

Problems:

  • State synchronization issues
  • Unclear which is authoritative
  • Bugs from stale widget state
// ✅ GOOD: Provider is single source of truth
class SpeedometerProvider extends ChangeNotifier {
  String displaySpeed = '--';
}

class SpeedDisplay extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Consumer<SpeedometerProvider>(
      builder: (context, speedometer, child) {
        return Text(speedometer.displaySpeed);
      },
    );
  }
}

Anti-Pattern 4: Polling Instead of Reactive Streams

// ❌ BAD: Timer-based polling
class SpeedometerProvider extends ChangeNotifier {
  Timer? _pollTimer;

  void startPolling() {
    _pollTimer = Timer.periodic(Duration(seconds: 1), (_) {
      final data = GpsDataManager.instance.currentData;
      displaySpeed = data.displaySpeed;
      notifyListeners();
    });
  }
}

Problems:

  • Unnecessary CPU usage
  • Delayed updates (up to 1 second lag)
  • Timer doesn't align with GPS update rate
// ✅ GOOD: Subscribe to GPS stream
class SpeedometerProvider extends ChangeNotifier {
  StreamSubscription<ProcessedGpsData>? _subscription;

  SpeedometerProvider() {
    _subscription = GpsDataManager.instance.dataStream.listen((data) {
      displaySpeed = data.displaySpeed;
      notifyListeners();
    });
  }
}

Overlay Communication Architecture

Challenge: Separate Isolates

FlutterOverlayWindow creates a separate Flutter engine in a different isolate:

┌─────────────────────────────────────┐
│        Main App (Isolate 1)         │
│  - Full Flutter app                 │
│  - GPS streams                      │
│  - Provider state                   │
│  - UI widgets                       │
└──────────────┬──────────────────────┘
               │ Platform Channel
               │ (JSON messages only)
┌──────────────▼──────────────────────┐
│     Overlay Window (Isolate 2)      │
│  - Separate Flutter engine          │
│  - No shared memory                 │
│  - Receives JSON via platform       │
│  - Must maintain own state          │
└─────────────────────────────────────┘

Implications:

  • No shared state: Overlay can't access main app providers
  • One-way communication: Main → Overlay only (reverse requires platform implementation)
  • Message serialization: All data must be JSON-serializable
  • Lifecycle independence: Overlay can survive main app backgrounding
// Service handles all overlay platform communication
class OverlayService {
  // Lifecycle management
  Future<void> show({int width, int height});
  Future<void> close();

  // Status tracking
  Stream<bool> get statusStream;
  bool get isActive;

  // Message sending
  void sendUpdate(OverlayMessage message);

  // Periodic status check (overlay can close externally)
  Timer? _heartbeatTimer;
}

// Provider uses service, never direct platform calls
class SpeedometerProvider extends ChangeNotifier {
  final OverlayService _overlayService;

  void _onGpsUpdate(ProcessedGpsData data) {
    // Update main app state
    displaySpeed = data.displaySpeed;
    notifyListeners();

    // Push to overlay if active
    if (_overlayService.isActive) {
      _overlayService.sendUpdate(
        OverlayMessage.speedUpdate(/* ... */),
      );
    }
  }
}

Message Schema Pattern

// Typed messages prevent bugs
class OverlayMessage {
  final String action;
  final Map<String, dynamic> payload;

  // Factory constructors for each message type
  factory OverlayMessage.speedUpdate({...}) { /* ... */ }
  factory OverlayMessage.themeUpdate({...}) { /* ... */ }
  factory OverlayMessage.close() { /* ... */ }

  Map<String, dynamic> toJson() => {'action': action, ...payload};
}

// Overlay receives and parses
FlutterOverlayWindow.overlayListener.listen((data) {
  if (data is Map && data['action'] == 'speedUpdate') {
    setState(() {
      _speedText = data['speedText'];
      _heading = data['heading'];
      // ...
    });
  }
});

Staleness Mitigation Strategies

Problem: Overlay updates can be missed if:

  • Overlay is busy rendering
  • Message queue fills up
  • Platform channel has backpressure

Solutions:

  1. Debounce updates (5 Hz instead of GPS rate)
  2. Include timestamp in messages to detect stale data
  3. Send full state every time (not deltas)
  4. Heartbeat sync every 5 seconds with full state refresh
class OverlayMessage {
  factory OverlayMessage.speedUpdate({
    required String speedText,
    required double heading,
    // ...
    DateTime? timestamp, // Detect staleness
  }) {
    return OverlayMessage(
      action: 'updateDisplay',
      payload: {
        'speedText': speedText,
        'heading': heading,
        'timestamp': (timestamp ?? DateTime.now()).millisecondsSinceEpoch,
      },
    );
  }
}

// Overlay checks staleness
FlutterOverlayWindow.overlayListener.listen((data) {
  if (data is Map && data['action'] == 'updateDisplay') {
    final timestamp = DateTime.fromMillisecondsSinceEpoch(data['timestamp']);
    final age = DateTime.now().difference(timestamp);

    if (age < Duration(seconds: 5)) {
      setState(() { /* apply update */ });
    } else {
      print('[Overlay] Ignoring stale update: ${age.inSeconds}s old');
    }
  }
});

Build Order Recommendations

Phase 1: Foundation (Extract Models & Config)

Goal: Establish data structures and configuration

1. Create directory structure
   lib/
   ├── models/
   ├── config/
   ├── services/
   ├── providers/
   ├── widgets/
   └── screens/

2. Move existing files
   - speed_units.dart → config/
   - color_themes.dart → config/

3. Extract ProcessedGpsData → models/processed_gps_data.dart

4. Create new models
   - models/overlay_message.dart
   - models/trip_stats.dart
   - models/speedometer_settings.dart

Validation: Code compiles, imports updated

Phase 2: Service Layer (Extract Overlay Logic)

Goal: Isolate platform channel communication

1. Create services/overlay_service.dart
   - Move overlay lifecycle logic from _SpeedometerScreenState
   - Extract FlutterOverlayWindow calls
   - Add status stream

2. Keep existing services
   - gps_data_manager.dart (no changes)
   - gps_service.dart (no changes)

Validation: Overlay can show/close via OverlayService

Phase 3: Provider Setup (Create State Management)

Goal: Replace setState with Provider pattern

1. Add provider dependency
   pubspec.yaml:
     provider: ^6.1.0

2. Create providers/speedometer_provider.dart
   - Migrate GPS subscription from _SpeedometerScreenState
   - Add trip stats logic
   - Add overlay coordination

3. Create providers/theme_provider.dart
   - Migrate theme index state

4. Create providers/overlay_provider.dart
   - Wrap OverlayService with ChangeNotifier

5. Update main.dart with MultiProvider
   - Wrap SpeedoApp with providers

Validation: State changes trigger rebuilds

Phase 4: Widget Extraction (Decompose Screen)

Goal: Break SpeedometerScreen into reusable widgets

Order (leaf to composite):

1. Extract leaf widgets (no dependencies)
   - widgets/speed_display.dart (speed + unit text)
   - widgets/heading_compass.dart (compass icon + heading)
   - widgets/unit_selector.dart (quick unit buttons)

2. Extract composite widgets
   - widgets/metrics_panel.dart (coordinates, distance, time)

3. Extract layout widgets
   - widgets/portrait_layout.dart (portrait orientation)
   - widgets/landscape_layout.dart (landscape orientation)

4. Simplify screens/speedometer_screen.dart
   - Remove setState (now StatelessWidget)
   - Compose extracted widgets

Validation: Screen renders identically, state updates work

Phase 5: Overlay Refactor (Apply Architecture)

Goal: Apply same architecture to overlay window

1. Move overlay to screens/overlay_speedometer.dart

2. Create overlay-specific widgets
   - widgets/overlay/overlay_speed_display.dart
   - widgets/overlay/overlay_compass.dart

3. Update overlay message handling
   - Use OverlayMessage.toJson() / fromJson()
   - Add timestamp staleness checks

Validation: Overlay receives updates reliably

Phase 6: Cleanup & Optimization

Goal: Remove dead code, optimize performance

1. Remove old setState code
2. Remove background heartbeat timers (replaced by stream subscriptions)
3. Add Consumer optimization (place deep in widget tree)
4. Add debouncing to overlay updates
5. Profile rebuild performance

Validation: App performs as well or better than before

Dependency Management

Wiring Pattern

// main.dart
void main() {
  runApp(
    MultiProvider(
      providers: [
        // Step 1: Provide services (no dependencies)
        Provider<GpsDataManager>(
          create: (_) => GpsDataManager.instance,
        ),
        Provider<OverlayService>(
          create: (_) => OverlayService(),
        ),

        // Step 2: Provide state (depend on services)
        ChangeNotifierProvider<ThemeProvider>(
          create: (_) => ThemeProvider(),
        ),
        ChangeNotifierProvider<SpeedometerProvider>(
          create: (context) => SpeedometerProvider(
            gpsManager: context.read<GpsDataManager>(),
            overlayService: context.read<OverlayService>(),
          ),
        ),
        ChangeNotifierProvider<OverlayProvider>(
          create: (context) => OverlayProvider(
            context.read<OverlayService>(),
          ),
        ),
      ],
      child: const SpeedoApp(),
    ),
  );
}

Rules:

  • Use context.read() in create (not watch/listen)
  • Services before providers (dependency order)
  • Dispose services in provider disposal

Scalability Considerations

Concern Current (< 100 users) At 10K users At 1M users
GPS data rate Geolocator default (1-5 Hz) Same, already optimized Same, hardware-limited
Overlay updates Debounce to 5 Hz Same Same
State size Small (< 1KB) Small Small
Provider rebuilds Consumer optimization Add Selector for granular rebuilds Same
Persistence SharedPreferences Same Consider encrypted storage
Analytics None Add Firebase Analytics Same + custom backend
Error reporting Print statements Add Sentry/Crashlytics Same

Architectural benefit: Provider pattern scales well. No fundamental changes needed for 1M users since app is local-first with no backend.

Testing Strategy

Unit Tests (Models & Services)

// Test models (pure Dart, no Flutter)
test('ProcessedGpsData.copyWith updates fields', () {
  final data = ProcessedGpsData(/* ... */);
  final updated = data.copyWith(speed: 10.0);
  expect(updated.speed, 10.0);
});

// Test services with mocks
test('OverlayService emits status on show', () async {
  final service = OverlayService();
  expectLater(service.statusStream, emits(true));
  await service.show(width: 100, height: 100);
});

Provider Tests (ChangeNotifier Logic)

testWidgets('SpeedometerProvider notifies on GPS update', (tester) async {
  final mockGpsManager = MockGpsDataManager();
  final provider = SpeedometerProvider(
    gpsManager: mockGpsManager,
    overlayService: MockOverlayService(),
  );

  bool notified = false;
  provider.addListener(() => notified = true);

  // Simulate GPS update
  mockGpsManager.simulateUpdate(ProcessedGpsData(/* ... */));

  expect(notified, true);
  expect(provider.displaySpeed, '10.0');
});

Widget Tests (UI Components)

testWidgets('SpeedDisplay shows speed from provider', (tester) async {
  await tester.pumpWidget(
    ChangeNotifierProvider<SpeedometerProvider>(
      create: (_) => MockSpeedometerProvider(displaySpeed: '25.5'),
      child: MaterialApp(home: SpeedDisplay()),
    ),
  );

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

Sources

Flutter Official Documentation (HIGH Confidence)

Provider Pattern (HIGH Confidence)

Modular Architecture (HIGH Confidence)

GPS & Location Tracking (MEDIUM Confidence)

Overlay Communication (MEDIUM Confidence - Known Issues)

Widget Extraction & Refactoring (HIGH Confidence)


Architecture Confidence: HIGH Overlay Communication Confidence: MEDIUM (known reliability issues with separate isolates) Build Order Confidence: HIGH (validated dependency chain)