-
-
Notifications
You must be signed in to change notification settings - Fork 23
Expand file tree
/
Copy pathservice.dart
More file actions
470 lines (375 loc) · 15.5 KB
/
Copy pathservice.dart
File metadata and controls
470 lines (375 loc) · 15.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
import 'dart:async';
import 'dart:io';
import 'dart:ui';
import 'package:dpip/core/i18n.dart';
import 'package:flutter/services.dart';
import 'package:awesome_notifications/awesome_notifications.dart';
import 'package:flutter_background_service/flutter_background_service.dart';
import 'package:geojson_vi/geojson_vi.dart';
import 'package:geolocator/geolocator.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:dpip/api/exptech.dart';
import 'package:dpip/api/model/location/location.dart';
import 'package:dpip/core/preference.dart';
import 'package:dpip/core/providers.dart';
import 'package:dpip/global.dart';
import 'package:dpip/utils/extensions/datetime.dart';
import 'package:dpip/utils/extensions/latlng.dart';
import 'package:dpip/utils/log.dart';
class PositionEvent {
final LatLng? coordinates;
final String? code;
PositionEvent(this.coordinates, this.code);
factory PositionEvent.fromJson(Map<String, dynamic> json) {
final coordinates = json['coordinates'] as List<dynamic>?;
final code = json['code'] as String?;
return PositionEvent(coordinates != null ? LatLng(coordinates[0] as double, coordinates[1] as double) : null, code);
}
Map<String, dynamic> toJson() {
return {'coordinates': coordinates?.toJson(), 'code': code};
}
}
/// Events emitted by the background service.
final class LocationServiceEvent {
/// Event emitted when a new position is set in the background service. Contains the updated location coordinates.
static const position = 'position';
/// Method event to stop the service.
static const stop = 'stop';
}
/// Background location service.
///
/// This class is responsible for managing the background location service. It is used to handle start and stop the
/// service.
class LocationServiceManager {
LocationServiceManager._();
/// The notification ID used for the background service notification
static const kNotificationId = 888888;
/// Instance of the background service
static FlutterBackgroundService? instance;
/// Platform channel for iOS
static const platform = MethodChannel('com.exptech.dpip/location');
/// Whether the background service is available on the current platform
static bool get avaliable => Platform.isAndroid || Platform.isIOS;
/// Initializes the background location service.
///
/// Configures the service with Android specific settings. Sets up a listener for position updates that reloads
/// preferences and updates device location.
///
/// Will starts the service if automatic location updates are enabled.
///
/// This method is Android specific.
static Future<void> initalize() async {
if (instance != null || !Platform.isAndroid) return;
TalkerManager.instance.info('👷 initializing location service');
final service = FlutterBackgroundService();
try {
await service.configure(
androidConfiguration: AndroidConfiguration(
onStart: LocationService._$onStart,
autoStart: false,
isForegroundMode: false,
foregroundServiceTypes: [AndroidForegroundType.location],
notificationChannelId: 'background',
initialNotificationTitle: 'DPIP',
initialNotificationContent: '正在初始化自動定位服務...',
foregroundServiceNotificationId: kNotificationId,
),
// iOS is handled in native code
iosConfiguration: IosConfiguration(autoStart: false),
);
// Reloads the UI isolate's preference cache when a new position is set in the background service.
service.on(LocationServiceEvent.position).listen((data) => _onPosition(PositionEvent.fromJson(data!)));
instance = service;
TalkerManager.instance.info('👷 service initialized');
} catch (e, s) {
TalkerManager.instance.error('👷 initializing location service FAILED', e, s);
}
if (Preference.locationAuto == true) await start();
}
/// Starts the background location service.
///
/// Initializes the service if not already initialized. Only starts if the service is not already running.
static Future<void> start() async {
if (!avaliable) return;
TalkerManager.instance.info('👷 starting location service');
try {
if (Platform.isIOS) {
await platform.invokeMethod('toggleLocation', {'isEnabled': true});
return;
}
final service = instance;
if (service == null) throw Exception('Not initialized.');
if (await service.isRunning()) {
TalkerManager.instance.warning('👷 location service is already running, skipping...');
return;
}
await service.startService();
} catch (e, s) {
TalkerManager.instance.error('👷 starting location service FAILED', e, s);
}
}
/// Stops the background location service by invoking the stop event.
static Future<void> stop() async {
if (!avaliable) return;
TalkerManager.instance.info('👷 stopping location service');
try {
if (Platform.isIOS) {
await platform.invokeMethod('toggleLocation', {'isEnabled': false});
return;
}
final service = instance;
if (service == null) throw Exception('Not initialized.');
service.invoke(LocationServiceEvent.stop);
} catch (e, s) {
TalkerManager.instance.error('👷 stopping location service FAILED', e, s);
}
}
/// The event handler for the "position" event.
///
/// Called when the service has updated the current location.
static Future<void> _onPosition(PositionEvent event) async {
try {
TalkerManager.instance.info('👷 location updated by service, reloading preferences');
await Preference.reload();
GlobalProviders.location.refresh();
final fcmToken = Preference.notifyToken;
if (fcmToken.isNotEmpty && event.coordinates != null) {
await ExpTech().updateDeviceLocation(token: fcmToken, coordinates: event.coordinates!);
}
TalkerManager.instance.info('👷 preferences reloaded');
} catch (e, s) {
TalkerManager.instance.error('👷 failed to update location', e, s);
}
}
}
/// The background location service.
///
/// This service is used to get the current location of the device in the background and notify the main isolate to
/// update the UI with the new location.
///
/// All property prefixed with `_$` are isolated from the main app.
@pragma('vm:entry-point')
class LocationService {
LocationService._();
/// The service instance
static late AndroidServiceInstance _$service;
/// The last known location coordinates
static LatLng? _$location;
/// Timer for scheduling periodic location updates
static Timer? _$locationUpdateTimer;
/// Cached GeoJSON data for location lookups
static late GeoJSONFeatureCollection _$geoJsonData;
/// Cached location data mapping
static late Map<String, Location> _$locationData;
/// Entry point for the background service.
///
/// Sets up notifications, initializes required data, and starts periodic location updates. Updates the notification
/// with current location information. Adjusts update frequency based on movement distance.
@pragma('vm:entry-point')
static Future<void> _$onStart(ServiceInstance service) async {
if (service is! AndroidServiceInstance) return;
_$service = service;
DartPluginRegistrant.ensureInitialized();
await Preference.init();
await AppLocalizations.load();
await LocationNameLocalizations.load();
if (Preference.locationAuto != true) {
await _$onStop();
return;
}
_$geoJsonData = await Global.loadTownGeojson();
_$locationData = await Global.loadLocationData();
_$service.setAutoStartOnBootMode(true);
await AwesomeNotifications().createNotification(
content: NotificationContent(
id: LocationServiceManager.kNotificationId,
channelKey: 'background',
title: 'DPIP',
body: '自動定位服務啟動中...',
locked: true,
autoDismissible: false,
icon: 'resource://drawable/ic_stat_name',
badge: 0,
),
);
await _$service.setAsForegroundService();
_$service.on(LocationServiceEvent.stop).listen((_) => _$onStop());
// Start the periodic location update task
await _$task();
_$locationUpdateTimer = Timer.periodic(const Duration(minutes: 10), (timer) => _$task());
}
/// The main tick function of the service.
///
/// This function is used to get the current location of the device and update the notification. It is called
/// periodically to check if the device has moved and update the notification accordingly.
@pragma('vm:entry-point')
static Future<void> _$task() async {
if (!await _$service.isForegroundService()) return;
final $perf = Stopwatch()..start();
TalkerManager.instance.debug('⚙️::BackgroundLocationService task started');
try {
// Get current position and location info
final coordinates = await _$getDeviceGeographicalLocation();
if (coordinates == null) {
_$updatePosition(_$service, null);
return;
}
final previousLocation = _$location;
final distanceInMeters = previousLocation != null ? coordinates.to(previousLocation) : null;
if (distanceInMeters == null || distanceInMeters >= 250) {
TalkerManager.instance.debug('⚙️::BackgroundLocationService distance: $distanceInMeters, updating position');
_$updatePosition(_$service, coordinates);
} else {
TalkerManager.instance.debug(
'⚙️::BackgroundLocationService distance: $distanceInMeters, not updating position',
);
}
} catch (e, s) {
$perf.stop();
TalkerManager.instance.error(
'⚙️::BackgroundLocationService task FAILED after ${$perf.elapsedMilliseconds}ms',
e,
s,
);
} finally {
if ($perf.isRunning) {
$perf.stop();
TalkerManager.instance.debug('⚙️::BackgroundLocationService task completed in ${$perf.elapsedMilliseconds}ms');
}
}
}
/// The event handler for the "stop" event.
///
/// Called when the service manager sends a stop signal to terminate the location service.
@pragma('vm:entry-point')
static Future<void> _$onStop() async {
try {
TalkerManager.instance.info('⚙️::BackgroundLocationService stopping location service');
// Cleanup timer
_$locationUpdateTimer?.cancel();
await _$service.setAutoStartOnBootMode(false);
await _$service.stopSelf();
TalkerManager.instance.info('⚙️::BackgroundLocationService location service stopped');
} catch (e, s) {
TalkerManager.instance.error('⚙️::BackgroundLocationService stopping location service FAILED', e, s);
}
}
/// Gets the current geographical location of the device.
///
/// Returns null if location services are disabled. Uses medium accuracy for location detection.
@pragma('vm:entry-point')
static Future<LatLng?> _$getDeviceGeographicalLocation() async {
final isLocationServiceEnabled = await Geolocator.isLocationServiceEnabled();
if (!isLocationServiceEnabled) {
TalkerManager.instance.warning('⚙️::BackgroundLocationService location service is not available');
return null;
}
final currentPosition = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(accuracy: LocationAccuracy.medium),
);
return LatLng(currentPosition.latitude, currentPosition.longitude);
}
/// Gets the location code for given coordinates by checking if they fall within polygon boundaries.
///
/// Takes a target LatLng and checks if it falls within any polygon in the GeoJSON data. Returns the location code if
/// found, null otherwise.
static ({String code, Location location})? _$getLocationFromCoordinates(LatLng target) {
final features = _$geoJsonData.features;
for (final feature in features) {
if (feature == null) continue;
final geometry = feature.geometry;
if (geometry == null) continue;
bool isInPolygon = false;
if (geometry is GeoJSONPolygon) {
final polygon = geometry.coordinates[0];
bool isInside = false;
int j = polygon.length - 1;
for (int i = 0; i < polygon.length; i++) {
final double xi = polygon[i][0];
final double yi = polygon[i][1];
final double xj = polygon[j][0];
final double yj = polygon[j][1];
final bool intersect =
((yi > target.latitude) != (yj > target.latitude)) &&
(target.longitude < (xj - xi) * (target.latitude - yi) / (yj - yi) + xi);
if (intersect) isInside = !isInside;
j = i;
}
isInPolygon = isInside;
}
if (geometry is GeoJSONMultiPolygon) {
final multiPolygon = geometry.coordinates;
for (final polygonCoordinates in multiPolygon) {
final polygon = polygonCoordinates[0];
bool isInside = false;
int j = polygon.length - 1;
for (int i = 0; i < polygon.length; i++) {
final double xi = polygon[i][0];
final double yi = polygon[i][1];
final double xj = polygon[j][0];
final double yj = polygon[j][1];
final bool intersect =
((yi > target.latitude) != (yj > target.latitude)) &&
(target.longitude < (xj - xi) * (target.latitude - yi) / (yj - yi) + xi);
if (intersect) isInside = !isInside;
j = i;
}
if (isInside) {
isInPolygon = true;
break;
}
}
}
if (isInPolygon) {
final code = feature.properties!['CODE']?.toString();
if (code == null) return null;
final location = _$locationData[code];
if (location == null) return null;
return (code: code, location: location);
}
}
return null;
}
/// Updates the current position in the service.
///
/// Invokes a position event with the new coordinates that can be listened to by the main app to update the UI.
@pragma('vm:entry-point')
static Future<void> _$updatePosition(ServiceInstance service, LatLng? position) async {
_$location = position;
final result = position != null ? _$getLocationFromCoordinates(position) : null;
Preference.locationCode = result?.code;
Preference.locationLatitude = position?.latitude;
Preference.locationLongitude = position?.longitude;
service.invoke(LocationServiceEvent.position, PositionEvent(position, result?.code).toJson());
// Update notification with current position
final timestamp = DateTime.now().toDateTimeString();
String content = '服務區域外'.i18n;
if (position == null) {
content = '服務區域外'.i18n;
} else {
final latitude = position.latitude.toStringAsFixed(6);
final longitude = position.longitude.toStringAsFixed(6);
if (result == null) {
content = '${'服務區域外'.i18n} ($latitude, $longitude)';
} else {
content = '${result.location.cityWithLevel} ${result.location.townWithLevel} ($latitude, $longitude)';
}
}
final notificationTitle = '自動定位中'.i18n;
final notificationBody =
'$timestamp\n'
'$content';
await AwesomeNotifications().createNotification(
content: NotificationContent(
id: LocationServiceManager.kNotificationId,
channelKey: 'background',
title: notificationTitle,
body: notificationBody,
locked: true,
autoDismissible: false,
badge: 0,
),
);
_$service.setForegroundNotificationInfo(title: notificationTitle, content: notificationBody);
}
}