From d53bab291274b9a4808c81aef73b76538ee5e16d Mon Sep 17 00:00:00 2001 From: Vinson Date: Thu, 10 Sep 2026 16:20:27 +0800 Subject: [PATCH 1/4] 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 | 222 +++++++++++++++++- .../urgent_notification_settings_test.dart | 120 ++++++++++ .../widgets/permission_checklist_test.dart | 19 ++ 28 files changed, 844 insertions(+), 4 deletions(-) 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 2bca9cc9a..a74d85b43 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -2347,6 +2347,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 4dd8644c1..4b6c68c24 100644 --- a/lib/l10n/app_fil.arb +++ b/lib/l10n/app_fil.arb @@ -857,6 +857,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 b74df6cca..30c40be43 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -857,6 +857,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 b97879faa..363349b15 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -857,6 +857,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 2d48cd9ea..4f4f90a8c 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -857,6 +857,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 d264769e0..37473b606 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -857,6 +857,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 a1c1d1ec8..2afca34c2 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -857,6 +857,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 e5edc8f2c..3bf70a601 100644 --- a/lib/l10n/app_yue.arb +++ b/lib/l10n/app_yue.arb @@ -858,6 +858,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 ffdefa3d8..f35b5e25c 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -857,6 +857,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 ae09bc0bd..0248f7f6a 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -857,6 +857,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 ed4230d59..3e7d52b75 100644 --- a/lib/l10n/app_zh_Hant_HK.arb +++ b/lib/l10n/app_zh_Hant_HK.arb @@ -857,6 +857,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 8bb3406f4..34f1cbfb0 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -857,6 +857,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 cbd4e6d8b..ed82f062f 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -3348,6 +3348,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 5c4c09634..6c576ab9a 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -1743,6 +1743,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 4ec39a8b7..ff626e0c8 100644 --- a/lib/l10n/gen/app_localizations_fil.dart +++ b/lib/l10n/gen/app_localizations_fil.dart @@ -1753,6 +1753,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 88f76f260..c0f825952 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -1747,6 +1747,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 0a5d20c47..f000b491f 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -1714,6 +1714,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 10bdb53b1..006f4215e 100644 --- a/lib/l10n/gen/app_localizations_ko.dart +++ b/lib/l10n/gen/app_localizations_ko.dart @@ -1714,6 +1714,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 3309032ce..0a0d7b722 100644 --- a/lib/l10n/gen/app_localizations_th.dart +++ b/lib/l10n/gen/app_localizations_th.dart @@ -1740,6 +1740,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 19393318d..d9c1a6699 100644 --- a/lib/l10n/gen/app_localizations_vi.dart +++ b/lib/l10n/gen/app_localizations_vi.dart @@ -1744,6 +1744,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 0ca2786d4..304d4d3a3 100644 --- a/lib/l10n/gen/app_localizations_yue.dart +++ b/lib/l10n/gen/app_localizations_yue.dart @@ -1703,6 +1703,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 fd620a528..e1fcea657 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -1703,6 +1703,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'; @@ -4981,6 +4997,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'; @@ -8259,6 +8291,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'; @@ -11537,6 +11585,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..dd9318ff8 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; @@ -103,6 +108,15 @@ class _PermissionChecklistState extends State const BackgroundExecutionStatus(); _PermissionItem? _busy; + + /// Which urgent channel is being opened. The section holds several rows that + /// share one [_PermissionItem], so without this every channel in it would + /// spin while one of them is being changed. + String? _busyUrgentChannel; + + /// The channel the user opened settings for and came back from unchanged. + /// Per channel for the same reason as [_busyUrgentChannel]. + String? _unchangedUrgentChannel; _PermissionItem? _awaitingReturn; _PermissionItem? _feedbackItem; PermissionRowFeedback? _feedback; @@ -126,8 +140,51 @@ class _PermissionChecklistState extends State // Every grant that cannot be made in-app is made in system settings, so // coming back is the moment to look again. if (state != AppLifecycleState.resumed) return; - final pending = _awaitingReturn; - unawaited(_refresh(feedbackFor: pending)); + unawaited(_recheck(_awaitingReturn)); + } + + /// Re-reads what coming back from a system screen can have changed. + /// + /// Every item but the urgent channels gets the whole page: the grants there + /// gate each other, and Settings can revoke one while granting another. A Do + /// Not Disturb channel gates nothing — it is a preference on one channel — so + /// it re-reads only itself. The full sweep is a dozen platform round trips + /// and repaints every row, which reads as the page re-checking the lot after + /// a single toggle. + Future _recheck(_PermissionItem? item) => item == _PermissionItem.urgent + ? _refreshUrgentChannels() + : _refresh(feedbackFor: item); + + /// Whether [channel] still deserves the "came back unchanged" hint. + /// + /// The user went to that channel's settings to let it through Do Not + /// Disturb; finding the switch still off says the trip did not take. Like + /// every other row's feedback, it stands until the thing it asks for is + /// done, and only on the channel that was opened — the rest were never + /// asked about. + String? _urgentHint(String? channel, UrgentNotificationStatus? status) { + if (channel == null || status == null) return null; + final unchanged = status.channels.any( + (each) => + each.channelId == channel && + each.state != UrgentNotificationChannelState.bypasses, + ); + return unchanged ? channel : null; + } + + Future _refreshUrgentChannels() async { + final epoch = ++_refreshEpoch; + final opened = _busyUrgentChannel; + final urgent = await _urgentSettings.status(); + if (!mounted || epoch != _refreshEpoch) return; + Log.info('permission state: urgent=${urgent.allBypass}'); + setState(() { + _urgent = urgent; + _unchangedUrgentChannel = _urgentHint(opened, urgent); + _busy = null; + _busyUrgentChannel = null; + _awaitingReturn = null; + }); } Future _refresh({_PermissionItem? feedbackFor}) async { @@ -139,6 +196,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 +205,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,12 +228,15 @@ class _PermissionChecklistState extends State setState(() { _notify = notify; _critical = critical; + _urgent = urgent; _location = locationGranted; _background = backgroundGranted; _batteryOk = batteryOk; _unusedAppStatus = unusedApp; _executionStatus = execution; + _unchangedUrgentChannel = _urgentHint(_unchangedUrgentChannel, urgent); _busy = null; + _busyUrgentChannel = null; _awaitingReturn = null; if (feedbackFor == _PermissionItem.vendor) { _feedbackItem = feedbackFor; @@ -220,6 +282,11 @@ class _PermissionChecklistState extends State }) => switch (item) { _PermissionItem.notify => notify, _PermissionItem.critical => critical, + // The section is many channels behind one item, so a single verdict cannot + // say anything useful about it. Its feedback is per channel instead, in + // [_urgentHint], and it never reaches here: [_recheck] routes the urgent + // item away from this sweep. + _PermissionItem.urgent => true, _PermissionItem.location => location, _PermissionItem.background => background, _PermissionItem.execution => !execution.restricted, @@ -231,6 +298,7 @@ class _PermissionChecklistState extends State void _begin(_PermissionItem item) { setState(() { _busy = item; + if (item == _PermissionItem.urgent) _unchangedUrgentChannel = null; if (_feedbackItem == item) { _feedbackItem = null; _feedback = null; @@ -240,7 +308,10 @@ class _PermissionChecklistState extends State void _cancel(_PermissionItem item) { if (!mounted || _busy != item) return; - setState(() => _busy = null); + setState(() { + _busy = null; + _busyUrgentChannel = null; + }); } Future _leaveForSettings( @@ -344,6 +415,27 @@ class _PermissionChecklistState extends State ); } + Future _openUrgentSettings( + UrgentNotificationChannelStatus target, + ) async { + final channelId = target.channelId; + if (channelId == null) return; + final l10n = AppLocalizations.of(context); + _busyUrgentChannel = channelId; + 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() { @@ -442,7 +534,7 @@ class _PermissionChecklistState extends State ); if (opened || !mounted) return; if (attempted) { - await _refresh(feedbackFor: item); + await _recheck(item); } else { _cancel(item); } @@ -514,6 +606,20 @@ 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, + loadingChannelId: _loading(_PermissionItem.urgent) + ? _busyUrgentChannel + : null, + unchangedChannelId: _unchangedUrgentChannel, + blocked: _busy != null, + onOpen: _openUrgentSettings, + ), + ], const SizedBox(height: AppSpacing.sm), PermissionRow( icon: Icons.location_on_outlined, @@ -768,3 +874,111 @@ class PermissionRow extends StatelessWidget { ); } } + +class _UrgentNotificationSection extends StatelessWidget { + const _UrgentNotificationSection({ + required this.status, + required this.title, + required this.description, + required this.loadingChannelId, + required this.unchangedChannelId, + required this.blocked, + required this.onOpen, + }); + + final UrgentNotificationStatus status; + final String title; + final String description; + + /// The one channel being opened, if any. These rows share a + /// [_PermissionItem], so a section-wide flag would spin all of them. + final String? loadingChannelId; + + /// The one channel the user came back from without allowing it, if any. + final String? unchangedChannelId; + 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, + // A ListTile reserves 40 for its leading and puts 16 beside it, so this + // header would indent its title 24 further than every PermissionRow + // above and below it. Stripping those back to the row's own icon width + // and gap is what keeps the column of titles straight; the same goes for + // the vertical padding, which is the row's, not the tile's. + child: ListTileTheme.merge( + minLeadingWidth: 0, + horizontalTitleGap: AppSpacing.md, + minVerticalPadding: 0, + child: ExpansionTile( + minTileHeight: 0, + tilePadding: const EdgeInsets.all(AppSpacing.md), + 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: 2), + 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: + loadingChannelId != null && + channels[index].channelId == loadingChannelId, + blocked: blocked, + settingsAction: true, + feedback: + unchangedChannelId != null && + channels[index].channelId == unchangedChannelId + ? PermissionRowFeedback.stillNeeded + : null, + 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..92e399b48 100644 --- a/test/shared/widgets/permission_checklist_test.dart +++ b/test/shared/widgets/permission_checklist_test.dart @@ -93,4 +93,23 @@ void main() { expect(tester.takeException(), isNull); expect(find.text('Open Settings'), findsOneWidget); }); + + testWidgets('a settled preference drops its settings action', (tester) async { + await tester.pumpWidget( + _wrap( + PermissionRow( + icon: Icons.notifications_active_outlined, + title: 'Major EEW', + description: 'Allowed during Do Not Disturb', + granted: true, + settingsAction: true, + onGrant: () async {}, + ), + ), + ); + + expect(find.byIcon(Icons.check_circle), findsOneWidget); + expect(find.text('Open Settings'), findsNothing); + expect(find.byType(OutlinedButton), findsNothing); + }); } From 94b06c85b27219ba9317a0108b7a2bdd34313fe8 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Fri, 18 Sep 2026 00:48:32 +0800 Subject: [PATCH 2/4] chore(l10n): use a full-width comma in the Do Not Disturb guide --- lib/l10n/app_yue.arb | 2 +- lib/l10n/app_zh.arb | 2 +- lib/l10n/app_zh_Hans.arb | 2 +- lib/l10n/app_zh_Hant_HK.arb | 2 +- lib/l10n/app_zh_TW.arb | 2 +- lib/l10n/gen/app_localizations_yue.dart | 2 +- lib/l10n/gen/app_localizations_zh.dart | 8 ++++---- 7 files changed, 10 insertions(+), 10 deletions(-) diff --git a/lib/l10n/app_yue.arb b/lib/l10n/app_yue.arb index 3bf70a601..0a35084fd 100644 --- a/lib/l10n/app_yue.arb +++ b/lib/l10n/app_yue.arb @@ -860,7 +860,7 @@ "onboardingPermCritical": "重大通知", "onboardingPermUrgentAndroid": "勿擾模式下嘅重大通知", "onboardingPermUrgentAndroidDesc": "按需要選擇可喺勿擾模式期間發出嘅重大通知。靜音、音量同目前模式仍然會影響聲音。", - "permissionGuideUrgentAndroid": "請喺頻道設定選擇係咪允許呢個通知打斷勿擾模式,再返到 DPIP。", + "permissionGuideUrgentAndroid": "請喺頻道設定選擇係咪允許呢個通知打斷勿擾模式,再返到 DPIP。", "urgentNotificationBypassesDnd": "勿擾模式期間仍允許通知", "urgentNotificationFollowsDnd": "跟隨勿擾模式", "trendCumulativeTotal": "累計 {total} mm", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index f35b5e25c..bf62cbb75 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -859,7 +859,7 @@ "onboardingPermCritical": "重大通知", "onboardingPermUrgentAndroid": "勿擾模式下的重大通知", "onboardingPermUrgentAndroidDesc": "依需求選擇可在勿擾模式期間發出的重大通知。靜音、音量及目前模式仍會影響聲音。", - "permissionGuideUrgentAndroid": "請在頻道設定中選擇是否允許此通知打斷勿擾模式,再返回 DPIP。", + "permissionGuideUrgentAndroid": "請在頻道設定中選擇是否允許此通知打斷勿擾模式,再返回 DPIP。", "urgentNotificationBypassesDnd": "勿擾模式期間仍允許通知", "urgentNotificationFollowsDnd": "遵循勿擾模式", "trendCumulativeTotal": "累計 {total} mm", diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index 0248f7f6a..b45269198 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -859,7 +859,7 @@ "onboardingPermCritical": "重要警告", "onboardingPermUrgentAndroid": "勿扰模式下的重要通知", "onboardingPermUrgentAndroidDesc": "按需选择可在勿扰模式期间发出的重要通知。静音、音量及当前模式仍会影响声音。", - "permissionGuideUrgentAndroid": "请在频道设置中选择是否允许此通知打断勿扰模式,再返回 DPIP。", + "permissionGuideUrgentAndroid": "请在频道设置中选择是否允许此通知打断勿扰模式,再返回 DPIP。", "urgentNotificationBypassesDnd": "勿扰模式期间仍允许通知", "urgentNotificationFollowsDnd": "遵循勿扰模式", "trendCumulativeTotal": "累计 {total} mm", diff --git a/lib/l10n/app_zh_Hant_HK.arb b/lib/l10n/app_zh_Hant_HK.arb index 3e7d52b75..08e1680f0 100644 --- a/lib/l10n/app_zh_Hant_HK.arb +++ b/lib/l10n/app_zh_Hant_HK.arb @@ -859,7 +859,7 @@ "onboardingPermCritical": "重大通知", "onboardingPermUrgentAndroid": "勿擾模式下的重大通知", "onboardingPermUrgentAndroidDesc": "依需要選擇可在勿擾模式期間發出的重大通知。靜音、音量及目前模式仍會影響聲音。", - "permissionGuideUrgentAndroid": "請在頻道設定中選擇是否允許此通知打斷勿擾模式,再返回 DPIP。", + "permissionGuideUrgentAndroid": "請在頻道設定中選擇是否允許此通知打斷勿擾模式,再返回 DPIP。", "urgentNotificationBypassesDnd": "勿擾模式期間仍允許通知", "urgentNotificationFollowsDnd": "遵循勿擾模式", "trendCumulativeTotal": "累計 {total} mm", diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index 34f1cbfb0..8794b276b 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -859,7 +859,7 @@ "onboardingPermCritical": "重大通知", "onboardingPermUrgentAndroid": "勿擾模式下的重大通知", "onboardingPermUrgentAndroidDesc": "依需求選擇可在勿擾模式期間發出的重大通知。靜音、音量及目前模式仍會影響聲音。", - "permissionGuideUrgentAndroid": "請在頻道設定中選擇是否允許此通知打斷勿擾模式,再返回 DPIP。", + "permissionGuideUrgentAndroid": "請在頻道設定中選擇是否允許此通知打斷勿擾模式,再返回 DPIP。", "urgentNotificationBypassesDnd": "勿擾模式期間仍允許通知", "urgentNotificationFollowsDnd": "遵循勿擾模式", "trendCumulativeTotal": "累計 {total} mm", diff --git a/lib/l10n/gen/app_localizations_yue.dart b/lib/l10n/gen/app_localizations_yue.dart index 304d4d3a3..acc8fba6d 100644 --- a/lib/l10n/gen/app_localizations_yue.dart +++ b/lib/l10n/gen/app_localizations_yue.dart @@ -1711,7 +1711,7 @@ class AppLocalizationsYue extends AppLocalizations { '按需要選擇可喺勿擾模式期間發出嘅重大通知。靜音、音量同目前模式仍然會影響聲音。'; @override - String get permissionGuideUrgentAndroid => '請喺頻道設定選擇係咪允許呢個通知打斷勿擾模式,再返到 DPIP。'; + String get permissionGuideUrgentAndroid => '請喺頻道設定選擇係咪允許呢個通知打斷勿擾模式,再返到 DPIP。'; @override String get urgentNotificationBypassesDnd => '勿擾模式期間仍允許通知'; diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index e1fcea657..0a570d471 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -1711,7 +1711,7 @@ class AppLocalizationsZh extends AppLocalizations { '依需求選擇可在勿擾模式期間發出的重大通知。靜音、音量及目前模式仍會影響聲音。'; @override - String get permissionGuideUrgentAndroid => '請在頻道設定中選擇是否允許此通知打斷勿擾模式,再返回 DPIP。'; + String get permissionGuideUrgentAndroid => '請在頻道設定中選擇是否允許此通知打斷勿擾模式,再返回 DPIP。'; @override String get urgentNotificationBypassesDnd => '勿擾模式期間仍允許通知'; @@ -5005,7 +5005,7 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { '按需选择可在勿扰模式期间发出的重要通知。静音、音量及当前模式仍会影响声音。'; @override - String get permissionGuideUrgentAndroid => '请在频道设置中选择是否允许此通知打断勿扰模式,再返回 DPIP。'; + String get permissionGuideUrgentAndroid => '请在频道设置中选择是否允许此通知打断勿扰模式,再返回 DPIP。'; @override String get urgentNotificationBypassesDnd => '勿扰模式期间仍允许通知'; @@ -8299,7 +8299,7 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { '依需要選擇可在勿擾模式期間發出的重大通知。靜音、音量及目前模式仍會影響聲音。'; @override - String get permissionGuideUrgentAndroid => '請在頻道設定中選擇是否允許此通知打斷勿擾模式,再返回 DPIP。'; + String get permissionGuideUrgentAndroid => '請在頻道設定中選擇是否允許此通知打斷勿擾模式,再返回 DPIP。'; @override String get urgentNotificationBypassesDnd => '勿擾模式期間仍允許通知'; @@ -11593,7 +11593,7 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { '依需求選擇可在勿擾模式期間發出的重大通知。靜音、音量及目前模式仍會影響聲音。'; @override - String get permissionGuideUrgentAndroid => '請在頻道設定中選擇是否允許此通知打斷勿擾模式,再返回 DPIP。'; + String get permissionGuideUrgentAndroid => '請在頻道設定中選擇是否允許此通知打斷勿擾模式,再返回 DPIP。'; @override String get urgentNotificationBypassesDnd => '勿擾模式期間仍允許通知'; From ff6b255c5df5c1fdd9cf0e34029d51b8454864b6 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Fri, 18 Sep 2026 00:48:33 +0800 Subject: [PATCH 3/4] test(notify): pin the urgent channel section's per-channel states --- lib/shared/widgets/permission_checklist.dart | 57 ++-- .../urgent_notification_section_test.dart | 249 ++++++++++++++++++ 2 files changed, 282 insertions(+), 24 deletions(-) create mode 100644 test/shared/widgets/urgent_notification_section_test.dart diff --git a/lib/shared/widgets/permission_checklist.dart b/lib/shared/widgets/permission_checklist.dart index dd9318ff8..e5448cbbf 100644 --- a/lib/shared/widgets/permission_checklist.dart +++ b/lib/shared/widgets/permission_checklist.dart @@ -155,23 +155,6 @@ class _PermissionChecklistState extends State ? _refreshUrgentChannels() : _refresh(feedbackFor: item); - /// Whether [channel] still deserves the "came back unchanged" hint. - /// - /// The user went to that channel's settings to let it through Do Not - /// Disturb; finding the switch still off says the trip did not take. Like - /// every other row's feedback, it stands until the thing it asks for is - /// done, and only on the channel that was opened — the rest were never - /// asked about. - String? _urgentHint(String? channel, UrgentNotificationStatus? status) { - if (channel == null || status == null) return null; - final unchanged = status.channels.any( - (each) => - each.channelId == channel && - each.state != UrgentNotificationChannelState.bypasses, - ); - return unchanged ? channel : null; - } - Future _refreshUrgentChannels() async { final epoch = ++_refreshEpoch; final opened = _busyUrgentChannel; @@ -180,7 +163,7 @@ class _PermissionChecklistState extends State Log.info('permission state: urgent=${urgent.allBypass}'); setState(() { _urgent = urgent; - _unchangedUrgentChannel = _urgentHint(opened, urgent); + _unchangedUrgentChannel = urgentUnchangedHint(opened, urgent); _busy = null; _busyUrgentChannel = null; _awaitingReturn = null; @@ -234,7 +217,10 @@ class _PermissionChecklistState extends State _batteryOk = batteryOk; _unusedAppStatus = unusedApp; _executionStatus = execution; - _unchangedUrgentChannel = _urgentHint(_unchangedUrgentChannel, urgent); + _unchangedUrgentChannel = urgentUnchangedHint( + _unchangedUrgentChannel, + urgent, + ); _busy = null; _busyUrgentChannel = null; _awaitingReturn = null; @@ -284,8 +270,8 @@ class _PermissionChecklistState extends State _PermissionItem.critical => critical, // The section is many channels behind one item, so a single verdict cannot // say anything useful about it. Its feedback is per channel instead, in - // [_urgentHint], and it never reaches here: [_recheck] routes the urgent - // item away from this sweep. + // [urgentUnchangedHint], and it never reaches here: [_recheck] routes the + // urgent item away from this sweep. _PermissionItem.urgent => true, _PermissionItem.location => location, _PermissionItem.background => background, @@ -608,7 +594,7 @@ class _PermissionChecklistState extends State ], if (Platform.isAndroid && _urgent != null) ...[ const SizedBox(height: AppSpacing.sm), - _UrgentNotificationSection( + UrgentNotificationSection( status: _urgent!, title: l10n.onboardingPermUrgentAndroid, description: l10n.onboardingPermUrgentAndroidDesc, @@ -875,8 +861,31 @@ class PermissionRow extends StatelessWidget { } } -class _UrgentNotificationSection extends StatelessWidget { - const _UrgentNotificationSection({ +/// Whether [channel] still deserves the "came back unchanged" hint. +/// +/// The user went to that channel's settings to let it through Do Not Disturb; +/// finding the switch still off says the trip did not take. Like every other +/// row's feedback, it stands until the thing it asks for is done, and only on +/// the channel that was opened — the rest were never asked about. +@visibleForTesting +String? urgentUnchangedHint(String? channel, UrgentNotificationStatus? status) { + if (channel == null || status == null) return null; + final unchanged = status.channels.any( + (each) => + each.channelId == channel && + each.state != UrgentNotificationChannelState.bypasses, + ); + return unchanged ? channel : null; +} + +/// The Android urgent-channel rows: one expandable section, one row per +/// channel the system actually has. +/// +/// Public only so its per-channel states can be tested; the checklist is its +/// one real host. +@visibleForTesting +class UrgentNotificationSection extends StatelessWidget { + const UrgentNotificationSection({ required this.status, required this.title, required this.description, diff --git a/test/shared/widgets/urgent_notification_section_test.dart b/test/shared/widgets/urgent_notification_section_test.dart new file mode 100644 index 000000000..2b0cbc6c9 --- /dev/null +++ b/test/shared/widgets/urgent_notification_section_test.dart @@ -0,0 +1,249 @@ +/// Pins the per-channel behaviour of the Android urgent-channel section. +/// +/// The section is many channels behind one checklist item, so every state it +/// shows has to be scoped to one row: a spinner, a "still needs attention" +/// hint, a settings action. Getting any of them section-wide makes every +/// channel look like it is being changed, or like it was left unchanged. +library; + +import 'package:dpip/core/notifications/urgent_notification_settings.dart'; +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/widgets/permission_checklist.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Widget _wrap(Widget child) => MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + locale: const Locale('en'), + home: Scaffold(body: SingleChildScrollView(child: child)), +); + +UrgentNotificationChannelStatus _channel( + String id, + UrgentNotificationChannelState state, { + String? name, +}) { + final present = + state == UrgentNotificationChannelState.bypasses || + state == UrgentNotificationChannelState.doesNotBypass; + return UrgentNotificationChannelStatus( + requestedId: id, + channelId: present ? id : null, + name: present ? (name ?? id) : null, + state: state, + ); +} + +const _allowed = 'Allowed during Do Not Disturb'; +const _follows = 'Follows Do Not Disturb'; +const _stillNeeded = + 'Still needs attention. Check the highlighted option in Settings.'; + +void main() { + group('urgentUnchangedHint', () { + final status = UrgentNotificationStatus([ + _channel('eew', UrgentNotificationChannelState.doesNotBypass), + _channel('tsunami', UrgentNotificationChannelState.bypasses), + _channel('storm', UrgentNotificationChannelState.missing), + ]); + + test('nothing was opened, or nothing was read', () { + expect(urgentUnchangedHint(null, status), isNull); + expect(urgentUnchangedHint('eew', null), isNull); + }); + + test('the opened channel still following DND keeps the hint', () { + expect(urgentUnchangedHint('eew', status), 'eew'); + }); + + test('the opened channel now allowed clears it', () { + expect(urgentUnchangedHint('tsunami', status), isNull); + }); + + test('a channel the system no longer reports clears it', () { + expect(urgentUnchangedHint('storm', status), isNull); + expect(urgentUnchangedHint('gone', status), isNull); + }); + }); + + group('UrgentNotificationSection', () { + Future> pump( + WidgetTester tester, { + required UrgentNotificationStatus status, + String? loadingChannelId, + String? unchangedChannelId, + bool blocked = false, + }) async { + final opened = []; + await tester.pumpWidget( + _wrap( + UrgentNotificationSection( + status: status, + title: 'Major alerts in Do Not Disturb', + description: 'Choose which channels may interrupt.', + loadingChannelId: loadingChannelId, + unchangedChannelId: unchangedChannelId, + blocked: blocked, + onOpen: (channel) async => opened.add(channel), + ), + ), + ); + if (find.byType(ExpansionTile).evaluate().isNotEmpty) { + await tester.tap(find.text('Major alerts in Do Not Disturb')); + // Not pumpAndSettle: a row that is loading spins for ever. The tile's + // own expansion animation is well under this. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + } + return opened; + } + + Finder row(String title) => find.ancestor( + of: find.text(title), + matching: find.byType(PermissionRow), + ); + + final mixed = UrgentNotificationStatus([ + _channel( + 'eew', + UrgentNotificationChannelState.doesNotBypass, + name: 'EEW', + ), + _channel( + 'tsunami', + UrgentNotificationChannelState.bypasses, + name: 'Tsunami', + ), + _channel('storm', UrgentNotificationChannelState.missing), + _channel('flood', UrgentNotificationChannelState.unavailable), + ]); + + testWidgets('lists only the channels the system actually has', ( + tester, + ) async { + await pump(tester, status: mixed); + + expect(find.byType(PermissionRow), findsNWidgets(2)); + expect( + find.descendant(of: row('EEW'), matching: find.text(_follows)), + findsOneWidget, + ); + expect( + find.descendant(of: row('Tsunami'), matching: find.text(_allowed)), + findsOneWidget, + ); + }); + + testWidgets('renders nothing when no channel is present', (tester) async { + await pump( + tester, + status: UrgentNotificationStatus([ + _channel('storm', UrgentNotificationChannelState.missing), + _channel('flood', UrgentNotificationChannelState.unavailable), + ]), + ); + + expect(find.byType(ExpansionTile), findsNothing); + expect(find.byType(PermissionRow), findsNothing); + }); + + testWidgets('only an unsettled channel offers its settings', ( + tester, + ) async { + await pump(tester, status: mixed); + + expect( + find.descendant(of: row('EEW'), matching: find.text('Open Settings')), + findsOneWidget, + ); + expect( + find.descendant( + of: row('Tsunami'), + matching: find.text('Open Settings'), + ), + findsNothing, + ); + }); + + testWidgets('the spinner belongs to the channel being opened', ( + tester, + ) async { + await pump( + tester, + status: UrgentNotificationStatus([ + _channel( + 'eew', + UrgentNotificationChannelState.doesNotBypass, + name: 'EEW', + ), + _channel( + 'storm', + UrgentNotificationChannelState.doesNotBypass, + name: 'Storm', + ), + ]), + loadingChannelId: 'storm', + ); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect( + find.descendant( + of: row('Storm'), + matching: find.byType(CircularProgressIndicator), + ), + findsOneWidget, + reason: 'a section-wide flag would spin every channel', + ); + }); + + testWidgets('the unchanged hint belongs to the channel that was opened', ( + tester, + ) async { + await pump( + tester, + status: UrgentNotificationStatus([ + _channel( + 'eew', + UrgentNotificationChannelState.doesNotBypass, + name: 'EEW', + ), + _channel( + 'storm', + UrgentNotificationChannelState.doesNotBypass, + name: 'Storm', + ), + ]), + unchangedChannelId: 'eew', + ); + + expect(find.text(_stillNeeded), findsOneWidget); + expect( + find.descendant(of: row('EEW'), matching: find.text(_stillNeeded)), + findsOneWidget, + reason: 'the channels nobody opened were never asked about', + ); + }); + + testWidgets('a tap opens that channel', (tester) async { + final opened = await pump(tester, status: mixed); + + await tester.tap( + find.descendant(of: row('EEW'), matching: find.text('Open Settings')), + ); + await tester.pump(); + expect(opened.map((channel) => channel.channelId), ['eew']); + }); + + testWidgets('a busy page blocks every channel', (tester) async { + final opened = await pump(tester, status: mixed, blocked: true); + + await tester.tap( + find.descendant(of: row('EEW'), matching: find.text('Open Settings')), + warnIfMissed: false, + ); + await tester.pump(); + expect(opened, isEmpty); + }); + }); +} From d0ba688d5e771406ec44e9a43cae945551fd7dd9 Mon Sep 17 00:00:00 2001 From: PiscesXD Date: Sat, 19 Sep 2026 12:45:51 +0800 Subject: [PATCH 4/4] feat(notify): mark the urgent channel header when one is not allowed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform: android New(zh-Hant): 有重大通知不能在勿擾模式發出時,權限檢查會在該區標上紅點 New(en-US): a dot marks the major-alert section when a channel is still held by Do Not Disturb --- lib/shared/widgets/permission_checklist.dart | 19 +++++- .../widgets/permission_checklist_test.dart | 63 +++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/lib/shared/widgets/permission_checklist.dart b/lib/shared/widgets/permission_checklist.dart index e5448cbbf..9da417944 100644 --- a/lib/shared/widgets/permission_checklist.dart +++ b/lib/shared/widgets/permission_checklist.dart @@ -918,6 +918,18 @@ class UrgentNotificationSection extends StatelessWidget { .toList(growable: false); if (channels.isEmpty) return const SizedBox.shrink(); + // A channel that still follows Do Not Disturb is only visible once the + // section is open, and collapsed is how it starts — so the header carries + // a dot saying there is something inside to deal with. Announced for + // screen readers, where a dot is otherwise silent. + final attention = channels.any( + (channel) => channel.state != UrgentNotificationChannelState.bypasses, + ); + final headerIcon = Icon( + Icons.priority_high_outlined, + color: colors.primary, + ); + return Material( color: colors.surfaceContainer, borderRadius: AppRadius.medium, @@ -934,7 +946,12 @@ class UrgentNotificationSection extends StatelessWidget { child: ExpansionTile( minTileHeight: 0, tilePadding: const EdgeInsets.all(AppSpacing.md), - leading: Icon(Icons.priority_high_outlined, color: colors.primary), + leading: attention + ? Semantics( + label: l10n.permissionsAttention, + child: Badge(child: headerIcon), + ) + : headerIcon, title: Text( title, style: theme.textTheme.titleMedium?.copyWith( diff --git a/test/shared/widgets/permission_checklist_test.dart b/test/shared/widgets/permission_checklist_test.dart index 92e399b48..112270567 100644 --- a/test/shared/widgets/permission_checklist_test.dart +++ b/test/shared/widgets/permission_checklist_test.dart @@ -1,3 +1,4 @@ +import 'package:dpip/core/notifications/urgent_notification_settings.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:dpip/shared/widgets/permission_checklist.dart'; import 'package:flutter/material.dart'; @@ -16,6 +17,27 @@ Widget _wrap(Widget child, {TextScaler? textScaler}) => MaterialApp( home: Scaffold(body: SingleChildScrollView(child: child)), ); +UrgentNotificationChannelStatus _channel( + String id, + UrgentNotificationChannelState state, +) => UrgentNotificationChannelStatus( + requestedId: id, + channelId: id, + name: id, + state: state, +); + +Widget _urgentSection(List channels) => + UrgentNotificationSection( + status: UrgentNotificationStatus(channels), + title: 'Major alerts in Do Not Disturb', + description: 'Choose which channels may interrupt.', + loadingChannelId: null, + unchangedChannelId: null, + blocked: false, + onOpen: (_) async {}, + ); + void main() { testWidgets('loading state is visible and disables its action', ( tester, @@ -65,6 +87,47 @@ void main() { expect(find.text('Open Settings'), findsOneWidget); }); + testWidgets('a channel still under Do Not Disturb marks the closed header', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + await tester.pumpWidget( + _wrap( + _urgentSection([ + _channel('tsunami', UrgentNotificationChannelState.bypasses), + _channel('eew', UrgentNotificationChannelState.doesNotBypass), + ]), + ), + ); + + // The row saying so is inside the collapsed section, so the dot on the + // header is the only thing the user has to go on. + expect(find.text('Follows Do Not Disturb'), findsNothing); + expect(find.byType(Badge), findsOneWidget); + // The header tile merges its parts into one node, so the dot's label is + // read out with the title rather than on its own. + expect( + find.bySemanticsLabel(RegExp('Permissions need attention')), + findsOneWidget, + ); + semantics.dispose(); + }); + + testWidgets('every channel allowed leaves the header unmarked', ( + tester, + ) async { + await tester.pumpWidget( + _wrap( + _urgentSection([ + _channel('tsunami', UrgentNotificationChannelState.bypasses), + _channel('eew', UrgentNotificationChannelState.bypasses), + ]), + ), + ); + + expect(find.byType(Badge), findsNothing); + }); + testWidgets('long guidance remains usable on a narrow large-text display', ( tester, ) async {