- 新增 SystemCalendarMirror / MirrorSnapshot / MirrorInbound:把选定的 CalDAV 日历本镜像写入系统日历(供小艺、桌面日历卡片、手表可见),并做入站对账,区分"自己写入的 / 用户改的 / 用户删的 / 用户新建的",镜像标识一律用稳定的 bookId(accId-href哈希),不再用会随勾选顺序漂移的 calKey - 后台同步接入镜像:同步前先做入站对账、同步后自动出站镜像,无需用户手动触发 - 系统日历备份目标改用 bookId:当用户取消同步某个日历本导致目标失效时,自动清空目标并把模式回退为"仅显示";开启备份必须先选定目标本,不再自动挑第一个本 - EventDb:覆盖行保留 RECURRENCE-ID,排除主事件对应发生改用 overrideKey(calKey, uid, recurrenceId, startTime),修复重复日程出现"幽灵条目"的问题 - 新增 WRITE_CALENDAR 权限声明及其用途说明;添加日历不再出现"本机",日历本对齐显示;版本升至 0.0.3(300)
1504 lines
62 KiB
Plaintext
1504 lines
62 KiB
Plaintext
// entry/src/main/ets/pages/SettingsPage.ets
|
||
// 设置页:系统日历模式(仅显示/备份到 CalDAV)+ 混合显示开关 + 自动同步间隔 + 后台同步
|
||
import { router } from '@kit.ArkUI';
|
||
import { common } from '@kit.AbilityKit';
|
||
import { notificationManager } from '@kit.NotificationKit';
|
||
import { AppSettings } from '../common/AppSettings';
|
||
import { CardDataService } from '../common/CardDataService';
|
||
import { CalendarDataService } from '../common/CalendarDataService';
|
||
import { BackgroundSyncService } from '../common/BackgroundSyncService';
|
||
import { AccountStore, DavAccount } from '../common/AccountStore';
|
||
import { ReminderService } from '../common/ReminderService';
|
||
import { BusinessError } from '@kit.BasicServicesKit';
|
||
import { BackupTargetCheck, BookRef, MirrorResult, PurgeResult, SystemCalendarMirror } from '../common/SystemCalendarMirror';
|
||
import { DocViewer } from '../common/DocViewer';
|
||
|
||
const INTERVAL_OPTIONS: number[] = [1, 5, 15, 30, 60];
|
||
|
||
/** 日历本选项(备份目标 / 只读标记 / 静音标记共用) */
|
||
class BackupTarget {
|
||
calKey: string = '';
|
||
bookId: string = ''; // ⭐ 稳定标识(由服务器 href 派生):镜像选中项用它存,不随序号变
|
||
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 = ''; // ⭐ 备份目标本的**稳定标识** bookId(不再用会漂移的 calKey)
|
||
@State backupTargets: BackupTarget[] = [];
|
||
@State backupNotice: string = ''; // 备份目标失效/未选择时的提示文案
|
||
@State allBooks: BackupTarget[] = []; // 全部 DAV 日历本(只读/静音标记管理用)
|
||
@State manualKeys: string[] = []; // 手动标记只读的 calKey
|
||
@State mutedKeys: string[] = []; // 提醒静音的 calKey
|
||
@State notifyEnabled: boolean = true; // 通知权限状态(提醒依赖)
|
||
@State showLegal: boolean = false; // 隐私政策与用户协议弹层
|
||
// 内置文档浏览器(《隐私政策》/《用户服务协议》/《软件特性》/《使用帮助》):整页 Web 覆盖层
|
||
@State showDoc: boolean = false;
|
||
@State docTitle: string = '';
|
||
@State docUrl: string = '';
|
||
@State defaultView: string = 'month'; // 打开 App 默认视图:month | week | agenda
|
||
@State displayStyle: string = 'timeline'; // 日程显示方式:timeline(时间轴)| list(列表)
|
||
@State mirrorEnabled: boolean = false; // 是否把选中 CalDAV 日历本镜像到系统日历(默认关)
|
||
@State mirrorKeys: string[] = []; // 要镜像的 DAV 日历本 **bookId**(稳定标识,不用会变的 calKey)
|
||
@State mirrorRunning: boolean = false; // 正在执行镜像
|
||
@State purgeRunning: boolean = false; // 正在清理残留镜像账户
|
||
@State mirrorCandidates: BackupTarget[] = []; // 可镜像的本(已排除"系统日历备份目标本",防回灌)
|
||
@State mirrorInbound: boolean = false; // 是否把系统日历的改动回写 CalDAV(默认关)
|
||
@State accountDump: string = ''; // 系统日历账户清单(诊断用,清理后填充)
|
||
private context?: common.Context;
|
||
|
||
aboutToAppear(): void {
|
||
const ctx = this.getUIContext().getHostContext();
|
||
if (ctx === undefined) {
|
||
return;
|
||
}
|
||
this.context = ctx;
|
||
AppSettings.getShowSystemCalendar(ctx).then((v: boolean): void => {
|
||
this.showSystem = v;
|
||
// 状态自洽:若记录为"开",但权限已被用户在系统设置里回收 → 自动回落为"关",避免开关与实际不符
|
||
if (v) {
|
||
CalendarDataService.hasSystemCalendarPermission().then((granted: boolean): void => {
|
||
if (!granted) {
|
||
this.showSystem = false;
|
||
AppSettings.setShowSystemCalendar(ctx, false);
|
||
}
|
||
});
|
||
}
|
||
});
|
||
AppSettings.getSyncIntervalMinutes(ctx).then((v: number): void => {
|
||
this.intervalMinutes = v;
|
||
});
|
||
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;
|
||
});
|
||
AppSettings.getMutedReminderKeys(ctx).then((v: string[]): void => {
|
||
this.mutedKeys = v;
|
||
});
|
||
AppSettings.getDefaultView(ctx).then((v: string): void => {
|
||
this.defaultView = v;
|
||
});
|
||
AppSettings.getDisplayStyle(ctx).then((v: string): void => {
|
||
this.displayStyle = v;
|
||
});
|
||
this.refreshNotifyState();
|
||
this.loadBackupSettings();
|
||
this.loadAllBooks();
|
||
AppSettings.getMirrorEnabled(ctx).then((v: boolean): void => {
|
||
this.mirrorEnabled = v;
|
||
// 状态自洽:记录为"开"但权限已被系统回收 → 自动回落为"关"
|
||
if (v) {
|
||
SystemCalendarMirror.hasPermission().then((ok: boolean): void => {
|
||
if (!ok && this.context !== undefined) {
|
||
this.mirrorEnabled = false;
|
||
AppSettings.setMirrorEnabled(this.context, false);
|
||
}
|
||
});
|
||
}
|
||
});
|
||
// ⭐ 走 selectedBookRefs:它兼做历史迁移(老版本存的是 calKey)与"排除备份目标本"过滤
|
||
SystemCalendarMirror.selectedBookRefs(ctx).then((refs: BookRef[]): void => {
|
||
const ids: string[] = [];
|
||
for (const r of refs) {
|
||
ids.push(r.bookId);
|
||
}
|
||
this.mirrorKeys = ids;
|
||
});
|
||
AppSettings.getMirrorInbound(ctx).then((v: boolean): void => {
|
||
this.mirrorInbound = v;
|
||
});
|
||
}
|
||
|
||
/** 重新检测通知权限状态 */
|
||
private refreshNotifyState(): void {
|
||
notificationManager.isNotificationEnabled()
|
||
.then((v: boolean): void => {
|
||
this.notifyEnabled = v;
|
||
})
|
||
.catch((): void => {
|
||
this.notifyEnabled = false;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 确认通知权限已开启 —— **只在用户主动使用依赖通知的功能时调用**
|
||
* (保存带提醒的日程 / 开启后台持续同步 / 点击设置页"去开启通知")。
|
||
* ⚠️ 严禁在 App 启动、首次打开等用户未操作的时机调用(《审核指南》7.17)。
|
||
*/
|
||
private async ensureNotifyPermission(): Promise<void> {
|
||
if (this.context === undefined) {
|
||
return;
|
||
}
|
||
try {
|
||
const enabled: boolean = await notificationManager.isNotificationEnabled();
|
||
if (!enabled) {
|
||
await notificationManager.requestEnableNotification(
|
||
this.context as common.UIAbilityContext);
|
||
}
|
||
} catch (err) {
|
||
// 用户拒绝或系统未弹窗:不阻断功能,仅状态仍显示为未开启
|
||
}
|
||
this.refreshNotifyState();
|
||
}
|
||
|
||
/** 加载全部 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);
|
||
}
|
||
}
|
||
// ⭐ 补上稳定标识 bookId(由服务器 href 派生)。镜像的"选中状态"必须按它存,
|
||
// 否则日历本重排后序号一变,选中的本就串成另一个本。
|
||
if (this.context !== undefined) {
|
||
const refs: BookRef[] = await SystemCalendarMirror.listBooks(this.context);
|
||
for (const b of books) {
|
||
for (const ref of refs) {
|
||
if (ref.calKey === b.calKey) {
|
||
b.bookId = ref.bookId;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
this.allBooks = books;
|
||
await this.refreshMirrorCandidates();
|
||
}
|
||
|
||
/**
|
||
* 可镜像的日历本 = 全部 DAV 本 **去掉「系统日历备份目标本」**。
|
||
* 备份目标本里的日程本来就来自系统日历(uid 前缀 syscal-),再镜像回系统日历就是自我复制。
|
||
*/
|
||
private async refreshMirrorCandidates(): Promise<void> {
|
||
if (this.context === undefined) {
|
||
return;
|
||
}
|
||
const ctx: common.Context = this.context;
|
||
const blocked: string[] = await SystemCalendarMirror.blockedBookIds(ctx);
|
||
this.mirrorCandidates =
|
||
this.allBooks.filter((b: BackupTarget): boolean => b.bookId === '' || !blocked.includes(b.bookId));
|
||
// 已选中的本若被改成备份目标 → 自动剔除并删掉它在系统日历里的镜像账户
|
||
const kept: string[] = this.mirrorKeys.filter((k: string): boolean => !blocked.includes(k));
|
||
if (kept.length !== this.mirrorKeys.length) {
|
||
for (const k of this.mirrorKeys) {
|
||
if (blocked.includes(k)) {
|
||
await SystemCalendarMirror.removeById(ctx, k);
|
||
}
|
||
}
|
||
this.mirrorKeys = kept;
|
||
await AppSettings.setMirrorBookIds(ctx, kept);
|
||
}
|
||
}
|
||
|
||
/** 因"是备份目标本"而被隐藏、不能镜像的本的数量 */
|
||
private mirrorHiddenCount(): number {
|
||
return this.allBooks.length - this.mirrorCandidates.length;
|
||
}
|
||
|
||
/** 该日历本是否只读:服务器无写权限(探测结果)或用户手动标记只读 —— 与首页色块/只读标记同一口径 */
|
||
private isBookReadonly(b: BackupTarget): boolean {
|
||
return !b.serverWritable || this.manualKeys.includes(b.calKey);
|
||
}
|
||
|
||
/** 手动标记/取消只读 */
|
||
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 async toggleMuteBook(b: BackupTarget): Promise<void> {
|
||
if (this.context === undefined) {
|
||
return;
|
||
}
|
||
const list: string[] = [...this.mutedKeys];
|
||
const idx: number = list.indexOf(b.calKey);
|
||
if (idx >= 0) {
|
||
list.splice(idx, 1);
|
||
} else {
|
||
list.push(b.calKey);
|
||
}
|
||
this.mutedKeys = list;
|
||
await AppSettings.setMutedReminderKeys(this.context, list);
|
||
// 立即重建提醒(全量取消后按新静音规则重发),无需等下次同步
|
||
try {
|
||
await ReminderService.refreshReminders(this.context as common.UIAbilityContext);
|
||
} catch (err) {
|
||
// 重建失败不影响设置保存,下次同步时会再刷新
|
||
}
|
||
this.getUIContext().getPromptAction().showToast({
|
||
message: idx >= 0
|
||
? '已恢复提醒'
|
||
: '已静音:该日历本的所有日程不再提醒'
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 加载备份目标候选(可写 DAV 日历本)+ 当前选择。
|
||
*
|
||
* ⭐ 当前选择用**稳定标识 bookId** 记录(老版本存 calKey,序号会漂移 → 曾把系统日历
|
||
* 导进用户没选过的本)。这里统一走 `checkBackupTarget()`:
|
||
* - 老值自动迁移为 bookId;
|
||
* - 目标本已不存在(被取消勾选/账号删除)→ 它会把备份模式**自动关回 display**,
|
||
* 这里只负责把结果反映到 UI 并给出提示。
|
||
*/
|
||
private async loadBackupSettings(): Promise<void> {
|
||
if (this.context === undefined) {
|
||
return;
|
||
}
|
||
const ctx: common.Context = this.context;
|
||
const accounts: DavAccount[] = await AccountStore.loadAll(ctx);
|
||
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(ctx);
|
||
this.backupTargets = targets.filter((t: BackupTarget): boolean => !manual.includes(t.calKey));
|
||
// 补 bookId(稳定标识)—— 选择状态必须按它存
|
||
const refs: BookRef[] = await SystemCalendarMirror.listBooks(ctx);
|
||
for (const t of this.backupTargets) {
|
||
for (const r of refs) {
|
||
if (r.calKey === t.calKey) {
|
||
t.bookId = r.bookId;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
// 解析当前目标(可能触发失效清理,并在功能开着时自动关闭)
|
||
const check: BackupTargetCheck = await SystemCalendarMirror.checkBackupTarget(ctx);
|
||
this.backupKey = check.ref === undefined ? '' : check.ref.bookId;
|
||
if (check.reason !== '') {
|
||
this.backupNotice = check.closed
|
||
? `${check.reason},已自动关闭系统日历备份。如需继续备份,请在下方重新选择目标日历本。`
|
||
: `${check.reason}。请重新选择备份目标日历本后再开启备份。`;
|
||
} else {
|
||
this.backupNotice = '';
|
||
}
|
||
// 最后再以"落盘值"为准刷新一次选择器:aboutToAppear 里另有一条 getSysCalMode 的异步读取,
|
||
// 若 checkBackupTarget 刚刚把模式关成了 display,就以这里读到的为准(避免旧值把 UI 覆盖回去)
|
||
this.sysMode = await AppSettings.getSysCalMode(ctx);
|
||
}
|
||
|
||
/**
|
||
* 切换「系统日历日程的处理方式」。
|
||
*
|
||
* ⚠️ 顺序很重要:**先校验备份目标、再申请权限、最后才落盘**。
|
||
* - 没有目标本就不允许开启备份(绝不替用户"随便挑一个本"——
|
||
* 那样会把系统日程导进他没选过的日历本,用户实测反馈的问题);
|
||
* - 校验不过就不申请「读取全部日程」权限,避免为一次注定失败的开启弹权限框。
|
||
*/
|
||
private async saveSysMode(mode: string): Promise<void> {
|
||
if (this.context === undefined) {
|
||
return;
|
||
}
|
||
if (mode !== 'backup') {
|
||
this.sysMode = 'display';
|
||
await AppSettings.setSysCalMode(this.context, 'display');
|
||
this.backupNotice = '';
|
||
await this.refreshMirrorCandidates(); // 备份模式变化 → 重算可镜像的本(防回灌)
|
||
this.getUIContext().getPromptAction().showToast({
|
||
message: '已切换为仅显示:不再把系统日程备份到 CalDAV'
|
||
});
|
||
return;
|
||
}
|
||
// ① 必须先有明确的备份目标本(见上方 loadBackupSettings / saveBackupTarget)
|
||
if (this.backupKey === '') {
|
||
this.sysMode = 'display'; // 回弹,且**不写 preferences**:宁可不开启,也不默认挑一个本
|
||
this.backupNotice = this.backupTargets.length > 0
|
||
? '请先选择备份目标日历本:未选定目标前不会开启备份,也不会导入任何系统日程。'
|
||
: '没有可写的 CalDAV 日历本,请先添加账号或检查日历本权限。';
|
||
this.getUIContext().getPromptAction().showToast({
|
||
message: '请先选择备份目标日历本,再开启备份'
|
||
});
|
||
return;
|
||
}
|
||
// ② "备份到 CalDAV" 需要读取手机系统(本地)日历 → 只在用户主动选择该模式时申请权限
|
||
const granted: boolean = await CalendarDataService.requestSystemCalendarPermission(
|
||
this.context as common.UIAbilityContext);
|
||
if (!granted) {
|
||
this.sysMode = 'display'; // 回弹选择,保持与实际授权状态一致
|
||
this.getUIContext().getPromptAction().showToast({
|
||
message: '未获得"读取全部日程"权限,无法开启系统日历备份'
|
||
});
|
||
return;
|
||
}
|
||
this.sysMode = mode;
|
||
await AppSettings.setSysCalMode(this.context, mode);
|
||
this.backupNotice = '';
|
||
await this.refreshMirrorCandidates(); // 备份模式/目标变化 → 重算可镜像的本(防回灌)
|
||
this.getUIContext().getPromptAction().showToast({
|
||
message: '已开启备份:下次同步时把系统本地日程导入所选日历本'
|
||
});
|
||
}
|
||
|
||
/** 保存备份目标(入参为 **bookId**,稳定标识,不再是会漂移的 calKey) */
|
||
private async saveBackupTarget(bookId: string): Promise<void> {
|
||
if (this.context === undefined) {
|
||
return;
|
||
}
|
||
this.backupKey = bookId;
|
||
await AppSettings.setBackupBookId(this.context, bookId);
|
||
this.backupNotice = ''; // 用户显式选定了目标 → 清掉"失效/未选择"提示
|
||
await this.refreshMirrorCandidates(); // 备份目标变了 → 可镜像的本也要跟着变(防回灌)
|
||
this.getUIContext().getPromptAction()
|
||
.showToast({ message: '备份目标已更新,下次同步生效' });
|
||
}
|
||
|
||
/**
|
||
* 保存「混合显示系统日历」开关。
|
||
* ⚠️ 合规约束(《审核指南》7.17):读取系统日历依赖"读取全部日程"敏感权限,
|
||
* **只有在用户主动打开该开关时才申请**;未授权则开关回弹、不保存设置。
|
||
*/
|
||
private async saveShowSystem(value: boolean): Promise<void> {
|
||
if (this.context === undefined) {
|
||
return;
|
||
}
|
||
if (value) {
|
||
const granted: boolean = await CalendarDataService.requestSystemCalendarPermission(
|
||
this.context as common.UIAbilityContext);
|
||
if (!granted) {
|
||
this.showSystem = false; // 回弹开关,保持与实际授权状态一致
|
||
this.getUIContext().getPromptAction().showToast({
|
||
message: '未获得"读取全部日程"权限,无法混合显示系统日历'
|
||
});
|
||
return;
|
||
}
|
||
}
|
||
await AppSettings.setShowSystemCalendar(this.context, value);
|
||
this.getUIContext().getPromptAction()
|
||
.showToast({ message: value ? '已开启系统日历混合显示,返回首页生效' : '已关闭系统日历混合显示,返回首页生效' });
|
||
}
|
||
|
||
/**
|
||
* 「镜像到系统日历」开关。
|
||
* ⚠️ 合规约束(《审核指南》7.17):写入系统日历依赖 WRITE_CALENDAR 权限,
|
||
* **只在用户主动打开这个开关时申请**;未授权则开关回弹、不保存设置。
|
||
* 关闭时会把之前镜像出去的账户一并删掉,避免系统日历里留下"幽灵日程"。
|
||
*/
|
||
private async saveMirrorEnabled(isOn: boolean): Promise<void> {
|
||
if (this.context === undefined) {
|
||
return;
|
||
}
|
||
if (isOn) {
|
||
const granted: boolean = await SystemCalendarMirror.requestPermission(
|
||
this.context as common.UIAbilityContext);
|
||
if (!granted) {
|
||
this.mirrorEnabled = false; // 回弹开关,保持与实际授权状态一致
|
||
this.getUIContext().getPromptAction().showToast({
|
||
message: '未获得"写入系统日历"权限,无法开启镜像'
|
||
});
|
||
return;
|
||
}
|
||
}
|
||
this.mirrorEnabled = isOn;
|
||
await AppSettings.setMirrorEnabled(this.context, isOn);
|
||
if (isOn) {
|
||
this.getUIContext().getPromptAction().showToast({
|
||
message: '已开启镜像:勾选要同步的日历本后点「立即执行镜像」'
|
||
});
|
||
return;
|
||
}
|
||
const n: number = await SystemCalendarMirror.removeAll(this.context);
|
||
this.getUIContext().getPromptAction().showToast({
|
||
message: n > 0 ? `已关闭镜像,并清除系统日历中的 ${n} 个镜像账户` : '已关闭镜像'
|
||
});
|
||
}
|
||
|
||
/** 勾选/取消勾选要镜像的日历本;取消勾选会顺手删掉对应的系统日历账户。⚠️ 传的是 bookId */
|
||
private async toggleMirrorKey(bookId: string, on: boolean): Promise<void> {
|
||
if (this.context === undefined || bookId === '') {
|
||
return;
|
||
}
|
||
const idx: number = this.mirrorKeys.indexOf(bookId);
|
||
if (on && idx < 0) {
|
||
this.mirrorKeys = [...this.mirrorKeys, bookId];
|
||
} else if (!on && idx >= 0) {
|
||
this.mirrorKeys = this.mirrorKeys.filter((k: string): boolean => k !== bookId);
|
||
await SystemCalendarMirror.removeById(this.context, bookId);
|
||
} else {
|
||
return;
|
||
}
|
||
await AppSettings.setMirrorBookIds(this.context, this.mirrorKeys);
|
||
}
|
||
|
||
/**
|
||
* 「把系统日历的改动回写」开关(双向闭环的回程)。
|
||
* 依赖镜像快照(MirrorSnapshot)防回灌:只把"和上次写入值不一样"的当成用户改动。
|
||
* ⚠️ 默认关,因为它会写用户的服务器数据 —— 建议先单向观察一轮再打开。
|
||
*/
|
||
private async saveMirrorInbound(isOn: boolean): Promise<void> {
|
||
if (this.context === undefined) {
|
||
return;
|
||
}
|
||
this.mirrorInbound = isOn;
|
||
await AppSettings.setMirrorInbound(this.context, isOn);
|
||
this.getUIContext().getPromptAction().showToast({
|
||
message: isOn
|
||
? '已开启回写:系统日历里的新建/修改/删除,下次同步时传回服务器'
|
||
: '已关闭回写:系统日历的改动不再传回服务器'
|
||
});
|
||
}
|
||
|
||
/** 手动执行一次镜像(幂等,可反复点) */
|
||
private async runMirrorNow(): Promise<void> {
|
||
if (this.context === undefined || this.mirrorRunning) {
|
||
return;
|
||
}
|
||
if (this.mirrorKeys.length === 0) {
|
||
this.getUIContext().getPromptAction().showToast({ message: '请先勾选要镜像的日历本' });
|
||
return;
|
||
}
|
||
this.mirrorRunning = true;
|
||
try {
|
||
const res: MirrorResult = await SystemCalendarMirror.syncNow(this.context);
|
||
const skip: string = res.skipped > 0 ? ` / 跳过 ${res.skipped}` : '';
|
||
this.getUIContext().getPromptAction().showToast({
|
||
message: `镜像完成:${res.books} 个本,新增 ${res.added} / 更新 ${res.updated} / 删除 ${res.deleted}${skip}`
|
||
});
|
||
} catch (err) {
|
||
const e = err as BusinessError;
|
||
const msg: string = e.message !== undefined && e.message !== ''
|
||
? e.message : '请确认已授予"写入系统日历"权限';
|
||
this.getUIContext().getPromptAction().showToast({ message: `镜像失败:${msg}` });
|
||
} finally {
|
||
this.mirrorRunning = false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 「清理残留镜像账户(诊断)」按钮:
|
||
* ① 把系统日历里**全部**账户(含 id / name / displayName / type)写进同步日志;
|
||
* ② 把"我们建的、但不属于当前选中本"的账户全部删掉(逐个独立 try,失败也继续);
|
||
* ③ Toast 汇总成功/失败/残留数量,残留账户名直接显示出来。
|
||
*/
|
||
private async runPurgeOrphans(): Promise<void> {
|
||
if (this.context === undefined || this.purgeRunning) {
|
||
return;
|
||
}
|
||
this.purgeRunning = true;
|
||
try {
|
||
const r: PurgeResult = await SystemCalendarMirror.forceCleanup(this.context);
|
||
this.accountDump = r.detail.join('\n');
|
||
let msg: string = `已清理 ${r.removed} 个残留账户`;
|
||
if (r.failed > 0) {
|
||
msg += `,失败 ${r.failed} 个`;
|
||
}
|
||
if (r.remaining > 0) {
|
||
msg += `;仍残留 ${r.remaining} 个:${r.remainingNames.join(' , ')}`;
|
||
}
|
||
msg += '(完整账户清单见同步日志)';
|
||
this.getUIContext().getPromptAction().showToast({ message: msg, duration: 6000 });
|
||
} catch (err) {
|
||
const e = err as BusinessError;
|
||
this.getUIContext().getPromptAction().showToast({
|
||
message: `清理失败:${e.message ?? '请确认已授予"写入系统日历"权限'}`
|
||
});
|
||
} finally {
|
||
this.purgeRunning = false;
|
||
}
|
||
}
|
||
|
||
private async saveInterval(minutes: number): Promise<void> {
|
||
if (this.context === undefined) {
|
||
return;
|
||
}
|
||
await AppSettings.setSyncIntervalMinutes(this.context, minutes);
|
||
AppStorage.setOrCreate('syncIntervalMinutes', minutes);
|
||
this.getUIContext().getPromptAction()
|
||
.showToast({ message: `自动同步间隔已设为 ${minutes} 分钟` });
|
||
}
|
||
|
||
private async saveBackgroundSync(value: boolean): Promise<void> {
|
||
if (this.context === undefined) {
|
||
return;
|
||
}
|
||
// 后台持续同步依赖常驻通知 → 在用户主动开启时才申请通知权限(不随启动自动弹窗)
|
||
if (value) {
|
||
await this.ensureNotifyPermission();
|
||
}
|
||
try {
|
||
await BackgroundSyncService.setBackgroundSync(
|
||
this.context as common.UIAbilityContext, value);
|
||
this.getUIContext().getPromptAction().showToast({
|
||
message: value
|
||
? '后台持续同步已开启,通知栏将显示常驻通知'
|
||
: '后台持续同步已关闭'
|
||
});
|
||
} catch (err) {
|
||
this.backgroundSync = !value; // 失败回滚 UI
|
||
const msg: string = (err as BusinessError).message !== ''
|
||
? (err as BusinessError).message : '请重试';
|
||
this.getUIContext().getPromptAction().showToast({ message: `开启失败:${msg}` });
|
||
}
|
||
}
|
||
|
||
private async saveDefaultView(view: string): Promise<void> {
|
||
if (this.context === undefined) {
|
||
return;
|
||
}
|
||
this.defaultView = view;
|
||
await AppSettings.setDefaultView(this.context, view);
|
||
const label: string = view === 'month' ? '月视图' : (view === 'week' ? '周视图' : '列表视图');
|
||
this.getUIContext().getPromptAction()
|
||
.showToast({ message: `默认视图已设为「${label}」,下次打开 App 生效` });
|
||
}
|
||
|
||
private defaultViewLabel(): string {
|
||
return this.defaultView === 'month' ? '月视图'
|
||
: (this.defaultView === 'week' ? '周视图' : '列表视图');
|
||
}
|
||
|
||
private async saveDisplayStyle(style: string): Promise<void> {
|
||
if (this.context === undefined) {
|
||
return;
|
||
}
|
||
this.displayStyle = style;
|
||
await AppSettings.setDisplayStyle(this.context, style);
|
||
// 同步刷桌面卡片:让 4×4 / 6×4 卡片立刻按新方式重绘(列表模式去掉红线)
|
||
try {
|
||
await CardDataService.pushToAllForms(this.context);
|
||
} catch (err) {
|
||
// 卡片刷新失败不影响设置保存,下次同步或卡片自刷新时会生效
|
||
}
|
||
this.getUIContext().getPromptAction().showToast({
|
||
message: style === 'list'
|
||
? '已切换为列表显示(不显示当前时间红线)'
|
||
: '已切换为时间轴显示'
|
||
});
|
||
}
|
||
|
||
private intervalLabel(minutes: number): string {
|
||
return minutes >= 60 ? `${minutes / 60} 小时` : `${minutes} 分钟`;
|
||
}
|
||
|
||
/** 确认后重置全量重拉标记,下次同步重建全部日程数据 */
|
||
private askRebuildData(): void {
|
||
this.getUIContext().showAlertDialog({
|
||
title: '重建日程数据',
|
||
message: '将重新从服务器拉取所有日程的完整数据(包括提醒),本地已修改未同步的内容不受影响。\n\n全量拉取可能需要几分钟,期间请保持 App 在前台、不要熄屏。',
|
||
autoCancel: true,
|
||
alignment: DialogAlignment.Center,
|
||
primaryButton: {
|
||
value: '取消',
|
||
action: (): void => {}
|
||
},
|
||
secondaryButton: {
|
||
value: '开始重建',
|
||
action: (): void => {
|
||
this.doRebuildData();
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
private async doRebuildData(): Promise<void> {
|
||
if (this.context === undefined) {
|
||
return;
|
||
}
|
||
await AppSettings.resetFullRefetch(this.context);
|
||
this.getUIContext().getPromptAction().showToast({
|
||
message: '已开启,返回首页后点一次同步即可,请保持屏幕常亮'
|
||
});
|
||
}
|
||
|
||
/** 确认后清空本地账号库(密码一并删除不可恢复;服务器日程不受影响) */
|
||
private askResetVault(): void {
|
||
this.getUIContext().showAlertDialog({
|
||
title: '重置账号库',
|
||
message: '将删除本机保存的全部账号、服务器地址与密码,回到"还没添加过账号"的状态。\n\n'
|
||
+ '日历数据保存在你的 CalDAV 服务器上,不受影响:重新添加账号并同步一次即可恢复。\n\n'
|
||
+ '本操作不可撤销(本地密码会一并删除),仅在账号读不出来、也无法新增账号时使用。',
|
||
autoCancel: true,
|
||
alignment: DialogAlignment.Center,
|
||
primaryButton: {
|
||
value: '取消',
|
||
action: (): void => {}
|
||
},
|
||
secondaryButton: {
|
||
value: '确认重置',
|
||
action: (): void => {
|
||
this.doResetVault();
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
private async doResetVault(): Promise<void> {
|
||
if (this.context === undefined) {
|
||
return;
|
||
}
|
||
try {
|
||
await AccountStore.resetVault(this.context);
|
||
this.getUIContext().getPromptAction().showToast({
|
||
message: '账号库已重置,请重新添加账号'
|
||
});
|
||
} catch (err) {
|
||
this.getUIContext().getPromptAction().showToast({
|
||
message: '重置失败,请稍后重试'
|
||
});
|
||
}
|
||
}
|
||
|
||
/** 去系统设置开启本应用通知 */
|
||
private async openNotifySettings(): Promise<void> {
|
||
if (this.context === undefined) {
|
||
return;
|
||
}
|
||
try {
|
||
await notificationManager.requestEnableNotification(this.context as common.UIAbilityContext);
|
||
this.refreshNotifyState(); // 返回后重新检测状态
|
||
} catch (err) {
|
||
// 用户取消或未跳转,状态保持原样
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 打开外部链接(隐私政策 / 用户服务协议 / 开源仓库)。
|
||
* 仅放行 http(s):避免 URL 被篡改或误传为 file://、自定义 scheme、intent 类 URI 时,
|
||
* 拉起本地文件或任意第三方应用 —— 把"打开网页"严格限定在预期范围内。
|
||
*/
|
||
private async openUrl(url: string): Promise<void> {
|
||
const context = this.getUIContext().getHostContext();
|
||
if (context === undefined) {
|
||
return;
|
||
}
|
||
if (!url.startsWith('https://') && !url.startsWith('http://')) {
|
||
this.getUIContext().getPromptAction().showToast({ message: '仅支持打开 http(s) 链接' });
|
||
return;
|
||
}
|
||
try {
|
||
await (context as common.UIAbilityContext).openLink(url);
|
||
} catch (err) {
|
||
this.getUIContext().getPromptAction().showToast({ message: '无法打开链接' });
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 用内置浏览器(整页 Web 覆盖层)打开法律文档 —— 不跳出应用、随时可关闭。
|
||
* 先收起"隐私政策与用户协议"弹层,再显示覆盖层,避免两层叠在一起看不清。
|
||
*/
|
||
private openDoc(title: string, url: string): void {
|
||
if (!url.startsWith('https://') && !url.startsWith('http://')) {
|
||
this.getUIContext().getPromptAction().showToast({ message: '仅支持打开 http(s) 链接' });
|
||
return;
|
||
}
|
||
this.showLegal = false;
|
||
this.docTitle = title;
|
||
this.docUrl = url;
|
||
this.showDoc = true;
|
||
}
|
||
|
||
build() {
|
||
Stack() {
|
||
Column() {
|
||
// 顶部
|
||
Row({ space: 10 }) {
|
||
Text('←')
|
||
.fontSize(20)
|
||
.fontColor($r('app.color.brand'))
|
||
.onClick(() => {
|
||
router.back();
|
||
})
|
||
Text('设置')
|
||
.fontSize(20)
|
||
.fontWeight(FontWeight.Bold)
|
||
.fontColor($r('app.color.text_primary'))
|
||
Blank()
|
||
}
|
||
.width('100%')
|
||
.padding({ left: 20, right: 20, top: 12, bottom: 8 })
|
||
|
||
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);
|
||
}
|
||
})
|
||
// ⭐ 备份目标**始终可配置**(不再只在 backup 模式下显示):
|
||
// 否则"先选目标才能开启备份"会变成死锁(Select 只在 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.bookId === this.backupKey))
|
||
.value(this.backupTargets.find((t: BackupTarget): boolean => t.bookId === 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].bookId);
|
||
}
|
||
})
|
||
}
|
||
// 失效/未选择提示:目标本没了时功能已被自动关闭,必须让用户看得见原因
|
||
if (this.backupNotice !== '') {
|
||
Row({ space: 6 }) {
|
||
Text('⚠️')
|
||
.fontSize(12)
|
||
.fontColor($r('app.color.error'))
|
||
Text(this.backupNotice)
|
||
.fontSize(12)
|
||
.fontColor($r('app.color.error'))
|
||
.layoutWeight(1)
|
||
}
|
||
.width('100%')
|
||
.padding(8)
|
||
.borderRadius(8)
|
||
.backgroundColor($r('app.color.error_bg'))
|
||
} else if (this.backupTargets.length > 0 && this.backupKey === '') {
|
||
Text('尚未选择备份目标:请先选择要把系统日历备份到哪个 CalDAV 日历本,否则无法开启备份')
|
||
.fontSize(12)
|
||
.fontColor($r('app.color.error'))
|
||
.width('100%')
|
||
}
|
||
}
|
||
.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('把选中的 CalDAV 日历本写入系统日历,这样小艺、桌面日历卡片和手表也能看到你的日程(需授权写入系统日历)')
|
||
.fontSize(12)
|
||
.fontColor($r('app.color.text_secondary'))
|
||
}
|
||
.alignItems(HorizontalAlign.Start)
|
||
.layoutWeight(1)
|
||
Toggle({ type: ToggleType.Switch, isOn: this.mirrorEnabled })
|
||
.selectedColor($r('app.color.brand'))
|
||
.onChange((isOn: boolean) => {
|
||
this.saveMirrorEnabled(isOn);
|
||
})
|
||
}
|
||
.width('100%')
|
||
|
||
if (this.mirrorEnabled) {
|
||
if (this.mirrorCandidates.length === 0) {
|
||
Text('没有可镜像的 CalDAV 日历本,请先添加账号')
|
||
.fontSize(12)
|
||
.fontColor($r('app.color.error'))
|
||
.width('100%')
|
||
} else {
|
||
Text('选择要镜像的日历本:每个本在系统日历里是独立账户,可单独设色与隐藏')
|
||
.fontSize(12)
|
||
.fontColor($r('app.color.text_secondary'))
|
||
.width('100%')
|
||
if (this.mirrorHiddenCount() > 0) {
|
||
Text(`另有 ${this.mirrorHiddenCount()} 个本被设为「系统日历备份目标」,其日程本就来自系统日历,已自动排除以免重复`)
|
||
.fontSize(12)
|
||
.fontColor($r('app.color.text_hint'))
|
||
.width('100%')
|
||
}
|
||
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
|
||
ForEach(this.mirrorCandidates, (b: BackupTarget): void => {
|
||
Row({ space: 6 }) {
|
||
Text(b.label)
|
||
.fontSize(13)
|
||
.fontColor($r('app.color.text_primary'))
|
||
.layoutWeight(1)
|
||
.maxLines(1)
|
||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||
Toggle({ type: ToggleType.Checkbox, isOn: this.mirrorKeys.indexOf(b.bookId) >= 0 })
|
||
.selectedColor($r('app.color.brand'))
|
||
.onChange((on: boolean) => {
|
||
this.toggleMirrorKey(b.bookId, on);
|
||
})
|
||
}
|
||
.width('48%')
|
||
.padding({ left: 2, right: 2, top: 4, bottom: 4 })
|
||
}, (b: BackupTarget): string => b.calKey)
|
||
}
|
||
.width('100%')
|
||
|
||
Row({ space: 10 }) {
|
||
Column({ space: 2 }) {
|
||
Text('把系统日历的改动回写')
|
||
.fontSize(13)
|
||
.fontWeight(FontWeight.Medium)
|
||
.fontColor($r('app.color.text_primary'))
|
||
Text('你在系统日历里新建、修改、删除的日程,同步回 CalDAV 服务器')
|
||
.fontSize(12)
|
||
.fontColor($r('app.color.text_secondary'))
|
||
}
|
||
.alignItems(HorizontalAlign.Start)
|
||
.layoutWeight(1)
|
||
Toggle({ type: ToggleType.Switch, isOn: this.mirrorInbound })
|
||
.selectedColor($r('app.color.brand'))
|
||
.onChange((isOn: boolean) => {
|
||
this.saveMirrorInbound(isOn);
|
||
})
|
||
}
|
||
.width('100%')
|
||
.padding({ top: 4 })
|
||
|
||
Button(this.mirrorRunning ? '正在执行…' : '立即执行镜像')
|
||
.fontSize(14)
|
||
.width('100%')
|
||
.enabled(!this.mirrorRunning)
|
||
.onClick(() => {
|
||
this.runMirrorNow();
|
||
})
|
||
|
||
Button(this.purgeRunning ? '正在清理…' : '清理残留镜像账户(诊断)')
|
||
.fontSize(13)
|
||
.fontColor($r('app.color.brand_text'))
|
||
.backgroundColor(Color.Transparent)
|
||
.width('100%')
|
||
.enabled(!this.purgeRunning)
|
||
.onClick(() => {
|
||
this.runPurgeOrphans();
|
||
})
|
||
|
||
if (this.accountDump !== '') {
|
||
Column({ space: 4 }) {
|
||
Text('系统日历账户清单(MINE=我们建的,KEEP=本次保留;长按可复制):')
|
||
.fontSize(11)
|
||
.fontColor($r('app.color.text_secondary'))
|
||
.width('100%')
|
||
Scroll() {
|
||
Text(this.accountDump)
|
||
.fontSize(10)
|
||
.lineHeight(14)
|
||
.fontColor($r('app.color.text_primary'))
|
||
.copyOption(CopyOptions.LocalDevice)
|
||
.width('100%')
|
||
}
|
||
.height(150)
|
||
.width('100%')
|
||
.backgroundColor($r('app.color.page_bg'))
|
||
.borderRadius(8)
|
||
.padding(6)
|
||
}
|
||
.width('100%')
|
||
.padding({ top: 4 })
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.alignItems(HorizontalAlign.Start)
|
||
.width('100%')
|
||
.padding(14)
|
||
.borderRadius(12)
|
||
.backgroundColor($r('app.color.card_bg'))
|
||
.border({ width: 1, color: $r('app.color.shadow_color') })
|
||
|
||
// 默认视图:打开 App 后默认展示的视图(月/周/列表)
|
||
Column({ space: 8 }) {
|
||
Row({ space: 10 }) {
|
||
Column({ space: 2 }) {
|
||
Text('默认视图')
|
||
.fontSize(15)
|
||
.fontWeight(FontWeight.Medium)
|
||
.fontColor($r('app.color.text_primary'))
|
||
Text('打开 App 后默认展示的视图(月 / 周 / 列表)')
|
||
.fontSize(12)
|
||
.fontColor($r('app.color.text_secondary'))
|
||
}
|
||
.alignItems(HorizontalAlign.Start)
|
||
.layoutWeight(1)
|
||
Select([{ value: '月视图' }, { value: '周视图' }, { value: '列表视图' }] as SelectOption[])
|
||
.selected(this.defaultView === 'month' ? 0 : (this.defaultView === 'week' ? 1 : 2))
|
||
.value(this.defaultViewLabel())
|
||
.fontColor($r('app.color.text_primary'))
|
||
.font({ size: 14 })
|
||
.optionFont({ size: 14 })
|
||
.selectedOptionFont({ size: 14 })
|
||
.onSelect((index: number) => {
|
||
const v: string = index === 0 ? 'month' : (index === 1 ? 'week' : 'agenda');
|
||
if (v !== this.defaultView) {
|
||
this.saveDefaultView(v);
|
||
}
|
||
})
|
||
}
|
||
.width('100%')
|
||
}
|
||
.alignItems(HorizontalAlign.Start)
|
||
.width('100%')
|
||
.padding(14)
|
||
.borderRadius(12)
|
||
.backgroundColor($r('app.color.card_bg'))
|
||
.border({ width: 1, color: $r('app.color.shadow_color') })
|
||
|
||
// 日程显示方式:时间轴 / 列表(同时作用于主界面与桌面卡片)
|
||
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)
|
||
Select([{ value: '时间轴' }, { value: '列表' }] as SelectOption[])
|
||
.selected(this.displayStyle === 'list' ? 1 : 0)
|
||
.value(this.displayStyle === 'list' ? '列表' : '时间轴')
|
||
.fontColor($r('app.color.text_primary'))
|
||
.font({ size: 14 })
|
||
.optionFont({ size: 14 })
|
||
.selectedOptionFont({ size: 14 })
|
||
.onSelect((index: number) => {
|
||
const v: string = index === 1 ? 'list' : 'timeline';
|
||
if (v !== this.displayStyle) {
|
||
this.saveDisplayStyle(v);
|
||
}
|
||
})
|
||
}
|
||
.width('100%')
|
||
}
|
||
.alignItems(HorizontalAlign.Start)
|
||
.width('100%')
|
||
.padding(14)
|
||
.borderRadius(12)
|
||
.backgroundColor($r('app.color.card_bg'))
|
||
.border({ width: 1, color: $r('app.color.shadow_color') })
|
||
|
||
// 数据修复:手动触发一次性全量重拉(重建提醒等本地残缺字段)
|
||
Column({ space: 8 }) {
|
||
Row({ space: 10 }) {
|
||
Column({ space: 2 }) {
|
||
Text('重建日程数据')
|
||
.fontSize(15)
|
||
.fontWeight(FontWeight.Medium)
|
||
.fontColor($r('app.color.text_primary'))
|
||
Text('同步时重新从服务器拉取全部日程的完整数据,修复提醒丢失等本地数据问题。耗时段落请保持 App 在前台、屏幕常亮')
|
||
.fontSize(12)
|
||
.fontColor($r('app.color.text_secondary'))
|
||
}
|
||
.alignItems(HorizontalAlign.Start)
|
||
.layoutWeight(1)
|
||
Button('重建')
|
||
.fontSize(13)
|
||
.backgroundColor($r('app.color.brand'))
|
||
.onClick(() => {
|
||
this.askRebuildData();
|
||
})
|
||
}
|
||
.width('100%')
|
||
}
|
||
.alignItems(HorizontalAlign.Start)
|
||
.width('100%')
|
||
.padding(14)
|
||
.borderRadius(12)
|
||
.backgroundColor($r('app.color.card_bg'))
|
||
.border({ width: 1, color: $r('app.color.shadow_color') })
|
||
|
||
// 逃生舱:账号库读不出来(密钥丢失/失效)时,只清账号,不动设置与日程
|
||
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)
|
||
Button('重置')
|
||
.fontSize(13)
|
||
.backgroundColor($r('app.color.error'))
|
||
.onClick(() => {
|
||
this.askResetVault();
|
||
})
|
||
}
|
||
.width('100%')
|
||
}
|
||
.alignItems(HorizontalAlign.Start)
|
||
.width('100%')
|
||
.padding(14)
|
||
.borderRadius(12)
|
||
.backgroundColor($r('app.color.card_bg'))
|
||
.border({ width: 1, color: $r('app.color.shadow_color') })
|
||
|
||
// 混合显示系统日历
|
||
Column({ space: 8 }) {
|
||
Row({ space: 10 }) {
|
||
Column({ space: 2 }) {
|
||
Text('混合显示系统日历')
|
||
.fontSize(15)
|
||
.fontWeight(FontWeight.Medium)
|
||
.fontColor($r('app.color.text_primary'))
|
||
Text('关闭后只显示 DAV 账号的日程')
|
||
.fontSize(12)
|
||
.fontColor($r('app.color.text_secondary'))
|
||
}
|
||
.alignItems(HorizontalAlign.Start)
|
||
.layoutWeight(1)
|
||
Toggle({ type: ToggleType.Switch, isOn: this.showSystem })
|
||
.selectedColor($r('app.color.brand'))
|
||
.onChange((isOn: boolean) => {
|
||
if (isOn !== this.showSystem) {
|
||
this.showSystem = isOn;
|
||
this.saveShowSystem(isOn);
|
||
}
|
||
})
|
||
}
|
||
.width('100%')
|
||
}
|
||
.alignItems(HorizontalAlign.Start)
|
||
.width('100%')
|
||
.padding(14)
|
||
.borderRadius(12)
|
||
.backgroundColor($r('app.color.card_bg'))
|
||
.border({ width: 1, color: $r('app.color.shadow_color') })
|
||
|
||
// 自动同步间隔
|
||
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)
|
||
Select([{ value: '1 分钟' }, { value: '5 分钟' }, { value: '15 分钟' },
|
||
{ value: '30 分钟' }, { value: '1 小时' }] as SelectOption[])
|
||
.selected(INTERVAL_OPTIONS.indexOf(this.intervalMinutes))
|
||
.value(this.intervalLabel(this.intervalMinutes))
|
||
.fontColor($r('app.color.text_primary'))
|
||
.font({ size: 14 })
|
||
.optionFont({ size: 14 })
|
||
.selectedOptionFont({ size: 14 })
|
||
.onSelect((index: number) => {
|
||
if (index >= 0 && index < INTERVAL_OPTIONS.length) {
|
||
this.intervalMinutes = INTERVAL_OPTIONS[index];
|
||
this.saveInterval(INTERVAL_OPTIONS[index]);
|
||
}
|
||
})
|
||
}
|
||
.width('100%')
|
||
}
|
||
.alignItems(HorizontalAlign.Start)
|
||
.width('100%')
|
||
.padding(14)
|
||
.borderRadius(12)
|
||
.backgroundColor($r('app.color.card_bg'))
|
||
.border({ width: 1, color: $r('app.color.shadow_color') })
|
||
|
||
// 后台持续同步(长时任务)
|
||
Column({ space: 8 }) {
|
||
Row({ space: 10 }) {
|
||
Column({ space: 2 }) {
|
||
Text('后台持续同步')
|
||
.fontSize(15)
|
||
.fontWeight(FontWeight.Medium)
|
||
.fontColor($r('app.color.text_primary'))
|
||
Text('开启后申请后台长时任务,App 退到后台也按设定间隔继续同步(通知栏会显示常驻通知)')
|
||
.fontSize(12)
|
||
.fontColor($r('app.color.text_secondary'))
|
||
}
|
||
.alignItems(HorizontalAlign.Start)
|
||
.layoutWeight(1)
|
||
Toggle({ type: ToggleType.Switch, isOn: this.backgroundSync })
|
||
.selectedColor($r('app.color.brand'))
|
||
.onChange((isOn: boolean) => {
|
||
if (isOn !== this.backgroundSync) {
|
||
this.backgroundSync = isOn;
|
||
this.saveBackgroundSync(isOn);
|
||
}
|
||
})
|
||
}
|
||
.width('100%')
|
||
}
|
||
.alignItems(HorizontalAlign.Start)
|
||
.width('100%')
|
||
.padding(14)
|
||
.borderRadius(12)
|
||
.backgroundColor($r('app.color.card_bg'))
|
||
.border({ width: 1, color: $r('app.color.shadow_color') })
|
||
|
||
// 通知权限:仅检测是否开启,未开启则引导去设置
|
||
Column({ space: 8 }) {
|
||
Row({ space: 10 }) {
|
||
Column({ space: 2 }) {
|
||
Row({ space: 6 }) {
|
||
Text('通知权限')
|
||
.fontSize(15)
|
||
.fontWeight(FontWeight.Medium)
|
||
.fontColor($r('app.color.text_primary'))
|
||
Text(this.notifyEnabled ? '已开启' : '未开启')
|
||
.fontSize(11)
|
||
.fontColor(this.notifyEnabled ? $r('app.color.success') : $r('app.color.error'))
|
||
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
|
||
.borderRadius(10)
|
||
.backgroundColor(this.notifyEnabled ? $r('app.color.success_bg') : $r('app.color.error_bg'))
|
||
}
|
||
Text(this.notifyEnabled
|
||
? '日程提醒将正常以系统通知弹出'
|
||
: '未开启系统通知,日程提醒无法弹出')
|
||
.fontSize(12)
|
||
.fontColor($r('app.color.text_secondary'))
|
||
}
|
||
.alignItems(HorizontalAlign.Start)
|
||
.layoutWeight(1)
|
||
}
|
||
.width('100%')
|
||
if (!this.notifyEnabled) {
|
||
Button('去开启通知')
|
||
.fontSize(13)
|
||
.backgroundColor($r('app.color.brand'))
|
||
.onClick(() => {
|
||
this.openNotifySettings();
|
||
})
|
||
}
|
||
}
|
||
.alignItems(HorizontalAlign.Start)
|
||
.width('100%')
|
||
.padding(14)
|
||
.borderRadius(12)
|
||
.backgroundColor($r('app.color.card_bg'))
|
||
.border({ width: 1, color: $r('app.color.shadow_color') })
|
||
|
||
// 软件特性(打开内置浏览器;内容为在线文档 sf.html,便于随时更新)
|
||
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)
|
||
Text('›')
|
||
.fontSize(18)
|
||
.fontColor($r('app.color.text_hint'))
|
||
}
|
||
.width('100%')
|
||
}
|
||
.alignItems(HorizontalAlign.Start)
|
||
.width('100%')
|
||
.padding(14)
|
||
.borderRadius(12)
|
||
.backgroundColor($r('app.color.card_bg'))
|
||
.border({ width: 1, color: $r('app.color.shadow_color') })
|
||
.onClick(() => {
|
||
this.openDoc(AppSettings.FEATURES_TITLE, AppSettings.FEATURES_URL);
|
||
})
|
||
|
||
// 使用帮助(打开内置浏览器;内容为在线文档 uh.html,便于随时更新)
|
||
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)
|
||
Text('›')
|
||
.fontSize(18)
|
||
.fontColor($r('app.color.text_hint'))
|
||
}
|
||
.width('100%')
|
||
}
|
||
.alignItems(HorizontalAlign.Start)
|
||
.width('100%')
|
||
.padding(14)
|
||
.borderRadius(12)
|
||
.backgroundColor($r('app.color.card_bg'))
|
||
.border({ width: 1, color: $r('app.color.shadow_color') })
|
||
.onClick(() => {
|
||
this.openDoc(AppSettings.HELP_TITLE, AppSettings.HELP_URL);
|
||
})
|
||
|
||
// 隐私政策与用户协议(点击弹层;bindSheet 挂在本卡片上,避免与其它弹层冲突)
|
||
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)
|
||
Text('›')
|
||
.fontSize(18)
|
||
.fontColor($r('app.color.text_hint'))
|
||
}
|
||
.width('100%')
|
||
}
|
||
.alignItems(HorizontalAlign.Start)
|
||
.width('100%')
|
||
.padding(14)
|
||
.borderRadius(12)
|
||
.backgroundColor($r('app.color.card_bg'))
|
||
.border({ width: 1, color: $r('app.color.shadow_color') })
|
||
.onClick(() => {
|
||
this.showLegal = true;
|
||
})
|
||
.bindSheet($$this.showLegal, this.legalSheet(), {
|
||
height: 400,
|
||
dragBar: true,
|
||
showClose: true,
|
||
title: { title: '隐私政策与用户协议' }
|
||
})
|
||
|
||
// 日历本只读 / 静音(放在最下方;本多时列表较长)
|
||
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 }) {
|
||
// 日历本色点:只读本变中性灰 —— 相当于给这个本盖了一层灰蒙板
|
||
Column()
|
||
.width(8)
|
||
.height(8)
|
||
.borderRadius(4)
|
||
.backgroundColor(this.isBookReadonly(b)
|
||
? '#B0B4BA' : $r('app.color.brand'))
|
||
Text(b.label)
|
||
.fontSize(13)
|
||
.fontColor(this.isBookReadonly(b)
|
||
? $r('app.color.text_hint') : $r('app.color.text_primary'))
|
||
.maxLines(1)
|
||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||
.layoutWeight(1)
|
||
Button(this.manualKeys.includes(b.calKey) ? '已只读' : '只读')
|
||
.fontSize(11)
|
||
.height(28)
|
||
.fontColor(this.manualKeys.includes(b.calKey)
|
||
? $r('app.color.button_text') : $r('app.color.text_secondary'))
|
||
.backgroundColor(this.manualKeys.includes(b.calKey)
|
||
? $r('app.color.brand') : $r('app.color.card_bg'))
|
||
.border({
|
||
width: this.manualKeys.includes(b.calKey) ? 0 : 1,
|
||
color: $r('app.color.shadow_color')
|
||
})
|
||
.onClick(() => {
|
||
this.toggleManualBook(b);
|
||
})
|
||
Button(this.mutedKeys.includes(b.calKey) ? '已静音' : '静音')
|
||
.fontSize(11)
|
||
.height(28)
|
||
.fontColor(this.mutedKeys.includes(b.calKey)
|
||
? $r('app.color.button_text') : $r('app.color.text_secondary'))
|
||
.backgroundColor(this.mutedKeys.includes(b.calKey)
|
||
? $r('app.color.error') : $r('app.color.card_bg'))
|
||
.border({
|
||
width: this.mutedKeys.includes(b.calKey) ? 0 : 1,
|
||
color: $r('app.color.shadow_color')
|
||
})
|
||
.onClick(() => {
|
||
this.toggleMuteBook(b);
|
||
})
|
||
}
|
||
.width('100%')
|
||
.padding({ left: 6, right: 6, top: 6, bottom: 6 })
|
||
.borderRadius(8)
|
||
}, (b: BackupTarget) => `book_${b.calKey}_r${this.manualKeys.includes(b.calKey) ? 1 : 0}_m${this.mutedKeys.includes(b.calKey) ? 1 : 0}_w${b.serverWritable ? 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') })
|
||
}
|
||
.width('100%')
|
||
.padding({ left: 20, right: 20, top: 8, bottom: 20 })
|
||
}
|
||
.layoutWeight(1)
|
||
.align(Alignment.Top)
|
||
.scrollBar(BarState.Off)
|
||
}
|
||
.width('100%')
|
||
.height('100%')
|
||
.backgroundColor($r('app.color.page_bg'))
|
||
|
||
// 内置文档浏览器(《隐私政策》/《用户服务协议》/《软件特性》/《使用帮助》):
|
||
// 整页覆盖层,点"✕"即刻返回设置页
|
||
if (this.showDoc) {
|
||
DocViewer({
|
||
title: this.docTitle,
|
||
url: this.docUrl,
|
||
onClose: (): void => {
|
||
this.showDoc = false;
|
||
}
|
||
})
|
||
}
|
||
}
|
||
.width('100%')
|
||
.height('100%')
|
||
}
|
||
|
||
/** 隐私政策与用户协议弹层 */
|
||
@Builder
|
||
legalSheet() {
|
||
Scroll() {
|
||
Column({ space: 12 }) {
|
||
Text('我们遵循"数据自持、最小必要"原则:开发者不保存你的日程与账号数据,不收集通讯录、相册、剪贴板等信息,不含任何第三方统计或广告 SDK。')
|
||
.fontSize(14)
|
||
.fontColor($r('app.color.text_primary'))
|
||
.width('100%')
|
||
Button('查看《隐私政策》')
|
||
.width('100%')
|
||
.fontSize(15)
|
||
.backgroundColor($r('app.color.brand'))
|
||
.onClick(() => {
|
||
this.openDoc(AppSettings.PRIVACY_TITLE, AppSettings.PRIVACY_URL);
|
||
})
|
||
Button('查看《用户服务协议》')
|
||
.width('100%')
|
||
.fontSize(15)
|
||
.backgroundColor($r('app.color.brand'))
|
||
.onClick(() => {
|
||
this.openDoc(AppSettings.AGREEMENT_TITLE, AppSettings.AGREEMENT_URL);
|
||
})
|
||
Text('《隐私政策》说明本应用如何收集、使用与保护你的信息(日历、位置等);《用户服务协议》说明使用本应用的权利与义务。两份文件随功能调整更新,最新版本以上述链接为准。')
|
||
.fontSize(12)
|
||
.fontColor($r('app.color.text_secondary'))
|
||
.width('100%')
|
||
}
|
||
.width('100%')
|
||
.align(Alignment.Top)
|
||
.padding(16)
|
||
}
|
||
.width('100%')
|
||
.height('100%')
|
||
.backgroundColor($r('app.color.page_bg'))
|
||
// 沉浸式:背景延伸到状态栏/导航条区域(只扩绘制、不移动子组件)
|
||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||
}
|
||
}
|