10 Commits
Author SHA1 Message Date
laoyang d993dd3d4b 移除定位权限:地理编码不需要 LOCATION(AGC 隐私标签审核)
AGC 以「未如实填写隐私标签」退回本次提审。核查后发现根因是
申请了两个用不到的敏感权限,与隐私标签无法自洽。

依据(本机 SDK API 26,targetSdk 26.0.0):
- @ohos.geoLocationManager.d.ts 中 getCurrentLocation / getLastLocation /
  on('locationChange') 均有 @permission APPROXIMATELY_LOCATION;
- 而 getAddressesFromLocationName(地址→坐标)与 getAddressesFromLocation
  (坐标→地址)**没有 @permission**,只有 @syscap ...Location.Geocoder。
- 本应用从不调用 getCurrentLocation,本就不读设备实时位置,只在「一键导航」
  时把用户填写的地址换算为坐标。

改动:
- module.json5:删除 LOCATION、APPROXIMATELY_LOCATION(现仅 5 个权限)
- Index.ets:删除 ensureLocationPermission(),openInAmap() 直接调
  geocodeAddress();同步清掉随之无用的 abilityAccessCtrl / bundleManager 导入
- string.json:删除 perm_location、perm_location_approx
- AppSettings.ets:更新权限一致性注释,警示后续勿再照旧文档写错政策

验证:assembleHap BUILD SUCCESSFUL;解包 hap 与 .app 内 module.json 均只剩
5 个权限、LOCATION 零命中;真机 hdc install -r 成功,aa start 无崩溃。

注意:华为旧文档 @ohos.geolocation(API 9 已废弃)仍写"需要权限 LOCATION",
那是老接口,勿据此判断——应以本机 .d.ts 为准。
2026-10-28 23:14:08 +08:00
laoyang b7bc4b5403 chore: 版本号 0.0.9 -> 0.1.0(versionCode 10000)
0.0.9 已上架,开启 0.1.0 开发周期。
versionCode 按 major*1e6 + minor*1e4 + patch*100 计 = 10000。

同步更新官网 deploy/site:index.html / sf.html / uh.html 的版本标识,
_404.tpl.html 改后重跑 _gen404.py 重新生成 404.html。
2026-10-21 14:12:52 +08:00
laoyang 96335728f5 feat(编辑页): 必填项校验失败改为强提醒(Toast + 字段标红 + 滚回定位)
原来校验失败只写 statusMsg,而它渲染在表单最底部("删除日程"按钮上方)。
用户在顶部点"保存"时那行红字远在屏幕之外,观感就是"点了没反应"。

新增 showFormError(msg, field) 作为唯一错误出口,同时做三件事:
- Toast:系统级浮层,用 bottom:'70%' 抬到屏幕偏上方,不受滚动位置影响,必定可见;
- 字段标红:标题输入框 / 时间卡片 / 日历本区域加 error 色边框,并在紧邻位置给出红字说明;
- 滚回出错位置:scrollEdge(Top) + 对标题 requestFocus('titleInput')。

配套:
- 标题 placeholder 改为「标题(必填)」,静态说明必填属性;
- 用户开始输入标题 / 选中日历本后自动撤掉红框与状态行,不会"改了还一直红着";
- 日历本未选也走 showFormError(此前只写状态行);
- 底部状态行仅在 errorField==='' 时显示,避免与就地提示重复;
- 保存异常分支补 Toast:该分支紧接着 router.back(),状态行来不及被看到。
2026-10-21 10:17:46 +08:00
laoyang a32c049199 fix(地点): 候选改为 bindPopup 浮层,根治输入框跳动与选完复现
旧方案把候选做成内联列表,虽放在输入框上方,但:
  1. 候选高度变化(搜索中 / 出结果 / 条数变化)会改变表单高度,输入框随之反复移动,
     再靠 scrollBy 事后纠正,表现为输入框来回跳好几次;
  2. 选中候选后 applyPlace 程序回写 text 会触发 TextInput.onChange,
     把 placePicked 重置为 false 并重新发起搜索,导致候选框消失后又冒出来一次。

改为:
- 候选用 .bindPopup($$placeOpen, { placement: Top }) 挂在输入框上,
  不参与布局,出现与消失完全不移动输入框;
- 移除全部 scrollBy 对齐逻辑(keyboardPadVp / ensurePlaceVisible / 键盘监听),
  键盘避让交由 KeyboardAvoidMode.RESIZE 的默认光标可见行为;
- 用 lastAppliedText 精确识别「程序回写 text 造成的 onChange」,不再发搜索;
- runSuggest 期间浮层持续展开(搜索中 → 结果),避免收起再弹造成闪烁;
- 抽出 closePlacePopup() 作为统一收起入口。
2026-10-21 09:03:31 +08:00
laoyang 6ab7bcfb76 fix(地点): 修掉 px/vp 单位混用的对齐 bug,输入框稳定钉在键盘上方,候选向上生长
- ensurePlaceVisible: getRectangleById 返回 px,scrollBy 收 vp,必须换算(上一版输入框被推飞的真因)
- 去掉 keyboardPadVp 动态底部留白:数学上不需要,只会在手动滚到底时露出大块空白
- 聚焦期间候选条数变化(加载中/有结果/无结果)每次都重新钉一次,避免输入框上下跳
- 提交请求前先收起旧候选,统一由一次对齐收口
- 新增 onBlur:失焦即收起候选,不留残影
2026-10-21 08:52:13 +08:00
laoyang 8e0c08ccfb 修复:地点候选列表被软键盘挡住
现象:地点输入框在表单第 7 位(标题/全天/时间/重复/提醒/日历本之后),
键盘弹起后候选列表正好落在键盘后面,看不到也点不到。

根因:ArkUI 默认 KeyboardAvoidMode.OFFSET 只保证"光标可见",
它不会为输入框下方新插入的候选列表预留空间;页面也不主动滚动。

修法(三层):
1) setKeyboardAvoidMode(RESIZE):把页面高度压到键盘上沿,
   Scroll 可视区随之变矮(OFFSET 是整页上移,会把表单底部顶出屏幕)。
2) 主动滚动 ensurePlaceVisible():用 getComponentUtils().getRectangleById()
   实测「输入框行顶边 + 行高 + 间距 + 候选列表高度」得到整块底边,
   与「窗口高度 - 键盘高度」比较,差多少就 scrollBy 多少。
   不直接 scrollEdge(Bottom):那会把地点字段顶出屏幕顶部。
3) 兜底:avoidAreaChange 可能还没回调(先弹键盘、后展开候选),
   此时按窗口高度 42% 保守估算键盘高度 —— 多滚一点只是页面上移,无害。

顺带:
- 候选列表 Column → List + constraintSize(maxHeight: 216),
  6 条候选内部可滚,不再占掉大半个屏幕;24px 行高分隔线改用 List.divider。
- 上限 8 条 → 6 条。

⚠️ 踩到的 API 坑(本 SDK):
- KeyboardAvoidMode 必须从 @kit.ArkUI 导入(不是全局);
- UIAbilityContext **没有** window 属性,要用 window.getLastWindow(ctx);
- getRectangleById 在 ui.getComponentUtils() 上,**不在** UIContext 上;
- avoidAreaChange 回调参数是 AvoidAreaOptions(含 type 字段),
  不是老的 AvoidArea,需先判 type === TYPE_KEYBOARD。

验证:assembleHap BUILD SUCCESSFUL;已 install -r 真机。
2026-10-21 08:26:16 +08:00
laoyang 8be539d8a3 新增:地点输入补全(鸿蒙 Map Kit / Site Kit)
需求:新建/修改日程时,地点输入几个字即可用地图 API 补全详细地址。

选型:鸿蒙系统内置 Map Kit,**不引入高德**。理由(详见
Caldav开发/地点输入补全-地图API选型.md):
1) 配额:高德「输入提示」个人认证仅 100 次/日、企业 1000 次/日(官方 FAQ),
   是全 App 共用额度。按 350ms 防抖、单次输入 4~8 请求算,个人额度约等于
   15~25 个用户输入一次地点;企业额度一天也烧完。华为侧「POI 搜索/地理编码
   当前不收费」,QPS 3000(官方论坛口径,HarmonyOS 5.0+),量级差 6 个数量级。
2) 合规:高德 Web 服务 Key 必须配 IP 白名单、不得出现在客户端;App 直连必然
   被反编译提取 → 额度被盗刷、账单算自己、Key 可能被冻结。唯一安全做法是自建
   服务端代理,会把「App 本体独立好用」的产品定位破坏掉。
