20 Commits

Author SHA1 Message Date
irene 411166ef5d feat: bump version to 2.3.0+2
Release 2.3.0 with 4Hz GPS update interval, CI workflow, and lint fixes
2026-08-27 20:53:41 +09:00
irene b2f0015556 Merge branch 'fix/analysis-options' into master
CI / Analyze & Build (push) Successful in 3m5s
2026-08-27 13:41:24 +09:00
irene f9a33809a2 fix: disable sort_pub_dependencies lint rule 2026-08-27 13:41:06 +09:00
irene abd6d779ea fix: sort pubspec.yaml dependencies alphabetically 2026-08-27 13:37:56 +09:00
irene 92815c4682 fix: address remaining lint warnings 2026-08-27 13:36:57 +09:00
irene 8a3487f65e fix: suppress constant_identifier_names lint in analysis_options.yaml
SCREAMING_SNAKE_CASE is project convention for GPS constants.
2026-08-27 13:35:16 +09:00
irene 64b80eecc9 Merge branch 'ci/gps-speedo-workflow' into master
CI / Analyze & Build (push) Failing after 11s
2026-08-27 13:17:43 +09:00
irene 43f1e4ecb2 Merge branch 'feat/gps-update-interval' into master 2026-08-27 13:17:43 +09:00
irene 449e345e3e ci: add CI workflow (analyze + release APK)
- Modelled on iperf3-gui: analyze job on flutter-android runner.
- flutter analyze + flutter build apk --release.
- Uploads release APK as artifact.
2026-08-27 13:17:43 +09:00
irene c2bcd685cf feat: add 4Hz GPS update interval
- Switch LocationSettings to AndroidSettings with intervalDuration: 250ms.
- 250ms = 4Hz; higher rate is only achievable when GNSS is available (not indoors).
2026-08-27 13:17:15 +09:00
irene 54cec166b4 fix: add SettingsProvider to SpeedometerViewModel and fix ProviderNotFoundException
- SpeedometerViewModel now requires both LocationRepository and SettingsProvider
- SpeedometerView uses SettingsProvider.currentTheme instead of non-existent Theme provider
- main.dart: SettingsProvider created before SpeedometerViewModel in provider hierarchy
- Restores proper DI wiring after layered architecture refactor
2026-08-23 23:26:03 +09:00
mikkeli fff81719b1 Merge pull request 'refactor: layered architecture (core/data/ui separation)' (#1) from refactor/flutter-architecture into master
Reviewed-on: https://git.novoyuuparosk.org/android-stuff/gps-speedo/pulls/1
2026-08-23 11:25:54 +00:00
irene 248053270b 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)
2026-08-23 17:32:29 +09:00
irene 6bba05efc8 refactor(data): move OverlayService to data/services/
- OverlayService was the only overlay-related file that should stay
- It is a stateless platform wrapper — fits in data/services/
- Will be used by future OverlayProvider when overlay is reimplemented
2026-08-23 17:27:42 +09:00
irene 1cc111bb76 feat(overlay): create stub ViewModel and View for deferred overlay feature
- Stub OverlayViewModel reserves abstraction point
- Stub OverlayView shows 'Overlay deferred' placeholder
- Original OverlayProvider (274 lines) was too tangled:
  mixed IPC, GPS forwarding, timers, foreground mgmt
- Will be reimplemented properly when overlay issues are resolved
2026-08-23 17:21:45 +09:00
irene 3c96571325 refactor(ui): create speedometer UI layer with ViewModel and shared widgets
- Create SpeedometerViewModel in ui/features/speedometer/
- Create thin SpeedometerView in ui/features/speedometer/
- Extract shared SpeedDisplay widget in ui/shared/
- Extract _CompassWidget from old speedometer screen
- Remove all business logic from UI layer
2026-08-23 17:21:09 +09:00
irene 44480eb969 refactor(data): create layered architecture with core/data/ui packages
- Move constants from lib/config/ to lib/core/constants/
- Move color themes to lib/core/theme/
- Create LocationData domain model in data/models/
- Create lightweight GpsService in data/services/
- Create LocationRepository in data/repositories/ consolidating
  GpsDataManager business logic (GPS stream, staleness, precision)
- Create shared SpeedDisplay widget in ui/shared/
- Defer overlay feature — stubs reserved in ui/features/overlay/
2026-08-23 17:16:43 +09:00
mikkeli 7a58483ba2 build(android): move to Flutter 3.47 template toolchain (Gradle 9.3.1 / AGP 9.1.0 / Kotlin 2.4.0)
The project pinned Gradle 8.12 / AGP 8.9.1 / Kotlin 2.1.0, all three below the
hard error floors that Flutter 3.47.1 enforces in DependencyVersionChecker.kt
(8.14.0 / 8.11.1 / 2.2.20). Nothing here had rotted; the SDK moved.

Targets Flutter's own template defaults rather than the lowest combo that
clears the floors, because Flutter promotes its warn floor to the error floor
in the very next release -- clearing only the hard floor buys one release of
runway. `flutter-android-preflight` on the host re-derives all of this from
the installed SDK.

Also:
- kotlinOptions{} -> kotlin{compilerOptions{}}: the old DSL is a script
  COMPILATION error under Kotlin >= 2.3, not a warning.
- Java/jvmTarget 11 -> 17, matching what the AndroidX artifacts now require.
- Drop android.enableJetifier (obsolete, removed outright in AGP 9); the
  Flutter migrator adds builtInKotlin/newDsl=false in its place. Keep them:
  builtInKotlin=true makes AGP 9.1.0 supply Kotlin 2.2.10, which is BELOW
  Flutter's own 2.2.20 floor, so Flutter rejects its own recommendation.
- Force plugin subprojects to compileSdk 36 / minSdk 24. bg_launcher 0.1.0 is
  abandoned at compileSdk 31 and the AndroidX artifacts it resolves to refuse
  it. The override must be registered before evaluationDependsOn(":app"),
  which evaluates subprojects eagerly. Dropping bg_launcher is the real fix.

