Merge pull request 'refactor: layered architecture (core/data/ui separation)' (#1) from refactor/flutter-architecture into master
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
@@ -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,
|
||||
@@ -36,4 +39,4 @@ class ProcessedGpsData {
|
||||
timestamp: timestamp ?? this.timestamp,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -40,17 +54,20 @@ class GpsService {
|
||||
}
|
||||
return _lastValidHeading ?? 0.0;
|
||||
}
|
||||
|
||||
|
||||
static String getCompassDirection(double heading) {
|
||||
final cachedHeading = getCachedHeading(heading);
|
||||
|
||||
const directions = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
|
||||
final index = ((cachedHeading + 22.5) / 45).floor() % 8;
|
||||
return directions[index];
|
||||
}
|
||||
|
||||
|
||||
static String formatHeading(double heading) {
|
||||
final cachedHeading = getCachedHeading(heading);
|
||||
return '${cachedHeading.round()} ${getCompassDirection(heading)}'; // don't add that degree symbol, this is cooler
|
||||
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
|
||||
+34
-30
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user