From 4a7fb3d53c452ec079c6d031d4a35cd5eeadfebe Mon Sep 17 00:00:00 2001 From: Vinson Date: Thu, 10 Sep 2026 16:20:27 +0800 Subject: [PATCH] feat(notify): let Android users review urgent channel settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform: android New(zh-Hant): Android 可逐一檢查重大通知是否能在勿擾模式中發出 New(en-US): Android users can review major-alert channels in Do Not Disturb --- .../exptech/dpip/PermissionSettingsChannel.kt | 53 +++++++ .../notifications/notification_channels.dart | 12 ++ .../urgent_notification_settings.dart | 125 +++++++++++++++++ lib/l10n/app_en.arb | 10 ++ lib/l10n/app_fil.arb | 5 + lib/l10n/app_id.arb | 5 + lib/l10n/app_ja.arb | 5 + lib/l10n/app_ko.arb | 5 + lib/l10n/app_th.arb | 5 + lib/l10n/app_vi.arb | 5 + lib/l10n/app_yue.arb | 5 + lib/l10n/app_zh.arb | 5 + lib/l10n/app_zh_Hans.arb | 5 + lib/l10n/app_zh_Hant_HK.arb | 5 + lib/l10n/app_zh_TW.arb | 5 + lib/l10n/gen/app_localizations.dart | 30 ++++ lib/l10n/gen/app_localizations_en.dart | 17 +++ lib/l10n/gen/app_localizations_fil.dart | 18 +++ lib/l10n/gen/app_localizations_id.dart | 18 +++ lib/l10n/gen/app_localizations_ja.dart | 17 +++ lib/l10n/gen/app_localizations_ko.dart | 17 +++ lib/l10n/gen/app_localizations_th.dart | 17 +++ lib/l10n/gen/app_localizations_vi.dart | 18 +++ lib/l10n/gen/app_localizations_yue.dart | 16 +++ lib/l10n/gen/app_localizations_zh.dart | 64 +++++++++ lib/shared/widgets/permission_checklist.dart | 129 +++++++++++++++++- .../urgent_notification_settings_test.dart | 120 ++++++++++++++++ .../widgets/permission_checklist_test.dart | 26 ++++ 28 files changed, 761 insertions(+), 1 deletion(-) create mode 100644 lib/core/notifications/urgent_notification_settings.dart create mode 100644 test/core/notifications/urgent_notification_settings_test.dart diff --git a/android/app/src/main/kotlin/com/exptech/dpip/PermissionSettingsChannel.kt b/android/app/src/main/kotlin/com/exptech/dpip/PermissionSettingsChannel.kt index 6078bed7c..d2f4766cb 100644 --- a/android/app/src/main/kotlin/com/exptech/dpip/PermissionSettingsChannel.kt +++ b/android/app/src/main/kotlin/com/exptech/dpip/PermissionSettingsChannel.kt @@ -1,5 +1,6 @@ package com.exptech.dpip +import android.app.NotificationManager import android.content.Context import android.content.Intent import android.net.Uri @@ -29,10 +30,62 @@ class PermissionSettingsChannel(private val context: Context) : when (call.method) { "backgroundLocationOptionLabel" -> result.success(backgroundLocationOptionLabel()) "openNotificationSettings" -> result.success(openNotificationSettings()) + "urgentNotificationChannelStatus" -> urgentNotificationChannelStatus(call, result) + "openNotificationChannelSettings" -> openNotificationChannelSettings(call, result) else -> result.notImplemented() } } + private fun urgentNotificationChannelStatus(call: MethodCall, result: MethodChannel.Result) { + val channelIds = call.argument>("channelIds") + if (channelIds == null || channelIds.isEmpty() || channelIds.any { it !is String || it.isBlank() }) { + result.error("invalid_channel_ids", "channelIds must be non-empty strings", null) + return + } + val manager = context.getSystemService(NotificationManager::class.java) + val statuses = channelIds.associate { rawId -> + val id = rawId as String + val channel = manager.getNotificationChannel(id) + id to channel?.let { + mapOf( + "id" to it.id, + "name" to it.name.toString(), + "bypassesDnd" to it.canBypassDnd(), + ) + } + } + result.success(statuses) + } + + private fun openNotificationChannelSettings(call: MethodCall, result: MethodChannel.Result) { + val channelId = call.argument("channelId") + if (channelId.isNullOrBlank()) { + result.error("invalid_channel_id", "channelId must be a non-empty string", null) + return + } + val manager = context.getSystemService(NotificationManager::class.java) + if (manager.getNotificationChannel(channelId) == null) { + result.error("unknown_channel", "Notification channel does not exist", null) + return + } + result.success(openNotificationChannelSettings(channelId)) + } + + private fun openNotificationChannelSettings(channelId: String): String { + try { + context.startActivity( + Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS) + .putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) + .putExtra(Settings.EXTRA_CHANNEL_ID, channelId) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), + ) + return "channel" + } catch (e: Exception) { + Log.i(TAG, "channel notification settings unavailable", e) + } + return openNotificationSettings() + } + private fun backgroundLocationOptionLabel(): String? { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return null return context.packageManager.backgroundPermissionOptionLabel.toString() diff --git a/lib/core/notifications/notification_channels.dart b/lib/core/notifications/notification_channels.dart index 5ed6b3ad2..dca214b8e 100644 --- a/lib/core/notifications/notification_channels.dart +++ b/lib/core/notifications/notification_channels.dart @@ -414,6 +414,18 @@ abstract final class NotificationChannels { ), ]; + /// Android channels whose system settings should be reviewed for Do Not + /// Disturb bypass. The two general EEW channels also use `criticalAlerts`, + /// but are not major alerts. + static const Set urgentChannelKeys = { + 'eew_alert-important-v2', + 'eew-important-v2', + 'thunderstorm-important-v2', + 'weather_major-important-v2', + 'evacuation_major-important-v2', + 'tsunami-important-v2', + }; + /// What [channel] will actually do when it fires. /// /// Derived from the definition rather than written out beside it, so a diff --git a/lib/core/notifications/urgent_notification_settings.dart b/lib/core/notifications/urgent_notification_settings.dart new file mode 100644 index 000000000..50f2acaf1 --- /dev/null +++ b/lib/core/notifications/urgent_notification_settings.dart @@ -0,0 +1,125 @@ +/// Reads Android Do Not Disturb bypass status for DPIP's urgent notification +/// channels and opens their system settings. +library; + +import 'package:dpip/core/logging/log.dart'; +import 'package:dpip/core/notifications/notification_channels.dart'; +import 'package:flutter/services.dart'; + +enum UrgentNotificationChannelState { + missing, + doesNotBypass, + bypasses, + unavailable, +} + +class UrgentNotificationChannelStatus { + const UrgentNotificationChannelStatus({ + required this.requestedId, + required this.channelId, + required this.name, + required this.state, + }); + + final String requestedId; + final String? channelId; + final String? name; + final UrgentNotificationChannelState state; +} + +class UrgentNotificationStatus { + const UrgentNotificationStatus(this.channels); + + final List channels; + + bool get allBypass => + channels.isNotEmpty && + channels.every( + (channel) => channel.state == UrgentNotificationChannelState.bypasses, + ); +} + +class UrgentNotificationSettings { + UrgentNotificationSettings([MethodChannel? channel]) + : _channel = + channel ?? + const MethodChannel('com.exptech.dpip/permission_settings'); + + final MethodChannel _channel; + + Future status() async { + final keys = NotificationChannels.urgentChannelKeys.toList(growable: false); + try { + final response = await _channel.invokeMapMethod( + 'urgentNotificationChannelStatus', + {'channelIds': keys}, + ); + return UrgentNotificationStatus([ + for (final key in keys) _parse(key, response?[key]), + ]); + } on PlatformException catch (error, stackTrace) { + Log.handle(error, stackTrace, 'urgent notification channel status'); + return _unavailable(keys); + } on MissingPluginException { + return _unavailable(keys); + } + } + + Future openChannelSettings(String channelId) async { + try { + return await _channel.invokeMethod( + 'openNotificationChannelSettings', + {'channelId': channelId}, + ) ?? + 'none'; + } on PlatformException catch (error, stackTrace) { + Log.handle(error, stackTrace, 'urgent notification channel settings'); + return 'none'; + } on MissingPluginException { + return 'none'; + } + } + + UrgentNotificationChannelStatus _parse(String key, Object? value) { + if (value == null) { + return UrgentNotificationChannelStatus( + requestedId: key, + channelId: null, + name: null, + state: UrgentNotificationChannelState.missing, + ); + } + if (value is! Map) return _unavailableChannel(key); + final id = value['id']; + final name = value['name']; + final bypasses = value['bypassesDnd']; + if (id is! String || + id.isEmpty || + name is! String || + name.isEmpty || + bypasses is! bool) { + return _unavailableChannel(key); + } + return UrgentNotificationChannelStatus( + requestedId: key, + channelId: id, + name: name, + state: bypasses + ? UrgentNotificationChannelState.bypasses + : UrgentNotificationChannelState.doesNotBypass, + ); + } + + UrgentNotificationStatus _unavailable(List keys) => + UrgentNotificationStatus([ + for (final key in keys) _unavailableChannel(key), + ]); + + UrgentNotificationChannelStatus _unavailableChannel(String key) => + UrgentNotificationChannelStatus( + requestedId: key, + channelId: null, + name: null, + state: UrgentNotificationChannelState.unavailable, + ); +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index c7912fde4..5e857dcde 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -2345,6 +2345,16 @@ }, "reportFilterTitle": "Filters", "onboardingPermCritical": "Critical alerts", + "onboardingPermUrgentAndroid": "Major alerts in Do Not Disturb", + "@onboardingPermUrgentAndroid": {"description": "Android permission-check row for urgent notification channels"}, + "onboardingPermUrgentAndroidDesc": "Choose which major-alert channels Android may allow during Do Not Disturb. Silent mode, volume, and the active Mode still apply.", + "@onboardingPermUrgentAndroidDesc": {"description": "Limitations of Android urgent notification channel settings"}, + "permissionGuideUrgentAndroid": "In the channel settings, choose whether this notification may interrupt Do Not Disturb, then return to DPIP.", + "@permissionGuideUrgentAndroid": {"description": "Instruction before opening one Android notification channel"}, + "urgentNotificationBypassesDnd": "Allowed during Do Not Disturb", + "@urgentNotificationBypassesDnd": {"description": "Android notification channel currently bypasses Do Not Disturb"}, + "urgentNotificationFollowsDnd": "Follows Do Not Disturb", + "@urgentNotificationFollowsDnd": {"description": "Android notification channel currently follows Do Not Disturb"}, "trendCumulativeTotal": "Cumulative {total} mm", "languageName": "English", "@mapLayerStyleJma": { diff --git a/lib/l10n/app_fil.arb b/lib/l10n/app_fil.arb index b244780b6..bf3c54ccb 100644 --- a/lib/l10n/app_fil.arb +++ b/lib/l10n/app_fil.arb @@ -855,6 +855,11 @@ "onboardingTermsBody": "Mangyaring basahin ang mga sumusunod na paunawa bago gamitin ang DPIP:\n\n• Ang lahat ng impormasyon ay dapat sumunod sa nilalamang inilathala ng Central Weather Administration (CWA).\n\n• Depende sa kalagayan ng network, server, app, at pinagmumulan ng datos, may posibilidad na hindi matanggap ang impormasyon; ginagawa namin ang lahat ng aming makakaya upang maiwasan ito ngunit hindi namin magagarantiya na hindi ito mangyayari.\n\n• Maaaring maunang makarating sa iyong lokasyon ang malakas na pagyanig bago pa dumating ang notipikasyon.\n\n• Ang mga maagang babala sa lindol ay mabilis na kinakalkulang resulta na maaaring magtaglay ng malaking pagkakamali — unawain ito at gamitin nang may pag-iingat.\n\n• Anumang gawaing hindi pinahihintulutan ng mga awtoridad ay maaaring magdala ng panganib sa batas; mangyaring sundin ang lahat ng naaangkop na regulasyon.\n\nBukod dito, upang magbigay ng lokal na mga alerto, kinokolekta at ini-upload ng serbisyong ito ang iyong tinatayang lokasyon at push identifier — sa foreground at background — para lamang matukoy kung aling mga alerto ang ipapadala sa iyo.\n\nSa pamamagitan ng pag-tap sa \"Sumang-ayon at magpatuloy\" kinukumpirma mo na nabasa, naunawaan, at sinasang-ayunan mo ang nasa itaas.", "reportFilterTitle": "Mga filter", "onboardingPermCritical": "Mga kritikal na alerto", + "onboardingPermUrgentAndroid": "Mahahalagang alerto sa Do Not Disturb", + "onboardingPermUrgentAndroidDesc": "Suriin ang bawat mahalagang channel para payagan ito ng Android sa Do Not Disturb. Nalalapat pa rin ang silent mode, volume, at kasalukuyang Mode.", + "permissionGuideUrgentAndroid": "Sa channel settings, piliin kung maaaring gambalain ng notification na ito ang Do Not Disturb, pagkatapos ay bumalik sa DPIP.", + "urgentNotificationBypassesDnd": "Pinapayagan sa Do Not Disturb", + "urgentNotificationFollowsDnd": "Sumusunod sa Do Not Disturb", "trendCumulativeTotal": "Kabuuang {total} mm", "languageName": "Filipino", "reportListEmptyFiltered": "Walang ulat na tumutugma sa mga filter", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index bdbda2370..d4149d8a1 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -855,6 +855,11 @@ "onboardingTermsBody": "Harap baca pemberitahuan berikut sebelum menggunakan DPIP:\n\n• Semua informasi harus mengacu pada konten yang diterbitkan oleh Central Weather Administration (CWA) Taiwan.\n\n• Bergantung pada kondisi jaringan, server, aplikasi, dan sumber data hulu, informasi mungkin tidak diterima; kami berupaya sebaik mungkin untuk menghindari hal ini tetapi tidak dapat menjamin bahwa hal itu tidak akan pernah terjadi.\n\n• Guncangan kuat dapat mencapai lokasi Anda sebelum notifikasi tiba.\n\n• Peringatan dini gempa adalah hasil perhitungan cepat yang mungkin mengandung kesalahan yang signifikan — pahami hal ini dan gunakan dengan hati-hati.\n\n• Setiap tindakan yang tidak disahkan oleh pihak berwenang dapat menimbulkan risiko hukum; harap patuhi semua peraturan yang berlaku.\n\nSelain itu, untuk menyediakan peringatan yang dilokalkan, layanan ini mengumpulkan dan mengunggah perkiraan lokasi Anda dan pengidentifikasi push — di latar depan maupun latar belakang — semata-mata untuk menentukan peringatan mana yang akan dikirimkan kepada Anda.\n\nDengan mengetuk \"Setuju dan lanjutkan\", Anda mengonfirmasi bahwa Anda telah membaca, memahami, dan menyetujui hal-hal di atas.", "reportFilterTitle": "Filter", "onboardingPermCritical": "Peringatan kritis", + "onboardingPermUrgentAndroid": "Peringatan penting saat Jangan Ganggu", + "onboardingPermUrgentAndroidDesc": "Periksa setiap saluran peringatan penting agar Android dapat mengizinkannya saat Jangan Ganggu. Mode senyap, volume, dan Mode aktif tetap berlaku.", + "permissionGuideUrgentAndroid": "Di setelan saluran, pilih apakah notifikasi ini boleh menginterupsi Jangan Ganggu, lalu kembali ke DPIP.", + "urgentNotificationBypassesDnd": "Diizinkan saat Jangan Ganggu", + "urgentNotificationFollowsDnd": "Mengikuti Jangan Ganggu", "trendCumulativeTotal": "Total {total} mm", "languageName": "Bahasa Indonesia", "reportListEmptyFiltered": "Tidak ada laporan yang cocok dengan filter", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 9426e3a4d..6857a1dac 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -855,6 +855,11 @@ "onboardingTermsBody": "DPIP をご利用になる前に、以下の注意事項を必ずお読みください:\n\n• すべての情報は、台湾中央気象署(CWA)が発表する内容を優先してください。\n\n• ネットワーク、サーバー、アプリ、上流のデータソースの状態によっては、情報を受信できない場合があります。可能な限り回避に努めますが、決して発生しないことを保証するものではありません。\n\n• 強い揺れが、通知より先にあなたの所在地へ到達する場合があります。\n\n• 緊急地震速報は高速に計算された結果であり、大きな誤差を含む可能性があります。この点を理解したうえで、慎重にご利用ください。\n\n• 公的機関に認められていない行為には法的リスクが伴う可能性があります。関連する規定を必ずお守りください。\n\nまた、地域に応じた警報を提供するため、本サービスは、どの警報をあなたに送信するかを判断する目的にのみ、あなたのおおよその位置情報とプッシュ識別子を、フォアグラウンドおよびバックグラウンドで収集・アップロードします。\n\n下部の「同意して続行」をタップすることで、上記を読み、理解し、同意したものとみなされます。", "reportFilterTitle": "絞り込み", "onboardingPermCritical": "重大な通知", + "onboardingPermUrgentAndroid": "おやすみモード中の重大通知", + "onboardingPermUrgentAndroidDesc": "重大通知の各チャンネルを確認し、Android でおやすみモード中の通知を許可します。消音、音量、現在のモード設定は引き続き適用されます。", + "permissionGuideUrgentAndroid": "チャンネル設定で、この通知がおやすみモードを中断できるかを選択してから、DPIP に戻ってください。", + "urgentNotificationBypassesDnd": "おやすみモード中も許可", + "urgentNotificationFollowsDnd": "おやすみモードに従う", "trendCumulativeTotal": "累計 {total} mm", "languageName": "日本語", "reportListEmptyFiltered": "条件に一致する地震報告はありません", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index e56868936..d7e3f8757 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -855,6 +855,11 @@ "onboardingTermsBody": "DPIP를 사용하기 전에 다음 유의 사항을 반드시 읽어 주세요:\n\n• 모든 정보는 중앙기상청(CWA)에서 발표한 내용을 기준으로 합니다.\n\n• 네트워크, 서버, 애플리케이션, 상위 데이터 출처의 상태에 따라 정보를 받지 못할 수 있습니다. 이러한 상황을 방지하기 위해 최선을 다하고 있으나, 절대 발생하지 않는다고 보장할 수는 없습니다.\n\n• 강한 흔들림이 알림보다 먼저 귀하의 위치에 도달할 수 있습니다.\n\n• 지진 조기경보는 빠르게 계산된 결과로 상당한 오차가 있을 수 있으므로, 이를 이해하고 신중하게 사용하시기 바랍니다.\n\n• 당국이 승인하지 않은 모든 행위는 법적 위험을 수반할 수 있으니, 관련 규정을 반드시 준수해 주세요.\n\n또한 지역 맞춤형 경보를 제공하기 위해, 본 서비스는 귀하에게 어떤 경보를 보낼지 결정하기 위한 목적으로만 포그라운드 및 백그라운드에서 귀하의 대략적인 위치와 푸시 식별자를 수집하여 업로드합니다.\n\n하단의 “동의하고 계속”을 누르면 위 사항을 읽고 이해했으며 이에 동의함을 확인하는 것입니다.", "reportFilterTitle": "필터", "onboardingPermCritical": "중요 알림", + "onboardingPermUrgentAndroid": "방해 금지 모드의 중요 알림", + "onboardingPermUrgentAndroidDesc": "각 중요 알림 채널을 확인하여 Android가 방해 금지 모드에서도 허용하도록 설정하세요. 무음, 음량 및 현재 모드 설정은 계속 적용됩니다.", + "permissionGuideUrgentAndroid": "채널 설정에서 이 알림이 방해 금지 모드를 중단할 수 있는지 선택한 후 DPIP로 돌아오세요.", + "urgentNotificationBypassesDnd": "방해 금지 모드에서도 허용", + "urgentNotificationFollowsDnd": "방해 금지 모드 따름", "trendCumulativeTotal": "누적 {total} mm", "languageName": "한국어", "reportListEmptyFiltered": "조건에 맞는 지진 보고서가 없습니다", diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 8a4728e2f..4361b979f 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -855,6 +855,11 @@ "onboardingTermsBody": "โปรดอ่านข้อควรทราบต่อไปนี้ก่อนใช้งาน DPIP:\n\n• ข้อมูลทั้งหมดควรยึดตามเนื้อหาที่เผยแพร่โดยกรมอุตุนิยมวิทยากลาง (CWA) เป็นหลัก\n\n• ขึ้นอยู่กับสภาพเครือข่าย เซิร์ฟเวอร์ แอปพลิเคชัน และแหล่งข้อมูลต้นทาง อาจมีความเป็นไปได้ที่จะไม่ได้รับข้อมูล เราพยายามอย่างเต็มที่เพื่อหลีกเลี่ยงกรณีเช่นนี้ แต่ไม่สามารถรับประกันได้ว่าจะไม่เกิดขึ้น\n\n• การสั่นสะเทือนอย่างรุนแรงอาจมาถึงตำแหน่งของคุณก่อนการแจ้งเตือน\n\n• การเตือนแผ่นดินไหวล่วงหน้าเป็นผลจากการคำนวณอย่างรวดเร็ว ซึ่งอาจมีความคลาดเคลื่อนสูง โปรดทำความเข้าใจและใช้งานด้วยความระมัดระวัง\n\n• พฤติกรรมใด ๆ ที่ไม่ได้รับการรับรองจากหน่วยงานราชการอาจมีความเสี่ยงทางกฎหมาย โปรดปฏิบัติตามระเบียบที่เกี่ยวข้องทั้งหมด\n\nนอกจากนี้ เพื่อให้บริการการเตือนภัยเฉพาะพื้นที่ บริการนี้จะเก็บรวบรวมและอัปโหลดตำแหน่งโดยประมาณและตัวระบุการแจ้งเตือนแบบพุชของคุณ — ทั้งขณะทำงานเบื้องหน้าและเบื้องหลัง — เพื่อใช้ตัดสินว่าจะส่งการเตือนใดให้คุณเท่านั้น\n\nการแตะ \"ยอมรับและดำเนินการต่อ\" ถือว่าคุณได้อ่าน เข้าใจ และยอมรับข้อความข้างต้นแล้ว", "reportFilterTitle": "ตัวกรอง", "onboardingPermCritical": "การแจ้งเตือนสำคัญ", + "onboardingPermUrgentAndroid": "การแจ้งเตือนสำคัญในโหมดห้ามรบกวน", + "onboardingPermUrgentAndroidDesc": "ตรวจสอบแต่ละช่องการแจ้งเตือนสำคัญเพื่อให้ Android อนุญาตในโหมดห้ามรบกวน โหมดเงียบ ระดับเสียง และโหมดปัจจุบันยังคงมีผล", + "permissionGuideUrgentAndroid": "ในการตั้งค่าช่อง ให้เลือกว่าจะอนุญาตให้การแจ้งเตือนนี้ขัดจังหวะโหมดห้ามรบกวนหรือไม่ แล้วกลับมาที่ DPIP", + "urgentNotificationBypassesDnd": "อนุญาตระหว่างโหมดห้ามรบกวน", + "urgentNotificationFollowsDnd": "ทำตามโหมดห้ามรบกวน", "trendCumulativeTotal": "สะสม {total} มม.", "languageName": "ไทย", "reportListEmptyFiltered": "ไม่มีรายงานที่ตรงกับเงื่อนไข", diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 5698698dd..4626fea5b 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -855,6 +855,11 @@ "onboardingTermsBody": "Vui lòng đọc kỹ các lưu ý sau đây trước khi sử dụng DPIP:\n\n• Mọi thông tin phải căn cứ theo nội dung do Cục Khí tượng Trung ương Đài Loan (CWA) công bố.\n\n• Tùy thuộc vào tình trạng mạng, máy chủ, ứng dụng và nguồn dữ liệu đầu nguồn, có khả năng không nhận được thông tin; chúng tôi nỗ lực hết sức để tránh điều này nhưng không thể bảo đảm rằng nó không bao giờ xảy ra.\n\n• Rung lắc mạnh có thể lan đến vị trí của bạn trước khi thông báo được gửi tới.\n\n• Cảnh báo sớm động đất là kết quả được tính toán nhanh nên có thể chứa sai số đáng kể — hãy hiểu rõ điều này và sử dụng một cách thận trọng.\n\n• Bất kỳ hành vi nào không được cơ quan chức năng cho phép đều có thể mang rủi ro pháp lý; vui lòng tuân thủ mọi quy định hiện hành.\n\nNgoài ra, để cung cấp cảnh báo theo khu vực, dịch vụ này thu thập và tải lên vị trí gần đúng cùng mã định danh thông báo đẩy của bạn — cả ở nền trước lẫn nền sau — chỉ nhằm quyết định những cảnh báo nào sẽ gửi cho bạn.\n\nBằng việc nhấn \"Đồng ý và tiếp tục\", bạn xác nhận rằng đã đọc, hiểu và đồng ý với những điều trên.", "reportFilterTitle": "Bộ lọc", "onboardingPermCritical": "Cảnh báo quan trọng", + "onboardingPermUrgentAndroid": "Cảnh báo quan trọng trong Không làm phiền", + "onboardingPermUrgentAndroidDesc": "Kiểm tra từng kênh cảnh báo quan trọng để Android có thể cho phép trong chế độ Không làm phiền. Chế độ im lặng, âm lượng và Chế độ hiện tại vẫn áp dụng.", + "permissionGuideUrgentAndroid": "Trong cài đặt kênh, chọn xem thông báo này có thể làm gián đoạn chế độ Không làm phiền hay không, rồi quay lại DPIP.", + "urgentNotificationBypassesDnd": "Được phép trong Không làm phiền", + "urgentNotificationFollowsDnd": "Tuân theo Không làm phiền", "trendCumulativeTotal": "Tổng cộng {total} mm", "languageName": "Tiếng Việt", "reportListEmptyFiltered": "Không có báo cáo khớp bộ lọc", diff --git a/lib/l10n/app_yue.arb b/lib/l10n/app_yue.arb index a53cedaf4..dcb4d3935 100644 --- a/lib/l10n/app_yue.arb +++ b/lib/l10n/app_yue.arb @@ -856,6 +856,11 @@ "onboardingTermsBody": "使用 DPIP 前,請詳閱以下注意事項:\n\n• 任何資訊應以中央氣象署發布嘅內容為準。\n\n• 根據網絡狀態、伺服器狀態、應用程式狀態、上游資料來源狀態等,有收唔到資訊嘅可能性,我哋會盡力避免此類情況,但唔保證一定唔會發生。\n\n• 強烈搖晃有機會早過通知到達用戶所在地。\n\n• 地震速報係快速計算嘅結果,可能存在較大誤差,應該理解並謹慎使用。\n\n• 任何唔受官方認可嘅行為均有可能承擔法律風險,請務必遵守相關規範。\n\n此外,為提供本地化警報,本服務會喺前景及背景收集並上傳你嘅概略位置同裝置推送識別碼,僅用嚟決定應向你推送嘅警報。\n\n㩒下方「同意並繼續」就表示你已閱讀、理解並同意上述事項。", "reportFilterTitle": "篩選", "onboardingPermCritical": "重大通知", + "onboardingPermUrgentAndroid": "勿擾模式下嘅重大通知", + "onboardingPermUrgentAndroidDesc": "按需要選擇可喺勿擾模式期間發出嘅重大通知。靜音、音量同目前模式仍然會影響聲音。", + "permissionGuideUrgentAndroid": "請喺頻道設定選擇係咪允許呢個通知打斷勿擾模式,再返到 DPIP。", + "urgentNotificationBypassesDnd": "勿擾模式期間仍允許通知", + "urgentNotificationFollowsDnd": "跟隨勿擾模式", "trendCumulativeTotal": "累計 {total} mm", "reportListEmptyFiltered": "冇符合條件嘅地震報告", "meshtasticExcludeMqtt": "隱藏 MQTT 節點", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 96de7d2d4..6dfd2a428 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -855,6 +855,11 @@ "onboardingTermsBody": "使用 DPIP 前,請詳閱以下注意事項:\n\n• 任何資訊應以中央氣象署發布之內容為準。\n\n• 根據網路狀態、伺服器狀態、應用程式狀態、上游資料來源狀態等,有收不到資訊的可能性,我們會盡力避免此類情況,但不保證一定不會發生。\n\n• 強烈搖晃有機率比通知早抵達使用者所在地。\n\n• 地震速報為快速計算之結果,可能存在較大誤差,應理解並謹慎使用。\n\n• 任何不被官方所認可的行為均有可能承擔法律風險,請務必遵守相關規範。\n\n此外,為提供在地化警報,本服務會在前景及背景蒐集並上傳您的概略位置與裝置推播識別碼,僅用於決定應向您推送之警報。\n\n點選下方「同意並繼續」即表示您已閱讀、理解並同意上述事項。", "reportFilterTitle": "篩選", "onboardingPermCritical": "重大通知", + "onboardingPermUrgentAndroid": "勿擾模式下的重大通知", + "onboardingPermUrgentAndroidDesc": "依需求選擇可在勿擾模式期間發出的重大通知。靜音、音量及目前模式仍會影響聲音。", + "permissionGuideUrgentAndroid": "請在頻道設定中選擇是否允許此通知打斷勿擾模式,再返回 DPIP。", + "urgentNotificationBypassesDnd": "勿擾模式期間仍允許通知", + "urgentNotificationFollowsDnd": "遵循勿擾模式", "trendCumulativeTotal": "累計 {total} mm", "languageName": "繁體中文(臺灣)", "reportListEmptyFiltered": "沒有符合條件的地震報告", diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index e9b67118e..028867cd5 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -855,6 +855,11 @@ "onboardingTermsBody": "使用 DPIP 前,请详细阅读以下注意事项:\n\n• 任何信息均应以中央气象署(CWA)发布的内容为准。\n\n• 受网络状态、服务器状态、应用程序状态、上游数据来源状态等因素影响,存在收不到信息的可能,我们会尽力避免此类情况,但不保证一定不会发生。\n\n• 强烈震动有可能比通知更早抵达您所在的位置。\n\n• 地震预警为快速计算的结果,可能存在较大误差,请理解并谨慎使用。\n\n• 任何未获官方认可的行为均可能承担法律风险,请务必遵守相关规定。\n\n此外,为提供本地化预警,本服务会在前台及后台收集并上传您的大致位置与设备推送标识符,仅用于决定应向您推送哪些预警。\n\n点击下方“同意并继续”即表示您已阅读、理解并同意上述事项。", "reportFilterTitle": "筛选", "onboardingPermCritical": "重要警告", + "onboardingPermUrgentAndroid": "勿扰模式下的重要通知", + "onboardingPermUrgentAndroidDesc": "按需选择可在勿扰模式期间发出的重要通知。静音、音量及当前模式仍会影响声音。", + "permissionGuideUrgentAndroid": "请在频道设置中选择是否允许此通知打断勿扰模式,再返回 DPIP。", + "urgentNotificationBypassesDnd": "勿扰模式期间仍允许通知", + "urgentNotificationFollowsDnd": "遵循勿扰模式", "trendCumulativeTotal": "累计 {total} mm", "languageName": "简体中文", "reportListEmptyFiltered": "没有符合条件的地震报告", diff --git a/lib/l10n/app_zh_Hant_HK.arb b/lib/l10n/app_zh_Hant_HK.arb index a40ea0a62..5e6bb2a44 100644 --- a/lib/l10n/app_zh_Hant_HK.arb +++ b/lib/l10n/app_zh_Hant_HK.arb @@ -855,6 +855,11 @@ "onboardingTermsBody": "使用 DPIP 前,請詳閱以下注意事項:\n\n• 任何資訊應以中央氣象署發布之內容為準。\n\n• 根據網絡狀態、伺服器狀態、應用程式狀態、上游資料來源狀態等,有收不到資訊的可能性,我們會盡力避免此類情況,但不保證一定不會發生。\n\n• 強烈搖晃有機會比通知早抵達用戶所在地。\n\n• 地震速報為快速計算之結果,可能存在較大誤差,應理解並謹慎使用。\n\n• 任何不被官方所認可的行為均有可能承擔法律風險,請務必遵守相關規範。\n\n此外,為提供本地化警報,本服務會在前景及背景收集並上傳您的概略位置與裝置推送識別碼,僅用於決定應向您推送之警報。\n\n點按下方「同意並繼續」即表示您已閱讀、理解並同意上述事項。", "reportFilterTitle": "篩選", "onboardingPermCritical": "重大通知", + "onboardingPermUrgentAndroid": "勿擾模式下的重大通知", + "onboardingPermUrgentAndroidDesc": "依需要選擇可在勿擾模式期間發出的重大通知。靜音、音量及目前模式仍會影響聲音。", + "permissionGuideUrgentAndroid": "請在頻道設定中選擇是否允許此通知打斷勿擾模式,再返回 DPIP。", + "urgentNotificationBypassesDnd": "勿擾模式期間仍允許通知", + "urgentNotificationFollowsDnd": "遵循勿擾模式", "trendCumulativeTotal": "累計 {total} mm", "languageName": "繁體中文(香港)", "reportListEmptyFiltered": "沒有符合條件的地震報告", diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index 7d7b8fb83..a538815a9 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -855,6 +855,11 @@ "onboardingTermsBody": "使用 DPIP 前,請詳閱以下注意事項:\n\n• 任何資訊應以中央氣象署發布之內容為準。\n\n• 根據網路狀態、伺服器狀態、應用程式狀態、上游資料來源狀態等,有收不到資訊的可能性,我們會盡力避免此類情況,但不保證一定不會發生。\n\n• 強烈搖晃有機率比通知早抵達使用者所在地。\n\n• 地震速報為快速計算之結果,可能存在較大誤差,應理解並謹慎使用。\n\n• 任何不被官方所認可的行為均有可能承擔法律風險,請務必遵守相關規範。\n\n此外,為提供在地化警報,本服務會在前景及背景蒐集並上傳您的概略位置與裝置推播識別碼,僅用於決定應向您推送之警報。\n\n點選下方「同意並繼續」即表示您已閱讀、理解並同意上述事項。", "reportFilterTitle": "篩選", "onboardingPermCritical": "重大通知", + "onboardingPermUrgentAndroid": "勿擾模式下的重大通知", + "onboardingPermUrgentAndroidDesc": "依需求選擇可在勿擾模式期間發出的重大通知。靜音、音量及目前模式仍會影響聲音。", + "permissionGuideUrgentAndroid": "請在頻道設定中選擇是否允許此通知打斷勿擾模式,再返回 DPIP。", + "urgentNotificationBypassesDnd": "勿擾模式期間仍允許通知", + "urgentNotificationFollowsDnd": "遵循勿擾模式", "trendCumulativeTotal": "累計 {total} mm", "languageName": "繁體中文(臺灣)", "reportListEmptyFiltered": "沒有符合條件的地震報告", diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index e84d556b5..e9c2ed856 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -3335,6 +3335,36 @@ abstract class AppLocalizations { /// **'Critical alerts'** String get onboardingPermCritical; + /// Android permission-check row for urgent notification channels + /// + /// In en, this message translates to: + /// **'Major alerts in Do Not Disturb'** + String get onboardingPermUrgentAndroid; + + /// Limitations of Android urgent notification channel settings + /// + /// In en, this message translates to: + /// **'Choose which major-alert channels Android may allow during Do Not Disturb. Silent mode, volume, and the active Mode still apply.'** + String get onboardingPermUrgentAndroidDesc; + + /// Instruction before opening one Android notification channel + /// + /// In en, this message translates to: + /// **'In the channel settings, choose whether this notification may interrupt Do Not Disturb, then return to DPIP.'** + String get permissionGuideUrgentAndroid; + + /// Android notification channel currently bypasses Do Not Disturb + /// + /// In en, this message translates to: + /// **'Allowed during Do Not Disturb'** + String get urgentNotificationBypassesDnd; + + /// Android notification channel currently follows Do Not Disturb + /// + /// In en, this message translates to: + /// **'Follows Do Not Disturb'** + String get urgentNotificationFollowsDnd; + /// Running total label above the cumulative station rain trend chart /// /// In en, this message translates to: diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index 27f27274b..41034f6e2 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -1735,6 +1735,23 @@ class AppLocalizationsEn extends AppLocalizations { @override String get onboardingPermCritical => 'Critical alerts'; + @override + String get onboardingPermUrgentAndroid => 'Major alerts in Do Not Disturb'; + + @override + String get onboardingPermUrgentAndroidDesc => + 'Choose which major-alert channels Android may allow during Do Not Disturb. Silent mode, volume, and the active Mode still apply.'; + + @override + String get permissionGuideUrgentAndroid => + 'In the channel settings, choose whether this notification may interrupt Do Not Disturb, then return to DPIP.'; + + @override + String get urgentNotificationBypassesDnd => 'Allowed during Do Not Disturb'; + + @override + String get urgentNotificationFollowsDnd => 'Follows Do Not Disturb'; + @override String trendCumulativeTotal(String total) { return 'Cumulative $total mm'; diff --git a/lib/l10n/gen/app_localizations_fil.dart b/lib/l10n/gen/app_localizations_fil.dart index 14641265f..f7bcbf218 100644 --- a/lib/l10n/gen/app_localizations_fil.dart +++ b/lib/l10n/gen/app_localizations_fil.dart @@ -1745,6 +1745,24 @@ class AppLocalizationsFil extends AppLocalizations { @override String get onboardingPermCritical => 'Mga kritikal na alerto'; + @override + String get onboardingPermUrgentAndroid => + 'Mahahalagang alerto sa Do Not Disturb'; + + @override + String get onboardingPermUrgentAndroidDesc => + 'Suriin ang bawat mahalagang channel para payagan ito ng Android sa Do Not Disturb. Nalalapat pa rin ang silent mode, volume, at kasalukuyang Mode.'; + + @override + String get permissionGuideUrgentAndroid => + 'Sa channel settings, piliin kung maaaring gambalain ng notification na ito ang Do Not Disturb, pagkatapos ay bumalik sa DPIP.'; + + @override + String get urgentNotificationBypassesDnd => 'Pinapayagan sa Do Not Disturb'; + + @override + String get urgentNotificationFollowsDnd => 'Sumusunod sa Do Not Disturb'; + @override String trendCumulativeTotal(String total) { return 'Kabuuang $total mm'; diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index f23cb4178..081d5cd0e 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -1739,6 +1739,24 @@ class AppLocalizationsId extends AppLocalizations { @override String get onboardingPermCritical => 'Peringatan kritis'; + @override + String get onboardingPermUrgentAndroid => + 'Peringatan penting saat Jangan Ganggu'; + + @override + String get onboardingPermUrgentAndroidDesc => + 'Periksa setiap saluran peringatan penting agar Android dapat mengizinkannya saat Jangan Ganggu. Mode senyap, volume, dan Mode aktif tetap berlaku.'; + + @override + String get permissionGuideUrgentAndroid => + 'Di setelan saluran, pilih apakah notifikasi ini boleh menginterupsi Jangan Ganggu, lalu kembali ke DPIP.'; + + @override + String get urgentNotificationBypassesDnd => 'Diizinkan saat Jangan Ganggu'; + + @override + String get urgentNotificationFollowsDnd => 'Mengikuti Jangan Ganggu'; + @override String trendCumulativeTotal(String total) { return 'Total $total mm'; diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index 49593f6de..abdaa8fdf 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -1706,6 +1706,23 @@ class AppLocalizationsJa extends AppLocalizations { @override String get onboardingPermCritical => '重大な通知'; + @override + String get onboardingPermUrgentAndroid => 'おやすみモード中の重大通知'; + + @override + String get onboardingPermUrgentAndroidDesc => + '重大通知の各チャンネルを確認し、Android でおやすみモード中の通知を許可します。消音、音量、現在のモード設定は引き続き適用されます。'; + + @override + String get permissionGuideUrgentAndroid => + 'チャンネル設定で、この通知がおやすみモードを中断できるかを選択してから、DPIP に戻ってください。'; + + @override + String get urgentNotificationBypassesDnd => 'おやすみモード中も許可'; + + @override + String get urgentNotificationFollowsDnd => 'おやすみモードに従う'; + @override String trendCumulativeTotal(String total) { return '累計 $total mm'; diff --git a/lib/l10n/gen/app_localizations_ko.dart b/lib/l10n/gen/app_localizations_ko.dart index e440e530d..e0df8c264 100644 --- a/lib/l10n/gen/app_localizations_ko.dart +++ b/lib/l10n/gen/app_localizations_ko.dart @@ -1706,6 +1706,23 @@ class AppLocalizationsKo extends AppLocalizations { @override String get onboardingPermCritical => '중요 알림'; + @override + String get onboardingPermUrgentAndroid => '방해 금지 모드의 중요 알림'; + + @override + String get onboardingPermUrgentAndroidDesc => + '각 중요 알림 채널을 확인하여 Android가 방해 금지 모드에서도 허용하도록 설정하세요. 무음, 음량 및 현재 모드 설정은 계속 적용됩니다.'; + + @override + String get permissionGuideUrgentAndroid => + '채널 설정에서 이 알림이 방해 금지 모드를 중단할 수 있는지 선택한 후 DPIP로 돌아오세요.'; + + @override + String get urgentNotificationBypassesDnd => '방해 금지 모드에서도 허용'; + + @override + String get urgentNotificationFollowsDnd => '방해 금지 모드 따름'; + @override String trendCumulativeTotal(String total) { return '누적 $total mm'; diff --git a/lib/l10n/gen/app_localizations_th.dart b/lib/l10n/gen/app_localizations_th.dart index 869d683cd..8cc7ec74a 100644 --- a/lib/l10n/gen/app_localizations_th.dart +++ b/lib/l10n/gen/app_localizations_th.dart @@ -1732,6 +1732,23 @@ class AppLocalizationsTh extends AppLocalizations { @override String get onboardingPermCritical => 'การแจ้งเตือนสำคัญ'; + @override + String get onboardingPermUrgentAndroid => 'การแจ้งเตือนสำคัญในโหมดห้ามรบกวน'; + + @override + String get onboardingPermUrgentAndroidDesc => + 'ตรวจสอบแต่ละช่องการแจ้งเตือนสำคัญเพื่อให้ Android อนุญาตในโหมดห้ามรบกวน โหมดเงียบ ระดับเสียง และโหมดปัจจุบันยังคงมีผล'; + + @override + String get permissionGuideUrgentAndroid => + 'ในการตั้งค่าช่อง ให้เลือกว่าจะอนุญาตให้การแจ้งเตือนนี้ขัดจังหวะโหมดห้ามรบกวนหรือไม่ แล้วกลับมาที่ DPIP'; + + @override + String get urgentNotificationBypassesDnd => 'อนุญาตระหว่างโหมดห้ามรบกวน'; + + @override + String get urgentNotificationFollowsDnd => 'ทำตามโหมดห้ามรบกวน'; + @override String trendCumulativeTotal(String total) { return 'สะสม $total มม.'; diff --git a/lib/l10n/gen/app_localizations_vi.dart b/lib/l10n/gen/app_localizations_vi.dart index 0e7afe520..3a5330747 100644 --- a/lib/l10n/gen/app_localizations_vi.dart +++ b/lib/l10n/gen/app_localizations_vi.dart @@ -1736,6 +1736,24 @@ class AppLocalizationsVi extends AppLocalizations { @override String get onboardingPermCritical => 'Cảnh báo quan trọng'; + @override + String get onboardingPermUrgentAndroid => + 'Cảnh báo quan trọng trong Không làm phiền'; + + @override + String get onboardingPermUrgentAndroidDesc => + 'Kiểm tra từng kênh cảnh báo quan trọng để Android có thể cho phép trong chế độ Không làm phiền. Chế độ im lặng, âm lượng và Chế độ hiện tại vẫn áp dụng.'; + + @override + String get permissionGuideUrgentAndroid => + 'Trong cài đặt kênh, chọn xem thông báo này có thể làm gián đoạn chế độ Không làm phiền hay không, rồi quay lại DPIP.'; + + @override + String get urgentNotificationBypassesDnd => 'Được phép trong Không làm phiền'; + + @override + String get urgentNotificationFollowsDnd => 'Tuân theo Không làm phiền'; + @override String trendCumulativeTotal(String total) { return 'Tổng cộng $total mm'; diff --git a/lib/l10n/gen/app_localizations_yue.dart b/lib/l10n/gen/app_localizations_yue.dart index 14b2b0f57..dd495a019 100644 --- a/lib/l10n/gen/app_localizations_yue.dart +++ b/lib/l10n/gen/app_localizations_yue.dart @@ -1695,6 +1695,22 @@ class AppLocalizationsYue extends AppLocalizations { @override String get onboardingPermCritical => '重大通知'; + @override + String get onboardingPermUrgentAndroid => '勿擾模式下嘅重大通知'; + + @override + String get onboardingPermUrgentAndroidDesc => + '按需要選擇可喺勿擾模式期間發出嘅重大通知。靜音、音量同目前模式仍然會影響聲音。'; + + @override + String get permissionGuideUrgentAndroid => '請喺頻道設定選擇係咪允許呢個通知打斷勿擾模式,再返到 DPIP。'; + + @override + String get urgentNotificationBypassesDnd => '勿擾模式期間仍允許通知'; + + @override + String get urgentNotificationFollowsDnd => '跟隨勿擾模式'; + @override String trendCumulativeTotal(String total) { return '累計 $total mm'; diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index a15f4b666..73d98993a 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -1695,6 +1695,22 @@ class AppLocalizationsZh extends AppLocalizations { @override String get onboardingPermCritical => '重大通知'; + @override + String get onboardingPermUrgentAndroid => '勿擾模式下的重大通知'; + + @override + String get onboardingPermUrgentAndroidDesc => + '依需求選擇可在勿擾模式期間發出的重大通知。靜音、音量及目前模式仍會影響聲音。'; + + @override + String get permissionGuideUrgentAndroid => '請在頻道設定中選擇是否允許此通知打斷勿擾模式,再返回 DPIP。'; + + @override + String get urgentNotificationBypassesDnd => '勿擾模式期間仍允許通知'; + + @override + String get urgentNotificationFollowsDnd => '遵循勿擾模式'; + @override String trendCumulativeTotal(String total) { return '累計 $total mm'; @@ -4945,6 +4961,22 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get onboardingPermCritical => '重要警告'; + @override + String get onboardingPermUrgentAndroid => '勿扰模式下的重要通知'; + + @override + String get onboardingPermUrgentAndroidDesc => + '按需选择可在勿扰模式期间发出的重要通知。静音、音量及当前模式仍会影响声音。'; + + @override + String get permissionGuideUrgentAndroid => '请在频道设置中选择是否允许此通知打断勿扰模式,再返回 DPIP。'; + + @override + String get urgentNotificationBypassesDnd => '勿扰模式期间仍允许通知'; + + @override + String get urgentNotificationFollowsDnd => '遵循勿扰模式'; + @override String trendCumulativeTotal(String total) { return '累计 $total mm'; @@ -8195,6 +8227,22 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { @override String get onboardingPermCritical => '重大通知'; + @override + String get onboardingPermUrgentAndroid => '勿擾模式下的重大通知'; + + @override + String get onboardingPermUrgentAndroidDesc => + '依需要選擇可在勿擾模式期間發出的重大通知。靜音、音量及目前模式仍會影響聲音。'; + + @override + String get permissionGuideUrgentAndroid => '請在頻道設定中選擇是否允許此通知打斷勿擾模式,再返回 DPIP。'; + + @override + String get urgentNotificationBypassesDnd => '勿擾模式期間仍允許通知'; + + @override + String get urgentNotificationFollowsDnd => '遵循勿擾模式'; + @override String trendCumulativeTotal(String total) { return '累計 $total mm'; @@ -11445,6 +11493,22 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get onboardingPermCritical => '重大通知'; + @override + String get onboardingPermUrgentAndroid => '勿擾模式下的重大通知'; + + @override + String get onboardingPermUrgentAndroidDesc => + '依需求選擇可在勿擾模式期間發出的重大通知。靜音、音量及目前模式仍會影響聲音。'; + + @override + String get permissionGuideUrgentAndroid => '請在頻道設定中選擇是否允許此通知打斷勿擾模式,再返回 DPIP。'; + + @override + String get urgentNotificationBypassesDnd => '勿擾模式期間仍允許通知'; + + @override + String get urgentNotificationFollowsDnd => '遵循勿擾模式'; + @override String trendCumulativeTotal(String total) { return '累計 $total mm'; diff --git a/lib/shared/widgets/permission_checklist.dart b/lib/shared/widgets/permission_checklist.dart index 002060f34..523866b58 100644 --- a/lib/shared/widgets/permission_checklist.dart +++ b/lib/shared/widgets/permission_checklist.dart @@ -21,6 +21,7 @@ import 'package:dpip/app/theme/app_spacing.dart'; import 'package:dpip/core/geo/location_service.dart'; import 'package:dpip/core/logging/log.dart'; import 'package:dpip/core/notifications/notification_service.dart'; +import 'package:dpip/core/notifications/urgent_notification_settings.dart'; import 'package:dpip/core/permissions/permission_health.dart'; import 'package:dpip/core/permissions/permission_outcome.dart'; import 'package:dpip/core/permissions/system_settings.dart'; @@ -35,6 +36,7 @@ import 'package:provider/provider.dart'; enum _PermissionItem { notify, critical, + urgent, location, background, execution, @@ -82,9 +84,12 @@ class _PermissionChecklistState extends State final UnusedAppRestrictionsService _unusedApp = UnusedAppRestrictionsService(); final BackgroundExecutionService _execution = BackgroundExecutionService(); + final UrgentNotificationSettings _urgentSettings = + UrgentNotificationSettings(); bool _notify = false; bool _critical = false; + UrgentNotificationStatus? _urgent; bool _location = false; bool _background = false; bool _batteryOk = false; @@ -139,6 +144,7 @@ class _PermissionChecklistState extends State final critical = Platform.isIOS ? await notifications.criticalAllowed() : false; + final urgent = Platform.isAndroid ? await _urgentSettings.status() : null; final locationGranted = await location.granted(); final backgroundGranted = await location.backgroundGranted(); final batteryOk = Platform.isAndroid ? await _battery.isIgnoring() : true; @@ -147,6 +153,7 @@ class _PermissionChecklistState extends State if (!mounted || epoch != _refreshEpoch) return; Log.info( 'permission state: notify=$notify critical=$critical ' + 'urgent=${urgent?.allBypass} ' 'location=$locationGranted background=$backgroundGranted ' 'battery=$batteryOk unusedApp=${unusedApp.name} ' 'bgExec=${execution.restricted ? "restricted" : "ok"} ' @@ -169,6 +176,7 @@ class _PermissionChecklistState extends State setState(() { _notify = notify; _critical = critical; + _urgent = urgent; _location = locationGranted; _background = backgroundGranted; _batteryOk = batteryOk; @@ -220,6 +228,9 @@ class _PermissionChecklistState extends State }) => switch (item) { _PermissionItem.notify => notify, _PermissionItem.critical => critical, + // This is a preference, not a grant: returning from any channel settings is + // never an error just because the user chose to keep Do Not Disturb active. + _PermissionItem.urgent => true, _PermissionItem.location => location, _PermissionItem.background => background, _PermissionItem.execution => !execution.restricted, @@ -344,6 +355,26 @@ class _PermissionChecklistState extends State ); } + Future _openUrgentSettings( + UrgentNotificationChannelStatus target, + ) async { + final channelId = target.channelId; + if (channelId == null) return; + final l10n = AppLocalizations.of(context); + await _openGuidedSettings( + _PermissionItem.urgent, + l10n.onboardingPermUrgentAndroid, + PermissionSettingsGuide(instruction: l10n.permissionGuideUrgentAndroid), + () async { + final destination = await _urgentSettings.openChannelSettings( + channelId, + ); + Log.info('permission[urgent notification]: opened $destination'); + return destination != 'none'; + }, + ); + } + // Foreground location only. Background ("Always") is a SEPARATE step — // Android 11+ silently denies both if they're requested in the same gesture. Future _grantLocation() { @@ -514,6 +545,17 @@ class _PermissionChecklistState extends State onGrant: _grantCritical, ), ], + if (Platform.isAndroid && _urgent != null) ...[ + const SizedBox(height: AppSpacing.sm), + _UrgentNotificationSection( + status: _urgent!, + title: l10n.onboardingPermUrgentAndroid, + description: l10n.onboardingPermUrgentAndroidDesc, + loading: _loading(_PermissionItem.urgent), + blocked: _busy != null, + onOpen: _openUrgentSettings, + ), + ], const SizedBox(height: AppSpacing.sm), PermissionRow( icon: Icons.location_on_outlined, @@ -628,6 +670,7 @@ class PermissionRow extends StatelessWidget { this.loading = false, this.blocked = false, this.settingsAction = false, + this.actionWhenGranted = false, this.feedback, }); @@ -648,6 +691,7 @@ class PermissionRow extends StatelessWidget { final bool loading; final bool blocked; final bool settingsAction; + final bool actionWhenGranted; final PermissionRowFeedback? feedback; @override @@ -668,7 +712,7 @@ class PermissionRow extends StatelessWidget { final borderColor = feedbackColor ?? (loading ? colors.primary : colors.outlineVariant.withValues(alpha: 0)); - final action = onGrant == null || granted + final action = onGrant == null || (granted && !actionWhenGranted) ? null : settingsAction || advisory ? OutlinedButton.icon( @@ -768,3 +812,86 @@ class PermissionRow extends StatelessWidget { ); } } + +class _UrgentNotificationSection extends StatelessWidget { + const _UrgentNotificationSection({ + required this.status, + required this.title, + required this.description, + required this.loading, + required this.blocked, + required this.onOpen, + }); + + final UrgentNotificationStatus status; + final String title; + final String description; + final bool loading; + final bool blocked; + final Future Function(UrgentNotificationChannelStatus) onOpen; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + final l10n = AppLocalizations.of(context); + final channels = status.channels + .where((channel) => channel.channelId != null && channel.name != null) + .toList(growable: false); + if (channels.isEmpty) return const SizedBox.shrink(); + + return Material( + color: colors.surfaceContainer, + borderRadius: AppRadius.medium, + clipBehavior: Clip.antiAlias, + child: ExpansionTile( + leading: Icon(Icons.priority_high_outlined, color: colors.primary), + title: Text( + title, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + subtitle: Padding( + padding: const EdgeInsets.only(top: AppSpacing.xs), + child: Text( + description, + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ), + shape: const Border(), + collapsedShape: const Border(), + childrenPadding: const EdgeInsets.fromLTRB( + AppSpacing.md, + 0, + AppSpacing.md, + AppSpacing.md, + ), + children: [ + for (var index = 0; index < channels.length; index++) ...[ + if (index > 0) const SizedBox(height: AppSpacing.sm), + PermissionRow( + icon: Icons.notifications_active_outlined, + title: channels[index].name!, + description: + channels[index].state == + UrgentNotificationChannelState.bypasses + ? l10n.urgentNotificationBypassesDnd + : l10n.urgentNotificationFollowsDnd, + granted: + channels[index].state == + UrgentNotificationChannelState.bypasses, + loading: loading, + blocked: blocked, + settingsAction: true, + actionWhenGranted: true, + onGrant: () => onOpen(channels[index]), + ), + ], + ], + ), + ); + } +} diff --git a/test/core/notifications/urgent_notification_settings_test.dart b/test/core/notifications/urgent_notification_settings_test.dart new file mode 100644 index 000000000..38a4114dc --- /dev/null +++ b/test/core/notifications/urgent_notification_settings_test.dart @@ -0,0 +1,120 @@ +/// Tests Android urgent notification channel status and settings integration. +library; + +import 'package:dpip/core/notifications/notification_channels.dart'; +import 'package:dpip/core/notifications/urgent_notification_settings.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const channel = MethodChannel('test.urgent_notification_settings'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + + tearDown(() => messenger.setMockMethodCallHandler(channel, null)); + + test('urgent catalogue contains only the six explicit major channels', () { + expect(NotificationChannels.urgentChannelKeys, { + 'eew_alert-important-v2', + 'eew-important-v2', + 'thunderstorm-important-v2', + 'weather_major-important-v2', + 'evacuation_major-important-v2', + 'tsunami-important-v2', + }); + expect( + NotificationChannels.channels + .map((channel) => channel.channelKey) + .where(NotificationChannels.urgentChannelKeys.contains), + containsAll(NotificationChannels.urgentChannelKeys), + ); + }); + + test('parses bypassing, DND-following, and missing channel states', () async { + messenger.setMockMethodCallHandler(channel, (call) async { + expect(call.method, 'urgentNotificationChannelStatus'); + final ids = (call.arguments as Map)['channelIds']!; + expect(ids, NotificationChannels.urgentChannelKeys.toList()); + return { + 'eew_alert-important-v2': { + 'id': 'native-eew-alert-channel', + 'name': 'Major EEW', + 'bypassesDnd': true, + }, + 'eew-important-v2': { + 'id': 'eew-important-v2', + 'name': 'Major earthquake alert', + 'bypassesDnd': false, + }, + }; + }); + + final status = await UrgentNotificationSettings(channel).status(); + + expect(status.allBypass, isFalse); + expect( + status.channels.first.state, + UrgentNotificationChannelState.bypasses, + ); + expect(status.channels.first.name, 'Major EEW'); + expect(status.channels.first.channelId, 'native-eew-alert-channel'); + expect( + status.channels[1].state, + UrgentNotificationChannelState.doesNotBypass, + ); + expect( + status.channels + .skip(2) + .every( + (channel) => + channel.state == UrgentNotificationChannelState.missing, + ), + isTrue, + ); + }); + + test('platform failures become unavailable states', () async { + messenger.setMockMethodCallHandler(channel, (_) async { + throw PlatformException(code: 'unavailable'); + }); + + final status = await UrgentNotificationSettings(channel).status(); + + expect(status.allBypass, isFalse); + expect( + status.channels.every( + (channel) => + channel.state == UrgentNotificationChannelState.unavailable, + ), + isTrue, + ); + }); + + test( + 'opens settings using the channel id returned by the platform', + () async { + messenger.setMockMethodCallHandler(channel, (call) async { + if (call.method == 'urgentNotificationChannelStatus') { + return { + 'eew_alert-important-v2': { + 'id': 'native-eew-alert-channel', + 'name': 'Major EEW', + 'bypassesDnd': true, + }, + }; + } + expect(call.method, 'openNotificationChannelSettings'); + expect(call.arguments, {'channelId': 'native-eew-alert-channel'}); + return 'channel'; + }); + + final settings = UrgentNotificationSettings(channel); + final status = await settings.status(); + final channelId = status.channels.first.channelId; + expect(channelId, isNotNull); + expect(await settings.openChannelSettings(channelId!), 'channel'); + }, + ); +} diff --git a/test/shared/widgets/permission_checklist_test.dart b/test/shared/widgets/permission_checklist_test.dart index 2ee4f64b4..6ec4d2573 100644 --- a/test/shared/widgets/permission_checklist_test.dart +++ b/test/shared/widgets/permission_checklist_test.dart @@ -93,4 +93,30 @@ void main() { expect(tester.takeException(), isNull); expect(find.text('Open Settings'), findsOneWidget); }); + + testWidgets('a granted preference still offers its settings action', ( + tester, + ) async { + var tapCount = 0; + await tester.pumpWidget( + _wrap( + PermissionRow( + icon: Icons.notifications_active_outlined, + title: 'Major EEW', + description: 'Allowed during Do Not Disturb', + granted: true, + settingsAction: true, + actionWhenGranted: true, + onGrant: () async => tapCount++, + ), + ), + ); + + expect(find.byIcon(Icons.check_circle), findsOneWidget); + expect(find.text('Open Settings'), findsOneWidget); + final button = tester.widget(find.byType(OutlinedButton)); + expect(button.onPressed, isNotNull); + await tester.tap(find.byType(OutlinedButton)); + expect(tapCount, 1); + }); }