Verified: flutter build apk --debug and --release, as both mikkeli and hermes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016dKAqFo9Uj3mPZWvDzj3G8
2026-08-23 11:56:45 +09:00
irene a9844e9665 docs: add MIT license (Mikkeli Matlock, 2026) 2026-08-23 02:26:23 +09:00
mikkeli c889defa2e chore/fix: added DIN1451 font asset and removed gitignore item - reason: moving to self-hosted gitea so copyright is less of a concern here - long term, need to find proper licensed fonts or reactivate gitignore 2026-08-22 21:28:00 +09:00
34 changed files with 993 additions and 1798 deletions
+41
View File
@@ -0,0 +1,41 @@
name: CI
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
workflow_dispatch:
# Target the self-hosted Flutter/Android runner at the android-stuff org.
# See tool/setup_ci_runner.sh for how that runner is provisioned.
#
# The runner is host-mode so the same JDK 17 + Android SDK + Flutter
# toolchain is used as the verified release — nothing installs per-run.
jobs:
analyze:
name: Analyze & Build
runs-on: flutter-android
steps:
- uses: actions/checkout@v4
- name: Toolchain
run: |
flutter --version
echo "ANDROID_HOME=${ANDROID_HOME:?not set on this runner}"
- name: Get dependencies
run: flutter pub get
- name: Analyze
run: flutter analyze
- name: Build release APK
run: flutter build apk --release
- uses: actions/upload-artifact@v4
with:
name: app-release-apk
path: build/app/outputs/flutter-apk/app-release.apk
if-no-files-found: error
+3 -2
View File
@@ -56,5 +56,6 @@ TODO.md
.tools/
# Font(s) provided for embedding into the app
# DO NOT REMOVE FROM GITIGNORE
fonts/
# remote tracking moved to local gitea so copyright is less of a concern
# reconsider when mirroring to github or other public git hosting sites
# fonts/
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Mikkeli Matlock
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+15
View File
@@ -3,3 +3,18 @@ include: package:flutter_lints/flutter.yaml
analyzer:
exclude:
- backup_v1.1/**
- build/**
- android/**
- ios/**
- web/**
- windows/**
- macos/**
- linux/**
linter:
rules:
prefer_single_quotes: true
# SCREAMING_SNAKE_CASE for constants is project convention
constant_identifier_names: false
# sort_pub_dependencies doesn't play well with flutter SDK dep first
sort_pub_dependencies: false
+10 -6
View File
@@ -1,3 +1,5 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
id("com.android.application")
id("kotlin-android")
@@ -11,12 +13,8 @@ android {
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_11.toString()
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
defaultConfig {
@@ -39,6 +37,12 @@ android {
}
}
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
}
}
flutter {
source = "../.."
}
+29
View File
@@ -15,6 +15,35 @@ subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
// Force plugin subprojects onto a modern compileSdk/minSdk.
// Several pinned pub plugins are stuck on ancient values -- bg_launcher 0.1.0 is
// compileSdk 31 / minSdk 16, flutter_overlay_window 0.5.0 is compileSdk 34 -- and the
// AndroidX artifacts they now resolve to refuse to be consumed by them.
// The fix belongs HERE, not in ~/.pub-cache: that cache is shared between users,
// unversioned, and invisible to git, so an edit there silently poisons every build on
// the machine and is wiped by the next `pub get`.
// Reflection is used because AGP's types are not on the root build script classpath.
// NOTE: must be registered BEFORE evaluationDependsOn(":app") below, which evaluates
// subprojects eagerly -- after it, afterEvaluate throws.
subprojects {
afterEvaluate {
val androidExt = extensions.findByName("android") ?: return@afterEvaluate
runCatching {
androidExt.javaClass
.getMethod("setCompileSdkVersion", Int::class.javaPrimitiveType)
.invoke(androidExt, 36)
}
runCatching {
val defaultConfig = androidExt.javaClass
.getMethod("getDefaultConfig")
.invoke(androidExt)
defaultConfig.javaClass
.getMethod("setMinSdkVersion", Int::class.javaPrimitiveType)
.invoke(defaultConfig, 24)
}
}
}
subprojects {
project.evaluationDependsOn(":app")
}
+4 -1
View File
@@ -1,3 +1,6 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
android.enableJetifier=true
# This builtInKotlin flag was added automatically by Flutter migrator
android.builtInKotlin=false
# This newDsl flag was added automatically by Flutter migrator
android.newDsl=false
+1 -1
View File
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip
+2 -2
View File
@@ -19,8 +19,8 @@ pluginManagement {
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.9.1" apply false
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
id("com.android.application") version "9.1.0" apply false
id("org.jetbrains.kotlin.android") version "2.4.0" apply false
}
include(":app")
Binary file not shown.
@@ -1,4 +1,7 @@
class ProcessedGpsData {
/// Domain model representing processed GPS data.
///
/// Immutable — use [copyWith] to create modified copies.
class LocationData {
final double speed; // Raw speed in m/s
final double heading; // Raw heading in degrees
final String displaySpeed; // Formatted speed text (with -- logic)
@@ -7,7 +10,7 @@ class ProcessedGpsData {
final bool isHeadingValid; // True if from recent GPS
final DateTime timestamp; // Timestamp for staleness detection
const ProcessedGpsData({
const LocationData({
required this.speed,
required this.heading,
required this.displaySpeed,
@@ -17,7 +20,7 @@ class ProcessedGpsData {
required this.timestamp,
});
ProcessedGpsData copyWith({
LocationData copyWith({
double? speed,
double? heading,
String? displaySpeed,
@@ -26,7 +29,7 @@ class ProcessedGpsData {
bool? isHeadingValid,
DateTime? timestamp,
}) {
return ProcessedGpsData(
return LocationData(
speed: speed ?? this.speed,
heading: heading ?? this.heading,
displaySpeed: displaySpeed ?? this.displaySpeed,
@@ -0,0 +1,335 @@
import 'dart:async';
import 'dart:developer' as developer;
import 'package:flutter/widgets.dart';
import 'package:geolocator/geolocator.dart';
import '../../core/constants/gps_constants.dart';
import '../../services/logger.dart';
import '../models/location_data.dart';
import '../services/gps_service.dart';
/// Repository that wraps GpsService and applies GPS business logic.
///
/// Single source of truth for location data. Handles:
/// - GPS stream management (start/stop/restart)
/// - Data processing (staleness, precision switching, validation)
/// - Lifecycle management (app foreground/background, wake lock)
class LocationRepository extends ChangeNotifier with WidgetsBindingObserver {
StreamSubscription<Position>? _gpsSubscription;
final StreamController<LocationData> _dataController =
StreamController<LocationData>.broadcast();
// Use late to defer DateTime.now() — const constructors can't call methods
late LocationData _currentData = LocationData(
speed: 0.0,
heading: -1.0,
displaySpeed: '--',
displayHeading: 'N/A',
isSpeedValid: false,
isHeadingValid: false,
timestamp: DateTime.now(),
);
Timer? _staleDataTimer;
Timer? _backgroundGracePeriodTimer;
bool _isDisposed = false;
// Lifecycle and precision state
LocationAccuracy _currentAccuracy = LocationAccuracy.high;
bool _hasFirstFix = false;
DateTime? _firstFixTime;
bool _isAppInForeground = true;
bool _isGpsActive = false;
// Public stream for UI components to subscribe to
Stream<LocationData> get locationStream => _dataController.stream;
// Get current data synchronously
LocationData get currentData => _currentData;
bool get isInitialized => _isInitialized;
bool get isGpsActive => _isGpsActive;
bool _isInitialized = false;
/// Initialize GPS: check permissions, get first fix, start stream.
Future<void> initialize() async {
if (_isInitialized) {
Logger.warn('Already initialized, skipping...', 'LocationRepository');
return;
}
Logger.info('Starting location repository initialization...', 'LocationRepository');
WidgetsBinding.instance.addObserver(this);
// Check GPS permissions and services first
final serviceEnabled = await GpsService.isLocationServiceEnabled();
Logger.debug('Location service enabled: $serviceEnabled', 'LocationRepository');
if (!serviceEnabled) {
Logger.error('GPS service disabled', 'LocationRepository');
_updateData(_currentData.copyWith(
displaySpeed: 'GPS OFF',
displayHeading: 'GPS OFF',
));
_isInitialized = true;
return;
}
final permissionGranted = await GpsService.requestPermissions();
Logger.debug('GPS permission granted: $permissionGranted', 'LocationRepository');
if (!permissionGranted) {
Logger.error('GPS permission denied', 'LocationRepository');
_updateData(_currentData.copyWith(
displaySpeed: 'NO PERM',
displayHeading: 'NO PERM',
));
_isInitialized = true;
return;
}
try {
// Get initial position with long timeout for cold start
final initialPosition = await GpsService.getCurrentPosition(
accuracy: LocationAccuracy.bestForNavigation,
timeLimit: GpsConfig.INITIAL_FIX_TIMEOUT,
);
if (initialPosition == null) {
Logger.error('Initial GPS fix failed — null position', 'LocationRepository');
_updateData(_currentData.copyWith(
displaySpeed: 'GPS ERR',
displayHeading: 'GPS ERR',
));
_isInitialized = true;
return;
}
Logger.info('Initial GPS fix obtained', 'LocationRepository');
_hasFirstFix = true;
_firstFixTime = DateTime.now();
_onPositionUpdate(initialPosition);
// Start position stream
Logger.debug('Starting GPS stream with accuracy: $_currentAccuracy', 'LocationRepository');
_startPositionStream(_currentAccuracy);
_isGpsActive = true;
await GpsService.setWakeLock(true);
_isInitialized = true;
} catch (e, stackTrace) {
developer.log('GPS subscription creation failed: $e',
error: e, stackTrace: stackTrace, name: 'LocationRepository');
_updateData(_currentData.copyWith(
displaySpeed: 'GPS ERR',
displayHeading: 'GPS ERR',
));
_isInitialized = true;
}
Logger.info('Location repository initialized successfully', 'LocationRepository');
}
void _startPositionStream(LocationAccuracy accuracy) {
_gpsSubscription = GpsService.createPositionStream(accuracy: accuracy).listen(
(Position position) {
Logger.debug('GPS callback triggered', 'LocationRepository');
_onPositionUpdate(position);
},
onError: (error) {
Logger.error('GPS stream error callback: $error', 'LocationRepository');
_onGpsError(error);
},
onDone: () {
Logger.warn('GPS stream done callback - stream ended', 'LocationRepository');
},
cancelOnError: false,
);
}
void _onPositionUpdate(Position position) {
Logger.debug('Raw GPS update: speed=${position.speed.toStringAsFixed(2)} m/s, heading=${position.heading.toStringAsFixed(1)}°', 'LocationRepository');
_scheduleStaleDataTimer();
// Validate speed
final rawSpeed = position.speed;
final bool speedValid = rawSpeed.isFinite && rawSpeed >= 0;
final double speed = speedValid ? rawSpeed : 0.0;
// Validate heading
final rawHeading = position.heading;
final bool headingValid = rawHeading.isFinite &&
rawHeading >= GpsConfig.VALID_HEADING_MIN &&
rawHeading < GpsConfig.VALID_HEADING_MAX;
final double heading = headingValid ? rawHeading : _currentData.heading;
// Format display
final displaySpeed = speed.toStringAsFixed(1);
final displayHeading = headingValid
? GpsService.formatHeading(heading)
: _currentData.displayHeading;
Logger.debug('Processed: displaySpeed="$displaySpeed", displayHeading="$displayHeading"', 'LocationRepository');
final locationData = LocationData(
speed: speed,
heading: heading,
displaySpeed: displaySpeed,
displayHeading: displayHeading,
isSpeedValid: speedValid,
isHeadingValid: headingValid,
timestamp: DateTime.now(),
);
_updateData(locationData);
// Check precision switching after first-fix gate
if (_hasFirstFix && _firstFixTime != null) {
final timeSinceFirstFix = DateTime.now().difference(_firstFixTime!);
if (timeSinceFirstFix >= GpsConfig.PRECISION_SWITCH_FIRST_FIX_DELAY) {
_checkPrecisionSwitch(position.speed);
}
}
}
void _onGpsError(dynamic error) {
if (_isDisposed) return;
final errorString = error.toString().toLowerCase();
if (!errorString.contains('timeout') && !errorString.contains('temporarily')) {
_updateData(_currentData.copyWith(
displaySpeed: 'GPS ERR',
displayHeading: 'GPS ERR',
isSpeedValid: false,
isHeadingValid: false,
));
}
}
void _scheduleStaleDataTimer() {
_staleDataTimer?.cancel();
_staleDataTimer = Timer(GpsConfig.STALE_DATA_THRESHOLD, _handleStaleDataTimeout);
}
void _handleStaleDataTimeout() {
if (_isDisposed) return;
final displayText = _currentData.displaySpeed;
if (displayText == '--' || displayText.startsWith('GPS') || displayText == 'NO PERM') {
return;
}
final headingStillValid = _currentData.isHeadingValid &&
_currentData.heading >= GpsConfig.VALID_HEADING_MIN &&
_currentData.heading < GpsConfig.VALID_HEADING_MAX;
final displayHeading = headingStillValid ? _currentData.displayHeading : '--';
Logger.warn('No GPS updates within ${GpsConfig.STALE_DATA_THRESHOLD.inSeconds}s - marking data as stale', 'LocationRepository');
final staleData = _currentData.copyWith(
displaySpeed: '--',
displayHeading: displayHeading,
isSpeedValid: false,
isHeadingValid: headingStillValid,
);
_staleDataTimer = null;
_updateData(staleData);
}
void _updateData(LocationData newData) {
if (_isDisposed) return;
_currentData = newData;
_dataController.add(newData);
notifyListeners();
Logger.debug('Broadcasting data: ${newData.displaySpeed} ${newData.displayHeading}', 'LocationRepository');
}
/// Check if GPS precision should be switched based on speed.
void _checkPrecisionSwitch(double speedMps) {
if (_currentAccuracy == LocationAccuracy.high) {
if (speedMps < GpsConfig.PRECISION_DOWN_THRESHOLD_MPS) {
_switchPrecision(LocationAccuracy.medium);
}
} else if (_currentAccuracy == LocationAccuracy.medium) {
if (speedMps > GpsConfig.PRECISION_UP_THRESHOLD_MPS) {
_switchPrecision(LocationAccuracy.high);
}
}
}
/// Switch GPS precision and restart stream with new accuracy.
void _switchPrecision(LocationAccuracy newAccuracy) {
if (_currentAccuracy == newAccuracy) return;
final speedKmh = _currentData.speed * 3.6;
Logger.debug('Precision switched to $newAccuracy at ${speedKmh.toStringAsFixed(1)} km/h', 'LocationRepository');
_currentAccuracy = newAccuracy;
_gpsSubscription?.cancel();
_startPositionStream(newAccuracy);
}
/// Handle app lifecycle state changes.
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.paused) {
_isAppInForeground = false;
// No overlay logic here — overlay is deferred
_updateWakeLock();
} else if (state == AppLifecycleState.resumed) {
_isAppInForeground = true;
_backgroundGracePeriodTimer?.cancel();
_backgroundGracePeriodTimer = null;
// Restart GPS if it was stopped
if (_gpsSubscription == null && _isInitialized) {
restartGps();
}
_updateWakeLock();
}
}
/// Stop GPS tracking.
void stopGps() {
_gpsSubscription?.cancel();
_gpsSubscription = null;
_isGpsActive = false;
_staleDataTimer?.cancel();
_updateWakeLock();
Logger.info('GPS stopped', 'LocationRepository');
}
/// Restart GPS tracking.
void restartGps() {
Logger.info('Restarting GPS', 'LocationRepository');
_startPositionStream(_currentAccuracy);
_isGpsActive = true;
_updateWakeLock();
}
/// Update wake lock based on GPS state and app visibility.
Future<void> _updateWakeLock() async {
final shouldKeepAwake = _isGpsActive && _isAppInForeground;
await GpsService.setWakeLock(shouldKeepAwake);
Logger.debug(
'Wake lock ${shouldKeepAwake ? "enabled" : "disabled"} (GPS: $_isGpsActive, foreground: $_isAppInForeground)',
'LocationRepository',
);
}
@override
void dispose() {
super.dispose();
_isDisposed = true;
Logger.info('Disposing location repository...', 'LocationRepository');
WidgetsBinding.instance.removeObserver(this);
_backgroundGracePeriodTimer?.cancel();
_backgroundGracePeriodTimer = null;
_gpsSubscription?.cancel();
_gpsSubscription = null;
_staleDataTimer?.cancel();
_staleDataTimer = null;
_dataController.close();
GpsService.setWakeLock(false);
Logger.info('Location repository disposed', 'LocationRepository');
}
}
@@ -1,29 +1,28 @@
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,
}) {
return Geolocator.getPositionStream(
locationSettings: LocationSettings(
locationSettings: AndroidSettings(
accuracy: accuracy,
distanceFilter: 0,
intervalDuration: Duration(milliseconds: 250),
),
);
}
/// 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 +32,22 @@ class GpsService {
return await Geolocator.isLocationServiceEnabled();
}
static Future<Position?> getCurrentPosition({
LocationAccuracy accuracy = LocationAccuracy.bestForNavigation,
Duration timeLimit = const Duration(seconds: 60),
}) async {
try {
return await Geolocator.getCurrentPosition(
locationSettings: LocationSettings(
accuracy: accuracy,
timeLimit: timeLimit,
),
);
} catch (_) {
return null;
}
}
static double getCachedHeading(double heading) {
if (heading >= GpsConfig.VALID_HEADING_MIN && heading <= GpsConfig.VALID_HEADING_MAX) {
_lastValidHeading = heading;
@@ -43,7 +58,6 @@ class GpsService {
static String getCompassDirection(double heading) {
final cachedHeading = getCachedHeading(heading);
const directions = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
final index = ((cachedHeading + 22.5) / 45).floor() % 8;
return directions[index];
@@ -51,6 +65,10 @@ class GpsService {
static String formatHeading(double heading) {
final cachedHeading = getCachedHeading(heading);
return '${cachedHeading.round()} ${getCompassDirection(heading)}'; // don't add that degree symbol, this is cooler
return '${cachedHeading.round()} ${getCompassDirection(heading)}';
}
static Future<void> setWakeLock(bool enable) async {
await WakelockPlus.toggle(enable: enable);
}
}
@@ -1,7 +1,7 @@
import 'package:flutter_overlay_window/flutter_overlay_window.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:retry/retry.dart';
import 'logger.dart';
import '../../services/logger.dart';
/// Service wrapper for FlutterOverlayWindow platform calls with retry logic
/// Encapsulates all overlay operations with error handling and verification
@@ -23,7 +23,7 @@ class OverlayService {
() async {
await FlutterOverlayWindow.showOverlay(
enableDrag: true,
overlayTitle: "Speedometer",
overlayTitle: 'Speedometer',
overlayContent: 'Speedo overlay active',
flag: OverlayFlag.defaultFlag,
visibility: NotificationVisibility.visibilityPublic,
+33 -28
View File
@@ -1,13 +1,19 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'screens/speedometer_screen.dart';
import 'screens/overlay_screen.dart';
import 'services/gps_data_manager.dart';
import 'services/overlay_service.dart';
import 'ui/features/speedometer/speedometer_view.dart';
import 'ui/features/speedometer/speedometer_view_model.dart';
import 'providers/settings_provider.dart';
import 'providers/overlay_provider.dart';
import 'services/logger.dart';
import 'data/repositories/location_repository.dart';
/// Stub ViewModel for the overlay feature.
///
/// [Overlay] has been deferred — see ui/features/overlay/ for details.
class OverlayViewModel extends ChangeNotifier {
bool get isOverlayActive => false;
void toggleOverlay() {}
void closeOverlay() {}
}
void main() async {
WidgetsFlutterBinding.ensureInitialized();
@@ -16,22 +22,30 @@ 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<SettingsProvider>(
create: (context) => SettingsProvider(
context.read<SharedPreferences>(),
),
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<SpeedometerViewModel>(
create: (context) => SpeedometerViewModel(
context.read<LocationRepository>(),
context.read<SettingsProvider>(),
),
),
ChangeNotifierProvider<OverlayViewModel>(
create: (_) => OverlayViewModel(),
),
],
child: const SpeedoApp(),
@@ -39,15 +53,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 +66,7 @@ class SpeedoApp extends StatelessWidget {
brightness: Brightness.dark,
colorSchemeSeed: Colors.green,
),
home: const SpeedometerScreen(),
home: const SpeedometerView(),
);
}
}
-128
View File
@@ -1,128 +0,0 @@
/// Typed message class for overlay IPC communication
/// Replaces unvalidated Map data with type-safe serialization
class OverlayMessage {
final String action;
final String? speedText;
final String? unitText;
final String? headingText;
final double? heading;
final int? unitIndex;
final int? themeIndex;
final double? overlayWidth;
final double? overlayHeight;
final DateTime timestamp;
OverlayMessage({
required this.action,
this.speedText,
this.unitText,
this.headingText,
this.heading,
this.unitIndex,
this.themeIndex,
this.overlayWidth,
this.overlayHeight,
required this.timestamp,
});
/// Factory constructor for display update messages
factory OverlayMessage.updateDisplay({
required String speedText,
required String unitText,
required String headingText,
required double heading,
required int unitIndex,
required int themeIndex,
double? overlayWidth,
double? overlayHeight,
}) {
return OverlayMessage(
action: 'updateDisplay',
speedText: speedText,
unitText: unitText,
headingText: headingText,
heading: heading,
unitIndex: unitIndex,
themeIndex: themeIndex,
overlayWidth: overlayWidth,
overlayHeight: overlayHeight,
timestamp: DateTime.now(),
);
}
/// Factory constructor for long press close signal
factory OverlayMessage.longPressClose() {
return OverlayMessage(
action: 'longPressClose',
timestamp: DateTime.now(),
);
}
/// Factory constructor for overlay closed notification
factory OverlayMessage.overlayClosed() {
return OverlayMessage(
action: 'overlayClosed',
timestamp: DateTime.now(),
);
}
/// Serialize to Map for overlay IPC
Map<String, dynamic> toMap() {
final map = <String, dynamic>{'action': action};
if (speedText != null) map['speedText'] = speedText;
if (unitText != null) map['unitText'] = unitText;
if (headingText != null) map['headingText'] = headingText;
if (heading != null) map['heading'] = heading;
if (unitIndex != null) map['unitIndex'] = unitIndex;
if (themeIndex != null) map['themeIndex'] = themeIndex;
if (overlayWidth != null) map['overlayWidth'] = overlayWidth;
if (overlayHeight != null) map['overlayHeight'] = overlayHeight;
map['timestamp'] = timestamp.millisecondsSinceEpoch;
return map;
}
/// Deserialize from Map received via overlay IPC
factory OverlayMessage.fromMap(Map<dynamic, dynamic> map) {
return OverlayMessage(
action: map['action'] as String? ?? 'unknown',
speedText: map['speedText'] as String?,
unitText: map['unitText'] as String?,
headingText: map['headingText'] as String?,
heading: (map['heading'] as num?)?.toDouble(),
unitIndex: map['unitIndex'] as int?,
themeIndex: map['themeIndex'] as int?,
overlayWidth: (map['overlayWidth'] as num?)?.toDouble(),
overlayHeight: (map['overlayHeight'] as num?)?.toDouble(),
timestamp: DateTime.fromMillisecondsSinceEpoch(
map['timestamp'] as int? ?? DateTime.now().millisecondsSinceEpoch,
),
);
}
/// Create a copy with updated fields
OverlayMessage copyWith({
String? action,
String? speedText,
String? unitText,
String? headingText,
double? heading,
int? unitIndex,
int? themeIndex,
double? overlayWidth,
double? overlayHeight,
DateTime? timestamp,
}) {
return OverlayMessage(
action: action ?? this.action,
speedText: speedText ?? this.speedText,
unitText: unitText ?? this.unitText,
headingText: headingText ?? this.headingText,
heading: heading ?? this.heading,
unitIndex: unitIndex ?? this.unitIndex,
themeIndex: themeIndex ?? this.themeIndex,
overlayWidth: overlayWidth ?? this.overlayWidth,
overlayHeight: overlayHeight ?? this.overlayHeight,
timestamp: timestamp ?? this.timestamp,
);
}
}
-32
View File
@@ -1,32 +0,0 @@
import '../config/color_themes.dart';
/// Theme model that wraps ColorThemes with rotation and state management.
/// Holds all possible color palettes via delegation, exposing only the current set.
class ThemeModel {
final int _currentIndex;
const ThemeModel({int currentIndex = 0}) : _currentIndex = currentIndex;
/// Get the current active color theme
ColorTheme get current => ColorThemes.getTheme(_currentIndex);
/// Get the name of the current theme
String get currentSetName => current.name;
/// Get the current theme index
int get currentIndex => _currentIndex;
/// Rotate to the next theme (circular)
ThemeModel rotate() {
return ThemeModel(
currentIndex: ColorThemes.getNextThemeIndex(_currentIndex),
);
}
/// Create a copy with an updated theme index
ThemeModel copyWith({int? currentIndex}) {
return ThemeModel(
currentIndex: currentIndex ?? _currentIndex,
);
}
}
-274
View File
@@ -1,274 +0,0 @@
import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:bg_launcher/bg_launcher.dart';
import '../config/overlay_constants.dart';
import '../config/timing_constants.dart';
import '../config/speed_unit.dart';
import '../models/overlay_message.dart';
import '../models/processed_gps_data.dart';
import '../services/gps_data_manager.dart';
import '../services/logger.dart';
import '../services/overlay_service.dart';
enum OverlayError { permission, creationFailed, communicationDegraded }
class OverlayProvider extends ChangeNotifier {
final OverlayService _overlayService;
bool _isOverlayActive = false;
bool _isDisposed = false;
bool _tapCloseRequested = false;
StreamSubscription<ProcessedGpsData>? _gpsSubscription;
StreamSubscription<dynamic>? _overlayMessageSubscription;
Timer? _overlayStatusCheckTimer;
Timer? _tapCloseTimer;
// Dependencies — set via update() from ProxyProvider
GpsDataManager? _gpsManager;
SpeedUnit _currentUnit = SpeedUnit.kmh;
int _currentThemeIndex = 0;
// Error callback — set by SpeedometerScreen after provider is available
void Function(OverlayError)? onError;
OverlayProvider(this._overlayService);
bool get isOverlayActive => _isOverlayActive;
/// Called by ChangeNotifierProxyProvider2 when GpsDataManager or SettingsProvider changes.
/// Sets up GPS stream subscription on first call, updates settings references on subsequent calls.
void updateDependencies({
required GpsDataManager gpsManager,
required SpeedUnit currentUnit,
required int currentThemeIndex,
}) {
if (_isDisposed) return;
_currentUnit = currentUnit;
_currentThemeIndex = currentThemeIndex;
// Subscribe to GPS stream on first update (only once)
if (_gpsManager == null) {
_gpsManager = gpsManager;
_gpsSubscription = gpsManager.dataStream.listen((data) {
if (_isOverlayActive && !_isDisposed) {
_pushDataToOverlay(data);
}
});
Logger.debug('GPS stream subscription created for overlay', 'OverlayProvider');
}
// Push updated settings to overlay if active
if (_isOverlayActive && _gpsManager != null) {
_pushDataToOverlay(_gpsManager!.currentData);
}
}
/// Show the floating overlay window
Future<void> showOverlay() async {
if (_isDisposed) return;
try {
// Check permission before attempting to show overlay
final hasPermission = await _overlayService.checkPermission();
if (!hasPermission) {
Logger.warn('Overlay permission denied', 'OverlayProvider');
onError?.call(OverlayError.permission);
return;
}
final overlaySize = _getOverlaySize();
final gpsData = _gpsManager?.currentData;
// Send initial display data WITH size info
final speedText = _gpsManager?.getFormattedSpeed(_currentUnit) ?? '--';
final headingText = gpsData?.displayHeading ?? 'N/A';
final unitText = _currentUnit.label;
await _overlayService.shareData(OverlayMessage.updateDisplay(
speedText: speedText,
unitText: unitText,
headingText: headingText,
heading: gpsData?.heading ?? -1.0,
unitIndex: _currentUnit.index,
themeIndex: _currentThemeIndex,
overlayWidth: overlaySize['width']!.toDouble(),
overlayHeight: overlaySize['height']!.toDouble(),
).toMap());
final success = await _overlayService.showOverlay(
width: overlaySize['width']!,
height: overlaySize['height']!,
);
if (!success) {
Logger.error('Overlay creation failed or verification failed', 'OverlayProvider');
onError?.call(OverlayError.creationFailed);
return;
}
Logger.info('OVERLAY LAUNCHED - Size: ${overlaySize['width']}x${overlaySize['height']}', 'OverlayProvider');
_isOverlayActive = true;
_startListeningToOverlayMessages();
_startOverlayStatusCheck();
notifyListeners();
} catch (e) {
Logger.error('Failed to show overlay: $e', 'OverlayProvider');
onError?.call(OverlayError.creationFailed);
}
}
/// Close the floating overlay window
Future<void> closeOverlay() async {
if (!_isOverlayActive || _isDisposed) return;
try {
await _overlayService.closeOverlay();
Logger.info('OVERLAY CLOSED - Requested from main app', 'OverlayProvider');
_handleOverlayClose();
} catch (e) {
Logger.error('Error closing overlay: $e', 'OverlayProvider');
}
}
/// Toggle overlay state (show if hidden, close if active)
Future<void> toggleOverlay() async {
if (_isOverlayActive) {
await closeOverlay();
} else {
await showOverlay();
}
}
Map<String, int> _getOverlaySize() {
final window = WidgetsBinding.instance.platformDispatcher.views.first;
final physicalSize = window.physicalSize;
final overlayWidth = (physicalSize.width * OverlayConfig.WIDTH_PERCENTAGE).round();
final overlayHeight = (overlayWidth * OverlayConfig.ASPECT_RATIO).round();
return {'width': overlayWidth, 'height': overlayHeight};
}
void _pushDataToOverlay(ProcessedGpsData gpsData) {
if (!_isOverlayActive || _isDisposed) return;
final speedText = _gpsManager?.getFormattedSpeed(_currentUnit) ?? '--';
final unitText = _currentUnit.label;
final headingText = gpsData.displayHeading;
Logger.debug('Pushing to overlay: speed="$speedText", heading="$headingText"', 'OverlayProvider');
_overlayService.shareData(OverlayMessage.updateDisplay(
speedText: speedText,
unitText: unitText,
headingText: headingText,
heading: gpsData.heading,
unitIndex: _currentUnit.index,
themeIndex: _currentThemeIndex,
).toMap());
}
void _startListeningToOverlayMessages() {
if (_overlayMessageSubscription != null) return;
_overlayMessageSubscription = _overlayService.overlayListener.listen(
(data) {
try {
if (data is Map) {
final action = data['action'];
switch (action) {
case 'overlayClosed':
_handleOverlayClose();
break;
case 'longPressClose':
_tapCloseRequested = true;
_tapCloseTimer?.cancel();
_tapCloseTimer = Timer(TimingConfig.TAP_CLOSE_DELAY, () {
_tapCloseRequested = false;
});
break;
default:
Logger.warn('Unknown overlay action: "$action"', 'OverlayProvider');
}
}
} catch (e) {
Logger.error('Error processing overlay message: $e', 'OverlayProvider');
}
},
onError: (error) {
Logger.error('Overlay listener error: $error', 'OverlayProvider');
},
);
}
void _stopListeningToOverlayMessages() {
_overlayMessageSubscription?.cancel();
_overlayMessageSubscription = null;
}
void _startOverlayStatusCheck() {
_overlayStatusCheckTimer?.cancel();
_overlayStatusCheckTimer = Timer.periodic(TimingConfig.OVERLAY_STATUS_CHECK_INTERVAL, (timer) async {
if (_isOverlayActive && !_isDisposed) {
try {
final isActive = await _overlayService.isActive();
if (!isActive) {
timer.cancel();
_handleOverlayClose();
}
} catch (e) {
Logger.error('Overlay status check failed: $e', 'OverlayProvider');
}
} else {
timer.cancel();
}
});
Logger.debug('Started overlay status monitoring', 'OverlayProvider');
}
void _handleOverlayClose({bool bringToForeground = false}) {
if (_isDisposed) return;
final shouldBringToFront = bringToForeground || _tapCloseRequested;
_tapCloseTimer?.cancel();
_overlayStatusCheckTimer?.cancel();
_tapCloseRequested = false;
_isOverlayActive = false;
_stopListeningToOverlayMessages();
notifyListeners();
if (shouldBringToFront) {
_bringAppToFront();
}
}
void _bringAppToFront() {
try {
BgLauncher.bringAppToForeground();
} catch (e) {
Logger.error('BgLauncher failed to bring app to front: $e', 'OverlayProvider');
}
}
/// Push current data to overlay — used by background heartbeat
void pushCurrentData() {
if (_isOverlayActive && _gpsManager != null && !_isDisposed) {
_pushDataToOverlay(_gpsManager!.currentData);
}
}
@override
void dispose() {
_isDisposed = true;
_gpsSubscription?.cancel();
_overlayMessageSubscription?.cancel();
_overlayStatusCheckTimer?.cancel();
_tapCloseTimer?.cancel();
Logger.info('OverlayProvider disposed', 'OverlayProvider');
super.dispose();
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../config/speed_unit.dart';
import '../config/color_themes.dart';
import '../core/constants/speed_unit.dart';
import '../core/theme/color_themes.dart';
import '../services/logger.dart';
class SettingsProvider extends ChangeNotifier {
-326
View File
@@ -1,326 +0,0 @@
import 'package:flutter/material.dart';
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter_overlay_window/flutter_overlay_window.dart';
import '../config/color_themes.dart';
import '../config/overlay_constants.dart';
import '../models/overlay_message.dart';
import '../services/logger.dart';
class OverlaySpeedometer extends StatefulWidget {
const OverlaySpeedometer({super.key});
@override
State<OverlaySpeedometer> createState() => _OverlaySpeedometerState();
}
class _OverlaySpeedometerState extends State<OverlaySpeedometer> {
String _speedText = '--';
String _unitText = 'km/h';
String _headingText = 'N/A';
double _heading = -1.0;
int _currentThemeIndex = 0;
double _systemScreenWidth = 400;
double _overlayWidth = 280;
double _overlayHeight = 140;
DateTime _lastUpdateTime = DateTime.now();
Timer? _stalenessCheckTimer;
@override
void initState() {
super.initState();
Logger.info('CREATED - Floating window initialized (${DateTime.now().millisecondsSinceEpoch})', 'Overlay');
_getSystemScreenSize();
_listenToMainAppMessages();
_startStalenessMonitoring();
}
@override
void dispose() {
_stalenessCheckTimer?.cancel();
Logger.info('DISPOSING - Overlay widget disposed (${DateTime.now().millisecondsSinceEpoch})', 'Overlay');
super.dispose();
}
void _getSystemScreenSize() {
try {
// Get actual system screen resolution, not just app window size
final window = WidgetsBinding.instance.platformDispatcher.views.first;
final physicalSize = window.physicalSize;
final devicePixelRatio = window.devicePixelRatio;
final systemSize = physicalSize / devicePixelRatio;
Logger.debug('Screen detection:', 'Overlay');
Logger.debug(' physicalSize: ${physicalSize.width.round()}x${physicalSize.height.round()}', 'Overlay');
Logger.debug(' devicePixelRatio: $devicePixelRatio', 'Overlay');
Logger.debug(' calculated systemSize: ${systemSize.width.round()}x${systemSize.height.round()}', 'Overlay');
setState(() {
_systemScreenWidth = systemSize.width;
});
Logger.debug('_systemScreenWidth set to: ${_systemScreenWidth.round()}', 'Overlay');
} catch (e) {
Logger.error('Error getting system screen size: $e', 'Overlay');
// Fallback to reasonable default
setState(() {
_systemScreenWidth = OverlayConfig.DEFAULT_SCREEN_WIDTH;
});
}
}
void _listenToMainAppMessages() {
FlutterOverlayWindow.overlayListener.listen((data) {
if (data is Map) {
final message = OverlayMessage.fromMap(data);
if (message.action == 'updateDisplay') {
setState(() {
_speedText = message.speedText ?? '--';
_unitText = message.unitText ?? '';
_headingText = message.headingText ?? 'N/A';
_heading = message.heading ?? -1.0;
_currentThemeIndex = message.themeIndex ?? 0;
_lastUpdateTime = message.timestamp;
// Update overlay dimensions if provided by main app
if (message.overlayWidth != null) {
_overlayWidth = message.overlayWidth!;
}
if (message.overlayHeight != null) {
_overlayHeight = message.overlayHeight!;
}
});
Logger.debug('UPDATE received:', 'Overlay');
Logger.debug(' speedText: "$_speedText"', 'Overlay');
Logger.debug(' headingText: "$_headingText"', 'Overlay');
Logger.debug(' direction: ${_heading.toStringAsFixed(1)}°', 'Overlay');
Logger.debug(' themeIndex: $_currentThemeIndex', 'Overlay');
Logger.debug(' systemScreenWidth: ${_systemScreenWidth.round()}', 'Overlay');
Logger.debug(' overlaySize from main: ${_overlayWidth.round()}x${_overlayHeight.round()}', 'Overlay');
}
}
});
}
void _startStalenessMonitoring() {
_stalenessCheckTimer = Timer.periodic(OverlayConfig.STALENESS_CHECK_INTERVAL, (timer) {
if (!mounted) return;
final age = DateTime.now().difference(_lastUpdateTime);
if (age >= OverlayConfig.STALENESS_DASH_THRESHOLD && _speedText != '--') {
setState(() {
_speedText = '--';
});
}
});
}
@override
Widget build(BuildContext context) {
final buildTimestamp = DateTime.now().millisecondsSinceEpoch;
final currentTheme = ColorThemes.getTheme(_currentThemeIndex);
// Calculate staleness opacity
final age = DateTime.now().difference(_lastUpdateTime);
final isStale = age >= OverlayConfig.STALENESS_DIM_THRESHOLD;
final staleOpacity = isStale ? OverlayConfig.STALENESS_DIM_OPACITY : 1.0;
// Use overlay dimensions provided by main app (not calculated locally)
final fontSize = (_overlayWidth * OverlayConfig.FONT_SIZE_RATIO);
Logger.debug('Building overlay widget ($buildTimestamp)', 'Overlay');
Logger.debug('Window size: ${_overlayWidth.round()}x${_overlayHeight.round()}, fontSize: ${fontSize.round()}', 'Overlay');
return Material(
color: Colors.transparent,
child: SizedBox(
// Use SizedBox to fill the overlay window completely
width: double.infinity,
height: double.infinity,
child: Container(
decoration: BoxDecoration(
color: currentTheme.background.withValues(alpha: OverlayConfig.BACKGROUND_OPACITY),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: currentTheme.speedText.withValues(alpha: OverlayConfig.BORDER_OPACITY),
width: 1
),
),
child: Stack(
children: [
// Main content - landscape layout (1:1 flex as per NOTES.txt)
Row(
children: [
// Speed section - left half
Expanded(
flex: 55,
child: Container(
padding: const EdgeInsets.all(6),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
flex: 3,
child: FittedBox(
fit: BoxFit.contain,
child: Text(
(_speedText),
style: TextStyle(
fontSize: fontSize,
fontWeight: FontWeight.w300,
color: currentTheme.speedText.withValues(alpha: staleOpacity),
fontFamily: 'DIN1451Alt',
),
),
),
),
Flexible(
flex: 1,
child: FittedBox(
fit: BoxFit.contain,
child: Text(
_unitText,
style: TextStyle(
fontSize: fontSize * OverlayConfig.UNIT_FONT_RATIO,
fontWeight: FontWeight.w300,
color: currentTheme.unitText.withValues(alpha: staleOpacity),
fontFamily: 'DIN1451Alt',
),
),
),
),
],
),
),
),
),
// Direction section - right half (same as full-screen behavior)
Expanded(
flex: 45,
child: Container(
padding: const EdgeInsets.all(8),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
flex: 5,
child: Transform.rotate(
angle: (_heading >= 0 && _heading < 360) ? (_heading * math.pi / 180.0) : 0,
child: FittedBox(
fit: BoxFit.contain,
child: Icon(
Icons.navigation,
size: fontSize * OverlayConfig.ICON_SIZE_RATIO,
color: currentTheme.headingText.withValues(alpha: staleOpacity),
),
),
),
),
Flexible(
flex: 2,
child: FittedBox(
fit: BoxFit.contain,
child: Text(
_headingText,
style: TextStyle(
fontSize: fontSize * OverlayConfig.HEADING_FONT_RATIO,
color: currentTheme.headingText.withValues(alpha: staleOpacity),
fontFamily: 'DIN1451Alt',
),
),
),
),
],
),
),
),
],
),
// Transparent full-overlay gesture detection layer
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.translucent, // Ensure gestures are captured
onPanStart: (details) {
Logger.debug('Pan start detected - gestures are working!', 'Overlay');
},
onLongPress: () async {
final timestamp = DateTime.now().millisecondsSinceEpoch;
Logger.info('Long press detected - closing overlay and bringing main app to front ($timestamp)', 'Overlay');
try {
// Skip haptic feedback in overlay - platform services not available
Logger.debug('Skipping haptic feedback (overlay context limitation)', 'Overlay');
// Signal main app to bring to foreground when overlay closes (non-blocking)
Logger.debug('Sending bring-to-front close signal...', 'Overlay');
FlutterOverlayWindow.shareData(OverlayMessage.longPressClose().toMap()).then((_) {
Logger.debug('Long press close signal sent successfully', 'Overlay');
}).catchError((error) {
Logger.warn('Long press close signal failed (expected): $error', 'Overlay');
});
// Small delay to give signal a chance
await Future.delayed(const Duration(milliseconds: 50));
// Close the overlay
Logger.info('Attempting to close overlay...', 'Overlay');
await FlutterOverlayWindow.closeOverlay();
Logger.info('Overlay closed successfully via long press', 'Overlay');
} catch (e, stackTrace) {
Logger.error('Long press close failed: $e', 'Overlay');
Logger.error('Stack trace: $stackTrace', 'Overlay');
// Fallback: try to close anyway
try {
Logger.debug('Attempting fallback close...', 'Overlay');
await FlutterOverlayWindow.closeOverlay();
Logger.info('Fallback close succeeded', 'Overlay');
} catch (fallbackError) {
Logger.error('Fallback close also failed: $fallbackError', 'Overlay');
}
}
},
// Tap to bring main app to front functionality removed per NOTES.txt
// onTap: () async {
// final timestamp = DateTime.now().millisecondsSinceEpoch;
// customDebugPrint('[Overlay] 👆 Tap detected - bringing main app to foreground ($timestamp)');
// try {
// // Try to signal main app to come to foreground (non-blocking)
// customDebugPrint('[Overlay] 📤 Attempting to signal bring-to-front...');
// FlutterOverlayWindow.shareData({
// 'action': 'bringToFront',
// }).catchError((error) {
// customDebugPrint('[Overlay] ❌ Bring-to-front signal failed (expected): $error');
// });
//
// customDebugPrint('[Overlay] ✅ Tap gesture completed (overlay stays open)');
//
// } catch (e, stackTrace) {
// customDebugPrint('[Overlay] ❌ Tap gesture failed: $e');
// customDebugPrint('[Overlay] 📚 Stack trace: $stackTrace');
// }
// },
child: Container(
width: double.infinity,
height: double.infinity,
color: Colors.transparent,
child: Center(
child: Text(
'GESTURE',
style: TextStyle(
color: Colors.transparent,
fontSize: 1,
),
),
),
),
),
),
],
),
),
),
);
}
}
-516
View File
@@ -1,516 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:permission_handler/permission_handler.dart';
import 'dart:async';
import 'dart:math' as math;
import '../config/timing_constants.dart';
import '../config/overlay_constants.dart';
import '../config/color_themes.dart';
import '../services/gps_data_manager.dart';
import '../models/processed_gps_data.dart';
import '../providers/settings_provider.dart';
import '../providers/overlay_provider.dart';
import '../services/logger.dart';
class SpeedometerScreen extends StatefulWidget {
const SpeedometerScreen({super.key});
@override
State<SpeedometerScreen> createState() => _SpeedometerScreenState();
}
class _SpeedometerScreenState extends State<SpeedometerScreen> {
// Local widget state only (not shared app state)
String _errorMessage = '';
Timer? _backgroundHeartbeatTimer;
Timer? _stalenessCheckTimer;
@override
void initState() {
super.initState();
// Delay GPS init and error callback setup to after first frame so context is available
WidgetsBinding.instance.addPostFrameCallback((_) {
_initializeGpsManager();
context.read<OverlayProvider>().onError = _handleOverlayError;
// Wire overlay provider into GPS manager for lifecycle decisions
context.read<GpsDataManager>().setOverlayProvider(context.read<OverlayProvider>());
});
_ensureOverlayPermission();
_startBackgroundHeartbeat();
_stalenessCheckTimer = Timer.periodic(OverlayConfig.STALENESS_CHECK_INTERVAL, (_) {
if (mounted) setState(() {}); // Trigger rebuild to re-evaluate staleness
});
}
@override
void dispose() {
_backgroundHeartbeatTimer?.cancel();
_stalenessCheckTimer?.cancel();
try {
context.read<OverlayProvider>().onError = null;
} catch (e) {
// Provider might already be disposed
}
Logger.info('SpeedometerScreen disposed', 'Main');
super.dispose();
}
Future<void> _initializeGpsManager() async {
Logger.info('Starting GPS manager initialization...', 'Main');
try {
await context.read<GpsDataManager>().initialize();
Logger.info('GPS manager initialized', 'Main');
} catch (e) {
Logger.error('GPS manager initialization failed: $e', 'Main');
if (mounted) {
setState(() => _errorMessage = 'Failed to initialize GPS manager');
}
}
}
void _handleOverlayError(OverlayError error) {
if (!mounted) return;
String message;
SnackBarAction? action;
switch (error) {
case OverlayError.permission:
message = 'Overlay permission required';
action = SnackBarAction(
label: 'Settings',
onPressed: () => openAppSettings(),
);
break;
case OverlayError.creationFailed:
message = 'Failed to create overlay window';
break;
case OverlayError.communicationDegraded:
message = 'Overlay communication degraded';
break;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
action: action,
duration: const Duration(seconds: 4),
),
);
}
Future<void> _ensureOverlayPermission() async {
try {
// Note: Overlay permission request is handled by OverlayProvider,
// but we check here to avoid issues at app startup
} catch (e) {
Logger.error('Error checking overlay permission: $e', 'Main');
}
}
void _startBackgroundHeartbeat() {
_backgroundHeartbeatTimer = Timer.periodic(TimingConfig.HEARTBEAT_INTERVAL, (timer) {
if (!mounted) return;
final overlay = context.read<OverlayProvider>();
if (overlay.isOverlayActive) {
overlay.pushCurrentData();
}
});
}
Widget _buildSpeedDisplay(String speedText, ColorTheme currentTheme, double fontSize, double opacity) {
if (speedText == '--') {
return Text(
speedText,
style: TextStyle(
fontSize: fontSize,
fontWeight: FontWeight.w300,
color: currentTheme.speedText.withValues(alpha: opacity),
fontFamily: 'DIN1451Alt',
),
);
}
// Split speed into integral and decimal parts
final parts = speedText.split('.');
final integralPart = parts[0];
final decimalPart = parts.length > 1 ? '.${parts[1]}' : '.0';
return Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(
integralPart,
style: TextStyle(
fontSize: fontSize,
fontWeight: FontWeight.w300,
color: currentTheme.speedText.withValues(alpha: opacity),
fontFamily: 'DIN1451Alt',
),
),
Text(
decimalPart,
style: TextStyle(
fontSize: fontSize * 0.5,
fontWeight: FontWeight.w300,
color: currentTheme.speedTextSub.withValues(alpha: opacity),
fontFamily: 'DIN1451Alt',
),
),
],
);
}
@override
Widget build(BuildContext context) {
return Selector<SettingsProvider, ColorTheme>(
selector: (_, settings) => settings.currentTheme,
builder: (context, currentTheme, _) {
return Scaffold(
backgroundColor: currentTheme.background,
body: SafeArea(
child: LayoutBuilder(
builder: (context, constraints) {
final isLandscape = constraints.maxWidth > constraints.maxHeight;
if (isLandscape) {
return _buildLandscapeLayout(currentTheme);
} else {
return _buildPortraitLayout(currentTheme);
}
},
),
),
);
},
);
}
Widget _buildPortraitLayout(ColorTheme currentTheme) {
return Column(
children: [
// Speed area - takes most space, precise tap targets on text only
Expanded(
flex: 65,
child: Container(
width: double.infinity,
color: Colors.transparent,
padding: const EdgeInsets.all(4),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (_errorMessage.isNotEmpty)
Flexible(
child: Text(
_errorMessage,
style: TextStyle(
color: currentTheme.speedText,
fontSize: 18,
fontFamily: 'DIN1451Alt',
),
textAlign: TextAlign.center,
),
)
else ...[
Flexible(
flex: 7,
child: Selector<GpsDataManager, ProcessedGpsData>(
selector: (_, gps) => gps.currentData,
builder: (context, data, _) {
final settings = context.read<SettingsProvider>();
final displaySpeed = settings.currentUnit.convert(data.speed);
// Calculate staleness
final age = DateTime.now().difference(data.timestamp);
final isDimmed = age >= OverlayConfig.STALENESS_DIM_THRESHOLD;
final showDashes = age >= OverlayConfig.STALENESS_DASH_THRESHOLD;
final staleOpacity = isDimmed ? OverlayConfig.STALENESS_DIM_OPACITY : 1.0;
final speedText = showDashes ? '--' : displaySpeed.toStringAsFixed(1);
return GestureDetector(
onTap: () {
HapticFeedback.lightImpact();
context.read<SettingsProvider>().cycleTheme();
},
child: FittedBox(
fit: BoxFit.contain,
child: _buildSpeedDisplay(speedText, currentTheme, 200, staleOpacity),
),
);
},
),
),
Flexible(
flex: 1,
child: FittedBox(
fit: BoxFit.contain,
),
),
Flexible(
flex: 2,
child: Selector<SettingsProvider, String>(
selector: (_, settings) => settings.currentUnit.label,
builder: (context, unitLabel, _) {
return GestureDetector(
onTap: () {
HapticFeedback.lightImpact();
context.read<SettingsProvider>().cycleUnit();
},
child: FittedBox(
fit: BoxFit.contain,
child: Text(
unitLabel,
style: TextStyle(
fontSize: 80,
color: currentTheme.unitText,
fontFamily: 'DIN1451Alt',
),
),
),
);
},
),
),
Flexible(
flex: 1,
child: FittedBox(
fit: BoxFit.contain,
),
),
],
],
),
),
),
// Compass area - compact but fully tappable for floating window
Expanded(
flex: 32,
child: GestureDetector(
onTap: () {
HapticFeedback.lightImpact();
context.read<OverlayProvider>().toggleOverlay();
},
onLongPress: () {
context.read<OverlayProvider>().closeOverlay();
},
child: Container(
width: double.infinity,
color: Colors.transparent,
padding: const EdgeInsets.all(2),
child: Selector<GpsDataManager, ProcessedGpsData>(
selector: (_, gps) => gps.currentData,
builder: (context, data, _) {
// Calculate staleness
final age = DateTime.now().difference(data.timestamp);
final isDimmed = age >= OverlayConfig.STALENESS_DIM_THRESHOLD;
final staleOpacity = isDimmed ? OverlayConfig.STALENESS_DIM_OPACITY : 1.0;
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
flex: 3,
child: Transform.rotate(
angle: (data.heading >= 0 && data.heading < 360) ? (data.heading * math.pi / 180.0) : 0,
child: FittedBox(
fit: BoxFit.contain,
child: Icon(
Icons.navigation,
size: 80,
color: currentTheme.headingText.withValues(alpha: staleOpacity),
),
),
),
),
Flexible(
flex: 1,
child: FittedBox(
fit: BoxFit.contain,
child: Text(
data.displayHeading,
style: TextStyle(
fontSize: 50,
color: currentTheme.headingText.withValues(alpha: staleOpacity),
fontFamily: 'DIN1451Alt',
),
),
),
),
],
);
},
),
),
),
),
],
);
}
Widget _buildLandscapeLayout(ColorTheme currentTheme) {
return Row(
children: [
// Speed area - takes majority of space, precise tap targets on text only
Expanded(
flex: 60,
child: Container(
height: double.infinity,
color: Colors.transparent,
padding: const EdgeInsets.all(3),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (_errorMessage.isNotEmpty)
Flexible(
child: Text(
_errorMessage,
style: TextStyle(
color: currentTheme.speedText,
fontSize: 16,
fontFamily: 'DIN1451Alt',
),
textAlign: TextAlign.center,
),
)
else ...[
Flexible(
flex: 1,
child: FittedBox(),
),
Flexible(
flex: 20,
child: Selector<GpsDataManager, ProcessedGpsData>(
selector: (_, gps) => gps.currentData,
builder: (context, data, _) {
final settings = context.read<SettingsProvider>();
final displaySpeed = settings.currentUnit.convert(data.speed);
// Calculate staleness
final age = DateTime.now().difference(data.timestamp);
final isDimmed = age >= OverlayConfig.STALENESS_DIM_THRESHOLD;
final showDashes = age >= OverlayConfig.STALENESS_DASH_THRESHOLD;
final staleOpacity = isDimmed ? OverlayConfig.STALENESS_DIM_OPACITY : 1.0;
final speedText = showDashes ? '--' : displaySpeed.toStringAsFixed(1);
return GestureDetector(
onTap: () {
HapticFeedback.lightImpact();
context.read<SettingsProvider>().cycleTheme();
},
child: FittedBox(
fit: BoxFit.contain,
child: _buildSpeedDisplay(speedText, currentTheme, 160, staleOpacity),
),
);
},
),
),
Flexible(
flex: 7,
child: Selector<SettingsProvider, String>(
selector: (_, settings) => settings.currentUnit.label,
builder: (context, unitLabel, _) {
return GestureDetector(
onTap: () {
HapticFeedback.lightImpact();
context.read<SettingsProvider>().cycleUnit();
},
child: FittedBox(
fit: BoxFit.contain,
child: Text(
unitLabel,
style: TextStyle(
fontSize: 50,
color: currentTheme.unitText,
fontFamily: 'DIN1451Alt',
),
),
),
);
},
),
),
Flexible(
flex: 5,
child: FittedBox(),
),
],
],
),
),
),
// Compass area - smaller but fully tappable for floating window
Expanded(
flex: 35,
child: GestureDetector(
onTap: () {
HapticFeedback.lightImpact();
context.read<OverlayProvider>().toggleOverlay();
},
onLongPress: () {
context.read<OverlayProvider>().closeOverlay();
},
child: Container(
height: double.infinity,
color: Colors.transparent,
padding: const EdgeInsets.all(2),
child: Selector<GpsDataManager, ProcessedGpsData>(
selector: (_, gps) => gps.currentData,
builder: (context, data, _) {
// Calculate staleness
final age = DateTime.now().difference(data.timestamp);
final isDimmed = age >= OverlayConfig.STALENESS_DIM_THRESHOLD;
final staleOpacity = isDimmed ? OverlayConfig.STALENESS_DIM_OPACITY : 1.0;
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
flex: 3,
child: Transform.rotate(
angle: (data.heading >= 0 && data.heading < 360) ? (data.heading * math.pi / 180.0) : 0,
child: FittedBox(
fit: BoxFit.contain,
child: Icon(
Icons.navigation,
size: 60,
color: currentTheme.headingText.withValues(alpha: staleOpacity),
),
),
),
),
Flexible(
flex: 1,
child: FittedBox(
fit: BoxFit.contain,
child: Text(
data.displayHeading,
style: TextStyle(
fontSize: 30,
color: currentTheme.headingText.withValues(alpha: staleOpacity),
fontFamily: 'DIN1451Alt',
),
),
),
),
],
);
},
),
),
),
),
Expanded(
flex: 1,
child: FittedBox(
fit: BoxFit.contain,
),
),
],
);
}
}
-422
View File
@@ -1,422 +0,0 @@
import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:geolocator/geolocator.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import '../gps_service.dart';
import '../config/speed_unit.dart';
import '../config/gps_constants.dart';
import '../config/timing_constants.dart';
import '../models/processed_gps_data.dart';
import '../providers/overlay_provider.dart';
import 'logger.dart';
class GpsDataManager extends ChangeNotifier with WidgetsBindingObserver {
GpsDataManager();
StreamSubscription<Position>? _gpsSubscription;
final StreamController<ProcessedGpsData> _dataController =
StreamController<ProcessedGpsData>.broadcast();
ProcessedGpsData _currentData = ProcessedGpsData(
speed: 0.0,
heading: -1.0,
displaySpeed: '--',
displayHeading: 'N/A',
isSpeedValid: false,
isHeadingValid: false,
timestamp: DateTime.now(),
);
Timer? _staleDataTimer;
Timer? _backgroundGracePeriodTimer;
bool _isDisposed = false;
// Overlay provider reference for background decision-making
OverlayProvider? _overlayProvider;
// Lifecycle and precision state
LocationAccuracy _currentAccuracy = LocationAccuracy.high;
bool _hasFirstFix = false;
DateTime? _firstFixTime;
bool _isAppInForeground = true;
bool _isGpsActive = false;
// Public stream for UI components to subscribe to
Stream<ProcessedGpsData> get dataStream => _dataController.stream;
// Get current data synchronously (for immediate access)
ProcessedGpsData get currentData => _currentData;
bool _isInitialized = false;
/// Set overlay provider reference for background GPS decision-making
void setOverlayProvider(OverlayProvider provider) {
_overlayProvider = provider;
}
Future<void> initialize() async {
if (_isInitialized) {
Logger.warn('Already initialized, skipping...', 'GpsDataManager');
return;
}
Logger.info('Starting GPS manager initialization...', 'GpsDataManager');
// Register lifecycle observer
WidgetsBinding.instance.addObserver(this);
// Check GPS permissions and services first
final serviceEnabled = await GpsService.isLocationServiceEnabled();
Logger.debug('Location service enabled: $serviceEnabled', 'GpsDataManager');
if (!serviceEnabled) {
Logger.error('GPS service disabled', 'GpsDataManager');
_updateData(_currentData.copyWith(
displaySpeed: 'GPS OFF',
displayHeading: 'GPS OFF'
));
_isInitialized = true; // Mark as initialized even if failed
return;
}
final permissionGranted = await GpsService.requestPermissions();
Logger.debug('GPS permission granted: $permissionGranted', 'GpsDataManager');
if (!permissionGranted) {
Logger.error('GPS permission denied', 'GpsDataManager');
_updateData(_currentData.copyWith(
displaySpeed: 'NO PERM',
displayHeading: 'NO PERM'
));
_isInitialized = true; // Mark as initialized even if failed
return;
}
// Create GPS stream subscription
Logger.debug('Creating GPS stream subscription...', 'GpsDataManager');
try {
Logger.debug('Getting initial GPS position with long timeout...', 'GpsDataManager');
// First, get initial position with long timeout for cold start
final initialPosition = await Geolocator.getCurrentPosition(
locationSettings: LocationSettings(
accuracy: LocationAccuracy.bestForNavigation,
timeLimit: GpsConfig.INITIAL_FIX_TIMEOUT,
),
);
Logger.info('Initial GPS fix obtained', 'GpsDataManager');
_hasFirstFix = true;
_firstFixTime = DateTime.now();
_onPositionUpdate(initialPosition);
// Then start position stream with current accuracy
Logger.debug('Starting GPS stream with accuracy: $_currentAccuracy', 'GpsDataManager');
_gpsSubscription = GpsService.createPositionStream(accuracy: _currentAccuracy).listen(
(Position position) {
Logger.debug('GPS callback triggered', 'GpsDataManager');
_onPositionUpdate(position);
},
onError: (error) {
Logger.error('GPS stream error callback: $error', 'GpsDataManager');
_onGpsError(error);
},
onDone: () {
Logger.warn('GPS stream done callback - stream ended', 'GpsDataManager');
},
cancelOnError: false,
);
Logger.info('GPS stream subscription created', 'GpsDataManager');
Logger.debug('Subscription details: ${_gpsSubscription.runtimeType}', 'GpsDataManager');
_isGpsActive = true;
_updateWakeLock();
_isInitialized = true;
} catch (e, stackTrace) {
Logger.error('GPS subscription creation failed: $e', 'GpsDataManager');
Logger.error('Stack trace: $stackTrace', 'GpsDataManager');
_updateData(_currentData.copyWith(
displaySpeed: 'GPS ERR',
displayHeading: 'GPS ERR'
));
_isInitialized = true; // Mark as initialized even if failed
return;
}
Logger.info('GPS manager initialized successfully', 'GpsDataManager');
}
void _onPositionUpdate(Position position) {
Logger.debug('Raw GPS update: speed=${position.speed.toStringAsFixed(2)} m/s, heading=${position.heading.toStringAsFixed(1)}°', 'GpsDataManager');
_scheduleStaleDataTimer();
final rawSpeed = position.speed;
final bool speedValid = rawSpeed.isFinite && rawSpeed >= 0;
final double speed = speedValid ? rawSpeed : 0.0;
final rawHeading = position.heading;
final bool headingValid = rawHeading.isFinite && rawHeading >= GpsConfig.VALID_HEADING_MIN && rawHeading < GpsConfig.VALID_HEADING_MAX;
final double heading = headingValid ? rawHeading : _currentData.heading;
// Basic display logic - TEMPORARILY show all speeds for indoor testing
// TODO: Re-enable low-speed logic later: (speed < GpsConfig.LOW_SPEED_THRESHOLD && (heading < GpsConfig.VALID_HEADING_MIN || heading >= GpsConfig.VALID_HEADING_MAX)) ? '--' : ...
final displaySpeed = speed.toStringAsFixed(1);
final displayHeading = headingValid
? GpsService.formatHeading(heading)
: _currentData.displayHeading;
Logger.debug('Processed: displaySpeed="$displaySpeed", displayHeading="$displayHeading"', 'GpsDataManager');
final processedData = ProcessedGpsData(
speed: speed,
heading: heading,
displaySpeed: displaySpeed,
displayHeading: displayHeading,
isSpeedValid: speedValid,
isHeadingValid: headingValid,
timestamp: DateTime.now(),
);
_updateData(processedData);
// Check precision switching after first-fix gate
if (_hasFirstFix && _firstFixTime != null) {
final timeSinceFirstFix = DateTime.now().difference(_firstFixTime!);
if (timeSinceFirstFix >= GpsConfig.PRECISION_SWITCH_FIRST_FIX_DELAY) {
_checkPrecisionSwitch(position.speed);
}
}
}
void _onGpsError(dynamic error) {
if (_isDisposed) return;
// Handle GPS errors gracefully
final errorString = error.toString().toLowerCase();
if (!errorString.contains('timeout') && !errorString.contains('temporarily')) {
_updateData(_currentData.copyWith(
displaySpeed: 'GPS ERR',
displayHeading: 'GPS ERR',
isSpeedValid: false,
isHeadingValid: false,
));
}
}
void _scheduleStaleDataTimer() {
_staleDataTimer?.cancel();
_staleDataTimer = Timer(GpsConfig.STALE_DATA_THRESHOLD, _handleStaleDataTimeout);
}
void _handleStaleDataTimeout() {
if (_isDisposed) return;
// Avoid overriding explicit GPS error states or repeated stale notifications
final displayText = _currentData.displaySpeed;
if (displayText == '--' || displayText.startsWith('GPS') || displayText == 'NO PERM') {
return;
}
final headingStillValid = _currentData.isHeadingValid && _currentData.heading >= GpsConfig.VALID_HEADING_MIN && _currentData.heading < GpsConfig.VALID_HEADING_MAX;
final displayHeading = headingStillValid ? _currentData.displayHeading : '--';
Logger.warn('No GPS updates within ${GpsConfig.STALE_DATA_THRESHOLD.inSeconds}s - marking data as stale', 'GpsDataManager');
final staleData = _currentData.copyWith(
displaySpeed: '--',
displayHeading: displayHeading,
isSpeedValid: false,
isHeadingValid: headingStillValid,
);
_staleDataTimer = null;
_updateData(staleData);
}
void _updateData(ProcessedGpsData newData) {
if (_isDisposed) return;
Logger.debug('Broadcasting data: ${newData.displaySpeed} ${newData.displayHeading}', 'GpsDataManager');
_currentData = newData;
_dataController.add(newData);
notifyListeners();
Logger.debug('Data broadcast complete', 'GpsDataManager');
}
// Format speed for specific unit (used by UI)
String getFormattedSpeed(SpeedUnit unit) {
if (_currentData.displaySpeed == '--') return '--';
final convertedSpeed = unit.convert(_currentData.speed);
return convertedSpeed < 1.0 ? '--' : convertedSpeed.toStringAsFixed(1);
}
/// Check if GPS precision should be switched based on speed
void _checkPrecisionSwitch(double speedMps) {
if (_currentAccuracy == LocationAccuracy.high) {
// Switch to MEDIUM if speed drops below down-threshold
if (speedMps < GpsConfig.PRECISION_DOWN_THRESHOLD_MPS) {
_switchPrecision(LocationAccuracy.medium);
}
} else if (_currentAccuracy == LocationAccuracy.medium) {
// Switch to HIGH if speed exceeds up-threshold
if (speedMps > GpsConfig.PRECISION_UP_THRESHOLD_MPS) {
_switchPrecision(LocationAccuracy.high);
}
}
}
/// Switch GPS precision and restart stream with new accuracy
void _switchPrecision(LocationAccuracy newAccuracy) {
if (_currentAccuracy == newAccuracy) return;
final speedKmh = _currentData.speed * 3.6;
Logger.debug('Precision switched to $newAccuracy at ${speedKmh.toStringAsFixed(1)} km/h', 'GpsDataManager');
_currentAccuracy = newAccuracy;
// Cancel existing subscription and create new one with new accuracy
_gpsSubscription?.cancel();
_gpsSubscription = GpsService.createPositionStream(accuracy: newAccuracy).listen(
(Position position) {
Logger.debug('GPS callback triggered', 'GpsDataManager');
_onPositionUpdate(position);
},
onError: (error) {
Logger.error('GPS stream error callback: $error', 'GpsDataManager');
_onGpsError(error);
},
onDone: () {
Logger.warn('GPS stream done callback - stream ended', 'GpsDataManager');
},
cancelOnError: false,
);
}
/// Handle app lifecycle state changes for background GPS management
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.paused) {
// App going to background
_isAppInForeground = false;
final overlayActive = _overlayProvider?.isOverlayActive ?? false;
if (overlayActive) {
Logger.info('App backgrounded with overlay active - maintaining full GPS', 'GpsDataManager');
} else {
Logger.info('App backgrounded without overlay - starting ${TimingConfig.BACKGROUND_GRACE_PERIOD.inSeconds}s grace period', 'GpsDataManager');
_backgroundGracePeriodTimer?.cancel();
_backgroundGracePeriodTimer = Timer(TimingConfig.BACKGROUND_GRACE_PERIOD, _onGracePeriodExpired);
}
_updateWakeLock();
} else if (state == AppLifecycleState.resumed) {
// App returning to foreground
_isAppInForeground = true;
// Cancel grace period if running
_backgroundGracePeriodTimer?.cancel();
_backgroundGracePeriodTimer = null;
// Restart GPS if it was stopped
if (_gpsSubscription == null && _isInitialized) {
_restartGps();
}
_updateWakeLock();
}
}
/// Grace period timer callback - re-check overlay state before stopping GPS
void _onGracePeriodExpired() {
// Race condition protection: overlay may have been activated during grace period
final overlayActive = _overlayProvider?.isOverlayActive ?? false;
if (overlayActive) {
Logger.info('Overlay became active during grace period - keeping GPS', 'GpsDataManager');
_backgroundGracePeriodTimer = null;
return;
}
_stopGps();
_backgroundGracePeriodTimer = null;
}
/// Stop GPS tracking (backgrounded without overlay)
void _stopGps() {
_gpsSubscription?.cancel();
_gpsSubscription = null;
_isGpsActive = false;
_staleDataTimer?.cancel();
_updateWakeLock();
Logger.info('GPS stopped (backgrounded without overlay)', 'GpsDataManager');
}
/// Restart GPS tracking (app foregrounded)
void _restartGps() {
Logger.info('Restarting GPS (app foregrounded)', 'GpsDataManager');
_gpsSubscription = GpsService.createPositionStream(accuracy: _currentAccuracy).listen(
(Position position) {
Logger.debug('GPS callback triggered', 'GpsDataManager');
_onPositionUpdate(position);
},
onError: (error) {
Logger.error('GPS stream error callback: $error', 'GpsDataManager');
_onGpsError(error);
},
onDone: () {
Logger.warn('GPS stream done callback - stream ended', 'GpsDataManager');
},
cancelOnError: false,
);
_isGpsActive = true;
_updateWakeLock();
}
/// Update wake lock based on GPS state and app visibility
void _updateWakeLock() {
final overlayActive = _overlayProvider?.isOverlayActive ?? false;
final shouldKeepAwake = _isGpsActive && (overlayActive || _isAppInForeground);
if (shouldKeepAwake) {
WakelockPlus.enable();
Logger.debug('Wake lock enabled (GPS: $_isGpsActive, overlay: $overlayActive, foreground: $_isAppInForeground)', 'GpsDataManager');
} else {
WakelockPlus.disable();
Logger.debug('Wake lock disabled (GPS: $_isGpsActive, overlay: $overlayActive, foreground: $_isAppInForeground)', 'GpsDataManager');
}
}
@override
void dispose() {
_isDisposed = true;
Logger.info('Disposing GPS manager...', 'GpsDataManager');
// Remove lifecycle observer BEFORE super.dispose()
WidgetsBinding.instance.removeObserver(this);
// Cancel all timers and subscriptions
_backgroundGracePeriodTimer?.cancel();
_backgroundGracePeriodTimer = null;
if (_gpsSubscription != null) {
Logger.debug('Cancelling GPS subscription...', 'GpsDataManager');
_gpsSubscription?.cancel();
_gpsSubscription = null;
}
_staleDataTimer?.cancel();
_staleDataTimer = null;
Logger.debug('Closing data controller...', 'GpsDataManager');
_dataController.close();
// Disable wake lock as safety net
WakelockPlus.disable();
Logger.info('GPS manager disposed', 'GpsDataManager');
super.dispose();
}
}
+17
View File
@@ -0,0 +1,17 @@
import 'package:flutter/material.dart';
/// Stub view for the overlay feature.
///
/// [Overlay] has been deferred due to ongoing issues. This placeholder
/// reserves the UI layer for when the overlay is properly reimplemented
/// with the new architecture.
class OverlayView extends StatelessWidget {
const OverlayView({super.key});
@override
Widget build(BuildContext context) {
return const Center(
child: Text('Overlay deferred'),
);
}
}
@@ -0,0 +1,26 @@
import 'package:flutter/widgets.dart';
/// Stub ViewModel for the overlay feature.
///
/// [Overlay] has been a major problem and is deferred. This stub
/// reserves the abstraction point for future implementation.
///
/// The original [OverlayProvider] mixed overlay IPC, GPS forwarding,
/// timers, and app foreground management — all of which violated
/// separation of concerns.
@Deprecated('Overlay is deferred. This stub reserves the abstraction point.')
class OverlayViewModel extends ChangeNotifier {
bool get isOverlayActive => false;
void init() {
// No-op — overlay is deferred
}
void toggleOverlay() {
// No-op — overlay is deferred
}
void closeOverlay() {
// No-op — overlay is deferred
}
}
@@ -0,0 +1,268 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../../../core/constants/overlay_constants.dart';
import '../../../core/theme/color_themes.dart';
import '../../shared/speed_display.dart';
import '../../../providers/settings_provider.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 settings = context.read<SettingsProvider>();
final theme = settings.currentTheme;
// 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,27 @@
import 'package:flutter/widgets.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;
final SettingsProvider _settings;
SpeedUnit get displayUnit => _settings.currentUnit;
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, this._settings);
double get displaySpeed {
return displayUnit.convert(_repository.currentData.speed);
}
}
+95
View File
@@ -0,0 +1,95 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../../../core/constants/overlay_constants.dart';
import '../../../core/theme/color_themes.dart';
import '../../providers/settings_provider.dart';
/// Shared speed display widget — integral + decimal parts with
/// staleness dimming and tap-to-cycle-theme.
///
/// Dumb widget — accepts data as parameters so it works in both
/// the old Provider-based and new ViewModel-based architectures.
class SpeedDisplay extends StatelessWidget {
final double fontSize;
final double opacity;
final String speedText;
final DateTime timestamp;
const SpeedDisplay({
super.key,
required this.fontSize,
required this.opacity,
required this.speedText,
required this.timestamp,
});
@override
Widget build(BuildContext context) {
return Selector<SettingsProvider, ColorTheme>(
selector: (_, s) => s.currentTheme,
builder: (context, theme, _) {
// Staleness — UI-layer concern
final age = DateTime.now().difference(timestamp);
final showDashes = age >= OverlayConfig.STALENESS_DASH_THRESHOLD;
final isDimmed = age >= OverlayConfig.STALENESS_DIM_THRESHOLD;
final staleOpacity = isDimmed ? OverlayConfig.STALENESS_DIM_OPACITY : 1.0;
final effectiveOpacity = opacity * staleOpacity;
final displayText = showDashes ? '--' : speedText;
return GestureDetector(
onTap: () {
HapticFeedback.lightImpact();
context.read<SettingsProvider>().cycleTheme();
},
child: _buildSpeedText(displayText, theme, fontSize, effectiveOpacity),
);
},
);
}
Widget _buildSpeedText(String speedText, ColorTheme theme, double fontSize, double opacity) {
if (speedText == '--') {
return Text(
speedText,
style: TextStyle(
fontSize: fontSize,
fontWeight: FontWeight.w300,
color: theme.speedText.withValues(alpha: opacity),
fontFamily: 'DIN1451Alt',
),
);
}
final parts = speedText.split('.');
final integralPart = parts[0];
final decimalPart = parts.length > 1 ? '.${parts[1]}' : '.0';
return Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(
integralPart,
style: TextStyle(
fontSize: fontSize,
fontWeight: FontWeight.w300,
color: theme.speedText.withValues(alpha: opacity),
fontFamily: 'DIN1451Alt',
),
),
Text(
decimalPart,
style: TextStyle(
fontSize: fontSize * 0.5,
fontWeight: FontWeight.w300,
color: theme.speedTextSub.withValues(alpha: opacity),
fontFamily: 'DIN1451Alt',
),
),
],
);
}
}
+13 -13
View File
@@ -45,10 +45,10 @@ packages:
dependency: transitive
description:
name: characters
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev"
source: hosted
version: "1.4.0"
version: "1.4.1"
checked_yaml:
dependency: transitive
description:
@@ -308,26 +308,26 @@ packages:
dependency: transitive
description:
name: matcher
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
url: "https://pub.dev"
source: hosted
version: "0.12.17"
version: "0.12.20"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev"
source: hosted
version: "0.11.1"
version: "0.13.0"
meta:
dependency: transitive
description:
name: meta
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9"
url: "https://pub.dev"
source: hosted
version: "1.16.0"
version: "1.19.0"
nested:
dependency: transitive
description:
@@ -593,10 +593,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
url: "https://pub.dev"
source: hosted
version: "0.7.6"
version: "0.7.12"
typed_data:
dependency: transitive
description:
@@ -617,10 +617,10 @@ packages:
dependency: transitive
description:
name: vector_math
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47
url: "https://pub.dev"
source: hosted
version: "2.2.0"
version: "2.4.2"
vm_service:
dependency: transitive
description:
@@ -686,5 +686,5 @@ packages:
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.9.0 <4.0.0"
dart: ">=3.11.0-0 <4.0.0"
flutter: ">=3.35.0"
+7 -22
View File
@@ -2,7 +2,7 @@ name: gps_speedo
description: "Speedo - A Flutter application."
publish_to: 'none'
version: 2.2.2+1
version: 2.3.0+2
environment:
sdk: ^3.9.0
@@ -11,37 +11,22 @@ dependencies:
flutter:
sdk: flutter
# cupertino_icons: ^1.0.8
# Probably no icons needed for now
# GPS and Location Services - Essential only
bg_launcher: ^0.1.0
flutter_overlay_window: ^0.5.0
geolocator: ^14.0.2
permission_handler: ^12.0.1
# Keep screen awake during navigation
wakelock_plus: ^1.2.8
# Floating window overlay capability
flutter_overlay_window: ^0.5.0
# Bring app to foreground from background/overlay
bg_launcher: ^0.1.0
# State management
provider: ^6.1.2
# Persistent settings storage
shared_preferences: ^2.3.4
# Retry logic for platform operations
retry: ^3.1.2
shared_preferences: ^2.3.4
wakelock_plus: ^1.2.8
dev_dependencies:
flutter_launcher_icons: "^0.14.4"
flutter_lints: ^6.0.0
flutter_test:
sdk: flutter
flutter_lints: ^6.0.0
flutter_launcher_icons: "^0.14.4"
flutter:
uses-material-design: true