1.增加了重力感应,也就是横屏响应式布局。
2.修改了沉浸式布局。 3.修改了添加日程和编辑日程页面,增加了重复、提醒等功能。 4.修改了权限问题,如果日历本是只读,则有删除线做标识。同时,对于只读的日程,点击后将不再进入编辑页面,而是展示详情。 5.系统日历的处理。给用户两个选择,第一个选择就是只显示系统日历。第二种选择,用户可以选择一个caldav账户中的某一个日历本,把系统日历中的日程,包括日历日程和应用创建的日程,都读取出来,然后加入到这个日历本下,最后同步到caldav的服务器上,这样的好处是,手机丢失了,或者换了手机品牌型号,手机上的日程仍然在自己的caldav服务器上有一个备份。当然,系统日历中的caldav日历,就不会再读取了。
This commit is contained in:
@@ -26,6 +26,8 @@ export class DavAccount {
|
||||
calendarNames: string[] = [];
|
||||
/** 服务器端定义的日历本颜色(calendar-color),与 calendarHrefs 一一对应,空串表示未定义 */
|
||||
calendarColors: string[] = [];
|
||||
/** 各日历本写权限('1'=可写 '0'=只读),与 calendarHrefs 一一对应;空数组表示未知(按可写处理) */
|
||||
calendarWritable: string[] = [];
|
||||
itemCount: number = 0;
|
||||
lastSyncTime: string = '';
|
||||
}
|
||||
@@ -37,6 +39,7 @@ export class CalSource {
|
||||
color: string = '#007DFF';
|
||||
source: string = 'dav'; // 'dav' | 'system' | 'local'
|
||||
visible: boolean = true;
|
||||
writable: boolean = true; // 只读日历本中的日程只能查看详情,不能编辑
|
||||
}
|
||||
|
||||
/** 与 UI 无关的调色板 */
|
||||
@@ -81,7 +84,8 @@ export class AccountStore {
|
||||
acc.calendarHrefs.join(';'),
|
||||
acc.calendarNames.join(';'),
|
||||
acc.calendarColors.join(';'),
|
||||
safe(acc.id)
|
||||
safe(acc.id),
|
||||
acc.calendarWritable.join(';')
|
||||
];
|
||||
return parts.join('|');
|
||||
}
|
||||
@@ -109,6 +113,9 @@ export class AccountStore {
|
||||
if (parts.length >= 11) {
|
||||
acc.id = parts[10];
|
||||
}
|
||||
if (parts.length >= 12) {
|
||||
acc.calendarWritable = parts[11] === '' ? [] : parts[11].split(';');
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,83 @@ export class AppSettings {
|
||||
private static readonly KEY_SHOW_SYSTEM: string = 'show_system_calendar';
|
||||
private static readonly KEY_SYNC_INTERVAL: string = 'sync_interval_minutes';
|
||||
private static readonly KEY_BACKGROUND_SYNC: string = 'background_sync';
|
||||
private static readonly KEY_SYS_MODE: string = 'sys_cal_mode'; // 'display' | 'backup'
|
||||
private static readonly KEY_BACKUP_KEY: string = 'sys_backup_cal_key'; // 备份目标 DAV 日历本
|
||||
private static readonly KEY_MANUAL_READONLY: string = 'manual_readonly_keys'; // 手动标记只读的 calKey
|
||||
|
||||
/**
|
||||
* 手动标记为只读的日历本 calKey 列表。
|
||||
* 部分服务器(如群晖某些共享方式)不在 CalDAV 层拒绝写入,自动探测无法区分,
|
||||
* 由用户手动标记,标记后其中日程只显示详情、不出现在新建日程选择中。
|
||||
*/
|
||||
static async getManualReadonlyKeys(context: common.Context): Promise<string[]> {
|
||||
try {
|
||||
const store: preferences.Preferences =
|
||||
await preferences.getPreferences(context, AppSettings.STORE);
|
||||
const raw: string = await store.get(AppSettings.KEY_MANUAL_READONLY, '') as string;
|
||||
return raw === '' ? [] : raw.split(';');
|
||||
} catch (err) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static async setManualReadonlyKeys(context: common.Context, keys: string[]): Promise<void> {
|
||||
try {
|
||||
const store: preferences.Preferences =
|
||||
await preferences.getPreferences(context, AppSettings.STORE);
|
||||
await store.put(AppSettings.KEY_MANUAL_READONLY, keys.join(';'));
|
||||
await store.flush();
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
console.error(`保存手动只读标记失败: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 系统日历模式:display = 仅混合显示;backup = 本地系统日程备份到选定 CalDAV 日历本 */
|
||||
static async getSysCalMode(context: common.Context): Promise<string> {
|
||||
try {
|
||||
const store: preferences.Preferences =
|
||||
await preferences.getPreferences(context, AppSettings.STORE);
|
||||
return await store.get(AppSettings.KEY_SYS_MODE, 'display') as string;
|
||||
} catch (err) {
|
||||
return 'display';
|
||||
}
|
||||
}
|
||||
|
||||
static async setSysCalMode(context: common.Context, mode: string): Promise<void> {
|
||||
try {
|
||||
const store: preferences.Preferences =
|
||||
await preferences.getPreferences(context, AppSettings.STORE);
|
||||
await store.put(AppSettings.KEY_SYS_MODE, mode);
|
||||
await store.flush();
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
console.error(`保存系统日历模式失败: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 备份目标日历本 calKey(accId_序号),空 = 未选择 */
|
||||
static async getBackupCalKey(context: common.Context): Promise<string> {
|
||||
try {
|
||||
const store: preferences.Preferences =
|
||||
await preferences.getPreferences(context, AppSettings.STORE);
|
||||
return await store.get(AppSettings.KEY_BACKUP_KEY, '') as string;
|
||||
} catch (err) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
static async setBackupCalKey(context: common.Context, calKey: string): Promise<void> {
|
||||
try {
|
||||
const store: preferences.Preferences =
|
||||
await preferences.getPreferences(context, AppSettings.STORE);
|
||||
await store.put(AppSettings.KEY_BACKUP_KEY, calKey);
|
||||
await store.flush();
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
console.error(`保存备份目标失败: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 是否开启后台持续同步(长时任务,默认关) */
|
||||
static async getBackgroundSync(context: common.Context): Promise<boolean> {
|
||||
|
||||
@@ -25,6 +25,7 @@ export class DisplayEvent {
|
||||
color: string = '#007DFF';
|
||||
completed: boolean = false; // 仅待办使用
|
||||
recurring: boolean = false; // 是否为重复日程展开出的发生
|
||||
writable: boolean = true; // 所属日历本是否可写(只读时点击显示详情而非编辑)
|
||||
}
|
||||
|
||||
export class CalendarDataService {
|
||||
@@ -53,6 +54,7 @@ export class CalendarDataService {
|
||||
/** 收集全部日历来源:DAV 日历本 + 本机 + 系统日历账户 */
|
||||
static async loadSources(context: common.Context): Promise<CalSource[]> {
|
||||
const sources: CalSource[] = [];
|
||||
const manualKeys: string[] = await AppSettings.getManualReadonlyKeys(context);
|
||||
try {
|
||||
const accounts: DavAccount[] = await AccountStore.loadAll(context);
|
||||
for (const acc of accounts) {
|
||||
@@ -69,6 +71,10 @@ export class CalendarDataService {
|
||||
s.color = color !== '' ? color : BookPalette.colorFor(i);
|
||||
s.source = 'dav';
|
||||
s.visible = true;
|
||||
// 写权限:服务器探测结果 + 手动标记(部分服务器不在 CalDAV 层拒绝写入)
|
||||
const detected: boolean = acc.calendarWritable.length > i
|
||||
? acc.calendarWritable[i] !== '0' : true;
|
||||
s.writable = detected && !manualKeys.includes(s.calKey);
|
||||
sources.push(s);
|
||||
}
|
||||
}
|
||||
@@ -142,6 +148,10 @@ export class CalendarDataService {
|
||||
const found = sources.find((s: CalSource): boolean => s.calKey === key);
|
||||
return found !== undefined ? found.name : '';
|
||||
};
|
||||
const writableOf = (key: string): boolean => {
|
||||
const found = sources.find((s: CalSource): boolean => s.calKey === key);
|
||||
return found !== undefined ? found.writable : true;
|
||||
};
|
||||
|
||||
// 1) 本地库(DAV + 本机);重复日程(RRULE)按规则展开为窗口内的多次发生
|
||||
try {
|
||||
@@ -202,6 +212,7 @@ export class CalendarDataService {
|
||||
d.calName = baseName;
|
||||
d.color = baseColor;
|
||||
d.recurring = e.rrule !== '';
|
||||
d.writable = writableOf(e.calKey);
|
||||
result.push(d);
|
||||
occTotal++;
|
||||
}
|
||||
|
||||
@@ -12,10 +12,12 @@ export class RemoteItem {
|
||||
ics: string = ''; // VCALENDAR 文本
|
||||
}
|
||||
|
||||
/** PROPFIND 返回的集合颜色 */
|
||||
/** PROPFIND 返回的集合颜色与写权限 */
|
||||
export class DavColorEntry {
|
||||
href: string = ''; // 集合路径(服务器返回的是路径,不带域名)
|
||||
color: string = ''; // 规范化后的 #RRGGBB,可能为空
|
||||
writable: boolean = true; // current-user-privilege 是否含 write 权限(默认可写)
|
||||
privilegeKnown: boolean = false; // 服务器是否返回了 current-user-privilege-set(未返回时需要写探测)
|
||||
}
|
||||
|
||||
export class DavClient {
|
||||
@@ -28,6 +30,7 @@ export class DavClient {
|
||||
'<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" ' +
|
||||
'xmlns:ical="http://apple.com/ns/ical/"><d:prop>' +
|
||||
'<d:resourcetype/><cs:getcolor/><ical:calendar-color/>' +
|
||||
'<d:current-user-privilege-set/>' +
|
||||
'</d:prop></d:propfind>';
|
||||
const httpRequest = http.createHttp();
|
||||
try {
|
||||
@@ -71,6 +74,13 @@ export class DavClient {
|
||||
color = DavClient.normalizeHex(DavClient.extractTag(block, 'calendar-color'));
|
||||
}
|
||||
entry.color = color;
|
||||
// 写权限:current-user-privilege-set 存在且不含任何 write 权限 → 只读
|
||||
const privSet: string = DavClient.extractTag(block, 'current-user-privilege-set');
|
||||
if (privSet !== '') {
|
||||
entry.privilegeKnown = true;
|
||||
entry.writable = /write/i.test(privSet);
|
||||
LogUtil.write(`PROPFIND ${href} 权限声明:${privSet.replace(/\s+/g, '').substring(0, 150)}`);
|
||||
}
|
||||
result.push(entry);
|
||||
}
|
||||
if (result.length > 0 && result.every((e: DavColorEntry): boolean => e.color === '')) {
|
||||
@@ -100,6 +110,61 @@ export class DavClient {
|
||||
return 'Basic ' + buffer.from(`${username}:${password}`).toString('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* 写权限探测:向日历本 PUT 一个探测资源——
|
||||
* 2xx → 可写(随后删除探测资源);403/401 等 → 只读;网络异常 → 乐观按可写。
|
||||
* 用于服务器不返回 current-user-privilege-set 的情况(如部分 Synology 配置)。
|
||||
*/
|
||||
static async probeWritable(href: string, auth: string): Promise<boolean> {
|
||||
const url: string = href.endsWith('/')
|
||||
? `${href}synccalendar-probe.ics` : `${href}/synccalendar-probe.ics`;
|
||||
const ics: string = 'BEGIN:VCALENDAR\r\n' +
|
||||
'VERSION:2.0\r\n' +
|
||||
'PRODID:-//SyncCalendar//Probe//CN\r\n' +
|
||||
'BEGIN:VEVENT\r\n' +
|
||||
'UID:syncprobe\r\n' +
|
||||
'DTSTAMP:20000101T000000Z\r\n' +
|
||||
'DTSTART:20000101T000000Z\r\n' +
|
||||
'DTEND:20000101T010000Z\r\n' +
|
||||
'SUMMARY:probe\r\n' +
|
||||
'END:VEVENT\r\n' +
|
||||
'END:VCALENDAR\r\n';
|
||||
const httpRequest = http.createHttp();
|
||||
try {
|
||||
const resp: http.HttpResponse = await httpRequest.request(url, {
|
||||
method: http.RequestMethod.PUT,
|
||||
header: {
|
||||
'Authorization': auth,
|
||||
'Content-Type': 'text/calendar; charset=utf-8',
|
||||
'User-Agent': 'SyncCalendar/1.0'
|
||||
},
|
||||
extraData: ics,
|
||||
connectTimeout: 10000,
|
||||
readTimeout: 15000
|
||||
});
|
||||
LogUtil.write(`HTTP PUT(探测) ${url} → ${resp.responseCode}`);
|
||||
if (resp.responseCode >= 200 && resp.responseCode < 300) {
|
||||
// 写入成功:删除探测资源,避免污染日历本
|
||||
try {
|
||||
await DavClient.deleteRemote(url, auth);
|
||||
} catch (err) {
|
||||
// 删除失败不影响权限判定
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (resp.responseCode === 412 || resp.responseCode === 405 || resp.responseCode === 409) {
|
||||
// 资源已存在/方法冲突等:说明有写权限
|
||||
return true;
|
||||
}
|
||||
return false; // 403/401 等 → 只读
|
||||
} catch (err) {
|
||||
// 网络异常无法判定:乐观按可写,避免误标只读
|
||||
return true;
|
||||
} finally {
|
||||
httpRequest.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
/** REPORT calendar-query:全量拉取某日历本内所有 VEVENT(不加时间范围,保证数据完整) */
|
||||
static async reportCalendar(href: string, auth: string): Promise<RemoteItem[]> {
|
||||
return DavClient.reportComponents(href, auth, 'VEVENT', '');
|
||||
|
||||
@@ -409,6 +409,17 @@ export class EventDb {
|
||||
return `新增${added} 更新${updated} 删除${removed} 不变${unchanged}`;
|
||||
}
|
||||
|
||||
/** 按 UID 判断事件是否已存在(系统日历备份导入的幂等判重) */
|
||||
static async uidExists(context: common.Context, uid: string): Promise<boolean> {
|
||||
const store = await EventDb.getDb(context);
|
||||
const predicates = new relationalStore.RdbPredicates('events');
|
||||
predicates.equalTo('uid', uid);
|
||||
const rs = await store.query(predicates);
|
||||
const exists: boolean = rs.rowCount > 0;
|
||||
rs.close();
|
||||
return exists;
|
||||
}
|
||||
|
||||
/** 删除某账号全部本地事件(删除账号时调用) */
|
||||
static async deleteAccountEvents(context: common.Context, accId: string): Promise<void> {
|
||||
const store = await EventDb.getDb(context);
|
||||
|
||||
@@ -240,13 +240,32 @@ export class IcsUtil {
|
||||
`DTSTAMP:${now}\r\n` +
|
||||
`${dtstart}\r\n` +
|
||||
`${dtend}\r\n` +
|
||||
(e.rrule !== '' ? `RRULE:${e.rrule}\r\n` : '') +
|
||||
`SUMMARY:${IcsUtil.escape(e.title)}\r\n` +
|
||||
(e.location !== '' ? `LOCATION:${IcsUtil.escape(e.location)}\r\n` : '') +
|
||||
(e.description !== '' ? `DESCRIPTION:${IcsUtil.escape(e.description)}\r\n` : '') +
|
||||
(e.reminder > 0 ? IcsUtil.valarm(e.reminder) : '') +
|
||||
'END:VEVENT\r\n' +
|
||||
'END:VCALENDAR\r\n';
|
||||
}
|
||||
|
||||
/** 由提前分钟数生成 VALARM(TRIGGER 负时长 = 提前触发) */
|
||||
private static valarm(minutes: number): string {
|
||||
let trigger: string;
|
||||
if (minutes >= 1440 && minutes % 1440 === 0) {
|
||||
trigger = `-P${minutes / 1440}D`;
|
||||
} else if (minutes >= 60 && minutes % 60 === 0) {
|
||||
trigger = `-PT${minutes / 60}H`;
|
||||
} else {
|
||||
trigger = `-PT${minutes}M`;
|
||||
}
|
||||
return 'BEGIN:VALARM\r\n' +
|
||||
`TRIGGER:${trigger}\r\n` +
|
||||
'ACTION:DISPLAY\r\n' +
|
||||
'DESCRIPTION:提醒\r\n' +
|
||||
'END:VALARM\r\n';
|
||||
}
|
||||
|
||||
static escape(s: string): string {
|
||||
return s.replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\n/g, '\\n');
|
||||
}
|
||||
|
||||
@@ -70,6 +70,9 @@ export class SyncEngine {
|
||||
}
|
||||
// 一个资源可能包含主事件 + 单次覆盖实例(RECURRENCE-ID),全部入库
|
||||
for (const r of parsed) {
|
||||
if (r.uid === 'syncprobe') {
|
||||
continue; // 写权限探测资源(万一删除失败),不入库展示
|
||||
}
|
||||
if (r.uid === '') {
|
||||
r.uid = SyncEngine.uidFromHref(it.href);
|
||||
}
|
||||
@@ -113,31 +116,44 @@ export class SyncEngine {
|
||||
* 失败静默(颜色不影响数据正确性)
|
||||
*/
|
||||
static async refreshCalendarColors(acc: DavAccount, auth: string): Promise<void> {
|
||||
let entries: DavColorEntry[] = [];
|
||||
try {
|
||||
const entries: DavColorEntry[] = await DavClient.propfindColors(acc.serverUrl, auth);
|
||||
if (entries.length === 0) {
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < acc.calendarHrefs.length; i++) {
|
||||
const target: string = acc.calendarHrefs[i];
|
||||
const originMatch = /https?:\/\/[^/]+/i.exec(target);
|
||||
let path: string = originMatch !== null ? target.substring(originMatch[0].length) : target;
|
||||
if (path === '') {
|
||||
path = '/';
|
||||
}
|
||||
const norm = (s: string): string => s.endsWith('/') ? s : s + '/';
|
||||
const found = entries.find((e: DavColorEntry): boolean =>
|
||||
norm(e.href) === norm(path));
|
||||
if (found !== undefined && found.color !== '') {
|
||||
while (acc.calendarColors.length <= i) {
|
||||
acc.calendarColors.push('');
|
||||
}
|
||||
acc.calendarColors[i] = found.color;
|
||||
}
|
||||
}
|
||||
entries = await DavClient.propfindColors(acc.serverUrl, auth);
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
console.info(`刷新日历本颜色失败(忽略): ${e.message}`);
|
||||
LogUtil.write(`PROPFIND 颜色/权限失败(继续探测写权限): ${e.message}`);
|
||||
}
|
||||
for (let i = 0; i < acc.calendarHrefs.length; i++) {
|
||||
const target: string = acc.calendarHrefs[i];
|
||||
const originMatch = /https?:\/\/[^/]+/i.exec(target);
|
||||
let path: string = originMatch !== null ? target.substring(originMatch[0].length) : target;
|
||||
if (path === '') {
|
||||
path = '/';
|
||||
}
|
||||
const norm = (s: string): string => s.endsWith('/') ? s : s + '/';
|
||||
const found = entries.find((e: DavColorEntry): boolean =>
|
||||
norm(e.href) === norm(path));
|
||||
if (found !== undefined && found.color !== '') {
|
||||
while (acc.calendarColors.length <= i) {
|
||||
acc.calendarColors.push('');
|
||||
}
|
||||
acc.calendarColors[i] = found.color;
|
||||
}
|
||||
// 回写各日历本写权限('1'=可写 '0'=只读)
|
||||
while (acc.calendarWritable.length <= i) {
|
||||
acc.calendarWritable.push('1');
|
||||
}
|
||||
const calName: string = i < acc.calendarNames.length ? acc.calendarNames[i] : `日历本${i}`;
|
||||
if (found !== undefined && found.privilegeKnown) {
|
||||
// 服务器声明了权限:直接采用
|
||||
acc.calendarWritable[i] = found.writable ? '1' : '0';
|
||||
LogUtil.write(`日历本[${i}]「${calName}」写权限(privilege):${found.writable ? '可写' : '只读'}`);
|
||||
} else {
|
||||
// 服务器未声明权限(如部分 Synology 配置)或 PROPFIND 未匹配到该本:真实写探测
|
||||
const w: boolean = await DavClient.probeWritable(target, auth);
|
||||
acc.calendarWritable[i] = w ? '1' : '0';
|
||||
LogUtil.write(`日历本[${i}]「${calName}」写权限(探测):${w ? '可写' : '只读'}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,17 +175,28 @@ export class SyncEngine {
|
||||
const mine: LocalEvent[] = dirty.filter((e: LocalEvent): boolean => acc.calendarHrefs.includes(e.href));
|
||||
LogUtil.write(`推送本地修改:全部待推送 ${dirty.length} 条,属于账号「${acc.name}」的 ${mine.length} 条`);
|
||||
for (const e of mine) {
|
||||
// 只读日历本:推送必然 403,跳过并保留 dirty(权限恢复后可再推)
|
||||
const bookIdx: number = acc.calendarHrefs.indexOf(e.href);
|
||||
if (bookIdx >= 0 && acc.calendarWritable.length > bookIdx
|
||||
&& acc.calendarWritable[bookIdx] === '0') {
|
||||
LogUtil.write(`推送跳过只读日历本事件「${e.title}」(uid=${e.uid})`);
|
||||
continue;
|
||||
}
|
||||
if (e.kind === 'todo') {
|
||||
// 待办只读:本地不会有 dirty 待办,兜底清除
|
||||
await EventDb.clearDirty(context, e.id, e.etag);
|
||||
continue;
|
||||
}
|
||||
if (e.recurring) {
|
||||
// 重复日程实例推送会破坏服务器整个序列,暂不支持
|
||||
if (e.recurring && e.rrule === '') {
|
||||
// 重复日程的"单次覆盖实例"(RECURRENCE-ID)推送会破坏服务器整个序列,暂不支持
|
||||
await EventDb.clearDirty(context, e.id, e.etag);
|
||||
LogUtil.write(`推送跳过重复日程实例「${e.title}」(uid=${e.uid})`);
|
||||
continue;
|
||||
}
|
||||
if (e.recurring) {
|
||||
// 重复主事件(含 RRULE,含本机新建的重复日程):整条 PUT 覆盖推送
|
||||
LogUtil.write(`推送重复主事件「${e.title}」(uid=${e.uid})`);
|
||||
}
|
||||
const url: string = e.href.endsWith('/') ? e.href + e.remotePath : `${e.href}/${e.remotePath}`;
|
||||
if (e.deleted) {
|
||||
await DavClient.deleteRemote(url, auth);
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
// entry/src/main/ets/common/SystemCalendarImport.ets
|
||||
// 系统日历备份:把手机"本地"系统日历(非 CalDAV 同步日历)的全部日程导入
|
||||
// 用户选定的 CalDAV 日历本(置 dirty 待推送),随后随同步上传服务器。
|
||||
// 价值:手机丢失/换机后,系统日历日程在用户自己的 CalDAV 服务器上仍有备份。
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { calendarManager } from '@kit.CalendarKit';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { AccountStore, DavAccount } from './AccountStore';
|
||||
import { EventDb, LocalEvent } from './EventDb';
|
||||
import { AppSettings } from './AppSettings';
|
||||
import { LogUtil } from './LogUtil';
|
||||
|
||||
export class SystemCalendarImport {
|
||||
/**
|
||||
* 备份模式下的导入入口(display 模式直接返回 0)。
|
||||
* 幂等:uid = syscal-<系统日历id>-<事件id>,已存在则跳过,因此每次同步都可安全执行。
|
||||
* 只读取 calendarType = LOCAL 的系统日历(手机本地日历/应用创建的日程),
|
||||
* 系统 CalDAV 同步产生的账户日历一律跳过,避免死循环回灌。
|
||||
*/
|
||||
static async importIfNeeded(context: common.Context): Promise<number> {
|
||||
try {
|
||||
const mode: string = await AppSettings.getSysCalMode(context);
|
||||
if (mode !== 'backup') {
|
||||
return 0;
|
||||
}
|
||||
const calKey: string = await AppSettings.getBackupCalKey(context);
|
||||
if (calKey === '') {
|
||||
LogUtil.write('系统日历备份:未选择目标日历本,跳过导入');
|
||||
return 0;
|
||||
}
|
||||
// 定位目标 CalDAV 日历本
|
||||
const accounts: DavAccount[] = await AccountStore.loadAll(context);
|
||||
let targetHref: string = '';
|
||||
for (const acc of accounts) {
|
||||
for (let i = 0; i < acc.calendarHrefs.length; i++) {
|
||||
if (`${acc.id}_${i}` === calKey) {
|
||||
targetHref = acc.calendarHrefs[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (targetHref === '') {
|
||||
LogUtil.write(`系统日历备份:目标日历本 ${calKey} 不存在(账号可能已删除),跳过导入`);
|
||||
return 0;
|
||||
}
|
||||
const uiContext = context as common.UIAbilityContext;
|
||||
const mgr: calendarManager.CalendarManager = calendarManager.getCalendarManager(uiContext);
|
||||
const calendars: calendarManager.Calendar[] = await mgr.getAllCalendars();
|
||||
let imported: number = 0;
|
||||
let skippedCal: number = 0;
|
||||
for (const cal of calendars) {
|
||||
try {
|
||||
// 只处理"本地"类型系统日历;CalDAV/订阅等账户日历的数据源本来就在服务器上,跳过防回灌
|
||||
const account: calendarManager.CalendarAccount = cal.getAccount();
|
||||
if (account.type !== calendarManager.CalendarType.LOCAL) {
|
||||
skippedCal++;
|
||||
continue;
|
||||
}
|
||||
const events: calendarManager.Event[] = await cal.getEvents();
|
||||
for (const ev of events) {
|
||||
if (ev.id === undefined) {
|
||||
continue;
|
||||
}
|
||||
const uid: string = `syscal-${account.name}-${ev.id}`;
|
||||
if (await EventDb.uidExists(context, uid)) {
|
||||
continue;
|
||||
}
|
||||
const e = new LocalEvent();
|
||||
e.uid = uid;
|
||||
e.calKey = calKey;
|
||||
e.href = targetHref;
|
||||
e.remotePath = encodeURIComponent(uid) + '.ics';
|
||||
e.title = ev.title !== undefined ? ev.title : '';
|
||||
e.description = ev.description !== undefined ? ev.description : '';
|
||||
e.location = ev.location !== undefined && ev.location.location !== undefined
|
||||
? ev.location.location : '';
|
||||
e.startTime = ev.startTime;
|
||||
e.endTime = ev.endTime;
|
||||
e.isAllDay = ev.isAllDay === true;
|
||||
e.kind = 'event';
|
||||
e.rrule = SystemCalendarImport.rruleOf(ev);
|
||||
e.recurring = e.rrule !== '';
|
||||
await EventDb.insertLocal(context, e); // insertLocal 置 dirty,随同步推送
|
||||
imported++;
|
||||
}
|
||||
} catch (err) {
|
||||
// 单个日历读取失败不影响其余
|
||||
}
|
||||
}
|
||||
if (imported > 0 || skippedCal > 0) {
|
||||
LogUtil.write(`系统日历备份:新增 ${imported} 条 → ${calKey}(跳过账户日历 ${skippedCal} 个)`);
|
||||
}
|
||||
return imported;
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
LogUtil.write(`系统日历备份导入失败(忽略): ${e.message}`);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** 系统日程的 recurrenceRule → 尽力转换的 RRULE 字符串(仅 FREQ/INTERVAL,复杂规则服务器端可再完善) */
|
||||
private static rruleOf(ev: calendarManager.Event): string {
|
||||
try {
|
||||
const rr = ev.recurrenceRule;
|
||||
if (rr === undefined) {
|
||||
return '';
|
||||
}
|
||||
const freqMap: string[] = ['YEARLY', 'MONTHLY', 'WEEKLY', 'DAILY'];
|
||||
const fi: number = rr.recurrenceFrequency as number;
|
||||
if (Number.isNaN(fi) || fi < 0 || fi >= freqMap.length) {
|
||||
return '';
|
||||
}
|
||||
let rrule: string = `FREQ=${freqMap[fi]}`;
|
||||
if (rr.interval !== undefined && rr.interval > 1) {
|
||||
rrule += `;INTERVAL=${rr.interval}`;
|
||||
}
|
||||
return rrule;
|
||||
} catch (err) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { AbilityConstant, ConfigurationConstant, UIAbility, Want } from '@kit.AbilityKit';
|
||||
import { hilog } from '@kit.PerformanceAnalysisKit';
|
||||
import { window } from '@kit.ArkUI';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { BackgroundSyncService } from '../common/BackgroundSyncService';
|
||||
|
||||
const DOMAIN = 0x0000;
|
||||
@@ -24,6 +25,27 @@ export default class EntryAbility extends UIAbility {
|
||||
return;
|
||||
}
|
||||
hilog.info(DOMAIN, 'testTag', 'Succeeded in loading the content.');
|
||||
// 沉浸式配色:状态栏/导航条与页面背景同色(app.color.page_bg = #F1F3F5),
|
||||
// 消除顶部通知栏和底部的白色条
|
||||
windowStage.getMainWindow((werr: BusinessError, win: window.Window) => {
|
||||
if (werr.code) {
|
||||
hilog.error(DOMAIN, 'testTag', 'Failed to get main window. Cause: %{public}s', JSON.stringify(werr));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
win.setWindowSystemBarProperties({
|
||||
statusBarColor: '#F1F3F5',
|
||||
statusBarContentColor: '#182431',
|
||||
navigationBarColor: '#F1F3F5',
|
||||
navigationBarContentColor: '#182431'
|
||||
});
|
||||
// 鸿蒙 NEXT 导航条背景跟随窗口背景色:设为页面背景灰,消除底部白条
|
||||
win.setWindowBackgroundColor('#F1F3F5');
|
||||
} catch (barErr) {
|
||||
hilog.error(DOMAIN, 'testTag', 'Failed to set system bar properties. Cause: %{public}s',
|
||||
JSON.stringify(barErr));
|
||||
}
|
||||
});
|
||||
});
|
||||
try {
|
||||
this.context.getApplicationContext().setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { common } from '@kit.AbilityKit';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { DavAccount, AccountStore, CalSource, BookPalette } from '../common/AccountStore';
|
||||
import { EventDb, LocalEvent } from '../common/EventDb';
|
||||
import { AppSettings } from '../common/AppSettings';
|
||||
import { DavClient } from '../common/DavClient';
|
||||
import { SyncEngine } from '../common/SyncEngine';
|
||||
|
||||
@@ -30,6 +31,13 @@ struct EventEditPage {
|
||||
@State isSaving: boolean = false;
|
||||
@State statusMsg: string = '';
|
||||
@State isExisting: boolean = false;
|
||||
@State repeatMode: string = 'none'; // none|daily|workday|weekly|monthly|yearly|custom
|
||||
@State reminderMin: number = 0; // 提前提醒分钟数,0 = 不提醒
|
||||
@State pickerShow: boolean = false; // 开始/结束时间选择底部弹层(日期+时间一次选完)
|
||||
@State pickerIsEnd: boolean = false;
|
||||
@State pickDate: Date = new Date();
|
||||
@State pickHour: number = 9;
|
||||
@State pickMin: number = 0;
|
||||
private event: LocalEvent | null = null;
|
||||
|
||||
aboutToAppear(): Promise<void> {
|
||||
@@ -41,7 +49,7 @@ struct EventEditPage {
|
||||
if (context === undefined) {
|
||||
return;
|
||||
}
|
||||
// 收集可写入的日历本(DAV + 本机)
|
||||
// 收集可写入的日历本(DAV 可写日历本 + 本机);只读日历本不出现在新建/编辑选择中
|
||||
const sources: CalSourceWithHref[] = await CalendarDataBridge.loadWritableSources(context);
|
||||
const choices: BookChoice[] = [];
|
||||
for (const s of sources) {
|
||||
@@ -68,6 +76,8 @@ struct EventEditPage {
|
||||
this.startMs = loaded.startTime;
|
||||
this.endMs = loaded.endTime;
|
||||
this.chosenKey = loaded.calKey;
|
||||
this.repeatMode = loaded.rrule !== '' ? this.modeFromRrule(loaded.rrule) : 'none';
|
||||
this.reminderMin = loaded.reminder;
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -98,62 +108,81 @@ struct EventEditPage {
|
||||
return `${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
private pickStartDate(): void {
|
||||
const cur = new Date(this.startMs);
|
||||
DatePickerDialog.show({
|
||||
start: new Date(2000, 0, 1),
|
||||
end: new Date(2050, 11, 31),
|
||||
selected: cur,
|
||||
onDateAccept: (value: Date) => {
|
||||
const keep = new Date(this.startMs);
|
||||
const newStart: number = new Date(value.getFullYear(), value.getMonth(), value.getDate(),
|
||||
keep.getHours(), keep.getMinutes()).getTime();
|
||||
const dur: number = this.endMs - this.startMs;
|
||||
this.startMs = newStart;
|
||||
this.endMs = this.allDay ? newStart + 86399999 : newStart + dur;
|
||||
}
|
||||
});
|
||||
/** 重复模式 → RRULE 字符串(RruleUtil 已支持这些规则) */
|
||||
private rruleForMode(mode: string): string {
|
||||
if (mode === 'daily') {
|
||||
return 'FREQ=DAILY';
|
||||
}
|
||||
if (mode === 'workday') {
|
||||
return 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR';
|
||||
}
|
||||
if (mode === 'weekly') {
|
||||
return 'FREQ=WEEKLY';
|
||||
}
|
||||
if (mode === 'monthly') {
|
||||
return 'FREQ=MONTHLY';
|
||||
}
|
||||
if (mode === 'yearly') {
|
||||
return 'FREQ=YEARLY';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
private pickStartTime(): void {
|
||||
const cur = new Date(this.startMs);
|
||||
TimePickerDialog.show({
|
||||
selected: cur,
|
||||
onAccept: (value: TimePickerResult) => {
|
||||
const d = new Date(this.startMs);
|
||||
const newStart: number = new Date(d.getFullYear(), d.getMonth(), d.getDate(),
|
||||
value.hour, value.minute).getTime();
|
||||
const dur: number = this.endMs - this.startMs;
|
||||
this.startMs = newStart;
|
||||
this.endMs = newStart + dur;
|
||||
}
|
||||
});
|
||||
/** RRULE → 重复模式(无法识别的规则归为 custom,保存时保留原规则) */
|
||||
private modeFromRrule(rrule: string): string {
|
||||
const u: string = rrule.toUpperCase();
|
||||
if (u.includes('FREQ=DAILY')) {
|
||||
return 'daily';
|
||||
}
|
||||
if (u.includes('FREQ=WEEKLY')) {
|
||||
const hasWeekday: boolean = u.includes('MO') && u.includes('TU') && u.includes('WE')
|
||||
&& u.includes('TH') && u.includes('FR');
|
||||
return hasWeekday ? 'workday' : 'weekly';
|
||||
}
|
||||
if (u.includes('FREQ=MONTHLY')) {
|
||||
return 'monthly';
|
||||
}
|
||||
if (u.includes('FREQ=YEARLY')) {
|
||||
return 'yearly';
|
||||
}
|
||||
return 'custom';
|
||||
}
|
||||
|
||||
private pickEndDate(): void {
|
||||
const cur = new Date(this.endMs);
|
||||
DatePickerDialog.show({
|
||||
start: new Date(2000, 0, 1),
|
||||
end: new Date(2050, 11, 31),
|
||||
selected: cur,
|
||||
onDateAccept: (value: Date) => {
|
||||
const keep = new Date(this.endMs);
|
||||
this.endMs = new Date(value.getFullYear(), value.getMonth(), value.getDate(),
|
||||
keep.getHours(), keep.getMinutes()).getTime();
|
||||
}
|
||||
});
|
||||
private reminderLabel(m: number): string {
|
||||
if (m === 0) {
|
||||
return '不提醒';
|
||||
}
|
||||
if (m < 60) {
|
||||
return `提前${m}分钟`;
|
||||
}
|
||||
if (m < 1440) {
|
||||
return `提前${m / 60}小时`;
|
||||
}
|
||||
return `提前${m / 1440}天`;
|
||||
}
|
||||
|
||||
private pickEndTime(): void {
|
||||
const cur = new Date(this.endMs);
|
||||
TimePickerDialog.show({
|
||||
selected: cur,
|
||||
onAccept: (value: TimePickerResult) => {
|
||||
const d = new Date(this.endMs);
|
||||
this.endMs = new Date(d.getFullYear(), d.getMonth(), d.getDate(),
|
||||
value.hour, value.minute).getTime();
|
||||
}
|
||||
});
|
||||
/** 打开时间选择弹层:日期 + 时间一次选完 */
|
||||
private openPicker(isEnd: boolean): void {
|
||||
this.pickerIsEnd = isEnd;
|
||||
const base = new Date(isEnd ? this.endMs : this.startMs);
|
||||
this.pickDate = new Date(base.getFullYear(), base.getMonth(), base.getDate());
|
||||
this.pickHour = base.getHours();
|
||||
this.pickMin = base.getMinutes();
|
||||
this.pickerShow = true;
|
||||
}
|
||||
|
||||
private applyPicker(): void {
|
||||
const d = this.pickDate;
|
||||
const picked: number = new Date(d.getFullYear(), d.getMonth(), d.getDate(),
|
||||
this.pickHour, this.pickMin).getTime();
|
||||
if (this.pickerIsEnd) {
|
||||
// 全天日程的结束存为"当天 23:59:59.999"(排他日期前 1 毫秒)
|
||||
this.endMs = this.allDay ? picked + 86399999 : picked;
|
||||
} else {
|
||||
const dur: number = Math.max(0, this.endMs - this.startMs);
|
||||
this.startMs = picked;
|
||||
this.endMs = this.allDay ? picked + 86399999 : picked + dur;
|
||||
}
|
||||
}
|
||||
|
||||
private toggleAllDay(): void {
|
||||
@@ -186,8 +215,9 @@ struct EventEditPage {
|
||||
if (this.isSaving || !this.validate()) {
|
||||
return;
|
||||
}
|
||||
if (this.event !== null && this.event.recurring) {
|
||||
this.statusMsg = '重复日程(系列中的一天)暂不支持修改,请到服务器端调整重复规则';
|
||||
// 仅阻止"单次覆盖实例"(RECURRENCE-ID)的修改;重复主事件(含 RRULE)允许编辑
|
||||
if (this.event !== null && this.event.recurring && this.event.rrule === '') {
|
||||
this.statusMsg = '重复日程的单次修改暂不支持,请到服务器端调整重复规则';
|
||||
return;
|
||||
}
|
||||
this.isSaving = true;
|
||||
@@ -208,6 +238,15 @@ struct EventEditPage {
|
||||
e.isAllDay = this.allDay;
|
||||
e.calKey = book !== null ? book.calKey : 'local';
|
||||
e.href = book !== null ? book.href : '';
|
||||
// 重复规则与提醒
|
||||
if (this.repeatMode === 'custom') {
|
||||
// 无法识别的既有规则原样保留
|
||||
e.rrule = this.event !== null ? this.event.rrule : '';
|
||||
} else {
|
||||
e.rrule = this.rruleForMode(this.repeatMode);
|
||||
}
|
||||
e.recurring = e.rrule !== '';
|
||||
e.reminder = this.reminderMin;
|
||||
if (isNew) {
|
||||
e.uid = `syncal-${Date.now()}-${Math.floor(Math.random() * 1000000)}`;
|
||||
e.remotePath = encodeURIComponent(e.uid) + '.ics';
|
||||
@@ -242,8 +281,8 @@ struct EventEditPage {
|
||||
if (this.event === null || this.isSaving) {
|
||||
return;
|
||||
}
|
||||
if (this.event.recurring) {
|
||||
this.statusMsg = '重复日程(系列中的一天)暂不支持删除,请到服务器端调整重复规则';
|
||||
if (this.event !== null && this.event.recurring && this.event.rrule === '') {
|
||||
this.statusMsg = '重复日程的单次删除暂不支持,请到服务器端调整重复规则';
|
||||
return;
|
||||
}
|
||||
this.isSaving = true;
|
||||
@@ -274,11 +313,12 @@ struct EventEditPage {
|
||||
|
||||
build() {
|
||||
Column({ space: 14 }) {
|
||||
// 顶部
|
||||
// 顶部(与内容区左右对齐,避免贴边误触)
|
||||
Row({ space: 6 }) {
|
||||
Text('取消')
|
||||
.fontSize(16)
|
||||
.fontColor($r('app.color.brand'))
|
||||
.padding({ left: 4, right: 4, top: 8, bottom: 8 })
|
||||
.onClick(() => {
|
||||
router.back();
|
||||
})
|
||||
@@ -292,11 +332,13 @@ struct EventEditPage {
|
||||
.fontSize(16)
|
||||
.fontColor(this.isSaving ? $r('app.color.text_hint') : $r('app.color.brand'))
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.padding({ left: 4, right: 4, top: 8, bottom: 8 })
|
||||
.onClick(() => {
|
||||
this.save();
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16 })
|
||||
|
||||
Scroll() {
|
||||
Column({ space: 14 }) {
|
||||
@@ -327,7 +369,7 @@ struct EventEditPage {
|
||||
.borderRadius(12)
|
||||
.backgroundColor($r('app.color.card_bg'))
|
||||
|
||||
// 时间卡片
|
||||
// 时间卡片(单击任一行:底部弹层中日期+时间一次选完)
|
||||
Column({ space: 10 }) {
|
||||
this.timeRow('开始', true)
|
||||
Divider().color($r('app.color.shadow_color'))
|
||||
@@ -338,6 +380,53 @@ struct EventEditPage {
|
||||
.borderRadius(12)
|
||||
.backgroundColor($r('app.color.card_bg'))
|
||||
|
||||
// 重复
|
||||
Column({ space: 8 }) {
|
||||
Text('重复')
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Start }) {
|
||||
this.repeatChip('none', '不重复')
|
||||
this.repeatChip('daily', '每天')
|
||||
this.repeatChip('workday', '工作日')
|
||||
this.repeatChip('weekly', '每周')
|
||||
this.repeatChip('monthly', '每月')
|
||||
this.repeatChip('yearly', '每年')
|
||||
if (this.repeatMode === 'custom') {
|
||||
// 服务器来的复杂规则(INTERVAL/COUNT 等)标记为自定义,保存时原样保留
|
||||
this.repeatChip('custom', '自定义')
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.borderRadius(12)
|
||||
.backgroundColor($r('app.color.card_bg'))
|
||||
|
||||
// 提醒
|
||||
Column({ space: 8 }) {
|
||||
Text('提醒')
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Start }) {
|
||||
this.reminderChip(0)
|
||||
this.reminderChip(5)
|
||||
this.reminderChip(10)
|
||||
this.reminderChip(15)
|
||||
this.reminderChip(30)
|
||||
this.reminderChip(60)
|
||||
this.reminderChip(1440)
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.borderRadius(12)
|
||||
.backgroundColor($r('app.color.card_bg'))
|
||||
|
||||
// 日历本选择
|
||||
Column({ space: 8 }) {
|
||||
Text('日历本')
|
||||
@@ -424,6 +513,12 @@ struct EventEditPage {
|
||||
.height('100%')
|
||||
.padding({ top: 12 })
|
||||
.backgroundColor($r('app.color.page_bg'))
|
||||
.bindSheet($$this.pickerShow, this.dateTimePickerSheet(), {
|
||||
height: 380,
|
||||
showClose: false,
|
||||
dragBar: true,
|
||||
backgroundColor: $r('app.color.card_bg')
|
||||
})
|
||||
}
|
||||
|
||||
@Builder
|
||||
@@ -433,32 +528,102 @@ struct EventEditPage {
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width(36)
|
||||
Text(this.fmtDate(isStart ? this.startMs : this.endMs))
|
||||
Text(this.allDay
|
||||
? this.fmtDate(isStart ? this.startMs : this.endMs)
|
||||
: `${this.fmtDate(isStart ? this.startMs : this.endMs)} ${this.fmtTime(isStart ? this.startMs : this.endMs)}`)
|
||||
.fontSize(15)
|
||||
.fontColor($r('app.color.brand'))
|
||||
.onClick(() => {
|
||||
if (isStart) {
|
||||
this.pickStartDate();
|
||||
} else {
|
||||
this.pickEndDate();
|
||||
}
|
||||
})
|
||||
if (!this.allDay) {
|
||||
Text(this.fmtTime(isStart ? this.startMs : this.endMs))
|
||||
.fontSize(15)
|
||||
.fontColor($r('app.color.brand'))
|
||||
.onClick(() => {
|
||||
if (isStart) {
|
||||
this.pickStartTime();
|
||||
} else {
|
||||
this.pickEndTime();
|
||||
}
|
||||
})
|
||||
}
|
||||
Blank()
|
||||
.layoutWeight(1)
|
||||
Text('›')
|
||||
.fontSize(16)
|
||||
.fontColor($r('app.color.text_hint'))
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 8, right: 8, top: 8, bottom: 8 })
|
||||
.padding({ left: 8, right: 8, top: 10, bottom: 10 })
|
||||
.onClick(() => {
|
||||
this.openPicker(isStart);
|
||||
})
|
||||
}
|
||||
|
||||
@Builder
|
||||
repeatChip(mode: string, label: string) {
|
||||
Text(label)
|
||||
.fontSize(12)
|
||||
.fontColor(this.repeatMode === mode
|
||||
? $r('app.color.button_text') : $r('app.color.text_primary'))
|
||||
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
|
||||
.borderRadius(14)
|
||||
.margin({ right: 8, bottom: 8 })
|
||||
.backgroundColor(this.repeatMode === mode ? $r('app.color.brand') : $r('app.color.chip_off_bg'))
|
||||
.onClick(() => {
|
||||
this.repeatMode = mode;
|
||||
})
|
||||
}
|
||||
|
||||
@Builder
|
||||
reminderChip(minutes: number) {
|
||||
Text(this.reminderLabel(minutes))
|
||||
.fontSize(12)
|
||||
.fontColor(this.reminderMin === minutes
|
||||
? $r('app.color.button_text') : $r('app.color.text_primary'))
|
||||
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
|
||||
.borderRadius(14)
|
||||
.margin({ right: 8, bottom: 8 })
|
||||
.backgroundColor(this.reminderMin === minutes ? $r('app.color.brand') : $r('app.color.chip_off_bg'))
|
||||
.onClick(() => {
|
||||
this.reminderMin = minutes;
|
||||
})
|
||||
}
|
||||
|
||||
/** 开始/结束时间选择弹层:左侧日期、右侧时间(全天时只有日期),一次确定 */
|
||||
@Builder
|
||||
dateTimePickerSheet() {
|
||||
Column({ space: 14 }) {
|
||||
Text(this.pickerIsEnd ? '选择结束时间' : '选择开始时间')
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
.textAlign(TextAlign.Center)
|
||||
.padding({ top: 12 })
|
||||
Row({ space: 6 }) {
|
||||
DatePicker({
|
||||
start: new Date(2000, 0, 1),
|
||||
end: new Date(2050, 11, 31),
|
||||
selected: this.pickDate
|
||||
})
|
||||
.onDateChange((value: Date) => {
|
||||
this.pickDate = value;
|
||||
})
|
||||
.layoutWeight(1)
|
||||
.height(210)
|
||||
if (!this.allDay) {
|
||||
TimePicker({
|
||||
selected: new Date(2000, 0, 1, this.pickHour, this.pickMin)
|
||||
})
|
||||
.onChange((value: TimePickerResult) => {
|
||||
this.pickHour = value.hour;
|
||||
this.pickMin = value.minute;
|
||||
})
|
||||
.layoutWeight(1)
|
||||
.height(210)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Button(this.allDay ? '确定日期' : '确定')
|
||||
.width('100%')
|
||||
.height(44)
|
||||
.borderRadius(12)
|
||||
.fontSize(15)
|
||||
.backgroundColor($r('app.color.brand'))
|
||||
.onClick(() => {
|
||||
this.applyPicker();
|
||||
this.pickerShow = false;
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, bottom: 24 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -467,8 +632,15 @@ class CalendarDataBridge {
|
||||
static async loadWritableSources(context: common.Context): Promise<CalSourceWithHref[]> {
|
||||
const result: CalSourceWithHref[] = [];
|
||||
const accounts: DavAccount[] = await AccountStore.loadAll(context);
|
||||
const manualKeys: string[] = await AppSettings.getManualReadonlyKeys(context);
|
||||
for (const acc of accounts) {
|
||||
for (let i = 0; i < acc.calendarHrefs.length; i++) {
|
||||
// 只读日历本(服务器无写权限,或用户手动标记只读)不可选
|
||||
const detected: boolean = acc.calendarWritable.length > i
|
||||
? acc.calendarWritable[i] !== '0' : true;
|
||||
if (!detected || manualKeys.includes(`${acc.id}_${i}`)) {
|
||||
continue;
|
||||
}
|
||||
const s = new CalSourceWithHref();
|
||||
s.calKey = `${acc.id}_${i}`;
|
||||
let name: string = i < acc.calendarNames.length ? acc.calendarNames[i] : '';
|
||||
@@ -479,6 +651,7 @@ class CalendarDataBridge {
|
||||
let color: string = i < acc.calendarColors.length ? AccountStore.normalizeColor(acc.calendarColors[i]) : '';
|
||||
s.color = color !== '' ? color : BookPalette.colorFor(i);
|
||||
s.href = acc.calendarHrefs[i];
|
||||
s.writable = true;
|
||||
result.push(s);
|
||||
}
|
||||
}
|
||||
|
||||
+308
-102
@@ -1,7 +1,7 @@
|
||||
// entry/src/main/ets/pages/Index.ets
|
||||
// 同步日历主界面:月视图(左右滑动翻月)/ 周视图 / 日视图 / 日程列表
|
||||
// 混合展示 DAV 与系统日历;每分钟自动同步;可"回到今天"
|
||||
import { router } from '@kit.ArkUI';
|
||||
import { mediaquery, router } from '@kit.ArkUI';
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { BusinessError, pasteboard } from '@kit.BasicServicesKit';
|
||||
import { DavAccount, AccountStore, CalSource, TYPE_CALDAV } from '../common/AccountStore';
|
||||
@@ -15,6 +15,7 @@ import { AppSettings } from '../common/AppSettings';
|
||||
import { EventDb, LocalEvent } from '../common/EventDb';
|
||||
import { RruleUtil } from '../common/RruleUtil';
|
||||
import { IcsUtil } from '../common/IcsUtil';
|
||||
import { SystemCalendarImport } from '../common/SystemCalendarImport';
|
||||
|
||||
/** 月视图单元格 */
|
||||
class MonthCell {
|
||||
@@ -43,6 +44,8 @@ struct Index {
|
||||
@State syncing: boolean = false;
|
||||
@State loading: boolean = true;
|
||||
@State menuOpen: boolean = false; // 顶部 ≡ 下拉菜单
|
||||
@State isLandscape: boolean = false; // 横屏:月视图切左右双栏(左月历/右当日日程)
|
||||
private landscapeListener: mediaquery.MediaQueryListener | null = null;
|
||||
private accounts: DavAccount[] = [];
|
||||
private sources: CalSource[] = [];
|
||||
private swiperController: SwiperController = new SwiperController();
|
||||
@@ -62,6 +65,7 @@ struct Index {
|
||||
this.displayMonth = now.getMonth();
|
||||
this.selectedDate = this.startOfDay(now.getTime());
|
||||
this.rebuildPages();
|
||||
this.initLandscapeListener();
|
||||
this.initPermissionAndLoad();
|
||||
}
|
||||
|
||||
@@ -70,6 +74,20 @@ struct Index {
|
||||
clearInterval(this.autoSyncTimer);
|
||||
this.autoSyncTimer = -1;
|
||||
}
|
||||
if (this.landscapeListener !== null) {
|
||||
this.landscapeListener.off('change');
|
||||
this.landscapeListener = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 监听横竖屏:旋转后 isLandscape 驱动月视图在上下/左右布局间切换 */
|
||||
private initLandscapeListener(): void {
|
||||
this.landscapeListener =
|
||||
this.getUIContext().getMediaQuery().matchMediaSync('(orientation: landscape)');
|
||||
this.isLandscape = this.landscapeListener.matches;
|
||||
this.landscapeListener.on('change', (result: mediaquery.MediaQueryResult) => {
|
||||
this.isLandscape = result.matches;
|
||||
});
|
||||
}
|
||||
|
||||
/** 从设置页/账号页返回时刷新(同步间隔、系统日历开关立即生效),并处理编辑账号后的待同步 */
|
||||
@@ -329,6 +347,12 @@ struct Index {
|
||||
}
|
||||
let ok: number = 0;
|
||||
let failMsg: string = '';
|
||||
// 备份模式:先把系统本地日历导入目标日历本(幂等),再随正常同步推送上服务器
|
||||
try {
|
||||
await SystemCalendarImport.importIfNeeded(context);
|
||||
} catch (err) {
|
||||
// 导入失败不影响正常同步
|
||||
}
|
||||
for (const acc of this.accounts) {
|
||||
if (acc.type !== TYPE_CALDAV) {
|
||||
continue;
|
||||
@@ -373,15 +397,53 @@ struct Index {
|
||||
}
|
||||
|
||||
private openEvent(e: DisplayEvent): void {
|
||||
if (e.isSystem) {
|
||||
this.getUIContext().getPromptAction()
|
||||
.showToast({ message: '系统日历日程,请在系统日历 App 中编辑' });
|
||||
// 只读日历本或系统日历日程:无法保存修改,直接显示详情
|
||||
if (e.isSystem || !e.writable) {
|
||||
this.showEventDetail(e);
|
||||
return;
|
||||
}
|
||||
AppStorage.setOrCreate<number>('pendingEventId', e.id);
|
||||
router.pushUrl({ url: 'pages/EventEditPage' });
|
||||
}
|
||||
|
||||
/** 只读日程详情弹窗 */
|
||||
private showEventDetail(e: DisplayEvent): void {
|
||||
const lines: string[] = [];
|
||||
if (e.isAllDay) {
|
||||
lines.push(`时间:全天 ${this.fmtDateCn(e.startTime)}`);
|
||||
if (this.spansDays(e)) {
|
||||
lines.push(` ~ ${this.fmtDateCn(e.endTime)}`);
|
||||
}
|
||||
} else if (this.spansDays(e)) {
|
||||
lines.push(`时间:${this.fmtDateCn(e.startTime)} ${this.fmtTime(e.startTime)}`);
|
||||
lines.push(` ~ ${this.fmtDateCn(e.endTime)} ${this.fmtTime(e.endTime)}`);
|
||||
} else {
|
||||
lines.push(`时间:${this.fmtDateCn(e.startTime)} ${this.fmtTime(e.startTime)} ~ ${this.fmtTime(e.endTime)}`);
|
||||
}
|
||||
if (e.location !== '') {
|
||||
lines.push(`地点:${e.location}`);
|
||||
}
|
||||
if (e.recurring) {
|
||||
lines.push('重复:是');
|
||||
}
|
||||
if (e.calName !== '') {
|
||||
lines.push(`日历本:${e.calName}${e.isSystem ? '' : '(只读)'}`);
|
||||
}
|
||||
if (e.description !== '') {
|
||||
lines.push(`备注:${e.description}`);
|
||||
}
|
||||
this.getUIContext().showAlertDialog({
|
||||
title: e.title === '' ? '(无标题)' : e.title,
|
||||
message: lines.join('\n'),
|
||||
autoCancel: true,
|
||||
alignment: DialogAlignment.Center,
|
||||
primaryButton: {
|
||||
value: '关闭',
|
||||
action: (): void => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private addEvent(): void {
|
||||
AppStorage.setOrCreate<number>('pendingEventId', 0);
|
||||
AppStorage.setOrCreate<number>('pendingEventDate', this.selectedDate);
|
||||
@@ -707,65 +769,109 @@ struct Index {
|
||||
|
||||
@Builder
|
||||
monthBody() {
|
||||
Column() {
|
||||
// 星期表头
|
||||
if (this.isLandscape) {
|
||||
// 横屏(平板适配):左右双栏——左侧月历,右侧当日日程
|
||||
Row() {
|
||||
ForEach(WEEK_LABELS, (w: string) => {
|
||||
Text(w)
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_hint'))
|
||||
.textAlign(TextAlign.Center)
|
||||
.layoutWeight(1)
|
||||
}, (w: string) => w)
|
||||
Column() {
|
||||
this.monthWeekHeader()
|
||||
this.monthSwiper()
|
||||
}
|
||||
.layoutWeight(3)
|
||||
.height('100%')
|
||||
|
||||
Divider()
|
||||
.vertical(true)
|
||||
.height('92%')
|
||||
.strokeWidth(1)
|
||||
.color($r('app.color.shadow_color'))
|
||||
|
||||
Column() {
|
||||
this.dayPanelHeader()
|
||||
this.eventList()
|
||||
}
|
||||
.layoutWeight(2)
|
||||
.height('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 12, right: 12 })
|
||||
|
||||
// 三页月格,左右滑动切换月份
|
||||
Swiper(this.swiperController) {
|
||||
ForEach(this.monthPages, (cells: MonthCell[]) => {
|
||||
Column({ space: 2 }) {
|
||||
ForEach(this.chunkCells(cells), (week: MonthCell[], idx: number) => {
|
||||
Row({ space: 2 }) {
|
||||
ForEach(week, (cell: MonthCell) => {
|
||||
this.dayCell(cell)
|
||||
}, (cell: MonthCell) => `${cell.dateMs}_${cell.dateMs === this.selectedDate}`)
|
||||
}
|
||||
.width('100%')
|
||||
}, (week: MonthCell[], idx: number) => String(idx))
|
||||
}
|
||||
.width('100%')
|
||||
}, (cells: MonthCell[]) => String(cells[0].dateMs))
|
||||
.layoutWeight(1)
|
||||
} else {
|
||||
// 竖屏:月历在上、当日日程在下(保持原布局)
|
||||
Column() {
|
||||
this.monthWeekHeader()
|
||||
this.monthSwiper()
|
||||
this.dayPanelHeader()
|
||||
this.eventList()
|
||||
}
|
||||
.index(1)
|
||||
.loop(false)
|
||||
.indicator(false)
|
||||
.width('100%')
|
||||
.onChange((index: number) => {
|
||||
this.handleSwiperChange(index);
|
||||
})
|
||||
.layoutWeight(1)
|
||||
}
|
||||
}
|
||||
|
||||
// 当日日程列表
|
||||
Row({ space: 8 }) {
|
||||
Text(this.fmtDateCn(this.selectedDate))
|
||||
.fontSize(13)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
Blank()
|
||||
Text(this.fmtMonthTitle())
|
||||
/** 月视图星期表头 */
|
||||
@Builder
|
||||
monthWeekHeader() {
|
||||
Row() {
|
||||
ForEach(WEEK_LABELS, (w: string) => {
|
||||
Text(w)
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_hint'))
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 20, right: 20, top: 6 })
|
||||
.gesture(LongPressGesture().onAction(() => {
|
||||
this.showOccurrenceDebug(this.selectedDate);
|
||||
}))
|
||||
|
||||
this.eventList()
|
||||
.textAlign(TextAlign.Center)
|
||||
.layoutWeight(1)
|
||||
}, (w: string) => w)
|
||||
}
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.padding({ left: 12, right: 12 })
|
||||
}
|
||||
|
||||
/** 月视图三页 Swiper(横屏时占满剩余高度并均分每周行,竖屏保持自然高度) */
|
||||
@Builder
|
||||
monthSwiper() {
|
||||
Swiper(this.swiperController) {
|
||||
ForEach(this.monthPages, (cells: MonthCell[]) => {
|
||||
Column({ space: 2 }) {
|
||||
ForEach(this.chunkCells(cells), (week: MonthCell[], idx: number) => {
|
||||
Row({ space: 2 }) {
|
||||
ForEach(week, (cell: MonthCell) => {
|
||||
this.dayCell(cell)
|
||||
}, (cell: MonthCell) => `${cell.dateMs}_${cell.dateMs === this.selectedDate}`)
|
||||
}
|
||||
.width('100%')
|
||||
.layoutWeight(this.isLandscape ? 1 : 0)
|
||||
}, (week: MonthCell[], idx: number) => String(idx))
|
||||
}
|
||||
.width('100%')
|
||||
// 注意:不要给页面设百分比高度——竖屏时 Swiper 是自然高度,
|
||||
// 子组件 height('100%') 对 auto 父容器解析为 0,整月网格会塌陷
|
||||
}, (cells: MonthCell[]) => String(cells[0].dateMs))
|
||||
}
|
||||
.index(1)
|
||||
.loop(false)
|
||||
.indicator(false)
|
||||
.width('100%')
|
||||
.layoutWeight(this.isLandscape ? 1 : 0)
|
||||
.onChange((index: number) => {
|
||||
this.handleSwiperChange(index);
|
||||
})
|
||||
}
|
||||
|
||||
/** 当日日程标题行(长按可触发重复日程调试) */
|
||||
@Builder
|
||||
dayPanelHeader() {
|
||||
Row({ space: 8 }) {
|
||||
Text(this.fmtDateCn(this.selectedDate))
|
||||
.fontSize(13)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
Blank()
|
||||
Text(this.fmtMonthTitle())
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_hint'))
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 20, right: 20, top: 6 })
|
||||
.gesture(LongPressGesture().onAction(() => {
|
||||
this.showOccurrenceDebug(this.selectedDate);
|
||||
}))
|
||||
}
|
||||
|
||||
/** 调试(临时):长按月视图日期标题,检查重复日程在该日的首次发生情况 */
|
||||
@@ -890,48 +996,144 @@ struct Index {
|
||||
|
||||
@Builder
|
||||
weekBody() {
|
||||
Column() {
|
||||
Row({ space: 16 }) {
|
||||
Blank()
|
||||
Text('本周')
|
||||
.fontSize(15)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.onClick(() => {
|
||||
this.goThisWeek();
|
||||
if (this.isLandscape) {
|
||||
// 横屏(平板适配):左侧一周日期,右侧选中日日程
|
||||
Row() {
|
||||
Column() {
|
||||
Text('本周')
|
||||
.fontSize(15)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
.textAlign(TextAlign.Center)
|
||||
.padding({ top: 4, bottom: 8 })
|
||||
.onClick(() => {
|
||||
this.goThisWeek();
|
||||
})
|
||||
this.weekStripVertical()
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.height('100%')
|
||||
|
||||
Divider()
|
||||
.vertical(true)
|
||||
.height('92%')
|
||||
.strokeWidth(1)
|
||||
.color($r('app.color.shadow_color'))
|
||||
|
||||
Column() {
|
||||
this.dayPanelHeader()
|
||||
this.eventList()
|
||||
}
|
||||
.layoutWeight(2)
|
||||
.height('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.gesture(
|
||||
SwipeGesture({ direction: SwipeDirection.Horizontal })
|
||||
.onAction((event: GestureEvent) => {
|
||||
if (Math.abs(event.angle) > 90) {
|
||||
this.switchWeek(1);
|
||||
} else {
|
||||
this.switchWeek(-1);
|
||||
}
|
||||
})
|
||||
Blank()
|
||||
)
|
||||
} else {
|
||||
// 竖屏:保持原上下结构
|
||||
Column() {
|
||||
Row({ space: 16 }) {
|
||||
Blank()
|
||||
Text('本周')
|
||||
.fontSize(15)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.onClick(() => {
|
||||
this.goThisWeek();
|
||||
})
|
||||
Blank()
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 20, right: 20, top: 4, bottom: 4 })
|
||||
|
||||
this.weekStrip()
|
||||
|
||||
Row({ space: 8 }) {
|
||||
Text(this.fmtDateCn(this.selectedDate))
|
||||
.fontSize(13)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
Blank()
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 20, right: 20, top: 8 })
|
||||
|
||||
this.eventList()
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 20, right: 20, top: 4, bottom: 4 })
|
||||
|
||||
this.weekStrip()
|
||||
|
||||
Row({ space: 8 }) {
|
||||
Text(this.fmtDateCn(this.selectedDate))
|
||||
.fontSize(13)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
Blank()
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 20, right: 20, top: 8 })
|
||||
|
||||
this.eventList()
|
||||
.layoutWeight(1)
|
||||
.gesture(
|
||||
SwipeGesture({ direction: SwipeDirection.Horizontal })
|
||||
.onAction((event: GestureEvent) => {
|
||||
// 左滑角度约 ±180(|angle|>90)→ 下一周;右滑约 0° → 上一周(与月视图 Swiper 方向一致)
|
||||
if (Math.abs(event.angle) > 90) {
|
||||
this.switchWeek(1);
|
||||
} else {
|
||||
this.switchWeek(-1);
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 周视图竖排日期条(横屏左栏:周一~周日从上到下,可上下滚动) */
|
||||
@Builder
|
||||
weekStripVertical() {
|
||||
Scroll() {
|
||||
Column({ space: 2 }) {
|
||||
ForEach(this.weekStripDays(), (cell: MonthCell) => {
|
||||
Row({ space: 10 }) {
|
||||
Text(WEEK_LABELS[(new Date(cell.dateMs).getDay() + 6) % 7])
|
||||
.fontSize(12)
|
||||
.fontColor(cell.dateMs === this.selectedDate
|
||||
? $r('app.color.text_secondary') : $r('app.color.text_hint'))
|
||||
.width(18)
|
||||
.textAlign(TextAlign.Center)
|
||||
Text(String(cell.day))
|
||||
.fontSize(15)
|
||||
.fontWeight(cell.dateMs === this.selectedDate || cell.isToday
|
||||
? FontWeight.Bold : FontWeight.Normal)
|
||||
.fontColor(cell.dateMs === this.selectedDate
|
||||
? $r('app.color.button_text') : $r('app.color.text_primary'))
|
||||
.width(32)
|
||||
.height(32)
|
||||
.textAlign(TextAlign.Center)
|
||||
.borderRadius(16)
|
||||
.backgroundColor(cell.dateMs === this.selectedDate
|
||||
? $r('app.color.selected_bg')
|
||||
: (cell.isToday ? $r('app.color.today_bg') : Color.Transparent))
|
||||
}
|
||||
.width('100%')
|
||||
.height(44)
|
||||
.borderRadius(10)
|
||||
.padding({ left: 10 })
|
||||
.backgroundColor(cell.dateMs === this.selectedDate
|
||||
? $r('app.color.chip_off_bg') : Color.Transparent)
|
||||
.onClick(() => {
|
||||
this.selectedDate = cell.dateMs;
|
||||
})
|
||||
}, (cell: MonthCell) => `${cell.dateMs}_${cell.dateMs === this.selectedDate}`)
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 8, right: 8, top: 2, bottom: 8 })
|
||||
.constraintSize({ minHeight: '100%' })
|
||||
}
|
||||
.scrollBar(BarState.Off)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
.align(Alignment.Top)
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.gesture(
|
||||
SwipeGesture({ direction: SwipeDirection.Horizontal })
|
||||
.onAction((event: GestureEvent) => {
|
||||
// 左滑角度约 ±180(|angle|>90)→ 下一周;右滑约 0° → 上一周(与月视图 Swiper 方向一致)
|
||||
if (Math.abs(event.angle) > 90) {
|
||||
this.switchWeek(1);
|
||||
} else {
|
||||
this.switchWeek(-1);
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/** 单日日程列表(周视图使用) */
|
||||
@@ -952,7 +1154,7 @@ struct Index {
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 20, right: 20, top: 4, bottom: 90 })
|
||||
.padding({ left: 20, right: 20, top: 4, bottom: 24 })
|
||||
.constraintSize({ minHeight: '100%' })
|
||||
}
|
||||
.layoutWeight(1)
|
||||
@@ -1000,7 +1202,7 @@ struct Index {
|
||||
.layoutWeight(1)
|
||||
.scrollBar(BarState.Auto)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
.padding({ left: 20, right: 20, top: 6, bottom: 90 })
|
||||
.padding({ left: 20, right: 20, top: 6, bottom: 24 })
|
||||
.cachedCount(8)
|
||||
}
|
||||
|
||||
@@ -1097,7 +1299,7 @@ struct Index {
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 20, right: 20, top: 8, bottom: 90 })
|
||||
.padding({ left: 20, right: 20, top: 8, bottom: 24 })
|
||||
.constraintSize({ minHeight: '100%' })
|
||||
}
|
||||
.layoutWeight(1)
|
||||
@@ -1196,7 +1398,7 @@ struct Index {
|
||||
.borderRadius(6)
|
||||
.backgroundColor($r('app.color.chip_off_bg'))
|
||||
}
|
||||
// 所属日历本:最右侧、垂直居中,颜色同日历本
|
||||
// 所属日历本:最右侧、垂直居中,颜色同日历本;只读日历本加删除线标识
|
||||
if (e.calName !== '') {
|
||||
Text(e.calName)
|
||||
.fontSize(11)
|
||||
@@ -1204,6 +1406,7 @@ struct Index {
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.constraintSize({ maxWidth: '30%' })
|
||||
.decoration({ type: e.writable ? TextDecorationType.None : TextDecorationType.LineThrough })
|
||||
}
|
||||
}
|
||||
.alignItems(VerticalAlign.Center)
|
||||
@@ -1221,23 +1424,26 @@ struct Index {
|
||||
dayCell(cell: MonthCell) {
|
||||
Column({ space: 2 }) {
|
||||
Text(String(cell.day))
|
||||
.fontSize(13)
|
||||
.fontSize(this.isLandscape ? 12 : 13)
|
||||
.fontWeight(cell.isToday || cell.dateMs === this.selectedDate ? FontWeight.Bold : FontWeight.Normal)
|
||||
.fontColor(!cell.inMonth
|
||||
? $r('app.color.text_hint')
|
||||
: (cell.dateMs === this.selectedDate ? $r('app.color.button_text') : $r('app.color.text_primary')))
|
||||
.width(26)
|
||||
.height(26)
|
||||
.width(this.isLandscape ? 22 : 26)
|
||||
.height(this.isLandscape ? 22 : 26)
|
||||
.textAlign(TextAlign.Center)
|
||||
.borderRadius(13)
|
||||
.borderRadius(this.isLandscape ? 11 : 13)
|
||||
.backgroundColor(cell.dateMs === this.selectedDate
|
||||
? $r('app.color.selected_bg')
|
||||
: (cell.isToday ? $r('app.color.today_bg') : Color.Transparent))
|
||||
Text(cell.lunar)
|
||||
.fontSize(8)
|
||||
.fontColor(cell.dateMs === this.selectedDate
|
||||
? $r('app.color.text_secondary') : $r('app.color.text_hint'))
|
||||
.maxLines(1)
|
||||
if (!this.isLandscape) {
|
||||
// 横屏高度有限,省略农历腾出空间
|
||||
Text(cell.lunar)
|
||||
.fontSize(8)
|
||||
.fontColor(cell.dateMs === this.selectedDate
|
||||
? $r('app.color.text_secondary') : $r('app.color.text_hint'))
|
||||
.maxLines(1)
|
||||
}
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.padding({ top: 4, bottom: 4 })
|
||||
|
||||
@@ -1,19 +1,32 @@
|
||||
// entry/src/main/ets/pages/SettingsPage.ets
|
||||
// 设置页:系统日历混合显示开关 + 自动同步间隔
|
||||
// 设置页:系统日历模式(仅显示/备份到 CalDAV)+ 混合显示开关 + 自动同步间隔 + 后台同步
|
||||
import { router } from '@kit.ArkUI';
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { AppSettings } from '../common/AppSettings';
|
||||
import { BackgroundSyncService } from '../common/BackgroundSyncService';
|
||||
import { AccountStore, DavAccount } from '../common/AccountStore';
|
||||
import { LogUtil } from '../common/LogUtil';
|
||||
|
||||
const INTERVAL_OPTIONS: number[] = [1, 5, 15, 30, 60];
|
||||
|
||||
/** 日历本选项(备份目标 / 只读标记共用) */
|
||||
class BackupTarget {
|
||||
calKey: string = '';
|
||||
label: string = '';
|
||||
serverWritable: boolean = true; // 服务器探测结果
|
||||
}
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct SettingsPage {
|
||||
@State showSystem: boolean = true;
|
||||
@State intervalMinutes: number = 1;
|
||||
@State backgroundSync: boolean = false;
|
||||
@State sysMode: string = 'display'; // display | backup
|
||||
@State backupKey: string = '';
|
||||
@State backupTargets: BackupTarget[] = [];
|
||||
@State allBooks: BackupTarget[] = []; // 全部 DAV 日历本(只读标记管理用)
|
||||
@State manualKeys: string[] = []; // 手动标记只读的 calKey
|
||||
private context?: common.Context;
|
||||
|
||||
aboutToAppear(): void {
|
||||
@@ -32,6 +45,128 @@ struct SettingsPage {
|
||||
AppSettings.getBackgroundSync(ctx).then((v: boolean): void => {
|
||||
this.backgroundSync = v;
|
||||
});
|
||||
AppSettings.getSysCalMode(ctx).then((v: string): void => {
|
||||
this.sysMode = v;
|
||||
});
|
||||
AppSettings.getManualReadonlyKeys(ctx).then((v: string[]): void => {
|
||||
this.manualKeys = v;
|
||||
});
|
||||
this.loadBackupSettings();
|
||||
this.loadAllBooks();
|
||||
}
|
||||
|
||||
/** 加载全部 DAV 日历本(含探测到的写权限,只读标记管理用) */
|
||||
private async loadAllBooks(): Promise<void> {
|
||||
if (this.context === undefined) {
|
||||
return;
|
||||
}
|
||||
const accounts: DavAccount[] = await AccountStore.loadAll(this.context);
|
||||
const books: BackupTarget[] = [];
|
||||
for (const acc of accounts) {
|
||||
if (acc.type !== 'caldav') {
|
||||
continue;
|
||||
}
|
||||
for (let i = 0; i < acc.calendarHrefs.length; i++) {
|
||||
const b = new BackupTarget();
|
||||
b.calKey = `${acc.id}_${i}`;
|
||||
const bookName: string = i < acc.calendarNames.length ? acc.calendarNames[i] : `日历本 ${i + 1}`;
|
||||
b.label = `${acc.name} · ${bookName}`;
|
||||
b.serverWritable = acc.calendarWritable.length > i ? acc.calendarWritable[i] !== '0' : true;
|
||||
books.push(b);
|
||||
}
|
||||
}
|
||||
this.allBooks = books;
|
||||
}
|
||||
|
||||
/** 手动标记/取消只读 */
|
||||
private async toggleManualBook(b: BackupTarget): Promise<void> {
|
||||
if (this.context === undefined) {
|
||||
return;
|
||||
}
|
||||
const list: string[] = [...this.manualKeys];
|
||||
const idx: number = list.indexOf(b.calKey);
|
||||
if (idx >= 0) {
|
||||
list.splice(idx, 1);
|
||||
} else {
|
||||
list.push(b.calKey);
|
||||
}
|
||||
this.manualKeys = list;
|
||||
await AppSettings.setManualReadonlyKeys(this.context, list);
|
||||
await this.loadBackupSettings(); // 备份目标候选同步排除
|
||||
this.getUIContext().getPromptAction().showToast({
|
||||
message: idx >= 0 ? '已恢复为可写,返回首页生效' : '已标记为只读,返回首页生效'
|
||||
});
|
||||
}
|
||||
|
||||
/** 日历本当前只读状态文案 */
|
||||
private bookStateLabel(b: BackupTarget): string {
|
||||
if (this.manualKeys.includes(b.calKey)) {
|
||||
return '只读(手动)';
|
||||
}
|
||||
return b.serverWritable ? '可写' : '只读';
|
||||
}
|
||||
|
||||
/** 加载备份目标候选(可写 DAV 日历本)+ 当前选择 */
|
||||
private async loadBackupSettings(): Promise<void> {
|
||||
if (this.context === undefined) {
|
||||
return;
|
||||
}
|
||||
const accounts: DavAccount[] = await AccountStore.loadAll(this.context);
|
||||
const targets: BackupTarget[] = [];
|
||||
for (const acc of accounts) {
|
||||
if (acc.type !== 'caldav') {
|
||||
continue;
|
||||
}
|
||||
for (let i = 0; i < acc.calendarHrefs.length; i++) {
|
||||
const writable: boolean = acc.calendarWritable.length > i
|
||||
? acc.calendarWritable[i] !== '0' : true;
|
||||
if (!writable) {
|
||||
continue;
|
||||
}
|
||||
const t = new BackupTarget();
|
||||
t.calKey = `${acc.id}_${i}`;
|
||||
const bookName: string = i < acc.calendarNames.length ? acc.calendarNames[i] : `日历本 ${i + 1}`;
|
||||
t.label = `${acc.name} · ${bookName}`;
|
||||
t.serverWritable = writable;
|
||||
targets.push(t);
|
||||
}
|
||||
}
|
||||
// 手动标记只读的本不可作为备份目标
|
||||
const manual: string[] = await AppSettings.getManualReadonlyKeys(this.context);
|
||||
this.backupTargets = targets.filter((t: BackupTarget): boolean => !manual.includes(t.calKey));
|
||||
this.backupTargets = targets;
|
||||
const saved: string = await AppSettings.getBackupCalKey(this.context);
|
||||
this.backupKey = targets.some((t: BackupTarget): boolean => t.calKey === saved) ? saved : '';
|
||||
}
|
||||
|
||||
private async saveSysMode(mode: string): Promise<void> {
|
||||
if (this.context === undefined) {
|
||||
return;
|
||||
}
|
||||
this.sysMode = mode;
|
||||
await AppSettings.setSysCalMode(this.context, mode);
|
||||
if (mode === 'backup' && this.backupKey === '') {
|
||||
// 自动选中第一个可写日历本
|
||||
if (this.backupTargets.length > 0) {
|
||||
this.backupKey = this.backupTargets[0].calKey;
|
||||
await AppSettings.setBackupCalKey(this.context, this.backupKey);
|
||||
}
|
||||
}
|
||||
this.getUIContext().getPromptAction().showToast({
|
||||
message: mode === 'backup'
|
||||
? '已开启备份:下次同步时把系统本地日程导入所选日历本'
|
||||
: '已切换为仅显示:不再把系统日程备份到 CalDAV'
|
||||
});
|
||||
}
|
||||
|
||||
private async saveBackupTarget(calKey: string): Promise<void> {
|
||||
if (this.context === undefined) {
|
||||
return;
|
||||
}
|
||||
this.backupKey = calKey;
|
||||
await AppSettings.setBackupCalKey(this.context, calKey);
|
||||
this.getUIContext().getPromptAction()
|
||||
.showToast({ message: '备份目标已更新,下次同步生效' });
|
||||
}
|
||||
|
||||
private async saveShowSystem(value: boolean): Promise<void> {
|
||||
@@ -96,7 +231,125 @@ struct SettingsPage {
|
||||
|
||||
Scroll() {
|
||||
Column({ space: 12 }) {
|
||||
// 系统日历混合显示
|
||||
// 系统日历模式:仅显示 / 备份到 CalDAV
|
||||
Column({ space: 8 }) {
|
||||
Row({ space: 10 }) {
|
||||
Column({ space: 2 }) {
|
||||
Text('系统日历')
|
||||
.fontSize(15)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Text('选择手机系统日历日程的处理方式')
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.layoutWeight(1)
|
||||
}
|
||||
.width('100%')
|
||||
Select([{ value: '仅显示(不做备份)' }, { value: '备份到 CalDAV 日历本' }] as SelectOption[])
|
||||
.selected(this.sysMode === 'backup' ? 1 : 0)
|
||||
.value(this.sysMode === 'backup' ? '备份到 CalDAV 日历本' : '仅显示(不做备份)')
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.font({ size: 14 })
|
||||
.optionFont({ size: 14 })
|
||||
.selectedOptionFont({ size: 14 })
|
||||
.width('100%')
|
||||
.onSelect((index: number) => {
|
||||
const mode: string = index === 1 ? 'backup' : 'display';
|
||||
if (mode !== this.sysMode) {
|
||||
this.saveSysMode(mode);
|
||||
}
|
||||
})
|
||||
if (this.sysMode === 'backup') {
|
||||
Text(this.backupTargets.length > 0
|
||||
? '备份目标(可写日历本):导入后随同步上传服务器,换机/丢失也有备份'
|
||||
: '没有可写的 CalDAV 日历本,请先添加账号或检查日历本权限')
|
||||
.fontSize(12)
|
||||
.fontColor(this.backupTargets.length > 0
|
||||
? $r('app.color.text_secondary') : $r('app.color.error'))
|
||||
.width('100%')
|
||||
if (this.backupTargets.length > 0) {
|
||||
Select(this.backupTargets.map((t: BackupTarget): SelectOption => {
|
||||
return { value: t.label } as SelectOption;
|
||||
}) as SelectOption[])
|
||||
.selected(this.backupTargets.findIndex((t: BackupTarget): boolean => t.calKey === this.backupKey))
|
||||
.value(this.backupTargets.find((t: BackupTarget): boolean => t.calKey === this.backupKey)?.label
|
||||
?? '请选择日历本')
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.font({ size: 14 })
|
||||
.optionFont({ size: 14 })
|
||||
.selectedOptionFont({ size: 14 })
|
||||
.width('100%')
|
||||
.onSelect((index: number) => {
|
||||
if (index >= 0 && index < this.backupTargets.length) {
|
||||
this.saveBackupTarget(this.backupTargets[index].calKey);
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.borderRadius(12)
|
||||
.backgroundColor($r('app.color.card_bg'))
|
||||
.border({ width: 1, color: $r('app.color.shadow_color') })
|
||||
|
||||
// 日历本只读标记(部分服务器不在 CalDAV 层拒绝写入,自动探测无法区分时手动标记)
|
||||
Column({ space: 8 }) {
|
||||
Row({ space: 10 }) {
|
||||
Column({ space: 2 }) {
|
||||
Text('日历本只读标记')
|
||||
.fontSize(15)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Text('点击日历本切换只读标记:标记后其中日程仅可查看详情,新建日程时不再显示')
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.layoutWeight(1)
|
||||
}
|
||||
.width('100%')
|
||||
if (this.allBooks.length === 0) {
|
||||
Text('暂无 CalDAV 日历本')
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_hint'))
|
||||
.width('100%')
|
||||
}
|
||||
ForEach(this.allBooks, (b: BackupTarget) => {
|
||||
Row({ space: 8 }) {
|
||||
Text(b.label)
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.layoutWeight(1)
|
||||
Text(this.bookStateLabel(b))
|
||||
.fontSize(11)
|
||||
.fontColor(this.bookStateLabel(b) === '可写'
|
||||
? $r('app.color.success') : $r('app.color.error'))
|
||||
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
|
||||
.borderRadius(10)
|
||||
.backgroundColor(this.bookStateLabel(b) === '可写'
|
||||
? $r('app.color.success_bg') : $r('app.color.error_bg'))
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ top: 6, bottom: 6 })
|
||||
.onClick(() => {
|
||||
this.toggleManualBook(b);
|
||||
})
|
||||
}, (b: BackupTarget) => `${b.calKey}_${this.manualKeys.includes(b.calKey) ? 1 : 0}`)
|
||||
}
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.borderRadius(12)
|
||||
.backgroundColor($r('app.color.card_bg'))
|
||||
.border({ width: 1, color: $r('app.color.shadow_color') })
|
||||
|
||||
// 混合显示系统日历
|
||||
Row({ space: 10 }) {
|
||||
Column({ space: 2 }) {
|
||||
Text('混合显示系统日历')
|
||||
|
||||
@@ -7,6 +7,7 @@ import { DavAccount, AccountStore, TYPE_CALDAV } from '../common/AccountStore';
|
||||
import { SyncEngine } from '../common/SyncEngine';
|
||||
import { CardDataService } from '../common/CardDataService';
|
||||
import { ReminderService } from '../common/ReminderService';
|
||||
import { SystemCalendarImport } from '../common/SystemCalendarImport';
|
||||
import { LogUtil } from '../common/LogUtil';
|
||||
|
||||
export default class SyncWorkAbility extends WorkSchedulerExtensionAbility {
|
||||
@@ -24,6 +25,12 @@ export default class SyncWorkAbility extends WorkSchedulerExtensionAbility {
|
||||
try {
|
||||
const context = this.context;
|
||||
const accounts: DavAccount[] = await AccountStore.loadAll(context);
|
||||
// 备份模式:先导入系统本地日历(幂等),再随同步推送
|
||||
try {
|
||||
await SystemCalendarImport.importIfNeeded(context);
|
||||
} catch (err) {
|
||||
// 导入失败不影响正常同步
|
||||
}
|
||||
let ok: number = 0;
|
||||
for (const acc of accounts) {
|
||||
if (acc.type !== TYPE_CALDAV) {
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
"description": "$string:module_desc",
|
||||
"mainElement": "EntryAbility",
|
||||
"deviceTypes": [
|
||||
"phone"
|
||||
"phone",
|
||||
"tablet"
|
||||
],
|
||||
"requestPermissions": [
|
||||
{
|
||||
@@ -58,6 +59,7 @@
|
||||
"startWindowIcon": "$media:startIcon",
|
||||
"startWindowBackground": "$color:start_window_background",
|
||||
"exported": true,
|
||||
"orientation": "auto_rotation_restricted",
|
||||
"backgroundModes": [
|
||||
"dataTransfer"
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user