一次性完成待办模块的 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:
@@ -30,6 +30,12 @@ export class DisplayEvent {
|
|||||||
recurRule: string = '';
|
recurRule: string = '';
|
||||||
/** 待办的 DTSTART(重复基准,毫秒;0 = 无) */
|
/** 待办的 DTSTART(重复基准,毫秒;0 = 无) */
|
||||||
dtstart: number = 0;
|
dtstart: number = 0;
|
||||||
|
/** 优先级,iCal 原生值:1=最高 … 9=最低,0=未设置 */
|
||||||
|
priority: number = 0;
|
||||||
|
/** 分类标签,逗号分隔(对应 CATEGORIES) */
|
||||||
|
categories: string = '';
|
||||||
|
/** 提醒提前分钟数(多选),空 = 不提醒 */
|
||||||
|
reminders: number[] = [];
|
||||||
writable: boolean = true; // 所属日历本是否可写(只读时点击显示详情而非编辑)
|
writable: boolean = true; // 所属日历本是否可写(只读时点击显示详情而非编辑)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,6 +379,9 @@ export class CalendarDataService {
|
|||||||
d.calName = nameOf(e.calKey);
|
d.calName = nameOf(e.calKey);
|
||||||
d.color = colorOf(e.calKey);
|
d.color = colorOf(e.calKey);
|
||||||
d.completed = e.completed;
|
d.completed = e.completed;
|
||||||
|
d.priority = e.priority;
|
||||||
|
d.categories = e.categories;
|
||||||
|
d.reminders = e.reminders;
|
||||||
d.recurRule = e.rrule;
|
d.recurRule = e.rrule;
|
||||||
d.dtstart = e.dtstart;
|
d.dtstart = e.dtstart;
|
||||||
d.recurring = e.rrule !== '';
|
d.recurring = e.rrule !== '';
|
||||||
|
|||||||
@@ -33,6 +33,13 @@ export class LocalEvent {
|
|||||||
* 只改标题时它等于 startTime;**把某次发生改期**时它才是原始时刻——
|
* 只改标题时它等于 startTime;**把某次发生改期**时它才是原始时刻——
|
||||||
* 排除主事件对应发生时必须用它,否则原时间会残留成"幽灵日程"。 */
|
* 排除主事件对应发生时必须用它,否则原时间会残留成"幽灵日程"。 */
|
||||||
recurrenceId: number = 0;
|
recurrenceId: number = 0;
|
||||||
|
/**
|
||||||
|
* 优先级,用 **iCal 原生值**:1=最高 … 9=最低,0=未设置。
|
||||||
|
* 直接存原生值,写回时零转换,也不会与服务器上其他客户端写下的值语义不一致。
|
||||||
|
*/
|
||||||
|
priority: number = 0;
|
||||||
|
/** 分类标签,逗号分隔(对应 iCal CATEGORIES,多值用逗号连接) */
|
||||||
|
categories: string = '';
|
||||||
reminder: number = 0; // 主提醒提前分钟数(第一个提醒,兼容旧字段),0 = 不提醒
|
reminder: number = 0; // 主提醒提前分钟数(第一个提醒,兼容旧字段),0 = 不提醒
|
||||||
reminders: number[] = []; // 全部提醒提前分钟数(多选),按添加顺序
|
reminders: number[] = []; // 全部提醒提前分钟数(多选),按添加顺序
|
||||||
}
|
}
|
||||||
@@ -44,6 +51,10 @@ export class RemoteEvent {
|
|||||||
title: string = '';
|
title: string = '';
|
||||||
description: string = '';
|
description: string = '';
|
||||||
location: string = '';
|
location: string = '';
|
||||||
|
/** 优先级,iCal 原生值:1=最高 … 9=最低,0=未设置 */
|
||||||
|
priority: number = 0;
|
||||||
|
/** 分类标签,逗号分隔(对应 CATEGORIES) */
|
||||||
|
categories: string = '';
|
||||||
startTime: number = 0;
|
startTime: number = 0;
|
||||||
endTime: number = 0;
|
endTime: number = 0;
|
||||||
isAllDay: boolean = false;
|
isAllDay: boolean = false;
|
||||||
@@ -138,6 +149,16 @@ export class EventDb {
|
|||||||
} catch (err) {
|
} 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 {
|
try {
|
||||||
await store.executeSql('ALTER TABLE events ADD COLUMN dtstart INTEGER DEFAULT 0');
|
await store.executeSql('ALTER TABLE events ADD COLUMN dtstart INTEGER DEFAULT 0');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -207,6 +228,9 @@ export class EventDb {
|
|||||||
e.title = rs.getString(rs.getColumnIndex('title'));
|
e.title = rs.getString(rs.getColumnIndex('title'));
|
||||||
e.description = rs.getString(rs.getColumnIndex('description'));
|
e.description = rs.getString(rs.getColumnIndex('description'));
|
||||||
e.location = rs.getString(rs.getColumnIndex('location'));
|
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.startTime = rs.getLong(rs.getColumnIndex('start_time'));
|
||||||
e.endTime = rs.getLong(rs.getColumnIndex('end_time'));
|
e.endTime = rs.getLong(rs.getColumnIndex('end_time'));
|
||||||
e.isAllDay = rs.getLong(rs.getColumnIndex('is_all_day')) === 1;
|
e.isAllDay = rs.getLong(rs.getColumnIndex('is_all_day')) === 1;
|
||||||
@@ -254,7 +278,9 @@ export class EventDb {
|
|||||||
'exdate': e.exdate,
|
'exdate': e.exdate,
|
||||||
'recurrence_id': e.recurrenceId,
|
'recurrence_id': e.recurrenceId,
|
||||||
'reminder': e.reminders.length > 0 ? e.reminders[0] : 0,
|
'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;
|
return bucket;
|
||||||
}
|
}
|
||||||
@@ -481,6 +507,49 @@ export class EventDb {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 未来 7 天内需要提醒的日程(reminder > 0),按开始时间升序 */
|
/** 未来 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[]> {
|
static async queryRemindable(context: common.Context, from: number, to: number): Promise<LocalEvent[]> {
|
||||||
const store = await EventDb.getDb(context);
|
const store = await EventDb.getDb(context);
|
||||||
const predicates = new relationalStore.RdbPredicates('events');
|
const predicates = new relationalStore.RdbPredicates('events');
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ class ParsedEvent {
|
|||||||
uid: string = '';
|
uid: string = '';
|
||||||
title: string = '';
|
title: string = '';
|
||||||
description: string = '';
|
description: string = '';
|
||||||
|
/** 优先级,iCal 原生值:1=最高 … 9=最低,0=未设置 */
|
||||||
|
priority: number = 0;
|
||||||
|
/** 分类标签,逗号分隔(对应 CATEGORIES) */
|
||||||
|
categories: string = '';
|
||||||
location: string = '';
|
location: string = '';
|
||||||
startTime: number = 0;
|
startTime: number = 0;
|
||||||
endTime: number = 0;
|
endTime: number = 0;
|
||||||
@@ -341,7 +345,13 @@ export class IcsUtil {
|
|||||||
* ② 再在块内替换/删除目标字段,缺失的在 END:VTODO 前补齐
|
* ② 再在块内替换/删除目标字段,缺失的在 END:VTODO 前补齐
|
||||||
*/
|
*/
|
||||||
static rewriteTodoFields(ics: string, uid: string, title: string,
|
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 crlf: string = '\r\n';
|
||||||
const unfolded: string = ics.replace(/\r?\n[ \t]/g, '');
|
const unfolded: string = ics.replace(/\r?\n[ \t]/g, '');
|
||||||
const lines: string[] = unfolded.split(/\r?\n/).map((s: string): string => s.replace(/\r$/, ''));
|
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 sawSummary: boolean = false;
|
||||||
let sawDesc: boolean = false;
|
let sawDesc: boolean = false;
|
||||||
let sawDue: 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++) {
|
for (let i = 0; i < lines.length; i++) {
|
||||||
const line: string = lines[i];
|
const line: string = lines[i];
|
||||||
const u: string = upperOf(line);
|
const u: string = upperOf(line);
|
||||||
@@ -412,6 +426,30 @@ export class IcsUtil {
|
|||||||
}
|
}
|
||||||
continue;
|
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);
|
out.push(line);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -426,6 +464,27 @@ export class IcsUtil {
|
|||||||
if (!sawDue && dueMs > 0) {
|
if (!sawDue && dueMs > 0) {
|
||||||
extra.push('DUE:' + IcsUtil.fmtIcsTime(dueMs, false));
|
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) {
|
if (extra.length === 0) {
|
||||||
const plain: string = out.join(crlf);
|
const plain: string = out.join(crlf);
|
||||||
return plain.endsWith(crlf) ? plain : plain + crlf;
|
return plain.endsWith(crlf) ? plain : plain + crlf;
|
||||||
@@ -764,6 +823,14 @@ export class IcsUtil {
|
|||||||
current.description = IcsUtil.unescape(value);
|
current.description = IcsUtil.unescape(value);
|
||||||
} else if (propName === 'LOCATION') {
|
} else if (propName === 'LOCATION') {
|
||||||
current.location = IcsUtil.unescape(value);
|
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') {
|
} else if (propName === 'RRULE') {
|
||||||
current.recurring = true;
|
current.recurring = true;
|
||||||
// ⭐ 保存规则原文:原来只置了 recurring 标志,规则本身被丢掉,
|
// ⭐ 保存规则原文:原来只置了 recurring 标志,规则本身被丢掉,
|
||||||
@@ -825,7 +892,8 @@ export class IcsUtil {
|
|||||||
* @param dueIsDateOnly 截止时间是否"只有日期无时刻"(true 写 `DUE;VALUE=DATE`)
|
* @param dueIsDateOnly 截止时间是否"只有日期无时刻"(true 写 `DUE;VALUE=DATE`)
|
||||||
*/
|
*/
|
||||||
static buildTodo(uid: string, title: string, desc: string, dueMs: number,
|
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();
|
const now: string = IcsUtil.fmtUtcNow();
|
||||||
let out: string = 'BEGIN:VCALENDAR\r\n' +
|
let out: string = 'BEGIN:VCALENDAR\r\n' +
|
||||||
'VERSION:2.0\r\n' +
|
'VERSION:2.0\r\n' +
|
||||||
@@ -847,11 +915,39 @@ export class IcsUtil {
|
|||||||
}
|
}
|
||||||
// 新建待办一律"未开始",完成态由 setCompleted 切换到 COMPLETED
|
// 新建待办一律"未开始",完成态由 setCompleted 切换到 COMPLETED
|
||||||
out += 'STATUS:NEEDS-ACTION\r\n';
|
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:VTODO\r\n';
|
||||||
out += 'END:VCALENDAR\r\n';
|
out += 'END:VCALENDAR\r\n';
|
||||||
return out;
|
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 里。
|
* ⭐ 构建"一个重复系列"的完整资源:**主事件 + 全部覆盖实例**放在同一个 VCALENDAR 里。
|
||||||
*
|
*
|
||||||
@@ -999,6 +1095,11 @@ export class IcsUtil {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 由提醒分钟数组生成多个 VALARM(已规范化去重) */
|
/** 由提醒分钟数组生成多个 VALARM(已规范化去重) */
|
||||||
|
/** 供待办侧复用的公开包装(VTODO 的 VALARM 与 VEVENT 格式相同) */
|
||||||
|
static valarmsPublic(minutes: number[]): string {
|
||||||
|
return IcsUtil.valarms(minutes);
|
||||||
|
}
|
||||||
|
|
||||||
private static valarms(minutes: number[]): string {
|
private static valarms(minutes: number[]): string {
|
||||||
let out: string = '';
|
let out: string = '';
|
||||||
for (const m of minutes) {
|
for (const m of minutes) {
|
||||||
|
|||||||
@@ -161,8 +161,13 @@ export class ReminderService {
|
|||||||
LogUtil.write(`取消全部提醒后系统仍剩 ${before} 条`);
|
LogUtil.write(`取消全部提醒后系统仍剩 ${before} 条`);
|
||||||
}
|
}
|
||||||
const now: number = Date.now();
|
const now: number = Date.now();
|
||||||
const rows: LocalEvent[] =
|
// ⭐ #38 日程 + 待办一起排提醒。两类分别查(过滤条件不同:
|
||||||
|
// 待办要求 completed=0,日程无此概念),合并后统一处理。
|
||||||
|
const eventRows: LocalEvent[] =
|
||||||
await EventDb.queryRemindable(context, now, now + ReminderService.WINDOW);
|
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);
|
const muted: string[] = await AppSettings.getMutedReminderKeys(context);
|
||||||
// ⭐⭐ 已镜像到系统日历的本:由系统日历负责提醒,App 不再重复排(消除"提醒两次")
|
// ⭐⭐ 已镜像到系统日历的本:由系统日历负责提醒,App 不再重复排(消除"提醒两次")
|
||||||
@@ -193,6 +198,8 @@ export class ReminderService {
|
|||||||
}
|
}
|
||||||
const d = new Date(remindAt);
|
const d = new Date(remindAt);
|
||||||
const calName: string = await ReminderService.calName(context, e.calKey);
|
const calName: string = await ReminderService.calName(context, e.calKey);
|
||||||
|
// #38 文案按类型区分:待办是"到期",日程是"开始"
|
||||||
|
const isTodo: boolean = e.kind === 'todo';
|
||||||
const request: reminderAgentManager.ReminderRequestCalendar = {
|
const request: reminderAgentManager.ReminderRequestCalendar = {
|
||||||
reminderType: reminderAgentManager.ReminderType.REMINDER_TYPE_CALENDAR,
|
reminderType: reminderAgentManager.ReminderType.REMINDER_TYPE_CALENDAR,
|
||||||
dateTime: {
|
dateTime: {
|
||||||
@@ -203,10 +210,14 @@ export class ReminderService {
|
|||||||
minute: d.getMinutes(),
|
minute: d.getMinutes(),
|
||||||
second: 0
|
second: 0
|
||||||
},
|
},
|
||||||
title: '日程提醒',
|
title: isTodo ? '待办提醒' : '日程提醒',
|
||||||
content: `「${calName}」中的「${e.title}」将在 ${rem} 分钟后开始`,
|
content: isTodo
|
||||||
|
? `「${calName}」中的待办「${e.title}」将在 ${rem} 分钟后到期`
|
||||||
|
: `「${calName}」中的「${e.title}」将在 ${rem} 分钟后开始`,
|
||||||
|
// ⚠️ 通知 id 必须唯一:日程与待办的 id 来自同一张表(自增主键),
|
||||||
|
// 天然不冲突,所以这里不用额外加偏移,否则会与 rem 的乘加混淆。
|
||||||
notificationId: (e.id * 10 + rem) % 100000,
|
notificationId: (e.id * 10 + rem) % 100000,
|
||||||
expiredContent: '该日程已开始',
|
expiredContent: isTodo ? '该待办已到期' : '该日程已开始',
|
||||||
ringDuration: 5,
|
ringDuration: 5,
|
||||||
snoozeTimes: 0,
|
snoozeTimes: 0,
|
||||||
timeInterval: 0
|
timeInterval: 0
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ export class TodoService {
|
|||||||
* @param dueMs 0 = 清除截止时间(删除 DUE 行)
|
* @param dueMs 0 = 清除截止时间(删除 DUE 行)
|
||||||
*/
|
*/
|
||||||
static async updateTodo(context: common.Context, localId: number, title: string,
|
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);
|
const row: LocalEvent | null = await EventDb.queryById(context, localId);
|
||||||
if (row === null || row.kind !== 'todo') {
|
if (row === null || row.kind !== 'todo') {
|
||||||
LogUtil.write(`待办更新失败:行不存在或不是待办 id=${localId}`);
|
LogUtil.write(`待办更新失败:行不存在或不是待办 id=${localId}`);
|
||||||
@@ -74,10 +75,13 @@ export class TodoService {
|
|||||||
LogUtil.write(`待办更新失败:服务器内容不是 VTODO id=${localId}`);
|
LogUtil.write(`待办更新失败:服务器内容不是 VTODO id=${localId}`);
|
||||||
return false;
|
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);
|
const newEtag: string = await DavClient.putEvent(url, auth, updated);
|
||||||
await EventDb.updateTodoFields(context, localId, title, desc,
|
await EventDb.updateTodoFields(context, localId, title, desc,
|
||||||
dueMs > 0 ? dueMs : 0, newEtag);
|
dueMs > 0 ? dueMs : 0, newEtag);
|
||||||
|
// 提醒单独落库(updateTodoFields 只处理三个字段)
|
||||||
|
await EventDb.setTodoReminders(context, localId, reminders);
|
||||||
LogUtil.write(`待办更新成功 id=${localId}(标题已脱敏)`);
|
LogUtil.write(`待办更新成功 id=${localId}(标题已脱敏)`);
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -101,7 +105,9 @@ export class TodoService {
|
|||||||
* @returns 成功返回新行 id(>0),失败返回 0
|
* @returns 成功返回新行 id(>0),失败返回 0
|
||||||
*/
|
*/
|
||||||
static async createTodo(context: common.Context, href: string, title: string,
|
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();
|
const t: string = title.trim();
|
||||||
if (t === '' || href === '') {
|
if (t === '' || href === '') {
|
||||||
LogUtil.write(`待办新建失败:标题或日历本为空`);
|
LogUtil.write(`待办新建失败:标题或日历本为空`);
|
||||||
@@ -131,7 +137,8 @@ export class TodoService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// 4) 先推服务器:成功才落库,避免产生"本地有、服务器没有"的孤儿
|
// 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);
|
const etag: string = await DavClient.putEvent(url, auth, ics);
|
||||||
|
|
||||||
// 5) 落本地库(insertLocal 会置 dirty=true,但 etag 已写回故不会被重复推送)
|
// 5) 落本地库(insertLocal 会置 dirty=true,但 etag 已写回故不会被重复推送)
|
||||||
@@ -148,6 +155,11 @@ export class TodoService {
|
|||||||
e.endTime = dueMs > 0 ? dueMs : 0;
|
e.endTime = dueMs > 0 ? dueMs : 0;
|
||||||
e.isAllDay = dueIsDateOnly;
|
e.isAllDay = dueIsDateOnly;
|
||||||
e.completed = false;
|
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.dirty = false; // 已经推成功了,无需再推
|
||||||
e.deleted = false;
|
e.deleted = false;
|
||||||
const newId: number = await EventDb.insertLocal(context, e);
|
const newId: number = await EventDb.insertLocal(context, e);
|
||||||
|
|||||||
@@ -84,6 +84,14 @@ struct Index {
|
|||||||
@State todoEditBook: string = ''; // 所属本名(只读展示)
|
@State todoEditBook: string = ''; // 所属本名(只读展示)
|
||||||
@State todoEditWritable: boolean = true;
|
@State todoEditWritable: boolean = true;
|
||||||
@State todoEditRecur: string = ''; // 当前循环('' = 不循环),存 FREQ 值如 WEEKLY
|
@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 todoEditHasDue: boolean = false;
|
||||||
@State todoEditPickShow: boolean = false; // 截止时间选择器
|
@State todoEditPickShow: boolean = false; // 截止时间选择器
|
||||||
@State todoEditPickDate: Date = new Date();
|
@State todoEditPickDate: Date = new Date();
|
||||||
@@ -2670,61 +2678,61 @@ struct Index {
|
|||||||
// 用户要求"按日历本分类显示,未完成里也能按家庭、生活分开,要有区分度"。
|
// 用户要求"按日历本分类显示,未完成里也能按家庭、生活分开,要有区分度"。
|
||||||
// 分组标题前带该本的颜色圆点 —— 这才是"区分度"的来源。
|
// 分组标题前带该本的颜色圆点 —— 这才是"区分度"的来源。
|
||||||
todoBody() {
|
todoBody() {
|
||||||
Scroll() {
|
// ⭐ 外层由 Scroll 改为 List:`swipeAction` 只能挂在 ListItem 上,
|
||||||
Column({ space: 8 }) {
|
// 而 ListItem 必须是 List 的直接子节点(#39 左滑删除)。
|
||||||
|
// 分组用 ListItemGroup 实现 —— 它天生支持分组头,正好放"色点 + 本名 + 条数"。
|
||||||
|
List({ space: 6 }) {
|
||||||
|
ListItem() {
|
||||||
Text(`未完成(${this.todos.filter((t: DisplayEvent): boolean => !t.completed).length})`)
|
Text(`未完成(${this.todos.filter((t: DisplayEvent): boolean => !t.completed).length})`)
|
||||||
.fontSize(13)
|
.fontSize(13)
|
||||||
.fontWeight(FontWeight.Bold)
|
.fontWeight(FontWeight.Bold)
|
||||||
.fontColor($r('app.color.text_secondary'))
|
.fontColor($r('app.color.text_secondary'))
|
||||||
.width('100%')
|
.width('100%')
|
||||||
ForEach(this.todoGroups(false), (g: TodoGroup) => {
|
.textAlign(TextAlign.Start)
|
||||||
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%')
|
.width('100%')
|
||||||
.padding({ top: 4 })
|
|
||||||
|
ForEach(this.todoGroups(false), (g: TodoGroup) => {
|
||||||
|
ListItemGroup({ header: this.todoGroupHeader(g) }) {
|
||||||
ForEach(g.items, (t: DisplayEvent) => {
|
ForEach(g.items, (t: DisplayEvent) => {
|
||||||
|
ListItem() {
|
||||||
this.todoRow(t)
|
this.todoRow(t)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.swipeAction({ end: this.todoSwipeDelete(t) })
|
||||||
}, (t: DisplayEvent) => `u${t.id}_${t.completed}`)
|
}, (t: DisplayEvent) => `u${t.id}_${t.completed}`)
|
||||||
}
|
}
|
||||||
.alignItems(HorizontalAlign.Start)
|
|
||||||
.width('100%')
|
.width('100%')
|
||||||
}, (g: TodoGroup) => `g0_${g.calKey}`)
|
}, (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})`)
|
Text(`已完成(${this.todos.filter((t: DisplayEvent): boolean => t.completed).length})`)
|
||||||
.fontSize(13)
|
.fontSize(13)
|
||||||
.fontWeight(FontWeight.Bold)
|
.fontWeight(FontWeight.Bold)
|
||||||
.fontColor($r('app.color.text_secondary'))
|
.fontColor($r('app.color.text_secondary'))
|
||||||
.width('100%')
|
.width('100%')
|
||||||
|
.textAlign(TextAlign.Start)
|
||||||
.margin({ top: 10 })
|
.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%')
|
.width('100%')
|
||||||
.padding({ top: 4 })
|
|
||||||
|
ForEach(this.todoGroups(true), (g: TodoGroup) => {
|
||||||
|
ListItemGroup({ header: this.todoGroupHeader(g) }) {
|
||||||
ForEach(g.items, (t: DisplayEvent) => {
|
ForEach(g.items, (t: DisplayEvent) => {
|
||||||
|
ListItem() {
|
||||||
this.todoRow(t)
|
this.todoRow(t)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.swipeAction({ end: this.todoSwipeDelete(t) })
|
||||||
}, (t: DisplayEvent) => `c${t.id}_${t.completed}`)
|
}, (t: DisplayEvent) => `c${t.id}_${t.completed}`)
|
||||||
}
|
}
|
||||||
.alignItems(HorizontalAlign.Start)
|
|
||||||
.width('100%')
|
.width('100%')
|
||||||
}, (g: TodoGroup) => `g1_${g.calKey}`)
|
}, (g: TodoGroup) => `g1_${g.calKey}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.todos.length === 0 && !this.loading) {
|
if (this.todos.length === 0 && !this.loading) {
|
||||||
|
ListItem() {
|
||||||
Column({ space: 8 }) {
|
Column({ space: 8 }) {
|
||||||
Text('☑')
|
Text('☑')
|
||||||
.fontSize(34)
|
.fontSize(34)
|
||||||
@@ -2745,15 +2753,59 @@ struct Index {
|
|||||||
.width('100%')
|
.width('100%')
|
||||||
.padding(32)
|
.padding(32)
|
||||||
}
|
}
|
||||||
|
.width('100%')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.width('100%')
|
.width('100%')
|
||||||
.padding({ left: 20, right: 20, top: 8, bottom: 24 })
|
|
||||||
.constraintSize({ minHeight: '100%' })
|
|
||||||
}
|
|
||||||
.layoutWeight(1)
|
.layoutWeight(1)
|
||||||
|
.padding({ left: 20, right: 20, top: 8, bottom: 24 })
|
||||||
.scrollBar(BarState.Off)
|
.scrollBar(BarState.Off)
|
||||||
.edgeEffect(EdgeEffect.Spring)
|
.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%')
|
.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) {
|
if (this.todoEditRecur !== '' && !this.todoEditHasDue) {
|
||||||
Text('提示:循环任务需要有截止时间,才能算出下一次')
|
Text('提示:循环任务需要有截止时间,才能算出下一次')
|
||||||
.fontSize(11)
|
.fontSize(11)
|
||||||
@@ -3101,7 +3252,11 @@ struct Index {
|
|||||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||||
}
|
}
|
||||||
.alignItems(HorizontalAlign.Start)
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
.justifyContent(FlexAlign.Center)
|
||||||
.layoutWeight(1)
|
.layoutWeight(1)
|
||||||
|
// ⭐ 撑满行高:原来高度只到文字高度,行内上下留白点不到,
|
||||||
|
// 用户觉得"只有点标题才有反应"(#42)。撑满后整块内容区都可点。
|
||||||
|
.height('100%')
|
||||||
// ⭐ 内容区点击 → 打开编辑(不改变完成状态)
|
// ⭐ 内容区点击 → 打开编辑(不改变完成状态)
|
||||||
.onClick(() => {
|
.onClick(() => {
|
||||||
this.openTodoEditor(t);
|
this.openTodoEditor(t);
|
||||||
@@ -3240,6 +3395,10 @@ struct Index {
|
|||||||
this.todoEditWritable = true;
|
this.todoEditWritable = true;
|
||||||
this.todoEditBook = '';
|
this.todoEditBook = '';
|
||||||
this.todoEditRecur = '';
|
this.todoEditRecur = '';
|
||||||
|
this.todoEditPriority = 0;
|
||||||
|
this.todoEditCategories = '';
|
||||||
|
this.todoEditLocation = '';
|
||||||
|
this.todoEditReminders = [];
|
||||||
this.todoEditPickShow = false;
|
this.todoEditPickShow = false;
|
||||||
this.todoEditShow = true;
|
this.todoEditShow = true;
|
||||||
}
|
}
|
||||||
@@ -3255,6 +3414,11 @@ struct Index {
|
|||||||
this.todoEditBook = t.calName;
|
this.todoEditBook = t.calName;
|
||||||
this.todoEditWritable = t.writable;
|
this.todoEditWritable = t.writable;
|
||||||
this.todoEditRecur = t.recurRule !== '' ? IcsUtil.freqOfRrule(t.recurRule) : '';
|
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 点)
|
// 默认值给"明天 09:00",与新建日程的口径一致(非今天用 9 点)
|
||||||
const base: Date = new Date();
|
const base: Date = new Date();
|
||||||
this.todoEditPickDate = new Date(base.getFullYear(), base.getMonth(), base.getDate() + 1);
|
this.todoEditPickDate = new Date(base.getFullYear(), base.getMonth(), base.getDate() + 1);
|
||||||
@@ -3305,7 +3469,9 @@ struct Index {
|
|||||||
try {
|
try {
|
||||||
const ok: boolean = await TodoService.updateTodo(ctx, this.todoEditId,
|
const ok: boolean = await TodoService.updateTodo(ctx, this.todoEditId,
|
||||||
this.todoEditTitle, this.todoEditDesc,
|
this.todoEditTitle, this.todoEditDesc,
|
||||||
this.todoEditHasDue ? this.todoEditDueMs : 0);
|
this.todoEditHasDue ? this.todoEditDueMs : 0,
|
||||||
|
this.todoEditPriority, this.todoEditCategories, this.todoEditLocation,
|
||||||
|
this.todoEditReminders);
|
||||||
if (ok) {
|
if (ok) {
|
||||||
// ⭐ 循环规则单独写:它与"改内容"是两回事,
|
// ⭐ 循环规则单独写:它与"改内容"是两回事,
|
||||||
// 而且要先有截止时间才有意义(没有 DUE 的循环任务无法算下一次)
|
// 而且要先有截止时间才有意义(没有 DUE 的循环任务无法算下一次)
|
||||||
@@ -3318,6 +3484,9 @@ struct Index {
|
|||||||
this.getUIContext().getPromptAction().showToast({ message: '已保存' });
|
this.getUIContext().getPromptAction().showToast({ message: '已保存' });
|
||||||
this.todos = await CalendarDataService.loadTodos(ctx, this.sources);
|
this.todos = await CalendarDataService.loadTodos(ctx, this.sources);
|
||||||
this.rebuildPages();
|
this.rebuildPages();
|
||||||
|
// ⭐ 卡片是独立实例,必须显式推送;提醒随标题/截止/提醒项变化需重排
|
||||||
|
await CardDataService.pushToAllForms(ctx);
|
||||||
|
await ReminderService.refreshReminders(ctx);
|
||||||
} else {
|
} else {
|
||||||
this.getUIContext().getPromptAction().showToast({
|
this.getUIContext().getPromptAction().showToast({
|
||||||
message: '保存失败:需联网,且该日历本需可写'
|
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> {
|
private async saveNewTodo(ctx: common.Context): Promise<void> {
|
||||||
const title: string = this.todoEditTitle.trim();
|
const title: string = this.todoEditTitle.trim();
|
||||||
@@ -3348,13 +3539,16 @@ struct Index {
|
|||||||
// 截止时间只到日(00:00 视为"当天")→ 用 DATE 值,避免服务器上显示 00:00
|
// 截止时间只到日(00:00 视为"当天")→ 用 DATE 值,避免服务器上显示 00:00
|
||||||
const dateOnly: boolean = dueMs > 0 && this.todoEditDueMs % 86400000 === 0;
|
const dateOnly: boolean = dueMs > 0 && this.todoEditDueMs % 86400000 === 0;
|
||||||
const newId: number = await TodoService.createTodo(ctx, href, title,
|
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) {
|
if (newId > 0) {
|
||||||
this.todoEditShow = false;
|
this.todoEditShow = false;
|
||||||
this.todoCreating = false;
|
this.todoCreating = false;
|
||||||
this.getUIContext().getPromptAction().showToast({ message: '待办已创建' });
|
this.getUIContext().getPromptAction().showToast({ message: '待办已创建' });
|
||||||
this.todos = await CalendarDataService.loadTodos(ctx, this.sources);
|
this.todos = await CalendarDataService.loadTodos(ctx, this.sources);
|
||||||
this.rebuildPages();
|
this.rebuildPages();
|
||||||
|
await CardDataService.pushToAllForms(ctx);
|
||||||
|
await ReminderService.refreshReminders(ctx);
|
||||||
} else {
|
} else {
|
||||||
this.getUIContext().getPromptAction().showToast({
|
this.getUIContext().getPromptAction().showToast({
|
||||||
message: '创建失败:需联网,且该日历本需可写'
|
message: '创建失败:需联网,且该日历本需可写'
|
||||||
@@ -3401,6 +3595,9 @@ struct Index {
|
|||||||
this.getUIContext().getPromptAction().showToast({ message: '已删除' });
|
this.getUIContext().getPromptAction().showToast({ message: '已删除' });
|
||||||
this.todos = await CalendarDataService.loadTodos(ctx, this.sources);
|
this.todos = await CalendarDataService.loadTodos(ctx, this.sources);
|
||||||
this.rebuildPages();
|
this.rebuildPages();
|
||||||
|
await CardDataService.pushToAllForms(ctx);
|
||||||
|
// 删除后必须重排,否则已删待办的提醒还会响
|
||||||
|
await ReminderService.refreshReminders(ctx);
|
||||||
} else {
|
} else {
|
||||||
this.getUIContext().getPromptAction().showToast({ message: '删除失败,请稍后重试' });
|
this.getUIContext().getPromptAction().showToast({ message: '删除失败,请稍后重试' });
|
||||||
}
|
}
|
||||||
@@ -3473,6 +3670,10 @@ struct Index {
|
|||||||
}
|
}
|
||||||
// 重新读库刷新列表(服务层已更新 completed)
|
// 重新读库刷新列表(服务层已更新 completed)
|
||||||
this.todos = await CalendarDataService.loadTodos(ctx, this.sources);
|
this.todos = await CalendarDataService.loadTodos(ctx, this.sources);
|
||||||
|
// ⭐ 桌面卡片是独立实例,必须显式推送,否则停在旧数据
|
||||||
|
await CardDataService.pushToAllForms(ctx);
|
||||||
|
// 完成/取消完成会改变"该不该提醒",需要重排
|
||||||
|
await ReminderService.refreshReminders(ctx);
|
||||||
} else {
|
} else {
|
||||||
this.getUIContext().getPromptAction().showToast({
|
this.getUIContext().getPromptAction().showToast({
|
||||||
message: '更新失败:需联网,且该日历本需可写'
|
message: '更新失败:需联网,且该日历本需可写'
|
||||||
|
|||||||
Reference in New Issue
Block a user