refactor: layered architecture (core/data/ui separation) #1

Merged
mikkeli merged 5 commits from refactor/flutter-architecture into master 2026-08-23 11:25:55 +00:00
22 changed files with 849 additions and 1748 deletions
@@ -1,4 +1,7 @@
class ProcessedGpsData {
/// 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)
@@ -7,7 +10,7 @@ class ProcessedGpsData {
final bool isHeadingValid; // True if from recent GPS
final DateTime timestamp; // Timestamp for staleness detection
const ProcessedGpsData({
const LocationData({
required this.speed,
required this.heading,
required this.displaySpeed,
@@ -17,7 +20,7 @@ class ProcessedGpsData {
required this.timestamp,
});
ProcessedGpsData copyWith({
LocationData copyWith({
double? speed,
double? heading,
String? displaySpeed,
@@ -26,7 +29,7 @@ class ProcessedGpsData {
bool? isHeadingValid,
DateTime? timestamp,
}) {
return ProcessedGpsData(
return LocationData(
speed: speed ?? this.speed,
heading: heading ?? this.heading,
displaySpeed: displaySpeed ?? this.displaySpeed,
@@ -0,0 +1,335 @@
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 ChangeNotifier with WidgetsBindingObserver {
StreamSubscription<Position>? _gpsSubscription;
final StreamController<LocationData> _dataController =
StreamController<LocationData>.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<LocationData> 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<void> 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);
notifyListeners();
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<void> _updateWakeLock() async {
final shouldKeepAwake = _isGpsActive && _isAppInForeground;
await GpsService.setWakeLock(shouldKeepAwake);
Logger.debug(
'Wake lock ${shouldKeepAwake ? "enabled" : "disabled"} (GPS: $_isGpsActive, foreground: $_isAppInForeground)',
'LocationRepository',
);
}
@override
void dispose() {
super.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');
}
}
@@ -1,12 +1,16 @@
import 'dart:async';
import 'package:geolocator/geolocator.dart';
import 'package:permission_handler/permission_handler.dart';
import 'config/gps_constants.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
/// Create a position stream with dynamic accuracy setting.
static Stream<Position> createPositionStream({
LocationAccuracy accuracy = LocationAccuracy.high,
}) {
@@ -18,12 +22,6 @@ class GpsService {
);
}
/// Deprecated: Use createPositionStream() instead
@Deprecated('Use createPositionStream(accuracy: LocationAccuracy.bestForNavigation) instead')
static Stream<Position> get positionStream {
return createPositionStream(accuracy: LocationAccuracy.bestForNavigation);
}
static Future<bool> requestPermissions() async {
final permission = await Permission.locationWhenInUse.request();
return permission.isGranted;
@@ -33,6 +31,22 @@ class GpsService {
return await Geolocator.isLocationServiceEnabled();
}
static Future<Position?> 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;
@@ -43,7 +57,6 @@ class GpsService {
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];
@@ -51,6 +64,10 @@ class GpsService {
static String formatHeading(double heading) {
final cachedHeading = getCachedHeading(heading);
return '${cachedHeading.round()} ${getCompassDirection(heading)}'; // don't add that degree symbol, this is cooler
return '${cachedHeading.round()} ${getCompassDirection(heading)}';
}
static Future<void> setWakeLock(bool enable) async {
await WakelockPlus.toggle(enable: enable);
}
}
@@ -1,7 +1,7 @@
import 'package:flutter_overlay_window/flutter_overlay_window.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:retry/retry.dart';
import 'logger.dart';
import '../../services/logger.dart';
/// Service wrapper for FlutterOverlayWindow platform calls with retry logic
/// Encapsulates all overlay operations with error handling and verification
+33 -29
View File
@@ -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<SharedPreferences>.value(value: prefs),
Provider<OverlayService>(create: (_) => OverlayService()),
ChangeNotifierProvider(
create: (context) => SettingsProvider(context.read<SharedPreferences>()),
// Data layer
ChangeNotifierProvider<LocationRepository>(
create: (_) {
final repo = LocationRepository();
repo.initialize();
return repo;
},
),
ChangeNotifierProvider(
create: (_) => GpsDataManager(),
// UI layer — ViewModels subscribe to repositories
ChangeNotifierProvider<SpeedometerViewModel>(
create: (context) => SpeedometerViewModel(
context.read<LocationRepository>(),
),
),
ChangeNotifierProxyProvider2<GpsDataManager, SettingsProvider, OverlayProvider>(
create: (context) => OverlayProvider(context.read<OverlayService>()),
update: (_, gpsManager, settings, overlay) => overlay!
..updateDependencies(
gpsManager: gpsManager,
currentUnit: settings.currentUnit,
currentThemeIndex: settings.currentThemeIndex,
),
ChangeNotifierProvider<OverlayViewModel>(
create: (_) => OverlayViewModel(),
),
ChangeNotifierProvider<SettingsProvider>(
create: (context) => SettingsProvider(
context.read<SharedPreferences>(),
),
),
],
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(),
);
}
}
-128
View File
@@ -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<String, dynamic> toMap() {
final map = <String, dynamic>{'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<dynamic, dynamic> 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,
);
}
}
-32
View File
@@ -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,
);
}
}
-274
View File
@@ -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<ProcessedGpsData>? _gpsSubscription;
StreamSubscription<dynamic>? _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<void> 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<void> 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<void> toggleOverlay() async {
if (_isOverlayActive) {
await closeOverlay();
} else {
await showOverlay();
}
}
Map<String, int> _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();
}
}
+2 -2
View File
@@ -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 {
-326
View File
@@ -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<OverlaySpeedometer> createState() => _OverlaySpeedometerState();
}
class _OverlaySpeedometerState extends State<OverlaySpeedometer> {
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,
),
),
),
),
),
),
],
),
),
),
);
}
}
-516
View File
@@ -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<SpeedometerScreen> createState() => _SpeedometerScreenState();
}
class _SpeedometerScreenState extends State<SpeedometerScreen> {
// 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<OverlayProvider>().onError = _handleOverlayError;
// Wire overlay provider into GPS manager for lifecycle decisions
context.read<GpsDataManager>().setOverlayProvider(context.read<OverlayProvider>());
});
_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<OverlayProvider>().onError = null;
} catch (e) {
// Provider might already be disposed
}
Logger.info('SpeedometerScreen disposed', 'Main');
super.dispose();
}
Future<void> _initializeGpsManager() async {
Logger.info('Starting GPS manager initialization...', 'Main');
try {
await context.read<GpsDataManager>().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<void> _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<OverlayProvider>();
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<SettingsProvider, ColorTheme>(
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<GpsDataManager, ProcessedGpsData>(
selector: (_, gps) => gps.currentData,
builder: (context, data, _) {
final settings = context.read<SettingsProvider>();
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<SettingsProvider>().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<SettingsProvider, String>(
selector: (_, settings) => settings.currentUnit.label,
builder: (context, unitLabel, _) {
return GestureDetector(
onTap: () {
HapticFeedback.lightImpact();
context.read<SettingsProvider>().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<OverlayProvider>().toggleOverlay();
},
onLongPress: () {
context.read<OverlayProvider>().closeOverlay();
},
child: Container(
width: double.infinity,
color: Colors.transparent,
padding: const EdgeInsets.all(2),
child: Selector<GpsDataManager, ProcessedGpsData>(
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<GpsDataManager, ProcessedGpsData>(
selector: (_, gps) => gps.currentData,
builder: (context, data, _) {
final settings = context.read<SettingsProvider>();
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<SettingsProvider>().cycleTheme();
},
child: FittedBox(
fit: BoxFit.contain,
child: _buildSpeedDisplay(speedText, currentTheme, 160, staleOpacity),
),
);
},
),
),
Flexible(
flex: 7,
child: Selector<SettingsProvider, String>(
selector: (_, settings) => settings.currentUnit.label,
builder: (context, unitLabel, _) {
return GestureDetector(
onTap: () {
HapticFeedback.lightImpact();
context.read<SettingsProvider>().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<OverlayProvider>().toggleOverlay();
},
onLongPress: () {
context.read<OverlayProvider>().closeOverlay();
},
child: Container(
height: double.infinity,
color: Colors.transparent,
padding: const EdgeInsets.all(2),
child: Selector<GpsDataManager, ProcessedGpsData>(
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,
),
),
],
);
}
}
-422
View File
@@ -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<Position>? _gpsSubscription;
final StreamController<ProcessedGpsData> _dataController =
StreamController<ProcessedGpsData>.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<ProcessedGpsData> 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<void> 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();
}
}
+17
View File
@@ -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'),
);
}
}
@@ -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
}
}
@@ -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<SpeedometerViewModel, double>(
selector: (_, vm) => vm.displaySpeed,
builder: (context, displaySpeed, _) {
final vm = context.watch<SpeedometerViewModel>();
final theme = context.select<Theme, ColorTheme>(
(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<SpeedometerViewModel>().displayData.timestamp,
),
),
),
const Flexible(flex: 1, child: FittedBox(fit: BoxFit.contain)),
Flexible(
flex: 2,
child: Selector<SpeedometerViewModel, String>(
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<SpeedometerViewModel>().displayData.heading,
headingText: context.read<SpeedometerViewModel>().displayHeading,
isValid: context.read<SpeedometerViewModel>().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<SpeedometerViewModel>().displayData.timestamp,
),
),
),
Flexible(
flex: 7,
child: Selector<SpeedometerViewModel, String>(
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<SpeedometerViewModel>().displayData.heading,
headingText: context.read<SpeedometerViewModel>().displayHeading,
isValid: context.read<SpeedometerViewModel>().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',
),
),
),
),
],
);
}
}
@@ -0,0 +1,34 @@
import 'package:flutter/widgets.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.
///
/// 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;
SettingsProvider? _settings;
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;
SpeedometerViewModel(this._repository);
/// 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<SettingsProvider>();
}
double get displaySpeed {
return displayUnit.convert(_repository.currentData.speed);
}
}
+95
View File
@@ -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<SettingsProvider, ColorTheme>(
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<SettingsProvider>().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',
),
),
],
);
}
}