Files
FamilyBook_harmonyos/entry/src/main/ets/components/FbEventSheet.ets
T
laoyang 48a0507455 v73:桌面卡片刷新修复 + 首页日程/任务就地弹详情卡 + 一键导航
用户 2026-10-21 三条:
  「卡片里面的数据并不刷新……应用里面的日程已经有了,但卡片上的日程却并不刷新」
  「首页上的那个日程和任务,点击之后都应该像日程和任务里面的卡片一样弹出来,
    不要进入到日程里面……有地址的还可以直接一键导航」
  「任务也是一样,点击任务之后弹出来详情,可以标记它是不是完成了」

① 卡片不刷新(根因)
   卡片进程(FormExtensionAbility)与应用进程靠 preferences 传 formIds / 翻页偏移。
   SDK 明文禁止跨进程用 preferences,且 getPreferences() 在**进程内**缓存实例、
   同名再取不读盘 ⇒ 应用进程可能永久持有空注册表 ⇒ pushAll() 首行
   `if (list.length === 0) return` 直接返回 ⇒ 卡片只靠系统 30 分钟自刷。
   修:store() 每次先 removePreferencesFromCacheSync 再取;并写死单写者纪律
   (register/unregister/setOffset/clearOffset 只由卡片进程调,应用进程只读)。

② 首页点日程 → 弹 FbEventSheet(canEdit: false,首页不给编辑入口)
   有地址的行整行可点 + 右侧「➤ 导航」胶囊;「编辑日程」才跳日历模块
   (编辑是日历页的职责,不在首页复制第二份表单)。

③ 首页点任务 → 新的 FbTaskDetailSheet
   状态圆可点(勾完成)、标题带删除线、底部「标记为已完成/未完成」+「改一改」。
   状态**必须走 POST /tasks/<id>/status**(PATCH 会忽略 status)。

④ 一键导航 FbNav.ets:startAbility 隐式 Want(不是 openLink,那是 App Linking);
   canOpenLink 需 module.json5 声明 querySchemes(未声明恒 false 且不抛错);
   高德/百度/花瓣/腾讯按序探测,一个地图都没装则复制地点到剪贴板 + Toast。

⑤ 首页登记 FB_BACK(与其余 10 个模块一致),根容器 Stack(浮层是兄弟节点,
   不能当 Column 子项排,否则卡片被挤出屏幕)。

静态守卫 905/0、v73 反向验证 17/0、assembleHap 通过、已装机启动。
2026-10-21 20:01:37 +08:00

