Files
SyncCalendar/entry/src/main/ets/common/ReminderService.ets
T
laoyang 413900bbce feat(待办): 提醒 / 左滑删除 / 优先级标签地点 / 编辑热区 / 卡片刷新 (#38 #39 #40 #41 #42)
一次性完成待办模块的 5 项缺口。

## #38 待办提醒
`ReminderService` 原本只服务 `kind='event'`,待办完全不提醒。

- `EventDb.queryRemindableTodos`:查未完成待办,**额外要求 `completed=0`**
  (否则做完的待办到期还会弹)
- `refreshReminders` 合并日程 + 待办两路查询,文案按 kind 分支:
  待办是「将在 X 分钟后到期」/`该待办已到期`,日程仍为「开始」
- `IcsUtil.buildTodo` 写 VALARM;`rewriteTodoFields` 编辑时整段重建 VALARM
  (逐行改 TRIGGER 容易漏 ACTION/DESCRIPTION)
- 待办弹层加提醒芯片(不提醒 / 5 / 15 / 30 分钟 / 1 小时 / 1 天,多选)
- 三个写操作后调 `refreshReminders`,保证改完立刻重排

协议要点:VTODO 的 VALARM `TRIGGER` 相对 **DUE**(RFC 5545 §3.8.6.3),
所以 `TRIGGER:-PT5M` 就是"到期前 5 分钟",无需额外写 RELATED=END。

## #39 待办列表左滑删除
`swipeAction` 只能挂 ListItem,而原结构是 `Scroll + Column + ForEach`。

- 外层 Scroll → List,分组改用 `ListItemGroup`(保留"色点+本名+条数"分组头)
- 每个 ListItem 挂 `swipeAction({ end: ... })`,滑出红底「删除」
- 点击复用现有 `askDeleteTodo`(二次确认)→ `doDeleteTodo`(软删 + 服务器 DELETE)
- 未完成 / 已完成两个清单都支持

## #40 优先级 / 标签 / 地点
- 表加 `priority`(INTEGER) / `categories`(TEXT);`location` 本来就有
- PRIORITY 存 **iCal 原生值**(1 最高 … 9 最低,0 未设置),写回零转换
- ICS:`parseTodos` 读 PRIORITY/CATEGORIES,`buildTodo` 与 `rewriteTodoFields` 写
- 新增 `todoExtras()` 供新建与改写共用,避免两处生成结构不一致
- `rewriteTodoFields` 用 `priority=-1` 做"不改"哨兵(0 是合法语义:清空)
- 弹层加优先级芯片(高/中/低)、标签输入、地点输入

## #41 重复待办打勾推进
核查结论:**已实现且完整**。`toggleTodo` 已按 `recurRule !== ''` 分流到
`completeRecurringRound`,且该方法会保留"基准→DUE 偏移"、复位 STATUS 后推进。
本次未改逻辑,仅补了完成后刷新卡片与提醒。

## #42 编辑入口热区
内容区 `Column` 原来高度只到文字高度,行内上下留白点不到。
加 `.height('100%')` + `justifyContent(Center)` 撑满行高。
勾选圈仍是独立 onClick(ArkUI 不冒泡),点圈只切完成态、不进编辑。

## 顺带修复(共性遗漏)
`toggleTodo` / `saveTodoEdit` / `saveNewTodo` / `doDeleteTodo` 四处
改完库只刷 `this.todos`,**都没推送桌面卡片** —— 卡片停在旧数据。
四处统一补 `CardDataService.pushToAllForms`。

## 验证
- 编译 `BUILD SUCCESSFUL`
- 真机 dump 确认弹层新 UI 全部渲染:优先级(高/中/低)、
  提醒(不提醒/提前5分钟/15/30/1小时/1天)、标签、地点
- ICS 生成逻辑用等价实现校验:PRIORITY、CATEGORIES(逗号转义 \,
  LOCATION、VALARM(TRIGGER:-PT5M 位于 VTODO 内、结构闭合)全部通过

## 未验证
端到端「填字段→创建→查服务器 ICS」未跑通:`uitest inputText` 按坐标输入时
多个输入框会累积到同一个(把标签/地点内容追加进了标题框),是自动化限制而非代码问题。
需人工建一条带各字段的待办,确认服务器端 ICS 与上述结构一致。
提醒能否实际弹出依赖代理提醒权限(此前有配额为 0 的记录)。
2026-11-04 08:23:42 +08:00

409 lines
18 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// entry/src/main/ets/common/ReminderService.ets
// 日程提醒,双模式:
// 1) 系统代理提醒(reminderAgentManager):需要华为审批的"代理提醒使用权限",
// 未申请的普通应用配额为 0(发布报 1700002 且池内为 0)
// 2) 应用内通知(notificationManager):配额为 0 时的降级方案。
// 依赖应用进程存活(前台或"后台持续同步"长时任务),由自动同步定时器驱动,
// 每次检查把"提醒时刻刚好已到"的日程用本地通知发出。
import { reminderAgentManager } from '@kit.BackgroundTasksKit';
import { notificationManager } from '@kit.NotificationKit';
import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { EventDb, LocalEvent } from './EventDb';
import { AppSettings } from './AppSettings';
import { AccountStore } from './AccountStore';
import { SystemCalendarMirror } from './SystemCalendarMirror';
import { LogUtil } from './LogUtil';
export class ReminderService {
// 系统对单应用代理提醒上限 30 条,留 5 个余量给测试提醒等临时请求
private static readonly MAX_REMINDERS: number = 25;
private static readonly WINDOW: number = 7 * 86400000; // 只排未来 7 天
/** 代理提醒可用性:null=未探测(每次进程启动重新探测),false=配额为0(走应用内通知),true=可用 */
static agentAvailable: boolean | null = null;
/** calKey → 日历本名称缓存(通知文案用),60 秒刷新一次 */
private static calNameCache: Map<string, string> = new Map();
private static calNameLoadedAt: number = 0;
/** 已镜像到系统日历的 calKey 集合缓存(避免重复排提醒),30 秒刷新一次 */
private static mirroredCalKeys: Set<string> = new Set();
private static mirroredLoadedAt: number = 0;
/**
* ⭐⭐ 取"已镜像到系统日历"的日历本 calKey 集合(0.0.8 修复双通道重复提醒)。
*
* 镜像到系统日历后,**系统日历自己会提醒**(条目的 reminderTime);
* 若 App 再为同一个本排代理提醒 → 同一条日程提醒两次。
* 因此这里返回"该由系统日历负责提醒"的本,App 侧**完全不再提醒**它们。
*
* ⚠️ 判据是"**镜像账户真实存在**"(`SystemCalendarMirror.mirroredCalKeys`),
* 而不是"用户勾选了要镜像"—— 后者只是意图,本可能还没被真正写进系统日历;
* 按意图跳过会导致"两边都不提醒"(漏提醒)。
* ⚠️ 返回空集 = 不跳过任何本(保守:宁可多提醒,不可漏提醒)。
*/
private static async mirroredKeys(context: common.Context): Promise<Set<string>> {
if (Date.now() - ReminderService.mirroredLoadedAt < 30000
&& ReminderService.mirroredCalKeys.size > 0) {
return ReminderService.mirroredCalKeys;
}
const out: Set<string> = await SystemCalendarMirror.mirroredCalKeys(context);
ReminderService.mirroredCalKeys = out;
ReminderService.mirroredLoadedAt = Date.now();
return out;
}
/** 镜像开关变化后调用,让缓存立即失效(如用户关闭镜像 → 立刻恢复 App 提醒) */
static invalidateMirrorCache(): void {
ReminderService.mirroredLoadedAt = 0;
ReminderService.mirroredCalKeys = new Set();
}
/** 日历本名称(通知文案用):缓存 60 秒,'local' = 本机日历 */
private static async calName(context: common.Context, calKey: string): Promise<string> {
if (Date.now() - ReminderService.calNameLoadedAt > 60000
|| ReminderService.calNameCache.size === 0) {
const map: Map<string, string> = new Map();
map.set('local', '本机日历');
try {
const accounts = await AccountStore.loadAll(context);
for (const acc of accounts) {
for (let i: number = 0; i < acc.calendarHrefs.length; i++) {
const bookName: string = i < acc.calendarNames.length
? acc.calendarNames[i] : `日历本${i + 1}`;
map.set(`${acc.id}_${i}`, `${acc.name}·${bookName}`);
}
}
} catch (err) {
// 读取失败用旧缓存
}
ReminderService.calNameCache = map;
ReminderService.calNameLoadedAt = Date.now();
}
const name: string | undefined = ReminderService.calNameCache.get(calKey);
return name !== undefined ? name : '日历';
}
private static delay(ms: number): Promise<void> {
return new Promise<void>((resolve: () => void) => {
setTimeout(resolve, ms);
});
}
/** 当前系统里本应用的有效提醒条数 */
private static async poolCount(): Promise<number> {
try {
const all = await reminderAgentManager.getValidReminders();
return all.length;
} catch (err) {
return -1;
}
}
/**
* 发布单条代理提醒,1700002(池满/配额0)时清空后等待释放再重试,最多 3 轮。
* 3 轮后仍满且池内为 0 → 判定配额为 0(需华为邮件申请权限),抛出标记错误。
*/
private static async publishWithRetry(request: reminderAgentManager.ReminderRequestCalendar): Promise<void> {
try {
await reminderAgentManager.publishReminder(request);
ReminderService.agentAvailable = true;
return;
} catch (err) {
const e = err as BusinessError;
if (e.code !== 1700002) {
// ArkTS 只允许抛 Error:错误码编入消息,调用方从 message 解析
throw new Error(`${e.code}:${e.message}`);
}
}
for (let attempt: number = 1; attempt <= 3; attempt++) {
// 已触发的代理提醒会残留为通知,一并清理
try {
await notificationManager.cancelAll();
} catch (err) {
// 忽略
}
await reminderAgentManager.cancelAllReminders();
await ReminderService.delay(1500);
const remain: number = await ReminderService.poolCount();
LogUtil.write(`提醒池满(第 ${attempt} 次腾位):清空后剩余 ${remain} 条,重试发布`);
try {
await reminderAgentManager.publishReminder(request);
ReminderService.agentAvailable = true;
return;
} catch (err) {
const e = err as BusinessError;
if (e.code !== 1700002) {
throw new Error(`${e.code}:${e.message}`);
}
}
}
throw new Error('1700002:提醒池腾位 3 轮后仍满');
}
/** 判定"配额为 0":1700002 且系统内有效提醒为 0(没有超额却报超额 = 没有配额) */
private static async isQuotaZero(): Promise<boolean> {
const remain: number = await ReminderService.poolCount();
return remain <= 0;
}
/** 全量重建代理提醒(仅在代理提醒可用时调用)。返回发布条数 */
static async refreshReminders(context: common.Context): Promise<number> {
let published: number = 0;
if (ReminderService.agentAvailable === false) {
return 0; // 应用内通知模式接管,不再请求代理提醒
}
try {
await reminderAgentManager.cancelAllReminders();
const before: number = await ReminderService.poolCount();
if (before > 0) {
LogUtil.write(`取消全部提醒后系统仍剩 ${before} 条`);
}
const now: number = Date.now();
// ⭐ #38 日程 + 待办一起排提醒。两类分别查(过滤条件不同:
// 待办要求 completed=0,日程无此概念),合并后统一处理。
const eventRows: LocalEvent[] =
await EventDb.queryRemindable(context, now, now + ReminderService.WINDOW);
const todoRows: LocalEvent[] =
await EventDb.queryRemindableTodos(context, now, now + ReminderService.WINDOW);
const rows: LocalEvent[] = eventRows.concat(todoRows);
// 提醒静音的日历本(如别人分享的本):其中所有日程都不再提醒
const muted: string[] = await AppSettings.getMutedReminderKeys(context);
// ⭐⭐ 已镜像到系统日历的本:由系统日历负责提醒,App 不再重复排(消除"提醒两次")
const mirrored: Set<string> = await ReminderService.mirroredKeys(context);
if (mirrored.size > 0) {
LogUtil.write(`提醒跳过 ${mirrored.size} 个已镜像到系统日历的日历本(交由系统日历提醒)`);
}
for (const e of rows) {
if (muted.includes(e.calKey)) {
continue;
}
if (mirrored.has(e.calKey)) {
continue; // ⭐ 已镜像 → 系统日历会提醒,这里跳过避免重复
}
if (published >= ReminderService.MAX_REMINDERS) {
break;
}
// 多提醒:每个提前量各发一条(含旧单提醒数据回退)
const rems: number[] = e.reminders.length > 0 ? e.reminders
: (e.reminder > 0 ? [e.reminder] : []);
for (const rem of rems) {
if (published >= ReminderService.MAX_REMINDERS) {
break;
}
const remindAt: number = e.startTime - rem * 60000;
if (remindAt <= now) {
continue; // 提醒时间已过
}
const d = new Date(remindAt);
const calName: string = await ReminderService.calName(context, e.calKey);
// #38 文案按类型区分:待办是"到期",日程是"开始"
const isTodo: boolean = e.kind === 'todo';
const request: reminderAgentManager.ReminderRequestCalendar = {
reminderType: reminderAgentManager.ReminderType.REMINDER_TYPE_CALENDAR,
dateTime: {
year: d.getFullYear(),
month: d.getMonth() + 1,
day: d.getDate(),
hour: d.getHours(),
minute: d.getMinutes(),
second: 0
},
title: isTodo ? '待办提醒' : '日程提醒',
content: isTodo
? `「${calName}」中的待办「${e.title}」将在 ${rem} 分钟后到期`
: `「${calName}」中的「${e.title}」将在 ${rem} 分钟后开始`,
// ⚠️ 通知 id 必须唯一:日程与待办的 id 来自同一张表(自增主键),
// 天然不冲突,所以这里不用额外加偏移,否则会与 rem 的乘加混淆。
notificationId: (e.id * 10 + rem) % 100000,
expiredContent: isTodo ? '该待办已到期' : '该日程已开始',
ringDuration: 5,
snoozeTimes: 0,
timeInterval: 0
};
await ReminderService.publishWithRetry(request);
published++;
}
}
const after: number = await ReminderService.poolCount();
LogUtil.write(`提醒刷新完成:发布 ${published} 个提醒(窗口 7 天,静音日历本 ${muted.length} 个,池内现有 ${after} 条)`);
return published;
} catch (err) {
const e = err as Error;
if (e.message.startsWith('1700002') && await ReminderService.isQuotaZero()) {
// 配额为 0:普通应用未获代理提醒权限,降级为应用内通知模式
ReminderService.agentAvailable = false;
await AppSettings.setReminderTick(context, Date.now());
LogUtil.write('代理提醒配额为 0(需向华为邮件申请权限),已自动切换为应用内通知模式');
return 0;
}
LogUtil.write(`提醒刷新失败: ${e.message}`);
return published;
}
}
/**
* 应用内提醒检查(降级模式):由自动同步定时器高频调用。
* 把"提醒时刻落在上次检查~现在之间"的日程用本地通知发出。
* App 进程被杀期间的提醒会错过(超过 2 分钟不补发),长时任务可避免。
*/
static async tickReminders(context: common.Context): Promise<number> {
if (ReminderService.agentAvailable !== false) {
return 0; // 代理提醒可用或未判定,不需要降级
}
try {
const now: number = Date.now();
const last: number = await AppSettings.getReminderTick(context);
if (last <= 0) {
await AppSettings.setReminderTick(context, now); // 首次运行:只记基线不补发
return 0;
}
const from: number = Math.max(last, now - 2 * 60000); // 最多补发 2 分钟内的错过提醒
await AppSettings.setReminderTick(context, now);
const rows: LocalEvent[] =
await EventDb.queryRemindable(context, now, now + ReminderService.WINDOW);
const muted: string[] = await AppSettings.getMutedReminderKeys(context);
// ⭐⭐ 已镜像到系统日历的本:由系统日历负责提醒,App 侧不再补发(消除"提醒两次")
const mirrored: Set<string> = await ReminderService.mirroredKeys(context);
let fired: number = 0;
for (const e of rows) {
if (muted.includes(e.calKey)) {
continue;
}
if (mirrored.has(e.calKey)) {
continue; // ⭐ 已镜像 → 交由系统日历提醒
}
const rems: number[] = e.reminders.length > 0 ? e.reminders
: (e.reminder > 0 ? [e.reminder] : []);
for (const rem of rems) {
const remindAt: number = e.startTime - rem * 60000;
if (remindAt <= from || remindAt > now) {
continue; // 不在本次检查窗口内
}
const calName: string = await ReminderService.calName(context, e.calKey);
const minLeft: number = Math.max(1,
Math.ceil((e.startTime - Date.now()) / 60000));
const fired2: number = await ReminderService.notifyNow(
'日程提醒', `「${calName}」中的「${e.title}」将在 ${minLeft} 分钟后开始`,
(e.id * 10 + rem) % 100000);
if (fired2 === 0) {
fired++;
}
}
}
if (fired > 0) {
LogUtil.write(`应用内提醒:本次发出 ${fired} 条到点提醒`);
}
return fired;
} catch (err) {
const e = err as Error;
LogUtil.write(`应用内提醒检查失败: ${e.message}`);
return 0;
}
}
/** 日历本通知渠道(SOCIAL_COMMUNICATION 默认横幅+锁屏+响铃振动),只注册一次 */
private static slotReady: boolean = false;
private static async ensureSlot(): Promise<void> {
if (ReminderService.slotReady) {
return;
}
try {
// 本 SDK 无锁屏可见性 API(已废弃),锁屏显示由系统"锁屏通知"设置 + 渠道默认行为决定
await notificationManager.addSlot(notificationManager.SlotType.SOCIAL_COMMUNICATION);
} catch (err) {
// 渠道已存在等情况忽略
}
ReminderService.slotReady = true;
}
/** 立即发一条本地通知。返回 0 表示成功 */
private static async notifyNow(title: string, text: string, id: number): Promise<number> {
await ReminderService.ensureSlot();
try {
const request: notificationManager.NotificationRequest = {
id: id,
notificationSlotType: notificationManager.SlotType.SOCIAL_COMMUNICATION,
content: {
notificationContentType: notificationManager.ContentType.NOTIFICATION_CONTENT_BASIC_TEXT,
normal: {
title: title,
text: text,
additionalText: ''
}
}
};
await notificationManager.publish(request);
return 0;
} catch (err) {
const e = err as BusinessError;
LogUtil.write(`本地通知发布失败: ${e.code} - ${e.message}`);
return -1;
}
}
/** 测试入口:代理可用时发 1 分钟后的代理提醒;降级模式发立即本地通知 */
static async publishTestReminder(context: common.Context): Promise<string> {
if (ReminderService.agentAvailable === false) {
const r: number = await ReminderService.notifyNow(
'测试通知(应用内模式)', '如果你看到这条通知,说明应用内提醒链路正常', 99998);
return r === 0
? '已发出测试通知(应用内模式:代理提醒配额为 0,由 App 进程在提醒时刻发通知)'
: '本地通知发布失败,详见日志';
}
// request 构造放在 try 外:腾位重试也要用到
const t: number = Date.now() + 60 * 1000;
const d = new Date(t);
const request: reminderAgentManager.ReminderRequestCalendar = {
reminderType: reminderAgentManager.ReminderType.REMINDER_TYPE_CALENDAR,
dateTime: {
year: d.getFullYear(),
month: d.getMonth() + 1,
day: d.getDate(),
hour: d.getHours(),
minute: d.getMinutes(),
second: 0
},
title: '测试提醒',
content: '这是一条测试提醒:如果你看到它,说明提醒链路正常',
notificationId: 99999,
expiredContent: '测试提醒已过期',
ringDuration: 5,
snoozeTimes: 0,
timeInterval: 0
};
const before: number = await ReminderService.poolCount();
LogUtil.write(`测试提醒发布前池内提醒 ${before} 条`);
try {
await ReminderService.publishWithRetry(request);
LogUtil.write('测试提醒已发布:1 分钟后应弹出通知');
return `已发布(发布前池内 ${before} 条),1 分钟后应弹出通知`;
} catch (err) {
const e = err as Error;
if (e.message.startsWith('1700002') && await ReminderService.isQuotaZero()) {
ReminderService.agentAvailable = false;
await AppSettings.setReminderTick(context, Date.now());
LogUtil.write('代理提醒配额为 0(需向华为邮件申请权限),已切换为应用内通知模式');
const r: number = await ReminderService.notifyNow(
'测试通知(应用内模式)', '如果你看到这条通知,说明应用内提醒链路正常', 99998);
return r === 0
? '代理提醒配额为 0(需邮件向华为申请),已切换为应用内通知模式并发出测试通知'
: '代理提醒配额为 0,已切换应用内模式,但测试通知发布失败(详见日志)';
}
LogUtil.write(`测试提醒发布失败: ${e.message}`);
const sep: number = e.message.indexOf(':');
return sep > 0
? `发布失败:错误码 ${e.message.substring(0, sep)}(${e.message.substring(sep + 1)})`
: `发布失败:${e.message}`;
}
}
private static fmt(ms: number): string {
const d = new Date(ms);
const p = (n: number): string => n < 10 ? '0' + n : String(n);
return `${d.getMonth() + 1}月${d.getDate()}日 ${p(d.getHours())}:${p(d.getMinutes())}`;
}
}