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..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,7 +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'; class ExpTech { String? apikey; @@ -486,8 +488,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 c0081ff6f..feca4f0e6 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..f74a21f5d 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,319 +35,168 @@ 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); - }, - ), - ], - ); - }, - ); + FilledButton( + child: Text('設定'.i18n), + onPressed: () { + openAppSettings(); + 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 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 ? '請您到應用程式設定中找到並允許「位置」權限後再試一次。' : ""}", - ), - 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); - }, - ), - ], - ); - }, - ); - + if (!await Permission.location.request().isGranted) { + TalkerManager.instance.warning('🧪 failed location (ACCESS_COARSE_LOCATION) permission test'); + showPermissionDialog(Permission.location); return false; } - return true; - } - - Future checkLocationAlwaysPermission() async { - final status = await Permission.locationAlways.status; - - 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 (!await Permission.locationWhenInUse.request().isGranted) { + TalkerManager.instance.warning('🧪 failed location when in use (ACCESS_FINE_LOCATION) permission test'); + showPermissionDialog(Permission.locationWhenInUse); + return false; } - } - 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); + if (!await Permission.locationAlways.request().isGranted) { + TalkerManager.instance.warning('🧪 failed location always (ACCESS_BACKGROUND_LOCATION) permission test'); + showPermissionDialog(Permission.locationAlways); return false; } - } - Future androidCheckBatteryOptimizationPermission(int num) async { if (!Platform.isAndroid) return true; - 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; - } - } + autoStart: + { + final available = await Autostarter.isAutoStartPermissionAvailable(); + if (available == null) break autoStart; - Future toggleAutoLocation() async { - final isAuto = context.read().auto; + final status = await DisableBatteryOptimization.isAutoStartEnabled; + if (status == null || status) { + batteryOptimizationPermission = true; + break autoStart; + } - stopAndroidBackgroundService(); + await DisableBatteryOptimization.showEnableAutoStartSettings( + '自動啟動', + '為了獲得更好的 DPIP 體驗,請依照步驟啟用自動啟動功能,以便讓 DPIP 在背景能正常接收資訊以及更新所在地。', + ); + } - if (!isAuto) { - final notification = await checkNotificationPermission(); - if (!notification) return; + batteryOptimization: + { + final status = await DisableBatteryOptimization.isBatteryOptimizationDisabled; + if (status == null || status) { + batteryOptimizationPermission = true; + break batteryOptimization; + } - final location = await checkLocationPermission(); - if (!location) return; + await DisableBatteryOptimization.showDisableBatteryOptimizationSettings(); + } - await checkLocationAlwaysPermission(); + manufacturerBatteryOptimization: + { + final status = await DisableBatteryOptimization.isManufacturerBatteryOptimizationDisabled; + if (status == null || status) break manufacturerBatteryOptimization; - final bool autoStart = await androidCheckAutoStartPermission(0); - autoStartPermission = autoStart; - if (!autoStart) return; + await DisableBatteryOptimization.showEnableAutoStartSettings( + '省電策略', + '為了獲得更好的 DPIP 體驗,請依照步驟關閉省電策略,以便讓 DPIP 在背景能正常接收資訊以及更新所在地。', + ); + } - final bool batteryOptimization = await androidCheckBatteryOptimizationPermission(0); - batteryOptimizationPermission = batteryOptimization; - if (!batteryOptimization) return; + setState(() {}); + return true; + } - if (!isAuto) { - startAndroidBackgroundService(shouldInitialize: false); - } - } + Future toggleAutoLocation(bool shouldEnable) async { + if (shouldEnable) { + if (!await requestPermissions()) return; - if (Platform.isIOS) { - await platform.invokeMethod('toggleLocation', {'isEnabled': !isAuto}).catchError((_) {}); + await LocationServiceManager.start(); + } else { + await LocationServiceManager.stop(); } - if (!mounted) return; - - context.read().setAuto(!isAuto); - context.read().setCode(null); - context.read().setLatLng(); + GlobalProviders.location.setAuto(shouldEnable); } @override @@ -355,57 +206,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 ? '一律允許' : '永遠'; @@ -422,7 +227,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), ); }, ), @@ -497,16 +302,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( @@ -523,16 +328,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( @@ -566,17 +371,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), ); }, ), @@ -594,86 +389,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/app/settings/location/select/[city]/page.dart b/lib/app/settings/location/select/[city]/page.dart index e22bc68f8..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,9 +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:material_symbols_icons/material_symbols_icons.dart'; -import 'package:provider/provider.dart'; class SettingsLocationSelectCityPage extends StatefulWidget { final String city; @@ -38,38 +41,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..e19b83c32 100644 --- a/lib/core/ios_get_location.dart +++ b/lib/core/ios_get_location.dart @@ -1,9 +1,12 @@ import 'dart:async'; +import 'package:flutter/services.dart'; + +import 'package:maplibre_gl/maplibre_gl.dart'; + 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:dpip/utils/map_utils.dart'; const _channel = MethodChannel('com.exptech.dpip/data'); Completer? _completer; @@ -17,20 +20,22 @@ 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; - GlobalProviders.location.setLatLng(latitude: latitude, longitude: longitude); + final latitude = result['lat'] as double?; + final longitude = result['lon'] as double?; if (latitude != null && longitude != null) { - final location = GeoJsonHelper.checkPointInPolygons(latitude, longitude); - print(location); - GlobalProviders.location.setCode(location?.code.toString()); + final code = getTownCodeFromCoordinates(LatLng(latitude, longitude)); + GlobalProviders.location.setCode(code); + GlobalProviders.location.setCoordinates(LatLng(latitude, longitude)); + } else { + 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; 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..1a133f713 100644 --- a/lib/core/service.dart +++ b/lib/core/service.dart @@ -2,247 +2,485 @@ 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:geojson_vi/geojson_vi.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; + import 'package:dpip/api/exptech.dart'; -import 'package:dpip/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/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; +class PositionEvent { + final LatLng? coordinates; + final String? code; + + PositionEvent(this.coordinates, this.code); -enum ServiceEvent { setAsForeground, setAsBackground, sendPosition, sendDebug, removePosition, stopService } + factory PositionEvent.fromJson(Map json) { + final coordinates = json['coordinates'] as List?; + final code = json['code'] as String?; -Future initBackgroundService() async { - final isAutoLocationEnabled = GlobalProviders.location.auto; - if (!isAutoLocationEnabled) { - return; + return PositionEvent(coordinates != null ? LatLng(coordinates[0] as double, coordinates[1] as double) : null, code); } - final notificationPermission = await Permission.notification.status; - final locationPermission = await Permission.locationAlways.status; + Map toJson() { + return {'coordinates': coordinates?.toJson(), 'code': code}; + } +} - if (notificationPermission.isGranted && locationPermission.isGranted) { - if (!Platform.isAndroid) return; +/// Events emitted by the background service. +final class LocationServiceEvent { + /// Event emitted when a new position is set in the background service. Contains the updated location coordinates. + static const position = 'position'; - await _initializeAndroidForegroundService(); - _setupPositionListener(); - startAndroidBackgroundService(shouldInitialize: true); - } + /// Method event to stop the service. + static const stop = 'stop'; } -Future startAndroidBackgroundService({required bool shouldInitialize}) async { - if (!_isAndroidServiceInitialized) { - await _initializeAndroidForegroundService(); - _setupPositionListener(); +/// Background location service. +/// +/// This class is responsible for managing the background location service. It is used to handle start and stop the +/// service. +class LocationServiceManager { + LocationServiceManager._(); + + /// The notification ID used for the background service notification + static const kNotificationId = 888888; + + /// Instance of the background service + static FlutterBackgroundService? instance; + + /// Platform channel for iOS + static const platform = MethodChannel('com.exptech.dpip/location'); + + /// Whether the background service is available on the current platform + static bool get avaliable => Platform.isAndroid || Platform.isIOS; + + /// Initializes the background location service. + /// + /// Configures the service with Android specific settings. Sets up a listener for position updates that reloads + /// preferences and updates device location. + /// + /// Will starts the service if automatic location updates are enabled. + static Future initalize() async { + if (instance != null || !avaliable) return; + + TalkerManager.instance.info('👷 initializing location service'); + + final service = FlutterBackgroundService(); + + try { + await service.configure( + androidConfiguration: AndroidConfiguration( + onStart: LocationService._$onStart, + autoStart: false, + isForegroundMode: false, + foregroundServiceTypes: [AndroidForegroundType.location], + notificationChannelId: 'background', + initialNotificationTitle: 'DPIP', + initialNotificationContent: '正在初始化自動定位服務...', + foregroundServiceNotificationId: kNotificationId, + ), + // iOS is handled in native code + iosConfiguration: IosConfiguration( + autoStart: false, + 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(LocationServiceEvent.position).listen((data) => _onPosition(PositionEvent.fromJson(data!))); + + instance = service; + TalkerManager.instance.info('👷 service initialized'); + } catch (e, s) { + TalkerManager.instance.error('👷 initializing location service FAILED', e, s); + } + + if (Preference.locationAuto == true) await start(); } - final isServiceRunning = await _backgroundService.isRunning(); - if (!isServiceRunning) { - _backgroundService.startService(); - } else if (!shouldInitialize) { - stopAndroidBackgroundService(); - _backgroundService.startService(); + /// 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 (!avaliable) return; + + TalkerManager.instance.info('👷 starting location service'); + + try { + final service = instance; + if (service == null) throw Exception('Not initialized.'); + + 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 service.startService(); + } catch (e, s) { + TalkerManager.instance.error('👷 starting location service FAILED', e, s); + } } -} -Future stopAndroidBackgroundService() async { - final isServiceRunning = await _backgroundService.isRunning(); - if (!isServiceRunning) return; + /// Stops the background location service by invoking the stop event. + static Future stop() async { + if (!avaliable) return; + + TalkerManager.instance.info('👷 stopping location service'); + + try { + final service = instance; + if (service == null) throw Exception('Not initialized.'); + + if (Platform.isIOS) { + await platform.invokeMethod('toggleLocation', {'isEnabled': false}); + return; + } - final isAutoLocationEnabled = GlobalProviders.location.auto; - if (isAutoLocationEnabled) { - _backgroundService.invoke(ServiceEvent.removePosition.name); + service.invoke(LocationServiceEvent.stop); + } catch (e, s) { + TalkerManager.instance.error('👷 stopping location service FAILED', e, s); + } } - _backgroundService.invoke(ServiceEvent.stopService.name); + /// 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); + } + } } -void _setupPositionListener() { - _backgroundService.on(ServiceEvent.sendPosition.name).listen((event) { - if (event == null) return; +/// The background location service. +/// +/// This service is used to get the current location of the device in the background and notify the main isolate to +/// update the UI with the new location. +/// +/// All property prefixed with `_$` are isolated from the main app. +@pragma('vm:entry-point') +class LocationService { + LocationService._(); - final result = GetLocationResult.fromJson(event); + /// The service instance + static late AndroidServiceInstance _$service; - final latitude = result.lat ?? 0; - final longitude = result.lng ?? 0; + /// The last known location coordinates + static LatLng? _$location; - final location = GeoJsonHelper.checkPointInPolygons(latitude, longitude); + /// Timer for scheduling periodic location updates + static Timer? _$locationUpdateTimer; - GlobalProviders.location.setCode(location?.code.toString()); - GlobalProviders.location.setLatLng(latitude: latitude, longitude: longitude); + /// Cached GeoJSON data for location lookups + static late GeoJSONFeatureCollection _$geoJsonData; - RadarMap.updatePosition(); - }); + /// Cached location data mapping + static late Map _$locationData; - _backgroundService.on(ServiceEvent.sendDebug.name).listen((event) { - if (event == null) return; + /// 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 { + if (service is! AndroidServiceInstance) return; + _$service = service; - final notificationBody = event['notifyBody']; - TalkerManager.instance.debug('自動定位: $notificationBody'); - }); -} + DartPluginRegistrant.ensureInitialized(); -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, + await AwesomeNotifications().createNotification( + content: NotificationContent( + id: LocationServiceManager.kNotificationId, + channelKey: 'background', + title: 'DPIP', + body: '自動定位服務啟動中...', 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; -} + autoDismissible: false, + icon: 'resource://drawable/ic_stat_name', + badge: 0, + ), + ); -@pragma('vm:entry-point') -Future _onIosBackground(ServiceInstance service) async { - WidgetsFlutterBinding.ensureInitialized(); - DartPluginRegistrant.ensureInitialized(); - return true; -} + await _$service.setAsForegroundService(); -@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(); + await Preference.init(); + _$geoJsonData = await Global.loadTownGeojson(); + _$locationData = await Global.loadLocationData(); - final locationService = LocationService(); + _$service.setAutoStartOnBootMode(true); - // 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('背景服務已停止'); - }); - - // 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(); - }); - - // Define the periodic location update task - Future updateLocation() async { - _locationUpdateTimer?.cancel(); - if (!await service.isForegroundService()) return; + _$service.on(LocationServiceEvent.stop).listen((_) => _$onStop()); + + // Start the periodic location update task + 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. 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('⚙️::BackgroundLocationService task started'); + + try { // Get current position and location info - final position = await locationService.androidGetLocation(); - service.invoke(ServiceEvent.sendPosition.name, position.toJson()); + final coordinates = await _$getDeviceGeographicalLocation(); - 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 previousLocation = _$location; + + final distanceInMeters = previousLocation != null ? coordinates.to(previousLocation) : null; + + if (distanceInMeters == null || distanceInMeters >= 250) { + TalkerManager.instance.debug('⚙️::BackgroundLocationService distance: $distanceInMeters, updating position'); + _$updatePosition(_$service, coordinates); + } else { + TalkerManager.instance.debug( + '⚙️::BackgroundLocationService distance: $distanceInMeters, not updating position', + ); } - // 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'; - - service.invoke(ServiceEvent.sendDebug.name, {'notifyBody': notificationBody}); - await AwesomeNotifications().createNotification( - content: NotificationContent( - id: 888, - channelKey: 'my_foreground', - title: notificationTitle, - body: notificationBody, - notificationLayout: NotificationLayout.Default, - locked: true, - autoDismissible: false, - ), - ); - service.setForegroundNotificationInfo(title: notificationTitle, content: notificationBody); + // Determine the next update time based on the distance moved + int nextUpdateInterval = 15; - final double dist = position.latlng.to( - LatLng(GlobalProviders.location.oldLatitude ?? 0, GlobalProviders.location.oldLongitude ?? 0), + if (distanceInMeters != null) { + if (distanceInMeters > 30) { + nextUpdateInterval = 5; + } else if (distanceInMeters > 10) { + nextUpdateInterval = 10; + } + } + + _$locationUpdateTimer?.cancel(); + _$locationUpdateTimer = Timer.periodic(Duration(minutes: nextUpdateInterval), (timer) => _$task()); + } catch (e, s) { + $perf.stop(); + TalkerManager.instance.error( + '⚙️::BackgroundLocationService task FAILED after ${$perf.elapsedMilliseconds}ms', + e, + s, ); + } finally { + if ($perf.isRunning) { + $perf.stop(); + TalkerManager.instance.debug('⚙️::BackgroundLocationService task completed in ${$perf.elapsedMilliseconds}ms'); + } + } + } + + /// The event handler for the "stop" event. + /// + /// Called when the service manager sends a stop signal to terminate the location service. + @pragma('vm:entry-point') + static Future _$onStop() async { + try { + TalkerManager.instance.info('⚙️::BackgroundLocationService stopping location service'); + + // Cleanup timer + _$locationUpdateTimer?.cancel(); + + await _$service.setAutoStartOnBootMode(false); + await _$service.stopSelf(); + + TalkerManager.instance.info('⚙️::BackgroundLocationService location service stopped'); + } catch (e, s) { + TalkerManager.instance.error('⚙️::BackgroundLocationService stopping location service FAILED', e, s); + } + } + + /// Gets the current geographical location of the device. + /// + /// Returns null if location services are disabled. Uses medium accuracy for location detection. + @pragma('vm:entry-point') + static Future _$getDeviceGeographicalLocation() async { + final isLocationServiceEnabled = await Geolocator.isLocationServiceEnabled(); + + if (!isLocationServiceEnabled) { + TalkerManager.instance.warning('位置服務未啟用'); + return null; + } + + final currentPosition = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings(accuracy: LocationAccuracy.medium), + ); - int time = 15; + return LatLng(currentPosition.latitude, currentPosition.longitude); + } + + /// Gets the location code for given coordinates by checking if they fall within polygon boundaries. + /// + /// Takes a target LatLng and checks if it falls within any polygon in the GeoJSON data. Returns the location code if + /// found, null otherwise. + static ({String code, Location location})? _$getLocationFromCoordinates(LatLng target) { + final features = _$geoJsonData.features; + + for (final feature in features) { + if (feature == null) continue; + + final geometry = feature.geometry; + if (geometry == null) continue; + + bool isInPolygon = false; + + if (geometry is GeoJSONPolygon) { + final polygon = geometry.coordinates[0]; + + bool isInside = false; + int j = polygon.length - 1; + for (int i = 0; i < polygon.length; i++) { + final double xi = polygon[i][0]; + final double yi = polygon[i][1]; + final double xj = polygon[j][0]; + final double yj = polygon[j][1]; - if (dist > 30) { - time = 5; - } else if (dist > 10) { - time = 10; + final bool intersect = + ((yi > target.latitude) != (yj > target.latitude)) && + (target.longitude < (xj - xi) * (target.latitude - yi) / (yj - yi) + xi); + if (intersect) isInside = !isInside; + + j = i; + } + isInPolygon = isInside; + } + + if (geometry is GeoJSONMultiPolygon) { + final multiPolygon = geometry.coordinates; + + for (final polygonCoordinates in multiPolygon) { + final polygon = polygonCoordinates[0]; + + bool isInside = false; + int j = polygon.length - 1; + for (int i = 0; i < polygon.length; i++) { + final double xi = polygon[i][0]; + final double yi = polygon[i][1]; + final double xj = polygon[j][0]; + final double yj = polygon[j][1]; + + final bool intersect = + ((yi > target.latitude) != (yj > target.latitude)) && + (target.longitude < (xj - xi) * (target.latitude - yi) / (yj - yi) + xi); + if (intersect) isInside = !isInside; + + j = i; + } + + if (isInside) { + isInPolygon = true; + break; + } + } } - GlobalProviders.location.setOldLongitude(position.lng); - GlobalProviders.location.setOldLatitude(position.lat); + if (isInPolygon) { + final code = feature.properties!['CODE']?.toString(); + if (code == null) return null; - _locationUpdateTimer = Timer.periodic(Duration(minutes: time), (timer) async => updateLocation()); + final location = _$locationData[code]; + if (location == null) return null; + + return (code: code, location: location); + } } - // Start the periodic task - updateLocation(); + 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 { + _$location = position; + + final result = position != null ? _$getLocationFromCoordinates(position) : null; + + Preference.locationCode = result?.code; + Preference.locationLatitude = position?.latitude; + Preference.locationLongitude = position?.longitude; + + service.invoke(LocationServiceEvent.position, PositionEvent(position, result?.code).toJson()); + + // Update notification with current position + final timestamp = DateTime.now().toDateTimeString(); + String content = '服務區域外'; + + 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); } } diff --git a/lib/core/update.dart b/lib/core/update.dart index 93b73145e..d49a2e353 100644 --- a/lib/core/update.dart +++ b/lib/core/update.dart @@ -1,13 +1,19 @@ 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; + try { + if (latitude == null || longitude == null) return; if (Preference.notifyToken != '' && DateTime.now().millisecondsSinceEpoch - (Preference.lastUpdateToServerTime ?? 0) > 86400 * 1 * 1000) { final random = Random(); @@ -15,14 +21,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 +51,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..f2337f990 100644 --- a/lib/global.dart +++ b/lib/global.dart @@ -1,27 +1,29 @@ -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: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 geojson; + static late GeoJSONFeatureCollection townGeojson; static late Map> timeTable; static late Map box; 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 { @@ -50,15 +52,20 @@ 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 loadTownGeojson(); - 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..bdae802e0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -50,7 +50,7 @@ void main() async { updateInfoToServer(), ]); - initBackgroundService(); + await LocationServiceManager.initalize(); runApp( I18n( diff --git a/lib/models/settings/location.dart b/lib/models/settings/location.dart index 02ef53eca..5402fde41 100644 --- a/lib/models/settings/location.dart +++ b/lib/models/settings/location.dart @@ -1,69 +1,188 @@ -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(); + } + + /// 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}; + } } 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..5f4b61a31 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..6ef63541f 100644 --- a/lib/utils/extensions/preference.dart +++ b/lib/utils/extensions/preference.dart @@ -1,5 +1,7 @@ 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. /// @@ -16,23 +18,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/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/location_to_code.dart b/lib/utils/location_to_code.dart deleted file mode 100644 index 0cb648846..000000000 --- a/lib/utils/location_to_code.dart +++ /dev/null @@ -1,92 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter/services.dart' show rootBundle; - -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 { - static Map? _geoJsonData; - - static Future loadGeoJson(String geojsonAssetPath) async { - final String geojsonStr = await rootBundle.loadString(geojsonAssetPath); - _geoJsonData = json.decode(geojsonStr); - } - - 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; - } -} diff --git a/lib/utils/map_utils.dart b/lib/utils/map_utils.dart index 8c05bb7ce..da3b93636 100644 --- a/lib/utils/map_utils.dart +++ b/lib/utils/map_utils.dart @@ -1,9 +1,12 @@ import 'dart:math'; +import 'package:geojson_vi/geojson_vi.dart'; +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 +187,72 @@ bool checkBoxSkip(Map eewLastInfo, Map eewDist, Lis return passed; } + +String? getTownCodeFromCoordinates(LatLng target) { + final features = Global.townGeojson.features; + + for (final feature in features) { + if (feature == null) continue; + + final geometry = feature.geometry; + if (geometry == null) continue; + + bool isInPolygon = false; + + if (geometry is GeoJSONPolygon) { + final polygon = geometry.coordinates[0]; + + bool isInside = false; + int j = polygon.length - 1; + for (int i = 0; i < polygon.length; i++) { + final double xi = polygon[i][0]; + final double yi = polygon[i][1]; + final double xj = polygon[j][0]; + final double yj = polygon[j][1]; + + final bool intersect = + ((yi > target.latitude) != (yj > target.latitude)) && + (target.longitude < (xj - xi) * (target.latitude - yi) / (yj - yi) + xi); + if (intersect) isInside = !isInside; + + j = i; + } + isInPolygon = isInside; + } + + if (geometry is GeoJSONMultiPolygon) { + final multiPolygon = geometry.coordinates; + + for (final polygonCoordinates in multiPolygon) { + final polygon = polygonCoordinates[0]; + + bool isInside = false; + int j = polygon.length - 1; + for (int i = 0; i < polygon.length; i++) { + final double xi = polygon[i][0]; + final double yi = polygon[i][1]; + final double xj = polygon[j][0]; + final double yj = polygon[j][1]; + + final bool intersect = + ((yi > target.latitude) != (yj > target.latitude)) && + (target.longitude < (xj - xi) * (target.latitude - yi) / (yj - yi) + xi); + if (intersect) isInside = !isInside; + + j = i; + } + + if (isInside) { + isInPolygon = true; + break; + } + } + } + + if (isInPolygon) { + return feature.properties!['CODE']?.toString(); + } + } + + 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(); } } 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