From 650a6efecaa78791d3fcf3b22362d8315307b21f Mon Sep 17 00:00:00 2001 From: fiatcode Date: Thu, 30 Apr 2026 16:11:45 +0700 Subject: [PATCH] feat: add permission screen shown before main screen --- android/app/src/main/AndroidManifest.xml | 1 + .../traccar/traccar_client/BridgeModule.kt | 26 + lib/bridge/location_bridge.dart | 12 + lib/main.dart | 6 +- lib/permission_screen.dart | 510 ++++++++++++++++++ lib/preferences.dart | 8 + 6 files changed, 562 insertions(+), 1 deletion(-) create mode 100644 lib/permission_screen.dart 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); }