3) 商用:高德个人认证仅限"研发/测试/个人兴趣",上架商店属企业认证场景。
4) Site Kit 全部接口**无需任何权限**(d.ts 无 @permission),完全不需要
   申请 LOCATION,契合"权限只在用户主动点该功能时申请"的硬约束。

实现:
- 新增 common/PlaceSuggest.ets(纯服务类):
  query()=queryAutoComplete、search()=searchByText(补全失败兜底)、
  pickOnMap()=sceneMap.chooseLocation(系统地图选点)、isUnavailable() 供界面降级。
  · 请求序号 seq 丢弃过期结果(边打字边搜时后发先至);
  · 异常一律返回空数组、绝不抛给调用方 —— 补全坏了日程照样能存;
  · 1002600004/1002600003 判定"能力未开通"→ 置 unavailable,只提示一次,
    之后不再反复打网络。
- EventEditPage:地点行改为 TextInput + 「地图」按钮 + 内联候选列表。
  · 输入停止 350ms 防抖;回车立即搜一次;
  · 候选**内联展开**,不新增 bindSheet(硬约束:单页只能一个 bindSheet,
    本页已用于时间/重复选择);
  · 选中后写入完整地址(formatAddress) 并收起,placePicked 防止再次自动弹出;
  · aboutToDisappear 清防抖定时器。
- LOCATION 的 ICS 转义无需改动:IcsUtil.escape() 已处理 , ; \ 换行,
  地图地址里的逗号不会破坏格式。

验证:assembleHap BUILD SUCCESSFUL;已 install -r 真机。
⚠️ 功能待 AGC 开通「地图服务」+ 重签 Profile 后才生效(否则抛 1002600004);
   未开通时界面显示「地图服务不可用,可直接手动输入地点」,不影响其他功能。
2026-10-21 07:59:21 +08:00
laoyang 29898d5dff 修复:改了日历本颜色,系统日历镜像颜色不跟变
根因:SyncEngine.refreshCalendarColors 每轮同步都 PROPFIND 服务器把新
calendar-color 写进**内存**里的账号对象,但从不 saveAll(唯一落盘点是
removeDeadBooks 的"服务器删本"分支)。而镜像 listBooks 是重新
AccountStore.loadAll 从磁盘读 → 拿到的永远是旧颜色 → ensureCalendar
setConfig(旧色) → 系统日历颜色永远不变。
用户在「账号设置」页看到的是**当场 PROPFIND** 的新颜色,所以表现为
"设置页颜色对、系统日历颜色错"。

修法:
1) refreshCalendarColors 返回 boolean(颜色/写权限是否有实际变化,
   含数组长度补齐);syncAccountInner 在有变化时立刻 loadAll+saveAll
   落盘(同 removeDeadBooks 的既有模式),镜像同轮即可读到新色;
2) SystemCalendarMirror.ensureCalendar 的 setConfig 失败由静默改为
   写日志(新建/复用两处),"颜色不变"从此可排查。

验证:assembleHap BUILD SUCCESSFUL;已 install -r 到真机。
2026-10-21 07:11:23 +08:00
laoyang c711cdb7d5 版本号 0.0.8 -> 0.0.9(versionCode 900) 2026-10-20 22:34:12 +08:00
laoyang 2f124eada0 镜像到系统日历:新增「全选/全不选」日历本
设置页镜像勾选网格上方加一行「全选(已选 x/N)」:
· 整行可点;勾选框半选时显示为关,点按 = 全选,再次点按 = 全不选;
· 全选:一次性把全部候选本(已排除"系统日历备份目标本"防回灌)写入
  mirror_book_ids —— preferences 只落盘一次、提醒缓存只失效一次,
  与逐个 toggle 的关键区别;
· 全不选:逐本 removeById 删对应系统日历账户(与取消单个勾选同语义),
  逐个独立 try,失败不影响其余;
· onChange 加同值守卫,过滤系统重复回调导致的二次翻转。

