refactor: complete layered architecture migration
Delete old flat files, wire new DI in main.dart:
- Removed: gps_service.dart, config/, models/, providers/overlay_provider.dart,
screens/, services/gps_data_manager.dart, services/overlay_service.dart
- Kept: settings_provider.dart (thin, correct), logger.dart (shared util)
- main.dart now wires: LocationRepository → SpeedometerViewModel → SpeedometerView
- overlay_provider.dart deferred, stub in ui/features/overlay/
- flutter analyze: 30 info-level constant_identifier_names warnings (project convention),
zero errors, zero warnings — builds clean
New structure:
lib/
core/constants/ — gps, overlay, timing, speed_unit
core/theme/ — color_themes
data/
models/ — LocationData (immutable domain model)
services/ — GpsService (stateless wrapper), OverlayService (IPC)
repositories/ — LocationRepository (business logic, ChangeNotifier)
providers/ — SettingsProvider (thin, unchanged)
ui/
features/
speedometer/ — SpeedometerViewModel + SpeedometerView
overlay/ — Stub ViewModel + View (deferred)
shared/ — SpeedDisplay widget
services/ — Logger (shared util)
This commit is contained in:
@@ -13,7 +13,7 @@ import '../services/gps_service.dart';
|
||||
/// - GPS stream management (start/stop/restart)
|
||||
/// - Data processing (staleness, precision switching, validation)
|
||||
/// - Lifecycle management (app foreground/background, wake lock)
|
||||
class LocationRepository extends WidgetsBindingObserver {
|
||||
class LocationRepository extends ChangeNotifier with WidgetsBindingObserver {
|
||||
StreamSubscription<Position>? _gpsSubscription;
|
||||
final StreamController<LocationData> _dataController =
|
||||
StreamController<LocationData>.broadcast();
|
||||
@@ -238,6 +238,7 @@ class LocationRepository extends WidgetsBindingObserver {
|
||||
if (_isDisposed) return;
|
||||
_currentData = newData;
|
||||
_dataController.add(newData);
|
||||
notifyListeners();
|
||||
Logger.debug('Broadcasting data: ${newData.displaySpeed} ${newData.displayHeading}', 'LocationRepository');
|
||||
}
|
||||
|
||||
@@ -315,7 +316,9 @@ class LocationRepository extends WidgetsBindingObserver {
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
_isDisposed = true;
|
||||
Logger.info('Disposing location repository...', 'LocationRepository');
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'config/gps_constants.dart';
|
||||
|
||||
class GpsService {
|
||||
static double? _lastValidHeading;
|
||||
|
||||
/// Create a position stream with dynamic accuracy setting
|
||||
static Stream<Position> createPositionStream({
|
||||
LocationAccuracy accuracy = LocationAccuracy.high,
|
||||
}) {
|
||||
return Geolocator.getPositionStream(
|
||||
locationSettings: LocationSettings(
|
||||
accuracy: accuracy,
|
||||
distanceFilter: 0,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Deprecated: Use createPositionStream() instead
|
||||
@Deprecated('Use createPositionStream(accuracy: LocationAccuracy.bestForNavigation) instead')
|
||||
static Stream<Position> get positionStream {
|
||||
return createPositionStream(accuracy: LocationAccuracy.bestForNavigation);
|
||||
}
|
||||
|
||||
static Future<bool> requestPermissions() async {
|
||||
final permission = await Permission.locationWhenInUse.request();
|
||||
return permission.isGranted;
|
||||
}
|
||||
|
||||
static Future<bool> isLocationServiceEnabled() async {
|
||||
return await Geolocator.isLocationServiceEnabled();
|
||||
}
|
||||
|
||||
static double getCachedHeading(double heading) {
|
||||
if (heading >= GpsConfig.VALID_HEADING_MIN && heading <= GpsConfig.VALID_HEADING_MAX) {
|
||||
_lastValidHeading = heading;
|
||||
return heading;
|
||||
}
|
||||
return _lastValidHeading ?? 0.0;
|
||||
}
|
||||
|
||||
static String getCompassDirection(double heading) {
|
||||
final cachedHeading = getCachedHeading(heading);
|
||||
|
||||
const directions = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
|
||||
final index = ((cachedHeading + 22.5) / 45).floor() % 8;
|
||||
return directions[index];
|
||||
}
|
||||
|
||||
static String formatHeading(double heading) {
|
||||
final cachedHeading = getCachedHeading(heading);
|
||||
return '${cachedHeading.round()} ${getCompassDirection(heading)}'; // don't add that degree symbol, this is cooler
|
||||
}
|
||||
}
|
||||
+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,39 +0,0 @@
|
||||
class ProcessedGpsData {
|
||||
final double speed; // Raw speed in m/s
|
||||
final double heading; // Raw heading in degrees
|
||||
final String displaySpeed; // Formatted speed text (with -- logic)
|
||||
final String displayHeading; // Formatted heading text
|
||||
final bool isSpeedValid; // True if from recent GPS
|
||||
final bool isHeadingValid; // True if from recent GPS
|
||||
final DateTime timestamp; // Timestamp for staleness detection
|
||||
|
||||
const ProcessedGpsData({
|
||||
required this.speed,
|
||||
required this.heading,
|
||||
required this.displaySpeed,
|
||||
required this.displayHeading,
|
||||
required this.isSpeedValid,
|
||||
required this.isHeadingValid,
|
||||
required this.timestamp,
|
||||
});
|
||||
|
||||
ProcessedGpsData copyWith({
|
||||
double? speed,
|
||||
double? heading,
|
||||
String? displaySpeed,
|
||||
String? displayHeading,
|
||||
bool? isSpeedValid,
|
||||
bool? isHeadingValid,
|
||||
DateTime? timestamp,
|
||||
}) {
|
||||
return ProcessedGpsData(
|
||||
speed: speed ?? this.speed,
|
||||
heading: heading ?? this.heading,
|
||||
displaySpeed: displaySpeed ?? this.displaySpeed,
|
||||
displayHeading: displayHeading ?? this.displayHeading,
|
||||
isSpeedValid: isSpeedValid ?? this.isSpeedValid,
|
||||
isHeadingValid: isHeadingValid ?? this.isHeadingValid,
|
||||
timestamp: timestamp ?? this.timestamp,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import 'package:flutter_overlay_window/flutter_overlay_window.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:retry/retry.dart';
|
||||
import 'logger.dart';
|
||||
|
||||
/// Service wrapper for FlutterOverlayWindow platform calls with retry logic
|
||||
/// Encapsulates all overlay operations with error handling and verification
|
||||
class OverlayService {
|
||||
/// Retry configuration for platform operations
|
||||
static final RetryOptions _retryOptions = RetryOptions(
|
||||
maxAttempts: 3,
|
||||
delayFactor: const Duration(milliseconds: 200),
|
||||
);
|
||||
|
||||
/// Show overlay window with retry logic and verification
|
||||
/// Returns true if overlay successfully opened and verified active
|
||||
Future<bool> showOverlay({
|
||||
required int width,
|
||||
required int height,
|
||||
}) async {
|
||||
try {
|
||||
await _retryOptions.retry(
|
||||
() async {
|
||||
await FlutterOverlayWindow.showOverlay(
|
||||
enableDrag: true,
|
||||
overlayTitle: "Speedometer",
|
||||
overlayContent: 'Speedo overlay active',
|
||||
flag: OverlayFlag.defaultFlag,
|
||||
visibility: NotificationVisibility.visibilityPublic,
|
||||
positionGravity: PositionGravity.none,
|
||||
width: width,
|
||||
height: height,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Verify overlay actually opened
|
||||
final isActive = await FlutterOverlayWindow.isActive();
|
||||
if (!isActive) {
|
||||
Logger.error('Overlay showOverlay succeeded but isActive returned false', 'OverlayService');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
Logger.error('Failed to show overlay after retries: $e', 'OverlayService');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Close overlay window with retry logic
|
||||
/// Returns true on success, false on failure after retries
|
||||
Future<bool> closeOverlay() async {
|
||||
try {
|
||||
await _retryOptions.retry(
|
||||
() async {
|
||||
await FlutterOverlayWindow.closeOverlay();
|
||||
},
|
||||
);
|
||||
return true;
|
||||
} catch (e) {
|
||||
Logger.error('Failed to close overlay after retries: $e', 'OverlayService');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Share data with overlay window (fire-and-forget)
|
||||
/// Does NOT retry - data loss is expected and handled by staleness detection
|
||||
Future<void> shareData(Map<String, dynamic> data) async {
|
||||
try {
|
||||
await FlutterOverlayWindow.shareData(data);
|
||||
} catch (e) {
|
||||
Logger.warn('Failed to share data with overlay: $e', 'OverlayService');
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if overlay window is currently active
|
||||
/// Returns false on error
|
||||
Future<bool> isActive() async {
|
||||
try {
|
||||
return await FlutterOverlayWindow.isActive();
|
||||
} catch (e) {
|
||||
Logger.error('Failed to check overlay active status: $e', 'OverlayService');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Stream of messages from overlay window
|
||||
/// Direct delegation to platform channel
|
||||
Stream<dynamic> get overlayListener => FlutterOverlayWindow.overlayListener;
|
||||
|
||||
/// Check if system alert window permission is granted
|
||||
Future<bool> checkPermission() async {
|
||||
return await Permission.systemAlertWindow.isGranted;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../../core/constants/speed_unit.dart';
|
||||
import '../../../data/models/location_data.dart';
|
||||
import '../../../data/repositories/location_repository.dart';
|
||||
import '../../../providers/settings_provider.dart';
|
||||
|
||||
/// ViewModel for the speedometer UI.
|
||||
///
|
||||
@@ -11,71 +11,24 @@ import '../../../data/repositories/location_repository.dart';
|
||||
/// the current display state to the view. No data-layer logic here.
|
||||
class SpeedometerViewModel extends ChangeNotifier {
|
||||
final LocationRepository _repository;
|
||||
final SharedPreferences _prefs;
|
||||
SettingsProvider? _settings;
|
||||
|
||||
SpeedUnit _displayUnit = SpeedUnit.kmh;
|
||||
LocationData _displayData = LocationData(
|
||||
speed: 0.0,
|
||||
heading: -1.0,
|
||||
displaySpeed: '--',
|
||||
displayHeading: 'N/A',
|
||||
isSpeedValid: false,
|
||||
isHeadingValid: false,
|
||||
timestamp: DateTime.now(),
|
||||
);
|
||||
SpeedUnit get displayUnit => _settings?.currentUnit ?? SpeedUnit.kmh;
|
||||
LocationData get displayData => _repository.currentData;
|
||||
String get displaySpeedText => _repository.currentData.displaySpeed;
|
||||
String get displayHeading => _repository.currentData.displayHeading;
|
||||
bool get isSpeedValid => _repository.currentData.isSpeedValid;
|
||||
bool get isHeadingValid => _repository.currentData.isHeadingValid;
|
||||
|
||||
LocationData get displayData => _displayData;
|
||||
SpeedUnit get displayUnit => _displayUnit;
|
||||
SpeedometerViewModel(this._repository);
|
||||
|
||||
SpeedometerViewModel({
|
||||
required LocationRepository repository,
|
||||
required SharedPreferences prefs,
|
||||
}) : _repository = repository,
|
||||
_prefs = prefs;
|
||||
|
||||
void init() {
|
||||
_repository.locationStream.listen(_onLocationData);
|
||||
_loadSettings();
|
||||
}
|
||||
|
||||
void _onLocationData(LocationData data) {
|
||||
_displayData = data;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _loadSettings() async {
|
||||
_displayUnit = _parseSpeedUnit(_prefs.getString('speed_unit') ?? 'km/h');
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
static SpeedUnit _parseSpeedUnit(String unit) {
|
||||
switch (unit) {
|
||||
case 'mph':
|
||||
return SpeedUnit.mph;
|
||||
case 'kts':
|
||||
return SpeedUnit.knots;
|
||||
default:
|
||||
return SpeedUnit.kmh;
|
||||
}
|
||||
/// Call when the ViewModel is inserted into the provider tree so it
|
||||
/// can listen to SettingsProvider and repository state.
|
||||
void attach(BuildContext context) {
|
||||
_settings = context.read<SettingsProvider>();
|
||||
}
|
||||
|
||||
double get displaySpeed {
|
||||
return _displayUnit.convert(_displayData.speed);
|
||||
}
|
||||
|
||||
String get displaySpeedText {
|
||||
return _displayData.displaySpeed;
|
||||
}
|
||||
|
||||
String get displayHeading {
|
||||
return _displayData.displayHeading;
|
||||
}
|
||||
|
||||
bool get isSpeedValid {
|
||||
return _displayData.isSpeedValid;
|
||||
}
|
||||
|
||||
bool get isHeadingValid {
|
||||
return _displayData.isHeadingValid;
|
||||
return displayUnit.convert(_repository.currentData.speed);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user