From 17942a2125102e7133ee5346e1f8066b9ea79ba3 Mon Sep 17 00:00:00 2001 From: Merack Date: Mon, 3 Aug 2026 17:18:40 +0800 Subject: [PATCH 1/3] chore: update dependencies --- .gitignore | 3 +- pubspec.lock | 140 +++++++++++++++++++++++++++------------------------ pubspec.yaml | 2 +- 3 files changed, 77 insertions(+), 68 deletions(-) diff --git a/.gitignore b/.gitignore index 8bf0430..732dbd2 100644 --- a/.gitignore +++ b/.gitignore @@ -54,4 +54,5 @@ macos/Flutter/GeneratedPluginRegistrant.swift .claude CLAUDE.md -data/ \ No newline at end of file +data/ +.codebase-memory \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index 10bcdb5..502ff4a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -29,58 +29,58 @@ packages: dependency: "direct main" description: name: audioplayers - sha256: "1d0c1b1f2095e59080e2d5046639096417a86687d89778da41b0c9a06d683dfd" + sha256: "2ba4bb2944baacbdd5372ff8254a8e7feb8c10d7739545e392f5605a8f618745" url: "https://pub.flutter-io.cn" source: hosted - version: "6.7.0" + version: "6.8.1" audioplayers_android: dependency: transitive description: name: audioplayers_android - sha256: "60a6728277228413a85755bd3ffd6fab98f6555608923813ce383b190a360605" + sha256: f5ff5b15620fbab8cb0849e9636c48e2b96c3f0f71723bbbe2ad3c761b205f05 url: "https://pub.flutter-io.cn" source: hosted - version: "5.2.1" + version: "5.3.0" audioplayers_darwin: dependency: transitive description: name: audioplayers_darwin - sha256: c994b3bb3a921e4904ac40e013fbc94488e824fd7c1de6326f549943b0b44a91 + sha256: "1ca553add991384ecf421b9569da850f3ab2472ffb83f6970b0416365abc51be" url: "https://pub.flutter-io.cn" source: hosted - version: "6.4.0" + version: "6.5.0" audioplayers_linux: dependency: transitive description: name: audioplayers_linux - sha256: f75bce1ce864170ef5e6a2c6a61cd3339e1a17ce11e99a25bae4474ea491d001 + sha256: "15178b726b7cdee5364d0463c8d445630c4e0fb7d26612b73c767e7d25de9417" url: "https://pub.flutter-io.cn" source: hosted - version: "4.2.1" + version: "4.3.0" audioplayers_platform_interface: dependency: transitive description: name: audioplayers_platform_interface - sha256: "0e2f6a919ab56d0fec272e801abc07b26ae7f31980f912f24af4748763e5a656" + sha256: "765f6f0e6dca55cb471c9483fc77700564b3484d19198aca4ebb5147c6c85acb" url: "https://pub.flutter-io.cn" source: hosted - version: "7.1.1" + version: "7.2.0" audioplayers_web: dependency: transitive description: name: audioplayers_web - sha256: "24a6f258062bd7da8cb2157e83fccb9816a08dd306cbaaa24f9813d071470545" + sha256: ae1e0103c865a03e273f6d13d97b93f5595eac09915729cd5e37ef96e2857319 url: "https://pub.flutter-io.cn" source: hosted - version: "5.2.1" + version: "5.3.0" audioplayers_windows: dependency: transitive description: name: audioplayers_windows - sha256: "95f875a96c88c3dbbcb608d4f8288e300b0113d256a81d0b3197fcc18f0dc91a" + sha256: a70ae82bba2dfcb6eb03dd4815d737a2d46d33ea5a96a03f535cfcaac490e413 url: "https://pub.flutter-io.cn" source: hosted - version: "4.3.1" + version: "4.4.1" boolean_selector: dependency: transitive description: @@ -125,10 +125,10 @@ packages: dependency: transitive description: name: code_assets - sha256: "67cf6d84013f9c601e42a6f8a6b74c4c0d9dc1a1619d775f2b28b732d3551b85" + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 url: "https://pub.flutter-io.cn" source: hosted - version: "1.2.0" + version: "1.2.1" collection: dependency: transitive description: @@ -141,10 +141,10 @@ packages: dependency: transitive description: name: cross_file - sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + sha256: "92c9c43c383bfa1c32079d3bc492d55d6d4318044b7b47edaff8971cbb555c51" url: "https://pub.flutter-io.cn" source: hosted - version: "0.3.5+2" + version: "0.3.5+4" crypto: dependency: transitive description: @@ -165,18 +165,18 @@ packages: dependency: transitive description: name: dbus - sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 + sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645" url: "https://pub.flutter-io.cn" source: hosted - version: "0.7.12" + version: "0.7.14" device_info_plus: dependency: "direct main" description: name: device_info_plus - sha256: "6a642e1daa10190af89ba6cb6386c0df7d071a3592080bfe1e44faa63ae1df65" + sha256: "0891702f96b2e465fe567b7ec448380e6b1c14f60af552a8536d9f583b6b8442" url: "https://pub.flutter-io.cn" source: hosted - version: "13.1.0" + version: "13.2.0" device_info_plus_platform_interface: dependency: transitive description: @@ -221,10 +221,10 @@ packages: dependency: "direct main" description: name: file_picker - sha256: fc83774ce5bd7ce08168333b5e53dbe9090ec04eb21e7aa7cd7bac921032c934 + sha256: fdc6a37f715d19f35b131decf1ce39242eeed5ddae18c0818c3eccb731ab76be url: "https://pub.flutter-io.cn" source: hosted - version: "12.0.0-beta.5" + version: "12.0.0-beta.7" fixnum: dependency: transitive description: @@ -290,42 +290,50 @@ packages: dependency: "direct main" description: name: flutter_local_notifications - sha256: "0d9035862236fe38250fe1644d7ed3b8254e34a21b2c837c9f539fbb3bba5ef1" + sha256: "40c6a69189a622bda89ddcf50a139f4ba0f0eb9c0fef6718845b1f8b95452ed6" url: "https://pub.flutter-io.cn" source: hosted - version: "21.0.0" + version: "22.1.0" flutter_local_notifications_linux: dependency: transitive description: name: flutter_local_notifications_linux - sha256: e0f25e243c6c44c825bbbc6b2b2e76f7d9222362adcfe9fd780bf01923c840bd + sha256: "9ca97e63776f29ab1b955725c09999fc2c150523269db150c39274f2a43c5a8b" url: "https://pub.flutter-io.cn" source: hosted - version: "8.0.0" + version: "8.0.1" flutter_local_notifications_platform_interface: dependency: transitive description: name: flutter_local_notifications_platform_interface - sha256: e7db3d5b49c2b7ecc68deba4aaaa67a348f92ee0fef34c8e4b4459dbef0d7307 + sha256: "4dfbae10debab9ac975d8b01943fed94c0c19dad43f825964b29f3c3f9a8a852" url: "https://pub.flutter-io.cn" source: hosted - version: "11.0.0" + version: "12.0.1" + flutter_local_notifications_web: + dependency: transitive + description: + name: flutter_local_notifications_web + sha256: "516afaf97a2d1e67a036c6617321b00d205d72f7a67b6eccf936cd565f985878" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.0" flutter_local_notifications_windows: dependency: transitive description: name: flutter_local_notifications_windows - sha256: "3a2654ba104fbb52c618ebed9def24ef270228470718c43b3a6afcd5c81bef0c" + sha256: "6f43bdd03b171b7a90f22647506fea33e2bb12294b7c7c7a3d690e960a382945" url: "https://pub.flutter-io.cn" source: hosted - version: "3.0.0" + version: "3.1.1" flutter_plugin_android_lifecycle: dependency: transitive description: name: flutter_plugin_android_lifecycle - sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0" + sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" url: "https://pub.flutter-io.cn" source: hosted - version: "2.0.34" + version: "2.0.35" flutter_test: dependency: "direct dev" description: flutter @@ -356,10 +364,10 @@ packages: dependency: transitive description: name: hooks - sha256: a41af4e8fc687cd6d33de9751eb936c8c0204ebe2bcb6c15ecf707504bf47f31 + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" url: "https://pub.flutter-io.cn" source: hosted - version: "2.0.0" + version: "2.0.2" http: dependency: transitive description: @@ -548,10 +556,10 @@ packages: dependency: "direct main" description: name: package_info_plus - sha256: "4bf625947f6c7713ee242296a682e23e44823c09cf9d79e4f1238923c92db852" + sha256: "127e1751e37ffb2ff4658beeaca77bad0c27bf5f932bd3a501c2296926d4b481" url: "https://pub.flutter-io.cn" source: hosted - version: "10.1.0" + version: "10.2.1" package_info_plus_platform_interface: dependency: transitive description: @@ -572,10 +580,10 @@ packages: dependency: "direct main" description: name: path_provider - sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 url: "https://pub.flutter-io.cn" source: hosted - version: "2.1.5" + version: "2.1.6" path_provider_android: dependency: transitive description: @@ -596,18 +604,18 @@ packages: dependency: transitive description: name: path_provider_linux - sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" url: "https://pub.flutter-io.cn" source: hosted - version: "2.2.1" + version: "2.2.2" path_provider_platform_interface: dependency: transitive description: name: path_provider_platform_interface - sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" url: "https://pub.flutter-io.cn" source: hosted - version: "2.1.2" + version: "2.1.3" path_provider_windows: dependency: transitive description: @@ -620,10 +628,10 @@ packages: dependency: "direct main" description: name: permission_handler - sha256: ca045d03615023c08ccdb297aad46a9198193666039ddd36d4d85fd0b1864b98 + sha256: fe54465bcc62a4564c6e4db337bbaded6c0c0fa6e10487414436d163114784f6 url: "https://pub.flutter-io.cn" source: hosted - version: "12.0.2" + version: "12.0.3" permission_handler_android: dependency: transitive description: @@ -636,10 +644,10 @@ packages: dependency: transitive description: name: permission_handler_apple - sha256: "447c18bc3c5fdea5c3039f042b2b365fd51e3634f5f6e269ed22c1f00071addc" + sha256: "79dfa1df734798aa3cfdad166d3a3698c206d8813de13516ea1071b5d7e2f420" url: "https://pub.flutter-io.cn" source: hosted - version: "9.4.8" + version: "9.4.10" permission_handler_html: dependency: transitive description: @@ -729,42 +737,42 @@ packages: dependency: "direct main" description: name: sqflite - sha256: "564cfed0746fe53140c23b70b308e045c3b31f17778f2f326ccb7d804ea0250a" + sha256: "58a799e6ac17dd32fbab93813d39ed835a75ccc0f8f85b8955fe318c6712b082" url: "https://pub.flutter-io.cn" source: hosted - version: "2.4.2+1" + version: "2.4.3" sqflite_android: dependency: transitive description: name: sqflite_android - sha256: "881e28efdcc9950fd8e9bb42713dcf1103e62a2e7168f23c9338d82db13dec40" + sha256: d0548f9d7422a2dae99ec6f8b0a3074463b132d216fa5ba0d230eeefc901983b url: "https://pub.flutter-io.cn" source: hosted - version: "2.4.2+3" + version: "2.4.3" sqflite_common: dependency: transitive description: name: sqflite_common - sha256: "1581ffbf7a0e333b380d6a30737d78516b826cb35beb7fb0bf8a3ea0c678b465" + sha256: "5bf6a55c166e73bf651ba7ec3ed486e577620e3dc8f3a9c6a258a8031b624590" url: "https://pub.flutter-io.cn" source: hosted - version: "2.5.8" + version: "2.5.11" sqflite_darwin: dependency: transitive description: name: sqflite_darwin - sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3" + sha256: c86ca18b8f666bbf903924687fe21cc16fc385d086005067e26619ca530bef9f url: "https://pub.flutter-io.cn" source: hosted - version: "2.4.2" + version: "2.4.3+1" sqflite_platform_interface: dependency: transitive description: name: sqflite_platform_interface - sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" + sha256: f84939f84350d92d04416f8bc4dc52d3896aec7716cc9e80cf0146342139dc50 url: "https://pub.flutter-io.cn" source: hosted - version: "2.4.0" + version: "2.4.1" stack_trace: dependency: transitive description: @@ -793,10 +801,10 @@ packages: dependency: transitive description: name: synchronized - sha256: "63896c27e81b28f8cb4e69ead0d3e8f03f1d1e5fc531a3e579cabed6a2c7c9e5" + sha256: "93b153dcb6a26dcddee6ca087dd634b53e38c10b5aa163e8e49501a776456153" url: "https://pub.flutter-io.cn" source: hosted - version: "3.4.0+1" + version: "3.4.1" term_glyph: dependency: transitive description: @@ -817,10 +825,10 @@ packages: dependency: transitive description: name: timezone - sha256: "784a5e34d2eb62e1326f24d6f600aaaee452eb8ca8ef2f384a59244e292d158b" + sha256: "981d1020d6ef8fe1e7b3de5054e5b25579ae7c403d7734adc508ffc47668e9cb" url: "https://pub.flutter-io.cn" source: hosted - version: "0.11.0" + version: "0.11.1" typed_data: dependency: transitive description: @@ -833,10 +841,10 @@ packages: dependency: transitive description: name: uuid - sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" url: "https://pub.flutter-io.cn" source: hosted - version: "4.5.3" + version: "4.6.0" vector_math: dependency: transitive description: @@ -902,5 +910,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.11.0 <4.0.0" - flutter: ">=3.41.0" + dart: ">=3.12.0 <4.0.0" + flutter: ">=3.44.0" diff --git a/pubspec.yaml b/pubspec.yaml index c422a71..f8a9020 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -37,7 +37,7 @@ dependencies: sqflite: ^2.4.2 path: ^1.9.1 flutter_background_service: ^5.1.0 - flutter_local_notifications: ^21.0.0 + flutter_local_notifications: ^22.1.0 device_info_plus: ^13.1.0 package_info_plus: ^10.1.0 file_picker: ^12.0.0-beta.5 From 03e7db6146572352a999242b4750d9604f507d8b Mon Sep 17 00:00:00 2001 From: Merack Date: Mon, 3 Aug 2026 18:25:37 +0800 Subject: [PATCH 2/3] feat: improve notification sending --- lib/config/storage_keys.dart | 43 ++++++ lib/main.dart | 4 + lib/page/home/controller.dart | 27 +++- .../notification_settings/controller.dart | 66 +++++++++ lib/page/notification_settings/state.dart | 8 ++ lib/page/notification_settings/view.dart | 98 ++++++++++++++ lib/page/setting/view.dart | 11 +- lib/page/setting/widgets/permission_row.dart | 81 ++++++++++++ .../setting/widgets/permission_settings.dart | 120 ++++------------- lib/page/setting/widgets/widgets.dart | 1 + lib/route/route_name.dart | 1 + lib/route/route_page.dart | 2 + lib/service/notification_service.dart | 125 ++++++++++++++++++ 13 files changed, 487 insertions(+), 100 deletions(-) create mode 100644 lib/page/notification_settings/controller.dart create mode 100644 lib/page/notification_settings/state.dart create mode 100644 lib/page/notification_settings/view.dart create mode 100644 lib/page/setting/widgets/permission_row.dart create mode 100644 lib/service/notification_service.dart diff --git a/lib/config/storage_keys.dart b/lib/config/storage_keys.dart index f0f79ac..acd3604 100644 --- a/lib/config/storage_keys.dart +++ b/lib/config/storage_keys.dart @@ -97,4 +97,47 @@ class StorageKeys { static String soundValueKey(String eventId) => 'sound_${eventId}_value'; static String soundDisplayNameKey(String eventId) => 'sound_${eventId}_display_name'; + // ===== 事件提醒通知 ===== + // 复用上面的 soundEventIds 作为通知事件集合, 不另建一套事件体系. + // 暂停/从暂停恢复不属于事件, 因此不会发通知. + + /// 事件提醒通知渠道 ID + /// + /// 注意: Android 通知渠道创建后其重要性/声音/振动均不可再由代码修改, + /// 只能通过更换渠道 ID 或用户清除应用数据来重建. 改动这些属性前务必确认. + static const String notificationChannelEvents = 'time_machine_events'; + static const String notificationChannelEventsName = 'Time Machine 计时提醒'; + static const String notificationChannelEventsDesc = '专注完成、休息结束等计时事件提醒'; + + /// 各事件通知 ID(与前台常驻通知的 1048596 区分开) + /// 每个事件固定一个 ID, 重复触发时替换自身旧通知而不是堆叠 + static const Map notificationIds = { + soundEventMicroBreakStart: 1048601, + soundEventMicroBreakComplete: 1048602, + soundEventFocusComplete: 1048603, + soundEventBreakComplete: 1048604, + }; + + /// 通知标题(默认取 eventDisplayNames, 这里只放需要区别于展示名的) + static const Map notificationTitles = { + soundEventMicroBreakStart: '微休息开始', + soundEventMicroBreakComplete: '微休息结束', + soundEventFocusComplete: '专注完成', + soundEventBreakComplete: '休息结束', + }; + + /// 通知正文 + static const Map notificationBodies = { + soundEventMicroBreakStart: '抬头远眺, 放松一下眼睛', + soundEventMicroBreakComplete: '微休息结束, 回到专注', + soundEventFocusComplete: '本轮专注已完成, 休息一下吧', + soundEventBreakComplete: '休息结束, 准备开始下一轮专注', + }; + + /// 事件通知开关默认值: 全部关闭, 由用户按需开启 + static const bool defaultNotificationEnabled = false; + + /// 事件通知开关 MMKV 键 + static String notificationEnabledKey(String eventId) => + 'notify_${eventId}_enabled'; } diff --git a/lib/main.dart b/lib/main.dart index 5dae6d8..2959739 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -11,6 +11,7 @@ import 'package:time_machine/service/database_service.dart'; import 'package:time_machine/service/background_timer_service.dart'; import 'package:time_machine/service/permission_service.dart'; import 'package:time_machine/service/custom_sound_storage_service.dart'; +import 'package:time_machine/service/notification_service.dart'; import 'package:time_machine/service/ringtone_picker_service.dart'; import 'package:time_machine/theme/theme_controller.dart'; import 'package:time_machine/theme/app_themes.dart'; @@ -80,6 +81,9 @@ Future initServices() async { // 权限管理服务 await Get.putAsync(() => PermissionService().init()); + // 计时事件提醒通知服务 + await Get.putAsync(() => NotificationService().init()); + // 自定义提示音文件管理 await Get.putAsync(() => CustomSoundStorageService().init()); diff --git a/lib/page/home/controller.dart b/lib/page/home/controller.dart index 97a9fe3..67faaff 100644 --- a/lib/page/home/controller.dart +++ b/lib/page/home/controller.dart @@ -7,6 +7,7 @@ import 'package:mmkv/mmkv.dart'; import '../../dao/focus_session_dao.dart'; import '../../model/focus_session_model.dart'; import '../../service/app_storage_service.dart'; +import '../../service/notification_service.dart'; import '../../service/ringtone_picker_service.dart'; import '../../config/storage_keys.dart'; import 'state.dart'; @@ -287,7 +288,7 @@ class HomeController extends GetxController { void _handleMicroBreakStart() { Get.log("微休息开始"); // 播放微休息开始音效 - _playAudio(StorageKeys.soundEventMicroBreakStart); + _notifyEvent(StorageKeys.soundEventMicroBreakStart); // 进入微休息状态 state.timerStatus.value = TimerStatus.microBreak; @@ -306,7 +307,7 @@ class HomeController extends GetxController { Get.log("微休息结束"); // 播放微休息结束音效 - _playAudio(StorageKeys.soundEventMicroBreakComplete); + _notifyEvent(StorageKeys.soundEventMicroBreakComplete); // 更新状态 state.timerStatus.value = TimerStatus.focus; @@ -353,7 +354,7 @@ class HomeController extends GetxController { _recordFocusSession(); // 播放专注完成音效 - _playAudio(StorageKeys.soundEventFocusComplete); + _notifyEvent(StorageKeys.soundEventFocusComplete); // 增加完成周期数(随机提示音模式) state.completedRandomCycles.value++; @@ -380,7 +381,7 @@ class HomeController extends GetxController { void _completeBigBreak() { // 播放大休息结束音效 if (state.bigBreakTimeSeconds.value != 0) { - _playAudio(StorageKeys.soundEventBreakComplete); + _notifyEvent(StorageKeys.soundEventBreakComplete); } if (state.autoStartNextFocus.value) { @@ -399,7 +400,7 @@ class HomeController extends GetxController { _recordFocusSession(); // 播放专注完成音效 - _playAudio(StorageKeys.soundEventFocusComplete); + _notifyEvent(StorageKeys.soundEventFocusComplete); // 增加完成周期数和番茄计数 state.completedPomodoroCycles.value++; @@ -432,7 +433,7 @@ class HomeController extends GetxController { /// 完成番茄短休息 void _completeShortBreak() { - _playAudio(StorageKeys.soundEventBreakComplete); + _notifyEvent(StorageKeys.soundEventBreakComplete); if (state.autoStartNextFocus.value) { resetTimer(); @@ -506,6 +507,20 @@ class HomeController extends GetxController { state.totalTime.value = focusTime; } + /// 触发计时事件的用户提醒(提示音 + 通知栏提醒) + /// + /// 两者共用同一套 eventId, 但开关互相独立: 提示音一直会响, + /// 通知则由用户在通知设置页按事件开启(默认全部关闭). + /// 暂停和从暂停恢复不是计时事件, 不走这里, 因此不会发通知. + void _notifyEvent(String eventId) { + _playAudio(eventId); + + // 通知发送是异步的且失败不影响计时, 这里不 await + if (Get.isRegistered()) { + Get.find().showEventNotification(eventId); + } + } + /// 播放音频 /// [eventId] 来自 StorageKeys.soundEventIds void _playAudio(String eventId) async { diff --git a/lib/page/notification_settings/controller.dart b/lib/page/notification_settings/controller.dart new file mode 100644 index 0000000..cb5d981 --- /dev/null +++ b/lib/page/notification_settings/controller.dart @@ -0,0 +1,66 @@ +import 'package:get/get.dart'; +import 'package:permission_handler/permission_handler.dart'; + +import '../../config/storage_keys.dart'; +import '../../service/notification_service.dart'; +import '../../service/permission_service.dart'; +import '../../utils/toast_util.dart'; +import 'state.dart'; + +class NotificationSettingsController extends GetxController { + final NotificationSettingsState state = NotificationSettingsState(); + + late final NotificationService _notificationService; + late final PermissionService _permissionService; + + PermissionService get permissionService => _permissionService; + + @override + void onInit() { + super.onInit(); + _notificationService = Get.find(); + _permissionService = Get.find(); + _loadAll(); + } + + void _loadAll() { + for (final eventId in StorageKeys.soundEventIds) { + state.enabled[eventId] = _notificationService.isEventEnabled(eventId); + } + } + + /// 切换某事件的通知开关 + /// + /// 开关立即写入 MMKV(与设置页的"保存"按钮无关, 这里是即时生效的). + /// 打开开关时如果系统通知权限还没给, 顺手引导用户去授权, 否则开了也收不到. + Future toggleEvent(String eventId, bool value) async { + _notificationService.setEventEnabled(eventId, value); + state.enabled[eventId] = value; + + if (!value) return; + + final status = _permissionService.notificationStatus.value; + if (status.isGranted) return; + + if (status.isPermanentlyDenied) { + ToastUtil.show('通知权限未开启', '请在系统设置中允许通知, 否则收不到提醒'); + return; + } + + final granted = await _permissionService.requestNotification(); + if (!granted) { + ToastUtil.show('通知权限未开启', '未获得通知权限, 开关已保存但暂时收不到提醒'); + } + } + + /// 请求通知权限(权限行按钮) + Future handlePermissionAction() async { + final status = _permissionService.notificationStatus.value; + if (status.isPermanentlyDenied) { + ToastUtil.show('需要在系统设置中手动开启', '权限已被永久拒绝, 即将跳转到应用设置'); + await _permissionService.openSettings(); + return; + } + await _permissionService.requestNotification(); + } +} diff --git a/lib/page/notification_settings/state.dart b/lib/page/notification_settings/state.dart new file mode 100644 index 0000000..c536f4e --- /dev/null +++ b/lib/page/notification_settings/state.dart @@ -0,0 +1,8 @@ +import 'package:get/get.dart'; + +class NotificationSettingsState { + /// eventId -> 是否开启通知 + final RxMap enabled = {}.obs; + + NotificationSettingsState(); +} diff --git a/lib/page/notification_settings/view.dart b/lib/page/notification_settings/view.dart new file mode 100644 index 0000000..3c9e989 --- /dev/null +++ b/lib/page/notification_settings/view.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +import '../../config/storage_keys.dart'; +import '../setting/widgets/permission_row.dart'; +import '../setting/widgets/setting_divider.dart'; +import '../setting/widgets/setting_section.dart'; +import '../setting/widgets/setting_switch.dart'; +import '../setting/widgets/setting_tile.dart'; +import 'controller.dart'; + +class NotificationSettingsPage extends StatelessWidget { + const NotificationSettingsPage({super.key}); + + @override + Widget build(BuildContext context) { + final controller = Get.isRegistered() + ? Get.find() + : Get.put(NotificationSettingsController()); + final theme = Theme.of(context); + + return Scaffold( + appBar: AppBar( + title: Text( + '通知设置', + style: theme.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w600), + ), + centerTitle: true, + ), + body: SafeArea( + child: ListView( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 16.0), + children: [ + SettingSection( + title: '权限', + children: [ + Obx(() { + final perm = controller.permissionService; + return PermissionRow( + title: '通知权限', + subtitle: '用于专注完成、休息结束等通知栏提醒', + status: perm.notificationStatus.value, + describe: perm.describeStatus(perm.notificationStatus.value), + onAction: controller.handlePermissionAction, + ); + }), + ], + ), + const SizedBox(height: 24), + SettingSection( + title: '事件提醒通知', + children: [ + for (int i = 0; i < StorageKeys.soundEventIds.length; i++) ...[ + _buildEventSwitch(controller, StorageKeys.soundEventIds[i]), + if (i != StorageKeys.soundEventIds.length - 1) + const SettingDivider(), + ], + ], + ), + const SizedBox(height: 24), + SettingSection( + title: '说明', + children: [ + Padding( + padding: const EdgeInsets.all(20), + child: Text( + '• 所有事件通知默认关闭, 按需开启\n' + '• 通知会带系统提示音和振动, 与应用自身的提示音同时触发\n' + '• 微休息在随机提示音模式下每几分钟触发一次, 开启后通知会比较频繁\n' + '• 暂停和从暂停恢复不发送通知\n' + '• 开关改动立即生效, 无需点击保存', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + height: 1.6, + ), + ), + ), + ], + ), + const SizedBox(height: 24), + ], + ), + ), + ); + } + + Widget _buildEventSwitch( + NotificationSettingsController controller, String eventId) { + return SettingTile( + title: StorageKeys.eventDisplayNames[eventId] ?? eventId, + subtitle: StorageKeys.notificationBodies[eventId] ?? '发送通知栏提醒', + trailing: Obx(() => SettingSwitch( + value: controller.state.enabled[eventId] ?? false, + onChanged: (value) => controller.toggleEvent(eventId, value), + )), + ); + } +} diff --git a/lib/page/setting/view.dart b/lib/page/setting/view.dart index 447b00c..24b7b92 100644 --- a/lib/page/setting/view.dart +++ b/lib/page/setting/view.dart @@ -141,9 +141,9 @@ class SettingPage extends StatelessWidget { const SizedBox(height: 24), - // 提示音设置入口(进入二级页面) + // 提示音与通知入口(进入二级页面) SettingSection( - title: '提示音', + title: '提示音与通知', children: [ SettingTile( title: '提示音设置', @@ -151,6 +151,13 @@ class SettingPage extends StatelessWidget { trailing: const Icon(Icons.chevron_right), onTap: () => Get.toNamed(AppRoutes.SOUND_SETTINGS), ), + const SettingDivider(), + SettingTile( + title: '通知设置', + subtitle: '通知权限, 以及各计时事件的通知栏提醒开关', + trailing: const Icon(Icons.chevron_right), + onTap: () => Get.toNamed(AppRoutes.NOTIFICATION_SETTINGS), + ), ], ), diff --git a/lib/page/setting/widgets/permission_row.dart b/lib/page/setting/widgets/permission_row.dart new file mode 100644 index 0000000..d66d523 --- /dev/null +++ b/lib/page/setting/widgets/permission_row.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; +import 'package:permission_handler/permission_handler.dart'; + +import 'setting_tile.dart'; + +/// 权限设置行 +/// +/// 已授权时右侧显示绿色状态徽章, 未授权显示操作按钮; +/// 永久拒绝时按钮变为错误色的"去设置", 由调用方决定跳转逻辑. +/// 设置页和通知设置页共用. +class PermissionRow extends StatelessWidget { + const PermissionRow({ + super.key, + required this.title, + required this.subtitle, + required this.status, + required this.describe, + required this.onAction, + }); + + final String title; + final String subtitle; + final PermissionStatus status; + final String describe; + final VoidCallback onAction; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final granted = status.isGranted; + + return SettingTile( + title: title, + subtitle: subtitle, + trailing: granted + ? _StatusBadge(text: describe, color: Colors.green.shade600) + : ElevatedButton( + onPressed: onAction, + style: ElevatedButton.styleFrom( + backgroundColor: status.isPermanentlyDenied + ? theme.colorScheme.error + : null, + foregroundColor: status.isPermanentlyDenied + ? theme.colorScheme.onError + : null, + ), + child: Text(status.isPermanentlyDenied ? '去设置' : '去授权'), + ), + ); + } +} + +class _StatusBadge extends StatelessWidget { + const _StatusBadge({required this.text, required this.color}); + + final String text; + final Color color; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: color.withValues(alpha: 0.3)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.check_circle, size: 14, color: color), + const SizedBox(width: 4), + Text( + text, + style: TextStyle(color: color, fontSize: 12, fontWeight: FontWeight.w600), + ), + ], + ), + ); + } +} diff --git a/lib/page/setting/widgets/permission_settings.dart b/lib/page/setting/widgets/permission_settings.dart index 71f86d3..b23042f 100644 --- a/lib/page/setting/widgets/permission_settings.dart +++ b/lib/page/setting/widgets/permission_settings.dart @@ -4,10 +4,13 @@ import 'package:permission_handler/permission_handler.dart'; import 'package:time_machine/utils/toast_util.dart'; import '../../../service/permission_service.dart'; -import 'setting_tile.dart'; +import 'permission_row.dart'; import 'setting_divider.dart'; /// 权限管理分组 +/// +/// 通知权限已移到通知设置二级页面(与各事件的通知开关放在一起), +/// 这里只保留存储和电池优化两项. class PermissionSettings extends StatelessWidget { const PermissionSettings({super.key}); @@ -17,15 +20,7 @@ class PermissionSettings extends StatelessWidget { return Column( children: [ - Obx(() => _PermissionRow( - title: '通知权限', - subtitle: '用于专注完成、休息开始等通知栏提醒', - status: perm.notificationStatus.value, - describe: perm.describeStatus(perm.notificationStatus.value), - onAction: () => _handleNotificationAction(perm), - )), - const SettingDivider(), - Obx(() => _PermissionRow( + Obx(() => PermissionRow( title: '存储权限', subtitle: '用于备份恢复数据到 Download 目录,以及读取自定义提示音', status: perm.storageStatus.value, @@ -33,26 +28,38 @@ class PermissionSettings extends StatelessWidget { onAction: () => _handleStorageAction(perm), )), const SettingDivider(), - Obx(() => _PermissionRow( + Obx(() => PermissionRow( title: '电池优化白名单', - subtitle: '将本应用加入白名单, 确保后台计时不被系统终止. ' - '本应用后台仅运行计时器, 耗电极低', + subtitle: '将本应用加入白名单, 确保后台计时不被系统终止', status: perm.batteryStatus.value, describe: perm.describeStatus(perm.batteryStatus.value), onAction: () => _handleBatteryAction(perm), )), + _buildBatteryHint(context), ], ); } - Future _handleNotificationAction(PermissionService perm) async { - final s = perm.notificationStatus.value; - if (s.isPermanentlyDenied) { - _showOpenSettingsHint(); - await perm.openSettings(); - return; - } - await perm.requestNotification(); + /// 电池优化说明 + /// + /// 各家 ROM 对 REQUEST_IGNORE_BATTERY_OPTIMIZATIONS 的处理差异很大, + /// 系统对话框返回成功也不代表后台真的不会被杀, 所以这里明确引导用户手动设置. + Widget _buildBatteryHint(BuildContext context) { + final theme = Theme.of(context); + + return Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 16), + child: Text( + '在此处请求的电池优化权限, 即使显示授权成功, 在某些系统上也可能并未真正生效. ' + '强烈建议手动在系统设置里搜索"电池优化", 将本应用加入白名单; ' + '同时在手机管家中把本应用的后台运行设为允许.\n' + '本应用后台只维护必要的计时器, 耗电量极小.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + height: 1.6, + ), + ), + ); } Future _handleStorageAction(PermissionService perm) async { @@ -79,74 +86,3 @@ class PermissionSettings extends StatelessWidget { ToastUtil.show('需要在系统设置中手动开启', '权限已被永久拒绝, 即将跳转到应用设置'); } } - -class _PermissionRow extends StatelessWidget { - const _PermissionRow({ - required this.title, - required this.subtitle, - required this.status, - required this.describe, - required this.onAction, - }); - - final String title; - final String subtitle; - final PermissionStatus status; - final String describe; - final VoidCallback onAction; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final granted = status.isGranted; - - return SettingTile( - title: title, - subtitle: subtitle, - trailing: granted - ? _StatusBadge(text: describe, color: Colors.green.shade600) - : ElevatedButton( - onPressed: onAction, - style: ElevatedButton.styleFrom( - backgroundColor: status.isPermanentlyDenied - ? theme.colorScheme.error - : null, - foregroundColor: status.isPermanentlyDenied - ? theme.colorScheme.onError - : null, - ), - child: Text(status.isPermanentlyDenied ? '去设置' : '去授权'), - ), - ); - } -} - -class _StatusBadge extends StatelessWidget { - const _StatusBadge({required this.text, required this.color}); - - final String text; - final Color color; - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: color.withValues(alpha: 0.3)), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.check_circle, size: 14, color: color), - const SizedBox(width: 4), - Text( - text, - style: TextStyle(color: color, fontSize: 12, fontWeight: FontWeight.w600), - ), - ], - ), - ); - } -} diff --git a/lib/page/setting/widgets/widgets.dart b/lib/page/setting/widgets/widgets.dart index 497052b..ecac33d 100644 --- a/lib/page/setting/widgets/widgets.dart +++ b/lib/page/setting/widgets/widgets.dart @@ -11,3 +11,4 @@ export 'developer_settings.dart'; export 'data_settings.dart'; export 'pomodoro_settings.dart'; export 'permission_settings.dart'; +export 'permission_row.dart'; diff --git a/lib/route/route_name.dart b/lib/route/route_name.dart index 18ceddc..79dcbe6 100644 --- a/lib/route/route_name.dart +++ b/lib/route/route_name.dart @@ -10,6 +10,7 @@ abstract class AppRoutes { static const String HOME = '/home'; static const String SETTING = '/setting'; static const String SOUND_SETTINGS = '/sound_settings'; + static const String NOTIFICATION_SETTINGS = '/notification_settings'; static const String OTHER = "/other"; static const String ABOUT = '/about'; static const String DONATE = '/donate'; diff --git a/lib/route/route_page.dart b/lib/route/route_page.dart index a1a71e9..83e677e 100644 --- a/lib/route/route_page.dart +++ b/lib/route/route_page.dart @@ -6,6 +6,7 @@ import '../page/main/view.dart'; import '../page/home/view.dart'; import '../page/setting/view.dart'; import '../page/sound_settings/view.dart'; +import '../page/notification_settings/view.dart'; import '../page/about/view.dart'; import '../page/donate/view.dart'; @@ -19,6 +20,7 @@ class AppPages { GetPage(name: AppRoutes.HOME, page: () => HomePage()), GetPage(name: AppRoutes.SETTING, page: () => SettingPage()), GetPage(name: AppRoutes.SOUND_SETTINGS, page: () => const SoundSettingsPage()), + GetPage(name: AppRoutes.NOTIFICATION_SETTINGS, page: () => const NotificationSettingsPage()), GetPage(name: AppRoutes.OTHER, page: () => Other()), GetPage(name: AppRoutes.ABOUT, page: () => const AboutPage()), GetPage(name: AppRoutes.DONATE, page: () => const DonatePage()), diff --git a/lib/service/notification_service.dart b/lib/service/notification_service.dart new file mode 100644 index 0000000..799b5c7 --- /dev/null +++ b/lib/service/notification_service.dart @@ -0,0 +1,125 @@ +import 'dart:io'; + +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:get/get.dart'; +import 'package:mmkv/mmkv.dart'; +import 'package:permission_handler/permission_handler.dart'; + +import '../config/storage_keys.dart'; +import 'app_storage_service.dart'; + +/// 计时事件提醒通知服务 +/// +/// 只负责"事件提醒"这一类通知(专注完成、休息结束等), 走独立的高优先级渠道. +/// 后台服务的前台常驻通知由 BackgroundTimerService 自己管理, 两者互不干扰: +/// - 常驻通知渠道 time_machine_timer: Importance.low, 静音, 不振动 +/// - 事件提醒渠道 time_machine_events: Importance.high, 默认声音 + 振动 +class NotificationService extends GetxService { + final FlutterLocalNotificationsPlugin _plugin = + FlutterLocalNotificationsPlugin(); + + late final MMKV _storage; + + /// 插件是否初始化成功, 失败时所有发送请求静默跳过 + bool _initialized = false; + + Future init() async { + _storage = Get.find().mmkv; + + try { + // 复用 launcher 图标, 项目暂未提供专用的通知小图标 + const settings = InitializationSettings( + android: AndroidInitializationSettings('@mipmap/ic_launcher'), + ); + await _plugin.initialize(settings: settings); + + await _createEventsChannel(); + _initialized = true; + Get.log('NotificationService initialized'); + } catch (e) { + Get.log('NotificationService 初始化失败: $e'); + } + + return this; + } + + /// 创建事件提醒渠道 + /// + /// 渠道属性创建后不可由代码修改, 详见 StorageKeys.notificationChannelEvents 注释. + /// 这里显式写出 playSound/enableVibration, 避免后续读代码时需要去查插件默认值. + Future _createEventsChannel() async { + if (!Platform.isAndroid) return; + + const channel = AndroidNotificationChannel( + StorageKeys.notificationChannelEvents, + StorageKeys.notificationChannelEventsName, + description: StorageKeys.notificationChannelEventsDesc, + importance: Importance.high, + playSound: true, + enableVibration: true, + ); + + await _plugin + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin>() + ?.createNotificationChannel(channel); + } + + /// 读取某事件的通知开关 + bool isEventEnabled(String eventId) { + return _storage.decodeBool( + StorageKeys.notificationEnabledKey(eventId), + defaultValue: StorageKeys.defaultNotificationEnabled, + ); + } + + /// 写入某事件的通知开关 + void setEventEnabled(String eventId, bool enabled) { + _storage.encodeBool(StorageKeys.notificationEnabledKey(eventId), enabled); + } + + /// 发送计时事件提醒通知 + /// + /// [eventId] 取自 StorageKeys.soundEventIds. + /// 开关关闭、权限未授予或插件初始化失败时静默跳过, 不打扰计时主流程. + Future showEventNotification(String eventId) async { + if (!_initialized || !Platform.isAndroid) return; + if (!isEventEnabled(eventId)) return; + + // 用户可能在设置里开了开关但系统层面没给权限, 这里兜一层 + if (!await Permission.notification.isGranted) { + Get.log('通知权限未授予, 跳过事件通知: $eventId'); + return; + } + + final id = StorageKeys.notificationIds[eventId]; + if (id == null) { + Get.log('未知的通知事件: $eventId'); + return; + } + + const androidDetails = AndroidNotificationDetails( + StorageKeys.notificationChannelEvents, + StorageKeys.notificationChannelEventsName, + channelDescription: StorageKeys.notificationChannelEventsDesc, + importance: Importance.high, + priority: Priority.high, + // 点击后自动消失, 用户不需要手动清 + autoCancel: true, + ); + + try { + await _plugin.show( + id: id, + title: StorageKeys.notificationTitles[eventId] ?? + StorageKeys.eventDisplayNames[eventId] ?? + 'Time Machine', + body: StorageKeys.notificationBodies[eventId], + notificationDetails: + const NotificationDetails(android: androidDetails), + ); + } catch (e) { + Get.log('发送事件通知失败($eventId): $e'); + } + } +} From b18a39d573a7d3f76e8a5fe92f05b91212a82cc2 Mon Sep 17 00:00:00 2001 From: Merack Date: Mon, 3 Aug 2026 19:04:29 +0800 Subject: [PATCH 3/3] ui: adjust some explanation text --- lib/page/notification_settings/view.dart | 1 - lib/page/setting/widgets/permission_settings.dart | 5 +++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/page/notification_settings/view.dart b/lib/page/notification_settings/view.dart index 3c9e989..f9c0f50 100644 --- a/lib/page/notification_settings/view.dart +++ b/lib/page/notification_settings/view.dart @@ -64,7 +64,6 @@ class NotificationSettingsPage extends StatelessWidget { Padding( padding: const EdgeInsets.all(20), child: Text( - '• 所有事件通知默认关闭, 按需开启\n' '• 通知会带系统提示音和振动, 与应用自身的提示音同时触发\n' '• 微休息在随机提示音模式下每几分钟触发一次, 开启后通知会比较频繁\n' '• 暂停和从暂停恢复不发送通知\n' diff --git a/lib/page/setting/widgets/permission_settings.dart b/lib/page/setting/widgets/permission_settings.dart index b23042f..c14e5f8 100644 --- a/lib/page/setting/widgets/permission_settings.dart +++ b/lib/page/setting/widgets/permission_settings.dart @@ -50,10 +50,11 @@ class PermissionSettings extends StatelessWidget { return Padding( padding: const EdgeInsets.fromLTRB(20, 0, 20, 16), child: Text( - '在此处请求的电池优化权限, 即使显示授权成功, 在某些系统上也可能并未真正生效. ' + '重要说明: \n' + '• 在此处请求的电池优化权限, 即使显示授权成功, 在某些系统上也可能并未真正生效. ' '强烈建议手动在系统设置里搜索"电池优化", 将本应用加入白名单; ' '同时在手机管家中把本应用的后台运行设为允许.\n' - '本应用后台只维护必要的计时器, 耗电量极小.', + '• 本应用后台运行只维护必要的计时器, 耗电量极小.', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, height: 1.6,