20260623212917
This commit is contained in:
1 parent
e9e3371be7
commit
056cd4970f
30 files changed
+2093
-636
No files matched your search
@@ -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 列网格
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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";
|
||||
|
||||
<Button variant="primary" onPress={() => console.log("Pressed!")}>
|
||||
Click me
|
||||
</Button>;
|
||||
```
|
||||
|
||||
**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 (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<HeroUINativeProvider>
|
||||
<App />
|
||||
</HeroUINativeProvider>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 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
|
||||
<Card>
|
||||
<Card.Header>{/* Icons, badges */}</Card.Header>
|
||||
<Card.Body>
|
||||
<Card.Title>Title</Card.Title>
|
||||
<Card.Description>Description</Card.Description>
|
||||
</Card.Body>
|
||||
<Card.Footer>{/* Actions */}</Card.Footer>
|
||||
</Card>
|
||||
```
|
||||
|
||||
**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`
|
||||
@@ -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 <Component1> [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();
|
||||
@@ -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 <path>");
|
||||
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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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",
|
||||
|
||||
Generated
+51
@@ -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
|
||||
|
||||
File renamed without changes.
@@ -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 (
|
||||
<LayoutView>
|
||||
<LayoutView.ScrollView>
|
||||
<View style={{ padding: 16, gap: 16, marginTop: 30 }}>
|
||||
|
||||
{/* DateTime */}
|
||||
<Text style={{ fontSize: 18, fontWeight: "bold" }}>DateTime(单选)</Text>
|
||||
|
||||
<Button title={`日期: ${dateValue}`} onPress={() => setShowDate(true)} />
|
||||
<DateTime
|
||||
show={showDate}
|
||||
value={dateValue}
|
||||
mode="date"
|
||||
onConfirm={(v) => { setDateValue(v); setShowDate(false); }}
|
||||
onClose={() => setShowDate(false)}
|
||||
/>
|
||||
|
||||
<Button title={`时间: ${timeValue}`} onPress={() => setShowTime(true)} />
|
||||
<DateTime
|
||||
show={showTime}
|
||||
value={timeValue}
|
||||
mode="time"
|
||||
onConfirm={(v) => { setTimeValue(v); setShowTime(false); }}
|
||||
onClose={() => setShowTime(false)}
|
||||
/>
|
||||
|
||||
<Button title={`日期时间: ${datetimeValue}`} onPress={() => setShowDatetime(true)} />
|
||||
<DateTime
|
||||
show={showDatetime}
|
||||
value={datetimeValue}
|
||||
mode="datetime"
|
||||
onConfirm={(v) => { setDatetimeValue(v); setShowDatetime(false); }}
|
||||
onClose={() => setShowDatetime(false)}
|
||||
/>
|
||||
|
||||
<Button title={`月份: ${monthValue}`} onPress={() => setShowMonth(true)} />
|
||||
<DateTime
|
||||
show={showMonth}
|
||||
value={monthValue}
|
||||
mode="month"
|
||||
onConfirm={(v) => { setMonthValue(v); setShowMonth(false); }}
|
||||
onClose={() => setShowMonth(false)}
|
||||
/>
|
||||
|
||||
<Button title={`年份: ${yearValue}`} onPress={() => setShowYear(true)} />
|
||||
<DateTime
|
||||
show={showYear}
|
||||
value={yearValue}
|
||||
mode="year"
|
||||
onConfirm={(v) => { setYearValue(v); setShowYear(false); }}
|
||||
onClose={() => setShowYear(false)}
|
||||
/>
|
||||
|
||||
{/* DateTimeRange */}
|
||||
<Text style={{ fontSize: 18, fontWeight: "bold", marginTop: 16 }}>DateTimeRange(范围)</Text>
|
||||
|
||||
<Button
|
||||
title={rangeStart ? `${rangeStart} ~ ${rangeEnd || "..."}` : "选择日期范围"}
|
||||
onPress={() => setShowRange(true)}
|
||||
/>
|
||||
<DateTimeRange
|
||||
show={showRange}
|
||||
startDate={rangeStart || undefined}
|
||||
endDate={rangeEnd || undefined}
|
||||
onConfirm={(start, end) => {
|
||||
setRangeStart(start);
|
||||
setRangeEnd(end);
|
||||
setShowRange(false);
|
||||
}}
|
||||
onClose={() => setShowRange(false)}
|
||||
/>
|
||||
</View>
|
||||
</LayoutView.ScrollView>
|
||||
</LayoutView>
|
||||
);
|
||||
}
|
||||
@@ -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<string, React.ComponentType<any>> = {
|
||||
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<any>({});
|
||||
const [duration, setDuration] = useState({
|
||||
days: 0,
|
||||
hours: 0,
|
||||
minutes: 0,
|
||||
seconds: 0,
|
||||
});
|
||||
const [feeds, setFeeds] = useState<FeedRecord[]>([]);
|
||||
|
||||
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 (
|
||||
<LayoutView>
|
||||
<LayoutView.ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* ═══════════ 头部 ═══════════ */}
|
||||
<View className="pt-2 pb-8">
|
||||
<Text className="text-[13px] text-gray-400 tracking-wide">
|
||||
{getGreeting()}
|
||||
</Text>
|
||||
<Text className="text-[28px] font-bold text-gray-900 mt-1 tracking-tight">
|
||||
{family.name || "我的空间"}
|
||||
</Text>
|
||||
|
||||
{/* 计时 — 开放式,无卡片 */}
|
||||
<View className="mt-6">
|
||||
<View className="flex-row items-baseline">
|
||||
<Text className="text-[44px] font-bold text-accent tracking-tighter">
|
||||
{duration.days}
|
||||
</Text>
|
||||
<Text className="text-[16px] text-accent/60 font-medium ml-1.5">
|
||||
天
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text className="text-sm text-gray-500 mt-2">
|
||||
{String(duration.hours).padStart(2, "0")} 小时{" "}
|
||||
{String(duration.minutes).padStart(2, "0")} 分钟{" "}
|
||||
{String(duration.seconds).padStart(2, "0")} 秒
|
||||
</Text>
|
||||
|
||||
<Text className="text-[13px] text-gray-400/70 mt-1.5">
|
||||
已经在一起这么久啦
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* ═══════════ 快捷工具 ═══════════ */}
|
||||
<View className="flex-row items-center mb-3">
|
||||
<View className="w-1.5 h-4 rounded-full bg-accent mr-1.5" />
|
||||
<Text className="text-[11px] text-gray-500 font-semibold tracking-wider uppercase">
|
||||
快捷工具
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{SUB_APPS.map((app) => {
|
||||
const Icon = app.icon;
|
||||
return (
|
||||
<PressableFeedback
|
||||
key={app.id}
|
||||
onPress={() => router.navigate(app.id as any)}
|
||||
className="flex-row items-center bg-white rounded-xl shadow-sm mb-2 px-4 py-3.5"
|
||||
>
|
||||
<View className="w-11 h-11 rounded-xl bg-accent/10 items-center justify-center mr-3.5">
|
||||
<Icon size={22} />
|
||||
</View>
|
||||
|
||||
<View className="flex-1">
|
||||
<Text className="text-[15px] font-semibold text-gray-800">
|
||||
{app.title}
|
||||
</Text>
|
||||
<Text className="text-[13px] text-gray-400 mt-0.5">
|
||||
{app.description}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<ChevronRight size={18} color="#d1d5db" />
|
||||
<PressableFeedback.Ripple />
|
||||
</PressableFeedback>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 更多工具 */}
|
||||
<PressableFeedback
|
||||
onPress={() => router.navigate("/tools")}
|
||||
className="flex-row items-center bg-white rounded-xl shadow-sm mb-1 mt-1 px-4 py-3.5"
|
||||
>
|
||||
<View className="w-11 h-11 rounded-xl bg-gray-100 items-center justify-center mr-3.5">
|
||||
<Grid3X3 size={20} color="#9ca3af" />
|
||||
</View>
|
||||
|
||||
<Text className="flex-1 text-[15px] font-medium text-gray-500">
|
||||
更多工具
|
||||
</Text>
|
||||
|
||||
<ChevronRight size={18} color="#d1d5db" />
|
||||
<PressableFeedback.Ripple />
|
||||
</PressableFeedback>
|
||||
|
||||
{/* ═══════════ 最近动态 ═══════════ */}
|
||||
<View className="flex-row items-center justify-between mb-4 mt-7">
|
||||
<View className="flex-row items-center">
|
||||
<View className="w-1.5 h-4 rounded-full bg-accent mr-1.5" />
|
||||
<Text className="text-[11px] text-gray-500 font-semibold tracking-wider uppercase">
|
||||
最近动态
|
||||
</Text>
|
||||
</View>
|
||||
<PressableFeedback
|
||||
onPress={() => router.navigate("/activities")}
|
||||
className="flex-row items-center"
|
||||
>
|
||||
<Text className="text-[12px] text-gray-400">更多</Text>
|
||||
<ChevronRight size={14} color="#d1d5db" />
|
||||
<PressableFeedback.Ripple />
|
||||
</PressableFeedback>
|
||||
</View>
|
||||
|
||||
{feeds.length === 0 ? (
|
||||
<Text className="text-sm text-gray-400 mb-6">
|
||||
暂无动态
|
||||
</Text>
|
||||
) : (
|
||||
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 (
|
||||
<View key={item.id} className="flex-row">
|
||||
{/* 时间轴列 */}
|
||||
<View className="items-center mr-3" style={{ width: 16 }}>
|
||||
<View className="w-2 h-2 rounded-full bg-accent mt-1.5" />
|
||||
{!isLast && <View className="flex-1 w-px bg-gray-200" />}
|
||||
</View>
|
||||
|
||||
{/* 内容 */}
|
||||
<View className={`flex-1 ${isLast ? "" : "pb-5"}`}>
|
||||
<Text className="text-xs text-gray-400">
|
||||
{formatFeedTime(item.create_time)}
|
||||
</Text>
|
||||
<Text className="text-[15px] text-gray-800 leading-5 mt-0.5">
|
||||
{item.content}
|
||||
</Text>
|
||||
{meta?.amount ? (
|
||||
<Text className="text-[13px] font-semibold text-accent mt-0.5">
|
||||
¥{meta.amount}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{/* 底部留白 */}
|
||||
<View className="h-4" />
|
||||
</LayoutView.ScrollView>
|
||||
</LayoutView>
|
||||
);
|
||||
}
|
||||
File renamed without changes.
@@ -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<string, React.ComponentType<any>> = {
|
||||
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<FeedRecord[]>([]);
|
||||
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 (
|
||||
<LayoutView>
|
||||
<Stack.Screen options={{ animation: "slide_from_bottom" }} />
|
||||
|
||||
<LayoutView.ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{loading ? (
|
||||
<View className="flex-1 items-center justify-center py-20">
|
||||
<Text className="text-sm text-gray-400">加载中...</Text>
|
||||
</View>
|
||||
) : feeds.length === 0 ? (
|
||||
<View className="flex-1 items-center justify-center py-20">
|
||||
<Text className="text-sm text-gray-400">暂无动态</Text>
|
||||
</View>
|
||||
) : (
|
||||
feeds.map((item, idx) => {
|
||||
const isLast = idx === feeds.length - 1;
|
||||
const Icon = TYPE_ICONS[item.type] || Receipt;
|
||||
|
||||
return (
|
||||
<View key={item.id} className="flex-row">
|
||||
{/* 时间轴列 */}
|
||||
<View className="items-center mr-3" style={{ width: 24 }}>
|
||||
<View className="w-7 h-7 rounded-full bg-accent/10 items-center justify-center mt-0.5">
|
||||
<Icon size={14} color="#0d9488" />
|
||||
</View>
|
||||
{!isLast && <View className="flex-1 w-px bg-gray-200 mt-1" />}
|
||||
</View>
|
||||
|
||||
{/* 内容 */}
|
||||
<View className={`flex-1 ${isLast ? "" : "pb-6"}`}>
|
||||
<Text className="text-xs text-gray-400">
|
||||
{formatTime(item.create_time)}
|
||||
</Text>
|
||||
<Text className="text-[15px] text-gray-800 leading-5 mt-0.5">
|
||||
{item.content}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
<View className="h-4" />
|
||||
</LayoutView.ScrollView>
|
||||
|
||||
{/* 关闭按钮 */}
|
||||
<View className="absolute bottom-6 left-0 right-0 items-center">
|
||||
<CloseButton
|
||||
onPress={() => router.back()}
|
||||
iconProps={{ size: 28 }}
|
||||
style={{ padding: 24 }}
|
||||
/>
|
||||
</View>
|
||||
</LayoutView>
|
||||
);
|
||||
}
|
||||
+1
-1
@@ -130,7 +130,7 @@ export default function Login() {
|
||||
family_id: user.family_id,
|
||||
});
|
||||
|
||||
router.replace("/(tabs)/home");
|
||||
router.replace("/home");
|
||||
}
|
||||
});
|
||||
};
|
||||
+8
-1
@@ -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<any, React.ComponentProps<typeof Input>>((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) {
|
||||
File renamed without changes.
File renamed without changes.
+29
@@ -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) {
|
||||
+71
-74
@@ -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<any>(null);
|
||||
const headerPositions = useRef<Record<string, number>>({});
|
||||
|
||||
@@ -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() {
|
||||
<LayoutView.ScrollView
|
||||
ref={scrollRef}
|
||||
showsVerticalScrollIndicator={false}
|
||||
topExtraPadding={70}
|
||||
bottomExtraPadding={100}
|
||||
>
|
||||
{sortedApps.length === 0 ? (
|
||||
<EmptyState />
|
||||
@@ -126,77 +117,82 @@ export default function Tools() {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Text className="text-[11px] text-gray-400/70 font-medium mb-2.5 ml-0.5 mt-3 first:mt-0 tracking-wider">
|
||||
{item.title}
|
||||
</Text>
|
||||
<View className="flex-row items-center mb-3 mt-5 first:mt-0">
|
||||
{/* 收藏分组特殊标记 */}
|
||||
{item.title === "收藏" && (
|
||||
<View className="w-5 h-5 rounded-full bg-accent/15 items-center justify-center mr-1.5">
|
||||
<Heart size={10} fill="#0d9488" color="#0d9488" />
|
||||
</View>
|
||||
)}
|
||||
{/* 普通分组标记 */}
|
||||
{item.title !== "收藏" && (
|
||||
<View className="w-1.5 h-4 rounded-full bg-accent mr-1.5" />
|
||||
)}
|
||||
<Text className="text-[11px] text-gray-500 font-semibold tracking-wider uppercase">
|
||||
{item.title}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<View key={`g-${idx}`} className="mb-4">
|
||||
{rows.map((row, rowIdx) => (
|
||||
<View
|
||||
key={rowIdx}
|
||||
className="flex-row mb-2.5 last:mb-0"
|
||||
style={{ gap: GAP }}
|
||||
>
|
||||
{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 (
|
||||
<View
|
||||
key={subItem.id}
|
||||
style={{ width: itemWidth, height: 80 }}
|
||||
>
|
||||
<PressableFeedback
|
||||
onPress={() => router.navigate(subItem.id as any)}
|
||||
className="absolute inset-0 items-center justify-center bg-white rounded-xl shadow-lg"
|
||||
>
|
||||
<Icon size={26} />
|
||||
<Text className="text-xs text-gray-700 mt-2 font-medium text-center">
|
||||
{subItem.title}
|
||||
return (
|
||||
<View key={subItem.id} className="bg-white rounded-xl shadow-sm mx-3 mb-2 overflow-hidden relative">
|
||||
<PressableFeedback
|
||||
onPress={() => router.navigate(subItem.id as any)}
|
||||
className="flex-row items-center py-3.5 px-4"
|
||||
>
|
||||
<Icon size={28} />
|
||||
<View className="flex-1 ml-3">
|
||||
<Text className="text-[15px] text-gray-700 font-medium leading-5">
|
||||
{subItem.title}
|
||||
</Text>
|
||||
{subItem.description ? (
|
||||
<Text className="text-[12px] text-gray-400 leading-4 mt-0.5" numberOfLines={1}>
|
||||
{subItem.description}
|
||||
</Text>
|
||||
<PressableFeedback.Ripple />
|
||||
</PressableFeedback>
|
||||
) : null}
|
||||
</View>
|
||||
<PressableFeedback.Ripple />
|
||||
</PressableFeedback>
|
||||
|
||||
{/* 星标按钮 — 独立于导航主体,避免事件冲突 */}
|
||||
<PressableFeedback
|
||||
onPress={() =>
|
||||
favoriteStore.toggleFavorite(
|
||||
{/* 收藏 — 行右侧,独立触摸 */}
|
||||
<PressableFeedback
|
||||
onPress={async () => {
|
||||
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"
|
||||
>
|
||||
<Star
|
||||
size={14}
|
||||
fill={isFav ? "#fbbf24" : "none"}
|
||||
color={isFav ? "#fbbf24" : "#d1d5db"}
|
||||
/>
|
||||
<PressableFeedback.Ripple />
|
||||
</PressableFeedback>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
{row.length < NUM_COLUMNS &&
|
||||
Array.from({ length: NUM_COLUMNS - row.length }).map(
|
||||
(_, emptyIdx) => (
|
||||
<View
|
||||
key={`empty-${emptyIdx}`}
|
||||
style={{ width: itemWidth }}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
}}
|
||||
className="absolute right-4 top-0 bottom-0 justify-center px-2 z-10"
|
||||
>
|
||||
<Heart
|
||||
size={18}
|
||||
fill={isFav ? "#0d9488" : "none"}
|
||||
color={isFav ? "#0d9488" : "#9ca3af"}
|
||||
/>
|
||||
<PressableFeedback.Ripple />
|
||||
</PressableFeedback>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
})
|
||||
@@ -227,6 +223,7 @@ export default function Tools() {
|
||||
style={{ padding: 24 }}
|
||||
/>
|
||||
</View>
|
||||
|
||||
</LayoutView>
|
||||
);
|
||||
}
|
||||
@@ -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<any[]>([]);
|
||||
|
||||
// 选择单张 + 裁剪
|
||||
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 (
|
||||
<View style={styles.container}>
|
||||
<Button title="选择图片(裁剪)" onPress={pickSingle} />
|
||||
<Button title="拍照(裁剪)" onPress={takePhoto} />
|
||||
<Button title="多选图片" onPress={pickMultiple} />
|
||||
|
||||
<ScrollView style={styles.preview}>
|
||||
{images.map((img, index) => (
|
||||
<Image key={index} source={{ uri: img.path }} style={styles.image} />
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
padding: 20,
|
||||
marginTop: 60,
|
||||
},
|
||||
preview: {
|
||||
marginTop: 20,
|
||||
},
|
||||
image: {
|
||||
width: "100%",
|
||||
height: 200,
|
||||
marginBottom: 10,
|
||||
borderRadius: 10,
|
||||
},
|
||||
});
|
||||
@@ -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<any>({});
|
||||
|
||||
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 (
|
||||
<LayoutView>
|
||||
<LayoutView.ScrollView>
|
||||
<View className="items-center justify-center">
|
||||
<View className="flex-row items-center justify-center gap-2">
|
||||
<Text className="text-4xl font-bold tracking-tight text-gray-900">
|
||||
{family.name || "-"}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="items-center mt-3">
|
||||
<View className="flex-row items-center gap-2">
|
||||
<Text className="text-3xl font-bold text-accent">
|
||||
{duration.days}
|
||||
</Text>
|
||||
<Text className="text-gray-400">天</Text>
|
||||
|
||||
<Text className="text-3xl font-bold text-accent">
|
||||
{duration.hours}
|
||||
</Text>
|
||||
<Text className="text-gray-400">时</Text>
|
||||
|
||||
<Text className="text-3xl font-bold text-accent">
|
||||
{duration.minutes}
|
||||
</Text>
|
||||
<Text className="text-gray-400">分</Text>
|
||||
|
||||
<Text className="text-3xl font-bold text-accent">
|
||||
{duration.seconds}
|
||||
</Text>
|
||||
<Text className="text-gray-400">秒</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="mt-6">
|
||||
<View className="flex-row items-center mb-2 gap-2">
|
||||
<LayoutGridIcon size={18} color="#6B7280" />
|
||||
<Text className="text-sm text-muted">快捷应用</Text>
|
||||
</View>
|
||||
|
||||
{cardRows.map((row, rowIdx) => (
|
||||
<View
|
||||
key={rowIdx}
|
||||
className="flex-row mb-2.5 last:mb-0"
|
||||
style={{ gap: GAP }}
|
||||
>
|
||||
{row.items.map((app) => {
|
||||
const Icon = app.icon;
|
||||
const isMoreBtn = app.id === "__more__";
|
||||
|
||||
return (
|
||||
<PressableFeedback
|
||||
key={app.id}
|
||||
onPress={() =>
|
||||
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"
|
||||
>
|
||||
<Icon size={26} />
|
||||
<Text className="text-xs text-gray-700 mt-2 font-medium">
|
||||
{app.title}
|
||||
</Text>
|
||||
<PressableFeedback.Ripple />
|
||||
</PressableFeedback>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 末尾空位占位,保持左对齐 */}
|
||||
{row.items.length < NUM_COLUMNS &&
|
||||
Array.from({ length: NUM_COLUMNS - row.items.length }).map(
|
||||
(_, emptyIdx) => (
|
||||
<View
|
||||
key={`empty-${emptyIdx}`}
|
||||
style={{ width: itemWidth }}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</LayoutView.ScrollView>
|
||||
</LayoutView>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import React from "react";
|
||||
import LayoutView from "@/components/layout/view";
|
||||
import { Text } from "react-native";
|
||||
|
||||
export default function Start() {
|
||||
return (
|
||||
<LayoutView>
|
||||
<LayoutView.ScrollView>
|
||||
<Text className="text-base text-black">Hello</Text>
|
||||
</LayoutView.ScrollView>
|
||||
</LayoutView>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
animation: "fade",
|
||||
}}
|
||||
/>
|
||||
<View className="flex-1 bg-white px-6 justify-center">
|
||||
<View className="items-center mb-12">
|
||||
<Image
|
||||
source={require("@/static/images/logo.png")}
|
||||
className="w-20 h-20 mb-4"
|
||||
resizeMode="contain"
|
||||
/>
|
||||
<Text className="text-xl font-semibold text-gray-900">Welcome to Allapp</Text>
|
||||
</View>
|
||||
|
||||
<View className="flex flex-col gap-4">
|
||||
{/* <Button onPress={handleLoginWechat}>
|
||||
{loading ? (
|
||||
<Spinner entering={FadeIn.delay(50)} color="white" />
|
||||
) : (
|
||||
<IconWeixin color="white" />
|
||||
)}
|
||||
<Button.Label>WeChat</Button.Label>
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" onPress={handleLoginQQ}>
|
||||
{loading ? <Spinner entering={FadeIn.delay(50)} /> : <IconQq />}
|
||||
<Button.Label>QQ</Button.Label>
|
||||
</Button> */}
|
||||
|
||||
<Button variant="outline" onPress={handleTest}>
|
||||
{loading ? <Spinner entering={FadeIn.delay(50)} /> : <IconQq />}
|
||||
<Button.Label>Test</Button.Label>
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
<ControlField
|
||||
isSelected={isAgreed}
|
||||
onSelectedChange={setIsAgreed}
|
||||
className="items-start mt-10"
|
||||
>
|
||||
<ControlField.Indicator>
|
||||
<Checkbox className="w-5 h-5 rounded-sm" />
|
||||
</ControlField.Indicator>
|
||||
<View className="flex-row flex-wrap flex-1 gap-1">
|
||||
<Text className="text-sm text-muted">登录即表示你同意我们的</Text>
|
||||
<LinkButton size="sm">
|
||||
<LinkButton.Label className="text-accent">用户协议</LinkButton.Label>
|
||||
</LinkButton>
|
||||
<Text className="text-sm text-muted">和</Text>
|
||||
<LinkButton size="sm">
|
||||
<LinkButton.Label className="text-accent">隐私政策</LinkButton.Label>
|
||||
</LinkButton>
|
||||
</View>
|
||||
</ControlField>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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("初始化失败,请稍后重试");
|
||||
|
||||
@@ -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<DatePickerMode, DateFormatType> = {
|
||||
datetime: "YYYY-MM-DD HH:mm",
|
||||
};
|
||||
|
||||
const modeViewMap: Record<DatePickerMode, "day" | "month" | "year"> = {
|
||||
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<Date>(toDate(value) ?? new Date());
|
||||
const [open, setOpen] = useState(false);
|
||||
const [tempDate, setTempDate] = useState<dayjs.Dayjs | undefined>(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 (
|
||||
<BottomSheet isOpen={show} onOpenChange={(v) => !v && handleClose()}>
|
||||
<BottomSheet isOpen={open} onOpenChange={setOpen}>
|
||||
<BottomSheet.Portal>
|
||||
<BottomSheet.Overlay />
|
||||
|
||||
<BottomSheet.Content>
|
||||
<View style={{ width: "100%", alignItems: "center" }}>
|
||||
<YzDatePicker
|
||||
showHeader={false}
|
||||
<DateTimePicker
|
||||
mode="single"
|
||||
date={tempDate}
|
||||
mode={mode}
|
||||
isModal={false}
|
||||
onDateChange={(d: Date) => setTempDate(d)}
|
||||
initialView={modeViewMap[mode]}
|
||||
timePicker={mode === "time" || mode === "datetime"}
|
||||
selectedItemColor="#0d9488"
|
||||
onChange={({ date }) => {
|
||||
if (date) setTempDate(dayjs(date));
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button className="w-full" onPress={handleConfirm} isDisabled={loading}>
|
||||
|
||||
@@ -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<void>;
|
||||
}
|
||||
|
||||
export default function DateTimeRange({
|
||||
@@ -21,121 +21,78 @@ export default function DateTimeRange({
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: DateRangePickerProps) {
|
||||
const [tempStart, setTempStart] = useState<Date | undefined>(undefined);
|
||||
const [tempEnd, setTempEnd] = useState<Date | undefined>(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 (
|
||||
<BottomSheet isOpen={show} onOpenChange={(v) => !v && handleClose()}>
|
||||
<BottomSheet isOpen={open} onOpenChange={setOpen}>
|
||||
<BottomSheet.Portal>
|
||||
<BottomSheet.Overlay />
|
||||
|
||||
<BottomSheet.Content>
|
||||
<View className="w-full items-center">
|
||||
{/* 顶部选择区 */}
|
||||
<View className="flex-row items-center justify-between w-full">
|
||||
{/* start */}
|
||||
<TouchableOpacity className="flex-1 items-center" onPress={() => setActive("start")}>
|
||||
<Text>开始时间</Text>
|
||||
<Text
|
||||
className={`text-base ${
|
||||
active === "start" ? "text-accent font-bold" : "text-gray-400"
|
||||
}`}
|
||||
>
|
||||
{formatDate(tempStart)}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<View style={{ width: "100%", alignItems: "center" }}>
|
||||
<DateTimePicker
|
||||
mode="range"
|
||||
startDate={range.startDate}
|
||||
endDate={range.endDate}
|
||||
selectedItemColor="#0d9488"
|
||||
selectedRangeBackgroundColor="rgba(13,148,136,0.15)"
|
||||
onChange={(params) => setRange(params)}
|
||||
/>
|
||||
|
||||
<View className="w-px h-10 bg-gray-300 mx-2" />
|
||||
|
||||
{/* end */}
|
||||
<TouchableOpacity
|
||||
className="flex-1 items-center"
|
||||
style={{ opacity: tempStart ? 1 : 0.4 }}
|
||||
disabled={!tempStart}
|
||||
onPress={() => {
|
||||
if (!tempStart) return;
|
||||
|
||||
// ✅ 核心优化:未选 end → 自动补 start
|
||||
if (!tempEnd) {
|
||||
setTempEnd(tempStart);
|
||||
}
|
||||
|
||||
setActive("end");
|
||||
}}
|
||||
>
|
||||
<Text>结束时间</Text>
|
||||
<Text
|
||||
className={`text-base ${
|
||||
active === "end" ? "text-accent font-bold" : "text-gray-400"
|
||||
}`}
|
||||
>
|
||||
{formatDate(tempEnd)}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Picker */}
|
||||
{active === "start" ? (
|
||||
<YzDatePicker
|
||||
showHeader={false}
|
||||
mode="date"
|
||||
date={tempStart ?? new Date()}
|
||||
onDateChange={handleStartChange}
|
||||
/>
|
||||
) : (
|
||||
<YzDatePicker
|
||||
showHeader={false}
|
||||
mode="date"
|
||||
date={tempEnd ?? tempStart ?? new Date()}
|
||||
minDate={tempStart}
|
||||
onDateChange={handleEndChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* confirm */}
|
||||
<Button className="w-full" onPress={handleConfirm}>
|
||||
<CheckIcon color="#fff" />
|
||||
<Button.Label>确认</Button.Label>
|
||||
<Button
|
||||
className="w-full"
|
||||
onPress={handleConfirm}
|
||||
isDisabled={loading || !range.startDate}
|
||||
>
|
||||
{loading ? (
|
||||
<Spinner entering={FadeIn.delay(50)} color="white" />
|
||||
) : (
|
||||
<>
|
||||
<CheckIcon color="#fff" />
|
||||
<Button.Label>确认</Button.Label>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
</BottomSheet.Content>
|
||||
|
||||
@@ -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<string> {
|
||||
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<FeedRecord[]> {
|
||||
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<number, string>();
|
||||
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) || "成员"),
|
||||
}));
|
||||
}
|
||||
@@ -21,7 +21,7 @@ export default function PageLayout({ title, titleIcon, children, renderTitle }:
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
router.navigate("/(tabs)/home");
|
||||
router.navigate("/home");
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
Reference in new issue
Block a user