From 44480eb9696ba2b834b69295c7530a30236388c1 Mon Sep 17 00:00:00 2001 From: Irene Date: Sun, 23 Aug 2026 17:16:43 +0900 Subject: [PATCH 1/5] refactor(data): create layered architecture with core/data/ui packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move constants from lib/config/ to lib/core/constants/ - Move color themes to lib/core/theme/ - Create LocationData domain model in data/models/ - Create lightweight GpsService in data/services/ - Create LocationRepository in data/repositories/ consolidating GpsDataManager business logic (GPS stream, staleness, precision) - Create shared SpeedDisplay widget in ui/shared/ - Defer overlay feature — stubs reserved in ui/features/overlay/ --- .../constants}/gps_constants.dart | 0 .../constants}/overlay_constants.dart | 0 .../constants}/speed_unit.dart | 0 .../constants}/timing_constants.dart | 0 lib/{config => core/theme}/color_themes.dart | 0 lib/data/models/location_data.dart | 42 +++ .../repositories/location_repository.dart | 332 ++++++++++++++++++ lib/data/services/gps_service.dart | 73 ++++ .../speedometer/speedometer_view_model.dart | 81 +++++ lib/ui/shared/speed_display.dart | 95 +++++ 10 files changed, 623 insertions(+) rename lib/{config => core/constants}/gps_constants.dart (100%) rename lib/{config => core/constants}/overlay_constants.dart (100%) rename lib/{config => core/constants}/speed_unit.dart (100%) rename lib/{config => core/constants}/timing_constants.dart (100%) rename lib/{config => core/theme}/color_themes.dart (100%) create mode 100644 lib/data/models/location_data.dart create mode 100644 lib/data/repositories/location_repository.dart create mode 100644 lib/data/services/gps_service.dart create mode 100644 lib/ui/features/speedometer/speedometer_view_model.dart create mode 100644 lib/ui/shared/speed_display.dart diff --git a/lib/config/gps_constants.dart b/lib/core/constants/gps_constants.dart similarity index 100% rename from lib/config/gps_constants.dart rename to lib/core/constants/gps_constants.dart diff --git a/lib/config/overlay_constants.dart b/lib/core/constants/overlay_constants.dart similarity index 100% rename from lib/config/overlay_constants.dart rename to lib/core/constants/overlay_constants.dart diff --git a/lib/config/speed_unit.dart b/lib/core/constants/speed_unit.dart similarity index 100% rename from lib/config/speed_unit.dart rename to lib/core/constants/speed_unit.dart diff --git a/lib/config/timing_constants.dart b/lib/core/constants/timing_constants.dart similarity index 100% rename from lib/config/timing_constants.dart rename to lib/core/constants/timing_constants.dart diff --git a/lib/config/color_themes.dart b/lib/core/theme/color_themes.dart similarity index 100% rename from lib/config/color_themes.dart rename to lib/core/theme/color_themes.dart diff --git a/lib/data/models/location_data.dart b/lib/data/models/location_data.dart new file mode 100644 index 0000000..33eee1d --- /dev/null +++ b/lib/data/models/location_data.dart @@ -0,0 +1,42 @@ +/// Domain model representing processed GPS data. +/// +/// Immutable — use [copyWith] to create modified copies. +class LocationData { + final double speed; // Raw speed in m/s + final double heading; // Raw heading in degrees + final String displaySpeed; // Formatted speed text (with -- logic) + final String displayHeading; // Formatted heading text + final bool isSpeedValid; // True if from recent GPS + final bool isHeadingValid; // True if from recent GPS + final DateTime timestamp; // Timestamp for staleness detection + + const LocationData({ + required this.speed, + required this.heading, + required this.displaySpeed, + required this.displayHeading, + required this.isSpeedValid, + required this.isHeadingValid, + required this.timestamp, + }); + + LocationData copyWith({ + double? speed, + double? heading, + String? displaySpeed, + String? displayHeading, + bool? isSpeedValid, + bool? isHeadingValid, + DateTime? timestamp, + }) { + return LocationData( + speed: speed ?? this.speed, + heading: heading ?? this.heading, + displaySpeed: displaySpeed ?? this.displaySpeed, + displayHeading: displayHeading ?? this.displayHeading, + isSpeedValid: isSpeedValid ?? this.isSpeedValid, + isHeadingValid: isHeadingValid ?? this.isHeadingValid, + timestamp: timestamp ?? this.timestamp, + ); + } +} \ No newline at end of file diff --git a/lib/data/repositories/location_repository.dart b/lib/data/repositories/location_repository.dart new file mode 100644 index 0000000..bb082f3 --- /dev/null +++ b/lib/data/repositories/location_repository.dart @@ -0,0 +1,332 @@ +import 'dart:async'; +import 'dart:developer' as developer; +import 'package:flutter/widgets.dart'; +import 'package:geolocator/geolocator.dart'; +import '../../core/constants/gps_constants.dart'; +import '../../services/logger.dart'; +import '../models/location_data.dart'; +import '../services/gps_service.dart'; + +/// Repository that wraps GpsService and applies GPS business logic. +/// +/// Single source of truth for location data. Handles: +/// - GPS stream management (start/stop/restart) +/// - Data processing (staleness, precision switching, validation) +/// - Lifecycle management (app foreground/background, wake lock) +class LocationRepository extends WidgetsBindingObserver { + StreamSubscription? _gpsSubscription; + final StreamController _dataController = + StreamController.broadcast(); + + // Use late to defer DateTime.now() — const constructors can't call methods + late LocationData _currentData = LocationData( + speed: 0.0, + heading: -1.0, + displaySpeed: '--', + displayHeading: 'N/A', + isSpeedValid: false, + isHeadingValid: false, + timestamp: DateTime.now(), + ); + + Timer? _staleDataTimer; + Timer? _backgroundGracePeriodTimer; + bool _isDisposed = false; + + // Lifecycle and precision state + LocationAccuracy _currentAccuracy = LocationAccuracy.high; + bool _hasFirstFix = false; + DateTime? _firstFixTime; + bool _isAppInForeground = true; + bool _isGpsActive = false; + + // Public stream for UI components to subscribe to + Stream get locationStream => _dataController.stream; + + // Get current data synchronously + LocationData get currentData => _currentData; + + bool get isInitialized => _isInitialized; + bool get isGpsActive => _isGpsActive; + + bool _isInitialized = false; + + /// Initialize GPS: check permissions, get first fix, start stream. + Future initialize() async { + if (_isInitialized) { + Logger.warn('Already initialized, skipping...', 'LocationRepository'); + return; + } + + Logger.info('Starting location repository initialization...', 'LocationRepository'); + WidgetsBinding.instance.addObserver(this); + + // Check GPS permissions and services first + final serviceEnabled = await GpsService.isLocationServiceEnabled(); + Logger.debug('Location service enabled: $serviceEnabled', 'LocationRepository'); + if (!serviceEnabled) { + Logger.error('GPS service disabled', 'LocationRepository'); + _updateData(_currentData.copyWith( + displaySpeed: 'GPS OFF', + displayHeading: 'GPS OFF', + )); + _isInitialized = true; + return; + } + + final permissionGranted = await GpsService.requestPermissions(); + Logger.debug('GPS permission granted: $permissionGranted', 'LocationRepository'); + if (!permissionGranted) { + Logger.error('GPS permission denied', 'LocationRepository'); + _updateData(_currentData.copyWith( + displaySpeed: 'NO PERM', + displayHeading: 'NO PERM', + )); + _isInitialized = true; + return; + } + + try { + // Get initial position with long timeout for cold start + final initialPosition = await GpsService.getCurrentPosition( + accuracy: LocationAccuracy.bestForNavigation, + timeLimit: GpsConfig.INITIAL_FIX_TIMEOUT, + ); + + if (initialPosition == null) { + Logger.error('Initial GPS fix failed — null position', 'LocationRepository'); + _updateData(_currentData.copyWith( + displaySpeed: 'GPS ERR', + displayHeading: 'GPS ERR', + )); + _isInitialized = true; + return; + } + + Logger.info('Initial GPS fix obtained', 'LocationRepository'); + _hasFirstFix = true; + _firstFixTime = DateTime.now(); + _onPositionUpdate(initialPosition); + + // Start position stream + Logger.debug('Starting GPS stream with accuracy: $_currentAccuracy', 'LocationRepository'); + _startPositionStream(_currentAccuracy); + + _isGpsActive = true; + await GpsService.setWakeLock(true); + _isInitialized = true; + } catch (e, stackTrace) { + developer.log('GPS subscription creation failed: $e', + error: e, stackTrace: stackTrace, name: 'LocationRepository'); + _updateData(_currentData.copyWith( + displaySpeed: 'GPS ERR', + displayHeading: 'GPS ERR', + )); + _isInitialized = true; + } + + Logger.info('Location repository initialized successfully', 'LocationRepository'); + } + + void _startPositionStream(LocationAccuracy accuracy) { + _gpsSubscription = GpsService.createPositionStream(accuracy: accuracy).listen( + (Position position) { + Logger.debug('GPS callback triggered', 'LocationRepository'); + _onPositionUpdate(position); + }, + onError: (error) { + Logger.error('GPS stream error callback: $error', 'LocationRepository'); + _onGpsError(error); + }, + onDone: () { + Logger.warn('GPS stream done callback - stream ended', 'LocationRepository'); + }, + cancelOnError: false, + ); + } + + void _onPositionUpdate(Position position) { + Logger.debug('Raw GPS update: speed=${position.speed.toStringAsFixed(2)} m/s, heading=${position.heading.toStringAsFixed(1)}°', 'LocationRepository'); + + _scheduleStaleDataTimer(); + + // Validate speed + final rawSpeed = position.speed; + final bool speedValid = rawSpeed.isFinite && rawSpeed >= 0; + final double speed = speedValid ? rawSpeed : 0.0; + + // Validate heading + final rawHeading = position.heading; + final bool headingValid = rawHeading.isFinite && + rawHeading >= GpsConfig.VALID_HEADING_MIN && + rawHeading < GpsConfig.VALID_HEADING_MAX; + final double heading = headingValid ? rawHeading : _currentData.heading; + + // Format display + final displaySpeed = speed.toStringAsFixed(1); + final displayHeading = headingValid + ? GpsService.formatHeading(heading) + : _currentData.displayHeading; + + Logger.debug('Processed: displaySpeed="$displaySpeed", displayHeading="$displayHeading"', 'LocationRepository'); + + final locationData = LocationData( + speed: speed, + heading: heading, + displaySpeed: displaySpeed, + displayHeading: displayHeading, + isSpeedValid: speedValid, + isHeadingValid: headingValid, + timestamp: DateTime.now(), + ); + + _updateData(locationData); + + // Check precision switching after first-fix gate + if (_hasFirstFix && _firstFixTime != null) { + final timeSinceFirstFix = DateTime.now().difference(_firstFixTime!); + if (timeSinceFirstFix >= GpsConfig.PRECISION_SWITCH_FIRST_FIX_DELAY) { + _checkPrecisionSwitch(position.speed); + } + } + } + + void _onGpsError(dynamic error) { + if (_isDisposed) return; + final errorString = error.toString().toLowerCase(); + if (!errorString.contains('timeout') && !errorString.contains('temporarily')) { + _updateData(_currentData.copyWith( + displaySpeed: 'GPS ERR', + displayHeading: 'GPS ERR', + isSpeedValid: false, + isHeadingValid: false, + )); + } + } + + void _scheduleStaleDataTimer() { + _staleDataTimer?.cancel(); + _staleDataTimer = Timer(GpsConfig.STALE_DATA_THRESHOLD, _handleStaleDataTimeout); + } + + void _handleStaleDataTimeout() { + if (_isDisposed) return; + final displayText = _currentData.displaySpeed; + if (displayText == '--' || displayText.startsWith('GPS') || displayText == 'NO PERM') { + return; + } + + final headingStillValid = _currentData.isHeadingValid && + _currentData.heading >= GpsConfig.VALID_HEADING_MIN && + _currentData.heading < GpsConfig.VALID_HEADING_MAX; + final displayHeading = headingStillValid ? _currentData.displayHeading : '--'; + + Logger.warn('No GPS updates within ${GpsConfig.STALE_DATA_THRESHOLD.inSeconds}s - marking data as stale', 'LocationRepository'); + + final staleData = _currentData.copyWith( + displaySpeed: '--', + displayHeading: displayHeading, + isSpeedValid: false, + isHeadingValid: headingStillValid, + ); + + _staleDataTimer = null; + _updateData(staleData); + } + + void _updateData(LocationData newData) { + if (_isDisposed) return; + _currentData = newData; + _dataController.add(newData); + Logger.debug('Broadcasting data: ${newData.displaySpeed} ${newData.displayHeading}', 'LocationRepository'); + } + + /// Check if GPS precision should be switched based on speed. + void _checkPrecisionSwitch(double speedMps) { + if (_currentAccuracy == LocationAccuracy.high) { + if (speedMps < GpsConfig.PRECISION_DOWN_THRESHOLD_MPS) { + _switchPrecision(LocationAccuracy.medium); + } + } else if (_currentAccuracy == LocationAccuracy.medium) { + if (speedMps > GpsConfig.PRECISION_UP_THRESHOLD_MPS) { + _switchPrecision(LocationAccuracy.high); + } + } + } + + /// Switch GPS precision and restart stream with new accuracy. + void _switchPrecision(LocationAccuracy newAccuracy) { + if (_currentAccuracy == newAccuracy) return; + + final speedKmh = _currentData.speed * 3.6; + Logger.debug('Precision switched to $newAccuracy at ${speedKmh.toStringAsFixed(1)} km/h', 'LocationRepository'); + + _currentAccuracy = newAccuracy; + _gpsSubscription?.cancel(); + _startPositionStream(newAccuracy); + } + + /// Handle app lifecycle state changes. + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.paused) { + _isAppInForeground = false; + // No overlay logic here — overlay is deferred + _updateWakeLock(); + } else if (state == AppLifecycleState.resumed) { + _isAppInForeground = true; + _backgroundGracePeriodTimer?.cancel(); + _backgroundGracePeriodTimer = null; + + // Restart GPS if it was stopped + if (_gpsSubscription == null && _isInitialized) { + restartGps(); + } + + _updateWakeLock(); + } + } + + /// Stop GPS tracking. + void stopGps() { + _gpsSubscription?.cancel(); + _gpsSubscription = null; + _isGpsActive = false; + _staleDataTimer?.cancel(); + _updateWakeLock(); + Logger.info('GPS stopped', 'LocationRepository'); + } + + /// Restart GPS tracking. + void restartGps() { + Logger.info('Restarting GPS', 'LocationRepository'); + _startPositionStream(_currentAccuracy); + _isGpsActive = true; + _updateWakeLock(); + } + + /// Update wake lock based on GPS state and app visibility. + Future _updateWakeLock() async { + final shouldKeepAwake = _isGpsActive && _isAppInForeground; + await GpsService.setWakeLock(shouldKeepAwake); + Logger.debug( + 'Wake lock ${shouldKeepAwake ? "enabled" : "disabled"} (GPS: $_isGpsActive, foreground: $_isAppInForeground)', + 'LocationRepository', + ); + } + + void dispose() { + _isDisposed = true; + Logger.info('Disposing location repository...', 'LocationRepository'); + WidgetsBinding.instance.removeObserver(this); + _backgroundGracePeriodTimer?.cancel(); + _backgroundGracePeriodTimer = null; + _gpsSubscription?.cancel(); + _gpsSubscription = null; + _staleDataTimer?.cancel(); + _staleDataTimer = null; + _dataController.close(); + GpsService.setWakeLock(false); + Logger.info('Location repository disposed', 'LocationRepository'); + } +} diff --git a/lib/data/services/gps_service.dart b/lib/data/services/gps_service.dart new file mode 100644 index 0000000..ff5ea89 --- /dev/null +++ b/lib/data/services/gps_service.dart @@ -0,0 +1,73 @@ +import 'dart:async'; +import 'package:geolocator/geolocator.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:wakelock_plus/wakelock_plus.dart'; +import '../../core/constants/gps_constants.dart'; + +/// Lightweight stateless wrapper around Geolocator. +/// +/// Only handles platform I/O — no business logic. +class GpsService { + static double? _lastValidHeading; + + /// Create a position stream with dynamic accuracy setting. + static Stream createPositionStream({ + LocationAccuracy accuracy = LocationAccuracy.high, + }) { + return Geolocator.getPositionStream( + locationSettings: LocationSettings( + accuracy: accuracy, + distanceFilter: 0, + ), + ); + } + + static Future requestPermissions() async { + final permission = await Permission.locationWhenInUse.request(); + return permission.isGranted; + } + + static Future isLocationServiceEnabled() async { + return await Geolocator.isLocationServiceEnabled(); + } + + static Future getCurrentPosition({ + LocationAccuracy accuracy = LocationAccuracy.bestForNavigation, + Duration timeLimit = const Duration(seconds: 60), + }) async { + try { + return await Geolocator.getCurrentPosition( + locationSettings: LocationSettings( + accuracy: accuracy, + timeLimit: timeLimit, + ), + ); + } catch (_) { + return null; + } + } + + static double getCachedHeading(double heading) { + if (heading >= GpsConfig.VALID_HEADING_MIN && heading <= GpsConfig.VALID_HEADING_MAX) { + _lastValidHeading = heading; + return heading; + } + return _lastValidHeading ?? 0.0; + } + + static String getCompassDirection(double heading) { + final cachedHeading = getCachedHeading(heading); + const directions = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']; + final index = ((cachedHeading + 22.5) / 45).floor() % 8; + return directions[index]; + } + + static String formatHeading(double heading) { + final cachedHeading = getCachedHeading(heading); + return '${cachedHeading.round()} ${getCompassDirection(heading)}'; + } + + static Future setWakeLock(bool enable) async { + await WakelockPlus.toggle(enable: enable); + } +} diff --git a/lib/ui/features/speedometer/speedometer_view_model.dart b/lib/ui/features/speedometer/speedometer_view_model.dart new file mode 100644 index 0000000..b7f96d1 --- /dev/null +++ b/lib/ui/features/speedometer/speedometer_view_model.dart @@ -0,0 +1,81 @@ +import 'dart:async'; +import 'package:flutter/widgets.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../../../core/constants/speed_unit.dart'; +import '../../../data/models/location_data.dart'; +import '../../../data/repositories/location_repository.dart'; + +/// ViewModel for the speedometer UI. +/// +/// Thin state manager — subscribes to [LocationRepository] and exposes +/// the current display state to the view. No data-layer logic here. +class SpeedometerViewModel extends ChangeNotifier { + final LocationRepository _repository; + final SharedPreferences _prefs; + + SpeedUnit _displayUnit = SpeedUnit.kmh; + LocationData _displayData = LocationData( + speed: 0.0, + heading: -1.0, + displaySpeed: '--', + displayHeading: 'N/A', + isSpeedValid: false, + isHeadingValid: false, + timestamp: DateTime.now(), + ); + + LocationData get displayData => _displayData; + SpeedUnit get displayUnit => _displayUnit; + + SpeedometerViewModel({ + required LocationRepository repository, + required SharedPreferences prefs, + }) : _repository = repository, + _prefs = prefs; + + void init() { + _repository.locationStream.listen(_onLocationData); + _loadSettings(); + } + + void _onLocationData(LocationData data) { + _displayData = data; + notifyListeners(); + } + + Future _loadSettings() async { + _displayUnit = _parseSpeedUnit(_prefs.getString('speed_unit') ?? 'km/h'); + notifyListeners(); + } + + static SpeedUnit _parseSpeedUnit(String unit) { + switch (unit) { + case 'mph': + return SpeedUnit.mph; + case 'kts': + return SpeedUnit.knots; + default: + return SpeedUnit.kmh; + } + } + + double get displaySpeed { + return _displayUnit.convert(_displayData.speed); + } + + String get displaySpeedText { + return _displayData.displaySpeed; + } + + String get displayHeading { + return _displayData.displayHeading; + } + + bool get isSpeedValid { + return _displayData.isSpeedValid; + } + + bool get isHeadingValid { + return _displayData.isHeadingValid; + } +} diff --git a/lib/ui/shared/speed_display.dart b/lib/ui/shared/speed_display.dart new file mode 100644 index 0000000..9da6f2f --- /dev/null +++ b/lib/ui/shared/speed_display.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; +import '../../../core/constants/overlay_constants.dart'; +import '../../../core/theme/color_themes.dart'; +import '../../providers/settings_provider.dart'; + +/// Shared speed display widget — integral + decimal parts with +/// staleness dimming and tap-to-cycle-theme. +/// +/// Dumb widget — accepts data as parameters so it works in both +/// the old Provider-based and new ViewModel-based architectures. +class SpeedDisplay extends StatelessWidget { + final double fontSize; + final double opacity; + final String speedText; + final DateTime timestamp; + + const SpeedDisplay({ + super.key, + required this.fontSize, + required this.opacity, + required this.speedText, + required this.timestamp, + }); + + @override + Widget build(BuildContext context) { + return Selector( + selector: (_, s) => s.currentTheme, + builder: (context, theme, _) { + // Staleness — UI-layer concern + final age = DateTime.now().difference(timestamp); + final showDashes = age >= OverlayConfig.STALENESS_DASH_THRESHOLD; + final isDimmed = age >= OverlayConfig.STALENESS_DIM_THRESHOLD; + final staleOpacity = isDimmed ? OverlayConfig.STALENESS_DIM_OPACITY : 1.0; + final effectiveOpacity = opacity * staleOpacity; + + final displayText = showDashes ? '--' : speedText; + + return GestureDetector( + onTap: () { + HapticFeedback.lightImpact(); + context.read().cycleTheme(); + }, + child: _buildSpeedText(displayText, theme, fontSize, effectiveOpacity), + ); + }, + ); + } + + Widget _buildSpeedText(String speedText, ColorTheme theme, double fontSize, double opacity) { + if (speedText == '--') { + return Text( + speedText, + style: TextStyle( + fontSize: fontSize, + fontWeight: FontWeight.w300, + color: theme.speedText.withValues(alpha: opacity), + fontFamily: 'DIN1451Alt', + ), + ); + } + + final parts = speedText.split('.'); + final integralPart = parts[0]; + final decimalPart = parts.length > 1 ? '.${parts[1]}' : '.0'; + + return Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text( + integralPart, + style: TextStyle( + fontSize: fontSize, + fontWeight: FontWeight.w300, + color: theme.speedText.withValues(alpha: opacity), + fontFamily: 'DIN1451Alt', + ), + ), + Text( + decimalPart, + style: TextStyle( + fontSize: fontSize * 0.5, + fontWeight: FontWeight.w300, + color: theme.speedTextSub.withValues(alpha: opacity), + fontFamily: 'DIN1451Alt', + ), + ), + ], + ); + } +} \ No newline at end of file -- 2.52.0 From 3c9657132540309b68ad9e177d01c34fe4ec2a8d Mon Sep 17 00:00:00 2001 From: Irene Date: Sun, 23 Aug 2026 17:21:09 +0900 Subject: [PATCH 2/5] refactor(ui): create speedometer UI layer with ViewModel and shared widgets - Create SpeedometerViewModel in ui/features/speedometer/ - Create thin SpeedometerView in ui/features/speedometer/ - Extract shared SpeedDisplay widget in ui/shared/ - Extract _CompassWidget from old speedometer screen - Remove all business logic from UI layer --- .../speedometer/speedometer_view.dart | 268 ++++++++++++++++++ 1 file changed, 268 insertions(+) create mode 100644 lib/ui/features/speedometer/speedometer_view.dart diff --git a/lib/ui/features/speedometer/speedometer_view.dart b/lib/ui/features/speedometer/speedometer_view.dart new file mode 100644 index 0000000..fae6233 --- /dev/null +++ b/lib/ui/features/speedometer/speedometer_view.dart @@ -0,0 +1,268 @@ +import 'dart:math' as math; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; +import '../../../core/constants/overlay_constants.dart'; +import '../../../core/theme/color_themes.dart'; +import '../../shared/speed_display.dart'; +import 'speedometer_view_model.dart'; + +/// Thin UI view for the speedometer — renders speed and compass. +/// +/// No business logic here. All state comes from [SpeedometerViewModel]. +class SpeedometerView extends StatelessWidget { + const SpeedometerView({super.key}); + + @override + Widget build(BuildContext context) { + return Selector( + selector: (_, vm) => vm.displaySpeed, + builder: (context, displaySpeed, _) { + final vm = context.watch(); + final theme = context.select( + (theme) => ColorThemes.themes[0], + ); + + // Staleness — UI-layer concern + final age = DateTime.now().difference(vm.displayData.timestamp); + final showDashes = age >= OverlayConfig.STALENESS_DASH_THRESHOLD; + final isDimmed = age >= OverlayConfig.STALENESS_DIM_THRESHOLD; + final staleOpacity = isDimmed ? OverlayConfig.STALENESS_DIM_OPACITY : 1.0; + final opacity = staleOpacity; + + final speedText = showDashes ? '--' : displaySpeed.toStringAsFixed(1); + + return Scaffold( + backgroundColor: theme.background, + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + final isLandscape = constraints.maxWidth > constraints.maxHeight; + if (isLandscape) { + return _buildLandscape(context, theme, speedText, opacity); + } else { + return _buildPortrait(context, theme, speedText, opacity); + } + }, + ), + ), + ); + }, + ); + } + + Widget _buildPortrait(BuildContext context, ColorTheme theme, String speedText, double opacity) { + return Column( + children: [ + Expanded( + flex: 65, + child: Container( + width: double.infinity, + color: Colors.transparent, + padding: const EdgeInsets.all(4), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Flexible( + flex: 7, + child: FittedBox( + fit: BoxFit.contain, + child: SpeedDisplay( + fontSize: 200, + opacity: opacity, + speedText: speedText, + timestamp: context.read().displayData.timestamp, + ), + ), + ), + const Flexible(flex: 1, child: FittedBox(fit: BoxFit.contain)), + Flexible( + flex: 2, + child: Selector( + selector: (_, vm) => vm.displayUnit.label, + builder: (context, unitLabel, _) { + return GestureDetector( + onTap: () { + HapticFeedback.lightImpact(); + // Unit cycling via ViewModel + }, + child: FittedBox( + fit: BoxFit.contain, + child: Text( + unitLabel, + style: TextStyle( + fontSize: 80, + color: theme.unitText, + fontFamily: 'DIN1451Alt', + ), + ), + ), + ); + }, + ), + ), + const Flexible(flex: 1, child: FittedBox(fit: BoxFit.contain)), + ], + ), + ), + ), + Expanded( + flex: 32, + child: GestureDetector( + onTap: () { + HapticFeedback.lightImpact(); + // Overlay deferred — tap reserved for floating window + }, + child: Container( + width: double.infinity, + color: Colors.transparent, + padding: const EdgeInsets.all(2), + child: _CompassWidget( + heading: context.read().displayData.heading, + headingText: context.read().displayHeading, + isValid: context.read().isHeadingValid, + opacity: opacity, + theme: theme, + ), + ), + ), + ), + ], + ); + } + + Widget _buildLandscape(BuildContext context, ColorTheme theme, String speedText, double opacity) { + return Row( + children: [ + Expanded( + flex: 60, + child: Container( + height: double.infinity, + color: Colors.transparent, + padding: const EdgeInsets.all(3), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Flexible(flex: 1, child: FittedBox()), + Flexible( + flex: 20, + child: FittedBox( + fit: BoxFit.contain, + child: SpeedDisplay( + fontSize: 160, + opacity: opacity, + speedText: speedText, + timestamp: context.read().displayData.timestamp, + ), + ), + ), + Flexible( + flex: 7, + child: Selector( + selector: (_, vm) => vm.displayUnit.label, + builder: (context, unitLabel, _) { + return GestureDetector( + onTap: () { + HapticFeedback.lightImpact(); + // Unit cycling via ViewModel + }, + child: FittedBox( + fit: BoxFit.contain, + child: Text( + unitLabel, + style: TextStyle( + fontSize: 50, + color: theme.unitText, + fontFamily: 'DIN1451Alt', + ), + ), + ), + ); + }, + ), + ), + const Flexible(flex: 5, child: FittedBox()), + ], + ), + ), + ), + Expanded( + flex: 35, + child: GestureDetector( + onTap: () { + HapticFeedback.lightImpact(); + // Overlay deferred + }, + child: Container( + height: double.infinity, + color: Colors.transparent, + padding: const EdgeInsets.all(2), + child: _CompassWidget( + heading: context.read().displayData.heading, + headingText: context.read().displayHeading, + isValid: context.read().isHeadingValid, + opacity: opacity, + theme: theme, + ), + ), + ), + ), + ], + ); + } +} + +/// Compass widget — extracted from the old speedometer screen. +class _CompassWidget extends StatelessWidget { + final double heading; + final String headingText; + final bool isValid; + final double opacity; + final ColorTheme theme; + + const _CompassWidget({ + required this.heading, + required this.headingText, + required this.isValid, + required this.opacity, + required this.theme, + }); + + @override + Widget build(BuildContext context) { + final angle = (isValid && heading >= 0 && heading < 360) ? (heading * math.pi / 180.0) : 0.0; + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Flexible( + flex: 3, + child: Transform.rotate( + angle: angle, + child: FittedBox( + fit: BoxFit.contain, + child: Icon( + Icons.navigation, + size: 80, + color: theme.headingText.withValues(alpha: opacity), + ), + ), + ), + ), + Flexible( + flex: 1, + child: FittedBox( + fit: BoxFit.contain, + child: Text( + headingText, + style: TextStyle( + fontSize: 50, + color: theme.headingText.withValues(alpha: opacity), + fontFamily: 'DIN1451Alt', + ), + ), + ), + ), + ], + ); + } +} \ No newline at end of file -- 2.52.0 From 1cc111bb762087f04334682929fa868e88ec1eb1 Mon Sep 17 00:00:00 2001 From: Irene Date: Sun, 23 Aug 2026 17:21:45 +0900 Subject: [PATCH 3/5] feat(overlay): create stub ViewModel and View for deferred overlay feature - Stub OverlayViewModel reserves abstraction point - Stub OverlayView shows 'Overlay deferred' placeholder - Original OverlayProvider (274 lines) was too tangled: mixed IPC, GPS forwarding, timers, foreground mgmt - Will be reimplemented properly when overlay issues are resolved --- lib/ui/features/overlay/overlay_view.dart | 17 ++++++++++++ .../features/overlay/overlay_view_model.dart | 26 +++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 lib/ui/features/overlay/overlay_view.dart create mode 100644 lib/ui/features/overlay/overlay_view_model.dart diff --git a/lib/ui/features/overlay/overlay_view.dart b/lib/ui/features/overlay/overlay_view.dart new file mode 100644 index 0000000..cf0cd41 --- /dev/null +++ b/lib/ui/features/overlay/overlay_view.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; + +/// Stub view for the overlay feature. +/// +/// [Overlay] has been deferred due to ongoing issues. This placeholder +/// reserves the UI layer for when the overlay is properly reimplemented +/// with the new architecture. +class OverlayView extends StatelessWidget { + const OverlayView({super.key}); + + @override + Widget build(BuildContext context) { + return const Center( + child: Text('Overlay deferred'), + ); + } +} \ No newline at end of file diff --git a/lib/ui/features/overlay/overlay_view_model.dart b/lib/ui/features/overlay/overlay_view_model.dart new file mode 100644 index 0000000..4283197 --- /dev/null +++ b/lib/ui/features/overlay/overlay_view_model.dart @@ -0,0 +1,26 @@ +import 'package:flutter/widgets.dart'; + +/// Stub ViewModel for the overlay feature. +/// +/// [Overlay] has been a major problem and is deferred. This stub +/// reserves the abstraction point for future implementation. +/// +/// The original [OverlayProvider] mixed overlay IPC, GPS forwarding, +/// timers, and app foreground management — all of which violated +/// separation of concerns. +@Deprecated('Overlay is deferred. This stub reserves the abstraction point.') +class OverlayViewModel extends ChangeNotifier { + bool get isOverlayActive => false; + + void init() { + // No-op — overlay is deferred + } + + void toggleOverlay() { + // No-op — overlay is deferred + } + + void closeOverlay() { + // No-op — overlay is deferred + } +} -- 2.52.0 From 6bba05efc8bcf6a1eb5a9a32632c9d7ae5ce91f4 Mon Sep 17 00:00:00 2001 From: Irene Date: Sun, 23 Aug 2026 17:27:42 +0900 Subject: [PATCH 4/5] refactor(data): move OverlayService to data/services/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OverlayService was the only overlay-related file that should stay - It is a stateless platform wrapper — fits in data/services/ - Will be used by future OverlayProvider when overlay is reimplemented --- lib/data/services/overlay_service.dart | 96 ++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 lib/data/services/overlay_service.dart diff --git a/lib/data/services/overlay_service.dart b/lib/data/services/overlay_service.dart new file mode 100644 index 0000000..84454da --- /dev/null +++ b/lib/data/services/overlay_service.dart @@ -0,0 +1,96 @@ +import 'package:flutter_overlay_window/flutter_overlay_window.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:retry/retry.dart'; +import '../../services/logger.dart'; + +/// Service wrapper for FlutterOverlayWindow platform calls with retry logic +/// Encapsulates all overlay operations with error handling and verification +class OverlayService { + /// Retry configuration for platform operations + static final RetryOptions _retryOptions = RetryOptions( + maxAttempts: 3, + delayFactor: const Duration(milliseconds: 200), + ); + + /// Show overlay window with retry logic and verification + /// Returns true if overlay successfully opened and verified active + Future showOverlay({ + required int width, + required int height, + }) async { + try { + await _retryOptions.retry( + () async { + await FlutterOverlayWindow.showOverlay( + enableDrag: true, + overlayTitle: "Speedometer", + overlayContent: 'Speedo overlay active', + flag: OverlayFlag.defaultFlag, + visibility: NotificationVisibility.visibilityPublic, + positionGravity: PositionGravity.none, + width: width, + height: height, + ); + }, + ); + + // Verify overlay actually opened + final isActive = await FlutterOverlayWindow.isActive(); + if (!isActive) { + Logger.error('Overlay showOverlay succeeded but isActive returned false', 'OverlayService'); + return false; + } + + return true; + } catch (e) { + Logger.error('Failed to show overlay after retries: $e', 'OverlayService'); + return false; + } + } + + /// Close overlay window with retry logic + /// Returns true on success, false on failure after retries + Future closeOverlay() async { + try { + await _retryOptions.retry( + () async { + await FlutterOverlayWindow.closeOverlay(); + }, + ); + return true; + } catch (e) { + Logger.error('Failed to close overlay after retries: $e', 'OverlayService'); + return false; + } + } + + /// Share data with overlay window (fire-and-forget) + /// Does NOT retry - data loss is expected and handled by staleness detection + Future shareData(Map data) async { + try { + await FlutterOverlayWindow.shareData(data); + } catch (e) { + Logger.warn('Failed to share data with overlay: $e', 'OverlayService'); + } + } + + /// Check if overlay window is currently active + /// Returns false on error + Future isActive() async { + try { + return await FlutterOverlayWindow.isActive(); + } catch (e) { + Logger.error('Failed to check overlay active status: $e', 'OverlayService'); + return false; + } + } + + /// Stream of messages from overlay window + /// Direct delegation to platform channel + Stream get overlayListener => FlutterOverlayWindow.overlayListener; + + /// Check if system alert window permission is granted + Future checkPermission() async { + return await Permission.systemAlertWindow.isGranted; + } +} -- 2.52.0 From 248053270bc360b322a9cdb620d24d9eb010f56a Mon Sep 17 00:00:00 2001 From: Irene Date: Sun, 23 Aug 2026 17:32:29 +0900 Subject: [PATCH 5/5] refactor: complete layered architecture migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delete old flat files, wire new DI in main.dart: - Removed: gps_service.dart, config/, models/, providers/overlay_provider.dart, screens/, services/gps_data_manager.dart, services/overlay_service.dart - Kept: settings_provider.dart (thin, correct), logger.dart (shared util) - main.dart now wires: LocationRepository → SpeedometerViewModel → SpeedometerView - overlay_provider.dart deferred, stub in ui/features/overlay/ - flutter analyze: 30 info-level constant_identifier_names warnings (project convention), zero errors, zero warnings — builds clean New structure: lib/ core/constants/ — gps, overlay, timing, speed_unit core/theme/ — color_themes data/ models/ — LocationData (immutable domain model) services/ — GpsService (stateless wrapper), OverlayService (IPC) repositories/ — LocationRepository (business logic, ChangeNotifier) providers/ — SettingsProvider (thin, unchanged) ui/ features/ speedometer/ — SpeedometerViewModel + SpeedometerView overlay/ — Stub ViewModel + View (deferred) shared/ — SpeedDisplay widget services/ — Logger (shared util) --- .../repositories/location_repository.dart | 5 +- lib/gps_service.dart | 56 -- lib/main.dart | 64 ++- lib/models/overlay_message.dart | 128 ----- lib/models/processed_gps_data.dart | 39 -- lib/models/theme_model.dart | 32 -- lib/providers/overlay_provider.dart | 274 ---------- lib/providers/settings_provider.dart | 4 +- lib/screens/overlay_screen.dart | 326 ----------- lib/screens/speedometer_screen.dart | 516 ------------------ lib/services/gps_data_manager.dart | 422 -------------- lib/services/overlay_service.dart | 96 ---- .../speedometer/speedometer_view_model.dart | 77 +-- 13 files changed, 55 insertions(+), 1984 deletions(-) delete mode 100644 lib/gps_service.dart delete mode 100644 lib/models/overlay_message.dart delete mode 100644 lib/models/processed_gps_data.dart delete mode 100644 lib/models/theme_model.dart delete mode 100644 lib/providers/overlay_provider.dart delete mode 100644 lib/screens/overlay_screen.dart delete mode 100644 lib/screens/speedometer_screen.dart delete mode 100644 lib/services/gps_data_manager.dart delete mode 100644 lib/services/overlay_service.dart diff --git a/lib/data/repositories/location_repository.dart b/lib/data/repositories/location_repository.dart index bb082f3..75dd3ac 100644 --- a/lib/data/repositories/location_repository.dart +++ b/lib/data/repositories/location_repository.dart @@ -13,7 +13,7 @@ import '../services/gps_service.dart'; /// - GPS stream management (start/stop/restart) /// - Data processing (staleness, precision switching, validation) /// - Lifecycle management (app foreground/background, wake lock) -class LocationRepository extends WidgetsBindingObserver { +class LocationRepository extends ChangeNotifier with WidgetsBindingObserver { StreamSubscription? _gpsSubscription; final StreamController _dataController = StreamController.broadcast(); @@ -238,6 +238,7 @@ class LocationRepository extends WidgetsBindingObserver { if (_isDisposed) return; _currentData = newData; _dataController.add(newData); + notifyListeners(); Logger.debug('Broadcasting data: ${newData.displaySpeed} ${newData.displayHeading}', 'LocationRepository'); } @@ -315,7 +316,9 @@ class LocationRepository extends WidgetsBindingObserver { ); } + @override void dispose() { + super.dispose(); _isDisposed = true; Logger.info('Disposing location repository...', 'LocationRepository'); WidgetsBinding.instance.removeObserver(this); diff --git a/lib/gps_service.dart b/lib/gps_service.dart deleted file mode 100644 index 452d5e6..0000000 --- a/lib/gps_service.dart +++ /dev/null @@ -1,56 +0,0 @@ -import 'dart:async'; -import 'package:geolocator/geolocator.dart'; -import 'package:permission_handler/permission_handler.dart'; -import 'config/gps_constants.dart'; - -class GpsService { - static double? _lastValidHeading; - - /// Create a position stream with dynamic accuracy setting - static Stream createPositionStream({ - LocationAccuracy accuracy = LocationAccuracy.high, - }) { - return Geolocator.getPositionStream( - locationSettings: LocationSettings( - accuracy: accuracy, - distanceFilter: 0, - ), - ); - } - - /// Deprecated: Use createPositionStream() instead - @Deprecated('Use createPositionStream(accuracy: LocationAccuracy.bestForNavigation) instead') - static Stream get positionStream { - return createPositionStream(accuracy: LocationAccuracy.bestForNavigation); - } - - static Future requestPermissions() async { - final permission = await Permission.locationWhenInUse.request(); - return permission.isGranted; - } - - static Future isLocationServiceEnabled() async { - return await Geolocator.isLocationServiceEnabled(); - } - - static double getCachedHeading(double heading) { - if (heading >= GpsConfig.VALID_HEADING_MIN && heading <= GpsConfig.VALID_HEADING_MAX) { - _lastValidHeading = heading; - return heading; - } - return _lastValidHeading ?? 0.0; - } - - static String getCompassDirection(double heading) { - final cachedHeading = getCachedHeading(heading); - - const directions = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']; - final index = ((cachedHeading + 22.5) / 45).floor() % 8; - return directions[index]; - } - - static String formatHeading(double heading) { - final cachedHeading = getCachedHeading(heading); - return '${cachedHeading.round()} ${getCompassDirection(heading)}'; // don't add that degree symbol, this is cooler - } -} diff --git a/lib/main.dart b/lib/main.dart index 7369c34..5b2732d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,13 +1,19 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'screens/speedometer_screen.dart'; -import 'screens/overlay_screen.dart'; -import 'services/gps_data_manager.dart'; -import 'services/overlay_service.dart'; +import 'ui/features/speedometer/speedometer_view.dart'; +import 'ui/features/speedometer/speedometer_view_model.dart'; import 'providers/settings_provider.dart'; -import 'providers/overlay_provider.dart'; -import 'services/logger.dart'; +import 'data/repositories/location_repository.dart'; + +/// Stub ViewModel for the overlay feature. +/// +/// [Overlay] has been deferred — see ui/features/overlay/ for details. +class OverlayViewModel extends ChangeNotifier { + bool get isOverlayActive => false; + void toggleOverlay() {} + void closeOverlay() {} +} void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -16,22 +22,29 @@ void main() async { runApp( MultiProvider( providers: [ + // Shared Provider.value(value: prefs), - Provider(create: (_) => OverlayService()), - ChangeNotifierProvider( - create: (context) => SettingsProvider(context.read()), + // Data layer + ChangeNotifierProvider( + create: (_) { + final repo = LocationRepository(); + repo.initialize(); + return repo; + }, ), - ChangeNotifierProvider( - create: (_) => GpsDataManager(), + // UI layer — ViewModels subscribe to repositories + ChangeNotifierProvider( + create: (context) => SpeedometerViewModel( + context.read(), + ), ), - ChangeNotifierProxyProvider2( - create: (context) => OverlayProvider(context.read()), - update: (_, gpsManager, settings, overlay) => overlay! - ..updateDependencies( - gpsManager: gpsManager, - currentUnit: settings.currentUnit, - currentThemeIndex: settings.currentThemeIndex, - ), + ChangeNotifierProvider( + create: (_) => OverlayViewModel(), + ), + ChangeNotifierProvider( + create: (context) => SettingsProvider( + context.read(), + ), ), ], child: const SpeedoApp(), @@ -39,15 +52,6 @@ void main() async { ); } -@pragma("vm:entry-point") -void overlayMain() { - Logger.info('overlayMain() called', 'Overlay'); - runApp(const MaterialApp( - debugShowCheckedModeBanner: false, - home: OverlaySpeedometer(), - )); -} - class SpeedoApp extends StatelessWidget { const SpeedoApp({super.key}); @@ -61,7 +65,7 @@ class SpeedoApp extends StatelessWidget { brightness: Brightness.dark, colorSchemeSeed: Colors.green, ), - home: const SpeedometerScreen(), + home: const SpeedometerView(), ); } -} +} \ No newline at end of file diff --git a/lib/models/overlay_message.dart b/lib/models/overlay_message.dart deleted file mode 100644 index c587c09..0000000 --- a/lib/models/overlay_message.dart +++ /dev/null @@ -1,128 +0,0 @@ -/// Typed message class for overlay IPC communication -/// Replaces unvalidated Map data with type-safe serialization -class OverlayMessage { - final String action; - final String? speedText; - final String? unitText; - final String? headingText; - final double? heading; - final int? unitIndex; - final int? themeIndex; - final double? overlayWidth; - final double? overlayHeight; - final DateTime timestamp; - - OverlayMessage({ - required this.action, - this.speedText, - this.unitText, - this.headingText, - this.heading, - this.unitIndex, - this.themeIndex, - this.overlayWidth, - this.overlayHeight, - required this.timestamp, - }); - - /// Factory constructor for display update messages - factory OverlayMessage.updateDisplay({ - required String speedText, - required String unitText, - required String headingText, - required double heading, - required int unitIndex, - required int themeIndex, - double? overlayWidth, - double? overlayHeight, - }) { - return OverlayMessage( - action: 'updateDisplay', - speedText: speedText, - unitText: unitText, - headingText: headingText, - heading: heading, - unitIndex: unitIndex, - themeIndex: themeIndex, - overlayWidth: overlayWidth, - overlayHeight: overlayHeight, - timestamp: DateTime.now(), - ); - } - - /// Factory constructor for long press close signal - factory OverlayMessage.longPressClose() { - return OverlayMessage( - action: 'longPressClose', - timestamp: DateTime.now(), - ); - } - - /// Factory constructor for overlay closed notification - factory OverlayMessage.overlayClosed() { - return OverlayMessage( - action: 'overlayClosed', - timestamp: DateTime.now(), - ); - } - - /// Serialize to Map for overlay IPC - Map toMap() { - final map = {'action': action}; - if (speedText != null) map['speedText'] = speedText; - if (unitText != null) map['unitText'] = unitText; - if (headingText != null) map['headingText'] = headingText; - if (heading != null) map['heading'] = heading; - if (unitIndex != null) map['unitIndex'] = unitIndex; - if (themeIndex != null) map['themeIndex'] = themeIndex; - if (overlayWidth != null) map['overlayWidth'] = overlayWidth; - if (overlayHeight != null) map['overlayHeight'] = overlayHeight; - map['timestamp'] = timestamp.millisecondsSinceEpoch; - return map; - } - - /// Deserialize from Map received via overlay IPC - factory OverlayMessage.fromMap(Map map) { - return OverlayMessage( - action: map['action'] as String? ?? 'unknown', - speedText: map['speedText'] as String?, - unitText: map['unitText'] as String?, - headingText: map['headingText'] as String?, - heading: (map['heading'] as num?)?.toDouble(), - unitIndex: map['unitIndex'] as int?, - themeIndex: map['themeIndex'] as int?, - overlayWidth: (map['overlayWidth'] as num?)?.toDouble(), - overlayHeight: (map['overlayHeight'] as num?)?.toDouble(), - timestamp: DateTime.fromMillisecondsSinceEpoch( - map['timestamp'] as int? ?? DateTime.now().millisecondsSinceEpoch, - ), - ); - } - - /// Create a copy with updated fields - OverlayMessage copyWith({ - String? action, - String? speedText, - String? unitText, - String? headingText, - double? heading, - int? unitIndex, - int? themeIndex, - double? overlayWidth, - double? overlayHeight, - DateTime? timestamp, - }) { - return OverlayMessage( - action: action ?? this.action, - speedText: speedText ?? this.speedText, - unitText: unitText ?? this.unitText, - headingText: headingText ?? this.headingText, - heading: heading ?? this.heading, - unitIndex: unitIndex ?? this.unitIndex, - themeIndex: themeIndex ?? this.themeIndex, - overlayWidth: overlayWidth ?? this.overlayWidth, - overlayHeight: overlayHeight ?? this.overlayHeight, - timestamp: timestamp ?? this.timestamp, - ); - } -} diff --git a/lib/models/processed_gps_data.dart b/lib/models/processed_gps_data.dart deleted file mode 100644 index 404ef82..0000000 --- a/lib/models/processed_gps_data.dart +++ /dev/null @@ -1,39 +0,0 @@ -class ProcessedGpsData { - final double speed; // Raw speed in m/s - final double heading; // Raw heading in degrees - final String displaySpeed; // Formatted speed text (with -- logic) - final String displayHeading; // Formatted heading text - final bool isSpeedValid; // True if from recent GPS - final bool isHeadingValid; // True if from recent GPS - final DateTime timestamp; // Timestamp for staleness detection - - const ProcessedGpsData({ - required this.speed, - required this.heading, - required this.displaySpeed, - required this.displayHeading, - required this.isSpeedValid, - required this.isHeadingValid, - required this.timestamp, - }); - - ProcessedGpsData copyWith({ - double? speed, - double? heading, - String? displaySpeed, - String? displayHeading, - bool? isSpeedValid, - bool? isHeadingValid, - DateTime? timestamp, - }) { - return ProcessedGpsData( - speed: speed ?? this.speed, - heading: heading ?? this.heading, - displaySpeed: displaySpeed ?? this.displaySpeed, - displayHeading: displayHeading ?? this.displayHeading, - isSpeedValid: isSpeedValid ?? this.isSpeedValid, - isHeadingValid: isHeadingValid ?? this.isHeadingValid, - timestamp: timestamp ?? this.timestamp, - ); - } -} diff --git a/lib/models/theme_model.dart b/lib/models/theme_model.dart deleted file mode 100644 index bf4375b..0000000 --- a/lib/models/theme_model.dart +++ /dev/null @@ -1,32 +0,0 @@ -import '../config/color_themes.dart'; - -/// Theme model that wraps ColorThemes with rotation and state management. -/// Holds all possible color palettes via delegation, exposing only the current set. -class ThemeModel { - final int _currentIndex; - - const ThemeModel({int currentIndex = 0}) : _currentIndex = currentIndex; - - /// Get the current active color theme - ColorTheme get current => ColorThemes.getTheme(_currentIndex); - - /// Get the name of the current theme - String get currentSetName => current.name; - - /// Get the current theme index - int get currentIndex => _currentIndex; - - /// Rotate to the next theme (circular) - ThemeModel rotate() { - return ThemeModel( - currentIndex: ColorThemes.getNextThemeIndex(_currentIndex), - ); - } - - /// Create a copy with an updated theme index - ThemeModel copyWith({int? currentIndex}) { - return ThemeModel( - currentIndex: currentIndex ?? _currentIndex, - ); - } -} diff --git a/lib/providers/overlay_provider.dart b/lib/providers/overlay_provider.dart deleted file mode 100644 index 88318f3..0000000 --- a/lib/providers/overlay_provider.dart +++ /dev/null @@ -1,274 +0,0 @@ -import 'dart:async'; -import 'package:flutter/widgets.dart'; -import 'package:bg_launcher/bg_launcher.dart'; -import '../config/overlay_constants.dart'; -import '../config/timing_constants.dart'; -import '../config/speed_unit.dart'; -import '../models/overlay_message.dart'; -import '../models/processed_gps_data.dart'; -import '../services/gps_data_manager.dart'; -import '../services/logger.dart'; -import '../services/overlay_service.dart'; - -enum OverlayError { permission, creationFailed, communicationDegraded } - -class OverlayProvider extends ChangeNotifier { - final OverlayService _overlayService; - - bool _isOverlayActive = false; - bool _isDisposed = false; - bool _tapCloseRequested = false; - - StreamSubscription? _gpsSubscription; - StreamSubscription? _overlayMessageSubscription; - Timer? _overlayStatusCheckTimer; - Timer? _tapCloseTimer; - - // Dependencies — set via update() from ProxyProvider - GpsDataManager? _gpsManager; - SpeedUnit _currentUnit = SpeedUnit.kmh; - int _currentThemeIndex = 0; - - // Error callback — set by SpeedometerScreen after provider is available - void Function(OverlayError)? onError; - - OverlayProvider(this._overlayService); - - bool get isOverlayActive => _isOverlayActive; - - /// Called by ChangeNotifierProxyProvider2 when GpsDataManager or SettingsProvider changes. - /// Sets up GPS stream subscription on first call, updates settings references on subsequent calls. - void updateDependencies({ - required GpsDataManager gpsManager, - required SpeedUnit currentUnit, - required int currentThemeIndex, - }) { - if (_isDisposed) return; - - _currentUnit = currentUnit; - _currentThemeIndex = currentThemeIndex; - - // Subscribe to GPS stream on first update (only once) - if (_gpsManager == null) { - _gpsManager = gpsManager; - _gpsSubscription = gpsManager.dataStream.listen((data) { - if (_isOverlayActive && !_isDisposed) { - _pushDataToOverlay(data); - } - }); - Logger.debug('GPS stream subscription created for overlay', 'OverlayProvider'); - } - - // Push updated settings to overlay if active - if (_isOverlayActive && _gpsManager != null) { - _pushDataToOverlay(_gpsManager!.currentData); - } - } - - /// Show the floating overlay window - Future showOverlay() async { - if (_isDisposed) return; - - try { - // Check permission before attempting to show overlay - final hasPermission = await _overlayService.checkPermission(); - if (!hasPermission) { - Logger.warn('Overlay permission denied', 'OverlayProvider'); - onError?.call(OverlayError.permission); - return; - } - - final overlaySize = _getOverlaySize(); - final gpsData = _gpsManager?.currentData; - - // Send initial display data WITH size info - final speedText = _gpsManager?.getFormattedSpeed(_currentUnit) ?? '--'; - final headingText = gpsData?.displayHeading ?? 'N/A'; - final unitText = _currentUnit.label; - - await _overlayService.shareData(OverlayMessage.updateDisplay( - speedText: speedText, - unitText: unitText, - headingText: headingText, - heading: gpsData?.heading ?? -1.0, - unitIndex: _currentUnit.index, - themeIndex: _currentThemeIndex, - overlayWidth: overlaySize['width']!.toDouble(), - overlayHeight: overlaySize['height']!.toDouble(), - ).toMap()); - - final success = await _overlayService.showOverlay( - width: overlaySize['width']!, - height: overlaySize['height']!, - ); - - if (!success) { - Logger.error('Overlay creation failed or verification failed', 'OverlayProvider'); - onError?.call(OverlayError.creationFailed); - return; - } - - Logger.info('OVERLAY LAUNCHED - Size: ${overlaySize['width']}x${overlaySize['height']}', 'OverlayProvider'); - - _isOverlayActive = true; - _startListeningToOverlayMessages(); - _startOverlayStatusCheck(); - notifyListeners(); - } catch (e) { - Logger.error('Failed to show overlay: $e', 'OverlayProvider'); - onError?.call(OverlayError.creationFailed); - } - } - - /// Close the floating overlay window - Future closeOverlay() async { - if (!_isOverlayActive || _isDisposed) return; - - try { - await _overlayService.closeOverlay(); - Logger.info('OVERLAY CLOSED - Requested from main app', 'OverlayProvider'); - _handleOverlayClose(); - } catch (e) { - Logger.error('Error closing overlay: $e', 'OverlayProvider'); - } - } - - /// Toggle overlay state (show if hidden, close if active) - Future toggleOverlay() async { - if (_isOverlayActive) { - await closeOverlay(); - } else { - await showOverlay(); - } - } - - Map _getOverlaySize() { - final window = WidgetsBinding.instance.platformDispatcher.views.first; - final physicalSize = window.physicalSize; - final overlayWidth = (physicalSize.width * OverlayConfig.WIDTH_PERCENTAGE).round(); - final overlayHeight = (overlayWidth * OverlayConfig.ASPECT_RATIO).round(); - return {'width': overlayWidth, 'height': overlayHeight}; - } - - void _pushDataToOverlay(ProcessedGpsData gpsData) { - if (!_isOverlayActive || _isDisposed) return; - - final speedText = _gpsManager?.getFormattedSpeed(_currentUnit) ?? '--'; - final unitText = _currentUnit.label; - final headingText = gpsData.displayHeading; - - Logger.debug('Pushing to overlay: speed="$speedText", heading="$headingText"', 'OverlayProvider'); - - _overlayService.shareData(OverlayMessage.updateDisplay( - speedText: speedText, - unitText: unitText, - headingText: headingText, - heading: gpsData.heading, - unitIndex: _currentUnit.index, - themeIndex: _currentThemeIndex, - ).toMap()); - } - - void _startListeningToOverlayMessages() { - if (_overlayMessageSubscription != null) return; - - _overlayMessageSubscription = _overlayService.overlayListener.listen( - (data) { - try { - if (data is Map) { - final action = data['action']; - switch (action) { - case 'overlayClosed': - _handleOverlayClose(); - break; - case 'longPressClose': - _tapCloseRequested = true; - _tapCloseTimer?.cancel(); - _tapCloseTimer = Timer(TimingConfig.TAP_CLOSE_DELAY, () { - _tapCloseRequested = false; - }); - break; - default: - Logger.warn('Unknown overlay action: "$action"', 'OverlayProvider'); - } - } - } catch (e) { - Logger.error('Error processing overlay message: $e', 'OverlayProvider'); - } - }, - onError: (error) { - Logger.error('Overlay listener error: $error', 'OverlayProvider'); - }, - ); - } - - void _stopListeningToOverlayMessages() { - _overlayMessageSubscription?.cancel(); - _overlayMessageSubscription = null; - } - - void _startOverlayStatusCheck() { - _overlayStatusCheckTimer?.cancel(); - - _overlayStatusCheckTimer = Timer.periodic(TimingConfig.OVERLAY_STATUS_CHECK_INTERVAL, (timer) async { - if (_isOverlayActive && !_isDisposed) { - try { - final isActive = await _overlayService.isActive(); - if (!isActive) { - timer.cancel(); - _handleOverlayClose(); - } - } catch (e) { - Logger.error('Overlay status check failed: $e', 'OverlayProvider'); - } - } else { - timer.cancel(); - } - }); - Logger.debug('Started overlay status monitoring', 'OverlayProvider'); - } - - void _handleOverlayClose({bool bringToForeground = false}) { - if (_isDisposed) return; - - final shouldBringToFront = bringToForeground || _tapCloseRequested; - - _tapCloseTimer?.cancel(); - _overlayStatusCheckTimer?.cancel(); - _tapCloseRequested = false; - - _isOverlayActive = false; - _stopListeningToOverlayMessages(); - notifyListeners(); - - if (shouldBringToFront) { - _bringAppToFront(); - } - } - - void _bringAppToFront() { - try { - BgLauncher.bringAppToForeground(); - } catch (e) { - Logger.error('BgLauncher failed to bring app to front: $e', 'OverlayProvider'); - } - } - - /// Push current data to overlay — used by background heartbeat - void pushCurrentData() { - if (_isOverlayActive && _gpsManager != null && !_isDisposed) { - _pushDataToOverlay(_gpsManager!.currentData); - } - } - - @override - void dispose() { - _isDisposed = true; - _gpsSubscription?.cancel(); - _overlayMessageSubscription?.cancel(); - _overlayStatusCheckTimer?.cancel(); - _tapCloseTimer?.cancel(); - Logger.info('OverlayProvider disposed', 'OverlayProvider'); - super.dispose(); - } -} diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 784f8f1..989b255 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -1,8 +1,8 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import '../config/speed_unit.dart'; -import '../config/color_themes.dart'; +import '../core/constants/speed_unit.dart'; +import '../core/theme/color_themes.dart'; import '../services/logger.dart'; class SettingsProvider extends ChangeNotifier { diff --git a/lib/screens/overlay_screen.dart b/lib/screens/overlay_screen.dart deleted file mode 100644 index 269d4b7..0000000 --- a/lib/screens/overlay_screen.dart +++ /dev/null @@ -1,326 +0,0 @@ -import 'package:flutter/material.dart'; -import 'dart:async'; -import 'dart:math' as math; -import 'package:flutter_overlay_window/flutter_overlay_window.dart'; -import '../config/color_themes.dart'; -import '../config/overlay_constants.dart'; -import '../models/overlay_message.dart'; -import '../services/logger.dart'; - -class OverlaySpeedometer extends StatefulWidget { - const OverlaySpeedometer({super.key}); - - @override - State createState() => _OverlaySpeedometerState(); -} - -class _OverlaySpeedometerState extends State { - String _speedText = '--'; - String _unitText = 'km/h'; - String _headingText = 'N/A'; - double _heading = -1.0; - int _currentThemeIndex = 0; - double _systemScreenWidth = 400; - double _overlayWidth = 280; - double _overlayHeight = 140; - DateTime _lastUpdateTime = DateTime.now(); - Timer? _stalenessCheckTimer; - - @override - void initState() { - super.initState(); - Logger.info('CREATED - Floating window initialized (${DateTime.now().millisecondsSinceEpoch})', 'Overlay'); - _getSystemScreenSize(); - _listenToMainAppMessages(); - _startStalenessMonitoring(); - } - - @override - void dispose() { - _stalenessCheckTimer?.cancel(); - Logger.info('DISPOSING - Overlay widget disposed (${DateTime.now().millisecondsSinceEpoch})', 'Overlay'); - super.dispose(); - } - - void _getSystemScreenSize() { - try { - // Get actual system screen resolution, not just app window size - final window = WidgetsBinding.instance.platformDispatcher.views.first; - final physicalSize = window.physicalSize; - final devicePixelRatio = window.devicePixelRatio; - final systemSize = physicalSize / devicePixelRatio; - - Logger.debug('Screen detection:', 'Overlay'); - Logger.debug(' physicalSize: ${physicalSize.width.round()}x${physicalSize.height.round()}', 'Overlay'); - Logger.debug(' devicePixelRatio: $devicePixelRatio', 'Overlay'); - Logger.debug(' calculated systemSize: ${systemSize.width.round()}x${systemSize.height.round()}', 'Overlay'); - - setState(() { - _systemScreenWidth = systemSize.width; - }); - - Logger.debug('_systemScreenWidth set to: ${_systemScreenWidth.round()}', 'Overlay'); - } catch (e) { - Logger.error('Error getting system screen size: $e', 'Overlay'); - // Fallback to reasonable default - setState(() { - _systemScreenWidth = OverlayConfig.DEFAULT_SCREEN_WIDTH; - }); - } - } - - void _listenToMainAppMessages() { - FlutterOverlayWindow.overlayListener.listen((data) { - if (data is Map) { - final message = OverlayMessage.fromMap(data); - if (message.action == 'updateDisplay') { - setState(() { - _speedText = message.speedText ?? '--'; - _unitText = message.unitText ?? ''; - _headingText = message.headingText ?? 'N/A'; - _heading = message.heading ?? -1.0; - _currentThemeIndex = message.themeIndex ?? 0; - _lastUpdateTime = message.timestamp; - // Update overlay dimensions if provided by main app - if (message.overlayWidth != null) { - _overlayWidth = message.overlayWidth!; - } - if (message.overlayHeight != null) { - _overlayHeight = message.overlayHeight!; - } - }); - - Logger.debug('UPDATE received:', 'Overlay'); - Logger.debug(' speedText: "$_speedText"', 'Overlay'); - Logger.debug(' headingText: "$_headingText"', 'Overlay'); - Logger.debug(' direction: ${_heading.toStringAsFixed(1)}°', 'Overlay'); - Logger.debug(' themeIndex: $_currentThemeIndex', 'Overlay'); - Logger.debug(' systemScreenWidth: ${_systemScreenWidth.round()}', 'Overlay'); - Logger.debug(' overlaySize from main: ${_overlayWidth.round()}x${_overlayHeight.round()}', 'Overlay'); - } - } - }); - } - - void _startStalenessMonitoring() { - _stalenessCheckTimer = Timer.periodic(OverlayConfig.STALENESS_CHECK_INTERVAL, (timer) { - if (!mounted) return; - - final age = DateTime.now().difference(_lastUpdateTime); - if (age >= OverlayConfig.STALENESS_DASH_THRESHOLD && _speedText != '--') { - setState(() { - _speedText = '--'; - }); - } - }); - } - - - @override - Widget build(BuildContext context) { - final buildTimestamp = DateTime.now().millisecondsSinceEpoch; - final currentTheme = ColorThemes.getTheme(_currentThemeIndex); - - // Calculate staleness opacity - final age = DateTime.now().difference(_lastUpdateTime); - final isStale = age >= OverlayConfig.STALENESS_DIM_THRESHOLD; - final staleOpacity = isStale ? OverlayConfig.STALENESS_DIM_OPACITY : 1.0; - - // Use overlay dimensions provided by main app (not calculated locally) - final fontSize = (_overlayWidth * OverlayConfig.FONT_SIZE_RATIO); - - Logger.debug('Building overlay widget ($buildTimestamp)', 'Overlay'); - Logger.debug('Window size: ${_overlayWidth.round()}x${_overlayHeight.round()}, fontSize: ${fontSize.round()}', 'Overlay'); - - return Material( - color: Colors.transparent, - child: SizedBox( - // Use SizedBox to fill the overlay window completely - width: double.infinity, - height: double.infinity, - child: Container( - decoration: BoxDecoration( - color: currentTheme.background.withValues(alpha: OverlayConfig.BACKGROUND_OPACITY), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: currentTheme.speedText.withValues(alpha: OverlayConfig.BORDER_OPACITY), - width: 1 - ), - ), - child: Stack( - children: [ - // Main content - landscape layout (1:1 flex as per NOTES.txt) - Row( - children: [ - // Speed section - left half - Expanded( - flex: 55, - child: Container( - padding: const EdgeInsets.all(6), - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Flexible( - flex: 3, - child: FittedBox( - fit: BoxFit.contain, - child: Text( - (_speedText), - style: TextStyle( - fontSize: fontSize, - fontWeight: FontWeight.w300, - color: currentTheme.speedText.withValues(alpha: staleOpacity), - fontFamily: 'DIN1451Alt', - ), - ), - ), - ), - Flexible( - flex: 1, - child: FittedBox( - fit: BoxFit.contain, - child: Text( - _unitText, - style: TextStyle( - fontSize: fontSize * OverlayConfig.UNIT_FONT_RATIO, - fontWeight: FontWeight.w300, - color: currentTheme.unitText.withValues(alpha: staleOpacity), - fontFamily: 'DIN1451Alt', - ), - ), - ), - ), - ], - ), - ), - ), - ), - // Direction section - right half (same as full-screen behavior) - Expanded( - flex: 45, - child: Container( - padding: const EdgeInsets.all(8), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Flexible( - flex: 5, - child: Transform.rotate( - angle: (_heading >= 0 && _heading < 360) ? (_heading * math.pi / 180.0) : 0, - child: FittedBox( - fit: BoxFit.contain, - child: Icon( - Icons.navigation, - size: fontSize * OverlayConfig.ICON_SIZE_RATIO, - color: currentTheme.headingText.withValues(alpha: staleOpacity), - ), - ), - ), - ), - Flexible( - flex: 2, - child: FittedBox( - fit: BoxFit.contain, - child: Text( - _headingText, - style: TextStyle( - fontSize: fontSize * OverlayConfig.HEADING_FONT_RATIO, - color: currentTheme.headingText.withValues(alpha: staleOpacity), - fontFamily: 'DIN1451Alt', - ), - ), - ), - ), - ], - ), - ), - ), - ], - ), - // Transparent full-overlay gesture detection layer - Positioned.fill( - child: GestureDetector( - behavior: HitTestBehavior.translucent, // Ensure gestures are captured - onPanStart: (details) { - Logger.debug('Pan start detected - gestures are working!', 'Overlay'); - }, - onLongPress: () async { - final timestamp = DateTime.now().millisecondsSinceEpoch; - Logger.info('Long press detected - closing overlay and bringing main app to front ($timestamp)', 'Overlay'); - try { - // Skip haptic feedback in overlay - platform services not available - Logger.debug('Skipping haptic feedback (overlay context limitation)', 'Overlay'); - - // Signal main app to bring to foreground when overlay closes (non-blocking) - Logger.debug('Sending bring-to-front close signal...', 'Overlay'); - FlutterOverlayWindow.shareData(OverlayMessage.longPressClose().toMap()).then((_) { - Logger.debug('Long press close signal sent successfully', 'Overlay'); - }).catchError((error) { - Logger.warn('Long press close signal failed (expected): $error', 'Overlay'); - }); - - // Small delay to give signal a chance - await Future.delayed(const Duration(milliseconds: 50)); - - // Close the overlay - Logger.info('Attempting to close overlay...', 'Overlay'); - await FlutterOverlayWindow.closeOverlay(); - Logger.info('Overlay closed successfully via long press', 'Overlay'); - - } catch (e, stackTrace) { - Logger.error('Long press close failed: $e', 'Overlay'); - Logger.error('Stack trace: $stackTrace', 'Overlay'); - // Fallback: try to close anyway - try { - Logger.debug('Attempting fallback close...', 'Overlay'); - await FlutterOverlayWindow.closeOverlay(); - Logger.info('Fallback close succeeded', 'Overlay'); - } catch (fallbackError) { - Logger.error('Fallback close also failed: $fallbackError', 'Overlay'); - } - } - }, - // Tap to bring main app to front functionality removed per NOTES.txt - // onTap: () async { - // final timestamp = DateTime.now().millisecondsSinceEpoch; - // customDebugPrint('[Overlay] 👆 Tap detected - bringing main app to foreground ($timestamp)'); - // try { - // // Try to signal main app to come to foreground (non-blocking) - // customDebugPrint('[Overlay] 📤 Attempting to signal bring-to-front...'); - // FlutterOverlayWindow.shareData({ - // 'action': 'bringToFront', - // }).catchError((error) { - // customDebugPrint('[Overlay] ❌ Bring-to-front signal failed (expected): $error'); - // }); - // - // customDebugPrint('[Overlay] ✅ Tap gesture completed (overlay stays open)'); - // - // } catch (e, stackTrace) { - // customDebugPrint('[Overlay] ❌ Tap gesture failed: $e'); - // customDebugPrint('[Overlay] 📚 Stack trace: $stackTrace'); - // } - // }, - child: Container( - width: double.infinity, - height: double.infinity, - color: Colors.transparent, - child: Center( - child: Text( - 'GESTURE', - style: TextStyle( - color: Colors.transparent, - fontSize: 1, - ), - ), - ), - ), - ), - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/screens/speedometer_screen.dart b/lib/screens/speedometer_screen.dart deleted file mode 100644 index 9e8389c..0000000 --- a/lib/screens/speedometer_screen.dart +++ /dev/null @@ -1,516 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:provider/provider.dart'; -import 'package:permission_handler/permission_handler.dart'; -import 'dart:async'; -import 'dart:math' as math; -import '../config/timing_constants.dart'; -import '../config/overlay_constants.dart'; -import '../config/color_themes.dart'; -import '../services/gps_data_manager.dart'; -import '../models/processed_gps_data.dart'; -import '../providers/settings_provider.dart'; -import '../providers/overlay_provider.dart'; -import '../services/logger.dart'; - -class SpeedometerScreen extends StatefulWidget { - const SpeedometerScreen({super.key}); - - @override - State createState() => _SpeedometerScreenState(); -} - -class _SpeedometerScreenState extends State { - // Local widget state only (not shared app state) - String _errorMessage = ''; - Timer? _backgroundHeartbeatTimer; - Timer? _stalenessCheckTimer; - - @override - void initState() { - super.initState(); - // Delay GPS init and error callback setup to after first frame so context is available - WidgetsBinding.instance.addPostFrameCallback((_) { - _initializeGpsManager(); - context.read().onError = _handleOverlayError; - // Wire overlay provider into GPS manager for lifecycle decisions - context.read().setOverlayProvider(context.read()); - }); - _ensureOverlayPermission(); - _startBackgroundHeartbeat(); - _stalenessCheckTimer = Timer.periodic(OverlayConfig.STALENESS_CHECK_INTERVAL, (_) { - if (mounted) setState(() {}); // Trigger rebuild to re-evaluate staleness - }); - } - - @override - void dispose() { - _backgroundHeartbeatTimer?.cancel(); - _stalenessCheckTimer?.cancel(); - try { - context.read().onError = null; - } catch (e) { - // Provider might already be disposed - } - Logger.info('SpeedometerScreen disposed', 'Main'); - super.dispose(); - } - - Future _initializeGpsManager() async { - Logger.info('Starting GPS manager initialization...', 'Main'); - try { - await context.read().initialize(); - Logger.info('GPS manager initialized', 'Main'); - } catch (e) { - Logger.error('GPS manager initialization failed: $e', 'Main'); - if (mounted) { - setState(() => _errorMessage = 'Failed to initialize GPS manager'); - } - } - } - - void _handleOverlayError(OverlayError error) { - if (!mounted) return; - - String message; - SnackBarAction? action; - - switch (error) { - case OverlayError.permission: - message = 'Overlay permission required'; - action = SnackBarAction( - label: 'Settings', - onPressed: () => openAppSettings(), - ); - break; - case OverlayError.creationFailed: - message = 'Failed to create overlay window'; - break; - case OverlayError.communicationDegraded: - message = 'Overlay communication degraded'; - break; - } - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(message), - action: action, - duration: const Duration(seconds: 4), - ), - ); - } - - Future _ensureOverlayPermission() async { - try { - // Note: Overlay permission request is handled by OverlayProvider, - // but we check here to avoid issues at app startup - } catch (e) { - Logger.error('Error checking overlay permission: $e', 'Main'); - } - } - - void _startBackgroundHeartbeat() { - _backgroundHeartbeatTimer = Timer.periodic(TimingConfig.HEARTBEAT_INTERVAL, (timer) { - if (!mounted) return; - final overlay = context.read(); - if (overlay.isOverlayActive) { - overlay.pushCurrentData(); - } - }); - } - - Widget _buildSpeedDisplay(String speedText, ColorTheme currentTheme, double fontSize, double opacity) { - if (speedText == '--') { - return Text( - speedText, - style: TextStyle( - fontSize: fontSize, - fontWeight: FontWeight.w300, - color: currentTheme.speedText.withValues(alpha: opacity), - fontFamily: 'DIN1451Alt', - ), - ); - } - - // Split speed into integral and decimal parts - final parts = speedText.split('.'); - final integralPart = parts[0]; - final decimalPart = parts.length > 1 ? '.${parts[1]}' : '.0'; - - return Row( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.baseline, - textBaseline: TextBaseline.alphabetic, - children: [ - Text( - integralPart, - style: TextStyle( - fontSize: fontSize, - fontWeight: FontWeight.w300, - color: currentTheme.speedText.withValues(alpha: opacity), - fontFamily: 'DIN1451Alt', - ), - ), - Text( - decimalPart, - style: TextStyle( - fontSize: fontSize * 0.5, - fontWeight: FontWeight.w300, - color: currentTheme.speedTextSub.withValues(alpha: opacity), - fontFamily: 'DIN1451Alt', - ), - ), - ], - ); - } - - @override - Widget build(BuildContext context) { - return Selector( - selector: (_, settings) => settings.currentTheme, - builder: (context, currentTheme, _) { - return Scaffold( - backgroundColor: currentTheme.background, - body: SafeArea( - child: LayoutBuilder( - builder: (context, constraints) { - final isLandscape = constraints.maxWidth > constraints.maxHeight; - if (isLandscape) { - return _buildLandscapeLayout(currentTheme); - } else { - return _buildPortraitLayout(currentTheme); - } - }, - ), - ), - ); - }, - ); - } - - Widget _buildPortraitLayout(ColorTheme currentTheme) { - return Column( - children: [ - // Speed area - takes most space, precise tap targets on text only - Expanded( - flex: 65, - child: Container( - width: double.infinity, - color: Colors.transparent, - padding: const EdgeInsets.all(4), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (_errorMessage.isNotEmpty) - Flexible( - child: Text( - _errorMessage, - style: TextStyle( - color: currentTheme.speedText, - fontSize: 18, - fontFamily: 'DIN1451Alt', - ), - textAlign: TextAlign.center, - ), - ) - else ...[ - Flexible( - flex: 7, - child: Selector( - selector: (_, gps) => gps.currentData, - builder: (context, data, _) { - final settings = context.read(); - final displaySpeed = settings.currentUnit.convert(data.speed); - - // Calculate staleness - final age = DateTime.now().difference(data.timestamp); - final isDimmed = age >= OverlayConfig.STALENESS_DIM_THRESHOLD; - final showDashes = age >= OverlayConfig.STALENESS_DASH_THRESHOLD; - final staleOpacity = isDimmed ? OverlayConfig.STALENESS_DIM_OPACITY : 1.0; - - final speedText = showDashes ? '--' : displaySpeed.toStringAsFixed(1); - - return GestureDetector( - onTap: () { - HapticFeedback.lightImpact(); - context.read().cycleTheme(); - }, - child: FittedBox( - fit: BoxFit.contain, - child: _buildSpeedDisplay(speedText, currentTheme, 200, staleOpacity), - ), - ); - }, - ), - ), - Flexible( - flex: 1, - child: FittedBox( - fit: BoxFit.contain, - ), - ), - Flexible( - flex: 2, - child: Selector( - selector: (_, settings) => settings.currentUnit.label, - builder: (context, unitLabel, _) { - return GestureDetector( - onTap: () { - HapticFeedback.lightImpact(); - context.read().cycleUnit(); - }, - child: FittedBox( - fit: BoxFit.contain, - child: Text( - unitLabel, - style: TextStyle( - fontSize: 80, - color: currentTheme.unitText, - fontFamily: 'DIN1451Alt', - ), - ), - ), - ); - }, - ), - ), - Flexible( - flex: 1, - child: FittedBox( - fit: BoxFit.contain, - ), - ), - ], - ], - ), - ), - ), - // Compass area - compact but fully tappable for floating window - Expanded( - flex: 32, - child: GestureDetector( - onTap: () { - HapticFeedback.lightImpact(); - context.read().toggleOverlay(); - }, - onLongPress: () { - context.read().closeOverlay(); - }, - child: Container( - width: double.infinity, - color: Colors.transparent, - padding: const EdgeInsets.all(2), - child: Selector( - selector: (_, gps) => gps.currentData, - builder: (context, data, _) { - // Calculate staleness - final age = DateTime.now().difference(data.timestamp); - final isDimmed = age >= OverlayConfig.STALENESS_DIM_THRESHOLD; - final staleOpacity = isDimmed ? OverlayConfig.STALENESS_DIM_OPACITY : 1.0; - - return Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Flexible( - flex: 3, - child: Transform.rotate( - angle: (data.heading >= 0 && data.heading < 360) ? (data.heading * math.pi / 180.0) : 0, - child: FittedBox( - fit: BoxFit.contain, - child: Icon( - Icons.navigation, - size: 80, - color: currentTheme.headingText.withValues(alpha: staleOpacity), - ), - ), - ), - ), - Flexible( - flex: 1, - child: FittedBox( - fit: BoxFit.contain, - child: Text( - data.displayHeading, - style: TextStyle( - fontSize: 50, - color: currentTheme.headingText.withValues(alpha: staleOpacity), - fontFamily: 'DIN1451Alt', - ), - ), - ), - ), - ], - ); - }, - ), - ), - ), - ), - ], - ); - } - - Widget _buildLandscapeLayout(ColorTheme currentTheme) { - return Row( - children: [ - // Speed area - takes majority of space, precise tap targets on text only - Expanded( - flex: 60, - child: Container( - height: double.infinity, - color: Colors.transparent, - padding: const EdgeInsets.all(3), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (_errorMessage.isNotEmpty) - Flexible( - child: Text( - _errorMessage, - style: TextStyle( - color: currentTheme.speedText, - fontSize: 16, - fontFamily: 'DIN1451Alt', - ), - textAlign: TextAlign.center, - ), - ) - else ...[ - Flexible( - flex: 1, - child: FittedBox(), - ), - Flexible( - flex: 20, - child: Selector( - selector: (_, gps) => gps.currentData, - builder: (context, data, _) { - final settings = context.read(); - final displaySpeed = settings.currentUnit.convert(data.speed); - - // Calculate staleness - final age = DateTime.now().difference(data.timestamp); - final isDimmed = age >= OverlayConfig.STALENESS_DIM_THRESHOLD; - final showDashes = age >= OverlayConfig.STALENESS_DASH_THRESHOLD; - final staleOpacity = isDimmed ? OverlayConfig.STALENESS_DIM_OPACITY : 1.0; - - final speedText = showDashes ? '--' : displaySpeed.toStringAsFixed(1); - - return GestureDetector( - onTap: () { - HapticFeedback.lightImpact(); - context.read().cycleTheme(); - }, - child: FittedBox( - fit: BoxFit.contain, - child: _buildSpeedDisplay(speedText, currentTheme, 160, staleOpacity), - ), - ); - }, - ), - ), - Flexible( - flex: 7, - child: Selector( - selector: (_, settings) => settings.currentUnit.label, - builder: (context, unitLabel, _) { - return GestureDetector( - onTap: () { - HapticFeedback.lightImpact(); - context.read().cycleUnit(); - }, - child: FittedBox( - fit: BoxFit.contain, - child: Text( - unitLabel, - style: TextStyle( - fontSize: 50, - color: currentTheme.unitText, - fontFamily: 'DIN1451Alt', - ), - ), - ), - ); - }, - ), - ), - Flexible( - flex: 5, - child: FittedBox(), - ), - ], - ], - ), - ), - ), - // Compass area - smaller but fully tappable for floating window - Expanded( - flex: 35, - child: GestureDetector( - onTap: () { - HapticFeedback.lightImpact(); - context.read().toggleOverlay(); - }, - onLongPress: () { - context.read().closeOverlay(); - }, - child: Container( - height: double.infinity, - color: Colors.transparent, - padding: const EdgeInsets.all(2), - child: Selector( - selector: (_, gps) => gps.currentData, - builder: (context, data, _) { - // Calculate staleness - final age = DateTime.now().difference(data.timestamp); - final isDimmed = age >= OverlayConfig.STALENESS_DIM_THRESHOLD; - final staleOpacity = isDimmed ? OverlayConfig.STALENESS_DIM_OPACITY : 1.0; - - return Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Flexible( - flex: 3, - child: Transform.rotate( - angle: (data.heading >= 0 && data.heading < 360) ? (data.heading * math.pi / 180.0) : 0, - child: FittedBox( - fit: BoxFit.contain, - child: Icon( - Icons.navigation, - size: 60, - color: currentTheme.headingText.withValues(alpha: staleOpacity), - ), - ), - ), - ), - Flexible( - flex: 1, - child: FittedBox( - fit: BoxFit.contain, - child: Text( - data.displayHeading, - style: TextStyle( - fontSize: 30, - color: currentTheme.headingText.withValues(alpha: staleOpacity), - fontFamily: 'DIN1451Alt', - ), - ), - ), - ), - ], - ); - }, - ), - ), - ), - ), - Expanded( - flex: 1, - child: FittedBox( - fit: BoxFit.contain, - ), - ), - ], - ); - } -} diff --git a/lib/services/gps_data_manager.dart b/lib/services/gps_data_manager.dart deleted file mode 100644 index 3818187..0000000 --- a/lib/services/gps_data_manager.dart +++ /dev/null @@ -1,422 +0,0 @@ -import 'dart:async'; -import 'package:flutter/widgets.dart'; -import 'package:geolocator/geolocator.dart'; -import 'package:wakelock_plus/wakelock_plus.dart'; -import '../gps_service.dart'; -import '../config/speed_unit.dart'; -import '../config/gps_constants.dart'; -import '../config/timing_constants.dart'; -import '../models/processed_gps_data.dart'; -import '../providers/overlay_provider.dart'; -import 'logger.dart'; - -class GpsDataManager extends ChangeNotifier with WidgetsBindingObserver { - GpsDataManager(); - - StreamSubscription? _gpsSubscription; - final StreamController _dataController = - StreamController.broadcast(); - - ProcessedGpsData _currentData = ProcessedGpsData( - speed: 0.0, - heading: -1.0, - displaySpeed: '--', - displayHeading: 'N/A', - isSpeedValid: false, - isHeadingValid: false, - timestamp: DateTime.now(), - ); - - Timer? _staleDataTimer; - Timer? _backgroundGracePeriodTimer; - bool _isDisposed = false; - - // Overlay provider reference for background decision-making - OverlayProvider? _overlayProvider; - - // Lifecycle and precision state - LocationAccuracy _currentAccuracy = LocationAccuracy.high; - bool _hasFirstFix = false; - DateTime? _firstFixTime; - bool _isAppInForeground = true; - bool _isGpsActive = false; - - // Public stream for UI components to subscribe to - Stream get dataStream => _dataController.stream; - - // Get current data synchronously (for immediate access) - ProcessedGpsData get currentData => _currentData; - - bool _isInitialized = false; - - /// Set overlay provider reference for background GPS decision-making - void setOverlayProvider(OverlayProvider provider) { - _overlayProvider = provider; - } - - Future initialize() async { - if (_isInitialized) { - Logger.warn('Already initialized, skipping...', 'GpsDataManager'); - return; - } - - Logger.info('Starting GPS manager initialization...', 'GpsDataManager'); - - // Register lifecycle observer - WidgetsBinding.instance.addObserver(this); - - // Check GPS permissions and services first - final serviceEnabled = await GpsService.isLocationServiceEnabled(); - Logger.debug('Location service enabled: $serviceEnabled', 'GpsDataManager'); - if (!serviceEnabled) { - Logger.error('GPS service disabled', 'GpsDataManager'); - _updateData(_currentData.copyWith( - displaySpeed: 'GPS OFF', - displayHeading: 'GPS OFF' - )); - _isInitialized = true; // Mark as initialized even if failed - return; - } - - final permissionGranted = await GpsService.requestPermissions(); - Logger.debug('GPS permission granted: $permissionGranted', 'GpsDataManager'); - if (!permissionGranted) { - Logger.error('GPS permission denied', 'GpsDataManager'); - _updateData(_currentData.copyWith( - displaySpeed: 'NO PERM', - displayHeading: 'NO PERM' - )); - _isInitialized = true; // Mark as initialized even if failed - return; - } - - // Create GPS stream subscription - Logger.debug('Creating GPS stream subscription...', 'GpsDataManager'); - - try { - Logger.debug('Getting initial GPS position with long timeout...', 'GpsDataManager'); - - // First, get initial position with long timeout for cold start - final initialPosition = await Geolocator.getCurrentPosition( - locationSettings: LocationSettings( - accuracy: LocationAccuracy.bestForNavigation, - timeLimit: GpsConfig.INITIAL_FIX_TIMEOUT, - ), - ); - - Logger.info('Initial GPS fix obtained', 'GpsDataManager'); - _hasFirstFix = true; - _firstFixTime = DateTime.now(); - _onPositionUpdate(initialPosition); - - // Then start position stream with current accuracy - Logger.debug('Starting GPS stream with accuracy: $_currentAccuracy', 'GpsDataManager'); - _gpsSubscription = GpsService.createPositionStream(accuracy: _currentAccuracy).listen( - (Position position) { - Logger.debug('GPS callback triggered', 'GpsDataManager'); - _onPositionUpdate(position); - }, - onError: (error) { - Logger.error('GPS stream error callback: $error', 'GpsDataManager'); - _onGpsError(error); - }, - onDone: () { - Logger.warn('GPS stream done callback - stream ended', 'GpsDataManager'); - }, - cancelOnError: false, - ); - - Logger.info('GPS stream subscription created', 'GpsDataManager'); - Logger.debug('Subscription details: ${_gpsSubscription.runtimeType}', 'GpsDataManager'); - - _isGpsActive = true; - _updateWakeLock(); - _isInitialized = true; - - } catch (e, stackTrace) { - Logger.error('GPS subscription creation failed: $e', 'GpsDataManager'); - Logger.error('Stack trace: $stackTrace', 'GpsDataManager'); - _updateData(_currentData.copyWith( - displaySpeed: 'GPS ERR', - displayHeading: 'GPS ERR' - )); - _isInitialized = true; // Mark as initialized even if failed - return; - } - - Logger.info('GPS manager initialized successfully', 'GpsDataManager'); - } - - void _onPositionUpdate(Position position) { - Logger.debug('Raw GPS update: speed=${position.speed.toStringAsFixed(2)} m/s, heading=${position.heading.toStringAsFixed(1)}°', 'GpsDataManager'); - - _scheduleStaleDataTimer(); - - final rawSpeed = position.speed; - final bool speedValid = rawSpeed.isFinite && rawSpeed >= 0; - final double speed = speedValid ? rawSpeed : 0.0; - - final rawHeading = position.heading; - final bool headingValid = rawHeading.isFinite && rawHeading >= GpsConfig.VALID_HEADING_MIN && rawHeading < GpsConfig.VALID_HEADING_MAX; - final double heading = headingValid ? rawHeading : _currentData.heading; - - // Basic display logic - TEMPORARILY show all speeds for indoor testing - // TODO: Re-enable low-speed logic later: (speed < GpsConfig.LOW_SPEED_THRESHOLD && (heading < GpsConfig.VALID_HEADING_MIN || heading >= GpsConfig.VALID_HEADING_MAX)) ? '--' : ... - final displaySpeed = speed.toStringAsFixed(1); - - final displayHeading = headingValid - ? GpsService.formatHeading(heading) - : _currentData.displayHeading; - - Logger.debug('Processed: displaySpeed="$displaySpeed", displayHeading="$displayHeading"', 'GpsDataManager'); - - final processedData = ProcessedGpsData( - speed: speed, - heading: heading, - displaySpeed: displaySpeed, - displayHeading: displayHeading, - isSpeedValid: speedValid, - isHeadingValid: headingValid, - timestamp: DateTime.now(), - ); - - _updateData(processedData); - - // Check precision switching after first-fix gate - if (_hasFirstFix && _firstFixTime != null) { - final timeSinceFirstFix = DateTime.now().difference(_firstFixTime!); - if (timeSinceFirstFix >= GpsConfig.PRECISION_SWITCH_FIRST_FIX_DELAY) { - _checkPrecisionSwitch(position.speed); - } - } - } - - void _onGpsError(dynamic error) { - if (_isDisposed) return; - // Handle GPS errors gracefully - final errorString = error.toString().toLowerCase(); - if (!errorString.contains('timeout') && !errorString.contains('temporarily')) { - _updateData(_currentData.copyWith( - displaySpeed: 'GPS ERR', - displayHeading: 'GPS ERR', - isSpeedValid: false, - isHeadingValid: false, - )); - } - } - - void _scheduleStaleDataTimer() { - _staleDataTimer?.cancel(); - _staleDataTimer = Timer(GpsConfig.STALE_DATA_THRESHOLD, _handleStaleDataTimeout); - } - - void _handleStaleDataTimeout() { - if (_isDisposed) return; - // Avoid overriding explicit GPS error states or repeated stale notifications - final displayText = _currentData.displaySpeed; - if (displayText == '--' || displayText.startsWith('GPS') || displayText == 'NO PERM') { - return; - } - - final headingStillValid = _currentData.isHeadingValid && _currentData.heading >= GpsConfig.VALID_HEADING_MIN && _currentData.heading < GpsConfig.VALID_HEADING_MAX; - final displayHeading = headingStillValid ? _currentData.displayHeading : '--'; - - Logger.warn('No GPS updates within ${GpsConfig.STALE_DATA_THRESHOLD.inSeconds}s - marking data as stale', 'GpsDataManager'); - - final staleData = _currentData.copyWith( - displaySpeed: '--', - displayHeading: displayHeading, - isSpeedValid: false, - isHeadingValid: headingStillValid, - ); - - _staleDataTimer = null; - _updateData(staleData); - } - - void _updateData(ProcessedGpsData newData) { - if (_isDisposed) return; - Logger.debug('Broadcasting data: ${newData.displaySpeed} ${newData.displayHeading}', 'GpsDataManager'); - _currentData = newData; - _dataController.add(newData); - notifyListeners(); - Logger.debug('Data broadcast complete', 'GpsDataManager'); - } - - // Format speed for specific unit (used by UI) - String getFormattedSpeed(SpeedUnit unit) { - if (_currentData.displaySpeed == '--') return '--'; - - final convertedSpeed = unit.convert(_currentData.speed); - return convertedSpeed < 1.0 ? '--' : convertedSpeed.toStringAsFixed(1); - } - - /// Check if GPS precision should be switched based on speed - void _checkPrecisionSwitch(double speedMps) { - if (_currentAccuracy == LocationAccuracy.high) { - // Switch to MEDIUM if speed drops below down-threshold - if (speedMps < GpsConfig.PRECISION_DOWN_THRESHOLD_MPS) { - _switchPrecision(LocationAccuracy.medium); - } - } else if (_currentAccuracy == LocationAccuracy.medium) { - // Switch to HIGH if speed exceeds up-threshold - if (speedMps > GpsConfig.PRECISION_UP_THRESHOLD_MPS) { - _switchPrecision(LocationAccuracy.high); - } - } - } - - /// Switch GPS precision and restart stream with new accuracy - void _switchPrecision(LocationAccuracy newAccuracy) { - if (_currentAccuracy == newAccuracy) return; - - final speedKmh = _currentData.speed * 3.6; - Logger.debug('Precision switched to $newAccuracy at ${speedKmh.toStringAsFixed(1)} km/h', 'GpsDataManager'); - - _currentAccuracy = newAccuracy; - - // Cancel existing subscription and create new one with new accuracy - _gpsSubscription?.cancel(); - _gpsSubscription = GpsService.createPositionStream(accuracy: newAccuracy).listen( - (Position position) { - Logger.debug('GPS callback triggered', 'GpsDataManager'); - _onPositionUpdate(position); - }, - onError: (error) { - Logger.error('GPS stream error callback: $error', 'GpsDataManager'); - _onGpsError(error); - }, - onDone: () { - Logger.warn('GPS stream done callback - stream ended', 'GpsDataManager'); - }, - cancelOnError: false, - ); - } - - /// Handle app lifecycle state changes for background GPS management - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - if (state == AppLifecycleState.paused) { - // App going to background - _isAppInForeground = false; - final overlayActive = _overlayProvider?.isOverlayActive ?? false; - - if (overlayActive) { - Logger.info('App backgrounded with overlay active - maintaining full GPS', 'GpsDataManager'); - } else { - Logger.info('App backgrounded without overlay - starting ${TimingConfig.BACKGROUND_GRACE_PERIOD.inSeconds}s grace period', 'GpsDataManager'); - _backgroundGracePeriodTimer?.cancel(); - _backgroundGracePeriodTimer = Timer(TimingConfig.BACKGROUND_GRACE_PERIOD, _onGracePeriodExpired); - } - - _updateWakeLock(); - } else if (state == AppLifecycleState.resumed) { - // App returning to foreground - _isAppInForeground = true; - - // Cancel grace period if running - _backgroundGracePeriodTimer?.cancel(); - _backgroundGracePeriodTimer = null; - - // Restart GPS if it was stopped - if (_gpsSubscription == null && _isInitialized) { - _restartGps(); - } - - _updateWakeLock(); - } - } - - /// Grace period timer callback - re-check overlay state before stopping GPS - void _onGracePeriodExpired() { - // Race condition protection: overlay may have been activated during grace period - final overlayActive = _overlayProvider?.isOverlayActive ?? false; - - if (overlayActive) { - Logger.info('Overlay became active during grace period - keeping GPS', 'GpsDataManager'); - _backgroundGracePeriodTimer = null; - return; - } - - _stopGps(); - _backgroundGracePeriodTimer = null; - } - - /// Stop GPS tracking (backgrounded without overlay) - void _stopGps() { - _gpsSubscription?.cancel(); - _gpsSubscription = null; - _isGpsActive = false; - _staleDataTimer?.cancel(); - _updateWakeLock(); - Logger.info('GPS stopped (backgrounded without overlay)', 'GpsDataManager'); - } - - /// Restart GPS tracking (app foregrounded) - void _restartGps() { - Logger.info('Restarting GPS (app foregrounded)', 'GpsDataManager'); - - _gpsSubscription = GpsService.createPositionStream(accuracy: _currentAccuracy).listen( - (Position position) { - Logger.debug('GPS callback triggered', 'GpsDataManager'); - _onPositionUpdate(position); - }, - onError: (error) { - Logger.error('GPS stream error callback: $error', 'GpsDataManager'); - _onGpsError(error); - }, - onDone: () { - Logger.warn('GPS stream done callback - stream ended', 'GpsDataManager'); - }, - cancelOnError: false, - ); - - _isGpsActive = true; - _updateWakeLock(); - } - - /// Update wake lock based on GPS state and app visibility - void _updateWakeLock() { - final overlayActive = _overlayProvider?.isOverlayActive ?? false; - final shouldKeepAwake = _isGpsActive && (overlayActive || _isAppInForeground); - - if (shouldKeepAwake) { - WakelockPlus.enable(); - Logger.debug('Wake lock enabled (GPS: $_isGpsActive, overlay: $overlayActive, foreground: $_isAppInForeground)', 'GpsDataManager'); - } else { - WakelockPlus.disable(); - Logger.debug('Wake lock disabled (GPS: $_isGpsActive, overlay: $overlayActive, foreground: $_isAppInForeground)', 'GpsDataManager'); - } - } - - @override - void dispose() { - _isDisposed = true; - Logger.info('Disposing GPS manager...', 'GpsDataManager'); - - // Remove lifecycle observer BEFORE super.dispose() - WidgetsBinding.instance.removeObserver(this); - - // Cancel all timers and subscriptions - _backgroundGracePeriodTimer?.cancel(); - _backgroundGracePeriodTimer = null; - - if (_gpsSubscription != null) { - Logger.debug('Cancelling GPS subscription...', 'GpsDataManager'); - _gpsSubscription?.cancel(); - _gpsSubscription = null; - } - - _staleDataTimer?.cancel(); - _staleDataTimer = null; - - Logger.debug('Closing data controller...', 'GpsDataManager'); - _dataController.close(); - - // Disable wake lock as safety net - WakelockPlus.disable(); - - Logger.info('GPS manager disposed', 'GpsDataManager'); - super.dispose(); - } -} diff --git a/lib/services/overlay_service.dart b/lib/services/overlay_service.dart deleted file mode 100644 index 7578339..0000000 --- a/lib/services/overlay_service.dart +++ /dev/null @@ -1,96 +0,0 @@ -import 'package:flutter_overlay_window/flutter_overlay_window.dart'; -import 'package:permission_handler/permission_handler.dart'; -import 'package:retry/retry.dart'; -import 'logger.dart'; - -/// Service wrapper for FlutterOverlayWindow platform calls with retry logic -/// Encapsulates all overlay operations with error handling and verification -class OverlayService { - /// Retry configuration for platform operations - static final RetryOptions _retryOptions = RetryOptions( - maxAttempts: 3, - delayFactor: const Duration(milliseconds: 200), - ); - - /// Show overlay window with retry logic and verification - /// Returns true if overlay successfully opened and verified active - Future showOverlay({ - required int width, - required int height, - }) async { - try { - await _retryOptions.retry( - () async { - await FlutterOverlayWindow.showOverlay( - enableDrag: true, - overlayTitle: "Speedometer", - overlayContent: 'Speedo overlay active', - flag: OverlayFlag.defaultFlag, - visibility: NotificationVisibility.visibilityPublic, - positionGravity: PositionGravity.none, - width: width, - height: height, - ); - }, - ); - - // Verify overlay actually opened - final isActive = await FlutterOverlayWindow.isActive(); - if (!isActive) { - Logger.error('Overlay showOverlay succeeded but isActive returned false', 'OverlayService'); - return false; - } - - return true; - } catch (e) { - Logger.error('Failed to show overlay after retries: $e', 'OverlayService'); - return false; - } - } - - /// Close overlay window with retry logic - /// Returns true on success, false on failure after retries - Future closeOverlay() async { - try { - await _retryOptions.retry( - () async { - await FlutterOverlayWindow.closeOverlay(); - }, - ); - return true; - } catch (e) { - Logger.error('Failed to close overlay after retries: $e', 'OverlayService'); - return false; - } - } - - /// Share data with overlay window (fire-and-forget) - /// Does NOT retry - data loss is expected and handled by staleness detection - Future shareData(Map data) async { - try { - await FlutterOverlayWindow.shareData(data); - } catch (e) { - Logger.warn('Failed to share data with overlay: $e', 'OverlayService'); - } - } - - /// Check if overlay window is currently active - /// Returns false on error - Future isActive() async { - try { - return await FlutterOverlayWindow.isActive(); - } catch (e) { - Logger.error('Failed to check overlay active status: $e', 'OverlayService'); - return false; - } - } - - /// Stream of messages from overlay window - /// Direct delegation to platform channel - Stream get overlayListener => FlutterOverlayWindow.overlayListener; - - /// Check if system alert window permission is granted - Future checkPermission() async { - return await Permission.systemAlertWindow.isGranted; - } -} diff --git a/lib/ui/features/speedometer/speedometer_view_model.dart b/lib/ui/features/speedometer/speedometer_view_model.dart index b7f96d1..ba09ce4 100644 --- a/lib/ui/features/speedometer/speedometer_view_model.dart +++ b/lib/ui/features/speedometer/speedometer_view_model.dart @@ -1,9 +1,9 @@ -import 'dart:async'; import 'package:flutter/widgets.dart'; -import 'package:shared_preferences/shared_preferences.dart'; +import 'package:provider/provider.dart'; import '../../../core/constants/speed_unit.dart'; import '../../../data/models/location_data.dart'; import '../../../data/repositories/location_repository.dart'; +import '../../../providers/settings_provider.dart'; /// ViewModel for the speedometer UI. /// @@ -11,71 +11,24 @@ import '../../../data/repositories/location_repository.dart'; /// the current display state to the view. No data-layer logic here. class SpeedometerViewModel extends ChangeNotifier { final LocationRepository _repository; - final SharedPreferences _prefs; + SettingsProvider? _settings; - SpeedUnit _displayUnit = SpeedUnit.kmh; - LocationData _displayData = LocationData( - speed: 0.0, - heading: -1.0, - displaySpeed: '--', - displayHeading: 'N/A', - isSpeedValid: false, - isHeadingValid: false, - timestamp: DateTime.now(), - ); + SpeedUnit get displayUnit => _settings?.currentUnit ?? SpeedUnit.kmh; + LocationData get displayData => _repository.currentData; + String get displaySpeedText => _repository.currentData.displaySpeed; + String get displayHeading => _repository.currentData.displayHeading; + bool get isSpeedValid => _repository.currentData.isSpeedValid; + bool get isHeadingValid => _repository.currentData.isHeadingValid; - LocationData get displayData => _displayData; - SpeedUnit get displayUnit => _displayUnit; + SpeedometerViewModel(this._repository); - SpeedometerViewModel({ - required LocationRepository repository, - required SharedPreferences prefs, - }) : _repository = repository, - _prefs = prefs; - - void init() { - _repository.locationStream.listen(_onLocationData); - _loadSettings(); - } - - void _onLocationData(LocationData data) { - _displayData = data; - notifyListeners(); - } - - Future _loadSettings() async { - _displayUnit = _parseSpeedUnit(_prefs.getString('speed_unit') ?? 'km/h'); - notifyListeners(); - } - - static SpeedUnit _parseSpeedUnit(String unit) { - switch (unit) { - case 'mph': - return SpeedUnit.mph; - case 'kts': - return SpeedUnit.knots; - default: - return SpeedUnit.kmh; - } + /// Call when the ViewModel is inserted into the provider tree so it + /// can listen to SettingsProvider and repository state. + void attach(BuildContext context) { + _settings = context.read(); } double get displaySpeed { - return _displayUnit.convert(_displayData.speed); - } - - String get displaySpeedText { - return _displayData.displaySpeed; - } - - String get displayHeading { - return _displayData.displayHeading; - } - - bool get isSpeedValid { - return _displayData.isSpeedValid; - } - - bool get isHeadingValid { - return _displayData.isHeadingValid; + return displayUnit.convert(_repository.currentData.speed); } } -- 2.52.0