20260625173106

This commit is contained in:
oneao committed 2026-06-25 17:31:07 +08:00
commit 2e928710df
178 files changed
+14812 -4314

No files matched your search

+1 -1
View File
@@ -1 +1 @@
EXPO_PUBLIC_API_URL=http://192.168.5.2:8087
EXPO_PUBLIC_API_URL=http://192.168.5.12:8087
@@ -1,304 +0,0 @@
# 项目分析计划
## 一、项目概述
这是一个基于 **React Native (Expo)** 的跨平台移动应用,采用 **React Navigation** 路由 + **expo-router** 文件约定式路由架构。
## 二、技术栈
### 框架与核心库
| 库 | 版本 | 用途 |
| ------------ | --------- | ------- |
| expo | \~55.0.15 | 基础框架 |
| expo-router | \~55.0.12 | 文件约定式路由 |
| react-native | 0.83.4 | 原生视图层 |
| react | 19.2.0 | UI 框架 |
### 状态管理
| 库 | 版本 | 用途 |
| ----------------------------------------- | ------- | ------ |
| zustand | ^5.0.12 | 轻量状态管理 |
| @react-native-async-storage/async-storage | ^2.2.0 | 持久化存储 |
### 请求与数据
| 库 | 版本 | 用途 |
| -------------------- | ------- | --------- |
| alova | ^3.5.1 | 请求库 |
| @alova/adapter-axios | ^2.0.18 | Axios 适配器 |
| axios | ^1.15.0 | HTTP 客户端 |
### UI 组件
| 库 | 版本 | 用途 |
| ----------------------- | ------ | ------- |
| heroui-native | ^1.0.1 | UI 组件库 |
| lucide-react-native | ^1.8.0 | 图标库 |
| react-native-reanimated | 4.2.1 | 动画库 |
| tailwindcss | ^4.2.2 | 原子化 CSS |
## 三、项目架构
### 3.1 目录结构
```
src/
├── app/ # 页面 (expo-router 文件约定式路由)
│ ├── (tabs)/ # 底部 Tab 页面组
│ │ ├── home/ # 首页
│ │ ├── chat/ # 聊天页
│ │ ├── star/ # 收藏页
│ │ ├── my/ # 个人中心
│ │ └── _layout.tsx # Tab 布局
│ ├── auth/ # 认证页面
│ │ └── login.tsx # 登录页
│ ├── family/ # 家庭模块
│ │ ├── entry.tsx # 家庭入口(创建/加入)
│ │ ├── settings.tsx # 家庭设置
│ │ ├── user.tsx # 家庭成员
│ │ └── member/[id].tsx # 成员详情
│ ├── finance/ # 记账模块
│ │ ├── (tabs)/ # 记账子 Tab
│ │ │ ├── home/ # 记账首页
│ │ │ ├── analysis/ # 统计分析
│ │ │ └── _layout.tsx
│ │ ├── add/ # 新增记账
│ │ └── sub/ # 订阅管理
│ ├── tools/ # 工具页面
│ ├── _layout.tsx # 根布局
│ └── index.tsx # 根页面
├── components/ # 组件
│ ├── iconfont/ # IconFont 图标组件
│ └── layout/ # 布局组件 (Navbar, Tabbar)
├── configs/ # 配置
│ └── pages.ts # 子应用配置
├── hooks/ # 自定义 Hooks
│ ├── use-color-scheme.ts
│ └── use-theme-color.ts
├── layouts/ # 布局模板
│ ├── AppLayout.tsx # App 主布局 (带 Navbar + Tabbar)
│ ├── ModuleLayout.tsx # 模块布局 (独立功能模块)
│ └── PageLayout.tsx # 页面布局
├── request/ # 请求相关
│ ├── api.ts # API 接口定义
│ └── index.ts # Alova 实例
├── store/ # Zustand 状态库
│ ├── app.ts # App 全局状态
│ ├── user.ts # 用户状态
│ ├── finance.ts # 记账状态
│ └── family.ts # 家庭状态
├── types/ # TypeScript 类型
│ └── auth.ts # 认证类型
└── utils/ # 工具函数
├── http.ts # HTTP 封装
├── time.ts # 时间处理
└── cn.ts # 样式合并
```
### 3.2 路由架构
```
根布局 (_layout.tsx)
├── 认证流程
│ └── auth/login.tsx (未登录跳转)
├── 主应用 (已登录)
│ ├── (tabs) 底部 Tab 导航
│ │ ├── home (首页)
│ │ ├── space (空间)
│ │ ├── tools (工具中心 - 中心按钮)
│ │ ├── chat (聊天)
│ │ └── my (我的)
│ └── 独立页面
│ ├── family/entry (家庭入口)
│ ├── family/settings (家庭设置)
│ └── finance/(tabs) (记账模块 Tab)
│ ├── home (记账)
│ ├── add (新增 - 中心按钮)
│ └── analysis (分析)
```
### 3.3 布局系统
| 布局组件 | 用途 | 层级 |
| ------------ | ------------------------ | ---- |
| AppLayout | 主应用布局,提供 Navbar + Tabbar | 页面容器 |
| ModuleLayout | 功能模块布局,独立 Tabbar | 模块容器 |
| PageLayout | 页面基础布局,处理安全区域 | 内容容器 |
## 四、API 接口分析
### 4.1 请求封装
使用 **Alova** 请求库,配合 **Axios** 适配器。请求基地址: `EXPO_PUBLIC_API_URL` (开发环境: `https://dev.api.com`)
### 4.2 HTTP 拦截器
**请求拦截器**:
* 白名单接口直接放行 (`/auth/login/**`)
* 非白名单接口检查登录状态
* 未登录拦截并跳转登录页
* 已登录则注入 JWT Token 到 Header
**响应拦截器**:
* `code: 1000` → 成功
* `code: 1001` → Token 过期,跳转登录
* `code: 2000` → 业务错误,显示错误提示
* `code: 3000` → 系统错误,显示错误提示
* 其他 → 请求失败
### 4.3 核心 API
| 接口 | 方法 | 参数 | 用途 |
| --------------------- | ---- | --------------------------------------------------------------- | ------- |
| `/auth/login/qq` | POST | openid, nickname, avatar | QQ 登录 |
| `/auth/login/wechat` | POST | code | 微信登录 |
| `/bind/qq` | POST | openid, nickname, avatar | 绑定 QQ |
| `/bind/wechat` | POST | code | 绑定微信 |
| `/data/getUniqueId` | POST | count | 获取自增 ID |
| `/data/loadData` | POST | view\_name, search\_condition, order\_by, search\_columns, args | 查询数据 |
| `/data/loadDataBySql` | POST | sql, args | SQL 查询 |
| `/data/saveData` | POST | table\_name, key\_field, inserts, updates, deletes | 保存数据 |
| `/s3/upload` | POST | files | 文件上传 |
### 4.4 数据表结构 (推测)
| 表名 | 用途 |
| ----------------------------- | --------- |
| b\_family | 家庭表 |
| b\_family\_member | 家庭成员表 |
| b\_finance\_category | 记账分类表 |
| b\_finance\_category\_default | 默认记账分类 |
| b\_finance\_record | 记账记录 (推测) |
| v\_family\_member | 家庭成员视图 |
## 五、状态管理
### 5.1 Store 概览
| Store | 持久化 | 用途 |
| ------------ | -------------- | --------------- |
| userStore | ✅ AsyncStorage | 用户信息、Token、登录状态 |
| familyStore | ✅ AsyncStorage | 当前家庭 ID |
| financeStore | ❌ 内存 | 记账分类数据 |
| appStore | ❌ 内存 | App 全局状态 |
### 5.2 用户状态 (userStore)
```typescript
interface UserInfo {
id: number;
token: string;
nickname: string;
avatar: string;
}
```
### 5.3 家庭状态 (familyStore)
```typescript
interface Family {
family_id: number;
}
```
## 六、第三方登录
### 6.1 QQ 登录
* 使用 `expo-qq` 库
* 需要配置 App ID: `102826474`
### 6.2 微信登录
* 使用 `expo-wechat` 库
* 需要配置 App ID: `wxdab3e21a1f7e392f`
## 七、核心业务流程
### 7.1 登录流程
```
启动 → 检查登录状态
├── 未登录 → 跳转 /auth/login
│ ├── 微信登录 → 调接口 → 写入 Store → 跳转 /family/entry 或 /(tabs)/home
│ └── QQ 登录 → 调接口 → 写入 Store → 跳转 /family/entry 或 /(tabs)/home
│
└── 已登录 → 跳转 /(tabs)/home
```
### 7.2 家庭流程
```
首次登录 → /family/entry
├── 创建家庭 → 生成邀请码 → 保存家庭 + 成员 → 跳转 /(tabs)/home
└── 加入家庭 → 输入邀请码 → 保存成员 → 跳转 /(tabs)/home
```
### 7.3 记账流程
```
点击 Tab → /finance/home
├── 查看记录 → 列表展示
├── 新增记账 → 点击中心 "+" → /finance/add
│ ├── 选择类别
│ ├── 输入金额
│ ├── 选择日期
│ └── 保存
└── 数据分析 → 点击 "分析" Tab → /finance/analysis
```
## 八、环境配置
| 环境变量 | 开发环境 | 生产环境 |
| ------------------------ | --------------------------- | ----- |
| EXPO\_PUBLIC\_API\_URL | <https://dev.api.com> | (待配置) |
| EXPO\_PUBLIC\_G3\_URL | <http://118.89.70.199:9001> | (待配置) |
| EXPO\_PUBLIC\_G3\_BUCKET | test | (待配置) |
## 九、关键实现细节
### 9.1 路由跳转
* 使用 `expo-router` 的 `useRouter` 和 `router.push/replace`
* 动态路由: `/family/member/[id].tsx`
### 9.2 安全区域
* 使用 `useSafeAreaInsets()` 处理 iOS 刘海屏和 Android 挖孔屏
* Navbar 和 Tabbar 绝对定位,内容区占满屏幕
### 9.3 动画
* 使用 `react-native-reanimated` 实现 Tabbar 点击缩放动画
* `withTiming` 控制动画时长
### 9.4 主题
* 支持浅色/深色模式
* 使用 `useIsDark()` hook 判断当前主题
## 十、后续对话需关注的重点
1. **API 接口**: 所有后端交互通过 `src/request/api.ts` 封装
2. **状态管理**: 核心状态使用 Zustand + AsyncStorage 持久化
3. **路由**: 使用 expo-router 文件约定式路由
4. **UI 组件**: 主要使用 heroui-native 组件库
5. **登录机制**: JWT Token 存储在 userStore,通过 http.ts 拦截器自动注入
-1
View File
@@ -1 +0,0 @@
{ "recommendations": ["expo.vscode-expo-tools"] }
-22
View File
@@ -1,22 +0,0 @@
{
"Print LayoutView Scroll Example (NativeWind)": {
"scope": "typescriptreact,javascriptreact",
"prefix": "rn",
"body": [
"import React from \"react\";",
"import LayoutView from \"@/components/layout/view\";",
"import { Text } from \"react-native\";",
"",
"export default function ${1/(.*)/${1:/pascalcase}/}() {",
" return (",
" <LayoutView>",
" <LayoutView.ScrollView>",
" <Text className=\"text-base text-black\">${2:Hello}</Text>",
" </LayoutView.ScrollView>",
" </LayoutView>",
" );",
"}"
],
"description": "LayoutView + ScrollView 结构组件模板"
}
}
-49
View File
@@ -1,49 +0,0 @@
{
"oxc.fmt.configPath": ".oxfmtrc.json",
"editor.defaultFormatter": "oxc.oxc-vscode",
"editor.formatOnSave": true,
"[javascript]": {
"editor.defaultFormatter": "oxc.oxc-vscode",
"editor.formatOnSave": true
},
"[typescript]": {
"editor.defaultFormatter": "oxc.oxc-vscode",
"editor.formatOnSave": true
},
"[typescriptreact]": {
"editor.defaultFormatter": "oxc.oxc-vscode",
"editor.formatOnSave": true
},
"tailwindCSS.classAttributes": [
"class",
"className",
"headerClassName",
"contentContainerClassName",
"columnWrapperClassName",
"endFillColorClassName",
"imageClassName",
"tintColorClassName",
"ios_backgroundColorClassName",
"thumbColorClassName",
"trackColorOnClassName",
"trackColorOffClassName",
"selectionColorClassName",
"cursorColorClassName",
"underlineColorAndroidClassName",
"placeholderTextColorClassName",
"selectionHandleColorClassName",
"colorsClassName",
"progressBackgroundColorClassName",
"titleColorClassName",
"underlayColorClassName",
"colorClassName",
"drawerBackgroundColorClassName",
"statusBarBackgroundColorClassName",
"backdropColorClassName",
"backgroundColorClassName",
"ListFooterComponentClassName",
"ListHeaderComponentClassName"
],
"tailwindCSS.classFunctions": ["useResolveClassNames"]
}
@@ -0,0 +1,69 @@
## 2026-06-24
- 改进登录页 loading 交互:
- 创建 `src/hooks/useLoading.ts`:基于 sonner-native 的 toast.loading / toast.success / toast.error / toast.dismiss 封装,提供 `startLoading`、`stopLoading`、`withLoading` 和 `isLoading` 状态。
- 创建 `src/components/LoadingOverlay.tsx`:全屏半透明遮罩,`pointerEvents="auto"` 在 loading 时拦截页面所有点击事件。
- 重写 `src/app/(main)/auth/login.tsx`:
- 移除按钮级 Spinner 和 `loading` state。
- 微信/QQ/测试登录全部使用 `useLoading` 的 `startLoading`/`stopLoading`。
- Loading 时页面禁止点击(通过 `LoadingOverlay` 实现)。
- 错误信息统一用 `toast.error` 显示,避免使用按钮级 loading。
- 三轮迭代优化 `src/components/layout/tabbar.tsx` 中心按钮突起效果:
- V1(球面版):64×64 三色强渐变 + 白色底座 + 高光 → 过于突兀复杂。
- V2(极简版):48×48 纯色 #5FAF7B + 浅阴影 → 太扁平,无突起感。
- V3(现代分层版,最终):50×50、top:-6、两色微渐变(#6CC587→#4D9E6A)、1.5px 白色窄边收光、底部投影椭圆、悬浮阴影(elevation:8 / shadowRadius:12 / opacity:0.12)。参考 Dimensional Layering + Soft UI Evolution 风格,有突起感但克制不过度。
- V6(全量重构):用户反馈多次微调后仍不满意,决定从头重构整个 tabbar 组件。
- 设计系统:参考 Apple Bento Grid 风格——20px 圆角白色条、极轻阴影、干净克制。
- 常量驱动:ICON_SIZE=20、LABEL_SIZE=10、BAR_HEIGHT=56、BAR_RADIUS=20、CENTER_SIZE=44、CENTER_LIFT=-5、COLOR_ACTIVE=#10B981、COLOR_IDLE=#9CA3AF。
- 大改动:
- tabbar:圆角 999→20,不要胶囊形,更像 Apple 风格浮动条。shadow 从多层降为单层。
- 普通 TabItem:icon 24→20,label 10px,间距 2px。选中态线宽加粗(strokeWidth 2.2 vs 1.8),视觉权重自然。
- CenterButton:去掉 LinearGradient(纯色更适合工具类),44×44 纯色 #10B981 圆钮 + 同色系阴影(shadowColor=#10B981,opacity 0.35),top:-5 微突起。保留脉冲动画。
- 统一 color 常量在文件顶部,后续调色改一处即可。
- 去掉 expo-linear-gradient 依赖(不再使用渐变)。
- V7(阴影调整失败,V8 重构):
- 尝试用 `react-native-shadow-2` 的 `Shadow` 组件模仿 navbar 弥散阴影,导致 tabbar 渲染/样式完全错误。回退到原生阴影。
- V8(Soft UI Evolution):参考 ui-ux-pro-max 搜索结果的 Soft UI Evolution 风格——极轻阴影、微妙深度。
- 移除 `react-native-shadow-2` 依赖。shadowColor 统一用 `#000`,bar: shadowOpacity 0.06 / elevation 2 / shadowRadius 10;中心按钮外圈白色(58×58)+ 内圈绿色(52×52),外圈投影 shadowOpacity 0.08 / elevation 3。
- CENTER_SIZE 44→52,CENTER_LIFT -5→-8,LABEL_SIZE 10→11,BAR_RADIUS 20→16。
- 中心按钮结构:外圈白色圆(bar 相同材质)+ 内圈绿色圆,产生"从 bar 表面隆起"的自然嵌套感。不再使用绿色色感阴影。
- V9(统一 shadow-2xl):
- bar 阴影:去掉手动 shadow 属性,改用 NativeWind `shadow-2xl`(与 navbar 的 groupShadow/titleShadown 一致)。
- 中心按钮:完全去掉阴影(centerOuter 无任何 shadow/elevation),纯白色外圈 + 绿色内圈,干净利落。
- V10(比例协调优化):
- 问题:52×52 绿色面积太大,22px icon 占比仅 42%,显得空。
- 调整:CENTER_SIZE 52→42(绿色区域缩小 19%),CENTER_ICON 22→24(icon 增大),icon/绿色面积比从 42%→57%。
- 外圈 ring 从 3px→4px 常量化(CENTER_RING=4),外圈整体 50×50。
- CENTER_LIFT -8→-5,不飞太高。
- V11(极简嵌入):
- 方向转变:不再向上突起,中心按钮嵌入 bar 内居中,纯粹靠颜色区分。
- 去掉白色外圈结构,简化为单层 40×40 纯色绿色圆。
- icon 24,icon:button 直径比 60%(24/40),视觉饱满。
- centerSlot 改为 justifyContent: "center",按钮在 56px bar 内垂直居中,和其他 tab 同一基线。
- 保留脉冲动画和弹性按压缩放。
- V12(极简化,最终):
- 用户决定完全放弃中心突起效果。移除整个 `CenterButton` 组件(含 pulse 呼吸动画、弹性回弹、绿色背景、白圈等)。
- 中心 tab 复用普通 `TabItem`,与其他 tab icon/label/尺寸完全一致。
- 通过 `showMarker={tab.isCenter}` 在标签下方渲染一个 4×4 小圆点作为"中间标志",颜色跟随选中态(绿/灰)。
- 清理 `withRepeat`、`Easing` import。移除 `centerSlot`、`centerButton` 样式。
- 最终:用户反复调整中心 tab 的突起/标记/背景效果均不满意,最终全部去掉,五个 tab 完全一致,不再区分 center。`isCenter` 字段保留在类型中但组件不再读取。
- 重构首页 `src/app/(main)/(tabs)/home/index.tsx`:
- 快捷工具从列表布局改为**宫格布局**(3列 grid),icon 在上 + title 在下。
- GridItem: 白色圆角卡片 `rounded-2xl` + `shadow-sm`,icon 区域 44×44 浅绿底(`#10B98118`),title 13px 灰色。
- 外容器白卡包裹所有宫格 cell,统一圆角和间距(gap-2.5, p-3)。
- 统一设计语言:所有区域卡片均用 `rounded-2xl shadow-sm`(16px 圆角 + 统一阴影)。
- SectionTitle 复用为组件:左侧绿色竖条 + uppercase 灰色标题。
- 最近动态也包入白卡,时间轴线条统一 `#E5E7EB`,空状态中心展示。
- 统一颜色常量 `GREEN = "#10B981"`,用于全部 accent 色。
- V13(微背景高亮,最终):
- 用户认为小圆点不协调。
- 去掉小圆点,改为中心 tab 背景包裹层微背景:
- `showMarker` tab 使用 `centerBg` 包裹 icon+label。
- 未选中:`rgba(16, 185, 129, 0.06)`(极淡绿,几乎不可见)。
- 选中:`rgba(16, 185, 129, 0.12)`(微微加深,隐约可见)。
- padding 12×6,borderRadius 12。不突兀,仅靠颜色暗示"这个 tab 是特殊的"。
- 用户明确要求不使用视觉常量(const GREEN 等),全部使用 Tailwind CSS 类。
- GridItem 的 Icon 不设置 color prop,使用默认颜色。
- 头部布局重构:问候语和辅助文字放一行,空间名单行,大数字和"天"放一行,时分秒单独一行,更紧凑不分散。字体缩小(24px 空间名 / 40px 数字)。
- 首页重构:删除时间计时和问候语,仅保留家庭名称居中显示 (28px bold centered)。时间轴点改为 heroui-native Avatar,匹配 activities 页风格。时间轴列宽 10→28px,内容间距 pb-4→pb-6。
@@ -1,114 +0,0 @@
#!/usr/bin/env node
/**
* This script is used to reset the project to a blank state.
* It deletes or moves the /src and /scripts directories to /example based on user input and creates a new /src/app directory with an index.tsx and _layout.tsx file.
* You can remove the `reset-project` script from package.json and safely delete this file after running it.
*/
const fs = require("fs");
const path = require("path");
const readline = require("readline");
const root = process.cwd();
const oldDirs = ["src", "scripts"];
const exampleDir = "example";
const newAppDir = "src/app";
const exampleDirPath = path.join(root, exampleDir);
const indexContent = `import { Text, View, StyleSheet } from "react-native";
export default function Index() {
return (
<View style={styles.container}>
<Text>Edit src/app/index.tsx to edit this screen.</Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: "center",
justifyContent: "center",
},
});
`;
const layoutContent = `import { Stack } from "expo-router";
export default function RootLayout() {
return <Stack />;
}
`;
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const moveDirectories = async (userInput) => {
try {
if (userInput === "y") {
// Create the app-example directory
await fs.promises.mkdir(exampleDirPath, { recursive: true });
console.log(`📁 /${exampleDir} directory created.`);
}
// Move old directories to new app-example directory or delete them
for (const dir of oldDirs) {
const oldDirPath = path.join(root, dir);
if (fs.existsSync(oldDirPath)) {
if (userInput === "y") {
const newDirPath = path.join(root, exampleDir, dir);
await fs.promises.rename(oldDirPath, newDirPath);
console.log(`➡️ /${dir} moved to /${exampleDir}/${dir}.`);
} else {
await fs.promises.rm(oldDirPath, { recursive: true, force: true });
console.log(`❌ /${dir} deleted.`);
}
} else {
console.log(`➡️ /${dir} does not exist, skipping.`);
}
}
// Create new /src/app directory
const newAppDirPath = path.join(root, newAppDir);
await fs.promises.mkdir(newAppDirPath, { recursive: true });
console.log("\n📁 New /src/app directory created.");
// Create index.tsx
const indexPath = path.join(newAppDirPath, "index.tsx");
await fs.promises.writeFile(indexPath, indexContent);
console.log("📄 src/app/index.tsx created.");
// Create _layout.tsx
const layoutPath = path.join(newAppDirPath, "_layout.tsx");
await fs.promises.writeFile(layoutPath, layoutContent);
console.log("📄 src/app/_layout.tsx created.");
console.log("\n✅ Project reset complete. Next steps:");
console.log(
`1. Run \`npx expo start\` to start a development server.\n2. Edit src/app/index.tsx to edit the main screen.\n3. Put all your application code in /src, only screens and layout files should be in /src/app.${
userInput === "y"
? `\n4. Delete the /${exampleDir} directory when you're done referencing it.`
: ""
}`
);
} catch (error) {
console.error(`❌ Error during script execution: ${error.message}`);
}
};
rl.question(
"Do you want to move existing files to /example instead of deleting them? (Y/n): ",
(answer) => {
const userInput = answer.trim().toLowerCase() || "y";
if (userInput === "y" || userInput === "n") {
moveDirectories(userInput).finally(() => rl.close());
} else {
console.log("❌ Invalid input. Please enter 'Y' or 'N'.");
rl.close();
}
}
);
@@ -1,16 +0,0 @@
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
import React from 'react';
import { useColorScheme } from 'react-native';
import { AnimatedSplashOverlay } from '@/components/animated-icon';
import AppTabs from '@/components/app-tabs';
export default function TabLayout() {
const colorScheme = useColorScheme();
return (
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
<AnimatedSplashOverlay />
<AppTabs />
</ThemeProvider>
);
}
-181
View File
@@ -1,181 +0,0 @@
import { Image } from 'expo-image';
import { SymbolView } from 'expo-symbols';
import React from 'react';
import { Platform, Pressable, ScrollView, StyleSheet } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { ExternalLink } from '@/components/external-link';
import { ThemedText } from '@/components/themed-text';
import { ThemedView } from '@/components/themed-view';
import { Collapsible } from '@/components/ui/collapsible';
import { WebBadge } from '@/components/web-badge';
import { BottomTabInset, MaxContentWidth, Spacing } from '@/constants/theme';
import { useTheme } from '@/hooks/use-theme';
export default function TabTwoScreen() {
const safeAreaInsets = useSafeAreaInsets();
const insets = {
...safeAreaInsets,
bottom: safeAreaInsets.bottom + BottomTabInset + Spacing.three,
};
const theme = useTheme();
const contentPlatformStyle = Platform.select({
android: {
paddingTop: insets.top,
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: insets.bottom,
},
web: {
paddingTop: Spacing.six,
paddingBottom: Spacing.four,
},
});
return (
<ScrollView
style={[styles.scrollView, { backgroundColor: theme.background }]}
contentInset={insets}
contentContainerStyle={[styles.contentContainer, contentPlatformStyle]}>
<ThemedView style={styles.container}>
<ThemedView style={styles.titleContainer}>
<ThemedText type="subtitle">Explore</ThemedText>
<ThemedText style={styles.centerText} themeColor="textSecondary">
This starter app includes example{'\n'}code to help you get started.
</ThemedText>
<ExternalLink href="https://docs.expo.dev" asChild>
<Pressable style={({ pressed }) => pressed && styles.pressed}>
<ThemedView type="backgroundElement" style={styles.linkButton}>
<ThemedText type="link">Expo documentation</ThemedText>
<SymbolView
tintColor={theme.text}
name={{ ios: 'arrow.up.right.square', android: 'link', web: 'link' }}
size={12}
/>
</ThemedView>
</Pressable>
</ExternalLink>
</ThemedView>
<ThemedView style={styles.sectionsWrapper}>
<Collapsible title="File-based routing">
<ThemedText type="small">
This app has two screens: <ThemedText type="code">src/app/index.tsx</ThemedText> and{' '}
<ThemedText type="code">src/app/explore.tsx</ThemedText>
</ThemedText>
<ThemedText type="small">
The layout file in <ThemedText type="code">src/app/_layout.tsx</ThemedText> sets up
the tab navigator.
</ThemedText>
<ExternalLink href="https://docs.expo.dev/router/introduction">
<ThemedText type="linkPrimary">Learn more</ThemedText>
</ExternalLink>
</Collapsible>
<Collapsible title="Android, iOS, and web support">
<ThemedView type="backgroundElement" style={styles.collapsibleContent}>
<ThemedText type="small">
You can open this project on Android, iOS, and the web. To open the web version,
press <ThemedText type="smallBold">w</ThemedText> in the terminal running this
project.
</ThemedText>
<Image
source={require('@/assets/images/tutorial-web.png')}
style={styles.imageTutorial}
/>
</ThemedView>
</Collapsible>
<Collapsible title="Images">
<ThemedText type="small">
For static images, you can use the <ThemedText type="code">@2x</ThemedText> and{' '}
<ThemedText type="code">@3x</ThemedText> suffixes to provide files for different
screen densities.
</ThemedText>
<Image source={require('@/assets/images/react-logo.png')} style={styles.imageReact} />
<ExternalLink href="https://reactnative.dev/docs/images">
<ThemedText type="linkPrimary">Learn more</ThemedText>
</ExternalLink>
</Collapsible>
<Collapsible title="Light and dark mode components">
<ThemedText type="small">
This template has light and dark mode support. The{' '}
<ThemedText type="code">useColorScheme()</ThemedText> hook lets you inspect what the
user&apos;s current color scheme is, and so you can adjust UI colors accordingly.
</ThemedText>
<ExternalLink href="https://docs.expo.dev/develop/user-interface/color-themes/">
<ThemedText type="linkPrimary">Learn more</ThemedText>
</ExternalLink>
</Collapsible>
<Collapsible title="Animations">
<ThemedText type="small">
This template includes an example of an animated component. The{' '}
<ThemedText type="code">src/components/ui/collapsible.tsx</ThemedText> component uses
the powerful <ThemedText type="code">react-native-reanimated</ThemedText> library to
animate opening this hint.
</ThemedText>
</Collapsible>
</ThemedView>
{Platform.OS === 'web' && <WebBadge />}
</ThemedView>
</ScrollView>
);
}
const styles = StyleSheet.create({
scrollView: {
flex: 1,
},
contentContainer: {
flexDirection: 'row',
justifyContent: 'center',
},
container: {
maxWidth: MaxContentWidth,
flexGrow: 1,
},
titleContainer: {
gap: Spacing.three,
alignItems: 'center',
paddingHorizontal: Spacing.four,
paddingVertical: Spacing.six,
},
centerText: {
textAlign: 'center',
},
pressed: {
opacity: 0.7,
},
linkButton: {
flexDirection: 'row',
paddingHorizontal: Spacing.four,
paddingVertical: Spacing.two,
borderRadius: Spacing.five,
justifyContent: 'center',
gap: Spacing.one,
alignItems: 'center',
},
sectionsWrapper: {
gap: Spacing.five,
paddingHorizontal: Spacing.four,
paddingTop: Spacing.three,
},
collapsibleContent: {
alignItems: 'center',
},
imageTutorial: {
width: '100%',
aspectRatio: 296 / 171,
borderRadius: Spacing.three,
marginTop: Spacing.two,
},
imageReact: {
width: 100,
height: 100,
alignSelf: 'center',
},
});
-98
View File
@@ -1,98 +0,0 @@
import * as Device from 'expo-device';
import { Platform, StyleSheet } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { AnimatedIcon } from '@/components/animated-icon';
import { HintRow } from '@/components/hint-row';
import { ThemedText } from '@/components/themed-text';
import { ThemedView } from '@/components/themed-view';
import { WebBadge } from '@/components/web-badge';
import { BottomTabInset, MaxContentWidth, Spacing } from '@/constants/theme';
function getDevMenuHint() {
if (Platform.OS === 'web') {
return <ThemedText type="small">use browser devtools</ThemedText>;
}
if (Device.isDevice) {
return (
<ThemedText type="small">
shake device or press <ThemedText type="code">m</ThemedText> in terminal
</ThemedText>
);
}
const shortcut = Platform.OS === 'android' ? 'cmd+m (or ctrl+m)' : 'cmd+d';
return (
<ThemedText type="small">
press <ThemedText type="code">{shortcut}</ThemedText>
</ThemedText>
);
}
export default function HomeScreen() {
return (
<ThemedView style={styles.container}>
<SafeAreaView style={styles.safeArea}>
<ThemedView style={styles.heroSection}>
<AnimatedIcon />
<ThemedText type="title" style={styles.title}>
Welcome to&nbsp;Expo
</ThemedText>
</ThemedView>
<ThemedText type="code" style={styles.code}>
get started
</ThemedText>
<ThemedView type="backgroundElement" style={styles.stepContainer}>
<HintRow
title="Try editing"
hint={<ThemedText type="code">src/app/index.tsx</ThemedText>}
/>
<HintRow title="Dev tools" hint={getDevMenuHint()} />
<HintRow
title="Fresh start"
hint={<ThemedText type="code">npm run reset-project</ThemedText>}
/>
</ThemedView>
{Platform.OS === 'web' && <WebBadge />}
</SafeAreaView>
</ThemedView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
flexDirection: 'row',
},
safeArea: {
flex: 1,
paddingHorizontal: Spacing.four,
alignItems: 'center',
gap: Spacing.three,
paddingBottom: BottomTabInset + Spacing.three,
maxWidth: MaxContentWidth,
},
heroSection: {
alignItems: 'center',
justifyContent: 'center',
flex: 1,
paddingHorizontal: Spacing.four,
gap: Spacing.four,
},
title: {
textAlign: 'center',
},
code: {
textTransform: 'uppercase',
},
stepContainer: {
gap: Spacing.three,
alignSelf: 'stretch',
paddingHorizontal: Spacing.three,
paddingVertical: Spacing.four,
borderRadius: Spacing.four,
},
});
@@ -1,6 +0,0 @@
.expoLogoBackground {
background-image: linear-gradient(180deg, #3c9ffe, #0274df);
border-radius: 40px;
width: 128px;
height: 128px;
}
@@ -1,132 +0,0 @@
import { Image } from 'expo-image';
import { useState } from 'react';
import { Dimensions, StyleSheet, View } from 'react-native';
import Animated, { Easing, Keyframe } from 'react-native-reanimated';
import { scheduleOnRN } from 'react-native-worklets';
const INITIAL_SCALE_FACTOR = Dimensions.get('screen').height / 90;
const DURATION = 600;
export function AnimatedSplashOverlay() {
const [visible, setVisible] = useState(true);
if (!visible) return null;
const splashKeyframe = new Keyframe({
0: {
transform: [{ scale: INITIAL_SCALE_FACTOR }],
opacity: 1,
},
20: {
opacity: 1,
},
70: {
opacity: 0,
easing: Easing.elastic(0.7),
},
100: {
opacity: 0,
transform: [{ scale: 1 }],
easing: Easing.elastic(0.7),
},
});
return (
<Animated.View
entering={splashKeyframe.duration(DURATION).withCallback((finished) => {
'worklet';
if (finished) {
scheduleOnRN(setVisible, false);
}
})}
style={styles.backgroundSolidColor}
/>
);
}
const keyframe = new Keyframe({
0: {
transform: [{ scale: INITIAL_SCALE_FACTOR }],
},
100: {
transform: [{ scale: 1 }],
easing: Easing.elastic(0.7),
},
});
const logoKeyframe = new Keyframe({
0: {
transform: [{ scale: 1.3 }],
opacity: 0,
},
40: {
transform: [{ scale: 1.3 }],
opacity: 0,
easing: Easing.elastic(0.7),
},
100: {
opacity: 1,
transform: [{ scale: 1 }],
easing: Easing.elastic(0.7),
},
});
const glowKeyframe = new Keyframe({
0: {
transform: [{ rotateZ: '0deg' }],
},
100: {
transform: [{ rotateZ: '7200deg' }],
},
});
export function AnimatedIcon() {
return (
<View style={styles.iconContainer}>
<Animated.View entering={glowKeyframe.duration(60 * 1000 * 4)} style={styles.glow}>
<Image style={styles.glow} source={require('@/assets/images/logo-glow.png')} />
</Animated.View>
<Animated.View entering={keyframe.duration(DURATION)} style={styles.background} />
<Animated.View style={styles.imageContainer} entering={logoKeyframe.duration(DURATION)}>
<Image style={styles.image} source={require('@/assets/images/expo-logo.png')} />
</Animated.View>
</View>
);
}
const styles = StyleSheet.create({
imageContainer: {
justifyContent: 'center',
alignItems: 'center',
},
glow: {
width: 201,
height: 201,
position: 'absolute',
},
iconContainer: {
justifyContent: 'center',
alignItems: 'center',
width: 128,
height: 128,
zIndex: 100,
},
image: {
position: 'absolute',
width: 76,
height: 71,
},
background: {
borderRadius: 40,
experimental_backgroundImage: `linear-gradient(180deg, #3C9FFE, #0274DF)`,
width: 128,
height: 128,
position: 'absolute',
},
backgroundSolidColor: {
...StyleSheet.absoluteFillObject,
backgroundColor: '#208AEF',
zIndex: 1000,
},
});
@@ -1,108 +0,0 @@
import { Image } from 'expo-image';
import { StyleSheet, View } from 'react-native';
import Animated, { Keyframe, Easing } from 'react-native-reanimated';
import classes from './animated-icon.module.css';
const DURATION = 300;
export function AnimatedSplashOverlay() {
return null;
}
const keyframe = new Keyframe({
0: {
transform: [{ scale: 0 }],
},
60: {
transform: [{ scale: 1.2 }],
easing: Easing.elastic(1.2),
},
100: {
transform: [{ scale: 1 }],
easing: Easing.elastic(1.2),
},
});
const logoKeyframe = new Keyframe({
0: {
opacity: 0,
},
60: {
transform: [{ scale: 1.2 }],
opacity: 0,
easing: Easing.elastic(1.2),
},
100: {
transform: [{ scale: 1 }],
opacity: 1,
easing: Easing.elastic(1.2),
},
});
const glowKeyframe = new Keyframe({
0: {
transform: [{ rotateZ: '-180deg' }, { scale: 0.8 }],
opacity: 0,
},
[DURATION / 1000]: {
transform: [{ rotateZ: '0deg' }, { scale: 1 }],
opacity: 1,
easing: Easing.elastic(0.7),
},
100: {
transform: [{ rotateZ: '7200deg' }],
},
});
export function AnimatedIcon() {
return (
<View style={styles.iconContainer}>
<Animated.View entering={glowKeyframe.duration(60 * 1000 * 4)} style={styles.glow}>
<Image style={styles.glow} source={require('@/assets/images/logo-glow.png')} />
</Animated.View>
<Animated.View style={styles.background} entering={keyframe.duration(DURATION)}>
<div className={classes.expoLogoBackground} />
</Animated.View>
<Animated.View style={styles.imageContainer} entering={logoKeyframe.duration(DURATION)}>
<Image style={styles.image} source={require('@/assets/images/expo-logo.png')} />
</Animated.View>
</View>
);
}
const styles = StyleSheet.create({
container: {
alignItems: 'center',
width: '100%',
zIndex: 1000,
position: 'absolute',
top: 128 / 2 + 138,
},
imageContainer: {
justifyContent: 'center',
alignItems: 'center',
},
glow: {
width: 201,
height: 201,
position: 'absolute',
},
iconContainer: {
justifyContent: 'center',
alignItems: 'center',
width: 128,
height: 128,
},
image: {
position: 'absolute',
width: 76,
height: 71,
},
background: {
width: 128,
height: 128,
position: 'absolute',
},
});
@@ -1,33 +0,0 @@
import { NativeTabs } from 'expo-router/unstable-native-tabs';
import React from 'react';
import { useColorScheme } from 'react-native';
import { Colors } from '@/constants/theme';
export default function AppTabs() {
const scheme = useColorScheme();
const colors = Colors[scheme === 'unspecified' ? 'light' : scheme];
return (
<NativeTabs
backgroundColor={colors.background}
indicatorColor={colors.backgroundElement}
labelStyle={{ selected: { color: colors.text } }}>
<NativeTabs.Trigger name="index">
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon
src={require('@/assets/images/tabIcons/home.png')}
renderingMode="template"
/>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="explore">
<NativeTabs.Trigger.Label>Explore</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon
src={require('@/assets/images/tabIcons/explore.png')}
renderingMode="template"
/>
</NativeTabs.Trigger>
</NativeTabs>
);
}
@@ -1,116 +0,0 @@
import {
Tabs,
TabList,
TabTrigger,
TabSlot,
TabTriggerSlotProps,
TabListProps,
} from 'expo-router/ui';
import { SymbolView } from 'expo-symbols';
import React from 'react';
import { Pressable, useColorScheme, View, StyleSheet } from 'react-native';
import { ExternalLink } from './external-link';
import { ThemedText } from './themed-text';
import { ThemedView } from './themed-view';
import { Colors, MaxContentWidth, Spacing } from '@/constants/theme';
export default function AppTabs() {
return (
<Tabs>
<TabSlot style={{ height: '100%' }} />
<TabList asChild>
<CustomTabList>
<TabTrigger name="home" href="/" asChild>
<TabButton>Home</TabButton>
</TabTrigger>
<TabTrigger name="explore" href="/explore" asChild>
<TabButton>Explore</TabButton>
</TabTrigger>
</CustomTabList>
</TabList>
</Tabs>
);
}
export function TabButton({ children, isFocused, ...props }: TabTriggerSlotProps) {
return (
<Pressable {...props} style={({ pressed }) => pressed && styles.pressed}>
<ThemedView
type={isFocused ? 'backgroundSelected' : 'backgroundElement'}
style={styles.tabButtonView}>
<ThemedText type="small" themeColor={isFocused ? 'text' : 'textSecondary'}>
{children}
</ThemedText>
</ThemedView>
</Pressable>
);
}
export function CustomTabList(props: TabListProps) {
const scheme = useColorScheme();
const colors = Colors[scheme === 'unspecified' ? 'light' : scheme];
return (
<View {...props} style={styles.tabListContainer}>
<ThemedView type="backgroundElement" style={styles.innerContainer}>
<ThemedText type="smallBold" style={styles.brandText}>
Expo Starter
</ThemedText>
{props.children}
<ExternalLink href="https://docs.expo.dev" asChild>
<Pressable style={styles.externalPressable}>
<ThemedText type="link">Docs</ThemedText>
<SymbolView
tintColor={colors.text}
name={{ ios: 'arrow.up.right.square', web: 'link' }}
size={12}
/>
</Pressable>
</ExternalLink>
</ThemedView>
</View>
);
}
const styles = StyleSheet.create({
tabListContainer: {
position: 'absolute',
width: '100%',
padding: Spacing.three,
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'row',
},
innerContainer: {
paddingVertical: Spacing.two,
paddingHorizontal: Spacing.five,
borderRadius: Spacing.five,
flexDirection: 'row',
alignItems: 'center',
flexGrow: 1,
gap: Spacing.two,
maxWidth: MaxContentWidth,
},
brandText: {
marginRight: 'auto',
},
pressed: {
opacity: 0.7,
},
tabButtonView: {
paddingVertical: Spacing.one,
paddingHorizontal: Spacing.three,
borderRadius: Spacing.three,
},
externalPressable: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
gap: Spacing.one,
marginLeft: Spacing.three,
},
});
@@ -1,25 +0,0 @@
import { Href, Link } from 'expo-router';
import { openBrowserAsync, WebBrowserPresentationStyle } from 'expo-web-browser';
import { type ComponentProps } from 'react';
type Props = Omit<ComponentProps<typeof Link>, 'href'> & { href: Href & string };
export function ExternalLink({ href, ...rest }: Props) {
return (
<Link
target="_blank"
{...rest}
href={href}
onPress={async (event) => {
if (process.env.EXPO_OS !== 'web') {
// Prevent the default behavior of linking to the default browser on native.
event.preventDefault();
// Open the link in an in-app browser.
await openBrowserAsync(href, {
presentationStyle: WebBrowserPresentationStyle.AUTOMATIC,
});
}
}}
/>
);
}
@@ -1,35 +0,0 @@
import React, { type ReactNode } from 'react';
import { View, StyleSheet } from 'react-native';
import { ThemedText } from './themed-text';
import { ThemedView } from './themed-view';
import { Spacing } from '@/constants/theme';
type HintRowProps = {
title?: string;
hint?: ReactNode;
};
export function HintRow({ title = 'Try editing', hint = 'app/index.tsx' }: HintRowProps) {
return (
<View style={styles.stepRow}>
<ThemedText type="small">{title}</ThemedText>
<ThemedView type="backgroundSelected" style={styles.codeSnippet}>
<ThemedText themeColor="textSecondary">{hint}</ThemedText>
</ThemedView>
</View>
);
}
const styles = StyleSheet.create({
stepRow: {
flexDirection: 'row',
justifyContent: 'space-between',
},
codeSnippet: {
borderRadius: Spacing.two,
paddingVertical: Spacing.half,
paddingHorizontal: Spacing.two,
},
});
@@ -1,73 +0,0 @@
import { Platform, StyleSheet, Text, type TextProps } from 'react-native';
import { Fonts, ThemeColor } from '@/constants/theme';
import { useTheme } from '@/hooks/use-theme';
export type ThemedTextProps = TextProps & {
type?: 'default' | 'title' | 'small' | 'smallBold' | 'subtitle' | 'link' | 'linkPrimary' | 'code';
themeColor?: ThemeColor;
};
export function ThemedText({ style, type = 'default', themeColor, ...rest }: ThemedTextProps) {
const theme = useTheme();
return (
<Text
style={[
{ color: theme[themeColor ?? 'text'] },
type === 'default' && styles.default,
type === 'title' && styles.title,
type === 'small' && styles.small,
type === 'smallBold' && styles.smallBold,
type === 'subtitle' && styles.subtitle,
type === 'link' && styles.link,
type === 'linkPrimary' && styles.linkPrimary,
type === 'code' && styles.code,
style,
]}
{...rest}
/>
);
}
const styles = StyleSheet.create({
small: {
fontSize: 14,
lineHeight: 20,
fontWeight: 500,
},
smallBold: {
fontSize: 14,
lineHeight: 20,
fontWeight: 700,
},
default: {
fontSize: 16,
lineHeight: 24,
fontWeight: 500,
},
title: {
fontSize: 48,
fontWeight: 600,
lineHeight: 52,
},
subtitle: {
fontSize: 32,
lineHeight: 44,
fontWeight: 600,
},
link: {
lineHeight: 30,
fontSize: 14,
},
linkPrimary: {
lineHeight: 30,
fontSize: 14,
color: '#3c87f7',
},
code: {
fontFamily: Fonts.mono,
fontWeight: Platform.select({ android: 700 }) ?? 500,
fontSize: 12,
},
});
@@ -1,16 +0,0 @@
import { View, type ViewProps } from "react-native";
import { ThemeColor } from "@/constants/theme";
import { useTheme } from "@/hooks/use-theme";
export type ThemedViewProps = ViewProps & {
lightColor?: string;
darkColor?: string;
type?: ThemeColor;
};
export function ThemedView({ style, lightColor, darkColor, type, ...otherProps }: ThemedViewProps) {
const theme = useTheme();
return <View style={[{ backgroundColor: theme[type ?? "background"] }, style]} {...otherProps} />;
}
@@ -1,65 +0,0 @@
import { SymbolView } from 'expo-symbols';
import { PropsWithChildren, useState } from 'react';
import { Pressable, StyleSheet } from 'react-native';
import Animated, { FadeIn } from 'react-native-reanimated';
import { ThemedText } from '@/components/themed-text';
import { ThemedView } from '@/components/themed-view';
import { Spacing } from '@/constants/theme';
import { useTheme } from '@/hooks/use-theme';
export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
const [isOpen, setIsOpen] = useState(false);
const theme = useTheme();
return (
<ThemedView>
<Pressable
style={({ pressed }) => [styles.heading, pressed && styles.pressedHeading]}
onPress={() => setIsOpen((value) => !value)}>
<ThemedView type="backgroundElement" style={styles.button}>
<SymbolView
name={{ ios: 'chevron.right', android: 'chevron_right', web: 'chevron_right' }}
size={14}
weight="bold"
tintColor={theme.text}
style={{ transform: [{ rotate: isOpen ? '-90deg' : '90deg' }] }}
/>
</ThemedView>
<ThemedText type="small">{title}</ThemedText>
</Pressable>
{isOpen && (
<Animated.View entering={FadeIn.duration(200)}>
<ThemedView type="backgroundElement" style={styles.content}>
{children}
</ThemedView>
</Animated.View>
)}
</ThemedView>
);
}
const styles = StyleSheet.create({
heading: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.two,
},
pressedHeading: {
opacity: 0.7,
},
button: {
width: Spacing.four,
height: Spacing.four,
borderRadius: 12,
justifyContent: 'center',
alignItems: 'center',
},
content: {
marginTop: Spacing.three,
borderRadius: Spacing.three,
marginLeft: Spacing.four,
padding: Spacing.four,
},
});
@@ -1,44 +0,0 @@
import { version } from 'expo/package.json';
import { Image } from 'expo-image';
import React from 'react';
import { useColorScheme, StyleSheet } from 'react-native';
import { ThemedText } from './themed-text';
import { ThemedView } from './themed-view';
import { Spacing } from '@/constants/theme';
export function WebBadge() {
const scheme = useColorScheme();
return (
<ThemedView style={styles.container}>
<ThemedText type="code" themeColor="textSecondary" style={styles.versionText}>
v{version}
</ThemedText>
<Image
source={
scheme === 'dark'
? require('@/assets/images/expo-badge-white.png')
: require('@/assets/images/expo-badge.png')
}
style={styles.badgeImage}
/>
</ThemedView>
);
}
const styles = StyleSheet.create({
container: {
padding: Spacing.five,
alignItems: 'center',
gap: Spacing.two,
},
versionText: {
textAlign: 'center',
},
badgeImage: {
width: 123,
aspectRatio: 123 / 24,
},
});
@@ -1,65 +0,0 @@
/**
* Below are the colors that are used in the app. The colors are defined in the light and dark mode.
* There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
*/
import '@/global.css';
import { Platform } from 'react-native';
export const Colors = {
light: {
text: '#000000',
background: '#ffffff',
backgroundElement: '#F0F0F3',
backgroundSelected: '#E0E1E6',
textSecondary: '#60646C',
},
dark: {
text: '#ffffff',
background: '#000000',
backgroundElement: '#212225',
backgroundSelected: '#2E3135',
textSecondary: '#B0B4BA',
},
} as const;
export type ThemeColor = keyof typeof Colors.light & keyof typeof Colors.dark;
export const Fonts = Platform.select({
ios: {
/** iOS `UIFontDescriptorSystemDesignDefault` */
sans: 'system-ui',
/** iOS `UIFontDescriptorSystemDesignSerif` */
serif: 'ui-serif',
/** iOS `UIFontDescriptorSystemDesignRounded` */
rounded: 'ui-rounded',
/** iOS `UIFontDescriptorSystemDesignMonospaced` */
mono: 'ui-monospace',
},
default: {
sans: 'normal',
serif: 'serif',
rounded: 'normal',
mono: 'monospace',
},
web: {
sans: 'var(--font-display)',
serif: 'var(--font-serif)',
rounded: 'var(--font-rounded)',
mono: 'var(--font-mono)',
},
});
export const Spacing = {
half: 2,
one: 4,
two: 8,
three: 16,
four: 24,
five: 32,
six: 64,
} as const;
export const BottomTabInset = Platform.select({ ios: 50, android: 80 }) ?? 0;
export const MaxContentWidth = 800;
-9
View File
@@ -1,9 +0,0 @@
:root {
--font-display:
Spline Sans, Inter, ui-sans-serif, system-ui, sans-serif, Apple Color Emoji, Segoe UI Emoji,
Segoe UI Symbol, Noto Color Emoji;
--font-mono:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace;
--font-rounded: 'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Meiryo, 'MS PGothic', sans-serif;
--font-serif: Georgia, 'Times New Roman', serif;
}
@@ -1 +0,0 @@
export { useColorScheme } from 'react-native';
@@ -1,21 +0,0 @@
import { useEffect, useState } from 'react';
import { useColorScheme as useRNColorScheme } from 'react-native';
/**
* To support static rendering, this value needs to be re-calculated on the client side for web
*/
export function useColorScheme() {
const [hasHydrated, setHasHydrated] = useState(false);
useEffect(() => {
setHasHydrated(true);
}, []);
const colorScheme = useRNColorScheme();
if (hasHydrated) {
return colorScheme;
}
return 'light';
}
@@ -1,14 +0,0 @@
/**
* Learn more about light and dark modes:
* https://docs.expo.dev/guides/color-schemes/
*/
import { Colors } from '@/constants/theme';
import { useColorScheme } from '@/hooks/use-color-scheme';
export function useTheme() {
const scheme = useColorScheme();
const theme = scheme === 'unspecified' ? 'light' : scheme;
return Colors[theme];
}
+1358 -1456
View File
File diff suppressed because it is too large. Load diff
-1
View File
@@ -1 +0,0 @@
nodeLinker: hoisted
@@ -26,7 +26,7 @@ const tabs: TabbarItem[] = [
},
{
id: "/tools",
title: "",
title: "工具",
icon: LayoutGridIcon,
isCenter: true,
},
@@ -15,6 +15,7 @@ import {
PressableFeedback,
} from "heroui-native";
import { SendHorizonalIcon, SearchIcon } from "lucide-react-native";
import { ChatMsgType } from "@/types";
function formatTime(ts: string) {
if (!ts) return "";
@@ -59,6 +60,15 @@ function ChatMessageItem({
);
}
// 系统消息
if (item.msg_type === ChatMsgType.System) {
return (
<View className="items-center py-2">
<Text className="text-xs text-gray-400">{item.content}</Text>
</View>
);
}
return (
<>
<PressableFeedback
@@ -73,7 +83,7 @@ function ChatMessageItem({
)}
{/* 文本 */}
{item.msg_type === 0 && (
{item.msg_type === ChatMsgType.Text && (
<View
className={cn(
"max-w-[75%] rounded-2xl px-3 py-2",
@@ -87,7 +97,7 @@ function ChatMessageItem({
)}
{/* 图片 */}
{item.msg_type === 1 && item.attachments && item.attachments.length > 0 && (
{item.msg_type === ChatMsgType.Image && item.attachments && item.attachments.length > 0 && (
<View className="flex-row flex-wrap gap-1 max-w-[75%] justify-end">
{item.attachments.map((att, i) => (
<Image
@@ -101,7 +111,7 @@ function ChatMessageItem({
)}
{/* 视频 */}
{item.msg_type === 2 && item.attachments && item.attachments.length > 0 && (
{item.msg_type === ChatMsgType.Video && item.attachments && item.attachments.length > 0 && (
<View className="w-48 h-32 rounded-xl bg-gray-800 items-center justify-center">
<Text className="text-white text-2xl">▶</Text>
{item.attachments[0].duration ? (
@@ -113,7 +123,7 @@ function ChatMessageItem({
)}
{/* 文件 */}
{item.msg_type === 3 && item.attachments && item.attachments.length > 0 && (
{item.msg_type === ChatMsgType.File && item.attachments && item.attachments.length > 0 && (
<View
className={cn(
"max-w-[75%] rounded-xl px-3 py-2 flex-row items-center gap-2",
@@ -135,7 +145,7 @@ function ChatMessageItem({
)}
{/* 转发 */}
{item.msg_type === 4 && item.source_data && (
{item.msg_type === ChatMsgType.Forward && item.source_data && (
<View className="max-w-[75%] rounded-xl overflow-hidden bg-white border border-gray-200">
<View className="px-3 py-2">
<Text className="text-xs text-gray-400">来自 {item.source_table}</Text>
@@ -293,7 +303,7 @@ export default function ChatPage() {
});
if (res.isSuccess && res.data) {
const rows = res.data.rows as ChatMessage[];
const mediaIds = rows.filter((r) => r.msg_type >= 1 && r.msg_type <= 3).map((r) => r.id);
const mediaIds = rows.filter((r) => r.msg_type >= ChatMsgType.Image && r.msg_type <= ChatMsgType.File).map((r) => r.id);
if (mediaIds.length > 0) {
const fileRes = await loadDataApi({
view_name: "b_file",
@@ -1,71 +1,72 @@
import { useEffect, useState, useCallback } from "react";
import LayoutView from "@/components/layout/view";
import { loadDataApi } from "@/request/api";
import { useEffect, useState } from "react";
import { View, Text } from "react-native";
import { useFamilyStore, useFavoriteStore } from "@/store";
import {
ChevronRight,
Grid3X3,
User,
Receipt,
Heart,
Clock3,
} from "lucide-react-native";
import { PressableFeedback } from "heroui-native";
import { useFamilyStore, useFavoriteStore } from "@/store";
import { loadDataApi } from "@/request/api";
import { SUB_APPS } from "@/configs/pages";
import { useRouter } from "expo-router";
import { loadFeeds, type FeedRecord } from "@/helpers/feed";
import { ChevronRight, Grid3X3 } from "lucide-react-native";
import LayoutView from "@/components/layout/view";
/* ─── 问候 ─── */
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 "晚上好";
// ── 组件:Section 标签 ────────────────────────────
function SectionLabel({
title,
more,
onMore,
}: {
title: string;
more?: string;
onMore?: () => void;
}) {
return (
<View className="flex-row items-center justify-between px-1 mb-3">
<Text className="text-[13px] font-semibold text-gray-800">
{title}
</Text>
{more ? (
<PressableFeedback onPress={onMore} className="flex-row items-center">
<Text className="text-[12px] text-gray-400">{more}</Text>
<ChevronRight size={14} color="#9CA3AF" />
<PressableFeedback.Ripple />
</PressableFeedback>
) : null}
</View>
);
}
/* ─── 类型 → 图标 ─── */
const TYPE_ICONS: Record<string, React.ComponentType<any>> = {
family: User,
finance: Receipt,
favorite: 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",
});
// ── 组件:宫格项 ──────────────────────────────────
function GridItem({
icon: Icon,
title,
onPress,
}: {
icon: any;
title: string;
onPress: () => void;
}) {
return (
<PressableFeedback
onPress={onPress}
className="flex-1 items-center py-3"
>
<Icon size={28} />
<Text className="text-[12px] text-gray-600 mt-2">
{title}
</Text>
<PressableFeedback.Ripple />
</PressableFeedback>
);
}
// ═══════════════════════════════════════════════════
// 🏠 首页
// ═══════════════════════════════════════════════════
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({
@@ -78,186 +79,54 @@ export default function Home() {
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}
>
<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">
<View className="pt-8 pb-6 items-center">
<Text className="text-[34px] font-bold text-gray-800 text-center">
{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>
<SectionLabel title="快捷工具" />
{SUB_APPS.map((app) => {
const Icon = app.icon;
return (
<PressableFeedback
<View className="flex-row">
{SUB_APPS.map((app) => (
<GridItem
key={app.id}
icon={app.icon}
title={app.title}
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>
/>
))}
<GridItem
icon={Grid3X3}
title="更多工具"
onPress={() => router.navigate("/tools")}
/>
{/* 填充空位保证对齐 */}
{Array.from({
length: Math.max(0, 2 - SUB_APPS.length),
}).map((_, i) => (
<View key={`ph-${i}`} className="flex-1" />
))}
</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" />
<View className="h-8" />
</LayoutView.ScrollView>
</LayoutView>
);
@@ -1,305 +0,0 @@
import { useEffect, useState, useCallback, useRef } from "react";
import LayoutView from "@/components/layout/view";
import { View, Text, FlatList, ScrollView } from "react-native";
import { Stack, useRouter } from "expo-router";
import { CloseButton, Button, PressableFeedback } from "heroui-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useFamilyStore } from "@/store";
import { loadFeedsPage, type FeedRecord } from "@/helpers/feed";
import EmptyState from "@/components/EmptyStatus";
import DateTimeRange from "@/components/DateTimeRange";
import { User, Receipt, Heart, ArrowUp, Calendar } from "lucide-react-native";
/* ─── 时间格式化 ─── */
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 insets = useSafeAreaInsets();
const familyStore = useFamilyStore();
const flatListRef = useRef<FlatList<FeedRecord>>(null);
const [feeds, setFeeds] = useState<FeedRecord[]>([]);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [typeFilter, setTypeFilter] = useState("");
const [dateFrom, setDateFrom] = useState("");
const [dateTo, setDateTo] = useState("");
const [showDatePicker, setShowDatePicker] = useState(false);
const [showScrollTop, setShowScrollTop] = useState(false);
const hasMore = feeds.length < total;
/* ─── 数据加载 ─── */
const fetchFeeds = useCallback(
async (pageNum: number, isLoadMore = false) => {
if (isLoadMore) {
setLoadingMore(true);
} else {
setLoading(true);
}
try {
const data = await loadFeedsPage(
familyStore.currentFamily.family_id,
pageNum,
20,
typeFilter || undefined,
dateFrom || undefined,
dateTo || undefined,
);
if (isLoadMore) {
setFeeds((prev) => [...prev, ...data.rows]);
} else {
setFeeds(data.rows);
}
setTotal(data.total);
setPage(pageNum);
} catch (e) {
console.error(e);
} finally {
setLoading(false);
setLoadingMore(false);
}
},
[familyStore.currentFamily.family_id, typeFilter, dateFrom, dateTo],
);
/* ─── 首次加载 & 类别/日期变更时重新加载 ─── */
const isInitialMount = useRef(true);
useEffect(() => {
if (isInitialMount.current) {
isInitialMount.current = false;
fetchFeeds(1);
return;
}
fetchFeeds(1);
flatListRef.current?.scrollToOffset({ offset: 0, animated: false });
}, [typeFilter, dateFrom, dateTo]); // eslint-disable-line react-hooks/exhaustive-deps
/* ─── 滚动处理 ─── */
const handleScroll = useCallback((event: { nativeEvent: { contentOffset: { y: number } } }) => {
setShowScrollTop(event.nativeEvent.contentOffset.y > 400);
}, []);
const handleLoadMore = useCallback(() => {
if (!loadingMore && hasMore) {
fetchFeeds(page + 1, true);
}
}, [loadingMore, hasMore, page, fetchFeeds]);
const handleScrollToTop = useCallback(() => {
flatListRef.current?.scrollToOffset({ offset: 0, animated: true });
}, []);
/* ─── 日期范围确认 ─── */
const handleDateConfirm = useCallback((start: string, end: string) => {
setDateFrom(start);
setDateTo(end);
setShowDatePicker(false);
}, []);
/* ─── 日期范围显示文本 ─── */
const dateLabel = dateFrom
? dateTo
? `${dateFrom} ~ ${dateTo}`
: dateFrom
: "筛选日期";
/* ─── 渲染每条动态 ─── */
const renderItem = useCallback(
({ item, index }: { item: FeedRecord; index: number }) => {
const isLast = index === feeds.length - 1;
const Icon = item.type === "family" ? User : item.type === "finance" ? Receipt : item.type === "favorite" ? Heart : Receipt;
return (
<View 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>
);
},
[feeds.length],
);
/* ─── 空状态 ─── */
const renderEmpty = useCallback(() => {
if (loading) return null;
return <EmptyState type="empty" />;
}, [loading]);
/* ─── 底部加载指示器 ─── */
const renderFooter = useCallback(() => {
if (loadingMore) {
return (
<View className="py-4 items-center">
<Text className="text-xs text-gray-400">加载更多...</Text>
</View>
);
}
if (!loading && feeds.length > 0 && !hasMore) {
return (
<View className="py-4 items-center">
<Text className="text-xs text-gray-300">没有更多了</Text>
</View>
);
}
return null;
}, [loadingMore, loading, feeds.length, hasMore]);
return (
<LayoutView>
<Stack.Screen options={{ animation: "slide_from_bottom" }} />
<LayoutView.FlatList
ref={flatListRef}
data={feeds}
keyExtractor={(item) => String(item.id)}
topExtraPadding={106 - insets.top}
bottomExtraPadding={20}
renderItem={renderItem}
ListEmptyComponent={renderEmpty}
ListFooterComponent={renderFooter}
onEndReached={handleLoadMore}
onEndReachedThreshold={0.3}
onScroll={handleScroll}
scrollEventThrottle={16}
/>
{/* ─── 浮动头部:类别筛选 + 日期筛选 ─── */}
<View
className="absolute top-0 left-0 right-0 z-10 bg-background"
style={{ paddingTop: insets.top }}
>
<View className="px-4 pt-3 pb-2">
{/* 类别筛选 chips */}
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
{([
{ key: "", label: "全部" },
{ key: "family", label: "家庭", Icon: User },
{ key: "finance", label: "记账", Icon: Receipt },
{ key: "favorite", label: "收藏", Icon: Heart },
] as const).map((item) => {
const { key, label } = item;
const isActive = typeFilter === key;
const Icon = "Icon" in item ? item.Icon : null;
return (
<PressableFeedback
key={key}
onPress={() => setTypeFilter(key)}
className={`flex-row items-center gap-1 px-3.5 py-1.5 rounded-full mr-2 ${
isActive ? "bg-accent" : "bg-gray-100"
}`}
>
{Icon && (
<Icon size={12} color={isActive ? "#fff" : "#9ca3af"} />
)}
<Text
className={`text-xs ${
isActive ? "text-white font-medium" : "text-gray-400"
}`}
>
{label}
</Text>
<PressableFeedback.Ripple />
</PressableFeedback>
);
})}
</ScrollView>
{/* 日期范围筛选 */}
<View className="flex-row items-center gap-2 mt-2">
<PressableFeedback
onPress={() => setShowDatePicker(true)}
className={`flex-row items-center gap-1.5 px-3 py-1.5 rounded-full ${
dateFrom ? "bg-accent/10" : "bg-gray-100"
}`}
>
<Calendar size={12} color={dateFrom ? "#0d9488" : "#9ca3af"} />
<Text
className={`text-xs ${
dateFrom ? "text-accent font-medium" : "text-gray-400"
}`}
>
{dateLabel}
</Text>
<PressableFeedback.Ripple />
</PressableFeedback>
{dateFrom ? (
<PressableFeedback
onPress={() => {
setDateFrom("");
setDateTo("");
}}
className="px-2 py-1.5"
>
<Text className="text-xs text-gray-400">清除</Text>
<PressableFeedback.Ripple />
</PressableFeedback>
) : null}
</View>
</View>
</View>
{/* ─── 日期范围选择器 ─── */}
<DateTimeRange
show={showDatePicker}
startDate={dateFrom || undefined}
endDate={dateTo || undefined}
onConfirm={handleDateConfirm}
onClose={() => setShowDatePicker(false)}
/>
{/* ─── 滚动到顶部 ─── */}
{showScrollTop && (
<Button
className="absolute bottom-24 right-4 w-10 h-10 rounded-full shadow-md items-center justify-center bg-white border border-gray-100"
onPress={handleScrollToTop}
>
<ArrowUp size={18} color="#0d9488" />
</Button>
)}
{/* ─── 关闭按钮 ─── */}
<View className="absolute bottom-6 left-0 right-0 items-center">
<CloseButton
onPress={() => router.back()}
iconProps={{ size: 28 }}
style={{ padding: 24 }}
/>
</View>
</LayoutView>
);
}
+56 -69
View File
@@ -1,10 +1,11 @@
import { useEffect } from "react";
import { Stack } from "expo-router";
import { Image, Text, View } from "react-native";
import { Button, LinkButton, ControlField, Checkbox, Spinner } from "heroui-native";
import { Button, LinkButton, ControlField, Checkbox } from "heroui-native";
import { IconQq, IconWeixin } from "@/components/iconfont";
import { useState } from "react";
import { FadeIn } from "react-native-reanimated";
import { useLoading } from "@/hooks/useLoading";
import { LoadingOverlay } from "@/components/LoadingOverlay";
import { useUserStore, useFamilyStore } from "@/store";
import { useRouter } from "expo-router";
import ExpoQQ from "expo-qq";
@@ -17,8 +18,8 @@ export default function Login() {
const router = useRouter();
const userStore = useUserStore();
const familyStore = useFamilyStore();
const { isLoading, startLoading, stopLoading } = useLoading();
const [loading, setLoading] = useState(false);
const [isAgreed, setIsAgreed] = useState(true);
const [form, setForm] = useState({
type: 0,
@@ -38,16 +39,13 @@ export default function Login() {
// 微信登录
async function handleLoginWechat() {
if (loading) {
return;
}
setLoading(true);
if (isLoading) return;
startLoading("正在唤起微信...");
// 检查微信是否安装
const isInstalled = await ExpoWeChat.isWXAppInstalled();
if (!isInstalled) {
toast.warning(onLoginFinished?.errorMessage || "请先安装微信");
setLoading(false);
stopLoading({ type: "error", message: "请先安装微信" });
return;
}
@@ -61,11 +59,8 @@ export default function Login() {
// QQ 登录
async function handleLoginQQ() {
if (loading) {
return;
}
setLoading(true);
if (isLoading) return;
startLoading("正在唤起 QQ...");
setForm((val) => ({
...val,
@@ -76,12 +71,15 @@ export default function Login() {
useEffect(() => {
if (!onLoginFinished) {
stopLoading();
return;
}
if (!onLoginFinished.success) {
toast.warning(onLoginFinished?.errorMessage || "授权失败,请检查手机是否安装 QQ 或确认授权");
setLoading(false);
toast.error(
onLoginFinished?.errorMessage || "授权失败,请检查手机是否安装 QQ 或确认授权"
);
stopLoading();
return;
}
@@ -89,8 +87,10 @@ export default function Login() {
try {
const tokenInfo = await ExpoQQ.getLoginTokenInfo();
if (!tokenInfo.openId) {
toast.warning(tokenInfo?.errorMessage || "授权失败,请检查手机是否安装 QQ 或确认授权");
setLoading(false);
toast.error(
tokenInfo?.errorMessage || "授权失败,请检查手机是否安装 QQ 或确认授权"
);
stopLoading();
return;
}
@@ -102,8 +102,7 @@ export default function Login() {
await ExpoQQ.sendGetUserInfoReq();
} catch (e) {
console.log(e);
setLoading(false);
stopLoading();
}
};
@@ -139,7 +138,7 @@ export default function Login() {
useEffect(() => {
const handleLogin = async () => {
if (!onGetUserInfo) {
setLoading(false);
stopLoading();
return;
}
@@ -150,13 +149,16 @@ export default function Login() {
avatar: onGetUserInfo.figureurlQq_2 || onGetUserInfo.figureurl_2,
});
if (!loginRes.isSuccess) return;
if (!loginRes.isSuccess) {
stopLoading();
return;
}
await handlePostLogin(loginRes.data);
} catch (err) {
console.error("QQ登录失败", err);
} finally {
setLoading(false);
stopLoading();
}
};
@@ -170,7 +172,7 @@ export default function Login() {
const handleLogin = async () => {
if (authResult.errorCode !== 0) {
console.log("授权失败:", authResult.errorMessage);
setLoading(false);
stopLoading();
return;
}
@@ -179,13 +181,16 @@ export default function Login() {
code: authResult.code,
});
if (!loginRes.isSuccess) return;
if (!loginRes.isSuccess) {
stopLoading();
return;
}
await handlePostLogin(loginRes.data);
} catch (err) {
console.error("微信登录失败", err);
} finally {
setLoading(false);
stopLoading();
}
};
@@ -193,25 +198,28 @@ export default function Login() {
}, [authResult]);
async function test1() {
if (loading) return;
setLoading(true);
if (isLoading) return;
startLoading("正在登录...");
try {
const loginRes = await loginTestApi({
user_id: 801787924787269,
});
if (!loginRes.isSuccess) return;
if (!loginRes.isSuccess) {
stopLoading();
return;
}
await handlePostLogin(loginRes.data);
stopLoading();
} catch (err) {
console.error("测试登录失败", err);
} finally {
setLoading(false);
stopLoading({ type: "error", message: "登录失败" });
}
}
function test2() {};
function test2() {}
return (
<>
@@ -227,53 +235,30 @@ export default function Login() {
className="w-20 h-20 mb-4"
resizeMode="contain"
/>
<Text className="text-xl font-semibold text-gray-900">Welcome to Allapp</Text>
<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={"#fff"} />
<Button.Label>WeChat</Button.Label>
</>
)}
<IconWeixin color={"#fff"} />
<Button.Label>WeChat</Button.Label>
</Button>
<Button variant="outline" onPress={handleLoginQQ} isDisabled={loading}>
{loading ? (
<Spinner entering={FadeIn.delay(50)} />
) : (
<>
<IconQq />
<Button.Label>QQ</Button.Label>
</>
)}
<Button variant="outline" onPress={handleLoginQQ}>
<IconQq />
<Button.Label>QQ</Button.Label>
</Button>
<Button variant="outline" onPress={test1} isDisabled={loading}>
{loading ? (
<Spinner entering={FadeIn.delay(50)} />
) : (
<>
<IconQq />
<Button.Label>测试1</Button.Label>
</>
)}
<Button variant="outline" onPress={test1}>
<IconQq />
<Button.Label>测试1</Button.Label>
</Button>
<Button variant="outline" onPress={test2} isDisabled={loading}>
{loading ? (
<Spinner entering={FadeIn.delay(50)} />
) : (
<>
<IconQq />
<Button.Label>测试2</Button.Label>
</>
)}
<Button variant="outline" onPress={test2}>
<IconQq />
<Button.Label>测试2</Button.Label>
</Button>
</View>
@@ -296,6 +281,8 @@ export default function Login() {
</LinkButton>
</View>
</ControlField>
<LoadingOverlay visible={isLoading} />
</View>
</>
);
@@ -8,14 +8,16 @@ import { useCallback, useEffect, useState } from "react";
import { Text, View } from "react-native";
import { useRouter } from "expo-router";
import { SearchField, Spinner, PressableFeedback } from "heroui-native";
import { ChatMsgType } from "@/types";
const MSG_TYPES = [
{ label: "全部", value: -1 },
{ label: "文本", value: 0 },
{ label: "图片", value: 1 },
{ label: "视频", value: 2 },
{ label: "文件", value: 3 },
{ label: "转发", value: 4 },
{ label: "文本", value: ChatMsgType.Text },
{ label: "图片", value: ChatMsgType.Image },
{ label: "视频", value: ChatMsgType.Video },
{ label: "文件", value: ChatMsgType.File },
{ label: "转发", value: ChatMsgType.Forward },
{ label: "系统", value: ChatMsgType.System },
];
function formatTime(ts: string) {
@@ -21,7 +21,6 @@ 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();
@@ -272,9 +271,6 @@ export default function EntryFamily() {
setShowCreate(false);
setCreateForm({ name: "" });
// 记录动态
recordFeed(familyId, userStore.userInfo.id, "family", `${userStore.userInfo.nickname} 创建了家庭「${createForm.name}」`);
enterFamily(familyId);
}
} catch (e) {
@@ -351,9 +347,6 @@ export default function EntryFamily() {
setShowJoin(false);
setJoinForm({ invite_code: "" });
// 记录动态
recordFeed(family.id, userStore.userInfo.id, "family", `${userStore.userInfo.nickname} 加入了家庭`);
enterFamily(family.id);
}
} catch (e) {
@@ -35,7 +35,6 @@ 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);
@@ -418,14 +417,6 @@ export default function UserSetting() {
name: "",
});
// 记录动态
recordFeed(
familyStore.currentFamily.family_id,
userStore.userInfo.id,
"family",
`${userStore.userInfo.nickname} 修改了昵称为「${renameForm.name}」`,
);
toast.success("名称修改成功");
}
} catch (e) {
@@ -10,7 +10,6 @@ import EmptyState from "@/components/EmptyStatus";
import AZIndex from "@/components/AZIndex";
import { useFavoriteStore, useFamilyStore, useUserStore } from "@/store";
import { Heart } from "lucide-react-native";
import { recordFeed } from "@/helpers/feed";
export default function Tools() {
const router = useRouter();
@@ -171,15 +170,6 @@ export default function Tools() {
subItem.id
);
toast(result ? "已收藏" : "已取消收藏");
// 记录收藏动态(仅收藏时)
if (result) {
recordFeed(
familyStore.currentFamily.family_id,
userStore.userInfo?.id ?? 0,
"favorite",
`${userStore.userInfo?.nickname ?? "成员"} 收藏了「${subItem.title}」`,
);
}
}}
className="absolute right-4 top-0 bottom-0 justify-center px-2 z-10"
>
+1 -1
View File
@@ -1,5 +1,5 @@
import { useIsDark } from "@/hooks/use-color-scheme";
import { DarkTheme, DefaultTheme, ThemeProvider } from "@react-navigation/native";
import { DarkTheme, DefaultTheme, ThemeProvider } from "expo-router";
import { StatusBar } from "expo-status-bar";
import { HeroUINativeProvider } from "heroui-native";
import { GestureHandlerRootView } from "react-native-gesture-handler";
+274 -201
View File
@@ -1,241 +1,314 @@
import { View, Text, TouchableOpacity } from "react-native";
import { View, Text, TouchableOpacity, TextInput } from "react-native";
import { Stack } from "expo-router";
import PageLayout from "@/layouts/PageLayout";
import { useState, useEffect, useRef } from "react";
import { useState, useEffect, useMemo, useCallback } from "react";
import LayoutView from "@/components/layout/view";
import { ScrollShadow, Tabs, TextField, Label, Button, Input, BottomSheet } from "heroui-native";
import { Tabs, Button, BottomSheet, Input, Label, TextField, PressableFeedback } from "heroui-native";
import { useFinanceStore } from "@/store/finance";
import { useUserStore, useFamilyStore } from "@/store";
import { getUniqueIdApi, saveDataApi } from "@/request/api";
import IconFont from "@/components/iconfont";
import { LegendList } from "@legendapp/list";
import { LinearGradient } from "expo-linear-gradient";
import { ChevronRight } from "lucide-react-native";
import { FinanceType } from "@/types";
import { getCurrentDate } from "@/utils/time";
import DateTime from "@/components/DateTime";
import { useLoading } from "@/hooks/useLoading";
import { LoadingOverlay } from "@/components/LoadingOverlay";
import { toast } from "sonner-native";
import { SearchIcon, ChevronLeft, ChevronRight } from "lucide-react-native";
export default function Add() {
const { getIncomeCategories, getExpenseCategories } = useFinanceStore((state) => state);
// ─── 简易日历 ─────────────────────────────
function MiniCalendar({
value,
onChange,
}: {
value: string; // YYYY-MM-DD
onChange: (d: string) => void;
}) {
const today = new Date();
const [viewYear, setViewYear] = useState(today.getFullYear());
const [viewMonth, setViewMonth] = useState(today.getMonth() + 1);
const [activeTab, setActiveTab] = useState("0");
const [categories, setCategories] = useState<any[]>([]);
const [showMoreCategories, setShowMoreCategories] = useState(false);
const selected = useMemo(() => {
const [y, m, d] = value.split("-").map(Number);
return { y, m, d };
}, [value]);
const listRef = useRef<any>(null);
const [selectedCategoryId, setSelectedCategoryId] = useState<number | null>(null);
const days = useMemo(() => {
const firstDay = new Date(viewYear, viewMonth - 1, 1).getDay();
const daysInMonth = new Date(viewYear, viewMonth, 0).getDate();
const result: (number | null)[] = [];
for (let i = 0; i < firstDay; i++) result.push(null);
for (let d = 1; d <= daysInMonth; d++) result.push(d);
return result;
}, [viewYear, viewMonth]);
const [showRecordTime, setShowRecordTime] = useState(false);
const prevMonth = () => {
if (viewMonth === 1) { setViewYear(viewYear - 1); setViewMonth(12); }
else setViewMonth(viewMonth - 1);
};
const nextMonth = () => {
if (viewMonth === 12) { setViewYear(viewYear + 1); setViewMonth(1); }
else setViewMonth(viewMonth + 1);
};
const [form, setForm] = useState<any>({
id: 0,
amount: "",
record_time: getCurrentDate(),
});
useEffect(() => {
const newCategories = activeTab === "0" ? getExpenseCategories() : getIncomeCategories();
setCategories(newCategories);
}, [activeTab]);
useEffect(() => {
if (!selectedCategoryId) {
listRef.current?.scrollToOffset?.({
offset: 0,
animated: false,
});
return;
}
const item = categories.find((c) => c.id === selectedCategoryId);
if (!item) {
listRef.current?.scrollToOffset?.({
offset: 0,
animated: false,
});
return;
}
requestAnimationFrame(() => {
listRef.current?.scrollToItem?.({
item,
animated: true,
viewPosition: 0.5,
});
});
}, [selectedCategoryId, categories]);
const pad = (n: number) => String(n).padStart(2, "0");
return (
<PageLayout title="新增记账">
<View className="bg-gray-50 rounded-xl p-3">
{/* 月份切换 */}
<View className="flex-row items-center justify-between mb-2">
<PressableFeedback onPress={prevMonth} className="p-1">
<ChevronLeft size={18} color="#6B7280" />
<PressableFeedback.Ripple />
</PressableFeedback>
<Text className="text-sm font-semibold text-gray-700">
{viewYear} 年 {viewMonth} 月
</Text>
<PressableFeedback onPress={nextMonth} className="p-1">
<ChevronRight size={18} color="#6B7280" />
<PressableFeedback.Ripple />
</PressableFeedback>
</View>
{/* 星期头 */}
<View className="flex-row mb-1">
{["日", "一", "二", "三", "四", "五", "六"].map((w) => (
<View key={w} className="flex-1 items-center py-1">
<Text className="text-[11px] text-gray-400">{w}</Text>
</View>
))}
</View>
{/* 日期网格 */}
<View className="flex-row flex-wrap">
{days.map((d, i) => {
const isToday = d === today.getDate() && viewMonth === today.getMonth() + 1 && viewYear === today.getFullYear();
const isSel = d === selected.d && viewMonth === selected.m && viewYear === selected.y;
return (
<View key={i} className="w-[14.28%] items-center py-0.5">
{d ? (
<PressableFeedback
onPress={() => onChange(`${viewYear}-${pad(viewMonth)}-${pad(d)}`)}
>
<View
className={`w-8 h-8 rounded-full items-center justify-center ${
isSel ? "bg-accent" : isToday ? "bg-accent/10" : ""
}`}
>
<Text
className={`text-xs ${
isSel ? "text-white font-semibold" : isToday ? "text-accent font-semibold" : "text-gray-700"
}`}
>
{d}
</Text>
</View>
<PressableFeedback.Ripple />
</PressableFeedback>
) : (
<View className="w-8 h-8" />
)}
</View>
);
})}
</View>
</View>
);
}
// ─── 页面 ─────────────────────────────────
export default function Add() {
const { getExpenseCategories, getIncomeCategories } = useFinanceStore();
const userId = useUserStore((s) => s.userInfo.id);
const familyId = useFamilyStore((s) => s.currentFamily.family_id);
const { isLoading, startLoading, stopLoading } = useLoading();
const [activeTab, setActiveTab] = useState("0"); // 0=支出 1=收入
const [keyword, setKeyword] = useState("");
const [selectedCategory, setSelectedCategory] = useState<any>(null);
const [amount, setAmount] = useState("");
const [date, setDate] = useState(getCurrentDate());
const [remark, setRemark] = useState("");
const allCategories = useMemo(() => {
const cats = activeTab === "0" ? getExpenseCategories() : getIncomeCategories();
if (!keyword.trim()) return cats;
return cats.filter((c: any) => c.name.includes(keyword.trim()));
}, [activeTab, keyword, getExpenseCategories, getIncomeCategories]);
const resetForm = useCallback(() => {
setAmount("");
setDate(getCurrentDate());
setRemark("");
}, []);
const handleSave = useCallback(
async (keepOpen: boolean) => {
if (!amount || !selectedCategory) {
toast.error("请选择分类并填写金额");
return;
}
if (isLoading) return;
startLoading("保存中...");
try {
const idRes = await getUniqueIdApi(1);
const newId = idRes.data?.[0];
if (!newId) { toast.error("获取ID失败"); stopLoading(); return; }
await saveDataApi([{
table_name: "b_finance",
key_field: "id",
inserts: [{
id: newId,
category_id: selectedCategory.id,
family_id: familyId,
amount: Number(amount),
remark,
record_time: date,
}],
updates: [],
deletes: [],
}]);
const label = activeTab === "0" ? "支出" : "收入";
toast.success(`已记${label} ¥${amount}`);
if (keepOpen) {
setAmount("");
setRemark("");
} else {
resetForm();
setSelectedCategory(null);
}
} catch (e) {
toast.error("保存失败");
} finally {
stopLoading();
}
},
[amount, selectedCategory, activeTab, resetForm, isLoading, startLoading, stopLoading, familyId, date, remark]
);
return (
<PageLayout title="记一笔">
<Stack.Screen options={{ animation: "slide_from_bottom" }} />
<LayoutView>
<LayoutView.ScrollView bottomOffset={50}>
<View className="flex-1">
<Tabs value={activeTab} onValueChange={setActiveTab} variant="primary">
<Tabs.List>
<Tabs.Indicator />
<Tabs.Trigger value="0" className="flex-1">
<Tabs.Label>支出</Tabs.Label>
</Tabs.Trigger>
<Tabs.Trigger value="1" className="flex-1">
<Tabs.Label>收入</Tabs.Label>
</Tabs.Trigger>
</Tabs.List>
</Tabs>
<View className="my-4 gap-4">
{/* <View className="w-full items-center justify-center py-3">
<Input className="w-full" inputMode="decimal" />
</View> */}
<TextField isRequired>
<View className="flex-row items-center justify-between">
<Label>类别</Label>
<TouchableOpacity
className="flex-row items-center"
onPress={() => {
setShowMoreCategories(true);
}}
{/* 支出/收入 Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab} variant="primary">
<Tabs.List>
<Tabs.Indicator />
<Tabs.Trigger value="0" className="flex-1">
<Tabs.Label>支出</Tabs.Label>
</Tabs.Trigger>
<Tabs.Trigger value="1" className="flex-1">
<Tabs.Label>收入</Tabs.Label>
</Tabs.Trigger>
</Tabs.List>
</Tabs>
{/* 搜索 */}
<View className="mt-3 mb-2 flex-row items-center bg-gray-100 rounded-full px-3 h-9">
<SearchIcon size={14} color="#9CA3AF" />
<TextInput
className="flex-1 ml-1.5 text-sm"
placeholder="搜索分类..."
value={keyword}
onChangeText={setKeyword}
/>
</View>
{/* 分类网格 */}
<View className="flex-row flex-wrap">
{allCategories.map((item: any) => {
const isActive = selectedCategory?.id === item.id;
return (
<TouchableOpacity
key={item.id}
onPress={() => setSelectedCategory(item)}
className="w-1/4 items-center mb-4"
>
<View
className={`w-14 h-14 rounded-2xl items-center justify-center ${
isActive ? "bg-accent" : "bg-gray-100"
}`}
>
<Text className="text-sm text-gray-400">更多</Text>
<ChevronRight size={16} color="#9CA3AF" />
</TouchableOpacity>
<IconFont name={item.icon || "IconFinanceZhichu"} size={28} />
</View>
<Text
className={`text-[11px] mt-1.5 text-center ${isActive ? "font-semibold text-gray-900" : "text-gray-500"}`}
numberOfLines={1}
>
{item.name}
</Text>
</TouchableOpacity>
);
})}
</View>
</LayoutView.ScrollView>
</LayoutView>
{/* 记账 BottomSheet */}
<BottomSheet isOpen={!!selectedCategory} onClose={() => setSelectedCategory(null)}>
<BottomSheet.Portal>
<BottomSheet.Overlay />
<BottomSheet.Content>
<View className="px-4 pb-6 gap-4">
{/* 选中的分类 */}
{selectedCategory && (
<View className="flex-row items-center gap-3 py-2">
<View className="w-10 h-10 rounded-xl bg-accent/10 items-center justify-center">
<IconFont name={selectedCategory.icon || "IconFinanceZhichu"} size={22} />
</View>
<Text className="text-base font-semibold text-gray-900">
{selectedCategory.name}
</Text>
</View>
<ScrollShadow LinearGradientComponent={LinearGradient}>
<LegendList
ref={listRef}
horizontal
data={categories}
keyExtractor={(item) => item.id.toString()}
extraData={selectedCategoryId}
renderItem={({ item }) => {
const isActive = selectedCategoryId === item.id;
)}
return (
<TouchableOpacity
onPress={() => {
setSelectedCategoryId(item.id);
}}
className="items-center justify-center mr-3"
>
<View
className={`w-12 aspect-square rounded-full items-center justify-center ${
isActive ? "bg-accent" : "bg-gray-100"
}`}
>
<IconFont name={item.icon || "IconFinanceZhichu"} size={28} />
</View>
<Text
className={`text-[10px] mt-1 text-center ${
isActive ? "font-semibold text-gray-900" : "text-gray-500"
}`}
numberOfLines={1}
>
{item.name}
</Text>
</TouchableOpacity>
);
}}
showsHorizontalScrollIndicator={false}
className="flex-1"
/>
</ScrollShadow>
</TextField>
{/* 金额 */}
<TextField isRequired>
<Label>金额</Label>
<Input
placeholder="0.00"
inputMode="decimal"
value={form.amount}
onChangeText={(value) => {
setForm((prev: any) => ({
...prev,
amount: value,
}));
}}
value={amount}
onChangeText={setAmount}
className="text-2xl font-bold h-14"
/>
</TextField>
{/* 日期 — 日历 */}
<TextField isRequired>
<Label>日期</Label>
<TouchableOpacity
onPress={() => {
console.log("open date picker");
setShowRecordTime(true);
}}
>
<View pointerEvents="none">
<Input placeholder="请选择日期" value={form.record_time} />
</View>
</TouchableOpacity>
</TextField>
<MiniCalendar value={date} onChange={setDate} />
{/* 备注 */}
<TextField>
<Label>备注</Label>
<Input placeholder="请输入备注" />
<Input placeholder="写点什么..." value={remark} onChangeText={setRemark} />
</TextField>
</View>
<Button>
<Button.Label>保存</Button.Label>
</Button>
</View>
</LayoutView.ScrollView>
</LayoutView>
<DateTime
show={showRecordTime}
value={form.record_time}
onConfirm={(value) => {
setForm((prev: any) => ({
...prev,
record_time: value,
}));
setShowRecordTime(false);
}}
onClose={() => {
setShowRecordTime(false);
}}
/>
<BottomSheet isOpen={showMoreCategories} onOpenChange={setShowMoreCategories}>
<BottomSheet.Portal>
<BottomSheet.Overlay />
<BottomSheet.Content>
<View className="flex-row flex-wrap">
{categories.map((item) => {
const isActive = selectedCategoryId === item.id;
return (
<TouchableOpacity
key={item.id}
onPress={() => {
setSelectedCategoryId(item.id);
setShowMoreCategories(false);
}}
className="items-center justify-center w-1/4 mb-4"
>
<View
className={`w-12 aspect-square rounded-full items-center justify-center ${
isActive ? "bg-accent" : "bg-gray-100"
}`}
>
<IconFont name={item.icon || "IconFinanceZhichu"} size={28} />
</View>
<Text
className={`text-[10px] mt-1 text-center ${
isActive ? "font-semibold text-gray-900" : "text-gray-500"
}`}
numberOfLines={1}
>
{item.name}
</Text>
</TouchableOpacity>
);
})}
{/* 按钮 */}
<View className="flex-row gap-3 mt-2">
<Button
onPress={() => handleSave(false)}
className="flex-1"
>
<Button.Label>保存</Button.Label>
</Button>
<Button
onPress={() => handleSave(true)}
className="flex-1"
variant="secondary"
>
<Button.Label>保存并再记</Button.Label>
</Button>
</View>
</View>
</BottomSheet.Content>
</BottomSheet.Portal>
</BottomSheet>
<LoadingOverlay visible={isLoading} />
</PageLayout>
);
}
@@ -69,7 +69,7 @@ export default function DateTimeRange({
<BottomSheet isOpen={open} onOpenChange={setOpen}>
<BottomSheet.Portal>
<BottomSheet.Overlay />
<BottomSheet.Content>
<BottomSheet.Content backgroundClassName="bg-white">
<View style={{ width: "100%", alignItems: "center" }}>
<DateTimePicker
mode="range"
@@ -1,23 +1,16 @@
import { View, Text } from "react-native";
import { Spinner } from "heroui-native";
import { View } from "react-native";
interface LoadingOverlayProps {
visible: boolean;
text?: string;
}
export default function LoadingOverlay({ visible, text = "加载中..." }: LoadingOverlayProps) {
export function LoadingOverlay({ visible }: LoadingOverlayProps) {
if (!visible) return null;
return (
<View
className="absolute inset-0 z-50 items-center justify-center bg-black/30"
className="absolute top-0 left-0 right-0 bottom-0 z-50 bg-black/5"
pointerEvents="auto"
>
<View className="bg-white rounded-2xl px-8 py-6 items-center gap-3 shadow-lg">
<Spinner />
<Text className="text-sm text-gray-500">{text}</Text>
</View>
</View>
/>
);
}
+75 -110
View File
@@ -1,8 +1,23 @@
import { View, StyleProp, ViewStyle, Pressable, StyleSheet } from "react-native";
import { View, Text, StyleProp, ViewStyle, Pressable, StyleSheet } from "react-native";
import { LucideIcon } from "lucide-react-native";
import { cn } from "@/utils/cn";
import Animated, { useSharedValue, useAnimatedStyle, withTiming } from "react-native-reanimated";
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
} from "react-native-reanimated";
import { useEffect } from "react";
// ── 视觉常量 ──────────────────────────────────────
const ICON_SIZE = 20;
const LABEL_SIZE = 11;
const BAR_HEIGHT = 56;
const BAR_RADIUS = 16;
const COLOR_ACTIVE = "#10B981";
const COLOR_IDLE = "#9CA3AF";
const BAR_BG = "#FFFFFF";
// ── 类型 ──────────────────────────────────────────
export interface TabbarItem {
id: string;
icon: LucideIcon;
@@ -14,54 +29,46 @@ export interface TabbarProps {
tabs: TabbarItem[];
value?: string;
onChange?: (id: string) => void;
className?: string;
style?: StyleProp<ViewStyle>;
}
// ── 主组件 ────────────────────────────────────────
export default function Tabbar({ tabs, value, onChange, className, style }: TabbarProps) {
const activeTab = value;
const handlePress = (id: string) => {
onChange?.(id);
};
return (
<View className={cn("m-3", className)} style={style}>
<View className="shadow-2xl" style={styles.tabbarShadow}>
<View className="w-full flex-row items-center justify-between">
{tabs.map((tab) =>
tab.isCenter ? (
<CenterButton key={tab.id} tab={tab} onPress={() => handlePress(tab.id)} />
) : (
<TabItem
key={tab.id}
tab={tab}
isActive={activeTab === tab.id}
onPress={() => handlePress(tab.id)}
/>
),
)}
</View>
<View className={cn("px-4", className)} style={style}>
<View className="shadow-2xl" style={s.bar}>
{tabs.map((tab) => (
<TabItem
key={tab.id}
tab={tab}
active={value === tab.id}
onPress={() => onChange?.(tab.id)}
/>
))}
</View>
</View>
);
}
// ── 普通 Tab ──────────────────────────────────────
function TabItem({
tab,
isActive,
active,
onPress,
}: {
tab: TabbarItem;
isActive: boolean;
active: boolean;
onPress: () => void;
}) {
const Icon = tab.icon;
const scale = useSharedValue(1);
const scaleStyle = useAnimatedStyle(() => ({
useEffect(() => {
scale.value = withTiming(1, { duration: 150 });
}, [active]);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
@@ -69,104 +76,62 @@ function TabItem({
<Pressable
onPress={onPress}
onPressIn={() => {
scale.value = withTiming(0.9, { duration: 80 });
scale.value = withTiming(0.92, { duration: 80 });
}}
onPressOut={() => {
scale.value = withTiming(1, { duration: 120 });
scale.value = withTiming(1, { duration: 150 });
}}
className="flex-1"
style={s.item}
>
<Animated.View style={scaleStyle} className="items-center justify-center">
<Icon size={24} color={isActive ? "#5FAF7B" : "#666"} />
{tab.title && (
<Animated.Text
<Animated.View style={[s.itemInner, animatedStyle]}>
<Icon
size={ICON_SIZE}
color={active ? COLOR_ACTIVE : COLOR_IDLE}
strokeWidth={active ? 2.2 : 1.8}
/>
{tab.title ? (
<Text
style={[
s.label,
{
fontSize: 10,
marginTop: 4,
fontWeight: isActive ? "600" : "400",
color: isActive ? "#5FAF7B" : "#666",
color: active ? COLOR_ACTIVE : COLOR_IDLE,
fontWeight: active ? "600" : "400",
},
]}
>
{tab.title}
</Animated.Text>
)}
</Text>
) : null}
</Animated.View>
</Pressable>
);
}
function CenterButton({ tab, onPress }: { tab: TabbarItem; onPress?: () => void }) {
const Icon = tab.icon;
const scale = useSharedValue(1);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
return (
<View className="flex-1 items-center justify-center">
<Pressable
className="absolute"
onPress={onPress}
onPressIn={() => {
scale.value = withTiming(0.9, { duration: 80 });
}}
onPressOut={() => {
scale.value = withTiming(1, { duration: 120 });
}}
>
<Animated.View style={animatedStyle}>
<View
style={{
width: 64,
height: 64,
borderRadius: 999,
backgroundColor: "#22C55E",
alignItems: "center",
justifyContent: "center",
borderWidth: 8,
borderColor: "white",
top: -4,
}}
>
{/* <Shadow
distance={6}
startColor="rgba(0,0,0,0.03)"
endColor="rgba(0,0,0,0)"
offset={[0, 2]}
style={[styles.centerButtonShadown]}
> */}
<Icon size={30} color="#fff" />
{/* </Shadow> */}
</View>
</Animated.View>
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
centerButtonShadown: {
width: 44,
height: 44,
borderRadius: 999,
alignItems: "center",
justifyContent: "center",
borderWidth: 0.5,
borderColor: "rgba(255,255,255,0.6)",
backgroundColor: "#22C55E",
},
tabbarShadow: {
borderRadius: 999,
// ── 样式 ──────────────────────────────────────────
const s = StyleSheet.create({
// === Bar ===
bar: {
height: BAR_HEIGHT,
borderRadius: BAR_RADIUS,
backgroundColor: BAR_BG,
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 4,
},
// === Tab Item ===
item: {
flex: 1,
height: "100%",
},
itemInner: {
flex: 1,
alignItems: "center",
justifyContent: "center",
borderWidth: 0.5,
borderColor: "rgba(255,255,255,0.6)",
height: 56,
backgroundColor: "#fff",
gap: 1,
},
label: {
fontSize: LABEL_SIZE,
marginTop: 2,
},
});
-99
View File
@@ -1,99 +0,0 @@
import { loadDataApi, loadDataPageApi, saveDataApi, getUniqueIdApi } from "@/request/api";
export type FeedType = "family" | "finance" | "favorite";
export interface FeedRecord {
id: number;
family_id: number;
user_id: number;
type: FeedType;
content: string;
meta: string | null;
create_time: string;
}
/** 写入一条动态 */
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: [],
},
]);
}
/** 加载家庭动态列表 */
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],
});
return (res.data || []) as FeedRecord[];
}
/** 分页加载家庭动态列表 */
export async function loadFeedsPage(
familyId: number,
page: number,
pageSize: number,
type?: string,
dateFrom?: string,
dateTo?: string,
): Promise<{ rows: FeedRecord[]; total: number; page: number; page_size: number }> {
const conditions: string[] = ["family_id = ?"];
const args: any[] = [familyId];
if (type) {
conditions.push("type = ?");
args.push(type);
}
if (dateFrom) {
conditions.push("create_time >= ?");
args.push(dateFrom);
}
if (dateTo) {
conditions.push("create_time <= ?");
args.push(dateTo + " 23:59:59");
}
const res = await loadDataPageApi({
view_name: "b_family_feed",
search_condition: conditions.join(" AND "),
order_by: "create_time DESC",
search_columns: ["id", "family_id", "user_id", "type", "content", "meta", "create_time"],
args,
page,
page_size: pageSize,
});
if (!res.isSuccess || !res.data) {
return { rows: [], total: 0, page, page_size: pageSize };
}
return { rows: res.data.rows as FeedRecord[], total: res.data.total, page: res.data.page, page_size: res.data.page_size };
}
+61
View File
@@ -0,0 +1,61 @@
import { useState, useCallback, useRef } from "react";
import { toast } from "sonner-native";
interface StopOptions {
type?: "success" | "error" | "none";
message?: string;
}
export function useLoading() {
const [isLoading, setIsLoading] = useState(false);
const toastIdRef = useRef<string | number | null>(null);
const startLoading = useCallback((message: string = "加载中...") => {
setIsLoading(true);
const id = toast.loading(message);
toastIdRef.current = id;
return id;
}, []);
const stopLoading = useCallback((options?: StopOptions) => {
setIsLoading(false);
const id = toastIdRef.current;
if (id) {
if (options?.type === "success") {
toast.success(options.message || "操作成功", { id });
} else if (options?.type === "error") {
toast.error(options.message || "操作失败", { id });
} else {
toast.dismiss(id);
}
toastIdRef.current = null;
}
}, []);
const withLoading = useCallback(
async <T,>(
promise: Promise<T>,
loadingMessage: string = "加载中...",
successMessage?: string,
errorMessage?: string
): Promise<T> => {
const id = startLoading(loadingMessage);
try {
const result = await promise;
stopLoading({ type: "success", message: successMessage || "操作成功" });
return result;
} catch (error) {
stopLoading({ type: "error", message: errorMessage || "操作失败" });
throw error;
}
},
[startLoading, stopLoading]
);
return {
isLoading,
startLoading,
stopLoading,
withLoading,
};
}
+15
View File
@@ -0,0 +1,15 @@
/** 聊天消息类型 */
export enum ChatMsgType {
/** 文本 */
Text = 0,
/** 图片 */
Image = 1,
/** 视频 */
Video = 2,
/** 文件 */
File = 3,
/** 转发(模块内容分享) */
Forward = 4,
/** 系统消息(加入家庭、改名、记账、收藏等) */
System = 5,
}
+9
View File
@@ -0,0 +1,9 @@
/** 附件文件类型 */
export enum FileType {
/** 图片 */
Image = 1,
/** 视频 */
Video = 2,
/** 文件 */
File = 3,
}
+7
View File
@@ -0,0 +1,7 @@
/** 记账类型 */
export enum FinanceType {
/** 支出 */
Expense = 0,
/** 收入 */
Income = 1,
}
+3
View File
@@ -0,0 +1,3 @@
export { ChatMsgType } from "./chat";
export { FileType } from "./file";
export { FinanceType } from "./finance";
-264
View File
@@ -1,264 +0,0 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8" />
<title>附件组件(最终版)</title>
<style>
body {
margin: 0;
font-family: Arial;
background: #f5f6f8;
}
/* grid */
.grid {
padding: 12px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px;
}
/* card */
.card {
position: relative;
background: #fff;
border-radius: 10px;
border: 1px solid transparent;
display: flex;
flex-direction: column;
cursor: pointer;
}
.card:hover {
border-color: #1677ff;
}
.card.selected {
background: #eaf2ff;
border-color: #1677ff;
}
/* checkbox(左上) */
.check {
position: absolute;
top: 6px;
left: 6px;
z-index: 5;
}
/* 状态(右上) */
.status {
position: absolute;
top: 6px;
right: 6px;
background: #ff4d4f;
color: #fff;
font-size: 10px;
padding: 2px 5px;
border-radius: 3px;
}
/* thumb */
.thumb {
height: 90px;
background: #f0f2f5;
display:flex;
align-items:center;
justify-content:center;
}
.thumb img {
width:100%;
height:100%;
object-fit:cover;
}
/* info */
.info {
padding: 8px;
}
.name {
font-size: 13px;
}
.meta {
font-size: 12px;
color: #888;
}
/* actions */
.actions {
display: flex;
border-top: 1px solid #eee;
}
.actions button {
flex: 1;
border: none;
background: #fff;
padding: 6px 0;
cursor: pointer;
font-size: 12px;
}
.actions button:hover {
background: #f5f6f8;
}
.danger {
color: #ff4d4f;
}
.more-menu {
position: absolute;
bottom: 35px;
right: 6px;
background: #fff;
border: 1px solid #eee;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
display: none;
}
.more-menu div {
padding: 6px 10px;
cursor: pointer;
}
.more-menu div:hover {
background: #f5f6f8;
}
</style>
</head>
<body>
<div class="grid" id="grid"></div>
<script>
const files = [
{ id: 1, name: "合同.pdf", type: "doc", size: "800KB", date: "2026-04-25" },
{ id: 2, name: "设计图.png", type: "img", url: "https://picsum.photos/400/300?1", size: "1.2MB", date: "2026-04-24", status: "未上传" }
];
let selected = new Set();
function render() {
const grid = document.getElementById("grid");
grid.innerHTML = "";
files.forEach(f => {
const card = document.createElement("div");
card.className = "card" + (selected.has(f.id) ? " selected" : "");
card.onclick = () => toggleSelect(f.id);
/* checkbox */
const check = document.createElement("input");
check.type = "checkbox";
check.className = "check";
check.checked = selected.has(f.id);
check.onclick = (e) => {
e.stopPropagation();
toggleSelect(f.id);
};
/* 状态 */
if (f.status) {
const st = document.createElement("div");
st.className = "status";
st.innerText = f.status;
card.appendChild(st);
}
/* thumb */
const thumb = document.createElement("div");
thumb.className = "thumb";
if (f.type === "img") {
thumb.innerHTML = `<img src="${f.url}">`;
} else {
thumb.innerText = "📄";
}
/* info */
const info = document.createElement("div");
info.className = "info";
info.innerHTML = `
<div class="name">${f.name}</div>
<div class="meta">${f.date} · ${f.size}</div>
`;
/* actions */
const actions = document.createElement("div");
actions.className = "actions";
const btnView = createBtn("预览", () => preview(f));
const btnDown = createBtn("下载", () => alert("下载 " + f.name));
const btnDelete = createBtn("删除", () => deleteFile(f.id), "danger");
/* 更多 */
const btnMore = document.createElement("button");
btnMore.innerText = "⋯";
const menu = document.createElement("div");
menu.className = "more-menu";
menu.innerHTML = `
<div>重命名</div>
<div>查看详情</div>
`;
btnMore.onclick = (e) => {
e.stopPropagation();
menu.style.display = menu.style.display === "block" ? "none" : "block";
};
document.addEventListener("click", () => {
menu.style.display = "none";
});
actions.append(btnView, btnDown, btnDelete, btnMore);
card.append(check, thumb, info, actions, menu);
grid.appendChild(card);
});
}
function createBtn(text, fn, cls="") {
const btn = document.createElement("button");
btn.innerText = text;
btn.className = cls;
btn.onclick = (e) => {
e.stopPropagation();
fn();
};
return btn;
}
function toggleSelect(id) {
if (selected.has(id)) selected.delete(id);
else selected.add(id);
render();
}
function preview(f) {
alert("预览 " + f.name);
}
function deleteFile(id) {
if (!confirm("确定删除?")) return;
const i = files.findIndex(f => f.id === id);
if (i > -1) files.splice(i, 1);
selected.delete(id);
render();
}
render();
</script>
</body>
</html>