From 776c95ba0b8c9519c949326e55eba9cd7dfc1f14 Mon Sep 17 00:00:00 2001 From: laoyang Date: Sun, 18 Oct 2026 03:35:39 +0800 Subject: [PATCH] =?UTF-8?q?v0.7.0=20=E6=8E=A5=E4=B8=8A=E7=9C=9F=E5=AE=9E?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E5=99=A8=EF=BC=9A=E7=99=BB=E5=BD=95=20/=20?= =?UTF-8?q?=E8=AE=BE=E5=A4=87=E4=BC=9A=E8=AF=9D=20/=20=E5=85=A8=E9=87=8F?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E4=B8=8E=E5=90=84=E6=A8=A1=E5=9D=97=E7=9C=9F?= =?UTF-8?q?=E5=AE=9E=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 从「演示数据壳子」走到「真的能连家里的服务器用」。 登录(用户 2026-10-17「完整的登录功能」) · 首次启动先填服务器地址 → /api/v1/ping 自报门户(认得出"这地址上真是家书") · 手机号或用户名 + 密码 → 拿令牌;失败提示统一,不泄露账号是否存在 · FbDeviceStore:首次启动生成并持久化设备 UUID(三方应用拿不到 UDID), 设备名取机型(marketName),并上报 platform / app_version 设备会话(v57,用户 2026-10-17) · 一把令牌 = 一台设备的一条会话;每次启动打一次 /me(有网就验) · 两种锁分开:need_login(401/被踢 → 清令牌)与 offline_expired (离线超 24h → 只锁界面、不清数据、联网自动恢复)—— 混成一种就会违背用户要的"一直联网就不用每天输密码" · 运行期任何请求 401 → 网络层全局回调立刻锁定跳登录页(FbHttp 的 authProbe 让登录/校验类请求跳过,避免"两个地方同时跳登录页") · 退出登录:先清本地令牌(主防线),再尽力通知服务端 · 设置页显示版本号与「这台设备」 数据层 · FbDb / FbSchema / FbParse / 各 Repo(日程 / 名册 / 观影 / 模块)+ FbSyncAll · 首页改为服务端 /home(农历/节气/纪念日/生日/天气/统计/往年的今天) · 日程页用 FbDayEventPanel,与网页端显示方式/红线/并排三处一致 验证:ArkTS 静态守卫 231/0;hvigor 编译 BUILD SUCCESSFUL(entry-default-signed.hap)。 --- AppScope/app.json5 | 6 +- AppScope/resources/base/media/fb_logo.png | Bin 0 -> 4696 bytes .../resources/base/media/layered_image.json | 7 - entry/src/main/ets/common/FbGrip.ets | 102 ++ entry/src/main/ets/common/FbScroll.ets | 213 +++ entry/src/main/ets/common/FbTimeline.ets | 407 +++++ entry/src/main/ets/common/Store.ets | 148 ++ entry/src/main/ets/components/Drawer.ets | 117 +- .../main/ets/components/FbCalOptionRow.ets | 72 + .../ets/components/FbCalSettingsSheet.ets | 253 +++ .../src/main/ets/components/FbCinemaSheet.ets | 158 ++ .../main/ets/components/FbDayEventPanel.ets | 182 ++ .../src/main/ets/components/FbDayTimeline.ets | 476 ++++++ entry/src/main/ets/components/FbEventChip.ets | 50 + .../main/ets/components/FbEventEditSheet.ets | 908 ++++++++++ .../src/main/ets/components/FbEventSheet.ets | 269 +++ entry/src/main/ets/components/FbKinGraph.ets | 288 ++++ .../ets/components/FbModuleSettingsSheet.ets | 150 ++ .../ets/components/FbMovieDetailSheet.ets | 506 ++++++ .../main/ets/components/FbPersonEditSheet.ets | 1415 ++++++++++++++++ .../src/main/ets/components/FbPersonSheet.ets | 481 ++++++ entry/src/main/ets/components/FbStatCell.ets | 58 + entry/src/main/ets/components/FbTagChip.ets | 59 + .../src/main/ets/components/FbTagPickChip.ets | 52 + entry/src/main/ets/components/FloatDock.ets | 285 +++- entry/src/main/ets/components/TopBar.ets | 48 +- entry/src/main/ets/components/Widgets.ets | 102 +- entry/src/main/ets/data/FbCalRepo.ets | 278 +++ entry/src/main/ets/data/FbCalSettings.ets | 161 ++ entry/src/main/ets/data/FbDb.ets | 460 +++++ entry/src/main/ets/data/FbDeviceStore.ets | 163 ++ entry/src/main/ets/data/FbHttp.ets | 385 +++++ entry/src/main/ets/data/FbLogin.ets | 192 +++ entry/src/main/ets/data/FbModuleLoad.ets | 92 + entry/src/main/ets/data/FbModuleRepo.ets | 160 ++ entry/src/main/ets/data/FbMovieRepo.ets | 890 ++++++++++ entry/src/main/ets/data/FbParse.ets | 864 ++++++++++ entry/src/main/ets/data/FbPersonRepo.ets | 985 +++++++++++ entry/src/main/ets/data/FbSchema.ets | 297 ++++ entry/src/main/ets/data/FbServerStore.ets | 179 ++ entry/src/main/ets/data/FbSession.ets | 227 +++ entry/src/main/ets/data/FbSyncAll.ets | 176 ++ entry/src/main/ets/data/FbTokenStore.ets | 131 ++ .../main/ets/entryability/EntryAbility.ets | 277 ++- entry/src/main/ets/model/DemoData.ets | 124 +- entry/src/main/ets/model/FbSettingsMenus.ets | 61 + entry/src/main/ets/model/Types.ets | 236 ++- entry/src/main/ets/pages/Index.ets | 373 ++++- .../src/main/ets/pages/books/FbBooksPage.ets | 195 ++- .../ets/pages/calendar/FbCalendarPage.ets | 1274 ++++++++++---- entry/src/main/ets/pages/days/FbDaysPage.ets | 156 +- entry/src/main/ets/pages/home/FbHomePage.ets | 743 +++++--- .../main/ets/pages/ledger/FbLedgerPage.ets | 222 ++- .../src/main/ets/pages/login/FbLoginPage.ets | 674 ++++++++ entry/src/main/ets/pages/misc/FbMiscPages.ets | 58 +- .../main/ets/pages/movies/FbMoviesPage.ets | 497 +++++- .../main/ets/pages/people/FbPeoplePage.ets | 1489 ++++++++++++++--- .../ets/pages/resources/FbResourcesPage.ets | 229 ++- .../ets/pages/settings/FbSettingsPage.ets | 306 +++- .../src/main/ets/pages/tasks/FbTasksPage.ets | 229 ++- .../src/main/ets/pages/trips/FbTripsPage.ets | 201 ++- entry/src/main/module.json5 | 20 +- .../main/resources/base/media/background.png | Bin 6274 -> 0 bytes .../main/resources/base/media/foreground.png | Bin 12045 -> 0 bytes 64 files changed, 18297 insertions(+), 1519 deletions(-) create mode 100644 AppScope/resources/base/media/fb_logo.png delete mode 100644 AppScope/resources/base/media/layered_image.json create mode 100644 entry/src/main/ets/common/FbGrip.ets create mode 100644 entry/src/main/ets/common/FbScroll.ets create mode 100644 entry/src/main/ets/common/FbTimeline.ets create mode 100644 entry/src/main/ets/components/FbCalOptionRow.ets create mode 100644 entry/src/main/ets/components/FbCalSettingsSheet.ets create mode 100644 entry/src/main/ets/components/FbCinemaSheet.ets create mode 100644 entry/src/main/ets/components/FbDayEventPanel.ets create mode 100644 entry/src/main/ets/components/FbDayTimeline.ets create mode 100644 entry/src/main/ets/components/FbEventChip.ets create mode 100644 entry/src/main/ets/components/FbEventEditSheet.ets create mode 100644 entry/src/main/ets/components/FbEventSheet.ets create mode 100644 entry/src/main/ets/components/FbKinGraph.ets create mode 100644 entry/src/main/ets/components/FbModuleSettingsSheet.ets create mode 100644 entry/src/main/ets/components/FbMovieDetailSheet.ets create mode 100644 entry/src/main/ets/components/FbPersonEditSheet.ets create mode 100644 entry/src/main/ets/components/FbPersonSheet.ets create mode 100644 entry/src/main/ets/components/FbStatCell.ets create mode 100644 entry/src/main/ets/components/FbTagChip.ets create mode 100644 entry/src/main/ets/components/FbTagPickChip.ets create mode 100644 entry/src/main/ets/data/FbCalRepo.ets create mode 100644 entry/src/main/ets/data/FbCalSettings.ets create mode 100644 entry/src/main/ets/data/FbDb.ets create mode 100644 entry/src/main/ets/data/FbDeviceStore.ets create mode 100644 entry/src/main/ets/data/FbHttp.ets create mode 100644 entry/src/main/ets/data/FbLogin.ets create mode 100644 entry/src/main/ets/data/FbModuleLoad.ets create mode 100644 entry/src/main/ets/data/FbModuleRepo.ets create mode 100644 entry/src/main/ets/data/FbMovieRepo.ets create mode 100644 entry/src/main/ets/data/FbParse.ets create mode 100644 entry/src/main/ets/data/FbPersonRepo.ets create mode 100644 entry/src/main/ets/data/FbSchema.ets create mode 100644 entry/src/main/ets/data/FbServerStore.ets create mode 100644 entry/src/main/ets/data/FbSession.ets create mode 100644 entry/src/main/ets/data/FbSyncAll.ets create mode 100644 entry/src/main/ets/data/FbTokenStore.ets create mode 100644 entry/src/main/ets/model/FbSettingsMenus.ets create mode 100644 entry/src/main/ets/pages/login/FbLoginPage.ets delete mode 100644 entry/src/main/resources/base/media/background.png delete mode 100644 entry/src/main/resources/base/media/foreground.png diff --git a/AppScope/app.json5 b/AppScope/app.json5 index 4d48eff..87e3d0f 100644 --- a/AppScope/app.json5 +++ b/AppScope/app.json5 @@ -2,10 +2,10 @@ "app": { "bundleName": "familybook.yangyq.net", "vendor": "yangyq", - "versionCode": 100, - "versionName": "0.1.0", + "versionCode": 700, + "versionName": "0.7.0", "buildVersion": "1", - "icon": "$media:layered_image", + "icon": "$media:fb_logo", "label": "$string:app_name" } } \ No newline at end of file diff --git a/AppScope/resources/base/media/fb_logo.png b/AppScope/resources/base/media/fb_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..e26dfef2cd2f034828a91f41b57e4ecf9ec40502 GIT binary patch literal 4696 zcmXY#c{r3`8^@m+Gh=J)SsPo{ti>cGL&9WFF)3TgFS{67GsB34h-A-LimX%FLS_*1 zEBjtyNExzB_AKKa@B997o^!6}y03G8o^ze^ea`oex3)BabBJ;P002ju8eL)bl%vK5 zV?M8Z_hw~w?Ea?L0|DT%$58{lx%2J}0Pu03jSQ}a8i!VN*S>e; z>4|qu^WspjXbt8O&Lk!}m?KT^l=4=Y`h=A0pP|89J%e*Lu4_&n_lON8FvFy*?jp}) zBN&df3`fy#CYSy>*ugw<6r+2Y-5K=pOLX@{unrC@BmJniSBG2W%Zuv?FU>0&It9`U_YXR`#dtofGq zjcxFLeFN=<*$6Efz>h>Vj7TyquRL`QV%-If5SQQRVdaR9T(qvjbI*JKsGfW|H zxbtzjX=6J7{uP2KTf(ySy!jJ~L_+QU!?0_hy+XySETJ9Vl+T3&8q@w@MjAw-RUTOp zrGEWduZ*?rvB|%GShL!OMyQzZy?Ff|)qP-$EzPfFmLETeKq}beXkO>X-w*F+eRk`P z1B=}=#E!~+Avge1aJ){S&e^m|ESK)p)r3F}4Hd4kyaUO>-Qthkr$`%NZt-zPnhAD1_A71HugNFC^X?a_B&njoJ za3Ai~Di$%|O}N4G(-XU{QwP6sruyzupyIB=Ogr)wb0xg`U)}M%&49ZwY6u_3dwoRW zLEC<#8M)nvwIl7cNW}}r4=IVL(J8H7hCc0}rdecj56kO5jc@?c$nlftzD_r5n;PJS z*dF&q4pP`Gyx+PxMurri&9$HOj9%{dW<0-$0_!NJA9`MBe$W-<0afQ%BSs?7l7U*{ z@&1_)9?;+6zgp{%CO_uA2>+&RV=EZbEPxP9z7(@ajQD#o zW2VmtI9|7f<|&Ze4Khu>gz-K+PM2>IWjq%oPsAO53Jk2m>)V37*+ z&$^gnyyfcBod}2Yx2&wK`h1{W*^m=PCWzf2G`Fnp+Xd&Z2d=;0=+32o{PvsZ{ios? zJ^3n--e@ll8n8)-d@y*DS1|m56Qn%#cv4DDX3B>1j#bYIKE?oBznX#6$8T0e&Ltbh z4n8|0dxN6bPJViZYWBTrJ0?o_%gc-4RLZ}-nXjjtAj8h?t+8FpHo-p$oa;kv`EVwt zQ>5_hGLkl?;wV1Q?Pt$Pg^nZc1#)a_5~2fToxrW7}p~ zN7(&U&YD-nkm*&4Gkp(%Q%JQhu_50SHyTYdE@3LN>rfiX4mB_NE@W!mGosI52GSSj zR|(@2r?92PW>O`0gNoWKETje{X=OpXWRxD0`=WGDIw^Q1 z#-=sji}`KOmDnwbV`hz#ZMX8FSlXkN1;v zltasovNG}GEtqTzF3r7ppBwG+6ew!cjS+*7A5bc<6QIM6;|r*M zrFh5f`8&rWOQ0m#ko!7DqDuV+Y1y$5QyT##443S?PIpA)G0@dAK`Vmkfem)NI93Y8 zAWG&{oVe^hmCC>K2C`!q9e|}4Gfzd!)niH_kjw6Kzj~8Z@RxrM*-Et~6EDgmIoQc>agA16+k%84b4fH_Z3SGgNFd$b#m$|FL_kRYL|@V;_&X}@PTI%q zM8eqebO7CzmI>aSAYRIIml-#atC6h zbM@Z{P33L*J!z}X0gMgAb3+28u`j;HaY@@nXtU%VhNv~`ZaO34oJ~N9pZWI1AKWwr z^OOBe)A5Zx3`m zg$Nt)W^;ST`lR`ySf_fx!k%)OI9=?C2ds$Un;mHyl&pgsmv6-q#P$^^=$Nd*j8A{0 zo6>GWD$uTAIMk9v3Yy&>FxXB7I`NRWdLm2Ca)>9abh?(S&azn?5unE5GR~JsSZCEL zOx9pe;bUmk#7!@Qg<1GcjN`d{>t^Fc@T(DsI&)zHSG@#8T@~%W(!NRiVF@nP#dUjC zMNKOm1uq4YAyqFxi&KA*-Xg2wJ0E251#6!w{qOgA(N64K!8cZxJF=4JAqovLtt<8e zq4ad1_J-4a+5Hy+L~1IeX|ynKh7)Azry5<~h2uuJ@k#onKq{Le6MExHpruZSI2w=! zgxcr9v!}yF^7ySd{L4b1I?XbuDUx`6c0D|GbV#l>qgl z-wR__&5Tg}Lwy4*#2_Zxx^@oNZ-5NQ?5wvVuFr{s%wfkbB^R({|Gw(CGy8`aH1F3h zg$&4z-R%2%JE1pY6g*B>L&a%~+35G%;{t-HjX&ngmaS4loT6uCzoL2iIT+48CUHaY z!sYqG*n^}TJ0gqcxs=_YFf=g^kRv499cS(#qq^^*dYGU%*ofqy|Ch-2K?%O|l}Qj9 z_>nc@nHr7_VO|nI%i4>pfZkX?r;{6d(bX%zMhl2g!ABoyd4B=+j89iAgZgY2rE)ei zHjS?$6zmuc$nD2v=sn^p9%6M~Se*SyIDPUZB$+@fUC#jiZ0RW6IM0bNO(axfYya%F zWI!RIDgO0vg2zwyrz&VTdX*5$KQI&6egwCC1owY& z4rX&{Kje(YH5_H{VrEbOzcTxQ{wKze4c+P-u9m@$YS!Dvr~a$SG)0ts8Ynz6QcNer z_dFATES@N$_vs&*iBFy{5a`1(Dbs6rG zPG)U&R`a)u{-@+(@f)xS@IRT>fiYJ0x}riC*;=dK)9?ABHs6XoL0i{`h#D;>>nNe* zmEuyf>d(Orqq@8ml-6}VyI?NE;H+u{gWyD=$Jmr!zthBzB^zi+Cp6y zSB}IOXa6=@;DNegH}42>dq_b?ze-S2#7B1LrmBnd*XZ7-UQikuTgWXbt6e6#A)}0a za#IQAoGb}As=7KSqvk)=g8OI34&L{FOFD|Bh@ZcFMhQvz^#~%FX>;efDBe;7K%>Bz z4K|}>q$Uec3A%fhXd;;)*s3YEF(8V+A9eRE-~jO=iI(_SulAZ=VR;_$j#J=uIX>3n z5>Lc3PVst$LHjG|raBpXC%Du=$G^|w*@;o2t1WL2pu<62v4oH{)=bXivXk;dJjC5FhKR4|?W;c@Q4#1(-&N8a`S|AC5nc4~GzG*H~V+{-~KB0|Xv;Ng`e1nUD~&@ew41{<#TN z6sjr@YS@O*Cz<2%EGbY!J%qllO)=g2g$7nsK>Lw=xeI&RGQjq8AQC5}Fti`x76vGeYQBC>$~El?QW$)@rFZTy7=G$yPMXq(j= z&l5fsf0uI?xRP2_7A1Po`$_C$()d4%yb95iPs?AfrYRWEuJDHIClS}vjPWEs;_;@F z_GZ%GAwprXWZ_pd8v>`^WC~lp!TkK9YtJHYsUZ2Jz15jPL%xP%3^$``ll&;QglnuZ z-JeOiXL3uM&}w>I9{DX+g{lqf+-wwXSWJgg$BL=3w9$>FJZSBQ=iS;MS(D(sDc^AA z;Y?aH6=8^K%`ibiTWuU}DSQpdt zMm^QsY^hnvw8!2ADJ$1W?9DPAXXv$^P{o>>eYB*Iim2W? z-1Y6L=oHCkI-``i^v44z)o(AyUTqgRD?jvfd9cvyxAr!-UB91LFjKxUcmkw=d>us^ z4`)^bkS5ea0^zOnSLqH`N;U6+norfgyU|){auyfjxjxff>5k@tt4#k5)?wBr9F7M6IsJ2nld`+gxtK2uvJ5Af~YF0eIiTHr~B7IasDf44q loxIizk)s)h=b!<5INPi!Wt!HFcxI^xKwq*nsxowm{tt5^uFn7f literal 0 HcmV?d00001 diff --git a/AppScope/resources/base/media/layered_image.json b/AppScope/resources/base/media/layered_image.json deleted file mode 100644 index fb49920..0000000 --- a/AppScope/resources/base/media/layered_image.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "layered-image": - { - "background" : "$media:background", - "foreground" : "$media:foreground" - } -} \ No newline at end of file diff --git a/entry/src/main/ets/common/FbGrip.ets b/entry/src/main/ets/common/FbGrip.ets new file mode 100644 index 0000000..70855e4 --- /dev/null +++ b/entry/src/main/ets/common/FbGrip.ets @@ -0,0 +1,102 @@ +/** + * 家书 FamilyBook · 「质感握姿」(鸿蒙端) + * + * 🔴 用户 2026-10-16 第 4 条: + * 「再加上**质感握姿**,也就是左手边握的时候,把家书图标放在左边; + * 右手边握的时候,它就跑到右边去,这样方便点击并弹出抽屉。 + * 也就是说,**左手握就从左边弹出抽屉,右手握就从右边弹出抽屉**。」 + * + * 实现:用**官方**的 `@kit.MultimodalAwarenessKit` → `motion` 模块, + * 监听 `holdingHandChanged`(`HoldingHandStatus`:左手/右手/双手/未持)。 + * ⚠️ 这**不是**自己拿重力/陀螺仪猜的 —— 猜出来的"左右手"极不可靠, + * 而且华为系统本来就把这个能力作为公开 API 提供。 + * + * ⚠️ 权限:`ohos.permission.DETECT_GESTURE` + * (已在 `module.json5` 声明;它是 system_grant + normal ⇒ 安装即得, + * 不会弹授权框)。 + * ⚠️ 版本:`@since 20`。低版本设备调 `on()` 会抛 **801(能力不支持)** + * ⇒ 全程 try/catch,拿不到就**保持默认方向**(左),不影响其它功能。 + * ⚠️ 必须 `off()` 取消监听:不取消会在页面重建后**叠加多个回调** + * (本项目在别处踩过同类:监听没注销 ⇒ 一次事件触发 N 次)。 + */ + +import { hilog } from '@kit.PerformanceAnalysisKit'; +import { motion } from '@kit.MultimodalAwarenessKit'; +import { FbAppState, fbAppState } from './Store'; + +const DOMAIN: number = 0x0000; +const TAG: string = 'FbGrip'; + +/** + * 握姿监听(全局单例)。 + * + * ⚠️ 它是**过程式**的(启动时 `start()` 一次即可),不需要渲染, + * 所以**不做成 @ComponentV2** —— 它不是 UI。 + */ +class FbGripWatcherImpl { + private listening: boolean = false; + private started: boolean = false; + + /** + * 开始监听握姿(幂等)。 + * + * @param app 全局状态;抽屉方向写进它的 `drawerSide` + */ + start(app: FbAppState): void { + if (this.started) { + return; + } + this.started = true; + const cb = (status: motion.HoldingHandStatus): void => { + this.apply(status, app); + }; + try { + motion.on('holdingHandChanged', cb); + this.listening = true; + hilog.info(DOMAIN, TAG, '握姿监听已开启'); + } catch (err) { + // 801 = 设备/系统不支持(API < 20);201 = 权限没给 + // ⇒ 都只是"没有握姿能力",**不能让应用起不来** + this.listening = false; + hilog.warn(DOMAIN, TAG, '握姿监听不可用(将保持默认抽屉方向):%{public}s', + JSON.stringify(err)); + } + } + + /** 停止监听(页面销毁时调,避免回调叠加) */ + stop(): void { + if (!this.listening) { + return; + } + try { + motion.off('holdingHandChanged'); + } catch (err) { + hilog.warn(DOMAIN, TAG, '取消握姿监听失败:%{public}s', JSON.stringify(err)); + } + this.listening = false; + } + + /** + * 握持状态 → 抽屉方向。 + * + * ⚠️ **双手握 / 未持 / 未知** 都**不改**方向:这三种情况没有"偏好哪边"的 + * 信息量,硬猜一边会让抽屉在双手拿时来回跳。 + */ + private apply(status: motion.HoldingHandStatus, app: FbAppState): void { + if (status === motion.HoldingHandStatus.LEFT_HAND_HELD) { + app.drawerSide = 'left'; + return; + } + if (status === motion.HoldingHandStatus.RIGHT_HAND_HELD) { + app.drawerSide = 'right'; + } + } +} + +/** 全局单例 */ +export const FB_GRIP: FbGripWatcherImpl = new FbGripWatcherImpl(); + +/** 便捷入口:拿到全局状态并开始监听 */ +export function fbStartGripWatch(): void { + FB_GRIP.start(fbAppState()); +} \ No newline at end of file diff --git a/entry/src/main/ets/common/FbScroll.ets b/entry/src/main/ets/common/FbScroll.ets new file mode 100644 index 0000000..3c98ec0 --- /dev/null +++ b/entry/src/main/ets/common/FbScroll.ets @@ -0,0 +1,213 @@ +/** + * 家书 FamilyBook · 滚动方向跟踪(鸿蒙端) + * + * 🔴 存在的原因(用户 2026-10-13 明确要求): + * 「下面四个悬浮的按钮,当往上滑的时候它就消失了。然后整个就是内容。 + * 等到从上往下滑的时候,它就又出现了。这个典型的应用呢,是像这个 IT 之家这种形式的。」 + * + * ⇒ 把「向上滚 / 向下滚」写进全局状态(`FbAppState.dockHidden`), + * 由 `Index.ets` 据此把悬浮栏滑走 / 滑回。 + * + * ⚠️ 为什么**不用** `@ohos.arkui.observer` 的全局 `'scrollEvent'` 观察者: + * 它只报 `SCROLL_START` / `SCROLL_STOP`(**只有起止、没有方向**), + * 做不了"往上还是往下"(已查 SDK 确认)。 + * ⇒ 只能在各页最外层滚动容器上挂 `onDidScroll`(API 12+)。 + * 它的 `scrollOffset` 是**每帧的增量**(vp): + * · `> 0` = 内容往上走(手指上滑、看后面的内容); + * · `< 0` = 内容往下走(手指下滑、回看前面的内容)。 + * + * ⚠️ 为什么各页要**各写一行** `.onDidScroll(fbOnScroll)`: + * ArkUI 没有"全局绊住所有滚动容器"的钩子。逐页显式挂最直白, + * 而且哪个页面参与这套行为,看代码就知道(没写这行 = 不参与)。 + * + * 🔴🔴 2026-10-15 改:**阈值从 48/40 降到 12/8**(真机反馈"其他模块不收起")。 + * + * 实测症状:**首页/名册能收起,其他模块(任务/资源/藏书/旅程/设置…)不能**。 + * 差别在于**内容高度** —— 那几页内容刚好一屏左右,向上滑只有很小一段可滚, + * 累积量到不了 48vp ⇒ 永远不触发。 + * ⇒ 阈值必须**按"最小可感知滑动"来定**,而不是"一整段内容"。 + * 12vp 约等于"手指明确地推了一下",既不会误触、 + * 又能在内容只有很短可滚行程时正常触发。 + * ⚠️ 收/唤两个阈值**不同**(收 12、唤 8):收起要"更确定"、唤回要"更灵敏"—— + * 用户往回翻,本意多半就是要找底部入口。 + */ + +import { fbAppState } from './Store'; + +/** + * 向上滚多少(vp 累计)才**收起**悬浮栏。 + * + * ⚠️ 别再调大:内容短的那些页面(任务/资源/藏书)可滚行程只有几十 vp, + * 阈值一大就永远不会触发(这正是本轮修的 bug)。 + */ +const HIDE_AT: number = 12; + +/** + * 向下滚多少(vp 累计)才**唤回**悬浮栏。 + * + * 比 `HIDE_AT` 更小:收起要"更确定"、唤回要"更灵敏"。 + */ +const SHOW_AT: number = 8; + +/** + * 横向滚动容器上报(挂在**横向** Scroll 上的 `.onDidScroll`)。 + * + * 🔴 用途(用户 2026-10-15 要求"有横滑组件时优先响应组件、别开抽屉"): + * 记一个时间戳,抽屉手势结束时会检查"刚刚有没有横滑在为"。 + * ⇒ 在标签条上横滑时,抽屉让位,不再"一不小心滑出来"。 + * + * ⚠️ 与 `fbOnScroll` 的区别:那个是**纵向**滚动(管悬浮栏收起), + * 这个是**横向**滚动(管抽屉让位)。两个都挂是正常的。 + * ⚠️ 用 `Date.now()` 而不是计数:需要的是"最近有没有"这个时间语义。 + */ +export function fbOnHScroll(_offset: number, _state: ScrollState): void { + fbAppState().hScrollAt = Date.now(); +} + +/** + * 判定"刚刚是否有横滑组件在为"。 + * + * @param withinMs 多久之内算"刚刚"。默认 350ms —— + * 横滑停下到手指抬起之间通常有几十到一两百毫秒, + * 抽屉的 `onActionEnd` 也在手指抬起那一刻触发 ⇒ 350 足够覆盖, + * 又不会把"上一次横滑"误判成这次(用户滑完停一会儿再想开抽屉是正常的)。 + */ +export function fbHScrollBusy(withinMs: number = 350): boolean { + const at: number = fbAppState().hScrollAt; + return at > 0 && (Date.now() - at) < withinMs; +} + +/** + * 各页最外层滚动容器的滚动回调。 + * + * 用法(每个模块页的**最外层**滚动容器挂一次): + * ```ts + * Scroll() { ... } + * .width('100%') + * .height('100%') + * .onDidScroll(fbOnScroll) + * ``` + * ⚠️ 只挂**最外层**那一个:内层横向滚动(标签条、看板筛选条、账本切换条) + * 要挂的是 `fbOnHScroll`(管抽屉让位),**不是**这个 —— + * 挂错了会让纵向的累积值被横向滚动污染。 + */ +export function fbOnScroll(offset: number, state: ScrollState): void { + const app = fbAppState(); + + // ⚠️ **不跳过 Idle**:`Fling`(惯性)结束时最后那一下会带 Idle, + // 而内容短的页面可能**只在惯性阶段**产生位移 —— 跳过就永远收不起来。 + // 但 Idle 的 offset 偶尔是 0(无方向含义),所以下面按 offset 的正负判断, + // 0 自然不产生任何影响。 + if (offset === 0) { + return; + } + + // ---- 方向法(主判据):一帧偏移足够大 ⇒ 直接按方向切 ---- + // 🔴 为什么不只靠累积:内容短的页面可滚行程小、帧数少, + // 累积还没到阈值滑动就结束了(旧实现就是这么漏掉的)。 + // 这里加一条"单帧方向足够明确就直接切"的快路径。 + if (offset >= HIDE_AT) { + app.scrollAcc = HIDE_AT; + if (!app.dockHidden) { + app.dockHidden = true; + } + return; + } + if (offset <= -SHOW_AT) { + app.scrollAcc = -SHOW_AT; + if (app.dockHidden) { + app.dockHidden = false; + } + return; + } + + // ---- 累积法(兜底):小步慢滑时把微小增量攒起来 ---- + app.scrollAcc += offset; + if (app.scrollAcc >= HIDE_AT) { + // 钉在阈值上(滞回):反向只需走 SHOW_AT 就能切回来 + app.scrollAcc = HIDE_AT; + if (!app.dockHidden) { + app.dockHidden = true; + } + } else if (app.scrollAcc <= -SHOW_AT) { + app.scrollAcc = -SHOW_AT; + if (app.dockHidden) { + app.dockHidden = false; + } + } +} + +// ============================================================ +// 触摸方向兜底(内容不足一屏的页面用) +// ============================================================ + +/** 手指按下的位置(**-1 = 当前没有按压**;用哨兵值而不是 null,ArkTS 对可选值更严格) */ +let touchStartY: number = -1; + +/** 一次滑动至少要移动这么多(vp)才认作"有方向意图"(避免把点击当滑动) */ +const TOUCH_MIN: number = 16; + +/** + * 触摸方向兜底:在**页面根容器**挂 `.onTouch(fbOnTouch)`。 + * + * 🔴 为什么需要它(2026-10-15 真机反馈): + * 「其他模块往上滑动的时候,那四个悬浮条却没有隐藏。」 + * 实测根因:**任务/资源/藏书/设置这些页面内容不足一屏,压根滚不动** + * ⇒ 挂在外层滚动容器上的 `onDidScroll` **一次都不会触发** + * ⇒ 悬浮栏永远收不起来(首页/名册内容多,所以正常)。 + * + * ⇒ 用**触摸位移方向**做兜底:手指从下往上抹(`dy < 0`)就收起、 + * 从上往下抹就唤回。它不依赖"内容能滚"这件事。 + * + * ⚠️ 与 `fbOnScroll` 的关系:**两者都放行、互不冲突**(同一个 `dockHidden` 状态, + * 同向重复设置是无害的幂等操作)。滚动能触发时以 `fbOnScroll` 为主 + * (它更精确:区分了内容位移与手指位移)。 + * ⚠️ 阈值 `TOUCH_MIN = 16vp` 是"手指位移",比滚动阈值大一些 —— + * 因为手指位移总是大于等于内容位移(滚到头时内容不动、手指还在动), + * 取小了会在**点击**时也触发。 + * ⚠️ 用 **`windowY`**(窗口坐标)而不是 `y`(组件内坐标): + * `y` 是相对**当前组件**的,手指移出组件后不再更新(可能拿到 0)。 + * `windowY` 是窗口坐标、全程连续。 + * ⚠️ `globalDisplayY` 在 SDK 里是**可选字段**(`number | undefined`), + * ArkTS 会报类型错 ⇒ 一律用必填的 `windowY`。 + */ +export function fbOnTouch(event: TouchEvent): void { + const app = fbAppState(); + const t: TouchType = event.type; + + if (t === TouchType.Down) { + const arr: TouchObject[] = event.touches; + if (arr.length > 0) { + touchStartY = arr[0].windowY; + } + return; + } + + if (t === TouchType.Move) { + if (touchStartY < 0) { + return; + } + const arr: TouchObject[] = event.touches; + if (arr.length === 0) { + return; + } + const dy: number = arr[0].windowY - touchStartY; + // dy < 0 ⇒ 手指往上抹 ⇒ 内容往上走 ⇒ 收起 + if (dy <= -TOUCH_MIN) { + if (!app.dockHidden) { + app.dockHidden = true; + } + // ⚠️ **不更新** touchStartY:否则手指继续上抹时会一直在阈值边缘反复触发。 + // 保持不动 ⇒ 判断是"相对起点的总位移",更稳。 + } else if (dy >= TOUCH_MIN) { + if (app.dockHidden) { + app.dockHidden = false; + } + } + return; + } + + if (t === TouchType.Up || t === TouchType.Cancel) { + touchStartY = -1; + } +} \ No newline at end of file diff --git a/entry/src/main/ets/common/FbTimeline.ets b/entry/src/main/ets/common/FbTimeline.ets new file mode 100644 index 0000000..da85677 --- /dev/null +++ b/entry/src/main/ets/common/FbTimeline.ets @@ -0,0 +1,407 @@ +/** + * 家书 FamilyBook · 日程「时间轴」布局(鸿蒙端) + * + * 🔴 用户 2026-10-16:「首先,**没有时间轴展示**……你这不是说让你把那个 + * SyncCalendar 里面的功能搬过来吗?」 + * ⇒ 照 `D:\Yongquan\code\SyncCalendar\entry\src\main\ets\common\TimelineUtil.ets` + * 移植(那份是成熟实现,算法经过实测):把一天的日程按**真实时间** + * 铺到 0-24 小时的纵向格子上。 + * + * 设计(与 SyncCalendar 一致): + * · 时间粒度:**1 小时 = 1 格**,色块高度按实际时长换算。 + * · 同一时间重叠(冲突)的日程**横向平分宽度**:1 条 = 100%、2 条 = 50% … + * · 色块背景色 = 所属日历本颜色。 + * · 全天日程**不进时间轴**,单独返回,由视图画在最上方。 + * · 视窗:**8:00 ~ 18:00 恒定显示**;某天在 8 点前/18 点后有日程时向外扩。 + * · 当前时间红线按"现在"在时间轴上的纵向比例定位。 + * + * ⚠️ 与 SyncCalendar 的差别(家书的数据形状不同,不照抄): + * · 它用**毫秒时间戳**;家书的 `FbCalEvent` 给的是 `HH:mm` 字符串 + * (`time` / `endTime`)⇒ 统一换算成"当天第几分钟"来算比例。 + * · 它的跨天日程要"逐天展开 + 按交集裁切";家书 `/calendar` 接口 + * **本来就是按天给出的条目**(同一条重复日程每天一条), + * ⇒ 不需要跨天裁切,逻辑简单一层。 + * · 它没有"没有结束时间"的日程;家书允许 ⇒ 缺结束时间时按 **1 小时** 估。 + * + * ArkTS 约束:所有"多字段返回值"必须是**命名 class**, + * 不能用内联对象字面量类型(见 r/quick-apis/08-state-decorators.md)。 + */ + +import { FbCalEvent } from '../model/Types'; + +/** 时间轴上的一个日程色块(位置/尺寸都用 0~1 比例,视图侧乘实际高度) */ +export class FbTimeBlock { + /** 稳定 key(ForEach 用) */ + key: string = ''; + title: string = ''; + /** `09:00 - 10:30` */ + timeText: string = ''; + place: string = ''; + color: string = '#A84A28'; + /** 距 0 点的比例 0~1(乘时间轴总高) */ + topRatio: number = 0; + /** 高度比例 */ + heightRatio: number = 0; + /** 横向起始比例 0~1(冲突平分) */ + leftRatio: number = 0; + /** 横向宽度比例 */ + widthRatio: number = 1; + /** 冲突组序号(视图侧按组分行) */ + groupIndex: number = 0; + /** 组内列号(同列互不重叠) */ + laneIndex: number = 0; + /** 正在进行中(视图侧高亮) */ + isNow: boolean = false; + /** 能不能编辑(衍生条目不给编辑) */ + editable: boolean = false; + /** 原始条目(点击/详情用) */ + ev: FbCalEvent | null = null; +} + +/** 一天的完整时间轴布局结果 */ +export class FbTimelineData { + /** 全天条目(画在 0 点之前) */ + allDay: FbCalEvent[] = []; + /** 时间轴色块(按 topRatio 升序) */ + blocks: FbTimeBlock[] = []; + /** 当天有没有"带时间"的日程 */ + hasTimed: boolean = false; +} + +/** 冲突组:一组时间上互相重叠的日程(用于横向平分宽度) */ +class FbOverlapGroup { + items: FbCalEvent[] = []; + endMin: number = 0; +} + +/** 视图渲染用的"冲突组"(组内按列并排) */ +export class FbTimeGroup { + /** 组内最早开始(距 0 点比例) */ + startRatio: number = 0; + /** 组内最晚结束 */ + endRatio: number = 0; + /** 列数 = 组内"最大同时重叠数" */ + laneCount: number = 1; + /** 组内全部色块(按开始时间升序) */ + blocks: FbTimeBlock[] = []; + /** 按列组织:lanes[i] = 第 i 列上的色块 */ + lanes: FbTimeBlock[][] = []; +} + +/** 视窗(要显示的小时区间) */ +export class FbViewRange { + startHour: number = 8; + endHour: number = 18; +} + +/** 视图分组用的"一行"(空格行 或 冲突组行) */ +export class FbTimeRow { + /** 0 = 空隙;1 = 冲突组 */ + kind: number = 0; + topRatio: number = 0; + botRatio: number = 0; + /** kind=1 时有效 */ + group: FbTimeGroup | null = null; + key: string = ''; +} + +export class FbTimelineUtil { + /** 1 小时 = 多少 vp(视图侧用同一常量换算) */ + static readonly HOUR_UNIT: number = 44; + /** 基准可视区:恒定显示的小时区间 */ + static readonly BASE_START_HOUR: number = 8; + static readonly BASE_END_HOUR: number = 18; + /** 色块最短时长:15 分钟(避免零高) */ + static readonly MIN_SPAN_MIN: number = 15; + + /** 一天有多少分钟 */ + private static readonly DAY_MIN: number = 1440; + + /** `HH:mm` → 当天第几分钟(认不出返回 -1) */ + static minutesOf(hhmm: string): number { + if (hhmm.length < 4) { + return -1; + } + const h: number = parseInt(hhmm.substring(0, 2), 10); + const m: number = parseInt(hhmm.substring(3, 5), 10); + if (isNaN(h) || isNaN(m)) { + return -1; + } + return h * 60 + m; + } + + /** 分钟 → `HH:mm` */ + static hhmm(min: number): string { + const h: number = Math.floor(min / 60); + const m: number = min % 60; + const p = (n: number): string => n < 10 ? '0' + n.toString() : n.toString(); + return p(h) + ':' + p(m); + } + + /** 条目的唯一 key(ForEach 用;同一天同一条目重复出现时要能区分) */ + static keyOf(e: FbCalEvent): string { + return e.kind + '_' + e.id.toString() + '_' + e.time + '_' + e.title; + } + + /** + * 构建某一天的时间轴布局。 + * + * @param events 这一天(`FbCalEvent.date` 已等于目标日期)的全部合格条目 + * @param isToday 是不是今天(决定要不要画"当前时间") + * @param nowMin 当前时刻(当天第几分钟;仅 isToday 时有意义) + */ + static build(events: FbCalEvent[], isToday: boolean, nowMin: number): FbTimelineData { + const res: FbTimelineData = new FbTimelineData(); + + // 1) 分类:全天 → 顶部;带时间的 → 时间轴 + const timed: FbCalEvent[] = []; + for (const e of events) { + if (e.allDay || FbTimelineUtil.minutesOf(e.time) < 0) { + res.allDay.push(e); + } else { + timed.push(e); + } + } + res.hasTimed = timed.length > 0; + if (timed.length === 0) { + return res; + } + + // 2) 算出每条的 [起, 止] 分钟(缺结束时间按 1 小时估;止 <= 起 则按 +1h) + const starts: number[] = []; + const ends: number[] = []; + for (const e of timed) { + const s: number = FbTimelineUtil.minutesOf(e.time); + let en: number = FbTimelineUtil.minutesOf(e.endTime); + if (en <= s) { + en = s + 60; + } + if (en > FbTimelineUtil.DAY_MIN) { + en = FbTimelineUtil.DAY_MIN; // 夹到当天 24:00(跨天不处理:接口按天给条目) + } + starts.push(s); + ends.push(en); + } + + // 3) 按开始时间排序(同步排 starts/ends,保持下标一致) + const idx: number[] = []; + for (let i = 0; i < timed.length; i++) { + idx.push(i); + } + idx.sort((a: number, b: number): number => starts[a] - starts[b]); + + const sortedEv: FbCalEvent[] = []; + const sortedS: number[] = []; + const sortedE: number[] = []; + for (const i of idx) { + sortedEv.push(timed[i]); + sortedS.push(starts[i]); + sortedE.push(ends[i]); + } + + // 4) 划分"冲突组"(时间上连通重叠的一簇) + const groups: FbOverlapGroup[] = []; + for (let i = 0; i < sortedEv.length; i++) { + const last: FbOverlapGroup | undefined = + groups.length > 0 ? groups[groups.length - 1] : undefined; + if (last !== undefined && sortedS[i] < last.endMin) { + // 与上一组还有重叠 ⇒ 并入(组的"最晚结束"跟着往外扩) + last.items.push(sortedEv[i]); + last.endMin = Math.max(last.endMin, sortedE[i]); + } else { + const ng: FbOverlapGroup = new FbOverlapGroup(); + ng.items = [sortedEv[i]]; + ng.endMin = sortedE[i]; + groups.push(ng); + } + } + + // 5) 组内"贪心分列"(区间图着色):每条放进"结束 <= 它开始"的编号最小的列 + // ⇒ 列数 = 该组"最大同时重叠数",而不是组内条数 + let gi: number = 0; + for (const g of groups) { + const laneEnd: number[] = []; + const laneOf: number[] = []; + for (const e of g.items) { + const s: number = FbTimelineUtil.minutesOf(e.time); + let lane: number = -1; + for (let i = 0; i < laneEnd.length; i++) { + if (laneEnd[i] <= s) { + lane = i; + break; + } + } + if (lane < 0) { + lane = laneEnd.length; + laneEnd.push(0); + } + // 该条的结束(同样规则) + let en: number = FbTimelineUtil.minutesOf(e.endTime); + if (en <= s) { + en = s + 60; + } + if (en > FbTimelineUtil.DAY_MIN) { + en = FbTimelineUtil.DAY_MIN; + } + laneEnd[lane] = en; + laneOf.push(lane); + } + const n: number = laneEnd.length > 0 ? laneEnd.length : 1; + for (let i = 0; i < g.items.length; i++) { + const e: FbCalEvent = g.items[i]; + const s: number = FbTimelineUtil.minutesOf(e.time); + let en: number = FbTimelineUtil.minutesOf(e.endTime); + if (en <= s) { + en = s + 60; + } + if (en > FbTimelineUtil.DAY_MIN) { + en = FbTimelineUtil.DAY_MIN; + } + const span: number = en - s > FbTimelineUtil.MIN_SPAN_MIN ? en - s : FbTimelineUtil.MIN_SPAN_MIN; + + const b: FbTimeBlock = new FbTimeBlock(); + b.key = FbTimelineUtil.keyOf(e); + b.title = e.title !== '' ? e.title : '(无标题)'; + b.timeText = FbTimelineUtil.hhmm(s) + ' - ' + FbTimelineUtil.hhmm(en); + b.place = e.place; + b.color = e.color; + b.topRatio = s / FbTimelineUtil.DAY_MIN; + b.heightRatio = span / FbTimelineUtil.DAY_MIN; + b.groupIndex = gi; + b.laneIndex = laneOf[i]; + b.widthRatio = 1 / n; + b.leftRatio = laneOf[i] / n; + b.isNow = isToday && nowMin >= s && nowMin < en; + b.editable = e.kind === 'custom' && e.id > 0 && e.calId > 0; + b.ev = e; + res.blocks.push(b); + } + gi++; + } + + // 兜底:严格按 topRatio 升序(视图侧求"空隙"无累积漂移) + res.blocks.sort((a: FbTimeBlock, b: FbTimeBlock): number => a.topRatio - b.topRatio); + return res; + } + + /** + * 由平坦的色块列表重建"冲突分组":视图按组分行、组内按列并排渲染。 + */ + static groupsOf(blocks: FbTimeBlock[]): FbTimeGroup[] { + const map: Map = new Map(); + for (const b of blocks) { + const arr: FbTimeBlock[] | undefined = map.get(b.groupIndex); + if (arr === undefined) { + map.set(b.groupIndex, [b]); + } else { + arr.push(b); + } + } + const idxs: number[] = Array.from(map.keys()).sort((a: number, b: number): number => a - b); + const out: FbTimeGroup[] = []; + for (const gidx of idxs) { + const bs: FbTimeBlock[] = map.get(gidx) ?? []; + bs.sort((a: FbTimeBlock, b: FbTimeBlock): number => { + if (a.topRatio !== b.topRatio) { + return a.topRatio - b.topRatio; + } + return b.heightRatio - a.heightRatio; + }); + // 贪心分列(用比例重算,保证与 build 一致) + const lanes: FbTimeBlock[][] = []; + const laneEnd: number[] = []; + for (const b of bs) { + let li: number = -1; + for (let i = 0; i < laneEnd.length; i++) { + if (laneEnd[i] <= b.topRatio + 0.0000001) { + li = i; + break; + } + } + const be: number = b.topRatio + b.heightRatio; + if (li < 0) { + li = lanes.length; + lanes.push([b]); + laneEnd.push(be); + } else { + lanes[li].push(b); + laneEnd[li] = be; + } + b.laneIndex = li; + } + const lc: number = lanes.length > 0 ? lanes.length : 1; + for (const b of bs) { + b.widthRatio = 1 / lc; + b.leftRatio = b.laneIndex / lc; + } + const g: FbTimeGroup = new FbTimeGroup(); + g.blocks = bs; + g.lanes = lanes; + g.laneCount = lc; + let s: number = 1; + let e: number = 0; + for (const b of bs) { + if (b.topRatio < s) { + s = b.topRatio; + } + const be: number = b.topRatio + b.heightRatio; + if (be > e) { + e = be; + } + } + g.startRatio = s; + g.endRatio = e; + out.push(g); + } + return out; + } + + /** + * 视窗:8:00 ~ 18:00 恒定显示;某天在 8 点前 / 18 点后有日程时向外扩。 + * + * ⚠️ 这条规则很重要:若只按"当天最早/最晚日程"截断, + * 那么只有一条 9:00 日程的日子就只画 1 小时的格子,观感很怪。 + */ + static viewRange(blocks: FbTimeBlock[]): FbViewRange { + const rg: FbViewRange = new FbViewRange(); + let s: number = FbTimelineUtil.BASE_START_HOUR; + let e: number = FbTimelineUtil.BASE_END_HOUR; + for (const b of blocks) { + const bh: number = Math.floor(b.topRatio * 24); + const eh: number = Math.ceil((b.topRatio + b.heightRatio) * 24 - 0.0001); + if (bh < s) { + s = bh; + } + if (eh > e) { + e = eh; + } + } + if (s < 0) { + s = 0; + } + if (s > 23) { + s = 23; + } + if (e > 24) { + e = 24; + } + if (e <= s) { + e = s + 1 > 24 ? 24 : s + 1; + } + rg.startHour = s; + rg.endHour = e; + return rg; + } + + /** 当前时刻的纵向比例 0~1 */ + static nowRatio(nowMin: number): number { + let r: number = nowMin / FbTimelineUtil.DAY_MIN; + if (r < 0) { + r = 0; + } + if (r > 1) { + r = 1; + } + return r; + } +} \ No newline at end of file diff --git a/entry/src/main/ets/common/Store.ets b/entry/src/main/ets/common/Store.ets index df9589c..b622575 100644 --- a/entry/src/main/ets/common/Store.ets +++ b/entry/src/main/ets/common/Store.ets @@ -45,6 +45,124 @@ export class FbAppState { /** 未读通知数(顶栏铃铛角标) */ @Trace unread: number = 2; + /** + * 状态栏高度(vp)。 + * + * 🔴 为什么页面需要知道它(用户 2026-10-13 要"全沉浸"): + * 打开 `setWindowLayoutFullScreen(true)` 之后,**窗口不再为状态栏让位**, + * 内容会真的画到状态栏底下 ⇒ 顶栏必须自己往下让出这一段, + * 否则首屏的"家书"标题会被时间/信号图标压住。 + * ⚠️ 但它**只用来给"滚动内容的第一行"垫高**(见 `FbTopBar`), + * 不是给根容器加 padding —— 那样内容就永远钻不到状态栏底下、 + * 也就没有"滑上去和通知栏融为一体"的效果了。 + */ + @Trace statusBarH: number = 0; + + /** + * 底部悬浮快捷栏是否**被滚动收起**。 + * + * 🔴 用户 2026-10-13:「下面四个悬浮按钮,往上滑的时候它就消失了……等到从上往下滑的时候, + * 它就又出现了。典型的应用像 IT 之家。」 + * ⇒ 向上滚(看后面的内容)收起、向下滚(回看)唤回。 + * ⚠️ 由 `common/FbScroll.ets` 的 `FB_SCROLL.track()` 写、`Index.ets` 读并驱动位移动画。 + */ + @Trace dockHidden: boolean = false; + + /** + * **页面内浮层**(人员详情 / 编辑 / 观影详情 / 影厅)当前打开了几个。 + * + * 🔴 为什么需要(用户 2026-10-15 实测踩出来的真 bug): + * 名册的详情浮层是**页面内的覆盖层**,装在页面自己的 `Stack` 里; + * 而底部悬浮快捷栏装在**更外层的 `Index` 的 Stack` 里** ⇒ 悬浮栏的层级更高。 + * 实测:详情卡片底部的「编辑」按钮(y≈2207-2331)与悬浮栏(y≈2240-2350) + * **在屏幕上重叠**,点「编辑」被悬浮栏抢走 ⇒ **直接跳到了日历页**。 + * (「关闭」同理。) + * ⇒ 浮层开着的时候,悬浮栏必须**让位**(收起 + 不可点)。 + * + * ⚠️ 用**计数**而不是 boolean:可能同时有"详情 + 编辑"两个浮层叠着 + * (只是本项目目前一次只开一个),计数能天然处理嵌套/多层。 + * ⚠️ 所有开/关都必须走 `openSheet()` / `closeSheet()`,不要直接改它。 + */ + @Trace sheetCount: number = 0; + + /** + * 滚动方向累积量(vp)。正 = 一直在往上滚;负 = 一直在往下滚。 + * + * ⚠️ 放在**状态类里**而不是 `FbScroll.ets` 的模块级变量 —— + * `FbScroll` 要读 `FbAppState`、`Store` 若又要 import `FbScroll` 的复位函数, + * 就形成**循环 import**(本项目此前已经因此踩过坑)。 + * ⇒ 让累积量归属状态类本身,依赖方向固定成单向:`FbScroll` → `Store`。 + * ⚠️ 它不是"界面状态",不参与渲染(没有任何组件读它); + * 只是随状态类一起活着的内部计数器,所以**不加 `@Trace`** + * (加了会让每个滚动帧都触发一次无谓的状态通知)。 + */ + scrollAcc: number = 0; + + /** + * **横向滚动"占用"标记**:记录最近一次横向滚动发生的时刻(ms;0 = 从没发生)。 + * + * 🔴 存在的理由(用户 2026-10-15 明确要求): + * 「不是在任何地方左滑都会出抽屉,而是在原来的页面上没有左滑右滑功能的时候, + * 左滑右滑才会出来抽屉。否则,如果页面上有左滑右滑的组件, + * 它就应该先优先响应这个组件。」 + * + * 抽屉的横滑是 `parallelGesture`(**并行**识别),它**不管内容区在干什么** + * 都会一起触发 ⇒ 在标签条上横滑时"一不小心就把抽屉滑出来"。 + * + * ⇒ 做法:横滑组件(标签条 / 看板筛选条 / 账本切换条 / 日历格)在自己滚动时 + * **打一个时间戳**;抽屉在手势结束时看"刚刚有没有横滑在为", + * 有就让位、不开抽屉。 + * + * ⚠️ 用**时间戳**而不是布尔标记: + * 横滑结束时并不知道"用户接下来不会再滑了", + * 用"最近 N 毫秒内发生过横滑"来判定更稳(见 `fbMarkHScroll`)。 + * ⚠️ 不加 `@Trace`:它不参与渲染,只是内部判据。 + */ + hScrollAt: number = 0; + + /** + * 🔴 v55:**请求回到登录页**(登出 / 更换服务器时由设置页置位)。 + * + * 为什么用状态位而不是"让设置页自己 push 一个页面": + * 本应用是**单页 + 模块分发**(`Index.moduleContent()`), + * 没有路由栈 —— 登录页是 `Index.build()` 里的一个分支。 + * ⇒ 想回到它,只能"把这个分支的判据改掉"。 + * ⚠️ 由 `Index.build()` 读;登录成功 / 用户选"先不登录"时**必须**复位, + * 否则下次渲染又会弹回登录页(死循环)。 + */ + @Trace needLogin: boolean = false; + + /** + * 🔴 v55:回到登录页时**从第几步开始**(0 = 自动判断)。 + * + * `1` = 强制从"填服务器地址"开始(用户点了「更换服务器」); + * `2` = 直接从"输账号密码"开始(用户只是「退出登录」,地址还留着); + * `0` = 按本地有没有地址自动决定(正常启动)。 + * + * ⚠️ 为什么必须是**状态位**而不是给登录页传个参数: + * 登录页的 `aboutToAppear` 只在组件**被创建**时跑一次; + * 本应用里它被 `if` 分支复用,状态位比"重建时参数对不对"更可控。 + */ + @Trace loginStartStep: number = 0; + + /** + * 🔴 v57(用户 2026-10-17):「这台设备已经被退出登录」之类的原因。 + * + * 由启动校验 / 401 拦截置位,登录页顶部原样显示 —— + * 用户必须知道"为什么突然要我重新登录",否则只会觉得 App 坏了。 + */ + @Trace lockReason: string = ''; + + /** + * 🔴 v57:离线超过 24 小时 → **锁界面但不删令牌**。 + * + * 用户原话:「离线超过 24 个小时就硬退出。」 + * ⚠️ 与 `needLogin` 分开是**刻意的**(见 `FbSession` 文件头): + * 这里只是"先别看了,联网确认一下",一联网校验通过就自动恢复, + * **不需要重新输密码**;只有被踢/退出登录才走 `needLogin`。 + */ + @Trace offlineLocked: boolean = false; + /** 当前模块的账号(便捷取用,避免页面里到处写 this.app.session.me) */ me(): FbAccount { return this.session.me; @@ -54,6 +172,36 @@ export class FbAppState { switchModule(key: string): void { this.activeModule = key; this.drawerOpen = false; + // 🔴 换页要**复位滚动累积**:那是跨页面共享的, + // 不清零的话下一页第一次轻滑就会"立刻收起悬浮栏"(其实用户什么都没做)。 + this.resetScroll(); + } + + /** 有浮层开着吗(悬浮栏据此让位) */ + sheetOpen(): boolean { + return this.sheetCount > 0; + } + + /** 开一个浮层(计数 +1) */ + openSheet(): void { + this.sheetCount += 1; + } + + /** 关一个浮层(计数 -1,不会低于 0) */ + closeSheet(): void { + this.sheetCount = this.sheetCount > 0 ? this.sheetCount - 1 : 0; + } + + /** + * 复位滚动累积(换模块、回到顶部时调)。 + * + * ⚠️ 用方法而不是让调用方直接写 `app.scrollAcc = 0`: + * 复位时**同时把悬浮栏放出来** —— 换到新模块时它必须是可见的, + * 否则会出现"进了新页面、底部入口却是收起来的"这种莫名其妙的状态。 + */ + resetScroll(): void { + this.scrollAcc = 0; + this.dockHidden = false; } } diff --git a/entry/src/main/ets/components/Drawer.ets b/entry/src/main/ets/components/Drawer.ets index 2b21844..b8121d1 100644 --- a/entry/src/main/ets/components/Drawer.ets +++ b/entry/src/main/ets/components/Drawer.ets @@ -7,8 +7,16 @@ * * 三条形态,同一个组件: * · **常驻侧栏**(宽屏:窗口短边 ≥ 600vp)—— 固定在左边,占 264vp; - * · **左滑出层**(手机,从左边缘向右滑)—— 覆盖内容,点遮罩或选中项即收起; - * · **右滑出层**(手机,从右边缘向左滑)—— 同上,只是贴右边(右手单手持机顺手)。 + * · **左滑出层**(手机)—— 覆盖内容,点遮罩或选中项即收起; + * · **右滑出层**(手机)—— 同上,只是贴右边(右手单手持机顺手)。 + * + * 🔴 抽屉**从哪边出**由「**握姿**」决定(用户 2026-10-16 第 4 条): + * 左手握 → `app.drawerSide = 'left'`;右手握 → `'right'`。 + * 逻辑在 `common/FbGrip.ets`(用官方 `multimodalAwareness.motion` 的 + * `HoldingHandStatus`),不在本组件里。 + * 🔴 **打开方式只有一种:点顶栏左侧的家书图标**(`pages/Index.ets`)。 + * ⚠️ **横向滑动开抽屉的旧手势已删**(用户要求)—— 它与页面里的横滑、 + * 与"点人名拨号"在 `parallelGesture` 下**并行触发**,冲突无法根治。 * * ⚠️ 这里**不用 SideBarContainer**:它的控制按钮与「滑出」是内建的一套, * 样式(图标、位置、宽度动画)很难与家书的视觉统一,且它只能有一层侧栏 @@ -21,6 +29,16 @@ import { FbAppState, fbAppState } from '../common/Store'; import { FbModuleDef, FB_MODULES, fbDefaultNavModules, fbThemeVars } from '../model/Modules'; import { FbAvatar } from './Widgets'; +/** + * 底部手势条区域的留白(vp)。 + * + * 🔴 应用已开 `setWindowLayoutFullScreen(true)`(见 EntryAbility): + * 内容会铺到屏幕最下沿,而**系统手势条已隐藏**(用户要求"下面没有任何的条") + * ⇒ 手势区**没有任何视觉提示**,贴着底边的元素很容易被"从下往上滑"误触。 + * 这里留一点缓冲,是给"手势区"而不是给"某个控件"。 + */ +const FB_BOTTOM_GAP: number = 16; + @ComponentV2 export struct FbDrawer { /** 全局状态(父组件传入,保证与页面同一份实例) */ @@ -60,7 +78,7 @@ export struct FbDrawer { } .width('100%') // 顶部内边距 = 状态栏高度 + 一点呼吸,保证不压状态栏 - .padding({ left: 18, right: 12, top: 16, bottom: 10 }) + .padding({ left: 18, right: 12, top: 16 + this.app.statusBarH, bottom: 10 }) } /** 一个模块行 */ @@ -112,29 +130,52 @@ export struct FbDrawer { .color(FbColors.line2) .margin({ bottom: 6 }) - this.footerRow('fb_ic_bell', '通知消息', this.app.unread > 0 ? this.app.unread.toString() : '') - this.footerRow('fb_ic_user', '个人设置', '') - this.footerRow('fb_ic_lock', '修改密码', '') + this.footerRow('fb_ic_bell', '通知消息', + this.app.unread > 0 ? this.app.unread.toString() : '', '__notifications') + this.footerRow('fb_ic_user', '个人设置', '', '__settings') + // ⚠️ 下面两项端上**还没做页面**,传空 route ⇒ 灰显、不可点。 + // 别给它们绑 `__password` / `__system` —— Index.ets 没这两个分支, + // 点下去是**空白页**,比"点不动"更让人困惑。 + this.footerRow('fb_ic_lock', '修改密码', '', '') if (this.app.session.me.role === 'admin') { - this.footerRow('fb_ic_gear', '系统设置', '') + this.footerRow('fb_ic_gear', '系统设置', '', '') } } .width('100%') - .padding({ left: 12, right: 12, bottom: 10 }) + // 🔴 bottom 给底部**手势条区域**留一点(全屏布局下抽屉会铺到屏幕最下沿, + // 底部这两行若贴着边,手指从下往上滑时容易误触到系统手势)。 + .padding({ left: 12, right: 12, bottom: 10 + FB_BOTTOM_GAP }) } - /** 底部一行 */ + /** + * 底部一行(通知消息 / 个人设置 / 修改密码 / 系统设置)。 + * + * 🔴 必须带 `route` 并绑 onClick —— 之前这四行**根本没有点击处理**, + * 界面上是个死按钮(点下去毫无反应)。 + * 排查记录:点「个人设置」后仍停在首页,看界面树发现该元素 + * `clickable=false` 且整行没有任何可点击祖先 —— 就是这个原因。 + * ⚠️ 与模块项(`moduleRow`)不同:这四项的路由 key 是 `__xxx` 形式, + * 不是 `FB_MODULES` 里的模块,走同一个 `switchModule` 分发 + * (Index.ets 里用 `activeModule === '__settings'` 渲染对应页面)。 + * + * ⚠️ `route` 传空串表示"**这个页面还没做**" ⇒ 不绑 onClick,且文字降为灰、 + * 保持不可点。为什么不给它绑个假跳转:那会跳到**空白页** + * (Index.ets 里没有对应分支),比"点了没反应"更让人困惑。 + * 现状:`__notifications` / `__settings` 已有页面; + * `修改密码` / `系统设置` **还没做**(Web 端有,端上待开发)。 + */ @Builder - footerRow(iconName: string, label: string, badge: string) { + footerRow(iconName: string, label: string, badge: string, route: string) { Row({ space: 12 }) { Image(FbIcon.ui(iconName)) .width(17) .height(17) .renderMode(ImageRenderMode.Template) - .fillColor(FbColors.muted) + // 未实现的项图标也变灰,明示"暂不可用" + .fillColor(route === '' ? FbColors.faint : FbColors.muted) Text(label) .fontSize(FbMetric.fontSmall) - .fontColor(FbColors.ink) + .fontColor(route === '' ? FbColors.faint : FbColors.ink) .layoutWeight(1) if (badge.length > 0) { Text(badge) @@ -148,6 +189,11 @@ export struct FbDrawer { .width('100%') .padding({ left: 10, right: 10, top: 9, bottom: 9 }) .borderRadius(FbMetric.radiusS) + .onClick(() => { + if (route !== '') { + this.app.switchModule(route); // switchModule 内部会收起抽屉 + } + }) } /** 当前是否选中该模块 */ @@ -263,41 +309,22 @@ export struct FbDrawerShell { .width('100%') .height('100%') .backgroundColor(FbColors.paper) - // 🔴 横向滑动开抽屉(用户 2026-10-12 定的规则)。 + // 🔴🔴 **横向滑动开抽屉的手势已删除**(用户 2026-10-16 第 3、4 条)。 // - // ⚠️ **不要判断"起点是否贴屏幕边缘"** —— 用户实测反馈:屏幕边缘是 - // **系统手势区**(返回/侧边栏),从边缘往里滑的事件根本轮不到应用处理, - // 我们无法控制。所以判据只能是"**在应用内部水平滑动**": - // 向右滑 → 抽屉从左边出;向左滑 → 抽屉从右边出。 - // 这样左手持机时往右划、右手持机时往左划,单手都顺。 + // 用户原话:「这个列表上左滑、右滑和点击后打电话的事件冲突了…… + // 如果实在不行,就把左滑、右滑弹出抽屉这个功能去掉吧, + // 只保留点击标题或图标后弹出抽屉的功能。」 + // 并再次强调:「**先这样做,把滑动出抽屉这个功能先去掉**, + // 因为它现在在很多地方都跟别的功能有冲突。」 // - // ⚠️ `offsetX` 的单位是 **vp**(SDK 原文 "in vp"),不要再除以 density —— - // 除一次会把位移缩到 1/3,导致阈值永远达不到、手势"看起来没生效"。 + // 历史:`parallelGesture(PanGesture)` + `fbHScrollBusy()` 的那套"让位" + // 机制(见 git 历史)**治不了根本** —— 因为 `parallelGesture` 是**并行**识别, + // 它和应用自己的横滑/点按/拨号**同时**成立,没有"谁先抢到"的机制。 + // 名册里"滑动既弹抽屉又跳拨号"就是这个并行识别的必然结果。 + // ⇒ 唯一可靠的办法是**不用横滑手势**。 // - // ⚠️ 用 `parallelGesture` 而不是 `gesture`:内容区自己还有竖向滚动 - // (以及纪念日/财务/任务的横向滚动条),用 gesture 会把它们抢掉。 - // 代价是横向滚动条扫得猛时也可能触发 —— 所以阈值给到 80vp, - // 轻扫(浏览横向列表)不会误开。 - .parallelGesture( - PanGesture({ direction: PanDirection.Horizontal, distance: 30 }) - .onActionEnd((e: GestureEvent) => { - // 宽屏下抽屉常驻,不需要手势 - if (this.app.wideLayout) { - return; - } - const dx: number = e.offsetX; // vp - // 向右滑 ⇒ 抽屉在左 - if (dx > 80) { - this.app.drawerSide = 'left'; - this.app.drawerOpen = true; - return; - } - // 向左滑 ⇒ 抽屉在右 - if (dx < -80) { - this.app.drawerSide = 'right'; - this.app.drawerOpen = true; - } - }) - ) + // 抽屉入口保留在:**顶栏左侧的家书图标**(`pages/Index.ets` 的 `topBarSlot`)。 + // 抽屉从哪边出,改由「**握姿**」决定(见 `FbGripWatcher` / `app.drawerSide`): + // 左手握 → 从左边出;右手握 → 从右边出。 } } \ No newline at end of file diff --git a/entry/src/main/ets/components/FbCalOptionRow.ets b/entry/src/main/ets/components/FbCalOptionRow.ets new file mode 100644 index 0000000..c64e022 --- /dev/null +++ b/entry/src/main/ets/components/FbCalOptionRow.ets @@ -0,0 +1,72 @@ +/** + * 家书 FamilyBook · 设置里的「单选行」(鸿蒙端) + * + * 🔴🔴 为什么必须是**组件**而不是 `@Builder`(用户 2026-10-16 报的真 bug): + * 「设置里面这个"**默认视图**",**都没法点**,也没有办法设置。」 + * 根因:原来是 `FbCalSettingsSheet.radioRow(label, hint, on: boolean, onTap)` —— + * **`@Builder` 的值类型参数(boolean)不参与状态观察** + * (SDK 规则 `r/quick-rules/05-state.md`:按值传递「状态变化刷新 ❌」)。 + * ⇒ 点一下 `this.view` 确实变了、`FB_CAL_SETTINGS.setView()` 也确实写了, + * 但**打勾 ✓ 与高亮底色的那份子树不重建** ⇒ 看着"点了没反应"。 + * 本项目已在 `FbTagChip` 上踩过同一个坑(见那份文件头),这里照同样办法修。 + * + * ⚠️ 顺带说明:ForEach 的 key **不用**带选中态 —— 只要这一片是 + * `@ComponentV2` + `@Param`,`active` 变了它自己就会重建。 + * (`FbTagChip` 的 key 也只是标签名。) + */ + +import { FbColors, FbMetric } from '../common/Theme'; +import { FbDot } from './Widgets'; + +@ComponentV2 +export struct FbCalOptionRow { + /** 主文字 */ + @Param label: string = ''; + /** 副说明(小字;空则不占位) */ + @Param hint: string = ''; + /** 是否选中(**必须由父组件算好传进来**) */ + @Param active: boolean = false; + /** 左侧色点(日历本那几行用它;空则不画) */ + @Param dotColor: string = ''; + /** 被点击 */ + @Event onPick: () => void = (): void => { + }; + + build() { + Row({ space: 10 }) { + if (this.dotColor !== '') { + FbDot({ dia: 10, fillC: this.dotColor }) + } + Column({ space: 2 }) { + Text(this.label) + .fontSize(FbMetric.fontSmall) + .fontColor(this.active ? '#4A7C82' : FbColors.ink) + .fontWeight(this.active ? FontWeight.Medium : FontWeight.Normal) + .maxLines(2) + if (this.hint !== '') { + Text(this.hint) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + .maxLines(2) + } + } + .alignItems(HorizontalAlign.Start) + .layoutWeight(1) + + Text(this.active ? '✓' : '') + .fontSize(FbMetric.fontSmall) + .fontColor('#4A7C82') + .width(20) + .textAlign(TextAlign.Center) + } + .width('100%') + .padding({ top: 10, bottom: 10, left: 12, right: 12 }) + .backgroundColor(this.active ? '#EAF2F2' : FbColors.inputBg) + .borderRadius(FbMetric.radiusS) + .margin({ bottom: 8 }) + .alignItems(VerticalAlign.Center) + .onClick((): void => { + this.onPick(); + }) + } +} \ No newline at end of file diff --git a/entry/src/main/ets/components/FbCalSettingsSheet.ets b/entry/src/main/ets/components/FbCalSettingsSheet.ets new file mode 100644 index 0000000..50f2c19 --- /dev/null +++ b/entry/src/main/ets/components/FbCalSettingsSheet.ets @@ -0,0 +1,253 @@ +/** + * 家书 FamilyBook · 日程设置浮层(鸿蒙端) + * + * 用户 2026-10-15:「在新建的那个地方,**右侧那加一个设置按钮**, + * 点进去之后实际上就是**对这个日程的设置**。」 + * 用户 2026-10-16:「设置里面这个"**默认视图**",都没法点,也没有办法设置。」 + * + * 对齐 SyncCalendar 设置页里**与日程显示相关**的那几项 + * (它那一页还有一堆 DAV 账号/镜像/系统的设置,那些家书用不上,不抄): + * · **默认视图**:月 / 周 / 列表 + * · **日程显示方式**:时间轴 / 列表 + * · **新建日程落到哪本日历**(默认日历本) + * · **哪些日历本可见**(逐本开关) + * + * 🔴🔴 v53 修的真 bug:「默认视图点不动」 + * 根因:单选行原来是 `@Builder radioRow(type, hint, on: boolean, onTap)` —— + * **`@Builder` 的值类型参数不参与状态观察** + * (SDK 规则 `r/quick-rules/05-state.md`)⇒ 点一下 `this.view` 变了、 + * 服务端也写了,但打勾/高亮那份子树**不重建** ⇒ 看着"点了没反应"。 + * ⇒ 改成 `FbCalOptionRow`(`@ComponentV2` + `@Param`)。 + * + * ⚠️ 这些设置**只存在本机**(`FbCalSettings` / preferences), + * 不上传服务端 —— 它是"这台设备上的人怎么看日历",不是共享数据。 + */ + +import { FbColors, FbMetric } from '../common/Theme'; +import { FbCalBook } from '../model/Types'; +import { FB_CAL_SETTINGS, FbCalView, FbCalStyle } from '../data/FbCalSettings'; +import { FbCalOptionRow } from './FbCalOptionRow'; +import { FbDot } from './Widgets'; + +const FB_SHEET_BOTTOM_GAP: number = 16; + +/** 视图选项 */ +interface FbViewOpt { + key: FbCalView; + label: string; + hint: string; +} + +const VIEW_OPTS: FbViewOpt[] = [ + { key: 'month', label: '月视图', hint: '看整月,点某天看当天日程' }, + { key: 'week', label: '周视图', hint: '看这一周,一周里的安排一目了然' }, + { key: 'agenda', label: '列表视图', hint: '从今天往后排,按天分组' } +]; + +/** 显示方式选项 */ +interface FbStyleOpt { + key: FbCalStyle; + label: string; + hint: string; +} + +const STYLE_OPTS: FbStyleOpt[] = [ + { key: 'timeline', label: '时间轴', hint: '按小时排布,一眼看出时间分布' }, + { key: 'list', label: '列表', hint: '一条一行,看得更清楚' } +]; + +@ComponentV2 +export struct FbCalSettingsSheet { + /** 日历本(可见性开关要列出来) */ + @Param books: FbCalBook[] = []; + /** 关掉 */ + @Event onClose: () => void = (): void => { + }; + /** + * 设置变了(父页面据此重新渲染 —— 比如切换视图后马上生效)。 + */ + @Event onChanged: () => void = (): void => { + }; + + @Local view: FbCalView = 'month'; + @Local style: FbCalStyle = 'timeline'; + @Local bookId: number = 0; + + aboutToAppear(): void { + this.view = FB_CAL_SETTINGS.getView(); + this.style = FB_CAL_SETTINGS.getStyle(); + this.bookId = FB_CAL_SETTINGS.getBookId(); + } + + /** 某一本当前是否可见 */ + private visibleOf(id: number): boolean { + return FB_CAL_SETTINGS.isVisible(id); + } + + /** 视图 key → 中文(给"当前:xx"那行用) */ + private viewWord(v: FbCalView): string { + if (v === 'week') { return '周视图'; } + if (v === 'agenda') { return '列表视图'; } + return '月视图'; + } + + private styleWord(s: FbCalStyle): string { + return s === 'list' ? '列表' : '时间轴'; + } + + @Builder + sectionTitle(t: string) { + Text(t) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + .width('100%') + .margin({ top: 4 }) + } + + build() { + // FB_WRAP_CENTER:把卡片**垂直居中**(用户 2026-10-16 要求:浮在屏幕中间)。 + // ⚠️ 居中放在**组件内部**做,而不是改宿主的 Stack 对齐 —— + // 宿主 Stack 里还有别的子元素(日程页的悬浮 +),改对齐会连带挪走它们。 + // ⚠️ 外层铺满 + Center;内层仍是那张 92% 宽的圆角卡。 + Column() { + Column({ space: 0 }) { + // ---- 标题栏 ---- + Row({ space: 12 }) { + Column({ space: 2 }) { + Text('日程设置') + .fontSize(FbMetric.fontTitle) + .fontWeight(FontWeight.Medium) + .fontColor(FbColors.ink) + // 🔴 把"当前是什么"直接写在标题下面 —— + // 用户点完一眼就能确认生效了(不用去几行里找哪颗打了勾) + Text('当前:' + this.viewWord(this.view) + ' · ' + this.styleWord(this.style)) + .fontSize(FbMetric.fontTiny) + .fontColor('#4A7C82') + } + .alignItems(HorizontalAlign.Start) + .layoutWeight(1) + + Text('完成') + .fontSize(FbMetric.fontSmall) + .fontColor('#4A7C82') + .padding({ left: 12, right: 12, top: 6, bottom: 6 }) + .onClick((): void => { + this.onClose(); + }) + } + .width('100%') + .alignItems(VerticalAlign.Center) + + Scroll() { + Column({ space: 6 }) { + // ---- 默认视图 ---- + this.sectionTitle('打开的默认视图') + ForEach(VIEW_OPTS, (o: FbViewOpt) => { + // 🔴 用**组件**而不是 @Builder —— @Builder 的值类型参数不参与观察, + // 点了不会重建(这正是"默认视图点不动"的根因) + FbCalOptionRow({ + label: o.label, + hint: o.hint, + active: this.view === o.key, + onPick: (): void => { + this.view = o.key; + FB_CAL_SETTINGS.setView(o.key); + this.onChanged(); + } + }) + }, (o: FbViewOpt): string => 'vo_' + o.key) + + // ---- 显示方式 ---- + this.sectionTitle('日程怎么显示') + ForEach(STYLE_OPTS, (o: FbStyleOpt) => { + FbCalOptionRow({ + label: o.label, + hint: o.hint, + active: this.style === o.key, + onPick: (): void => { + this.style = o.key; + FB_CAL_SETTINGS.setStyle(o.key); + this.onChanged(); + } + }) + }, (o: FbStyleOpt): string => 'so_' + o.key) + + // ---- 默认日历本 ---- + if (this.books.length > 0) { + this.sectionTitle('新建日程默认加入') + ForEach(this.books, (b: FbCalBook) => { + if (b.canWrite) { + FbCalOptionRow({ + label: b.name, + hint: '', + active: this.bookId === b.id, + dotColor: b.color, + onPick: (): void => { + this.bookId = b.id; + FB_CAL_SETTINGS.setBookId(b.id); + this.onChanged(); + } + }) + } + }, (b: FbCalBook): string => 'db_' + b.id.toString()) + + // ---- 哪些日历本可见 ---- + this.sectionTitle('显示哪些日历本') + ForEach(this.books, (b: FbCalBook) => { + // ⚠️ 这里**不用** `FbCalOptionRow`(那个是"单选、带打勾"的行); + // 可见性是**独立开关**,一行配一个 Toggle 更直观。 + Row({ space: 10 }) { + FbDot({ dia: 10, fillC: b.color }) + Text(b.name) + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.ink) + .layoutWeight(1) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + Toggle({ type: ToggleType.Switch, isOn: this.visibleOf(b.id) }) + .selectedColor(b.color) + .onChange((on: boolean): void => { + FB_CAL_SETTINGS.setVisible(b.id, on); + this.onChanged(); + }) + } + .width('100%') + .padding({ top: 6, bottom: 6, left: 12, right: 12 }) + .backgroundColor(FbColors.inputBg) + .borderRadius(FbMetric.radiusS) + .margin({ bottom: 8 }) + .alignItems(VerticalAlign.Center) + }, (b: FbCalBook): string => 'vb_' + b.id.toString()) + } + + Text('这些设置只存在这台手机上,不会同步给别人。') + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + .width('100%') + .margin({ top: 8 }) + } + .width('100%') + .padding({ top: 14 }) + } + .layoutWeight(1) + .width('100%') + .scrollBar(BarState.Off) + .align(Alignment.TopStart) + } + // 🔴 用户 2026-10-16:「它应该弹出一个**圆角的、浮在上面的卡片**…… + // **不要让它左右充满整个屏幕,下面也不要挨着屏幕**, + // 而是放在**屏幕中间**,采用**圆角浮窗**式的形式。」 + // ⇒ 不再贴底满宽:左右留边、四角圆角、垂直居中(由宿主的 Stack 对齐)。 + .width('92%') + .constraintSize({ maxHeight: '72%' }) + .padding({ left: 18, right: 18, top: 18, bottom: 20 + FB_SHEET_BOTTOM_GAP }) + .backgroundColor(FbColors.surface) + .borderRadius(FbMetric.radius) + .alignItems(HorizontalAlign.Start) + } + .width('100%') + .height('100%') + .justifyContent(FlexAlign.Center) + .hitTestBehavior(HitTestMode.Transparent) + } +} \ No newline at end of file diff --git a/entry/src/main/ets/components/FbCinemaSheet.ets b/entry/src/main/ets/components/FbCinemaSheet.ets new file mode 100644 index 0000000..6871eee --- /dev/null +++ b/entry/src/main/ets/components/FbCinemaSheet.ets @@ -0,0 +1,158 @@ +/** + * 家书 FamilyBook · 影院列表浮层(鸿蒙端) + * + * 观影模块的"影院管理"入口(Web 端在观影页的设置里)。 + * + * 🔴 为什么影院**不占抽屉里的位置**: + * 它不是独立模块,是"观影"的一部分 —— 一家影院只有"在哪、 + * 叫什么、在那看过几部"这三个属性,独立成一页太空。 + * ⇒ 做成观影页里的浮层(与详情、设置同一套交互)。 + * + * ⚠️ 数据来自**本地库**(观影同步时一起落库的 `cinemas` 表), + * 不是现拉接口 —— 见 `FbMovieRepo.cinemaList()` 的说明。 + */ + +import { FbColors, FbMetric } from '../common/Theme'; +import { FbCinemaItem } from '../data/FbMovieRepo'; + +/** + * 贴底卡片的手势区缓冲(vp)。 + * + * 🔴 应用开了全屏布局且**隐藏了系统手势条**(见 EntryAbility)—— + * 手势区不再有任何视觉提示,贴底的卡片必须自己让出这一段, + * 否则最后一排按钮会被"从下往上滑"误触。 + */ +const FB_SHEET_BOTTOM_GAP: number = 16; + + +@ComponentV2 +export struct FbCinemaSheet { + /** 影院列表 */ + @Param cinemas: FbCinemaItem[] = []; + /** 关闭回调 */ + @Event onClose: () => void = (): void => { + }; + + build() { + // FB_WRAP_CENTER:把卡片**垂直居中**(用户 2026-10-16 要求:浮在屏幕中间)。 + // ⚠️ 居中放在**组件内部**做,而不是改宿主的 Stack 对齐 —— + // 宿主 Stack 里还有别的子元素(日程页的悬浮 +),改对齐会连带挪走它们。 + // ⚠️ 外层铺满 + Center;内层仍是那张 92% 宽的圆角卡。 + Column() { + Column({ space: 0 }) { + // ---- 标题 ---- + Row() { + Column({ space: 2 }) { + Text('影院') + .fontSize(FbMetric.fontTitle) + .fontWeight(FontWeight.Medium) + .fontColor(FbColors.ink) + Text(this.cinemas.length.toString() + ' 家 · 按名字排序') + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + } + .alignItems(HorizontalAlign.Start) + .layoutWeight(1) + + Text('关闭') + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.muted) + .padding({ left: 12, right: 12, top: 6, bottom: 6 }) + .backgroundColor(FbColors.inputBg) + .borderRadius(FbMetric.radiusS) + .onClick((): void => { + this.onClose(); + }) + } + .width('100%') + .alignItems(VerticalAlign.Center) + + Divider() + .color(FbColors.line) + .margin({ top: 12, bottom: 4 }) + + // ---- 列表 ---- + // ⚠️ 用 List 而不是 ForEach 堆在 Column 里:影院可能几十家, + // List 才会**按需渲染**(进视口才建),Column 会一次全建。 + if (this.cinemas.length === 0) { + Text('还没有影院。同步观影记录后,去过的地方会自动出现在这里。') + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.faint) + .width('100%') + .padding({ top: 32, bottom: 32, left: 12, right: 12 }) + .textAlign(TextAlign.Center) + } else { + List({ space: 0 }) { + ForEach(this.cinemas, (c: FbCinemaItem, idx: number) => { + ListItem() { + Column({ space: 0 }) { + Row({ space: 12 }) { + // 色点:每家影院一个色(与 Web 端一致,便于在别处一眼认出来) + Column() + .width(10) + .height(10) + .backgroundColor(c.color) + .borderRadius(5) + Column({ space: 3 }) { + Row({ space: 8 }) { + Text(c.name) + .fontSize(FbMetric.fontBody) + .fontColor(FbColors.ink) + .maxLines(1) + if (c.movieCount > 0) { + Text(c.movieCount.toString() + ' 部') + .fontSize(10) + .fontColor(FbColors.faint) + } + } + .alignItems(VerticalAlign.Center) + + // 场所 / 地址(有才显示) + if (c.place !== '' || c.address !== '') { + Text(c.place !== '' ? c.place : c.address) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.muted) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + } + .alignItems(HorizontalAlign.Start) + .layoutWeight(1) + } + .width('100%') + .padding({ left: 4, right: 4, top: 11, bottom: 11 }) + .alignItems(VerticalAlign.Center) + + if (idx < this.cinemas.length - 1) { + Divider() + .strokeWidth(0.5) + .color(FbColors.line) + } + } + .width('100%') + } + }, (c: FbCinemaItem): string => 'cin_' + c.id.toString()) + } + .width('100%') + .layoutWeight(1) + .scrollBar(BarState.Off) + .edgeEffect(EdgeEffect.Spring) + } + } + // 🔴 用户 2026-10-16:「它应该弹出一个**圆角的、浮在上面的卡片**…… + // **不要让它左右充满整个屏幕,下面也不要挨着屏幕**, + // 而是放在**屏幕中间**,采用**圆角浮窗**式的形式。」 + // ⇒ 不再贴底满宽:左右留边、四角圆角、垂直居中(由宿主的 Stack 对齐)。 + .width('92%') + .constraintSize({ maxHeight: '72%' }) + .padding({ left: 18, right: 18, top: 18, bottom: 20 + FB_SHEET_BOTTOM_GAP }) + .backgroundColor(FbColors.surface) + .borderRadius(FbMetric.radius) + .alignItems(HorizontalAlign.Start) + } + .width('100%') + .height('100%') + .justifyContent(FlexAlign.Center) + .hitTestBehavior(HitTestMode.Transparent) + } +} \ No newline at end of file diff --git a/entry/src/main/ets/components/FbDayEventPanel.ets b/entry/src/main/ets/components/FbDayEventPanel.ets new file mode 100644 index 0000000..7ce7903 --- /dev/null +++ b/entry/src/main/ets/components/FbDayEventPanel.ets @@ -0,0 +1,182 @@ +/** + * 家书 FamilyBook · 「某一天的日程」面板(鸿蒙端) + * + * 日程页里"日历下方 / 周条下方 / 列表里某天"共用的那一块。 + * + * 🔴🔴 为什么必须是**组件**而不是 `@Builder`(用户 2026-10-16 报的真 bug): + * 「你在日历上点击任何一个地方,**下面的日程都不变**。」 + * 根因:原来是 `@Builder dayPanel(iso: string)` —— + * **`@Builder` 的值类型参数不参与状态观察** + * (SDK 规则 `r/quick-rules/05-state.md`:按值传递「状态变化刷新 ❌」; + * 本项目已在 `FbTagChip` 上踩过同一个坑,见那份的文件头)。 + * ⇒ 点格子后 `pickedIso` 确实变了、`eventsOf()` 也能算对, + * 但**已经建好的那份 Builder 子树不会重建** ⇒ 看起来"下面不变"。 + * ⇒ 改成 `@ComponentV2` + `@Param`:`@Param` **是参与观察的**, + * 父组件传进来的日期/条目一变,这一整块就重建。 + * + * ⚠️ 父组件必须把**算好的条目**传进来(`events`), + * 而不是传日期让本组件自己去查 —— 组件拿不到父组件的筛选状态。 + * (传 `events` 还有个好处:每次都是新数组引用 ⇒ 一定触发重建。) + */ + +import { FbColors, FbMetric } from '../common/Theme'; +import { FbCalEvent } from '../model/Types'; +import { FbCalStyle } from '../data/FbCalSettings'; +import { FbTimelineUtil } from '../common/FbTimeline'; +import { FbDayTimeline } from './FbDayTimeline'; +import { FbEmptyBox } from './Widgets'; + +@ComponentV2 +export struct FbDayEventPanel { + /** 面板标题(`10月15日 周三`) */ + @Param label: string = ''; + /** 这一天的条目(**父组件已筛选排序好的**) */ + @Param events: FbCalEvent[] = []; + /** 显示方式:时间轴 / 列表(来自日程设置) */ + @Param style: FbCalStyle = 'timeline'; + /** 是不是今天(时间轴画红线用) */ + @Param isToday: boolean = false; + /** 当前时刻(当天第几分钟) */ + @Param nowMin: number = 0; + /** + * 面板头是否显示。 + * + * 🔴 v54:首页复用了本组件(用户 2026-10-16:「把**今天那个块**拿到首页上来」), + * 而首页自己有一个 `FbSectionTitle`("今天的日程 · N 条")—— + * 两个头叠在一起会重复。⇒ 首页传 `false` 把内置头关掉。 + * ⚠️ 日程页保持 `true`(那里没有别的标题)。 + */ + @Param showHead: boolean = true; + /** + * 空态的提示语。空串 = 用默认那句(日程页:点右下角 + 加一条)。 + * + * ⚠️ 首页**不能**说"点右下角的 +" —— 那是日程页的悬浮按钮, + * 首页没有它,说了等于骗人。 + */ + @Param emptyHint: string = ''; + /** + * 空态的标题。空串 = `这天没有安排`。 + * ⚠️ 日程页的 `label` 是具体日期(`10月15日 周三`),说"这天"是对的; + * 首页说的是"今天",所以那边传 `今天没有安排`。 + */ + @Param emptyTitle: string = ''; + /** 点一条日程 */ + @Event onPick: (e: FbCalEvent) => void = (e: FbCalEvent): void => { + }; + + build() { + Column({ space: 0 }) { + // ---- 面板头(首页会关掉它,见 showHead 的说明)---- + if (this.showHead) { + Row() { + Text(this.label) + .fontSize(FbMetric.fontSmall) + .fontWeight(FontWeight.Medium) + .fontColor(FbColors.ink) + Blank() + Text(this.events.length.toString() + ' 条') + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + } + .width('100%') + .padding({ left: 4, right: 4, top: 12, bottom: 8 }) + } + + // ---- 主体 ---- + if (this.events.length === 0) { + Column() { + FbEmptyBox({ + title: this.emptyTitle !== '' ? this.emptyTitle : '这天没有安排', + hint: this.emptyHint !== '' ? this.emptyHint : '点右下角的 + 加一条日程', + icon: $r('app.media.fb_ic_calendar') + }) + } + .width('100%') + .padding({ left: FbMetric.cardPad, right: FbMetric.cardPad, bottom: 6 }) + } else if (this.style === 'timeline') { + // ---- 时间轴:按真实时间铺在 0-24 小时格子上 ---- + FbDayTimeline({ + timeline: FbTimelineUtil.build(this.events, this.isToday, this.nowMin), + isToday: this.isToday, + nowMin: this.nowMin, + onPick: (e: FbCalEvent): void => { + this.onPick(e); + } + }) + .width('100%') + .padding({ left: FbMetric.cardPad, right: FbMetric.cardPad, bottom: 8 }) + } else { + // ---- 列表:一条一行 ---- + Column({ space: 0 }) { + ForEach(this.events, (ev: FbCalEvent, idx: number) => { + this.eventRow(ev, idx === this.events.length - 1) + }, (ev: FbCalEvent, idx: number): string => + 'ev_' + this.label + '_' + idx.toString() + '_' + ev.id.toString()) + } + .width('100%') + .padding({ left: FbMetric.cardPad, right: FbMetric.cardPad, bottom: 6 }) + } + } + .backgroundColor(FbColors.surface) + .borderRadius(FbMetric.radius) + .margin({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 12 }) + .alignItems(HorizontalAlign.Start) + } + + /** 列表模式的一条(左侧色条 + 标题 + 时间 + 地点 + 日历本名) */ + @Builder + eventRow(ev: FbCalEvent, isLast: boolean) { + Column({ space: 0 }) { + Row({ space: 10 }) { + Row() + .width(4) + .height(38) + .backgroundColor(ev.color) + .borderRadius(2) + + Column({ space: 3 }) { + Text(ev.title !== '' ? ev.title : '(无标题)') + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.ink) + .maxLines(2) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + Row({ space: 6 }) { + Text(ev.allDay ? '全天' + : (ev.endTime !== '' ? (ev.time + ' - ' + ev.endTime) : ev.time)) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.muted) + if (ev.place !== '') { + Text('📍' + ev.place) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + .maxLines(1) + .layoutWeight(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + } + .width('100%') + } + .alignItems(HorizontalAlign.Start) + .layoutWeight(1) + + Text(ev.calName) + .fontSize(10) + .fontColor(ev.color) + .maxLines(1) + } + .width('100%') + .padding({ top: 10, bottom: 10 }) + .alignItems(VerticalAlign.Center) + .onClick((): void => { + this.onPick(ev); + }) + + if (!isLast) { + Divider() + .color(FbColors.line2) + .margin({ left: 14 }) + } + } + .width('100%') + } +} \ No newline at end of file diff --git a/entry/src/main/ets/components/FbDayTimeline.ets b/entry/src/main/ets/components/FbDayTimeline.ets new file mode 100644 index 0000000..174487a --- /dev/null +++ b/entry/src/main/ets/components/FbDayTimeline.ets @@ -0,0 +1,476 @@ +/** + * 家书 FamilyBook · 日程「时间轴」视图(鸿蒙端) + * + * 🔴 用户 2026-10-16:「**没有时间轴展示**……你这不是说让你把 SyncCalendar + * 里面的功能搬过来吗?」 + * ⇒ 照 SyncCalendar 的 `DayTimelineView`(在 `Index.ets` 里)移植: + * 左侧小时刻度 + 右侧**三层堆叠**渲染。 + * + * 为什么是"三层堆叠"(照 SyncCalendar 的设计,别改): + * · 第 1 层 **整点网格**:每小时一格、格底一条灰线; + * · 第 2 层 **日程色块**:按冲突组分列、纵向用 `Blank` 定位; + * · 第 3 层 **当前时间红线**:浮在最上面。 + * ⚠️ 三层尺寸与分格完全一致、上层透明 ⇒ + * **色块不会被整点线或红线"切开"**,红线也永远压在最上面。 + * 如果图省事把线直接画进日程层,一条日程就会被切成两段。 + * + * 🔴 为什么是"组件"而不是 `@Builder`: + * `@Builder` 的**值类型参数不参与状态观察**(见 `FbTagChip.ets` 的说明、 + * SDK 规则 `r/quick-rules/05-state.md`)—— 本项目的日程页正是因此 + * 出现"点日历下面的日程不变"。这里用 `@ComponentV2` + `@Param`, + * `timeline` 一变整棵子树就重建。 + */ + +import { FbColors, FbMetric } from '../common/Theme'; +import { FbCalEvent } from '../model/Types'; +import { + FbTimelineData, FbTimeBlock, FbTimeGroup, FbTimeRow, FbTimelineUtil, FbViewRange +} from '../common/FbTimeline'; + +/** 「当前时间红线」的颜色(照 SyncCalendar:系统红) */ +const FB_NOW_RED: string = '#FF3B30'; + +@ComponentV2 +export struct FbDayTimeline { + /** 一天的布局(**必须由父组件按选中日期算好传进来**) */ + @Param timeline: FbTimelineData = new FbTimelineData(); + /** 是不是"今天"(决定要不要画红线) */ + @Param isToday: boolean = false; + /** 当前时刻(当天第几分钟) */ + @Param nowMin: number = 0; + /** 点一条日程 */ + @Event onPick: (e: FbCalEvent) => void = (e: FbCalEvent): void => { + }; + + /** 1 小时的高度(vp) */ + private hourUnit: number = FbTimelineUtil.HOUR_UNIT; + /** 极短色块的可读高度下限(一行"标题 + 时间") */ + private minBlockVp: number = 16; + /** 能放下两行的高度门槛 */ + private twoLineVp: number = 30; + /** 能放下三行的高度门槛 */ + private threeLineVp: number = 46; + + // ---- 视窗 ---- + private range(): FbViewRange { + return FbTimelineUtil.viewRange(this.timeline.blocks); + } + + private viewTop(): number { + return this.range().startHour / 24; + } + + private viewBot(): number { + return this.range().endHour / 24; + } + + /** 视窗总高(vp) */ + private viewH(): number { + const r: FbViewRange = this.range(); + return (r.endHour - r.startHour) * this.hourUnit; + } + + /** 整日高度(vp) */ + private totalH(): number { + return this.hourUnit * 24; + } + + /** 视窗内的小时刻度 */ + private hourLabels(): number[] { + const r: FbViewRange = this.range(); + const out: number[] = []; + for (let h = r.startHour; h < r.endHour; h++) { + out.push(h); + } + return out; + } + + private hourText(h: number): string { + return h < 10 ? ('0' + h.toString() + ':00') : (h.toString() + ':00'); + } + + /** 当前小时是否高亮(仅今天) */ + private isNowHour(h: number): boolean { + return this.isToday && Math.floor(FbTimelineUtil.nowRatio(this.nowMin) * 24) === h; + } + + /** 红线是否可见(今天 + 落在视窗内) */ + private nowVisible(): boolean { + if (!this.isToday) { + return false; + } + const r: number = FbTimelineUtil.nowRatio(this.nowMin); + return r >= this.viewTop() - 0.0001 && r <= this.viewBot() + 0.0001; + } + + /** 红线距层顶的偏移(vp) */ + private nowOffsetVp(): number { + const y: number = (FbTimelineUtil.nowRatio(this.nowMin) - this.viewTop()) * this.totalH() - 1; + return y < 0 ? 0 : y; + } + + // ---- 行序列(空隙 + 冲突组)---- + + private groups(): FbTimeGroup[] { + return FbTimelineUtil.groupsOf(this.timeline.blocks); + } + + /** + * 纵向序列:只看视窗内的部分。 + * **本层只有两样东西:空隙(Blank)与冲突组行** —— + * 整点线与红线交给另外两层,所以每条日程永远是完整连续的色块。 + */ + private buildRows(): FbTimeRow[] { + const rows: FbTimeRow[] = []; + const gs: FbTimeGroup[] = this.groups(); + const top: number = this.viewTop(); + const bot: number = this.viewBot(); + const dk: string = this.range().startHour.toString() + '_' + this.range().endHour.toString(); + let cursor: number = top; + for (const g of gs) { + const gsx: number = g.startRatio > top ? g.startRatio : top; + const gex: number = g.endRatio < bot ? g.endRatio : bot; + if (gex - gsx <= 0.0002) { + continue; // 整组都在视窗外 + } + this.pushRow(rows, cursor, gsx, null, dk); + this.pushRow(rows, gsx, gex, g, dk); + cursor = gex; + } + this.pushRow(rows, cursor, bot, null, dk); + return rows; + } + + private pushRow(rows: FbTimeRow[], from: number, to: number, + g: FbTimeGroup | null, dk: string): void { + if (to - from <= 0.0002) { + return; + } + const r: FbTimeRow = new FbTimeRow(); + r.topRatio = from; + r.botRatio = to; + if (g === null) { + r.kind = 0; + r.key = 'g_' + dk + '_' + rows.length.toString() + '_' + + Math.round((to - from) * 100000).toString(); + } else { + r.kind = 1; + r.group = g; + let sig: string = ''; + for (const b of g.blocks) { + sig = sig + '_' + b.key; + } + r.key = 'b_' + dk + '_' + rows.length.toString() + sig; + } + rows.push(r); + } + + /** 某一列中、与本行窗口有交集的色块 */ + private laneBlocks(r: FbTimeRow, lane: FbTimeBlock[]): FbTimeBlock[] { + const out: FbTimeBlock[] = []; + for (const b of lane) { + const be: number = b.topRatio + b.heightRatio; + if (be > r.topRatio + 0.0001 && b.topRatio < r.botRatio - 0.0001) { + out.push(b); + } + } + return out; + } + + /** 列内第 index 个色块之前要空出的高度(vp) */ + private lanePadH(r: FbTimeRow, lane: FbTimeBlock[], index: number): number { + const arr: FbTimeBlock[] = this.laneBlocks(r, lane); + let prevEnd: number = r.topRatio; + if (index > 0) { + const p: FbTimeBlock = arr[index - 1]; + const pe: number = p.topRatio + p.heightRatio; + prevEnd = pe > r.topRatio ? pe : r.topRatio; + } + const b: FbTimeBlock = arr[index]; + const top: number = b.topRatio > r.topRatio ? b.topRatio : r.topRatio; + const d: number = (top - prevEnd) * this.totalH(); + return d < 0 ? 0 : d; + } + + /** 色块实际渲染高度(vp):极短的尽量垫到可读高度,但不越过同列下一块 */ + private blockVp(r: FbTimeRow, lane: FbTimeBlock[], index: number): number { + const arr: FbTimeBlock[] = this.laneBlocks(r, lane); + const b: FbTimeBlock = arr[index]; + const real: number = b.heightRatio * this.totalH(); + if (real >= this.minBlockVp) { + return real; + } + let room: number = 0; + if (index + 1 < arr.length) { + room = this.lanePadH(r, lane, index + 1); + } else { + const tail: number = (r.botRatio - (b.topRatio + b.heightRatio)) * this.totalH(); + room = tail > 0 ? tail : 0; + } + const h: number = real + room; + return h > this.minBlockVp ? this.minBlockVp : h; + } + + /** 色块信息行数(按渲染高度判定) */ + private blockLines(r: FbTimeRow, lane: FbTimeBlock[], index: number, b: FbTimeBlock): number { + const h: number = this.blockVp(r, lane, index); + if (h >= this.threeLineVp && b.place !== '') { + return 3; + } + if (h >= this.twoLineVp) { + return 2; + } + return 1; + } + + /** 两行色块的第二行:`09:00 - 10:30 · 地点` */ + private blockMeta(b: FbTimeBlock): string { + if (b.place !== '') { + return b.timeText + ' · ' + b.place; + } + return b.timeText; + } + + /** 色块 key 的内容签名(内容变了必须重建,否则 ArkUI 复用旧子组件) */ + private blockSig(r: FbTimeRow, lane: FbTimeBlock[], index: number, b: FbTimeBlock): string { + return b.title.length.toString() + '_' + b.place.length.toString() + '_' + + Math.round(this.blockVp(r, lane, index)).toString(); + } + + // ============================================================ + // 三层 + // ============================================================ + + /** 第 1 层:整点网格(每小时一格,格底一条灰线) */ + @Builder + gridLayer() { + Column() { + ForEach(this.hourLabels(), (h: number) => { + Column() + .width('100%') + .height(this.hourUnit) + .border({ width: { bottom: 0.5 }, color: { bottom: FbColors.line2 } }) + .hitTestBehavior(HitTestMode.None) + }, (h: number): string => 'gd_' + h.toString()) + } + .width('100%') + .height('100%') + .hitTestBehavior(HitTestMode.None) // 装饰层:点击穿透到色块 + } + + /** 第 2 层:日程色块(冲突组分行 + 组内分列,纵向用 Blank 定位) */ + @Builder + eventLayer() { + Column() { + ForEach(this.buildRows(), (r: FbTimeRow) => { + if (r.kind === 1 && r.group !== null) { + Row() { + ForEach(r.group!.lanes, (lane: FbTimeBlock[], li: number) => { + Column() { + ForEach(this.laneBlocks(r, lane), (b: FbTimeBlock, index: number) => { + Blank().height(this.lanePadH(r, lane, index)) + Column({ space: 1 }) { + if (this.blockLines(r, lane, index, b) === 1) { + // 一行:标题 · 时间(地点放不下) + Row({ space: 4 }) { + Text(b.title) + .fontSize(10) + .fontWeight(FontWeight.Medium) + .fontColor(Color.White) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .constraintSize({ maxWidth: '50%' }) + Text(b.timeText) + .fontSize(9) + .fontColor('#E6FFFFFF') + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .constraintSize({ maxWidth: '55%' }) + } + .width('100%') + .alignItems(VerticalAlign.Center) + } else { + // 第一行:标题 + Text(b.title) + .fontSize(11) + .fontWeight(FontWeight.Medium) + .fontColor(Color.White) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .width('100%') + if (this.blockLines(r, lane, index, b) >= 3) { + Text(b.timeText) + .fontSize(9) + .fontColor('#E6FFFFFF') + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .width('100%') + Text(b.place) + .fontSize(9) + .fontColor('#B3FFFFFF') + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .width('100%') + } else { + Text(this.blockMeta(b)) + .fontSize(9) + .fontColor('#E6FFFFFF') + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .width('100%') + } + } + } + .alignItems(HorizontalAlign.Start) + .justifyContent(FlexAlign.Start) + .padding({ left: 6, right: 3, top: 2, bottom: 2 }) + .borderRadius(6) + .backgroundColor(b.color) + .clip(true) + .width('100%') + .height(this.blockVp(r, lane, index)) + .onClick((): void => { + if (b.ev !== null) { + this.onPick(b.ev); + } + }) + }, (b: FbTimeBlock, index: number): string => + 'blk_' + r.key + '_' + b.key + '_' + this.blockSig(r, lane, index, b)) + } + .layoutWeight(1) + .height('100%') + .padding({ right: 2 }) + .clip(true) + }, (lane: FbTimeBlock[], li: number): string => 'ln_' + r.key + '_' + li.toString()) + } + .width('100%') + .height((r.botRatio - r.topRatio) * this.totalH()) + .alignItems(VerticalAlign.Top) + } else { + // 空隙:纯占位 + Blank().height((r.botRatio - r.topRatio) * this.totalH()) + } + }, (r: FbTimeRow): string => r.key) + } + .width('100%') + .height('100%') + } + + /** 第 3 层:当前时间红线(浮在最上层) */ + @Builder + nowLayer() { + if (this.nowVisible()) { + Row() { + // 左侧时间文字(红线那头) + Text(FbTimelineUtil.hhmm(this.nowMin)) + .fontSize(9) + .fontColor(FB_NOW_RED) + .fontWeight(FontWeight.Medium) + .textAlign(TextAlign.End) + .width(34) + Row() + .layoutWeight(1) + .height(2) + .backgroundColor(FB_NOW_RED) + Row() + .width(6) + .height(6) + .borderRadius(3) + .backgroundColor(FB_NOW_RED) + } + .width('100%') + .alignItems(VerticalAlign.Center) + .margin({ top: this.nowOffsetVp() }) + .hitTestBehavior(HitTestMode.None) + } + } + + build() { + Column({ space: 0 }) { + // ---- 全天 / 跨天:画在 0 点之前,用色块展示 ---- + if (this.timeline.allDay.length > 0) { + Column({ space: 4 }) { + ForEach(this.timeline.allDay, (e: FbCalEvent) => { + Row({ space: 6 }) { + Text('全天') + .fontSize(9) + .fontColor(Color.White) + .backgroundColor('#26000000') + .borderRadius(6) + .padding({ left: 5, right: 5, top: 1, bottom: 1 }) + Text(e.title !== '' ? e.title : '(无标题)') + .fontSize(12) + .fontWeight(FontWeight.Medium) + .fontColor(Color.White) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .layoutWeight(1) + if (e.place !== '') { + Text(e.place) + .fontSize(10) + .fontColor('#D9FFFFFF') + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .constraintSize({ maxWidth: 110 }) + } + } + .alignItems(VerticalAlign.Center) + .width('100%') + .height(26) + .padding({ left: 8, right: 8 }) + .borderRadius(8) + .backgroundColor(e.color) + .onClick((): void => { + this.onPick(e); + }) + }, (e: FbCalEvent): string => 'al_' + FbTimelineUtil.keyOf(e)) + } + .width('100%') + .padding({ bottom: 6 }) + } + + // ---- 时间轴主体 ---- + Row() { + // 左侧小时刻度 + Column() { + ForEach(this.hourLabels(), (h: number) => { + Text(this.hourText(h)) + .fontSize(10) + .fontColor(this.isNowHour(h) ? FB_NOW_RED : FbColors.faint) + .width(36) + .height(this.hourUnit) + .textAlign(TextAlign.End) + .padding({ right: 4, top: 2 }) + .border({ width: { bottom: 0.5 }, color: { bottom: FbColors.line2 } }) + }, (h: number): string => 'h' + h.toString()) + } + .width(36) + + // 右侧内容区:三层堆叠 + Stack() { + this.gridLayer() + this.eventLayer() + this.nowLayer() + } + .layoutWeight(1) + .height(this.viewH()) + .alignContent(Alignment.TopStart) + .border({ width: { left: 0.5 }, color: { left: FbColors.line2 } }) + } + .width('100%') + .alignItems(VerticalAlign.Top) + + // ---- 当天没有带时间的日程 ---- + if (!this.timeline.hasTimed && this.timeline.allDay.length === 0) { + Text('这天没有安排') + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.faint) + .width('100%') + .textAlign(TextAlign.Center) + .padding({ top: 20, bottom: 20 }) + } + } + .width('100%') + .alignItems(HorizontalAlign.Start) + } +} \ No newline at end of file diff --git a/entry/src/main/ets/components/FbEventChip.ets b/entry/src/main/ets/components/FbEventChip.ets new file mode 100644 index 0000000..9ba8109 --- /dev/null +++ b/entry/src/main/ets/components/FbEventChip.ets @@ -0,0 +1,50 @@ +/** + * 家书 FamilyBook · 可选中胶囊片(鸿蒙端) + * + * 日程「新建/编辑」里给**重复**和**提醒**两组选项用。 + * + * 🔴🔴 为什么必须是**组件**而不是 `@Builder`(用户 2026-10-16 报的真 bug): + * 「重复和提醒都挤在一块了……而且**重复和提醒什么的都点不了**。」 + * 根因:原来是 `FbEventEditSheet.chip(label, on: boolean, onTap)` —— + * **`@Builder` 的值类型参数(boolean)不参与状态观察** + * (SDK 规则 `r/quick-rules/05-state.md`:按值传递「状态变化刷新 ❌」)。 + * ⇒ 点一下 `this.fRepeat` / `this.fRemind` 确实改了、保存也发得出去, + * 但**选中态那份子树不重建** ⇒ 界面上没有任何反馈 ⇒ 看着"点不了"。 + * 本项目已在 `FbTagChip`、`FbCalOptionRow` 上踩过同一个坑(各见其文件头)。 + * ⇒ 统一做法:抽成 `@ComponentV2` + `@Param active`。 + * + * ⚠️ 顺带的另一处:它原来是裸放在 `Flex({wrap})` 里、**没有 margin** —— + * 项目里别处的 wrap 都写了 `.margin({ right: 8, bottom: 8 })` + * (见 `FbHomePage` 的标签云、`FbSettingsPage` 的色板)。 + * 少了 margin,两行 chip 会**贴在一起**(这正是"挤在一块"的观感来源)。 + * ⇒ margin 放在**本组件根节点**上,调用方不用再写。 + */ + +import { FbColors, FbMetric } from '../common/Theme'; + +@ComponentV2 +export struct FbEventChip { + /** 显示文字 */ + @Param label: string = ''; + /** 是否选中(**必须由父组件按状态算好传进来**) */ + @Param active: boolean = false; + /** 选中时的底色(默认用日程模块的主题色;日历本那组传本色) */ + @Param activeBg: string = '#4A7C82'; + /** 被点击 */ + @Event onPick: () => void = (): void => { + }; + + build() { + Text(this.label) + .fontSize(FbMetric.fontSmall) + .fontColor(this.active ? Color.White : FbColors.muted) + .backgroundColor(this.active ? this.activeBg : FbColors.inputBg) + .borderRadius(FbMetric.radiusPill) + .padding({ left: 12, right: 12, top: 6, bottom: 6 }) + // ⚠️ 间距放在组件内部:wrap 容器里的子项必须自己有 margin,否则会贴在一起 + .margin({ right: 8, bottom: 8 }) + .onClick((): void => { + this.onPick(); + }) + } +} \ No newline at end of file diff --git a/entry/src/main/ets/components/FbEventEditSheet.ets b/entry/src/main/ets/components/FbEventEditSheet.ets new file mode 100644 index 0000000..c7a115b --- /dev/null +++ b/entry/src/main/ets/components/FbEventEditSheet.ets @@ -0,0 +1,908 @@ +/** + * 家书 FamilyBook · 日程新建 / 编辑浮层(鸿蒙端) + * + * 用户 2026-10-15:「我们家书的日程功能模块……跟 SyncCalendar 做得一样」。 + * + * 字段对齐 SyncCalendar 的 `EventEditPage`: + * **标题 / 全天 / 日期与时间(同一行)/ 重复 / 提醒 / 日历本 / 地点 / 备注**, + * 编辑既有日程时底部还有一颗**删除**。 + * + * 🔴 用户 2026-10-16 的两条修正: + * 1. 「**重复和提醒都挤在一块了**……而且**重复和提醒什么的都点不了**。」 + * · "点不了" = chip 曾是 `@Builder` 值参数(不参与观察)⇒ 换成 `FbEventChip` 组件; + * · "挤在一块" = 裸 `Flex({wrap})` 里的 chip **没有 margin** ⇒ + * 间距放进 `FbEventChip` 组件内部(项目别处的 wrap 也都写了 margin)。 + * 2. 「时间和日期……**放在一行上吧**,别放上下了。」 + * · 原来开始/结束各占一整行(`日期 时间` 拼成一串)。 + * 3. **第二轮**:「你参考一下 SyncCalendar 的做法,实际上就是 + * **把日期和时间放在一个控件里面**。」 + * · 🔴 第一版我只做到"日期块 + 时间块并排一行",**但仍是两个可点区**、 + * 点开的是**两个独立选择器** —— 不是 SyncCalendar 的形态。 + * · SyncCalendar(`EventEditPage.dateTimePickerSheet`)是: + * **一个底部弹层里 `DatePicker` 与 `TimePicker` 并排**, + * 选完点「确定」**一次回填**。 + * · ⇒ 改成:控件是**一整块可点的框**(显示 `10月16日 周五 20:00`), + * 点它开弹层、里面两个滚轮并排、点「确定」写回; + * 弹层内部用**草稿**(`pickDate`/`pickHour`/`pickMin`)—— 取消可丢弃。 + * + * 🔴 为什么用**浮层**而不是像 SyncCalendar 那样跳新页面: + * 本项目的浮层范式已经统一(见 `FbPersonEditSheet` / `FbMovieDetailSheet`)—— + * 都是页面内的 `Stack` 覆盖层。跳页面会丢列表滚动位置、还要处理返回栈; + * 浮层状态完全由父页面掌控(一个 `@Param`),更简单。 + * ⚠️ 与 SyncCalendar 的**功能对齐**是有意的;**形态不同**是有意的。 + * + * ⚠️ 服务端 `POST/PATCH /api/v1/events` 的字段名是**下划线**(`starts_at` 等), + * 且"没传的字段沿用旧值" ⇒ 编辑时只需发改动的那些(见 `FbCalRepo.toBody`)。 + */ + +import { hilog } from '@kit.PerformanceAnalysisKit'; +import { FbColors, FbMetric } from '../common/Theme'; +import { FbCalEventFull, FbCalEventInput, FbCalRepo } from '../data/FbCalRepo'; +import { FbCalBook } from '../model/Types'; +import { FB_TOKEN_STORE } from '../data/FbTokenStore'; +import { FB_CAL_SETTINGS } from '../data/FbCalSettings'; +import { FbEventChip } from './FbEventChip'; + +const DOMAIN: number = 0x0000; +const TAG: string = 'FbEventEdit'; + +/** 贴底卡片的手势区缓冲(同其他浮层) */ +const FB_SHEET_BOTTOM_GAP: number = 16; + +/** + * 提醒选项(分钟 → 文案)。 + * + * 🔴 与 SyncCalendar 的 `reminderChip` 同一组值(0/5/10/15/30/60/1440)—— + * 两边口径一致,用户在两个 App 里看到的选项不会不一样。 + */ +interface FbRemindOpt { + min: number; + label: string; +} + +const REMIND_OPTS: FbRemindOpt[] = [ + { min: 0, label: '不提醒' }, + { min: 5, label: '提前5分钟' }, + { min: 10, label: '提前10分钟' }, + { min: 15, label: '提前15分钟' }, + { min: 30, label: '提前30分钟' }, + { min: 60, label: '提前1小时' }, + { min: 1440, label: '提前1天' } +]; + +/** + * 重复选项(key → 文案 + RRULE)。 + * + * ⚠️ 只做**最常见的那几种**(对齐 SyncCalendar 的 chip 组), + * "每两周/自定义"这类留给网页端 —— 手机上点五层下拉去拼 RRULE 得不偿失。 + * ⚠️ 端上拼的是**完整 RRULE 串**,服务端 `rruleFromBody()` 也认这个口径。 + */ +interface FbRepeatOpt { + key: string; + label: string; + rule: string; +} + +const REPEAT_OPTS: FbRepeatOpt[] = [ + { key: 'none', label: '不重复', rule: '' }, + { key: 'daily', label: '每天', rule: 'FREQ=DAILY' }, + { key: 'weekday', label: '工作日', rule: 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR' }, + { key: 'weekly', label: '每周', rule: 'FREQ=WEEKLY' }, + { key: 'monthly', label: '每月', rule: 'FREQ=MONTHLY' }, + { key: 'yearly', label: '每年', rule: 'FREQ=YEARLY' } +]; + +@ComponentV2 +export struct FbEventEditSheet { + /** 要编辑的日程(null + isCreate=false 表示没打开;isCreate=true 时是"新建") */ + @Param event: FbCalEventFull | null = null; + /** 新建模式 */ + @Param isCreate: boolean = false; + /** 新建时预填的日期(`YYYY-MM-DD`,从"点哪天的格子"来) */ + @Param presetDate: string = ''; + /** 可选的日历本(新建时要选落到哪本) */ + @Param books: FbCalBook[] = []; + /** 取消 */ + @Event onCancel: () => void = (): void => { + }; + /** 保存成功 */ + @Event onSaved: () => void = (): void => { + }; + /** 删除成功 */ + @Event onDeleted: () => void = (): void => { + }; + + // ---- 表单状态 ---- + @Local fTitle: string = ''; + @Local fAllDay: boolean = false; + /** `YYYY-MM-DD` */ + @Local fStartDate: string = ''; + /** `HH:mm` */ + @Local fStartTime: string = '09:00'; + @Local fEndDate: string = ''; + @Local fEndTime: string = '10:00'; + @Local fPlace: string = ''; + @Local fNote: string = ''; + /** 重复的 key(REPEAT_OPTS 里那个) */ + @Local fRepeat: string = 'none'; + /** 提醒分钟(可多选;空数组 = 不提醒) */ + @Local fRemind: number[] = []; + /** 选中的日历本 id */ + @Local fBookId: number = 0; + + @Local busy: boolean = false; + @Local err: string = ''; + @Local confirmDel: boolean = false; + @Local deleting: boolean = false; + + // ---- 「日期 + 时间」选择弹层(照 SyncCalendar 的 `dateTimePickerSheet`)---- + /** + * 弹层开着吗。 + * + * 🔴 **为什么不是"就地展开"**(这是用户 2026-10-16 的第二条反馈): + * SyncCalendar 的做法是**一个底部弹层里 `DatePicker` 与 `TimePicker` 并排** + * (见其 `EventEditPage.dateTimePickerSheet`),选完点「确定」一次回填。 + * 我第一版做成了"点日期块就地展开一个 DatePicker、点时间块再展开一个 TimePicker" + * —— 那是两个控件、分两次选,不是它的做法。 + */ + @Local pickOpen: boolean = false; + /** 正在选的是"结束"(false = 开始) */ + @Local pickIsEnd: boolean = false; + /** 弹层里的**草稿**值:点「确定」才写回 `fStartXxx`/`fEndXxx`(取消则丢弃) */ + @Local pickDate: Date = new Date(); + @Local pickHour: number = 9; + @Local pickMin: number = 0; + + aboutToAppear(): void { + const e: FbCalEventFull | null = this.event; + if (e !== null) { + // ---- 编辑:回填 ---- + this.fTitle = e.title; + this.fAllDay = e.all_day; + this.fStartDate = this.dateOf(e.starts_at); + this.fStartTime = this.timeOf(e.starts_at); + this.fEndDate = e.ends_at !== '' ? this.dateOf(e.ends_at) : this.dateOf(e.starts_at); + this.fEndTime = e.ends_at !== '' ? this.timeOf(e.ends_at) : this.addHour(e.starts_at); + this.fPlace = e.location; + this.fNote = e.note; + this.fRepeat = this.repeatKeyOf(e.rrule); + this.fRemind = this.minsOf(e.reminder_minutes); + this.fBookId = e.calendar_id; + return; + } + // ---- 新建:预填 ---- + const d: string = this.presetDate !== '' ? this.presetDate : this.todayStr(); + this.fStartDate = d; + this.fEndDate = d; + // 新建时把**开始时间**预填成"下一个整点"、结束 +1 小时 —— + // 比固定 09:00 更符合"现在就要加一条"的场景(打开就能直接存)。 + const now: Date = new Date(); + const nh: number = Math.min(now.getHours() + 1, 22); + this.fStartTime = this.pad(nh) + ':00'; + this.fEndTime = this.pad(nh + 1 > 23 ? 23 : nh + 1) + ':00'; + this.fBookId = this.defaultBookId(); + } + + // ============================================================ + // 取值 / 换算 + // ============================================================ + + private todayStr(): string { + const n: Date = new Date(); + return this.pad(n.getFullYear()) + '-' + this.pad(n.getMonth() + 1) + '-' + this.pad(n.getDate()); + } + + private pad(n: number): string { + return n < 10 ? ('0' + n.toString()) : n.toString(); + } + + /** 从 `YYYY-MM-DD HH:MM:SS` 取日期段 */ + private dateOf(dt: string): string { + return dt.length >= 10 ? dt.substring(0, 10) : this.todayStr(); + } + + /** 从 `YYYY-MM-DD HH:MM:SS` 取时间段的 `HH:mm` */ + private timeOf(dt: string): string { + return dt.length >= 16 ? dt.substring(11, 16) : '09:00'; + } + + /** 给一个时间的时分 +1 小时(当结束时间没填时的兜底) */ + private addHour(dt: string): string { + const hh: number = dt.length >= 16 ? parseInt(dt.substring(11, 13), 10) : 9; + const h2: number = Math.min(hh + 1, 23); + return this.pad(h2) + ':' + (dt.length >= 16 ? dt.substring(14, 16) : '00'); + } + + /** 默认落到的日历本:设置里选的(若还在可选列表里),否则第一本可写的 */ + private defaultBookId(): number { + const want: number = FB_CAL_SETTINGS.getBookId(); + for (const b of this.books) { + if (b.id === want && b.canWrite) { + return b.id; + } + } + for (const b of this.books) { + if (b.canWrite) { + return b.id; + } + } + return 0; + } + + /** 重复:从 RRULE 串反推是哪个 chip(认不出就归"不重复") */ + private repeatKeyOf(rrule: string): string { + if (rrule === '') { + return 'none'; + } + for (const o of REPEAT_OPTS) { + if (o.rule !== '' && rrule.indexOf(o.rule) === 0) { + return o.key; + } + } + // 认不出的复杂规则:给"不重复"更危险(会覆盖掉原规则), + // 但端上确实没有编辑自定义规则的能力 ⇒ 保留原文不覆盖(见 save() 的处理) + return 'none'; + } + + /** 重复 chip → RRULE 串 */ + private ruleOfRepeat(): string { + for (const o of REPEAT_OPTS) { + if (o.key === this.fRepeat) { + return o.rule; + } + } + return ''; + } + + /** 提醒串 → 分钟数组 */ + private minsOf(s: string): number[] { + const out: number[] = []; + if (s === '') { + return out; + } + for (const x of s.split(',')) { + const n: number = parseInt(x.trim(), 10); + if (!isNaN(n) && n > 0) { + out.push(n); + } + } + return out; + } + + /** 登录令牌 */ + private token(): string { + return FB_TOKEN_STORE.get(); + } + + // ============================================================ + // 小件 + // ============================================================ + + @Builder + field(label: string, value: string, hint: string, onChange: (v: string) => void) { + Column({ space: 4 }) { + Text(label) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + TextInput({ text: value, placeholder: hint }) + .placeholderColor(FbColors.faint) + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.ink) + .backgroundColor(FbColors.inputBg) + .borderRadius(FbMetric.radiusS) + .height(40) + .padding({ left: 12, right: 12 }) + .onChange((v: string): void => { + onChange(v); + }) + } + .width('100%') + .alignItems(HorizontalAlign.Start) + } + + @Builder + area(label: string, value: string, hint: string, onChange: (v: string) => void) { + Column({ space: 4 }) { + Text(label) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + TextArea({ text: value, placeholder: hint }) + .placeholderColor(FbColors.faint) + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.ink) + .backgroundColor(FbColors.inputBg) + .borderRadius(FbMetric.radiusS) + .height(72) + .padding({ left: 12, right: 12, top: 8, bottom: 8 }) + .onChange((v: string): void => { + onChange(v); + }) + } + .width('100%') + .alignItems(HorizontalAlign.Start) + } + + /** 小标题(把一大串字段分开) */ + @Builder + section(title: string) { + Text(title) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + .width('100%') + .margin({ top: 6 }) + } + + /** + * ⚠️ 这里删掉过两个 `@Builder`,都**不要**加回来: + * · `chip(label, on: boolean, onTap)` —— "重复/提醒点不了"的根因 + * (@Builder 值参数不参与观察)⇒ 已换成 `FbEventChip` 组件。 + * · `pickRow(label, value, onTap)` —— 被"日期时间并排一行"取代 + * (用户要求时间和日期同一行,不再上下堆)⇒ 见 `dateTimeRow`。 + */ + + build() { + // 🔴 外层 `Stack` 是**必需**的:本组件有**三个**要显示的层 + // (编辑卡片 + 日期时间弹层 + 删除确认遮罩),而 build() 只能有一个根节点。 + // ⚠️ `alignContent: Bottom` 让卡片/弹层都贴底(同一形态)。 + Stack({ alignContent: Alignment.Bottom }) { + this.editCard() + + // ---- 「日期 + 时间」选择弹层(照 SyncCalendar)---- + if (this.pickOpen) { + this.dateTimePickerSheet() + } + + if (this.confirmDel) { + this.deleteConfirm() + } + } + .width('100%') + .height('100%') + } + + @Builder + editCard() { + Column({ space: 0 }) { + // ---- 标题栏 ---- + Row({ space: 12 }) { + Text(this.isCreate ? '新建日程' : '编辑日程') + .fontSize(FbMetric.fontTitle) + .fontWeight(FontWeight.Medium) + .fontColor(FbColors.ink) + Blank() + Text('取消') + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.muted) + .padding({ left: 12, right: 12, top: 6, bottom: 6 }) + .onClick((): void => { + this.onCancel(); + }) + } + .width('100%') + .alignItems(VerticalAlign.Center) + + Scroll() { + Column({ space: 12 }) { + this.field('标题', this.fTitle, '日程得有个标题', (v: string): void => { + this.fTitle = v; + }) + + // ---- 全天 ---- + Row() { + Text('全天') + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.ink) + Blank() + Toggle({ type: ToggleType.Switch, isOn: this.fAllDay }) + .selectedColor('#4A7C82') + .onChange((on: boolean): void => { + this.fAllDay = on; + }) + } + .width('100%') + .height(42) + .padding({ left: 12, right: 12 }) + .backgroundColor(FbColors.inputBg) + .borderRadius(FbMetric.radiusS) + + // ---- 日期与时间 ---- + // 🔴 照 SyncCalendar:**一行一个"日期 + 时间"控件**(点开一个弹层、 + // 里面 DatePicker 与 TimePicker 并排,选完点「确定」)。 + // 用户 2026-10-16:「实际上就是把日期和时间放在一个控件里面。」 + this.section('日期与时间') + this.dateTimeRow('开始', true) + if (!this.fAllDay) { + this.dateTimeRow('结束', false) + } + + // ---- 重复 ---- + // 🔴 用 `FbEventChip` **组件**而不是 @Builder —— + // @Builder 的值类型参数(on: boolean)变化**不触发重建**, + // 这正是"重复/提醒点不了"的根因(见该组件文件头)。 + this.section('重复') + Flex({ wrap: FlexWrap.Wrap }) { + ForEach(REPEAT_OPTS, (o: FbRepeatOpt) => { + FbEventChip({ + label: o.label, + active: this.fRepeat === o.key, + onPick: (): void => { + this.fRepeat = o.key; + } + }) + }, (o: FbRepeatOpt): string => 'rp_' + o.key) + } + .width('100%') + .margin({ top: 2 }) + + // ---- 提醒 ---- + this.section('提醒(可多选)') + Flex({ wrap: FlexWrap.Wrap }) { + ForEach(REMIND_OPTS, (o: FbRemindOpt) => { + FbEventChip({ + label: o.label, + active: this.hasRemind(o.min), + onPick: (): void => { + this.toggleRemind(o.min); + } + }) + }, (o: FbRemindOpt): string => 'rm_' + o.min.toString()) + } + .width('100%') + .margin({ top: 2 }) + + // ---- 日历本 ---- + if (this.books.length > 0) { + this.section('加入哪本日历') + Flex({ wrap: FlexWrap.Wrap }) { + ForEach(this.books, (b: FbCalBook) => { + FbEventChip({ + label: b.name, + active: this.fBookId === b.id, + // 选中时用**这本日历自己的颜色**(一眼看出落到哪本) + activeBg: b.color, + onPick: (): void => { + this.fBookId = b.id; + } + }) + }, (b: FbCalBook): string => 'bk_' + b.id.toString()) + } + .width('100%') + } + + // ---- 地点 / 备注 ---- + this.field('地点', this.fPlace, '地点(可选)', (v: string): void => { + this.fPlace = v; + }) + this.area('备注', this.fNote, '备注(可选)', (v: string): void => { + this.fNote = v; + }) + + if (this.err !== '') { + Text(this.err) + .fontSize(FbMetric.fontTiny) + .fontColor('#A32D2D') + .width('100%') + } + } + .width('100%') + .padding({ top: 14 }) + } + .layoutWeight(1) + .width('100%') + .scrollBar(BarState.Off) + .align(Alignment.TopStart) + + // ---- 底部按钮:删除 / 保存 ---- + if (this.deleting) { + Text('删除中…') + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.faint) + .textAlign(TextAlign.Center) + .width('100%') + .height(42) + .margin({ top: 16 }) + } else { + Row({ space: 10 }) { + if (!this.isCreate) { + Text('删除') + .fontSize(FbMetric.fontSmall) + .fontColor('#C0533F') + .textAlign(TextAlign.Center) + .height(42) + .padding({ left: 18, right: 18 }) + .border({ width: 1, color: '#C0533F' }) + .borderRadius(FbMetric.radiusS) + .onClick((): void => { + if (!this.busy) { + this.confirmDel = true; + } + }) + } + Text(this.busy ? '保存中…' : (this.isCreate ? '创建' : '保存')) + .fontSize(FbMetric.fontSmall) + .fontColor(Color.White) + .textAlign(TextAlign.Center) + .layoutWeight(1) + .height(42) + .backgroundColor(this.busy ? '#4A7C8288' : '#4A7C82') + .borderRadius(FbMetric.radiusS) + .onClick((): void => { + if (!this.busy) { + this.save(); + } + }) + } + .width('100%') + .margin({ top: 16 }) + } + } + .width('100%') + .constraintSize({ maxHeight: '86%' }) + .padding({ left: 18, right: 18, top: 18, bottom: 20 + FB_SHEET_BOTTOM_GAP }) + .backgroundColor(FbColors.surface) + .borderRadius({ topLeft: FbMetric.radius, topRight: FbMetric.radius }) + .alignItems(HorizontalAlign.Start) + } + + /** + * **一个「日期 + 时间」控件**(照 SyncCalendar 的 `EventEditPage`)。 + * + * 🔴 用户 2026-10-16:「你参考一下 SyncCalendar 的做法,实际上就是 + * **把日期和时间放在一个控件里面**。」 + * 它的做法(`dateTimePickerSheet`):**一个底部弹层里 `DatePicker` 与 + * `TimePicker` 并排**,选完点「确定」一次回填。 + * ⚠️ 我第一版做成了"点日期块就地展开 DatePicker、点时间块再展开 TimePicker" + * —— 那是**两个控件、分两次选**,形态与它不同(也不如一次选完顺手)。 + * + * 控件本身是**一整块可点的框**,显示 `10月16日 周五 20:00`; + * 点它 → 打开弹层(里面日期/时间并排)→ 「确定」写回。 + */ + @Builder + dateTimeRow(label: string, isStart: boolean) { + Row({ space: 10 }) { + Text(label) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + .width(28) + // 整块可点:日期与时间在**同一个控件**里,中间不再分两块 + Row() { + Text(this.cnDate(isStart ? this.fStartDate : this.fEndDate)) + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.ink) + if (!this.fAllDay) { + Text(' ' + (isStart ? this.fStartTime : this.fEndTime)) + .fontSize(FbMetric.fontSmall) + .fontWeight(FontWeight.Medium) + .fontColor(FbColors.ink) + } + } + .layoutWeight(1) + .height(42) + .padding({ left: 12, right: 12 }) + .backgroundColor(FbColors.inputBg) + .borderRadius(FbMetric.radiusS) + .alignItems(VerticalAlign.Center) + .onClick((): void => { + this.openPicker(isStart); + }) + // 右侧小箭头:暗示"点开会选"(与 SyncCalendar 的行样式一致) + Text('›') + .fontSize(FbMetric.fontBody) + .fontColor(FbColors.faint) + } + .width('100%') + .alignItems(VerticalAlign.Center) + } + + /** + * 打开「日期 + 时间」弹层(把当前值装进草稿)。 + * + * ⚠️ 草稿的意义:用户滚了一半又点「取消」⇒ **不写回**(`fStartXxx` 保持原样)。 + * 若直接改 `fStartXxx`,"取消"就没法撤销了。 + */ + private openPicker(isEnd: boolean): void { + this.pickIsEnd = isEnd; + const d: Date = this.parseDate(isEnd ? this.fEndDate : this.fStartDate); + this.pickDate = d; + const tm: Date = this.parseTime(isEnd ? this.fEndTime : this.fStartTime); + this.pickHour = tm.getHours(); + this.pickMin = tm.getMinutes(); + this.pickOpen = true; + } + + /** 「确定」:把草稿写回;开始改了还会把结束顺延(避免"负时长") */ + private applyPicker(): void { + const iso: string = this.pad(this.pickDate.getFullYear()) + '-' + + this.pad(this.pickDate.getMonth() + 1) + '-' + this.pad(this.pickDate.getDate()); + const hm: string = this.pad(this.pickHour) + ':' + this.pad(this.pickMin); + if (this.pickIsEnd) { + this.fEndDate = iso; + this.fEndTime = hm; + } else { + this.fStartDate = iso; + this.fStartTime = hm; + // ⚠️ 开始改到"结束之后" ⇒ 把结束顺延到同一时刻(服务端不校验,但负时长是坏的) + if (this.fEndDate < iso || (this.fEndDate === iso && this.fEndTime < hm)) { + this.fEndDate = iso; + this.fEndTime = hm; + } + } + this.pickOpen = false; + } + + /** + * 日期 + 时间选择弹层(**照 SyncCalendar 的 `dateTimePickerSheet`**)。 + * + * 结构:标题 → `Row { DatePicker | TimePicker }`(**并排**)→ 「确定」。 + * ⚠️ 滚轮的 `textStyle`/`selectedTextStyle`/`disappearTextStyle` **要显式给色**: + * 默认取系统主题色,深色模式下会和弹层底色撞色(SyncCalendar 注释里点明过)。 + * ⚠️ 全天日程没有"时刻"可言 ⇒ 只画 `DatePicker`。 + */ + @Builder + dateTimePickerSheet() { + Column({ space: 14 }) { + Text(this.pickIsEnd ? '选择结束时间' : '选择开始时间') + .fontSize(FbMetric.fontTitle) + .fontWeight(FontWeight.Medium) + .fontColor(FbColors.ink) + .width('100%') + .textAlign(TextAlign.Center) + .padding({ top: 12 }) + + Row({ space: 6 }) { + DatePicker({ + start: new Date('2000-01-01'), + end: new Date('2035-12-31'), + selected: this.pickDate + }) + .onDateChange((v: Date): void => { + this.pickDate = v; + }) + .textStyle({ color: FbColors.faint, font: { size: 14 } }) + .selectedTextStyle({ color: FbColors.ink, font: { size: 16, weight: FontWeight.Medium } }) + .disappearTextStyle({ color: FbColors.faint, font: { size: 14 } }) + .layoutWeight(1) + .height(210) + + if (!this.fAllDay) { + TimePicker({ selected: new Date(2000, 0, 1, this.pickHour, this.pickMin) }) + .useMilitaryTime(true) + .onChange((v: TimePickerResult): void => { + // ⚠️ `hour`/`minute` 是**数字**;这里存草稿数字,拼 `HH:MM` 时再补零 + this.pickHour = v.hour; + this.pickMin = v.minute; + }) + .textStyle({ color: FbColors.faint, font: { size: 14 } }) + .selectedTextStyle({ color: FbColors.ink, font: { size: 16, weight: FontWeight.Medium } }) + .disappearTextStyle({ color: FbColors.faint, font: { size: 14 } }) + .layoutWeight(1) + .height(210) + } + } + .width('100%') + + Button(this.fAllDay ? '确定日期' : '确定') + .width('100%') + .height(44) + .borderRadius(FbMetric.radiusS) + .fontSize(FbMetric.fontBody) + .backgroundColor('#4A7C82') + .fontColor(Color.White) + .onClick((): void => { + this.applyPicker(); + }) + } + .width('100%') + .backgroundColor(FbColors.surface) + .borderRadius({ topLeft: FbMetric.radius, topRight: FbMetric.radius }) + .padding({ left: 18, right: 18, top: 8, bottom: 20 + FB_SHEET_BOTTOM_GAP }) + .alignItems(HorizontalAlign.Start) + } + + /** `YYYY-MM-DD` → `10月16日 周五`(比裸日期好读,且和时刻并排也不长) */ + private cnDate(iso: string): string { + if (iso.length < 10) { + return iso; + } + const y: number = parseInt(iso.substring(0, 4), 10); + const m: number = parseInt(iso.substring(5, 7), 10); + const d: number = parseInt(iso.substring(8, 10), 10); + const w: string[] = ['日', '一', '二', '三', '四', '五', '六']; + return m.toString() + '月' + d.toString() + '日 周' + w[new Date(y, m - 1, d).getDay()]; + } + + private parseDate(s: string): Date { + if (s.length < 10) { + return new Date(); + } + const y: number = parseInt(s.substring(0, 4), 10); + const m: number = parseInt(s.substring(5, 7), 10) - 1; + const d: number = parseInt(s.substring(8, 10), 10); + return new Date(y, m, d); + } + + private parseTime(s: string): Date { + const out: Date = new Date(); + if (s.length >= 5) { + out.setHours(parseInt(s.substring(0, 2), 10)); + out.setMinutes(parseInt(s.substring(3, 5), 10)); + } + return out; + } + + @Builder + deleteConfirm() { + Column() { + Column({ space: 8 }) { + Text('删除这条日程?') + .fontSize(FbMetric.fontBody) + .fontWeight(FontWeight.Medium) + .fontColor(FbColors.ink) + Text('删掉之后不能撤销。若这条日程是重复的,这一次和以后的都会不见。') + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.muted) + .maxLines(3) + Row({ space: 10 }) { + Text('取消') + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.muted) + .textAlign(TextAlign.Center) + .layoutWeight(1) + .height(42) + .backgroundColor(FbColors.inputBg) + .borderRadius(FbMetric.radiusS) + .onClick((): void => { + this.confirmDel = false; + }) + Text('删除') + .fontSize(FbMetric.fontSmall) + .fontColor(Color.White) + .textAlign(TextAlign.Center) + .layoutWeight(1) + .height(42) + .backgroundColor('#C0533F') + .borderRadius(FbMetric.radiusS) + .onClick((): void => { + this.confirmDel = false; + this.doDelete(); + }) + } + .width('100%') + .margin({ top: 10 }) + } + .width('100%') + .padding(18) + .backgroundColor(FbColors.surface) + .borderRadius(FbMetric.radius) + .alignItems(HorizontalAlign.Start) + } + .width('100%') + .height('100%') + .justifyContent(FlexAlign.Center) + .padding({ left: 32, right: 32 }) + .backgroundColor('#00000066') + .onClick((): void => { + this.confirmDel = false; + }) + } + + // ============================================================ + // 提醒多选 + // ============================================================ + + private hasRemind(min: number): boolean { + // 0 = "不提醒":选中它等价于"没有任何提醒" + if (min === 0) { + return this.fRemind.length === 0; + } + return this.fRemind.indexOf(min) >= 0; + } + + private toggleRemind(min: number): void { + if (min === 0) { + this.fRemind = []; + return; + } + const at: number = this.fRemind.indexOf(min); + if (at >= 0) { + this.fRemind.splice(at, 1); + } else { + this.fRemind.push(min); + } + } + + // ============================================================ + // 网络 + // ============================================================ + + private async doDelete(): Promise { + const e: FbCalEventFull | null = this.event; + if (e === null) { + return; + } + const token: string = this.token(); + if (token === '') { + this.err = '未登录,无法删除'; + return; + } + this.deleting = true; + this.err = ''; + const res = await FbCalRepo.inst().remove(e.id, token); + this.deleting = false; + if (!res.ok) { + hilog.warn(DOMAIN, TAG, '删除日程失败:%{public}s', res.error); + this.err = '删除失败:' + res.error; + return; + } + this.onDeleted(); + } + + /** 保存(新建走 POST、编辑走 PATCH) */ + private async save(): Promise { + const title: string = this.fTitle.trim(); + if (title === '') { + this.err = '日程得有个标题'; + return; + } + const token: string = this.token(); + if (token === '') { + this.err = this.isCreate ? '未登录,无法创建' : '未登录,无法保存'; + return; + } + const isNew: boolean = this.isCreate || this.event === null; + if (isNew && this.fBookId <= 0) { + this.err = '请选一本日历'; + return; + } + + // ---- 时间:全天归零到 00:00;否则拼上时分 ---- + const startAt: string = this.fAllDay + ? (this.fStartDate + ' 00:00:00') + : (this.fStartDate + ' ' + this.fStartTime + ':00'); + let endAt: string = this.fAllDay + ? (this.fEndDate + ' 00:00:00') + : (this.fEndDate + ' ' + this.fEndTime + ':00'); + // ⚠️ 结束早于开始 ⇒ 补成同一时刻(服务端不校验时长,但"负时长"看着是坏的) + if (endAt < startAt) { + endAt = startAt; + } + + this.busy = true; + this.err = ''; + const input: FbCalEventInput = { + title: title, + location: this.fPlace.trim(), + starts_at: startAt, + ends_at: endAt, + all_day: this.fAllDay, + note: this.fNote, + reminder_minutes: this.fRemind + }; + // ⚠️ 重复:只在用户**确实选过**时才发。 + // 编辑一条"自定义重复"的日程时,端上认不出来(归到"不重复"), + // 若照样发个空 rrule,就会把人家原来的规则**清掉**。 + // ⇒ 规则原文认不出(非空且 chip 是 none)⇒ 这一项**不发**,保持原样。 + const recognize: boolean = this.fRepeat !== 'none' + || (this.event !== null && this.event.rrule === ''); + if (recognize) { + input.rrule = this.ruleOfRepeat(); + } + if (isNew) { + input.calendar_id = this.fBookId; + } + + const repo: FbCalRepo = FbCalRepo.inst(); + const res = isNew ? await repo.create(input, token) : await repo.update(this.event!.id, input, token); + this.busy = false; + if (!res.ok) { + hilog.warn(DOMAIN, TAG, '保存日程失败:%{public}s', res.error); + this.err = (isNew ? '创建失败:' : '保存失败:') + res.error; + return; + } + // 新建时把选的日历本记成"以后默认用它"(与 SyncCalendar 的默认本同一体验) + if (isNew) { + FB_CAL_SETTINGS.setBookId(this.fBookId); + } + this.onSaved(); + } +} \ No newline at end of file diff --git a/entry/src/main/ets/components/FbEventSheet.ets b/entry/src/main/ets/components/FbEventSheet.ets new file mode 100644 index 0000000..9431799 --- /dev/null +++ b/entry/src/main/ets/components/FbEventSheet.ets @@ -0,0 +1,269 @@ +/** + * 家书 FamilyBook · 日程详情浮层(鸿蒙端) + * + * 用户 2026-10-15:「……包括下面日程的显示啊什么之类的,就按照 SyncCalendar 那个模式去做。」 + * SyncCalendar 的详情是**半屏 bindSheet**,内容为: + * 标题(带日历本色点 + 系统/只读徽标 + 关闭)→ 时间 → 日历本/重复 → + * 地点(可点导航)→ 备注 → 「编辑日程」。 + * + * 🔴 这里用**页面内 Stack 覆盖层**而不是 `bindSheet`(与家书其他浮层一致, + * 理由见 `FbMovieDetailSheet` 的文件头); + * **功能与信息结构**照 SyncCalendar 对齐。 + * + * ⚠️ 详情数据是**异步拉的**(`/events/` 才给 note/rrule/reminder/ends_at) + * ⇒ 三种态都要画:加载中 / 成功 / 失败。别只画成功态。 + */ + +import { FbColors, FbMetric } from '../common/Theme'; +import { FbCalEventFull } from '../data/FbCalRepo'; +import { FbCalBook } from '../model/Types'; +import { FbDot } from './Widgets'; + +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'; + /** 能不能编辑(不能写的日历本不给"编辑"按钮) */ + @Param canEdit: boolean = false; + @Event onClose: () => void = (): void => { + }; + @Event onEdit: () => void = (): void => { + }; + + /** 时间显示:`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) + } + + build() { + // FB_WRAP_CENTER:把卡片**垂直居中**(用户 2026-10-16 要求:浮在屏幕中间)。 + // ⚠️ 居中放在**组件内部**做,而不是改宿主的 Stack 对齐 —— + // 宿主 Stack 里还有别的子元素(日程页的悬浮 +),改对齐会连带挪走它们。 + // ⚠️ 外层铺满 + Center;内层仍是那张 92% 宽的圆角卡。 + Column() { + Column({ space: 0 }) { + // ---- 标题区 ---- + Row({ space: 10 }) { + FbDot({ dia: 12, fillC: this.bookColor }) + Text(this.event !== null && this.event.title !== '' + ? this.event.title : '(无标题)') + .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) + } + } + .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) { + Text(this.error !== '' ? ('读不到这条日程:' + this.error) : '读不到这条日程') + .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 !== '') { + this.row('地点', this.event.location) + } + if (this.event.note !== '') { + this.row('备注', this.event.note) + } + Row().height(8) + } + .width('100%') + .alignItems(HorizontalAlign.Start) + } + .layoutWeight(1) + .width('100%') + .scrollBar(BarState.Off) + .align(Alignment.TopStart) + } + + // ---- 底部按钮 ---- + Row({ space: 10 }) { + Text('关闭') + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.muted) + .textAlign(TextAlign.Center) + .layoutWeight(1) + .height(42) + .backgroundColor(FbColors.inputBg) + .borderRadius(FbMetric.radiusS) + .onClick((): void => { + this.onClose(); + }) + if (this.canEdit && this.event !== null) { + Text('编辑日程') + .fontSize(FbMetric.fontSmall) + .fontColor(Color.White) + .textAlign(TextAlign.Center) + .layoutWeight(1) + .height(42) + .backgroundColor('#4A7C82') + .borderRadius(FbMetric.radiusS) + .onClick((): void => { + this.onEdit(); + }) + } + } + .width('100%') + .margin({ top: 14 }) + } + // 🔴 用户 2026-10-16:「它应该弹出一个**圆角的、浮在上面的卡片**…… + // **不要让它左右充满整个屏幕,下面也不要挨着屏幕**, + // 而是放在**屏幕中间**,采用**圆角浮窗**式的形式。」 + // ⇒ 不再贴底满宽:左右留边、四角圆角、垂直居中(由宿主的 Stack 对齐)。 + .width('92%') + .constraintSize({ maxHeight: '72%' }) + .padding({ left: 18, right: 18, top: 18, bottom: 20 + FB_SHEET_BOTTOM_GAP }) + .backgroundColor(FbColors.surface) + .borderRadius(FbMetric.radius) + .alignItems(HorizontalAlign.Start) + } + .width('100%') + .height('100%') + .justifyContent(FlexAlign.Center) + .hitTestBehavior(HitTestMode.Transparent) + } +} \ No newline at end of file diff --git a/entry/src/main/ets/components/FbKinGraph.ets b/entry/src/main/ets/components/FbKinGraph.ets new file mode 100644 index 0000000..6e2b2df --- /dev/null +++ b/entry/src/main/ets/components/FbKinGraph.ets @@ -0,0 +1,288 @@ +/** + * 家书 FamilyBook · 家谱图谱(鸿蒙端) + * + * 把亲属关系画成**知识图谱**那样的图:节点是圆形头像,边是连线。 + * + * 🔴 为什么用 `Canvas` 而不是 `Circle`/`Path` 组件拼: + * · 布局是**算出来的**(按代数分层、同代均分、夫妻相邻), + * 节点数量与位置都不定 —— 组件树的写法要动态拼 Path 的 `commands`, + * 比直接算坐标画线**难得多**、也更难调; + * · 连线要**画在节点下面**(否则线会压在头像上)—— + * Canvas 一次画完,天然有先后顺序。 + * ⚠️ 代价:Canvas **不是响应式的**,数据变了要**自己重画**。 + * 所以这里在 `onReady`(首帧)与**每次数据/尺寸变化时**都重画一遍。 + * + * 布局规则(与"从上往下读家谱"的直觉一致): + * · **纵向 = 代数**:长辈在上、我居中、晚辈在下(`gen` 越小越靠上); + * · **横向 = 同代展开**:同代的人均分宽度;配偶排在彼此旁边; + * · 连线:父母→子女(带箭头感的折线)、夫妻(水平短线,更粗)。 + * ⚠️ 不做"力导向"那种自动布局:家谱有**明确的层次语义**, + * 力导向会把人随机甩开,反而看不懂谁是长辈。 + */ + +import { FbColors, FbMetric } from '../common/Theme'; +import { FbKinPerson } from '../model/Types'; + +/** 一个节点在画布上的位置 */ +interface KinNode { + p: FbKinPerson; + x: number; + y: number; +} + +@ComponentV2 +export struct FbKinGraphView { + /** 家谱里的人 */ + @Param people: FbKinPerson[] = []; + /** 我(居中显示、加粗) */ + @Param meId: number = 0; + /** 点某个节点(弹详情) */ + @Event onPick: (id: number) => void = (id: number): void => { + }; + + /** 画布上下文(必须在组件里创建,不能跨组件共享) */ + private settings: RenderingContextSettings = new RenderingContextSettings(true); + private ctx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings); + /** 画布尺寸(onAreaChange 拿到) */ + @Local cw: number = 0; + @Local ch: number = 0; + /** 算好的节点位置(画与点击都用它) */ + private nodes: KinNode[] = []; + + /** 节点半径(vp) */ + private readonly R: number = 22; + + build() { + Canvas(this.ctx) + .width('100%') + .height('100%') + .backgroundColor(FbColors.paper) + // ⚠️ `onReady` 只在**首帧**触发一次;后续尺寸变化要靠 `onAreaChange`。 + // 只用 onReady 的话,切到家谱页(宽高这时才定下来)会画不出东西。 + .onReady((): void => { + this.redraw(); + }) + .onAreaChange((_old: Area, now: Area): void => { + this.cw = Number(now.width); + this.ch = Number(now.height); + this.redraw(); + }) + // 🔴 `Canvas` **没有**"点到了哪个图形"的概念 —— 命中检测要自己算。 + // 画的位置由 `layout()` 定,点击时拿同一份 `this.nodes` 比距离即可。 + .onClick((ev: ClickEvent): void => { + const x: number = ev.x; + const y: number = ev.y; + // 命中半径放宽一点(手指比鼠标粗):用 1.35×R 更"跟手" + const hit: number = this.R * 1.35; + for (const n of this.nodes) { + const dx: number = x - n.x; + const dy: number = y - n.y; + if (dx * dx + dy * dy <= hit * hit) { + this.onPick(n.p.id); + return; + } + } + }) + } + + /** + * 重画整张图(**先算位置、再画线、最后画节点**)。 + * + * ⚠️ 顺序很重要:线在节点下面 —— 否则线会横穿头像。 + * Canvas 没有 z-index,只能靠绘制顺序。 + */ + private redraw(): void { + if (this.cw <= 0 || this.ch <= 0 || this.people.length === 0) { + return; + } + this.layout(); + this.drawEdges(); + this.drawNodes(); + } + + /** + * 算节点位置(按代数分层)。 + * + * ⚠️ 结果存进 `this.nodes` —— 点击命中检测要用**同一份坐标**, + * 重算一遍容易出现"看到的和点到的不一样"。 + */ + private layout(): void { + // ---- ① 按代数分组 ---- + const byGen: Map = new Map(); + for (const p of this.people) { + const cur: FbKinPerson[] | undefined = byGen.get(p.gen); + if (cur !== undefined) { + cur.push(p); + } else { + byGen.set(p.gen, [p]); + } + } + const gens: number[] = []; + byGen.forEach((_v: FbKinPerson[], k: number): void => { + gens.push(k); + }); + // ⚠️ 必须按**数值**排(Map 的键直接 sort 会按字符串:-1,-2,0,1) + gens.sort((a: number, b: number): number => a - b); + + // ---- ② 纵向:每代一条水平带,均分高度 ---- + const padTop: number = 46; // 给顶部代数标签留位置 + const padBottom: number = 26; + const bandH: number = (this.ch - padTop - padBottom) / Math.max(1, gens.length); + + const nodes: KinNode[] = []; + for (let gi: number = 0; gi < gens.length; gi++) { + const g: number = gens[gi]; + const ps: FbKinPerson[] = byGen.get(g) !== undefined ? (byGen.get(g) as FbKinPerson[]) : []; + const y: number = padTop + bandH * gi + bandH / 2; + + // ---- ③ 横向:同代均分 ---- + // ⚠️ 左右各留 `R + 8`,否则第一个/最后一个节点会贴边、头像被切掉。 + const padX: number = this.R + 10; + const usable: number = this.cw - padX * 2; + const step: number = ps.length > 1 ? usable / (ps.length - 1) : 0; + // ⚠️ 只有一个人时放**正中间**(step=0 会让它贴在左边) + const startX: number = ps.length > 1 ? padX : this.cw / 2; + + for (let i: number = 0; i < ps.length; i++) { + nodes.push({ p: ps[i], x: startX + step * i, y: y }); + } + } + this.nodes = nodes; + } + + /** 按 id 找节点 */ + private nodeOf(id: number): KinNode | undefined { + for (const n of this.nodes) { + if (n.p.id === id) { + return n; + } + } + return undefined; + } + + /** 画连线 */ + private drawEdges(): void { + const c: CanvasRenderingContext2D = this.ctx; + c.lineWidth = 1.6; + c.strokeStyle = '#B9A896'; + + for (const n of this.nodes) { + // ---- 父母 → 子女(折线:下-横-下)---- + const parents: number[] = n.p.parents; + for (const pid of parents) { + const par: KinNode | undefined = this.nodeOf(pid); + if (par === undefined) { + continue; + } + // 折线中点取两者的横向中点,纵向上 1/3 处拐 + const midY: number = par.y + (n.y - par.y) * 0.55; + c.beginPath(); + c.moveTo(par.x, par.y + this.R); + c.lineTo(par.x, midY); + c.lineTo(n.x, midY); + c.lineTo(n.x, n.y - this.R); + c.stroke(); + } + + // ---- 夫妻(水平短线,**画粗一点**,与血缘线区分)---- + for (const sid of n.p.spouses) { + // 只画一次(按 id 大小去重,否则同一对会画两条) + if (sid < n.p.id) { + continue; + } + const sp: KinNode | undefined = this.nodeOf(sid); + if (sp === undefined) { + continue; + } + c.save(); + c.lineWidth = 3; + c.strokeStyle = '#C97B6A'; + c.beginPath(); + c.moveTo(n.x + this.R, n.y); + c.lineTo(sp.x - this.R, sp.y); + c.stroke(); + c.restore(); + } + } + } + + /** 画节点(圆 + 首字 + 称谓) */ + private drawNodes(): void { + const c: CanvasRenderingContext2D = this.ctx; + for (const n of this.nodes) { + const isMe: boolean = n.p.id === this.meId; + + // 圆底(用头像色) + c.beginPath(); + c.arc(n.x, n.y, this.R, 0, Math.PI * 2); + c.fillStyle = n.p.avatarColor !== '' ? n.p.avatarColor : '#8A6B4B'; + c.fill(); + // 「我」加一圈描边,一眼认出中心 + if (isMe) { + c.lineWidth = 2.5; + c.strokeStyle = '#A84A28'; + c.stroke(); + } + + // 名字首字 + c.font = '16px sans-serif'; + c.textAlign = 'center'; + c.textBaseline = 'middle'; + c.fillStyle = '#FFFFFF'; + const nm: string = n.p.shown !== '' ? n.p.shown : n.p.name; + c.fillText(nm.length > 0 ? nm.substring(0, 1) : '?', n.x, n.y); + + // 名字(圆下方) + c.font = isMe ? 'bold 12px sans-serif' : '12px sans-serif'; + c.fillStyle = isMe ? '#A84A28' : '#2C231D'; + // ⚠️ 名字可能很长("青岛西海岸电影城"那种)—— 截断,别糊成一片 + const label: string = nm.length > 5 ? nm.substring(0, 5) + '…' : nm; + c.fillText(label, n.x, n.y + this.R + 11); + + // 称谓(名字下面,小字灰色) + if (n.p.label !== '' && n.p.label !== '我') { + c.font = '10px sans-serif'; + c.fillStyle = '#8A7A6A'; + c.fillText(n.p.label, n.x, n.y + this.R + 24); + } + + // ---- 代数标签(每层左边)---- + // ⚠️ 只在每层**第一个**节点旁写,避免重复画 + const first: KinNode | undefined = this.firstOfGen(n.p.gen); + if (first !== undefined && first.p.id === n.p.id) { + c.font = '11px sans-serif'; + c.textAlign = 'left'; + c.fillStyle = '#8A7A6A'; + c.fillText(this.genLabel(n.p.gen), 8, n.y - this.R - 8); + c.textAlign = 'center'; + } + } + } + + /** 某代里排在最前的那个节点(用来放代数标签) */ + private firstOfGen(gen: number): KinNode | undefined { + for (const n of this.nodes) { + if (n.p.gen === gen) { + return n; + } + } + return undefined; + } + + /** 代数 → 层名(与家谱页的分层标题同口径) */ + private genLabel(g: number): string { + if (g === 0) { + return '我这一辈'; + } + if (g === -1) { + return '父母辈'; + } + if (g <= -2) { + return '祖辈'; + } + if (g === 1) { + return '子女辈'; + } + return '孙辈'; + } +} \ No newline at end of file diff --git a/entry/src/main/ets/components/FbModuleSettingsSheet.ets b/entry/src/main/ets/components/FbModuleSettingsSheet.ets new file mode 100644 index 0000000..71e484a --- /dev/null +++ b/entry/src/main/ets/components/FbModuleSettingsSheet.ets @@ -0,0 +1,150 @@ +/** + * 家书 FamilyBook · 模块设置浮层(鸿蒙端) + * + * 🔴 用户 2026-10-16:「**所有页面的设置按钮全部都改成和网页版一致**, + * 就是**三个横线**那种设置按钮。」 + * + * 网页版的做法(`templates/` 下各模块的 `_settings_menu.php`): + * 每个模块的动作区最右有一个 `