From 44480eb9696ba2b834b69295c7530a30236388c1 Mon Sep 17 00:00:00 2001 From: Irene Date: Sun, 23 Aug 2026 17:16:43 +0900 Subject: [PATCH] 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