diff --git a/.gitignore b/.gitignore index ebca769..6946cd3 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,26 @@ /local.properties /.clangd /.clang-format -/.clang-tidy \ No newline at end of file +/.clang-tidy + +# 签名材料:私钥 / 证书 / Profile / 口令 —— 严禁入库 +/sign/ +/signature/ +*.p12 +*.cer +*.p7b +*.csr +*.keystore +*.jks +password.txt +*password*.txt + +# 仓库根目录的签名构建配置:含本机绝对路径与加密口令,机器相关,不入公共仓库 +# (仅锚定根目录;entry/build-profile.json5 是普通模块构建配置,仍需入库) +/build-profile.json5 + +# hvigor 本地引擎 / 插件:构建期从 DevEco 恢复进来的机器相关副本,不入公共仓库 +# 注意 /hvigor/hvigor-config.json5 是项目构建配置,仍需入库 +/hvigor/* +!/hvigor/hvigor-config.json5 +/hvigor-ohos-plugin/ \ No newline at end of file diff --git a/AppScope/app.json5 b/AppScope/app.json5 index 3cf6e5c..bf6f90d 100644 --- a/AppScope/app.json5 +++ b/AppScope/app.json5 @@ -2,8 +2,8 @@ "app": { "bundleName": "synccalendar.yangyq.net", "vendor": "yangyq", - "versionCode": 1000000, - "versionName": "1.0.0", + "versionCode": 100, + "versionName": "0.0.1", "buildVersion": "1", "icon": "$media:layered_image", "label": "$string:app_name" diff --git a/build-profile.json5 b/build-profile.json5 deleted file mode 100644 index 838563d..0000000 --- a/build-profile.json5 +++ /dev/null @@ -1,56 +0,0 @@ -{ - "app": { - "signingConfigs": [ - { - "name": "default", - "type": "HarmonyOS", - "material": { - "certpath": "C:\\Users\\Yongquan\\.ohos\\config\\default_SyncCalendar_0sxf9mFmzO-SlvjrtFdry7WYo0XNlBNQv53r6bYgUXU=.cer", - "keyAlias": "debugKey", - "keyPassword": "0000001B2A717AAE3B463934D7117A852F00EE5A851BE39B576A32220F81D791848450D464F2E017471F64", - "profile": "C:\\Users\\Yongquan\\.ohos\\config\\default_SyncCalendar_0sxf9mFmzO-SlvjrtFdry7WYo0XNlBNQv53r6bYgUXU=.p7b", - "signAlg": "SHA256withECDSA", - "storeFile": "C:\\Users\\Yongquan\\.ohos\\config\\default_SyncCalendar_0sxf9mFmzO-SlvjrtFdry7WYo0XNlBNQv53r6bYgUXU=.p12", - "storePassword": "0000001B354C1491B727A5491186A36C464DDCD646FA5ECCF62F29785D3444143FDD089E52B2A4359B8F4F" - } - } - ], - "products": [ - { - "name": "default", - "signingConfig": "default", - "targetSdkVersion": "26.0.0", - "compatibleSdkVersion": "6.1.1(24)", - "runtimeOS": "HarmonyOS", - "buildOption": { - "strictMode": { - "caseSensitiveCheck": true, - "useNormalizedOHMUrl": true - } - } - } - ], - "buildModeSet": [ - { - "name": "debug", - }, - { - "name": "release" - } - ] - }, - "modules": [ - { - "name": "entry", - "srcPath": "./entry", - "targets": [ - { - "name": "default", - "applyToProducts": [ - "default" - ] - } - ] - } - ] -} \ No newline at end of file diff --git a/entry/src/main/ets/common/AppSettings.ets b/entry/src/main/ets/common/AppSettings.ets index 9efe4f5..f434cec 100644 --- a/entry/src/main/ets/common/AppSettings.ets +++ b/entry/src/main/ets/common/AppSettings.ets @@ -17,6 +17,69 @@ export class AppSettings { private static readonly KEY_REMINDER_TICK: string = 'reminder_tick'; // 应用内提醒上次检查时间戳 private static readonly KEY_FULL_REFETCH: string = 'full_refetch_done'; // 一次性全量重拉已完成 private static readonly KEY_DEFAULT_VIEW: string = 'default_view'; // 打开 App 默认视图 + private static readonly KEY_POLICY_AGREED: string = 'policy_agreed'; // 是否已同意隐私政策与用户协议 + // 首启功能引导"用户已看过并关闭"的标记。键名带版本号:改动引导内容后把 vN 加 1,用户即可再看一次。 + // 注意:只在用户主动关闭("开始使用" / ✕ / 点遮罩)时才置 true,**不再"显示前就置 true"**—— + // 否则一旦某次因故没显示出来,旧标记会把功能永久锁死(v2 正是这样被锁住 → 本次升到 v3)。 + private static readonly KEY_TIPS_SHOWN: string = 'tips_shown_v3'; + + /** 隐私政策网址(华为 AGC 隐私政策托管服务生成,上架时填此地址) */ + static readonly PRIVACY_URL: string = + 'https://agreement-drcn.hispace.dbankcloud.cn/index.html?lang=zh&agreementId=2039401852717510592'; + /** 用户服务协议网址 */ + static readonly AGREEMENT_URL: string = 'https://synccalendar.yangyq.net/usa.html'; + + /** + * 是否已同意《隐私政策》与《用户协议》(首启同意弹窗)。 + * 默认 false —— 未同意前不得申请任何系统权限、不得加载/同步数据。 + */ + static async getPolicyAgreed(context: common.Context): Promise { + try { + const store: preferences.Preferences = + await preferences.getPreferences(context, AppSettings.STORE); + return await store.get(AppSettings.KEY_POLICY_AGREED, false) as boolean; + } catch (err) { + return false; + } + } + + static async setPolicyAgreed(context: common.Context, value: boolean): Promise { + try { + const store: preferences.Preferences = + await preferences.getPreferences(context, AppSettings.STORE); + await store.put(AppSettings.KEY_POLICY_AGREED, value); + await store.flush(); + } catch (err) { + const e = err as BusinessError; + console.error(`保存隐私政策同意状态失败: ${e.message}`); + } + } + + /** + * 首启功能引导卡片是否已展示过(避免每次启动都弹)。 + * 默认 false —— 启动(首次同意后 / 已同意用户再次启动)时若为 false 会自动弹出一次并置为 true。 + */ + static async getTipsShown(context: common.Context): Promise { + try { + const store: preferences.Preferences = + await preferences.getPreferences(context, AppSettings.STORE); + return await store.get(AppSettings.KEY_TIPS_SHOWN, false) as boolean; + } catch (err) { + return false; + } + } + + static async setTipsShown(context: common.Context, value: boolean): Promise { + try { + const store: preferences.Preferences = + await preferences.getPreferences(context, AppSettings.STORE); + await store.put(AppSettings.KEY_TIPS_SHOWN, value); + await store.flush(); + } catch (err) { + const e = err as BusinessError; + console.error(`保存功能引导展示状态失败: ${e.message}`); + } + } /** 打开 App 后默认展示的视图:'month' | 'week' | 'agenda'(默认 month) */ static async getDefaultView(context: common.Context): Promise { diff --git a/entry/src/main/ets/common/TipsPanel.ets b/entry/src/main/ets/common/TipsPanel.ets new file mode 100644 index 0000000..b0bd617 --- /dev/null +++ b/entry/src/main/ets/common/TipsPanel.ets @@ -0,0 +1,117 @@ +// entry/src/main/ets/common/TipsPanel.ets +// 首启功能引导卡片:底部弹出,Swiper 三页,左下"上一步" / 右下"下一步"(末页变"开始使用")。 +// 数据源 items 中图标为 media 下的 SVG(tip_security / tip_calendar / tip_mute)。 +// 同时被首页(首启自动弹出)与设置页(手动 revisar)复用。 +import { AppSettings } from './AppSettings'; + +/** 单张引导卡的数据 */ +interface TipItem { + icon: Resource; // app.media 下的 SVG 矢量图标 + title: string; + desc: string; +} + +@Component +export struct TipsPanel { + @State current: number = 0; + private controller: SwiperController = new SwiperController(); + /** 末页点击"开始使用"时回调(由宿主关闭弹层) */ + onClose: () => void = () => {}; + + private items: TipItem[] = [ + { + icon: $r('app.media.tip_security'), + title: '你的数据安全,由你做主', + desc: '我们不在服务器保存任何日程或账号数据。日历只在你的设备与你自己填写的 CalDAV 服务器之间同步,' + + '全程加密保存。开源、无后门,数据始终属于你。' + }, + { + icon: $r('app.media.tip_calendar'), + title: '系统日历,也能一起管', + desc: '手机系统日历可与 CalDAV 日历混合显示在同一时间轴;还能把系统日历本通过 CalDAV 同步备份,' + + '一处编辑、处处更新。' + }, + { + icon: $r('app.media.tip_mute'), + title: '只读与静音,自由设置', + desc: '每个日历本都能单独设为只读或静音:只读日历不出现在新建选择中、避免误改;' + + '静音日历照常显示,但不再推送提醒。' + } + ]; + + build() { + Column({ space: 0 }) { + Swiper(this.controller) { + ForEach(this.items, (item: TipItem, idx: number) => { + Column({ space: 16 }) { + Image(item.icon) + .width(140) + .height(140) + .objectFit(ImageFit.Contain) + .margin({ top: 24, bottom: 8 }) + Text(item.title) + .fontSize(19) + .fontWeight(FontWeight.Bold) + .fontColor($r('app.color.text_primary')) + .margin({ bottom: 8 }) + Text(item.desc) + .fontSize(14) + .fontColor($r('app.color.text_secondary')) + .textAlign(TextAlign.Center) + .width('100%') + .lineHeight(22) + .padding({ left: 28, right: 28 }) + } + .width('100%') + .height('100%') + .justifyContent(FlexAlign.Center) + .alignItems(HorizontalAlign.Center) + }, (item: TipItem, idx: number) => `tip_${idx}`) + } + .width('100%') + .layoutWeight(1) + .indicator(new DotIndicator().selectedColor('#007DFF').color('#CFD8E3').itemWidth(6).itemHeight(6)) + .loop(false) + .onChange((index: number): void => { + this.current = index; + }) + + // 底部导航:左下角"上一步" / 右下角"下一步"(末页变"开始使用") + Row() { + Button('上一步') + .fontSize(15) + .width(96) + .height(42) + .backgroundColor($r('app.color.card_bg')) + .fontColor(this.current === 0 ? $r('app.color.text_hint') : $r('app.color.text_primary')) + .enabled(this.current > 0) + .onClick(() => { + if (this.current > 0) { + this.current -= 1; + this.controller.changeIndex(this.current, false); + } + }) + Button(this.current >= this.items.length - 1 ? '开始使用' : '下一步') + .fontSize(15) + .width(96) + .height(42) + .backgroundColor($r('app.color.brand')) + .fontColor($r('app.color.button_text')) + .onClick(() => { + if (this.current >= this.items.length - 1) { + this.onClose(); + } else { + this.current += 1; + this.controller.changeIndex(this.current, false); + } + }) + } + .width('100%') + .justifyContent(FlexAlign.SpaceBetween) + .padding({ left: 20, right: 20, top: 12, bottom: 20 }) + } + .width('100%') + .height('100%') + .backgroundColor($r('app.color.page_bg')) + } +} diff --git a/entry/src/main/ets/entryability/EntryAbility.ets b/entry/src/main/ets/entryability/EntryAbility.ets index 6845c2e..7edd9b9 100644 --- a/entry/src/main/ets/entryability/EntryAbility.ets +++ b/entry/src/main/ets/entryability/EntryAbility.ets @@ -5,6 +5,7 @@ import { notificationManager } from '@kit.NotificationKit'; import { BusinessError } from '@kit.BasicServicesKit'; import { BackgroundSyncService } from '../common/BackgroundSyncService'; import { LogUtil } from '../common/LogUtil'; +import { AppSettings } from '../common/AppSettings'; const DOMAIN = 0x0000; @@ -18,14 +19,22 @@ export default class EntryAbility extends UIAbility { BackgroundSyncService.initOnLaunch(this.context); // 服务卡片"添加"按钮跳转:把 widgetAction=add 透传给主页面(在 AppStorage 中暂存) this.handleWidgetParam(want); - // 申请通知权限:日程提醒、后台同步常驻通知都依赖它(系统只弹一次授权框) - notificationManager.isNotificationEnabled() - .then((enabled: boolean): Promise => { - if (!enabled) { - return notificationManager.requestEnableNotification(this.context) - .catch((): void => {}); + // 通知权限:日程提醒、后台同步常驻通知都依赖它(系统只弹一次授权框)。 + // 必须在用户同意《隐私政策》之后才申请 —— 同意前不得申请任何权限; + // 首次安装此处跳过,用户点"同意并继续"后由 Index.initPermissionAndLoad() 补申请。 + AppSettings.getPolicyAgreed(this.context) + .then((agreed: boolean): Promise => { + if (!agreed) { + LogUtil.write('未同意隐私政策:跳过通知权限申请'); + return Promise.resolve(); } - return Promise.resolve(); + return notificationManager.isNotificationEnabled() + .then((enabled: boolean): Promise => { + if (!enabled) { + return notificationManager.requestEnableNotification(this.context).catch((): void => {}); + } + return Promise.resolve(); + }); }) .catch((): void => {}); } diff --git a/entry/src/main/ets/pages/Index.ets b/entry/src/main/ets/pages/Index.ets index ed18cd5..8d56f9d 100644 --- a/entry/src/main/ets/pages/Index.ets +++ b/entry/src/main/ets/pages/Index.ets @@ -5,6 +5,7 @@ import { mediaquery, router } from '@kit.ArkUI'; import { common, abilityAccessCtrl, bundleManager } from '@kit.AbilityKit'; import { geoLocationManager } from '@kit.LocationKit'; import { BusinessError, pasteboard } from '@kit.BasicServicesKit'; +import { notificationManager } from '@kit.NotificationKit'; import { DavAccount, AccountStore, CalSource, TYPE_CALDAV } from '../common/AccountStore'; import { DisplayEvent, CalendarDataService } from '../common/CalendarDataService'; import { SyncEngine } from '../common/SyncEngine'; @@ -13,6 +14,7 @@ import { LunarUtil } from '../common/LunarUtil'; import { CardDataService } from '../common/CardDataService'; import { ReminderService } from '../common/ReminderService'; import { AppSettings } from '../common/AppSettings'; +import { TipsPanel } from '../common/TipsPanel'; import { EventDb, LocalEvent, RemoteEvent } from '../common/EventDb'; import { RruleUtil } from '../common/RruleUtil'; import { IcsUtil } from '../common/IcsUtil'; @@ -73,6 +75,12 @@ struct Index { @State dayTimeline: DayTimeline = new DayTimeline(); // 选中日(月/周视图用) @State agendaTimelines: AgendaTimelineGroup[] = []; // 列表视图:每天一条时间轴 + // ---- 隐私政策与用户协议首启同意(未同意前不申请任何权限、不加载数据) ---- + @State policyChecked: boolean = false; // 是否已读取过同意状态(避免首帧闪烁) + @State policyAgreed: boolean = false; // 是否已同意《隐私政策》与《用户协议》 + private notifAsked: boolean = false; // 通知权限是否已申请(同意后才申请) + @State showTips: boolean = false; // 首启功能引导(整页覆盖层:遮罩 + 底部卡片 + Swiper 三页) + aboutToAppear(): void { const ctx = this.getUIContext().getHostContext(); if (ctx !== undefined) { @@ -85,14 +93,17 @@ struct Index { this.selectedDate = this.startOfDay(now.getTime()); this.rebuildPages(); this.initLandscapeListener(); - this.initPermissionAndLoad(); - // 读取"默认视图"设置:打开 App 后按用户选择展示(月/周/列表,默认月) - if (ctx !== undefined) { - AppSettings.getDefaultView(ctx).then((v: string): void => { - this.mode = v; - if (v === 'agenda') { - this.ensureAgendaData(); + // 首启同意门禁:未同意《隐私政策》与《用户协议》前,不申请任何系统权限、不加载/同步数据(AGC 硬性要求) + if (ctx === undefined) { + this.policyChecked = true; + this.startApp(ctx); + } else { + AppSettings.getPolicyAgreed(ctx).then((ok: boolean): void => { + this.policyChecked = true; + if (ok) { + this.startApp(ctx); } + // 未同意:仅显示整页同意门禁(consentGate),不申请权限、不加载数据 }); } // 红线"播放"效果:每 30s 更新 nowMs,让当前时间红线随真实时间推进(同时刷新色块"进行中"标记) @@ -129,6 +140,15 @@ struct Index { /** 从设置页/账号页返回时刷新(同步间隔、系统日历开关立即生效),并处理编辑账号后的待同步 */ onPageShow(): void { + // 未同意《隐私政策》前不加载、不同步任何数据 + if (!this.policyAgreed) { + return; + } + // 首启功能引导兜底:已同意用户若在 aboutToAppear 阶段漏触发,这里再补一次(幂等:已展示过会直接跳过) + const tipCtx = this.getUIContext().getHostContext(); + if (tipCtx !== undefined) { + this.maybeShowTips(tipCtx); + } // 卡片"添加"按钮深链:首次冷启动经 onCreate→AppStorage 落到这里消费 this.consumeWidgetAction(); // 无条件刷新:之前"列表为空就跳过"的条件会导致账号列表卡死在空状态, @@ -164,6 +184,7 @@ struct Index { return; } this.reloadAll(); + this.ensureNotificationPermission(context as common.UIAbilityContext); if (!this.permissionAsked) { this.permissionAsked = true; // 申请读取全部日历权限(用于混合展示系统日程) @@ -174,6 +195,101 @@ struct Index { } } + /** + * 申请通知权限(日程提醒、后台同步常驻通知都依赖它)。 + * 必须在用户同意《隐私政策》之后才调用 —— 同意前不得申请任何权限。 + */ + private ensureNotificationPermission(context: common.UIAbilityContext): void { + if (this.notifAsked) { + return; + } + this.notifAsked = true; + notificationManager.isNotificationEnabled() + .then((enabled: boolean): Promise => { + if (!enabled) { + return notificationManager.requestEnableNotification(context).catch((): void => {}); + } + return Promise.resolve(); + }) + .catch((): void => {}); + } + + /** + * 同意之后(或"已同意用户"启动时)的正式初始化: + * 申请权限、加载数据、读取默认视图,并首启弹一次功能引导卡片。 + * 首次同意(acceptPolicy)与已同意直接启动(aboutToAppear)都走这里,保证两条路径都能弹出引导。 + */ + private startApp(ctx: common.Context | undefined): void { + this.policyAgreed = true; + this.initPermissionAndLoad(); + if (ctx !== undefined) { + // 读取"默认视图"设置:打开 App 后按用户选择展示(月/周/列表,默认月) + AppSettings.getDefaultView(ctx).then((v: string): void => { + this.mode = v; + if (v === 'agenda') { + this.ensureAgendaData(); + } + }); + this.maybeShowTips(ctx); + } + } + + /** + * 首启功能引导:仅在用户从未看过时显示一次(整页覆盖层),之后不再出现。 + * 注意:这里**不写"已看过"标记** —— 标记由 hideTips() 在用户主动关闭时写入。 + * 若在此处提前写标记,一旦显示环节出问题就会把引导永久锁死(历史踩过坑)。 + */ + private maybeShowTips(ctx: common.Context): void { + AppSettings.getTipsShown(ctx).then((seen: boolean): void => { + if (seen) { + return; + } + // 略延迟:先让主页面完成首帧布局,并尽量错开紧随其后的权限申请系统弹窗 + setTimeout((): void => { + this.showTips = true; + }, 600); + }); + } + + /** 关闭功能引导覆盖层("开始使用" / ✕ / 点遮罩 均走这里),此刻才标记"已看过" */ + private hideTips(): void { + this.showTips = false; + const ctx = this.getUIContext().getHostContext(); + if (ctx !== undefined) { + AppSettings.setTipsShown(ctx, true); + } + } + + /** 用户点击"同意并继续":持久化同意标记,之后才申请权限、加载数据 */ + private acceptPolicy(): void { + const ctx = this.getUIContext().getHostContext(); + if (ctx !== undefined) { + AppSettings.setPolicyAgreed(ctx, true); + } + this.startApp(ctx); + } + + /** 用户点击"不同意并退出":不申请任何权限、不处理任何数据,直接退出应用 */ + private rejectPolicy(): void { + const ctx = this.getUIContext().getHostContext(); + if (ctx !== undefined) { + (ctx as common.UIAbilityContext).terminateSelf(); + } + } + + /** 打开外部链接(隐私政策 / 用户协议 / 开源仓库等) */ + private async openExternalUrl(url: string): Promise { + const ctx = this.getUIContext().getHostContext(); + if (ctx === undefined) { + return; + } + try { + await (ctx as common.UIAbilityContext).openLink(url); + } catch (err) { + this.getUIContext().getPromptAction().showToast({ message: '无法打开链接' }); + } + } + // ---------- 工具 ---------- private startOfDay(ms: number): number { const d = new Date(ms); @@ -881,6 +997,23 @@ struct Index { .onClick(() => { this.addEvent(); }) + + // 未同意《隐私政策》与《用户协议》:整页门禁盖在最上层(不申请权限、不加载数据) + if (!this.policyChecked) { + // 读取同意状态期间的占位(极短),避免闪一下主界面或门禁页 + Column() { + } + .width('100%') + .height('100%') + .backgroundColor($r('app.color.page_bg')) + } else if (!this.policyAgreed) { + this.consentGate() + } + + // 功能引导:整页覆盖层,放根 Stack 最后一层(盖住主界面与悬浮按钮) + if (this.showTips) { + this.tipsOverlay() + } } .width('100%') .height('100%') @@ -892,6 +1025,126 @@ struct Index { }) } + /** + * 首启隐私政策同意门禁(整页渲染,不用弹窗 —— 弹窗在本环境实测无法弹出)。 + * AGC 要求:明确同意前不得申请权限、不得收集/处理个人信息,故未同意时整页挡住主界面。 + */ + @Builder + consentGate() { + Column({ space: 14 }) { + Text('隐私政策与用户协议') + .fontSize(20) + .fontWeight(FontWeight.Bold) + .fontColor($r('app.color.text_primary')) + .width('100%') + .margin({ top: 40 }) + Text('欢迎使用「同步日历」。在使用前,请阅读并同意以下条款。') + .fontSize(14) + .fontColor($r('app.color.text_primary')) + .width('100%') + Text('我们不会保存你的日程与账号数据:日历仅在你的设备与你自己填写的 CalDAV 服务器之间同步;位置信息仅在你点击“导航”时用于调用第三方地图,不会上传。') + .fontSize(13) + .fontColor($r('app.color.text_secondary')) + .width('100%') + Button('《隐私政策》') + .width('100%') + .fontSize(14) + .backgroundColor($r('app.color.card_bg')) + .fontColor($r('app.color.brand')) + .onClick(() => { + this.openExternalUrl(AppSettings.PRIVACY_URL); + }) + Button('《用户服务协议》') + .width('100%') + .fontSize(14) + .backgroundColor($r('app.color.card_bg')) + .fontColor($r('app.color.brand')) + .onClick(() => { + this.openExternalUrl(AppSettings.AGREEMENT_URL); + }) + Blank() + Text('点击“同意并继续”,即表示你已阅读并同意上述两份文件。') + .fontSize(12) + .fontColor($r('app.color.text_hint')) + .width('100%') + Row({ space: 10 }) { + Button('不同意并退出') + .fontSize(15) + .layoutWeight(1) + .backgroundColor($r('app.color.card_bg')) + .fontColor($r('app.color.text_secondary')) + .onClick(() => { + this.rejectPolicy(); + }) + Button('同意并继续') + .fontSize(15) + .layoutWeight(1) + .backgroundColor($r('app.color.brand')) + .onClick(() => { + this.acceptPolicy(); + }) + } + .width('100%') + .margin({ bottom: 24 }) + } + .width('100%') + .height('100%') + .padding({ left: 24, right: 24, top: 20, bottom: 12 }) + .backgroundColor($r('app.color.page_bg')) + } + + /** + * 首启功能引导:整页覆盖层(半透明遮罩 + 底部卡片),由 TipsPanel 提供 Swiper 三页与 + * 左下"上一步" / 右下"下一步"。 + * 用页面内容渲染而非 bindSheet/CustomDialog —— 本环境从"启动时的异步回调"里弹出系统弹层不可靠 + * (详见 MEMORY:CustomDialogController 静默失败;bindSheet 同样实测弹不出来)。 + */ + @Builder + tipsOverlay() { + Column() { + // 上部留白:露出被遮罩压暗的主界面,制造"从下方弹出"的层次(点空白处也可关闭) + Column() { + } + .width('100%') + .layoutWeight(1) + .onClick((): void => { + this.hideTips(); + }) + + // 底部引导卡片 + Column() { + // 右上角关闭 + Row() { + Blank() + Text('✕') + .fontSize(16) + .fontColor($r('app.color.text_hint')) + .padding({ left: 10, right: 10, top: 6, bottom: 6 }) + .onClick((): void => { + this.hideTips(); + }) + } + .width('100%') + .padding({ left: 12, right: 12, top: 8 }) + + Column() { + TipsPanel({ onClose: (): void => { this.hideTips(); } }) + } + .width('100%') + .layoutWeight(1) + } + .width('100%') + .height('70%') + .backgroundColor($r('app.color.page_bg')) + .borderRadius({ topLeft: 20, topRight: 20 }) + .clip(true) + .shadow({ radius: 16, color: $r('app.color.shadow_color'), offsetX: 0, offsetY: -2 }) + } + .width('100%') + .height('100%') + .backgroundColor('#66000000') + } + @Builder header() { Row({ space: 10 }) { diff --git a/entry/src/main/ets/pages/SettingsPage.ets b/entry/src/main/ets/pages/SettingsPage.ets index 7f7526a..ea79c4f 100644 --- a/entry/src/main/ets/pages/SettingsPage.ets +++ b/entry/src/main/ets/pages/SettingsPage.ets @@ -7,6 +7,7 @@ import { AppSettings } from '../common/AppSettings'; import { BackgroundSyncService } from '../common/BackgroundSyncService'; import { AccountStore, DavAccount } from '../common/AccountStore'; import { ReminderService } from '../common/ReminderService'; +import { TipsPanel } from '../common/TipsPanel'; const INTERVAL_OPTIONS: number[] = [1, 5, 15, 30, 60]; @@ -32,6 +33,8 @@ struct SettingsPage { @State notifyEnabled: boolean = true; // 通知权限状态(提醒依赖) @State showFeatures: boolean = false; // 软件特性弹层 @State showHelp: boolean = false; // 使用帮助弹层 + @State showLegal: boolean = false; // 隐私政策与用户协议弹层 + @State showTips: boolean = false; // 功能引导卡片(底部弹出) @State defaultView: string = 'month'; // 打开 App 默认视图:month | week | agenda private context?: common.Context; @@ -692,6 +695,78 @@ struct SettingsPage { title: { title: '使用帮助' } }) + // 功能引导(首启引导卡片,可在设置里再看一次;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.showTips = true; + }) + .bindSheet($$this.showTips, this.tipsSheet(), { + height: '72%', + dragBar: true, + showClose: true, + title: { title: '功能引导' } + }) + + // 隐私政策与用户协议(点击弹层;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 }) { @@ -774,6 +849,49 @@ struct SettingsPage { .backgroundColor($r('app.color.page_bg')) } + /** 功能引导卡片(与首页共用 TipsPanel) */ + @Builder + tipsSheet() { + TipsPanel({ onClose: (): void => { this.showTips = false; } }) + } + + /** 隐私政策与用户协议弹层 */ + @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.openUrl(AppSettings.PRIVACY_URL); + }) + Button('查看《用户服务协议》') + .width('100%') + .fontSize(15) + .backgroundColor($r('app.color.brand')) + .onClick(() => { + this.openUrl(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')) + } + @Builder featureSheet() { Scroll() { diff --git a/entry/src/main/resources/base/media/tip_calendar.svg b/entry/src/main/resources/base/media/tip_calendar.svg new file mode 100644 index 0000000..3e77f02 --- /dev/null +++ b/entry/src/main/resources/base/media/tip_calendar.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/entry/src/main/resources/base/media/tip_mute.svg b/entry/src/main/resources/base/media/tip_mute.svg new file mode 100644 index 0000000..ca6f4a0 --- /dev/null +++ b/entry/src/main/resources/base/media/tip_mute.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/entry/src/main/resources/base/media/tip_security.svg b/entry/src/main/resources/base/media/tip_security.svg new file mode 100644 index 0000000..a761afe --- /dev/null +++ b/entry/src/main/resources/base/media/tip_security.svg @@ -0,0 +1,11 @@ + + + + + + + + + + +