Add Android background geolocation with notification error handling
Add flutter_background_geolocation for background location tracking on Android. Service automatically sends coordinates to server when app is in background. Error messages are shown as system notifications using flutter_local_notifications.
This commit is contained in:
@@ -2,11 +2,23 @@
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:label="family_safety_frontend"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<service
|
||||
android:name="com.transistorsoft.flutter.backgroundgeolocation.HeadlessJobService"
|
||||
android:enabled="true"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="location"
|
||||
android:permission="android.permission.BIND_JOB_SERVICE" />
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
|
||||
+11
-2
@@ -6,6 +6,7 @@ import 'providers/map_provider.dart';
|
||||
import 'providers/share_provider.dart';
|
||||
import 'screens/login_screen.dart';
|
||||
import 'screens/map_screen.dart';
|
||||
import 'services/background_geo_service.dart';
|
||||
import 'services/settings_service.dart';
|
||||
|
||||
void main() async {
|
||||
@@ -13,13 +14,20 @@ void main() async {
|
||||
final settings = SettingsService();
|
||||
await settings.initialize();
|
||||
ApiConfig.setBaseUrl(settings.baseUrl);
|
||||
runApp(MyApp(settingsService: settings));
|
||||
final bgGeo = BackgroundGeoService();
|
||||
await bgGeo.init();
|
||||
runApp(MyApp(settingsService: settings, bgGeo: bgGeo));
|
||||
}
|
||||
|
||||
class MyApp extends StatelessWidget {
|
||||
final SettingsService settingsService;
|
||||
final BackgroundGeoService bgGeo;
|
||||
|
||||
const MyApp({super.key, required this.settingsService});
|
||||
const MyApp({
|
||||
super.key,
|
||||
required this.settingsService,
|
||||
required this.bgGeo,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -29,6 +37,7 @@ class MyApp extends StatelessWidget {
|
||||
ChangeNotifierProvider(create: (_) => AuthProvider()),
|
||||
ChangeNotifierProvider(create: (_) => MapProvider()),
|
||||
ChangeNotifierProvider(create: (_) => ShareProvider()),
|
||||
Provider.value(value: bgGeo),
|
||||
],
|
||||
child: MaterialApp(
|
||||
title: 'Family Safety',
|
||||
|
||||
@@ -9,6 +9,7 @@ import '../providers/auth_provider.dart';
|
||||
import '../providers/map_provider.dart';
|
||||
import '../providers/share_provider.dart';
|
||||
import '../services/geo_service.dart';
|
||||
import '../services/background_geo_service.dart';
|
||||
|
||||
class MapScreen extends StatefulWidget {
|
||||
const MapScreen({super.key});
|
||||
@@ -69,6 +70,14 @@ class _MapScreenState extends State<MapScreen> {
|
||||
position.longitude,
|
||||
position.latitude,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
final bgGeo = context.read<BackgroundGeoService>();
|
||||
bgGeo.configure(
|
||||
token: authProvider.token,
|
||||
geoId: shareProvider.geoId,
|
||||
);
|
||||
await bgGeo.start();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
@@ -219,8 +228,11 @@ class _MapScreenState extends State<MapScreen> {
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.logout),
|
||||
onPressed: () {
|
||||
onPressed: () async {
|
||||
_geoTimer?.cancel();
|
||||
final bgGeo = context.read<BackgroundGeoService>();
|
||||
await bgGeo.stop();
|
||||
bgGeo.dispose();
|
||||
authProvider.logout();
|
||||
Navigator.of(context).pushReplacementNamed('/');
|
||||
},
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_background_geolocation/flutter_background_geolocation.dart' as bg;
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../config/api.dart';
|
||||
|
||||
class BackgroundGeoService {
|
||||
static final BackgroundGeoService _instance = BackgroundGeoService._init();
|
||||
|
||||
factory BackgroundGeoService() => _instance;
|
||||
|
||||
BackgroundGeoService._init();
|
||||
|
||||
final http.Client _client = http.Client();
|
||||
final FlutterLocalNotificationsPlugin _notifications =
|
||||
FlutterLocalNotificationsPlugin();
|
||||
String? _token;
|
||||
String? _geoId;
|
||||
bool _isReady = false;
|
||||
bool _isStarted = false;
|
||||
bool _notificationsInitialized = false;
|
||||
|
||||
bool get isReady => _isReady;
|
||||
bool get isStarted => _isStarted;
|
||||
String? get token => _token;
|
||||
String? get geoId => _geoId;
|
||||
|
||||
Future<void> initNotifications() async {
|
||||
if (_notificationsInitialized) return;
|
||||
|
||||
const androidSettings = AndroidInitializationSettings(
|
||||
'@mipmap/ic_launcher',
|
||||
);
|
||||
const iosSettings = DarwinInitializationSettings(
|
||||
requestAlertPermission: true,
|
||||
requestBadgePermission: true,
|
||||
requestSoundPermission: true,
|
||||
);
|
||||
const initSettings = InitializationSettings(
|
||||
android: androidSettings,
|
||||
iOS: iosSettings,
|
||||
);
|
||||
|
||||
await _notifications.initialize(
|
||||
initSettings,
|
||||
onDidReceiveNotificationResponse: _onNotificationResponse,
|
||||
);
|
||||
|
||||
_notificationsInitialized = true;
|
||||
}
|
||||
|
||||
void configure({required String token, required String geoId}) {
|
||||
_token = token;
|
||||
_geoId = geoId;
|
||||
}
|
||||
|
||||
Future<void> init() async {
|
||||
if (_isReady) return;
|
||||
|
||||
await initNotifications();
|
||||
|
||||
bg.BackgroundGeolocation.onLocation(_onLocation, _onLocationError);
|
||||
bg.BackgroundGeolocation.onMotionChange(_onMotionChange);
|
||||
bg.BackgroundGeolocation.onProviderChange(_onProviderChange);
|
||||
bg.BackgroundGeolocation.onHttp(_onHttp);
|
||||
|
||||
await bg.BackgroundGeolocation.ready(bg.Config(
|
||||
desiredAccuracy: bg.Config.DESIRED_ACCURACY_HIGH,
|
||||
distanceFilter: 10.0,
|
||||
stopOnTerminate: false,
|
||||
startOnBoot: true,
|
||||
debug: false,
|
||||
logLevel: bg.Config.LOG_LEVEL_OFF,
|
||||
autoSync: false,
|
||||
));
|
||||
|
||||
_isReady = true;
|
||||
}
|
||||
|
||||
Future<void> start() async {
|
||||
if (!_isReady) return;
|
||||
if (_isStarted) return;
|
||||
if (_token == null || _geoId == null || _geoId!.isEmpty) return;
|
||||
|
||||
await bg.BackgroundGeolocation.start();
|
||||
_isStarted = true;
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
if (!_isStarted) return;
|
||||
await bg.BackgroundGeolocation.stop();
|
||||
_isStarted = false;
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
bg.BackgroundGeolocation.removeListeners();
|
||||
_isReady = false;
|
||||
_isStarted = false;
|
||||
_token = null;
|
||||
_geoId = null;
|
||||
}
|
||||
|
||||
void _onLocation(bg.Location location) {
|
||||
if (_token == null || _geoId == null || _geoId!.isEmpty) return;
|
||||
if (location.sample == true) return;
|
||||
|
||||
_sendPosition(location.coords.latitude, location.coords.longitude);
|
||||
}
|
||||
|
||||
void _onLocationError(bg.LocationError error) {
|
||||
_showErrorNotification('Ошибка геолокации: $error');
|
||||
}
|
||||
|
||||
void _onMotionChange(bg.Location location) {
|
||||
kDebugMode? print('[BackgroundGeo] Motion changed: isMoving=${location.isMoving}'):{};
|
||||
}
|
||||
|
||||
void _onProviderChange(bg.ProviderChangeEvent event) {
|
||||
kDebugMode?print('[BackgroundGeo] Provider changed: $event'):{};
|
||||
}
|
||||
|
||||
void _onHttp(bg.HttpEvent event) {
|
||||
kDebugMode? print('[BackgroundGeo] HTTP: status=${event.status}, success=${event.success}'):{};
|
||||
}
|
||||
|
||||
Future<void> _sendPosition(double latitude, double longitude) async {
|
||||
if (_token == null || _geoId == null || _geoId!.isEmpty) return;
|
||||
|
||||
try {
|
||||
final response = await _client.put(
|
||||
Uri.parse('${ApiConfig.geoUrl}?id=$_geoId'),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer $_token',
|
||||
},
|
||||
body: jsonEncode({
|
||||
'x': longitude,
|
||||
'y': latitude,
|
||||
}),
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
_showErrorNotification(
|
||||
'Не удалось отправить позицию. Код ответа: ${response.statusCode}',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
_showErrorNotification('Ошибка отправки позиции: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showErrorNotification(String message) async {
|
||||
if (!_notificationsInitialized) return;
|
||||
|
||||
const androidDetails = AndroidNotificationDetails(
|
||||
'background_geo_errors',
|
||||
'Background Geo Errors',
|
||||
channelDescription: 'Уведомления об ошибках геолокации в фоне',
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
ticker: 'geo_error',
|
||||
);
|
||||
const iosDetails = DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
sound: 'default',
|
||||
);
|
||||
const details = NotificationDetails(
|
||||
android: androidDetails,
|
||||
iOS: iosDetails,
|
||||
);
|
||||
|
||||
await _notifications.show(
|
||||
0,
|
||||
'Ошибка геолокации',
|
||||
message,
|
||||
details,
|
||||
payload: 'geo_error',
|
||||
);
|
||||
}
|
||||
|
||||
void _onNotificationResponse(NotificationResponse response) {
|
||||
print('[Notification] ${response.payload}');
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,13 @@
|
||||
import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import flutter_local_notifications
|
||||
import geolocator_apple
|
||||
import package_info_plus
|
||||
import shared_preferences_foundation
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
||||
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
|
||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
|
||||
+50
-10
@@ -17,6 +17,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.13.1"
|
||||
background_fetch:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: background_fetch
|
||||
sha256: a185b471f6ff93c0074e2da98fbdf1e0c5af5a83adb97dfe924a9ef06c9e0175
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.7.0"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -49,16 +57,8 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
convert:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: convert
|
||||
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
crypto:
|
||||
dependency: "direct main"
|
||||
dependency: transitive
|
||||
description:
|
||||
name: crypto
|
||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||
@@ -118,6 +118,14 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_background_geolocation:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_background_geolocation
|
||||
sha256: "2c5f23bf35837b31c4e1badadf7fb5da418a1862133b5a671b3e0bfa60c3bf2f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.18.3"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@@ -126,6 +134,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
flutter_local_notifications:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_local_notifications
|
||||
sha256: ef41ae901e7529e52934feba19ed82827b11baa67336829564aeab3129460610
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "18.0.1"
|
||||
flutter_local_notifications_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_linux
|
||||
sha256: "8f685642876742c941b29c32030f6f4f6dacd0e4eaecb3efbb187d6a3812ca01"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.0.0"
|
||||
flutter_local_notifications_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_platform_interface
|
||||
sha256: "6c5b83c86bf819cdb177a9247a3722067dd8cc6313827ce7c77a4b238a26fd52"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.0.0"
|
||||
flutter_localizations:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
@@ -546,6 +578,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.7"
|
||||
timezone:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: timezone
|
||||
sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.10.1"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -628,4 +668,4 @@ packages:
|
||||
version: "6.6.1"
|
||||
sdks:
|
||||
dart: ">=3.10.1 <4.0.0"
|
||||
flutter: ">=3.35.0"
|
||||
flutter: ">=3.38.0"
|
||||
|
||||
@@ -42,6 +42,8 @@ dependencies:
|
||||
latlong2: ^0.9.1
|
||||
geolocator: ^14.0.2
|
||||
shared_preferences: ^2.2.2
|
||||
flutter_background_geolocation: ^4.13.0
|
||||
flutter_local_notifications: ^18.0.1
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
@@ -9,12 +9,17 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:family_safety_frontend/main.dart';
|
||||
import 'package:family_safety_frontend/services/background_geo_service.dart';
|
||||
import 'package:family_safety_frontend/services/settings_service.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('App loads login screen', (WidgetTester tester) async {
|
||||
final bgGeo = BackgroundGeoService();
|
||||
await bgGeo.init();
|
||||
await bgGeo.initNotifications();
|
||||
await tester.pumpWidget(MyApp(
|
||||
settingsService: SettingsService(),
|
||||
bgGeo: bgGeo,
|
||||
));
|
||||
await tester.pump();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user