diff --git a/code/app/.workbuddy/memory/2026-06-23.md b/code/app/.workbuddy/memory/2026-06-23.md
new file mode 100644
index 00000000..d9f291a0
--- /dev/null
+++ b/code/app/.workbuddy/memory/2026-06-23.md
@@ -0,0 +1,109 @@
+# 2026-06-23 工作日志
+
+## Skills 配置
+- 将 `heroui-native` skill 从临时目录安装到用户级 (`~/.workbuddy/skills/`) 和项目级 (`项目/.workbuddy/skills/`)
+- 项目中仅使用两个 skills:`heroui-native` 和 `allapp-conventions`
+- heroui-native 来源:通过 `npx skills` 安装的 `heroui-inc/heroui` 仓库,原始路径在临时目录 `C:\Users\admin\AppData\Local\Temp\skills-oUbMIJ\`
+
+## 前端页面优化
+
+### tools/index.tsx — 多轮迭代
+- 去掉卡片背景(`bg-white shadow-sm`),极简化
+- 图标保持原生色,不加彩色圆底
+- 收藏:用 `Heart`(非 `Star`)图标,18px 右上角独立触摸,teal 实心/灰色空心
+- 图标 26→32px,文字 11→13px,整体放大更利于触摸
+
+### home/index.tsx — 重构
+- 宫格布局 → 横向功能卡片(图标圆底 + 标题 + 描述 + 右箭头)
+- 计时数字层级化:天 `text-3xl` 主力,时分降级
+- 去掉卡片背景,与 tools 页风格统一
+- 更多工具独立卡片,用 `Grid3X3` 图标
+
+### 项目目录结构重组
+- 主应用所有页面收进 `(main)/` route group:`(tabs)/`, `auth/`, `family/`, `tools/`
+- 子应用 `finance/` 保持独立在顶层
+- 删除未用的 `star/` 占位页
+- 路由引用 `/(tabs)/home` → `/home`(4 处更新)
+- 最终目录:`app/(main)/*` + `app/finance/*`,主副清晰分离
+
+### 最近动态功能 — 完整实现
+
+**数据库:**
+- 建表 `b_feed`(动态),字段:id, family_id, user_id, type, content, meta(VARCHAR), create_time, update_time, create_by, update_by
+- 插入 7 条模拟数据(加入家庭/修改昵称/记账/收藏工具)
+
+**前端新增:**
+- `src/helpers/feed.ts` — `recordFeed()` 写入、`loadFeeds()` 查询,全程用通用接口
+- `src/app/(main)/activities/index.tsx` — 完整动态列表页,时间轴布局(图标圆底 + 竖线 + 内容)
+- 首页 `/activities` 路由,CloseButton 关闭
+
+**埋点位置(4 处):**
+- `tools/index.tsx` — 收藏工具时 → `favorite_app`
+- `family/entry.tsx` — 创建家庭 → `join_family`(xxx 创建了家庭)
+- `family/entry.tsx` — 加入家庭 → `join_family`(xxx 加入了家庭)
+- `family/user.tsx` — 修改昵称 → `change_nickname`(旧名 修改了昵称为「新名」)
+
+**首页改造:**
+- 最近动态区从 Demo 数据 → 真实 `loadFeeds` 加载,展示最新 3 条
+- 右上角「更多 →」入口跳转 `/activities`
+- 时间格式化:刚刚 / N分钟前 / N小时前 / N天前 / 日期
+
+### 动态功能优化 — 表名 + 昵称修复
+
+**表名重命名:**
+- `b_feed` → `b_family_feed`(统一 `b_family_*` 风格)
+- `feed.ts` 中 `table_name` / `view_name` 同步更新
+
+**昵称来源修正:**
+- 新增 `getFamilyMemberNickname(familyId, userId)` — 从 `b_family_member.nickname` 查询
+- 所有 `recordFeed()` 调用处改用此函数,不再从 `userStore.userInfo.nickname` 取
+- `family/user.tsx` 改名时同步更新 `b_family_member.nickname`(之前只更新 `b_user.name`)
+
+**Skill 更新:**
+- `allapp-conventions` 新增「家庭动态记录规范」章节,含 5 种触发场景、昵称来源规则、代码示例
+- 目录结构修正为 `(main)/` route group
+
+### 动态 type 简化 + content 占位符化
+
+**type 简码:**
+- `join_family` → `join`,`change_nickname` → `rename`,`add_finance` → `bill`,`favorite_app` → `fav`
+- 数据库存量数据已批量 UPDATE,前端 `TYPE_ICONS` 全部更新
+
+**content 占位符:**
+- content 存储模板 `{nickname} 加入了家庭`,不再拼接具体昵称
+- `loadFeeds()` 内部通过 `getFamilyMemberNickname()` 动态替换 `{nickname}` 为 `b_family_member.nickname` 最新值
+- 改名后历史动态自动显示新昵称,彻底解决写死问题
+- `recordFeed()` 调用处不再需要 `getFamilyMemberNickname`,只需传模板字符串
+- `entry.tsx` / `user.tsx` / `tools/index.tsx` 的 `getFamilyMemberNickname` import 已移除
+
+### 动态 meta 字段清理
+
+**原则变更**:content 自包含,不依赖 meta。`recordFeed()` 不再接收 meta 参数,所有展示信息直接写入 content。
+
+**改动**:
+- `helpers/feed.ts` — `recordFeed` 签名去掉 `meta?` 参数,inserts 不再写 meta 字段
+- `tools/index.tsx` — fav 调用去掉 `{ app_name: subItem.title }` 冗余 meta
+- `family/user.tsx` — rename content 改为 `{nickname} 修改了昵称为「{新昵称}」`
+- `activities/index.tsx` — 去掉 meta JSON.parse + amount 显示逻辑
+- 数据库:7 条存量记录 meta 全部置 NULL,bill 记录金额已嵌入 content(如「午餐」¥35)
+- Skill 更新:content 模板表同步,新增「content 自包含原则」说明
+
+### tools 卡片样式
+- item 加 `bg-white rounded-xl shadow-sm`,去底部分割线
+- 左右间距 `mx-3`;标题 + 描述双行布局,`items-center` 垂直居中
+
+### DateTime/DateTimeRange 组件重构
+- **替换依赖**:`@yz1311/react-native-wheel-picker` → `react-native-dates-picker`(纯 JS、样式可控)
+- `DateTime.tsx` — `mode="single"` + `initialView` 映射(year/month/day)+ `timePicker`
+- `DateTimeRange.tsx` — `mode="range"` 原生支持起止日期选择,代码大幅简化(去掉 active/toggle 逻辑)
+- 选中色 `selectedItemColor="#0d9488"` 与 teal 主题统一
+- BottomSheet 包裹保留
+- `chat/index.tsx` 写入完整测试代码(5 种 DateTime 模式 + DateTimeRange)
+
+### 记账页面重构
+- 页面分为上下两部分:上 — 大字号金额(40px bold,点击可输入)+ 日期行(文字 + ChevronDown 图标);下 — 类别网格(一行 5 个,flex-wrap)
+- 金额使用 `TextInput` 内联,点击切换显示/输入态,`inputMode="decimal"`
+- 日期不包在 Input 里,改为 `bg-gray-50 rounded-xl` 行 + 右侧 `ChevronDown`
+- 类别区域顶部加本地搜索框(`useMemo` 过滤 `name.includes`),无 API
+- 去掉横向滚动的 `LegendList`,改为 `flex-row flex-wrap` 的 `View`
+- BottomSheet "更多"面板保留,同样含搜索框 + 5 列网格
diff --git a/code/app/.workbuddy/skills/allapp-conventions/SKILL.md b/code/app/.workbuddy/skills/allapp-conventions/SKILL.md
index 118a213f..d2c39e1f 100644
--- a/code/app/.workbuddy/skills/allapp-conventions/SKILL.md
+++ b/code/app/.workbuddy/skills/allapp-conventions/SKILL.md
@@ -44,10 +44,13 @@ app-rn/ # React Native 前端
├── src/
│ ├── app/ # Expo Router 页面
│ │ ├── _layout.tsx # 根布局
-│ │ ├── (tabs)/ # 主 Tab(home / space / tools / chat / mine)
-│ │ ├── auth/ # 登录页
-│ │ ├── tools/ # 工具广场
-│ │ └── finance/ # 记账子模块
+│ │ ├── (main)/ # 主应用(route group,不影响 URL)
+│ │ │ ├── (tabs)/ # 主 Tab(home / chat / my)
+│ │ │ ├── auth/ # 登录页
+│ │ │ ├── family/ # 家庭管理
+│ │ │ ├── tools/ # 工具广场
+│ │ │ └── activities/ # 动态列表
+│ │ └── finance/ # 记账子模块(独立于 main)
│ ├── components/ # 通用组件(layout/view / AZIndex 等)
│ ├── configs/ # pages.ts(子应用列表配置)
│ ├── layouts/ # 布局(ModuleLayout 等)
@@ -188,11 +191,47 @@ b_finance - 记账记录
b_finance_category - 记账分类
b_finance_category_default - 默认分类模板
b_app_favorite - 子应用收藏
+b_family_feed - 家庭动态
```
视图:`v_family_member`(join b_family_member + b_user)
-## 常见操作示例
+## 家庭动态(b_family_feed)记录规范
+
+以下场景**必须**调用 `recordFeed()` 写入动态。
+
+**type 简码对照**:`join`(加入/创建)、`rename`(改名)、`bill`(记账)、`fav`(收藏)
+
+| 触发场景 | type | content 模板 | 触发位置 |
+|---------|------|-------------|---------|
+| 创建家庭 | `join` | `{nickname} 创建了家庭「{名称}」` | `family/entry.tsx` handleCreate |
+| 加入家庭 | `join` | `{nickname} 加入了家庭` | `family/entry.tsx` handleJoin |
+| 修改昵称 | `rename` | `{nickname} 修改了昵称为「{新昵称}」` | `family/user.tsx` handleRename |
+| 记账 | `bill` | `{nickname} 记了一笔「{备注}」¥{金额}` | `finance/add/` 保存成功 |
+| 收藏应用 | `fav` | `{nickname} 收藏了「{应用名}」` | `tools/index.tsx` 收藏操作 |
+
+**⚠️ content 自包含原则**:所有展示信息直接写在 content 字符串中,不依赖 meta 字段。`recordFeed()` 不接收 meta 参数,`loadFeeds()` 只做 `{nickname}` 替换。meta 列保留但始终为 NULL。
+
+**content 占位符规则**:content 存储模板字符串,用户昵称用 `{nickname}` 占位,**不在写入时拼接**。`loadFeeds()` 会自动将 `{nickname}` 替换为 `b_family_member.nickname` 的最新值,确保改名后历史动态也显示最新昵称。
+
+```ts
+import { recordFeed } from "@/helpers/feed";
+
+// 正确写法 — 用 {nickname} 占位符,不拼接昵称
+recordFeed(familyId, userId, "join", "{nickname} 加入了家庭");
+
+// 错误写法 — 禁止拼接昵称写死
+recordFeed(familyId, userId, "join", `${nick} 加入了家庭`); // ❌
+```
+
+**修改昵称时同步更新**:`family/user.tsx` 的 `handleRename` 除了更新 `b_user.name`,还必须同步更新 `b_family_member.nickname`:
+
+```ts
+saveDataApi([
+ { table_name: "b_user", updates: [{ id: uid, name: newName }] },
+ { table_name: "b_family_member", updates: [{ id: memberId, nickname: newName }] },
+]);
+```
### 前端:拉取数据
```ts
diff --git a/code/app/.workbuddy/skills/heroui-native/LICENSE.txt b/code/app/.workbuddy/skills/heroui-native/LICENSE.txt
new file mode 100644
index 00000000..bf4dfe2e
--- /dev/null
+++ b/code/app/.workbuddy/skills/heroui-native/LICENSE.txt
@@ -0,0 +1,199 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to the Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by the Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding any notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. Please also get an approval
+ from the project maintainers before using the Apache License.
+
+ Copyright 2025 NextUI Inc.
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/code/app/.workbuddy/skills/heroui-native/SKILL.md b/code/app/.workbuddy/skills/heroui-native/SKILL.md
new file mode 100644
index 00000000..33ace072
--- /dev/null
+++ b/code/app/.workbuddy/skills/heroui-native/SKILL.md
@@ -0,0 +1,229 @@
+---
+name: heroui-native
+description: "HeroUI Native component library for React Native (Tailwind v4 via Uniwind). Use when building mobile UIs with HeroUI Native — creating Buttons, Cards, TextFields, Dialogs; installing heroui-native; configuring dark/light themes; or fetching component docs. Keywords: HeroUI Native, heroui-native, React Native UI, Uniwind, mobile components."
+metadata:
+ author: heroui
+ version: "2.0.1"
+---
+
+# HeroUI Native Development Guide
+
+HeroUI Native is a component library built on **Uniwind (Tailwind CSS for React Native)** and **React Native**, providing accessible, customizable UI components for mobile applications.
+
+---
+
+## Installation
+
+```bash
+curl -fsSL https://heroui.com/install | bash -s heroui-native
+```
+
+---
+
+## CRITICAL: Native Only - Do Not Use Web Patterns
+
+**This guide is for HeroUI Native ONLY.** Do NOT apply HeroUI React (web) patterns — the package, styling engine, and color format all differ:
+
+| Feature | React (Web) | Native (Mobile) |
+| ------------ | -------------------- | ----------------------------------- |
+| **Styling** | Tailwind CSS v4 | Uniwind (Tailwind for React Native) |
+| **Colors** | oklch format | HSL format |
+| **Package** | `@heroui/react` | `heroui-native` |
+| **Platform** | Web browsers | iOS & Android |
+
+```tsx
+// CORRECT — Native pattern
+import { Button } from "heroui-native";
+
+;
+```
+
+**Always fetch Native docs before implementing.**
+
+---
+
+## Core Principles
+
+- Semantic variants (`primary`, `secondary`, `tertiary`) over visual descriptions
+- Composition over configuration (compound components)
+- Theme variables with HSL color format
+- React Native StyleSheet patterns with Uniwind utilities
+
+---
+
+## Accessing Documentation & Component Information
+
+**For component details, examples, props, and implementation patterns, always fetch documentation:**
+
+### Using Scripts
+
+```bash
+# List all available components
+node scripts/list_components.mjs
+
+# Get component documentation (MDX)
+node scripts/get_component_docs.mjs Button
+node scripts/get_component_docs.mjs Button Card TextField
+
+# Get theme variables
+node scripts/get_theme.mjs
+
+# Get non-component docs (guides, releases)
+node scripts/get_docs.mjs /docs/native/getting-started/theming
+```
+
+### Direct MDX URLs
+
+Component docs: `https://heroui.com/docs/native/components/{component-name}.mdx`
+
+Examples:
+
+- Button: `https://heroui.com/docs/native/components/button.mdx`
+- Dialog: `https://heroui.com/docs/native/components/dialog.mdx`
+- TextField: `https://heroui.com/docs/native/components/text-field.mdx`
+
+Getting started guides: `https://heroui.com/docs/native/getting-started/{topic}.mdx`
+
+**Important:** Always fetch component docs before implementing. The MDX docs include complete examples, props, anatomy, and API references.
+
+---
+
+## Installation Essentials
+
+### Quick Install
+
+```bash
+npm i heroui-native react-native-reanimated react-native-gesture-handler react-native-safe-area-context @gorhom/bottom-sheet react-native-svg react-native-worklets tailwind-merge tailwind-variants
+```
+
+### Framework Setup (Expo - Recommended)
+
+1. **Install dependencies:**
+
+```bash
+npx create-expo-app MyApp
+cd MyApp
+npm i heroui-native uniwind tailwindcss
+npm i react-native-reanimated react-native-gesture-handler react-native-safe-area-context @gorhom/bottom-sheet react-native-svg react-native-worklets tailwind-merge tailwind-variants
+```
+
+2. **Create `global.css`:**
+
+```css
+@import "tailwindcss";
+@import "uniwind";
+@import "heroui-native/styles";
+
+@source "./node_modules/heroui-native/lib";
+```
+
+3. **Wrap app with providers:**
+
+```tsx
+import { GestureHandlerRootView } from "react-native-gesture-handler";
+import { HeroUINativeProvider } from "heroui-native";
+import "./global.css";
+
+export default function Layout() {
+ return (
+
+
+
+
+
+ );
+}
+```
+
+### Critical Setup Requirements
+
+1. **Uniwind is Required** - HeroUI Native uses Uniwind (Tailwind CSS for React Native)
+2. **HeroUINativeProvider Required** - Wrap your app with `HeroUINativeProvider`
+3. **GestureHandlerRootView Required** - Wrap with `GestureHandlerRootView` from react-native-gesture-handler
+4. **Use Compound Components** - Components use compound structure (e.g., `Card.Header`, `Card.Body`)
+5. **Use onPress, not onClick** - React Native uses `onPress` event handlers
+6. **Platform-Specific Code** - Use `Platform.OS` for iOS/Android differences
+
+---
+
+## Component Patterns
+
+HeroUI Native uses **compound component patterns**. Each component has subcomponents accessed via dot notation.
+
+**Example - Card:**
+
+```tsx
+
+ {/* Icons, badges */}
+
+ Title
+ Description
+
+ {/* Actions */}
+
+```
+
+**Key Points:**
+
+- Always use compound structure - don't flatten to props
+- Subcomponents are accessed via dot notation (e.g., `Card.Header`)
+- Native Card uses `Card.Body` (not `Card.Content`); Title and Description go inside Body
+- **Fetch component docs for complete anatomy and examples**
+
+---
+
+## Semantic Variants
+
+HeroUI uses semantic naming to communicate functional intent:
+
+| Variant | Purpose | Usage |
+| ------------- | --------------------------------- | -------------- |
+| `primary` | Main action to move forward | 1 per context |
+| `secondary` | Alternative actions | Multiple |
+| `tertiary` | Dismissive actions (cancel, skip) | Sparingly |
+| `danger` | Destructive actions | When needed |
+| `danger-soft` | Soft destructive actions | Less prominent |
+| `ghost` | Low-emphasis actions | Minimal weight |
+| `outline` | Secondary actions | Bordered style |
+
+**Don't use raw colors** - semantic variants adapt to themes and accessibility.
+
+---
+
+## Theming
+
+HeroUI Native uses CSS variables via Tailwind/Uniwind for theming. Theme colors are defined in `global.css`:
+
+```css
+@theme {
+ --color-accent: hsl(260, 100%, 70%);
+ --color-accent-foreground: hsl(0, 0%, 100%);
+}
+```
+
+**Get current theme variables:**
+
+```bash
+node scripts/get_theme.mjs
+```
+
+**Access theme colors programmatically:**
+
+```tsx
+import { useThemeColor } from "heroui-native";
+
+const accentColor = useThemeColor("accent");
+```
+
+**Theme switching (Light/Dark Mode):**
+
+```tsx
+import { Uniwind, useUniwind } from "uniwind";
+
+const { theme } = useUniwind();
+Uniwind.setTheme(theme === "light" ? "dark" : "light");
+```
+
+For detailed theming, fetch: `https://heroui.com/docs/native/getting-started/theming.mdx`
diff --git a/code/app/.workbuddy/skills/heroui-native/scripts/get_component_docs.mjs b/code/app/.workbuddy/skills/heroui-native/scripts/get_component_docs.mjs
new file mode 100644
index 00000000..1cec3258
--- /dev/null
+++ b/code/app/.workbuddy/skills/heroui-native/scripts/get_component_docs.mjs
@@ -0,0 +1,157 @@
+#!/usr/bin/env node
+/**
+ * Get complete component documentation (MDX) for HeroUI Native components.
+ *
+ * Usage:
+ * node get_component_docs.mjs Button
+ * node get_component_docs.mjs Button Card TextField
+ *
+ * Output:
+ * MDX documentation including imports, usage, variants, props, examples
+ */
+
+const API_BASE = process.env.HEROUI_NATIVE_API_BASE || "https://native-mcp-api.heroui.com";
+const FALLBACK_BASE = "https://heroui.com";
+const APP_PARAM = "app=native-skills";
+
+/**
+ * Convert PascalCase to kebab-case.
+ */
+function toKebabCase(name) {
+ return name
+ .replace(/([a-z])([A-Z])/g, "$1-$2")
+ .replace(/([A-Z])([A-Z][a-z])/g, "$1-$2")
+ .toLowerCase();
+}
+
+/**
+ * Fetch data from HeroUI Native API with app parameter for analytics.
+ */
+async function fetchApi(endpoint, method = "GET", body = null) {
+ const separator = endpoint.includes("?") ? "&" : "?";
+ const url = `${API_BASE}${endpoint}${separator}${APP_PARAM}`;
+
+ try {
+ const options = {
+ headers: {
+ "Content-Type": "application/json",
+ "User-Agent": "HeroUI-Native-Skill/1.0",
+ },
+ method,
+ signal: AbortSignal.timeout(30000),
+ };
+
+ if (body) {
+ options.body = JSON.stringify(body);
+ }
+
+ const response = await fetch(url, options);
+
+ if (!response.ok) {
+ return null;
+ }
+
+ return await response.json();
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Fetch MDX directly from v3.heroui.com as fallback.
+ */
+async function fetchFallback(component) {
+ const kebabName = toKebabCase(component);
+ const url = `${FALLBACK_BASE}/docs/native/components/${kebabName}.mdx`;
+
+ try {
+ const response = await fetch(url, {
+ headers: {"User-Agent": "HeroUI-Native-Skill/1.0"},
+ signal: AbortSignal.timeout(30000),
+ });
+
+ if (!response.ok) {
+ return {component, error: `Failed to fetch docs for ${component}`};
+ }
+
+ const content = await response.text();
+
+ return {
+ component,
+ content,
+ contentType: "mdx",
+ source: "fallback",
+ url,
+ };
+ } catch {
+ return {component, error: `Failed to fetch docs for ${component}`};
+ }
+}
+
+/**
+ * Main function to get component documentation.
+ */
+async function main() {
+ const args = process.argv.slice(2);
+
+ if (args.length === 0) {
+ console.error("Usage: node get_component_docs.mjs [Component2] ...");
+ console.error("Example: node get_component_docs.mjs Button Card");
+ process.exit(1);
+ }
+
+ const components = args;
+
+ // Try API first - use POST /v1/components/docs for batch requests
+ console.error(`# Fetching Native docs for: ${components.join(", ")}...`);
+ const data = await fetchApi("/v1/components/docs", "POST", {components});
+
+ if (data && data.results) {
+ // Output results
+ if (data.results.length === 1) {
+ // Single component - output content directly for easier reading
+ const result = data.results[0];
+
+ if (result.content) {
+ console.log(result.content);
+ } else if (result.error) {
+ console.error(`# Error for ${result.component}: ${result.error}`);
+ console.log(JSON.stringify(result, null, 2));
+ } else {
+ console.log(JSON.stringify(result, null, 2));
+ }
+ } else {
+ // Multiple components - output as JSON array
+ console.log(JSON.stringify(data, null, 2));
+ }
+
+ return;
+ }
+
+ // Fallback to individual component fetches
+ console.error("# API failed, using fallback...");
+ const results = [];
+
+ for (const component of components) {
+ const result = await fetchFallback(component);
+
+ results.push(result);
+ }
+
+ // Output results
+ if (results.length === 1) {
+ // Single component - output content directly for easier reading
+ const result = results[0];
+
+ if (result.content) {
+ console.log(result.content);
+ } else {
+ console.log(JSON.stringify(result, null, 2));
+ }
+ } else {
+ // Multiple components - output as JSON array
+ console.log(JSON.stringify(results, null, 2));
+ }
+}
+
+main();
diff --git a/code/app/.workbuddy/skills/heroui-native/scripts/get_docs.mjs b/code/app/.workbuddy/skills/heroui-native/scripts/get_docs.mjs
new file mode 100644
index 00000000..cf3a47ed
--- /dev/null
+++ b/code/app/.workbuddy/skills/heroui-native/scripts/get_docs.mjs
@@ -0,0 +1,154 @@
+#!/usr/bin/env node
+/**
+ * Get non-component HeroUI Native documentation (guides, theming, releases).
+ *
+ * Usage:
+ * node get_docs.mjs /docs/native/getting-started/theming
+ * node get_docs.mjs /docs/native/releases/beta-12
+ *
+ * Output:
+ * MDX documentation content
+ *
+ * Note: For component docs, use get_component_docs.mjs instead.
+ */
+
+const API_BASE = process.env.HEROUI_NATIVE_API_BASE || "https://native-mcp-api.heroui.com";
+const FALLBACK_BASE = "https://heroui.com";
+const APP_PARAM = "app=native-skills";
+
+/**
+ * Fetch documentation from HeroUI Native API.
+ * Uses v1 endpoint pattern: /v1/docs/:path
+ */
+async function fetchApi(path) {
+ // The v1 API expects path without /docs/ prefix
+ // Input: /docs/native/getting-started/theming
+ // API expects: native/getting-started/theming (route is /v1/docs/:path(*))
+ let apiPath = path.startsWith("/docs/")
+ ? path.slice(6) // Remove /docs/ prefix
+ : path.startsWith("/")
+ ? path.slice(1) // Remove leading /
+ : path;
+
+ const separator = "?";
+ const url = `${API_BASE}/v1/docs/${apiPath}${separator}${APP_PARAM}`;
+
+ try {
+ const response = await fetch(url, {
+ headers: {"User-Agent": "HeroUI-Native-Skill/1.0"},
+ signal: AbortSignal.timeout(30000),
+ });
+
+ if (!response.ok) {
+ console.error(`# API Error: HTTP ${response.status}`);
+
+ return null;
+ }
+
+ return await response.json();
+ } catch (error) {
+ console.error(`# API Error: ${error.message}`);
+
+ return null;
+ }
+}
+
+/**
+ * Fetch MDX directly from v3.heroui.com as fallback.
+ */
+async function fetchFallback(path) {
+ // Ensure path starts with /docs and ends with .mdx
+ let cleanPath = path.replace(/^\//, "");
+
+ if (!cleanPath.endsWith(".mdx")) {
+ cleanPath = `${cleanPath}.mdx`;
+ }
+
+ const url = `${FALLBACK_BASE}/${cleanPath}`;
+
+ try {
+ const response = await fetch(url, {
+ headers: {"User-Agent": "HeroUI-Native-Skill/1.0"},
+ signal: AbortSignal.timeout(30000),
+ });
+
+ if (!response.ok) {
+ return {error: `HTTP ${response.status}: ${response.statusText}`, path};
+ }
+
+ const content = await response.text();
+
+ return {
+ content,
+ contentType: "mdx",
+ path,
+ source: "fallback",
+ url,
+ };
+ } catch (error) {
+ return {error: `Fetch Error: ${error.message}`, path};
+ }
+}
+
+/**
+ * Main function to get documentation for specified path.
+ */
+async function main() {
+ const args = process.argv.slice(2);
+
+ if (args.length === 0) {
+ console.error("Usage: node get_docs.mjs ");
+ console.error("Example: node get_docs.mjs /docs/native/getting-started/theming");
+ console.error();
+ console.error("Available paths include:");
+ console.error(" /docs/native/getting-started/theming");
+ console.error(" /docs/native/getting-started/colors");
+ console.error(" /docs/native/getting-started/styling");
+ console.error(" /docs/native/releases/beta-12");
+ console.error();
+ console.error("Note: For component docs, use get_component_docs.mjs instead.");
+ process.exit(1);
+ }
+
+ const path = args[0];
+
+ // Check if user is trying to get component docs
+ if (path.includes("/components/")) {
+ console.error("# Warning: Use get_component_docs.mjs for component documentation.");
+ const componentName = path.split("/").pop().replace(".mdx", "");
+ const titleCase = componentName.charAt(0).toUpperCase() + componentName.slice(1);
+
+ console.error(`# Example: node get_component_docs.mjs ${titleCase}`);
+ }
+
+ // Validate Native path
+ if (!path.startsWith("/docs/native/")) {
+ console.error("# Warning: Native documentation paths should start with /docs/native/");
+ console.error(`# Provided path: ${path}`);
+ }
+
+ console.error(`# Fetching Native documentation for ${path}...`);
+
+ // Try API first
+ const data = await fetchApi(path);
+
+ if (data && data.content) {
+ data.source = "api";
+ console.log(data.content);
+
+ return;
+ }
+
+ // Fallback to direct fetch
+ console.error("# API failed, using fallback...");
+ const fallbackData = await fetchFallback(path);
+
+ if (fallbackData.content) {
+ console.log(fallbackData.content);
+ } else {
+ console.log(JSON.stringify(fallbackData, null, 2));
+ process.exit(1);
+ }
+}
+
+main();
diff --git a/code/app/.workbuddy/skills/heroui-native/scripts/get_theme.mjs b/code/app/.workbuddy/skills/heroui-native/scripts/get_theme.mjs
new file mode 100644
index 00000000..88faf950
--- /dev/null
+++ b/code/app/.workbuddy/skills/heroui-native/scripts/get_theme.mjs
@@ -0,0 +1,222 @@
+#!/usr/bin/env node
+/**
+ * Get theme variables and design tokens for HeroUI Native.
+ *
+ * Usage:
+ * node get_theme.mjs
+ *
+ * Output:
+ * Theme variables organized by light/dark with HSL color format
+ */
+
+const API_BASE = process.env.HEROUI_NATIVE_API_BASE || "https://native-mcp-api.heroui.com";
+const APP_PARAM = "app=native-skills";
+
+// Fallback theme reference when API is unavailable
+const FALLBACK_THEME = {
+ borderRadius: {
+ full: 9999,
+ lg: 12,
+ md: 8,
+ sm: 6,
+ },
+ dark: {
+ colors: [
+ {
+ category: "base",
+ name: "--color-background",
+ value: "hsl(0, 0%, 14.5%)",
+ },
+ {
+ category: "semantic",
+ name: "--color-foreground",
+ value: "hsl(0, 0%, 98.4%)",
+ },
+ {
+ category: "semantic",
+ name: "--color-accent",
+ value: "hsl(264.1, 100%, 55.1%)",
+ },
+ {
+ category: "status",
+ name: "--color-danger",
+ value: "hsl(25.3, 100%, 63.7%)",
+ },
+ {
+ category: "status",
+ name: "--color-success",
+ value: "hsl(163.2, 100%, 76.5%)",
+ },
+ {
+ category: "status",
+ name: "--color-warning",
+ value: "hsl(86.0, 100%, 79.5%)",
+ },
+ ],
+ },
+ latestVersion: "beta",
+ light: {
+ colors: [
+ {
+ category: "base",
+ name: "--color-background",
+ value: "hsl(0, 0%, 100%)",
+ },
+ {
+ category: "semantic",
+ name: "--color-foreground",
+ value: "hsl(285.89, 5.9%, 21.03%)",
+ },
+ {
+ category: "semantic",
+ name: "--color-accent",
+ value: "hsl(253.83, 100%, 62.04%)",
+ },
+ {
+ category: "status",
+ name: "--color-danger",
+ value: "hsl(25.74, 100%, 65.32%)",
+ },
+ {
+ category: "status",
+ name: "--color-success",
+ value: "hsl(150.81, 100%, 73.29%)",
+ },
+ {
+ category: "status",
+ name: "--color-warning",
+ value: "hsl(72.33, 100%, 78.19%)",
+ },
+ ],
+ },
+ note: "This is a fallback. For complete theme variables, ensure the API is accessible.",
+ opacity: {
+ disabled: 0.4,
+ hover: 0.8,
+ pressed: 0.6,
+ },
+ source: "fallback",
+ theme: "default",
+};
+
+/**
+ * Fetch data from HeroUI Native API with app parameter for analytics.
+ */
+async function fetchApi(endpoint) {
+ const separator = endpoint.includes("?") ? "&" : "?";
+ const url = `${API_BASE}${endpoint}${separator}${APP_PARAM}`;
+
+ try {
+ const response = await fetch(url, {
+ headers: {"User-Agent": "HeroUI-Native-Skill/1.0"},
+ signal: AbortSignal.timeout(30000),
+ });
+
+ if (!response.ok) {
+ console.error(`# API Error: HTTP ${response.status}`);
+
+ return null;
+ }
+
+ return await response.json();
+ } catch (error) {
+ console.error(`# API Error: ${error.message}`);
+
+ return null;
+ }
+}
+
+/**
+ * Format colors grouped by category.
+ */
+function formatColors(colors) {
+ const grouped = {};
+
+ for (const color of colors) {
+ const category = color.category || "semantic";
+
+ if (!grouped[category]) {
+ grouped[category] = [];
+ }
+ grouped[category].push(color);
+ }
+
+ const lines = [];
+
+ for (const [category, tokens] of Object.entries(grouped)) {
+ lines.push(` /* ${category.charAt(0).toUpperCase() + category.slice(1)} Colors */`);
+ for (const token of tokens) {
+ const name = token.name || "";
+ const value = token.value || "";
+
+ lines.push(` ${name}: ${value};`);
+ }
+ lines.push("");
+ }
+
+ return lines.join("\n");
+}
+
+/**
+ * Main function to get theme variables.
+ */
+async function main() {
+ console.error("# Fetching Native theme variables...");
+
+ const rawData = await fetchApi("/v1/themes/variables?theme=default");
+
+ let data;
+ let version;
+
+ if (!rawData) {
+ console.error("# API failed, using fallback theme reference...");
+ data = FALLBACK_THEME;
+ version = FALLBACK_THEME.latestVersion || "unknown";
+ } else {
+ // Handle API response format
+ data = rawData;
+ version = rawData.latestVersion || "unknown";
+ }
+
+ // Output as formatted structure for readability
+ console.log("/* HeroUI Native Theme Variables */");
+ console.log(`/* Theme: ${data.theme || "default"} */`);
+ console.log(`/* Version: ${version} */`);
+ console.log();
+
+ // Light mode colors
+ if (data.light && data.light.colors) {
+ console.log("/* Light Mode Colors */");
+ console.log(formatColors(data.light.colors));
+ }
+
+ // Dark mode colors
+ if (data.dark && data.dark.colors) {
+ console.log("/* Dark Mode Colors */");
+ console.log(formatColors(data.dark.colors));
+ }
+
+ // Border radius
+ if (data.borderRadius) {
+ console.log("/* Border Radius */");
+ for (const [key, value] of Object.entries(data.borderRadius)) {
+ console.log(` --radius-${key}: ${value};`);
+ }
+ console.log();
+ }
+
+ // Opacity
+ if (data.opacity) {
+ console.log("/* Opacity */");
+ for (const [key, value] of Object.entries(data.opacity)) {
+ console.log(` --opacity-${key}: ${value};`);
+ }
+ console.log();
+ }
+
+ // Also output raw JSON to stderr for programmatic use
+ console.error("\n# Raw JSON output:");
+ console.error(JSON.stringify(rawData || data, null, 2));
+}
+
+main();
diff --git a/code/app/.workbuddy/skills/heroui-native/scripts/list_components.mjs b/code/app/.workbuddy/skills/heroui-native/scripts/list_components.mjs
new file mode 100644
index 00000000..5210eb41
--- /dev/null
+++ b/code/app/.workbuddy/skills/heroui-native/scripts/list_components.mjs
@@ -0,0 +1,134 @@
+#!/usr/bin/env node
+/**
+ * List all available HeroUI Native components.
+ *
+ * Usage:
+ * node list_components.mjs
+ *
+ * Output:
+ * JSON with components array, latestVersion, and count
+ */
+
+const API_BASE = process.env.HEROUI_NATIVE_API_BASE || "https://native-mcp-api.heroui.com";
+const APP_PARAM = "app=native-skills";
+const LLMS_TXT_URL = "https://heroui.com/native/llms.txt";
+
+/**
+ * Fetch data from HeroUI Native API with app parameter for analytics.
+ */
+async function fetchApi(endpoint) {
+ const separator = endpoint.includes("?") ? "&" : "?";
+ const url = `${API_BASE}${endpoint}${separator}${APP_PARAM}`;
+
+ try {
+ const response = await fetch(url, {
+ headers: {"User-Agent": "HeroUI-Native-Skill/1.0"},
+ signal: AbortSignal.timeout(30000),
+ });
+
+ if (!response.ok) {
+ console.error(`HTTP Error ${response.status}: ${response.statusText}`);
+
+ return null;
+ }
+
+ return await response.json();
+ } catch (error) {
+ console.error(`API Error: ${error.message}`);
+
+ return null;
+ }
+}
+
+/**
+ * Fetch component list from llms.txt fallback URL.
+ */
+async function fetchFallback() {
+ try {
+ const response = await fetch(LLMS_TXT_URL, {
+ headers: {"User-Agent": "HeroUI-Native-Skill/1.0"},
+ signal: AbortSignal.timeout(30000),
+ });
+
+ if (!response.ok) {
+ return null;
+ }
+
+ const content = await response.text();
+
+ // Parse markdown to extract component names from pattern: - [ComponentName](url)
+ // Look for links under the Components section (### Components)
+ const components = [];
+ let inComponentsSection = false;
+
+ for (const line of content.split("\n")) {
+ // Check if we're entering the Components section (uses ### header)
+ if (line.trim() === "### Components") {
+ inComponentsSection = true;
+ continue;
+ }
+
+ // Check if we're leaving the Components section (another ### header)
+ if (inComponentsSection && line.trim().startsWith("### ")) {
+ break;
+ }
+
+ // Extract component name from markdown link pattern
+ // Match: - [ComponentName](https://www.heroui.com/docs/native/components/component-name)
+ // Skip "All Components" which links to /components without a specific component
+ if (inComponentsSection) {
+ const match = line.match(
+ /^\s*-\s*\[([^\]]+)\]\(https:\/\/www\.heroui\.com\/docs\/native\/components\/[a-z]/,
+ );
+
+ if (match) {
+ components.push(match[1]);
+ }
+ }
+ }
+
+ if (components.length > 0) {
+ console.error(`# Using fallback: ${LLMS_TXT_URL}`);
+
+ return {
+ components: components.sort(),
+ count: components.length,
+ latestVersion: "unknown",
+ };
+ }
+
+ return null;
+ } catch (error) {
+ console.error(`Fallback Error: ${error.message}`);
+
+ return null;
+ }
+}
+
+/**
+ * Main function to list all available HeroUI Native components.
+ */
+async function main() {
+ let data = await fetchApi("/v1/components");
+
+ // Check if API returned valid data with components
+ if (!data || !data.components || data.components.length === 0) {
+ console.error("# API returned no components, trying fallback...");
+ data = await fetchFallback();
+ }
+
+ if (!data || !data.components || data.components.length === 0) {
+ console.error("Error: Failed to fetch component list from API and fallback");
+ process.exit(1);
+ }
+
+ // Output formatted JSON
+ console.log(JSON.stringify(data, null, 2));
+
+ // Print summary to stderr for human readability
+ console.error(
+ `\n# Found ${data.components.length} Native components (${data.latestVersion || "unknown"})`,
+ );
+}
+
+main();
diff --git a/code/app/app-rn/package.json b/code/app/app-rn/package.json
index f5247456..284611e2 100644
--- a/code/app/app-rn/package.json
+++ b/code/app/app-rn/package.json
@@ -49,6 +49,7 @@
"react": "19.2.0",
"react-dom": "19.2.0",
"react-native": "0.83.4",
+ "react-native-dates-picker": "^0.2.4",
"react-native-gesture-handler": "~2.30.0",
"react-native-image-crop-picker": "^0.51.1",
"react-native-keyboard-controller": "1.20.7",
diff --git a/code/app/app-rn/pnpm-lock.yaml b/code/app/app-rn/pnpm-lock.yaml
index 6c864ce8..21dcb0d6 100644
--- a/code/app/app-rn/pnpm-lock.yaml
+++ b/code/app/app-rn/pnpm-lock.yaml
@@ -116,6 +116,9 @@ importers:
react-native:
specifier: 0.83.4
version: 0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0)
+ react-native-dates-picker:
+ specifier: ^0.2.4
+ version: 0.2.4(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)
react-native-gesture-handler:
specifier: ~2.30.0
version: 2.30.1(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)
@@ -1028,48 +1031,56 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@oxfmt/binding-linux-arm64-musl@0.44.0':
resolution: {integrity: sha512-AlObQIXyVRZ96LbtVljtFq0JqH5B92NU+BQeDFrXWBUWlCKAM0wF5GLfIhCLT5kQ3Sl+U0YjRJ7Alqj5hGQaCg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@oxfmt/binding-linux-ppc64-gnu@0.44.0':
resolution: {integrity: sha512-YcFE8/q/BbrCiIiM5piwbkA6GwJc5QqhMQp2yDrqQ2fuVkZ7CInb1aIijZ/k8EXc72qXMSwKpVlBv1w/MsGO/A==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
+ libc: [glibc]
'@oxfmt/binding-linux-riscv64-gnu@0.44.0':
resolution: {integrity: sha512-eOdzs6RqkRzuqNHUX5C8ISN5xfGh4xDww8OEd9YAmc3OWN8oAe5bmlIqQ+rrHLpv58/0BuU48bxkhnIGjA/ATQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
+ libc: [glibc]
'@oxfmt/binding-linux-riscv64-musl@0.44.0':
resolution: {integrity: sha512-YBgNTxntD/QvlFUfgvh8bEdwOhXiquX8gaofZJAwYa/Xp1S1DQrFVZEeck7GFktr24DztsSp8N8WtWCBwxs0Hw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
+ libc: [musl]
'@oxfmt/binding-linux-s390x-gnu@0.44.0':
resolution: {integrity: sha512-GLIh1R6WHWshl/i4QQDNgj0WtT25aRO4HNUWEoitxiywyRdhTFmFEYT2rXlcl9U6/26vhmOqG5cRlMLG3ocaIA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
+ libc: [glibc]
'@oxfmt/binding-linux-x64-gnu@0.44.0':
resolution: {integrity: sha512-gZOpgTlOsLcLfAF9qgpTr7FIIFSKnQN3hDf/0JvQ4CIwMY7h+eilNjxq/CorqvYcEOu+LRt1W4ZS7KccEHLOdA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@oxfmt/binding-linux-x64-musl@0.44.0':
resolution: {integrity: sha512-1CyS9JTB+pCUFYFI6pkQGGZaT/AY5gnhHVrQQLhFba6idP9AzVYm1xbdWfywoldTYvjxQJV6x4SuduCIfP3W+A==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@oxfmt/binding-openharmony-arm64@0.44.0':
resolution: {integrity: sha512-bmEv70Ak6jLr1xotCbF5TxIKjsmQaiX+jFRtnGtfA03tJPf6VG3cKh96S21boAt3JZc+Vjx8PYcDuLj39vM2Pw==}
@@ -1142,48 +1153,56 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@oxlint/binding-linux-arm64-musl@1.60.0':
resolution: {integrity: sha512-eDYDXZGhQAXyn6GwtwiX/qcLS0HlOLPJ/+iiIY8RYr+3P8oKBmgKxADLlniL6FtWfE7pPk7IGN9/xvDEvDvFeg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@oxlint/binding-linux-ppc64-gnu@1.60.0':
resolution: {integrity: sha512-nxehly5XYBHUWI9VJX1bqCf9j/B43DaK/aS/T1fcxCpX3PA4Rm9BB54nPD1CKayT8xg6REN1ao+01hSRNgy8OA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
+ libc: [glibc]
'@oxlint/binding-linux-riscv64-gnu@1.60.0':
resolution: {integrity: sha512-j1qf/NaUfOWQutjeoooNG1Q0zsK0XGmSu1uDLq3cctquRF3j7t9Hxqf/76ehCc5GEUAanth2W4Fa+XT1RFg/nw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
+ libc: [glibc]
'@oxlint/binding-linux-riscv64-musl@1.60.0':
resolution: {integrity: sha512-YELKPRefQ/q/h3RUmeRfPCUhh2wBvgV1RyZ/F9M9u8cDyXsQW2ojv1DeWQTt466yczDITjZnIOg/s05pk7Ve2A==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
+ libc: [musl]
'@oxlint/binding-linux-s390x-gnu@1.60.0':
resolution: {integrity: sha512-JkO3C6Gki7Y6h/MiIkFKvHFOz98/YWvQ4WYbK9DLXACMP2rjULzkeGyAzorJE5S1dzLQGFgeqvN779kSFwoV1g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
+ libc: [glibc]
'@oxlint/binding-linux-x64-gnu@1.60.0':
resolution: {integrity: sha512-XjKHdFVCpZZZSWBCKyyqCq65s2AKXykMXkjLoKYODrD+f5toLhlwsMESscu8FbgnJQ4Y/dpR/zdazsahmgBJIA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@oxlint/binding-linux-x64-musl@1.60.0':
resolution: {integrity: sha512-js29ZWIuPhNWzY8NC7KoffEMEeWG105vbmm+8EOJsC+T/jHBiKIJEUF78+F/IrgEWMMP9N0kRND4Pp75+xAhKg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@oxlint/binding-openharmony-arm64@1.60.0':
resolution: {integrity: sha512-H+PUITKHk04stFpWj3x3Kg08Afp/bcXSBi0EhasR5a0Vw7StXHTzdl655PUI0fB4qdh2Wsu6Dsi+3ACxPoyQnA==}
@@ -1605,24 +1624,28 @@ packages:
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.2.1':
resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.2.1':
resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.2.1':
resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.2.1':
resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==}
@@ -1696,10 +1719,12 @@ packages:
'@ungap/structured-clone@1.3.0':
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
+ deprecated: Potential CWE-502 - Update to 1.3.1 or higher
'@xmldom/xmldom@0.8.12':
resolution: {integrity: sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==}
engines: {node: '>=10.0.0'}
+ deprecated: this version has critical issues, please update to the latest version
'@yz1311/react-native-wheel-picker@0.4.5':
resolution: {integrity: sha512-07ga/fYdgHYI3JTAd+ntaXcJ1D3VxbgaqCPxABjxigehmFYD3VpBbMMVRhxmZC3oVnpedc+SKJXHTNwGtLriCQ==}
@@ -2907,72 +2932,84 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
lightningcss-linux-arm64-gnu@1.31.1:
resolution: {integrity: sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
lightningcss-linux-arm64-gnu@1.32.0:
resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
lightningcss-linux-arm64-musl@1.30.1:
resolution: {integrity: sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
+ libc: [musl]
lightningcss-linux-arm64-musl@1.31.1:
resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
+ libc: [musl]
lightningcss-linux-arm64-musl@1.32.0:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
+ libc: [musl]
lightningcss-linux-x64-gnu@1.30.1:
resolution: {integrity: sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
+ libc: [glibc]
lightningcss-linux-x64-gnu@1.31.1:
resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
+ libc: [glibc]
lightningcss-linux-x64-gnu@1.32.0:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
+ libc: [glibc]
lightningcss-linux-x64-musl@1.30.1:
resolution: {integrity: sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
+ libc: [musl]
lightningcss-linux-x64-musl@1.31.1:
resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
+ libc: [musl]
lightningcss-linux-x64-musl@1.32.0:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
+ libc: [musl]
lightningcss-win32-arm64-msvc@1.30.1:
resolution: {integrity: sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==}
@@ -3518,6 +3555,13 @@ packages:
react-native-animatable@1.4.0:
resolution: {integrity: sha512-DZwaDVWm2NBvBxf7I0wXKXLKb/TxDnkV53sWhCvei1pRyTX3MVFpkvdYBknNBqPrxYuAIlPxEp7gJOidIauUkw==}
+ react-native-dates-picker@0.2.4:
+ resolution: {integrity: sha512-zKNri33Ci8ozvWuZW9ErlYcrs66CUy3ZyVvLqqaYz9quE+sRyu2AMouLl1LQrwq4Fsk0hUjoSDuK2PPc0zUE/g==}
+ engines: {node: '>= 16.0.0'}
+ peerDependencies:
+ react: '>=17.0.0'
+ react-native: '*'
+
react-native-gesture-handler@2.30.1:
resolution: {integrity: sha512-xIUBDo5ktmJs++0fZlavQNvDEE4PsihWhSeJsJtoz4Q6p0MiTM9TgrTgfEgzRR36qGPytFoeq+ShLrVwGdpUdA==}
peerDependencies:
@@ -4076,6 +4120,7 @@ packages:
uuid@7.0.3:
resolution: {integrity: sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==}
+ deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
hasBin: true
validate-npm-package-name@5.0.1:
@@ -8017,6 +8062,12 @@ snapshots:
dependencies:
prop-types: 15.8.1
+ react-native-dates-picker@0.2.4(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0):
+ dependencies:
+ dayjs: 1.11.20
+ react: 19.2.0
+ react-native: 0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0)
+
react-native-gesture-handler@2.30.1(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0):
dependencies:
'@egjs/hammerjs': 2.0.17
diff --git a/code/app/app-rn/src/app/(tabs)/_layout.tsx b/code/app/app-rn/src/app/(main)/(tabs)/_layout.tsx
similarity index 100%
rename from code/app/app-rn/src/app/(tabs)/_layout.tsx
rename to code/app/app-rn/src/app/(main)/(tabs)/_layout.tsx
diff --git a/code/app/app-rn/src/app/(main)/(tabs)/chat/index.tsx b/code/app/app-rn/src/app/(main)/(tabs)/chat/index.tsx
new file mode 100644
index 00000000..112575b8
--- /dev/null
+++ b/code/app/app-rn/src/app/(main)/(tabs)/chat/index.tsx
@@ -0,0 +1,104 @@
+import React, { useState } from "react";
+import { View, Text, Button, ScrollView } from "react-native";
+import LayoutView from "@/components/layout/view";
+import DateTime from "@/components/DateTime";
+import DateTimeRange from "@/components/DateTimeRange";
+
+export default function ChatTest() {
+ // --- DateTime (单选) ---
+ const [showDate, setShowDate] = useState(false);
+ const [dateValue, setDateValue] = useState("2026-06-23");
+
+ const [showTime, setShowTime] = useState(false);
+ const [timeValue, setTimeValue] = useState("20:00:00");
+
+ const [showDatetime, setShowDatetime] = useState(false);
+ const [datetimeValue, setDatetimeValue] = useState("2026-06-23 20:00");
+
+ const [showMonth, setShowMonth] = useState(false);
+ const [monthValue, setMonthValue] = useState("2026-06");
+
+ const [showYear, setShowYear] = useState(false);
+ const [yearValue, setYearValue] = useState("2026");
+
+ // --- DateTimeRange (范围) ---
+ const [showRange, setShowRange] = useState(false);
+ const [rangeStart, setRangeStart] = useState("");
+ const [rangeEnd, setRangeEnd] = useState("");
+
+ return (
+
+
+
+
+ {/* DateTime */}
+ DateTime(单选)
+
+
+
+
+ );
+}
diff --git a/code/app/app-rn/src/app/(main)/(tabs)/home/index.tsx b/code/app/app-rn/src/app/(main)/(tabs)/home/index.tsx
new file mode 100644
index 00000000..7c553a3c
--- /dev/null
+++ b/code/app/app-rn/src/app/(main)/(tabs)/home/index.tsx
@@ -0,0 +1,266 @@
+import { useEffect, useState, useCallback } from "react";
+import LayoutView from "@/components/layout/view";
+import { loadDataApi } from "@/request/api";
+import { View, Text } from "react-native";
+import { useFamilyStore, useFavoriteStore } from "@/store";
+import {
+ ChevronRight,
+ Grid3X3,
+ UserPlus,
+ User,
+ Receipt,
+ Heart,
+ Clock3,
+} from "lucide-react-native";
+import { PressableFeedback } from "heroui-native";
+import { SUB_APPS } from "@/configs/pages";
+import { useRouter } from "expo-router";
+import { loadFeeds, type FeedRecord } from "@/helpers/feed";
+
+/* ─── 问候 ─── */
+function getGreeting() {
+ const h = new Date().getHours();
+ if (h < 6) return "夜深了";
+ if (h < 9) return "早上好";
+ if (h < 12) return "上午好";
+ if (h < 14) return "中午好";
+ if (h < 18) return "下午好";
+ return "晚上好";
+}
+
+/* ─── 类型 → 图标 ─── */
+const TYPE_ICONS: Record> = {
+ join: UserPlus,
+ rename: User,
+ bill: Receipt,
+ fav: Heart,
+};
+
+/* ─── 时间格式化 ─── */
+function formatFeedTime(dateStr: string) {
+ const date = new Date(dateStr);
+ const now = new Date();
+ const diff = now.getTime() - date.getTime();
+ const mins = Math.floor(diff / 60000);
+ const hours = Math.floor(diff / 3600000);
+ const days = Math.floor(diff / 86400000);
+
+ if (mins < 1) return "刚刚";
+ if (mins < 60) return `${mins} 分钟前`;
+ if (hours < 24) return `${hours} 小时前`;
+ if (days < 7) return `${days} 天前`;
+ return date.toLocaleDateString("zh-CN", {
+ month: "short",
+ day: "numeric",
+ });
+}
+
+export default function Home() {
+ const router = useRouter();
+ const familyStore = useFamilyStore();
+ const favoriteStore = useFavoriteStore();
+
+ const [family, setFamily] = useState({});
+ const [duration, setDuration] = useState({
+ days: 0,
+ hours: 0,
+ minutes: 0,
+ seconds: 0,
+ });
+ const [feeds, setFeeds] = useState([]);
+
+ async function loadFamilyData() {
+ const res = await loadDataApi({
+ view_name: "b_family",
+ search_condition: "id = ?",
+ search_columns: [],
+ order_by: "",
+ args: [familyStore.currentFamily?.family_id],
+ });
+ if (res.isSuccess && res.data.length > 0) setFamily(res.data[0]);
+ }
+
+ function calcDuration(startTime: string) {
+ const start = new Date(startTime).getTime();
+ const now = Date.now();
+ let diff = Math.floor((now - start) / 1000);
+ const days = Math.floor(diff / (3600 * 24));
+ diff %= 3600 * 24;
+ const hours = Math.floor(diff / 3600);
+ diff %= 3600;
+ const minutes = Math.floor(diff / 60);
+ const seconds = diff % 60;
+ return { days, hours, minutes, seconds };
+ }
+
+ useEffect(() => {
+ loadFamilyData();
+ }, []);
+
+ useEffect(() => {
+ if (!family.create_time) return;
+ const timer = setInterval(
+ () => setDuration(calcDuration(family.create_time)),
+ 1000,
+ );
+ return () => clearInterval(timer);
+ }, [family.create_time]);
+
+ useEffect(() => {
+ const fid = familyStore.currentFamily.family_id;
+ if (fid) {
+ favoriteStore.fetchFavorites(fid);
+ loadFeeds(fid).then(setFeeds).catch(console.error);
+ }
+ }, [familyStore.currentFamily.family_id]);
+
+ return (
+
+
+ {/* ═══════════ 头部 ═══════════ */}
+
+
+ {getGreeting()}
+
+
+ {family.name || "我的空间"}
+
+
+ {/* 计时 — 开放式,无卡片 */}
+
+
+
+ {duration.days}
+
+
+ 天
+
+
+
+
+ {String(duration.hours).padStart(2, "0")} 小时{" "}
+ {String(duration.minutes).padStart(2, "0")} 分钟{" "}
+ {String(duration.seconds).padStart(2, "0")} 秒
+
+
+
+ 已经在一起这么久啦
+
+
+
+
+ {/* ═══════════ 快捷工具 ═══════════ */}
+
+
+
+ 快捷工具
+
+
+
+ {SUB_APPS.map((app) => {
+ const Icon = app.icon;
+ return (
+ router.navigate(app.id as any)}
+ className="flex-row items-center bg-white rounded-xl shadow-sm mb-2 px-4 py-3.5"
+ >
+
+
+
+
+
+
+ {app.title}
+
+
+ {app.description}
+
+
+
+
+
+
+ );
+ })}
+
+ {/* 更多工具 */}
+ router.navigate("/tools")}
+ className="flex-row items-center bg-white rounded-xl shadow-sm mb-1 mt-1 px-4 py-3.5"
+ >
+
+
+
+
+
+ 更多工具
+
+
+
+
+
+
+ {/* ═══════════ 最近动态 ═══════════ */}
+
+
+
+
+ 最近动态
+
+
+ router.navigate("/activities")}
+ className="flex-row items-center"
+ >
+ 更多
+
+
+
+
+
+ {feeds.length === 0 ? (
+
+ 暂无动态
+
+ ) : (
+ feeds.slice(0, 3).map((item, idx) => {
+ const isLast = idx === Math.min(feeds.length, 3) - 1;
+ const Icon = TYPE_ICONS[item.type] || Clock3;
+ const meta = item.meta ? JSON.parse(item.meta) : null;
+
+ return (
+
+ {/* 时间轴列 */}
+
+
+ {!isLast && }
+
+
+ {/* 内容 */}
+
+
+ {formatFeedTime(item.create_time)}
+
+
+ {item.content}
+
+ {meta?.amount ? (
+
+ ¥{meta.amount}
+
+ ) : null}
+
+
+ );
+ })
+ )}
+
+ {/* 底部留白 */}
+
+
+
+ );
+}
diff --git a/code/app/app-rn/src/app/(tabs)/my/index.tsx b/code/app/app-rn/src/app/(main)/(tabs)/my/index.tsx
similarity index 100%
rename from code/app/app-rn/src/app/(tabs)/my/index.tsx
rename to code/app/app-rn/src/app/(main)/(tabs)/my/index.tsx
diff --git a/code/app/app-rn/src/app/(main)/activities/index.tsx b/code/app/app-rn/src/app/(main)/activities/index.tsx
new file mode 100644
index 00000000..e83220c5
--- /dev/null
+++ b/code/app/app-rn/src/app/(main)/activities/index.tsx
@@ -0,0 +1,117 @@
+import { useEffect, useState, useCallback } from "react";
+import LayoutView from "@/components/layout/view";
+import { View, Text } from "react-native";
+import { Stack, useRouter } from "expo-router";
+import { CloseButton } from "heroui-native";
+import { useFamilyStore } from "@/store";
+import { loadFeeds, type FeedRecord } from "@/helpers/feed";
+import { UserPlus, User, Receipt, Heart } from "lucide-react-native";
+
+/* ─── 类型 → 图标 ─── */
+const TYPE_ICONS: Record> = {
+ join: UserPlus,
+ rename: User,
+ bill: Receipt,
+ fav: Heart,
+};
+
+/* ─── 时间格式化 ─── */
+function formatTime(dateStr: string) {
+ const date = new Date(dateStr);
+ const now = new Date();
+ const diff = now.getTime() - date.getTime();
+ const mins = Math.floor(diff / 60000);
+ const hours = Math.floor(diff / 3600000);
+ const days = Math.floor(diff / 86400000);
+
+ if (mins < 1) return "刚刚";
+ if (mins < 60) return `${mins} 分钟前`;
+ if (hours < 24) return `${hours} 小时前`;
+ if (days < 7) return `${days} 天前`;
+ return date.toLocaleDateString("zh-CN", {
+ month: "short",
+ day: "numeric",
+ });
+}
+
+export default function Activities() {
+ const router = useRouter();
+ const familyStore = useFamilyStore();
+
+ const [feeds, setFeeds] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ const fetchFeeds = useCallback(async () => {
+ setLoading(true);
+ try {
+ const data = await loadFeeds(familyStore.currentFamily.family_id);
+ setFeeds(data);
+ } catch (e) {
+ console.error(e);
+ } finally {
+ setLoading(false);
+ }
+ }, [familyStore.currentFamily.family_id]);
+
+ useEffect(() => {
+ fetchFeeds();
+ }, []);
+
+ return (
+
+
+
+
+ {loading ? (
+
+ 加载中...
+
+ ) : feeds.length === 0 ? (
+
+ 暂无动态
+
+ ) : (
+ feeds.map((item, idx) => {
+ const isLast = idx === feeds.length - 1;
+ const Icon = TYPE_ICONS[item.type] || Receipt;
+
+ return (
+
+ {/* 时间轴列 */}
+
+
+
+
+ {!isLast && }
+
+
+ {/* 内容 */}
+
+
+ {formatTime(item.create_time)}
+
+
+ {item.content}
+
+
+
+ );
+ })
+ )}
+
+
+
+
+ {/* 关闭按钮 */}
+
+ router.back()}
+ iconProps={{ size: 28 }}
+ style={{ padding: 24 }}
+ />
+
+
+ );
+}
diff --git a/code/app/app-rn/src/app/auth/login.tsx b/code/app/app-rn/src/app/(main)/auth/login.tsx
similarity index 99%
rename from code/app/app-rn/src/app/auth/login.tsx
rename to code/app/app-rn/src/app/(main)/auth/login.tsx
index 3b8da28d..a7e1ffc8 100644
--- a/code/app/app-rn/src/app/auth/login.tsx
+++ b/code/app/app-rn/src/app/(main)/auth/login.tsx
@@ -130,7 +130,7 @@ export default function Login() {
family_id: user.family_id,
});
- router.replace("/(tabs)/home");
+ router.replace("/home");
}
});
};
diff --git a/code/app/app-rn/src/app/family/entry.tsx b/code/app/app-rn/src/app/(main)/family/entry.tsx
similarity index 97%
rename from code/app/app-rn/src/app/family/entry.tsx
rename to code/app/app-rn/src/app/(main)/family/entry.tsx
index fe92fbee..378e7177 100644
--- a/code/app/app-rn/src/app/family/entry.tsx
+++ b/code/app/app-rn/src/app/(main)/family/entry.tsx
@@ -21,6 +21,7 @@ import { generateCode } from "@/utils/common";
import { FadeIn } from "react-native-reanimated";
import { Keyboard } from "react-native";
import { useRouter } from "expo-router";
+import { recordFeed } from "@/helpers/feed";
const BottomSheetTextInput = forwardRef>((props, ref) => {
const { onFocus, onBlur } = useBottomSheetAwareHandlers();
@@ -98,7 +99,7 @@ export default function EntryFamily() {
toast.success("你已经有家庭了");
familyStore.setCurrentFamily({ family_id: res.data[0].family_id });
- router.replace("/(tabs)/home");
+ router.replace("/home");
}
// 否则保持在 entry 页面
};
@@ -271,6 +272,9 @@ export default function EntryFamily() {
setShowCreate(false);
setCreateForm({ name: "" });
+ // 记录动态
+ recordFeed(familyId, userStore.userInfo.id, "join", `{nickname} 创建了家庭「${createForm.name}」`);
+
enterFamily(familyId);
}
} catch (e) {
@@ -347,6 +351,9 @@ export default function EntryFamily() {
setShowJoin(false);
setJoinForm({ invite_code: "" });
+ // 记录动态
+ recordFeed(family.id, userStore.userInfo.id, "join", "{nickname} 加入了家庭");
+
enterFamily(family.id);
}
} catch (e) {
diff --git a/code/app/app-rn/src/app/family/member/[id].tsx b/code/app/app-rn/src/app/(main)/family/member/[id].tsx
similarity index 100%
rename from code/app/app-rn/src/app/family/member/[id].tsx
rename to code/app/app-rn/src/app/(main)/family/member/[id].tsx
diff --git a/code/app/app-rn/src/app/family/settings.tsx b/code/app/app-rn/src/app/(main)/family/settings.tsx
similarity index 100%
rename from code/app/app-rn/src/app/family/settings.tsx
rename to code/app/app-rn/src/app/(main)/family/settings.tsx
diff --git a/code/app/app-rn/src/app/family/user.tsx b/code/app/app-rn/src/app/(main)/family/user.tsx
similarity index 95%
rename from code/app/app-rn/src/app/family/user.tsx
rename to code/app/app-rn/src/app/(main)/family/user.tsx
index ac3ec4fd..283d7c62 100644
--- a/code/app/app-rn/src/app/family/user.tsx
+++ b/code/app/app-rn/src/app/(main)/family/user.tsx
@@ -35,6 +35,7 @@ import { forwardRef, useRef } from "react";
import { FadeIn } from "react-native-reanimated";
import DateTime from "@/components/DateTime";
import { toDateString } from "@/utils/time";
+import { recordFeed } from "@/helpers/feed";
const StyledIonicons = withUniwind(Ionicons);
@@ -384,6 +385,26 @@ export default function UserSetting() {
deletes: [],
});
+ // 同步更新家庭成员昵称
+ const memberRes = await loadDataApi({
+ view_name: "b_family_member",
+ search_condition: "family_id = ? AND user_id = ?",
+ search_columns: ["id", "nickname"],
+ order_by: "",
+ args: [familyStore.currentFamily.family_id, userStore.userInfo.id],
+ });
+
+ if (memberRes.isSuccess && memberRes.data?.length > 0) {
+ const memberId = memberRes.data[0].id;
+ requestData.push({
+ table_name: "b_family_member",
+ key_field: "id",
+ inserts: [],
+ updates: [{ id: memberId, nickname: renameForm.name }],
+ deletes: [],
+ });
+ }
+
const saveRes = await saveDataApi(requestData);
if (saveRes.isSuccess) {
@@ -397,6 +418,14 @@ export default function UserSetting() {
name: "",
});
+ // 记录动态
+ recordFeed(
+ familyStore.currentFamily.family_id,
+ userStore.userInfo.id,
+ "rename",
+ `{nickname} 修改了昵称为「${renameForm.name}」`,
+ );
+
toast.success("名称修改成功");
}
} catch (e) {
diff --git a/code/app/app-rn/src/app/tools/index.tsx b/code/app/app-rn/src/app/(main)/tools/index.tsx
similarity index 58%
rename from code/app/app-rn/src/app/tools/index.tsx
rename to code/app/app-rn/src/app/(main)/tools/index.tsx
index 0a86ddd2..d99b9ab4 100644
--- a/code/app/app-rn/src/app/tools/index.tsx
+++ b/code/app/app-rn/src/app/(main)/tools/index.tsx
@@ -1,22 +1,20 @@
import LayoutView from "@/components/layout/view";
import { SearchField, CloseButton, PressableFeedback } from "heroui-native";
-import { View, Text, useWindowDimensions } from "react-native";
+import { toast } from "sonner-native";
+import { View, Text } from "react-native";
import { Stack, useRouter } from "expo-router";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { getGroupedApps, type SubAppItem } from "@/configs/pages";
import { useState, useMemo, useRef, useCallback, useEffect } from "react";
import EmptyState from "@/components/EmptyStatus";
import AZIndex from "@/components/AZIndex";
-import { useFavoriteStore, useFamilyStore } from "@/store";
-import { Star } from "lucide-react-native";
-
-const NUM_COLUMNS = 3;
-const GAP = 10;
+import { useFavoriteStore, useFamilyStore, useUserStore } from "@/store";
+import { Heart } from "lucide-react-native";
+import { recordFeed } from "@/helpers/feed";
export default function Tools() {
const router = useRouter();
const safeAreaInsets = useSafeAreaInsets();
- const { width: screenWidth } = useWindowDimensions();
const scrollRef = useRef(null);
const headerPositions = useRef>({});
@@ -24,6 +22,7 @@ export default function Tools() {
const familyStore = useFamilyStore();
const favoriteStore = useFavoriteStore();
+ const userStore = useUserStore();
// 进入页面时拉取收藏
useEffect(() => {
@@ -81,12 +80,6 @@ export default function Tools() {
return result;
}, [keyword, favoriteStore.favorites]);
- // 16 是 LayoutView.ScrollView 内置的 paddingHorizontal
- const contentPadding = 16;
- const itemWidth = Math.floor(
- (screenWidth - contentPadding * 2 - GAP * (NUM_COLUMNS - 1)) / NUM_COLUMNS
- );
-
const handleLetterPress = useCallback(
(letter: string) => {
const y = headerPositions.current[letter];
@@ -104,8 +97,6 @@ export default function Tools() {
{sortedApps.length === 0 ? (
@@ -126,77 +117,82 @@ export default function Tools() {
}
}}
>
-
- {item.title}
-
+
+ {/* 收藏分组特殊标记 */}
+ {item.title === "收藏" && (
+
+
+
+ )}
+ {/* 普通分组标记 */}
+ {item.title !== "收藏" && (
+
+ )}
+
+ {item.title}
+
+
);
}
- const rows: (typeof item.data)[] = [];
- for (let i = 0; i < item.data.length; i += NUM_COLUMNS) {
- rows.push(item.data.slice(i, i + NUM_COLUMNS));
- }
-
return (
- {rows.map((row, rowIdx) => (
-
- {row.map((subItem) => {
- const Icon = subItem.icon;
- const isFav = favoriteStore.isFavorite(subItem.id);
+ {item.data.map((subItem) => {
+ const Icon = subItem.icon;
+ const isFav = favoriteStore.isFavorite(subItem.id);
- return (
-
- router.navigate(subItem.id as any)}
- className="absolute inset-0 items-center justify-center bg-white rounded-xl shadow-lg"
- >
-
-
- {subItem.title}
+ return (
+
+ router.navigate(subItem.id as any)}
+ className="flex-row items-center py-3.5 px-4"
+ >
+
+
+
+ {subItem.title}
+
+ {subItem.description ? (
+
+ {subItem.description}
-
-
+ ) : null}
+
+
+
- {/* 星标按钮 — 独立于导航主体,避免事件冲突 */}
-
- favoriteStore.toggleFavorite(
+ {/* 收藏 — 行右侧,独立触摸 */}
+ {
+ const result =
+ await favoriteStore.toggleFavorite(
familyStore.currentFamily.family_id,
subItem.id
- )
+ );
+ toast(result ? "已收藏" : "已取消收藏");
+ // 记录收藏动态(仅收藏时)
+ if (result) {
+ recordFeed(
+ familyStore.currentFamily.family_id,
+ userStore.userInfo?.id ?? 0,
+ "fav",
+ `{nickname} 收藏了「${subItem.title}」`,
+ );
}
- className="absolute top-1 right-1 p-1 z-10"
- >
-
-
-
-
- );
- })}
- {row.length < NUM_COLUMNS &&
- Array.from({ length: NUM_COLUMNS - row.length }).map(
- (_, emptyIdx) => (
-
- )
- )}
-
- ))}
+ }}
+ className="absolute right-4 top-0 bottom-0 justify-center px-2 z-10"
+ >
+
+
+
+
+ );
+ })}
);
})
@@ -227,6 +223,7 @@ export default function Tools() {
style={{ padding: 24 }}
/>
+
);
}
diff --git a/code/app/app-rn/src/app/(tabs)/chat/index.tsx b/code/app/app-rn/src/app/(tabs)/chat/index.tsx
deleted file mode 100644
index 6364b63e..00000000
--- a/code/app/app-rn/src/app/(tabs)/chat/index.tsx
+++ /dev/null
@@ -1,83 +0,0 @@
-import React, { useState } from "react";
-import { View, Button, Image, ScrollView, StyleSheet } from "react-native";
-import ImagePicker from "react-native-image-crop-picker";
-
-export default function ImageCropPickerDemo() {
- const [images, setImages] = useState([]);
-
- // 选择单张 + 裁剪
- const pickSingle = async () => {
- try {
- const image = await ImagePicker.openPicker({
- freeStyleCropEnabled: true,
- cropping: true,
- compressImageQuality: 0.7,
- });
-
- setImages([image]);
- } catch (e) {
- console.log(e);
- }
- };
-
- // 拍照 + 裁剪
- const takePhoto = async () => {
- try {
- const image = await ImagePicker.openCamera({
- width: 300,
- height: 300,
- cropping: true,
- compressImageQuality: 0.7,
- });
-
- setImages([image]);
- } catch (e) {
- console.log(e);
- }
- };
-
- // 多选(不裁剪)
- const pickMultiple = async () => {
- try {
- const result = await ImagePicker.openPicker({
- multiple: true,
- compressImageQuality: 0.7,
- });
-
- setImages(result);
- } catch (e) {
- console.log(e);
- }
- };
-
- return (
-
-
-
-
-
-
- {images.map((img, index) => (
-
- ))}
-
-
- );
-}
-
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- padding: 20,
- marginTop: 60,
- },
- preview: {
- marginTop: 20,
- },
- image: {
- width: "100%",
- height: 200,
- marginBottom: 10,
- borderRadius: 10,
- },
-});
diff --git a/code/app/app-rn/src/app/(tabs)/home/index.tsx b/code/app/app-rn/src/app/(tabs)/home/index.tsx
deleted file mode 100644
index 2cc8d2ea..00000000
--- a/code/app/app-rn/src/app/(tabs)/home/index.tsx
+++ /dev/null
@@ -1,207 +0,0 @@
-import { useEffect, useState } from "react";
-import LayoutView from "@/components/layout/view";
-import { loadDataApi } from "@/request/api";
-import { View, Text, useWindowDimensions } from "react-native";
-import { useFamilyStore, useFavoriteStore } from "@/store";
-import { LayoutGridIcon, LayersIcon } from "lucide-react-native";
-import { PressableFeedback } from "heroui-native";
-import { SUB_APPS, type SubAppItem } from "@/configs/pages";
-import { useRouter } from "expo-router";
-
-const NUM_COLUMNS = 3;
-const GAP = 10;
-
-export default function Home() {
- const router = useRouter();
- const familyStore = useFamilyStore();
- const favoriteStore = useFavoriteStore();
- const { width: screenWidth } = useWindowDimensions();
-
- const [family, setFamily] = useState({});
-
- const [duration, setDuration] = useState({
- days: 0,
- hours: 0,
- minutes: 0,
- seconds: 0,
- });
-
- // 16 是 LayoutView.ScrollView 内置的 paddingHorizontal
- const contentPadding = 16;
- const itemWidth = Math.floor(
- (screenWidth - contentPadding * 2 - GAP * (NUM_COLUMNS - 1)) / NUM_COLUMNS
- );
-
- async function loadFamilyData() {
- const res = await loadDataApi({
- view_name: "b_family",
- search_condition: "id = ?",
- search_columns: [],
- order_by: "",
- args: [familyStore.currentFamily?.family_id],
- });
-
- if (res.isSuccess && res.data.length > 0) {
- setFamily(res.data[0]);
- }
- }
-
- function calcDuration(startTime: string) {
- const start = new Date(startTime).getTime();
- const now = Date.now();
- let diff = Math.floor((now - start) / 1000);
-
- const days = Math.floor(diff / (3600 * 24));
- diff %= 3600 * 24;
-
- const hours = Math.floor(diff / 3600);
- diff %= 3600;
-
- const minutes = Math.floor(diff / 60);
- const seconds = diff % 60;
-
- return { days, hours, minutes, seconds };
- }
-
- useEffect(() => {
- loadFamilyData();
- }, []);
-
- useEffect(() => {
- if (!family.create_time) return;
-
- const timer = setInterval(() => {
- setDuration(calcDuration(family.create_time));
- }, 1000);
-
- return () => clearInterval(timer);
- }, [family.create_time]);
-
- // 监听家庭变化,拉取收藏
- useEffect(() => {
- const fid = familyStore.currentFamily.family_id;
- if (fid) favoriteStore.fetchFavorites(fid);
- }, [familyStore.currentFamily.family_id]);
-
- // 首页展示:收藏在前,其余在后 + "更多" 铺满行
- const displayItems: SubAppItem[] = (() => {
- const favIds = favoriteStore.favorites;
- const starred = SUB_APPS.filter((a) => favIds.has(a.id));
- const unstarred = SUB_APPS.filter((a) => !favIds.has(a.id));
- return [...starred, ...unstarred];
- })();
-
- // 按 3 列分行使"更多"自然填充
- const cardRows: { items: SubAppItem[]; hasMore: boolean }[] = (() => {
- const rows: { items: SubAppItem[]; hasMore: boolean }[] = [];
- const all = [...displayItems];
- const hasMore = true; // 永远显示"更多"
-
- for (let i = 0; i < all.length; i += NUM_COLUMNS) {
- rows.push({ items: all.slice(i, i + NUM_COLUMNS), hasMore: false });
- }
-
- // 最后一行若不满 3 个,"更多" 补在末尾
- const last = rows[rows.length - 1];
- if (last && last.items.length < NUM_COLUMNS) {
- last.hasMore = true;
- last.items.push({ id: "__more__", title: "更多", icon: LayersIcon, group: "" } as any);
- } else {
- // 最后一行已满或没有数据,新增一行
- rows.push({
- items: [{ id: "__more__", title: "更多", icon: LayersIcon, group: "" } as any],
- hasMore: true,
- });
- }
-
- return rows;
- })();
-
- return (
-
-
-
-
-
- {family.name || "-"}
-
-
-
-
-
-
- {duration.days}
-
- 天
-
-
- {duration.hours}
-
- 时
-
-
- {duration.minutes}
-
- 分
-
-
- {duration.seconds}
-
- 秒
-
-
-
-
-
-
-
- 快捷应用
-
-
- {cardRows.map((row, rowIdx) => (
-
- {row.items.map((app) => {
- const Icon = app.icon;
- const isMoreBtn = app.id === "__more__";
-
- return (
-
- isMoreBtn
- ? router.navigate("/tools")
- : router.navigate(app.id as any)
- }
- style={{ width: itemWidth }}
- className="items-center justify-center h-20 bg-white rounded-xl shadow-lg"
- >
-
-
- {app.title}
-
-
-
- );
- })}
-
- {/* 末尾空位占位,保持左对齐 */}
- {row.items.length < NUM_COLUMNS &&
- Array.from({ length: NUM_COLUMNS - row.items.length }).map(
- (_, emptyIdx) => (
-
- )
- )}
-
- ))}
-
-
-
- );
-}
diff --git a/code/app/app-rn/src/app/(tabs)/star/index.tsx b/code/app/app-rn/src/app/(tabs)/star/index.tsx
deleted file mode 100644
index e3ecf607..00000000
--- a/code/app/app-rn/src/app/(tabs)/star/index.tsx
+++ /dev/null
@@ -1,13 +0,0 @@
-import React from "react";
-import LayoutView from "@/components/layout/view";
-import { Text } from "react-native";
-
-export default function Start() {
- return (
-
-
- Hello
-
-
- );
-}
diff --git a/code/app/app-rn/src/app/auth/login.txt b/code/app/app-rn/src/app/auth/login.txt
deleted file mode 100644
index e811d96d..00000000
--- a/code/app/app-rn/src/app/auth/login.txt
+++ /dev/null
@@ -1,119 +0,0 @@
-import { Stack } from "expo-router";
-import { Image, Text, View } from "react-native";
-import { Button, LinkButton, ControlField, Checkbox, Spinner } from "heroui-native";
-import { IconQq } from "@/components/iconfont";
-import { useState } from "react";
-import { FadeIn } from "react-native-reanimated";
-import { useUserStore, useFamilyStore } from "@/store";
-import { useRouter } from "expo-router";
-
-export default function Login() {
- const router = useRouter();
- const userStore = useUserStore();
- const familyStore = useFamilyStore();
-
- const [loading, setLoading] = useState(false);
- const [isAgreed, setIsAgreed] = useState(true);
-
- /**
- * {"code": 1000, "data": {"avatar": "http://thirdqq.qlogo.cn/ek_qqapp/AQHVialeUibE42oV8RvTTVZ4tyRlPYeJrUibWTwtCPfV68E4rSzn9cw00GkXQ3QDTiawwhjoqQb7/100", "family_id": 797225979793477, "nickname": "Zhang", "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJkYXRhIjp7InVzZXJfaWQiOjc5NjU0MTkwMTM4OTg5M30sImlhdCI6MTc3Njc3MTcyOX0.JldxjA-0u2hza3qNx20xgUyVBVVV6hQuVuTW1GcJyr8", "userId": 796541901389893}, "isSuccess": true, "message": "success", "track_id": "hI5gEsPHu1LGwkQaJ6lf1dDQby0rzxGVJj0KcNC3Nxw"}
- */
-
- function handleTest() {
- if (loading) {
- return;
- }
-
- setLoading(true);
-
- setTimeout(() => {
- requestAnimationFrame(() => {
- userStore.setUserInfo({
- id: 796541901389893,
- token:
- "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJkYXRhIjp7InVzZXJfaWQiOjc5NjU0MTkwMTM4OTg5M30sImlhdCI6MTc3Njc3MTcyOX0.JldxjA-0u2hza3qNx20xgUyVBVVV6hQuVuTW1GcJyr8",
- nickname: "Zhang",
- avatar:
- "http://thirdqq.qlogo.cn/ek_qqapp/AQHVialeUibE42oV8RvTTVZ4tyRlPYeJrUibWTwtCPfV68E4rSzn9cw00GkXQ3QDTiawwhjoqQb7/100",
- });
- userStore.setAuthExpired(false);
- console.log(familyStore.inFamily());
-
- // if (familyStore.inFamily()) {
- // router.replace("/(tabs)/home");
- // } else {
- // router.replace("/family/entry");
- // return;
- // }
-
- familyStore.setCurrentFamily({
- family_id: 797225979793477,
- });
-
- router.replace("/(tabs)/home");
- });
- setLoading(false);
- }, 500);
- }
-
- return (
- <>
-
-
-
-
- Welcome to Allapp
-
-
-
- {/*
- {loading ? (
-
- ) : (
-
- )}
- WeChat
-
-
-
- {loading ? : }
- QQ
- */}
-
-
- {loading ? : }
- Test
-
-
-
-
-
-
-
-
- 登录即表示你同意我们的
-
- 用户协议
-
- 和
-
- 隐私政策
-
-
-
-
- >
- );
-}
diff --git a/code/app/app-rn/src/app/index.tsx b/code/app/app-rn/src/app/index.tsx
index 3911d2b9..eb270184 100644
--- a/code/app/app-rn/src/app/index.tsx
+++ b/code/app/app-rn/src/app/index.tsx
@@ -63,7 +63,7 @@ export default function Index() {
family_id: res.data[0].family_id,
});
- router.replace("/(tabs)/home");
+ router.replace("/home");
} catch (e) {
console.error(e);
toast.error("初始化失败,请稍后重试");
diff --git a/code/app/app-rn/src/components/DateTime.tsx b/code/app/app-rn/src/components/DateTime.tsx
index b0e7aa5d..807fdd88 100644
--- a/code/app/app-rn/src/components/DateTime.tsx
+++ b/code/app/app-rn/src/components/DateTime.tsx
@@ -1,15 +1,19 @@
-import { DatePicker as YzDatePicker } from "@yz1311/react-native-wheel-picker";
+import DateTimePicker from "react-native-dates-picker";
import dayjs from "dayjs";
import { BottomSheet, Button, Spinner } from "heroui-native";
-import { useState, useEffect } from "react";
-import { toDate } from "@/utils/time";
+import { useState, useEffect, useCallback, useRef } from "react";
import { View } from "react-native";
import { CheckIcon } from "lucide-react-native";
import { FadeIn } from "react-native-reanimated";
export type DatePickerMode = "year" | "month" | "date" | "time" | "datetime";
-export type DateFormatType = "YYYY" | "YYYY-MM" | "YYYY-MM-DD" | "HH:mm:ss" | "YYYY-MM-DD HH:mm";
+export type DateFormatType =
+ | "YYYY"
+ | "YYYY-MM"
+ | "YYYY-MM-DD"
+ | "HH:mm:ss"
+ | "YYYY-MM-DD HH:mm";
interface DatePickerProps {
value: Date | string;
@@ -28,6 +32,14 @@ const defaultFormatMap: Record = {
datetime: "YYYY-MM-DD HH:mm",
};
+const modeViewMap: Record = {
+ year: "year",
+ month: "month",
+ date: "day",
+ time: "day",
+ datetime: "day",
+};
+
export default function DateTime({
value,
show = false,
@@ -38,51 +50,60 @@ export default function DateTime({
}: DatePickerProps) {
const finalFormat = format ?? defaultFormatMap[mode];
- const [tempDate, setTempDate] = useState(toDate(value) ?? new Date());
+ const [open, setOpen] = useState(false);
+ const [tempDate, setTempDate] = useState(dayjs());
const [loading, setLoading] = useState(false);
+ const onCloseRef = useRef(onClose);
+ onCloseRef.current = onClose;
+
+ // 打开
useEffect(() => {
if (show) {
- setTempDate(toDate(value) ?? new Date());
+ const d = dayjs(value);
+ setTempDate(d.isValid() ? d : dayjs());
+ setOpen(true);
}
}, [show, value]);
- const handleClose = () => {
- if (loading) return;
- onClose?.();
- };
+ // 关闭:BottomSheet 的 onOpenChange(false) 或 confirm 后 setOpen(false)
+ // 立刻通知父组件,不延迟 — BottomSheet 自身驱动关闭动画
+ useEffect(() => {
+ if (!open && show) {
+ onCloseRef.current?.();
+ }
+ }, [open, show]);
- const handleConfirm = async () => {
- if (!tempDate || loading) return;
+ const handleConfirm = useCallback(async () => {
+ if (loading || !tempDate) return;
try {
setLoading(true);
-
- const result = dayjs(tempDate).format(finalFormat);
-
+ const result = tempDate.format(finalFormat);
await onConfirm?.(result);
-
- handleClose();
+ setOpen(false);
} catch (e) {
console.log(e);
} finally {
setLoading(false);
}
- };
+ }, [loading, tempDate, finalFormat, onConfirm]);
return (
- !v && handleClose()}>
+
-
- setTempDate(d)}
+ initialView={modeViewMap[mode]}
+ timePicker={mode === "time" || mode === "datetime"}
+ selectedItemColor="#0d9488"
+ onChange={({ date }) => {
+ if (date) setTempDate(dayjs(date));
+ }}
/>
diff --git a/code/app/app-rn/src/components/DateTimeRange.tsx b/code/app/app-rn/src/components/DateTimeRange.tsx
index 0b3e029e..361c36ba 100644
--- a/code/app/app-rn/src/components/DateTimeRange.tsx
+++ b/code/app/app-rn/src/components/DateTimeRange.tsx
@@ -1,17 +1,17 @@
-import React, { useState, useEffect } from "react";
-import { DatePicker as YzDatePicker } from "@yz1311/react-native-wheel-picker";
-import { BottomSheet, Button } from "heroui-native";
-import { View, Text, TouchableOpacity } from "react-native";
+import DateTimePicker, { DateType } from "react-native-dates-picker";
import dayjs from "dayjs";
-import { toDate } from "@/utils/time";
+import { BottomSheet, Button, Spinner } from "heroui-native";
+import { View } from "react-native";
+import { useState, useEffect, useCallback, useRef } from "react";
import { CheckIcon } from "lucide-react-native";
+import { FadeIn } from "react-native-reanimated";
interface DateRangePickerProps {
startDate?: Date | string;
endDate?: Date | string;
show?: boolean;
onClose?: () => void;
- onConfirm?: (start: string, end: string) => void;
+ onConfirm?: (start: string, end: string) => void | Promise;
}
export default function DateTimeRange({
@@ -21,121 +21,78 @@ export default function DateTimeRange({
onClose,
onConfirm,
}: DateRangePickerProps) {
- const [tempStart, setTempStart] = useState(undefined);
- const [tempEnd, setTempEnd] = useState(undefined);
+ const [open, setOpen] = useState(false);
+ const [range, setRange] = useState<{
+ startDate: DateType;
+ endDate: DateType;
+ }>({ startDate: undefined, endDate: undefined });
+ const [loading, setLoading] = useState(false);
- const [active, setActive] = useState<"start" | "end">("start");
+ const onCloseRef = useRef(onClose);
+ onCloseRef.current = onClose;
- // ✅ 每次打开重置
useEffect(() => {
if (show) {
- setTempStart(toDate(startDate) ?? new Date());
- setTempEnd(toDate(endDate) ?? undefined);
- setActive("start");
+ setRange({
+ startDate: startDate ? dayjs(startDate) : undefined,
+ endDate: endDate ? dayjs(endDate) : undefined,
+ });
+ setOpen(true);
}
}, [show, startDate, endDate]);
- const handleClose = () => {
- onClose?.();
- };
+ useEffect(() => {
+ if (!open && show) {
+ onCloseRef.current?.();
+ }
+ }, [open, show]);
- const handleConfirm = () => {
- if (!tempStart) return;
+ const handleConfirm = useCallback(async () => {
+ if (loading) return;
+ if (!range.startDate) return;
- onConfirm?.(
- dayjs(tempStart).format("YYYY-MM-DD"),
- tempEnd ? dayjs(tempEnd).format("YYYY-MM-DD") : "",
- );
-
- handleClose();
- };
-
- // ❗ start 修改 → 清空 end
- const handleStartChange = (date: Date) => {
- setTempStart(date);
- setTempEnd(undefined);
- };
-
- // ❗ end 修改
- const handleEndChange = (date: Date) => {
- if (!tempStart) return;
- setTempEnd(date);
- };
-
- const formatDate = (date?: Date) => (date ? dayjs(date).format("YYYY-MM-DD") : "请选择");
+ try {
+ setLoading(true);
+ await onConfirm?.(
+ dayjs(range.startDate).format("YYYY-MM-DD"),
+ range.endDate ? dayjs(range.endDate).format("YYYY-MM-DD") : "",
+ );
+ setOpen(false);
+ } catch (e) {
+ console.log(e);
+ } finally {
+ setLoading(false);
+ }
+ }, [loading, range, onConfirm]);
return (
- !v && handleClose()}>
+
-
-
- {/* 顶部选择区 */}
-
- {/* start */}
- setActive("start")}>
- 开始时间
-
- {formatDate(tempStart)}
-
-
+
+ setRange(params)}
+ />
-
-
- {/* end */}
- {
- if (!tempStart) return;
-
- // ✅ 核心优化:未选 end → 自动补 start
- if (!tempEnd) {
- setTempEnd(tempStart);
- }
-
- setActive("end");
- }}
- >
- 结束时间
-
- {formatDate(tempEnd)}
-
-
-
-
- {/* Picker */}
- {active === "start" ? (
-
- ) : (
-
- )}
-
- {/* confirm */}
-
-
- 确认
+
+ {loading ? (
+
+ ) : (
+ <>
+
+ 确认
+ >
+ )}
diff --git a/code/app/app-rn/src/helpers/feed.ts b/code/app/app-rn/src/helpers/feed.ts
new file mode 100644
index 00000000..d8e467af
--- /dev/null
+++ b/code/app/app-rn/src/helpers/feed.ts
@@ -0,0 +1,86 @@
+import { loadDataApi, saveDataApi, getUniqueIdApi } from "@/request/api";
+
+export type FeedType = "join" | "rename" | "bill" | "fav";
+
+export interface FeedRecord {
+ id: number;
+ family_id: number;
+ user_id: number;
+ type: FeedType;
+ content: string;
+ meta: string | null;
+ create_time: string;
+}
+
+/** 获取用户在家庭中的昵称(来自 b_family_member,非 b_user) */
+export async function getFamilyMemberNickname(
+ familyId: number,
+ userId: number,
+): Promise {
+ const res = await loadDataApi({
+ view_name: "b_family_member",
+ search_condition: "family_id = ? AND user_id = ?",
+ search_columns: ["nickname"],
+ order_by: "",
+ args: [familyId, userId],
+ });
+ if (res.isSuccess && res.data?.length > 0) {
+ return res.data[0].nickname || "成员";
+ }
+ return "成员";
+}
+
+/** 写入一条动态 — content 直接包含所有展示信息,用 {nickname} 占位 */
+export async function recordFeed(
+ familyId: number,
+ userId: number,
+ type: FeedType,
+ content: string,
+) {
+ const idRes = await getUniqueIdApi(1);
+ const feedId = idRes.data?.[0];
+ if (!feedId) return;
+
+ await saveDataApi([
+ {
+ table_name: "b_family_feed",
+ key_field: "id",
+ inserts: [
+ {
+ id: feedId,
+ family_id: familyId,
+ user_id: userId,
+ type,
+ content,
+ },
+ ],
+ updates: [],
+ deletes: [],
+ },
+ ]);
+}
+
+/** 加载家庭动态列表 — 自动替换 {nickname} 占位符 */
+export async function loadFeeds(familyId: number): Promise {
+ const res = await loadDataApi({
+ view_name: "b_family_feed",
+ search_condition: "family_id = ?",
+ order_by: "create_time DESC",
+ search_columns: ["id", "family_id", "user_id", "type", "content", "meta", "create_time"],
+ args: [familyId],
+ });
+ const feeds = (res.data || []) as FeedRecord[];
+
+ // 批量加载昵称:收集唯一 user_id,逐个查 b_family_member
+ const uniqueUserIds = [...new Set(feeds.map((f) => f.user_id))];
+ const nickMap = new Map();
+ for (const uid of uniqueUserIds) {
+ nickMap.set(uid, await getFamilyMemberNickname(familyId, uid));
+ }
+
+ // 替换 {nickname} 占位符,保证每次展示都是最新昵称
+ return feeds.map((f) => ({
+ ...f,
+ content: f.content.replace(/\{nickname\}/g, nickMap.get(f.user_id) || "成员"),
+ }));
+}
diff --git a/code/app/app-rn/src/layouts/PageLayout.tsx b/code/app/app-rn/src/layouts/PageLayout.tsx
index 8388fe54..18dc17d0 100644
--- a/code/app/app-rn/src/layouts/PageLayout.tsx
+++ b/code/app/app-rn/src/layouts/PageLayout.tsx
@@ -21,7 +21,7 @@ export default function PageLayout({ title, titleIcon, children, renderTitle }:
}
function handleClose() {
- router.navigate("/(tabs)/home");
+ router.navigate("/home");
}
return (