Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,5 @@ macos/Flutter/GeneratedPluginRegistrant.swift

.claude
CLAUDE.md
data/
data/
.codebase-memory
43 changes: 43 additions & 0 deletions lib/config/storage_keys.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, int> notificationIds = {
soundEventMicroBreakStart: 1048601,
soundEventMicroBreakComplete: 1048602,
soundEventFocusComplete: 1048603,
soundEventBreakComplete: 1048604,
};

/// 通知标题(默认取 eventDisplayNames, 这里只放需要区别于展示名的)
static const Map<String, String> notificationTitles = {
soundEventMicroBreakStart: '微休息开始',
soundEventMicroBreakComplete: '微休息结束',
soundEventFocusComplete: '专注完成',
soundEventBreakComplete: '休息结束',
};

/// 通知正文
static const Map<String, String> notificationBodies = {
soundEventMicroBreakStart: '抬头远眺, 放松一下眼睛',
soundEventMicroBreakComplete: '微休息结束, 回到专注',
soundEventFocusComplete: '本轮专注已完成, 休息一下吧',
soundEventBreakComplete: '休息结束, 准备开始下一轮专注',
};

/// 事件通知开关默认值: 全部关闭, 由用户按需开启
static const bool defaultNotificationEnabled = false;

/// 事件通知开关 MMKV 键
static String notificationEnabledKey(String eventId) =>
'notify_${eventId}_enabled';
}
4 changes: 4 additions & 0 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -80,6 +81,9 @@ Future<void> initServices() async {
// 权限管理服务
await Get.putAsync(() => PermissionService().init());

// 计时事件提醒通知服务
await Get.putAsync(() => NotificationService().init());

// 自定义提示音文件管理
await Get.putAsync(() => CustomSoundStorageService().init());

Expand Down
27 changes: 21 additions & 6 deletions lib/page/home/controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -287,7 +288,7 @@ class HomeController extends GetxController {
void _handleMicroBreakStart() {
Get.log("微休息开始");
// 播放微休息开始音效
_playAudio(StorageKeys.soundEventMicroBreakStart);
_notifyEvent(StorageKeys.soundEventMicroBreakStart);

// 进入微休息状态
state.timerStatus.value = TimerStatus.microBreak;
Expand All @@ -306,7 +307,7 @@ class HomeController extends GetxController {
Get.log("微休息结束");

// 播放微休息结束音效
_playAudio(StorageKeys.soundEventMicroBreakComplete);
_notifyEvent(StorageKeys.soundEventMicroBreakComplete);

// 更新状态
state.timerStatus.value = TimerStatus.focus;
Expand Down Expand Up @@ -353,7 +354,7 @@ class HomeController extends GetxController {
_recordFocusSession();

// 播放专注完成音效
_playAudio(StorageKeys.soundEventFocusComplete);
_notifyEvent(StorageKeys.soundEventFocusComplete);

// 增加完成周期数(随机提示音模式)
state.completedRandomCycles.value++;
Expand All @@ -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) {
Expand All @@ -399,7 +400,7 @@ class HomeController extends GetxController {
_recordFocusSession();

// 播放专注完成音效
_playAudio(StorageKeys.soundEventFocusComplete);
_notifyEvent(StorageKeys.soundEventFocusComplete);

// 增加完成周期数和番茄计数
state.completedPomodoroCycles.value++;
Expand Down Expand Up @@ -432,7 +433,7 @@ class HomeController extends GetxController {

/// 完成番茄短休息
void _completeShortBreak() {
_playAudio(StorageKeys.soundEventBreakComplete);
_notifyEvent(StorageKeys.soundEventBreakComplete);

if (state.autoStartNextFocus.value) {
resetTimer();
Expand Down Expand Up @@ -506,6 +507,20 @@ class HomeController extends GetxController {
state.totalTime.value = focusTime;
}

/// 触发计时事件的用户提醒(提示音 + 通知栏提醒)
///
/// 两者共用同一套 eventId, 但开关互相独立: 提示音一直会响,
/// 通知则由用户在通知设置页按事件开启(默认全部关闭).
/// 暂停和从暂停恢复不是计时事件, 不走这里, 因此不会发通知.
void _notifyEvent(String eventId) {
_playAudio(eventId);

// 通知发送是异步的且失败不影响计时, 这里不 await
if (Get.isRegistered<NotificationService>()) {
Get.find<NotificationService>().showEventNotification(eventId);
}
}

/// 播放音频
/// [eventId] 来自 StorageKeys.soundEventIds
void _playAudio(String eventId) async {
Expand Down
66 changes: 66 additions & 0 deletions lib/page/notification_settings/controller.dart
Original file line number Diff line number Diff line change
@@ -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<NotificationService>();
_permissionService = Get.find<PermissionService>();
_loadAll();
}

void _loadAll() {
for (final eventId in StorageKeys.soundEventIds) {
state.enabled[eventId] = _notificationService.isEventEnabled(eventId);
}
}

/// 切换某事件的通知开关
///
/// 开关立即写入 MMKV(与设置页的"保存"按钮无关, 这里是即时生效的).
/// 打开开关时如果系统通知权限还没给, 顺手引导用户去授权, 否则开了也收不到.
Future<void> 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<void> handlePermissionAction() async {
final status = _permissionService.notificationStatus.value;
if (status.isPermanentlyDenied) {
ToastUtil.show('需要在系统设置中手动开启', '权限已被永久拒绝, 即将跳转到应用设置');
await _permissionService.openSettings();
return;
}
await _permissionService.requestNotification();
}
}
8 changes: 8 additions & 0 deletions lib/page/notification_settings/state.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import 'package:get/get.dart';

class NotificationSettingsState {
/// eventId -> 是否开启通知
final RxMap<String, bool> enabled = <String, bool>{}.obs;

NotificationSettingsState();
}
97 changes: 97 additions & 0 deletions lib/page/notification_settings/view.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
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<NotificationSettingsController>()
? Get.find<NotificationSettingsController>()
: 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'
'• 开关改动立即生效, 无需点击保存',
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),
)),
);
}
}
11 changes: 9 additions & 2 deletions lib/page/setting/view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -141,16 +141,23 @@ class SettingPage extends StatelessWidget {

const SizedBox(height: 24),

// 提示音设置入口(进入二级页面)
// 提示音与通知入口(进入二级页面)
SettingSection(
title: '提示音',
title: '提示音与通知',
children: [
SettingTile(
title: '提示音设置',
subtitle: '为各计时事件单独选择提示音',
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),
),
],
),

Expand Down
Loading
Loading