465 lines
20 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.
/**
* 家书 FamilyBook · 日程详情浮层(鸿蒙端)
*
* 用户 2026-10-15:「……包括下面日程的显示啊什么之类的,就按照 SyncCalendar 那个模式去做。」
* SyncCalendar 的详情是**半屏 bindSheet**,内容为:
* 标题(带日历本色点 + 系统/只读徽标 + 关闭)→ 时间 → 日历本/重复 →
* 地点(可点导航)→ 备注 → 「编辑日程」。
*
* 🔴 这里用**页面内 Stack 覆盖层**而不是 `bindSheet`(与家书其他浮层一致,
* 理由见 `FbMovieDetailSheet` 的文件头);
* **功能与信息结构**照 SyncCalendar 对齐。
*
* ⚠️ 详情数据是**异步拉的**(`/events/<id>` 才给 note/rrule/reminder/ends_at)
* ⇒ 三种态都要画:加载中 / 成功 / 失败。别只画成功态。
*/
import { FbColors, FbMetric, FbTint } from '../common/Theme';
import { FbModuleDef, fbModuleOf, fbThemeVars } from '../model/Modules';
import { FbCalEventFull } from '../data/FbCalRepo';
import { FbCalBook } from '../model/Types';
import { FbDot } from './Widgets';
import { FbSheetRing } from './FbSheetRing';
import { FbSheetClose } from './FbSheetClose';
import { fbCanNavigate } from '../common/FbNav';
const FB_SHEET_BOTTOM_GAP: number = 16;
@ComponentV2
export struct FbEventSheet {
/** 日程详情(null = 还在加载) */
@Param event: FbCalEventFull | null = null;
/** 加载中 */
@Param loading: boolean = false;
/** 加载失败说明(空 = 没失败) */
@Param error: string = '';
/** 这条日程属于哪本日历(列表里那份已知,用来显示本色/名字) */
@Param bookName: string = '';
@Param bookColor: string = '#9AA0A6';
/**
* 外圈环色 —— **模块主题色**(用户 2026-10-19:「弹框的外圈颜色,
* 跟模块的主题色保持一致」)。空 = 日程模块色。
* ⚠️ 与 `bookColor` 分工不同:环是"我在哪个模块里",
* 标题左侧那个点才是"这条日程属于哪本日历"(一本模块下可以有多本)。
*/
@Param accent: string = '';
/** 能不能编辑(不能写的日历本不给"编辑"按钮) */
@Param canEdit: boolean = false;
/**
* 点「导航」。
*
* 🔴 v73(用户 2026-10-21「有地址的可以一键导航」):
* 地点那一行从"纯文字"变成"可点"。
* ⚠️ 为什么把动作**抛给宿主**而不是在这里直接拉地图:
* 拉起应用要 `UIAbilityContext`,而组件里 `getUIContext().getHostContext()`
* 拿到的是 `common.Context`,还得强转 —— 那个强转在**卡片/浮层重建**时可能拿到
* undefined。宿主页本来就有可靠的上下文来源,且"要不要拉地图"属于导航决策
* (跟"当前在哪个模块"有关),不是这张卡的职责。
*/
@Event onNavigate: () => void = (): void => {
};
@Event onClose: () => void = (): void => {
};
@Event onEdit: () => void = (): void => {
};
/** 环色:显式给了就用给的,否则取日程模块色(与日程页其它浮层一致) */
private accentColor(): string {
if (this.accent !== '') {
return this.accent;
}
const m: FbModuleDef | undefined = fbModuleOf('calendar');
return m !== undefined ? fbThemeVars(m.color).brand : FbTint.brand;
}
/**
* 取主题色的**半透明版本**(八位色 `#AARRGGBB`)。
*
* 🔴 本项目踩过:ArkUI 的八位色是 **alpha 在前**(`#AARRGGBB`),
* 与 CSS 的 `#RRGGBBAA` **相反** ⇒ 直接把 CSS 那套搬过来会得到
* "几乎透明的怪色"或者干脆渲染成黑色。
* ⚠️ 还要兼容"传进来的本身就是八位色"(模块色可能带 alpha):
* 先剥掉已有的 alpha,再拼新的,否则会得到 10 位非法色值。
*/
private accentAlpha(aa: string): string {
const c: string = this.accentColor().replace('#', '');
const rgb: string = c.length >= 8 ? c.substring(2) : c;
return '#' + aa + rgb;
}
/**
* 标题文字。
*
* 🔴 2026-10-19:原来为空时写死「(无标题)」—— 但那条文案在**农历/节气**
* 这些衍生条目上会误导(用户看到"农历显示无标题"以为数据错了,
* 实际是标题为空时的兜底文案不合适)。派生条目本来就该用**日历本名**
* 当兜底(「农历」「节假日」本身就是它是什么),自建日程才用「未命名日程」。
*/
private titleText(): string {
const e: FbCalEventFull | null = this.event;
if (e === null) {
return '';
}
if (e.title !== '') {
return e.title;
}
// 日历本名能说明"这是什么"(农历/节假日/节气…)
if (this.bookName !== '') {
return this.bookName;
}
return '未命名日程';
}
/**
* 详情加载失败的说明。
*
* 🔴 2026-10-19:原来加载失败会把**整张卡**换成一句错误文案 ——
* 而本地列表那份本来就能画标题/时间/地点 ⇒ 现在改成
* **照常画内容 + 底下加一行小字说明**(见 `openEvent` 的说明)。
*/
private errorNote(): string {
if (this.error === '') {
return '';
}
return '部分详情没读回来:' + this.error;
}
/** 时间显示:`10月15日 周三 09:00 - 10:30` / `全天 10月15日 周三` */
private timeText(): string {
const e: FbCalEventFull | null = this.event;
if (e === null) {
return '';
}
const d: string = this.cnDate(e.starts_at);
if (e.all_day) {
return '全天 ' + d;
}
const t1: string = this.hhmm(e.starts_at);
if (e.ends_at === '') {
return d + ' ' + t1;
}
// 同一天就只写一次日期
const sameDay: boolean = e.ends_at.length >= 10 && e.starts_at.length >= 10
&& e.ends_at.substring(0, 10) === e.starts_at.substring(0, 10);
const t2: string = this.hhmm(e.ends_at);
return sameDay ? (d + ' ' + t1 + ' - ' + t2) : (d + ' ' + t1 + ' → ' + this.cnDate(e.ends_at) + ' ' + t2);
}
/** `YYYY-MM-DD HH:MM:SS` → `10月15日 周三` */
private cnDate(dt: string): string {
if (dt.length < 10) {
return '';
}
const y: number = parseInt(dt.substring(0, 4), 10);
const m: number = parseInt(dt.substring(5, 7), 10);
const d: number = parseInt(dt.substring(8, 10), 10);
const w: string[] = ['日', '一', '二', '三', '四', '五', '六'];
const day: number = new Date(y, m - 1, d).getDay();
return m.toString() + '月' + d.toString() + '日 周' + w[day];
}
/** `YYYY-MM-DD HH:MM:SS` → `HH:mm` */
private hhmm(dt: string): string {
return dt.length >= 16 ? dt.substring(11, 16) : '';
}
/**
* 重复规则 → 中文。
*
* ⚠️ 只翻译**端上能生成的那几种**(见 `FbEventEditSheet.REPEAT_OPTS`);
* 认不出的(网页端配的"每两周"等)原样显示 RRULE 串 ——
* "看不懂"也好过"显示成不重复"(那是错的)。
*/
private repeatText(): string {
const e: FbCalEventFull | null = this.event;
if (e === null || e.rrule === '') {
return '';
}
const r: string = e.rrule;
if (r.indexOf('FREQ=DAILY') === 0) { return '每天'; }
if (r.indexOf('FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR') === 0) { return '每个工作日'; }
if (r.indexOf('FREQ=WEEKLY') === 0) { return '每周'; }
if (r.indexOf('FREQ=MONTHLY') === 0) { return '每月'; }
if (r.indexOf('FREQ=YEARLY') === 0) { return '每年'; }
return r;
}
/** 提醒 → 中文(多个用顿号连) */
private remindText(): string {
const e: FbCalEventFull | null = this.event;
if (e === null || e.reminder_minutes === '') {
return '';
}
const out: string[] = [];
for (const x of e.reminder_minutes.split(',')) {
const n: number = parseInt(x.trim(), 10);
if (isNaN(n)) {
continue;
}
if (n === 0) { continue; }
if (n < 60) { out.push('提前 ' + n.toString() + ' 分钟'); }
else if (n === 60) { out.push('提前 1 小时'); }
else if (n === 1440) { out.push('提前 1 天'); }
else if (n % 1440 === 0) { out.push('提前 ' + (n / 1440).toString() + ' 天'); }
else if (n % 60 === 0) { out.push('提前 ' + (n / 60).toString() + ' 小时'); }
else { out.push('提前 ' + n.toString() + ' 分钟'); }
}
return out.join('、');
}
@Builder
row(label: string, value: string) {
Row({ space: 10 }) {
Text(label)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width(56)
Text(value)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.layoutWeight(1)
}
.width('100%')
.padding({ top: 8, bottom: 8 })
.alignItems(VerticalAlign.Top)
}
/**
* 「地点」这一行 —— 比普通 `row()` 多一颗「导航」标签,整行可点。
*
* ⚠️ 为什么单独抽一个 Builder 而不是给 `row()` 加参数:
* `row()` 有 2 个参数,加个 `tappable: boolean` 就变成 3 个 ——
* 3 个参数的 @Builder 更危险(多值参数不参与状态观察,本项目已踩 6+ 次)。
* 这里的数据是**每次打开浮层才建一次**(详情卡整体由 `if (detailOpen())`
* 重建),不存在"数据变了行不刷新"的场景,所以用 Builder 是安全的;
* 但仍保持**单参数**,把风险面压到最小。
*/
@Builder
navRow(place: string) {
Row({ space: 10 }) {
Text('地点')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width(56)
Text(place)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.layoutWeight(1)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
/* 标签用模块色(与「编辑日程」按钮同色系),一眼看出"这是这颗票能做的事"。
⚠️ 底色用**极浅的主题色**(`fbAlpha` 那套思路:八位色 `#AARRGGBB`,
alpha 在前)—— 深底白字会把这一行压得比标题还重。 */
if (fbCanNavigate(place)) {
Row({ space: 3 }) {
Text('➤')
.fontSize(10)
.fontColor(this.accentColor())
Text('导航')
.fontSize(FbMetric.fontTiny)
.fontColor(this.accentColor())
}
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.borderRadius(FbMetric.radiusPill)
.backgroundColor(this.accentAlpha('1A'))
.border({ width: 1, color: this.accentAlpha('33') })
}
}
.width('100%')
.padding({ top: 8, bottom: 8 })
.alignItems(VerticalAlign.Center)
.onClick((): void => {
this.onNavigate();
})
}
build() {
// FB_WRAP_CENTER:把卡片**垂直居中**(用户 2026-10-16 要求:浮在屏幕中间)。
// ⚠️ 居中放在**组件内部**做,而不是改宿主的 Stack 对齐 ——
// 宿主 Stack 里还有别的子元素(日程页的悬浮 +),改对齐会连带挪走它们。
// ⚠️ 外层铺满 + Center;内层仍是那张 92% 宽的圆角卡。
Column() {
// 🔴 2026-10-19:卡片外面的主题色环统一由 `FbSheetRing` 给
// (用户:「所有弹框都加线,保持一致」)。环色用**这本日历的颜色**,
// 一眼能看出这条日程属于哪个日历本。
Column() {
FbSheetRing({ radius: FbMetric.radius, ringColor: this.accentColor() }) {
Column({ space: 0 }) {
// ---- 标题区 ----
Row({ space: 10 }) {
FbDot({ dia: 12, fillC: this.bookColor })
/* 🔴 2026-10-19:标题为空时要**说清这是什么**,而不是写「(无标题)」。
农历 / 节气这类衍生条目的标题服务端是给了的(如「九月初一」),
真为空的情况主要是「没填标题的自建日程」—— 那种回「未命名日程」
比「(无标题)」更像人话。 */
Text(this.titleText())
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
.maxLines(2)
.layoutWeight(1)
if (this.event !== null && this.event.all_day) {
Text('全天')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusPill)
}
/* 🔴 关闭统一到**右上角**(用户 2026-10-19:全应用只留一种关闭位置)。
⚠️ 放在标题行的最右端,而不是下面独立一行 —— 独立一行会让卡片
顶部多出一段空白,标题反而往下掉。 */
FbSheetClose({
onClose: (): void => {
this.onClose();
},
color: ''
})
}
.width('100%')
.alignItems(VerticalAlign.Top)
Divider()
.color(FbColors.line)
.margin({ top: 12, bottom: 4 })
if (this.loading) {
Row({ space: 8 }) {
LoadingProgress().width(18).height(18).color(FbColors.faint)
Text('正在读取…')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.faint)
}
.width('100%')
.padding({ top: 20, bottom: 20 })
.justifyContent(FlexAlign.Center)
} else if (this.event === null) {
// 正常情况下不会走到这里(`openEvent` 一定会垫一份壳);
// 只有"打开浮层时状态被清掉"这种瞬时态才可能空着 ——
// 所以这里说的是"正在读取",不是"读不到"。
Text('正在读取…')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.faint)
.width('100%')
.padding({ top: 20, bottom: 20 })
} else {
Scroll() {
Column({ space: 0 }) {
this.row('时间', this.timeText())
if (this.bookName !== '') {
this.row('日历本', this.bookName)
}
// ⚠️ build() 里**不能声明变量**(只有 UI 组件语法)⇒
// 条件直接调方法(方法返回空串就不显示)
if (this.repeatText() !== '') {
this.row('重复', this.repeatText())
}
if (this.remindText() !== '') {
this.row('提醒', this.remindText())
}
if (this.event.location !== '') {
/* 🔴 v73(用户 2026-10-21「有地址的还可以直接一键导航」):
地点不再是纯文字 —— **整行可点**,右侧带一颗「导航」小标签。
⚠️ 为什么整行可点而不是只让那颗小标签可点:
小标签只有 40vp 宽,手指点不中会以为"没反应";
整行(含地点文字)都是热区,命中率高得多。
⚠️ 但**视觉上**仍要有那颗「导航」标签 —— 只有"整行可点"
而没有任何标记的话,用户根本不知道地点能点
(本项目踩过:卡尾"查看行程"是纯文本,用户以为按钮坏了)。 */
this.navRow(this.event.location)
}
if (this.event.note !== '') {
this.row('备注', this.event.note)
}
// 🔴 详情没拉全时给一行小字(而不是把整张卡变成"读不到")
if (this.errorNote() !== '') {
Text(this.errorNote())
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width('100%')
.padding({ top: 8 })
}
Row().height(8)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
/* 🔴 2026-10-19:原来是 `.layoutWeight(1)` —— 它让 Scroll **吃满剩余高度**,
于是短内容的日程也会把卡片顶到 `maxHeight`(72%),
按钮被推到最底、中间留一大片空白(真机截图:标题下面空半屏)。
⇒ 改成 `flexShrink(1)`:卡片按内容自适应高,
只有当内容真的超过 72% 时这一块才收缩并滚动。
⚠️ 不能只删掉 `layoutWeight`:`Column` 的子项默认 `flexShrink = 0`,
不收缩 ⇒ 长内容会**溢出卡片**被裁掉(反方向踩坑)。 */
.flexShrink(1)
.width('100%')
.scrollBar(BarState.Off)
.align(Alignment.TopStart)
}
// ---- 底部按钮(只有动作,关闭统一到右上角 ✕)----
/* 🔴 v73(用户 2026-10-21「有地址的还可以直接一键导航」):
有地点时底部多一颗「导航」—— 与上面那行可点的地点**并存**,
是有意的:地点行是"看的时候顺手点",底部按钮是"看完了决定要去"。
两个入口做同一件事,比只留一个更好用(日期详情卡不长,
地点行可能已经滚出视野)。
⚠️ 判据用 `event.location`,不用别的字段 —— 那是唯一权威的地点来源。
⚠️ 两个按钮用 `layoutWeight(1)` 均分宽度;只有一个时它自己占满。 */
if (this.event !== null
&& (fbCanNavigate(this.event.location) || this.canEdit)) {
Row({ space: 10 }) {
if (fbCanNavigate(this.event.location)) {
Text('导航')
.fontSize(FbMetric.fontSmall)
.fontColor(this.accentColor())
.textAlign(TextAlign.Center)
.layoutWeight(1)
.height(42)
.backgroundColor(this.accentAlpha('14'))
.borderRadius(FbMetric.radiusS)
.border({ width: 1, color: this.accentAlpha('33') })
.onClick((): void => {
this.onNavigate();
})
}
if (this.canEdit) {
/* 🔴 用户 2026-10-19:「点击日程弹出的框,编辑按钮也颜色也要修改。」
⇒ 原来写死名册青 #4A7C82,在日程页里跟模块色(棕)不一致。
改成 `accentColor()`(= 日程模块主题色,与卡片外环同色)。 */
Text('编辑日程')
.fontSize(FbMetric.fontSmall)
.fontColor(Color.White)
.textAlign(TextAlign.Center)
.layoutWeight(1)
.height(42)
.backgroundColor(this.accentColor())
.borderRadius(FbMetric.radiusS)
.onClick((): void => {
this.onEdit();
})
}
}
.width('100%')
.margin({ top: 14 })
}
}
// 🔴 用户 2026-10-16:「它应该弹出一个**圆角的、浮在上面的卡片**……
// **不要让它左右充满整个屏幕,下面也不要挨着屏幕**,
// 而是放在**屏幕中间**,采用**圆角浮窗**式的形式。」
// ⇒ 不再贴底满宽:左右留边、四角圆角、垂直居中(由宿主的 Stack 对齐)。
.width('100%')
.constraintSize({ maxHeight: '72%' })
.padding({ left: 18, right: 18, top: 18, bottom: 20 + FB_SHEET_BOTTOM_GAP })
.alignItems(HorizontalAlign.Start)
}
}
.width('92%')
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.hitTestBehavior(HitTestMode.Transparent)
}
}