feat: add permission screen shown before main screen

This commit is contained in:
fiatcode 2026-04-30 16:11:45 +07:00
parent a7a6a6a82d
commit 650a6efeca
No known key found for this signature in database
6 changed files with 562 additions and 1 deletions

View file

@ -7,6 +7,7 @@
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<application
android:label="traccar_client"

View file

@ -87,6 +87,9 @@ class BridgeModule : FlutterPlugin, MethodChannel.MethodCallHandler {
val limit = (call.arguments as? Number)?.toInt() ?: 100
fetchLogs(limit, result)
}
"openBatteryOptimizationSettings" -> {
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)
}
}
}
}

View file

@ -92,4 +92,16 @@ class LocationBridge {
return [];
}
}
static Future<bool> openBatteryOptimizationSettings() async {
try {
final result = await _methodChannel.invokeMethod<bool>(
'openBatteryOptimizationSettings',
);
return result ?? false;
} on PlatformException catch (e) {
debugPrint('Failed to open battery settings: ${e.message}');
return false;
}
}
}

View file

@ -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(),
);
}
}

510
lib/permission_screen.dart Normal file
View file

@ -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<PermissionScreen> createState() => _PermissionScreenState();
}
class _PermissionScreenState extends State<PermissionScreen> {
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<void> _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<bool> _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<void> _requestLocation() async {
final status = await Permission.locationAlways.request();
if (mounted) {
setState(() => _locationGranted = status.isGranted);
}
}
Future<void> _requestNotification() async {
final status = await Permission.notification.request();
if (mounted) {
setState(() => _notificationGranted = status.isGranted);
}
}
Future<void> _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<void> _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),
),
),
),
),
),
);
}
}

View file

@ -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<void> setPermissionsGranted(bool value) async {
await instance.setBool(keyPermissionsGranted, value);
}
static Future<void> setServerUrl(String value) async {
await instance.setString(keyServerUrl, value);
}