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 的记录)。
This commit is contained in:
2026-11-04 08:23:42 +08:00
parent c0b9b44ca2
commit 413900bbce
6 changed files with 459 additions and 56 deletions
+246 -45
View File
@@ -84,6 +84,14 @@ struct Index {
@State todoEditBook: string = ''; // 所属本名(只读展示)
@State todoEditWritable: boolean = true;
@State todoEditRecur: string = ''; // 当前循环('' = 不循环),存 FREQ 值如 WEEKLY
/** 优先级:iCal 原生值 1=高 5=中 9=低,0=未设置 */
@State todoEditPriority: number = 0;
/** 分类标签,逗号分隔(对应 CATEGORIES) */
@State todoEditCategories: string = '';
/** 地点(复用 VTODO 的 LOCATION) */
@State todoEditLocation: string = '';
/** 提醒:提前分钟数(多选),空 = 不提醒 */
@State todoEditReminders: number[] = [];
@State todoEditHasDue: boolean = false;
@State todoEditPickShow: boolean = false; // 截止时间选择器
@State todoEditPickDate: Date = new Date();
@@ -2670,61 +2678,61 @@ struct Index {
// 用户要求"按日历本分类显示,未完成里也能按家庭、生活分开,要有区分度"。
// 分组标题前带该本的颜色圆点 —— 这才是"区分度"的来源。
todoBody() {
Scroll() {
Column({ space: 8 }) {
// ⭐ 外层由 Scroll 改为 List:`swipeAction` 只能挂在 ListItem 上,
// 而 ListItem 必须是 List 的直接子节点(#39 左滑删除)。
// 分组用 ListItemGroup 实现 —— 它天生支持分组头,正好放"色点 + 本名 + 条数"。
List({ space: 6 }) {
ListItem() {
Text(`未完成(${this.todos.filter((t: DisplayEvent): boolean => !t.completed).length})`)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_secondary'))
.width('100%')
ForEach(this.todoGroups(false), (g: TodoGroup) => {
Column({ space: 6 }) {
Row({ space: 6 }) {
Circle({ width: 8, height: 8 }).fill(g.color)
Text(`${g.name}(${g.items.length})`)
.fontSize(12)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_secondary'))
Blank()
.textAlign(TextAlign.Start)
}
.width('100%')
ForEach(this.todoGroups(false), (g: TodoGroup) => {
ListItemGroup({ header: this.todoGroupHeader(g) }) {
ForEach(g.items, (t: DisplayEvent) => {
ListItem() {
this.todoRow(t)
}
.width('100%')
.padding({ top: 4 })
ForEach(g.items, (t: DisplayEvent) => {
this.todoRow(t)
}, (t: DisplayEvent) => `u${t.id}_${t.completed}`)
}
.alignItems(HorizontalAlign.Start)
.width('100%')
}, (g: TodoGroup) => `g0_${g.calKey}`)
.swipeAction({ end: this.todoSwipeDelete(t) })
}, (t: DisplayEvent) => `u${t.id}_${t.completed}`)
}
.width('100%')
}, (g: TodoGroup) => `g0_${g.calKey}`)
if (this.todos.some((t: DisplayEvent): boolean => t.completed)) {
if (this.todos.some((t: DisplayEvent): boolean => t.completed)) {
ListItem() {
Text(`已完成(${this.todos.filter((t: DisplayEvent): boolean => t.completed).length})`)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_secondary'))
.width('100%')
.textAlign(TextAlign.Start)
.margin({ top: 10 })
ForEach(this.todoGroups(true), (g: TodoGroup) => {
Column({ space: 6 }) {
Row({ space: 6 }) {
Circle({ width: 8, height: 8 }).fill(g.color)
Text(`${g.name}(${g.items.length})`)
.fontSize(12)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_secondary'))
Blank()
}
.width('100%')
ForEach(this.todoGroups(true), (g: TodoGroup) => {
ListItemGroup({ header: this.todoGroupHeader(g) }) {
ForEach(g.items, (t: DisplayEvent) => {
ListItem() {
this.todoRow(t)
}
.width('100%')
.padding({ top: 4 })
ForEach(g.items, (t: DisplayEvent) => {
this.todoRow(t)
}, (t: DisplayEvent) => `c${t.id}_${t.completed}`)
}
.alignItems(HorizontalAlign.Start)
.width('100%')
}, (g: TodoGroup) => `g1_${g.calKey}`)
}
if (this.todos.length === 0 && !this.loading) {
.swipeAction({ end: this.todoSwipeDelete(t) })
}, (t: DisplayEvent) => `c${t.id}_${t.completed}`)
}
.width('100%')
}, (g: TodoGroup) => `g1_${g.calKey}`)
}
if (this.todos.length === 0 && !this.loading) {
ListItem() {
Column({ space: 8 }) {
Text('☑')
.fontSize(34)
@@ -2745,15 +2753,59 @@ struct Index {
.width('100%')
.padding(32)
}
.width('100%')
}
.width('100%')
.padding({ left: 20, right: 20, top: 8, bottom: 24 })
.constraintSize({ minHeight: '100%' })
}
.width('100%')
.layoutWeight(1)
.padding({ left: 20, right: 20, top: 8, bottom: 24 })
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
.align(Alignment.Top)
}
/** 分组头:色点 + 本名(条数) —— 与原 Column 版一致 */
@Builder
todoGroupHeader(g: TodoGroup) {
Row({ space: 6 }) {
Circle({ width: 8, height: 8 }).fill(g.color)
Text(`${g.name}(${g.items.length})`)
.fontSize(12)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_secondary'))
Blank()
}
.width('100%')
.padding({ top: 6, bottom: 2 })
}
/**
* 左滑露出的「删除」按钮。
*
* 复用现有 `askDeleteTodo`(二次确认)→ `doDeleteTodo`(本地软删 + 服务器 DELETE),
* 不新写删除逻辑,保证与编辑弹层里的删除走同一条路径。
* 删除前先把目标存进 todoEditId,因为 askDeleteTodo 用的是这个字段。
*/
@Builder
todoSwipeDelete(t: DisplayEvent) {
Row() {
Text('删除')
.fontSize(14)
.fontColor(Color.White)
.textAlign(TextAlign.Center)
}
.width(72)
.height('100%')
.justifyContent(FlexAlign.Center)
.backgroundColor($r('app.color.error'))
.borderRadius(12)
.margin({ left: 6 })
.onClick(() => {
// askDeleteTodo 依赖 todoEditId 定位要删哪一条
this.todoEditId = t.id;
this.todoEditTitle = t.title;
this.todoEditWritable = t.writable;
this.askDeleteTodo();
})
}
/**
@@ -2927,6 +2979,105 @@ struct Index {
}
.width('100%')
// ⭐ 优先级(iCal 原生值 1/5/9,再点一次取消)
Row({ space: 8 }) {
Text('优先级')
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
Blank()
ForEach([
{ v: 1, label: '高' },
{ v: 5, label: '中' },
{ v: 9, label: '低' }
], (opt: Record<string, Object>): void => {
Text(opt.label as string)
.fontSize(13)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(14)
.backgroundColor(this.todoEditPriority === (opt.v as number)
? $r('app.color.brand') : $r('app.color.chip_off_bg'))
.fontColor(this.todoEditPriority === (opt.v as number)
? $r('app.color.button_text') : $r('app.color.text_primary'))
.onClick(() => {
// 再点一次 = 取消优先级
const v: number = opt.v as number;
this.todoEditPriority = this.todoEditPriority === v ? 0 : v;
})
}, (opt: Record<string, Object>): string => `pri_${opt.v}`)
}
.width('100%')
.padding({ top: 2, bottom: 2 })
// ⭐ 提醒(#38):与日程同一套选项;待办的提醒基准是 DUE 到期时刻
Row({ space: 8 }) {
Text('提醒')
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
.width(52)
Flex({ wrap: FlexWrap.Wrap }) {
ForEach([
{ v: 0, label: '不提醒' },
{ v: 5, label: '提前5分钟' },
{ v: 15, label: '提前15分钟' },
{ v: 30, label: '提前30分钟' },
{ v: 60, label: '提前1小时' },
{ v: 1440, label: '提前1天' }
], (opt: Record<string, Object>): void => {
Text(opt.label as string)
.fontSize(12)
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.margin({ right: 6, bottom: 6 })
.borderRadius(13)
.backgroundColor(this.todoReminderActive(opt.v as number)
? $r('app.color.brand') : $r('app.color.chip_off_bg'))
.fontColor(this.todoReminderActive(opt.v as number)
? $r('app.color.button_text') : $r('app.color.text_primary'))
.onClick(() => {
this.toggleTodoReminder(opt.v as number);
})
}, (opt: Record<string, Object>): string => `tr_${opt.v}`)
}
.layoutWeight(1)
}
.width('100%')
.alignItems(VerticalAlign.Top)
// ⭐ 分类标签(逗号分隔,对应 CATEGORIES)
Row({ space: 8 }) {
Text('标签')
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
.width(52)
TextInput({ text: this.todoEditCategories, placeholder: '多个用逗号分隔' })
.fontSize(14)
.height(40)
.layoutWeight(1)
.borderRadius(10)
.backgroundColor($r('app.color.page_bg'))
.onChange((v: string) => {
this.todoEditCategories = v;
})
}
.width('100%')
// ⭐ 地点(复用 location 字段)
Row({ space: 8 }) {
Text('地点')
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
.width(52)
TextInput({ text: this.todoEditLocation, placeholder: '可选' })
.fontSize(14)
.height(40)
.layoutWeight(1)
.borderRadius(10)
.backgroundColor($r('app.color.page_bg'))
.onChange((v: string) => {
this.todoEditLocation = v;
})
}
.width('100%')
if (this.todoEditRecur !== '' && !this.todoEditHasDue) {
Text('提示:循环任务需要有截止时间,才能算出下一次')
.fontSize(11)
@@ -3101,7 +3252,11 @@ struct Index {
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.alignItems(HorizontalAlign.Start)
.justifyContent(FlexAlign.Center)
.layoutWeight(1)
// ⭐ 撑满行高:原来高度只到文字高度,行内上下留白点不到,
// 用户觉得"只有点标题才有反应"(#42)。撑满后整块内容区都可点。
.height('100%')
// ⭐ 内容区点击 → 打开编辑(不改变完成状态)
.onClick(() => {
this.openTodoEditor(t);
@@ -3240,6 +3395,10 @@ struct Index {
this.todoEditWritable = true;
this.todoEditBook = '';
this.todoEditRecur = '';
this.todoEditPriority = 0;
this.todoEditCategories = '';
this.todoEditLocation = '';
this.todoEditReminders = [];
this.todoEditPickShow = false;
this.todoEditShow = true;
}
@@ -3255,6 +3414,11 @@ struct Index {
this.todoEditBook = t.calName;
this.todoEditWritable = t.writable;
this.todoEditRecur = t.recurRule !== '' ? IcsUtil.freqOfRrule(t.recurRule) : '';
// 优先级 / 标签 / 地点(DisplayEvent 已带这些字段)
this.todoEditPriority = t.priority > 0 ? t.priority : 0;
this.todoEditCategories = t.categories;
this.todoEditLocation = t.location;
this.todoEditReminders = t.reminders.slice();
// 默认值给"明天 09:00",与新建日程的口径一致(非今天用 9 点)
const base: Date = new Date();
this.todoEditPickDate = new Date(base.getFullYear(), base.getMonth(), base.getDate() + 1);
@@ -3305,7 +3469,9 @@ struct Index {
try {
const ok: boolean = await TodoService.updateTodo(ctx, this.todoEditId,
this.todoEditTitle, this.todoEditDesc,
this.todoEditHasDue ? this.todoEditDueMs : 0);
this.todoEditHasDue ? this.todoEditDueMs : 0,
this.todoEditPriority, this.todoEditCategories, this.todoEditLocation,
this.todoEditReminders);
if (ok) {
// ⭐ 循环规则单独写:它与"改内容"是两回事,
// 而且要先有截止时间才有意义(没有 DUE 的循环任务无法算下一次)
@@ -3318,6 +3484,9 @@ struct Index {
this.getUIContext().getPromptAction().showToast({ message: '已保存' });
this.todos = await CalendarDataService.loadTodos(ctx, this.sources);
this.rebuildPages();
// ⭐ 卡片是独立实例,必须显式推送;提醒随标题/截止/提醒项变化需重排
await CardDataService.pushToAllForms(ctx);
await ReminderService.refreshReminders(ctx);
} else {
this.getUIContext().getPromptAction().showToast({
message: '保存失败:需联网,且该日历本需可写'
@@ -3330,6 +3499,28 @@ struct Index {
}
}
/** 提醒是否选中(0 表示"不提醒",即列表为空) */
private todoReminderActive(v: number): boolean {
if (v === 0) {
return this.todoEditReminders.length === 0;
}
return this.todoEditReminders.indexOf(v) >= 0;
}
/** 切换提醒:0=清空(不提醒),其余=多选切换 */
private toggleTodoReminder(v: number): void {
if (v === 0) {
this.todoEditReminders = [];
return;
}
const idx: number = this.todoEditReminders.indexOf(v);
if (idx >= 0) {
this.todoEditReminders = this.todoEditReminders.filter((x: number): boolean => x !== v);
} else {
this.todoEditReminders = this.todoEditReminders.concat([v]);
}
}
/** 保存新建的待办 */
private async saveNewTodo(ctx: common.Context): Promise<void> {
const title: string = this.todoEditTitle.trim();
@@ -3348,13 +3539,16 @@ struct Index {
// 截止时间只到日(00:00 视为"当天")→ 用 DATE 值,避免服务器上显示 00:00
const dateOnly: boolean = dueMs > 0 && this.todoEditDueMs % 86400000 === 0;
const newId: number = await TodoService.createTodo(ctx, href, title,
this.todoEditDesc, dueMs, dateOnly);
this.todoEditDesc, dueMs, dateOnly,
this.todoEditPriority, this.todoEditCategories, this.todoEditLocation);
if (newId > 0) {
this.todoEditShow = false;
this.todoCreating = false;
this.getUIContext().getPromptAction().showToast({ message: '待办已创建' });
this.todos = await CalendarDataService.loadTodos(ctx, this.sources);
this.rebuildPages();
await CardDataService.pushToAllForms(ctx);
await ReminderService.refreshReminders(ctx);
} else {
this.getUIContext().getPromptAction().showToast({
message: '创建失败:需联网,且该日历本需可写'
@@ -3401,6 +3595,9 @@ struct Index {
this.getUIContext().getPromptAction().showToast({ message: '已删除' });
this.todos = await CalendarDataService.loadTodos(ctx, this.sources);
this.rebuildPages();
await CardDataService.pushToAllForms(ctx);
// 删除后必须重排,否则已删待办的提醒还会响
await ReminderService.refreshReminders(ctx);
} else {
this.getUIContext().getPromptAction().showToast({ message: '删除失败,请稍后重试' });
}
@@ -3473,6 +3670,10 @@ struct Index {
}
// 重新读库刷新列表(服务层已更新 completed)
this.todos = await CalendarDataService.loadTodos(ctx, this.sources);
// ⭐ 桌面卡片是独立实例,必须显式推送,否则停在旧数据
await CardDataService.pushToAllForms(ctx);
// 完成/取消完成会改变"该不该提醒",需要重排
await ReminderService.refreshReminders(ctx);
} else {
this.getUIContext().getPromptAction().showToast({
message: '更新失败:需联网,且该日历本需可写'