4 Commits
15 changed files with 437 additions and 137 deletions
+2 -2
View File
@@ -2,8 +2,8 @@
"app": { "app": {
"bundleName": "synccalendar.yangyq.net", "bundleName": "synccalendar.yangyq.net",
"vendor": "yangyq", "vendor": "yangyq",
"versionCode": 100, "versionCode": 200,
"versionName": "0.0.1", "versionName": "0.0.2",
"buildVersion": "1", "buildVersion": "1",
"icon": "$media:layered_image", "icon": "$media:layered_image",
"label": "$string:app_name" "label": "$string:app_name"
+7 -3
View File
@@ -365,14 +365,18 @@ export class AppSettings {
} }
} }
/** 是否混合显示系统日历日程(默认开) */ /**
* 是否混合显示系统日历日程。
* ⚠️ 默认**关闭**:读取系统日历需要"读取全部日程"敏感权限,按《审核指南》7.17
* 只能在用户主动打开该开关时才申请,因此默认值必须与实际授权状态一致(无授权 = 关)。
*/
static async getShowSystemCalendar(context: common.Context): Promise<boolean> { static async getShowSystemCalendar(context: common.Context): Promise<boolean> {
try { try {
const store: preferences.Preferences = const store: preferences.Preferences =
await preferences.getPreferences(context, AppSettings.STORE); await preferences.getPreferences(context, AppSettings.STORE);
return await store.get(AppSettings.KEY_SHOW_SYSTEM, true) as boolean; return await store.get(AppSettings.KEY_SHOW_SYSTEM, false) as boolean;
} catch (err) { } catch (err) {
return true; return false;
} }
} }
@@ -1,6 +1,6 @@
// entry/src/main/ets/common/CalendarDataService.ets // entry/src/main/ets/common/CalendarDataService.ets
// 合并数据源:本地库(DAV 同步 + 本机事件) + 系统日历(只读展示) // 合并数据源:本地库(DAV 同步 + 本机事件) + 系统日历(只读展示)
import { common, abilityAccessCtrl, Permissions } from '@kit.AbilityKit'; import { common, abilityAccessCtrl, bundleManager, Permissions } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit'; import { BusinessError } from '@kit.BasicServicesKit';
import { calendarManager } from '@kit.CalendarKit'; import { calendarManager } from '@kit.CalendarKit';
import { AccountStore, CalSource, BookPalette, LOCAL_CAL_KEY, DavAccount } from './AccountStore'; import { AccountStore, CalSource, BookPalette, LOCAL_CAL_KEY, DavAccount } from './AccountStore';
@@ -29,15 +29,52 @@ export class DisplayEvent {
} }
export class CalendarDataService { export class CalendarDataService {
/** 申请读取全部日历权限(混合展示系统日历需要) */ /** 本功能依赖的日历权限:读取本应用日历 + 读取全部日历(即"读取全部日程") */
static async ensureSystemCalendarPermission(context: common.UIAbilityContext): Promise<boolean> { private static readonly CALENDAR_PERMISSIONS: Permissions[] = [
try {
const atManager = abilityAccessCtrl.createAtManager();
const permissions: Permissions[] = [
'ohos.permission.READ_CALENDAR', 'ohos.permission.READ_CALENDAR',
'ohos.permission.READ_WHOLE_CALENDAR' 'ohos.permission.READ_WHOLE_CALENDAR'
]; ];
const result = await atManager.requestPermissionsFromUser(context, permissions);
/**
* 是否已获得"读取全部日程"权限。
* **只检查、绝不弹窗** —— 供界面判断状态、决定是否需要引导用户主动授权。
* 注意:checkAccessToken 只反映本应用授权状态,不会触发任何系统弹窗。
*/
static async hasSystemCalendarPermission(): Promise<boolean> {
try {
const atManager = abilityAccessCtrl.createAtManager();
const info = bundleManager.getBundleInfoForSelfSync(
bundleManager.BundleFlag.GET_BUNDLE_INFO_WITH_APPLICATION);
const tokenId: number = info.appInfo.accessTokenId;
for (const p of CalendarDataService.CALENDAR_PERMISSIONS) {
const status = await atManager.checkAccessToken(tokenId, p);
if (status !== abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED) {
return false;
}
}
return true;
} catch (err) {
return false;
}
}
/**
* 申请"读取全部日程"权限(READ_CALENDAR + READ_WHOLE_CALENDAR)。
*
* ⚠️ 合规约束(《审核指南》7.17):**只能在用户主动点击使用对应功能时调用**,
* 例如在设置页打开"混合显示系统日历"开关、把系统日历模式切到"备份到 CalDAV 日历本"。
* 严禁在 App 启动 / 首次打开 / 未同意隐私政策等用户未主动操作的时机调用。
*
* 已授权时直接返回 true,不会再弹窗(先 checkAccessToken,避免多余弹窗)。
*/
static async requestSystemCalendarPermission(context: common.UIAbilityContext): Promise<boolean> {
if (await CalendarDataService.hasSystemCalendarPermission()) {
return true;
}
try {
const atManager = abilityAccessCtrl.createAtManager();
const result = await atManager.requestPermissionsFromUser(
context, CalendarDataService.CALENDAR_PERMISSIONS);
for (const r of result.authResults) { for (const r of result.authResults) {
if (r !== 0) { if (r !== 0) {
return false; return false;
+2
View File
@@ -52,5 +52,7 @@ export struct DocViewer {
.width('100%') .width('100%')
.height('100%') .height('100%')
.backgroundColor($r('app.color.page_bg')) .backgroundColor($r('app.color.page_bg'))
// 沉浸式:背景延伸到状态栏/导航条区域(只扩绘制,Web 与标题栏仍在安全区内)
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
} }
} }
+2 -2
View File
@@ -28,8 +28,8 @@ export struct TipsPanel {
{ {
icon: $r('app.media.tip_calendar'), icon: $r('app.media.tip_calendar'),
title: '系统日历,也能一起管', title: '系统日历,也能一起管',
desc: '手机系统日历可与 CalDAV 日历混合显示在同一时间轴;还能把系统日历本通过 CalDAV 同步备份,' + desc: '手机系统日历可与 CalDAV 日历混合显示在同一时间轴,也能把系统日历本通过 CalDAV 同步备份,' +
'一处编辑、处处更新。' '一处编辑、处处更新。到「设置 → 混合显示系统日历」开启并授权即可。'
}, },
{ {
icon: $r('app.media.tip_mute'), icon: $r('app.media.tip_mute'),
+106 -40
View File
@@ -1,11 +1,8 @@
import { AbilityConstant, ConfigurationConstant, UIAbility, Want } from '@kit.AbilityKit'; import { AbilityConstant, ConfigurationConstant, UIAbility, Want, Configuration, EnvironmentCallback } from '@kit.AbilityKit';
import { hilog } from '@kit.PerformanceAnalysisKit'; import { hilog } from '@kit.PerformanceAnalysisKit';
import { window } from '@kit.ArkUI'; import { window } from '@kit.ArkUI';
import { notificationManager } from '@kit.NotificationKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { BackgroundSyncService } from '../common/BackgroundSyncService'; import { BackgroundSyncService } from '../common/BackgroundSyncService';
import { LogUtil } from '../common/LogUtil'; import { LogUtil } from '../common/LogUtil';
import { AppSettings } from '../common/AppSettings';
const DOMAIN = 0x0000; const DOMAIN = 0x0000;
@@ -19,24 +16,11 @@ export default class EntryAbility extends UIAbility {
BackgroundSyncService.initOnLaunch(this.context); BackgroundSyncService.initOnLaunch(this.context);
// 服务卡片"添加"按钮跳转:把 widgetAction=add 透传给主页面(在 AppStorage 中暂存) // 服务卡片"添加"按钮跳转:把 widgetAction=add 透传给主页面(在 AppStorage 中暂存)
this.handleWidgetParam(want); this.handleWidgetParam(want);
// 通知权限:日程提醒、后台同步常驻通知都依赖它(系统只弹一次授权框)。 // ⚠️ 这里**不再申请任何权限**(含通知权限)。
// 必须在用户同意《隐私政策》之后才申请 —— 同意前不得申请任何权限; // 依据《审核指南》7.17:应用不得在用户未主动点击对应功能时提前弹窗申请权限。
// 首次安装此处跳过,用户点"同意并继续"后由 Index.initPermissionAndLoad() 补申请。 // 通知权限改为在用户主动操作时申请:
AppSettings.getPolicyAgreed(this.context) // ① 保存带提醒的日程(EventEditPage)② 开启「后台持续同步」(SettingsPage)
.then((agreed: boolean): Promise<void> => { // ③ 设置页「通知权限」→「去开启通知」按钮
if (!agreed) {
LogUtil.write('未同意隐私政策:跳过通知权限申请');
return Promise.resolve();
}
return notificationManager.isNotificationEnabled()
.then((enabled: boolean): Promise<void> => {
if (!enabled) {
return notificationManager.requestEnableNotification(this.context).catch((): void => {});
}
return Promise.resolve();
});
})
.catch((): void => {});
} }
onDestroy(): void { onDestroy(): void {
@@ -82,38 +66,120 @@ export default class EntryAbility extends UIAbility {
onWindowStageCreate(windowStage: window.WindowStage): void { onWindowStageCreate(windowStage: window.WindowStage): void {
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onWindowStageCreate'); hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onWindowStageCreate');
// ① 先把应用固定为「跟随系统」深浅色。必须在取窗口/刷系统栏之前调用,
// 否则会读到尚未更新到深色的色彩配置(曾导致深色下状态栏仍是浅灰)。
try {
this.context.getApplicationContext().setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET);
} catch (err) {
hilog.error(DOMAIN, 'testTag', 'Failed to set colorMode. Cause: %{public}s', JSON.stringify(err));
}
// ② 沉浸式配色:状态栏/导航条背景**透明**,透出「主题化」的窗口底色,
// 消除顶部通知栏与底部灰条;前景(时间/图标)颜色按实际底色亮度取反。
try {
const win: window.Window = windowStage.getMainWindowSync();
this.mainWindow = win;
this.applySystemBars(win);
} catch (err) {
hilog.error(DOMAIN, 'testTag', 'Failed to apply immersive bars. Cause: %{public}s', JSON.stringify(err));
}
windowStage.loadContent('pages/Index', (err) => { windowStage.loadContent('pages/Index', (err) => {
if (err.code) { if (err.code) {
hilog.error(DOMAIN, 'testTag', 'Failed to load the content. Cause: %{public}s', JSON.stringify(err)); hilog.error(DOMAIN, 'testTag', 'Failed to load the content. Cause: %{public}s', JSON.stringify(err));
return; return;
} }
hilog.info(DOMAIN, 'testTag', 'Succeeded in loading the content.'); hilog.info(DOMAIN, 'testTag', 'Succeeded in loading the content.');
// 沉浸式配色:状态栏/导航条与页面背景同色(app.color.page_bg = #F1F3F5), });
// 消除顶部通知栏和底部的白色条
windowStage.getMainWindow((werr: BusinessError, win: window.Window) => { // ③ 系统深浅色切换时重刷配色。用 applicationContext 的 environment 回调,
if (werr.code) { // 它比 UIAbility.onConfigurationUpdate 更可靠(后者在部分场景下不回调,
hilog.error(DOMAIN, 'testTag', 'Failed to get main window. Cause: %{public}s', JSON.stringify(werr)); // 会出现"页面已变深色但状态栏/底部仍是浅灰"的现象)。
return; try {
const cb: EnvironmentCallback = {
onConfigurationUpdated: (config: Configuration): void => {
if (this.mainWindow !== undefined) {
this.applySystemBars(this.mainWindow);
} }
},
onMemoryLevel: (level: AbilityConstant.MemoryLevel): void => {
}
};
this.context.getApplicationContext().on('environment', cb);
} catch (err) {
hilog.error(DOMAIN, 'testTag', 'Failed to watch environment. Cause: %{public}s', JSON.stringify(err));
}
}
/** 主窗口引用:系统深浅色切换时需要重新刷状态栏/导航栏配色 */
private mainWindow?: window.Window;
/** 当前是否处于系统深色模式(兜底用) */
private isDarkMode(): boolean {
try {
return this.context.config.colorMode === ConfigurationConstant.ColorMode.COLOR_MODE_DARK;
} catch (err) {
return false;
}
}
/**
* 取当前主题下 app.color.page_bg 的真实取值(经 dark/ 限定符解析):
* 浅色 #F1F3F5 / 深色 #000000。以"资源系统实际解析出的颜色"为准,
* 这样才能与页面真正渲染出来的背景色一致,不受 config 更新时机影响。
*/
private themePageBg(): string {
try {
const c: number = this.context.resourceManager.getColorSync($r('app.color.page_bg').id) >>> 0;
return '#' + c.toString(16).padStart(8, '0');
} catch (err) {
return this.isDarkMode() ? '#000000' : '#F1F3F5';
}
}
/** 背景是深色时需要浅色前景,反之用深色前景 */
private isDarkBg(bg: string): boolean {
const s: string = bg.startsWith('#') ? bg.substring(1) : bg;
if (s.length < 6) {
return this.isDarkMode();
}
const off: number = s.length >= 8 ? 2 : 0;
const r: number = parseInt(s.substring(off, off + 2), 16);
const g: number = parseInt(s.substring(off + 2, off + 4), 16);
const b: number = parseInt(s.substring(off + 4, off + 6), 16);
if (isNaN(r) || isNaN(g) || isNaN(b)) {
return this.isDarkMode();
}
return (0.2126 * r + 0.7152 * g + 0.0722 * b) < 128;
}
/**
* 按当前主题设置状态栏/导航条与窗口底色。
* 背景取 0x00000000(全透明)——官方推荐的背景沉浸方案:系统栏透明,
* 透传应用自身背景,因此底色天然跟随深浅色,不会再出现恒定灰条。
*/
private applySystemBars(win: window.Window): void {
const bgColor: string = this.themePageBg();
const fgColor: string = this.isDarkBg(bgColor) ? '#FFFFFF' : '#182431';
try { try {
win.setWindowSystemBarProperties({ win.setWindowSystemBarProperties({
statusBarColor: '#F1F3F5', statusBarColor: '#00000000',
statusBarContentColor: '#182431', statusBarContentColor: fgColor,
navigationBarColor: '#F1F3F5', navigationBarColor: '#00000000',
navigationBarContentColor: '#182431' navigationBarContentColor: fgColor
}); });
// 鸿蒙 NEXT 导航条背景跟随窗口背景色:设为页面背景灰,消除底部白条 // 窗口底色=页面背景色,供透明的状态栏/导航条透出
win.setWindowBackgroundColor('#F1F3F5'); win.setWindowBackgroundColor(bgColor);
} catch (barErr) { } catch (barErr) {
hilog.error(DOMAIN, 'testTag', 'Failed to set system bar properties. Cause: %{public}s', hilog.error(DOMAIN, 'testTag', 'Failed to set system bar properties. Cause: %{public}s',
JSON.stringify(barErr)); JSON.stringify(barErr));
} }
}); }
});
try { /** 兜底:部分设备/场景只回调 UIAbility.onConfigurationUpdate */
this.context.getApplicationContext().setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET); onConfigurationUpdate(): void {
} catch (err) { if (this.mainWindow !== undefined) {
hilog.error(DOMAIN, 'testTag', 'Failed to set colorMode. Cause: %{public}s', JSON.stringify(err)); this.applySystemBars(this.mainWindow);
} }
} }
@@ -247,6 +247,9 @@ struct AccountsPage {
} }
.width('100%') .width('100%')
.height('100%') .height('100%')
// 沉浸式:背景延伸到状态栏/导航条区域(只扩绘制、不移动子组件)
.backgroundColor($r('app.color.page_bg'))
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
} }
@Builder @Builder
@@ -292,6 +292,8 @@ struct AddAccountPage {
.height('100%') .height('100%')
.padding({ left: 24, right: 24, top: 16, bottom: 24 }) .padding({ left: 24, right: 24, top: 16, bottom: 24 })
.backgroundColor($r('app.color.page_bg')) .backgroundColor($r('app.color.page_bg'))
// 沉浸式:背景延伸到状态栏/导航条区域(只扩绘制、不移动子组件)
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
.alignItems(HorizontalAlign.Center) .alignItems(HorizontalAlign.Center)
} }
@@ -310,6 +312,10 @@ struct AddAccountPage {
.fontSize(15) .fontSize(15)
.backgroundColor($r('app.color.input_bg')) .backgroundColor($r('app.color.input_bg'))
.borderRadius(8) .borderRadius(8)
// 显式指定文字/提示/光标颜色:默认取系统主题色,深色模式下会与底色撞色而"看不清"
.fontColor($r('app.color.text_primary'))
.placeholderColor($r('app.color.text_hint'))
.caretColor($r('app.color.brand'))
.onChange(onChange) .onChange(onChange)
} }
.alignItems(HorizontalAlign.Start) .alignItems(HorizontalAlign.Start)
@@ -249,6 +249,7 @@ struct CalendarListPage {
.fontSize(16) .fontSize(16)
.fontColor($r('app.color.text_primary')) .fontColor($r('app.color.text_primary'))
.placeholderColor($r('app.color.text_hint')) .placeholderColor($r('app.color.text_hint'))
.caretColor($r('app.color.brand'))
.backgroundColor($r('app.color.input_bg')) .backgroundColor($r('app.color.input_bg'))
.borderRadius(10) .borderRadius(10)
.border({ width: 1.5, color: $r('app.color.brand') }) .border({ width: 1.5, color: $r('app.color.brand') })
@@ -364,6 +365,8 @@ struct CalendarListPage {
.height('100%') .height('100%')
.padding({ left: 24, right: 24, top: 16, bottom: 24 }) .padding({ left: 24, right: 24, top: 16, bottom: 24 })
.backgroundColor($r('app.color.page_bg')) .backgroundColor($r('app.color.page_bg'))
// 沉浸式:背景延伸到状态栏/导航条区域(只扩绘制、不移动子组件)
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
.alignItems(HorizontalAlign.Center) .alignItems(HorizontalAlign.Center)
} }
} }
@@ -318,6 +318,7 @@ struct EditAccountPage {
.fontSize(16) .fontSize(16)
.fontColor($r('app.color.text_primary')) .fontColor($r('app.color.text_primary'))
.placeholderColor($r('app.color.text_hint')) .placeholderColor($r('app.color.text_hint'))
.caretColor($r('app.color.brand'))
.backgroundColor($r('app.color.input_bg')) .backgroundColor($r('app.color.input_bg'))
.borderRadius(10) .borderRadius(10)
.border({ width: 1.5, color: $r('app.color.brand') }) .border({ width: 1.5, color: $r('app.color.brand') })
@@ -434,6 +435,8 @@ struct EditAccountPage {
.height('100%') .height('100%')
.padding({ left: 24, right: 24, top: 16, bottom: 24 }) .padding({ left: 24, right: 24, top: 16, bottom: 24 })
.backgroundColor($r('app.color.page_bg')) .backgroundColor($r('app.color.page_bg'))
// 沉浸式:背景延伸到状态栏/导航条区域(只扩绘制、不移动子组件)
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
.alignItems(HorizontalAlign.Center) .alignItems(HorizontalAlign.Center)
} }
} }
+120 -19
View File
@@ -1,9 +1,10 @@
// entry/src/main/ets/pages/EventEditPage.ets // entry/src/main/ets/pages/EventEditPage.ets
// 日程编辑页:新建 / 修改 / 删除本地(DAV 或本机)日程 // 日程编辑页:新建 / 修改 / 删除日程(一律属于某个 CalDAV 日历本,不再支持"本机"日程)
import { router } from '@kit.ArkUI'; import { router } from '@kit.ArkUI';
import { common } from '@kit.AbilityKit'; import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit'; import { BusinessError } from '@kit.BasicServicesKit';
import { DavAccount, AccountStore, CalSource, BookPalette } from '../common/AccountStore'; import { notificationManager } from '@kit.NotificationKit';
import { DavAccount, AccountStore, CalSource, BookPalette, LOCAL_CAL_KEY } from '../common/AccountStore';
import { EventDb, LocalEvent } from '../common/EventDb'; import { EventDb, LocalEvent } from '../common/EventDb';
import { AppSettings } from '../common/AppSettings'; import { AppSettings } from '../common/AppSettings';
import { DavClient } from '../common/DavClient'; import { DavClient } from '../common/DavClient';
@@ -51,8 +52,9 @@ struct EventEditPage {
if (context === undefined) { if (context === undefined) {
return; return;
} }
// 收集可写入的日历本(DAV 可写日历本 + 本机);只读日历本不出现在新建/编辑选择中 // 收集可写入的日历本:只取 **CalDAV** 可写日历本("本机"不再出现在选择里);
const sources: CalSourceWithHref[] = await CalendarDataBridge.loadWritableSources(context); // 只读日历本同样不出现
const sources: CalSourceWithHref[] = await CalendarDataBridge.loadWritableDavSources(context);
const choices: BookChoice[] = []; const choices: BookChoice[] = [];
for (const s of sources) { for (const s of sources) {
const b = new BookChoice(); const b = new BookChoice();
@@ -63,6 +65,10 @@ struct EventEditPage {
choices.push(b); choices.push(b);
} }
this.books = choices; this.books = choices;
if (choices.length === 0) {
// 没有可写入的 CalDAV 日历本 → 日程无处可写,给明确指引(保存时也会再拦一次)
this.statusMsg = '还没有可写入的 CalDAV 日历本,请先到「更多 → 账号管理」添加账号';
}
// 编辑既有事件 // 编辑既有事件
const pendingId: number | undefined = AppStorage.get<number>('pendingEventId'); const pendingId: number | undefined = AppStorage.get<number>('pendingEventId');
@@ -78,6 +84,11 @@ struct EventEditPage {
this.startMs = loaded.startTime; this.startMs = loaded.startTime;
this.endMs = loaded.endTime; this.endMs = loaded.endTime;
this.chosenKey = loaded.calKey; this.chosenKey = loaded.calKey;
// 历史遗留的"本机"日程:本应用已不再支持本机日程,改为引导用户挑一个 CalDAV 日历本
// (保存时 calKey/href 会改写为该日历本,事件随后被推送到服务器)
if (loaded.calKey === LOCAL_CAL_KEY) {
this.statusMsg = '这条日程原先只存在「本机」(不同步)。请选一个 CalDAV 日历本,保存后即会同步到服务器。';
}
this.repeatMode = loaded.rrule !== '' ? this.modeFromRrule(loaded.rrule) : 'none'; this.repeatMode = loaded.rrule !== '' ? this.modeFromRrule(loaded.rrule) : 'none';
this.reminderList = loaded.reminders.length > 0 ? loaded.reminders this.reminderList = loaded.reminders.length > 0 ? loaded.reminders
: (loaded.reminder > 0 ? [loaded.reminder] : []); : (loaded.reminder > 0 ? [loaded.reminder] : []);
@@ -224,6 +235,14 @@ struct EventEditPage {
this.statusMsg = '重复日程的单次修改暂不支持,请到服务器端调整重复规则'; this.statusMsg = '重复日程的单次修改暂不支持,请到服务器端调整重复规则';
return; return;
} }
// 日程必须落在某个 CalDAV 日历本上("本机"不再是可选目标)——没选到就直接拦下
const book = this.chosenBook();
if (book === null) {
this.statusMsg = this.books.length === 0
? '还没有可写入的 CalDAV 日历本,请先到「更多 → 账号管理」添加账号'
: '请选择要同步到的 CalDAV 日历本';
return;
}
this.isSaving = true; this.isSaving = true;
this.statusMsg = ''; this.statusMsg = '';
try { try {
@@ -231,7 +250,6 @@ struct EventEditPage {
if (context === undefined) { if (context === undefined) {
return; return;
} }
const book = this.chosenBook();
const e = this.event ?? new LocalEvent(); const e = this.event ?? new LocalEvent();
const isNew: boolean = this.event === null; const isNew: boolean = this.event === null;
e.title = this.title.trim(); e.title = this.title.trim();
@@ -240,8 +258,9 @@ struct EventEditPage {
e.startTime = this.startMs; e.startTime = this.startMs;
e.endTime = this.allDay ? this.startMs + 86399999 : this.endMs; e.endTime = this.allDay ? this.startMs + 86399999 : this.endMs;
e.isAllDay = this.allDay; e.isAllDay = this.allDay;
e.calKey = book !== null ? book.calKey : 'local'; // 一律写入所选 CalDAV 日历本(新建/编辑/从「本机」迁过来都一样)
e.href = book !== null ? book.href : ''; e.calKey = book.calKey;
e.href = book.href;
// 重复规则与提醒 // 重复规则与提醒
if (this.repeatMode === 'custom') { if (this.repeatMode === 'custom') {
// 无法识别的既有规则原样保留 // 无法识别的既有规则原样保留
@@ -272,6 +291,11 @@ struct EventEditPage {
} }
// 日志仅记录 uid 与提醒/重复设置,不落盘日程标题(避免隐私内容进入可备份的 sync.log) // 日志仅记录 uid 与提醒/重复设置,不落盘日程标题(避免隐私内容进入可备份的 sync.log)
LogUtil.write(`本地保存日程(标题已脱敏)uid=${e.uid} 提醒=${e.reminders.join('/')}分钟 重复=${e.rrule === '' ? '否' : e.rrule}`); LogUtil.write(`本地保存日程(标题已脱敏)uid=${e.uid} 提醒=${e.reminders.join('/')}分钟 重复=${e.rrule === '' ? '否' : e.rrule}`);
// 设置了提醒 → 此刻才申请通知权限(用户刚主动使用了依赖通知的功能,
// 符合《审核指南》7.17"在用户主动点击对应功能时申请",不随 App 启动自动弹窗)
if (e.reminders.length > 0) {
await this.ensureNotifyPermission();
}
// 立即刷新提醒(不等下一轮同步),保证刚保存的提醒马上生效 // 立即刷新提醒(不等下一轮同步),保证刚保存的提醒马上生效
try { try {
await ReminderService.refreshReminders(context as common.UIAbilityContext); await ReminderService.refreshReminders(context as common.UIAbilityContext);
@@ -290,6 +314,27 @@ struct EventEditPage {
this.isSaving = false; this.isSaving = false;
} }
/**
* 确认通知权限已开启 —— 仅"用户保存了带提醒的日程"这条路径会调用。
* 日程提醒依赖系统通知,用户刚主动设置了提醒,此时申请权限与其使用目的完全一致。
* ⚠️ 严禁在 App 启动 / 首次打开时调用(《审核指南》7.17:不得提前弹窗申请权限)。
*/
private async ensureNotifyPermission(): Promise<void> {
try {
const enabled: boolean = await notificationManager.isNotificationEnabled();
if (enabled) {
return;
}
const context = this.getUIContext().getHostContext();
if (context === undefined) {
return;
}
await notificationManager.requestEnableNotification(context as common.UIAbilityContext);
} catch (err) {
// 用户拒绝或未弹窗:日程照常保存,只是提醒可能不弹出(设置页可再开启)
}
}
/** /**
* 删除前二次确认。 * 删除前二次确认。
* 删除日程不可撤销,且会同时从本地库与服务器(DAV)移除,故破坏性操作前必须显式确认, * 删除日程不可撤销,且会同时从本地库与服务器(DAV)移除,故破坏性操作前必须显式确认,
@@ -398,6 +443,11 @@ struct EventEditPage {
.fontSize(16) .fontSize(16)
.backgroundColor($r('app.color.card_bg')) .backgroundColor($r('app.color.card_bg'))
.borderRadius(12) .borderRadius(12)
// 显式指定文字/提示/光标颜色:TextInput 默认取系统主题色,
// 深色模式下会变成浅色文字 → 与卡片底色撞色而"看不清"
.fontColor($r('app.color.text_primary'))
.placeholderColor($r('app.color.text_hint'))
.caretColor($r('app.color.brand'))
.onChange((v: string) => { .onChange((v: string) => {
this.title = v; this.title = v;
}) })
@@ -477,23 +527,51 @@ struct EventEditPage {
.borderRadius(12) .borderRadius(12)
.backgroundColor($r('app.color.card_bg')) .backgroundColor($r('app.color.card_bg'))
// 日历本选择 // 日历本选择:两列等宽对齐(每项固定 48% 宽 + 两端对齐),只列 CalDAV 可写日历本
Column({ space: 8 }) { Column({ space: 8 }) {
Row({ space: 6 }) {
Text('日历本') Text('日历本')
.fontSize(14) .fontSize(14)
.fontColor($r('app.color.text_secondary')) .fontColor($r('app.color.text_secondary'))
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Start }) { Text('同步到 CalDAV')
.fontSize(11)
.fontColor($r('app.color.text_hint'))
}
.width('100%')
if (this.books.length === 0) {
// 没有可写入的 CalDAV 日历本:说明原因 + 给替代方案(本机日程请用系统「日历」)
Text('没有可写入的 CalDAV 日历本。请先到「更多 → 账号管理」添加账号;'
+ '这条日程需要同步到服务器。若只想记在本机,请使用系统「日历」应用。')
.fontSize(12)
.fontColor($r('app.color.error'))
.width('100%')
} else {
if (this.chosenBook() === null) {
// 未选中任何日历本(如历史遗留的"本机"日程)→ 就地提示,别让用户找不到原因
Text(this.chosenKey === LOCAL_CAL_KEY
? '这条日程原先只存在「本机」(不同步)。请选一个日历本,保存后即会同步到服务器。'
: '请选择一个日历本,日程会同步到服务器。')
.fontSize(12)
.fontColor($r('app.color.brand_text'))
.width('100%')
}
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
ForEach(this.books, (b: BookChoice) => { ForEach(this.books, (b: BookChoice) => {
Row({ space: 5 }) { Row({ space: 6 }) {
Circle().width(8).height(8).fill(b.color) Circle().width(8).height(8).fill(b.color)
Text(b.name) Text(b.name)
.fontSize(12) .fontSize(12)
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.fontColor(this.chosenKey === b.calKey .fontColor(this.chosenKey === b.calKey
? $r('app.color.button_text') : $r('app.color.text_primary')) ? $r('app.color.button_text') : $r('app.color.text_primary'))
} }
.padding({ left: 10, right: 10, top: 6, bottom: 6 }) .width('48%')
.padding({ left: 10, right: 10, top: 8, bottom: 8 })
.borderRadius(14) .borderRadius(14)
.margin({ right: 8, bottom: 8 }) .margin({ bottom: 8 })
.backgroundColor(this.chosenKey === b.calKey ? b.color : $r('app.color.chip_off_bg')) .backgroundColor(this.chosenKey === b.calKey ? b.color : $r('app.color.chip_off_bg'))
.onClick(() => { .onClick(() => {
this.chosenKey = b.calKey; this.chosenKey = b.calKey;
@@ -502,6 +580,7 @@ struct EventEditPage {
} }
.width('100%') .width('100%')
} }
}
.alignItems(HorizontalAlign.Start) .alignItems(HorizontalAlign.Start)
.width('100%') .width('100%')
.padding(14) .padding(14)
@@ -514,6 +593,9 @@ struct EventEditPage {
.fontSize(14) .fontSize(14)
.backgroundColor($r('app.color.card_bg')) .backgroundColor($r('app.color.card_bg'))
.borderRadius(12) .borderRadius(12)
.fontColor($r('app.color.text_primary'))
.placeholderColor($r('app.color.text_hint'))
.caretColor($r('app.color.brand'))
.onChange((v: string) => { .onChange((v: string) => {
this.location = v; this.location = v;
}) })
@@ -524,6 +606,9 @@ struct EventEditPage {
.fontSize(14) .fontSize(14)
.backgroundColor($r('app.color.card_bg')) .backgroundColor($r('app.color.card_bg'))
.borderRadius(12) .borderRadius(12)
.fontColor($r('app.color.text_primary'))
.placeholderColor($r('app.color.text_hint'))
.caretColor($r('app.color.brand'))
.onChange((v: string) => { .onChange((v: string) => {
this.description = v; this.description = v;
}) })
@@ -564,6 +649,8 @@ struct EventEditPage {
.height('100%') .height('100%')
.padding({ top: 12 }) .padding({ top: 12 })
.backgroundColor($r('app.color.page_bg')) .backgroundColor($r('app.color.page_bg'))
// 沉浸式:背景延伸到状态栏/导航条区域(只扩绘制、不移动子组件)
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
.bindSheet($$this.pickerShow, this.dateTimePickerSheet(), { .bindSheet($$this.pickerShow, this.dateTimePickerSheet(), {
height: 380, height: 380,
showClose: false, showClose: false,
@@ -671,6 +758,13 @@ struct EventEditPage {
.onDateChange((value: Date) => { .onDateChange((value: Date) => {
this.pickDate = value; this.pickDate = value;
}) })
// 滚轮文字显式指定颜色:默认取系统主题色,深色模式下与弹层底色撞色会"看不清"
.textStyle({ color: $r('app.color.text_secondary'), font: { size: 14 } })
.selectedTextStyle({
color: $r('app.color.text_primary'),
font: { size: 16, weight: FontWeight.Medium }
})
.disappearTextStyle({ color: $r('app.color.text_hint'), font: { size: 14 } })
.layoutWeight(1) .layoutWeight(1)
.height(210) .height(210)
if (!this.allDay) { if (!this.allDay) {
@@ -681,6 +775,12 @@ struct EventEditPage {
this.pickHour = value.hour; this.pickHour = value.hour;
this.pickMin = value.minute; this.pickMin = value.minute;
}) })
.textStyle({ color: $r('app.color.text_secondary'), font: { size: 14 } })
.selectedTextStyle({
color: $r('app.color.text_primary'),
font: { size: 16, weight: FontWeight.Medium }
})
.disappearTextStyle({ color: $r('app.color.text_hint'), font: { size: 14 } })
.layoutWeight(1) .layoutWeight(1)
.height(210) .height(210)
} }
@@ -703,9 +803,15 @@ struct EventEditPage {
} }
} }
/** 桥接:从账号存储拿可写来源(DAV 日历本 + 本机),附上 href */ /**
* 桥接:从账号存储拿**可写入且能同步**的日历本(只取 CalDAV 日历本),并附上 href。
*
* ⚠️ 刻意**不返回"本机"(calKey='local')**:本应用新建/编辑的日程一律写入某个 CalDAV
* 日历本并同步到服务器;纯本机日程请用户改用系统「日历」应用完成
* (本应用也没有申请写系统日历本地的权限,做不到)。
*/
class CalendarDataBridge { class CalendarDataBridge {
static async loadWritableSources(context: common.Context): Promise<CalSourceWithHref[]> { static async loadWritableDavSources(context: common.Context): Promise<CalSourceWithHref[]> {
const result: CalSourceWithHref[] = []; const result: CalSourceWithHref[] = [];
const accounts: DavAccount[] = await AccountStore.loadAll(context); const accounts: DavAccount[] = await AccountStore.loadAll(context);
const manualKeys: string[] = await AppSettings.getManualReadonlyKeys(context); const manualKeys: string[] = await AppSettings.getManualReadonlyKeys(context);
@@ -731,11 +837,6 @@ class CalendarDataBridge {
result.push(s); result.push(s);
} }
} }
const local = new CalSourceWithHref();
local.calKey = 'local';
local.name = '本机(不同步)';
local.color = '#5A6068';
result.push(local);
return result; return result;
} }
} }
+24 -40
View File
@@ -5,7 +5,6 @@ import { mediaquery, router } from '@kit.ArkUI';
import { common, abilityAccessCtrl, bundleManager } from '@kit.AbilityKit'; import { common, abilityAccessCtrl, bundleManager } from '@kit.AbilityKit';
import { geoLocationManager } from '@kit.LocationKit'; import { geoLocationManager } from '@kit.LocationKit';
import { BusinessError, pasteboard } from '@kit.BasicServicesKit'; import { BusinessError, pasteboard } from '@kit.BasicServicesKit';
import { notificationManager } from '@kit.NotificationKit';
import { DavAccount, AccountStore, CalSource, TYPE_CALDAV } from '../common/AccountStore'; import { DavAccount, AccountStore, CalSource, TYPE_CALDAV } from '../common/AccountStore';
import { DisplayEvent, CalendarDataService } from '../common/CalendarDataService'; import { DisplayEvent, CalendarDataService } from '../common/CalendarDataService';
import { SyncEngine } from '../common/SyncEngine'; import { SyncEngine } from '../common/SyncEngine';
@@ -84,7 +83,6 @@ struct Index {
// ---- 隐私政策与用户协议首启同意(未同意前不申请任何权限、不加载数据) ---- // ---- 隐私政策与用户协议首启同意(未同意前不申请任何权限、不加载数据) ----
@State policyChecked: boolean = false; // 是否已读取过同意状态(避免首帧闪烁) @State policyChecked: boolean = false; // 是否已读取过同意状态(避免首帧闪烁)
@State policyAgreed: boolean = false; // 是否已同意《隐私政策》与《用户协议》 @State policyAgreed: boolean = false; // 是否已同意《隐私政策》与《用户协议》
private notifAsked: boolean = false; // 通知权限是否已申请(同意后才申请)
@State showTips: boolean = false; // 首启功能引导(整页覆盖层:遮罩 + 底部卡片 + Swiper 三页) @State showTips: boolean = false; // 首启功能引导(整页覆盖层:遮罩 + 底部卡片 + Swiper 三页)
// ---- 内置文档浏览器(《隐私政策》/《用户服务协议》,整页 Web 覆盖层,不跳出应用) ---- // ---- 内置文档浏览器(《隐私政策》/《用户服务协议》,整页 Web 覆盖层,不跳出应用) ----
@State showDoc: boolean = false; @State showDoc: boolean = false;
@@ -211,40 +209,17 @@ struct Index {
} }
} }
private initPermissionAndLoad(): void {
const context = this.getUIContext().getHostContext();
if (context === undefined) {
return;
}
this.reloadAll();
this.ensureNotificationPermission(context as common.UIAbilityContext);
if (!this.permissionAsked) {
this.permissionAsked = true;
// 申请读取全部日历权限(用于混合展示系统日程)
CalendarDataService.ensureSystemCalendarPermission(context as common.UIAbilityContext)
.then((): void => {
this.reloadAll();
});
}
}
/** /**
* 申请通知权限(日程提醒、后台同步常驻通知都依赖它)。 * 启动/同意隐私政策后的初始化:**只加载本地库与 DAV 数据**。
* 必须在用户同意《隐私政策》之后才调用 —— 同意前不得申请任何权限。 *
* ⚠️ 合规约束(《审核指南》7.17):这里**不申请任何权限**。
* 权限必须由用户主动点击对应功能时申请,否则会被判定为"未主动点击即提前弹窗申请":
* - 读取全部日程(日历)→ 设置页「混合显示系统日历」开关 / 「备份到 CalDAV 日历本」
* - 通知 → 保存带提醒的日程 / 开启「后台持续同步」/ 设置页「去开启通知」
* 本方法只负责加载数据;系统日历来源由 CalendarDataService.loadSources 按开关与授权状态自行决定是否读取。
*/ */
private ensureNotificationPermission(context: common.UIAbilityContext): void { private initPermissionAndLoad(): void {
if (this.notifAsked) { this.reloadAll();
return;
}
this.notifAsked = true;
notificationManager.isNotificationEnabled()
.then((enabled: boolean): Promise<void> => {
if (!enabled) {
return notificationManager.requestEnableNotification(context).catch((): void => {});
}
return Promise.resolve();
})
.catch((): void => {});
} }
/** /**
@@ -1067,6 +1042,10 @@ struct Index {
} }
.width('100%') .width('100%')
.height('100%') .height('100%')
// 沉浸式:根容器背景延伸到状态栏/导航条区域(只扩绘制、不移动子组件),
// 配合 EntryAbility 里"系统栏透明"的设置,彻底消除顶部通知栏与底部灰条。
.backgroundColor($r('app.color.page_bg'))
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
.bindSheet($$this.detailShow, this.detailSheet(), { .bindSheet($$this.detailShow, this.detailSheet(), {
height: 540, height: 540,
dragBar: true, dragBar: true,
@@ -1324,7 +1303,7 @@ struct Index {
.borderRadius(12) .borderRadius(12)
.backgroundColor($r('app.color.card_bg')) .backgroundColor($r('app.color.card_bg'))
.border({ width: 1, color: $r('app.color.shadow_color') }) .border({ width: 1, color: $r('app.color.shadow_color') })
.shadow({ radius: 12, color: '#22000000', offsetY: 4 }) .shadow({ radius: 12, color: $r('app.color.shadow_color'), offsetY: 4 })
.margin({ top: 56, right: 20 }) .margin({ top: 56, right: 20 })
} }
.width('100%') .width('100%')
@@ -1540,8 +1519,12 @@ struct Index {
Column({ space: 2 }) { Column({ space: 2 }) {
Text(WEEK_LABELS[(new Date(cell.dateMs).getDay() + 6) % 7]) Text(WEEK_LABELS[(new Date(cell.dateMs).getDay() + 6) % 7])
.fontSize(11) .fontSize(11)
// ⚠️ 这里原来用 button_text(纯白),但该标签**没有背景**、坐在页面底色上,
// 浅色模式下是"白字压浅灰底"→ 对比度仅 1.11:1(应用市场 UX 检测被判不合格)。
// 改用 brand_text(品牌色文字版):浅 #0057C2 → 6.01:1,深 #4DA3FF → 8.0:1,
// 既保留"选中"的强调含义,又满足对比度要求。
.fontColor(cell.dateMs === this.selectedDate .fontColor(cell.dateMs === this.selectedDate
? $r('app.color.button_text') : $r('app.color.text_hint')) ? $r('app.color.brand_text') : $r('app.color.text_hint'))
Text(String(cell.day)) Text(String(cell.day))
.fontSize(14) .fontSize(14)
.fontWeight(cell.dateMs === this.selectedDate || cell.isToday .fontWeight(cell.dateMs === this.selectedDate || cell.isToday
@@ -1992,8 +1975,9 @@ struct Index {
Row({ space: 10 }) { Row({ space: 10 }) {
Text(WEEK_LABELS[(new Date(cell.dateMs).getDay() + 6) % 7]) Text(WEEK_LABELS[(new Date(cell.dateMs).getDay() + 6) % 7])
.fontSize(12) .fontSize(12)
// 同上:选中态改用高对比的 brand_text(原 text_secondary 在浅色下仅 2.85:1)
.fontColor(cell.dateMs === this.selectedDate .fontColor(cell.dateMs === this.selectedDate
? $r('app.color.text_secondary') : $r('app.color.text_hint')) ? $r('app.color.brand_text') : $r('app.color.text_hint'))
.width(18) .width(18)
.textAlign(TextAlign.Center) .textAlign(TextAlign.Center)
Text(String(cell.day)) Text(String(cell.day))
@@ -2950,7 +2934,7 @@ struct DayTimelineView {
.height(this.hourUnit) .height(this.hourUnit)
.textAlign(TextAlign.End) .textAlign(TextAlign.End)
.padding({ right: 4, top: 2 }) .padding({ right: 4, top: 2 })
.border({ width: { bottom: 0.5 }, color: { bottom: '#14000000' } }) .border({ width: { bottom: 0.5 }, color: { bottom: $r('app.color.grid_line') } })
}, (h: number) => `h${h}`) }, (h: number) => `h${h}`)
} }
.width(36) .width(36)
@@ -2966,7 +2950,7 @@ struct DayTimelineView {
.layoutWeight(1) .layoutWeight(1)
.height(this.viewH()) .height(this.viewH())
.alignContent(Alignment.TopStart) .alignContent(Alignment.TopStart)
.border({ width: { left: 0.5 }, color: { left: '#14000000' } }) .border({ width: { left: 0.5 }, color: { left: $r('app.color.grid_line') } })
} }
.width('100%') .width('100%')
.alignItems(VerticalAlign.Top) .alignItems(VerticalAlign.Top)
@@ -2984,7 +2968,7 @@ struct DayTimelineView {
Column() Column()
.width('100%') .width('100%')
.height(this.hourUnit) .height(this.hourUnit)
.border({ width: { bottom: 0.5 }, color: { bottom: '#14000000' } }) .border({ width: { bottom: 0.5 }, color: { bottom: $r('app.color.grid_line') } })
.hitTestBehavior(HitTestMode.None) // 装饰层子节点同样不参与命中测试 .hitTestBehavior(HitTestMode.None) // 装饰层子节点同样不参与命中测试
}, (h: number) => `gd_${h}`) }, (h: number) => `gd_${h}`)
} }
+68 -2
View File
@@ -5,6 +5,7 @@ import { common } from '@kit.AbilityKit';
import { notificationManager } from '@kit.NotificationKit'; import { notificationManager } from '@kit.NotificationKit';
import { AppSettings } from '../common/AppSettings'; import { AppSettings } from '../common/AppSettings';
import { CardDataService } from '../common/CardDataService'; import { CardDataService } from '../common/CardDataService';
import { CalendarDataService } from '../common/CalendarDataService';
import { BackgroundSyncService } from '../common/BackgroundSyncService'; import { BackgroundSyncService } from '../common/BackgroundSyncService';
import { AccountStore, DavAccount } from '../common/AccountStore'; import { AccountStore, DavAccount } from '../common/AccountStore';
import { ReminderService } from '../common/ReminderService'; import { ReminderService } from '../common/ReminderService';
@@ -49,6 +50,15 @@ struct SettingsPage {
this.context = ctx; this.context = ctx;
AppSettings.getShowSystemCalendar(ctx).then((v: boolean): void => { AppSettings.getShowSystemCalendar(ctx).then((v: boolean): void => {
this.showSystem = v; 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 => { AppSettings.getSyncIntervalMinutes(ctx).then((v: number): void => {
this.intervalMinutes = v; this.intervalMinutes = v;
@@ -87,6 +97,27 @@ struct SettingsPage {
}); });
} }
/**
* 确认通知权限已开启 —— **只在用户主动使用依赖通知的功能时调用**
* (保存带提醒的日程 / 开启后台持续同步 / 点击设置页"去开启通知")。
* ⚠️ 严禁在 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 日历本(含探测到的写权限,只读/静音标记管理用) */ /** 加载全部 DAV 日历本(含探测到的写权限,只读/静音标记管理用) */
private async loadAllBooks(): Promise<void> { private async loadAllBooks(): Promise<void> {
if (this.context === undefined) { if (this.context === undefined) {
@@ -198,6 +229,18 @@ struct SettingsPage {
if (this.context === undefined) { if (this.context === undefined) {
return; return;
} }
// "备份到 CalDAV" 需要读取手机系统(本地)日历 → 只在用户主动选择该模式时申请权限
if (mode === 'backup') {
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; this.sysMode = mode;
await AppSettings.setSysCalMode(this.context, mode); await AppSettings.setSysCalMode(this.context, mode);
if (mode === 'backup' && this.backupKey === '') { if (mode === 'backup' && this.backupKey === '') {
@@ -223,9 +266,26 @@ struct SettingsPage {
.showToast({ message: '备份目标已更新,下次同步生效' }); .showToast({ message: '备份目标已更新,下次同步生效' });
} }
private async saveShowSystem(value: boolean): Promise<void> { if (this.context === undefined) { /**
* 保存「混合显示系统日历」开关。
* ⚠️ 合规约束(《审核指南》7.17):读取系统日历依赖"读取全部日程"敏感权限,
* **只有在用户主动打开该开关时才申请**;未授权则开关回弹、不保存设置。
*/
private async saveShowSystem(value: boolean): Promise<void> {
if (this.context === undefined) {
return; 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); await AppSettings.setShowSystemCalendar(this.context, value);
this.getUIContext().getPromptAction() this.getUIContext().getPromptAction()
.showToast({ message: value ? '已开启系统日历混合显示,返回首页生效' : '已关闭系统日历混合显示,返回首页生效' }); .showToast({ message: value ? '已开启系统日历混合显示,返回首页生效' : '已关闭系统日历混合显示,返回首页生效' });
@@ -245,6 +305,10 @@ struct SettingsPage {
if (this.context === undefined) { if (this.context === undefined) {
return; return;
} }
// 后台持续同步依赖常驻通知 → 在用户主动开启时才申请通知权限(不随启动自动弹窗)
if (value) {
await this.ensureNotifyPermission();
}
try { try {
await BackgroundSyncService.setBackgroundSync( await BackgroundSyncService.setBackgroundSync(
this.context as common.UIAbilityContext, value); this.context as common.UIAbilityContext, value);
@@ -447,7 +511,7 @@ struct SettingsPage {
.fontSize(15) .fontSize(15)
.fontWeight(FontWeight.Medium) .fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary')) .fontColor($r('app.color.text_primary'))
Text('选择手机系统日历日程的处理方式') Text('选择手机系统日历日程的处理方式(需授权读取系统日历)')
.fontSize(12) .fontSize(12)
.fontColor($r('app.color.text_secondary')) .fontColor($r('app.color.text_secondary'))
} }
@@ -1019,5 +1083,7 @@ struct SettingsPage {
.width('100%') .width('100%')
.height('100%') .height('100%')
.backgroundColor($r('app.color.page_bg')) .backgroundColor($r('app.color.page_bg'))
// 沉浸式:背景延伸到状态栏/导航条区域(只扩绘制、不移动子组件)
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
} }
} }
@@ -8,6 +8,7 @@
{ "name": "text_secondary", "value": "#66000000" }, { "name": "text_secondary", "value": "#66000000" },
{ "name": "text_hint", "value": "#4D000000" }, { "name": "text_hint", "value": "#4D000000" },
{ "name": "brand", "value": "#007DFF" }, { "name": "brand", "value": "#007DFF" },
{ "name": "brand_text", "value": "#0057C2" },
{ "name": "brand_disabled", "value": "#99007DFF" }, { "name": "brand_disabled", "value": "#99007DFF" },
{ "name": "button_text", "value": "#FFFFFF" }, { "name": "button_text", "value": "#FFFFFF" },
{ "name": "success", "value": "#00B34A" }, { "name": "success", "value": "#00B34A" },
@@ -0,0 +1,24 @@
{
"color": [
{ "name": "start_window_background", "value": "#000000" },
{ "name": "page_bg", "value": "#000000" },
{ "name": "card_bg", "value": "#1C1C1E" },
{ "name": "input_bg", "value": "#2C2C2E" },
{ "name": "text_primary", "value": "#E5E5E7" },
{ "name": "text_secondary", "value": "#99FFFFFF" },
{ "name": "text_hint", "value": "#66FFFFFF" },
{ "name": "brand", "value": "#0A84FF" },
{ "name": "brand_text", "value": "#4DA3FF" },
{ "name": "brand_disabled", "value": "#660A84FF" },
{ "name": "button_text", "value": "#FFFFFF" },
{ "name": "success", "value": "#34C759" },
{ "name": "success_bg", "value": "#1F34C759" },
{ "name": "error", "value": "#FF453A" },
{ "name": "error_bg", "value": "#1FFF453A" },
{ "name": "shadow_color", "value": "#1FFFFFFF" },
{ "name": "grid_line", "value": "#1FFFFFFF" },
{ "name": "today_bg", "value": "#330A84FF" },
{ "name": "selected_bg", "value": "#0A84FF" },
{ "name": "chip_off_bg", "value": "#1FFFFFFF" }
]
}