Files
SyncCalendar/entry/src/main/ets/entryability/EntryAbility.ets
T
laoyang b8d5aa630c fix(卡片/粘贴): 5 项反馈修复 —— 粘贴按钮、卡片数据、卡片加号、卡片滚动、列表统一
## 1. 粘贴按钮文字看不清(EventEditPage)

根因不是"忘了设文字"——空参时系统本就给默认样式(LINES 图标 + PASTE 文案)。
真正原因是我**给了一个和系统文字颜色太接近的背景色**(card_bg 近乎白)。

查 SDK 类型定义 `paste_button.d.ts`,错误码 2 明确列出:
  「字体或图标颜色与组件背景颜色相近」「文字超出组件背景区域」等
安全组件创建时会做样式校验,对比度不足会让文字灰淡难辨、甚至授权失败。

同时发现 icon/text/buttonType **只能走构造参数**,不是链式属性
(`.text()` 会编译报 "Property 'text' does not exist")。

改为:
```
PasteButton({
  icon: PasteIconStyle.LINES,      // 该枚举只有 LINES
  text: PasteDescription.PASTE,    // 该枚举只有 PASTE
  buttonType: ButtonType.Capsule
})
```
并**去掉自定义背景色**,交给系统默认样式,只保留尺寸与圆角。

## 2. 待办卡片显示 0 条(CardDataService)

`pushToAllForms()` 是同步完成后刷新所有卡片的主路径,
但它只调 `buildCardData()`(日程),**从未调 `buildTodoCardData()`** ⇒
推给卡片的 binding 里 todoCount / todoListJson 全是默认值。

而 EntryFormAbility.pushData() 里合并了待办 —— 两条路径不一致,
所以表现成"有时对、有时 0"。

修:抽出 `mergeTodoInto()` 供两处共用;待办数据与 dayOffset 无关,
整个 pushToAllForms 只构建一次。

真机日志:`待办卡片数据刷新:未完成 5 条…列表输出 5 条`(原为 0)。

## 3. 待办卡片右上角 + 点了没反应(EntryAbility + Index)

待办卡片发的是 `widgetAction: 'addTodo'` / `'openTodo'`,
但 EntryAbility 只判断 `raw === 'add'`,Index 也只处理 `'add'`
⇒ 参数被静默丢弃。

修:两端都补上 addTodo / openTodo。
  addTodo  → 切待办 Tab + 打开新建待办
  openTodo → 切待办 Tab

## 4. 日程 2×4 卡片右侧可滚动,15 条(CardDataService + Widget4x2)

数据侧 `slice(0, 2)` → `slice(0, 15)`
视图侧 `Column + ForEach` → `List + ListItem`(虚拟化 + 滚动惯性 + 回弹)

真机日志:`进行中 15 条`,确认上限生效。

## 5. 待办 4×4 / 6×4 列表样式统一,全部可滚动

按用户选择,**统一成 4×4 样式**(勾选框 + 色条 + 标题 + 副行「截止时间 · 日历本」)。

6×4 原来是「标题 + 独立截止时间列 + 独立日历本列」,窄卡片里会把标题挤没,
改为与 4×4 完全相同的结构。已用逐行 diff 校验:两卡 todoRow **36 行完全一致**。

`MAX_TODO_ITEMS` 20 → 15,与日程卡片口径一致(所有待办卡片都已可滚动,
不必再按卡片尺寸给不同上限)。

## 编译
`BUILD SUCCESSFUL`

## 未验证
真机仅验证了「待办卡片数据不再为 0」与「日程 15 条上限」两项。
粘贴按钮视觉、卡片内滚动、卡片 + 跳转、两卡样式一致性需上机目视确认。
2026-11-03 23:14:43 +08:00

