From 6a598f0a98b97fdf340741b3563ee6da6dfc3e29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=95=E6=96=B0=E9=9B=A8?= Date: Wed, 28 Jan 2026 22:54:21 +0800 Subject: [PATCH] chore: vendor client into main repo --- client | 1 - client/.env.example | 3 + client/.vscode/extensions.json | 1 + client/.vscode/settings.json | 7 + client/README.md | 343 + client/app.json | 40 + client/app/(app)/_layout.tsx | 50 + client/app/(app)/favorites.tsx | 61 + client/app/(app)/home.tsx | 76 + client/app/(app)/settings.tsx | 50 + client/app/(onboarding)/_layout.tsx | 18 + client/app/(onboarding)/onboarding.tsx | 104 + client/app/(onboarding)/push-prompt.tsx | 74 + client/app/+html.tsx | 38 + client/app/+not-found.tsx | 40 + client/app/_layout.tsx | 81 + client/app/index.tsx | 35 + client/app/modal.tsx | 35 + client/assets/fonts/SpaceMono-Regular.ttf | Bin 0 -> 93252 bytes client/assets/images/adaptive-icon.png | Bin 0 -> 17547 bytes client/assets/images/favicon.png | Bin 0 -> 1466 bytes client/assets/images/icon.png | Bin 0 -> 22380 bytes client/assets/images/splash-icon.png | Bin 0 -> 17547 bytes client/components/EditScreenInfo.tsx | 77 + client/components/ExternalLink.tsx | 26 + client/components/StyledText.tsx | 5 + client/components/Themed.tsx | 45 + .../components/__tests__/StyledText-test.js | 10 + client/components/useClientOnlyValue.ts | 4 + client/components/useClientOnlyValue.web.ts | 12 + client/components/useColorScheme.ts | 1 + client/components/useColorScheme.web.ts | 8 + client/constants/Colors.ts | 19 + client/ios/.xcode.env | 11 + .../MindfulnessWidget/MindfulnessWidget.swift | 128 + .../MindfulnessWidgetBundle.swift | 12 + client/ios/MindfulnessWidget/README.md | 12 + client/ios/Podfile | 63 + client/ios/Podfile.lock | 2347 ++++++ client/ios/Podfile.properties.json | 5 + client/ios/client.xcodeproj/project.pbxproj | 778 ++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/xcschemes/client.xcscheme | 88 + .../contents.xcworkspacedata | 10 + client/ios/client/AppDelegate.swift | 70 + .../App-Icon-1024x1024@1x.png | Bin 0 -> 59468 bytes .../AppIcon.appiconset/Contents.json | 14 + .../ios/client/Images.xcassets/Contents.json | 6 + .../Contents.json | 20 + .../SplashScreenLegacy.imageset/Contents.json | 23 + .../SplashScreenLegacy.imageset/image.png | Bin 0 -> 60870 bytes .../SplashScreenLegacy.imageset/image@2x.png | Bin 0 -> 60870 bytes .../SplashScreenLegacy.imageset/image@3x.png | Bin 0 -> 60870 bytes client/ios/client/Info.plist | 81 + client/ios/client/PrivacyInfo.xcprivacy | 48 + client/ios/client/SplashScreen.storyboard | 48 + client/ios/client/Supporting/Expo.plist | 12 + client/ios/client/client-Bridging-Header.h | 3 + client/ios/client/client.entitlements | 8 + client/ios/情绪小组件/AppIntent.swift | 18 + .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 35 + .../情绪小组件/Assets.xcassets/Contents.json | 6 + .../WidgetBackground.colorset/Contents.json | 11 + client/ios/情绪小组件/EmotionWidget.swift | 131 + client/ios/情绪小组件/Info.plist | 11 + client/package.json | 43 + client/pnpm-lock.yaml | 7115 +++++++++++++++++ client/src/constants/env.ts | 33 + client/src/constants/mockContent.ts | 16 + client/src/i18n/index.ts | 123 + client/src/i18n/locales/en.json | 50 + client/src/i18n/locales/es.json | 50 + client/src/i18n/locales/pt.json | 50 + client/src/i18n/locales/zh-CN.json | 50 + client/src/i18n/locales/zh-TW.json | 50 + client/src/storage/appStorage.ts | 67 + client/tsconfig.json | 17 + 79 files changed, 12952 insertions(+), 1 deletion(-) delete mode 160000 client create mode 100644 client/.env.example create mode 100644 client/.vscode/extensions.json create mode 100644 client/.vscode/settings.json create mode 100644 client/README.md create mode 100644 client/app.json create mode 100644 client/app/(app)/_layout.tsx create mode 100644 client/app/(app)/favorites.tsx create mode 100644 client/app/(app)/home.tsx create mode 100644 client/app/(app)/settings.tsx create mode 100644 client/app/(onboarding)/_layout.tsx create mode 100644 client/app/(onboarding)/onboarding.tsx create mode 100644 client/app/(onboarding)/push-prompt.tsx create mode 100644 client/app/+html.tsx create mode 100644 client/app/+not-found.tsx create mode 100644 client/app/_layout.tsx create mode 100644 client/app/index.tsx create mode 100644 client/app/modal.tsx create mode 100755 client/assets/fonts/SpaceMono-Regular.ttf create mode 100644 client/assets/images/adaptive-icon.png create mode 100644 client/assets/images/favicon.png create mode 100644 client/assets/images/icon.png create mode 100644 client/assets/images/splash-icon.png create mode 100644 client/components/EditScreenInfo.tsx create mode 100644 client/components/ExternalLink.tsx create mode 100644 client/components/StyledText.tsx create mode 100644 client/components/Themed.tsx create mode 100644 client/components/__tests__/StyledText-test.js create mode 100644 client/components/useClientOnlyValue.ts create mode 100644 client/components/useClientOnlyValue.web.ts create mode 100644 client/components/useColorScheme.ts create mode 100644 client/components/useColorScheme.web.ts create mode 100644 client/constants/Colors.ts create mode 100644 client/ios/.xcode.env create mode 100644 client/ios/MindfulnessWidget/MindfulnessWidget.swift create mode 100644 client/ios/MindfulnessWidget/MindfulnessWidgetBundle.swift create mode 100644 client/ios/MindfulnessWidget/README.md create mode 100644 client/ios/Podfile create mode 100644 client/ios/Podfile.lock create mode 100644 client/ios/Podfile.properties.json create mode 100644 client/ios/client.xcodeproj/project.pbxproj create mode 100644 client/ios/client.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 client/ios/client.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 client/ios/client.xcodeproj/xcshareddata/xcschemes/client.xcscheme create mode 100644 client/ios/client.xcworkspace/contents.xcworkspacedata create mode 100644 client/ios/client/AppDelegate.swift create mode 100644 client/ios/client/Images.xcassets/AppIcon.appiconset/App-Icon-1024x1024@1x.png create mode 100644 client/ios/client/Images.xcassets/AppIcon.appiconset/Contents.json create mode 100644 client/ios/client/Images.xcassets/Contents.json create mode 100644 client/ios/client/Images.xcassets/SplashScreenBackground.colorset/Contents.json create mode 100644 client/ios/client/Images.xcassets/SplashScreenLegacy.imageset/Contents.json create mode 100644 client/ios/client/Images.xcassets/SplashScreenLegacy.imageset/image.png create mode 100644 client/ios/client/Images.xcassets/SplashScreenLegacy.imageset/image@2x.png create mode 100644 client/ios/client/Images.xcassets/SplashScreenLegacy.imageset/image@3x.png create mode 100644 client/ios/client/Info.plist create mode 100644 client/ios/client/PrivacyInfo.xcprivacy create mode 100644 client/ios/client/SplashScreen.storyboard create mode 100644 client/ios/client/Supporting/Expo.plist create mode 100644 client/ios/client/client-Bridging-Header.h create mode 100644 client/ios/client/client.entitlements create mode 100644 client/ios/情绪小组件/AppIntent.swift create mode 100644 client/ios/情绪小组件/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 client/ios/情绪小组件/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 client/ios/情绪小组件/Assets.xcassets/Contents.json create mode 100644 client/ios/情绪小组件/Assets.xcassets/WidgetBackground.colorset/Contents.json create mode 100644 client/ios/情绪小组件/EmotionWidget.swift create mode 100644 client/ios/情绪小组件/Info.plist create mode 100644 client/package.json create mode 100644 client/pnpm-lock.yaml create mode 100644 client/src/constants/env.ts create mode 100644 client/src/constants/mockContent.ts create mode 100644 client/src/i18n/index.ts create mode 100644 client/src/i18n/locales/en.json create mode 100644 client/src/i18n/locales/es.json create mode 100644 client/src/i18n/locales/pt.json create mode 100644 client/src/i18n/locales/zh-CN.json create mode 100644 client/src/i18n/locales/zh-TW.json create mode 100644 client/src/storage/appStorage.ts create mode 100644 client/tsconfig.json diff --git a/client b/client deleted file mode 160000 index e7237a8..0000000 --- a/client +++ /dev/null @@ -1 +0,0 @@ -Subproject commit e7237a8f3eb8ea3f5bda3dc8b8de6ac1cf0a4804 diff --git a/client/.env.example b/client/.env.example new file mode 100644 index 0000000..8e08019 --- /dev/null +++ b/client/.env.example @@ -0,0 +1,3 @@ +EXPO_PUBLIC_API_BASE_URL=http://localhost:8000 +EXPO_PUBLIC_ENV=dev +EXPO_PUBLIC_DEFAULT_LANGUAGE=auto diff --git a/client/.vscode/extensions.json b/client/.vscode/extensions.json new file mode 100644 index 0000000..b7ed837 --- /dev/null +++ b/client/.vscode/extensions.json @@ -0,0 +1 @@ +{ "recommendations": ["expo.vscode-expo-tools"] } diff --git a/client/.vscode/settings.json b/client/.vscode/settings.json new file mode 100644 index 0000000..e2798e4 --- /dev/null +++ b/client/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "editor.codeActionsOnSave": { + "source.fixAll": "explicit", + "source.organizeImports": "explicit", + "source.sortMembers": "explicit" + } +} diff --git a/client/README.md b/client/README.md new file mode 100644 index 0000000..ca175af --- /dev/null +++ b/client/README.md @@ -0,0 +1,343 @@ +# 正念 APP(客户端) + +本目录用于存放「正念 APP」客户端工程,定位是面向宝妈人群的情绪价值与正念练习支持应用。 + +## 功能概览(一期) + +- **APP-PUSH 推送**:定时推送情绪文字(固定时间 / 用户自定义时间),推送内容可由后端配置更新 +- **iOS 小组件(Widget)**:展示当前情绪文字与背景图/渐变色,支持多尺寸与可配置刷新频率 +- **情绪卡片滑动**:卡片列表左右滑动切换(类似 Tinder),收藏/分享可作为后续迭代 + +## 技术栈 + +- **React Native + Expo** +- **TypeScript** +- **React Navigation**:页面路由 +- **状态管理**:Zustand(或 Redux Toolkit) +- **网络请求**:Axios(或 Fetch) +- **推送**:Expo Notifications +- **环境区分**:Expo App Config + `.env.dev` / `.env.prod` +- **iOS 打包**:EAS Build + +## 开发环境要求 + +- **Node.js**:建议使用 **20+(LTS)** +- **包管理器**:pnpm +- **Expo CLI**:推荐通过 `npx expo` 使用(避免全局版本漂移) +- **iOS 真机调试**:macOS + Xcode(如需) + + +> 本项目已内置 `postinstall` 补丁脚本用于兼容(安装依赖后会自动修复),但仍建议升级到 Node 20+。 + +## 快速开始 + +> 说明:本仓库已包含 Expo 工程文件;如果你只是想把客户端跑起来,从「安装依赖」开始即可。 + +### 1)初始化工程(若尚未创建) + +在 `client/` 目录下创建 Expo 工程(示例): + +```bash +cd client +npx create-expo-app@latest . +``` + +执行后按提示选择模板即可;如果你希望固定使用 TypeScript 模板,也可以在创建时选择对应模板(以实际 CLI 提示为准)。 + +### 2)安装依赖 + +```bash +cd client +pnpm install +``` + +### 3)配置环境变量(dev / prod) + +按根目录约定,客户端使用 `.env.dev` 与 `.env.prod` 区分环境;`.env` 文件不提交到 Git,仓库内应提供 `.env.example` 作为模板。 + +#### 本地开发如何注入环境变量(推荐) + +Expo 在本地开发时会自动读取当前目录下的 `.env` / `.env.local`(仅以 `EXPO_PUBLIC_` 前缀变量注入到客户端运行时)。推荐做法: + +```bash +cd client +cp .env.example .env.local +# 然后按需修改 .env.local +pnpm start +``` + +> 修改 `.env*` 后需要重启 `pnpm start` 才会生效。 + +#### 本地开发如何区分 dev / prod(可选) + +如果你希望坚持使用 `.env.dev` / `.env.prod` 文件名,可以在启动前导出环境变量(zsh 示例): + +```bash +cd client +set -a +source .env.dev +set +a +pnpm start +``` + +> 注意:`source` 方式要求 `.env.dev` 内容是合法的 shell 格式(例如 `KEY=value`),不要带 `export` 以外的复杂语法;值包含空格时需要加引号。 + +#### 必填环境变量 + +- **`EXPO_PUBLIC_API_BASE_URL`**:后端 API 基地址 + - 本地:`http://localhost:8000` + - 真机联调:改为电脑局域网 IP(例如 `http://192.168.1.10:8000`) +- **`EXPO_PUBLIC_ENV`**:环境标识(建议 `dev` / `prod`) + +#### 可选环境变量 + +- **`EXPO_PUBLIC_DEFAULT_LANGUAGE`**:默认语言策略 + - `auto`:优先设备语言(默认) + - `zh-CN/en/es/pt/zh-TW`:固定默认语言(仍允许用户在设置中切换并持久化) + +建议字段(示例): + +```env +EXPO_PUBLIC_API_BASE_URL=http://localhost:8000 +EXPO_PUBLIC_ENV=dev +EXPO_PUBLIC_DEFAULT_LANGUAGE=auto +``` + +> 提示:在 Expo 中建议使用 `EXPO_PUBLIC_` 前缀,方便在运行时读取并支持 EAS 构建注入。 + +### 4)启动开发服务器 + +```bash +cd client +pnpm start +``` + +启动后你会看到终端输出的二维码(QR Code)与可用命令提示。 + +### 5)运行到 iOS / Android / 真机 / Web + +- **iOS 模拟器(macOS + Xcode)**: + - 方式一:在 `pnpm start` 的终端里按 `i` + - 方式二:直接运行: + +```bash +cd client +pnpm ios +``` + +- **Android 模拟器(Android Studio)**: + - 方式一:在 `pnpm start` 的终端里按 `a` + - 方式二:直接运行: + +```bash +cd client +pnpm android +``` + +- **真机(推荐使用 Expo Go)**: + - 手机安装 **Expo Go** + - 确保手机与电脑在**同一局域网** + - 运行 `pnpm start` 后,用 Expo Go 扫描终端二维码即可打开 App + +- **Web(可选)**: + +```bash +cd client +pnpm web +``` + +常用命令(脚本): + +```bash +pnpm ios +pnpm android +pnpm web +``` + +### 6)常见问题 + +- **真机打不开 / 扫码后卡住**: + - 检查手机与电脑是否同一 Wi-Fi + - 尝试重启 `pnpm start -- --clear` 清缓存 +- **启动时提示 `Networking has been disabled` / `Network connection is unreliable`**: + - 这是 Expo CLI 的网络探测/请求失败导致的,按下面方式跳过网络请求即可: + +```bash +cd client +EXPO_OFFLINE=1 pnpm start +``` + + - 如果你本机设置了代理(例如 `HTTP_PROXY/ALL_PROXY`),也可以临时关闭代理后再启动: + +```bash +cd client +unset HTTP_PROXY HTTPS_PROXY ALL_PROXY +pnpm start +``` + +- **Watchman 提示 Recrawl(不影响运行,但建议处理)**: + - 按提示执行一次即可清除警告: + +```bash +watchman watch-del '/Users/jojo/Desktop/lxy/gitea/mindfulness/client' ; watchman watch-project '/Users/jojo/Desktop/lxy/gitea/mindfulness/client' +``` +- **联调时请求打到 localhost**: + - 真机上 `localhost` 指向手机自身,请把 `EXPO_PUBLIC_API_BASE_URL` 改成电脑的局域网 IP(例如 `http://192.168.1.10:8000`),或使用内网穿透方案 + +## 与后端联调 + +- **后端基座**:FastAPI(详见 `server/README.md`) +- **API Base URL**:通过 `EXPO_PUBLIC_API_BASE_URL` 配置 +- **本地真机注意**:如果在手机上调试,请将 `localhost` 替换为电脑局域网 IP,或使用内网穿透方案 + +## 多语言(i18n):CN / EN / ES / PT / TC + +### 语言码约定 + +- **CN(简体中文)**:`zh-CN` +- **EN(英语)**:`en` +- **ES(西班牙语)**:`es` +- **PT(葡萄牙语)**:`pt` +- **TC(繁体中文)**:`zh-TW` + +### 推荐选型(Expo/RN 常用组合) + +- **文案管理**:`i18next` + `react-i18next` +- **系统语言读取**:`expo-localization` + +### 建议目录结构 + +```text +src/ +└── i18n/ + ├── index.ts # i18n 初始化(默认语言、回退语言、资源注册) + ├── locales/ + │ ├── zh-CN.json + │ ├── en.json + │ ├── es.json + │ ├── pt.json + │ └── zh-TW.json + └── types.ts #(可选)语言码与 key 的类型定义 +``` + +### 约定与最佳实践 + +- **key 规则**:使用稳定的点分层 key,例如 `common.ok`、`push.permissionTitle`、`cards.swipeHint` +- **默认与回退**:默认优先跟随用户当前设备语言(在支持列表内时生效);设备语言不支持时回退到 `zh-CN`(或团队指定默认) +- **插值与复数**:优先使用 i18next 的插值(例如 `{{name}}`),避免在代码里拼字符串 +- **动态切换**:提供“语言设置”入口允许手动切换;切换后持久化(例如 AsyncStorage),后续启动优先生效,并立即刷新文案 +- **语言优先级**:用户设置(若存在)> 设备语言(在支持列表内)> 默认回退(`zh-CN` 或团队指定默认) +- **设置入口建议**:设置页 -> 语言(或 设置页 -> 通用 -> 语言) +- **与服务端一致性**:若后端也需要多语言(推送文案等),建议统一语言码(`zh-CN/en/es/pt/zh-TW`)并在接口里明确 `lang` + +## 推送(Expo Notifications) + +客户端侧通常需要: + +- **申请通知权限** +- **获取 Expo Push Token** +- **将 Token 上报后端**(用于定时推送任务) + +后端侧通常需要: + +- **保存用户 Push Token** +- **按策略触发推送**(固定时间 / 用户自定义时间) +- **支持后台配置推送文案并更新** + +> 具体实现以客户端工程代码为准;当代码接入后,建议在 README 补充「Token 上报接口」与「字段定义」。 + +## iOS 小组件(Widget) + +小组件通常需要: + +- **数据源**:来自本地缓存或后端拉取(需要设计刷新策略与缓存) +- **展示内容**:当前情绪文字 + 背景图/渐变 +- **尺寸**:小 / 中 / 大 +- **刷新频率**:如每小时/每天(iOS 对频率有系统限制,以实际效果为准) + +> 若你计划使用 Expo 的相关能力,请在工程中明确选型与实现路径,并补充到本 README。 + +## EAS Build(iOS 打包) + +建议按根目录约定区分 dev/prod: + +- **dev**:`com.damer.mindfulness.dev` +- **prod**:`com.damer.mindfulness` + +常见流程(示例): + +```bash +cd client +eas login +eas build --platform ios --profile development +``` + +> 实际 profile、证书、bundle id、环境变量注入方式以工程内 `eas.json` / app config 为准。 + +## 目录结构(建议) + +当客户端工程落地后,建议使用更标准、可扩展的目录结构(Expo + TypeScript 常见组织方式): + +```text +client/ +├── app/ #(推荐)expo-router 路由目录(若使用 expo-router) +│ ├── (tabs)/ # Tabs 分组(可选) +│ ├── _layout.tsx # 根布局 +│ └── index.tsx # 首页 +├── src/ # 业务源码(与路由/平台代码解耦) +│ ├── components/ # 通用 UI 组件 +│ ├── features/ # 按功能域拆分(推荐) +│ │ ├── push/ # 推送相关(权限、token、上报等) +│ │ ├── widget/ # 小组件数据与样式相关 +│ │ └── cards/ # 情绪卡片滑动相关 +│ ├── hooks/ # 自定义 hooks +│ ├── navigation/ #(若不用 expo-router)React Navigation 配置 +│ ├── screens/ #(若不用 expo-router)页面 +│ ├── services/ # API 封装(request、接口定义) +│ ├── store/ # 状态管理(Zustand/RTK) +│ ├── utils/ # 工具函数(时间、格式化、校验等) +│ ├── constants/ # 常量与配置(主题、枚举等) +│ └── types/ # 全局类型声明 +├── assets/ # 静态资源(图片/字体/音频等) +├── app.json / app.config.ts # Expo 配置(环境区分可在此处理) +├── eas.json # EAS Build 配置(如使用) +├── package.json +└── README.md +``` + +> 说明:如果你不使用 `expo-router`,可以删除 `app/`,并以 `src/navigation/` + `src/screens/` 作为主路由结构;其余目录保持不变即可。 + +## iOS 小组件开发(V1:写死文案) + +> 重要:**Expo Go 不支持 WidgetKit**。要做真正的小组件,必须预构建 iOS 工程并用 Xcode 跑。 + +### 1)生成 iOS 工程 + +```bash +cd client +npx expo prebuild -p ios --no-install +``` + +生成后会得到 `client/ios/`(Xcode 工程文件都在里面)。 + +### 2)在 Xcode 创建 Widget Extension + +- 用 Xcode 打开:`client/ios/client.xcworkspace` +- 菜单:`File -> New -> Target... -> Widget Extension` +- Target 名称示例:`MindfulnessWidget` + +### 3)写死文案与三尺寸布局 + +仓库里已提供 SwiftUI 代码骨架(写死文案 + Small/Medium/Large + 点击跳转): + +- `client/ios/情绪小组件/EmotionWidget.swift` + +创建 Widget target 后,把该文件加入到 Widget target 中即可(Target Membership 勾选:`情绪小组件`)。 + +### 4)点击跳转到 Home(Deep Link) + +Widget 点击跳转使用: + +- `client:///(app)/home` + +代码里已通过 `widgetURL` 设置(见 `MindfulnessWidget.swift`)。 diff --git a/client/app.json b/client/app.json new file mode 100644 index 0000000..6c4afd0 --- /dev/null +++ b/client/app.json @@ -0,0 +1,40 @@ +{ + "expo": { + "name": "client", + "slug": "client", + "version": "1.0.0", + "orientation": "portrait", + "icon": "./assets/images/icon.png", + "scheme": "client", + "userInterfaceStyle": "automatic", + "newArchEnabled": true, + "splash": { + "image": "./assets/images/splash-icon.png", + "resizeMode": "contain", + "backgroundColor": "#ffffff" + }, + "ios": { + "supportsTablet": true, + "bundleIdentifier": "com.anonymous.client" + }, + "android": { + "adaptiveIcon": { + "foregroundImage": "./assets/images/adaptive-icon.png", + "backgroundColor": "#ffffff" + }, + "edgeToEdgeEnabled": true, + "predictiveBackGestureEnabled": false + }, + "web": { + "bundler": "metro", + "output": "static", + "favicon": "./assets/images/favicon.png" + }, + "plugins": [ + "expo-router" + ], + "experiments": { + "typedRoutes": true + } + } +} diff --git a/client/app/(app)/_layout.tsx b/client/app/(app)/_layout.tsx new file mode 100644 index 0000000..854f3ba --- /dev/null +++ b/client/app/(app)/_layout.tsx @@ -0,0 +1,50 @@ +import { Stack } from 'expo-router'; +import { Pressable, Text } from 'react-native'; +import { useTranslation } from 'react-i18next'; +import { useRouter } from 'expo-router'; + +export default function AppLayout() { + const router = useRouter(); + const { t } = useTranslation(); + + return ( + + ( + router.push('/(app)/settings')} + hitSlop={10} + > + {t('home.settings')} + + ), + headerLeft: () => ( + router.push('/(app)/favorites')} hitSlop={10}> + {t('home.favorites')} + + ), + }} + /> + + + + ); +} + diff --git a/client/app/(app)/favorites.tsx b/client/app/(app)/favorites.tsx new file mode 100644 index 0000000..8cbe391 --- /dev/null +++ b/client/app/(app)/favorites.tsx @@ -0,0 +1,61 @@ +import { useEffect, useMemo, useState } from 'react'; +import { FlatList, StyleSheet, Text, View } from 'react-native'; +import { useTranslation } from 'react-i18next'; + +import { MOCK_CONTENT } from '@/src/constants/mockContent'; +import { getFavorites } from '@/src/storage/appStorage'; + +export default function FavoritesScreen() { + const { t } = useTranslation(); + const [ids, setIds] = useState([]); + + useEffect(() => { + let cancelled = false; + (async () => { + const list = await getFavorites(); + if (!cancelled) setIds(list); + })(); + return () => { + cancelled = true; + }; + }, []); + + const items = useMemo(() => { + const map = new Map(MOCK_CONTENT.map((c) => [c.id, c])); + return ids.map((id) => map.get(id)).filter(Boolean) as { id: string; text: string }[]; + }, [ids]); + + return ( + + {items.length === 0 ? ( + {t('favorites.empty')} + ) : ( + it.id} + contentContainerStyle={styles.list} + renderItem={({ item }) => ( + + {item.text} + + )} + /> + )} + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1, padding: 16 }, + empty: { color: '#6B7280', fontSize: 16, textAlign: 'center', marginTop: 40 }, + list: { gap: 12, paddingBottom: 24 }, + row: { + borderRadius: 14, + padding: 16, + backgroundColor: '#F9FAFB', + borderWidth: StyleSheet.hairlineWidth, + borderColor: '#E5E7EB', + }, + text: { color: '#111827', fontSize: 16, lineHeight: 22 }, +}); + diff --git a/client/app/(app)/home.tsx b/client/app/(app)/home.tsx new file mode 100644 index 0000000..baa428e --- /dev/null +++ b/client/app/(app)/home.tsx @@ -0,0 +1,76 @@ +import { useMemo, useState } from 'react'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; +import { useTranslation } from 'react-i18next'; + +import { MOCK_CONTENT } from '@/src/constants/mockContent'; +import { addFavorite, setReaction } from '@/src/storage/appStorage'; + +export default function HomeScreen() { + const { t } = useTranslation(); + const [index, setIndex] = useState(0); + + const item = useMemo(() => MOCK_CONTENT[index % MOCK_CONTENT.length], [index]); + + async function onLike() { + await setReaction(item.id, 'like'); + await addFavorite(item.id); + setIndex((i) => i + 1); + } + + async function onDislike() { + await setReaction(item.id, 'dislike'); + setIndex((i) => i + 1); + } + + return ( + + + {item.text} + + + + + {t('home.dislike')} + + + {t('home.like')} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + padding: 20, + justifyContent: 'center', + gap: 16, + }, + card: { + borderRadius: 16, + padding: 20, + backgroundColor: '#F6F7FB', + borderWidth: StyleSheet.hairlineWidth, + borderColor: '#E5E7EB', + }, + text: { + fontSize: 20, + lineHeight: 28, + color: '#111827', + }, + actions: { + flexDirection: 'row', + gap: 12, + }, + button: { + flex: 1, + paddingVertical: 14, + borderRadius: 14, + alignItems: 'center', + }, + like: { backgroundColor: '#16A34A' }, + dislike: { backgroundColor: '#EF4444' }, + buttonText: { color: 'white', fontSize: 16, fontWeight: '600' }, +}); + diff --git a/client/app/(app)/settings.tsx b/client/app/(app)/settings.tsx new file mode 100644 index 0000000..cb6a633 --- /dev/null +++ b/client/app/(app)/settings.tsx @@ -0,0 +1,50 @@ +import Constants from 'expo-constants'; +import { StyleSheet, Text, View } from 'react-native'; +import { useTranslation } from 'react-i18next'; + +export default function SettingsScreen() { + const { t } = useTranslation(); + + const version = + Constants.expoConfig?.version ?? + // 兜底:部分环境下 expoConfig 可能为空 + 'unknown'; + + return ( + + + {t('settings.version')} + {version} + + + + {t('settings.widgetTitle')} + {t('settings.widgetDesc')} + + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1, padding: 16, gap: 16 }, + section: { + borderRadius: 14, + padding: 16, + backgroundColor: '#FFFFFF', + borderWidth: StyleSheet.hairlineWidth, + borderColor: '#E5E7EB', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + label: { color: '#374151', fontSize: 16 }, + value: { color: '#111827', fontSize: 16, fontWeight: '600' }, + card: { + borderRadius: 16, + padding: 16, + backgroundColor: '#111827', + }, + cardTitle: { color: 'white', fontSize: 16, fontWeight: '700', marginBottom: 8 }, + cardText: { color: '#E5E7EB', fontSize: 14, lineHeight: 20 }, +}); + diff --git a/client/app/(onboarding)/_layout.tsx b/client/app/(onboarding)/_layout.tsx new file mode 100644 index 0000000..916954e --- /dev/null +++ b/client/app/(onboarding)/_layout.tsx @@ -0,0 +1,18 @@ +import { Stack } from 'expo-router'; +import { useTranslation } from 'react-i18next'; + +export default function OnboardingLayout() { + const { t } = useTranslation(); + + return ( + + + + + ); +} + diff --git a/client/app/(onboarding)/onboarding.tsx b/client/app/(onboarding)/onboarding.tsx new file mode 100644 index 0000000..84e2a06 --- /dev/null +++ b/client/app/(onboarding)/onboarding.tsx @@ -0,0 +1,104 @@ +import { useMemo, useState } from 'react'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; +import { useRouter } from 'expo-router'; +import { useTranslation } from 'react-i18next'; + +import { setOnboardingCompleted } from '@/src/storage/appStorage'; + +type OnboardingPage = { + title: string; + desc: string; +}; + +export default function OnboardingScreen() { + const router = useRouter(); + const { t } = useTranslation(); + + // 3–5 页:这里默认 4 页,后续可按产品调整为 3 或 5 + const pages = useMemo( + () => [ + { title: t('onboarding.q1Title'), desc: t('onboarding.q1Desc') }, + { title: t('onboarding.q2Title'), desc: t('onboarding.q2Desc') }, + { title: t('onboarding.q3Title'), desc: t('onboarding.q3Desc') }, + { title: t('onboarding.q4Title'), desc: t('onboarding.q4Desc') } + ], + [t] + ); + + const total = pages.length; + const [step, setStep] = useState(0); + const page = pages[Math.min(step, total - 1)]; + + async function finishAndNext() { + await setOnboardingCompleted(true); + router.replace('/(onboarding)/push-prompt'); + } + + async function onNext() { + if (step >= total - 1) { + await finishAndNext(); + return; + } + setStep((s) => s + 1); + } + + async function onSkipPage() { + // 每页可跳过:直接进入下一页(最后一页则结束) + await onNext(); + } + + async function onSkipAll() { + // 一键跳过整个 Onboarding + await finishAndNext(); + } + + return ( + + + {t('onboarding.progress', { current: step + 1, total })} + + + + {page.title} + {page.desc} + + + + + {t('onboarding.skip')} + + + {t('onboarding.next')} + + + + + {t('onboarding.skipAll')} + + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1, padding: 20, justifyContent: 'center', gap: 16 }, + progress: { textAlign: 'center', color: '#6B7280' }, + card: { + borderRadius: 18, + padding: 20, + backgroundColor: '#FFFFFF', + borderWidth: StyleSheet.hairlineWidth, + borderColor: '#E5E7EB', + gap: 10 + }, + title: { fontSize: 22, fontWeight: '700', color: '#111827' }, + desc: { fontSize: 16, lineHeight: 22, color: '#374151' }, + actions: { flexDirection: 'row', gap: 12 }, + btn: { flex: 1, paddingVertical: 14, borderRadius: 14, alignItems: 'center' }, + primary: { backgroundColor: '#111827' }, + secondary: { backgroundColor: '#F3F4F6' }, + btnText: { fontSize: 16, fontWeight: '600', color: '#FFFFFF' }, + secondaryText: { color: '#111827' }, + skipAll: { alignItems: 'center', paddingTop: 10 }, + skipAllText: { color: '#6B7280', textDecorationLine: 'underline' } +}); + diff --git a/client/app/(onboarding)/push-prompt.tsx b/client/app/(onboarding)/push-prompt.tsx new file mode 100644 index 0000000..1448371 --- /dev/null +++ b/client/app/(onboarding)/push-prompt.tsx @@ -0,0 +1,74 @@ +import { useState } from 'react'; +import { Alert, Pressable, StyleSheet, Text, View } from 'react-native'; +import { useRouter } from 'expo-router'; +import { useTranslation } from 'react-i18next'; +import * as Notifications from 'expo-notifications'; + +import { setPushPromptState } from '@/src/storage/appStorage'; + +export default function PushPromptScreen() { + const router = useRouter(); + const { t } = useTranslation(); + const [loading, setLoading] = useState(false); + + async function goHome() { + router.replace('/(app)/home'); + } + + async function onLater() { + await setPushPromptState('skipped'); + await goHome(); + } + + async function onEnableNow() { + // 触发系统权限申请(可失败,但不阻塞进入主功能) + setLoading(true); + try { + await Notifications.requestPermissionsAsync(); + await setPushPromptState('enabled'); + await goHome(); + } catch (e) { + Alert.alert(t('push.errorTitle'), t('push.errorDesc')); + await goHome(); + } finally { + setLoading(false); + } + } + + return ( + + + {t('push.cardTitle')} + {t('push.cardDesc')} + + + + + {t('push.later')} + + + {loading ? t('push.loading') : t('push.enable')} + + + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1, padding: 20, justifyContent: 'center', gap: 16 }, + card: { + borderRadius: 18, + padding: 20, + backgroundColor: '#111827', + gap: 10 + }, + title: { color: 'white', fontSize: 20, fontWeight: '700' }, + desc: { color: '#E5E7EB', fontSize: 15, lineHeight: 21 }, + actions: { flexDirection: 'row', gap: 12 }, + btn: { flex: 1, paddingVertical: 14, borderRadius: 14, alignItems: 'center' }, + primary: { backgroundColor: '#16A34A' }, + secondary: { backgroundColor: '#F3F4F6' }, + btnText: { fontSize: 16, fontWeight: '600', color: '#FFFFFF' }, + secondaryText: { color: '#111827' } +}); + diff --git a/client/app/+html.tsx b/client/app/+html.tsx new file mode 100644 index 0000000..cb31090 --- /dev/null +++ b/client/app/+html.tsx @@ -0,0 +1,38 @@ +import { ScrollViewStyleReset } from 'expo-router/html'; + +// This file is web-only and used to configure the root HTML for every +// web page during static rendering. +// The contents of this function only run in Node.js environments and +// do not have access to the DOM or browser APIs. +export default function Root({ children }: { children: React.ReactNode }) { + return ( + + + + + + + {/* + Disable body scrolling on web. This makes ScrollView components work closer to how they do on native. + However, body scrolling is often nice to have for mobile web. If you want to enable it, remove this line. + */} + + + {/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */} +