From 413900bbce23fe764b4fc99123c41aa2c8349fd2 Mon Sep 17 00:00:00 2001 From: laoyang Date: Wed, 4 Nov 2026 08:23:42 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=BE=85=E5=8A=9E):=20=E6=8F=90=E9=86=92?= =?UTF-8?q?=20/=20=E5=B7=A6=E6=BB=91=E5=88=A0=E9=99=A4=20/=20=E4=BC=98?= =?UTF-8?q?=E5=85=88=E7=BA=A7=E6=A0=87=E7=AD=BE=E5=9C=B0=E7=82=B9=20/=20?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E7=83=AD=E5=8C=BA=20/=20=E5=8D=A1=E7=89=87?= =?UTF-8?q?=E5=88=B7=E6=96=B0=20(#38=20#39=20#40=20#41=20#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一次性完成待办模块的 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 的记录)。 --- .../main/ets/common/CalendarDataService.ets | 9 + entry/src/main/ets/common/EventDb.ets | 71 ++++- entry/src/main/ets/common/IcsUtil.ets | 105 ++++++- entry/src/main/ets/common/ReminderService.ets | 19 +- entry/src/main/ets/common/TodoService.ets | 20 +- entry/src/main/ets/pages/Index.ets | 291 +++++++++++++++--- 6 files changed, 459 insertions(+), 56 deletions(-) diff --git a/entry/src/main/ets/common/CalendarDataService.ets b/entry/src/main/ets/common/CalendarDataService.ets index f9279d8..80196f4 100644 --- a/entry/src/main/ets/common/CalendarDataService.ets +++ b/entry/src/main/ets/common/CalendarDataService.ets @@ -30,6 +30,12 @@ export class DisplayEvent { recurRule: string = ''; /** 待办的 DTSTART(重复基准,毫秒;0 = 无) */ dtstart: number = 0; + /** 优先级,iCal 原生值:1=最高 … 9=最低,0=未设置 */ + priority: number = 0; + /** 分类标签,逗号分隔(对应 CATEGORIES) */ + categories: string = ''; + /** 提醒提前分钟数(多选),空 = 不提醒 */ + reminders: number[] = []; writable: boolean = true; // 所属日历本是否可写(只读时点击显示详情而非编辑) } @@ -373,6 +379,9 @@ export class CalendarDataService { 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 !== ''; diff --git a/entry/src/main/ets/common/EventDb.ets b/entry/src/main/ets/common/EventDb.ets index db7dccf..c2bbade 100644 --- a/entry/src/main/ets/common/EventDb.ets +++ b/entry/src/main/ets/common/EventDb.ets @@ -33,6 +33,13 @@ export class LocalEvent { * 只改标题时它等于 startTime;**把某次发生改期**时它才是原始时刻—— * 排除主事件对应发生时必须用它,否则原时间会残留成"幽灵日程"。 */ recurrenceId: number = 0; + /** + * 优先级,用 **iCal 原生值**:1=最高 … 9=最低,0=未设置。 + * 直接存原生值,写回时零转换,也不会与服务器上其他客户端写下的值语义不一致。 + */ + priority: number = 0; + /** 分类标签,逗号分隔(对应 iCal CATEGORIES,多值用逗号连接) */ + categories: string = ''; reminder: number = 0; // 主提醒提前分钟数(第一个提醒,兼容旧字段),0 = 不提醒 reminders: number[] = []; // 全部提醒提前分钟数(多选),按添加顺序 } @@ -44,6 +51,10 @@ export class RemoteEvent { title: string = ''; description: string = ''; location: string = ''; + /** 优先级,iCal 原生值:1=最高 … 9=最低,0=未设置 */ + priority: number = 0; + /** 分类标签,逗号分隔(对应 CATEGORIES) */ + categories: string = ''; startTime: number = 0; endTime: number = 0; isAllDay: boolean = false; @@ -138,6 +149,16 @@ export class EventDb { } catch (err) { // 列已存在 } + try { + await store.executeSql('ALTER TABLE events ADD COLUMN priority INTEGER DEFAULT 0'); + } catch (err) { + // 列已存在 + } + try { + await store.executeSql("ALTER TABLE events ADD COLUMN categories TEXT DEFAULT ''"); + } catch (err) { + // 列已存在 + } try { await store.executeSql('ALTER TABLE events ADD COLUMN dtstart INTEGER DEFAULT 0'); } catch (err) { @@ -207,6 +228,9 @@ export class EventDb { e.title = rs.getString(rs.getColumnIndex('title')); e.description = rs.getString(rs.getColumnIndex('description')); e.location = rs.getString(rs.getColumnIndex('location')); + e.priority = rs.getLong(rs.getColumnIndex('priority')); + const cats: string = rs.getString(rs.getColumnIndex('categories')); + e.categories = cats === undefined || cats === null ? '' : cats; e.startTime = rs.getLong(rs.getColumnIndex('start_time')); e.endTime = rs.getLong(rs.getColumnIndex('end_time')); e.isAllDay = rs.getLong(rs.getColumnIndex('is_all_day')) === 1; @@ -254,7 +278,9 @@ export class EventDb { 'exdate': e.exdate, 'recurrence_id': e.recurrenceId, 'reminder': e.reminders.length > 0 ? e.reminders[0] : 0, - 'reminders': e.reminders.map((n: number): string => String(n)).join(',') + 'reminders': e.reminders.map((n: number): string => String(n)).join(','), + 'priority': e.priority, + 'categories': e.categories }; return bucket; } @@ -481,6 +507,49 @@ export class EventDb { } /** 未来 7 天内需要提醒的日程(reminder > 0),按开始时间升序 */ + /** + * 查"未来窗口内需要提醒的未完成待办"(#38)。 + * + * 与 queryRemindable(日程)的区别: + * - `kind='todo'` 而不是 'event' + * - **必须 `completed=0`** —— 已完成的待办不该再响 + * - 待办的提醒基准同样是 start_time(loadTodos 把 DUE 存进 startTime) + */ + /** 单独更新待办的提醒列表(updateTodoFields 只管标题/备注/截止三字段) */ + static async setTodoReminders(context: common.Context, id: number, + reminders: number[]): Promise { + const store = await EventDb.getDb(context); + const bucket: relationalStore.ValuesBucket = { + 'reminder': reminders.length > 0 ? reminders[0] : 0, + 'reminders': reminders.map((n: number): string => String(n)).join(',') + }; + const predicates = new relationalStore.RdbPredicates('events'); + predicates.equalTo('id', id); + await store.update(bucket, predicates); + } + + static async queryRemindableTodos(context: common.Context, from: number, + to: number): Promise { + const store = await EventDb.getDb(context); + const predicates = new relationalStore.RdbPredicates('events'); + predicates.equalTo('deleted', 0).and().equalTo('kind', 'todo') + .and().equalTo('completed', 0) + .and().greaterThan('reminder', 0) + .and().greaterThanOrEqualTo('start_time', from) + .and().lessThanOrEqualTo('start_time', to) + .orderByAsc('start_time').limitAs(50); + const rs = await store.query(predicates); + const list: LocalEvent[] = []; + try { + while (rs.goToNextRow()) { + list.push(EventDb.fromRow(rs)); + } + } finally { + rs.close(); + } + return list; + } + static async queryRemindable(context: common.Context, from: number, to: number): Promise { const store = await EventDb.getDb(context); const predicates = new relationalStore.RdbPredicates('events'); diff --git a/entry/src/main/ets/common/IcsUtil.ets b/entry/src/main/ets/common/IcsUtil.ets index 7dc271d..f6233a3 100644 --- a/entry/src/main/ets/common/IcsUtil.ets +++ b/entry/src/main/ets/common/IcsUtil.ets @@ -7,6 +7,10 @@ class ParsedEvent { uid: string = ''; title: string = ''; description: string = ''; + /** 优先级,iCal 原生值:1=最高 … 9=最低,0=未设置 */ + priority: number = 0; + /** 分类标签,逗号分隔(对应 CATEGORIES) */ + categories: string = ''; location: string = ''; startTime: number = 0; endTime: number = 0; @@ -341,7 +345,13 @@ export class IcsUtil { * ② 再在块内替换/删除目标字段,缺失的在 END:VTODO 前补齐 */ static rewriteTodoFields(ics: string, uid: string, title: string, - desc: string, dueMs: number): string { + desc: string, dueMs: number, priority: number = -1, categories: string = '', + location: string = '', reminders: number[] = []): string { + // priority === -1 表示"本次不改优先级";0 表示"清空优先级"。 + // 用 -1 而不是 0 做哨兵,因为 0 本身是合法语义(无优先级)。 + const touchPriority: boolean = priority >= 0; + const touchCategories: boolean = categories !== ''; + const touchLocation: boolean = location !== ''; const crlf: string = '\r\n'; const unfolded: string = ics.replace(/\r?\n[ \t]/g, ''); const lines: string[] = unfolded.split(/\r?\n/).map((s: string): string => s.replace(/\r$/, '')); @@ -383,6 +393,10 @@ export class IcsUtil { let sawSummary: boolean = false; let sawDesc: boolean = false; let sawDue: boolean = false; + let inAlarm: boolean = false; + let sawPriority: boolean = false; + let sawCategories: boolean = false; + let sawLocation: boolean = false; for (let i = 0; i < lines.length; i++) { const line: string = lines[i]; const u: string = upperOf(line); @@ -412,6 +426,30 @@ export class IcsUtil { } continue; } + // 提醒:整段丢弃旧 VALARM,末尾按新列表重建(比逐行改 TRIGGER 简单且不会漏 ACTION/DESCRIPTION) + if (reminders.length > 0 && (u.startsWith('BEGIN:VALARM') || inAlarm)) { + if (u.startsWith('BEGIN:VALARM')) { inAlarm = true; } + if (u.startsWith('END:VALARM')) { inAlarm = false; } + continue; + } + if (u.startsWith('PRIORITY') && touchPriority) { + sawPriority = true; + if (priority >= 1 && priority <= 9) { + out.push('PRIORITY:' + priority); + } + // priority===0 → 清空,不 push(等于删除该行) + continue; + } + if (u.startsWith('CATEGORIES') && touchCategories) { + sawCategories = true; + out.push('CATEGORIES:' + IcsUtil.escape(categories)); + continue; + } + if (u.startsWith('LOCATION') && touchLocation) { + sawLocation = true; + out.push('LOCATION:' + IcsUtil.escape(location)); + continue; + } out.push(line); } @@ -426,6 +464,27 @@ export class IcsUtil { if (!sawDue && dueMs > 0) { extra.push('DUE:' + IcsUtil.fmtIcsTime(dueMs, false)); } + if (!sawPriority && touchPriority && priority >= 1 && priority <= 9) { + extra.push('PRIORITY:' + priority); + } + if (!sawCategories && touchCategories) { + extra.push('CATEGORIES:' + IcsUtil.escape(categories)); + } + if (!sawLocation && touchLocation) { + extra.push('LOCATION:' + IcsUtil.escape(location)); + } + // 提醒整体重建(上面已把旧 VALARM 整段跳过) + if (reminders.length > 0) { + const alarms: string = IcsUtil.valarms(reminders); + if (alarms !== '') { + // 去掉末尾的 CRLF(extra 每项会由 join(crlf) 拼接,带尾部换行会多一个空行) + let a: string = alarms; + while (a.length >= 2 && a.charAt(a.length - 2) === '\r' && a.charAt(a.length - 1) === '\n') { + a = a.substring(0, a.length - 2); + } + extra.push(a); + } + } if (extra.length === 0) { const plain: string = out.join(crlf); return plain.endsWith(crlf) ? plain : plain + crlf; @@ -764,6 +823,14 @@ export class IcsUtil { current.description = IcsUtil.unescape(value); } else if (propName === 'LOCATION') { current.location = IcsUtil.unescape(value); + } else if (propName === 'PRIORITY') { + // iCal 原生值:1=最高 … 9=最低,0/缺失=未设置。直接存,不二次映射。 + const pv: number = parseInt(value, 10); + current.priority = isNaN(pv) ? 0 : pv; + } else if (propName === 'CATEGORIES') { + // 多标签用逗号分隔;iCal 允许多个 CATEGORIES 行,这里取最后一行即可 + // (本应用写回时只写一行,读别人的多行时最后一行足够用) + current.categories = IcsUtil.unescape(value); } else if (propName === 'RRULE') { current.recurring = true; // ⭐ 保存规则原文:原来只置了 recurring 标志,规则本身被丢掉, @@ -825,7 +892,8 @@ export class IcsUtil { * @param dueIsDateOnly 截止时间是否"只有日期无时刻"(true 写 `DUE;VALUE=DATE`) */ static buildTodo(uid: string, title: string, desc: string, dueMs: number, - dueIsDateOnly: boolean): string { + dueIsDateOnly: boolean, priority: number = 0, categories: string = '', + location: string = '', reminders: number[] = []): string { const now: string = IcsUtil.fmtUtcNow(); let out: string = 'BEGIN:VCALENDAR\r\n' + 'VERSION:2.0\r\n' + @@ -847,11 +915,39 @@ export class IcsUtil { } // 新建待办一律"未开始",完成态由 setCompleted 切换到 COMPLETED out += 'STATUS:NEEDS-ACTION\r\n'; + out += IcsUtil.todoExtras(priority, categories, location); + // VALARM:VTODO 的 TRIGGER 相对 DUE(RFC 5545 §3.8.6.3), + // 所以复用现有 valarm() 生成的 -PT5M 就是"到期前 5 分钟" + out += IcsUtil.valarmsPublic(reminders); out += 'END:VTODO\r\n'; out += 'END:VCALENDAR\r\n'; return out; } + /** + * 生成 VTODO 的扩展属性行(PRIORITY / CATEGORIES / LOCATION)。 + * + * 抽成方法的原因:新建(buildTodo)与改写(rewriteTodoFields)都要写这三行, + * 逻辑必须一致,否则新建的和改过的会生成不同结构。 + * + * @param priority iCal 原生值 1(最高)…9(最低);0 或非法值 = 不写 + * @param categories 逗号分隔的标签;空 = 不写 + * @param location 地点文本;空 = 不写 + */ + static todoExtras(priority: number, categories: string, location: string): string { + let out: string = ''; + if (priority >= 1 && priority <= 9) { + out += `PRIORITY:${priority}\r\n`; + } + if (categories !== '') { + out += `CATEGORIES:${IcsUtil.escape(categories)}\r\n`; + } + if (location !== '') { + out += `LOCATION:${IcsUtil.escape(location)}\r\n`; + } + return out; + } + /** * ⭐ 构建"一个重复系列"的完整资源:**主事件 + 全部覆盖实例**放在同一个 VCALENDAR 里。 * @@ -999,6 +1095,11 @@ export class IcsUtil { } /** 由提醒分钟数组生成多个 VALARM(已规范化去重) */ + /** 供待办侧复用的公开包装(VTODO 的 VALARM 与 VEVENT 格式相同) */ + static valarmsPublic(minutes: number[]): string { + return IcsUtil.valarms(minutes); + } + private static valarms(minutes: number[]): string { let out: string = ''; for (const m of minutes) { diff --git a/entry/src/main/ets/common/ReminderService.ets b/entry/src/main/ets/common/ReminderService.ets index 9dcf3bd..468b8f8 100644 --- a/entry/src/main/ets/common/ReminderService.ets +++ b/entry/src/main/ets/common/ReminderService.ets @@ -161,8 +161,13 @@ export class ReminderService { LogUtil.write(`取消全部提醒后系统仍剩 ${before} 条`); } const now: number = Date.now(); - const rows: LocalEvent[] = + // ⭐ #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 不再重复排(消除"提醒两次") @@ -193,6 +198,8 @@ export class ReminderService { } 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: { @@ -203,10 +210,14 @@ export class ReminderService { minute: d.getMinutes(), second: 0 }, - title: '日程提醒', - content: `「${calName}」中的「${e.title}」将在 ${rem} 分钟后开始`, + title: isTodo ? '待办提醒' : '日程提醒', + content: isTodo + ? `「${calName}」中的待办「${e.title}」将在 ${rem} 分钟后到期` + : `「${calName}」中的「${e.title}」将在 ${rem} 分钟后开始`, + // ⚠️ 通知 id 必须唯一:日程与待办的 id 来自同一张表(自增主键), + // 天然不冲突,所以这里不用额外加偏移,否则会与 rem 的乘加混淆。 notificationId: (e.id * 10 + rem) % 100000, - expiredContent: '该日程已开始', + expiredContent: isTodo ? '该待办已到期' : '该日程已开始', ringDuration: 5, snoozeTimes: 0, timeInterval: 0 diff --git a/entry/src/main/ets/common/TodoService.ets b/entry/src/main/ets/common/TodoService.ets index 7e79f0f..aa408c5 100644 --- a/entry/src/main/ets/common/TodoService.ets +++ b/entry/src/main/ets/common/TodoService.ets @@ -44,7 +44,8 @@ export class TodoService { * @param dueMs 0 = 清除截止时间(删除 DUE 行) */ static async updateTodo(context: common.Context, localId: number, title: string, - desc: string, dueMs: number): Promise { + desc: string, dueMs: number, priority: number = -1, categories: string = '', + location: string = '', reminders: number[] = []): Promise { const row: LocalEvent | null = await EventDb.queryById(context, localId); if (row === null || row.kind !== 'todo') { LogUtil.write(`待办更新失败:行不存在或不是待办 id=${localId}`); @@ -74,10 +75,13 @@ export class TodoService { LogUtil.write(`待办更新失败:服务器内容不是 VTODO id=${localId}`); return false; } - const updated: string = IcsUtil.rewriteTodoFields(original, row.uid, title, desc, dueMs); + const updated: string = IcsUtil.rewriteTodoFields(original, row.uid, title, desc, dueMs, + priority, categories, location, reminders); const newEtag: string = await DavClient.putEvent(url, auth, updated); await EventDb.updateTodoFields(context, localId, title, desc, dueMs > 0 ? dueMs : 0, newEtag); + // 提醒单独落库(updateTodoFields 只处理三个字段) + await EventDb.setTodoReminders(context, localId, reminders); LogUtil.write(`待办更新成功 id=${localId}(标题已脱敏)`); return true; } catch (err) { @@ -101,7 +105,9 @@ export class TodoService { * @returns 成功返回新行 id(>0),失败返回 0 */ static async createTodo(context: common.Context, href: string, title: string, - desc: string, dueMs: number, dueIsDateOnly: boolean): Promise { + desc: string, dueMs: number, dueIsDateOnly: boolean, priority: number = 0, + categories: string = '', location: string = '', + reminders: number[] = []): Promise { const t: string = title.trim(); if (t === '' || href === '') { LogUtil.write(`待办新建失败:标题或日历本为空`); @@ -131,7 +137,8 @@ export class TodoService { try { // 4) 先推服务器:成功才落库,避免产生"本地有、服务器没有"的孤儿 - const ics: string = IcsUtil.buildTodo(uid, t, desc, dueMs, dueIsDateOnly); + const ics: string = IcsUtil.buildTodo(uid, t, desc, dueMs, dueIsDateOnly, + priority, categories, location, reminders); const etag: string = await DavClient.putEvent(url, auth, ics); // 5) 落本地库(insertLocal 会置 dirty=true,但 etag 已写回故不会被重复推送) @@ -148,6 +155,11 @@ export class TodoService { e.endTime = dueMs > 0 ? dueMs : 0; e.isAllDay = dueIsDateOnly; e.completed = false; + e.priority = priority; + e.categories = categories; + e.location = location; + e.reminders = reminders; + e.reminder = reminders.length > 0 ? reminders[0] : 0; e.dirty = false; // 已经推成功了,无需再推 e.deleted = false; const newId: number = await EventDb.insertLocal(context, e); diff --git a/entry/src/main/ets/pages/Index.ets b/entry/src/main/ets/pages/Index.ets index 0b4a1ac..4bdf097 100644 --- a/entry/src/main/ets/pages/Index.ets +++ b/entry/src/main/ets/pages/Index.ets @@ -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): 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 => `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): 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 => `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 { 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: '更新失败:需联网,且该日历本需可写'