diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index fe31907..e46b749 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -7,6 +7,7 @@
+
{
+ openBatteryOptimizationSettings(result)
+ }
else -> result.notImplemented()
}
}
@@ -189,4 +192,27 @@ class BridgeModule : FlutterPlugin, MethodChannel.MethodCallHandler {
}
}.start()
}
+
+ private fun openBatteryOptimizationSettings(result: MethodChannel.Result) {
+ try {
+ val packageName = context?.packageName ?: ""
+ val intent = android.content.Intent("android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS").apply {
+ data = android.net.Uri.parse("package:$packageName")
+ }
+ intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
+ context?.startActivity(intent)
+ result.success(true)
+ } catch (e: Exception) {
+ // Fallback to general battery settings
+ try {
+ val intent = android.content.Intent("android.settings.IGNORE_BATTERY_OPTIMIZATION_SETTINGS").apply {
+ }
+ intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
+ context?.startActivity(intent)
+ result.success(true)
+ } catch (e2: Exception) {
+ result.success(false)
+ }
+ }
+ }
}
\ No newline at end of file
diff --git a/lib/bridge/location_bridge.dart b/lib/bridge/location_bridge.dart
index 32ee88c..04407a1 100644
--- a/lib/bridge/location_bridge.dart
+++ b/lib/bridge/location_bridge.dart
@@ -92,4 +92,16 @@ class LocationBridge {
return [];
}
}
+
+ static Future openBatteryOptimizationSettings() async {
+ try {
+ final result = await _methodChannel.invokeMethod(
+ 'openBatteryOptimizationSettings',
+ );
+ return result ?? false;
+ } on PlatformException catch (e) {
+ debugPrint('Failed to open battery settings: ${e.message}');
+ return false;
+ }
+ }
}
diff --git a/lib/main.dart b/lib/main.dart
index ea1fe1b..828f71e 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
import 'package:traccar_client/main_screen.dart';
+import 'package:traccar_client/permission_screen.dart';
import 'package:traccar_client/preferences.dart';
void main() async {
@@ -21,7 +23,9 @@ class TraccarClientApp extends StatelessWidget {
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
appBarTheme: const AppBarTheme(centerTitle: true),
),
- home: const MainScreen(),
+ home: Preferences.permissionsGranted
+ ? const MainScreen()
+ : const PermissionScreen(),
);
}
}
diff --git a/lib/permission_screen.dart b/lib/permission_screen.dart
new file mode 100644
index 0000000..4e96d8a
--- /dev/null
+++ b/lib/permission_screen.dart
@@ -0,0 +1,510 @@
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:permission_handler/permission_handler.dart';
+import 'package:traccar_client/bridge/location_bridge.dart';
+import 'package:traccar_client/preferences.dart';
+import 'package:traccar_client/main_screen.dart';
+
+class PermissionScreen extends StatefulWidget {
+ const PermissionScreen({super.key});
+
+ @override
+ State createState() => _PermissionScreenState();
+}
+
+class _PermissionScreenState extends State {
+ bool _locationGranted = false;
+ bool _notificationGranted = false;
+ bool _batteryOptOut = false;
+
+ @override
+ void initState() {
+ super.initState();
+ SystemChrome.setSystemUIOverlayStyle(
+ const SystemUiOverlayStyle(
+ statusBarColor: Color(0xFF0d0d0d),
+ statusBarIconBrightness: Brightness.light,
+ ),
+ );
+ _checkPermissions();
+ }
+
+ Future _checkPermissions() async {
+ final location = await Permission.locationAlways.status;
+ final notification = await Permission.notification.status;
+ // Battery optimization check is Android-only
+ final batteryOptOut = await _isBatteryOptimizationDisabled();
+
+ if (mounted) {
+ setState(() {
+ _locationGranted = location.isGranted;
+ _notificationGranted = notification.isGranted;
+ _batteryOptOut = batteryOptOut;
+ });
+ }
+ }
+
+ Future _isBatteryOptimizationDisabled() async {
+ // On Android, check if battery optimization is disabled for the app
+ // We approximate this - in production you'd use a native method
+ // For now, assume not granted until user says so
+ return false;
+ }
+
+ bool get _allGranted =>
+ _locationGranted && _notificationGranted && _batteryOptOut;
+
+ Future _requestLocation() async {
+ final status = await Permission.locationAlways.request();
+ if (mounted) {
+ setState(() => _locationGranted = status.isGranted);
+ }
+ }
+
+ Future _requestNotification() async {
+ final status = await Permission.notification.request();
+ if (mounted) {
+ setState(() => _notificationGranted = status.isGranted);
+ }
+ }
+
+ Future _requestBatteryOptOut() async {
+ final success = await LocationBridge.openBatteryOptimizationSettings();
+ if (success) {
+ // Show dialog asking user to confirm after disabling battery opt
+ if (mounted) _showBatteryOptDialog();
+ } else {
+ // Fallback to manual instructions dialog
+ if (mounted) _showBatteryOptDialog();
+ }
+ }
+
+ void _showBatteryOptDialog() {
+ showDialog(
+ context: context,
+ builder: (ctx) => AlertDialog(
+ backgroundColor: const Color(0xFF1a1a1a),
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(4),
+ side: const BorderSide(color: Color(0xFF2a2a2a)),
+ ),
+ title: const Text(
+ 'BATTERY OPTIMIZATION',
+ style: TextStyle(
+ fontFamily: 'monospace',
+ fontSize: 14,
+ fontWeight: FontWeight.w700,
+ letterSpacing: 2,
+ color: Color(0xFFe0e0e0),
+ ),
+ ),
+ content: const Text(
+ 'Please disable battery optimization for this app to ensure reliable background tracking.\n\n'
+ 'In the next screen, find "Traccar Client" and set it to "Don\'t optimize" or "Unrestricted".',
+ style: TextStyle(
+ fontFamily: 'monospace',
+ fontSize: 12,
+ color: Color(0xFF9e9e9e),
+ ),
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.pop(ctx),
+ child: const Text(
+ 'OPEN SETTINGS',
+ style: TextStyle(
+ fontFamily: 'monospace',
+ fontSize: 11,
+ fontWeight: FontWeight.w700,
+ letterSpacing: 1,
+ color: Color(0xFF00bcd4),
+ ),
+ ),
+ ),
+ TextButton(
+ onPressed: () {
+ Navigator.pop(ctx);
+ setState(() => _batteryOptOut = true);
+ },
+ child: const Text(
+ 'DONE',
+ style: TextStyle(
+ fontFamily: 'monospace',
+ fontSize: 11,
+ fontWeight: FontWeight.w700,
+ letterSpacing: 1,
+ color: Color(0xFF00e676),
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Future _complete() async {
+ await Preferences.setPermissionsGranted(true);
+ if (mounted) {
+ Navigator.pushReplacement(
+ context,
+ MaterialPageRoute(builder: (_) => const MainScreen()),
+ );
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ backgroundColor: const Color(0xFF0d0d0d),
+ body: SafeArea(
+ child: Column(
+ children: [
+ _buildHeader(),
+ Expanded(
+ child: SingleChildScrollView(
+ padding: const EdgeInsets.all(20),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ _buildIntroCard(),
+ const SizedBox(height: 20),
+ _buildPermissionItem(
+ icon: Icons.location_on,
+ title: 'LOCATION',
+ subtitle: 'All the time access',
+ granted: _locationGranted,
+ onRequest: _requestLocation,
+ ),
+ const SizedBox(height: 12),
+ _buildPermissionItem(
+ icon: Icons.notifications,
+ title: 'NOTIFICATION',
+ subtitle: 'Show sync & tracking alerts',
+ granted: _notificationGranted,
+ onRequest: _requestNotification,
+ ),
+ const SizedBox(height: 12),
+ _buildPermissionItem(
+ icon: Icons.battery_full,
+ title: 'BATTERY',
+ subtitle: 'Disable optimization for reliability',
+ granted: _batteryOptOut,
+ onRequest: _requestBatteryOptOut,
+ ),
+ const SizedBox(height: 32),
+ _buildStatusSummary(),
+ const SizedBox(height: 24),
+ _buildContinueButton(),
+ const SizedBox(height: 20),
+ ],
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Widget _buildHeader() {
+ return Container(
+ padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
+ decoration: const BoxDecoration(
+ color: Color(0xFF161616),
+ border: Border(bottom: BorderSide(color: Color(0xFF2a2a2a), width: 1)),
+ ),
+ child: Row(
+ children: [
+ const Icon(Icons.shield, color: Color(0xFF00bcd4), size: 20),
+ const SizedBox(width: 12),
+ const Text(
+ 'PERMISSIONS',
+ style: TextStyle(
+ fontFamily: 'monospace',
+ fontSize: 16,
+ fontWeight: FontWeight.w700,
+ letterSpacing: 3,
+ color: Color(0xFFe0e0e0),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildIntroCard() {
+ return Container(
+ padding: const EdgeInsets.all(16),
+ decoration: BoxDecoration(
+ color: const Color(0xFF161616),
+ borderRadius: BorderRadius.circular(4),
+ border: Border.all(color: const Color(0xFF2a2a2a), width: 1),
+ ),
+ child: Row(
+ children: [
+ Container(
+ width: 40,
+ height: 40,
+ decoration: BoxDecoration(
+ color: const Color(0xFF00bcd4).withOpacity(0.15),
+ borderRadius: BorderRadius.circular(4),
+ ),
+ child: const Icon(
+ Icons.info_outline,
+ color: Color(0xFF00bcd4),
+ size: 20,
+ ),
+ ),
+ const SizedBox(width: 14),
+ const Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ 'Required Permissions',
+ style: TextStyle(
+ fontFamily: 'monospace',
+ fontSize: 13,
+ fontWeight: FontWeight.w700,
+ letterSpacing: 1,
+ color: Color(0xFFe0e0e0),
+ ),
+ ),
+ SizedBox(height: 4),
+ Text(
+ 'Traccar Client needs these permissions to track your location reliably in the background.',
+ style: TextStyle(
+ fontFamily: 'monospace',
+ fontSize: 11,
+ color: Color(0xFF757575),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildPermissionItem({
+ required IconData icon,
+ required String title,
+ required String subtitle,
+ required bool granted,
+ required VoidCallback onRequest,
+ }) {
+ return Material(
+ color: const Color(0xFF161616),
+ borderRadius: BorderRadius.circular(4),
+ child: InkWell(
+ onTap: granted ? null : onRequest,
+ borderRadius: BorderRadius.circular(4),
+ child: Container(
+ padding: const EdgeInsets.all(14),
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(4),
+ border: Border.all(
+ color: granted
+ ? const Color(0xFF00e676).withOpacity(0.3)
+ : const Color(0xFF2a2a2a),
+ width: 1,
+ ),
+ ),
+ child: Row(
+ children: [
+ Container(
+ width: 36,
+ height: 36,
+ decoration: BoxDecoration(
+ color: granted
+ ? const Color(0xFF00e676).withOpacity(0.15)
+ : const Color(0xFF2a2a2a),
+ borderRadius: BorderRadius.circular(4),
+ ),
+ child: Icon(
+ icon,
+ color: granted
+ ? const Color(0xFF00e676)
+ : const Color(0xFF616161),
+ size: 18,
+ ),
+ ),
+ const SizedBox(width: 14),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ title,
+ style: const TextStyle(
+ fontFamily: 'monospace',
+ fontSize: 12,
+ fontWeight: FontWeight.w700,
+ letterSpacing: 1,
+ color: Color(0xFFe0e0e0),
+ ),
+ ),
+ const SizedBox(height: 2),
+ Text(
+ subtitle,
+ style: const TextStyle(
+ fontFamily: 'monospace',
+ fontSize: 10,
+ color: Color(0xFF616161),
+ ),
+ ),
+ ],
+ ),
+ ),
+ if (granted)
+ Container(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 8,
+ vertical: 4,
+ ),
+ decoration: BoxDecoration(
+ color: const Color(0xFF00e676).withOpacity(0.15),
+ borderRadius: BorderRadius.circular(3),
+ border: Border.all(
+ color: const Color(0xFF00e676).withOpacity(0.4),
+ width: 1,
+ ),
+ ),
+ child: const Text(
+ 'GRANTED',
+ style: TextStyle(
+ fontFamily: 'monospace',
+ fontSize: 9,
+ fontWeight: FontWeight.w700,
+ letterSpacing: 1,
+ color: Color(0xFF00e676),
+ ),
+ ),
+ )
+ else
+ Container(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 8,
+ vertical: 4,
+ ),
+ decoration: BoxDecoration(
+ color: const Color(0xFF00bcd4).withOpacity(0.1),
+ borderRadius: BorderRadius.circular(3),
+ border: Border.all(
+ color: const Color(0xFF00bcd4).withOpacity(0.4),
+ width: 1,
+ ),
+ ),
+ child: const Text(
+ 'TAP TO GRANT',
+ style: TextStyle(
+ fontFamily: 'monospace',
+ fontSize: 9,
+ fontWeight: FontWeight.w700,
+ letterSpacing: 1,
+ color: Color(0xFF00bcd4),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+
+ Widget _buildStatusSummary() {
+ final granted = [
+ _locationGranted,
+ _notificationGranted,
+ _batteryOptOut,
+ ].where((x) => x).length;
+ final total = 3;
+ final progress = granted / total;
+
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ const Icon(
+ Icons.check_circle_outline,
+ color: Color(0xFF00e676),
+ size: 14,
+ ),
+ const SizedBox(width: 8),
+ Text(
+ '$granted / $total GRANTED',
+ style: const TextStyle(
+ fontFamily: 'monospace',
+ fontSize: 11,
+ fontWeight: FontWeight.w700,
+ letterSpacing: 1,
+ color: Color(0xFF00e676),
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 10),
+ Container(
+ height: 4,
+ decoration: BoxDecoration(
+ color: const Color(0xFF2a2a2a),
+ borderRadius: BorderRadius.circular(2),
+ ),
+ child: FractionallySizedBox(
+ alignment: Alignment.centerLeft,
+ widthFactor: progress,
+ child: Container(
+ decoration: BoxDecoration(
+ color: const Color(0xFF00e676),
+ borderRadius: BorderRadius.circular(2),
+ ),
+ ),
+ ),
+ ),
+ ],
+ );
+ }
+
+ Widget _buildContinueButton() {
+ return Material(
+ color: _allGranted
+ ? const Color(0xFF00e676).withOpacity(0.15)
+ : const Color(0xFF2a2a2a),
+ borderRadius: BorderRadius.circular(4),
+ child: InkWell(
+ onTap: _allGranted ? _complete : null,
+ borderRadius: BorderRadius.circular(4),
+ child: Container(
+ width: double.infinity,
+ padding: const EdgeInsets.symmetric(vertical: 16),
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(4),
+ border: Border.all(
+ color: _allGranted
+ ? const Color(0xFF00e676).withOpacity(0.4)
+ : const Color(0xFF2a2a2a),
+ width: 1,
+ ),
+ ),
+ child: Center(
+ child: Text(
+ _allGranted ? 'CONTINUE' : 'GRANT ALL PERMISSIONS',
+ style: TextStyle(
+ fontFamily: 'monospace',
+ fontSize: 13,
+ fontWeight: FontWeight.w700,
+ letterSpacing: 2,
+ color: _allGranted
+ ? const Color(0xFF00e676)
+ : const Color(0xFF616161),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/preferences.dart b/lib/preferences.dart
index 3feb651..085b541 100644
--- a/lib/preferences.dart
+++ b/lib/preferences.dart
@@ -12,6 +12,7 @@ class Preferences {
static const String keyHeartbeat = 'heartbeat';
static const String keyOfflineBuffer = 'offline_buffer';
static const String keyStopDetection = 'stop_detection';
+ static const String keyPermissionsGranted = 'permissions_granted';
static SharedPreferences? _instance;
@@ -52,6 +53,13 @@ class Preferences {
static bool get stopDetection => instance.getBool(keyStopDetection) ?? true;
+ static bool get permissionsGranted =>
+ instance.getBool(keyPermissionsGranted) ?? false;
+
+ static Future setPermissionsGranted(bool value) async {
+ await instance.setBool(keyPermissionsGranted, value);
+ }
+
static Future setServerUrl(String value) async {
await instance.setString(keyServerUrl, value);
}