Files
SyncCalendar/entry/src/main/ets/common/CalendarDataService.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

396 lines
17 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/CalendarDataService.ets
// 合并数据源:本地库(DAV 同步 + 本机事件) + 系统日历(只读展示)
import { common, abilityAccessCtrl, bundleManager, Permissions } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { calendarManager } from '@kit.CalendarKit';
import { AccountStore, CalSource, BookPalette, LOCAL_CAL_KEY, DavAccount } from './AccountStore';
import { EventDb, LocalEvent } from './EventDb';
import { LogUtil } from './LogUtil';
import { AppSettings } from './AppSettings';
import { RruleUtil } from './RruleUtil';
import { IcsUtil } from './IcsUtil';
/** 界面展示用事件 */
export class DisplayEvent {
id: number = 0; // 本地库 id 或系统日历事件 id
isSystem: boolean = false;
title: string = '';
description: string = '';
location: string = '';
startTime: number = 0;
endTime: number = 0;
isAllDay: boolean = false;
calKey: string = '';
calName: string = '';
color: string = '#007DFF';
completed: boolean = false; // 仅待办使用
recurring: boolean = false; // 是否为重复日程展开出的发生
/** ⭐ 待办的 RRULE 原文(空 = 非循环)。与 recurring 不同:
* recurring 只说"重不重复",这里带的是"多久重复一次"。 */
recurRule: string = '';
/** 待办的 DTSTART(重复基准,毫秒;0 = 无) */
dtstart: number = 0;
/** 优先级,iCal 原生值:1=最高 … 9=最低,0=未设置 */
priority: number = 0;
/** 分类标签,逗号分隔(对应 CATEGORIES) */
categories: string = '';
/** 提醒提前分钟数(多选),空 = 不提醒 */
reminders: number[] = [];
writable: boolean = true; // 所属日历本是否可写(只读时点击显示详情而非编辑)
}
export class CalendarDataService {
/** 本功能依赖的日历权限:读取本应用日历 + 读取全部日历(即"读取全部日程") */
private static readonly CALENDAR_PERMISSIONS: Permissions[] = [
'ohos.permission.READ_CALENDAR',
'ohos.permission.READ_WHOLE_CALENDAR'
];
/**
* 是否已获得"读取全部日程"权限。
* **只检查、绝不弹窗** —— 供界面判断状态、决定是否需要引导用户主动授权。
* 注意:checkAccessToken 只反映本应用授权状态,不会触发任何系统弹窗。
*/
static async hasSystemCalendarPermission(): Promise<boolean> {
try {
const atManager = abilityAccessCtrl.createAtManager();
const info = bundleManager.getBundleInfoForSelfSync(
bundleManager.BundleFlag.GET_BUNDLE_INFO_WITH_APPLICATION);
const tokenId: number = info.appInfo.accessTokenId;
for (const p of CalendarDataService.CALENDAR_PERMISSIONS) {
const status = await atManager.checkAccessToken(tokenId, p);
if (status !== abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED) {
return false;
}
}
return true;
} catch (err) {
return false;
}
}
/**
* 申请"读取全部日程"权限(READ_CALENDAR + READ_WHOLE_CALENDAR)。
*
* ⚠️ 合规约束(《审核指南》7.17):**只能在用户主动点击使用对应功能时调用**,
* 例如在设置页打开"混合显示系统日历"开关、把系统日历模式切到"备份到 CalDAV 日历本"。
* 严禁在 App 启动 / 首次打开 / 未同意隐私政策等用户未主动操作的时机调用。
*
* 已授权时直接返回 true,不会再弹窗(先 checkAccessToken,避免多余弹窗)。
*/
static async requestSystemCalendarPermission(context: common.UIAbilityContext): Promise<boolean> {
if (await CalendarDataService.hasSystemCalendarPermission()) {
return true;
}
try {
const atManager = abilityAccessCtrl.createAtManager();
const result = await atManager.requestPermissionsFromUser(
context, CalendarDataService.CALENDAR_PERMISSIONS);
for (const r of result.authResults) {
if (r !== 0) {
return false;
}
}
return true;
} catch (err) {
const e = err as BusinessError;
console.error(`日历权限申请失败: ${e.code} - ${e.message}`);
return false;
}
}
/** 收集全部日历来源:DAV 日历本 + 本机 + 系统日历账户 */
static async loadSources(context: common.Context): Promise<CalSource[]> {
const sources: CalSource[] = [];
const manualKeys: string[] = await AppSettings.getManualReadonlyKeys(context);
try {
const accounts: DavAccount[] = await AccountStore.loadAll(context);
for (const acc of accounts) {
for (let i = 0; i < acc.calendarHrefs.length; i++) {
const s = new CalSource();
s.calKey = `${acc.id}_${i}`;
let name: string = i < acc.calendarNames.length ? acc.calendarNames[i] : '';
if (name === '') {
name = acc.calendarHrefs.length === 1 ? acc.name : `日历本 ${i + 1}`;
}
s.name = `${acc.name}:${name}`;
// 优先使用服务器端定义的颜色;未定义时按**集合 href** 派生(不随顺序变化,见 colorForHref 说明)
let color: string = i < acc.calendarColors.length ? AccountStore.normalizeColor(acc.calendarColors[i]) : '';
s.color = color !== '' ? color : BookPalette.colorForHref(acc.calendarHrefs[i]);
s.source = 'dav';
s.visible = true;
// 写权限:服务器探测结果 + 手动标记(部分服务器不在 CalDAV 层拒绝写入)
const detected: boolean = acc.calendarWritable.length > i
? acc.calendarWritable[i] !== '0' : true;
s.writable = detected && !manualKeys.includes(s.calKey);
// 透传本支持的类型(新建待办/日程时据此过滤,避免把待办写进纯日历本)
s.comps = i < acc.calendarComps.length && acc.calendarComps[i] !== ''
? acc.calendarComps[i] : 'unknown';
sources.push(s);
}
}
// 本机事件虚拟日历
const local = new CalSource();
local.calKey = LOCAL_CAL_KEY;
local.name = '本机';
local.color = '#5A6068';
local.source = 'local';
local.visible = true;
sources.push(local);
} catch (err) {
const e = err as BusinessError;
console.error(`加载 DAV 来源失败: ${e.message}`);
}
// 系统日历账户(受"混合显示系统日历"开关控制;按账户名去重)
const showSys: boolean = await AppSettings.getShowSystemCalendar(context);
if (!showSys) {
LogUtil.write('来源加载:系统日历混合显示已关闭,跳过全部系统日历');
} else {
try {
const uiContext = context as common.UIAbilityContext;
const mgr: calendarManager.CalendarManager = calendarManager.getCalendarManager(uiContext);
const calendars: calendarManager.Calendar[] = await mgr.getAllCalendars();
let sysRaw: number = 0;
for (const cal of calendars) {
try {
const account: calendarManager.CalendarAccount = cal.getAccount();
sysRaw++;
const sysKey: string = `sys_${account.name}`;
if (sources.some((x: CalSource): boolean => x.calKey === sysKey)) {
continue; // 同名账户只保留一个来源
}
const s = new CalSource();
s.calKey = sysKey;
s.name = '系统';
let color = '#9AA0A6';
const config: calendarManager.CalendarConfig = cal.getConfig();
if (typeof config.color === 'string') {
color = config.color;
}
s.color = color;
s.source = 'system';
s.visible = true;
sources.push(s);
} catch (err) {
// 单个日历账户读取失败不影响整体
}
}
LogUtil.write(`来源加载:系统日历 ${sysRaw} 个(按账户去重后保留,共 ${sources.length} 个来源)`);
} catch (err) {
const e = err as BusinessError;
console.info(`系统日历不可用(可能未授权): ${e.code}`);
}
}
return sources;
}
/** 加载时间区间内的合并事件(按来源显隐过滤) */
static async loadEvents(context: common.Context, start: number, end: number,
sources: CalSource[]): Promise<DisplayEvent[]> {
LogUtil.init(context);
const result: DisplayEvent[] = [];
const visibleKeys: string[] = sources.filter((s: CalSource): boolean => s.visible)
.map((s: CalSource): string => s.calKey);
const colorOf = (key: string): string => {
const found = sources.find((s: CalSource): boolean => s.calKey === key);
return found !== undefined ? found.color : '#9AA0A6';
};
const nameOf = (key: string): string => {
const found = sources.find((s: CalSource): boolean => s.calKey === key);
return found !== undefined ? found.name : '';
};
const writableOf = (key: string): boolean => {
const found = sources.find((s: CalSource): boolean => s.calKey === key);
return found !== undefined ? found.writable : true;
};
// 1) 本地库(DAV + 本机);重复日程(RRULE)按规则展开为窗口内的多次发生
try {
const rows: LocalEvent[] = await EventDb.queryRange(context, start, end);
// 重复主事件的首次发生可能在窗口外,也纳入(仅用于展开)
const masters: LocalEvent[] = await EventDb.queryRecurringMasters(context, end);
for (const m of masters) {
if (!rows.some((r: LocalEvent): boolean => r.id === m.id)) {
rows.push(m);
}
}
// 单次覆盖实例(RECURRENCE-ID 独立行):展开时跳过对应发生,避免重复。
// 注意:①排除键必须带 calKey(同一系列可能出现在多个日历本);
// ②键必须用 **recurrenceId(原始发生时刻)**——覆盖行的 startTime 是"被改到的新时间",
// 用它做键时主事件在原时间的那次发生不会被排除 → 同一日程显示两条(幽灵日程);
// ③只有"可见日历本"的覆盖行才参与排除——历史孤儿行(旧账号迁移残留)
// 自己不显示,却会把主事件的对应发生排除掉,导致"第一次不显示"
const overrideKeys: string[] = [];
for (const e of rows) {
if (e.rrule === '' && e.recurring && visibleKeys.includes(e.calKey)) {
overrideKeys.push(EventDb.overrideKey(e.calKey, e.uid, e.recurrenceId, e.startTime));
}
}
let occTotal: number = 0;
for (const e of rows) {
if (!visibleKeys.includes(e.calKey)) {
continue;
}
const baseColor: string = colorOf(e.calKey);
const baseName: string = nameOf(e.calKey);
const dur: number = Math.max(0, e.endTime - e.startTime);
let times: number[] = [e.startTime];
if (e.rrule !== '') {
const exNums: number[] = [];
if (e.exdate !== '') {
for (const raw of e.exdate.split(';')) {
const t = IcsUtil.parseTime(raw, !raw.includes('T'));
if (t !== null) {
exNums.push(t.time);
}
}
}
const occs: number[] = RruleUtil.expand(e.rrule, e.startTime, start, end, exNums, 1500);
if (occs.length > 0) {
times = occs.filter((occ: number): boolean =>
!overrideKeys.includes(EventDb.overrideKey(e.calKey, e.uid, 0, occ)));
}
}
for (const occ of times) {
const d = new DisplayEvent();
d.id = e.id;
d.isSystem = false;
d.title = e.title;
d.description = e.description;
d.location = e.location;
d.startTime = occ;
d.endTime = occ + dur;
d.isAllDay = e.isAllDay;
d.calKey = e.calKey;
d.calName = baseName;
d.color = baseColor;
d.recurring = e.rrule !== '';
d.writable = writableOf(e.calKey);
result.push(d);
occTotal++;
}
}
LogUtil.write(`显示加载:本地库 ${rows.length} 行,展开后 ${occTotal} 条(区间 ${new Date(start).toLocaleString()} ~ ${new Date(end).toLocaleString()})`);
} catch (err) {
const e = err as BusinessError;
console.error(`读取本地事件失败: ${e.message}`);
}
// 2) 系统日历(只读混合展示)——单日历失败只跳过该日历,不影响其余
if (visibleKeys.some((k: string): boolean => k.startsWith('sys_'))) {
try {
const uiContext = context as common.UIAbilityContext;
const mgr: calendarManager.CalendarManager = calendarManager.getCalendarManager(uiContext);
const calendars: calendarManager.Calendar[] = await mgr.getAllCalendars();
for (const cal of calendars) {
let accountName: string = '';
let color = '#9AA0A6';
try {
const account: calendarManager.CalendarAccount = cal.getAccount();
accountName = account.name;
const config: calendarManager.CalendarConfig = cal.getConfig();
if (typeof config.color === 'string') {
color = config.color;
}
} catch (err) {
// 忽略单个日历
}
const sysKey: string = `sys_${accountName}`;
if (!visibleKeys.includes(sysKey)) {
continue;
}
try {
const events: calendarManager.Event[] = await cal.queryEventInstances(start, end);
let skipped: number = 0;
for (const ev of events) {
const d = new DisplayEvent();
d.id = ev.id !== undefined ? ev.id : 0;
d.isSystem = true;
d.title = ev.title !== undefined ? ev.title : '(无标题)';
d.description = ev.description !== undefined ? ev.description : '';
d.location = ev.location !== undefined && ev.location.location !== undefined ? ev.location.location : '';
d.startTime = ev.startTime;
d.endTime = ev.endTime;
d.isAllDay = ev.isAllDay === true;
d.calKey = sysKey;
d.calName = nameOf(sysKey);
d.color = color;
// 去重:与本地(DAV/本机)同标题+同时间的视为同一条,避免两个来源重复展示
const dup: boolean = result.some((x: DisplayEvent): boolean => !x.isSystem &&
x.title === d.title && x.startTime === d.startTime &&
x.endTime === d.endTime && x.isAllDay === d.isAllDay);
if (!dup) {
result.push(d);
} else {
skipped++;
}
}
LogUtil.write(`系统日历 ${sysKey}:查询到 ${events.length} 条,与本地重复跳过 ${skipped} 条`);
} catch (err) {
const e = err as BusinessError;
console.info(`查询系统日历 ${sysKey} 失败(跳过): ${e.code}`);
}
}
} catch (err) {
const e = err as BusinessError;
console.info(`读取系统日历失败(可能未授权): ${e.code}`);
}
}
// 排序:全天日程最前,然后按开始时间
result.sort((a: DisplayEvent, b: DisplayEvent): number => {
if (a.isAllDay !== b.isAllDay) {
return a.isAllDay ? -1 : 1;
}
return a.startTime - b.startTime;
});
return result;
}
/** 加载全部待办(VTODO,来自 DAV 同步,只读展示) */
static async loadTodos(context: common.Context, sources: CalSource[]): Promise<DisplayEvent[]> {
const result: DisplayEvent[] = [];
const visibleKeys: string[] = sources.filter((s: CalSource): boolean => s.visible)
.map((s: CalSource): string => s.calKey);
const colorOf = (key: string): string => {
const found = sources.find((s: CalSource): boolean => s.calKey === key);
return found !== undefined ? found.color : '#9AA0A6';
};
// ⭐ 待办按日历本分组显示需要"本名";原来只带 calKey,分组标题无法显示
const nameOf = (key: string): string => {
const found = sources.find((s: CalSource): boolean => s.calKey === key);
return found !== undefined ? found.name : '';
};
try {
const rows: LocalEvent[] = await EventDb.queryTodos(context);
for (const e of rows) {
if (!visibleKeys.includes(e.calKey)) {
continue;
}
const d = new DisplayEvent();
d.id = e.id;
d.isSystem = false;
d.title = e.title;
d.description = e.description;
d.location = e.location;
d.startTime = e.startTime;
d.endTime = e.endTime;
d.isAllDay = e.isAllDay;
d.calKey = e.calKey;
d.calName = nameOf(e.calKey);
d.color = colorOf(e.calKey);
d.completed = e.completed;
d.priority = e.priority;
d.categories = e.categories;
d.reminders = e.reminders;
d.recurRule = e.rrule;
d.dtstart = e.dtstart;
d.recurring = e.rrule !== '';
result.push(d);
}
} catch (err) {
const e = err as BusinessError;
console.error(`读取待办失败: ${e.message}`);
}
return result;
}
}