验证:assembleHap BUILD SUCCESSFUL;已 install -r 到真机。
2026-10-20 22:31:29 +08:00
10 changed files with 835 additions and 95 deletions
+2 -2
View File
@@ -2,8 +2,8 @@
"app": { "app": {
"bundleName": "synccalendar.yangyq.net", "bundleName": "synccalendar.yangyq.net",
"vendor": "yangyq", "vendor": "yangyq",
"versionCode": 800, "versionCode": 10000,
"versionName": "0.0.8", "versionName": "0.1.0",
"buildVersion": "1", "buildVersion": "1",
"icon": "$media:layered_image", "icon": "$media:layered_image",
"label": "$string:app_name" "label": "$string:app_name"
+7 -2
View File
@@ -45,8 +45,13 @@ export class AppSettings {
// 在线文档网址(自托管,AGC 上架的"隐私政策网址"填 PRIVACY_URL)。 // 在线文档网址(自托管,AGC 上架的"隐私政策网址"填 PRIVACY_URL)。
// ⚠️ 隐私政策与用户协议必须与安装包实际申请的 user_grant 权限保持一致: // ⚠️ 隐私政策与用户协议必须与安装包实际申请的 user_grant 权限保持一致:
// 应用仅申请 READ_CALENDAR / READ_WHOLE_CALENDAR / LOCATION / APPROXIMATELY_LOCATION(均前台), // 应用仅申请 READ_CALENDAR / READ_WHOLE_CALENDAR / WRITE_CALENDAR(均前台)。
// 文档中不得出现"在后台获取位置"(LOCATION_IN_BACKGROUND)与"添加/修改/删除日历活动"(WRITE_CALENDAR)。 // 文档中不得出现"在后台获取位置"(LOCATION_IN_BACKGROUND)。
// ⚠️ 0.1.0 起已**移除 LOCATION / APPROXIMATELY_LOCATION**:实测本 SDK(API 26)的
// `geoLocationManager.getAddressesFromLocationName`(地址→坐标)无 @permission 标注,
// 地理编码不需要定位权限;且应用从不调用 getCurrentLocation,本就不读实时位置。
// ⇒ 隐私政策/隐私标签里都**不得**再声称"读取设备精确位置或大致位置"。
// ⇒ 若将来真要用实时定位,必须同时改 module.json5、本注释、upa.html 与 AGC 隐私标签。
// 四份文档均在设置页通过内置浏览器(DocViewer)打开,内容在线更新、无需重新发版。 // 四份文档均在设置页通过内置浏览器(DocViewer)打开,内容在线更新、无需重新发版。
/** 隐私政策网址 */ /** 隐私政策网址 */
static readonly PRIVACY_URL: string = 'https://synccalendar.yangyq.net/upa.html'; static readonly PRIVACY_URL: string = 'https://synccalendar.yangyq.net/upa.html';
+253
View File
@@ -0,0 +1,253 @@
// entry/src/main/ets/common/PlaceSuggest.ets
// 地点补全/选点:基于鸿蒙系统内置的 Map Kit(Site Kit + sceneMap)
//
// 为什么用华为 Map Kit 而不是高德:
// 1) 系统内置、无需第三方 SDK 依赖,也不需要我们自己维护一个 API Key;
// 2) Site Kit 的 queryAutoComplete / searchByText / reverseGeocode **不需要任何权限**
// (SDK 声明里没有 @permission),符合本项目「权限只在用户主动点该功能时申请」的硬约束;
// 3) HarmonyOS 5.0 及以上 Map Kit 的地图加载/POI 搜索/地理编码当前免费(官方论坛口径)。
//
// ⚠️ 前置条件(必须由用户在 AGC 完成,代码无法替代):
// AGC 项目 → 开放能力管理 → 打开「地图服务」,并**重新申请 Profile**(旧的 Profile 不含地图能力)。
// 未开通时调用会抛 1002600004(The Map permission is not enabled)。
// 因此本类所有入口都必须**优雅降级**:失败只返回空列表,绝不影响日程本身的保存。
import { site, sceneMap, mapCommon } from '@kit.MapKit';
import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { LogUtil } from './LogUtil';
/** 一条地点候选(界面直接消费) */
export class PlaceItem {
/** Site Kit 的地点 ID,可用于 searchById 取详情 */
siteId: string = '';
/** 地点名称,如「星巴克(中关村店)」 */
name: string = '';
/** 完整格式化地址,如「北京市海淀区中关村大街1号」 */
address: string = '';
/** 纬度(无法取得时为 0) */
lat: number = 0;
/** 经度(无法取得时为 0) */
lng: number = 0;
/** 是否有有效坐标 */
hasCoord: boolean = false;
/** 界面上第一行显示:有名称用名称,否则用地址 */
get title(): string {
return this.name !== '' ? this.name : this.address;
}
/** 界面上第二行显示:名称+地址重复时不显示 */
get subtitle(): string {
if (this.address === '') {
return '';
}
if (this.name === '' || this.address === this.name) {
return '';
}
return this.address;
}
/** 写入日程 location 字段的文本:优先完整地址,退化为名称 */
get locationText(): string {
return this.address !== '' ? this.address : this.name;
}
}
export class PlaceSuggest {
/** 地图能力是否可用(false 时界面应显示提示而不是反复重试) */
private static unavailable: boolean = false;
/** 最近一次失败原因(给界面显示,便于用户/开发者判断是"没开通"还是"网络问题") */
private static lastError: string = '';
/** 当前在途请求序号:用于丢弃过期结果(边打字边搜时后发先至) */
private static seq: number = 0;
static isUnavailable(): boolean {
return PlaceSuggest.unavailable;
}
static getLastError(): string {
return PlaceSuggest.lastError;
}
/**
* 关键字补全:输入几个字返回候选地点。
* 失败/未开通一律返回空数组(不抛异常,不影响其他功能)。
* @param keyword 用户已输入的关键字
* @param context 可选,传了就用带 context 的重载(定位更准)
*/
static async query(keyword: string, context?: common.Context): Promise<PlaceItem[]> {
const q: string = keyword.trim();
if (q === '') {
return [];
}
if (PlaceSuggest.unavailable) {
return [];
}
// 序号机制:只接受最后一次请求的结果
PlaceSuggest.seq++;
const mySeq: number = PlaceSuggest.seq;
try {
const params: site.QueryAutoCompleteParams = {
query: q,
language: 'zh-CN',
isCityLimit: false
};
const res: site.QueryAutoCompleteResult = context !== undefined
? await site.queryAutoComplete(context, params)
: await site.queryAutoComplete(params);
if (mySeq !== PlaceSuggest.seq) {
// 已有更新的请求发出,丢弃本次结果
return [];
}
return PlaceSuggest.toItems(res.sites);
} catch (err) {
PlaceSuggest.handleError('补全', err);
// 补全失败时退一步用关键字搜索(部分环境 autoComplete 不可用但 searchByText 可用)
return await PlaceSuggest.searchFallback(q, context, mySeq);
}
}
/**
* 关键字搜索(补全失败时的兜底,也用于用户直接点"搜索")。
*/
static async search(keyword: string, context?: common.Context): Promise<PlaceItem[]> {
const q: string = keyword.trim();
if (q === '' || PlaceSuggest.unavailable) {
return [];
}
PlaceSuggest.seq++;
const mySeq: number = PlaceSuggest.seq;
return await PlaceSuggest.searchFallback(q, context, mySeq);
}
private static async searchFallback(keyword: string, context: common.Context | undefined,
mySeq: number): Promise<PlaceItem[]> {
try {
const params: site.SearchByTextParams = {
query: keyword,
language: 'zh-CN'
};
const res: site.SearchByTextResult = context !== undefined
? await site.searchByText(context, params)
: await site.searchByText(params);
if (mySeq !== PlaceSuggest.seq) {
return [];
}
return PlaceSuggest.toItems(res.sites);
} catch (err) {
PlaceSuggest.handleError('搜索', err);
return [];
}
}
/**
* 打开系统地图选点页,返回用户选中的地点(用户取消返回 null)。
* 这是最省事也最不容易出错的方式:地图、搜索、周边都由系统界面提供。
*/
static async pickOnMap(context: common.UIAbilityContext): Promise<PlaceItem | null> {
try {
const options: sceneMap.LocationChoosingOptions = {
language: 'zh-CN',
searchEnabled: true,
showNearbyPoi: true
};
const r: sceneMap.LocationChoosingResult = await sceneMap.chooseLocation(context, options);
const item: PlaceItem = new PlaceItem();
item.siteId = r.siteId !== undefined ? r.siteId : '';
item.name = r.name !== undefined ? r.name : '';
item.address = r.address !== undefined ? r.address : '';
if (r.location !== undefined) {
item.lat = r.location.latitude;
item.lng = r.location.longitude;
item.hasCoord = true;
}
// 两者都空视为取消/无效选择
if (item.name === '' && item.address === '') {
return null;
}
return item;
} catch (err) {
PlaceSuggest.handleError('地图选点', err);
return null;
}
}
/** 生成"名称 — 地址"这种便于粘贴进备注/地点的单行文本 */
static formatOneLine(item: PlaceItem): string {
if (item.name !== '' && item.address !== '' && item.name !== item.address) {
return `${item.name}|${item.address}`;
}
return item.locationText;
}
private static toItems(sites: site.Site[] | undefined): PlaceItem[] {
const out: PlaceItem[] = [];
if (sites === undefined) {
return out;
}
for (let i = 0; i < sites.length; i++) {
const s: site.Site = sites[i];
const item: PlaceItem = new PlaceItem();
item.siteId = s.siteId !== undefined ? s.siteId : '';
item.name = s.name !== undefined ? s.name : '';
// formatAddress 是"省市区+街道门牌"的完整串,最适合写进 LOCATION
item.address = s.formatAddress !== undefined ? s.formatAddress : '';
if (item.address === '' && s.addressComponent !== undefined) {
// 极少数数据只有结构化 addressComponent(字段仅 countryName/locality/city/streetNumber),
// 按"国 → 城市 → 区县(locality) → 街道门牌"拼一个保底地址
item.address = PlaceSuggest.joinAddress(s.addressComponent);
}
if (s.location !== undefined) {
item.lat = s.location.latitude;
item.lng = s.location.longitude;
item.hasCoord = true;
}
if (item.name !== '' || item.address !== '') {
out.push(item);
}
}
return out;
}
/** 从结构化地址组件拼一条保底地址(SDK 里就这几个字段) */
private static joinAddress(ac: site.AddressComponent): string {
const parts: string[] = [];
if (ac.countryName !== undefined && ac.countryName !== '') {
parts.push(ac.countryName);
}
if (ac.city !== undefined && ac.city.cityName !== undefined && ac.city.cityName !== '') {
parts.push(ac.city.cityName);
}
if (ac.locality !== undefined && ac.locality !== '') {
parts.push(ac.locality);
}
if (ac.streetNumber !== undefined) {
const sn: site.StreetNumber = ac.streetNumber;
if (sn.streetName !== undefined && sn.streetName !== '') {
parts.push(sn.streetName);
}
if (sn.streetNumber !== undefined && sn.streetNumber !== '') {
parts.push(sn.streetNumber);
}
}
return parts.join('');
}
/**
* 统一错误处理:
* - 1002600004 地图权限未开通 → 标记不可用,界面不再重试(避免每次打字都打一遍网络)
* - 1002600003 鉴权失败(Profile 未含地图能力)
* - 1002600002 连不上服务器
*/
private static handleError(op: string, err: Object): void {
const e = err as BusinessError;
const code: number = e.code !== undefined ? e.code : 0;
PlaceSuggest.lastError = `${op}失败(code=${code}): ${e.message}`;
if (code === 1002600004 || code === 1002600003) {
PlaceSuggest.unavailable = true;
LogUtil.write(`[地点] ${op}不可用(code=${code}):需在 AGC 打开「地图服务」并重新申请 Profile`);
} else {
LogUtil.write(`[地点] ${PlaceSuggest.lastError}`);
}
}
}
+33 -4
View File
@@ -122,7 +122,23 @@ export class SyncEngine {
private static async syncAccountInner(context: common.Context, acc: DavAccount): Promise<number> { private static async syncAccountInner(context: common.Context, acc: DavAccount): Promise<number> {
const auth: string = DavClient.authHeader(acc.username, acc.password); const auth: string = DavClient.authHeader(acc.username, acc.password);
// 0) 刷新服务器端日历本颜色(每次同步都校正) // 0) 刷新服务器端日历本颜色(每次同步都校正)
await SyncEngine.refreshCalendarColors(acc, auth); // ⭐ 有变化就立刻落盘:镜像 listBooks 从磁盘重新 loadAll,
// 不落盘的话镜像 setConfig 的永远是旧颜色(系统日历颜色不跟服务器变)。
const colorChanged: boolean = await SyncEngine.refreshCalendarColors(acc, auth);
if (colorChanged) {
try {
const all: DavAccount[] = await AccountStore.loadAll(context);
const idx: number = all.findIndex((a: DavAccount): boolean => a.id === acc.id);
if (idx >= 0) {
all[idx] = acc;
await AccountStore.saveAll(context, all);
LogUtil.write('日历本颜色/权限有变化,已落盘(镜像将使用新颜色)');
}
} catch (err) {
const e = err as BusinessError;
LogUtil.write(`颜色/权限落盘失败(不影响同步): ${e.message}`);
}
}
// 1) 推送该账号日历本下的本地修改 // 1) 推送该账号日历本下的本地修改
await SyncEngine.pushDirtyForAccount(context, acc, auth); await SyncEngine.pushDirtyForAccount(context, acc, auth);
// 2) 拉取远端变更:REPORT 仅取 etag;新增/变化的资源用 GET 补拉完整 ICS。 // 2) 拉取远端变更:REPORT 仅取 etag;新增/变化的资源用 GET 补拉完整 ICS。
@@ -363,8 +379,13 @@ export class SyncEngine {
/** /**
* 刷新服务器端日历本颜色:PROPFIND getcolor → 按集合路径匹配更新 acc.calendarColors * 刷新服务器端日历本颜色:PROPFIND getcolor → 按集合路径匹配更新 acc.calendarColors
* 失败静默(颜色不影响数据正确性) * 失败静默(颜色不影响数据正确性)
* ⭐ 返回**是否有实际变化**(颜色或写权限)——调用方据此落盘。
* (0.0.9 修复:此前变化只留在内存、从不 saveAll ⇒ 镜像 listBooks 从磁盘读到的
* 永远是旧颜色 ⇒ setConfig 旧色 ⇒ 系统日历颜色永远不跟服务器变;
* 而账号设置页是当场 PROPFIND 的,所以"页面上是新色、系统日历是旧色"。)
*/ */
static async refreshCalendarColors(acc: DavAccount, auth: string): Promise<void> { static async refreshCalendarColors(acc: DavAccount, auth: string): Promise<boolean> {
let changed: boolean = false;
let entries: DavColorEntry[] = []; let entries: DavColorEntry[] = [];
try { try {
entries = await DavClient.propfindColors(acc.serverUrl, auth); entries = await DavClient.propfindColors(acc.serverUrl, auth);
@@ -382,9 +403,11 @@ export class SyncEngine {
if (found !== undefined && found.color !== '') { if (found !== undefined && found.color !== '') {
while (acc.calendarColors.length <= i) { while (acc.calendarColors.length <= i) {
acc.calendarColors.push(''); acc.calendarColors.push('');
changed = true; // 长度补齐也视作变化(旧账号可能还没这一位)
} }
if (acc.calendarColors[i] !== found.color) { if (acc.calendarColors[i] !== found.color) {
LogUtil.write(`日历本[${i}] 颜色更新:${acc.calendarColors[i] === '' ? '(空)' : acc.calendarColors[i]} → ${found.color}`); LogUtil.write(`日历本[${i}] 颜色更新:${acc.calendarColors[i] === '' ? '(空)' : acc.calendarColors[i]} → ${found.color}`);
changed = true;
} }
acc.calendarColors[i] = found.color; acc.calendarColors[i] = found.color;
} else if (found === undefined) { } else if (found === undefined) {
@@ -396,17 +419,23 @@ export class SyncEngine {
acc.calendarWritable.push('1'); acc.calendarWritable.push('1');
} }
const calName: string = i < acc.calendarNames.length ? acc.calendarNames[i] : `日历本${i}`; const calName: string = i < acc.calendarNames.length ? acc.calendarNames[i] : `日历本${i}`;
let writable: string;
if (found !== undefined && found.privilegeKnown) { if (found !== undefined && found.privilegeKnown) {
// 服务器声明了权限:直接采用 // 服务器声明了权限:直接采用
acc.calendarWritable[i] = found.writable ? '1' : '0'; writable = found.writable ? '1' : '0';
LogUtil.write(`日历本[${i}]「${calName}」写权限(privilege):${found.writable ? '可写' : '只读'}`); LogUtil.write(`日历本[${i}]「${calName}」写权限(privilege):${found.writable ? '可写' : '只读'}`);
} else { } else {
// 服务器未声明权限(如部分 Synology 配置)或 PROPFIND 未匹配到该本:真实写探测 // 服务器未声明权限(如部分 Synology 配置)或 PROPFIND 未匹配到该本:真实写探测
const w: boolean = await DavClient.probeWritable(target, auth); const w: boolean = await DavClient.probeWritable(target, auth);
acc.calendarWritable[i] = w ? '1' : '0'; writable = w ? '1' : '0';
LogUtil.write(`日历本[${i}]「${calName}」写权限(探测):${w ? '可写' : '只读'}`); LogUtil.write(`日历本[${i}]「${calName}」写权限(探测):${w ? '可写' : '只读'}`);
} }
if (acc.calendarWritable[i] !== writable) {
changed = true;
}
acc.calendarWritable[i] = writable;
} }
return changed;
} }
/** Map<key, 行数组> 追加一行(没有该 key 就建数组) */ /** Map<key, 行数组> 追加一行(没有该 key 就建数组) */
@@ -1146,7 +1146,9 @@ export class SystemCalendarMirror {
try { try {
await cal.setConfig({ enableReminder: true, color: ref.color }); await cal.setConfig({ enableReminder: true, color: ref.color });
} catch (err) { } catch (err) {
// 设色失败不影响镜像本身 // 设色失败不影响镜像本身,但要留痕(否则"颜色不变"无从排查)
const e = err as BusinessError;
LogUtil.write(`镜像账户设色失败 ${accountName}: ${e.message}`);
} }
return cal; return cal;
} }
@@ -1159,7 +1161,8 @@ export class SystemCalendarMirror {
try { try {
await cal.setConfig({ enableReminder: true, color: ref.color }); await cal.setConfig({ enableReminder: true, color: ref.color });
} catch (err) { } catch (err) {
// 同上 const e = err as BusinessError;
LogUtil.write(`镜像账户设色失败(新建) ${accountName}: ${e.message}`);
} }
return cal; return cal;
} }
+455 -24
View File
@@ -1,6 +1,6 @@
// entry/src/main/ets/pages/EventEditPage.ets // entry/src/main/ets/pages/EventEditPage.ets
// 日程编辑页:新建 / 修改 / 删除日程(一律属于某个 CalDAV 日历本,不再支持"本机"日程) // 日程编辑页:新建 / 修改 / 删除日程(一律属于某个 CalDAV 日历本,不再支持"本机"日程)
import { curves, router } from '@kit.ArkUI'; import { curves, router, KeyboardAvoidMode } from '@kit.ArkUI';
import { common } from '@kit.AbilityKit'; import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit'; import { BusinessError } from '@kit.BasicServicesKit';
import { notificationManager } from '@kit.NotificationKit'; import { notificationManager } from '@kit.NotificationKit';
@@ -13,6 +13,7 @@ import { ReminderService } from '../common/ReminderService';
import { LogUtil } from '../common/LogUtil'; import { LogUtil } from '../common/LogUtil';
import { IcsUtil } from '../common/IcsUtil'; import { IcsUtil } from '../common/IcsUtil';
import { RruleUtil } from '../common/RruleUtil'; import { RruleUtil } from '../common/RruleUtil';
import { PlaceSuggest, PlaceItem } from '../common/PlaceSuggest';
/** 重复系列某次发生的前后边界(用于 EXDATE / UNTIL / 拆分) */ /** 重复系列某次发生的前后边界(用于 EXDATE / UNTIL / 拆分) */
class SeriesBounds { class SeriesBounds {
@@ -34,6 +35,34 @@ class BookChoice {
struct EventEditPage { struct EventEditPage {
@State title: string = ''; @State title: string = '';
@State location: string = ''; @State location: string = '';
/**
* ⭐ 地点补全(鸿蒙 Map Kit / Site Kit):
* placeItems —— 候选数据(**不参与页面布局**,由 bindPopup 渲染成浮层)
* placeLoading —— 是否正在请求,浮层内显示"搜索中"
* placeOpen —— 浮层是否弹出(绑在输入框上,向上弹)
* placeHint —— 无结果/地图能力不可用时的提示(不阻塞手输地点)
*
* ⚠️ 为什么必须是浮层而不是内联列表:内联列表改变表单高度 ⇒ 输入框跟着移动 ⇒
* 每次"搜索中/出结果/结果变少"都要滚动纠正 ⇒ 用户看到输入框反复上下跳。
*/
@State placeItems: PlaceItem[] = [];
@State placeLoading: boolean = false;
@State placeOpen: boolean = false;
@State placeHint: string = '';
/** 用户是否已从候选里选中(选中后不再自动弹候选,避免"选完又弹出") */
private placePicked: boolean = false;
/**
* 最后一次**程序写入**输入框的文本(选中候选后回写的地址)。
* TextInput 的 text 被程序改写同样会触发 onChange;用它精确识别"这次 onChange
* 不是用户打字造成的",从而不重复发起搜索。
*/
private lastAppliedText: string = '';
/** 地点输入框当前是否聚焦(仅用于日志/状态,不再驱动滚动) */
private placeFocused: boolean = false;
/** 输入防抖定时器 */
private placeTimer: number = -1;
/** 表单滚动控制器(键盘避让由系统 RESIZE 模式负责,这里不再主动滚) */
private formScroller: Scroller = new Scroller();
@State description: string = ''; @State description: string = '';
@State allDay: boolean = false; @State allDay: boolean = false;
@State startMs: number = 0; @State startMs: number = 0;
@@ -41,7 +70,20 @@ struct EventEditPage {
@State books: BookChoice[] = []; @State books: BookChoice[] = [];
@State chosenKey: string = ''; @State chosenKey: string = '';
@State isSaving: boolean = false; @State isSaving: boolean = false;
/**
* 表单状态行文字。
* ⚠️ 它渲染在表单**最底部**("删除日程"按钮上方),用户在顶部点"保存"时根本看不见。
* 所以它只能作为"事后可复查的记录",**不能**作为唯一的错误反馈手段 ——
* 真正的提醒必须走 showFormError():Toast(必可见)+ 字段标红 + 滚到出错字段。
*/
@State statusMsg: string = ''; @State statusMsg: string = '';
/**
* ⭐ 哪个字段校验没过('' = 没有)。
* 用途:把对应输入框描红并显示一条紧贴字段的红字说明,
* 让用户一眼看到"哪儿错了",而不是在一屏之外找一行小字。
* 取值:'title' | 'time' | ''
*/
@State errorField: string = '';
@State isExisting: boolean = false; @State isExisting: boolean = false;
@State repeatMode: string = 'none'; // none|daily|workday|weekly|monthly|yearly|custom @State repeatMode: string = 'none'; // none|daily|workday|weekly|monthly|yearly|custom
/** /**
@@ -87,9 +129,36 @@ struct EventEditPage {
private event: LocalEvent | null = null; private event: LocalEvent | null = null;
aboutToAppear(): Promise<void> { aboutToAppear(): Promise<void> {
// 键盘避让不阻塞页面加载:拿窗口是异步的,失败也只是少一层滚动校正
this.setupKeyboardAvoid();
return this.initPage(); return this.initPage();
} }
/** 页面销毁:清掉地点补全的防抖定时器 */
aboutToDisappear(): void {
this.cancelSuggestTimer();
}
/**
* ⭐ 键盘避让:改成 RESIZE 模式。
* 默认 OFFSET 是「整页上移」,页面上移后表单底部(地点/备注/按钮)会被顶出屏幕。
* RESIZE 是「把页面高度压缩到键盘上沿」,滚动区域随之变矮,
* 聚焦的输入框由系统自动滚入可见区,**无需再手动 scrollBy**。
*
* ⚠️ 曾经写过"手动量矩形 + scrollBy 对齐"的方案,是错的方向:
* ① getRectangleById 返回 px、scrollBy 收 vp,单位混用会把输入框推飞;
* ② 更根本的是——只要候选参与布局,位置就会反复变化,
* 靠"事后纠正"必然表现为抖动。所以候选改成了浮层(见 placePopup)。
*/
private setupKeyboardAvoid(): void {
try {
this.getUIContext().setKeyboardAvoidMode(KeyboardAvoidMode.RESIZE);
} catch (err) {
const e = err as BusinessError;
LogUtil.write(`设置键盘避让模式失败: ${e.message}`);
}
}
private async initPage(): Promise<void> { private async initPage(): Promise<void> {
const context = this.getUIContext().getHostContext(); const context = this.getUIContext().getHostContext();
if (context === undefined) { if (context === undefined) {
@@ -214,6 +283,191 @@ struct EventEditPage {
} }
} }
// ==================== 地点补全(鸿蒙 Map Kit / Site Kit) ====================
/**
* 输入防抖:停止输入 350ms 后才真正发请求。
* 好处:① 不把服务端配额/网络浪费在"每敲一个字"上;② 避免结果闪烁。
* ⚠️ 这里只用「关键词补全」这一种能力(不需要定位权限),
* 所以**不需要**申请 LOCATION 权限,符合"权限只在用户主动点该功能时申请"的约束。
*/
private scheduleSuggest(v: string): void {
this.cancelSuggestTimer();
const q: string = v.trim();
if (q.length < 1) {
this.closePlacePopup();
return;
}
this.placeTimer = setTimeout(() => {
this.placeTimer = -1;
this.runSuggest(q, false);
}, 350);
}
private cancelSuggestTimer(): void {
if (this.placeTimer !== -1) {
clearTimeout(this.placeTimer);
this.placeTimer = -1;
}
}
private async runSuggest(q: string, forceOpen: boolean): Promise<void> {
if (this.placePicked && !forceOpen) {
return;
}
if (PlaceSuggest.isUnavailable()) {
// 地图服务没开通:给一次明确提示,然后永久静默(不阻塞手输)
this.placeHint = '地图服务不可用,可直接手动输入地点';
this.placeItems = [];
this.placeOpen = true;
return;
}
// ⭐ 浮层一直保持展开(从"搜索中"过渡到结果),中间不收起、不重弹 ——
// 收起再弹会让浮层闪一下,看起来像"消失了又出现"。
this.placeLoading = true;
this.placeHint = '';
this.placeOpen = true;
try {
const c = this.getUIContext().getHostContext();
const ctx: common.Context | undefined = c;
// 先补全;补全空则内部会自动退到关键字搜索
let items: PlaceItem[] = await PlaceSuggest.query(q, ctx);
if (items.length === 0 && !PlaceSuggest.isUnavailable()) {
items = await PlaceSuggest.search(q, ctx);
}
// ⭐ 丢弃过期结果:用户又改了输入框,就别再覆盖成旧候选
if (q !== this.location.trim()) {
return;
}
this.placeItems = items.slice(0, 6);
if (this.placeItems.length === 0) {
this.placeHint = PlaceSuggest.isUnavailable()
? '地图服务不可用,可直接手动输入地点'
: '没有找到匹配地点,可直接手动输入';
// 无结果也留在浮层里给提示;用户再输入会被下一次请求覆盖
this.placeOpen = true;
}
} catch (err) {
const e = err as BusinessError;
LogUtil.write(`地点补全异常: ${e.message}`);
this.placeItems = [];
this.placeHint = '地点搜索失败,可直接手动输入';
this.placeOpen = true;
} finally {
this.placeLoading = false;
}
}
/**
* ⭐ 候选浮层(bindPopup)的内容。
*
* 为什么是浮层而不是内联列表:
* 内联列表会改变表单高度 ⇒ 输入框位置随之变化 ⇒ 每次"搜索中/出结果/结果变少"
* 都要靠滚动去纠正 ⇒ 用户看到的就是**输入框上下跳好几次**。
* 浮层不参与布局,出现与消失**完全不移动输入框**,这是唯一能根治该问题的结构。
*
* 为什么向上弹(Placement.Top):
* 输入框在表单下部、键盘又在下方,向下弹必然被键盘盖住;
* 向上弹则整块候选都落在输入框与标题之间的空白区。
*/
@Builder
placePopup() {
Column({ space: 0 }) {
if (this.placeLoading) {
Row({ space: 8 }) {
LoadingProgress().width(16).height(16).color($r('app.color.text_hint'))
Text('搜索中…')
.fontSize(13)
.fontColor($r('app.color.text_hint'))
}
.width('100%')
.padding({ left: 14, right: 14, top: 12, bottom: 12 })
} else if (this.placeItems.length > 0) {
// 限高 + 内部滚动:候选最多 6 条,一次全铺开会顶到屏幕顶部
List() {
ForEach(this.placeItems, (it: PlaceItem, idx: number) => {
ListItem() {
Column({ space: 2 }) {
Text(it.title)
.fontSize(14)
.fontColor($r('app.color.text_primary'))
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
.textAlign(TextAlign.Start)
if (it.subtitle !== '') {
Text(it.subtitle)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
.textAlign(TextAlign.Start)
}
}
.alignItems(HorizontalAlign.Start)
.width('100%')
.padding({ left: 14, right: 14, top: 10, bottom: 10 })
}
.onClick(() => {
this.applyPlace(it);
})
}, (it: PlaceItem, idx: number) => `${it.siteId}_${idx}`)
}
.width('100%')
.constraintSize({ maxHeight: 216 })
.scrollBar(BarState.Auto)
.divider({ strokeWidth: 0.5, color: $r('app.color.shadow_color') })
} else {
Text(this.placeHint !== '' ? this.placeHint : '没有找到匹配地点,可直接手动输入')
.fontSize(12)
.fontColor($r('app.color.text_hint'))
.width('100%')
.padding({ left: 14, right: 14, top: 12, bottom: 12 })
}
}
.width('100%')
.padding({ top: 4, bottom: 4 })
}
/** 选中一条候选:写进地点输入框并收起列表 */
private applyPlace(it: PlaceItem): void {
// ⭐ 顺序很关键:先收起浮层,再写文本。
// TextInput 的 text 被程序改写会触发 onChange,若此时 placePicked 已被
// onChange 重置为 false,就会**重新发起一次搜索** → 用户看到
// "候选框消失了一下又冒出来一次"。用 lastAppliedText 精确识别这类回写。
this.cancelSuggestTimer();
this.closePlacePopup();
this.placePicked = true;
this.lastAppliedText = it.locationText;
this.location = it.locationText;
}
/** 收起候选浮层(统一入口,保证 placePicked 之外的状态都被清干净) */
private closePlacePopup(): void {
this.placeOpen = false;
this.placeLoading = false;
this.placeItems = [];
this.placeHint = '';
}
/** 打开系统地图选点页(可搜索、可周边、可拖图钉) */
private async pickOnMap(): Promise<void> {
const c = this.getUIContext().getHostContext();
if (c === undefined) {
return;
}
const it: PlaceItem | null = await PlaceSuggest.pickOnMap(c as common.UIAbilityContext);
if (it === null) {
// 用户取消,或地图服务不可用
if (PlaceSuggest.isUnavailable()) {
this.placeHint = '地图服务不可用,可直接手动输入地点';
}
return;
}
this.applyPlace(it);
}
private chosenBook(): BookChoice | null { private chosenBook(): BookChoice | null {
const found = this.books.find((b: BookChoice): boolean => b.calKey === this.chosenKey); const found = this.books.find((b: BookChoice): boolean => b.calKey === this.chosenKey);
if (found !== undefined) { if (found !== undefined) {
@@ -437,15 +691,68 @@ struct EventEditPage {
} }
} }
/**
* ⭐ 统一的表单错误提示(**唯一的错误出口**)。
*
* 为什么需要专门做这件事:
* 校验提示原本只写进 `statusMsg`,而它渲染在表单最底部("删除日程"按钮上方)。
* 用户在顶部点"保存"时,那行红字远在屏幕之外 —— 看起来就是"点了没反应"。
*
* 三件事一起做,保证用户一定看得见、也知道该改哪儿:
* ① **Toast**:系统级浮在屏幕中上部,不受滚动位置影响,必定可见;
* ② **字段标红**:给对应输入框加红框 + 紧贴字段的红字说明;
* ③ **滚回出错字段并聚焦**:把视野直接带到问题所在(标题 → 打光标;时间 → 滚到时间卡片)。
*
* @param msg 给用户看的话术(要具体,说清"该做什么",不要只说"错误")
* @param field 'title' | 'time' | ''('' 表示没有具体字段,只弹 Toast)
*/
private showFormError(msg: string, field: string): void {
this.statusMsg = msg;
this.errorField = field;
// ① Toast:不走布局,无论滚到哪儿都能看到
try {
this.getUIContext().getPromptAction().showToast({
message: msg,
duration: 2500,
bottom: '70%' // 抬到屏幕偏上方,避免被键盘/底部按钮遮挡
});
} catch (err) {
// Toast 失败也不影响其它两种提示
}
// ③ 滚回出错位置:延时一帧,等状态刷新(标红后高度可能变化)后再滚
setTimeout(() => {
try {
if (field === 'title') {
this.formScroller.scrollEdge(Edge.Top);
try {
this.getUIContext().getFocusController().requestFocus('titleInput');
} catch (e2) {
// 聚焦失败(例如输入法正在切换)不阻塞流程
}
} else if (field === 'time') {
// 时间卡片在表单上部,滚到顶基本就能看到;再留一点余量
this.formScroller.scrollEdge(Edge.Top);
} else {
this.formScroller.scrollEdge(Edge.Top);
}
} catch (err2) {
const e = err2 as BusinessError;
LogUtil.write(`错误提示定位失败: ${e.message}`);
}
}, 60);
}
private validate(): boolean { private validate(): boolean {
if (this.title.trim() === '') { if (this.title.trim() === '') {
this.statusMsg = '请输入日程标题'; this.showFormError('请先填写日程标题', 'title');
return false; return false;
} }
if (this.endMs < this.startMs) { if (this.endMs < this.startMs) {
this.statusMsg = '结束时间不能早于开始时间'; this.showFormError('结束时间不能早于开始时间', 'time');
return false; return false;
} }
// 通过校验:清掉上一轮的错误标记
this.errorField = '';
return true; return true;
} }
@@ -711,9 +1018,10 @@ struct EventEditPage {
// 日程必须落在某个 CalDAV 日历本上("本机"不再是可选目标)——没选到就直接拦下 // 日程必须落在某个 CalDAV 日历本上("本机"不再是可选目标)——没选到就直接拦下
const book = this.chosenBook(); const book = this.chosenBook();
if (book === null) { if (book === null) {
this.statusMsg = this.books.length === 0 // 这也是"必填项缺失",同样要弹 Toast + 滚到对应字段,不能只写底部状态行
this.showFormError(this.books.length === 0
? '还没有可写入的 CalDAV 日历本,请先到「更多 → 账号管理」添加账号' ? '还没有可写入的 CalDAV 日历本,请先到「更多 → 账号管理」添加账号'
: '请选择要同步到的 CalDAV 日历本'; : '请选择要同步到的 CalDAV 日历本', 'book');
LogUtil.write(`保存判定:日历本解析失败(选本=${this.books.length} chosenKey=${this.chosenKey})`); LogUtil.write(`保存判定:日历本解析失败(选本=${this.books.length} chosenKey=${this.chosenKey})`);
return; return;
} }
@@ -726,19 +1034,20 @@ struct EventEditPage {
return; return;
} }
// ⭐ 范围确认后回到这里再校验一次:范围弹层打开时并未跳过校验, // ⭐ 范围确认后回到这里再校验一次:范围弹层打开时并未跳过校验,
// 只是把它推迟到"选完范围"之后(失败时弹层已关闭,错误显示在表单状态行)。 // 只是把它推迟到"选完范围"之后(失败时弹层已关闭,错误用 showFormError 强提示)。
if (!this.validate()) { if (!this.validate()) {
return; return;
} }
const book = this.chosenBook(); const book = this.chosenBook();
if (book === null) { if (book === null) {
this.statusMsg = this.books.length === 0 this.showFormError(this.books.length === 0
? '还没有可写入的 CalDAV 日历本,请先到「更多 → 账号管理」添加账号' ? '还没有可写入的 CalDAV 日历本,请先到「更多 → 账号管理」添加账号'
: '请选择要同步到的 CalDAV 日历本'; : '请选择要同步到的 CalDAV 日历本', 'book');
return; return;
} }
this.isSaving = true; this.isSaving = true;
this.statusMsg = ''; this.statusMsg = '';
this.errorField = '';
try { try {
const context = this.getUIContext().getHostContext(); const context = this.getUIContext().getHostContext();
if (context === undefined) { if (context === undefined) {
@@ -798,6 +1107,16 @@ struct EventEditPage {
const ex = err as BusinessError; const ex = err as BusinessError;
LogUtil.write(`保存日程流程失败(数据已存本地待重试):${ex.message}`); LogUtil.write(`保存日程流程失败(数据已存本地待重试):${ex.message}`);
this.statusMsg = `保存失败:${ex.message}(已保存到本地,稍后同步会重试)`; this.statusMsg = `保存失败:${ex.message}(已保存到本地,稍后同步会重试)`;
// ⭐ 这里紧接着会 router.back(),底部状态行根本来不及被看到 → 必须用 Toast
try {
this.getUIContext().getPromptAction().showToast({
message: '已保存到本地,同步到服务器失败,稍后会自动重试',
duration: 3000,
bottom: '70%'
});
} catch (e2) {
// Toast 失败不影响返回
}
// 数据仍在本地且带 dirty 标记,不会丢 // 数据仍在本地且带 dirty 标记,不会丢
router.back(); router.back();
} }
@@ -968,10 +1287,12 @@ struct EventEditPage {
.width('100%') .width('100%')
.padding({ left: 16, right: 16 }) .padding({ left: 16, right: 16 })
Scroll() { Scroll(this.formScroller) {
Column({ space: 14 }) { Column({ space: 14 }) {
// 标题 // 标题(必填)
TextInput({ text: this.title, placeholder: '标题' }) // ⭐ 校验失败时:输入框描红 + 紧贴下方一条红字说明。
// 只在 errorField === 'title' 时出现,用户开始输入即自动清除。
TextInput({ text: this.title, placeholder: '标题(必填)' })
.height(46) .height(46)
.fontSize(16) .fontSize(16)
.backgroundColor($r('app.color.card_bg')) .backgroundColor($r('app.color.card_bg'))
@@ -981,9 +1302,28 @@ struct EventEditPage {
.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')) .caretColor($r('app.color.brand'))
// 标红:用边框而不是背景,深色/浅色主题下都醒目且不难看
.border({
width: this.errorField === 'title' ? 1.5 : 0,
color: $r('app.color.error')
})
.onChange((v: string) => { .onChange((v: string) => {
this.title = v; this.title = v;
// 用户开始填写就把红框撤掉,避免"改了还一直红着"
if (v.trim() !== '' && this.errorField === 'title') {
this.errorField = '';
this.statusMsg = '';
}
}) })
.id('titleInput')
if (this.errorField === 'title') {
Text('请填写标题后再保存')
.fontSize(12)
.fontColor($r('app.color.error'))
.width('100%')
.padding({ left: 4 })
}
// 全天 // 全天
Row() { Row() {
@@ -1012,6 +1352,19 @@ struct EventEditPage {
.padding(6) .padding(6)
.borderRadius(12) .borderRadius(12)
.backgroundColor($r('app.color.card_bg')) .backgroundColor($r('app.color.card_bg'))
// 时间顺序错误时整卡描红,用户一眼能看到问题在这个卡片里
.border({
width: this.errorField === 'time' ? 1.5 : 0,
color: $r('app.color.error')
})
if (this.errorField === 'time') {
Text('结束时间不能早于开始时间')
.fontSize(12)
.fontColor($r('app.color.error'))
.width('100%')
.padding({ left: 4 })
}
// 重复 // 重复
Column({ space: 8 }) { Column({ space: 8 }) {
@@ -1173,6 +1526,11 @@ struct EventEditPage {
.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;
// 选了日历本就把红框撤掉
if (this.errorField === 'book') {
this.errorField = '';
this.statusMsg = '';
}
}) })
}, (b: BookChoice) => b.calKey) }, (b: BookChoice) => b.calKey)
} }
@@ -1183,20 +1541,90 @@ struct EventEditPage {
.width('100%') .width('100%')
.padding(14) .padding(14)
.borderRadius(12) .borderRadius(12)
// 日历本未选且用户点了保存 → 描红,提示"必填"
.border({
width: this.errorField === 'book' ? 1.5 : 0,
color: $r('app.color.error')
})
.backgroundColor($r('app.color.card_bg')) .backgroundColor($r('app.color.card_bg'))
// 地点 // 地点(输入几个字 → 系统地图服务补全候选)
TextInput({ text: this.location, placeholder: '地点(可选)' }) // ⭐ 候选列表**不在布局流里**:它通过 bindPopup 挂在输入框上、向**上方**弹出。
.height(44) // 这样候选的出现/消失完全不改变输入框位置 —— 从根上消除"输入框上下跳"。
.fontSize(14) Row({ space: 8 }) {
.backgroundColor($r('app.color.card_bg')) TextInput({ text: this.location, placeholder: '地点(可选,输入几个字可搜索)' })
.borderRadius(12) .height(44)
.fontColor($r('app.color.text_primary')) .layoutWeight(1)
.placeholderColor($r('app.color.text_hint')) .fontSize(14)
.caretColor($r('app.color.brand')) .backgroundColor($r('app.color.card_bg'))
.onChange((v: string) => { .borderRadius(12)
this.location = v; .fontColor($r('app.color.text_primary'))
}) .placeholderColor($r('app.color.text_hint'))
.caretColor($r('app.color.brand'))
.onChange((v: string) => {
// ⭐ 程序改写 text(选中候选后回写地址)也会触发 onChange。
// 精确比对最后一次程序写入值:命中就不再发起搜索,
// 否则会"选完 → 候选框消失 → 又冒出来一次"。
if (v === this.lastAppliedText) {
this.lastAppliedText = '';
this.location = v;
return;
}
this.lastAppliedText = '';
this.location = v;
// 用户重新输入时解除"已选中"状态,恢复自动补全
this.placePicked = false;
this.scheduleSuggest(v);
})
.onSubmit(() => {
// 回车:立刻搜一次(不等防抖)
this.cancelSuggestTimer();
this.runSuggest(this.location, true);
})
.onFocus(() => {
// 键盘避让交给系统(RESIZE 已保证光标可见);
// 候选是浮层,不需要再滚动页面去给它腾位置
this.placeFocused = true;
})
.onBlur(() => {
// 失焦(点"地图"按钮、点别处、收起键盘)→ 收起候选浮层
this.placeFocused = false;
this.closePlacePopup();
})
.bindPopup($$this.placeOpen, {
builder: (): void => {
this.placePopup()
},
placement: Placement.Top,
popupColor: $r('app.color.card_bg'),
enableArrow: false,
showInSubWindow: false,
focusable: false,
autoCancel: true,
radius: 12,
onStateChange: (e: PopupStateChangeParam) => {
// 用户点浮层外部/floating 自动关闭时,同步状态,避免下次不弹
if (!e.isVisible && this.placeOpen) {
this.placeOpen = false;
}
}
})
.id('placeAnchor')
// 在地图上选点(系统地图界面,可选可搜可周边)
Text('地图')
.fontSize(13)
.fontColor($r('app.color.brand_text'))
.padding({ left: 12, right: 12 })
.height(44)
.textAlign(TextAlign.Center)
.borderRadius(12)
.backgroundColor($r('app.color.card_bg'))
.onClick(() => {
this.pickOnMap();
})
}
.width('100%')
.id('placeRow')
// 描述 // 描述
TextArea({ text: this.description, placeholder: '备注(可选)' }) TextArea({ text: this.description, placeholder: '备注(可选)' })
@@ -1211,7 +1639,10 @@ struct EventEditPage {
this.description = v; this.description = v;
}) })
if (this.statusMsg !== '') { // 状态行:只用于"保存失败/账号未配置"这类**系统级**信息,且必须有 Toast 同时提示。
// ⚠️ 校验类错误不走这里 —— 它们已经在对应字段旁就地显示,
// 再在这里重复一遍只会让用户以为有两处不同的问题。
if (this.statusMsg !== '' && this.errorField === '') {
Text(this.statusMsg) Text(this.statusMsg)
.fontSize(13) .fontSize(13)
.fontColor($r('app.color.error')) .fontColor($r('app.color.error'))
+6 -33
View File
@@ -2,7 +2,7 @@
// 同步日历主界面:月视图(左右滑动翻月)/ 周视图 / 日视图 / 日程列表 // 同步日历主界面:月视图(左右滑动翻月)/ 周视图 / 日视图 / 日程列表
// 混合展示 DAV 与系统日历;每分钟自动同步;可"回到今天" // 混合展示 DAV 与系统日历;每分钟自动同步;可"回到今天"
import { curves, display, router, window } from '@kit.ArkUI'; import { curves, display, router, window } from '@kit.ArkUI';
import { common, abilityAccessCtrl, bundleManager } from '@kit.AbilityKit'; import { common } 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 { DavAccount, AccountStore, CalSource, TYPE_CALDAV } from '../common/AccountStore'; import { DavAccount, AccountStore, CalSource, TYPE_CALDAV } from '../common/AccountStore';
@@ -756,11 +756,10 @@ struct Index {
} }
const ctx = context as common.UIAbilityContext; const ctx = context as common.UIAbilityContext;
const enc: string = encodeURIComponent(address); const enc: string = encodeURIComponent(address);
// 高德鸿蒙深链 dlat/dlon 为必填(dname 仅作名称显示),先地理编码拿坐标 // 高德鸿蒙深链 dlat/dlon 为必填(dname 仅作名称显示),先地理编码拿坐标。
let coords: number[] | null = null; // 地理编码(getAddressesFromLocationName)在本 SDK 上不要求定位权限,
if (await this.ensureLocationPermission()) { // 因此这里既不申请、也不使用定位权限 —— 应用自始至终不读取设备实时位置。
coords = await this.geocodeAddress(address); const coords: number[] | null = await this.geocodeAddress(address);
}
const base: string = 'amapuri://route/plan/?sourceApplication=SyncCalendar'; const base: string = 'amapuri://route/plan/?sourceApplication=SyncCalendar';
let link: string; let link: string;
if (coords !== null) { if (coords !== null) {
@@ -782,33 +781,7 @@ struct Index {
this.getUIContext().getPromptAction().showToast({ message: '未安装高德地图或无法打开' }); this.getUIContext().getPromptAction().showToast({ message: '未安装高德地图或无法打开' });
} }
/** 确认定位权限(地理编码依赖),未授权时向用户申请一次 */ /** 正向地理编码:地址 → 坐标(系统服务,无需高德 key、无需定位权限) */
private async ensureLocationPermission(): Promise<boolean> {
try {
const atManager = abilityAccessCtrl.createAtManager();
const bundleInfo = bundleManager.getBundleInfoForSelfSync(
bundleManager.BundleFlag.GET_BUNDLE_INFO_WITH_APPLICATION);
const tokenId = bundleInfo.appInfo.accessTokenId;
const status = await atManager.checkAccessToken(tokenId, 'ohos.permission.LOCATION');
if (status === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED) {
return true;
}
} catch (err) {
// 未授权会走申请流程
}
try {
const context = this.getUIContext().getHostContext() as common.UIAbilityContext;
const atManager = abilityAccessCtrl.createAtManager();
// LOCATION 与 APPROXIMATELY_LOCATION 必须一起申请
const result = await atManager.requestPermissionsFromUser(context,
['ohos.permission.LOCATION', 'ohos.permission.APPROXIMATELY_LOCATION']);
return result.authResults.every((r: number): boolean => r === 0);
} catch (err) {
return false;
}
}
/** 正向地理编码:地址 → 坐标(系统服务,无需高德 key) */
private async geocodeAddress(address: string): Promise<number[] | null> { private async geocodeAddress(address: string): Promise<number[] | null> {
try { try {
if (!geoLocationManager.isGeocoderAvailable()) { if (!geoLocationManager.isGeocoderAvailable()) {
+74
View File
@@ -293,6 +293,51 @@ struct SettingsPage {
return this.allBooks.length - this.mirrorCandidates.length; return this.allBooks.length - this.mirrorCandidates.length;
} }
/** 已勾选的可镜像本数量(mirrorKeys 里可能残留已不在候选里的 bookId,按候选口径统计) */
private mirrorSelectedCount(): number {
return this.mirrorCandidates.filter((b: BackupTarget): boolean =>
this.mirrorKeys.indexOf(b.bookId) >= 0).length;
}
/**
* 「全选 / 全不选」要镜像的日历本。
* ⭐ 批量落盘只写一次 preferences、缓存只失效一次(与逐个 toggle 的关键区别);
* 全不选时逐本删除对应系统日历账户(与取消单个勾选同一语义)。
* 半选状态下点按 = 全选。
*/
private async toggleMirrorAll(): Promise<void> {
if (this.context === undefined || this.mirrorCandidates.length === 0) {
return;
}
const selectAll: boolean = this.mirrorSelectedCount() < this.mirrorCandidates.length;
if (selectAll) {
const ids: string[] = this.mirrorCandidates.map((b: BackupTarget): string => b.bookId);
this.mirrorKeys = ids;
await AppSettings.setMirrorBookIds(this.context, ids);
this.getUIContext().getPromptAction().showToast({
message: `已全选 ${ids.length} 个日历本,点「立即执行镜像」写入系统日历`
});
} else {
// 逐本删除系统日历账户;逐个独立 try,失败不影响其余
for (const k of this.mirrorKeys) {
try {
await SystemCalendarMirror.removeById(this.context, k);
} catch (err) {
const e = err as BusinessError;
console.error(`全不选:删除镜像账户失败 bookId=${k}: ${e.message}`);
}
}
this.mirrorKeys = [];
await AppSettings.setMirrorBookIds(this.context, []);
this.getUIContext().getPromptAction().showToast({
message: '已取消全部勾选,并清除对应的系统日历账户'
});
}
// ⭐ 勾选本集合变化 → 提醒侧"已镜像本"缓存失效并重排提醒
ReminderService.invalidateMirrorCache();
await ReminderService.refreshReminders(this.context as common.UIAbilityContext);
}
/** 该日历本是否只读:服务器无写权限(探测结果)或用户手动标记只读 —— 与首页色块/只读标记同一口径 */ /** 该日历本是否只读:服务器无写权限(探测结果)或用户手动标记只读 —— 与首页色块/只读标记同一口径 */
private isBookReadonly(b: BackupTarget): boolean { private isBookReadonly(b: BackupTarget): boolean {
return !b.serverWritable || this.manualKeys.includes(b.calKey); return !b.serverWritable || this.manualKeys.includes(b.calKey);
@@ -978,6 +1023,35 @@ struct SettingsPage {
.fontColor($r('app.color.text_hint')) .fontColor($r('app.color.text_hint'))
.width('100%') .width('100%')
} }
// ⭐ 全选 / 全不选:半选状态下点按 = 全选
Row({ space: 6 }) {
Text(`全选(已选 ${this.mirrorSelectedCount()}/${this.mirrorCandidates.length})`)
.fontSize(13)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary'))
.layoutWeight(1)
.maxLines(1)
Toggle({
type: ToggleType.Checkbox,
isOn: this.mirrorCandidates.length > 0
&& this.mirrorSelectedCount() === this.mirrorCandidates.length
})
.selectedColor($r('app.color.brand'))
.onChange((on: boolean): void => {
// 半选时勾选框显示为关,onChange(on=true) 走全选;全选时取消走全不选
if (on === (this.mirrorSelectedCount() === this.mirrorCandidates.length)) {
return; // 状态未变化(个别系统会重复回调),忽略
}
this.toggleMirrorAll();
})
}
.width('100%')
.padding({ top: 2, bottom: 2 })
.onClick(() => {
// 整行可点:直接按"是否已全选"翻转,比依赖 Checkbox 的 onChange 更可靠
this.toggleMirrorAll();
})
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) { Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
ForEach(this.mirrorCandidates, (b: BackupTarget): void => { ForEach(this.mirrorCandidates, (b: BackupTarget): void => {
Row({ space: 6 }) { Row({ space: 6 }) {
-20
View File
@@ -44,26 +44,6 @@
], ],
"when": "inuse" "when": "inuse"
} }
},
{
"name": "ohos.permission.LOCATION",
"reason": "$string:perm_location",
"usedScene": {
"abilities": [
"EntryAbility"
],
"when": "inuse"
}
},
{
"name": "ohos.permission.APPROXIMATELY_LOCATION",
"reason": "$string:perm_location_approx",
"usedScene": {
"abilities": [
"EntryAbility"
],
"when": "inuse"
}
} }
], ],
"deliveryWithInstall": true, "deliveryWithInstall": true,
@@ -24,14 +24,6 @@
"name": "perm_read_whole_calendar", "name": "perm_read_whole_calendar",
"value": "读取所有日历账户的日程,用于在日历中统一展示" "value": "读取所有日历账户的日程,用于在日历中统一展示"
}, },
{
"name": "perm_location",
"value": "将日程中的地址转换为坐标,用于发起高德地图导航"
},
{
"name": "perm_location_approx",
"value": "将日程中的地址转换为坐标,用于发起高德地图导航"
},
{ {
"name": "card_ability_desc", "name": "card_ability_desc",
"value": "同步日历服务卡片" "value": "同步日历服务卡片"