From a3e20acdc1658dda17943e4a75260ce8381d0649 Mon Sep 17 00:00:00 2001 From: Kamiya Date: Thu, 7 Aug 2025 22:03:57 +0800 Subject: [PATCH 01/21] refactor: background service --- android/app/src/main/AndroidManifest.xml | 4 +- lib/api/exptech.dart | 5 +- lib/app/home/_widgets/eew_card.dart | 59 +- lib/app/home/_widgets/thunderstorm_card.dart | 2 +- lib/app/home/page.dart | 6 +- lib/app/map/_lib/managers/monitor.dart | 660 +++++++++++------- lib/app/map/_lib/managers/precipitation.dart | 4 +- lib/app/map/_lib/managers/radar.dart | 4 +- lib/app/map/_lib/managers/temperature.dart | 4 +- lib/app/settings/location/page.dart | 101 ++- .../settings/location/select/[city]/page.dart | 56 +- lib/app/settings/notify/page.dart | 5 +- lib/app_old/page/map/radar/radar.dart | 40 +- lib/core/ios_get_location.dart | 6 +- lib/core/location.dart | 89 --- lib/core/notify.dart | 18 +- lib/core/preference.dart | 8 + lib/core/service.dart | 458 +++++++----- lib/core/update.dart | 15 +- lib/global.dart | 9 +- lib/main.dart | 2 +- lib/models/settings/location.dart | 195 ++++-- lib/route/event_viewer/intensity.dart | 60 +- lib/route/event_viewer/thunderstorm.dart | 82 +-- lib/utils/extensions/datetime.dart | 11 +- lib/utils/extensions/int.dart | 2 +- lib/utils/extensions/latlng.dart | 19 +- lib/utils/extensions/preference.dart | 40 +- lib/utils/extensions/string.dart | 2 +- lib/utils/location_to_code.dart | 66 +- lib/widgets/map/map.dart | 8 +- 31 files changed, 1073 insertions(+), 967 deletions(-) delete mode 100644 lib/core/location.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 0ed01c6a7..fc7f27a2e 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,5 +1,4 @@ - + @@ -8,6 +7,7 @@ + diff --git a/lib/api/exptech.dart b/lib/api/exptech.dart index 60c467718..e397f1a28 100644 --- a/lib/api/exptech.dart +++ b/lib/api/exptech.dart @@ -25,6 +25,7 @@ import 'package:dpip/models/settings/notify.dart'; import 'package:dpip/utils/extensions/response.dart'; import 'package:dpip/utils/extensions/string.dart'; import 'package:http/http.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; class ExpTech { String? apikey; @@ -486,8 +487,8 @@ class ExpTech { } /// 回傳所在地 - Future updateDeviceLocation({required String token, required String lat, required String lng}) async { - final requestUrl = Route.location(token: token, lat: lat, lng: lng); + Future updateDeviceLocation({required String token, required LatLng coordinates}) async { + final requestUrl = Route.location(token: token, lat: '${coordinates.latitude}', lng: '${coordinates.longitude}'); final res = await get(requestUrl); diff --git a/lib/app/home/_widgets/eew_card.dart b/lib/app/home/_widgets/eew_card.dart index 6c7f8cb2f..7a300a8c2 100644 --- a/lib/app/home/_widgets/eew_card.dart +++ b/lib/app/home/_widgets/eew_card.dart @@ -28,14 +28,16 @@ class EewCard extends StatefulWidget { } class _EewCardState extends State { - late int localIntensity; - late int localArrivalTime; + int? localIntensity; + int? localArrivalTime; int countdown = 0; Timer? _timer; void _updateCountdown() { - final remainingSeconds = ((localArrivalTime - GlobalProviders.data.currentTime) / 1000).floor(); + if (localArrivalTime == null) return; + + final remainingSeconds = ((localArrivalTime! - GlobalProviders.data.currentTime) / 1000).floor(); if (remainingSeconds < -1) return; setState(() => countdown = remainingSeconds); @@ -45,17 +47,19 @@ class _EewCardState extends State { void initState() { super.initState(); - final info = eewLocationInfo( - widget.data.info.magnitude, - widget.data.info.depth, - widget.data.info.latitude, - widget.data.info.longitude, - GlobalProviders.location.coordinateNotifier.value.latitude, - GlobalProviders.location.coordinateNotifier.value.longitude, - ); + if (GlobalProviders.location.coordinates != null) { + final info = eewLocationInfo( + widget.data.info.magnitude, + widget.data.info.depth, + widget.data.info.latitude, + widget.data.info.longitude, + GlobalProviders.location.coordinates!.latitude, + GlobalProviders.location.coordinates!.longitude, + ); - localIntensity = intensityFloatToInt(info.i); - localArrivalTime = (widget.data.info.time + sWaveTimeByDistance(widget.data.info.depth, info.dist)).floor(); + localIntensity = intensityFloatToInt(info.i); + localArrivalTime = (widget.data.info.time + sWaveTimeByDistance(widget.data.info.depth, info.dist)).floor(); + } _updateCountdown(); _timer = Timer.periodic(const Duration(seconds: 1), (_) => _updateCountdown()); @@ -118,14 +122,23 @@ class _EewCardState extends State { padding: const EdgeInsets.only(top: 8), child: StyledText( text: - '{time} 左右,{location}附近發生有感地震,預估規模 M{magnitude}、所在地最大震度{intensity}。' - .i18n - .args({ - 'time': widget.data.info.time.toSimpleDateTimeString(context), - 'location': widget.data.info.location, - 'magnitude': widget.data.info.magnitude.toStringAsFixed(1), - 'intensity': localIntensity.asIntensityLabel, - }), + localIntensity != null + ? '{time} 左右,{location}附近發生有感地震,預估規模 M{magnitude}、所在地最大震度{intensity}。' + .i18n + .args({ + 'time': widget.data.info.time.toSimpleDateTimeString(context), + 'location': widget.data.info.location, + 'magnitude': widget.data.info.magnitude.toStringAsFixed(1), + 'intensity': localIntensity!.asIntensityLabel, + }) + : '{time} 左右,{location}附近發生有感地震,預估規模 M{magnitude}、深度{depth}公里。' + .i18n + .args({ + 'time': widget.data.info.time.toSimpleDateTimeString(context), + 'location': widget.data.info.location, + 'magnitude': widget.data.info.magnitude.toStringAsFixed(1), + 'depth': widget.data.info.depth.toStringAsFixed(1), + }), style: context.textTheme.bodyLarge!.copyWith(color: context.colors.onErrorContainer), tags: {'bold': StyledTextTag(style: const TextStyle(fontWeight: FontWeight.bold))}, ), @@ -133,7 +146,7 @@ class _EewCardState extends State { Selector( selector: (context, model) => model.code, builder: (context, code, child) { - if (code == null) { + if (code == null || localIntensity == null) { return const SizedBox.shrink(); } @@ -160,7 +173,7 @@ class _EewCardState extends State { Padding( padding: const EdgeInsets.only(top: 12, bottom: 8), child: Text( - localIntensity.asIntensityLabel, + localIntensity!.asIntensityLabel, style: context.textTheme.displayMedium!.copyWith( fontWeight: FontWeight.bold, color: context.colors.onErrorContainer, diff --git a/lib/app/home/_widgets/thunderstorm_card.dart b/lib/app/home/_widgets/thunderstorm_card.dart index b705d7367..d9f2e34cc 100644 --- a/lib/app/home/_widgets/thunderstorm_card.dart +++ b/lib/app/home/_widgets/thunderstorm_card.dart @@ -74,7 +74,7 @@ class ThunderstormCard extends StatelessWidget { padding: const EdgeInsets.only(top: 8), child: StyledText( text: '您所在區域附近有劇烈雷雨或降雨發生,請注意防範,持續至 {time} 。'.i18n.args({ - 'time': history.time.expiresAt.toSimpleDateTimeString(context), + 'time': history.time.expiresAt.toSimpleDateTimeString(), }), style: context.textTheme.bodyLarge!.copyWith(color: context.theme.extendedColors.onBlueContainer), tags: {'bold': StyledTextTag(style: const TextStyle(fontWeight: FontWeight.bold))}, diff --git a/lib/app/home/page.dart b/lib/app/home/page.dart index 402982f1e..a8c62aa3a 100644 --- a/lib/app/home/page.dart +++ b/lib/app/home/page.dart @@ -63,7 +63,7 @@ class _HomePageState extends State { if (_isLoading) return; final auto = GlobalProviders.location.auto; - final code = GlobalProviders.location.codeNotifier.value; + final code = GlobalProviders.location.code; final location = Global.location[code]; if (code == null || location == null) { @@ -123,7 +123,7 @@ class _HomePageState extends State { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) => _checkVersion()); - GlobalProviders.location.codeNotifier.addListener(_refresh); + GlobalProviders.location.$code.addListener(_refresh); _refresh(); } @@ -218,7 +218,7 @@ class _HomePageState extends State { @override void dispose() { - GlobalProviders.location.codeNotifier.removeListener(_refresh); + GlobalProviders.location.$code.removeListener(_refresh); super.dispose(); } } diff --git a/lib/app/map/_lib/managers/monitor.dart b/lib/app/map/_lib/managers/monitor.dart index 5a228f405..8e4c6eb5a 100644 --- a/lib/app/map/_lib/managers/monitor.dart +++ b/lib/app/map/_lib/managers/monitor.dart @@ -110,9 +110,9 @@ class MonitorMapLayerManager extends MapLayerManager { Future _focus() async { try { - final location = GlobalProviders.location.coordinateNotifier.value; + final location = GlobalProviders.location.coordinates; - if (location.isValid) { + if (location != null && location.isValid) { await controller.animateCamera(CameraUpdate.newLatLngZoom(location, 7.4)); } else { await controller.animateCamera(CameraUpdate.newLatLngZoom(DpipMap.kTaiwanCenter, 6.4)); @@ -704,231 +704,415 @@ class _MonitorMapLayerSheetState extends State { } else { final data = activeEew.first; - final info = eewLocationInfo( - data.info.magnitude, - data.info.depth, - data.info.latitude, - data.info.longitude, - GlobalProviders.location.coordinateNotifier.value.latitude, - GlobalProviders.location.coordinateNotifier.value.longitude, - ); - - localIntensity = intensityFloatToInt(info.i); - localArrivalTime = (data.info.time + sWaveTimeByDistance(data.info.depth, info.dist)).floor(); - - _timer = Timer.periodic(const Duration(seconds: 1), (_) => _updateCountdown()); - - return InkWell( - onTap: () => _toggleCollapse(), - child: Padding( - padding: const EdgeInsets.all(12), - child: - _isCollapsed - ? Column( - crossAxisAlignment: CrossAxisAlignment.start, + if (GlobalProviders.location.coordinates == null) { + if (_isCollapsed) { + child = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + spacing: 8, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - spacing: 8, - children: [ - Container( - decoration: BoxDecoration( - color: context.colors.error, - borderRadius: BorderRadius.circular(8), - ), - padding: - activeEew.length > 1 - ? const EdgeInsets.fromLTRB(8, 6, 12, 6) - : const EdgeInsets.fromLTRB(8, 6, 8, 6), - child: Row( - mainAxisSize: MainAxisSize.min, - spacing: 4, + Container( + decoration: BoxDecoration( + color: context.colors.error, + borderRadius: BorderRadius.circular(8), + ), + padding: + activeEew.length > 1 + ? const EdgeInsets.fromLTRB(8, 6, 12, 6) + : const EdgeInsets.fromLTRB(8, 6, 8, 6), + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: 4, + children: [ + Icon( + Symbols.crisis_alert_rounded, + color: context.colors.onError, + weight: 700, + size: 16, + ), + if (activeEew.length > 1) + RichText( + text: TextSpan( children: [ - Icon( - Symbols.crisis_alert_rounded, - color: context.colors.onError, - weight: 700, - size: 16, + TextSpan( + text: '1', + style: context.textTheme.labelMedium!.copyWith( + color: context.colors.onError, + fontWeight: FontWeight.bold, + ), ), - if (activeEew.length > 1) - RichText( - text: TextSpan( - children: [ - TextSpan( - text: '1', - style: context.textTheme.labelMedium!.copyWith( - color: context.colors.onError, - fontWeight: FontWeight.bold, - ), - ), - TextSpan( - text: '/${activeEew.length}', - style: context.textTheme.labelMedium!.copyWith( - color: context.colors.onError.withValues(alpha: 0.6), - fontWeight: FontWeight.bold, - ), - ), - ], - ), + TextSpan( + text: '/${activeEew.length}', + style: context.textTheme.labelMedium!.copyWith( + color: context.colors.onError.withValues(alpha: 0.6), + fontWeight: FontWeight.bold, ), + ), ], ), ), - Text( - '#${data.serial} ${data.info.time.toSimpleDateTimeString(context)} ${data.info.location}', - style: context.textTheme.bodyMedium!.copyWith( - fontWeight: FontWeight.bold, - color: context.colors.onErrorContainer, - ), - ), - ], - ), - Icon( - Symbols.expand_less_rounded, - color: context.colors.onErrorContainer, - size: 24, - ), - ], + ], + ), + ), + Text( + '#${data.serial} ${data.info.time.toSimpleDateTimeString(context)} ${data.info.location}', + style: context.textTheme.bodyMedium!.copyWith( + fontWeight: FontWeight.bold, + color: context.colors.onErrorContainer, + ), ), - Padding( - padding: const EdgeInsets.only(top: 8), + ], + ), + Icon(Symbols.expand_less_rounded, color: context.colors.onErrorContainer, size: 24), + ], + ), + Padding( + padding: const EdgeInsets.only(top: 8), + child: StyledText( + text: '規模 M{magnitude},深度{depth}公里'.i18n.args({ + 'magnitude': data.info.magnitude.toStringAsFixed(1), + 'depth': data.info.depth.toStringAsFixed(1), + }), + style: context.textTheme.bodyMedium!.copyWith(color: context.colors.onErrorContainer), + tags: {'bold': StyledTextTag(style: const TextStyle(fontWeight: FontWeight.bold))}, + ), + ), + ], + ); + } else { + child = Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + spacing: 8, + children: [ + Container( + decoration: BoxDecoration( + color: context.colors.error, + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.fromLTRB(8, 6, 12, 6), child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisSize: MainAxisSize.min, + spacing: 4, children: [ - StyledText( - text: '規模 M{magnitude},所在地預估{intensity}'.i18n.args({ - 'time': data.info.time.toSimpleDateTimeString(context), - 'location': data.info.location, - 'magnitude': data.info.magnitude.toStringAsFixed(1), - 'intensity': localIntensity.asIntensityLabel, - }), - style: context.textTheme.bodyMedium!.copyWith( - color: context.colors.onErrorContainer, - ), - tags: { - 'bold': StyledTextTag(style: const TextStyle(fontWeight: FontWeight.bold)), - }, + Icon( + Symbols.crisis_alert_rounded, + color: context.colors.onError, + weight: 700, + size: 22, ), - Text( - countdown >= 0 - ? '{countdown}秒後抵達'.i18n.args({'countdown': countdown}) - : '已抵達'.i18n, - style: context.textTheme.bodyMedium!.copyWith( + '緊急地震速報'.i18n, + style: context.textTheme.labelLarge!.copyWith( + color: context.colors.onError, fontWeight: FontWeight.bold, - color: context.colors.onErrorContainer, - height: 1, - leadingDistribution: TextLeadingDistribution.even, ), ), ], ), ), + Text( + '第 {serial} 報'.i18n.args({'serial': activeEew.first.serial}), + style: context.textTheme.bodyLarge!.copyWith( + color: context.colors.onErrorContainer, + ), + ), ], - ) - : Column( - mainAxisSize: MainAxisSize.min, + ), + Icon(Symbols.expand_more_rounded, color: context.colors.onErrorContainer, size: 24), + ], + ), + Padding( + padding: const EdgeInsets.only(top: 8), + child: StyledText( + text: + '{time} 左右,{location}附近發生有感地震,預估規模 M{magnitude}、深度{depth}公里。' + .i18n + .args({ + 'time': data.info.time.toSimpleDateTimeString(context), + 'location': data.info.location, + 'magnitude': data.info.magnitude.toStringAsFixed(1), + 'depth': data.info.depth.toStringAsFixed(1), + }), + style: context.textTheme.bodyLarge!.copyWith(color: context.colors.onErrorContainer), + tags: {'bold': StyledTextTag(style: const TextStyle(fontWeight: FontWeight.bold))}, + ), + ), + ], + ); + } + } else { + final info = eewLocationInfo( + data.info.magnitude, + data.info.depth, + data.info.latitude, + data.info.longitude, + GlobalProviders.location.coordinates!.latitude, + GlobalProviders.location.coordinates!.longitude, + ); + + localIntensity = intensityFloatToInt(info.i); + localArrivalTime = (data.info.time + sWaveTimeByDistance(data.info.depth, info.dist)).floor(); + + _timer ??= Timer.periodic(const Duration(seconds: 1), (_) => _updateCountdown()); + + if (_isCollapsed) { + child = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + spacing: 8, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - spacing: 8, - children: [ - Container( - decoration: BoxDecoration( - color: context.colors.error, - borderRadius: BorderRadius.circular(8), - ), - padding: const EdgeInsets.fromLTRB(8, 6, 12, 6), - child: Row( - mainAxisSize: MainAxisSize.min, - spacing: 4, + Container( + decoration: BoxDecoration( + color: context.colors.error, + borderRadius: BorderRadius.circular(8), + ), + padding: + activeEew.length > 1 + ? const EdgeInsets.fromLTRB(8, 6, 12, 6) + : const EdgeInsets.fromLTRB(8, 6, 8, 6), + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: 4, + children: [ + Icon( + Symbols.crisis_alert_rounded, + color: context.colors.onError, + weight: 700, + size: 16, + ), + if (activeEew.length > 1) + RichText( + text: TextSpan( children: [ - Icon( - Symbols.crisis_alert_rounded, - color: context.colors.onError, - weight: 700, - size: 22, - ), - Text( - '緊急地震速報'.i18n, - style: context.textTheme.labelLarge!.copyWith( + TextSpan( + text: '1', + style: context.textTheme.labelMedium!.copyWith( color: context.colors.onError, fontWeight: FontWeight.bold, ), ), + TextSpan( + text: '/${activeEew.length}', + style: context.textTheme.labelMedium!.copyWith( + color: context.colors.onError.withValues(alpha: 0.6), + fontWeight: FontWeight.bold, + ), + ), ], ), ), - Text( - '第 {serial} 報'.i18n.args({'serial': activeEew.first.serial}), - style: context.textTheme.bodyLarge!.copyWith( - color: context.colors.onErrorContainer, - ), + ], + ), + ), + Text( + '#${data.serial} ${data.info.time.toSimpleDateTimeString(context)} ${data.info.location}', + style: context.textTheme.bodyMedium!.copyWith( + fontWeight: FontWeight.bold, + color: context.colors.onErrorContainer, + ), + ), + ], + ), + Icon(Symbols.expand_less_rounded, color: context.colors.onErrorContainer, size: 24), + ], + ), + Padding( + padding: const EdgeInsets.only(top: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + StyledText( + text: '規模 M{magnitude},所在地預估{intensity}'.i18n.args({ + 'magnitude': data.info.magnitude.toStringAsFixed(1), + 'intensity': localIntensity.asIntensityLabel, + }), + style: context.textTheme.bodyMedium!.copyWith(color: context.colors.onErrorContainer), + tags: {'bold': StyledTextTag(style: const TextStyle(fontWeight: FontWeight.bold))}, + ), + + Text( + countdown >= 0 ? '{countdown}秒後抵達'.i18n.args({'countdown': countdown}) : '已抵達'.i18n, + style: context.textTheme.bodyMedium!.copyWith( + fontWeight: FontWeight.bold, + color: context.colors.onErrorContainer, + height: 1, + leadingDistribution: TextLeadingDistribution.even, + ), + ), + ], + ), + ), + ], + ); + } else { + child = Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + spacing: 8, + children: [ + Container( + decoration: BoxDecoration( + color: context.colors.error, + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.fromLTRB(8, 6, 12, 6), + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: 4, + children: [ + Icon( + Symbols.crisis_alert_rounded, + color: context.colors.onError, + weight: 700, + size: 22, + ), + Text( + '緊急地震速報'.i18n, + style: context.textTheme.labelLarge!.copyWith( + color: context.colors.onError, + fontWeight: FontWeight.bold, ), - ], - ), - Icon( - Symbols.expand_more_rounded, - color: context.colors.onErrorContainer, - size: 24, - ), - ], + ), + ], + ), ), - Padding( - padding: const EdgeInsets.only(top: 8), - child: StyledText( - text: - '{time} 左右,{location}附近發生有感地震,預估規模 M{magnitude}、所在地最大震度{intensity}。' - .i18n - .args({ - 'time': data.info.time.toSimpleDateTimeString(context), - 'location': data.info.location, - 'magnitude': data.info.magnitude.toStringAsFixed(1), - 'intensity': localIntensity.asIntensityLabel, - }), - style: context.textTheme.bodyLarge!.copyWith( - color: context.colors.onErrorContainer, - ), - tags: { - 'bold': StyledTextTag(style: const TextStyle(fontWeight: FontWeight.bold)), - }, + Text( + '第 {serial} 報'.i18n.args({'serial': activeEew.first.serial}), + style: context.textTheme.bodyLarge!.copyWith( + color: context.colors.onErrorContainer, ), ), - Selector( - selector: (context, model) => model.code, - builder: (context, code, child) { - if (code == null) { - return const SizedBox.shrink(); - } - - return Padding( - padding: const EdgeInsets.only(top: 8, bottom: 4), - child: IntrinsicHeight( - child: Row( + ], + ), + Icon(Symbols.expand_more_rounded, color: context.colors.onErrorContainer, size: 24), + ], + ), + Padding( + padding: const EdgeInsets.only(top: 8), + child: StyledText( + text: + '{time} 左右,{location}附近發生有感地震,預估規模 M{magnitude}、所在地最大震度{intensity}。' + .i18n + .args({ + 'time': data.info.time.toSimpleDateTimeString(context), + 'location': data.info.location, + 'magnitude': data.info.magnitude.toStringAsFixed(1), + 'intensity': localIntensity.asIntensityLabel, + }), + style: context.textTheme.bodyLarge!.copyWith(color: context.colors.onErrorContainer), + tags: {'bold': StyledTextTag(style: const TextStyle(fontWeight: FontWeight.bold))}, + ), + ), + Selector( + selector: (context, model) => model.code, + builder: (context, code, child) { + if (code == null) { + return const SizedBox.shrink(); + } + + return Padding( + padding: const EdgeInsets.only(top: 8, bottom: 4), + child: IntrinsicHeight( + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.all(4), + child: Column( mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Expanded( - child: Padding( - padding: const EdgeInsets.all(4), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - '所在地預估'.i18n, - style: context.textTheme.labelLarge!.copyWith( - color: context.colors.onErrorContainer.withValues(alpha: 0.6), - ), - ), - Padding( - padding: const EdgeInsets.only(top: 12, bottom: 8), - child: Text( - localIntensity.asIntensityLabel, + Text( + '所在地預估'.i18n, + style: context.textTheme.labelLarge!.copyWith( + color: context.colors.onErrorContainer.withValues(alpha: 0.6), + ), + ), + Padding( + padding: const EdgeInsets.only(top: 12, bottom: 8), + child: Text( + localIntensity.asIntensityLabel, + style: context.textTheme.displayMedium!.copyWith( + fontWeight: FontWeight.bold, + color: context.colors.onErrorContainer, + height: 1, + leadingDistribution: TextLeadingDistribution.even, + ), + textAlign: TextAlign.center, + ), + ), + ], + ), + ), + ), + VerticalDivider( + color: context.colors.onErrorContainer.withValues(alpha: 0.4), + width: 24, + ), + Expanded( + child: Padding( + padding: const EdgeInsets.all(4), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + '震波'.i18n, + style: context.textTheme.labelLarge!.copyWith( + color: context.colors.onErrorContainer.withValues(alpha: 0.6), + ), + ), + Padding( + padding: const EdgeInsets.only(top: 12, bottom: 8), + child: + (countdown >= 0) + ? RichText( + text: TextSpan( + children: [ + TextSpan( + text: countdown.toString(), + style: TextStyle( + fontSize: + context.textTheme.displayMedium!.fontSize! * 1.15, + ), + ), + TextSpan( + text: ' 秒'.i18n, + style: TextStyle( + fontSize: context.textTheme.labelLarge!.fontSize, + ), + ), + ], + style: context.textTheme.displayMedium!.copyWith( + fontWeight: FontWeight.bold, + color: context.colors.onErrorContainer, + height: 1, + leadingDistribution: TextLeadingDistribution.even, + ), + ), + textAlign: TextAlign.center, + ) + : Text( + '抵達'.i18n, style: context.textTheme.displayMedium!.copyWith( fontWeight: FontWeight.bold, color: context.colors.onErrorContainer, @@ -937,87 +1121,25 @@ class _MonitorMapLayerSheetState extends State { ), textAlign: TextAlign.center, ), - ), - ], - ), - ), - ), - VerticalDivider( - color: context.colors.onErrorContainer.withValues(alpha: 0.4), - width: 24, - ), - Expanded( - child: Padding( - padding: const EdgeInsets.all(4), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - '震波'.i18n, - style: context.textTheme.labelLarge!.copyWith( - color: context.colors.onErrorContainer.withValues(alpha: 0.6), - ), - ), - Padding( - padding: const EdgeInsets.only(top: 12, bottom: 8), - child: - (countdown >= 0) - ? RichText( - text: TextSpan( - children: [ - TextSpan( - text: countdown.toString(), - style: TextStyle( - fontSize: - context - .textTheme - .displayMedium! - .fontSize! * - 1.15, - ), - ), - TextSpan( - text: ' 秒'.i18n, - style: TextStyle( - fontSize: - context.textTheme.labelLarge!.fontSize, - ), - ), - ], - style: context.textTheme.displayMedium!.copyWith( - fontWeight: FontWeight.bold, - color: context.colors.onErrorContainer, - height: 1, - leadingDistribution: TextLeadingDistribution.even, - ), - ), - textAlign: TextAlign.center, - ) - : Text( - '抵達'.i18n, - style: context.textTheme.displayMedium!.copyWith( - fontWeight: FontWeight.bold, - color: context.colors.onErrorContainer, - height: 1, - leadingDistribution: TextLeadingDistribution.even, - ), - textAlign: TextAlign.center, - ), - ), - ], - ), - ), ), ], ), ), - ); - }, + ), + ], ), - ], - ), - ), + ), + ); + }, + ), + ], + ); + } + } + + return InkWell( + onTap: () => _toggleCollapse(), + child: Padding(padding: const EdgeInsets.all(12), child: child), ); } }, diff --git a/lib/app/map/_lib/managers/precipitation.dart b/lib/app/map/_lib/managers/precipitation.dart index ed78af6d7..9d19fbdfd 100644 --- a/lib/app/map/_lib/managers/precipitation.dart +++ b/lib/app/map/_lib/managers/precipitation.dart @@ -93,9 +93,9 @@ class PrecipitationMapLayerManager extends MapLayerManager { Future _focus() async { try { - final location = GlobalProviders.location.coordinateNotifier.value; + final location = GlobalProviders.location.coordinates; - if (location.isValid) { + if (location != null && location.isValid) { await controller.animateCamera(CameraUpdate.newLatLngZoom(location, 7.4)); } else { await controller.animateCamera(CameraUpdate.newLatLngZoom(DpipMap.kTaiwanCenter, 6.4)); diff --git a/lib/app/map/_lib/managers/radar.dart b/lib/app/map/_lib/managers/radar.dart index 8f8a4b622..60c86a6c0 100644 --- a/lib/app/map/_lib/managers/radar.dart +++ b/lib/app/map/_lib/managers/radar.dart @@ -314,9 +314,9 @@ class RadarMapLayerManager extends MapLayerManager { Future _focus() async { try { - final location = GlobalProviders.location.coordinateNotifier.value; + final location = GlobalProviders.location.coordinates; - if (location.isValid) { + if (location != null && location.isValid) { await controller.animateCamera(CameraUpdate.newLatLngZoom(location, 7.4)); } else { await controller.animateCamera(CameraUpdate.newLatLngZoom(DpipMap.kTaiwanCenter, 6.4)); diff --git a/lib/app/map/_lib/managers/temperature.dart b/lib/app/map/_lib/managers/temperature.dart index 1709da6c6..7d97967d3 100644 --- a/lib/app/map/_lib/managers/temperature.dart +++ b/lib/app/map/_lib/managers/temperature.dart @@ -74,9 +74,9 @@ class TemperatureMapLayerManager extends MapLayerManager { Future _focus() async { try { - final location = GlobalProviders.location.coordinateNotifier.value; + final location = GlobalProviders.location.coordinates; - if (location.isValid) { + if (location != null && location.isValid) { await controller.animateCamera(CameraUpdate.newLatLngZoom(location, 7.4)); } else { await controller.animateCamera(CameraUpdate.newLatLngZoom(DpipMap.kTaiwanCenter, 6.4)); diff --git a/lib/app/settings/location/page.dart b/lib/app/settings/location/page.dart index 1bdc150d9..99acdea88 100644 --- a/lib/app/settings/location/page.dart +++ b/lib/app/settings/location/page.dart @@ -159,56 +159,22 @@ class _SettingsLocationPageState extends State with Widget } Future checkLocationAlwaysPermission() async { - final status = await Permission.locationAlways.status; + final status = await [Permission.location, Permission.locationWhenInUse, Permission.locationAlways].request(); - setState(() => locationAlwaysPermission = status); - - if (status.isGranted) { - return true; - } else { - if (!mounted) return false; - final permissionType = Platform.isAndroid ? '一律允許' : '永遠'; - - final status = - await showDialog( - context: context, - builder: (context) { - return AlertDialog( - icon: const Icon(Symbols.my_location), - title: Text('$permissionType 位置權限'), - content: Text('為了獲得更好的自動定位體驗,您需要將位置權限提升至「$permissionType」以便讓 DPIP 在背景自動設定所在地資訊。'), - actionsAlignment: MainAxisAlignment.spaceBetween, - actions: [ - TextButton( - child: const Text('取消'), - onPressed: () { - Navigator.pop(context, false); - }, - ), - FilledButton( - child: const Text('確定'), - onPressed: () async { - final status = await Permission.locationAlways.request(); - - setState(() => locationAlwaysPermission = status); - - if (status.isPermanentlyDenied) { - openAppSettings(); - } - - if (!context.mounted) return; - - Navigator.pop(context, status.isGranted); - }, - ), - ], - ); - }, - ) ?? - false; - - return status; + if (!status[Permission.location]!.isGranted) { + TalkerManager.instance.warning('🧪 failed location (ACCESS_COARSE_LOCATION) permission test'); + return false; + } + if (!status[Permission.locationWhenInUse]!.isGranted) { + TalkerManager.instance.warning('🧪 failed location when in use (ACCESS_FINE_LOCATION) permission test'); + return false; } + if (!status[Permission.locationAlways]!.isGranted) { + TalkerManager.instance.warning('🧪 failed location always (ACCESS_BACKGROUND_LOCATION) permission test'); + return false; + } + + return true; } Future androidCheckAutoStartPermission(int num) async { @@ -311,41 +277,54 @@ class _SettingsLocationPageState extends State with Widget } Future toggleAutoLocation() async { - final isAuto = context.read().auto; + final shouldEnable = !context.read().auto; - stopAndroidBackgroundService(); + await BackgroundLocationService.stop(); - if (!isAuto) { + if (shouldEnable) { final notification = await checkNotificationPermission(); - if (!notification) return; + if (!notification) { + TalkerManager.instance.warning('🧪 failed notification permission test'); + return; + } final location = await checkLocationPermission(); - if (!location) return; + if (!location) { + TalkerManager.instance.warning('🧪 failed location permission test'); + return; + } await checkLocationAlwaysPermission(); final bool autoStart = await androidCheckAutoStartPermission(0); autoStartPermission = autoStart; - if (!autoStart) return; + if (!autoStart) { + TalkerManager.instance.warning('🧪 failed auto start permission test'); + return; + } final bool batteryOptimization = await androidCheckBatteryOptimizationPermission(0); batteryOptimizationPermission = batteryOptimization; - if (!batteryOptimization) return; - - if (!isAuto) { - startAndroidBackgroundService(shouldInitialize: false); + if (!batteryOptimization) { + TalkerManager.instance.warning('🧪 failed battery optimization permission test'); + return; } } + if (Platform.isAndroid) { + if (shouldEnable) { + await BackgroundLocationService.start(); + } + } if (Platform.isIOS) { - await platform.invokeMethod('toggleLocation', {'isEnabled': !isAuto}).catchError((_) {}); + await platform.invokeMethod('toggleLocation', {'isEnabled': shouldEnable}).catchError((_) {}); } if (!mounted) return; - context.read().setAuto(!isAuto); + context.read().setAuto(shouldEnable); context.read().setCode(null); - context.read().setLatLng(); + context.read().setCoordinates(null); } @override diff --git a/lib/app/settings/location/select/[city]/page.dart b/lib/app/settings/location/select/[city]/page.dart index e22bc68f8..1fc054042 100644 --- a/lib/app/settings/location/select/[city]/page.dart +++ b/lib/app/settings/location/select/[city]/page.dart @@ -8,6 +8,7 @@ import 'package:dpip/widgets/list/list_section.dart'; import 'package:dpip/widgets/list/list_tile.dart'; import 'package:dpip/widgets/ui/loading_icon.dart'; import 'package:flutter/material.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:material_symbols_icons/material_symbols_icons.dart'; import 'package:provider/provider.dart'; @@ -38,38 +39,35 @@ class _SettingsLocationSelectCityPageState extends State( selector: (context, model) => model.code, - builder: - (context, code, child) => ListSectionTile( - title: '${town.value.city} ${town.value.town}', - subtitle: Text( - '${town.key}・${town.value.lng.toStringAsFixed(2)}°E・${town.value.lat.toStringAsFixed(2)}°N', - ), - trailing: - loadingTown == town.key - ? const LoadingIcon() - : Icon(code == town.key ? Symbols.check_rounded : null), - enabled: loadingTown == null, - onTap: () async { - if (loadingTown != null) return; + builder: (context, code, child) { + return ListSectionTile( + title: '${town.value.city} ${town.value.town}', + subtitle: Text( + '${town.key}・${town.value.lng.toStringAsFixed(2)}°E・${town.value.lat.toStringAsFixed(2)}°N', + ), + trailing: + loadingTown == town.key + ? const LoadingIcon() + : Icon(code == town.key ? Symbols.check_rounded : null), + enabled: loadingTown == null, + onTap: () async { + if (loadingTown != null) return; - setState(() => loadingTown = town.key); - await ExpTech().updateDeviceLocation( - token: Preference.notifyToken, - lat: town.value.lat.toString(), - lng: town.value.lng.toString(), - ); + setState(() => loadingTown = town.key); + await ExpTech().updateDeviceLocation( + token: Preference.notifyToken, + coordinates: LatLng(town.value.lat, town.value.lng), + ); - if (!context.mounted) return; - setState(() => loadingTown = null); + if (!context.mounted) return; + setState(() => loadingTown = null); - context.read().setCode(town.key); - context.read().setLatLng( - latitude: town.value.lat, - longitude: town.value.lng, - ); - context.popUntil(SettingsLocationPage.route); - }, - ), + context.read().setCode(town.key); + context.read().setCoordinates(LatLng(town.value.lat, town.value.lng)); + context.popUntil(SettingsLocationPage.route); + }, + ); + }, ), ], ), diff --git a/lib/app/settings/notify/page.dart b/lib/app/settings/notify/page.dart index a039923c0..d652ac3d6 100644 --- a/lib/app/settings/notify/page.dart +++ b/lib/app/settings/notify/page.dart @@ -84,13 +84,12 @@ class _SettingsNotifyPageState extends State { }) .catchError((error) { if (error.toString().contains('401')) { - if (GlobalProviders.location.latitude != null && GlobalProviders.location.longitude != null) { + if (GlobalProviders.location.coordinates != null) { Future.delayed(const Duration(seconds: 2), () { ExpTech() .updateDeviceLocation( token: Preference.notifyToken, - lat: GlobalProviders.location.latitude.toString(), - lng: GlobalProviders.location.longitude.toString(), + coordinates: GlobalProviders.location.coordinates!, ) .then((_) { if (mounted) { diff --git a/lib/app_old/page/map/radar/radar.dart b/lib/app_old/page/map/radar/radar.dart index b78406115..63df24c5c 100644 --- a/lib/app_old/page/map/radar/radar.dart +++ b/lib/app_old/page/map/radar/radar.dart @@ -1,17 +1,18 @@ import 'dart:io'; +import 'package:flutter/material.dart'; + +import 'package:maplibre_gl/maplibre_gl.dart'; + import 'package:dpip/api/exptech.dart'; import 'package:dpip/core/ios_get_location.dart'; import 'package:dpip/core/providers.dart'; import 'package:dpip/utils/extensions/build_context.dart'; import 'package:dpip/utils/extensions/latlng.dart'; -import 'package:dpip/utils/need_location.dart'; import 'package:dpip/utils/radar_color.dart'; import 'package:dpip/widgets/list/time_selector.dart'; import 'package:dpip/widgets/map/legend.dart'; import 'package:dpip/widgets/map/map.dart'; -import 'package:flutter/material.dart'; -import 'package:maplibre_gl/maplibre_gl.dart'; typedef PositionUpdateCallback = void Function(); @@ -141,36 +142,15 @@ class _RadarMapState extends State { } Future start() async { - userLat = GlobalProviders.location.latitude ?? 0; - userLon = GlobalProviders.location.longitude ?? 0; - - final location = LatLng(userLat, userLon); - - if (location.isValid) { - await _mapController.setGeoJsonSource('markers-geojson', { - 'type': 'FeatureCollection', - 'features': [ - { - 'type': 'Feature', - 'properties': {}, - 'geometry': { - 'coordinates': [userLon, userLat], - 'type': 'Point', - }, - }, - ], - }); - final cameraUpdate = CameraUpdate.newLatLngZoom(LatLng(userLat, userLon), 8); - await _mapController.animateCamera(cameraUpdate, duration: const Duration(milliseconds: 1000)); - } + final location = GlobalProviders.location.coordinates; - if (!mounted) return; - - if (!location.isValid && !GlobalProviders.location.auto) { - await showLocationDialog(context); + if (location != null && location.isValid) { + await _mapController.animateCamera(CameraUpdate.newLatLngZoom(location, 7.4)); + } else { + await _mapController.animateCamera(CameraUpdate.newLatLngZoom(DpipMap.kTaiwanCenter, 6.4)); } - _addUserLocationMarker(); + if (!mounted) return; setState(() {}); } diff --git a/lib/core/ios_get_location.dart b/lib/core/ios_get_location.dart index 10d55146d..1a96ee6ca 100644 --- a/lib/core/ios_get_location.dart +++ b/lib/core/ios_get_location.dart @@ -4,6 +4,7 @@ import 'package:dpip/core/providers.dart'; import 'package:dpip/utils/location_to_code.dart'; import 'package:dpip/utils/log.dart'; import 'package:flutter/services.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; const _channel = MethodChannel('com.exptech.dpip/data'); Completer? _completer; @@ -22,12 +23,13 @@ Future getSavedLocation() async { final latitude = data?['lat'] as double?; final longitude = data?['lon'] as double?; - GlobalProviders.location.setLatLng(latitude: latitude, longitude: longitude); - if (latitude != null && longitude != null) { + GlobalProviders.location.setCoordinates(LatLng(latitude, longitude)); final location = GeoJsonHelper.checkPointInPolygons(latitude, longitude); print(location); GlobalProviders.location.setCode(location?.code.toString()); + } else { + GlobalProviders.location.setCoordinates(null); } } catch (e) { TalkerManager.instance.error('Error in getSavedLocation: $e'); diff --git a/lib/core/location.dart b/lib/core/location.dart deleted file mode 100644 index a1bcf2a4b..000000000 --- a/lib/core/location.dart +++ /dev/null @@ -1,89 +0,0 @@ -import 'dart:async'; - -import 'package:dpip/core/providers.dart'; -import 'package:dpip/utils/location_to_code.dart'; -import 'package:dpip/utils/log.dart'; -import 'package:geolocator/geolocator.dart'; -import 'package:maplibre_gl/maplibre_gl.dart'; - -StreamSubscription? positionStreamSubscription; -Timer? restartTimer; - -class GetLocationResult { - final bool change; - final int? code; - final double? lat; - final double? lng; - - GetLocationResult({required this.change, this.code, this.lat, this.lng}); - - factory GetLocationResult.fromJson(Map json) { - return GetLocationResult( - change: json['change'] as bool, - code: json['code'] as int?, - lat: json['lat'] as double?, - lng: json['lng'] as double?, - ); - } - - Map toJson() { - return {'code': code, 'change': change, 'lat': lat, 'lng': lng}; - } - - LatLng get latlng => LatLng(lat ?? 0, lng ?? 0); -} - -class LocationService { - static final LocationService _instance = LocationService._internal(); - - factory LocationService() { - return _instance; - } - - LocationService._internal(); - - final GeolocatorPlatform geolocatorPlatform = GeolocatorPlatform.instance; - - @pragma('vm:entry-point') - Future androidGetLocation() async { - final isLocationServiceEnabled = await Geolocator.isLocationServiceEnabled(); - - if (!isLocationServiceEnabled) { - TalkerManager.instance.warning('位置服務未啟用'); - return GetLocationResult(change: false, lat: 0, lng: 0); - } - - bool hasLocationChanged = false; - final lastLatitude = GlobalProviders.location.latitude ?? 0; - final lastLongitude = GlobalProviders.location.longitude ?? 0; - - final currentPosition = await Geolocator.getCurrentPosition( - locationSettings: const LocationSettings(accuracy: LocationAccuracy.medium), - ); - - final currentLocation = GeoJsonHelper.checkPointInPolygons(currentPosition.latitude, currentPosition.longitude); - - final distanceInMeters = Geolocator.distanceBetween( - lastLatitude, - lastLongitude, - currentPosition.latitude, - currentPosition.longitude, - ); - - if (distanceInMeters >= 250) { - GlobalProviders.location.setLatLng(latitude: currentPosition.latitude, longitude: currentPosition.longitude); - GlobalProviders.location.setCode(currentLocation?.code.toString()); - hasLocationChanged = true; - TalkerManager.instance.debug('距離: $distanceInMeters 更新位置'); - } else { - TalkerManager.instance.debug('距離: $distanceInMeters 不更新位置'); - } - - return GetLocationResult( - change: hasLocationChanged, - code: currentLocation?.code, - lat: currentPosition.latitude, - lng: currentPosition.longitude, - ); - } -} diff --git a/lib/core/notify.dart b/lib/core/notify.dart index 876f466b3..8fa5e511a 100644 --- a/lib/core/notify.dart +++ b/lib/core/notify.dart @@ -44,17 +44,17 @@ void _navigateBasedOnChannelKey(BuildContext context, String? channelKey) { context.push(MapPage.route(options: MapPageOptions(initialLayers: {MapLayer.monitor}))); return; } - + if (channelKey.startsWith('int_report')) { context.push(MapPage.route(options: MapPageOptions(initialLayers: {MapLayer.monitor}))); return; } - + if (channelKey.startsWith('eq')) { context.push(MapPage.route(options: MapPageOptions(initialLayers: {MapLayer.monitor}))); return; } - + context.go('/home'); } @@ -356,6 +356,18 @@ Future notifyInit() async { enableVibration: true, vibrationPattern: lowVibrationPattern, ), + NotificationChannel( + channelKey: 'background', + channelName: '自動定位', + channelDescription: '背景定位服務通知', + importance: NotificationImportance.Low, + defaultColor: const Color(0xFF2196f3), + channelShowBadge: false, + enableVibration: false, + enableLights: false, + playSound: false, + locked: true, + ), ], channelGroups: [ NotificationChannelGroup(channelGroupKey: 'group_eew', channelGroupName: '地震速報音效'), diff --git a/lib/core/preference.dart b/lib/core/preference.dart index 6f7b91fe1..f00f2295a 100644 --- a/lib/core/preference.dart +++ b/lib/core/preference.dart @@ -11,6 +11,7 @@ class PreferenceKeys { static const locationLatitude = 'location:latitude'; static const locationOldLongitude = 'location:oldLongitude'; static const locationOldLatitude = 'location:oldLatitude'; + static const locationFavorited = 'location:favorite'; // #endregion // #region User Interface @@ -45,6 +46,10 @@ class Preference { instance = await SharedPreferencesWithCache.create(cacheOptions: const SharedPreferencesWithCacheOptions()); } + static Future reload() async { + await instance.reloadCache(); + } + static String? get version => instance.getString('app-version'); static set version(String? value) => instance.set('app-version', value); @@ -81,6 +86,9 @@ class Preference { static double? get locationOldLatitude => instance.getDouble(PreferenceKeys.locationOldLatitude); static set locationOldLatitude(double? value) => instance.set(PreferenceKeys.locationOldLatitude, value); + + static List get locationFavorited => instance.getStringList(PreferenceKeys.locationFavorited) ?? []; + static set locationFavorited(List value) => instance.set(PreferenceKeys.locationFavorited, value); // #endregion // #region User Interface diff --git a/lib/core/service.dart b/lib/core/service.dart index 034bc8f89..dfc1cc977 100644 --- a/lib/core/service.dart +++ b/lib/core/service.dart @@ -1,248 +1,354 @@ import 'dart:async'; -import 'dart:io'; import 'dart:ui'; +import 'package:flutter/services.dart'; + +import 'package:awesome_notifications/awesome_notifications.dart'; +import 'package:flutter_background_service/flutter_background_service.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; + import 'package:dpip/api/exptech.dart'; -import 'package:dpip/app_old/page/map/radar/radar.dart'; -import 'package:dpip/core/location.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/asset_bundle.dart'; +import 'package:dpip/utils/extensions/datetime.dart'; import 'package:dpip/utils/extensions/latlng.dart'; -import 'package:dpip/utils/location_to_code.dart'; import 'package:dpip/utils/log.dart'; -import 'package:awesome_notifications/awesome_notifications.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_background_service/flutter_background_service.dart'; -import 'package:intl/intl.dart'; -import 'package:maplibre_gl/maplibre_gl.dart'; -import 'package:permission_handler/permission_handler.dart'; - -Timer? _locationUpdateTimer; -final _backgroundService = FlutterBackgroundService(); -bool _isAndroidServiceInitialized = false; -enum ServiceEvent { setAsForeground, setAsBackground, sendPosition, sendDebug, removePosition, stopService } +class PositionEvent { + final LatLng? coordinates; -Future initBackgroundService() async { - final isAutoLocationEnabled = GlobalProviders.location.auto; - if (!isAutoLocationEnabled) { - return; - } + PositionEvent(this.coordinates); - final notificationPermission = await Permission.notification.status; - final locationPermission = await Permission.locationAlways.status; + factory PositionEvent.fromJson(Map json) { + final coordinates = json['coordinates'] as List?; - if (notificationPermission.isGranted && locationPermission.isGranted) { - if (!Platform.isAndroid) return; + return PositionEvent(coordinates != null ? LatLng(coordinates[0] as double, coordinates[1] as double) : null); + } - await _initializeAndroidForegroundService(); - _setupPositionListener(); - startAndroidBackgroundService(shouldInitialize: true); + Map toJson() { + return {'coordinates': coordinates?.toJson()}; } } -Future startAndroidBackgroundService({required bool shouldInitialize}) async { - if (!_isAndroidServiceInitialized) { - await _initializeAndroidForegroundService(); - _setupPositionListener(); - } +/// Events emitted by the background service. +final class BackgroundLocationServiceEvent { + /// Event emitted when a new position is set in the background service. + /// Contains the updated location coordinates. + static const position = 'position'; - final isServiceRunning = await _backgroundService.isRunning(); - if (!isServiceRunning) { - _backgroundService.startService(); - } else if (!shouldInitialize) { - stopAndroidBackgroundService(); - _backgroundService.startService(); - } + /// Method event to stop the service. + static const stop = 'stop'; } -Future stopAndroidBackgroundService() async { - final isServiceRunning = await _backgroundService.isRunning(); - if (!isServiceRunning) return; +/// Background location service. +/// +/// This class is responsible for managing the background location service. +/// It is used to get the current location of the device inthe 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 BackgroundLocationService { + BackgroundLocationService._(); + + /// The notification ID used for the background service notification + static const kNotificationId = 888888; + + /// Instance of the background service + static final instance = FlutterBackgroundService(); + + /// Whether the background service has been initialized + static bool initialized = false; + + /// 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. + static Future initalize() async { + if (initialized) return; + + TalkerManager.instance.info('⚙️ initializing location service'); + + await instance.configure( + androidConfiguration: AndroidConfiguration( + onStart: _$onStart, + isForegroundMode: true, + foregroundServiceTypes: [AndroidForegroundType.location], + notificationChannelId: 'background', + initialNotificationTitle: 'DPIP', + initialNotificationContent: '正在初始化自動定位服務...', + foregroundServiceNotificationId: kNotificationId, + ), + // iOS is handled in native code + iosConfiguration: IosConfiguration(), + ); + + // Reloads the UI isolate's preference cache when a new position is set in the background service. + instance.on(BackgroundLocationServiceEvent.position).listen((data) { + final event = PositionEvent.fromJson(data!); + + Preference.reload(); + + // Handle FCM notification + final fcmToken = Preference.notifyToken; + if (fcmToken.isNotEmpty && event.coordinates != null) { + ExpTech().updateDeviceLocation(token: fcmToken, coordinates: event.coordinates!); + } + }); - final isAutoLocationEnabled = GlobalProviders.location.auto; - if (isAutoLocationEnabled) { - _backgroundService.invoke(ServiceEvent.removePosition.name); - } + initialized = true; - _backgroundService.invoke(ServiceEvent.stopService.name); -} + if (Preference.locationAuto == true) await start(); + } -void _setupPositionListener() { - _backgroundService.on(ServiceEvent.sendPosition.name).listen((event) { - if (event == null) return; + /// Starts the background location service. + /// + /// Initializes the service if not already initialized. Only starts if the service is not already running. + static Future start() async { + if (!initialized) await initalize(); + TalkerManager.instance.info('⚙️ starting location service'); - final result = GetLocationResult.fromJson(event); + if (await instance.isRunning()) { + TalkerManager.instance.warning('⚙️ location service is already running, skipping...'); + return; + } - final latitude = result.lat ?? 0; - final longitude = result.lng ?? 0; + await instance.startService(); + } - final location = GeoJsonHelper.checkPointInPolygons(latitude, longitude); + /// Stops the background location service by invoking the stop event. + static Future stop() async { + if (!initialized) return; - GlobalProviders.location.setCode(location?.code.toString()); - GlobalProviders.location.setLatLng(latitude: latitude, longitude: longitude); + TalkerManager.instance.info('⚙️ stopping location service'); + instance.invoke(BackgroundLocationServiceEvent.stop); + } - RadarMap.updatePosition(); - }); + /// The last known location coordinates + static LatLng? _$location; - _backgroundService.on(ServiceEvent.sendDebug.name).listen((event) { - if (event == null) return; + /// Timer for scheduling periodic location updates + static Timer? _$locationUpdateTimer; - final notificationBody = event['notifyBody']; - TalkerManager.instance.debug('自動定位: $notificationBody'); - }); -} + /// Cached GeoJSON data for location lookups + static late Map _$geoJsonData; -Future _initializeAndroidForegroundService() async { - if (_isAndroidServiceInitialized) return; - - await AwesomeNotifications().initialize( - null, // 使用預設 launcher icon - [ - NotificationChannel( - channelKey: 'my_foreground', - channelName: '前景自動定位', - channelDescription: '背景定位服務通知', - importance: NotificationImportance.Low, - defaultColor: const Color(0xFF2196f3), - ledColor: Colors.white, - channelShowBadge: false, - locked: true, - ) - ], - ); - - await _backgroundService.configure( - androidConfiguration: AndroidConfiguration( - onStart: _onServiceStart, - isForegroundMode: true, - foregroundServiceTypes: [AndroidForegroundType.location], - notificationChannelId: 'my_foreground', - initialNotificationTitle: 'DPIP', - initialNotificationContent: '前景服務啟動中...', - foregroundServiceNotificationId: 888, - ), - iosConfiguration: IosConfiguration(onForeground: _onServiceStart, onBackground: _onIosBackground), - ); - _isAndroidServiceInitialized = true; -} + /// Cached location data mapping + static late Map _$locationData; -@pragma('vm:entry-point') -Future _onIosBackground(ServiceInstance service) async { - WidgetsFlutterBinding.ensureInitialized(); - DartPluginRegistrant.ensureInitialized(); - return true; -} + /// 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 _$onStart(ServiceInstance service) async { + DartPluginRegistrant.ensureInitialized(); -@pragma('vm:entry-point') -Future _onServiceStart(ServiceInstance service) async { - WidgetsFlutterBinding.ensureInitialized(); - DartPluginRegistrant.ensureInitialized(); - if (service is AndroidServiceInstance) { - service.setAsForegroundService(); - await AwesomeNotifications().createNotification( - content: NotificationContent( - id: 888, - channelKey: 'my_foreground', - title: 'DPIP', - body: '前景服務啟動中...', - notificationLayout: NotificationLayout.Default, - locked: true, - autoDismissible: false, - icon: 'resource://drawable/ic_stat_name', - ), - ); -} - await Global.init(); - await Preference.init(); - GlobalProviders.init(); + if (service is! AndroidServiceInstance) return; - final locationService = LocationService(); + await AwesomeNotifications().createNotification( + content: NotificationContent( + id: kNotificationId, + channelKey: 'background', + title: 'DPIP', + body: '自動定位服務啟動中...', + locked: true, + autoDismissible: false, + icon: 'resource://drawable/ic_stat_name', + ), + ); - // Setup service event listeners - service.on(ServiceEvent.stopService.name).listen((event) { - _locationUpdateTimer?.cancel(); - if (service is AndroidServiceInstance) { - service.setAutoStartOnBootMode(false); - } - service.stopSelf(); - TalkerManager.instance.info('背景服務已停止'); - }); + await Preference.init(); + _$geoJsonData = await rootBundle.loadJson('assets/map/town.json'); + _$locationData = await Global.loadLocationData(); - // Only proceed with Android-specific setup if this is an Android service - if (service is AndroidServiceInstance) { service.setAutoStartOnBootMode(true); - // Setup service state change listeners - service.on(ServiceEvent.setAsForeground.name).listen((event) => service.setAsForegroundService()); - service.on(ServiceEvent.setAsBackground.name).listen((event) => service.setAsBackgroundService()); - service.on(ServiceEvent.removePosition.name).listen((event) { - GlobalProviders.location.setCode(null); - GlobalProviders.location.setLatLng(); + service.on(BackgroundLocationServiceEvent.stop).listen((data) { + _$locationUpdateTimer?.cancel(); + service.stopSelf().then((_) { + TalkerManager.instance.info('⚙️ location service stopped'); + }); }); // Define the periodic location update task Future updateLocation() async { - _locationUpdateTimer?.cancel(); + _$locationUpdateTimer?.cancel(); if (!await service.isForegroundService()) return; // Get current position and location info - final position = await locationService.androidGetLocation(); - service.invoke(ServiceEvent.sendPosition.name, position.toJson()); + final coordinates = await _$getDeviceGeographicalLocation(service); - final latitude = position.lat.toString(); - final longitude = position.lng.toString(); - final locationName = - position.code == null - ? '服務區域外' - : '${Global.location[position.code.toString()]?.city}${Global.location[position.code.toString()]?.town}'; + if (coordinates == null) { + _$updatePosition(service, null); + return; + } - // Handle FCM notification if position changed - final fcmToken = Preference.notifyToken; - if (position.change && fcmToken.isNotEmpty) { - final response = await ExpTech().updateDeviceLocation(token: fcmToken, lat: latitude, lng: longitude); - TalkerManager.instance.debug(response); + final locationCode = _$getLocationFromCoordinates(coordinates); + final lastLocation = _$location; + + final distanceInKm = lastLocation != null ? coordinates.to(lastLocation) : null; + + if (distanceInKm == null || distanceInKm >= 250) { + TalkerManager.instance.debug('距離: $distanceInKm 更新位置'); + _$updatePosition(service, coordinates); + } else { + TalkerManager.instance.debug('距離: $distanceInKm 不更新位置'); } + final latitude = coordinates.latitude.toStringAsFixed(4); + final longitude = coordinates.longitude.toStringAsFixed(4); + + final location = locationCode != null ? _$locationData[locationCode] : null; + final locationName = location == null ? '服務區域外' : '${location.city} ${location.town}'; + // Update notification with current position const notificationTitle = '自動定位中'; - final timestamp = DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now()); - final notificationBody = '$timestamp\n$latitude,$longitude $locationName'; + final timestamp = DateTime.now().toDateTimeString(); + final notificationBody = + '$timestamp\n' + '$locationName ($latitude, $longitude) '; - service.invoke(ServiceEvent.sendDebug.name, {'notifyBody': notificationBody}); await AwesomeNotifications().createNotification( content: NotificationContent( - id: 888, - channelKey: 'my_foreground', + id: kNotificationId, + channelKey: 'background', title: notificationTitle, body: notificationBody, - notificationLayout: NotificationLayout.Default, locked: true, autoDismissible: false, + badge: 0, ), ); service.setForegroundNotificationInfo(title: notificationTitle, content: notificationBody); - final double dist = position.latlng.to( - LatLng(GlobalProviders.location.oldLatitude ?? 0, GlobalProviders.location.oldLongitude ?? 0), - ); - int time = 15; - if (dist > 30) { - time = 5; - } else if (dist > 10) { - time = 10; + if (distanceInKm != null) { + if (distanceInKm > 30) { + time = 5; + } else if (distanceInKm > 10) { + time = 10; + } } - GlobalProviders.location.setOldLongitude(position.lng); - GlobalProviders.location.setOldLatitude(position.lat); + _$location = coordinates; - _locationUpdateTimer = Timer.periodic(Duration(minutes: time), (timer) async => updateLocation()); + _$locationUpdateTimer = Timer.periodic(Duration(minutes: time), (timer) => updateLocation()); } // Start the periodic task updateLocation(); } + + /// 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 _$getDeviceGeographicalLocation(ServiceInstance service) async { + final isLocationServiceEnabled = await Geolocator.isLocationServiceEnabled(); + + if (!isLocationServiceEnabled) { + TalkerManager.instance.warning('位置服務未啟用'); + 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? _$getLocationFromCoordinates(LatLng target) { + final features = (_$geoJsonData['features'] as List).cast>(); + + for (final feature in features) { + final geometry = (feature['geometry'] as Map).cast(); + final type = geometry['type'] as String; + + if (type == 'Polygon' || type == 'MultiPolygon') { + bool isInPolygon = false; + + if (type == 'Polygon') { + final coordinates = ((geometry['coordinates'] as List)[0] as List).cast(); + final List> polygon = + coordinates.map>((coord) { + return coord.map((e) => (e as num).toDouble()).toList(); + }).toList(); + + 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; + } else { + final multiPolygon = (geometry['coordinates'] as List).cast>(); + for (final polygonCoordinates in multiPolygon) { + final coordinates = polygonCoordinates[0].cast(); + final List> polygon = + coordinates.map>((coord) { + return coord.map((e) => (e as num).toDouble()).toList(); + }).toList(); + + 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) { + return (feature['properties'] as Map)['CODE'] as String; + } + } + } + + 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 _$updatePosition(ServiceInstance service, LatLng? position) async { + service.invoke(BackgroundLocationServiceEvent.position, PositionEvent(position).toJson()); + } } diff --git a/lib/core/update.dart b/lib/core/update.dart index 93b73145e..2f0c19e9c 100644 --- a/lib/core/update.dart +++ b/lib/core/update.dart @@ -5,9 +5,14 @@ import 'package:dpip/api/exptech.dart'; import 'package:dpip/core/preference.dart'; import 'package:flutter_icmp_ping/flutter_icmp_ping.dart'; import 'package:ip_country_lookup/ip_country_lookup.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; Future updateInfoToServer() async { + final latitude = Preference.locationLatitude; + final longitude = Preference.locationLongitude; + try { + if (latitude == null || longitude == null) return; if (Preference.notifyToken != '' && DateTime.now().millisecondsSinceEpoch - (Preference.lastUpdateToServerTime ?? 0) > 86400 * 1 * 1000) { final random = Random(); @@ -15,14 +20,10 @@ Future updateInfoToServer() async { if (rand != 0) return; - ExpTech().updateDeviceLocation( - token: Preference.notifyToken, - lat: Preference.locationLatitude.toString(), - lng: Preference.locationLongitude.toString(), - ); + ExpTech().updateDeviceLocation(token: Preference.notifyToken, coordinates: LatLng(latitude, longitude)); } - unawaited(_performNetworkCheck()); + _performNetworkCheck(); } catch (e) { print('Network info update failed: $e'); } @@ -49,5 +50,3 @@ Future _performNetworkCheck() async { print('Network check failed: $e'); } } - -void unawaited(Future future) {} diff --git a/lib/global.dart b/lib/global.dart index ecd95657f..cc4803e37 100644 --- a/lib/global.dart +++ b/lib/global.dart @@ -1,7 +1,6 @@ import 'package:dpip/api/exptech.dart'; import 'package:dpip/api/model/location/location.dart'; import 'package:dpip/utils/extensions/asset_bundle.dart'; -import 'package:dpip/utils/location_to_code.dart'; import 'package:flutter/services.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -18,10 +17,10 @@ class Global { static late Map notifyTestContent; static ExpTech api = ExpTech(); - static Future loadLocationData() async { + static Future> loadLocationData() async { final data = await rootBundle.loadJson('assets/location.json'); - location = data.map((key, value) => MapEntry(key, Location.fromJson(value as Map))); + return data.map((key, value) => MapEntry(key, Location.fromJson(value as Map))); } static Future loadTimeTableData() async { @@ -54,11 +53,9 @@ class Global { packageInfo = await PackageInfo.fromPlatform(); preference = await SharedPreferences.getInstance(); box = await rootBundle.loadJson('assets/box.json'); + location = await loadLocationData(); - await loadLocationData(); await loadTimeTableData(); await loadNotifyTestContent(); - - await GeoJsonHelper.loadGeoJson('assets/map/town.json'); } } diff --git a/lib/main.dart b/lib/main.dart index 781d67001..cc98f9ac8 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -50,7 +50,7 @@ void main() async { updateInfoToServer(), ]); - initBackgroundService(); + await BackgroundLocationService.initalize(); runApp( I18n( diff --git a/lib/models/settings/location.dart b/lib/models/settings/location.dart index 02ef53eca..2df6ab450 100644 --- a/lib/models/settings/location.dart +++ b/lib/models/settings/location.dart @@ -1,69 +1,178 @@ -import 'dart:developer'; +import 'dart:collection'; -import 'package:dpip/core/preference.dart'; import 'package:flutter/material.dart'; + import 'package:maplibre_gl/maplibre_gl.dart'; -class SettingsLocationModel extends ChangeNotifier { - void _log(String message) => log(message, name: 'SettingsLocationModel'); +import 'package:dpip/core/preference.dart'; +import 'package:dpip/global.dart'; - bool get _auto => Preference.locationAuto ?? false; - final ValueNotifier codeNotifier = ValueNotifier(Preference.locationCode); - final ValueNotifier coordinateNotifier = ValueNotifier( - LatLng(Preference.locationLatitude ?? 0, Preference.locationLongitude ?? 0), - ); - double? get _oldLongitude => Preference.locationOldLongitude; - double? get _oldLatitude => Preference.locationOldLatitude; +class _SettingsLocationModel extends ChangeNotifier { + /// The underlying [ValueNotifier] for the current location represented as a postal code. + /// + /// Returns the stored location code from preferences. + /// Returns `null` if no location code has been set. + final $code = ValueNotifier(Preference.locationCode); - /// 自動定位 + /// The current location represented as a postal code. /// - /// 預設:不自動定位 - bool get auto => _auto; - void setAuto(bool value) { - Preference.locationAuto = value; - _log('Changed ${PreferenceKeys.locationAuto} to ${Preference.locationAuto}'); - notifyListeners(); - } + /// Returns the stored location code from preferences. + /// Returns `null` if no location code has been set. + String? get code => $code.value; - /// 縣市代碼 - String? get code => Preference.locationCode; + /// Sets the current location using a postal code. + /// + /// [value] The postal code to set as the current location. + /// + /// Invoking this method will also update [$code] and notify all attached listeners. + /// + /// If [value] matches the current code, no changes are made. + /// When [auto] is false, also updates the stored latitude and longitude based on the + /// location data associated with the postal code. void setCode(String? value) { + if (code == value) return; + + final location = Global.location[value]; + + // Check if the location is invalid + if (location == null) { + Preference.locationCode = null; + $code.value = null; + + if (!auto) { + Preference.locationLatitude = null; + Preference.locationLongitude = null; + + $coordinates.value = null; + } + + notifyListeners(); + return; + } + Preference.locationCode = value; - codeNotifier.value = value; - _log('Changed ${PreferenceKeys.locationCode} to ${Preference.locationCode}'); + $code.value = value; + + if (!auto) { + Preference.locationLatitude = location.lat; + Preference.locationLongitude = location.lng; + + $coordinates.value = LatLng(location.lat, location.lng); + } + notifyListeners(); } - /// 經度 - double? get longitude => Preference.locationLongitude; + /// The underlying [ValueNotifier] for the current location represented as a [LatLng] coordinate. + /// + /// Returns a [LatLng] object containing the stored coordinates for the current [code]. + /// Returns `null` if either latitude or longitude is not set. + /// + /// This is used to display the precise location of the user on the map. + /// + /// Depends on [code]. + final $coordinates = ValueNotifier( + Preference.locationLatitude != null && Preference.locationLongitude != null + ? LatLng(Preference.locationLatitude!, Preference.locationLongitude!) + : null, + ); - /// 緯度 - double? get latitude => Preference.locationLatitude; - void setLatLng({double? latitude, double? longitude}) { - Preference.locationLatitude = latitude; - _log('Changed ${PreferenceKeys.locationLatitude} to ${Preference.locationLatitude}'); + /// The current location represented as a LatLng coordinate. + /// + /// Returns a [LatLng] object containing the stored coordinates for the current [code]. + /// Returns `null` if either latitude or longitude is not set. + /// + /// This is used to display the precise location of the user on the map. + /// + /// Depends on [code]. + LatLng? get coordinates => $coordinates.value; - Preference.locationLongitude = longitude; - _log('Changed ${PreferenceKeys.locationLongitude} to ${Preference.locationLongitude}'); + /// Sets the current location using a LatLng coordinate. + /// + /// Takes a [LatLng] value containing latitude and longitude coordinates and updates + /// the stored location preferences. If value is `null`, both latitude and longitude + /// will be set to `null`. + /// + /// Invoking this method will also update [$coordinates] and notify all attached listeners. + /// + /// This method should be called aside with [setCode] if automatic location update is enabled. + /// + /// Use [setCode] instead when automatic location update is disabled. + void setCoordinates(LatLng? value) { + Preference.locationLatitude = value?.latitude; + Preference.locationLongitude = value?.longitude; - coordinateNotifier.value = LatLng(latitude ?? 0, longitude ?? 0); + $coordinates.value = value; notifyListeners(); } - /// 經度 - double? get oldLongitude => _oldLongitude; - void setOldLongitude(double? value) { - Preference.locationOldLongitude = value; - _log('Changed ${PreferenceKeys.locationOldLongitude} to ${Preference.locationOldLongitude}'); + /// The underlying [ValueNotifier] for the current state of automatic location update. + /// + /// Returns a [bool] indicating if automatic location update is enabled. + /// When enabled, the app will use GPS to automatically update + /// the current location. When disabled, the location must be set manually either by [setCode] or [setCoordinates]. + /// + /// Defaults to `false` if no preference has been set. + final $auto = ValueNotifier(Preference.locationAuto ?? false); + + /// The current state of automatic location update. + /// + /// Returns a [bool] indicating if automatic location update is enabled. + /// When enabled, the app will use GPS to automatically update + /// the current location. When disabled, the location must be set manually either by [setCode] or [setCoordinates]. + /// + /// Defaults to `false` if no preference has been set. + bool get auto => $auto.value; + + /// Sets whether location should be automatically determined using GPS. + /// + /// Takes a [bool] value indicating if automatic location detection should be enabled. + /// When enabled, the app will use GPS to automatically determine and update the current location. + /// When disabled, the location must be set manually. + void setAuto(bool value) { + Preference.locationAuto = value; + + $auto.value = value; + notifyListeners(); } - /// 緯度 - double? get oldLatitude => _oldLatitude; - void setOldLatitude(double? value) { - Preference.locationOldLatitude = value; - _log('Changed ${PreferenceKeys.locationOldLatitude} to ${Preference.locationOldLatitude}'); + /// The underlying [ValueNotifier] for the list of favorited locations. + /// + /// Returns a [List] of [String] containing the postal codes of the favorited locations. + /// + /// Defaults to an empty list if no favorited locations have been set. + final $favorited = ValueNotifier(Preference.locationFavorited); + + /// The list of favorited locations. + /// + /// Returns a [List] containing the postal codes of the favorited locations. + /// + /// Defaults to an empty list if no favorited locations have been set. + UnmodifiableListView get favorited => UnmodifiableListView($favorited.value); + + /// Adds a location to the list of favorited locations. + /// + /// Takes a [String] value representing the postal code of the location to add to the list. + /// + /// If the location is already favorited, this method will do nothing. + void favorite(String code) { + Preference.locationFavorited.add(code); + + notifyListeners(); + } + + /// Removes a location from the list of favorited locations. + /// + /// Takes a [String] value representing the postal code of the location to remove from the list. + /// + /// If the location is not favorited, this method will do nothing. + void unfavorite(String code) { + Preference.locationFavorited.remove(code); + notifyListeners(); } } + +class SettingsLocationModel extends _SettingsLocationModel {} diff --git a/lib/route/event_viewer/intensity.dart b/lib/route/event_viewer/intensity.dart index 24dce69d3..7abe1ddc9 100644 --- a/lib/route/event_viewer/intensity.dart +++ b/lib/route/event_viewer/intensity.dart @@ -15,9 +15,9 @@ import 'package:dpip/core/ios_get_location.dart'; import 'package:dpip/core/providers.dart'; import 'package:dpip/global.dart'; import 'package:dpip/utils/extensions/build_context.dart'; +import 'package:dpip/utils/extensions/latlng.dart'; import 'package:dpip/utils/intensity_color.dart'; import 'package:dpip/utils/list_icon.dart'; -import 'package:dpip/utils/need_location.dart'; import 'package:dpip/utils/parser.dart'; import 'package:dpip/widgets/chip/label_chip.dart'; import 'package:dpip/widgets/list/detail_field_tile.dart'; @@ -55,31 +55,6 @@ class _IntensityPageState extends State { _mapController = controller; } - Future _addUserLocationMarker() async { - if (isUserLocationValid) { - await _mapController.removeLayer('markers'); - await _mapController.addLayer( - 'markers-geojson', - 'markers', - const SymbolLayerProperties( - symbolZOrder: 'source', - iconSize: [ - Expressions.interpolate, - ['linear'], - [Expressions.zoom], - 5, - 0.5, - 10, - 1.5, - ], - iconImage: 'gps', - iconAllowOverlap: true, - iconIgnorePlacement: true, - ), - ); - } - } - Future _loadMap() async { radarList = await ExpTech().getRadarList(); @@ -96,37 +71,14 @@ class _IntensityPageState extends State { } Future start() async { - userLat = GlobalProviders.location.latitude ?? 0; - userLon = GlobalProviders.location.longitude ?? 0; - - isUserLocationValid = userLon != 0 && userLat != 0; - - if (isUserLocationValid) { - await _mapController.setGeoJsonSource('markers-geojson', { - 'type': 'FeatureCollection', - 'features': [ - { - 'type': 'Feature', - 'properties': {}, - 'geometry': { - 'coordinates': [userLon, userLat], - 'type': 'Point', - }, - }, - ], - }); - final cameraUpdate = CameraUpdate.newLatLngZoom(LatLng(userLat, userLon), 8); - await _mapController.animateCamera(cameraUpdate, duration: const Duration(milliseconds: 1000)); - } + final location = GlobalProviders.location.coordinates; - if (!isUserLocationValid && !GlobalProviders.location.auto) { - await showLocationDialog(context); + if (location != null && location.isValid) { + await _mapController.animateCamera(CameraUpdate.newLatLngZoom(location, 7.4)); + } else { + await _mapController.animateCamera(CameraUpdate.newLatLngZoom(DpipMap.kTaiwanCenter, 6.4)); } - await _addUserLocationMarker(); - - setState(() {}); - getEventInfo(); if (!widget.item.addition.isFinal) { diff --git a/lib/route/event_viewer/thunderstorm.dart b/lib/route/event_viewer/thunderstorm.dart index 8bbf20bb4..1983a1585 100644 --- a/lib/route/event_viewer/thunderstorm.dart +++ b/lib/route/event_viewer/thunderstorm.dart @@ -12,18 +12,16 @@ import 'package:timezone/timezone.dart'; import 'package:dpip/api/exptech.dart'; import 'package:dpip/api/model/history/history.dart'; -import 'package:dpip/app_old/page/map/radar/radar.dart'; +import 'package:dpip/app/map/_widgets/map_legend.dart'; import 'package:dpip/core/ios_get_location.dart'; import 'package:dpip/core/providers.dart'; import 'package:dpip/global.dart'; import 'package:dpip/utils/extensions/build_context.dart'; +import 'package:dpip/utils/extensions/latlng.dart'; import 'package:dpip/utils/list_icon.dart'; -import 'package:dpip/utils/need_location.dart'; import 'package:dpip/utils/parser.dart'; -import 'package:dpip/utils/radar_color.dart'; import 'package:dpip/widgets/chip/label_chip.dart'; import 'package:dpip/widgets/list/detail_field_tile.dart'; -import 'package:dpip/widgets/map/legend.dart'; import 'package:dpip/widgets/map/map.dart'; import 'package:dpip/widgets/sheet/bottom_sheet_drag_handle.dart'; @@ -39,8 +37,8 @@ class ThunderstormPage extends StatefulWidget { class _ThunderstormPageState extends State { late MapLibreMapController _mapController; List radarList = []; - double userLat = 0; - double userLon = 0; + double? userLat; + double? userLon; bool isUserLocationValid = false; bool _showLegend = false; Timer? _blinkTimer; @@ -122,34 +120,13 @@ class _ThunderstormPageState extends State { } Future start() async { - userLat = GlobalProviders.location.latitude ?? 0; - userLon = GlobalProviders.location.longitude ?? 0; - - isUserLocationValid = userLon != 0 && userLat != 0; - - if (isUserLocationValid) { - await _mapController.setGeoJsonSource('markers-geojson', { - 'type': 'FeatureCollection', - 'features': [ - { - 'type': 'Feature', - 'properties': {}, - 'geometry': { - 'coordinates': [userLon, userLat], - 'type': 'Point', - }, - }, - ], - }); - final cameraUpdate = CameraUpdate.newLatLngZoom(LatLng(userLat, userLon), 8); - await _mapController.animateCamera(cameraUpdate, duration: const Duration(milliseconds: 1000)); - } + final location = GlobalProviders.location.coordinates; - if (!isUserLocationValid && !GlobalProviders.location.auto) { - await showLocationDialog(context); + if (location != null && location.isValid) { + await _mapController.animateCamera(CameraUpdate.newLatLngZoom(location, 7.4)); + } else { + await _mapController.animateCamera(CameraUpdate.newLatLngZoom(DpipMap.kTaiwanCenter, 6.4)); } - - setState(() {}); } void _toggleLegend() { @@ -159,32 +136,29 @@ class _ThunderstormPageState extends State { } Widget _buildLegend() { - return MapLegend( - children: [ - _buildColorBar(), - const SizedBox(height: 8), - _buildColorBarLabels(), - const SizedBox(height: 12), - Text('單位:dBZ', style: context.theme.textTheme.labelMedium), + return ColorLegend( + reverse: true, + unit: 'dBZ', + items: [ + ColorLegendItem(color: const Color(0xff00ffff), value: 0), + ColorLegendItem(color: const Color(0xff00a3ff), value: 5), + ColorLegendItem(color: const Color(0xff005bff), value: 10), + ColorLegendItem(color: const Color(0xff0000ff), value: 15, blendTail: false), + ColorLegendItem(color: const Color(0xff00ff00), value: 16, hidden: true), + ColorLegendItem(color: const Color(0xff00d300), value: 20), + ColorLegendItem(color: const Color(0xff00a000), value: 25), + ColorLegendItem(color: const Color(0xffccea00), value: 30), + ColorLegendItem(color: const Color(0xffffd300), value: 35), + ColorLegendItem(color: const Color(0xffff8800), value: 40), + ColorLegendItem(color: const Color(0xffff1800), value: 45), + ColorLegendItem(color: const Color(0xffd30000), value: 50), + ColorLegendItem(color: const Color(0xffa00000), value: 55), + ColorLegendItem(color: const Color(0xffea00cc), value: 60), + ColorLegendItem(color: const Color(0xff9600ff), value: 65), ], ); } - Widget _buildColorBar() { - return SizedBox(height: 20, width: 300, child: CustomPaint(painter: ColorBarPainter(dBZColors))); - } - - Widget _buildColorBarLabels() { - final labels = List.generate(14, (index) => (index * 5).toString()); - return SizedBox( - width: 300, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: labels.map((label) => Text(label, style: const TextStyle(fontSize: 9))).toList(), - ), - ); - } - @override Widget build(BuildContext context) { final TZDateTime radarDateTime = TZDateTime.fromMillisecondsSinceEpoch( diff --git a/lib/utils/extensions/datetime.dart b/lib/utils/extensions/datetime.dart index c1b962781..a944d7724 100644 --- a/lib/utils/extensions/datetime.dart +++ b/lib/utils/extensions/datetime.dart @@ -4,9 +4,16 @@ import 'package:i18n_extension/i18n_extension.dart'; import 'package:intl/intl.dart'; import 'package:timezone/timezone.dart'; +extension DateTimeExtension on DateTime { + String toSimpleDateTimeString() => DateFormat('MM/dd HH:mm').format(this); + String toLocaleFullDateString(BuildContext context) => + DateFormat('yyyy/MM/dd (EEEE)', context.locale.toLanguageTag()).format(this); + String toDateTimeString() => DateFormat('yyyy/MM/dd HH:mm:ss').format(this); + String toLocaleTimeString() => DateFormat('HH:mm:ss').format(this); +} + extension TZDateTimeExtension on TZDateTime { - String toSimpleDateTimeString(BuildContext context) => - DateFormat('MM/dd HH:mm', context.locale.toLanguageTag()).format(this); + String toSimpleDateTimeString() => DateFormat('MM/dd HH:mm').format(this); String toLocaleFullDateString(BuildContext context) => DateFormat('yyyy/MM/dd (EEEE)', context.locale.toLanguageTag()).format(this); String toLocaleDateTimeString(BuildContext context) => diff --git a/lib/utils/extensions/int.dart b/lib/utils/extensions/int.dart index 8aa717177..362c7b055 100644 --- a/lib/utils/extensions/int.dart +++ b/lib/utils/extensions/int.dart @@ -25,7 +25,7 @@ extension CommonContext on int { TZDateTime get asTZDateTime => parseDateTime(this); int get asFahrenheit => (this * 9 / 5 + 32).round(); - String toSimpleDateTimeString(BuildContext context) => asTZDateTime.toSimpleDateTimeString(context); + String toSimpleDateTimeString(BuildContext context) => asTZDateTime.toSimpleDateTimeString(); String toLocaleFullDateString(BuildContext context) => asTZDateTime.toLocaleFullDateString(context); String toLocaleDateTimeString(BuildContext context) => asTZDateTime.toLocaleDateTimeString(context); String toLocaleTimeString(BuildContext context) => asTZDateTime.toLocaleTimeString(context); diff --git a/lib/utils/extensions/latlng.dart b/lib/utils/extensions/latlng.dart index e427a854d..185c39c77 100644 --- a/lib/utils/extensions/latlng.dart +++ b/lib/utils/extensions/latlng.dart @@ -1,5 +1,4 @@ -import 'dart:math'; - +import 'package:geolocator/geolocator.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:dpip/utils/geojson.dart'; @@ -11,17 +10,7 @@ extension GeoJsonLatLng on LatLng { return GeoJsonFeatureBuilder(GeoJsonFeatureType.Point)..setGeometry(toGeoJsonCoordinates() as List); } - double to(LatLng other) { - final lat1 = latitude * pi / 180; - final lat2 = other.latitude * pi / 180; - final lon1 = longitude * pi / 180; - final lon2 = other.longitude * pi / 180; - - final dlon = lon2 - lon1; - final dlat = lat2 - lat1; - final a = sin(dlat / 2) * sin(dlat / 2) + cos(lat1) * cos(lat2) * sin(dlon / 2) * sin(dlon / 2); - final c = 2 * atan2(sqrt(a), sqrt(1 - a)); - - return 6371.0 * c; - } + /// Calculates the distance between the supplied coordinates in meters. + /// The distance between the coordinates is calculated using the Haversine formula (see https://en.wikipedia.org/wiki/Haversine_formula). + double to(LatLng other) => Geolocator.distanceBetween(latitude, longitude, other.latitude, other.longitude); } diff --git a/lib/utils/extensions/preference.dart b/lib/utils/extensions/preference.dart index c2ab5ceff..d34727802 100644 --- a/lib/utils/extensions/preference.dart +++ b/lib/utils/extensions/preference.dart @@ -1,3 +1,4 @@ +import 'package:dpip/utils/log.dart'; import 'package:shared_preferences/shared_preferences.dart'; extension PreferenceExtension on SharedPreferencesWithCache { @@ -16,23 +17,30 @@ extension PreferenceExtension on SharedPreferencesWithCache { /// /// If [value] is null or omitted, the key will be removed from SharedPreferences. Future set(String key, [T? value]) { - if (value == null) { - return remove(key); - } + try { + if (value == null) { + return remove(key); + } - switch (value) { - case String(): - return setString(key, value); - case int(): - return setInt(key, value); - case bool(): - return setBool(key, value); - case double(): - return setDouble(key, value); - case List(): - return setStringList(key, value); - default: - throw ArgumentError.value(value, 'value', 'Unsupported type: ${value.runtimeType}'); + switch (value) { + case String(): + return setString(key, value); + case int(): + return setInt(key, value); + case bool(): + return setBool(key, value); + case double(): + return setDouble(key, value); + case List(): + return setStringList(key, value); + default: + throw ArgumentError.value(value, 'value', 'Unsupported type: ${value.runtimeType}'); + } + } catch (e, s) { + TalkerManager.instance.error('💾 $key set to "$value" FAILED', e, s); + rethrow; + } finally { + TalkerManager.instance.info('💾 $key set to "$value"'); } } } diff --git a/lib/utils/extensions/string.dart b/lib/utils/extensions/string.dart index eaaba9b36..a0505f473 100644 --- a/lib/utils/extensions/string.dart +++ b/lib/utils/extensions/string.dart @@ -15,7 +15,7 @@ extension StringExtension on String { String toLocaleFullDateString(BuildContext context) => asInt.asTZDateTime.toLocaleFullDateString(context); String toLocaleTimeString(BuildContext context) => asInt.asTZDateTime.toLocaleTimeString(context); - String toSimpleDateTimeString(BuildContext context) => asInt.asTZDateTime.toSimpleDateTimeString(context); + String toSimpleDateTimeString(BuildContext context) => asInt.asTZDateTime.toSimpleDateTimeString(); Text get asText => Text(this); TextSpan get asTextSpan => TextSpan(text: this); diff --git a/lib/utils/location_to_code.dart b/lib/utils/location_to_code.dart index 0cb648846..a162fbba3 100644 --- a/lib/utils/location_to_code.dart +++ b/lib/utils/location_to_code.dart @@ -1,7 +1,3 @@ -import 'dart:convert'; - -import 'package:flutter/services.dart' show rootBundle; - class GeoJsonProperties { final String town; final String county; @@ -26,67 +22,11 @@ class GeoJsonProperties { } class GeoJsonHelper { - static Map? _geoJsonData; + GeoJsonHelper._(); - static Future loadGeoJson(String geojsonAssetPath) async { - final String geojsonStr = await rootBundle.loadString(geojsonAssetPath); - _geoJsonData = json.decode(geojsonStr); - } + static Map? _$geoJsonData; static GeoJsonProperties? checkPointInPolygons(double lat, double lng) { - if (_geoJsonData == null) return null; - for (final feature in _geoJsonData!['features']) { - if (feature['geometry']['type'] == 'Polygon' || feature['geometry']['type'] == 'MultiPolygon') { - final List>> polygons = _getPolygons(feature['geometry']); - - for (final polygon in polygons) { - if (_isPointInPolygon(lat, lng, polygon)) { - return GeoJsonProperties.fromJson(feature['properties']); - } - } - } - } - return null; - } - - static List>> _getPolygons(Map geometry) { - final List>> polygons = []; - - if (geometry['type'] == 'Polygon') { - polygons.add(_convertToDoubleList(geometry['coordinates'][0])); - } else if (geometry['type'] == 'MultiPolygon') { - for (final polygon in geometry['coordinates']) { - polygons.add(_convertToDoubleList(polygon[0])); - } - } - - return polygons; - } - - static List> _convertToDoubleList(List coordinates) { - return coordinates.map>((coord) { - if (coord is List) { - return coord.map((e) => e is num ? e.toDouble() : 0.0).toList(); - } else { - return [0.0, 0.0]; - } - }).toList(); - } - - static bool _isPointInPolygon(double lat, double lng, List> polygon) { - 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 > lat) != (yj > lat)) && (lng < (xj - xi) * (lat - yi) / (yj - yi) + xi); - if (intersect) isInside = !isInside; - - j = i; - } - return isInside; + if (_$geoJsonData == null) return null; } } diff --git a/lib/widgets/map/map.dart b/lib/widgets/map/map.dart index 76b4ff074..6c66456cf 100644 --- a/lib/widgets/map/map.dart +++ b/lib/widgets/map/map.dart @@ -220,7 +220,7 @@ class DpipMapState extends State { await getSavedLocation(); } - final location = GlobalProviders.location.coordinateNotifier.value; + final location = GlobalProviders.location.coordinates; const sourceId = BaseMapSourceIds.userLocation; const layerId = BaseMapLayerIds.userLocation; @@ -228,7 +228,7 @@ class DpipMapState extends State { final isSourceExists = (await controller.getSourceIds()).contains(sourceId); final isLayerExists = (await controller.getLayerIds()).contains(layerId); - if (!location.isValid) { + if (location == null || !location.isValid) { if (isLayerExists) { await controller.removeLayer(layerId); TalkerManager.instance.info('Removed Layer "$layerId"'); @@ -285,7 +285,7 @@ class DpipMapState extends State { void initState() { super.initState(); - GlobalProviders.location.coordinateNotifier.addListener(_updateUserLocation); + GlobalProviders.location.$coordinates.addListener(_updateUserLocation); getApplicationDocumentsDirectory().then((dir) async { final documentDir = dir.path; @@ -355,7 +355,7 @@ class DpipMapState extends State { @override void dispose() { - GlobalProviders.location.coordinateNotifier.removeListener(_updateUserLocation); + GlobalProviders.location.$coordinates.removeListener(_updateUserLocation); super.dispose(); } } From e87ffd37d6264f36e9e6cb55207a461395b2bf02 Mon Sep 17 00:00:00 2001 From: Kamiya Date: Thu, 7 Aug 2025 22:08:17 +0800 Subject: [PATCH 02/21] fix: service is auto starting after configure --- lib/core/service.dart | 1 + lib/utils/location_to_code.dart | 32 -------------------------------- 2 files changed, 1 insertion(+), 32 deletions(-) delete mode 100644 lib/utils/location_to_code.dart diff --git a/lib/core/service.dart b/lib/core/service.dart index dfc1cc977..387709179 100644 --- a/lib/core/service.dart +++ b/lib/core/service.dart @@ -76,6 +76,7 @@ class BackgroundLocationService { await instance.configure( androidConfiguration: AndroidConfiguration( onStart: _$onStart, + autoStart: false, isForegroundMode: true, foregroundServiceTypes: [AndroidForegroundType.location], notificationChannelId: 'background', diff --git a/lib/utils/location_to_code.dart b/lib/utils/location_to_code.dart deleted file mode 100644 index a162fbba3..000000000 --- a/lib/utils/location_to_code.dart +++ /dev/null @@ -1,32 +0,0 @@ -class GeoJsonProperties { - final String town; - final String county; - final String name; - final int code; - - GeoJsonProperties({required this.town, required this.county, required this.name, required this.code}); - - factory GeoJsonProperties.fromJson(Map json) { - return GeoJsonProperties( - town: json['TOWN'] as String, - county: json['COUNTY'] as String, - name: json['NAME'] as String, - code: json['CODE'] as int, - ); - } - - @override - String toString() { - return 'GeoJsonProperties(town: $town, county: $county, name: $name, code: $code)'; - } -} - -class GeoJsonHelper { - GeoJsonHelper._(); - - static Map? _$geoJsonData; - - static GeoJsonProperties? checkPointInPolygons(double lat, double lng) { - if (_$geoJsonData == null) return null; - } -} From 84ba77a8fd050646b24f97cb0dfe1a733925f35f Mon Sep 17 00:00:00 2001 From: Kamiya Date: Thu, 7 Aug 2025 22:31:24 +0800 Subject: [PATCH 03/21] fix: missing function --- lib/core/ios_get_location.dart | 21 ++++++---- lib/global.dart | 3 +- lib/utils/map_utils.dart | 77 +++++++++++++++++++++++++++++++++- 3 files changed, 90 insertions(+), 11 deletions(-) diff --git a/lib/core/ios_get_location.dart b/lib/core/ios_get_location.dart index 1a96ee6ca..2c8cf9c2f 100644 --- a/lib/core/ios_get_location.dart +++ b/lib/core/ios_get_location.dart @@ -1,11 +1,13 @@ import 'dart:async'; -import 'package:dpip/core/providers.dart'; -import 'package:dpip/utils/location_to_code.dart'; -import 'package:dpip/utils/log.dart'; import 'package:flutter/services.dart'; + import 'package:maplibre_gl/maplibre_gl.dart'; +import 'package:dpip/core/providers.dart'; +import 'package:dpip/utils/log.dart'; +import 'package:dpip/utils/map_utils.dart'; + const _channel = MethodChannel('com.exptech.dpip/data'); Completer? _completer; @@ -18,17 +20,18 @@ Future getSavedLocation() async { try { final result = await _channel.invokeMethod>('getSavedLocation'); - final data = result?.map((key, value) => MapEntry(key, value.toDouble())); - final latitude = data?['lat'] as double?; - final longitude = data?['lon'] as double?; + if (result == null) return; + + final latitude = result['lat'] as double?; + final longitude = result['lon'] as double?; if (latitude != null && longitude != null) { + final code = getTownCodeFromCoordinates(LatLng(latitude, longitude)); + GlobalProviders.location.setCode(code); GlobalProviders.location.setCoordinates(LatLng(latitude, longitude)); - final location = GeoJsonHelper.checkPointInPolygons(latitude, longitude); - print(location); - GlobalProviders.location.setCode(location?.code.toString()); } else { + GlobalProviders.location.setCode(null); GlobalProviders.location.setCoordinates(null); } } catch (e) { diff --git a/lib/global.dart b/lib/global.dart index cc4803e37..939d4f0c4 100644 --- a/lib/global.dart +++ b/lib/global.dart @@ -11,7 +11,7 @@ class Global { static late PackageInfo packageInfo; static late SharedPreferences preference; static late Map location; - static late Map geojson; + static late Map townGeojson; static late Map> timeTable; static late Map box; static late Map notifyTestContent; @@ -54,6 +54,7 @@ class Global { preference = await SharedPreferences.getInstance(); box = await rootBundle.loadJson('assets/box.json'); location = await loadLocationData(); + townGeojson = await rootBundle.loadJson('assets/map/town.json'); await loadTimeTableData(); await loadNotifyTestContent(); diff --git a/lib/utils/map_utils.dart b/lib/utils/map_utils.dart index 8c05bb7ce..3557a1541 100644 --- a/lib/utils/map_utils.dart +++ b/lib/utils/map_utils.dart @@ -1,9 +1,11 @@ import 'dart:math'; +import 'package:maplibre_gl/maplibre_gl.dart'; + import 'package:dpip/api/model/eew.dart'; +import 'package:dpip/global.dart'; import 'package:dpip/utils/extensions/latlng.dart'; import 'package:dpip/utils/geojson.dart'; -import 'package:maplibre_gl/maplibre_gl.dart'; List expandBounds(List bounds, LatLng point) { // [南西,北東] @@ -184,3 +186,76 @@ bool checkBoxSkip(Map eewLastInfo, Map eewDist, Lis return passed; } + +String? getTownCodeFromCoordinates(LatLng target) { + final features = (Global.townGeojson['features'] as List).cast>(); + + for (final feature in features) { + final geometry = (feature['geometry'] as Map).cast(); + final type = geometry['type'] as String; + + if (type == 'Polygon' || type == 'MultiPolygon') { + bool isInPolygon = false; + + if (type == 'Polygon') { + final coordinates = ((geometry['coordinates'] as List)[0] as List).cast(); + final List> polygon = + coordinates.map>((coord) { + return coord.map((e) => (e as num).toDouble()).toList(); + }).toList(); + + 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; + } else { + final multiPolygon = (geometry['coordinates'] as List).cast>(); + for (final polygonCoordinates in multiPolygon) { + final coordinates = polygonCoordinates[0].cast(); + final List> polygon = + coordinates.map>((coord) { + return coord.map((e) => (e as num).toDouble()).toList(); + }).toList(); + + 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) { + return (feature['properties'] as Map)['CODE'] as String; + } + } + } + + return null; +} From a90399ab90f241c254d0d3afe6a390ca2bdcf164 Mon Sep 17 00:00:00 2001 From: Kamiya Date: Thu, 7 Aug 2025 23:05:30 +0800 Subject: [PATCH 04/21] fix(BackgroundLocationService): platform guard --- lib/core/service.dart | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/core/service.dart b/lib/core/service.dart index 387709179..ff370d6a6 100644 --- a/lib/core/service.dart +++ b/lib/core/service.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'dart:ui'; import 'package:flutter/services.dart'; @@ -73,6 +74,11 @@ class BackgroundLocationService { TalkerManager.instance.info('⚙️ initializing location service'); + if (!Platform.isAndroid && !Platform.isIOS) { + TalkerManager.instance.warning('⚙️ service is not supported on this platform (${Platform.operatingSystem})'); + return; + } + await instance.configure( androidConfiguration: AndroidConfiguration( onStart: _$onStart, @@ -85,7 +91,7 @@ class BackgroundLocationService { foregroundServiceNotificationId: kNotificationId, ), // iOS is handled in native code - iosConfiguration: IosConfiguration(), + iosConfiguration: IosConfiguration(autoStart: false), ); // Reloads the UI isolate's preference cache when a new position is set in the background service. From 1f67c5a4a5dc341406bfd3104da99a3d2b88be83 Mon Sep 17 00:00:00 2001 From: Kamiya Date: Fri, 8 Aug 2025 10:17:28 +0800 Subject: [PATCH 05/21] fix: attemp to fix DidNotStartInTimeException --- lib/app/settings/location/page.dart | 4 +- lib/core/service.dart | 180 ++++++++++++++++++---------- lib/main.dart | 2 +- lib/models/settings/location.dart | 15 +++ lib/models/settings/map.dart | 12 ++ 5 files changed, 147 insertions(+), 66 deletions(-) diff --git a/lib/app/settings/location/page.dart b/lib/app/settings/location/page.dart index 99acdea88..93ebbc781 100644 --- a/lib/app/settings/location/page.dart +++ b/lib/app/settings/location/page.dart @@ -279,7 +279,7 @@ class _SettingsLocationPageState extends State with Widget Future toggleAutoLocation() async { final shouldEnable = !context.read().auto; - await BackgroundLocationService.stop(); + await BackgroundLocationServiceManager.stop(); if (shouldEnable) { final notification = await checkNotificationPermission(); @@ -313,7 +313,7 @@ class _SettingsLocationPageState extends State with Widget if (Platform.isAndroid) { if (shouldEnable) { - await BackgroundLocationService.start(); + await BackgroundLocationServiceManager.start(); } } if (Platform.isIOS) { diff --git a/lib/core/service.dart b/lib/core/service.dart index ff370d6a6..1153309e1 100644 --- a/lib/core/service.dart +++ b/lib/core/service.dart @@ -12,6 +12,7 @@ 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/asset_bundle.dart'; import 'package:dpip/utils/extensions/datetime.dart'; @@ -47,12 +48,9 @@ final class BackgroundLocationServiceEvent { /// Background location service. /// /// This class is responsible for managing the background location service. -/// It is used to get the current location of the device inthe 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 BackgroundLocationService { - BackgroundLocationService._(); +/// It is used to handle start and stop the service. +class BackgroundLocationServiceManager { + BackgroundLocationServiceManager._(); /// The notification ID used for the background service notification static const kNotificationId = 888888; @@ -78,36 +76,48 @@ class BackgroundLocationService { TalkerManager.instance.warning('⚙️ service is not supported on this platform (${Platform.operatingSystem})'); return; } + try { + await instance.configure( + androidConfiguration: AndroidConfiguration( + onStart: BackgroundLocationService._$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), + ); - await instance.configure( - androidConfiguration: AndroidConfiguration( - onStart: _$onStart, - autoStart: false, - isForegroundMode: true, - 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. + instance.on(BackgroundLocationServiceEvent.position).listen((data) async { + final event = PositionEvent.fromJson(data!); + try { + TalkerManager.instance.info('⚙️ location updated by service, reloading preferences'); - // Reloads the UI isolate's preference cache when a new position is set in the background service. - instance.on(BackgroundLocationServiceEvent.position).listen((data) { - final event = PositionEvent.fromJson(data!); + await Preference.reload(); + GlobalProviders.location.refresh(); - Preference.reload(); + // Handle FCM notification + final fcmToken = Preference.notifyToken; + if (fcmToken.isNotEmpty && event.coordinates != null) { + await ExpTech().updateDeviceLocation(token: fcmToken, coordinates: event.coordinates!); + } - // Handle FCM notification - final fcmToken = Preference.notifyToken; - if (fcmToken.isNotEmpty && event.coordinates != null) { - ExpTech().updateDeviceLocation(token: fcmToken, coordinates: event.coordinates!); - } - }); + TalkerManager.instance.info('⚙️ preferences reloaded'); + } catch (e, s) { + TalkerManager.instance.error('⚙️ failed to reload preferences', e, s); + } + }); - initialized = true; + initialized = true; + TalkerManager.instance.info('⚙️ service initialized'); + } catch (e, s) { + TalkerManager.instance.error('⚙️ initializing location service FAILED', e, s); + } if (Preference.locationAuto == true) await start(); } @@ -134,6 +144,19 @@ class BackgroundLocationService { TalkerManager.instance.info('⚙️ stopping location service'); instance.invoke(BackgroundLocationServiceEvent.stop); } +} + +/// 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 BackgroundLocationService { + BackgroundLocationService._(); + + /// The service instance + static late AndroidServiceInstance _$service; /// The last known location coordinates static LatLng? _$location; @@ -154,13 +177,14 @@ class BackgroundLocationService { /// Adjusts update frequency based on movement distance. @pragma('vm:entry-point') static Future _$onStart(ServiceInstance service) async { - DartPluginRegistrant.ensureInitialized(); - if (service is! AndroidServiceInstance) return; + _$service = service; + + DartPluginRegistrant.ensureInitialized(); await AwesomeNotifications().createNotification( content: NotificationContent( - id: kNotificationId, + id: BackgroundLocationServiceManager.kNotificationId, channelKey: 'background', title: 'DPIP', body: '自動定位服務啟動中...', @@ -170,51 +194,73 @@ class BackgroundLocationService { ), ); + await _$service.setAsForegroundService(); + await Preference.init(); _$geoJsonData = await rootBundle.loadJson('assets/map/town.json'); _$locationData = await Global.loadLocationData(); - service.setAutoStartOnBootMode(true); + _$service.setAutoStartOnBootMode(true); + + _$service.on(BackgroundLocationServiceEvent.stop).listen((data) async { + try { + TalkerManager.instance.info('⚙️ stopping location service'); + + // Cleanup timer + _$locationUpdateTimer?.cancel(); + + await _$service.setAutoStartOnBootMode(false); + await _$service.stopSelf(); - service.on(BackgroundLocationServiceEvent.stop).listen((data) { - _$locationUpdateTimer?.cancel(); - service.stopSelf().then((_) { TalkerManager.instance.info('⚙️ location service stopped'); - }); + } catch (e, s) { + TalkerManager.instance.error('⚙️ stopping location service FAILED', e, s); + } }); - // Define the periodic location update task - Future updateLocation() async { - _$locationUpdateTimer?.cancel(); - if (!await service.isForegroundService()) return; + // Start the periodic location update task + await _$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 _$task() async { + if (!await _$service.isForegroundService()) return; + + final $perf = Stopwatch()..start(); + TalkerManager.instance.debug('⚙️ task started'); + + try { // Get current position and location info - final coordinates = await _$getDeviceGeographicalLocation(service); + final coordinates = await _$getDeviceGeographicalLocation(); if (coordinates == null) { - _$updatePosition(service, null); + _$updatePosition(_$service, null); return; } final locationCode = _$getLocationFromCoordinates(coordinates); - final lastLocation = _$location; + final previousLocation = _$location; - final distanceInKm = lastLocation != null ? coordinates.to(lastLocation) : null; + final distanceInKm = previousLocation != null ? coordinates.to(previousLocation) : null; if (distanceInKm == null || distanceInKm >= 250) { - TalkerManager.instance.debug('距離: $distanceInKm 更新位置'); - _$updatePosition(service, coordinates); + TalkerManager.instance.debug('⚙️ distance: $distanceInKm, updating position'); + _$updatePosition(_$service, coordinates); } else { - TalkerManager.instance.debug('距離: $distanceInKm 不更新位置'); + TalkerManager.instance.debug('⚙️ distance: $distanceInKm, not updating position'); } + // Update notification with current position final latitude = coordinates.latitude.toStringAsFixed(4); final longitude = coordinates.longitude.toStringAsFixed(4); final location = locationCode != null ? _$locationData[locationCode] : null; final locationName = location == null ? '服務區域外' : '${location.city} ${location.town}'; - // Update notification with current position const notificationTitle = '自動定位中'; final timestamp = DateTime.now().toDateTimeString(); final notificationBody = @@ -223,7 +269,7 @@ class BackgroundLocationService { await AwesomeNotifications().createNotification( content: NotificationContent( - id: kNotificationId, + id: BackgroundLocationServiceManager.kNotificationId, channelKey: 'background', title: notificationTitle, body: notificationBody, @@ -232,25 +278,31 @@ class BackgroundLocationService { badge: 0, ), ); - service.setForegroundNotificationInfo(title: notificationTitle, content: notificationBody); - int time = 15; + _$service.setForegroundNotificationInfo(title: notificationTitle, content: notificationBody); + + // Determine the next update time based on the distance moved + int nextUpdateInterval = 15; if (distanceInKm != null) { if (distanceInKm > 30) { - time = 5; + nextUpdateInterval = 5; } else if (distanceInKm > 10) { - time = 10; + nextUpdateInterval = 10; } } - _$location = coordinates; - - _$locationUpdateTimer = Timer.periodic(Duration(minutes: time), (timer) => updateLocation()); + _$locationUpdateTimer?.cancel(); + _$locationUpdateTimer = Timer.periodic(Duration(minutes: nextUpdateInterval), (timer) => _$task()); + } catch (e, s) { + $perf.stop(); + TalkerManager.instance.error('⚙️ task FAILED after ${$perf.elapsedMilliseconds}ms', e, s); + } finally { + if ($perf.isRunning) { + $perf.stop(); + TalkerManager.instance.debug('⚙️ task completed in ${$perf.elapsedMilliseconds}ms'); + } } - - // Start the periodic task - updateLocation(); } /// Gets the current geographical location of the device. @@ -258,7 +310,7 @@ class BackgroundLocationService { /// Returns null if location services are disabled. /// Uses medium accuracy for location detection. @pragma('vm:entry-point') - static Future _$getDeviceGeographicalLocation(ServiceInstance service) async { + static Future _$getDeviceGeographicalLocation() async { final isLocationServiceEnabled = await Geolocator.isLocationServiceEnabled(); if (!isLocationServiceEnabled) { @@ -356,6 +408,8 @@ class BackgroundLocationService { /// by the main app to update the UI. @pragma('vm:entry-point') static Future _$updatePosition(ServiceInstance service, LatLng? position) async { + _$location = position; + service.invoke(BackgroundLocationServiceEvent.position, PositionEvent(position).toJson()); } } diff --git a/lib/main.dart b/lib/main.dart index cc98f9ac8..a5961b91f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -50,7 +50,7 @@ void main() async { updateInfoToServer(), ]); - await BackgroundLocationService.initalize(); + await BackgroundLocationServiceManager.initalize(); runApp( I18n( diff --git a/lib/models/settings/location.dart b/lib/models/settings/location.dart index 2df6ab450..d9658f069 100644 --- a/lib/models/settings/location.dart +++ b/lib/models/settings/location.dart @@ -173,6 +173,21 @@ class _SettingsLocationModel extends ChangeNotifier { notifyListeners(); } + + /// Refreshes the location settings from preferences. + /// + /// Updates the [code], [coordinates], and [auto] properties to reflect the current preferences. + /// + /// This method is used to refresh the location settings when the preferences are updated. + void refresh() { + $code.value = Preference.locationCode; + $coordinates.value = + Preference.locationLatitude != null && Preference.locationLongitude != null + ? LatLng(Preference.locationLatitude!, Preference.locationLongitude!) + : null; + $auto.value = Preference.locationAuto ?? false; + $favorited.value = Preference.locationFavorited; + } } class SettingsLocationModel extends _SettingsLocationModel {} diff --git a/lib/models/settings/map.dart b/lib/models/settings/map.dart index 7d8f72119..2bb0f7710 100644 --- a/lib/models/settings/map.dart +++ b/lib/models/settings/map.dart @@ -40,4 +40,16 @@ class SettingsMapModel extends ChangeNotifier { _log('Changed ${PreferenceKeys.mapLayers} to $value'); notifyListeners(); } + + /// Refreshes the map settings from preferences. + /// + /// Updates the [updateInterval], [baseMap], and [layers] properties to reflect the current preferences. + /// + /// This method is used to refresh the map settings when the preferences are updated. + void refresh() { + updateIntervalNotifier.value = Preference.mapUpdateFps ?? 10; + baseMapNotifier.value = BaseMapType.values.asNameMap()[Preference.mapBase] ?? BaseMapType.exptech; + layersNotifier.value = + Preference.mapLayers?.split(',').map((v) => MapLayer.values.byName(v)).toSet() ?? {MapLayer.monitor}; + } } From 6e12a613aba20fb5d5e4b4b706e054bc36a661d0 Mon Sep 17 00:00:00 2001 From: Kamiya Date: Fri, 8 Aug 2025 11:26:24 +0800 Subject: [PATCH 06/21] refactor: simplify logics for location settings --- lib/app/settings/location/page.dart | 549 ++++++++-------------------- lib/core/service.dart | 54 ++- 2 files changed, 186 insertions(+), 417 deletions(-) diff --git a/lib/app/settings/location/page.dart b/lib/app/settings/location/page.dart index 93ebbc781..58ebd033e 100644 --- a/lib/app/settings/location/page.dart +++ b/lib/app/settings/location/page.dart @@ -1,10 +1,19 @@ import 'dart:io'; +import 'package:flutter/material.dart'; + import 'package:autostarter/autostarter.dart'; import 'package:disable_battery_optimization/disable_battery_optimization.dart'; +import 'package:go_router/go_router.dart'; +import 'package:material_symbols_icons/material_symbols_icons.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:provider/provider.dart'; + import 'package:dpip/app/settings/location/select/%5Bcity%5D/page.dart'; import 'package:dpip/app/settings/location/select/page.dart'; import 'package:dpip/core/i18n.dart'; +import 'package:dpip/core/providers.dart'; import 'package:dpip/core/service.dart'; import 'package:dpip/global.dart'; import 'package:dpip/models/settings/location.dart'; @@ -12,13 +21,6 @@ import 'package:dpip/utils/extensions/build_context.dart'; import 'package:dpip/utils/log.dart'; import 'package:dpip/widgets/list/list_section.dart'; import 'package:dpip/widgets/list/list_tile.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:go_router/go_router.dart'; -import 'package:material_symbols_icons/material_symbols_icons.dart'; -import 'package:material_symbols_icons/symbols.dart'; -import 'package:permission_handler/permission_handler.dart'; -import 'package:provider/provider.dart'; final stateSettingsLocationView = _SettingsLocationPageState(); @@ -33,298 +35,170 @@ class SettingsLocationPage extends StatefulWidget { State createState() => _SettingsLocationPageState(); } -const platform = MethodChannel('com.exptech.dpip/location'); - class _SettingsLocationPageState extends State with WidgetsBindingObserver { PermissionStatus? notificationPermission; PermissionStatus? locationPermission; PermissionStatus? locationAlwaysPermission; - bool? autoStartPermission; - bool? batteryOptimizationPermission; + bool autoStartPermission = true; + bool batteryOptimizationPermission = true; + + Future permissionStatusUpdate() async { + final values = await Future.wait([ + Permission.notification.status, + Permission.location.status, + Permission.locationAlways.status, + if (Platform.isAndroid) Autostarter.checkAutoStartState(), + if (Platform.isAndroid) DisableBatteryOptimization.isBatteryOptimizationDisabled, + ]); - Future requestLocationAlwaysPermission() async { - var status = await Permission.locationWhenInUse.status; - if (status.isDenied) { - status = await Permission.locationWhenInUse.request(); - } - - if (status.isPermanentlyDenied) { - openAppSettings(); - return false; - } + if (!mounted) return; - return status.isGranted; + setState(() { + notificationPermission = values[0] as PermissionStatus?; + locationPermission = values[1] as PermissionStatus?; + locationAlwaysPermission = values[2] as PermissionStatus?; + autoStartPermission = values[3] as bool? ?? true; + batteryOptimizationPermission = values[4] as bool? ?? true; + }); } - Future checkNotificationPermission() async { - final status = await Permission.notification.request(); - if (!mounted) return false; - - setState(() => notificationPermission = status); - - if (!status.isGranted) { - await showDialog( - context: context, - builder: (context) { - return AlertDialog( - icon: const Icon(Symbols.error), - title: const Text('無法取得通知權限'), - content: Text( - "'自動定位功能需要您允許 DPIP 使用通知權限才能正常運作。'${status.isPermanentlyDenied ? '請您到應用程式設定中找到並允許「通知」權限後再試一次。' : ""}", + /// Shows a error dialog to the user with the given permission type. + /// [type] can be either [Permission] or `"auto-start"` + Future showPermissionDialog(dynamic type) async { + if (!mounted) return; + if (type is! Permission && type is! String) return; + + final title = switch (type) { + Permission.notification => '無法取得通知權限'.i18n, + Permission.location => '無法取得位置權限'.i18n, + Permission.locationAlways => '無法取得位置權限'.i18n, + 'auto-start' => '無法取得自啟動權限'.i18n, + 'battery-optimization' => '省電策略'.i18n, + _ => '無法取得權限'.i18n, + }; + + final content = switch (type) { + Permission.notification => '自動定位功能需要您允許 DPIP 使用通知權限才能正常運作。請您到應用程式設定中找到並允許「通知」權限後再試一次。'.i18n, + Permission.location => '自動定位功能需要您允許 DPIP 使用位置權限才能正常運作。請您到應用程式設定中找到並允許「位置」權限後再試一次。'.i18n, + Permission.locationAlways => + Platform.isIOS + ? '自動定位功能需要您永遠允許 DPIP 使用位置權限才能正常運作。請您到應用程式設定中找到位置權限設定並選擇「永遠」後再試一次。'.i18n + : '自動定位功能需要您一律允許 DPIP 使用位置權限才能正常運作。請您到應用程式設定中找到位置權限設定並選擇「一律允許」後再試一次。'.i18n, + 'auto-start' => '為了獲得更好的自動定位體驗,您需要給予「自啟動權限」以便讓 DPIP 在背景自動設定所在地資訊。'.i18n, + 'battery-optimization' => '為了獲得更好的自動定位體驗,您需要給予「無限制」以便讓 DPIP 在背景自動設定所在地資訊。'.i18n, + _ => '自動定位功能需要您允許 DPIP 使用權限才能正常運作。請您到應用程式設定中找到並允許「權限」後再試一次。'.i18n, + }; + + await showDialog( + context: context, + builder: (context) { + return AlertDialog( + icon: const Icon(Symbols.error_rounded), + title: Text(title), + content: Text(content), + actionsAlignment: MainAxisAlignment.spaceBetween, + actions: [ + TextButton( + child: Text('取消'.i18n), + onPressed: () { + Navigator.pop(context); + }, ), - actionsAlignment: MainAxisAlignment.spaceBetween, - actions: [ - TextButton( - child: const Text('取消'), - onPressed: () { - Navigator.pop(context); - }, - ), - if (status.isPermanentlyDenied) - FilledButton( - child: const Text('設定'), - onPressed: () { - openAppSettings(); - Navigator.pop(context); - }, - ) - else - FilledButton( - child: const Text('再試一次'), - onPressed: () { - checkNotificationPermission(); - Navigator.pop(context); - }, - ), - ], - ); - }, - ); - - return false; - } - - return true; - } - - Future checkLocationPermission() async { - final status = await Permission.location.request(); - if (!mounted) return false; - - setState(() => locationPermission = status); - - if (!status.isGranted) { - await showDialog( - context: context, - builder: (context) { - return AlertDialog( - icon: const Icon(Symbols.error), - title: const Text('無法取得位置權限'), - content: Text( - "'自動定位功能需要您允許 DPIP 使用位置權限才能正常運作。'${status.isPermanentlyDenied ? '請您到應用程式設定中找到並允許「位置」權限後再試一次。' : ""}", + FilledButton( + child: Text('設定'.i18n), + onPressed: () { + openAppSettings(); + Navigator.pop(context); + }, ), - actionsAlignment: MainAxisAlignment.spaceBetween, - actions: [ - TextButton( - child: const Text('取消'), - onPressed: () { - Navigator.pop(context); - }, - ), - if (status.isPermanentlyDenied) - FilledButton( - child: const Text('設定'), - onPressed: () { - openAppSettings(); - Navigator.pop(context); - }, - ) - else - FilledButton( - child: const Text('再試一次'), - onPressed: () { - checkLocationPermission(); - Navigator.pop(context); - }, - ), - ], - ); - }, - ); + ], + ); + }, + ); + } + Future requestPermissions() async { + if (!await Permission.notification.request().isGranted) { + TalkerManager.instance.warning('🧪 failed notification (NOTIFICATION) permission test'); + await showPermissionDialog(Permission.notification); return false; } - return true; - } - - Future checkLocationAlwaysPermission() async { - final status = await [Permission.location, Permission.locationWhenInUse, Permission.locationAlways].request(); - - if (!status[Permission.location]!.isGranted) { + if (!await Permission.location.request().isGranted) { TalkerManager.instance.warning('🧪 failed location (ACCESS_COARSE_LOCATION) permission test'); + showPermissionDialog(Permission.location); return false; } - if (!status[Permission.locationWhenInUse]!.isGranted) { + + if (!await Permission.locationWhenInUse.request().isGranted) { TalkerManager.instance.warning('🧪 failed location when in use (ACCESS_FINE_LOCATION) permission test'); + showPermissionDialog(Permission.locationWhenInUse); return false; } - if (!status[Permission.locationAlways]!.isGranted) { + + if (!await Permission.locationAlways.request().isGranted) { TalkerManager.instance.warning('🧪 failed location always (ACCESS_BACKGROUND_LOCATION) permission test'); + showPermissionDialog(Permission.locationAlways); return false; } - return true; - } - - Future androidCheckAutoStartPermission(int num) async { if (!Platform.isAndroid) return true; - try { - final bool? isAvailable = await Autostarter.isAutoStartPermissionAvailable(); - if (isAvailable == null || !isAvailable) return true; - - final bool? status = await Autostarter.checkAutoStartState(); - if (status == null || status) return true; - - if (!mounted) return true; - - final String contentText = - (num == 0) - ? '為了獲得更好的自動定位體驗,您需要給予「自啟動權限」以便讓 DPIP 在背景自動設定所在地資訊。' - : '為了獲得更好的 DPIP 體驗,您需要給予「自啟動權限」以便讓 DPIP 在背景有正常接收警訊通知。'; - - return await showDialog( - context: context, - builder: (context) { - return AlertDialog( - icon: const Icon(Symbols.my_location), - title: const Text('自啟動權限'), - content: Text(contentText), - actionsAlignment: MainAxisAlignment.spaceBetween, - actions: [ - TextButton( - child: const Text('取消'), - onPressed: () { - Navigator.pop(context, false); - }, - ), - FilledButton( - child: const Text('確定'), - onPressed: () async { - await Autostarter.getAutoStartPermission(newTask: true); - - if (!context.mounted) return; - Navigator.pop(context, false); - }, - ), - ], - ); - }, - ) ?? - false; - } catch (err) { - TalkerManager.instance.error(err); - return false; - } - } + autoStart: + { + final available = await Autostarter.isAutoStartPermissionAvailable(); + if (available == null) break autoStart; - Future androidCheckBatteryOptimizationPermission(int num) async { - if (!Platform.isAndroid) return true; + final status = await DisableBatteryOptimization.isAutoStartEnabled; + if (status == null || status) { + batteryOptimizationPermission = true; + break autoStart; + } - try { - final bool status = await DisableBatteryOptimization.isBatteryOptimizationDisabled ?? false; - if (status) return true; - - if (!mounted) return true; - - final String contentText = - (num == 0) - ? '為了獲得更好的自動定位體驗,您需要給予「無限制」以便讓 DPIP 在背景自動設定所在地資訊。' - : '為了獲得更好的 DPIP 體驗,您需要給予「無限制」以便讓 DPIP 在背景有正常接收警訊通知。'; - - return await showDialog( - context: context, - builder: (context) { - return AlertDialog( - icon: const Icon(Symbols.my_location), - title: const Text('省電策略'), - content: Text(contentText), - actionsAlignment: MainAxisAlignment.spaceBetween, - actions: [ - TextButton( - child: const Text('取消'), - onPressed: () { - Navigator.pop(context, false); - }, - ), - FilledButton( - child: const Text('確定'), - onPressed: () { - DisableBatteryOptimization.showDisableBatteryOptimizationSettings(); - Navigator.pop(context, false); - }, - ), - ], - ); - }, - ) ?? - false; - } catch (err) { - TalkerManager.instance.error(err); - return false; + await DisableBatteryOptimization.showEnableAutoStartSettings( + '自動啟動', + '為了獲得更好的 DPIP 體驗,請依照步驟啟用自動啟動功能,以便讓 DPIP 在背景能正常接收資訊以及更新所在地。', + ); } - } - - Future toggleAutoLocation() async { - final shouldEnable = !context.read().auto; - - await BackgroundLocationServiceManager.stop(); - - if (shouldEnable) { - final notification = await checkNotificationPermission(); - if (!notification) { - TalkerManager.instance.warning('🧪 failed notification permission test'); - return; - } - final location = await checkLocationPermission(); - if (!location) { - TalkerManager.instance.warning('🧪 failed location permission test'); - return; + batteryOptimization: + { + final status = await DisableBatteryOptimization.isBatteryOptimizationDisabled; + if (status == null || status) { + batteryOptimizationPermission = true; + break batteryOptimization; } - await checkLocationAlwaysPermission(); + await DisableBatteryOptimization.showDisableBatteryOptimizationSettings(); + } - final bool autoStart = await androidCheckAutoStartPermission(0); - autoStartPermission = autoStart; - if (!autoStart) { - TalkerManager.instance.warning('🧪 failed auto start permission test'); - return; - } + manufacturerBatteryOptimization: + { + final status = await DisableBatteryOptimization.isManufacturerBatteryOptimizationDisabled; + if (status == null || status) break manufacturerBatteryOptimization; - final bool batteryOptimization = await androidCheckBatteryOptimizationPermission(0); - batteryOptimizationPermission = batteryOptimization; - if (!batteryOptimization) { - TalkerManager.instance.warning('🧪 failed battery optimization permission test'); - return; - } + await DisableBatteryOptimization.showEnableAutoStartSettings( + '省電策略', + '為了獲得更好的 DPIP 體驗,請依照步驟關閉省電策略,以便讓 DPIP 在背景能正常接收資訊以及更新所在地。', + ); } - if (Platform.isAndroid) { - if (shouldEnable) { - await BackgroundLocationServiceManager.start(); - } - } - if (Platform.isIOS) { - await platform.invokeMethod('toggleLocation', {'isEnabled': shouldEnable}).catchError((_) {}); - } + setState(() {}); + return true; + } - if (!mounted) return; + Future toggleAutoLocation(bool shouldEnable) async { + if (shouldEnable) { + if (!await requestPermissions()) return; - context.read().setAuto(shouldEnable); - context.read().setCode(null); - context.read().setCoordinates(null); + await BackgroundLocationServiceManager.start(); + } else { + await BackgroundLocationServiceManager.stop(); + } + + GlobalProviders.location.setAuto(shouldEnable); + GlobalProviders.location.setCode(null); + GlobalProviders.location.setCoordinates(null); } @override @@ -334,57 +208,11 @@ class _SettingsLocationPageState extends State with Widget permissionStatusUpdate(); } - @override - void dispose() { - WidgetsBinding.instance.removeObserver(this); - super.dispose(); - } - @override void didChangeAppLifecycleState(AppLifecycleState state) { permissionStatusUpdate(); } - void permissionStatusUpdate() { - Permission.notification.status.then((value) { - setState(() { - notificationPermission = value; - }); - }); - Permission.location.status.then((value) { - setState(() { - locationPermission = value; - }); - }); - Permission.locationAlways.status.then((value) { - setState(() { - locationAlwaysPermission = value; - }); - }); - if (Platform.isAndroid) { - Future checkAutoStart() async { - final autoStart = await Autostarter.checkAutoStartState(); - if (mounted) { - setState(() { - autoStartPermission = autoStart ?? false; - }); - } - } - - Future checkBatteryOptimization() async { - final batteryOptimization = await DisableBatteryOptimization.isBatteryOptimizationDisabled; - if (mounted) { - setState(() { - batteryOptimizationPermission = batteryOptimization ?? false; - }); - } - } - - checkAutoStart(); - checkBatteryOptimization(); - } - } - @override Widget build(BuildContext context) { final permissionType = Platform.isAndroid ? '一律允許' : '永遠'; @@ -401,7 +229,7 @@ class _SettingsLocationPageState extends State with Widget title: '自動更新'.i18n, subtitle: Text('定期更新目前的所在地'.i18n), icon: Symbols.my_location_rounded, - trailing: Switch(value: auto, onChanged: (value) => toggleAutoLocation()), + trailing: Switch(value: auto, onChanged: toggleAutoLocation), ); }, ), @@ -476,16 +304,16 @@ class _SettingsLocationPageState extends State with Widget ); }, ), - if (Platform.isAndroid && false) + if (Platform.isAndroid) Selector( selector: (context, model) => model.auto, builder: (context, auto, child) { return Visibility( - visible: auto && !autoStartPermission!, + visible: auto && !autoStartPermission, maintainAnimation: true, maintainState: true, child: AnimatedOpacity( - opacity: auto && !autoStartPermission! ? 1 : 0, + opacity: auto && !autoStartPermission ? 1 : 0, curve: const Interval(0.2, 1, curve: Easing.standard), duration: Durations.medium2, child: SettingsListTextSection( @@ -502,16 +330,16 @@ class _SettingsLocationPageState extends State with Widget ); }, ), - if (batteryOptimizationPermission != null && Platform.isAndroid) + if (Platform.isAndroid) Selector( selector: (context, model) => model.auto, builder: (context, auto, child) { return Visibility( - visible: auto && !batteryOptimizationPermission!, + visible: auto && !batteryOptimizationPermission, maintainAnimation: true, maintainState: true, child: AnimatedOpacity( - opacity: auto && !batteryOptimizationPermission! ? 1 : 0, + opacity: auto && !batteryOptimizationPermission ? 1 : 0, curve: const Interval(0.2, 1, curve: Easing.standard), duration: Durations.medium2, child: SettingsListTextSection( @@ -545,17 +373,7 @@ class _SettingsLocationPageState extends State with Widget icon: Symbols.location_city_rounded, trailing: const Icon(Symbols.chevron_right_rounded), enabled: !auto, - onTap: () async { - final bool autoStart = await androidCheckAutoStartPermission(1); - if (!autoStart) return; - - final bool batteryOptimization = await androidCheckBatteryOptimizationPermission(1); - if (!batteryOptimization) return; - - if (!context.mounted) return; - - context.push(SettingsLocationSelectPage.route); - }, + onTap: () => context.push(SettingsLocationSelectPage.route), ); }, ), @@ -573,86 +391,19 @@ class _SettingsLocationPageState extends State with Widget icon: Symbols.forest_rounded, trailing: const Icon(Symbols.chevron_right_rounded), enabled: !auto && city != null, - onTap: () async { - if (city == null) return; - - final bool autoStart = await androidCheckAutoStartPermission(1); - if (!autoStart) return; - - final bool batteryOptimization = await androidCheckBatteryOptimizationPermission(1); - if (!batteryOptimization) return; - - if (!context.mounted) return; - - context.push(SettingsLocationSelectCityPage.route(city)); - }, + onTap: city == null ? null : () => context.push(SettingsLocationSelectCityPage.route(city)), ); }, ), ], ), - if (false && Platform.isAndroid) - Selector( - selector: (context, model) => (auto: model.auto, code: model.code), - builder: (context, data, child) { - final (:auto, :code) = data; - - return Visibility( - visible: !auto && code != null && !autoStartPermission!, - maintainAnimation: true, - maintainState: true, - child: AnimatedOpacity( - opacity: !auto && code != null && !autoStartPermission! ? 1 : 0, - curve: const Interval(0.2, 1, curve: Easing.standard), - duration: Durations.medium2, - child: SettingsListTextSection( - icon: Symbols.warning_rounded, - iconColor: context.colors.error, - content: '自啟動權限已被拒絕,請移至設定允許權限。', - contentColor: context.colors.error, - trailing: TextButton( - child: const Text('設定'), - onPressed: () async { - await Autostarter.getAutoStartPermission(newTask: true); - }, - ), - ), - ), - ); - }, - ), - if (batteryOptimizationPermission != null && Platform.isAndroid) - Selector( - selector: (context, model) => (auto: model.auto, code: model.code), - builder: (context, data, child) { - final (:auto, :code) = data; - - return Visibility( - visible: !auto && code != null && !batteryOptimizationPermission!, - maintainAnimation: true, - maintainState: true, - child: AnimatedOpacity( - opacity: !auto && code != null && !batteryOptimizationPermission! ? 1 : 0, - curve: const Interval(0.2, 1, curve: Easing.standard), - duration: Durations.medium2, - child: SettingsListTextSection( - icon: Symbols.warning_rounded, - iconColor: context.colors.error, - content: '省電策略已被拒絕,請移至設定允許權限。', - contentColor: context.colors.error, - trailing: TextButton( - child: const Text('設定'), - onPressed: () { - DisableBatteryOptimization.showDisableBatteryOptimizationSettings(); - Navigator.pop(context); - }, - ), - ), - ), - ); - }, - ), ], ); } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } } diff --git a/lib/core/service.dart b/lib/core/service.dart index 1153309e1..21f60484f 100644 --- a/lib/core/service.dart +++ b/lib/core/service.dart @@ -58,6 +58,9 @@ class BackgroundLocationServiceManager { /// Instance of the background service static final instance = FlutterBackgroundService(); + /// Platform channel for iOS + static const platform = MethodChannel('com.exptech.dpip/location'); + /// Whether the background service has been initialized static bool initialized = false; @@ -70,10 +73,10 @@ class BackgroundLocationServiceManager { static Future initalize() async { if (initialized) return; - TalkerManager.instance.info('⚙️ initializing location service'); + TalkerManager.instance.info('👷 initializing location service'); if (!Platform.isAndroid && !Platform.isIOS) { - TalkerManager.instance.warning('⚙️ service is not supported on this platform (${Platform.operatingSystem})'); + TalkerManager.instance.warning('👷 service is not supported on this platform (${Platform.operatingSystem})'); return; } try { @@ -96,7 +99,7 @@ class BackgroundLocationServiceManager { instance.on(BackgroundLocationServiceEvent.position).listen((data) async { final event = PositionEvent.fromJson(data!); try { - TalkerManager.instance.info('⚙️ location updated by service, reloading preferences'); + TalkerManager.instance.info('👷 location updated by service, reloading preferences'); await Preference.reload(); GlobalProviders.location.refresh(); @@ -107,16 +110,16 @@ class BackgroundLocationServiceManager { await ExpTech().updateDeviceLocation(token: fcmToken, coordinates: event.coordinates!); } - TalkerManager.instance.info('⚙️ preferences reloaded'); + TalkerManager.instance.info('👷 preferences reloaded'); } catch (e, s) { - TalkerManager.instance.error('⚙️ failed to reload preferences', e, s); + TalkerManager.instance.error('👷 failed to reload preferences', e, s); } }); initialized = true; - TalkerManager.instance.info('⚙️ service initialized'); + TalkerManager.instance.info('👷 service initialized'); } catch (e, s) { - TalkerManager.instance.error('⚙️ initializing location service FAILED', e, s); + TalkerManager.instance.error('👷 initializing location service FAILED', e, s); } if (Preference.locationAuto == true) await start(); @@ -127,10 +130,15 @@ class BackgroundLocationServiceManager { /// Initializes the service if not already initialized. Only starts if the service is not already running. static Future start() async { if (!initialized) await initalize(); - TalkerManager.instance.info('⚙️ starting location service'); + TalkerManager.instance.info('👷 starting location service'); + + if (Platform.isIOS) { + await platform.invokeMethod('toggleLocation', {'isEnabled': true}); + return; + } if (await instance.isRunning()) { - TalkerManager.instance.warning('⚙️ location service is already running, skipping...'); + TalkerManager.instance.warning('👷 location service is already running, skipping...'); return; } @@ -141,7 +149,13 @@ class BackgroundLocationServiceManager { static Future stop() async { if (!initialized) return; - TalkerManager.instance.info('⚙️ stopping location service'); + TalkerManager.instance.info('👷 stopping location service'); + + if (Platform.isIOS) { + await platform.invokeMethod('toggleLocation', {'isEnabled': false}); + return; + } + instance.invoke(BackgroundLocationServiceEvent.stop); } } @@ -204,7 +218,7 @@ class BackgroundLocationService { _$service.on(BackgroundLocationServiceEvent.stop).listen((data) async { try { - TalkerManager.instance.info('⚙️ stopping location service'); + TalkerManager.instance.info('⚙️::BackgroundLocationService stopping location service'); // Cleanup timer _$locationUpdateTimer?.cancel(); @@ -212,9 +226,9 @@ class BackgroundLocationService { await _$service.setAutoStartOnBootMode(false); await _$service.stopSelf(); - TalkerManager.instance.info('⚙️ location service stopped'); + TalkerManager.instance.info('⚙️::BackgroundLocationService location service stopped'); } catch (e, s) { - TalkerManager.instance.error('⚙️ stopping location service FAILED', e, s); + TalkerManager.instance.error('⚙️::BackgroundLocationService stopping location service FAILED', e, s); } }); @@ -231,7 +245,7 @@ class BackgroundLocationService { if (!await _$service.isForegroundService()) return; final $perf = Stopwatch()..start(); - TalkerManager.instance.debug('⚙️ task started'); + TalkerManager.instance.debug('⚙️::BackgroundLocationService task started'); try { // Get current position and location info @@ -248,10 +262,10 @@ class BackgroundLocationService { final distanceInKm = previousLocation != null ? coordinates.to(previousLocation) : null; if (distanceInKm == null || distanceInKm >= 250) { - TalkerManager.instance.debug('⚙️ distance: $distanceInKm, updating position'); + TalkerManager.instance.debug('⚙️::BackgroundLocationService distance: $distanceInKm, updating position'); _$updatePosition(_$service, coordinates); } else { - TalkerManager.instance.debug('⚙️ distance: $distanceInKm, not updating position'); + TalkerManager.instance.debug('⚙️::BackgroundLocationService distance: $distanceInKm, not updating position'); } // Update notification with current position @@ -296,11 +310,15 @@ class BackgroundLocationService { _$locationUpdateTimer = Timer.periodic(Duration(minutes: nextUpdateInterval), (timer) => _$task()); } catch (e, s) { $perf.stop(); - TalkerManager.instance.error('⚙️ task FAILED after ${$perf.elapsedMilliseconds}ms', e, s); + TalkerManager.instance.error( + '⚙️::BackgroundLocationService task FAILED after ${$perf.elapsedMilliseconds}ms', + e, + s, + ); } finally { if ($perf.isRunning) { $perf.stop(); - TalkerManager.instance.debug('⚙️ task completed in ${$perf.elapsedMilliseconds}ms'); + TalkerManager.instance.debug('⚙️::BackgroundLocationService task completed in ${$perf.elapsedMilliseconds}ms'); } } } From e85ff6ad61e0f37aea6d63e86fa631dab477c09a Mon Sep 17 00:00:00 2001 From: Kamiya Date: Fri, 8 Aug 2025 11:41:19 +0800 Subject: [PATCH 07/21] fix: attemp to fix not supported issue --- lib/core/service.dart | 67 ++++++++++++++++++++++++++----------------- 1 file changed, 40 insertions(+), 27 deletions(-) diff --git a/lib/core/service.dart b/lib/core/service.dart index 21f60484f..8e1201d01 100644 --- a/lib/core/service.dart +++ b/lib/core/service.dart @@ -56,13 +56,13 @@ class BackgroundLocationServiceManager { static const kNotificationId = 888888; /// Instance of the background service - static final instance = FlutterBackgroundService(); + static FlutterBackgroundService? instance; /// Platform channel for iOS static const platform = MethodChannel('com.exptech.dpip/location'); - /// Whether the background service has been initialized - static bool initialized = false; + /// Whether the background service is available on the current platform + static bool get avaliable => Platform.isAndroid || Platform.isIOS; /// Initializes the background location service. /// @@ -71,16 +71,14 @@ class BackgroundLocationServiceManager { /// /// Will starts the service if automatic location updates are enabled. static Future initalize() async { - if (initialized) return; + if (instance != null || !avaliable) return; TalkerManager.instance.info('👷 initializing location service'); - if (!Platform.isAndroid && !Platform.isIOS) { - TalkerManager.instance.warning('👷 service is not supported on this platform (${Platform.operatingSystem})'); - return; - } + final service = FlutterBackgroundService(); + try { - await instance.configure( + await service.configure( androidConfiguration: AndroidConfiguration( onStart: BackgroundLocationService._$onStart, autoStart: false, @@ -96,7 +94,7 @@ class BackgroundLocationServiceManager { ); // Reloads the UI isolate's preference cache when a new position is set in the background service. - instance.on(BackgroundLocationServiceEvent.position).listen((data) async { + service.on(BackgroundLocationServiceEvent.position).listen((data) async { final event = PositionEvent.fromJson(data!); try { TalkerManager.instance.info('👷 location updated by service, reloading preferences'); @@ -116,7 +114,7 @@ class BackgroundLocationServiceManager { } }); - initialized = true; + instance = service; TalkerManager.instance.info('👷 service initialized'); } catch (e, s) { TalkerManager.instance.error('👷 initializing location service FAILED', e, s); @@ -129,34 +127,49 @@ class BackgroundLocationServiceManager { /// /// Initializes the service if not already initialized. Only starts if the service is not already running. static Future start() async { - if (!initialized) await initalize(); + if (!avaliable) return; + TalkerManager.instance.info('👷 starting location service'); - if (Platform.isIOS) { - await platform.invokeMethod('toggleLocation', {'isEnabled': true}); - return; - } + try { + final service = instance; + if (service == null) throw Exception('Not initialized.'); - if (await instance.isRunning()) { - TalkerManager.instance.warning('👷 location service is already running, skipping...'); - return; - } + if (Platform.isIOS) { + await platform.invokeMethod('toggleLocation', {'isEnabled': true}); + return; + } + + if (await service.isRunning()) { + TalkerManager.instance.warning('👷 location service is already running, skipping...'); + return; + } - await instance.startService(); + 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 stop() async { - if (!initialized) return; + if (!avaliable) return; TalkerManager.instance.info('👷 stopping location service'); - if (Platform.isIOS) { - await platform.invokeMethod('toggleLocation', {'isEnabled': false}); - return; - } + try { + final service = instance; + if (service == null) throw Exception('Not initialized.'); - instance.invoke(BackgroundLocationServiceEvent.stop); + if (Platform.isIOS) { + await platform.invokeMethod('toggleLocation', {'isEnabled': false}); + return; + } + + service.invoke(BackgroundLocationServiceEvent.stop); + } catch (e, s) { + TalkerManager.instance.error('👷 stopping location service FAILED', e, s); + } } } From 12497779a499d078f292eed6d22517c08531359e Mon Sep 17 00:00:00 2001 From: Kamiya Date: Fri, 8 Aug 2025 11:58:24 +0800 Subject: [PATCH 08/21] fix: ios service initialization --- lib/core/service.dart | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/lib/core/service.dart b/lib/core/service.dart index 8e1201d01..694b200d9 100644 --- a/lib/core/service.dart +++ b/lib/core/service.dart @@ -90,7 +90,11 @@ class BackgroundLocationServiceManager { foregroundServiceNotificationId: kNotificationId, ), // iOS is handled in native code - iosConfiguration: IosConfiguration(autoStart: false), + iosConfiguration: IosConfiguration( + autoStart: false, + onForeground: BackgroundLocationService._$onStartIOS, + onBackground: BackgroundLocationService._$onStartIOS, + ), ); // Reloads the UI isolate's preference cache when a new position is set in the background service. @@ -249,6 +253,15 @@ class BackgroundLocationService { await _$task(); } + /// Entry point for ios background service. + /// + /// iOS background service is handled in native code. + @pragma('vm:entry-point') + static Future _$onStartIOS(ServiceInstance service) async { + DartPluginRegistrant.ensureInitialized(); + return true; + } + /// The main tick function of the service. /// /// This function is used to get the current location of the device and update the notification. From 62a027445da8cd183d44b903dd8c74cbdc1f53b7 Mon Sep 17 00:00:00 2001 From: Kamiya Date: Fri, 8 Aug 2025 12:07:08 +0800 Subject: [PATCH 09/21] fix: log stack trace --- lib/core/ios_get_location.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/core/ios_get_location.dart b/lib/core/ios_get_location.dart index 2c8cf9c2f..e19b83c32 100644 --- a/lib/core/ios_get_location.dart +++ b/lib/core/ios_get_location.dart @@ -34,8 +34,8 @@ Future getSavedLocation() async { GlobalProviders.location.setCode(null); GlobalProviders.location.setCoordinates(null); } - } catch (e) { - TalkerManager.instance.error('Error in getSavedLocation: $e'); + } catch (e, s) { + TalkerManager.instance.error('Error in getSavedLocation', e, s); } finally { _completer?.complete(); _completer = null; From 3472808736a5d636ed1e5a633048d5ebfccf567b Mon Sep 17 00:00:00 2001 From: Kamiya Date: Fri, 8 Aug 2025 12:32:18 +0800 Subject: [PATCH 10/21] fix: geojson --- lib/global.dart | 19 ++++++--- lib/utils/geojson.dart | 2 +- lib/utils/map_utils.dart | 84 +++++++++++++++++++--------------------- pubspec.lock | 8 ++++ pubspec.yaml | 1 + 5 files changed, 64 insertions(+), 50 deletions(-) diff --git a/lib/global.dart b/lib/global.dart index 939d4f0c4..f2337f990 100644 --- a/lib/global.dart +++ b/lib/global.dart @@ -1,17 +1,20 @@ -import 'package:dpip/api/exptech.dart'; -import 'package:dpip/api/model/location/location.dart'; -import 'package:dpip/utils/extensions/asset_bundle.dart'; import 'package:flutter/services.dart'; + +import 'package:geojson_vi/geojson_vi.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:dpip/api/exptech.dart'; +import 'package:dpip/api/model/location/location.dart'; +import 'package:dpip/utils/extensions/asset_bundle.dart'; + class Global { Global._(); static late PackageInfo packageInfo; static late SharedPreferences preference; static late Map location; - static late Map townGeojson; + static late GeoJSONFeatureCollection townGeojson; static late Map> timeTable; static late Map box; static late Map notifyTestContent; @@ -49,12 +52,18 @@ class Global { }); } + static Future loadTownGeojson() async { + final data = await rootBundle.loadJson('assets/map/town.json'); + + return GeoJSONFeatureCollection.fromMap(data); + } + static Future init() async { packageInfo = await PackageInfo.fromPlatform(); preference = await SharedPreferences.getInstance(); box = await rootBundle.loadJson('assets/box.json'); location = await loadLocationData(); - townGeojson = await rootBundle.loadJson('assets/map/town.json'); + townGeojson = await loadTownGeojson(); await loadTimeTableData(); await loadNotifyTestContent(); diff --git a/lib/utils/geojson.dart b/lib/utils/geojson.dart index 260b7626d..7f5217355 100644 --- a/lib/utils/geojson.dart +++ b/lib/utils/geojson.dart @@ -62,7 +62,7 @@ class GeoJsonFeatureBuilder { } GeoJsonFeatureBuilder setProperty(String key, dynamic value) { - this.properties[key] = value; + properties[key] = value; return this; } diff --git a/lib/utils/map_utils.dart b/lib/utils/map_utils.dart index 3557a1541..6019cdcc4 100644 --- a/lib/utils/map_utils.dart +++ b/lib/utils/map_utils.dart @@ -1,5 +1,6 @@ import 'dart:math'; +import 'package:geojson_vi/geojson_vi.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:dpip/api/model/eew.dart'; @@ -188,21 +189,41 @@ bool checkBoxSkip(Map eewLastInfo, Map eewDist, Lis } String? getTownCodeFromCoordinates(LatLng target) { - final features = (Global.townGeojson['features'] as List).cast>(); - + final features = Global.townGeojson.features; for (final feature in features) { - final geometry = (feature['geometry'] as Map).cast(); - final type = geometry['type'] as String; + 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; - if (type == 'Polygon' || type == 'MultiPolygon') { - bool isInPolygon = false; + j = i; + } + isInPolygon = isInside; + } + + if (geometry is GeoJSONMultiPolygon) { + final multiPolygon = geometry.coordinates; - if (type == 'Polygon') { - final coordinates = ((geometry['coordinates'] as List)[0] as List).cast(); - final List> polygon = - coordinates.map>((coord) { - return coord.map((e) => (e as num).toDouble()).toList(); - }).toList(); + for (final polygonCoordinates in multiPolygon) { + final polygon = polygonCoordinates[0]; bool isInside = false; int j = polygon.length - 1; @@ -219,41 +240,16 @@ String? getTownCodeFromCoordinates(LatLng target) { j = i; } - isInPolygon = isInside; - } else { - final multiPolygon = (geometry['coordinates'] as List).cast>(); - for (final polygonCoordinates in multiPolygon) { - final coordinates = polygonCoordinates[0].cast(); - final List> polygon = - coordinates.map>((coord) { - return coord.map((e) => (e as num).toDouble()).toList(); - }).toList(); - - 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 (isInside) { + isInPolygon = true; + break; } } + } - if (isInPolygon) { - return (feature['properties'] as Map)['CODE'] as String; - } + if (isInPolygon) { + return feature.properties!['CODE'] as String?; } } diff --git a/pubspec.lock b/pubspec.lock index d97c17bc5..4eb5919f5 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -570,6 +570,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.1" + geojson_vi: + dependency: "direct main" + description: + name: geojson_vi + sha256: a47e0efd17617aef8b239719ea2a3ef743563e873709531ee5498ccfd04069f2 + url: "https://pub.dev" + source: hosted + version: "2.2.5" geolocator: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 2dc1c66e6..6400a270f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -63,6 +63,7 @@ dependencies: url_launcher: ^6.3.1 ip_country_lookup: ^1.0.0 flutter_icmp_ping: ^3.1.3 + geojson_vi: ^2.2.5 dependency_overrides: intl: 0.19.0 From fcb34097b29af90eec328473e74e35e94001e237 Mon Sep 17 00:00:00 2001 From: Kamiya Date: Fri, 8 Aug 2025 12:35:21 +0800 Subject: [PATCH 11/21] fix: geojson type --- lib/core/service.dart | 88 +++++++++++++++++++--------------------- lib/utils/map_utils.dart | 1 + 2 files changed, 43 insertions(+), 46 deletions(-) diff --git a/lib/core/service.dart b/lib/core/service.dart index 694b200d9..f120b1c01 100644 --- a/lib/core/service.dart +++ b/lib/core/service.dart @@ -6,6 +6,7 @@ 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'; @@ -14,7 +15,6 @@ 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/asset_bundle.dart'; import 'package:dpip/utils/extensions/datetime.dart'; import 'package:dpip/utils/extensions/latlng.dart'; import 'package:dpip/utils/log.dart'; @@ -196,7 +196,7 @@ class BackgroundLocationService { static Timer? _$locationUpdateTimer; /// Cached GeoJSON data for location lookups - static late Map _$geoJsonData; + static late GeoJSONFeatureCollection _$geoJsonData; /// Cached location data mapping static late Map _$locationData; @@ -228,7 +228,7 @@ class BackgroundLocationService { await _$service.setAsForegroundService(); await Preference.init(); - _$geoJsonData = await rootBundle.loadJson('assets/map/town.json'); + _$geoJsonData = await Global.loadTownGeojson(); _$locationData = await Global.loadLocationData(); _$service.setAutoStartOnBootMode(true); @@ -374,21 +374,42 @@ class BackgroundLocationService { /// 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? _$getLocationFromCoordinates(LatLng target) { - final features = (_$geoJsonData['features'] as List).cast>(); + final features = _$geoJsonData.features; for (final feature in features) { - final geometry = (feature['geometry'] as Map).cast(); - final type = geometry['type'] as String; + if (feature == null) continue; - if (type == 'Polygon' || type == 'MultiPolygon') { - bool isInPolygon = false; + final geometry = feature.geometry; + if (geometry == null) continue; - if (type == 'Polygon') { - final coordinates = ((geometry['coordinates'] as List)[0] as List).cast(); - final List> polygon = - coordinates.map>((coord) { - return coord.map((e) => (e as num).toDouble()).toList(); - }).toList(); + 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; @@ -405,41 +426,16 @@ class BackgroundLocationService { j = i; } - isInPolygon = isInside; - } else { - final multiPolygon = (geometry['coordinates'] as List).cast>(); - for (final polygonCoordinates in multiPolygon) { - final coordinates = polygonCoordinates[0].cast(); - final List> polygon = - coordinates.map>((coord) { - return coord.map((e) => (e as num).toDouble()).toList(); - }).toList(); - - 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 (isInside) { + isInPolygon = true; + break; } } + } - if (isInPolygon) { - return (feature['properties'] as Map)['CODE'] as String; - } + if (isInPolygon) { + return feature.properties!['CODE'] as String?; } } diff --git a/lib/utils/map_utils.dart b/lib/utils/map_utils.dart index 6019cdcc4..1c5b87b80 100644 --- a/lib/utils/map_utils.dart +++ b/lib/utils/map_utils.dart @@ -190,6 +190,7 @@ bool checkBoxSkip(Map eewLastInfo, Map eewDist, Lis String? getTownCodeFromCoordinates(LatLng target) { final features = Global.townGeojson.features; + for (final feature in features) { if (feature == null) continue; From d4b11b3ad5cf5543f03fda83a08dd0323ec80d64 Mon Sep 17 00:00:00 2001 From: Kamiya Date: Fri, 8 Aug 2025 12:39:59 +0800 Subject: [PATCH 12/21] fix: use double toString --- lib/core/service.dart | 2 +- lib/utils/map_utils.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/core/service.dart b/lib/core/service.dart index f120b1c01..2a6ccd5f0 100644 --- a/lib/core/service.dart +++ b/lib/core/service.dart @@ -435,7 +435,7 @@ class BackgroundLocationService { } if (isInPolygon) { - return feature.properties!['CODE'] as String?; + return feature.properties!['CODE']?.toString(); } } diff --git a/lib/utils/map_utils.dart b/lib/utils/map_utils.dart index 1c5b87b80..da3b93636 100644 --- a/lib/utils/map_utils.dart +++ b/lib/utils/map_utils.dart @@ -250,7 +250,7 @@ String? getTownCodeFromCoordinates(LatLng target) { } if (isInPolygon) { - return feature.properties!['CODE'] as String?; + return feature.properties!['CODE']?.toString(); } } From 1b108b438d55cbfd661e0c7658c72dcbc9093a64 Mon Sep 17 00:00:00 2001 From: Kamiya Date: Fri, 8 Aug 2025 12:55:40 +0800 Subject: [PATCH 13/21] style: sort imports --- lib/api/exptech.dart | 5 +++-- lib/app/settings/location/select/[city]/page.dart | 10 ++++++---- lib/core/update.dart | 5 +++-- lib/utils/extensions/preference.dart | 3 ++- 4 files changed, 14 insertions(+), 9 deletions(-) diff --git a/lib/api/exptech.dart b/lib/api/exptech.dart index e397f1a28..6efe01dd5 100644 --- a/lib/api/exptech.dart +++ b/lib/api/exptech.dart @@ -1,6 +1,9 @@ import 'dart:convert'; import 'dart:io'; +import 'package:http/http.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; + import 'package:dpip/api/model/announcement.dart'; import 'package:dpip/api/model/changelog/changelog.dart'; import 'package:dpip/api/model/crowdin/localization_progress.dart'; @@ -24,8 +27,6 @@ import 'package:dpip/core/preference.dart'; import 'package:dpip/models/settings/notify.dart'; import 'package:dpip/utils/extensions/response.dart'; import 'package:dpip/utils/extensions/string.dart'; -import 'package:http/http.dart'; -import 'package:maplibre_gl/maplibre_gl.dart'; class ExpTech { String? apikey; diff --git a/lib/app/settings/location/select/[city]/page.dart b/lib/app/settings/location/select/[city]/page.dart index 1fc054042..26c85a14c 100644 --- a/lib/app/settings/location/select/[city]/page.dart +++ b/lib/app/settings/location/select/[city]/page.dart @@ -1,3 +1,9 @@ +import 'package:flutter/material.dart'; + +import 'package:maplibre_gl/maplibre_gl.dart'; +import 'package:material_symbols_icons/material_symbols_icons.dart'; +import 'package:provider/provider.dart'; + import 'package:dpip/api/exptech.dart'; import 'package:dpip/app/settings/location/page.dart'; import 'package:dpip/core/preference.dart'; @@ -7,10 +13,6 @@ import 'package:dpip/utils/extensions/build_context.dart'; import 'package:dpip/widgets/list/list_section.dart'; import 'package:dpip/widgets/list/list_tile.dart'; import 'package:dpip/widgets/ui/loading_icon.dart'; -import 'package:flutter/material.dart'; -import 'package:maplibre_gl/maplibre_gl.dart'; -import 'package:material_symbols_icons/material_symbols_icons.dart'; -import 'package:provider/provider.dart'; class SettingsLocationSelectCityPage extends StatefulWidget { final String city; diff --git a/lib/core/update.dart b/lib/core/update.dart index 2f0c19e9c..d49a2e353 100644 --- a/lib/core/update.dart +++ b/lib/core/update.dart @@ -1,12 +1,13 @@ import 'dart:async'; import 'dart:math'; -import 'package:dpip/api/exptech.dart'; -import 'package:dpip/core/preference.dart'; import 'package:flutter_icmp_ping/flutter_icmp_ping.dart'; import 'package:ip_country_lookup/ip_country_lookup.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; +import 'package:dpip/api/exptech.dart'; +import 'package:dpip/core/preference.dart'; + Future updateInfoToServer() async { final latitude = Preference.locationLatitude; final longitude = Preference.locationLongitude; diff --git a/lib/utils/extensions/preference.dart b/lib/utils/extensions/preference.dart index d34727802..6ef63541f 100644 --- a/lib/utils/extensions/preference.dart +++ b/lib/utils/extensions/preference.dart @@ -1,6 +1,7 @@ -import 'package:dpip/utils/log.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:dpip/utils/log.dart'; + extension PreferenceExtension on SharedPreferencesWithCache { /// Sets a value of any supported type to SharedPreferences. /// From 7090eedba1f0bba092ecda02f420d1db9e87f172 Mon Sep 17 00:00:00 2001 From: Kamiya Date: Fri, 8 Aug 2025 13:07:43 +0800 Subject: [PATCH 14/21] chore: shorter class name --- lib/app/settings/location/page.dart | 4 ++-- lib/core/service.dart | 29 +++++++++++++++-------------- lib/main.dart | 2 +- 3 files changed, 18 insertions(+), 17 deletions(-) diff --git a/lib/app/settings/location/page.dart b/lib/app/settings/location/page.dart index 58ebd033e..48f150088 100644 --- a/lib/app/settings/location/page.dart +++ b/lib/app/settings/location/page.dart @@ -191,9 +191,9 @@ class _SettingsLocationPageState extends State with Widget if (shouldEnable) { if (!await requestPermissions()) return; - await BackgroundLocationServiceManager.start(); + await LocationServiceManager.start(); } else { - await BackgroundLocationServiceManager.stop(); + await LocationServiceManager.stop(); } GlobalProviders.location.setAuto(shouldEnable); diff --git a/lib/core/service.dart b/lib/core/service.dart index 2a6ccd5f0..cc1872330 100644 --- a/lib/core/service.dart +++ b/lib/core/service.dart @@ -36,7 +36,7 @@ class PositionEvent { } /// Events emitted by the background service. -final class BackgroundLocationServiceEvent { +final class LocationServiceEvent { /// Event emitted when a new position is set in the background service. /// Contains the updated location coordinates. static const position = 'position'; @@ -49,8 +49,8 @@ final class BackgroundLocationServiceEvent { /// /// This class is responsible for managing the background location service. /// It is used to handle start and stop the service. -class BackgroundLocationServiceManager { - BackgroundLocationServiceManager._(); +class LocationServiceManager { + LocationServiceManager._(); /// The notification ID used for the background service notification static const kNotificationId = 888888; @@ -80,7 +80,7 @@ class BackgroundLocationServiceManager { try { await service.configure( androidConfiguration: AndroidConfiguration( - onStart: BackgroundLocationService._$onStart, + onStart: LocationService._$onStart, autoStart: false, isForegroundMode: false, foregroundServiceTypes: [AndroidForegroundType.location], @@ -92,13 +92,13 @@ class BackgroundLocationServiceManager { // iOS is handled in native code iosConfiguration: IosConfiguration( autoStart: false, - onForeground: BackgroundLocationService._$onStartIOS, - onBackground: BackgroundLocationService._$onStartIOS, + onForeground: LocationService._$onStartIOS, + onBackground: LocationService._$onStartIOS, ), ); // Reloads the UI isolate's preference cache when a new position is set in the background service. - service.on(BackgroundLocationServiceEvent.position).listen((data) async { + service.on(LocationServiceEvent.position).listen((data) async { final event = PositionEvent.fromJson(data!); try { TalkerManager.instance.info('👷 location updated by service, reloading preferences'); @@ -170,7 +170,7 @@ class BackgroundLocationServiceManager { return; } - service.invoke(BackgroundLocationServiceEvent.stop); + service.invoke(LocationServiceEvent.stop); } catch (e, s) { TalkerManager.instance.error('👷 stopping location service FAILED', e, s); } @@ -183,8 +183,8 @@ class BackgroundLocationServiceManager { /// /// All property prefixed with `_$` are isolated from the main app. @pragma('vm:entry-point') -class BackgroundLocationService { - BackgroundLocationService._(); +class LocationService { + LocationService._(); /// The service instance static late AndroidServiceInstance _$service; @@ -215,13 +215,14 @@ class BackgroundLocationService { await AwesomeNotifications().createNotification( content: NotificationContent( - id: BackgroundLocationServiceManager.kNotificationId, + id: LocationServiceManager.kNotificationId, channelKey: 'background', title: 'DPIP', body: '自動定位服務啟動中...', locked: true, autoDismissible: false, icon: 'resource://drawable/ic_stat_name', + badge: 0, ), ); @@ -233,7 +234,7 @@ class BackgroundLocationService { _$service.setAutoStartOnBootMode(true); - _$service.on(BackgroundLocationServiceEvent.stop).listen((data) async { + _$service.on(LocationServiceEvent.stop).listen((data) async { try { TalkerManager.instance.info('⚙️::BackgroundLocationService stopping location service'); @@ -309,7 +310,7 @@ class BackgroundLocationService { await AwesomeNotifications().createNotification( content: NotificationContent( - id: BackgroundLocationServiceManager.kNotificationId, + id: LocationServiceManager.kNotificationId, channelKey: 'background', title: notificationTitle, body: notificationBody, @@ -450,6 +451,6 @@ class BackgroundLocationService { static Future _$updatePosition(ServiceInstance service, LatLng? position) async { _$location = position; - service.invoke(BackgroundLocationServiceEvent.position, PositionEvent(position).toJson()); + service.invoke(LocationServiceEvent.position, PositionEvent(position).toJson()); } } diff --git a/lib/main.dart b/lib/main.dart index a5961b91f..bdae802e0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -50,7 +50,7 @@ void main() async { updateInfoToServer(), ]); - await BackgroundLocationServiceManager.initalize(); + await LocationServiceManager.initalize(); runApp( I18n( From d4926e19141ff682d6dd8bf91431a8157fff59a1 Mon Sep 17 00:00:00 2001 From: Kamiya Date: Fri, 8 Aug 2025 15:40:14 +0800 Subject: [PATCH 15/21] fix: set preference --- lib/core/service.dart | 93 +++++++++++++++++++++++++++---------------- 1 file changed, 58 insertions(+), 35 deletions(-) diff --git a/lib/core/service.dart b/lib/core/service.dart index cc1872330..06774752b 100644 --- a/lib/core/service.dart +++ b/lib/core/service.dart @@ -21,17 +21,19 @@ import 'package:dpip/utils/log.dart'; class PositionEvent { final LatLng? coordinates; + final String? code; - PositionEvent(this.coordinates); + PositionEvent(this.coordinates, this.code); factory PositionEvent.fromJson(Map json) { final coordinates = json['coordinates'] as List?; + final code = json['code'] as String?; - return PositionEvent(coordinates != null ? LatLng(coordinates[0] as double, coordinates[1] as double) : null); + return PositionEvent(coordinates != null ? LatLng(coordinates[0] as double, coordinates[1] as double) : null, code); } Map toJson() { - return {'coordinates': coordinates?.toJson()}; + return {'coordinates': coordinates?.toJson(), 'code': code}; } } @@ -100,6 +102,7 @@ class LocationServiceManager { // Reloads the UI isolate's preference cache when a new position is set in the background service. service.on(LocationServiceEvent.position).listen((data) async { final event = PositionEvent.fromJson(data!); + try { TalkerManager.instance.info('👷 location updated by service, reloading preferences'); @@ -114,7 +117,7 @@ class LocationServiceManager { TalkerManager.instance.info('👷 preferences reloaded'); } catch (e, s) { - TalkerManager.instance.error('👷 failed to reload preferences', e, s); + TalkerManager.instance.error('👷 failed to update location', e, s); } }); @@ -283,7 +286,6 @@ class LocationService { return; } - final locationCode = _$getLocationFromCoordinates(coordinates); final previousLocation = _$location; final distanceInKm = previousLocation != null ? coordinates.to(previousLocation) : null; @@ -295,33 +297,6 @@ class LocationService { TalkerManager.instance.debug('⚙️::BackgroundLocationService distance: $distanceInKm, not updating position'); } - // Update notification with current position - final latitude = coordinates.latitude.toStringAsFixed(4); - final longitude = coordinates.longitude.toStringAsFixed(4); - - final location = locationCode != null ? _$locationData[locationCode] : null; - final locationName = location == null ? '服務區域外' : '${location.city} ${location.town}'; - - const notificationTitle = '自動定位中'; - final timestamp = DateTime.now().toDateTimeString(); - final notificationBody = - '$timestamp\n' - '$locationName ($latitude, $longitude) '; - - 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); - // Determine the next update time based on the distance moved int nextUpdateInterval = 15; @@ -374,7 +349,7 @@ class LocationService { /// /// 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? _$getLocationFromCoordinates(LatLng target) { + static ({String code, Location location})? _$getLocationFromCoordinates(LatLng target) { final features = _$geoJsonData.features; for (final feature in features) { @@ -436,7 +411,13 @@ class LocationService { } if (isInPolygon) { - return feature.properties!['CODE']?.toString(); + 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); } } @@ -451,6 +432,48 @@ class LocationService { static Future _$updatePosition(ServiceInstance service, LatLng? position) async { _$location = position; - service.invoke(LocationServiceEvent.position, PositionEvent(position).toJson()); + 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 = '服務區域外'; + + if (position == null) { + content = '服務區域外'; + } else { + final latitude = position.latitude.toStringAsFixed(6); + final longitude = position.longitude.toStringAsFixed(6); + + if (result == null) { + content = '服務區域外 ($latitude, $longitude)'; + } else { + content = '${result.location.city} ${result.location.town} ($latitude, $longitude)'; + } + } + + const notificationTitle = '自動定位中'; + 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); } } From 98b0c35687709fb569980458bae440f34d8fe601 Mon Sep 17 00:00:00 2001 From: Kamiya Date: Fri, 8 Aug 2025 16:04:17 +0800 Subject: [PATCH 16/21] fix: meters --- lib/core/service.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/core/service.dart b/lib/core/service.dart index 06774752b..9fda42172 100644 --- a/lib/core/service.dart +++ b/lib/core/service.dart @@ -290,7 +290,7 @@ class LocationService { final distanceInKm = previousLocation != null ? coordinates.to(previousLocation) : null; - if (distanceInKm == null || distanceInKm >= 250) { + if (distanceInKm == null || distanceInKm >= 0.25) { TalkerManager.instance.debug('⚙️::BackgroundLocationService distance: $distanceInKm, updating position'); _$updatePosition(_$service, coordinates); } else { @@ -301,9 +301,9 @@ class LocationService { int nextUpdateInterval = 15; if (distanceInKm != null) { - if (distanceInKm > 30) { + if (distanceInKm > 0.03) { nextUpdateInterval = 5; - } else if (distanceInKm > 10) { + } else if (distanceInKm > 0.01) { nextUpdateInterval = 10; } } From d8138ec54ea143c1f1eaffc837c4f4a7f6928840 Mon Sep 17 00:00:00 2001 From: Kamiya Date: Fri, 8 Aug 2025 16:04:48 +0800 Subject: [PATCH 17/21] Revert "fix: meters" This reverts commit 98b0c35687709fb569980458bae440f34d8fe601. --- lib/core/service.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/core/service.dart b/lib/core/service.dart index 9fda42172..06774752b 100644 --- a/lib/core/service.dart +++ b/lib/core/service.dart @@ -290,7 +290,7 @@ class LocationService { final distanceInKm = previousLocation != null ? coordinates.to(previousLocation) : null; - if (distanceInKm == null || distanceInKm >= 0.25) { + if (distanceInKm == null || distanceInKm >= 250) { TalkerManager.instance.debug('⚙️::BackgroundLocationService distance: $distanceInKm, updating position'); _$updatePosition(_$service, coordinates); } else { @@ -301,9 +301,9 @@ class LocationService { int nextUpdateInterval = 15; if (distanceInKm != null) { - if (distanceInKm > 0.03) { + if (distanceInKm > 30) { nextUpdateInterval = 5; - } else if (distanceInKm > 0.01) { + } else if (distanceInKm > 10) { nextUpdateInterval = 10; } } From 505f1041da4e6d6d923194b7b8e9ff688eab525a Mon Sep 17 00:00:00 2001 From: Kamiya Date: Fri, 8 Aug 2025 16:05:24 +0800 Subject: [PATCH 18/21] fix: variable name meters --- lib/core/service.dart | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/lib/core/service.dart b/lib/core/service.dart index 06774752b..3574fe73e 100644 --- a/lib/core/service.dart +++ b/lib/core/service.dart @@ -288,22 +288,24 @@ class LocationService { final previousLocation = _$location; - final distanceInKm = previousLocation != null ? coordinates.to(previousLocation) : null; + final distanceInMeters = previousLocation != null ? coordinates.to(previousLocation) : null; - if (distanceInKm == null || distanceInKm >= 250) { - TalkerManager.instance.debug('⚙️::BackgroundLocationService distance: $distanceInKm, updating position'); + if (distanceInMeters == null || distanceInMeters >= 250) { + TalkerManager.instance.debug('⚙️::BackgroundLocationService distance: $distanceInMeters, updating position'); _$updatePosition(_$service, coordinates); } else { - TalkerManager.instance.debug('⚙️::BackgroundLocationService distance: $distanceInKm, not updating position'); + TalkerManager.instance.debug( + '⚙️::BackgroundLocationService distance: $distanceInMeters, not updating position', + ); } // Determine the next update time based on the distance moved int nextUpdateInterval = 15; - if (distanceInKm != null) { - if (distanceInKm > 30) { + if (distanceInMeters != null) { + if (distanceInMeters > 30) { nextUpdateInterval = 5; - } else if (distanceInKm > 10) { + } else if (distanceInMeters > 10) { nextUpdateInterval = 10; } } From faa5946a70076e8f72bf1b7dcca79aede5de627d Mon Sep 17 00:00:00 2001 From: Kamiya Date: Fri, 8 Aug 2025 16:08:20 +0800 Subject: [PATCH 19/21] style: wrap docstrings --- lib/app/settings/location/page.dart | 4 +-- lib/core/service.dart | 33 +++++++++++------------ lib/models/settings/location.dart | 41 +++++++++++++---------------- lib/utils/extensions/latlng.dart | 4 +-- 4 files changed, 37 insertions(+), 45 deletions(-) diff --git a/lib/app/settings/location/page.dart b/lib/app/settings/location/page.dart index 48f150088..83c602e47 100644 --- a/lib/app/settings/location/page.dart +++ b/lib/app/settings/location/page.dart @@ -62,8 +62,8 @@ class _SettingsLocationPageState extends State with Widget }); } - /// Shows a error dialog to the user with the given permission type. - /// [type] can be either [Permission] or `"auto-start"` + /// Shows a error dialog to the user with the given permission type. [type] can be either [Permission] or + /// `"auto-start"` Future showPermissionDialog(dynamic type) async { if (!mounted) return; if (type is! Permission && type is! String) return; diff --git a/lib/core/service.dart b/lib/core/service.dart index 3574fe73e..d368e6b43 100644 --- a/lib/core/service.dart +++ b/lib/core/service.dart @@ -39,8 +39,7 @@ class PositionEvent { /// 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. + /// 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. @@ -49,8 +48,8 @@ final class LocationServiceEvent { /// Background location service. /// -/// This class is responsible for managing the background location service. -/// It is used to handle start and stop the service. +/// This class is responsible for managing the background location service. It is used to handle start and stop the +/// service. class LocationServiceManager { LocationServiceManager._(); @@ -68,8 +67,8 @@ class LocationServiceManager { /// 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. + /// 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. static Future initalize() async { @@ -182,7 +181,8 @@ class LocationServiceManager { /// 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. +/// 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') @@ -206,9 +206,8 @@ class LocationService { /// 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. + /// 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 _$onStart(ServiceInstance service) async { if (service is! AndroidServiceInstance) return; @@ -268,8 +267,8 @@ class LocationService { /// 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. + /// 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 _$task() async { if (!await _$service.isForegroundService()) return; @@ -329,8 +328,7 @@ class LocationService { /// Gets the current geographical location of the device. /// - /// Returns null if location services are disabled. - /// Uses medium accuracy for location detection. + /// Returns null if location services are disabled. Uses medium accuracy for location detection. @pragma('vm:entry-point') static Future _$getDeviceGeographicalLocation() async { final isLocationServiceEnabled = await Geolocator.isLocationServiceEnabled(); @@ -349,8 +347,8 @@ class LocationService { /// 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. + /// 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; @@ -428,8 +426,7 @@ class LocationService { /// 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. + /// 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 _$updatePosition(ServiceInstance service, LatLng? position) async { _$location = position; diff --git a/lib/models/settings/location.dart b/lib/models/settings/location.dart index d9658f069..5402fde41 100644 --- a/lib/models/settings/location.dart +++ b/lib/models/settings/location.dart @@ -10,14 +10,12 @@ import 'package:dpip/global.dart'; class _SettingsLocationModel extends ChangeNotifier { /// The underlying [ValueNotifier] for the current location represented as a postal code. /// - /// Returns the stored location code from preferences. - /// Returns `null` if no location code has been set. + /// Returns the stored location code from preferences. Returns `null` if no location code has been set. final $code = ValueNotifier(Preference.locationCode); /// The current location represented as a postal code. /// - /// Returns the stored location code from preferences. - /// Returns `null` if no location code has been set. + /// Returns the stored location code from preferences. Returns `null` if no location code has been set. String? get code => $code.value; /// Sets the current location using a postal code. @@ -26,9 +24,8 @@ class _SettingsLocationModel extends ChangeNotifier { /// /// Invoking this method will also update [$code] and notify all attached listeners. /// - /// If [value] matches the current code, no changes are made. - /// When [auto] is false, also updates the stored latitude and longitude based on the - /// location data associated with the postal code. + /// If [value] matches the current code, no changes are made. When [auto] is false, also updates the stored latitude + /// and longitude based on the location data associated with the postal code. void setCode(String? value) { if (code == value) return; @@ -65,8 +62,8 @@ class _SettingsLocationModel extends ChangeNotifier { /// The underlying [ValueNotifier] for the current location represented as a [LatLng] coordinate. /// - /// Returns a [LatLng] object containing the stored coordinates for the current [code]. - /// Returns `null` if either latitude or longitude is not set. + /// Returns a [LatLng] object containing the stored coordinates for the current [code]. Returns `null` if either + /// latitude or longitude is not set. /// /// This is used to display the precise location of the user on the map. /// @@ -79,8 +76,8 @@ class _SettingsLocationModel extends ChangeNotifier { /// The current location represented as a LatLng coordinate. /// - /// Returns a [LatLng] object containing the stored coordinates for the current [code]. - /// Returns `null` if either latitude or longitude is not set. + /// Returns a [LatLng] object containing the stored coordinates for the current [code]. Returns `null` if either + /// latitude or longitude is not set. /// /// This is used to display the precise location of the user on the map. /// @@ -89,9 +86,8 @@ class _SettingsLocationModel extends ChangeNotifier { /// Sets the current location using a LatLng coordinate. /// - /// Takes a [LatLng] value containing latitude and longitude coordinates and updates - /// the stored location preferences. If value is `null`, both latitude and longitude - /// will be set to `null`. + /// Takes a [LatLng] value containing latitude and longitude coordinates and updates the stored location preferences. + /// If value is `null`, both latitude and longitude will be set to `null`. /// /// Invoking this method will also update [$coordinates] and notify all attached listeners. /// @@ -109,27 +105,26 @@ class _SettingsLocationModel extends ChangeNotifier { /// The underlying [ValueNotifier] for the current state of automatic location update. /// - /// Returns a [bool] indicating if automatic location update is enabled. - /// When enabled, the app will use GPS to automatically update - /// the current location. When disabled, the location must be set manually either by [setCode] or [setCoordinates]. + /// Returns a [bool] indicating if automatic location update is enabled. When enabled, the app will use GPS to + /// automatically update the current location. When disabled, the location must be set manually either by [setCode] or + /// [setCoordinates]. /// /// Defaults to `false` if no preference has been set. final $auto = ValueNotifier(Preference.locationAuto ?? false); /// The current state of automatic location update. /// - /// Returns a [bool] indicating if automatic location update is enabled. - /// When enabled, the app will use GPS to automatically update - /// the current location. When disabled, the location must be set manually either by [setCode] or [setCoordinates]. + /// Returns a [bool] indicating if automatic location update is enabled. When enabled, the app will use GPS to + /// automatically update the current location. When disabled, the location must be set manually either by [setCode] or + /// [setCoordinates]. /// /// Defaults to `false` if no preference has been set. bool get auto => $auto.value; /// Sets whether location should be automatically determined using GPS. /// - /// Takes a [bool] value indicating if automatic location detection should be enabled. - /// When enabled, the app will use GPS to automatically determine and update the current location. - /// When disabled, the location must be set manually. + /// Takes a [bool] value indicating if automatic location detection should be enabled. When enabled, the app will use + /// GPS to automatically determine and update the current location. When disabled, the location must be set manually. void setAuto(bool value) { Preference.locationAuto = value; diff --git a/lib/utils/extensions/latlng.dart b/lib/utils/extensions/latlng.dart index 185c39c77..5f4b61a31 100644 --- a/lib/utils/extensions/latlng.dart +++ b/lib/utils/extensions/latlng.dart @@ -10,7 +10,7 @@ extension GeoJsonLatLng on LatLng { return GeoJsonFeatureBuilder(GeoJsonFeatureType.Point)..setGeometry(toGeoJsonCoordinates() as List); } - /// Calculates the distance between the supplied coordinates in meters. - /// The distance between the coordinates is calculated using the Haversine formula (see https://en.wikipedia.org/wiki/Haversine_formula). + /// Calculates the distance between the supplied coordinates in meters. The distance between the coordinates is + /// calculated using the Haversine formula (see https://en.wikipedia.org/wiki/Haversine_formula). double to(LatLng other) => Geolocator.distanceBetween(latitude, longitude, other.latitude, other.longitude); } From 2b2b638c17a1f759bca576fbb5e962c9f8dfb678 Mon Sep 17 00:00:00 2001 From: Kamiya Date: Fri, 8 Aug 2025 16:14:14 +0800 Subject: [PATCH 20/21] fix: remove location reset upon enable auto --- lib/app/settings/location/page.dart | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/app/settings/location/page.dart b/lib/app/settings/location/page.dart index 83c602e47..f74a21f5d 100644 --- a/lib/app/settings/location/page.dart +++ b/lib/app/settings/location/page.dart @@ -197,8 +197,6 @@ class _SettingsLocationPageState extends State with Widget } GlobalProviders.location.setAuto(shouldEnable); - GlobalProviders.location.setCode(null); - GlobalProviders.location.setCoordinates(null); } @override From ecada53786a42fe1bb1e71aeb297965d57f01aa7 Mon Sep 17 00:00:00 2001 From: Kamiya Date: Fri, 8 Aug 2025 22:28:05 +0800 Subject: [PATCH 21/21] refactor: separate handler method --- lib/core/service.dart | 78 ++++++++++++++++++++++++------------------- 1 file changed, 43 insertions(+), 35 deletions(-) diff --git a/lib/core/service.dart b/lib/core/service.dart index d368e6b43..1a133f713 100644 --- a/lib/core/service.dart +++ b/lib/core/service.dart @@ -99,26 +99,7 @@ class LocationServiceManager { ); // Reloads the UI isolate's preference cache when a new position is set in the background service. - service.on(LocationServiceEvent.position).listen((data) async { - final event = PositionEvent.fromJson(data!); - - try { - TalkerManager.instance.info('👷 location updated by service, reloading preferences'); - - await Preference.reload(); - GlobalProviders.location.refresh(); - - // Handle FCM notification - 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); - } - }); + service.on(LocationServiceEvent.position).listen((data) => _onPosition(PositionEvent.fromJson(data!))); instance = service; TalkerManager.instance.info('👷 service initialized'); @@ -177,6 +158,27 @@ class LocationServiceManager { 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 _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. @@ -236,21 +238,7 @@ class LocationService { _$service.setAutoStartOnBootMode(true); - _$service.on(LocationServiceEvent.stop).listen((data) 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); - } - }); + _$service.on(LocationServiceEvent.stop).listen((_) => _$onStop()); // Start the periodic location update task await _$task(); @@ -326,6 +314,26 @@ class LocationService { } } + /// 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 _$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.