210 lines
9.5 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { AbilityConstant, ConfigurationConstant, UIAbility, Want, Configuration, EnvironmentCallback } from '@kit.AbilityKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { window } from '@kit.ArkUI';
import { BackgroundSyncService } from '../common/BackgroundSyncService';
import { NetworkSyncWatcher } from '../common/NetworkSyncWatcher';
import { BackgroundSyncRunner } from '../common/BackgroundSyncRunner';
import { LogUtil } from '../common/LogUtil';
const DOMAIN = 0x0000;
export default class EntryAbility extends UIAbility {
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onCreate');
// 日志初始化:发行版只输出 console(不再落盘 sync.log),同时清理历史遗留调试文件
LogUtil.init(this.context);
LogUtil.write('---- App 启动 ----');
// 后台同步:注册延迟任务兜底(2 小时)+ 网络恢复补同步监听
// ⚠️ 不再申请"长时任务":dataTransfer 类型有 10 分钟无进度即被取消的一致性校验,
// 日历同步不符合该语义、必被取消(详见 BackgroundSyncService 顶部注释)。
BackgroundSyncService.initOnLaunch(this.context);
NetworkSyncWatcher.start(this.context);
// 服务卡片"添加"按钮跳转:把 widgetAction=add 透传给主页面(在 AppStorage 中暂存)
this.handleWidgetParam(want);
// ⚠️ 这里**不再申请任何权限**(含通知权限)。
// 依据《审核指南》7.17:应用不得在用户未主动点击对应功能时提前弹窗申请权限。
// 通知权限改为在用户主动操作时申请:
// ① 保存带提醒的日程(EventEditPage)② 开启「后台持续同步」(SettingsPage)
// ③ 设置页「通知权限」→「去开启通知」按钮
}
onDestroy(): void {
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onDestroy');
}
/** 卡片"添加"按钮已在后台运行时再次点击:同样把 widgetAction 透传给主页面 */
onNewWant(want: Want, launchParam: AbilityConstant.LaunchParam): void {
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onNewWant');
this.handleWidgetParam(want);
}
/** 读取卡片深链参数:widgetAction=add 拉起新建日程;openDate 定位到卡片当前显示日 */
private handleWidgetParam(want: Want): void {
const params = want.parameters;
if (params !== undefined) {
// ⭐ 白名单:日程卡片发 'add',待办卡片发 'addTodo'(新建待办)/ 'openTodo'(进待办页)。
// 之前只认 'add',待办卡片点 + 毫无反应 —— 参数被静默丢弃。
const raw = params['widgetAction'];
if (typeof raw === 'string' && (raw === 'add' || raw === 'addTodo' || raw === 'openTodo')) {
AppStorage.setOrCreate<string>('widgetAction', raw);
LogUtil.write(`卡片入口:已设置 widgetAction=${raw}`);
}
// 卡片点击进入日历时,定位到卡片当前显示的那一天(用户可能已翻到其他日期)
const od = params['openDate'];
let odMs: number = 0;
if (typeof od === 'number') {
odMs = od;
} else if (typeof od === 'string' && od.length > 0) {
const parsed = parseInt(od, 10);
if (!isNaN(parsed)) {
odMs = parsed;
}
}
// 合法性校验:13 位毫秒时间戳若按 int32 传递会溢出成 2147483647 等小值 →
// 只接受 [2000-01-01, 2100-01-01] 区间,避免把日历定位到 1970
if (odMs >= 946684800000 && odMs <= 4102444800000) {
AppStorage.setOrCreate<number>('widgetOpenDate', odMs);
LogUtil.write(`卡片进入日历:定位到显示日 ${odMs}`);
} else if (odMs > 0) {
LogUtil.write(`卡片 openDate 非法(疑似 int32 溢出):${odMs},忽略`);
}
}
}
onWindowStageCreate(windowStage: window.WindowStage): void {
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onWindowStageCreate');
// ① 先把应用固定为「跟随系统」深浅色。必须在取窗口/刷系统栏之前调用,
// 否则会读到尚未更新到深色的色彩配置(曾导致深色下状态栏仍是浅灰)。
try {
this.context.getApplicationContext().setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET);
} catch (err) {
hilog.error(DOMAIN, 'testTag', 'Failed to set colorMode. Cause: %{public}s', JSON.stringify(err));
}
// ② 沉浸式配色:状态栏/导航条背景**透明**,透出「主题化」的窗口底色,
// 消除顶部通知栏与底部灰条;前景(时间/图标)颜色按实际底色亮度取反。
try {
const win: window.Window = windowStage.getMainWindowSync();
this.mainWindow = win;
this.applySystemBars(win);
} catch (err) {
hilog.error(DOMAIN, 'testTag', 'Failed to apply immersive bars. Cause: %{public}s', JSON.stringify(err));
}
windowStage.loadContent('pages/Index', (err) => {
if (err.code) {
hilog.error(DOMAIN, 'testTag', 'Failed to load the content. Cause: %{public}s', JSON.stringify(err));
return;
}
hilog.info(DOMAIN, 'testTag', 'Succeeded in loading the content.');
});
// ③ 系统深浅色切换时重刷配色。用 applicationContext 的 environment 回调,
// 它比 UIAbility.onConfigurationUpdate 更可靠(后者在部分场景下不回调,
// 会出现"页面已变深色但状态栏/底部仍是浅灰"的现象)。
try {
const cb: EnvironmentCallback = {
onConfigurationUpdated: (config: Configuration): void => {
if (this.mainWindow !== undefined) {
this.applySystemBars(this.mainWindow);
}
},
onMemoryLevel: (level: AbilityConstant.MemoryLevel): void => {
}
};
this.context.getApplicationContext().on('environment', cb);
} catch (err) {
hilog.error(DOMAIN, 'testTag', 'Failed to watch environment. Cause: %{public}s', JSON.stringify(err));
}
}
/** 主窗口引用:系统深浅色切换时需要重新刷状态栏/导航栏配色 */
private mainWindow?: window.Window;
/** 当前是否处于系统深色模式(兜底用) */
private isDarkMode(): boolean {
try {
return this.context.config.colorMode === ConfigurationConstant.ColorMode.COLOR_MODE_DARK;
} catch (err) {
return false;
}
}
/**
* 取当前主题下 app.color.page_bg 的真实取值(经 dark/ 限定符解析):
* 浅色 #F1F3F5 / 深色 #000000。以"资源系统实际解析出的颜色"为准,
* 这样才能与页面真正渲染出来的背景色一致,不受 config 更新时机影响。
*/
private themePageBg(): string {
try {
const c: number = this.context.resourceManager.getColorSync($r('app.color.page_bg').id) >>> 0;
return '#' + c.toString(16).padStart(8, '0');
} catch (err) {
return this.isDarkMode() ? '#000000' : '#F1F3F5';
}
}
/** 背景是深色时需要浅色前景,反之用深色前景 */
private isDarkBg(bg: string): boolean {
const s: string = bg.startsWith('#') ? bg.substring(1) : bg;
if (s.length < 6) {
return this.isDarkMode();
}
const off: number = s.length >= 8 ? 2 : 0;
const r: number = parseInt(s.substring(off, off + 2), 16);
const g: number = parseInt(s.substring(off + 2, off + 4), 16);
const b: number = parseInt(s.substring(off + 4, off + 6), 16);
if (isNaN(r) || isNaN(g) || isNaN(b)) {
return this.isDarkMode();
}
return (0.2126 * r + 0.7152 * g + 0.0722 * b) < 128;
}
/**
* 按当前主题设置状态栏/导航条与窗口底色。
* 背景取 0x00000000(全透明)——官方推荐的背景沉浸方案:系统栏透明,
* 透传应用自身背景,因此底色天然跟随深浅色,不会再出现恒定灰条。
*/
private applySystemBars(win: window.Window): void {
const bgColor: string = this.themePageBg();
const fgColor: string = this.isDarkBg(bgColor) ? '#FFFFFF' : '#182431';
try {
win.setWindowSystemBarProperties({
statusBarColor: '#00000000',
statusBarContentColor: fgColor,
navigationBarColor: '#00000000',
navigationBarContentColor: fgColor
});
// 窗口底色=页面背景色,供透明的状态栏/导航条透出
win.setWindowBackgroundColor(bgColor);
} catch (barErr) {
hilog.error(DOMAIN, 'testTag', 'Failed to set system bar properties. Cause: %{public}s',
JSON.stringify(barErr));
}
}
/** 兜底:部分设备/场景只回调 UIAbility.onConfigurationUpdate */
onConfigurationUpdate(): void {
if (this.mainWindow !== undefined) {
this.applySystemBars(this.mainWindow);
}
}
onWindowStageDestroy(): void {
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onWindowStageDestroy');
}
onForeground(): void {
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onForeground');
// ⭐ 回到前台:① 归还短时任务配额 ② 立刻补同步一次(用户真正要看时数据必须最新)
BackgroundSyncService.cancelTransientSync();
BackgroundSyncRunner.runOnce(this.context, 'foreground');
}
onBackground(): void {
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onBackground');
// ⭐ 退到后台:申请短时任务,兜住"退后台后那一次"同步能跑完
// (必须在退后台 5 秒内申请;单次最长 3 分钟,单日配额 10 分钟)
BackgroundSyncService.requestTransientSync('正在同步日历数据');
}
}