增加了用户隐私协议等,增加了引导卡片。

Signed-off-by: Yang Yongquan <i@yangyq.net>
This commit is contained in:
2026-09-15 09:06:15 +08:00
parent a7503a7a1f
commit 088f7b3773
11 changed files with 663 additions and 73 deletions
+23 -1
View File
@@ -14,4 +14,26 @@
/local.properties /local.properties
/.clangd /.clangd
/.clang-format /.clang-format
/.clang-tidy /.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/
+2 -2
View File
@@ -2,8 +2,8 @@
"app": { "app": {
"bundleName": "synccalendar.yangyq.net", "bundleName": "synccalendar.yangyq.net",
"vendor": "yangyq", "vendor": "yangyq",
"versionCode": 1000000, "versionCode": 100,
"versionName": "1.0.0", "versionName": "0.0.1",
"buildVersion": "1", "buildVersion": "1",
"icon": "$media:layered_image", "icon": "$media:layered_image",
"label": "$string:app_name" "label": "$string:app_name"
-56
View File
@@ -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"
]
}
]
}
]
}
+63
View File
@@ -17,6 +17,69 @@ export class AppSettings {
private static readonly KEY_REMINDER_TICK: string = 'reminder_tick'; // 应用内提醒上次检查时间戳 private static readonly KEY_REMINDER_TICK: string = 'reminder_tick'; // 应用内提醒上次检查时间戳
private static readonly KEY_FULL_REFETCH: string = 'full_refetch_done'; // 一次性全量重拉已完成 private static readonly KEY_FULL_REFETCH: string = 'full_refetch_done'; // 一次性全量重拉已完成
private static readonly KEY_DEFAULT_VIEW: string = 'default_view'; // 打开 App 默认视图 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<boolean> {
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<void> {
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<boolean> {
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<void> {
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 */ /** 打开 App 后默认展示的视图:'month' | 'week' | 'agenda'(默认 month */
static async getDefaultView(context: common.Context): Promise<string> { static async getDefaultView(context: common.Context): Promise<string> {
+117
View File
@@ -0,0 +1,117 @@
// entry/src/main/ets/common/TipsPanel.ets
// 首启功能引导卡片:底部弹出,Swiper 三页,左下"上一步" / 右下"下一步"(末页变"开始使用")。
// 数据源 items 中图标为 media 下的 SVGtip_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'))
}
}
@@ -5,6 +5,7 @@ import { notificationManager } from '@kit.NotificationKit';
import { BusinessError } from '@kit.BasicServicesKit'; 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;
@@ -18,14 +19,22 @@ 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);
// 申请通知权限:日程提醒、后台同步常驻通知都依赖它(系统只弹一次授权框) // 通知权限:日程提醒、后台同步常驻通知都依赖它(系统只弹一次授权框)
notificationManager.isNotificationEnabled() // 必须在用户同意《隐私政策》之后才申请 —— 同意前不得申请任何权限;
.then((enabled: boolean): Promise<void> => { // 首次安装此处跳过,用户点"同意并继续"后由 Index.initPermissionAndLoad() 补申请。
if (!enabled) { AppSettings.getPolicyAgreed(this.context)
return notificationManager.requestEnableNotification(this.context) .then((agreed: boolean): Promise<void> => {
.catch((): void => {}); if (!agreed) {
LogUtil.write('未同意隐私政策:跳过通知权限申请');
return Promise.resolve();
} }
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 => {}); .catch((): void => {});
} }
+260 -7
View File
@@ -5,6 +5,7 @@ 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';
@@ -13,6 +14,7 @@ import { LunarUtil } from '../common/LunarUtil';
import { CardDataService } from '../common/CardDataService'; import { CardDataService } from '../common/CardDataService';
import { ReminderService } from '../common/ReminderService'; import { ReminderService } from '../common/ReminderService';
import { AppSettings } from '../common/AppSettings'; import { AppSettings } from '../common/AppSettings';
import { TipsPanel } from '../common/TipsPanel';
import { EventDb, LocalEvent, RemoteEvent } from '../common/EventDb'; import { EventDb, LocalEvent, RemoteEvent } from '../common/EventDb';
import { RruleUtil } from '../common/RruleUtil'; import { RruleUtil } from '../common/RruleUtil';
import { IcsUtil } from '../common/IcsUtil'; import { IcsUtil } from '../common/IcsUtil';
@@ -73,6 +75,12 @@ struct Index {
@State dayTimeline: DayTimeline = new DayTimeline(); // 选中日(月/周视图用) @State dayTimeline: DayTimeline = new DayTimeline(); // 选中日(月/周视图用)
@State agendaTimelines: AgendaTimelineGroup[] = []; // 列表视图:每天一条时间轴 @State agendaTimelines: AgendaTimelineGroup[] = []; // 列表视图:每天一条时间轴
// ---- 隐私政策与用户协议首启同意(未同意前不申请任何权限、不加载数据) ----
@State policyChecked: boolean = false; // 是否已读取过同意状态(避免首帧闪烁)
@State policyAgreed: boolean = false; // 是否已同意《隐私政策》与《用户协议》
private notifAsked: boolean = false; // 通知权限是否已申请(同意后才申请)
@State showTips: boolean = false; // 首启功能引导(整页覆盖层:遮罩 + 底部卡片 + Swiper 三页)
aboutToAppear(): void { aboutToAppear(): void {
const ctx = this.getUIContext().getHostContext(); const ctx = this.getUIContext().getHostContext();
if (ctx !== undefined) { if (ctx !== undefined) {
@@ -85,14 +93,17 @@ struct Index {
this.selectedDate = this.startOfDay(now.getTime()); this.selectedDate = this.startOfDay(now.getTime());
this.rebuildPages(); this.rebuildPages();
this.initLandscapeListener(); this.initLandscapeListener();
this.initPermissionAndLoad(); // 首启同意门禁:未同意《隐私政策》与《用户协议》前,不申请任何系统权限、不加载/同步数据(AGC 硬性要求)
// 读取"默认视图"设置:打开 App 后按用户选择展示(月/周/列表,默认月) if (ctx === undefined) {
if (ctx !== undefined) { this.policyChecked = true;
AppSettings.getDefaultView(ctx).then((v: string): void => { this.startApp(ctx);
this.mode = v; } else {
if (v === 'agenda') { AppSettings.getPolicyAgreed(ctx).then((ok: boolean): void => {
this.ensureAgendaData(); this.policyChecked = true;
if (ok) {
this.startApp(ctx);
} }
// 未同意:仅显示整页同意门禁(consentGate),不申请权限、不加载数据
}); });
} }
// 红线"播放"效果:每 30s 更新 nowMs,让当前时间红线随真实时间推进(同时刷新色块"进行中"标记) // 红线"播放"效果:每 30s 更新 nowMs,让当前时间红线随真实时间推进(同时刷新色块"进行中"标记)
@@ -129,6 +140,15 @@ struct Index {
/** 从设置页/账号页返回时刷新(同步间隔、系统日历开关立即生效),并处理编辑账号后的待同步 */ /** 从设置页/账号页返回时刷新(同步间隔、系统日历开关立即生效),并处理编辑账号后的待同步 */
onPageShow(): void { onPageShow(): void {
// 未同意《隐私政策》前不加载、不同步任何数据
if (!this.policyAgreed) {
return;
}
// 首启功能引导兜底:已同意用户若在 aboutToAppear 阶段漏触发,这里再补一次(幂等:已展示过会直接跳过)
const tipCtx = this.getUIContext().getHostContext();
if (tipCtx !== undefined) {
this.maybeShowTips(tipCtx);
}
// 卡片"添加"按钮深链:首次冷启动经 onCreate→AppStorage 落到这里消费 // 卡片"添加"按钮深链:首次冷启动经 onCreate→AppStorage 落到这里消费
this.consumeWidgetAction(); this.consumeWidgetAction();
// 无条件刷新:之前"列表为空就跳过"的条件会导致账号列表卡死在空状态, // 无条件刷新:之前"列表为空就跳过"的条件会导致账号列表卡死在空状态,
@@ -164,6 +184,7 @@ struct Index {
return; return;
} }
this.reloadAll(); this.reloadAll();
this.ensureNotificationPermission(context as common.UIAbilityContext);
if (!this.permissionAsked) { if (!this.permissionAsked) {
this.permissionAsked = true; 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<void> => {
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<void> {
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 { private startOfDay(ms: number): number {
const d = new Date(ms); const d = new Date(ms);
@@ -881,6 +997,23 @@ struct Index {
.onClick(() => { .onClick(() => {
this.addEvent(); 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%') .width('100%')
.height('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 —— 本环境从"启动时的异步回调"里弹出系统弹层不可靠
* (详见 MEMORYCustomDialogController 静默失败;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 @Builder
header() { header() {
Row({ space: 10 }) { Row({ space: 10 }) {
+118
View File
@@ -7,6 +7,7 @@ import { AppSettings } from '../common/AppSettings';
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';
import { TipsPanel } from '../common/TipsPanel';
const INTERVAL_OPTIONS: number[] = [1, 5, 15, 30, 60]; const INTERVAL_OPTIONS: number[] = [1, 5, 15, 30, 60];
@@ -32,6 +33,8 @@ struct SettingsPage {
@State notifyEnabled: boolean = true; // 通知权限状态(提醒依赖) @State notifyEnabled: boolean = true; // 通知权限状态(提醒依赖)
@State showFeatures: boolean = false; // 软件特性弹层 @State showFeatures: boolean = false; // 软件特性弹层
@State showHelp: boolean = false; // 使用帮助弹层 @State showHelp: boolean = false; // 使用帮助弹层
@State showLegal: boolean = false; // 隐私政策与用户协议弹层
@State showTips: boolean = false; // 功能引导卡片(底部弹出)
@State defaultView: string = 'month'; // 打开 App 默认视图:month | week | agenda @State defaultView: string = 'month'; // 打开 App 默认视图:month | week | agenda
private context?: common.Context; private context?: common.Context;
@@ -692,6 +695,78 @@ struct SettingsPage {
title: { title: '使用帮助' } 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 }) { Column({ space: 8 }) {
Row({ space: 10 }) { Row({ space: 10 }) {
@@ -774,6 +849,49 @@ struct SettingsPage {
.backgroundColor($r('app.color.page_bg')) .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 @Builder
featureSheet() { featureSheet() {
Scroll() { Scroll() {
@@ -0,0 +1,29 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" width="200" height="200">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#3AA0FF"/>
<stop offset="1" stop-color="#0057C8"/>
</linearGradient>
</defs>
<rect x="0" y="0" width="200" height="200" rx="44" fill="url(#bg)"/>
<!-- 日历本体 -->
<rect x="50" y="58" width="96" height="92" rx="14" fill="#FFFFFF"/>
<rect x="50" y="58" width="96" height="26" rx="14" fill="#007DFF"/>
<rect x="50" y="74" width="96" height="10" fill="#007DFF"/>
<rect x="74" y="50" width="8" height="14" rx="4" fill="#0057C8"/>
<rect x="118" y="50" width="8" height="14" rx="4" fill="#0057C8"/>
<!-- 网格点 -->
<g fill="#BBD9FF">
<circle cx="72" cy="104" r="5"/>
<circle cx="98" cy="104" r="5"/>
<circle cx="124" cy="104" r="5"/>
<circle cx="72" cy="126" r="5"/>
<circle cx="98" cy="126" r="5"/>
<circle cx="124" cy="126" r="5"/>
</g>
<!-- 同步圆环(白色,叠在蓝色角上,表示 CalDAV 同步) -->
<g transform="translate(150,148)">
<path d="M16 0 A16 16 0 1 1 0 -16" fill="none" stroke="#FFFFFF" stroke-width="9" stroke-linecap="round"/>
<path d="M0 -16 L-11 -13 L-2 -25 Z" fill="#FFFFFF"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" width="200" height="200">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#3AA0FF"/>
<stop offset="1" stop-color="#0057C8"/>
</linearGradient>
</defs>
<rect x="0" y="0" width="200" height="200" rx="44" fill="url(#bg)"/>
<!-- 日历本体 -->
<rect x="50" y="60" width="96" height="92" rx="14" fill="#FFFFFF"/>
<rect x="50" y="60" width="96" height="26" rx="14" fill="#007DFF"/>
<rect x="50" y="76" width="96" height="10" fill="#007DFF"/>
<rect x="74" y="52" width="8" height="14" rx="4" fill="#0057C8"/>
<rect x="118" y="52" width="8" height="14" rx="4" fill="#0057C8"/>
<!-- 铃铛(静音) -->
<path d="M100 92 C88 92 82 101 82 112 L82 120 L78 126 L122 126 L118 120 L118 112 C118 101 112 92 100 92 Z" fill="#007DFF"/>
<circle cx="100" cy="131" r="5" fill="#007DFF"/>
<!-- 静音斜杠(红) -->
<path d="M80 96 L120 136" stroke="#FF3B30" stroke-width="8" stroke-linecap="round"/>
<!-- 只读小锁(右上角,白色,叠在蓝色角上) -->
<rect x="150" y="50" width="16" height="13" rx="3" fill="#FFFFFF"/>
<path d="M153 50 v-5 a5 5 0 0 1 10 0 v5" fill="none" stroke="#FFFFFF" stroke-width="3"/>
<circle cx="158" cy="57" r="2.4" fill="#0057C8"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" width="200" height="200">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#3AA0FF"/>
<stop offset="1" stop-color="#0057C8"/>
</linearGradient>
</defs>
<rect x="0" y="0" width="200" height="200" rx="44" fill="url(#bg)"/>
<path d="M100 40 L150 62 V112 C150 142 128 162 100 172 C72 162 50 142 50 112 V62 Z" fill="#FFFFFF"/>
<path d="M82 106 L95 119 L122 86" fill="none" stroke="#007DFF" stroke-width="11" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 591 B