diff --git a/.github/workflows/analyze.yml b/.github/workflows/analyze.yml new file mode 100644 index 0000000..173b0f7 --- /dev/null +++ b/.github/workflows/analyze.yml @@ -0,0 +1,16 @@ +name: Flutter Analyze + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + analyze: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2 + - run: flutter pub get + - run: flutter analyze diff --git a/.github/workflows/translation.yml b/.github/workflows/translation.yml new file mode 100644 index 0000000..3ae9e9f --- /dev/null +++ b/.github/workflows/translation.yml @@ -0,0 +1,26 @@ +name: Update Translations + +on: + workflow_dispatch: + +jobs: + translation: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: transifex/cli-action@v2 + with: + token: ${{ secrets.TRANSIFEX_TOKEN }} + args: pull -a --force + - run: | + git config --global user.name "Traccar Bot" + git config --global user.email "support@traccar.org" + for f in lib/l10n/*.arb; do + [[ $f == */app_en.arb ]] && continue + diff -q "$f" lib/l10n/app_en.arb && continue + git diff --quiet -- "$f" || git add "$f" + done + git diff --cached --quiet || git commit -m "Update translations" + git push + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/SPEC.md b/SPEC.md deleted file mode 100644 index 984aee8..0000000 --- a/SPEC.md +++ /dev/null @@ -1,188 +0,0 @@ -# Traccar Client Fork - Agent Specification - -This spec describes how to transform a fresh fork of [traccar/traccar-client](https://github.com/traccar/traccar-client) (Flutter) into a simplified, hardcoded tracking client. Apply all changes to the `main` branch. - -## Goal - -Create a minimal Traccar Client that: -- Hard-codes all server/tracking settings (no user configuration) -- Shows only a device ID and a tracking toggle in the UI -- Removes access to settings, status, manual send, QR scanning, and password protection - -## Upstream Repository - -``` -https://github.com/traccar/traccar-client -``` - -The app is a Flutter project targeting iOS and Android from a single Dart codebase. - ---- - -## 1. Configuration File: `lib/config.dart` - -Create a new file `lib/config.dart` with compile-time constants. This is the single source of truth for all hardcoded settings. A developer forking this project only needs to edit this file to reconfigure the app. - -```dart -class AppConfig { - AppConfig._(); - - /// Traccar server URL - static const String serverUrl = 'https://c.track.rs'; - - /// Location accuracy: 'highest', 'high', 'medium', or 'low' - static const String accuracy = 'highest'; - - /// Location update interval in seconds - static const int intervalSeconds = 60; - - /// Distance filter in meters (0 = disabled) - static const int distanceFilter = 0; - - /// Whether to enable stop detection - static const bool stopDetection = false; - - /// Whether to buffer locations when offline - static const bool buffer = true; - - /// Fastest location update interval in seconds (Android) - static const int fastestIntervalSeconds = 30; -} -``` - -All other modified files must read from `AppConfig` instead of using inline literals. - ---- - -## 2. Hardcoded Settings (via `AppConfig`) - -These values are set in `lib/config.dart` and enforced at runtime: - -| Setting | Constant | Default Value | -|--------------------|-----------------------------------|--------------------------| -| Server URL | `AppConfig.serverUrl` | `https://c.track.rs` | -| Location Accuracy | `AppConfig.accuracy` | `highest` | -| Interval | `AppConfig.intervalSeconds` | `60` | -| Distance Filter | `AppConfig.distanceFilter` | `0` (disabled) | -| Stop Detection | `AppConfig.stopDetection` | `false` (disabled) | -| Buffer | `AppConfig.buffer` | `true` | -| Fastest Interval | `AppConfig.fastestIntervalSeconds`| `30` | - ---- - -## 3. File Changes - -### 3.1 `lib/preferences.dart` - -Add `import 'config.dart';` at the top. - -#### `migrate()` method -After the existing migration logic and device ID generation, force-set values from `AppConfig`: - -```dart -// After device ID generation -await instance.setString(url, AppConfig.serverUrl); -await instance.setString(accuracy, AppConfig.accuracy); -await instance.setInt(interval, AppConfig.intervalSeconds); -await instance.setInt(distance, AppConfig.distanceFilter); -await instance.setBool(buffer, instance.getBool(buffer) ?? AppConfig.buffer); -await instance.setBool(stopDetection, AppConfig.stopDetection); -await instance.setInt(fastestInterval, instance.getInt(fastestInterval) ?? AppConfig.fastestIntervalSeconds); -``` - -#### `geolocationConfig()` method -Replace dynamic preference reads with `AppConfig` references: - -- `isHighestAccuracy` = `AppConfig.accuracy == 'highest'` -- `locationUpdateInterval` = `AppConfig.intervalSeconds * 1000` (but set to `0` in the config when highest accuracy) -- `desiredAccuracy`: Use `DESIRED_ACCURACY_NAVIGATION` on iOS, `DESIRED_ACCURACY_HIGH` on Android (when highest) -- `url`: `AppConfig.serverUrl` (passed through `_formatUrl`) -- `distanceFilter`: `AppConfig.distanceFilter.toDouble()` -- `locationUpdateInterval` (in config): `0` (when highest accuracy, for continuous tracking) -- `disableElasticity`: `true` -- `disableStopDetection`: `!AppConfig.stopDetection` -- `pausesLocationUpdatesAutomatically`: `false` on iOS, `null` on Android -- `fastestLocationUpdateInterval`: `0` when highest accuracy, else `AppConfig.fastestIntervalSeconds * 1000` - -### 3.2 `lib/main_screen.dart` - -**Complete rewrite.** Replace the entire file with a minimal screen containing: - -1. **AppBar** with title `'Traccar Client'` -2. **Centered body** with two cards: - - **Device ID card**: Shows label "Device ID" and a `SelectableText` displaying the device ID in monospace bold (`headlineSmall`) - - **Tracking toggle card**: A `SwitchListTile` labeled "Continuous Tracking" with subtitle showing state ("Sending location updates" / "Location tracking disabled") -3. **Tracking toggle behavior**: - - On: calls `bg.BackgroundGeolocation.start()` then checks battery optimizations - - Off: calls `bg.BackgroundGeolocation.stop()` - - Track `isMoving` state; set `activeTrackColor` to error color when `isMoving == false` (stationary warning) -4. **State listeners**: Listen to `onEnabledChange` and `onMotionChange` from BackgroundGeolocation -5. **Battery optimization dialog**: Keep the `_checkBatteryOptimizations` method that shows a dialog using `bg.DeviceSettings` - -**Remove entirely:** -- Settings button/navigation -- Status screen button/navigation -- "Send Location" button -- Password service import and usage -- Any reference to `SettingsScreen` or `StatusScreen` navigation - -**Imports needed:** `flutter/material.dart`, `flutter/services.dart`, `traccar_client/main.dart` (for `messengerKey`), `traccar_client/preferences.dart`, `flutter_background_geolocation`, `l10n/app_localizations.dart` - -### 3.3 `lib/settings_screen.dart` - -**Complete rewrite.** Replace with a minimal read-only screen (not navigable from main UI, but kept for completeness): - -Add `import 'config.dart';` at the top. - -- Device ID display (read-only `ListTile`) -- Continuous Tracking `SwitchListTile` (functional) -- Read-only disabled `ListTile` entries displaying values from `AppConfig`: - - Server: `AppConfig.serverUrl` - - Location Accuracy: `AppConfig.accuracy` - - Distance: `AppConfig.distanceFilter` (show "Disabled" when 0) - - Interval: `'${AppConfig.intervalSeconds} seconds'` - - Stop Detection: `AppConfig.stopDetection` (show "Disabled"/"Enabled") - -### 3.4 `ios/Runner.xcodeproj/project.pbxproj` - -Find the build phase named `FlutterFire: "flutterfire upload-crashlytics-symbols"` and prepend `exit 0` to the shell script to skip it. This prevents `flutterfire: command not found` build failures. - -The `shellScript` value should start with: -``` -\n#!/bin/bash\n# Skip Firebase Crashlytics for now\nexit 0\n -``` -followed by the original script content (which will never execute). - ---- - -## 4. Files NOT Modified - -These files remain unchanged from upstream: -- `lib/main.dart` - App entry point, Firebase init, app links, rate dialog -- `lib/geolocation_service.dart` -- `lib/configuration_service.dart` -- `lib/push_service.dart` -- `lib/quick_actions.dart` -- `lib/location_cache.dart` -- `lib/status_screen.dart` - Kept in codebase but not navigable -- `lib/password_service.dart` - Kept in codebase but unused -- `lib/qr_code_screen.dart` - Kept in codebase but not navigable -- `pubspec.yaml` -- All localization files (`lib/l10n/`) -- Android platform files - ---- - -## 5. Verification Checklist - -After applying changes: - -1. `flutter pub get` succeeds -2. `flutter build ios` succeeds (no flutterfire errors) -3. `flutter build apk --release` succeeds -4. App launches showing only device ID and tracking toggle -5. No settings, status, or send location buttons visible -6. Toggling tracking on starts background location reporting to `https://c.track.rs` -7. Device ID is auto-generated (8-digit random number) and persists across launches -8. Location updates use highest accuracy with 0 distance filter -9. Stop detection is disabled (tracking never auto-pauses) diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index d8c551a..4533c73 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -344,7 +344,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\n#!/bin/bash\n# Skip Firebase Crashlytics for now\nexit 0\nPATH=\"${PATH}:$FLUTTER_ROOT/bin:${PUB_CACHE}/bin:$HOME/.pub-cache/bin\"\n\nif [ -z \"$PODS_ROOT\" ] || [ ! -d \"$PODS_ROOT/FirebaseCrashlytics\" ]; then\n # Cannot use \"BUILD_DIR%/Build/*\" as per Firebase documentation, it points to \"flutter-project/build/ios/*\" path which doesn't have run script\n DERIVED_DATA_PATH=$(echo \"$BUILD_ROOT\" | sed -E 's|(.*DerivedData/[^/]+).*|\\1|')\n PATH_TO_CRASHLYTICS_UPLOAD_SCRIPT=\"${DERIVED_DATA_PATH}/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/run\"\nelse\n PATH_TO_CRASHLYTICS_UPLOAD_SCRIPT=\"$PODS_ROOT/FirebaseCrashlytics/run\"\nfi\n\n# Command to upload symbols script used to upload symbols to Firebase server\nflutterfire upload-crashlytics-symbols --upload-symbols-script-path=\"$PATH_TO_CRASHLYTICS_UPLOAD_SCRIPT\" --platform=ios --apple-project-path=\"${SRCROOT}\" --env-platform-name=\"${PLATFORM_NAME}\" --env-configuration=\"${CONFIGURATION}\" --env-project-dir=\"${PROJECT_DIR}\" --env-built-products-dir=\"${BUILT_PRODUCTS_DIR}\" --env-dwarf-dsym-folder-path=\"${DWARF_DSYM_FOLDER_PATH}\" --env-dwarf-dsym-file-name=\"${DWARF_DSYM_FILE_NAME}\" --env-infoplist-path=\"${INFOPLIST_PATH}\" --default-config=default\n"; + shellScript = "\n#!/bin/bash\nPATH=\"${PATH}:$FLUTTER_ROOT/bin:${PUB_CACHE}/bin:$HOME/.pub-cache/bin\"\n\nif [ -z \"$PODS_ROOT\" ] || [ ! -d \"$PODS_ROOT/FirebaseCrashlytics\" ]; then\n # Cannot use \"BUILD_DIR%/Build/*\" as per Firebase documentation, it points to \"flutter-project/build/ios/*\" path which doesn't have run script\n DERIVED_DATA_PATH=$(echo \"$BUILD_ROOT\" | sed -E 's|(.*DerivedData/[^/]+).*|\\1|')\n PATH_TO_CRASHLYTICS_UPLOAD_SCRIPT=\"${DERIVED_DATA_PATH}/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/run\"\nelse\n PATH_TO_CRASHLYTICS_UPLOAD_SCRIPT=\"$PODS_ROOT/FirebaseCrashlytics/run\"\nfi\n\n# Command to upload symbols script used to upload symbols to Firebase server\nflutterfire upload-crashlytics-symbols --upload-symbols-script-path=\"$PATH_TO_CRASHLYTICS_UPLOAD_SCRIPT\" --platform=ios --apple-project-path=\"${SRCROOT}\" --env-platform-name=\"${PLATFORM_NAME}\" --env-configuration=\"${CONFIGURATION}\" --env-project-dir=\"${PROJECT_DIR}\" --env-built-products-dir=\"${BUILT_PRODUCTS_DIR}\" --env-dwarf-dsym-folder-path=\"${DWARF_DSYM_FOLDER_PATH}\" --env-dwarf-dsym-file-name=\"${DWARF_DSYM_FILE_NAME}\" --env-infoplist-path=\"${INFOPLIST_PATH}\" --default-config=default\n"; }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; diff --git a/lib/config.dart b/lib/config.dart deleted file mode 100644 index cfe42f3..0000000 --- a/lib/config.dart +++ /dev/null @@ -1,24 +0,0 @@ -class AppConfig { - AppConfig._(); - - /// Traccar server URL - static const String serverUrl = 'https://c.track.rs'; - - /// Location accuracy: 'highest', 'high', 'medium', or 'low' - static const String accuracy = 'highest'; - - /// Location update interval in seconds - static const int intervalSeconds = 60; - - /// Distance filter in meters (0 = disabled) - static const int distanceFilter = 0; - - /// Whether to enable stop detection - static const bool stopDetection = false; - - /// Whether to buffer locations when offline - static const bool buffer = true; - - /// Fastest location update interval in seconds (Android) - static const int fastestIntervalSeconds = 30; -} diff --git a/lib/main_screen.dart b/lib/main_screen.dart index 21ad27f..08e0155 100644 --- a/lib/main_screen.dart +++ b/lib/main_screen.dart @@ -1,10 +1,15 @@ +import 'package:app_settings/app_settings.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:traccar_client/main.dart'; +import 'package:traccar_client/password_service.dart'; import 'package:traccar_client/preferences.dart'; +import 'package:firebase_crashlytics/firebase_crashlytics.dart'; import 'package:flutter_background_geolocation/flutter_background_geolocation.dart' as bg; import 'l10n/app_localizations.dart'; +import 'status_screen.dart'; +import 'settings_screen.dart'; class MainScreen extends StatefulWidget { const MainScreen({super.key}); @@ -69,76 +74,144 @@ class _MainScreenState extends State { } } - @override - Widget build(BuildContext context) { - final deviceId = Preferences.instance.getString(Preferences.id) ?? ''; - return Scaffold( - appBar: AppBar( - title: const Text('Traccar Client'), - ), - body: Center( - child: SingleChildScrollView( - padding: const EdgeInsets.all(16.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Card( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - children: [ - Text( - 'Device ID', - style: Theme.of(context).textTheme.labelLarge, - ), - const SizedBox(height: 8), - SelectableText( - deviceId, - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - fontFamily: 'monospace', - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - ), - const SizedBox(height: 16), - Card( - child: SwitchListTile( - title: const Text('Continuous Tracking'), - subtitle: Text( - trackingEnabled - ? 'Sending location updates' - : 'Location tracking disabled', - ), - value: trackingEnabled, - activeTrackColor: isMoving == false - ? Theme.of(context).colorScheme.error - : null, - onChanged: (bool value) async { - if (value) { - try { - await bg.BackgroundGeolocation.start(); - if (mounted) { - _checkBatteryOptimizations(context); - } - } on PlatformException catch (error) { + Widget _buildTrackingCard() { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ListTile( + contentPadding: EdgeInsets.zero, + title: Text(AppLocalizations.of(context)!.trackingTitle), + titleTextStyle: Theme.of(context).textTheme.headlineMedium, + ), + ListTile( + contentPadding: EdgeInsets.zero, + title: Text(AppLocalizations.of(context)!.idLabel), + subtitle: Text(Preferences.instance.getString(Preferences.id) ?? ''), + ), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text(AppLocalizations.of(context)!.trackingLabel), + value: trackingEnabled, + activeTrackColor: isMoving == false ? Theme.of(context).colorScheme.secondary : null, + onChanged: (bool value) async { + if (await PasswordService.authenticate(context) && mounted) { + if (value) { + try { + FirebaseCrashlytics.instance.log('tracking_toggle_start'); + await bg.BackgroundGeolocation.start(); + if (mounted) { + _checkBatteryOptimizations(context); + } + } on PlatformException catch (error) { + final providerState = await bg.BackgroundGeolocation.providerState; + final isPermissionError = providerState.status == bg.ProviderChangeEvent.AUTHORIZATION_STATUS_DENIED || + providerState.status == bg.ProviderChangeEvent.AUTHORIZATION_STATUS_RESTRICTED; if (!mounted) return; messengerKey.currentState?.showSnackBar( SnackBar( content: Text(error.message ?? error.code), + duration: const Duration(seconds: 4), + action: isPermissionError + ? SnackBarAction( + label: AppLocalizations.of(context)!.settingsTitle, + onPressed: () => AppSettings.openAppSettings( + type: AppSettingsType.settings, + ), + ) + : null, ), ); - } - } else { - bg.BackgroundGeolocation.stop(); + } + } else { + FirebaseCrashlytics.instance.log('tracking_toggle_stop'); + bg.BackgroundGeolocation.stop(); + } + } + }, + ), + const SizedBox(height: 8), + OverflowBar( + spacing: 8, + children: [ + FilledButton.tonal( + onPressed: () async { + try { + await bg.BackgroundGeolocation.getCurrentPosition(samples: 1, persist: true, extras: {'manual': true}); + } on PlatformException catch (error) { + messengerKey.currentState?.showSnackBar(SnackBar(content: Text(error.message ?? error.code))); } }, + child: Text(AppLocalizations.of(context)!.locationButton), ), - ), - ], - ), + FilledButton.tonal( + onPressed: () { + Navigator.push(context, MaterialPageRoute(builder: (_) => const StatusScreen())); + }, + child: Text(AppLocalizations.of(context)!.statusButton), + ), + ], + ), + ], + ), + ), + ); + } + + Widget _buildSettingsCard() { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ListTile( + contentPadding: EdgeInsets.zero, + title: Text(AppLocalizations.of(context)!.settingsTitle), + titleTextStyle: Theme.of(context).textTheme.headlineMedium, + ), + ListTile( + contentPadding: EdgeInsets.zero, + title: Text(AppLocalizations.of(context)!.urlLabel), + subtitle: Text(Preferences.instance.getString(Preferences.url) ?? ''), + ), + const SizedBox(height: 8), + OverflowBar( + spacing: 8, + children: [ + FilledButton.tonal( + onPressed: () async { + if (await PasswordService.authenticate(context) && mounted) { + await Navigator.push(context, MaterialPageRoute(builder: (_) => const SettingsScreen())); + setState(() {}); + } + }, + child: Text(AppLocalizations.of(context)!.settingsButton), + ), + ], + ), + ] + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text('Traccar Client'), + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + _buildTrackingCard(), + const SizedBox(height: 16), + _buildSettingsCard(), + ], ), ), ); diff --git a/lib/preferences.dart b/lib/preferences.dart index 4c5230d..44ff9d0 100644 --- a/lib/preferences.dart +++ b/lib/preferences.dart @@ -5,8 +5,6 @@ import 'package:flutter_background_geolocation/flutter_background_geolocation.da import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences_android/shared_preferences_android.dart'; -import 'config.dart'; - class Preferences { static Future? _initFuture; static late SharedPreferencesWithCache instance; @@ -49,35 +47,37 @@ class Preferences { ); if (instance.getString(id) == null) { await instance.setString(id, (Random().nextInt(90000000) + 10000000).toString()); + await instance.setString(url, 'http://demo.traccar.org:5055'); + await instance.setString(accuracy, 'medium'); + await instance.setInt(interval, 300); + await instance.setInt(distance, 75); + await instance.setBool(buffer, true); + await instance.setBool(stopDetection, true); + await instance.setInt(fastestInterval, 30); } - await instance.setString(url, AppConfig.serverUrl); - await instance.setString(accuracy, AppConfig.accuracy); - await instance.setInt(interval, AppConfig.intervalSeconds); - await instance.setInt(distance, AppConfig.distanceFilter); - await instance.setBool(buffer, instance.getBool(buffer) ?? AppConfig.buffer); - await instance.setBool(stopDetection, AppConfig.stopDetection); - await instance.setInt(fastestInterval, instance.getInt(fastestInterval) ?? AppConfig.fastestIntervalSeconds); } static bg.Config geolocationConfig() { - final isHighestAccuracy = AppConfig.accuracy == 'highest'; - final locationUpdateInterval = isHighestAccuracy ? 0 : AppConfig.intervalSeconds * 1000; - final fastestLocationUpdateInterval = isHighestAccuracy ? 0 : AppConfig.fastestIntervalSeconds * 1000; + final isHighestAccuracy = instance.getString(accuracy) == 'highest'; + final locationUpdateInterval = (instance.getInt(interval) ?? 0) * 1000; + final fastestLocationUpdateInterval = (instance.getInt(fastestInterval) ?? 30) * 1000; final heartbeatInterval = instance.getInt(heartbeat) ?? 0; return bg.Config( isMoving: true, geolocation: bg.GeoConfig( - desiredAccuracy: switch (AppConfig.accuracy) { + desiredAccuracy: switch (instance.getString(accuracy)) { 'highest' => Platform.isIOS ? bg.DesiredAccuracy.navigation : bg.DesiredAccuracy.high, 'high' => bg.DesiredAccuracy.high, 'low' => bg.DesiredAccuracy.low, _ => bg.DesiredAccuracy.medium, }, - distanceFilter: AppConfig.distanceFilter.toDouble(), - locationUpdateInterval: Platform.isAndroid ? locationUpdateInterval : null, - fastestLocationUpdateInterval: Platform.isAndroid ? fastestLocationUpdateInterval : null, + distanceFilter: isHighestAccuracy ? 0 : instance.getInt(distance)?.toDouble(), + locationUpdateInterval: Platform.isAndroid + ? (isHighestAccuracy ? 0 : (locationUpdateInterval > 0 ? locationUpdateInterval : null)) + : null, + fastestLocationUpdateInterval: Platform.isAndroid ? (isHighestAccuracy ? 0 : fastestLocationUpdateInterval) : null, disableElasticity: true, - pausesLocationUpdatesAutomatically: Platform.isIOS ? false : null, + pausesLocationUpdatesAutomatically: Platform.isIOS ? !(isHighestAccuracy || instance.getBool(stopDetection) == false) : null, showsBackgroundLocationIndicator: Platform.isIOS ? false : null, ), app: bg.AppConfig( @@ -102,7 +102,7 @@ class Preferences { ), http: bg.HttpConfig( autoSync: false, - url: _formatUrl(AppConfig.serverUrl), + url: _formatUrl(instance.getString(url)), params: { 'device_id': instance.getString(id), }, @@ -112,10 +112,10 @@ class Preferences { logMaxDays: 1, ), activity: bg.ActivityConfig( - disableStopDetection: !AppConfig.stopDetection, + disableStopDetection: instance.getBool(stopDetection) == false, ), persistence: bg.PersistenceConfig( - maxRecordsToPersist: AppConfig.buffer ? -1 : 1, + maxRecordsToPersist: instance.getBool(buffer) != false ? -1 : 1, locationTemplate: _locationTemplate(), ), ); diff --git a/lib/settings_screen.dart b/lib/settings_screen.dart index cac0f2c..ccc8b1c 100644 --- a/lib/settings_screen.dart +++ b/lib/settings_screen.dart @@ -1,9 +1,14 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_background_geolocation/flutter_background_geolocation.dart' as bg; import 'package:traccar_client/main.dart'; +import 'package:traccar_client/password_service.dart'; +import 'package:traccar_client/qr_code_screen.dart'; +import 'package:wakelock_partial_android/wakelock_partial_android.dart'; -import 'config.dart'; +import 'l10n/app_localizations.dart'; import 'preferences.dart'; class SettingsScreen extends StatefulWidget { @@ -14,81 +19,223 @@ class SettingsScreen extends StatefulWidget { } class _SettingsScreenState extends State { - bool trackingEnabled = false; + bool advanced = false; - @override - void initState() { - super.initState(); - _initState(); + String _getAccuracyLabel(String? key) { + return switch (key) { + 'highest' => AppLocalizations.of(context)!.highestAccuracyLabel, + 'high' => AppLocalizations.of(context)!.highAccuracyLabel, + 'low' => AppLocalizations.of(context)!.lowAccuracyLabel, + _ => AppLocalizations.of(context)!.mediumAccuracyLabel, + }; } - void _initState() async { - final state = await bg.BackgroundGeolocation.state; - setState(() { - trackingEnabled = state.enabled; - }); - bg.BackgroundGeolocation.onEnabledChange((bool enabled) { - setState(() { - trackingEnabled = enabled; - }); - }); + Future _editSetting(String title, String key, bool isInt) async { + final initialValue = isInt + ? Preferences.instance.getInt(key)?.toString() ?? '0' + : Preferences.instance.getString(key) ?? ''; + + final controller = TextEditingController(text: initialValue); + final errorMessage = AppLocalizations.of(context)!.invalidValue; + + final result = await showDialog( + context: context, + builder: (context) => AlertDialog( + scrollable: true, + title: Text(title), + content: TextField( + controller: controller, + keyboardType: isInt ? TextInputType.number : TextInputType.text, + inputFormatters: isInt ? [FilteringTextInputFormatter.digitsOnly] : [], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(AppLocalizations.of(context)!.cancelButton), + ), + TextButton( + onPressed: () => Navigator.pop(context, controller.text), + child: Text(AppLocalizations.of(context)!.saveButton), + ), + ], + ), + ); + + if (result != null && result.isNotEmpty) { + if (key == Preferences.url) { + final uri = Uri.tryParse(result); + if (uri == null || uri.host.isEmpty || !(uri.scheme == 'http' || uri.scheme == 'https')) { + messengerKey.currentState?.showSnackBar(SnackBar(content: Text(errorMessage))); + return; + } + } + if (isInt) { + int? intValue = int.tryParse(result); + if (intValue != null) { + if (key == Preferences.heartbeat && intValue > 0 && intValue < 60) { + intValue = 60; // minimum heartbeat is 60 seconds + } + await Preferences.instance.setInt(key, intValue); + } + } else { + await Preferences.instance.setString(key, result); + } + await bg.BackgroundGeolocation.setConfig(Preferences.geolocationConfig()); + setState(() {}); + } + } + + Future _changePassword() async { + final controller = TextEditingController(); + final result = await showDialog( + context: context, + builder: (context) => AlertDialog( + scrollable: true, + content: TextField( + controller: controller, + decoration: InputDecoration(labelText: AppLocalizations.of(context)!.passwordLabel), + obscureText: true, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(AppLocalizations.of(context)!.cancelButton), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: Text(AppLocalizations.of(context)!.saveButton), + ), + ], + ), + ); + if (result == true) { + await PasswordService.setPassword(controller.text); + } + } + + Widget _buildListTile(String title, String key, bool isInt) { + String? value; + if (isInt) { + final intValue = Preferences.instance.getInt(key); + if (intValue != null && intValue > 0) { + value = intValue.toString(); + } else { + value = AppLocalizations.of(context)!.disabledValue; + } + } else { + value = Preferences.instance.getString(key); + } + return ListTile( + title: Text(title), + subtitle: Text(value ?? ''), + onTap: () => _editSetting(title, key, isInt), + ); + } + + Widget _buildAccuracyListTile() { + final accuracyOptions = ['highest', 'high', 'medium', 'low']; + return ListTile( + title: Text(AppLocalizations.of(context)!.accuracyLabel), + subtitle: Text(_getAccuracyLabel(Preferences.instance.getString(Preferences.accuracy))), + onTap: () async { + final selectedAccuracy = await showDialog( + context: context, + builder: (context) => SimpleDialog( + title: Text(AppLocalizations.of(context)!.accuracyLabel), + children: accuracyOptions.map((option) => SimpleDialogOption( + child: Text(_getAccuracyLabel(option)), + onPressed: () => Navigator.pop(context, option), + )).toList(), + ), + ); + if (selectedAccuracy != null) { + await Preferences.instance.setString(Preferences.accuracy, selectedAccuracy); + await bg.BackgroundGeolocation.setConfig(Preferences.geolocationConfig()); + setState(() {}); + } + }, + ); } @override Widget build(BuildContext context) { + final isHighestAccuracy = Preferences.instance.getString(Preferences.accuracy) == 'highest'; + final distance = Preferences.instance.getInt(Preferences.distance); return Scaffold( appBar: AppBar( - title: const Text('Settings'), + title: Text(AppLocalizations.of(context)!.settingsTitle), + actions: [ + IconButton( + icon: const Icon(Icons.qr_code_scanner), + onPressed: () async { + await Navigator.push(context, MaterialPageRoute(builder: (_) => const QrCodeScreen())); + setState(() {}); + }, + ), + ], ), body: ListView( children: [ - ListTile( - title: const Text('Device ID'), - subtitle: Text(Preferences.instance.getString(Preferences.id) ?? ''), - enabled: false, - ), + _buildListTile(AppLocalizations.of(context)!.idLabel, Preferences.id, false), + _buildListTile(AppLocalizations.of(context)!.urlLabel, Preferences.url, false), + _buildAccuracyListTile(), + _buildListTile(AppLocalizations.of(context)!.distanceLabel, Preferences.distance, true), + if (isHighestAccuracy || Platform.isAndroid && distance == 0) + _buildListTile(AppLocalizations.of(context)!.intervalLabel, Preferences.interval, true), + if (isHighestAccuracy) + _buildListTile(AppLocalizations.of(context)!.angleLabel, Preferences.angle, true), + _buildListTile(AppLocalizations.of(context)!.heartbeatLabel, Preferences.heartbeat, true), SwitchListTile( - title: const Text('Continuous Tracking'), - value: trackingEnabled, - onChanged: (bool value) async { - if (value) { - try { - await bg.BackgroundGeolocation.start(); - } on PlatformException catch (error) { - messengerKey.currentState?.showSnackBar( - SnackBar(content: Text(error.message ?? error.code)), - ); - } - } else { - bg.BackgroundGeolocation.stop(); - } + title: Text(AppLocalizations.of(context)!.advancedLabel), + value: advanced, + onChanged: (value) { + setState(() => advanced = value); }, ), - ListTile( - title: const Text('Server'), - subtitle: Text(AppConfig.serverUrl), - enabled: false, - ), - ListTile( - title: const Text('Location Accuracy'), - subtitle: Text(AppConfig.accuracy), - enabled: false, - ), - ListTile( - title: const Text('Distance'), - subtitle: Text(AppConfig.distanceFilter == 0 ? 'Disabled' : '${AppConfig.distanceFilter}'), - enabled: false, - ), - ListTile( - title: const Text('Interval'), - subtitle: Text('${AppConfig.intervalSeconds} seconds'), - enabled: false, - ), - ListTile( - title: const Text('Stop Detection'), - subtitle: Text(AppConfig.stopDetection ? 'Enabled' : 'Disabled'), - enabled: false, - ), + if (advanced) + _buildListTile(AppLocalizations.of(context)!.fastestIntervalLabel, Preferences.fastestInterval, true), + if (advanced) + SwitchListTile( + title: Text(AppLocalizations.of(context)!.bufferLabel), + value: Preferences.instance.getBool(Preferences.buffer) ?? true, + onChanged: (value) async { + await Preferences.instance.setBool(Preferences.buffer, value); + await bg.BackgroundGeolocation.setConfig(Preferences.geolocationConfig()); + setState(() {}); + }, + ), + if (advanced && Platform.isAndroid) + SwitchListTile( + title: Text(AppLocalizations.of(context)!.wakelockLabel), + value: Preferences.instance.getBool(Preferences.wakelock) ?? false, + onChanged: (value) async { + await Preferences.instance.setBool(Preferences.wakelock, value); + if (value) { + final state = await bg.BackgroundGeolocation.state; + if (state.isMoving == true) { + WakelockPartialAndroid.acquire(); + } + } else { + WakelockPartialAndroid.release(); + } + setState(() {}); + }, + ), + if (advanced) + SwitchListTile( + title: Text(AppLocalizations.of(context)!.stopDetectionLabel), + value: Preferences.instance.getBool(Preferences.stopDetection) ?? true, + onChanged: (value) async { + await Preferences.instance.setBool(Preferences.stopDetection, value); + await bg.BackgroundGeolocation.setConfig(Preferences.geolocationConfig()); + setState(() {}); + }, + ), + if (advanced) + ListTile( + title: Text(AppLocalizations.of(context)!.passwordLabel), + onTap: _changePassword, + ), ], ), ); diff --git a/pubspec.lock b/pubspec.lock index 1c2a317..5f90aa8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,10 +5,10 @@ packages: dependency: transitive description: name: _flutterfire_internals - sha256: afe15ce18a287d2f89da95566e62892df339b1936bbe9b83587df45b944ee72a + sha256: cd83f7d6bd4e4c0b0b4fef802e8796784032e1cc23d7b0e982cf5d05d9bbe182 url: "https://pub.dev" source: hosted - version: "1.3.67" + version: "1.3.66" app_links: dependency: "direct main" description: @@ -165,34 +165,34 @@ packages: dependency: "direct main" description: name: firebase_analytics - sha256: "8170273394694efdf567e7e30c26457ff346377a52eb679c278f97b36b786d70" + sha256: "07e0a82e9b045dbd14522a0b23764e2dbcc269720e785d63c6a021133ebb766a" url: "https://pub.dev" source: hosted - version: "12.1.3" + version: "12.1.2" firebase_analytics_platform_interface: dependency: transitive description: name: firebase_analytics_platform_interface - sha256: "2c25cd85f640a47fcc15e970a04a50470f0a4d0e76f23c5bc5ec93a3d48d8775" + sha256: "62fd3f27f342c898bd819fb97fa87c0b971e9fbe03357477282c0e14e1a40c3c" url: "https://pub.dev" source: hosted - version: "5.0.7" + version: "5.0.6" firebase_analytics_web: dependency: transitive description: name: firebase_analytics_web - sha256: e0eb2b21eccb18109af2c1d3154f9e69b45abf4816e3290a6251008e0503aca9 + sha256: "8fc488bb008439fc3b850cfac892dec1ff4cd438eee44438919a14c5e61b9828" url: "https://pub.dev" source: hosted - version: "0.6.1+3" + version: "0.6.1+2" firebase_core: dependency: "direct main" description: name: firebase_core - sha256: f0997fee80fbb6d2c658c5b88ae87ba1f9506b5b37126db64fc2e75d8e977fbb + sha256: "923085c881663ef685269b013e241b428e1fb03cdd0ebde265d9b40ff18abf80" url: "https://pub.dev" source: hosted - version: "4.5.0" + version: "4.4.0" firebase_core_platform_interface: dependency: transitive description: @@ -205,50 +205,50 @@ packages: dependency: transitive description: name: firebase_core_web - sha256: "856ca92bf2d75a63761286ab8e791bda3a85184c2b641764433b619647acfca6" + sha256: "83e7356c704131ca4d8d8dd57e360d8acecbca38b1a3705c7ae46cc34c708084" url: "https://pub.dev" source: hosted - version: "3.5.0" + version: "3.4.0" firebase_crashlytics: dependency: "direct main" description: name: firebase_crashlytics - sha256: "2a6dc88d762af01790a05ff0cf814f7d4020050e8c69dec01962d9ed5dc1a531" + sha256: a6e6cb8b2ea1214533a54e4c1b11b19c40f6a29333f3ab0854a479fdc3237c5b url: "https://pub.dev" source: hosted - version: "5.0.8" + version: "5.0.7" firebase_crashlytics_platform_interface: dependency: transitive description: name: firebase_crashlytics_platform_interface - sha256: "5fd59d76d691f370e42fd2b786d46078e69ed4126ca0d84b585119f55cd97937" + sha256: fc6837c4c64c48fa94cab8a872a632b9194fa9208ca76a822f424b3da945584d url: "https://pub.dev" source: hosted - version: "3.8.18" + version: "3.8.17" firebase_messaging: dependency: "direct main" description: name: firebase_messaging - sha256: bd17823b70e629877904d384841cda72ed2cc197517404c0c90da5c0ba786a8c + sha256: "06fad40ea14771e969a8f2bbce1944aa20ee2f4f57f4eca5b3ba346b65f3f644" url: "https://pub.dev" source: hosted - version: "16.1.2" + version: "16.1.1" firebase_messaging_platform_interface: dependency: transitive description: name: firebase_messaging_platform_interface - sha256: "550435235cc7d53683f32bf0762c28ef8cfc20a8d36318a033676ae09526d7fb" + sha256: "6c49e901c77e6e10e86d98e32056a087eb1ca1b93acdf58524f1961e617657b7" url: "https://pub.dev" source: hosted - version: "4.7.7" + version: "4.7.6" firebase_messaging_web: dependency: transitive description: name: firebase_messaging_web - sha256: "6b1b93ed90309fbce91c219e3cd32aa831e8eccaf4a61f3afaea1625479275d2" + sha256: "2756f8fea583ffb9d294d15ddecb3a9ad429b023b70c9990c151fc92c54a32b3" url: "https://pub.dev" source: hosted - version: "4.1.3" + version: "4.1.2" flutter: dependency: "direct main" description: flutter @@ -441,10 +441,10 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.18" material_color_utilities: dependency: transitive description: @@ -726,10 +726,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.9" typed_data: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index dfa5f4d..56ff9d7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -14,10 +14,10 @@ dependencies: intl: any flutter_background_geolocation: ^5.0.5 shared_preferences: ^2.5.4 - firebase_core: ^4.5.0 - firebase_messaging: ^16.1.2 - firebase_analytics: ^12.1.3 - firebase_crashlytics: ^5.0.8 + firebase_core: ^4.4.0 + firebase_messaging: ^16.1.1 + firebase_analytics: ^12.1.2 + firebase_crashlytics: ^5.0.7 quick_actions: ^1.1.0 rate_my_app: ^2.3.2 shared_preferences_android: ^2.4.21