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
@@ -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 !== '';
+70 -1
View File
@@ -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<void> {
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<LocalEvent[]> {
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<LocalEvent[]> {
const store = await EventDb.getDb(context);
const predicates = new relationalStore.RdbPredicates('events');
+103 -2
View File
@@ -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) {
+15 -4
View File
@@ -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
+16 -4
View File
@@ -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<boolean> {
desc: string, dueMs: number, priority: number = -1, categories: string = '',
location: string = '', reminders: number[] = []): Promise<boolean> {
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<number> {
desc: string, dueMs: number, dueIsDateOnly: boolean, priority: number = 0,
categories: string = '', location: string = '',
reminders: number[] = []): Promise<number> {
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);
+231 -30
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%')
.padding({ top: 4 })
ForEach(this.todoGroups(false), (g: TodoGroup) => {
ListItemGroup({ header: this.todoGroupHeader(g) }) {
ForEach(g.items, (t: DisplayEvent) => {
ListItem() {
this.todoRow(t)
}
.width('100%')
.swipeAction({ end: this.todoSwipeDelete(t) })
}, (t: DisplayEvent) => `u${t.id}_${t.completed}`)
}
.alignItems(HorizontalAlign.Start)
.width('100%')
}, (g: TodoGroup) => `g0_${g.calKey}`)
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%')
.padding({ top: 4 })
ForEach(this.todoGroups(true), (g: TodoGroup) => {
ListItemGroup({ header: this.todoGroupHeader(g) }) {
ForEach(g.items, (t: DisplayEvent) => {
ListItem() {
this.todoRow(t)
}
.width('100%')
.swipeAction({ end: this.todoSwipeDelete(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) {
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%' })
}
.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: '更新失败:需联网,且该日历本需可写'