Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
165740fe9e | ||
|
|
69a4a86a04 | ||
|
|
d7c49db304 | ||
|
|
353473127a |
+2
-2
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user