59 Commits

Author SHA1 Message Date
雷汀岚
ce018880f4 fix(i18n): 繁中開屏 consent 文案不生效 - 寫死繁中文案、清理腳本、文件
- splash: 繁中 consent 使用元件內 ZH_TW_CONSENT,避免 bundle 快取
- splash: 使用 isTraditionalChineseLocaleTag 判斷繁中
- i18n: 註解與 __DEV__ debug log
- package: clean:cache, start:clean, ios:clean, clean:ios-build
- ALL_COPY: 故障排除與 consent 只改 zh-TW.json 說明
- spec_kit: Splash Consent overflow 記錄排查結論

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 23:05:17 +08:00
雷汀岚
b4ec17fcac onboarding: 转场动画、名字步取消自动跳页、标题个性化招呼语、Skip/提醒步等文案与交互优化
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 17:33:53 +08:00
雷汀岚
aa4e1e9947 onboarding: 选项字体与问题一致、底部间距、提醒页文案与移除底部 Skip
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 17:11:13 +08:00
66241e5231 Merge pull request 'damer' (#17) from damer into main
Reviewed-on: #17
2026-02-09 06:49:41 +00:00
吕新雨
e980bd4e4d fix:更新容器启动 2026-02-09 14:47:17 +08:00
吕新雨
0b8bbebf6a fix:增加定时推动 2026-02-09 11:52:11 +08:00
吕新雨
1e1e49ea57 fix:同意隐私 2026-02-05 16:33:58 +08:00
吕新雨
8e71503169 fix:修复 2026-02-05 16:06:49 +08:00
吕新雨
2b67a571bb f 2026-02-05 02:02:14 +08:00
吕新雨
8f84f25616 IOS小组件/文案 2026-02-05 01:49:29 +08:00
吕新雨
4c03fce720 APP-PUSH和纯色小组件 2026-02-05 01:14:13 +08:00
吕新雨
c1c2c6197d fix:小组件- PUSH 2026-02-03 17:43:58 +08:00
d742b398ef Merge pull request 'docs: 更新隐私协议与用户使用协议' (#16) from lei into main
Reviewed-on: #16
2026-02-03 05:51:54 +00:00
吕新雨
d525c6b939 fix:test打包 2026-02-03 13:50:58 +08:00
吕新雨
c37bb2e2a5 fix:打包 2026-02-03 11:19:25 +08:00
吕新雨
4c450db9b2 fix:环境变量 2026-02-03 02:36:42 +08:00
8c02c5b9a0 Merge pull request 'feat(client): i18n copy + reco lang refresh + home polish' (#15) from Hao into main
Reviewed-on: #15
2026-02-02 18:13:13 +00:00
吕新雨
90cbde1bac fix:部署 2026-02-03 02:04:48 +08:00
吕新雨
bb7a4c7361 fix:main.py 2026-02-03 01:44:25 +08:00
吕新雨
17d19f9172 更新蓝v 2026-02-03 01:36:35 +08:00
吕新雨
b35b71470d upload 2026-02-03 01:25:47 +08:00
吕新雨
ccdc6350a1 最后一班 2026-02-03 01:22:25 +08:00
吕新雨
7fea799087 最后一班 2026-02-03 01:21:10 +08:00
吕新雨
e6b64b39f2 最后一班 2026-02-03 01:18:47 +08:00
吕新雨
a457186f46 放在宿主机 2026-02-03 01:07:06 +08:00
吕新雨
2d5b8b094f fix:upload 2026-02-03 00:44:00 +08:00
吕新雨
ef7c4e774d end 2026-02-03 00:41:38 +08:00
吕新雨
cc36301638 u 2026-02-03 00:36:48 +08:00
吕新雨
5dd632d4d3 fix:更新 2026-02-03 00:26:13 +08:00
吕新雨
4d70f16b69 更新部署工作了 2026-02-03 00:23:48 +08:00
吕新雨
2ddee6b8f8 fix:更新runner 2026-02-02 23:02:49 +08:00
吕新雨
aa530b0ce3 fix:更新runner 2026-02-02 22:58:42 +08:00
cdd0ac32de Merge pull request 'fix:更新自建仓库' (#13) from damer into main
Reviewed-on: #13
2026-02-02 14:50:28 +00:00
吕新雨
d98ec76dc0 fix:更新自建仓库 2026-02-02 22:50:11 +08:00
3c35e14c3d Merge pull request 'damer' (#12) from damer into main
Reviewed-on: #12
2026-02-02 14:47:30 +00:00
吕新雨
c0deea9318 fix:更新自建仓库 2026-02-02 22:47:09 +08:00
915e995ab7 Merge pull request 'fix:更新' (#11) from damer into main
Reviewed-on: #11
2026-02-02 14:26:16 +00:00
吕新雨
0e42e6f2a9 fix:更新 2026-02-02 22:25:48 +08:00
39e4bcab6c Merge pull request 'fix:更新server' (#10) from damer into main
Reviewed-on: #10
2026-02-02 14:22:49 +00:00
吕新雨
69a1046ff4 fix:更新server 2026-02-02 22:22:20 +08:00
ceaf459d97 Merge pull request 'damer' (#9) from damer into main
Reviewed-on: #9
2026-02-02 14:01:03 +00:00
吕新雨
d9a5dbafd6 fix:新增工作流 2026-02-02 21:59:15 +08:00
1
996999453a feat(client): i18n copy + reco lang refresh + home polish 2026-02-02 20:11:09 +08:00
雷汀岚
3f91e734fa docs: 更新隐私协议与用户使用协议 2026-02-02 18:58:12 +08:00
吕新雨
86e4853709 Merge branch 'main' into damer 2026-02-02 18:37:44 +08:00
吕新雨
2adf2475fa fix:git忽略文件 2026-02-02 18:37:00 +08:00
Hao
9dbba04408 Merge pull request 'Hao' (#8) from Hao into main
Reviewed-on: #8
2026-02-02 09:33:41 +00:00
1
64b8352ad3 fix:并 2026-02-02 17:33:20 +08:00
吕新雨
240cdda68f Merge branch 'main' into damer 2026-02-02 16:54:51 +08:00
吕新雨
ce48e54c03 增加git忽略 2026-02-02 16:53:26 +08:00
吕新雨
d045237952 chore: 清理 Python 运行产物并忽略
- 从仓库移除 __pycache__/ 与 *.pyc 等运行产物,避免 git pull 覆盖未跟踪文件导致合并失败
- 在 .gitignore 增加 Python 运行产物忽略规则
2026-02-02 16:52:22 +08:00
3587a24115 Merge pull request '新功能:个性化推荐算法' (#7) from damer into main
Reviewed-on: #7
2026-02-02 08:48:11 +00:00
吕新雨
6dc4e2b943 新功能:个性化推荐算法 2026-02-02 16:47:37 +08:00
吕新雨
936094211b Merge remote-tracking branch 'origin/main' 2026-02-02 11:27:00 +08:00
吕新雨
be38d817d5 Merge branch 'damer' 2026-02-02 11:23:53 +08:00
吕新雨
58d17fc39f fix:更新算法 2026-02-02 11:22:35 +08:00
Hao
502a6ac500 Merge pull request 'Hao' (#5) from Hao into main
Reviewed-on: #5
2026-02-02 02:51:14 +00:00
f49cbb7186 Merge pull request 'fix:文明' (#4) from damer into main
Reviewed-on: #4
2026-02-02 02:49:27 +00:00
吕新雨
814b96edb6 fix:文明 2026-02-02 10:47:24 +08:00
226 changed files with 22025 additions and 1352 deletions

View File

@@ -1,4 +1,69 @@
请开始完成编码
客户端请按照标准的RN架构目录写代码
客户端API请求 统一使用utlis中封装的请求
客户端的架构目录参考
project-root
├── android/ # Android 原生工程
├── ios/ # iOS 原生工程
├── src/ # 业务代码主目录 ⭐⭐⭐
│ ├── app.tsx # App 入口(注册 Provider / Navigation
│ ├── navigation/ # 路由导航
│ │ ├── index.tsx
│ │ ├── RootNavigator.tsx
│ │ └── types.ts
│ ├── screens/ # 页面Screen 级别)
│ │ ├── Home/
│ │ │ ├── index.tsx
│ │ │ ├── styles.ts
│ │ │ └── hooks.ts
│ │ └── Profile/
│ ├── components/ # 通用 UI 组件(无业务)
│ │ ├── Button/
│ │ │ ├── index.tsx
│ │ │ └── styles.ts
│ │ └── Empty/
│ ├── modules/ # 业务模块(强烈推荐)
│ │ ├── user/
│ │ │ ├── api.ts
│ │ │ ├── model.ts
│ │ │ ├── store.ts
│ │ │ └── index.ts
│ │ └── emotion/
│ ├── services/ # 跨模块服务(网络、存储等)
│ │ ├── http.ts # axios/fetch 封装
│ │ ├── storage.ts # AsyncStorage 封装
│ │ └── logger.ts
│ ├── store/ # 全局状态Redux / Zustand / Jotai
│ │ ├── index.ts
│ │ └── middleware.ts
│ ├── hooks/ # 全局通用 hooks
│ │ ├── useTheme.ts
│ │ └── useDebounce.ts
│ ├── utils/ # 工具函数
│ │ ├── date.ts
│ │ └── uuid.ts
│ ├── constants/ # 常量
│ │ ├── colors.ts
│ │ ├── env.ts
│ │ └── storageKeys.ts
│ ├── assets/ # 静态资源
│ │ ├── images/
│ │ ├── icons/
│ │ └── fonts/
│ ├── theme/ # 主题系统
│ │ ├── index.ts
│ │ └── dark.ts
│ └── types/ # 全局 TS 类型
│ └── index.d.ts
├── __tests__/ # 测试
├── .env # 环境变量
├── babel.config.js
├── metro.config.js
├── tsconfig.json
├── package.json
└── index.js # RN 启动入口
后端请按照标准的python FastAPI 架构目录写代码
现在多语言仅支持 EN / TC
现在多语言仅支持 EN / TC
整个task.md执行完毕后需要在对应的overview.md标记并且说明变更的文件名

View File

@@ -9,6 +9,6 @@
- 输入/输出定义
- 验收标准(可验证)
3. 拆分后输出一个 `modules/` 目录结构列表,并为每个模块生成对应 spec 内容。
4. 保留大 spec.md 的高层背景/总览到 overview 部分。
4. 保留大 spec.md 的高层背景/总览到 overview 部分,并标明各个模块的实现顺序
5. 子模块之间按逻辑关系关联。
6. 不生成 plan.md 或 tasks.md仅拆出子模块 spec。
6. 不生成 plan.md 或 tasks.md仅拆出子模块 spec。

View File

@@ -2,3 +2,4 @@
根据对应的plan.md 生成task.md
任务清单详细可执行
执行完要标记
整个task.md执行完毕后需要在对应的overview.md标记

View File

@@ -0,0 +1 @@
使用测试工具完成集成测试,并给我一份简单的测试报告

View File

@@ -28,4 +28,5 @@ modules/ 可嵌套 modules/,每层都独立规范。
输出时根据这个结构生成内容时,请保持文件职责清晰。
简短记录项目的该层每个spec的内容 每次编码完成后更新overview.md
可以通过nvm 切换node版本
在对数据库操作中,禁止执行破坏性操作,如果必须请让我同意,并回复:允许操作数据库

View File

@@ -0,0 +1,5 @@
docker exec -it gitea-runner bash
# 然后在容器里安装 Node.js
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt-get install -y nodejs
node -v

View File

@@ -0,0 +1,104 @@
name: Build and Push Server Docker Image
# 手动触发 workflow从哪个分支运行就打包哪个分支的代码
on:
workflow_dispatch:
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
# 1⃣ Checkout 仓库代码
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
# 需要能 push tag请在仓库 Secrets 配置 RUNNER_TOKEN
token: ${{ secrets.RUNNER_TOKEN }}
persist-credentials: true
# 2⃣ 自动递增 tag 并推送回 Gitea 仓库(默认按 vX.Y.Z 的 patch +1
- name: Auto bump tag and push to repository
shell: bash
run: |
set -euo pipefail
# 配置提交信息(用于创建注释 tag
git config user.name "gitea-actions"
git config user.email "actions@local"
# 确保本地有最新 tags
git fetch --tags --force
# 取最新的 semver tagvX.Y.Z按版本号排序
LATEST_TAG="$(git tag --list 'v*' --sort=-v:refname | head -n 1 || true)"
echo "LATEST_TAG=${LATEST_TAG}"
if [[ -z "${LATEST_TAG}" ]]; then
NEXT_TAG="v1.0.0"
else
if [[ "${LATEST_TAG}" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
MAJOR="${BASH_REMATCH[1]}"
MINOR="${BASH_REMATCH[2]}"
PATCH="${BASH_REMATCH[3]}"
NEXT_TAG="v${MAJOR}.${MINOR}.$((PATCH + 1))"
else
# 如果最新 tag 不符合 vX.Y.Z回退到 v1.0.0,避免误解析
NEXT_TAG="v1.0.0"
fi
fi
echo "NEXT_TAG=${NEXT_TAG}"
# 如果 tag 已存在则直接复用(避免重复运行失败)
if git rev-parse -q --verify "refs/tags/${NEXT_TAG}" >/dev/null; then
echo "Tag ${NEXT_TAG} 已存在,跳过创建。"
else
git tag -a "${NEXT_TAG}" -m "Release ${NEXT_TAG}"
git push origin "${NEXT_TAG}"
fi
# 输出给后续步骤使用
if [[ -n "${GITHUB_ENV:-}" ]]; then
echo "IMAGE_TAG=${NEXT_TAG}" >> "$GITHUB_ENV"
fi
# 兼容部分 Gitea Runner 环境变量命名
if [[ -n "${GITEA_ENV:-}" ]]; then
echo "IMAGE_TAG=${NEXT_TAG}" >> "$GITEA_ENV"
fi
# 3⃣ 设置镜像仓库与镜像名称(自建 Registry / Docker Hub 都可)
- name: Set image variables
shell: bash
run: |
# 直接写死:推送到自建仓库
IMAGE_NAME="docker.damer.fun/damer/mindfulness-server"
if [[ -n "${GITHUB_ENV:-}" ]]; then
echo "IMAGE_NAME=$IMAGE_NAME" >> "$GITHUB_ENV"
fi
if [[ -n "${GITEA_ENV:-}" ]]; then
echo "IMAGE_NAME=$IMAGE_NAME" >> "$GITEA_ENV"
fi
# 4⃣ 登录镜像仓库(自建 Registry / Docker Hub
- name: Login to Docker Registry
uses: docker/login-action@v3
with:
# 与 IMAGE_NAME 的 registry 保持一致
registry: docker.damer.fun
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
# 5⃣ 构建 Docker 镜像(使用 server/ 作为构建上下文)
- name: Build Docker Image
shell: bash
run: |
docker build -f server/Dockerfile -t "$IMAGE_NAME:$IMAGE_TAG" server
# 6⃣ 推送 Docker 镜像到镜像仓库
- name: Push Docker Image
shell: bash
run: |
docker push "$IMAGE_NAME:$IMAGE_TAG"

View File

@@ -0,0 +1,431 @@
name: Deploy Server (SSH + Nginx 蓝绿)
# 手动触发选择部署环境dev/pro并输入要部署的镜像 tag
on:
workflow_dispatch:
inputs:
env:
description: '部署环境'
type: choice
required: true
options:
- dev
- pro
tag:
description: '要部署的镜像 Tag例如v1.2.7'
required: true
# 同一环境同一时间只允许一个部署在跑,避免互相覆盖
concurrency:
group: deploy-server-${{ github.event.inputs.env }}
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
env:
# 统一在 env 中注入变量,减少 runner 差异带来的兼容性问题
DEPLOY_ENV: ${{ github.event.inputs.env }}
DEPLOY_TAG: ${{ github.event.inputs.tag }}
# 镜像名(优先 vars.DOCKER_IMAGE未配置则步骤里兜底
DOCKER_IMAGE: ${{ vars.DOCKER_IMAGE }}
# 可选:明确 registry私有仓库用。未配置会从 DOCKER_IMAGE 推断
DOCKER_REGISTRY: ${{ vars.DOCKER_REGISTRY }}
# SSHdev 用 secretspro 用 vars按你现有用法
SSH_HOST: ${{ github.event.inputs.env == 'dev' && secrets.DEV_SSH_HOST || vars.PRO_SSH_HOST }}
SSH_USER: ${{ github.event.inputs.env == 'dev' && secrets.DEV_SSH_USER || vars.PRO_SSH_USER }}
SSH_PORT: ${{ github.event.inputs.env == 'dev' && secrets.DEV_SSH_PORT || vars.PRO_SSH_PORT }}
SSH_KEY: ${{ github.event.inputs.env == 'dev' && secrets.DEV_SSH_KEY || secrets.PRO_SSH_KEY }}
# 推荐把私钥做成 base64单行存到 Secrets避免多行变量丢换行
SSH_KEY_B64: ${{ github.event.inputs.env == 'dev' && secrets.DEV_SSH_KEY_B64 || secrets.PRO_SSH_KEY_B64 }}
# Nginx upstream 配置(建议放到 vars
NGINX_UPSTREAM_FILE: ${{ vars.NGINX_UPSTREAM_FILE }}
NGINX_UPSTREAM_NAME: ${{ vars.NGINX_UPSTREAM_NAME }}
# 蓝绿端口(宿主机端口,建议放到 vars未配置则脚本内有默认值
BLUE_PORT: ${{ vars.BLUE_PORT }}
GREEN_PORT: ${{ vars.GREEN_PORT }}
# 容器内监听端口FastAPI 常用 8000未配置则默认 8000
CONTAINER_PORT: ${{ vars.CONTAINER_PORT }}
# 健康检查路径(未配置则默认 /health如果你没有 health 接口,可改为 /docs 或 /
HEALTHCHECK_PATH: ${{ vars.HEALTHCHECK_PATH }}
# 可选:远端 env 文件路径(例如 /opt/mindfulness-server/.env.prod存在则 docker run --env-file
REMOTE_ENV_FILE: ${{ vars.REMOTE_ENV_FILE }}
steps:
- name: 配置 SSH Key密钥登陆
shell: bash
run: |
set -euo pipefail
# 镜像名兜底(与 .gitea/workflows/server-build.yml 的默认一致)
if [[ -z "${DOCKER_IMAGE:-}" ]]; then
DOCKER_IMAGE="docker.damer.fun/damer/mindfulness-server"
fi
if [[ -n "${GITHUB_ENV:-}" ]]; then
echo "DOCKER_IMAGE=${DOCKER_IMAGE}" >> "$GITHUB_ENV"
fi
if [[ -n "${GITEA_ENV:-}" ]]; then
echo "DOCKER_IMAGE=${DOCKER_IMAGE}" >> "$GITEA_ENV"
fi
mkdir -p ~/.ssh
chmod 700 ~/.ssh
# 从 Secrets 写入私钥(推荐使用 *_SSH_KEY_B64
SSH_KEY_PATH="${HOME}/.ssh/id_rsa"
if [[ -n "${SSH_KEY_B64:-}" ]]; then
# 兼容两种常见误配置:
# 1) *_SSH_KEY_B64 里其实粘贴的是“原始私钥”(包含 -----BEGIN ... PRIVATE KEY-----
# 2) base64 值在粘贴/保存过程中混入空白或其他无关字符
if printf '%s' "${SSH_KEY_B64}" | grep -qE 'BEGIN[[:space:]].*PRIVATE[[:space:]]KEY'; then
echo "提示:检测到 *_SSH_KEY_B64 看起来是原始私钥内容,将按原始私钥写入(建议你改用真正的 base64 单行值)。"
echo "SSH_KEY_B64 字符数(原始):${#SSH_KEY_B64}"
printf '%s' "${SSH_KEY_B64}" | tr -d '\r' > "${SSH_KEY_PATH}"
else
CLEAN_B64="$(printf '%s' "${SSH_KEY_B64}" | tr -d '\r\n\t ')"
echo "SSH_KEY_B64 字符数(原始/清理后):${#SSH_KEY_B64}/${#CLEAN_B64}"
if ! printf '%s' "${CLEAN_B64}" | base64 -d -i | tr -d '\r' > "${SSH_KEY_PATH}"; then
echo "私钥 base64 解码失败:"
echo "- 请确认你在 Gitea Secrets 配置的 *_SSH_KEY_B64 是“单行 base64 字符串”,不要带引号/前后空格。"
echo "- 推荐用仓库里的脚本生成并复制node scripts/ssh-key-to-b64.mjs ~/.ssh/你的私钥 --clipboard"
exit 1
fi
fi
else
echo "使用 SSH_KEY原始私钥写入。SSH_KEY 字符数:${#SSH_KEY}"
printf '%s' "${SSH_KEY}" | tr -d '\r' > "${SSH_KEY_PATH}"
fi
chmod 600 "${SSH_KEY_PATH}"
# 自检(不输出私钥内容,仅输出元信息,方便定位是否写入了错误文件/被截断)
echo "SSH_KEY_PATH=${SSH_KEY_PATH}"
KEY_BYTES="$(wc -c < "${SSH_KEY_PATH}" | tr -d ' ')"
KEY_LINES="$(wc -l < "${SSH_KEY_PATH}" | tr -d ' ')"
echo "SSH key 字节数:${KEY_BYTES}"
echo "SSH key 行数:${KEY_LINES}"
echo "SSH key 首行:$(head -n 1 "${SSH_KEY_PATH}" | tr -d '\r')"
echo "SSH key 末行:$(tail -n 1 "${SSH_KEY_PATH}" | tr -d '\r')"
if command -v stat >/dev/null 2>&1; then
# Ubuntu runner: stat -c不同系统做兼容
if stat -c '%a %U %G %n' "${SSH_KEY_PATH}" >/dev/null 2>&1; then
echo "SSH key 权限:$(stat -c '%a %U %G %n' "${SSH_KEY_PATH}")"
else
echo "SSH key 权限:$(stat -f '%Lp %Su %Sg %N' "${SSH_KEY_PATH}" 2>/dev/null || true)"
fi
fi
if command -v file >/dev/null 2>&1; then
echo "file(1) 识别:$(file "${SSH_KEY_PATH}")"
fi
if command -v sha256sum >/dev/null 2>&1; then
echo "SSH key sha256(前12位)$(sha256sum "${SSH_KEY_PATH}" | awk '{print substr($1,1,12)}')"
fi
echo "ssh 版本:$(ssh -V 2>&1 || true)"
echo "ssh-keygen 版本:$(ssh-keygen -V 2>&1 || true)"
echo "openssl 版本:$(openssl version 2>&1 || true)"
# 进一步校验OpenSSH 私钥中间的 base64 块是否可解码(不输出内容)
if grep -q '^-----BEGIN OPENSSH PRIVATE KEY-----$' "${SSH_KEY_PATH}" && grep -q '^-----END OPENSSH PRIVATE KEY-----$' "${SSH_KEY_PATH}"; then
# 注意:不要用变量名 in是 awk 关键字,部分实现会报语法错)
OPENSSH_B64_LEN="$(
awk '
BEGIN{in_block=0; n=0}
/^-----BEGIN OPENSSH PRIVATE KEY-----$/{in_block=1; next}
/^-----END OPENSSH PRIVATE KEY-----$/{in_block=0; exit}
in_block==1{gsub(/\r/,""); n+=length($0)}
END{print n}
' "${SSH_KEY_PATH}" 2>/dev/null || true
)"
if [[ -n "${OPENSSH_B64_LEN:-}" ]]; then
echo "OpenSSH base64 块字符数(合计):${OPENSSH_B64_LEN}"
fi
# 该校验仅用于提示,不应阻断部署流程
if ! awk '
BEGIN{in_block=0}
/^-----BEGIN OPENSSH PRIVATE KEY-----$/{in_block=1; next}
/^-----END OPENSSH PRIVATE KEY-----$/{in_block=0; exit}
in_block==1{gsub(/\r/,""); print}
' "${SSH_KEY_PATH}" 2>/dev/null | tr -d '\n' | base64 -d >/dev/null 2>&1; then
echo "提示OpenSSH 私钥的 base64 块无法解码(疑似内容被截断/损坏),将继续执行 ssh-keygen 校验定位。"
fi
fi
# 额外信息ssh-keygen -lf 的报错(不输出私钥内容)
if ! ssh-keygen -lf "${SSH_KEY_PATH}" >/dev/null 2>~/.ssh/ssh_key_fingerprint.err; then
echo "ssh-keygen -lf 报错:"
cat ~/.ssh/ssh_key_fingerprint.err || true
fi
# 快速校验私钥是否可解析(不会输出私钥内容)
if ! ssh-keygen -y -f "${SSH_KEY_PATH}" >/dev/null 2>~/.ssh/ssh_key_check.err; then
echo "SSH 私钥无法解析。下面是 ssh-keygen 的报错(不包含私钥内容):"
cat ~/.ssh/ssh_key_check.err || true
echo
echo "常见原因:"
echo "- 你填的是公钥(.pub不是私钥"
echo "- 私钥带口令CI 无法交互输入 passphrase"
echo "- 内容复制丢换行/被截断/不是正确的 base64"
echo
echo "推荐:生成一把“无口令”的部署专用私钥,并用 base64 存储到 Secrets*_SSH_KEY_B64。"
exit 1
fi
SSH_PORT="${SSH_PORT:-22}"
if [[ -n "${GITHUB_ENV:-}" ]]; then
echo "SSH_PORT=${SSH_PORT}" >> "$GITHUB_ENV"
fi
if [[ -n "${GITEA_ENV:-}" ]]; then
echo "SSH_PORT=${SSH_PORT}" >> "$GITEA_ENV"
fi
# 预写 known_hosts避免交互
ssh-keyscan -p "${SSH_PORT}" -H "${SSH_HOST}" >> ~/.ssh/known_hosts 2>/dev/null
- name: 验证 SSH 连接(快速定位公钥/用户/端口问题)
shell: bash
run: |
set -euo pipefail
SSH_PORT="${SSH_PORT:-22}"
ssh -p "${SSH_PORT}" \
-i ~/.ssh/id_rsa \
-o StrictHostKeyChecking=yes \
-o BatchMode=yes \
-o IdentitiesOnly=yes \
"${SSH_USER}@${SSH_HOST}" 'echo "SSH_OK $(whoami)@$(hostname)"'
- name: 远程登录 Docker Registry如需私有镜像
shell: bash
env:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DOCKER_TOKEN: ${{ secrets.DOCKER_TOKEN }}
run: |
set -euo pipefail
# 如果镜像是公开的,可不配 DOCKER_USERNAME/DOCKER_TOKEN此步骤会自动跳过
if [[ -z "${DOCKER_USERNAME:-}" || -z "${DOCKER_TOKEN:-}" ]]; then
echo "未配置 DOCKER_USERNAME/DOCKER_TOKEN跳过远程 docker login。"
exit 0
fi
SSH_PORT="${SSH_PORT:-22}"
# 推断 registry优先使用 DOCKER_REGISTRY否则从 DOCKER_IMAGE 的第一个段推断
REG="${DOCKER_REGISTRY:-}"
if [[ -z "${REG}" ]]; then
FIRST_SEG="${DOCKER_IMAGE%%/*}"
if [[ "${FIRST_SEG}" == *.* || "${FIRST_SEG}" == *:* || "${FIRST_SEG}" == "localhost" ]]; then
REG="${FIRST_SEG}"
fi
fi
if [[ -z "${REG}" ]]; then
echo "无法推断 registry看起来像 Docker Hub 公有镜像),跳过 docker login。"
exit 0
fi
ssh -p "${SSH_PORT}" -i ~/.ssh/id_rsa -o StrictHostKeyChecking=yes -o IdentitiesOnly=yes \
"${SSH_USER}@${SSH_HOST}" bash -s -- "${DOCKER_TOKEN}" "${DOCKER_USERNAME}" "${REG}" <<'REMOTE'
set -euo pipefail
TOKEN="$1"
USERNAME="$2"
REGISTRY="$3"
SUDO=""
if [[ "$(id -u)" -ne 0 ]] && command -v sudo >/dev/null 2>&1; then
SUDO="sudo"
fi
printf '%s' "$TOKEN" | ${SUDO} docker login "${REGISTRY}" -u "$USERNAME" --password-stdin
REMOTE
- name: SSH 部署Nginx 蓝绿切换)
shell: bash
run: |
set -euo pipefail
SSH_PORT="${SSH_PORT:-22}"
NGINX_UPSTREAM_NAME="${NGINX_UPSTREAM_NAME:-mindfulness_backend}"
NGINX_UPSTREAM_FILE="${NGINX_UPSTREAM_FILE:-/etc/nginx/conf.d/api.damer.fun.conf}"
BLUE_PORT="${BLUE_PORT:-8001}"
GREEN_PORT="${GREEN_PORT:-8002}"
CONTAINER_PORT="${CONTAINER_PORT:-8000}"
HEALTHCHECK_PATH="${HEALTHCHECK_PATH:-/health}"
echo "准备部署:${DOCKER_IMAGE}:${DEPLOY_TAG} -> ${DEPLOY_ENV} (${SSH_USER}@${SSH_HOST}:${SSH_PORT})"
ssh -p "${SSH_PORT}" -i ~/.ssh/id_rsa -o StrictHostKeyChecking=yes -o IdentitiesOnly=yes "${SSH_USER}@${SSH_HOST}" bash -s -- \
"${DOCKER_IMAGE}" "${DEPLOY_TAG}" "${NGINX_UPSTREAM_FILE}" "${NGINX_UPSTREAM_NAME}" "${BLUE_PORT}" "${GREEN_PORT}" "${CONTAINER_PORT}" "${HEALTHCHECK_PATH}" "${REMOTE_ENV_FILE:-}" <<'REMOTE'
set -euo pipefail
IMAGE="$1"
TAG="$2"
UPSTREAM_FILE="$3"
UPSTREAM_NAME="$4"
BLUE_PORT="$5"
GREEN_PORT="$6"
CONTAINER_PORT="$7"
HEALTHCHECK_PATH="$8"
REMOTE_ENV_FILE="$9"
APP_DIR="/opt/mindfulness-server"
ACTIVE_FILE="${APP_DIR}/active_color"
mkdir -p "${APP_DIR}"
# 判断 sudo如果非 root 且存在 sudo则使用 sudo
SUDO=""
if [[ "$(id -u)" -ne 0 ]] && command -v sudo >/dev/null 2>&1; then
SUDO="sudo"
fi
ACTIVE_COLOR="blue"
if [[ -f "${ACTIVE_FILE}" ]]; then
ACTIVE_COLOR="$(cat "${ACTIVE_FILE}" || echo blue)"
fi
if [[ "${ACTIVE_COLOR}" == "blue" ]]; then
NEW_COLOR="green"
NEW_PORT="${GREEN_PORT}"
OLD_COLOR="blue"
OLD_PORT="${BLUE_PORT}"
else
NEW_COLOR="blue"
NEW_PORT="${BLUE_PORT}"
OLD_COLOR="green"
OLD_PORT="${GREEN_PORT}"
fi
echo "当前在线:${ACTIVE_COLOR}${OLD_PORT}),准备发布:${NEW_COLOR}${NEW_PORT}"
# 拉取镜像
${SUDO} docker pull "${IMAGE}:${TAG}"
# 启动新颜色容器
${SUDO} docker rm -f "mindfulness-server-${NEW_COLOR}" >/dev/null 2>&1 || true
ENV_FILE_ARGS=()
if [[ -n "${REMOTE_ENV_FILE}" && -f "${REMOTE_ENV_FILE}" ]]; then
ENV_FILE_ARGS=(--env-file "${REMOTE_ENV_FILE}")
echo "将使用远端 env 文件:${REMOTE_ENV_FILE}"
elif [[ -n "${REMOTE_ENV_FILE}" ]]; then
echo "提示REMOTE_ENV_FILE 已配置但文件不存在:${REMOTE_ENV_FILE}(将忽略 env-file"
fi
${SUDO} docker run -d \
--name "mindfulness-server-${NEW_COLOR}" \
--restart=always \
-p "${NEW_PORT}:${CONTAINER_PORT}" \
"${ENV_FILE_ARGS[@]}" \
"${IMAGE}:${TAG}"
# 健康检查
if [[ "${HEALTHCHECK_PATH}" != /* ]]; then
HEALTHCHECK_PATH="/${HEALTHCHECK_PATH}"
fi
HEALTH_URL="http://127.0.0.1:${NEW_PORT}${HEALTHCHECK_PATH}"
echo "健康检查:${HEALTH_URL}"
for i in $(seq 1 30); do
if command -v curl >/dev/null 2>&1; then
if curl -fsS "${HEALTH_URL}" >/dev/null; then
echo "健康检查通过"
break
fi
elif command -v wget >/dev/null 2>&1; then
if wget -q -O /dev/null "${HEALTH_URL}"; then
echo "健康检查通过"
break
fi
else
echo "远端缺少 curl/wget跳过 HTTP 健康检查"
break
fi
if [[ "$i" -eq 30 ]]; then
echo "健康检查失败:新版本未就绪,回滚并退出"
${SUDO} docker logs --tail 200 "mindfulness-server-${NEW_COLOR}" || true
${SUDO} docker rm -f "mindfulness-server-${NEW_COLOR}" || true
exit 1
fi
sleep 2
done
# 切换 Nginx upstream在同一个 conf 文件中通过 backup 做主备切换)
if [[ ! -f "${UPSTREAM_FILE}" ]]; then
echo "未找到 Nginx upstream 配置文件:${UPSTREAM_FILE}"
${SUDO} docker rm -f "mindfulness-server-${NEW_COLOR}" || true
exit 1
fi
if ! ${SUDO} grep -qE "upstream[[:space:]]+${UPSTREAM_NAME}[[:space:]]*\\{" "${UPSTREAM_FILE}"; then
echo "在 ${UPSTREAM_FILE} 中未找到 upstream${UPSTREAM_NAME}"
${SUDO} docker rm -f "mindfulness-server-${NEW_COLOR}" || true
exit 1
fi
if ! ${SUDO} grep -qE "server[[:space:]]+127\\.0\\.0\\.1:${BLUE_PORT}" "${UPSTREAM_FILE}"; then
echo "在 ${UPSTREAM_FILE} 中未找到 server 127.0.0.1:${BLUE_PORT}(请先按参考配置写入 upstream"
${SUDO} docker rm -f "mindfulness-server-${NEW_COLOR}" || true
exit 1
fi
if ! ${SUDO} grep -qE "server[[:space:]]+127\\.0\\.0\\.1:${GREEN_PORT}" "${UPSTREAM_FILE}"; then
echo "在 ${UPSTREAM_FILE} 中未找到 server 127.0.0.1:${GREEN_PORT}(请先按参考配置写入 upstream"
${SUDO} docker rm -f "mindfulness-server-${NEW_COLOR}" || true
exit 1
fi
# 备份(回滚用)
BACKUP_FILE="${UPSTREAM_FILE}.bak.$(date +%s)"
${SUDO} cp -f "${UPSTREAM_FILE}" "${BACKUP_FILE}"
echo "切换 upstream${UPSTREAM_NAME}${OLD_PORT} -> ${NEW_PORT})通过 backup 切换"
TMP_FILE="$(mktemp)"
${SUDO} awk -v name="${UPSTREAM_NAME}" -v old="${OLD_PORT}" -v new="${NEW_PORT}" '
BEGIN { in_up = 0 }
$0 ~ ("^[[:space:]]*upstream[[:space:]]+" name "[[:space:]]*\\{[[:space:]]*$") { in_up = 1 }
in_up == 1 {
# 新端口:主(去掉 backup
if ($0 ~ ("server[[:space:]]+127\\.0\\.0\\.1:" new)) {
gsub(/[[:space:]]+backup[[:space:]]*;/, ";")
}
# 旧端口:备(确保有 backup;
if ($0 ~ ("server[[:space:]]+127\\.0\\.0\\.1:" old)) {
gsub(/[[:space:]]+backup[[:space:]]*;/, ";")
sub(/;[[:space:]]*$/, " backup;")
}
}
in_up == 1 && $0 ~ /^[[:space:]]*\}[[:space:]]*$/ { in_up = 0 }
{ print }
' "${UPSTREAM_FILE}" > "${TMP_FILE}"
${SUDO} cp -f "${TMP_FILE}" "${UPSTREAM_FILE}"
rm -f "${TMP_FILE}"
# 校验并 reload nginx失败则回滚并退出
if ${SUDO} nginx -t; then
${SUDO} nginx -s reload
else
echo "Nginx 配置校验失败,回滚 upstream 配置并退出"
${SUDO} cp -f "${BACKUP_FILE}" "${UPSTREAM_FILE}" || true
${SUDO} nginx -t && ${SUDO} nginx -s reload || true
${SUDO} docker rm -f "mindfulness-server-${NEW_COLOR}" || true
exit 1
fi
# 记录当前在线颜色
echo "${NEW_COLOR}" | ${SUDO} tee "${ACTIVE_FILE}" >/dev/null
# 下线旧容器(切流后再停旧的)
${SUDO} docker rm -f "mindfulness-server-${OLD_COLOR}" >/dev/null 2>&1 || true
echo "部署完成:${NEW_COLOR} 已上线"
REMOTE

4
.gitignore vendored
View File

@@ -4,6 +4,10 @@
.DS_Store
*.pem
# Python运行产物
__pycache__/
*.py[cod]
# Node / JS
node_modules/
npm-debug.*

View File

@@ -13,7 +13,9 @@ git pull
# 创建自己的分支
git checkout -b 姓名拼写
# 生产密钥
ssh-keygen -t rsa -b 4096 -m PEM -N '' -f deploy_key_rsa
# 目录结构
/mindfulness

View File

@@ -1,3 +1,7 @@
EXPO_PUBLIC_API_BASE_URL=http://localhost:8000
EXPO_PUBLIC_ENV=dev
EXPO_PUBLIC_DEFAULT_LANGUAGE=auto
#
# Expo/EAS 项目 IDUUID。用于真机获取 Expo Push Tokenexpo-notifications
# 获取方式:在 client 目录执行 `eas project:init` 或 `eas project:info` 查看。
EXPO_PUBLIC_EAS_PROJECT_ID=c519f016-e5c8-426c-868f-5545dce8beef

1
client/.npmrc Normal file
View File

@@ -0,0 +1 @@
registry=https://registry.npmmirror.com

30
client/app.config.ts Normal file
View File

@@ -0,0 +1,30 @@
import type { ConfigContext, ExpoConfig } from 'expo/config';
/**
* 运行时获取 Push Tokenexpo-notifications在真机/Dev Client 场景下通常需要 projectId。
*
* 这里把 projectId 注入到 `extra.eas.projectId`
* - 开发/本地:从 `.env.local`EXPO_PUBLIC_EAS_PROJECT_ID读取并写入配置
* - CI/EAS也可通过环境变量注入EXPO_PUBLIC_EAS_PROJECT_ID 或 EAS_PROJECT_ID
*/
export default ({ config }: ConfigContext): ExpoConfig => {
const projectId =
process.env.EXPO_PUBLIC_EAS_PROJECT_ID ||
// 兼容部分 CI/EAS 注入的变量名
process.env.EAS_PROJECT_ID ||
undefined;
return {
...config,
extra: {
...(config.extra ?? {}),
eas: {
// 保留已有配置,再覆盖 projectId
// eslint-disable-next-line @typescript-eslint/no-explicit-any
...(((config.extra as any) ?? {}).eas ?? {}),
projectId: projectId ?? (config.extra as any)?.eas?.projectId,
},
},
};
};

View File

@@ -1,6 +1,6 @@
{
"expo": {
"name": "client",
"name": "Hey Mama",
"slug": "client",
"version": "1.0.0",
"orientation": "portrait",
@@ -9,13 +9,13 @@
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"splash": {
"image": "./assets/images/splash-icon.png",
"image": "./assets/images/splashScreen.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
"backgroundColor": "#EAD2BA"
},
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.anonymous.client"
"bundleIdentifier": "com.damer.mindfulness"
},
"android": {
"adaptiveIcon": {
@@ -23,7 +23,8 @@
"backgroundColor": "#ffffff"
},
"edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false
"predictiveBackGestureEnabled": false,
"package": "com.damer.mindfulness"
},
"web": {
"bundler": "metro",
@@ -35,6 +36,13 @@
],
"experiments": {
"typedRoutes": true
}
},
"extra": {
"eas": {
"projectId": "c519f016-e5c8-426c-868f-5545dce8beef"
},
"router": {}
},
"owner": "damersu"
}
}

View File

@@ -1,9 +1,22 @@
import { useEffect } from 'react';
import { Stack } from 'expo-router';
import { useTranslation } from 'react-i18next';
import { ensureDailyWidgetRecoUpToDate, syncWidgetConfig, syncWidgetUserProfileFromStorage } from '@/src/modules/dailyWidgetReco';
export default function AppLayout() {
const { t } = useTranslation();
useEffect(() => {
// 小组件数据同步(仅 iOS 生效;内部会判断原生模块是否可用)
// 目的:避免“仅 Onboarding 写入一次”导致老用户小组件一直显示兜底文案
void (async () => {
await syncWidgetConfig();
await syncWidgetUserProfileFromStorage();
await ensureDailyWidgetRecoUpToDate({ reason: 'app_start' });
})();
}, []);
return (
<Stack
screenOptions={{
@@ -13,6 +26,8 @@ export default function AppLayout() {
<Stack.Screen
name="home"
options={{
// Home 页不使用系统 Header避免 iOS 原生导航栏自带的“毛玻璃/液玻璃”材质
headerShown: false,
// 卡片页标题按设计留空(右上角为 icon 按钮)
title: '',
headerShadowVisible: false,

View File

@@ -1,7 +1,8 @@
import { useEffect, useLayoutEffect, useMemo, useState, useCallback, useRef } from 'react';
import { StyleSheet, View, Dimensions, Text, Pressable, PanResponder, Animated as RNAnimated, ImageBackground } from 'react-native';
import { useTranslation } from 'react-i18next';
import { useNavigation, useFocusEffect } from 'expo-router';
import { useFocusEffect } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import Animated, {
Easing,
runOnJS,
@@ -18,9 +19,20 @@ import {
getUserProfile,
setReaction,
setThemeMode,
getRecoFeedCache,
setRecoFeedCache,
getUserProfileScoring,
getRecoFeedHistory,
recordRecoFeedServed,
type ThemeMode,
getSuixinThemeState,
setSuixinThemeState,
type SuixinThemeStateV1,
} from '@/src/storage/appStorage';
import { fetchRecoFeed } from '@/src/services/recoApi';
import { toBackendLocaleFromLanguageTag } from '@/src/i18n/locale';
import ProfileModal from '@/components/home/ProfileModal';
import ThemeModal from '@/components/home/ThemeModal';
@@ -29,6 +41,9 @@ import MyIcon from '@/assets/images/home/my.svg';
import LikeFilledIcon from '@/assets/images/home/like_filled.svg';
import LikeIcon from '@/assets/images/icon/like_icon.svg';
import { getBootId } from '@/src/utils/bootSession';
import { advanceSuixinState, buildInitialSuixinState, NEUTRAL_THEME_COLORS } from '@/src/features/suixinTheme';
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
// 预定义风景图列表
@@ -65,48 +80,191 @@ const THEME_COLORS = [
'#CBD9F2',
];
type FeedItem = { content_id: string; text: string };
export default function HomeScreen() {
const { t } = useTranslation();
const navigation = useNavigation();
const { t, i18n } = useTranslation();
const isEnglish = i18n.language?.startsWith('en');
const recoLang: 'en' | 'tc' = toBackendLocaleFromLanguageTag(i18n.language);
const insets = useSafeAreaInsets();
const [index, setIndex] = useState(0);
const [themeMode, setThemeModeState] = useState<ThemeMode>('scenery');
const [suixinBgColor, setSuixinBgColor] = useState<string>(NEUTRAL_THEME_COLORS[1]);
const [themeOpen, setThemeOpen] = useState(false);
const [profileOpen, setProfileOpen] = useState(false);
const [profileName, setProfileName] = useState<string | undefined>(undefined);
const [busy, setBusy] = useState(false);
const [likeFilled, setLikeFilled] = useState(false);
const [feedItems, setFeedItems] = useState<FeedItem[]>([]);
const [isFetching, setIsFetching] = useState(false);
const item = useMemo(() => MOCK_CONTENT[index % MOCK_CONTENT.length], [index]);
// 解决语言切换时重复触发拉取/清空导致“文案不停跳动”的问题:
// 用 ref 持有最新状态,避免 useCallback 依赖 feedItems/isFetching 造成函数 identity 变化 → effect 重复执行
const feedItemsRef = useRef<FeedItem[]>([]);
const isFetchingRef = useRef(false);
const themeModeRef = useRef<ThemeMode>('scenery');
const suixinStateRef = useRef<SuixinThemeStateV1 | null>(null);
useEffect(() => {
feedItemsRef.current = feedItems;
}, [feedItems]);
useEffect(() => {
isFetchingRef.current = isFetching;
}, [isFetching]);
useEffect(() => {
themeModeRef.current = themeMode;
}, [themeMode]);
const ensureSuixinReady = useCallback(async () => {
const bootId = getBootId();
const stored = await getSuixinThemeState();
if (stored && stored.boot_id === bootId) {
suixinStateRef.current = stored;
setSuixinBgColor(stored.last_color || NEUTRAL_THEME_COLORS[1]);
return stored;
}
const profile = await getUserProfileScoring();
const next = buildInitialSuixinState({ bootId, profile });
suixinStateRef.current = next;
setSuixinBgColor(next.last_color || NEUTRAL_THEME_COLORS[1]);
await setSuixinThemeState(next);
return next;
}, []);
const advanceSuixinOnNextContent = useCallback(async () => {
if (themeModeRef.current !== 'suixin') return;
const bootId = getBootId();
let current = suixinStateRef.current;
if (!current) {
current = await getSuixinThemeState();
}
// 冷启动后首次触发/或状态丢失:先初始化
if (!current || current.boot_id !== bootId) {
await ensureSuixinReady();
return;
}
const next = advanceSuixinState(current);
suixinStateRef.current = next;
setSuixinBgColor(next.last_color || NEUTRAL_THEME_COLORS[1]);
await setSuixinThemeState(next);
}, [ensureSuixinReady]);
// 动画相关 Shared Values
const translateY = useSharedValue(0);
const opacity = useSharedValue(1);
const likeScale = useSharedValue(1);
// 每次进入页面或页面获得焦点时刷新个人信息
// 统一文案对象结构
const currentFeed = useMemo(() => {
if (feedItems.length > 0) {
return feedItems;
}
return MOCK_CONTENT.map(item => ({
content_id: item.id,
text: t(item.textKey)
}));
}, [feedItems, t]);
const item = useMemo(() => {
const data = currentFeed[index % currentFeed.length];
return {
id: String(data.content_id),
text: data.text
};
}, [currentFeed, index]);
// 异步拉取新文案
const fetchNewFeed = useCallback(async () => {
if (isFetchingRef.current) return;
isFetchingRef.current = true;
setIsFetching(true);
try {
const scoringProfile = await getUserProfileScoring();
if (!scoringProfile) return;
const history = await getRecoFeedHistory();
const { items, meta } = await fetchRecoFeed({
k: 30,
user_profile: scoringProfile,
already_recommended_ids: history.already_recommended_ids,
touched_or_viewed_ids: history.touched_or_viewed_ids,
});
if (items.length > 0) {
const wasEmpty = feedItemsRef.current.length === 0;
const newCache = {
saved_at: new Date().toISOString(),
lang: recoLang,
items: items.map((x) => ({ content_id: x.content_id, text: x.text })),
meta: meta as Record<string, unknown>,
};
await setRecoFeedCache(newCache);
await recordRecoFeedServed(items.map((x) => x.content_id));
setFeedItems(newCache.items.map((x) => ({ content_id: String(x.content_id), text: x.text })));
// 如果当前是 mock 数据,切换到新数据的第一条
if (wasEmpty) {
setIndex(0);
}
}
} catch (error) {
console.error('Failed to fetch new feed:', error);
} finally {
isFetchingRef.current = false;
setIsFetching(false);
}
}, [recoLang]);
// 每次进入页面或页面获得焦点时刷新个人信息和缓存文案
useFocusEffect(
useCallback(() => {
let cancelled = false;
(async () => {
const mode = await getThemeMode();
const profile = await getUserProfile();
const cache = await getRecoFeedCache();
if (cancelled) return;
setThemeModeState(mode);
setProfileName(profile.name);
// 随心:若当前主题为随心,进入 Home 时确保状态就绪(仅冷启动会话重算)
if (mode === 'suixin') {
ensureSuixinReady().catch(() => {
// ignore失败时回退默认中性底色
setSuixinBgColor(NEUTRAL_THEME_COLORS[1]);
});
}
// 语言切换时:旧语言缓存不复用,触发重新拉取
if (cache && cache.items.length > 0 && (cache.lang ?? 'en') === recoLang) {
setFeedItems(cache.items.map((x) => ({ content_id: String(x.content_id), text: x.text })));
} else {
// 语言不匹配或没有缓存:先清空回落到本地 mock会立即随语言切换再拉取对应语言的推荐文案
setFeedItems([]);
setIndex(0);
fetchNewFeed();
}
})();
return () => {
cancelled = true;
};
}, [])
}, [fetchNewFeed, recoLang, ensureSuixinReady])
);
const backgroundColor = useMemo(() => {
if (themeMode === 'suixin') {
return suixinBgColor;
}
if (themeMode === 'color') {
const colorIndex = Math.floor(index / 10) % THEME_COLORS.length;
return THEME_COLORS[colorIndex];
}
return '#F4D6C2'; // 风景模式下的默认底色(图片加载前显示)
}, [themeMode, index]);
}, [themeMode, suixinBgColor, index]);
// 计算当前应该显示的风景图索引(滑动 10 次切换一张)
const natureImageIndex = useMemo(() => {
@@ -115,30 +273,6 @@ export default function HomeScreen() {
const currentNatureImage = NATURE_IMAGES[natureImageIndex];
useLayoutEffect(() => {
navigation.setOptions({
headerShadowVisible: false,
headerStyle: { backgroundColor: themeMode === 'scenery' ? 'transparent' : backgroundColor },
headerTransparent: themeMode === 'scenery',
headerRight: () => (
<View style={styles.headerRight}>
<CircleIconButton
onPress={() => setThemeOpen(true)}
accessibilityLabel={t('home.theme')}
>
<ThemeIcon width={18} height={18} />
</CircleIconButton>
<CircleIconButton
onPress={() => setProfileOpen(true)}
accessibilityLabel={t('home.profile')}
>
<MyIcon width={18} height={18} />
</CircleIconButton>
</View>
),
});
}, [backgroundColor, themeMode, navigation, t]);
const textAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ translateY: translateY.value }],
opacity: opacity.value,
@@ -160,6 +294,12 @@ export default function HomeScreen() {
// 2. 切换数据索引
runOnJS(setIndex)(index + 1);
runOnJS(setLikeFilled)(false);
runOnJS(advanceSuixinOnNextContent)();
// 检查是否需要拉取新文案(当接近当前列表末尾时,例如还剩 5 条)
if (index + 5 >= currentFeed.length && !isFetching) {
runOnJS(fetchNewFeed)();
}
// 3. 准备下一条文案:先瞬移到下方 40pt
translateY.value = 40;
@@ -173,7 +313,7 @@ export default function HomeScreen() {
});
}
});
}, [busy, index, translateY, opacity]);
}, [busy, index, currentFeed.length, isFetching, fetchNewFeed, translateY, opacity]);
const lastTapRef = useRef<number>(0);
@@ -223,20 +363,28 @@ export default function HomeScreen() {
const dateStr = `${now.getFullYear()}.${String(now.getMonth() + 1).padStart(2, '0')}.${String(now.getDate()).padStart(2, '0')}`;
// 2. 保存到收藏夹,包含当前背景信息
await addFavorite({
const favItem = {
favId: String(Date.now()), // 生成唯一 ID
id: item.id,
text: item.text,
date: dateStr,
themeMode: themeMode,
background: themeMode === 'scenery' ? String(natureImageIndex) : backgroundColor,
});
};
console.log('Home: Triggering addFavorite', JSON.stringify(favItem));
await addFavorite(favItem);
// 3. 爱心缩放动画
// 3. 记录到后端 Reaction喜欢
console.log('Home: Triggering setReaction', item.id);
await setReaction(item.id, 'like');
// 4. 爱心缩放动画
likeScale.value = withSequence(
withTiming(0.8, { duration: 100 }),
withTiming(1.2, { duration: 150 }),
withTiming(1, { duration: 100 }, (finished) => {
if (finished) {
console.log('Home: Like animation finished, triggering next content');
runOnJS(triggerNextContent)();
}
})
@@ -247,6 +395,13 @@ export default function HomeScreen() {
setThemeModeState(next);
await setThemeMode(next);
setThemeOpen(false);
// 切换到随心:不主动重算(除非冷启动会话变化/状态不存在),仅确保可用
if (next === 'suixin') {
await ensureSuixinReady().catch(() => {
setSuixinBgColor(NEUTRAL_THEME_COLORS[1]);
});
}
}
return (
@@ -258,8 +413,27 @@ export default function HomeScreen() {
resizeMode="cover"
/>
)}
{/* 自绘顶部按钮:不使用系统 Header彻底避免 iOS 导航栏的毛玻璃/液玻璃材质 */}
<View style={[styles.topRight, { top: insets.top + 8 }]}>
<CircleIconButton
onPress={() => setThemeOpen(true)}
accessibilityLabel={t('home.theme')}
>
<ThemeIcon width={18} height={18} />
</CircleIconButton>
<CircleIconButton
onPress={() => setProfileOpen(true)}
accessibilityLabel={t('home.profile')}
>
<MyIcon width={18} height={18} />
</CircleIconButton>
</View>
<Animated.View style={[styles.card, textAnimatedStyle, themeMode === 'scenery' && styles.sceneryCard]}>
<Text style={[styles.text, themeMode === 'scenery' && styles.sceneryText]}>{item.text}</Text>
<Text style={[styles.text, isEnglish && styles.textEnglish, themeMode === 'scenery' && styles.sceneryText]}>
{item.text}
</Text>
</Animated.View>
<View style={styles.actions}>
@@ -272,12 +446,12 @@ export default function HomeScreen() {
style={styles.reactionInner}
>
{likeFilled ? (
<LikeFilledIcon width={35} height={36} style={{ color: '#EA6969' }} />
<LikeFilledIcon width={35} height={36} color="#EA6969" />
) : (
<LikeIcon
width={35}
height={36}
style={{ color: themeMode === 'scenery' ? '#FFFFFF' : '#5E2A28' }}
color={themeMode === 'scenery' ? '#FFFFFF' : '#5E2A28'}
/>
)}
</Pressable>
@@ -323,10 +497,12 @@ const styles = StyleSheet.create({
justifyContent: 'center',
alignItems: 'center',
},
headerRight: {
topRight: {
position: 'absolute',
right: 20,
flexDirection: 'row',
gap: 10,
paddingRight: 10,
zIndex: 30,
},
circleBtn: {
width: 34,
@@ -354,6 +530,11 @@ const styles = StyleSheet.create({
fontWeight: '700',
textAlign: 'center',
},
textEnglish: {
fontFamily: 'STIXTwoText',
// 英文字体观感更细一点,避免过粗
fontWeight: '600',
},
sceneryCard: {
// 风景模式下稍微收窄文案宽度,增加呼吸感
paddingHorizontal: 50,

View File

@@ -12,7 +12,6 @@ export default function OnboardingLayout() {
}}
>
<Stack.Screen name="onboarding" options={{ title: t('onboarding.title') }} />
<Stack.Screen name="push-prompt" options={{ title: t('push.title') }} />
</Stack>
);
}

View File

@@ -1,75 +1,106 @@
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { useRouter } from 'expo-router';
import { useTranslation } from 'react-i18next';
import { Alert } from 'react-native';
import * as Notifications from 'expo-notifications';
import * as Device from 'expo-device';
import { OnboardingLayout } from '@/components/onboarding/OnboardingLayout';
import { NameInputStep } from '@/components/onboarding/NameInputStep';
import { SelectionStep } from '@/components/onboarding/SelectionStep';
import { ReminderStep } from '@/components/onboarding/ReminderStep';
import { setOnboardingCompleted, setUserProfile, setDailyReminderSettings } from '@/src/storage/appStorage';
import { buildUserProfileFromQuestionnaire, mapOnboardingSelectionsToQuestionnaireAnswers } from '@/src/features/userProfileScoring';
import { ensureDailyWidgetRecoUpToDate, syncWidgetConfig, syncWidgetUserProfileFromScoring } from '@/src/modules/dailyWidgetReco';
import { toBackendLocaleFromLanguageTag } from '@/src/i18n/locale';
import { fetchRecoFeed } from '@/src/services/recoApi';
import { getExpoPushTokenOrThrow, registerPushToken, setPushPreferences } from '@/src/services/pushApi';
import {
recordRecoFeedServed,
setOnboardingCompleted,
setUserProfile,
setDailyReminderSettings,
setUserProfileScoring,
setRecoFeedCache,
setPushPromptState,
} from '@/src/storage/appStorage';
const STEPS = [
{ id: 'name', type: 'name', title: '我可以怎么称呼你?' },
{
id: 'status',
type: 'selection',
title: '媽媽的狀態?',
options: [
{ id: 'pregnant', label: '懷孕中/準備成為媽媽' },
{ id: 'has_kids', label: '已經有孩子' },
{ id: 'no_fill', label: '不想填寫' },
]
},
{
id: 'emotion',
type: 'selection',
title: '當下情緒狀態?',
options: [
{ id: 'happy', label: '愉悅、滿足' },
{ id: 'calm', label: '平靜、安穩' },
{ id: 'stressed', label: '被壓得有點喘不過氣' },
{ id: 'low', label: '情緒低落' },
]
},
{
id: 'influence',
type: 'selection',
title: '是什麼影響了你最近的感受?',
options: [
{ id: 'family', label: '家庭與孩子' },
{ id: 'work', label: '工作或學習' },
{ id: 'relationship', label: '親密關係' },
{ id: 'friends', label: '朋友與人際' },
{ id: 'health', label: '身心健康' },
]
},
{
id: 'support',
type: 'selection',
title: '最需要什麼支持?',
options: [
{ id: 'emotional', label: '情緒支持' },
{ id: 'parenting', label: '育兒壓力' },
{ id: 'self_worth', label: '自我價值' },
{ id: 'anxiety', label: '焦慮舒緩' },
{ id: 'balance', label: '休息與平衡' },
]
},
{ id: 'reminder', type: 'reminder', title: '你需要每天几次提醒?' },
type Step =
| { id: 'name'; type: 'name' }
| { id: 'status' | 'emotion' | 'influence' | 'support'; type: 'selection'; optionIds: string[] }
| { id: 'reminder'; type: 'reminder' };
const STEPS: Step[] = [
{ id: 'name', type: 'name' },
{ id: 'status', type: 'selection', optionIds: ['pregnant', 'has_kids', 'no_fill'] },
{ id: 'emotion', type: 'selection', optionIds: ['happy', 'calm', 'okay', 'tired', 'stressed', 'low'] },
{ id: 'influence', type: 'selection', optionIds: ['family', 'work', 'relationship', 'friends', 'health'] },
{ id: 'support', type: 'selection', optionIds: ['emotional', 'parenting', 'self_worth', 'anxiety', 'balance'] },
{ id: 'reminder', type: 'reminder' },
];
export default function OnboardingScreen() {
const router = useRouter();
const { t, i18n } = useTranslation();
const [stepIndex, setStepIndex] = useState(0);
const [name, setName] = useState('');
const [selections, setSelections] = useState<Record<string, string[]>>({});
const [reminderTimes, setReminderTimes] = useState(3);
const currentStep = STEPS[stepIndex];
const currentTitle = useMemo(() => t(`onboardingSurvey.steps.${currentStep.id}.title`), [t, currentStep.id]);
const currentOptions = useMemo(() => {
if (currentStep.type !== 'selection') return [];
return currentStep.optionIds.map((optId) => ({
id: optId,
label: t(`onboardingSurvey.steps.${currentStep.id}.options.${optId}`),
}));
}, [t, currentStep]);
async function onFinish() {
// 请求推送权限
const { status } = await Notifications.requestPermissionsAsync();
const pushEnabled = status === 'granted';
// 用户选择每日次数 > 0在此页直接触发系统通知权限已移除单独的 push 引导页)。
const wantsPush = reminderTimes > 0;
// 将 Onboarding 选择映射为标准问卷枚举(允许跳过)
const answers = mapOnboardingSelectionsToQuestionnaireAnswers(selections);
// 生成用户画像(供推荐/Push/Widget 复用)
const scoringProfile = buildUserProfileFromQuestionnaire(answers);
await setUserProfileScoring(scoringProfile);
// 同步到 App Group供 iOS Widget 拉取与展示
await syncWidgetConfig();
await syncWidgetUserProfileFromScoring(scoringProfile);
// 可选:前台辅助拉取一次“每日推荐”,提升小组件首次展示的成功率与一致性(失败不阻塞)
await ensureDailyWidgetRecoUpToDate({ reason: 'onboarding_finish', scoringProfile });
// Onboarding 结束后预拉取一次 Feed 文案(失败不阻塞进入首页)
try {
const lang = toBackendLocaleFromLanguageTag(i18n.language);
const { items, meta } = await fetchRecoFeed({
k: 30,
user_profile: {
profile_version: scoringProfile.profile_version,
profile_source: scoringProfile.profile_source,
profile_generated_at: scoringProfile.profile_generated_at,
profile_confidence: scoringProfile.profile_confidence,
profile_answered: scoringProfile.profile_answered,
stage: scoringProfile.stage,
emotion_score: scoringProfile.emotion_score,
context: scoringProfile.context,
need: scoringProfile.need,
},
});
await setRecoFeedCache({
saved_at: new Date().toISOString(),
lang,
items: items.map((x) => ({ content_id: x.content_id, text: x.text })),
meta: meta as Record<string, unknown>,
});
await recordRecoFeedServed(items.map((x) => x.content_id));
} catch {
// 网络失败时使用首页本地 mock 兜底
}
await setUserProfile({
name,
@@ -77,10 +108,57 @@ export default function OnboardingScreen() {
});
await setDailyReminderSettings({
timesPerDay: reminderTimes,
pushEnabled: pushEnabled
// 这里表示“用户意愿”,不代表系统权限一定已 granted
pushEnabled: wantsPush,
});
await setOnboardingCompleted(true);
router.replace('/(app)/home');
// 用户选择 0 次(关闭)或跳过:直接进入首页
if (!wantsPush) {
await setPushPromptState('skipped');
router.replace('/(app)/home');
return;
}
// 用户想要 Push请求系统权限并尽量完成 token/偏好上报(失败不阻塞进入首页)
await setPushPromptState('unknown');
try {
const { status } = await Notifications.requestPermissionsAsync();
if (status !== 'granted') {
await setPushPromptState('skipped');
return;
}
// iOS 模拟器通常无法获取 Expo Push Token系统限制此时不要提示“失败”而是明确告知需要真机测试。
if (Device.osName === 'iOS' && !Device.isDevice) {
Alert.alert('提示', '当前为 iOS 模拟器,无法获取推送 Token。请使用真机测试推送功能。');
await setPushPromptState('unknown');
return;
}
// 1) 获取 Expo Push Token失败才认为“推送开启失败”
const expoPushToken = await getExpoPushTokenOrThrow();
// 2) 上报 token 到后端(幂等;失败才认为“推送开启失败”)
await registerPushToken({ pushToken: expoPushToken });
// 3) 上报推送偏好(幂等)
// 注意:这一步失败时,后端仍可能已成功接收 token。
// 为避免出现“后端已接收 token 但前端弹窗提示失败”的错觉,这里改为:偏好同步失败不弹“开启失败”,仅记录并继续。
try {
await setPushPreferences({ enabled: wantsPush, timesPerDay: reminderTimes });
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.warn('[PushPreferences] 同步失败Onboarding不阻塞', msg);
}
await setPushPromptState('enabled');
} catch (e) {
// 失败不阻塞进入首页;但这里给出更明确的文案(常见原因:模拟器/网络/后端异常)
Alert.alert(t('push.errorTitle'), t('push.errorDesc'));
await setPushPromptState('unknown');
} finally {
router.replace('/(app)/home');
}
}
const onNext = () => {
@@ -97,11 +175,20 @@ export default function OnboardingScreen() {
}
};
const onSkip = async () => {
await setOnboardingCompleted(true);
router.replace('/(app)/home');
/** 只跳過當前這一步(不填/不選當前題,進入下一步) */
const handleSkipCurrentStep = () => {
if (currentStep.type === 'name') {
onNext();
} else if (currentStep.type === 'selection') {
setSelections((prev) => ({ ...prev, [currentStep.id]: [] }));
onNext();
} else if (currentStep.type === 'reminder') {
setReminderTimes(0);
onFinish();
}
};
// 题目为多选:点击切换选中状态
const handleToggleSelection = (id: string) => {
setSelections(prev => {
const currentIds = prev[currentStep.id] || [];
@@ -112,14 +199,20 @@ export default function OnboardingScreen() {
});
};
const handleSkipStep = () => {
setSelections((prev) => ({ ...prev, [currentStep.id]: [] }));
onNext();
};
return (
<OnboardingLayout
title={currentStep.title}
title={currentTitle}
currentStep={stepIndex}
totalSteps={STEPS.length - 1}
onSkip={onSkip}
onSkip={handleSkipCurrentStep}
onBack={onBack}
showBackButton={stepIndex > 0}
userName={name}
>
{currentStep.type === 'name' && (
<NameInputStep
@@ -131,7 +224,7 @@ export default function OnboardingScreen() {
{currentStep.type === 'selection' && (
<SelectionStep
options={currentStep.options!}
options={currentOptions}
selectedIds={selections[currentStep.id] || []}
onToggle={handleToggleSelection}
onNext={onNext}
@@ -140,9 +233,14 @@ export default function OnboardingScreen() {
{currentStep.type === 'reminder' && (
<ReminderStep
value={reminderTimes}
value={Math.max(1, reminderTimes)}
onChange={setReminderTimes}
onFinish={onFinish}
onSkip={() => {
// 跳过每日提醒:视为 0 次(关闭)
setReminderTimes(0);
onFinish();
}}
/>
)}
</OnboardingLayout>

View File

@@ -1,74 +0,0 @@
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 (
<View style={styles.container}>
<View style={styles.card}>
<Text style={styles.title}>{t('push.cardTitle')}</Text>
<Text style={styles.desc}>{t('push.cardDesc')}</Text>
</View>
<View style={styles.actions}>
<Pressable style={[styles.btn, styles.secondary]} onPress={onLater} disabled={loading}>
<Text style={[styles.btnText, styles.secondaryText]}>{t('push.later')}</Text>
</Pressable>
<Pressable style={[styles.btn, styles.primary]} onPress={onEnableNow} disabled={loading}>
<Text style={styles.btnText}>{loading ? t('push.loading') : t('push.enable')}</Text>
</Pressable>
</View>
</View>
);
}
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' }
});

View File

@@ -1,10 +1,14 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Dimensions, Platform, Alert, Image } from 'react-native';
import { useRouter } from 'expo-router';
import * as WebBrowser from 'expo-web-browser';
import { useTranslation } from 'react-i18next';
import { Trans, useTranslation } from 'react-i18next';
import { SafeAreaView } from 'react-native-safe-area-context';
import { setConsentAccepted, getConsentAccepted } from '../../src/storage/appStorage';
import { fetchLegalLinks } from '@/src/services/legalApi';
import { getOnboardingCompleted } from '@/src/storage/appStorage';
import { API_BASE_URL } from '@/src/constants/env';
import { isTraditionalChineseLocaleTag } from '@/src/i18n/locale';
// 导入 SVG 组件
import FlowersBg from '../../assets/images/index/flowers_endbg.svg';
@@ -12,20 +16,55 @@ import WelcomeBtn from '../../assets/images/index/welcome_btn.svg';
const { width, height } = Dimensions.get('window');
// 繁中開屏 consent 文案:寫死在元件內,避免 Metro/iOS bundle 快取導致永遠顯示舊文案。
// 若需修改,請改這裡並同步 client/src/i18n/locales/zh-TW.json 的 consent 區塊。
const ZH_TW_CONSENT = {
title: '我們知道,',
subtitle: '當媽媽很不容易。',
subtitleSecondary: '這裡給你一些溫柔的肯定與提醒',
};
export default function SplashScreen() {
const router = useRouter();
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const [showConsent, setShowConsent] = useState(false);
// 繁中時強制使用上方常數(含 zh-TW / zh-Hant / zh-Hant-TW其餘用 i18n
const isZhTW = isTraditionalChineseLocaleTag(i18n.language || '');
const title = isZhTW ? ZH_TW_CONSENT.title : t('consent.title');
const subtitle = isZhTW ? ZH_TW_CONSENT.subtitle : t('consent.subtitle');
const subtitleSecondary = isZhTW ? ZH_TW_CONSENT.subtitleSecondary : t('consent.subtitleSecondary');
useEffect(() => {
if (typeof __DEV__ !== 'undefined' && __DEV__ && showConsent) {
console.log('[i18n consent] language=', i18n.language, 'title=', title, 'subtitle=', subtitle);
}
}, [showConsent, i18n.language, title, subtitle]);
const [links, setLinks] = useState<{ privacy?: string; terms?: string }>({});
const [linksLoading, setLinksLoading] = useState(false);
const mountedRef = useRef(true);
useEffect(() => {
checkConsent();
}, []);
useEffect(() => {
return () => {
mountedRef.current = false;
};
}, []);
const checkConsent = async () => {
const accepted = await getConsentAccepted();
setShowConsent(!accepted);
if (accepted) {
router.replace('/');
// 已同意协议则直接分发到目标页,避免先回到 /index再二次跳转导致“闪一下”
const completed = await getOnboardingCompleted();
if (completed) {
router.replace('/(app)/home');
} else {
router.replace('/(onboarding)/onboarding');
}
}
};
@@ -44,6 +83,50 @@ export default function SplashScreen() {
}
};
async function refreshLegalLinks(): Promise<{ privacy?: string; terms?: string }> {
if (mountedRef.current) setLinksLoading(true);
try {
const res = await fetchLegalLinks();
const next = { privacy: res.privacyPolicyUrl, terms: res.termsOfUseUrl };
if (mountedRef.current) setLinks(next);
return next;
} catch (e) {
// 不阻塞主流程:失败时不崩溃,链接入口仍可点(会提示)
if (__DEV__) console.log('[LegalLinks] 拉取失败splash:', e);
if (mountedRef.current) setLinks({});
return {};
} finally {
if (mountedRef.current) setLinksLoading(false);
}
}
async function handleOpenLegal(type: 'privacy' | 'terms') {
const currentUrl = type === 'privacy' ? links.privacy : links.terms;
if (currentUrl) {
await openLink(currentUrl);
return;
}
// 链接还没拿到/拉取失败:点击时主动再拉一次,避免“点了没反应”
const next = await refreshLegalLinks();
const nextUrl = type === 'privacy' ? next.privacy : next.terms;
if (nextUrl) {
await openLink(nextUrl);
return;
}
const msg =
typeof __DEV__ !== 'undefined' && __DEV__
? t('consent.linkUnavailableDev', { baseUrl: API_BASE_URL })
: t('consent.linkUnavailable');
Alert.alert(t('common.notice'), msg);
}
// 拉取协议链接(由后端按语言下发;默认 EN
useEffect(() => {
void refreshLegalLinks();
}, []);
const bgDecorationTop = 363;
const bgDecorationHeight = height * 0.6;
const contentTop = bgDecorationTop + (bgDecorationHeight * 0.25);
@@ -64,31 +147,58 @@ export default function SplashScreen() {
/>
</View>
{/* 文案内容 */}
{/* 文案内容:主標題兩行 + 可選二級標題(字號更小、顏色更淺);繁中為元件內常數,其餘用 i18n */}
<View style={[styles.contentContainer, { position: 'absolute', top: contentTop }]}>
<Text style={styles.titleText}>
You Are Perfect.{"\n"}
Everything{"\n"}
Will Be Better.
{title}
{'\n'}
{subtitle}
</Text>
{subtitleSecondary ? (
<Text style={styles.consentSubtitleSecondary}>{subtitleSecondary}</Text>
) : null}
</View>
<SafeAreaView style={styles.bottomContainer} edges={['bottom']}>
{showConsent && (
<>
<TouchableOpacity onPress={handleAgree} activeOpacity={0.8} style={styles.buttonWrapper}>
<TouchableOpacity
onPress={handleAgree}
activeOpacity={0.8}
style={styles.buttonWrapper}
accessibilityRole="button"
accessibilityLabel={t('consent.agree')}
>
<WelcomeBtn width={87} height={57} />
</TouchableOpacity>
<View style={styles.linksContainer}>
<TouchableOpacity onPress={() => openLink('https://example.com/privacy')}>
<Text style={styles.linkText}>{t('consent.privacy')}</Text>
</TouchableOpacity>
<View style={styles.divider} />
<TouchableOpacity onPress={() => openLink('https://example.com/terms')}>
<Text style={styles.linkText}>{t('consent.terms')}</Text>
</TouchableOpacity>
</View>
<Text style={styles.noticeText}>
<Trans
i18nKey="consent.noticeRich"
values={{
privacyLabel: t('consent.privacy'),
termsLabel: t('consent.terms'),
privacySuffix: !links.privacy && linksLoading ? t('consent.linkLoadingSuffix') : '',
termsSuffix: !links.terms && linksLoading ? t('consent.linkLoadingSuffix') : '',
}}
components={{
privacy: (
<Text
style={[styles.noticeLinkText, !links.privacy && styles.noticeLinkTextDisabled]}
onPress={() => void handleOpenLegal('privacy')}
suppressHighlighting
/>
),
terms: (
<Text
style={[styles.noticeLinkText, !links.terms && styles.noticeLinkTextDisabled]}
onPress={() => void handleOpenLegal('terms')}
suppressHighlighting
/>
),
}}
/>
</Text>
</>
)}
</SafeAreaView>
@@ -127,6 +237,14 @@ const styles = StyleSheet.create({
fontWeight: '600',
fontFamily: Platform.OS === 'ios' ? 'STIX Two Text' : 'serif',
},
consentSubtitleSecondary: {
marginTop: 12,
fontSize: 16,
lineHeight: 22,
color: 'rgba(119, 47, 0, 0.6)',
textAlign: 'center',
fontFamily: Platform.OS === 'ios' ? 'STIX Two Text' : 'serif',
},
bottomContainer: {
position: 'absolute',
bottom: 60,
@@ -137,19 +255,22 @@ const styles = StyleSheet.create({
buttonWrapper: {
marginBottom: 40,
},
linksContainer: {
flexDirection: 'row',
alignItems: 'center',
},
linkText: {
noticeText: {
marginTop: 10,
paddingHorizontal: 28,
fontSize: 12,
color: 'rgba(119, 47, 0, 0.5)', // 使用半透明的文字颜色
textDecorationLine: 'underline',
lineHeight: 16,
textAlign: 'center',
color: 'rgba(119, 47, 0, 0.45)',
},
divider: {
width: 1,
height: 12,
backgroundColor: 'rgba(119, 47, 0, 0.2)',
marginHorizontal: 15,
noticeLinkText: {
fontSize: 12,
// 颜色区分:协议链接更醒目
color: 'rgba(119, 47, 0, 0.75)',
textDecorationLine: 'underline',
fontWeight: '600',
},
noticeLinkTextDisabled: {
opacity: 0.55,
},
});

View File

@@ -4,16 +4,22 @@ import { useFonts } from 'expo-font';
import { Stack } from 'expo-router';
import * as SplashScreen from 'expo-splash-screen';
import * as Notifications from 'expo-notifications';
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import 'react-native-reanimated';
import { Animated, AppState, Image, StyleSheet, View } from 'react-native';
import { useColorScheme } from '@/components/useColorScheme';
import { initI18n } from '@/src/i18n';
import { ensureDailyWidgetRecoUpToDate, syncWidgetConfig, syncWidgetUserProfileFromStorage } from '@/src/modules/dailyWidgetReco';
import { getOrCreateClientUserId } from '@/src/storage/appStorage';
// 配置通知处理方式(即使不发送也建议配置,以确保权限接口正常)
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
// 新版 expo-notifications 类型要求显式返回 banner/list 行为
shouldShowBanner: true,
shouldShowList: true,
shouldPlaySound: false,
shouldSetBadge: false,
}),
@@ -26,7 +32,8 @@ export {
export const unstable_settings = {
// Ensure that reloading on `/modal` keeps a back button present.
initialRouteName: 'index',
// 让首次启动(未同意协议)直接进入协议页,避免先渲染 index 再跳转导致“闪一下”
initialRouteName: '(splash)/splash',
};
// Prevent the splash screen from auto-hiding before asset loading is complete.
@@ -34,10 +41,14 @@ SplashScreen.preventAutoHideAsync();
export default function RootLayout() {
const [loaded, error] = useFonts({
SpaceMono: require('../assets/fonts/SpaceMono-Regular.ttf'),
STIXTwoText: require('../assets/fonts/STIXTwoText-VariableFont_wght.ttf'),
...FontAwesome.font,
});
const [i18nReady, setI18nReady] = useState(false);
const [appReady, setAppReady] = useState(false);
const [splashOverlayVisible, setSplashOverlayVisible] = useState(true);
const splashOpacity = useRef(new Animated.Value(1)).current;
const hasHiddenNativeSplashRef = useRef(false);
// Expo Router uses Error Boundaries to catch errors in the navigation tree.
useEffect(() => {
@@ -48,31 +59,95 @@ export default function RootLayout() {
initI18n()
.catch((e) => {
// i18n 初始化失败不应阻塞 App 启动,先打印错误再继续
console.error('i18n 初始化失败', e);
console.error('i18n init failed', e);
})
.finally(() => setI18nReady(true));
}, []);
useEffect(() => {
// 等字体与 i18n 都准备好后再隐藏启动页,避免文案闪烁
if (loaded && i18nReady) {
SplashScreen.hideAsync();
}
// 尽早生成 client_user_id便于后续任意时刻与后端建立关联Push Token/偏好等)
getOrCreateClientUserId()
.then((id) => {
if (__DEV__) console.log('[client_user_id]', id);
})
.catch((e) => {
console.warn('[client_user_id] 生成失败(不阻塞启动)', e);
});
}, []);
useEffect(() => {
// 字体与 i18n 都准备好后,允许渲染 App原生 splash 的隐藏交给 onLayout避免“硬切/闪白”)
if (loaded && i18nReady) setAppReady(true);
}, [loaded, i18nReady]);
const onLayoutRootView = useCallback(() => {
if (!appReady) return;
if (hasHiddenNativeSplashRef.current) return;
hasHiddenNativeSplashRef.current = true;
// 先隐藏原生 splash再把同款覆盖层淡出视觉上实现平滑过渡
void SplashScreen.hideAsync().finally(() => {
Animated.timing(splashOpacity, {
toValue: 0,
duration: 380,
useNativeDriver: true,
}).start(({ finished }) => {
if (finished) setSplashOverlayVisible(false);
});
});
}, [appReady, splashOpacity]);
const content = useMemo(() => {
if (!appReady) return null;
return <RootLayoutNav />;
}, [appReady]);
if (!loaded || !i18nReady) {
return null;
}
return <RootLayoutNav />;
return (
<View style={styles.root} onLayout={onLayoutRootView}>
{content}
{splashOverlayVisible && (
<Animated.View pointerEvents="none" style={[StyleSheet.absoluteFill, { opacity: splashOpacity }]}>
<View style={styles.splashOverlay}>
<Image
source={require('../assets/images/splashScreen.png')}
style={styles.splashImage}
resizeMode="contain"
/>
</View>
</Animated.View>
)}
</View>
);
}
function RootLayoutNav() {
const colorScheme = useColorScheme();
useEffect(() => {
// iOS 小组件:启动时把必要信息写入共享区,并尽力刷新一次“每日推荐”
syncWidgetConfig().catch(() => {});
syncWidgetUserProfileFromStorage().catch(() => {});
ensureDailyWidgetRecoUpToDate({ reason: 'app_start' }).catch(() => {});
const sub = AppState.addEventListener('change', (state) => {
if (state === 'active') {
// App 回到前台时尝试刷新(失败不阻塞)
ensureDailyWidgetRecoUpToDate({ reason: 'app_active' }).catch(() => {});
}
});
return () => sub.remove();
}, []);
return (
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
<Stack screenOptions={{ headerShown: false }}>
{/* 协议页分组(首次启动优先进入) */}
<Stack.Screen name="(splash)" />
{/* 启动分发页:根据 onboarding 状态跳转 */}
<Stack.Screen name="index" />
@@ -89,3 +164,21 @@ function RootLayoutNav() {
</ThemeProvider>
);
}
const styles = StyleSheet.create({
root: {
flex: 1,
},
splashOverlay: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
// 与 app.json 的 expo.splash.backgroundColor 保持一致
backgroundColor: '#EAD2BA',
},
splashImage: {
// 覆盖层图片尺寸需与系统原生 Splash 的视觉一致,避免出现“缩小一下”的错觉
width: '100%',
height: '100%',
},
});

View File

@@ -1,9 +1,8 @@
import { useEffect } from 'react';
import { ActivityIndicator, StyleSheet, View } from 'react-native';
import { useRouter } from 'expo-router';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { getOnboardingCompleted, getConsentAccepted, setOnboardingCompleted, setConsentAccepted } from '@/src/storage/appStorage';
import { getOnboardingCompleted, getConsentAccepted } from '@/src/storage/appStorage';
/**
* 启动分发:根据 consent 和 onboarding 状态跳转
@@ -14,10 +13,6 @@ export default function Index() {
useEffect(() => {
let cancelled = false;
(async () => {
// 【临时清除数据】:用于测试完整流程
await AsyncStorage.clear();
console.log('AsyncStorage has been cleared for testing.');
// 1. 检查是否同意协议
const consentAccepted = await getConsentAccepted();
if (cancelled) return;
@@ -54,4 +49,3 @@ export default function Index() {
const styles = StyleSheet.create({
container: { flex: 1, alignItems: 'center', justifyContent: 'center' },
});

Binary file not shown.

View File

@@ -1,3 +1,3 @@
<svg width="32" height="27" viewBox="0 0 32 27" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M23.0879 1C24.9887 1 26.7592 1.93575 28.0762 3.42188C29.3956 4.91097 30.2001 6.88999 30.2002 8.84082C30.2002 13.2337 27.1054 17.3696 23.5225 20.499C21.7556 22.0422 19.9254 23.2909 18.4199 24.1494C17.6667 24.5789 17.0062 24.904 16.4863 25.1182C16.2262 25.2253 16.0121 25.3001 15.8467 25.3467C15.6728 25.3956 15.5993 25.4002 15.5996 25.4004C15.5928 25.3997 15.5178 25.3932 15.3525 25.3467C15.1871 25.3001 14.973 25.2253 14.7129 25.1182C14.193 24.904 13.5333 24.5788 12.7803 24.1494C11.2748 23.2909 9.44467 22.0423 7.67773 20.499C4.0947 17.3696 1 13.2338 1 8.84082C1.00008 6.89007 1.80381 4.91108 3.12207 3.42188C4.43787 1.93557 6.20587 1.00033 8.10059 1C9.51294 1.00117 10.8927 1.41742 12.0635 2.19434C12.7589 2.65582 13.3618 3.2322 13.8486 3.89355C14.2951 4.50017 15.0032 4.73135 15.5996 4.73145C16.1961 4.73144 16.905 4.5003 17.3516 3.89355C17.8376 3.23336 18.4389 2.65737 19.1328 2.19629C20.3012 1.4199 21.6779 1.00339 23.0879 1Z" stroke="#5E2A28" stroke-width="2"/>
<path d="M23.0879 1C24.9887 1 26.7592 1.93575 28.0762 3.42188C29.3956 4.91097 30.2001 6.88999 30.2002 8.84082C30.2002 13.2337 27.1054 17.3696 23.5225 20.499C21.7556 22.0422 19.9254 23.2909 18.4199 24.1494C17.6667 24.5789 17.0062 24.904 16.4863 25.1182C16.2262 25.2253 16.0121 25.3001 15.8467 25.3467C15.6728 25.3956 15.5993 25.4002 15.5996 25.4004C15.5928 25.3997 15.5178 25.3932 15.3525 25.3467C15.1871 25.3001 14.973 25.2253 14.7129 25.1182C14.193 24.904 13.5333 24.5788 12.7803 24.1494C11.2748 23.2909 9.44467 22.0423 7.67773 20.499C4.0947 17.3696 1 13.2338 1 8.84082C1.00008 6.89007 1.80381 4.91108 3.12207 3.42188C4.43787 1.93557 6.20587 1.00033 8.10059 1C9.51294 1.00117 10.8927 1.41742 12.0635 2.19434C12.7589 2.65582 13.3618 3.2322 13.8486 3.89355C14.2951 4.50017 15.0032 4.73135 15.5996 4.73145C16.1961 4.73144 16.905 4.5003 17.3516 3.89355C17.8376 3.23336 18.4389 2.65737 19.1328 2.19629C20.3012 1.4199 21.6779 1.00339 23.0879 1Z" stroke="currentColor" stroke-width="2"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,4 +1,4 @@
<svg width="32" height="27" viewBox="0 0 32 27" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M23.0879 1C24.9887 1 26.7592 1.93575 28.0762 3.42188C29.3956 4.91097 30.2001 6.88999 30.2002 8.84082C30.2002 13.2337 27.1054 17.3696 23.5225 20.499C21.7556 22.0422 19.9254 23.2909 18.4199 24.1494C17.6667 24.5789 17.0062 24.904 16.4863 25.1182C16.2262 25.2253 16.0121 25.3001 15.8467 25.3467C15.6728 25.3956 15.5993 25.4002 15.5996 25.4004C15.5928 25.3997 15.5178 25.3932 15.3525 25.3467C15.1871 25.3001 14.973 25.2253 14.7129 25.1182C14.193 24.904 13.5333 24.5788 12.7803 24.1494C11.2748 23.2909 9.44467 22.0423 7.67773 20.499C4.0947 17.3696 1 13.2338 1 8.84082C1.00008 6.89007 1.80381 4.91108 3.12207 3.42188C4.43787 1.93557 6.20587 1.00033 8.10059 1C9.51294 1.00117 10.8927 1.41742 12.0635 2.19434C12.7589 2.65582 13.3618 3.2322 13.8486 3.89355C14.2951 4.50017 15.0032 4.73135 15.5996 4.73145C16.1961 4.73144 16.905 4.5003 17.3516 3.89355C17.8376 3.23336 18.4389 2.65737 19.1328 2.19629C20.3012 1.4199 21.6779 1.00339 23.0879 1Z" fill="#5E2A28" stroke="#5E2A28" stroke-width="2"/>
<path d="M23.0879 1C24.9887 1 26.7592 1.93575 28.0762 3.42188C29.3956 4.91097 30.2001 6.88999 30.2002 8.84082C30.2002 13.2337 27.1054 17.3696 23.5225 20.499C21.7556 22.0422 19.9254 23.2909 18.4199 24.1494C17.6667 24.5789 17.0062 24.904 16.4863 25.1182C16.2262 25.2253 16.0121 25.3001 15.8467 25.3467C15.6728 25.3956 15.5993 25.4002 15.5996 25.4004C15.5928 25.3997 15.5178 25.3932 15.3525 25.3467C15.1871 25.3001 14.973 25.2253 14.7129 25.1182C14.193 24.904 13.5333 24.5788 12.7803 24.1494C11.2748 23.2909 9.44467 22.0423 7.67773 20.499C4.0947 17.3696 1 13.2338 1 8.84082C1.00008 6.89007 1.80381 4.91108 3.12207 3.42188C4.43787 1.93557 6.20587 1.00033 8.10059 1C9.51294 1.00117 10.8927 1.41742 12.0635 2.19434C12.7589 2.65582 13.3618 3.2322 13.8486 3.89355C14.2951 4.50017 15.0032 4.73135 15.5996 4.73145C16.1961 4.73144 16.905 4.5003 17.3516 3.89355C17.8376 3.23336 18.4389 2.65737 19.1328 2.19629C20.3012 1.4199 21.6779 1.00339 23.0879 1Z" fill="currentColor" stroke="currentColor" stroke-width="2"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,3 +1,3 @@
<svg width="35" height="36" viewBox="0 0 35 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M25.0879 6C26.9887 6 28.7592 6.93575 30.0762 8.42188C31.3956 9.91097 32.2001 11.89 32.2002 13.8408C32.2002 18.2337 29.1054 22.3696 25.5225 25.499C23.7556 27.0422 21.9254 28.2909 20.4199 29.1494C19.6667 29.5789 19.0062 29.904 18.4863 30.1182C18.2262 30.2253 18.0121 30.3001 17.8467 30.3467C17.6728 30.3956 17.5993 30.4002 17.5996 30.4004C17.5928 30.3997 17.5178 30.3932 17.3525 30.3467C17.1871 30.3001 16.973 30.2253 16.7129 30.1182C16.193 29.904 15.5333 29.5788 14.7803 29.1494C13.2748 28.2909 11.4447 27.0423 9.67773 25.499C6.0947 22.3696 3 18.2338 3 13.8408C3.00008 11.8901 3.80381 9.91108 5.12207 8.42188C6.43787 6.93557 8.20587 6.00033 10.1006 6C11.5129 6.00117 12.8927 6.41742 14.0635 7.19434C14.7589 7.65582 15.3618 8.2322 15.8486 8.89355C16.2951 9.50017 17.0032 9.73135 17.5996 9.73145C18.1961 9.73144 18.905 9.5003 19.3516 8.89355C19.8376 8.23336 20.4389 7.65737 21.1328 7.19629C22.3012 6.4199 23.6779 6.00339 25.0879 6Z" stroke="#5E2A28" stroke-width="2"/>
<path d="M25.0879 6C26.9887 6 28.7592 6.93575 30.0762 8.42188C31.3956 9.91097 32.2001 11.89 32.2002 13.8408C32.2002 18.2337 29.1054 22.3696 25.5225 25.499C23.7556 27.0422 21.9254 28.2909 20.4199 29.1494C19.6667 29.5789 19.0062 29.904 18.4863 30.1182C18.2262 30.2253 18.0121 30.3001 17.8467 30.3467C17.6728 30.3956 17.5993 30.4002 17.5996 30.4004C17.5928 30.3997 17.5178 30.3932 17.3525 30.3467C17.1871 30.3001 16.973 30.2253 16.7129 30.1182C16.193 29.904 15.5333 29.5788 14.7803 29.1494C13.2748 28.2909 11.4447 27.0423 9.67773 25.499C6.0947 22.3696 3 18.2338 3 13.8408C3.00008 11.8901 3.80381 9.91108 5.12207 8.42188C6.43787 6.93557 8.20587 6.00033 10.1006 6C11.5129 6.00117 12.8927 6.41742 14.0635 7.19434C14.7589 7.65582 15.3618 8.2322 15.8486 8.89355C16.2951 9.50017 17.0032 9.73135 17.5996 9.73145C18.1961 9.73144 18.905 9.5003 19.3516 8.89355C19.8376 8.23336 20.4389 7.65737 21.1328 7.19629C22.3012 6.4199 23.6779 6.00339 25.0879 6Z" stroke="currentColor" stroke-width="2"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

View File

@@ -39,13 +39,17 @@ export default function DailyReminderModal({ visible, onClose }: Props) {
}, [visible]);
function clamp(next: number) {
return Math.min(10, Math.max(1, next));
// 需求050 表示关闭)
return Math.min(5, Math.max(0, next));
}
async function onOk() {
if (loading) return;
setLoading(true);
const next: DailyReminderSettings = { timesPerDay, pushEnabled };
const next: DailyReminderSettings = {
timesPerDay: Math.min(5, Math.max(0, Math.round(timesPerDay))),
pushEnabled: Boolean(pushEnabled) && timesPerDay > 0,
};
await setDailyReminderSettings(next);
setLoading(false);
onClose();

View File

@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
import SheetModal from '@/components/ui/SheetModal';
import { MOCK_CONTENT } from '@/src/constants/mockContent';
import { getFavorites } from '@/src/storage/appStorage';
import { getFavorites, getRecoFeedCache, type FavoriteItem } from '@/src/storage/appStorage';
type Props = {
visible: boolean;
@@ -13,25 +13,50 @@ type Props = {
export default function FavoritesModal({ visible, onClose }: Props) {
const { t } = useTranslation();
const [ids, setIds] = useState<string[]>([]);
const [items, setItems] = useState<(FavoriteItem & { text: string })[]>([]);
// 每次打开时刷新一次,确保展示最新“喜欢”
useEffect(() => {
if (!visible) return;
let cancelled = false;
(async () => {
const list = await getFavorites();
if (!cancelled) setIds(list);
const [favList, cache] = await Promise.all([
getFavorites(),
getRecoFeedCache()
]);
console.log('FavoritesModal: Loaded favList', favList.length, 'items');
console.log('FavoritesModal: Loaded cache items', cache?.items?.length || 0);
if (cancelled) return;
// 建立文案查找表
const textMap = new Map<string, string>();
// 1. 放入 Mock 数据
MOCK_CONTENT.forEach(c => textMap.set(String(c.id), t(c.textKey)));
// 2. 放入缓存数据
if (cache?.items) {
cache.items.forEach(c => textMap.set(String(c.content_id), c.text));
}
// 3. 组装最终展示列表
const enriched = favList.map(fav => {
const favIdStr = String(fav.id);
const text = fav.text || textMap.get(favIdStr);
console.log(`FavoritesModal: Matching fav.id=${favIdStr}, found text=${!!text}, textValue=${text?.substring(0, 10)}...`);
return {
...fav,
text: text || t('favorites.unknownText')
};
});
setItems(enriched);
})();
return () => {
cancelled = true;
};
}, [visible]);
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]);
}, [visible, t]);
return (
<SheetModal visible={visible} title={t('profile.favorites')} onClose={onClose}>
@@ -41,7 +66,7 @@ export default function FavoritesModal({ visible, onClose }: Props) {
) : (
<FlatList
data={items}
keyExtractor={(it) => it.id}
keyExtractor={(it) => it.favId}
contentContainerStyle={styles.list}
renderItem={({ item }) => (
<View style={styles.row}>

View File

@@ -3,6 +3,7 @@ import { Alert, FlatList, Image, Pressable, StyleSheet, Text, View, Platform, Di
import { useTranslation } from 'react-i18next';
import { LinearGradient } from 'expo-linear-gradient';
import { Switch } from 'react-native';
import * as WebBrowser from 'expo-web-browser';
import Animated, {
Easing,
FadeIn,
@@ -22,6 +23,7 @@ import {
getFavorites,
setDailyReminderSettings,
removeFavorite,
getRecoFeedCache,
getUserProfile,
type DailyReminderSettings,
type FavoriteItem,
@@ -38,6 +40,8 @@ import SelectedIcon from '@/assets/images/icon/selected_icon.svg';
import QuestionIcon from '@/assets/images/home/Profile/widget/question_icon.svg';
import * as Notifications from 'expo-notifications';
import { changeLanguage } from '@/src/i18n';
import { fetchLegalLinks } from '@/src/services/legalApi';
import { getExpoPushTokenOrThrow, registerPushToken, setPushPreferences } from '@/src/services/pushApi';
const { width } = Dimensions.get('window');
@@ -79,6 +83,7 @@ export default function ProfileModal({ visible, name: propName, onClose }: Props
const [page, setPage] = useState<Page>('root');
const [navDirection, setNavDirection] = useState<NavDirection>('forward');
const [currentName, setCurrentName] = useState(propName);
const [legalLinks, setLegalLinks] = useState<{ privacy?: string; terms?: string }>({});
const isRoot = page === 'root';
// 当弹窗打开时,尝试从存储中获取最新的昵称,确保与 onboarding 同步
@@ -89,6 +94,16 @@ export default function ProfileModal({ visible, name: propName, onClose }: Props
setCurrentName(profile.name);
}
});
// 打开弹窗时拉取协议链接(由后端按语言下发;默认 EN
fetchLegalLinks()
.then((res) => {
setLegalLinks({ privacy: res.privacyPolicyUrl, terms: res.termsOfUseUrl });
})
.catch((e) => {
if (__DEV__) console.log('[LegalLinks] 拉取失败ProfileModal:', e);
setLegalLinks({});
});
} else {
setNavDirection('back');
setPage('root');
@@ -122,6 +137,21 @@ export default function ProfileModal({ visible, name: propName, onClose }: Props
handleClose();
}
const openLink = useCallback(
async (url?: string) => {
if (!url) {
Alert.alert(t('common.notice'), t('consent.linkUnavailable'));
return;
}
try {
await WebBrowser.openBrowserAsync(url);
} catch (error) {
Alert.alert(t('common.error'), t('common.openLinkError'));
}
},
[t],
);
const title = useMemo(() => {
if (page === 'favorites') return t('profile.favorites');
if (page === 'dailyReminder') return t('dailyReminder.title');
@@ -135,23 +165,13 @@ export default function ProfileModal({ visible, name: propName, onClose }: Props
const duration = 220;
const easing = Easing.out(Easing.cubic);
// 进入二级页:从右侧滑入;返回:从左侧滑入
const entering =
navDirection === 'forward'
? SlideInRight.duration(duration).easing(easing)
: SlideInLeft.duration(duration).easing(easing);
// 离开:进入二级页时旧页面向左滑出;返回时旧页面向右滑出
const exiting =
navDirection === 'forward'
? SlideOutLeft.duration(duration).easing(easing)
: SlideOutRight.duration(duration).easing(easing);
// 需求:去掉左右滑动的切页动效,改为纯淡入淡出
const entering = FadeIn.duration(duration).easing(easing);
const exiting = FadeOut.duration(duration).easing(easing);
return {
entering,
exiting,
fadeIn: FadeIn.duration(duration).easing(easing),
fadeOut: FadeOut.duration(duration).easing(easing),
};
}, [navDirection]);
@@ -169,11 +189,6 @@ export default function ProfileModal({ visible, name: propName, onClose }: Props
exiting={transition.exiting}
style={!isRoot ? { flex: 1 } : undefined}
>
<Animated.View
entering={transition.fadeIn}
exiting={transition.fadeOut}
style={!isRoot ? { flex: 1 } : undefined}
>
{page === 'root' ? (
<RootPage
name={currentName}
@@ -181,6 +196,8 @@ export default function ProfileModal({ visible, name: propName, onClose }: Props
onOpenWidget={() => go('widget', 'forward')}
onOpenDailyReminder={() => go('dailyReminder', 'forward')}
onOpenLanguage={() => go('language', 'forward')}
onOpenPrivacy={() => openLink(legalLinks.privacy)}
onOpenTerms={() => openLink(legalLinks.terms)}
/>
) : page === 'favorites' ? (
<FavoritesPage visible={visible} page={page} />
@@ -193,7 +210,6 @@ export default function ProfileModal({ visible, name: propName, onClose }: Props
) : (
<WidgetPage onOpenHowTo={() => go('widgetHowTo', 'forward')} />
)}
</Animated.View>
</Animated.View>
</View>
</SheetModal>
@@ -210,12 +226,16 @@ function RootPage({
onOpenWidget,
onOpenDailyReminder,
onOpenLanguage,
onOpenPrivacy,
onOpenTerms,
}: {
name?: string;
onOpenFavorites: () => void;
onOpenWidget: () => void;
onOpenDailyReminder: () => void;
onOpenLanguage: () => void;
onOpenPrivacy: () => void;
onOpenTerms: () => void;
}) {
const { t } = useTranslation();
return (
@@ -243,12 +263,12 @@ function RootPage({
<ListItem
icon={<PrivacyIcon width={22} height={22} />}
title={t('profile.privacy')}
onPress={() => toastTodo(t)}
onPress={onOpenPrivacy}
/>
<ListItem
icon={<TermsIcon width={22} height={22} />}
title={t('profile.terms')}
onPress={() => toastTodo(t)}
onPress={onOpenTerms}
/>
<ListItem
icon={<LanguageIcon width={22} height={22} />}
@@ -271,15 +291,21 @@ function FavoritesPage({ visible, page }: { visible: boolean; page: Page }) {
async function refreshFavorites() {
const storedFavs = await getFavorites();
const map = new Map(MOCK_CONTENT.map((c) => [c.id, c.text]));
const list = storedFavs
.map((fav) => ({
...fav,
text: map.get(fav.id) || ''
}))
.filter(item => item.text !== '');
const textMap = new Map<string, string>();
// 1) Mock 文案
MOCK_CONTENT.forEach((c) => textMap.set(String(c.id), t(c.textKey)));
// 2) 后端推荐缓存文案(避免收藏后 cache 覆盖就丢文案)
const cache = await getRecoFeedCache();
cache?.items?.forEach((c) => textMap.set(String(c.content_id), c.text));
// 3) 组装:优先使用收藏时写入的 text其次从 map 回填
const list = storedFavs.map((fav) => ({
...fav,
text: fav.text || textMap.get(String(fav.id)) || t('favorites.unknownText'),
}));
setFavorites(list);
}
@@ -366,13 +392,15 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () =
(async () => {
// 1. 获取本地存储设置
const s = await getDailyReminderSettings();
// 【测试模式】:强制模拟无权限状态
const granted = false;
// 2. 获取系统通知权限(用于 UI 展示/引导)
const settings = await Notifications.getPermissionsAsync();
const granted = settings.status === 'granted';
if (cancelled) return;
setTimesPerDay(s.timesPerDay);
setPushEnabled(granted);
// pushEnabled 表示用户意愿;若系统未授权则强制展示为关闭
setPushEnabled(Boolean(s.pushEnabled) && granted);
setHasSystemPermission(granted);
setLoading(false);
})();
@@ -390,7 +418,7 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () =
if (settings.status === 'denied') {
Alert.alert(
t('common.notice'),
"系统权限已被拒绝,请前往手机设置开启通知。"
t('permissions.notificationsDenied')
);
setPushEnabled(false);
return;
@@ -405,6 +433,23 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () =
if (status === 'granted') {
setPushEnabled(true);
setHasSystemPermission(true);
// 获取 token 并上报后端(幂等)
try {
const expoPushToken = await getExpoPushTokenOrThrow();
await registerPushToken({ pushToken: expoPushToken });
// 偏好同步失败不应被用户感知为“开启失败”
// (常见现象:后端已接收 token但偏好接口短暂失败/超时)
try {
await setPushPreferences({ enabled: true, timesPerDay });
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.warn('[PushPreferences] 同步失败ProfileModal不阻塞', msg);
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
Alert.alert(t('common.notice'), msg);
}
} else {
setPushEnabled(false);
setHasSystemPermission(false);
@@ -412,18 +457,35 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () =
}
} else {
setPushEnabled(false);
// 关闭时尝试同步到后端(不阻塞)
try {
await setPushPreferences({ enabled: false, timesPerDay: 0 });
} catch {
// ignore
}
}
};
function clamp(next: number) {
return Math.min(10, Math.max(1, next));
// 需求050 表示关闭)
return Math.min(5, Math.max(0, next));
}
async function onOk() {
if (loading) return;
setLoading(true);
const next: DailyReminderSettings = { timesPerDay, pushEnabled };
const nextTimes = Math.min(5, Math.max(0, Math.round(timesPerDay)));
const nextEnabled = Boolean(pushEnabled) && nextTimes > 0;
const next: DailyReminderSettings = { timesPerDay: nextTimes, pushEnabled: nextEnabled };
await setDailyReminderSettings(next);
// 同步后端偏好(幂等;失败不阻塞)
try {
await setPushPreferences({ enabled: nextEnabled, timesPerDay: nextTimes });
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.warn('[PushPreferences] 同步失败', msg);
}
setLoading(false);
onDone();
}
@@ -457,24 +519,22 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () =
</Pressable>
</View>
{!hasSystemPermission && (
<View style={styles.remindRow}>
<View style={styles.rowLeft}>
<View style={styles.rowIcon}>
<RemindIcon width={18} height={18} />
</View>
<Text style={styles.rowText}>{t('dailyReminder.pushLabel')}</Text>
</View>
<View style={styles.rowRight}>
<Switch
value={pushEnabled}
onValueChange={handleTogglePush}
trackColor={{ false: '#D1D1D6', true: '#4CD964' }}
ios_backgroundColor="#D1D1D6"
/>
<View style={styles.remindRow}>
<View style={styles.rowLeft}>
<View style={styles.rowIcon}>
<RemindIcon width={18} height={18} />
</View>
<Text style={styles.rowText}>{t('dailyReminder.pushLabel')}</Text>
</View>
)}
<View style={styles.rowRight}>
<Switch
value={pushEnabled}
onValueChange={handleTogglePush}
trackColor={{ false: '#D1D1D6', true: '#4CD964' }}
ios_backgroundColor="#D1D1D6"
/>
</View>
</View>
<Pressable onPress={onOk} disabled={loading} style={styles.okPressable}>
<LinearGradient
@@ -530,7 +590,8 @@ function WidgetHowToPage() {
const flatListRef = useRef<FlatList>(null);
const [activeIndex, setActiveIndex] = useState(0);
const [isManual, setIsManual] = useState(false);
const timerRef = useRef<NodeJS.Timeout | null>(null);
// React Native 环境下 setInterval 返回值类型与 Node 不同,这里用 ReturnType 兼容
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const images = currentLang === 'en' ? [
{ id: '1', src: require('@/assets/images/home/Profile/widget/Widget_description1_en.png'), desc: t('widget.howToDesc1') },
@@ -607,12 +668,12 @@ function WidgetHowToPage() {
}
function LanguagePage() {
const { i18n } = useTranslation();
const { t, i18n } = useTranslation();
const currentLang = i18n.language;
const languages = [
{ id: 'zh-TW', label: '繁体' },
{ id: 'en', label: 'English' },
{ id: 'zh-TW', label: t('language.zhTW') },
{ id: 'en', label: t('language.en') },
];
return (

View File

@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
import SheetModal from '@/components/ui/SheetModal';
export type ThemeMode = 'scenery' | 'color';
import type { ThemeMode } from '@/src/storage/appStorage';
type Props = {
visible: boolean;
@@ -16,7 +16,7 @@ type Props = {
export default function ThemeModal({ visible, mode, onSelect, onClose }: Props) {
const { t } = useTranslation();
return (
<SheetModal visible={visible} title={t('theme.title')} onClose={onClose} height={350}>
<SheetModal visible={visible} title={t('theme.title')} onClose={onClose} height={360}>
<View style={styles.row}>
<ThemeCard
title={t('theme.scenery')}
@@ -41,6 +41,19 @@ export default function ThemeModal({ visible, mode, onSelect, onClose }: Props)
style={styles.previewImage}
/>
</ThemeCard>
<ThemeCard
title={t('theme.suixin')}
selected={mode === 'suixin'}
onPress={() => onSelect('suixin')}
>
<Image
// 占位:一期复用纯色预览图,后续可替换为专用资源
source={require('../../assets/images/theme/theme_color.png')}
resizeMode="cover"
style={styles.previewImage}
/>
</ThemeCard>
</View>
</SheetModal>
);
@@ -68,7 +81,12 @@ function ThemeCard({
{children}
{/* 文案展示在图片中心 */}
<View style={styles.textOverlay}>
<Text style={[styles.overlayTitle, selected && styles.selectedOverlayTitle]}>
<Text
style={[styles.overlayTitle, selected && styles.selectedOverlayTitle]}
numberOfLines={1}
adjustsFontSizeToFit
minimumFontScale={0.85}
>
{title}
</Text>
</View>
@@ -81,19 +99,21 @@ function ThemeCard({
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
gap: 30,
paddingHorizontal: 10,
flexWrap: 'nowrap',
gap: 12,
paddingHorizontal: 4,
paddingBottom: 50,
paddingTop: 20,
justifyContent: 'center',
justifyContent: 'space-between',
},
cardContainer: {
alignItems: 'center',
width: 143,
flex: 1,
minWidth: 0,
alignItems: 'stretch',
},
previewWrapper: {
width: 138,
height: 203,
width: '100%',
aspectRatio: 110 / 178,
borderRadius: 26,
padding: 6.5,
justifyContent: 'center',

View File

@@ -1,13 +1,13 @@
import React from 'react';
import { View, StyleSheet, TouchableOpacity } from 'react-native';
import { SerifText } from './SerifText';
import { OnboardingColors } from '@/constants/OnboardingTheme';
import { View, StyleSheet, TouchableOpacity, Text } from 'react-native';
import { useTranslation } from 'react-i18next';
import { OnboardingColors, OnboardingFont } from '@/constants/OnboardingTheme';
export const INTENTS = [
{ id: 'love', label: '爱情', icon: '❤️' },
{ id: 'life', label: '生活', icon: '⛅' },
{ id: 'travel', label: '旅游', icon: '🌴' },
{ id: 'work', label: '职场', icon: '💼' },
{ id: 'love', labelKey: 'intent.love', icon: '❤️' },
{ id: 'life', labelKey: 'intent.life', icon: '⛅' },
{ id: 'travel', labelKey: 'intent.travel', icon: '🌴' },
{ id: 'work', labelKey: 'intent.work', icon: '💼' },
];
interface IntentSelectionStepProps {
@@ -16,9 +16,10 @@ interface IntentSelectionStepProps {
}
export function IntentSelectionStep({ selectedIds, onToggle }: IntentSelectionStepProps) {
const { t } = useTranslation();
return (
<View style={styles.container}>
<SerifText style={styles.title}></SerifText>
<Text style={styles.title}>{t('intent.title')}</Text>
<View style={styles.grid}>
{INTENTS.map((intent) => {
@@ -33,10 +34,10 @@ export function IntentSelectionStep({ selectedIds, onToggle }: IntentSelectionSt
onPress={() => onToggle(intent.id)}
activeOpacity={0.7}
>
<SerifText style={styles.icon}>{intent.icon}</SerifText>
<SerifText style={[styles.label, isSelected && styles.labelSelected]}>
{intent.label}
</SerifText>
<Text style={styles.icon}>{intent.icon}</Text>
<Text style={[styles.label, isSelected && styles.labelSelected]}>
{t(intent.labelKey)}
</Text>
</TouchableOpacity>
);
})}
@@ -54,6 +55,8 @@ const styles = StyleSheet.create({
fontSize: 24,
marginBottom: 40,
textAlign: 'center',
fontFamily: OnboardingFont.question,
color: OnboardingColors.textPrimary,
},
grid: {
flexDirection: 'row',
@@ -84,10 +87,12 @@ const styles = StyleSheet.create({
},
icon: {
fontSize: 32,
fontFamily: OnboardingFont.question,
},
label: {
fontSize: 18,
color: OnboardingColors.textPrimary,
fontFamily: OnboardingFont.question,
},
labelSelected: {
fontWeight: 'bold',

View File

@@ -1,12 +1,12 @@
import React, { useEffect, useRef, useState } from 'react';
import { View, StyleSheet, TextInput, Platform, Animated, TouchableOpacity, Dimensions, Text } from 'react-native';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { View, StyleSheet, TextInput, Platform, Animated, TouchableOpacity, Text, Keyboard, Pressable } from 'react-native';
import { useTranslation } from 'react-i18next';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { OnboardingColors } from '@/constants/OnboardingTheme';
import BtnNotClicked from '@/assets/images/icon/btn_Notclicked.svg';
import BtnClicked from '@/assets/images/icon/btn_clicked.svg';
import EnterLightIcon from '@/assets/images/icon/enter_Light_icon.svg';
const { height } = Dimensions.get('window');
interface NameInputStepProps {
value: string;
onChangeText: (text: string) => void;
@@ -14,10 +14,30 @@ interface NameInputStepProps {
}
export function NameInputStep({ value, onChangeText, onNext }: NameInputStepProps) {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const [isFocused, setIsFocused] = useState(false);
const [keyboardHeight, setKeyboardHeight] = useState(0);
const blinkAnim = useRef(new Animated.Value(1)).current;
const hasInput = value.trim().length > 0;
useEffect(() => {
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
const subShow = Keyboard.addListener(showEvent, (e) => {
setKeyboardHeight(e.endCoordinates?.height ?? 0);
});
const subHide = Keyboard.addListener(hideEvent, () => {
setKeyboardHeight(0);
});
return () => {
subShow.remove();
subHide.remove();
};
}, []);
useEffect(() => {
const animation = Animated.loop(
Animated.sequence([
@@ -34,8 +54,14 @@ export function NameInputStep({ value, onChangeText, onNext }: NameInputStepProp
return () => animation.stop();
}, [blinkAnim, isFocused]);
const footerBottom = useMemo(() => {
// iOS 的 keyboard height 通常已包含底部安全区,避免重复叠加
const keyboardOffset = Math.max(0, keyboardHeight - insets.bottom);
return 16 + insets.bottom + keyboardOffset;
}, [insets.bottom, keyboardHeight]);
return (
<View style={styles.container}>
<Pressable style={styles.container} onPress={Keyboard.dismiss} accessible={false}>
<View style={styles.inputCard}>
<View style={styles.inputWrapper}>
{/* 显示层:文案 + 跟随的光标 */}
@@ -46,7 +72,7 @@ export function NameInputStep({ value, onChangeText, onNext }: NameInputStepProp
(!isFocused && !hasInput) && { color: OnboardingColors.textSecondary }
]}
>
{hasInput ? value : (isFocused ? "" : "Mama")}
{hasInput ? value : isFocused ? '' : t('onboardingSurvey.steps.name.placeholder')}
</Text>
{isFocused && (
<Animated.View style={[styles.cursorWrapper, { opacity: blinkAnim, marginLeft: 2 }]}>
@@ -65,20 +91,29 @@ export function NameInputStep({ value, onChangeText, onNext }: NameInputStepProp
caretHidden={true}
autoCorrect={false}
spellCheck={false}
returnKeyType="done"
blurOnSubmit={true}
onSubmitEditing={() => {
Keyboard.dismiss();
// 不再自動跳頁,僅收起鍵盤;前進需點擊底部 ➡️
}}
/>
</View>
</View>
<View style={styles.footer}>
<View style={[styles.footer, { bottom: footerBottom }]}>
<TouchableOpacity
onPress={onNext}
onPress={() => {
Keyboard.dismiss();
onNext();
}}
disabled={!hasInput}
activeOpacity={0.8}
>
{hasInput ? <BtnClicked width={87} height={57} /> : <BtnNotClicked width={87} height={57} />}
</TouchableOpacity>
</View>
</View>
</Pressable>
);
}
@@ -130,7 +165,6 @@ const styles = StyleSheet.create({
},
footer: {
position: 'absolute',
bottom: height * 0.12,
alignItems: 'center',
}
});

View File

@@ -1,6 +1,10 @@
import React from 'react';
import { View, StyleSheet, SafeAreaView, TouchableOpacity, StatusBar, Text, Image, Platform } from 'react-native';
import { OnboardingColors } from '@/constants/OnboardingTheme';
import React, { useRef, useEffect } from 'react';
import { View, StyleSheet, SafeAreaView, TouchableOpacity, StatusBar, Text, Image, Platform, Animated, Easing } from 'react-native';
import { useTranslation } from 'react-i18next';
import { OnboardingColors, OnboardingFont } from '@/constants/OnboardingTheme';
const TRANSITION_OFFSET = 24;
const TRANSITION_DURATION = 280;
interface OnboardingLayoutProps {
children: React.ReactNode;
@@ -10,6 +14,8 @@ interface OnboardingLayoutProps {
onSkip: () => void;
onBack?: () => void;
showBackButton?: boolean;
/** 用户名字仅在名字步骤之后的第一个问题currentStep === 1且非空时显示招呼语 */
userName?: string;
}
export function OnboardingLayout({
@@ -19,8 +25,48 @@ export function OnboardingLayout({
totalSteps,
onSkip,
onBack,
showBackButton = false
showBackButton = false,
userName = '',
}: OnboardingLayoutProps) {
const { t } = useTranslation();
const showGreeting = currentStep === 1 && userName.trim().length > 0;
const displayName = userName.trim();
const prevStepRef = useRef(currentStep);
const isFirstRenderRef = useRef(true);
const translateX = useRef(new Animated.Value(0)).current;
const opacity = useRef(new Animated.Value(1)).current;
useEffect(() => {
if (isFirstRenderRef.current) {
isFirstRenderRef.current = false;
prevStepRef.current = currentStep;
return;
}
if (prevStepRef.current === currentStep) return;
const direction = currentStep > prevStepRef.current ? 'forward' : 'back';
prevStepRef.current = currentStep;
const startX = direction === 'forward' ? TRANSITION_OFFSET : -TRANSITION_OFFSET;
translateX.setValue(startX);
opacity.setValue(0.72);
Animated.parallel([
Animated.timing(translateX, {
toValue: 0,
duration: TRANSITION_DURATION,
useNativeDriver: true,
easing: Easing.out(Easing.cubic),
}),
Animated.timing(opacity, {
toValue: 1,
duration: TRANSITION_DURATION,
useNativeDriver: true,
easing: Easing.out(Easing.cubic),
}),
]).start();
}, [currentStep, translateX, opacity]);
return (
<View style={styles.container}>
<StatusBar barStyle="dark-content" />
@@ -39,7 +85,7 @@ export function OnboardingLayout({
</View>
<TouchableOpacity onPress={onSkip} style={styles.skipButton}>
<Text style={styles.skipText}>skip</Text>
<Text style={styles.skipText}>{t('onboarding.skipAll')}</Text>
<Image
source={require('@/assets/images/icon/skip_icon.png')}
style={styles.skipIcon}
@@ -47,16 +93,29 @@ export function OnboardingLayout({
</TouchableOpacity>
</View>
{/* Title & Progress Row */}
{/* Title & Progress Row(名字步骤后第一步且名字非空时显示招呼语 + 问题) */}
<View style={styles.titleRow}>
<Text style={styles.questionTitle}>{title}</Text>
<View style={styles.titleBlock}>
{showGreeting && (
<Text style={styles.greetingText}>{t('onboardingSurvey.greeting', { name: displayName })}</Text>
)}
<Text style={styles.questionTitle}>{title}</Text>
</View>
<Text style={styles.progressText}>({currentStep}/{totalSteps})</Text>
</View>
{/* Content */}
<View style={styles.content}>
{/* Contentstep 切换时滑动 + 淡入 */}
<Animated.View
style={[
styles.content,
{
opacity,
transform: [{ translateX }],
},
]}
>
{children}
</View>
</Animated.View>
</SafeAreaView>
</View>
);
@@ -112,18 +171,27 @@ const styles = StyleSheet.create({
alignItems: 'flex-end',
paddingHorizontal: 20,
marginTop: 20,
marginBottom: 20,
marginBottom: 8,
},
titleBlock: {
flex: 1,
justifyContent: 'flex-end',
},
greetingText: {
fontSize: 22,
color: OnboardingColors.questionTitle,
fontFamily: OnboardingFont.question,
marginBottom: 4,
},
questionTitle: {
fontSize: 22,
color: OnboardingColors.questionTitle,
fontFamily: Platform.OS === 'ios' ? 'PingFang TC' : 'sans-serif',
flex: 1,
fontFamily: OnboardingFont.question,
},
progressText: {
fontSize: 18,
color: OnboardingColors.textProgress,
fontFamily: Platform.OS === 'ios' ? 'PingFang TC' : 'sans-serif',
fontFamily: OnboardingFont.question,
marginLeft: 10,
},
content: {

View File

@@ -1,20 +1,25 @@
import React from 'react';
import { View, StyleSheet, TouchableOpacity, Text, Platform, Dimensions } from 'react-native';
import { View, StyleSheet, TouchableOpacity, Text, Platform } from 'react-native';
import { useTranslation } from 'react-i18next';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { OnboardingColors } from '@/constants/OnboardingTheme';
import AddIcon from '@/assets/images/icon/add_icon.svg';
import ReduceIcon from '@/assets/images/icon/reduce_icon.svg';
import BtnClicked from '@/assets/images/icon/btn_clicked.svg';
const { height } = Dimensions.get('window');
interface ReminderStepProps {
value: number;
onChange: (value: number) => void;
onFinish: () => void;
onSkip?: () => void;
}
export function ReminderStep({ value, onChange, onFinish }: ReminderStepProps) {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const handleReduce = () => {
// 本页最小为 1不接收提醒请使用右上角 Skip
if (value > 1) onChange(value - 1);
};
@@ -31,7 +36,9 @@ export function ReminderStep({ value, onChange, onFinish }: ReminderStepProps) {
<View style={styles.numberWrapper}>
<Text style={styles.numberText}>{value}</Text>
<Text style={styles.unitText}></Text>
<Text style={styles.unitText}>
{value === 1 ? t('dailyReminder.timesUnitSingular') : t('dailyReminder.timesUnit')}
</Text>
</View>
<TouchableOpacity onPress={handleAdd} activeOpacity={0.7}>
@@ -39,7 +46,7 @@ export function ReminderStep({ value, onChange, onFinish }: ReminderStepProps) {
</TouchableOpacity>
</View>
<View style={styles.footer}>
<View style={[styles.footer, { bottom: insets.bottom + 16 }]}>
<TouchableOpacity onPress={onFinish} activeOpacity={0.8}>
<BtnClicked width={87} height={57} />
</TouchableOpacity>
@@ -83,7 +90,6 @@ const styles = StyleSheet.create({
},
footer: {
position: 'absolute',
bottom: height * 0.12,
alignItems: 'center',
}
},
});

View File

@@ -1,13 +1,10 @@
import React from 'react';
import { View, StyleSheet, TouchableOpacity, ScrollView, Dimensions } from 'react-native';
import { OnboardingColors } from '@/constants/OnboardingTheme';
import { SerifText } from './SerifText';
import SelectedIcon from '@/assets/images/icon/selected_icon.svg';
import { View, StyleSheet, TouchableOpacity, ScrollView, Text } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { OnboardingColors, OnboardingFont } from '@/constants/OnboardingTheme';
import BtnNotClicked from '@/assets/images/icon/btn_Notclicked.svg';
import BtnClicked from '@/assets/images/icon/btn_clicked.svg';
const { height } = Dimensions.get('window');
interface Option {
id: string;
label: string;
@@ -18,41 +15,42 @@ interface SelectionStepProps {
selectedIds: string[];
onToggle: (id: string) => void;
onNext: () => void;
onSkip?: () => void;
}
export function SelectionStep({ options, selectedIds, onToggle, onNext }: SelectionStepProps) {
export function SelectionStep({ options, selectedIds, onToggle, onNext, onSkip }: SelectionStepProps) {
const hasSelection = selectedIds.length > 0;
const insets = useSafeAreaInsets();
const footerBottom = insets.bottom + 16;
const footerButtonHeight = 57;
// 底部留白加大,避免最后一项与按钮边框视觉重叠
const footerPaddingBottom = footerBottom + footerButtonHeight + 40;
return (
<View style={styles.container}>
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.optionsList}>
<ScrollView
style={styles.scroll}
showsVerticalScrollIndicator={false}
contentContainerStyle={[styles.optionsList, { paddingBottom: footerPaddingBottom }]}
>
{options.map((option) => {
const isSelected = selectedIds.includes(option.id);
return (
<TouchableOpacity
key={option.id}
style={styles.optionCard}
style={[styles.optionCard, isSelected && styles.optionCardSelected]}
onPress={() => onToggle(option.id)}
activeOpacity={0.7}
>
<SerifText style={styles.optionText}>{option.label}</SerifText>
{isSelected && (
<View style={styles.iconWrapper}>
<SelectedIcon width={20} height={20} />
</View>
)}
<Text style={styles.optionText}>{option.label}</Text>
</TouchableOpacity>
);
})}
</ScrollView>
{/* 底部按钮:距离底部 12% 高度 */}
<View style={styles.footer}>
<TouchableOpacity
onPress={onNext}
disabled={!hasSelection}
activeOpacity={0.8}
>
<View style={[styles.footer, { bottom: footerBottom }]}>
<TouchableOpacity onPress={onNext} disabled={!hasSelection} activeOpacity={0.8}>
{hasSelection ? <BtnClicked width={87} height={57} /> : <BtnNotClicked width={87} height={57} />}
</TouchableOpacity>
</View>
@@ -63,10 +61,13 @@ export function SelectionStep({ options, selectedIds, onToggle, onNext }: Select
const styles = StyleSheet.create({
container: {
flex: 1,
paddingTop: 20,
paddingTop: 8,
},
scroll: {
flex: 1,
},
optionsList: {
paddingBottom: 150, // 为底部按钮留出空间
// paddingBottom 由安全区 + 按钮高度动态计算,避免选项被遮住
},
optionCard: {
width: '100%',
@@ -75,7 +76,7 @@ const styles = StyleSheet.create({
borderRadius: 20,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
justifyContent: 'center',
paddingHorizontal: 24,
marginBottom: 12,
shadowColor: '#000',
@@ -84,20 +85,34 @@ const styles = StyleSheet.create({
shadowRadius: 10,
elevation: 2,
},
optionCardSelected: {
backgroundColor: OnboardingColors.cardSelected,
},
optionText: {
fontSize: 18,
color: OnboardingColors.textPrimary,
fontWeight: '500',
flex: 1,
},
iconWrapper: {
marginLeft: 10,
fontFamily: OnboardingFont.question,
},
footer: {
position: 'absolute',
bottom: height * 0.12,
left: 0,
right: 0,
alignItems: 'center',
}
},
footerRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 16,
},
skipBtn: {
paddingVertical: 10,
paddingHorizontal: 14,
borderRadius: 12,
backgroundColor: 'rgba(0,0,0,0.04)',
},
skipText: {
fontSize: 16,
color: OnboardingColors.textMuted,
},
});

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useMemo, useState, useRef } from 'react';
import { Modal, Pressable, StyleSheet, Text, View, PanResponder, Animated as RNAnimated, Dimensions, Image, ImageSourcePropType } from 'react-native';
import { useTranslation } from 'react-i18next';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import Animated, {
Easing,
@@ -27,6 +28,7 @@ type Props = {
* - 高度固定:默认距离顶部固定间距,也支持传入指定高度
*/
export default function SheetModal({ visible, title, onClose, children, leftIcon, height: customHeight }: Props) {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const [mounted, setMounted] = useState(false);
const progress = useSharedValue(0); // 0: 关闭, 1: 打开
@@ -121,7 +123,7 @@ export default function SheetModal({ visible, title, onClose, children, leftIcon
</Text>
<Pressable
accessibilityRole="button"
accessibilityLabel={leftIcon ? "返回" : "关闭"}
accessibilityLabel={leftIcon ? t('common.back') : t('common.close')}
onPress={onClose}
hitSlop={10}
style={styles.close}

View File

@@ -1,3 +1,10 @@
import { Platform } from 'react-native';
/** 与 onboarding 问题标题一致的字体PingFang TC / sans-serif */
export const OnboardingFont = {
question: Platform.OS === 'ios' ? 'PingFang TC' : 'sans-serif',
};
export const OnboardingColors = {
background: '#FFF4EA',
textPrimary: '#772F00',

8
client/global.d.ts vendored Normal file
View File

@@ -0,0 +1,8 @@
/**
* React Native 全局常量声明。
*
* 说明:`__DEV__` 在运行时由 RN 注入,用于区分开发/生产环境。
* 这里补充 TypeScript 声明,避免在代码里使用时出现类型报错。
*/
declare const __DEV__: boolean;

View File

@@ -1,128 +0,0 @@
import WidgetKit
import SwiftUI
struct Provider: TimelineProvider {
func placeholder(in context: Context) -> SimpleEntry {
SimpleEntry(date: Date())
}
func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
completion(SimpleEntry(date: Date()))
}
func getTimeline(in context: Context, completion: @escaping (Timeline<SimpleEntry>) -> ()) {
// V1
let entry = SimpleEntry(date: Date())
let nextUpdate = Calendar.current.date(byAdding: .day, value: 7, to: Date()) ?? Date().addingTimeInterval(60 * 60 * 24 * 7)
completion(Timeline(entries: [entry], policy: .after(nextUpdate)))
}
}
struct SimpleEntry: TimelineEntry {
let date: Date
}
struct MindfulnessWidgetEntryView: View {
var entry: Provider.Entry
@Environment(\.widgetFamily) var family
private let title = "正念"
private let text = "你已经很努力了,今天也值得被温柔对待。"
private let deepLink = URL(string: "client:///(app)/home")
var body: some View {
switch family {
case .systemSmall:
smallView()
case .systemMedium:
mediumView()
case .systemLarge:
largeView()
default:
smallView()
}
}
private func smallView() -> some View {
ZStack {
LinearGradient(
colors: [Color(red: 0.07, green: 0.09, blue: 0.13), Color(red: 0.15, green: 0.18, blue: 0.26)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
VStack(alignment: .leading, spacing: 8) {
Text(title).font(.headline).foregroundStyle(.white)
Text(text)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(Color.white.opacity(0.92))
.lineLimit(4)
Spacer(minLength: 0)
}
.padding(14)
}
.widgetURL(deepLink)
}
private func mediumView() -> some View {
ZStack {
LinearGradient(
colors: [Color(red: 0.07, green: 0.09, blue: 0.13), Color(red: 0.10, green: 0.12, blue: 0.18)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
HStack(spacing: 14) {
VStack(alignment: .leading, spacing: 8) {
Text(title).font(.headline).foregroundStyle(.white)
Text(text)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(Color.white.opacity(0.92))
.lineLimit(5)
Spacer(minLength: 0)
}
Spacer(minLength: 0)
}
.padding(16)
}
.widgetURL(deepLink)
}
private func largeView() -> some View {
ZStack {
LinearGradient(
colors: [Color(red: 0.07, green: 0.09, blue: 0.13), Color(red: 0.17, green: 0.22, blue: 0.32)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
VStack(alignment: .leading, spacing: 12) {
Text(title)
.font(.title3)
.foregroundStyle(.white)
.bold()
Text(text)
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(Color.white.opacity(0.92))
.lineLimit(7)
Spacer(minLength: 0)
Text("轻轻呼吸,回到当下")
.font(.footnote)
.foregroundStyle(Color.white.opacity(0.7))
}
.padding(18)
}
.widgetURL(deepLink)
}
}
struct MindfulnessWidget: Widget {
let kind: String = "MindfulnessWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: Provider()) { entry in
MindfulnessWidgetEntryView(entry: entry)
}
.configurationDisplayName("正念")
.description("一段温柔提醒,陪你回到当下。")
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
}
}

View File

@@ -1,12 +0,0 @@
import WidgetKit
import SwiftUI
// Widget Extension @main
@main
struct MindfulnessWidgetBundle: WidgetBundle {
var body: some Widget {
MindfulnessWidget()
EmotionWidget()
}
}

View File

@@ -1,12 +0,0 @@
# MindfulnessWidgetWidgetKit 扩展骨架)
本目录提供 iOS WidgetV1 写死文案)的 SwiftUI 代码骨架。
注意:**仅把文件放进仓库还不够**,你还需要在 Xcode 中创建 Widget Extension target并把这些文件加入 target。
## 目标
- 支持 Small/Medium/Large 三种尺寸
- 展示写死文案
- 点击小组件跳转到 App 的 Home`client:///(app)/home`

View File

@@ -59,5 +59,92 @@ target 'client' do
:mac_catalyst_enabled => false,
:ccache_enabled => ccache_enabled?(podfile_properties),
)
# 生成并随归档产物携带 dSYM用于崩溃符号化与 Upload Symbols Failed 修复)
installer.pods_project.targets.each do |target|
target.build_configurations.each do |build_config|
build_config.build_settings['DEBUG_INFORMATION_FORMAT'] = 'dwarf-with-dsym'
build_config.build_settings['DWARF_DSYM_FILE_SHOULD_ACCOMPANY_PRODUCT'] = 'YES'
end
end
# 修复Xcode 编译阶段找不到 Expo 相关 modulemap
# 现象PrecompileSwiftBridgingHeader 报错 module map file '.../Build/Products/.../Expo/Expo.modulemap' not found
# 原因Pods-client 的 xcconfig 把 -fmodule-map-file 指向了 ${PODS_CONFIGURATION_BUILD_DIR},但该文件在构建早期并不存在。
# 方案:将这些 modulemap 路径改为 Pods 内稳定存在的 Target Support Files 路径。
def patch_pods_client_xcconfig!(path)
return unless File.exist?(path)
s = File.read(path)
# expo-dev-* 的 modulemap 文件名与 module 名不同,需要单独映射
s = s.gsub('${PODS_CONFIGURATION_BUILD_DIR}/expo-dev-launcher/EXDevLauncher.modulemap',
'${PODS_ROOT}/Target Support Files/expo-dev-launcher/expo-dev-launcher.modulemap')
s = s.gsub('${PODS_CONFIGURATION_BUILD_DIR}/expo-dev-menu/EXDevMenu.modulemap',
'${PODS_ROOT}/Target Support Files/expo-dev-menu/expo-dev-menu.modulemap')
s = s.gsub('${PODS_CONFIGURATION_BUILD_DIR}/expo-dev-menu-interface/EXDevMenuInterface.modulemap',
'${PODS_ROOT}/Target Support Files/expo-dev-menu-interface/expo-dev-menu-interface.modulemap')
# 通用映射:${PODS_CONFIGURATION_BUILD_DIR}/<Pod>/<Pod>.modulemap -> ${PODS_ROOT}/Target Support Files/<Pod>/<Pod>.modulemap
s = s.gsub(/\$\{PODS_CONFIGURATION_BUILD_DIR\}\/([^\/]+)\/\1\.modulemap/,
'${PODS_ROOT}/Target Support Files/\1/\1.modulemap')
File.write(path, s)
end
support_dir = File.join(__dir__, 'Pods', 'Target Support Files', 'Pods-client')
patch_pods_client_xcconfig!(File.join(support_dir, 'Pods-client.debug.xcconfig'))
patch_pods_client_xcconfig!(File.join(support_dir, 'Pods-client.release.xcconfig'))
# 修复:缺失 [CP] Copy XCFrameworks 阶段时React/Expo 的 XCFramework 中间产物不会生成,
# 导致 Swift 报 no such module 'React' 等。
# 方案:在 [CP] Embed Pods Frameworks 脚本中,先执行各个 *-xcframeworks.sh 生成中间产物。
def patch_pods_client_frameworks_sh!(path)
return unless File.exist?(path)
s = File.read(path)
marker = "# [Mindfulness Fix] Prepare XCFramework intermediates\n"
return if s.include?(marker)
insert = marker +
"if [ -r \"${PODS_ROOT}/Target Support Files/React-Core-prebuilt/React-Core-prebuilt-xcframeworks.sh\" ]; then\n" \
" /bin/sh \"${PODS_ROOT}/Target Support Files/React-Core-prebuilt/React-Core-prebuilt-xcframeworks.sh\"\n" \
"fi\n" \
"if [ -r \"${PODS_ROOT}/Target Support Files/ReactNativeDependencies/ReactNativeDependencies-xcframeworks.sh\" ]; then\n" \
" /bin/sh \"${PODS_ROOT}/Target Support Files/ReactNativeDependencies/ReactNativeDependencies-xcframeworks.sh\"\n" \
"fi\n" \
"if [ -r \"${PODS_ROOT}/Target Support Files/hermes-engine/hermes-engine-xcframeworks.sh\" ]; then\n" \
" /bin/sh \"${PODS_ROOT}/Target Support Files/hermes-engine/hermes-engine-xcframeworks.sh\"\n" \
"fi\n\n"
s = s.sub(/^if \[\[ \"\$CONFIGURATION\" == \"Debug\" \]\]; then\n/, insert + "if [[ \"$CONFIGURATION\" == \"Debug\" ]]; then\n")
File.write(path, s)
end
patch_pods_client_frameworks_sh!(File.join(support_dir, 'Pods-client-frameworks.sh'))
# 让 React/ReactNativeDependencies/hermes 的 XCFramework 切片在编译 Swift 之前就准备好,
# 否则会在 AppDelegate.swift 的 `import React` 阶段报 no such module。
def patch_expo_configure_project_sh!(path)
return unless File.exist?(path)
s = File.read(path)
marker = "# [Mindfulness Fix] Prepare XCFramework intermediates (before Swift compile)\n"
return if s.include?(marker)
insert = marker +
"if [ -r \"${PODS_ROOT}/Target Support Files/React-Core-prebuilt/React-Core-prebuilt-xcframeworks.sh\" ]; then\n" \
" /bin/sh \"${PODS_ROOT}/Target Support Files/React-Core-prebuilt/React-Core-prebuilt-xcframeworks.sh\"\n" \
"fi\n" \
"if [ -r \"${PODS_ROOT}/Target Support Files/ReactNativeDependencies/ReactNativeDependencies-xcframeworks.sh\" ]; then\n" \
" /bin/sh \"${PODS_ROOT}/Target Support Files/ReactNativeDependencies/ReactNativeDependencies-xcframeworks.sh\"\n" \
"fi\n" \
"if [ -r \"${PODS_ROOT}/Target Support Files/hermes-engine/hermes-engine-xcframeworks.sh\" ]; then\n" \
" /bin/sh \"${PODS_ROOT}/Target Support Files/hermes-engine/hermes-engine-xcframeworks.sh\"\n" \
"fi\n\n"
# 插在首次调用 with_node 之前即可(不能用 ^,因为 with_node 不在文件开头)
s = s.sub("with_node \\\n", insert + "with_node \\\n")
File.write(path, s)
end
patch_expo_configure_project_sh!(File.join(support_dir, 'expo-configure-project.sh'))
end
end

View File

@@ -206,6 +206,10 @@ PODS:
- Yoga
- ExpoAsset (12.0.12):
- ExpoModulesCore
- ExpoCrypto (15.0.8):
- ExpoModulesCore
- ExpoDevice (8.0.10):
- ExpoModulesCore
- ExpoFileSystem (19.0.21):
- ExpoModulesCore
- ExpoFont (14.0.11):
@@ -2236,298 +2240,304 @@ PODS:
- Yoga (0.0.0)
DEPENDENCIES:
- "EXApplication (from `../node_modules/.pnpm/expo-application@7.0.8_expo@54.0.32/node_modules/expo-application/ios`)"
- "EXConstants (from `../node_modules/.pnpm/expo-constants@18.0.13_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0_/node_modules/expo-constants/ios`)"
- "EXJSONUtils (from `../node_modules/.pnpm/expo-json-utils@0.15.0/node_modules/expo-json-utils/ios`)"
- "EXManifests (from `../node_modules/.pnpm/expo-manifests@1.0.10_expo@54.0.32/node_modules/expo-manifests/ios`)"
- "EXNotifications (from `../node_modules/.pnpm/expo-notifications@0.32.16_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+r_758952db70529f49bda448def1c13c49/node_modules/expo-notifications/ios`)"
- "Expo (from `../node_modules/.pnpm/expo@54.0.32_@babel+core@7.28.6_@expo+metro-runtime@6.1.2_expo-router@6.0.22_react-nati_18ad48ba284ee86e6eb1cb0f939697b0/node_modules/expo`)"
- "expo-dev-client (from `../node_modules/.pnpm/expo-dev-client@6.0.20_expo@54.0.32/node_modules/expo-dev-client/ios`)"
- "expo-dev-launcher (from `../node_modules/.pnpm/expo-dev-launcher@6.0.20_expo@54.0.32/node_modules/expo-dev-launcher`)"
- "expo-dev-menu (from `../node_modules/.pnpm/expo-dev-menu@7.0.18_expo@54.0.32/node_modules/expo-dev-menu`)"
- "expo-dev-menu-interface (from `../node_modules/.pnpm/expo-dev-menu-interface@2.0.0_expo@54.0.32/node_modules/expo-dev-menu-interface/ios`)"
- "ExpoAsset (from `../node_modules/.pnpm/expo-asset@12.0.12_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-asset/ios`)"
- "ExpoFileSystem (from `../node_modules/.pnpm/expo-file-system@19.0.21_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0_/node_modules/expo-file-system/ios`)"
- "ExpoFont (from `../node_modules/.pnpm/expo-font@14.0.11_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-font/ios`)"
- "ExpoHead (from `../node_modules/.pnpm/expo-router@6.0.22_@expo+metro-runtime@6.1.2_@types+react@19.1.17_expo-constants@18.0.1_bd9aa16746ed7110429f931eb008e6d2/node_modules/expo-router/ios`)"
- "ExpoKeepAwake (from `../node_modules/.pnpm/expo-keep-awake@15.0.8_expo@54.0.32_react@19.1.0/node_modules/expo-keep-awake/ios`)"
- "ExpoLinearGradient (from `../node_modules/.pnpm/expo-linear-gradient@15.0.8_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+_53aef72480df9baa4504f4743d9c64bb/node_modules/expo-linear-gradient/ios`)"
- "ExpoLinking (from `../node_modules/.pnpm/expo-linking@8.0.11_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-linking/ios`)"
- "ExpoLocalization (from `../node_modules/.pnpm/expo-localization@17.0.8_expo@54.0.32_react@19.1.0/node_modules/expo-localization/ios`)"
- "ExpoModulesCore (from `../node_modules/.pnpm/expo-modules-core@3.0.29_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-modules-core`)"
- "ExpoSplashScreen (from `../node_modules/.pnpm/expo-splash-screen@31.0.13_expo@54.0.32/node_modules/expo-splash-screen/ios`)"
- "ExpoWebBrowser (from `../node_modules/.pnpm/expo-web-browser@15.0.10_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0_/node_modules/expo-web-browser/ios`)"
- "EXUpdatesInterface (from `../node_modules/.pnpm/expo-updates-interface@2.0.0_expo@54.0.32/node_modules/expo-updates-interface/ios`)"
- "FBLazyVector (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/FBLazyVector`)"
- "hermes-engine (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`)"
- "RCTDeprecation (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`)"
- "RCTRequired (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Required`)"
- "RCTTypeSafety (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/TypeSafety`)"
- "React (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/`)"
- "React-callinvoker (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/callinvoker`)"
- "React-Core (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/`)"
- "React-Core-prebuilt (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/React-Core-prebuilt.podspec`)"
- "React-Core/RCTWebSocket (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/`)"
- "React-CoreModules (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/React/CoreModules`)"
- "React-cxxreact (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/cxxreact`)"
- "React-debug (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/debug`)"
- "React-defaultsnativemodule (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/defaults`)"
- "React-domnativemodule (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/dom`)"
- "React-Fabric (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon`)"
- "React-FabricComponents (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon`)"
- "React-FabricImage (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon`)"
- "React-featureflags (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/featureflags`)"
- "React-featureflagsnativemodule (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/featureflags`)"
- "React-graphics (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/renderer/graphics`)"
- "React-hermes (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/hermes`)"
- "React-idlecallbacksnativemodule (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/idlecallbacks`)"
- "React-ImageManager (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/renderer/imagemanager/platform/ios`)"
- "React-jserrorhandler (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jserrorhandler`)"
- "React-jsi (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsi`)"
- "React-jsiexecutor (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsiexecutor`)"
- "React-jsinspector (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsinspector-modern`)"
- "React-jsinspectorcdp (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsinspector-modern/cdp`)"
- "React-jsinspectornetwork (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsinspector-modern/network`)"
- "React-jsinspectortracing (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsinspector-modern/tracing`)"
- "React-jsitooling (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsitooling`)"
- "React-jsitracing (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/hermes/executor/`)"
- "React-logger (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/logger`)"
- "React-Mapbuffer (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon`)"
- "React-microtasksnativemodule (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/microtasks`)"
- "react-native-safe-area-context (from `../node_modules/.pnpm/react-native-safe-area-context@5.6.2_react-native@0.81.5_@babel+core@7.28.6_@types+reac_14122a3aa345cfabcc022a0f638ef16d/node_modules/react-native-safe-area-context`)"
- "React-NativeModulesApple (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`)"
- "React-oscompat (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/oscompat`)"
- "React-perflogger (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/reactperflogger`)"
- "React-performancetimeline (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/performance/timeline`)"
- "React-RCTActionSheet (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/ActionSheetIOS`)"
- "React-RCTAnimation (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/NativeAnimation`)"
- "React-RCTAppDelegate (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/AppDelegate`)"
- "React-RCTBlob (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Blob`)"
- "React-RCTFabric (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/React`)"
- "React-RCTFBReactNativeSpec (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/React`)"
- "React-RCTImage (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Image`)"
- "React-RCTLinking (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/LinkingIOS`)"
- "React-RCTNetwork (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Network`)"
- "React-RCTRuntime (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/React/Runtime`)"
- "React-RCTSettings (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Settings`)"
- "React-RCTText (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Text`)"
- "React-RCTVibration (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Vibration`)"
- "React-rendererconsistency (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/renderer/consistency`)"
- "React-renderercss (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/renderer/css`)"
- "React-rendererdebug (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/renderer/debug`)"
- "React-RuntimeApple (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/runtime/platform/ios`)"
- "React-RuntimeCore (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/runtime`)"
- "React-runtimeexecutor (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/runtimeexecutor`)"
- "React-RuntimeHermes (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/runtime`)"
- "React-runtimescheduler (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/renderer/runtimescheduler`)"
- "React-timing (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/timing`)"
- "React-utils (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/utils`)"
- EXApplication (from `../node_modules/expo-application/ios`)
- EXConstants (from `../node_modules/expo-constants/ios`)
- EXJSONUtils (from `../node_modules/expo-json-utils/ios`)
- EXManifests (from `../node_modules/expo-manifests/ios`)
- EXNotifications (from `../node_modules/expo-notifications/ios`)
- Expo (from `../node_modules/expo`)
- expo-dev-client (from `../node_modules/expo-dev-client/ios`)
- expo-dev-launcher (from `../node_modules/expo-dev-launcher`)
- expo-dev-menu (from `../node_modules/expo-dev-menu`)
- expo-dev-menu-interface (from `../node_modules/expo-dev-menu-interface/ios`)
- ExpoAsset (from `../node_modules/expo-asset/ios`)
- ExpoCrypto (from `../node_modules/expo-crypto/ios`)
- ExpoDevice (from `../node_modules/expo-device/ios`)
- ExpoFileSystem (from `../node_modules/expo-file-system/ios`)
- ExpoFont (from `../node_modules/expo-font/ios`)
- ExpoHead (from `../node_modules/expo-router/ios`)
- ExpoKeepAwake (from `../node_modules/expo-keep-awake/ios`)
- ExpoLinearGradient (from `../node_modules/expo-linear-gradient/ios`)
- ExpoLinking (from `../node_modules/expo-linking/ios`)
- ExpoLocalization (from `../node_modules/expo-localization/ios`)
- ExpoModulesCore (from `../node_modules/expo-modules-core`)
- ExpoSplashScreen (from `../node_modules/expo-splash-screen/ios`)
- ExpoWebBrowser (from `../node_modules/expo-web-browser/ios`)
- EXUpdatesInterface (from `../node_modules/expo-updates-interface/ios`)
- FBLazyVector (from `../node_modules/react-native/Libraries/FBLazyVector`)
- hermes-engine (from `../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`)
- RCTDeprecation (from `../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`)
- RCTRequired (from `../node_modules/react-native/Libraries/Required`)
- RCTTypeSafety (from `../node_modules/react-native/Libraries/TypeSafety`)
- React (from `../node_modules/react-native/`)
- React-callinvoker (from `../node_modules/react-native/ReactCommon/callinvoker`)
- React-Core (from `../node_modules/react-native/`)
- React-Core-prebuilt (from `../node_modules/react-native/React-Core-prebuilt.podspec`)
- React-Core/RCTWebSocket (from `../node_modules/react-native/`)
- React-CoreModules (from `../node_modules/react-native/React/CoreModules`)
- React-cxxreact (from `../node_modules/react-native/ReactCommon/cxxreact`)
- React-debug (from `../node_modules/react-native/ReactCommon/react/debug`)
- React-defaultsnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/defaults`)
- React-domnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/dom`)
- React-Fabric (from `../node_modules/react-native/ReactCommon`)
- React-FabricComponents (from `../node_modules/react-native/ReactCommon`)
- React-FabricImage (from `../node_modules/react-native/ReactCommon`)
- React-featureflags (from `../node_modules/react-native/ReactCommon/react/featureflags`)
- React-featureflagsnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/featureflags`)
- React-graphics (from `../node_modules/react-native/ReactCommon/react/renderer/graphics`)
- React-hermes (from `../node_modules/react-native/ReactCommon/hermes`)
- React-idlecallbacksnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/idlecallbacks`)
- React-ImageManager (from `../node_modules/react-native/ReactCommon/react/renderer/imagemanager/platform/ios`)
- React-jserrorhandler (from `../node_modules/react-native/ReactCommon/jserrorhandler`)
- React-jsi (from `../node_modules/react-native/ReactCommon/jsi`)
- React-jsiexecutor (from `../node_modules/react-native/ReactCommon/jsiexecutor`)
- React-jsinspector (from `../node_modules/react-native/ReactCommon/jsinspector-modern`)
- React-jsinspectorcdp (from `../node_modules/react-native/ReactCommon/jsinspector-modern/cdp`)
- React-jsinspectornetwork (from `../node_modules/react-native/ReactCommon/jsinspector-modern/network`)
- React-jsinspectortracing (from `../node_modules/react-native/ReactCommon/jsinspector-modern/tracing`)
- React-jsitooling (from `../node_modules/react-native/ReactCommon/jsitooling`)
- React-jsitracing (from `../node_modules/react-native/ReactCommon/hermes/executor/`)
- React-logger (from `../node_modules/react-native/ReactCommon/logger`)
- React-Mapbuffer (from `../node_modules/react-native/ReactCommon`)
- React-microtasksnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`)
- react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`)
- React-NativeModulesApple (from `../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`)
- React-oscompat (from `../node_modules/react-native/ReactCommon/oscompat`)
- React-perflogger (from `../node_modules/react-native/ReactCommon/reactperflogger`)
- React-performancetimeline (from `../node_modules/react-native/ReactCommon/react/performance/timeline`)
- React-RCTActionSheet (from `../node_modules/react-native/Libraries/ActionSheetIOS`)
- React-RCTAnimation (from `../node_modules/react-native/Libraries/NativeAnimation`)
- React-RCTAppDelegate (from `../node_modules/react-native/Libraries/AppDelegate`)
- React-RCTBlob (from `../node_modules/react-native/Libraries/Blob`)
- React-RCTFabric (from `../node_modules/react-native/React`)
- React-RCTFBReactNativeSpec (from `../node_modules/react-native/React`)
- React-RCTImage (from `../node_modules/react-native/Libraries/Image`)
- React-RCTLinking (from `../node_modules/react-native/Libraries/LinkingIOS`)
- React-RCTNetwork (from `../node_modules/react-native/Libraries/Network`)
- React-RCTRuntime (from `../node_modules/react-native/React/Runtime`)
- React-RCTSettings (from `../node_modules/react-native/Libraries/Settings`)
- React-RCTText (from `../node_modules/react-native/Libraries/Text`)
- React-RCTVibration (from `../node_modules/react-native/Libraries/Vibration`)
- React-rendererconsistency (from `../node_modules/react-native/ReactCommon/react/renderer/consistency`)
- React-renderercss (from `../node_modules/react-native/ReactCommon/react/renderer/css`)
- React-rendererdebug (from `../node_modules/react-native/ReactCommon/react/renderer/debug`)
- React-RuntimeApple (from `../node_modules/react-native/ReactCommon/react/runtime/platform/ios`)
- React-RuntimeCore (from `../node_modules/react-native/ReactCommon/react/runtime`)
- React-runtimeexecutor (from `../node_modules/react-native/ReactCommon/runtimeexecutor`)
- React-RuntimeHermes (from `../node_modules/react-native/ReactCommon/react/runtime`)
- React-runtimescheduler (from `../node_modules/react-native/ReactCommon/react/renderer/runtimescheduler`)
- React-timing (from `../node_modules/react-native/ReactCommon/react/timing`)
- React-utils (from `../node_modules/react-native/ReactCommon/react/utils`)
- ReactAppDependencyProvider (from `build/generated/ios`)
- ReactCodegen (from `build/generated/ios`)
- "ReactCommon/turbomodule/core (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon`)"
- "ReactNativeDependencies (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec`)"
- "RNCAsyncStorage (from `../node_modules/.pnpm/@react-native-async-storage+async-storage@2.2.0_react-native@0.81.5_@babel+core@7.28.6__ce3c4972004f3d6791573ec5b64bee38/node_modules/@react-native-async-storage/async-storage`)"
- "RNGestureHandler (from `../node_modules/.pnpm/react-native-gesture-handler@2.30.0_react-native@0.81.5_@babel+core@7.28.6_@types+react_39cf7da47c9c8531caaa923ee740e293/node_modules/react-native-gesture-handler`)"
- "RNReanimated (from `../node_modules/.pnpm/react-native-reanimated@4.1.6_@babel+core@7.28.6_react-native-worklets@0.5.1_@babel+cor_c7c888bd389fb93c9cfe2d3c1c8b0777/node_modules/react-native-reanimated`)"
- "RNScreens (from `../node_modules/.pnpm/react-native-screens@4.16.0_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/react-native-screens`)"
- "RNSVG (from `../node_modules/.pnpm/react-native-svg@15.12.1_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/react-native-svg`)"
- "RNWorklets (from `../node_modules/.pnpm/react-native-worklets@0.5.1_@babel+core@7.28.6_react-native@0.81.5_@babel+core@7.28.6_@_40f69326ce21d3f9f6d74d3965fd9adf/node_modules/react-native-worklets`)"
- "Yoga (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/yoga`)"
- ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`)
- ReactNativeDependencies (from `../node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec`)
- "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)"
- RNGestureHandler (from `../node_modules/react-native-gesture-handler`)
- RNReanimated (from `../node_modules/react-native-reanimated`)
- RNScreens (from `../node_modules/react-native-screens`)
- RNSVG (from `../node_modules/react-native-svg`)
- RNWorklets (from `../node_modules/react-native-worklets`)
- Yoga (from `../node_modules/react-native/ReactCommon/yoga`)
EXTERNAL SOURCES:
EXApplication:
:path: "../node_modules/.pnpm/expo-application@7.0.8_expo@54.0.32/node_modules/expo-application/ios"
:path: "../node_modules/expo-application/ios"
EXConstants:
:path: "../node_modules/.pnpm/expo-constants@18.0.13_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0_/node_modules/expo-constants/ios"
:path: "../node_modules/expo-constants/ios"
EXJSONUtils:
:path: "../node_modules/.pnpm/expo-json-utils@0.15.0/node_modules/expo-json-utils/ios"
:path: "../node_modules/expo-json-utils/ios"
EXManifests:
:path: "../node_modules/.pnpm/expo-manifests@1.0.10_expo@54.0.32/node_modules/expo-manifests/ios"
:path: "../node_modules/expo-manifests/ios"
EXNotifications:
:path: "../node_modules/.pnpm/expo-notifications@0.32.16_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+r_758952db70529f49bda448def1c13c49/node_modules/expo-notifications/ios"
:path: "../node_modules/expo-notifications/ios"
Expo:
:path: "../node_modules/.pnpm/expo@54.0.32_@babel+core@7.28.6_@expo+metro-runtime@6.1.2_expo-router@6.0.22_react-nati_18ad48ba284ee86e6eb1cb0f939697b0/node_modules/expo"
:path: "../node_modules/expo"
expo-dev-client:
:path: "../node_modules/.pnpm/expo-dev-client@6.0.20_expo@54.0.32/node_modules/expo-dev-client/ios"
:path: "../node_modules/expo-dev-client/ios"
expo-dev-launcher:
:path: "../node_modules/.pnpm/expo-dev-launcher@6.0.20_expo@54.0.32/node_modules/expo-dev-launcher"
:path: "../node_modules/expo-dev-launcher"
expo-dev-menu:
:path: "../node_modules/.pnpm/expo-dev-menu@7.0.18_expo@54.0.32/node_modules/expo-dev-menu"
:path: "../node_modules/expo-dev-menu"
expo-dev-menu-interface:
:path: "../node_modules/.pnpm/expo-dev-menu-interface@2.0.0_expo@54.0.32/node_modules/expo-dev-menu-interface/ios"
:path: "../node_modules/expo-dev-menu-interface/ios"
ExpoAsset:
:path: "../node_modules/.pnpm/expo-asset@12.0.12_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-asset/ios"
:path: "../node_modules/expo-asset/ios"
ExpoCrypto:
:path: "../node_modules/expo-crypto/ios"
ExpoDevice:
:path: "../node_modules/expo-device/ios"
ExpoFileSystem:
:path: "../node_modules/.pnpm/expo-file-system@19.0.21_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0_/node_modules/expo-file-system/ios"
:path: "../node_modules/expo-file-system/ios"
ExpoFont:
:path: "../node_modules/.pnpm/expo-font@14.0.11_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-font/ios"
:path: "../node_modules/expo-font/ios"
ExpoHead:
:path: "../node_modules/.pnpm/expo-router@6.0.22_@expo+metro-runtime@6.1.2_@types+react@19.1.17_expo-constants@18.0.1_bd9aa16746ed7110429f931eb008e6d2/node_modules/expo-router/ios"
:path: "../node_modules/expo-router/ios"
ExpoKeepAwake:
:path: "../node_modules/.pnpm/expo-keep-awake@15.0.8_expo@54.0.32_react@19.1.0/node_modules/expo-keep-awake/ios"
:path: "../node_modules/expo-keep-awake/ios"
ExpoLinearGradient:
:path: "../node_modules/.pnpm/expo-linear-gradient@15.0.8_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+_53aef72480df9baa4504f4743d9c64bb/node_modules/expo-linear-gradient/ios"
:path: "../node_modules/expo-linear-gradient/ios"
ExpoLinking:
:path: "../node_modules/.pnpm/expo-linking@8.0.11_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-linking/ios"
:path: "../node_modules/expo-linking/ios"
ExpoLocalization:
:path: "../node_modules/.pnpm/expo-localization@17.0.8_expo@54.0.32_react@19.1.0/node_modules/expo-localization/ios"
:path: "../node_modules/expo-localization/ios"
ExpoModulesCore:
:path: "../node_modules/.pnpm/expo-modules-core@3.0.29_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-modules-core"
:path: "../node_modules/expo-modules-core"
ExpoSplashScreen:
:path: "../node_modules/.pnpm/expo-splash-screen@31.0.13_expo@54.0.32/node_modules/expo-splash-screen/ios"
:path: "../node_modules/expo-splash-screen/ios"
ExpoWebBrowser:
:path: "../node_modules/.pnpm/expo-web-browser@15.0.10_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0_/node_modules/expo-web-browser/ios"
:path: "../node_modules/expo-web-browser/ios"
EXUpdatesInterface:
:path: "../node_modules/.pnpm/expo-updates-interface@2.0.0_expo@54.0.32/node_modules/expo-updates-interface/ios"
:path: "../node_modules/expo-updates-interface/ios"
FBLazyVector:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/FBLazyVector"
:path: "../node_modules/react-native/Libraries/FBLazyVector"
hermes-engine:
:podspec: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec"
:podspec: "../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec"
:tag: hermes-2025-07-07-RNv0.81.0-e0fc67142ec0763c6b6153ca2bf96df815539782
RCTDeprecation:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation"
:path: "../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation"
RCTRequired:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Required"
:path: "../node_modules/react-native/Libraries/Required"
RCTTypeSafety:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/TypeSafety"
:path: "../node_modules/react-native/Libraries/TypeSafety"
React:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/"
:path: "../node_modules/react-native/"
React-callinvoker:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/callinvoker"
:path: "../node_modules/react-native/ReactCommon/callinvoker"
React-Core:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/"
:path: "../node_modules/react-native/"
React-Core-prebuilt:
:podspec: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/React-Core-prebuilt.podspec"
:podspec: "../node_modules/react-native/React-Core-prebuilt.podspec"
React-CoreModules:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/React/CoreModules"
:path: "../node_modules/react-native/React/CoreModules"
React-cxxreact:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/cxxreact"
:path: "../node_modules/react-native/ReactCommon/cxxreact"
React-debug:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/debug"
:path: "../node_modules/react-native/ReactCommon/react/debug"
React-defaultsnativemodule:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/defaults"
:path: "../node_modules/react-native/ReactCommon/react/nativemodule/defaults"
React-domnativemodule:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/dom"
:path: "../node_modules/react-native/ReactCommon/react/nativemodule/dom"
React-Fabric:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon"
:path: "../node_modules/react-native/ReactCommon"
React-FabricComponents:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon"
:path: "../node_modules/react-native/ReactCommon"
React-FabricImage:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon"
:path: "../node_modules/react-native/ReactCommon"
React-featureflags:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/featureflags"
:path: "../node_modules/react-native/ReactCommon/react/featureflags"
React-featureflagsnativemodule:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/featureflags"
:path: "../node_modules/react-native/ReactCommon/react/nativemodule/featureflags"
React-graphics:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/renderer/graphics"
:path: "../node_modules/react-native/ReactCommon/react/renderer/graphics"
React-hermes:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/hermes"
:path: "../node_modules/react-native/ReactCommon/hermes"
React-idlecallbacksnativemodule:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/idlecallbacks"
:path: "../node_modules/react-native/ReactCommon/react/nativemodule/idlecallbacks"
React-ImageManager:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/renderer/imagemanager/platform/ios"
:path: "../node_modules/react-native/ReactCommon/react/renderer/imagemanager/platform/ios"
React-jserrorhandler:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jserrorhandler"
:path: "../node_modules/react-native/ReactCommon/jserrorhandler"
React-jsi:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsi"
:path: "../node_modules/react-native/ReactCommon/jsi"
React-jsiexecutor:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsiexecutor"
:path: "../node_modules/react-native/ReactCommon/jsiexecutor"
React-jsinspector:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsinspector-modern"
:path: "../node_modules/react-native/ReactCommon/jsinspector-modern"
React-jsinspectorcdp:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsinspector-modern/cdp"
:path: "../node_modules/react-native/ReactCommon/jsinspector-modern/cdp"
React-jsinspectornetwork:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsinspector-modern/network"
:path: "../node_modules/react-native/ReactCommon/jsinspector-modern/network"
React-jsinspectortracing:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsinspector-modern/tracing"
:path: "../node_modules/react-native/ReactCommon/jsinspector-modern/tracing"
React-jsitooling:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsitooling"
:path: "../node_modules/react-native/ReactCommon/jsitooling"
React-jsitracing:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/hermes/executor/"
:path: "../node_modules/react-native/ReactCommon/hermes/executor/"
React-logger:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/logger"
:path: "../node_modules/react-native/ReactCommon/logger"
React-Mapbuffer:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon"
:path: "../node_modules/react-native/ReactCommon"
React-microtasksnativemodule:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/microtasks"
:path: "../node_modules/react-native/ReactCommon/react/nativemodule/microtasks"
react-native-safe-area-context:
:path: "../node_modules/.pnpm/react-native-safe-area-context@5.6.2_react-native@0.81.5_@babel+core@7.28.6_@types+reac_14122a3aa345cfabcc022a0f638ef16d/node_modules/react-native-safe-area-context"
:path: "../node_modules/react-native-safe-area-context"
React-NativeModulesApple:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios"
:path: "../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios"
React-oscompat:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/oscompat"
:path: "../node_modules/react-native/ReactCommon/oscompat"
React-perflogger:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/reactperflogger"
:path: "../node_modules/react-native/ReactCommon/reactperflogger"
React-performancetimeline:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/performance/timeline"
:path: "../node_modules/react-native/ReactCommon/react/performance/timeline"
React-RCTActionSheet:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/ActionSheetIOS"
:path: "../node_modules/react-native/Libraries/ActionSheetIOS"
React-RCTAnimation:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/NativeAnimation"
:path: "../node_modules/react-native/Libraries/NativeAnimation"
React-RCTAppDelegate:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/AppDelegate"
:path: "../node_modules/react-native/Libraries/AppDelegate"
React-RCTBlob:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Blob"
:path: "../node_modules/react-native/Libraries/Blob"
React-RCTFabric:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/React"
:path: "../node_modules/react-native/React"
React-RCTFBReactNativeSpec:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/React"
:path: "../node_modules/react-native/React"
React-RCTImage:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Image"
:path: "../node_modules/react-native/Libraries/Image"
React-RCTLinking:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/LinkingIOS"
:path: "../node_modules/react-native/Libraries/LinkingIOS"
React-RCTNetwork:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Network"
:path: "../node_modules/react-native/Libraries/Network"
React-RCTRuntime:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/React/Runtime"
:path: "../node_modules/react-native/React/Runtime"
React-RCTSettings:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Settings"
:path: "../node_modules/react-native/Libraries/Settings"
React-RCTText:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Text"
:path: "../node_modules/react-native/Libraries/Text"
React-RCTVibration:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Vibration"
:path: "../node_modules/react-native/Libraries/Vibration"
React-rendererconsistency:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/renderer/consistency"
:path: "../node_modules/react-native/ReactCommon/react/renderer/consistency"
React-renderercss:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/renderer/css"
:path: "../node_modules/react-native/ReactCommon/react/renderer/css"
React-rendererdebug:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/renderer/debug"
:path: "../node_modules/react-native/ReactCommon/react/renderer/debug"
React-RuntimeApple:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/runtime/platform/ios"
:path: "../node_modules/react-native/ReactCommon/react/runtime/platform/ios"
React-RuntimeCore:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/runtime"
:path: "../node_modules/react-native/ReactCommon/react/runtime"
React-runtimeexecutor:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/runtimeexecutor"
:path: "../node_modules/react-native/ReactCommon/runtimeexecutor"
React-RuntimeHermes:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/runtime"
:path: "../node_modules/react-native/ReactCommon/react/runtime"
React-runtimescheduler:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/renderer/runtimescheduler"
:path: "../node_modules/react-native/ReactCommon/react/renderer/runtimescheduler"
React-timing:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/timing"
:path: "../node_modules/react-native/ReactCommon/react/timing"
React-utils:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/utils"
:path: "../node_modules/react-native/ReactCommon/react/utils"
ReactAppDependencyProvider:
:path: build/generated/ios
ReactCodegen:
:path: build/generated/ios
ReactCommon:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon"
:path: "../node_modules/react-native/ReactCommon"
ReactNativeDependencies:
:podspec: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec"
:podspec: "../node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec"
RNCAsyncStorage:
:path: "../node_modules/.pnpm/@react-native-async-storage+async-storage@2.2.0_react-native@0.81.5_@babel+core@7.28.6__ce3c4972004f3d6791573ec5b64bee38/node_modules/@react-native-async-storage/async-storage"
:path: "../node_modules/@react-native-async-storage/async-storage"
RNGestureHandler:
:path: "../node_modules/.pnpm/react-native-gesture-handler@2.30.0_react-native@0.81.5_@babel+core@7.28.6_@types+react_39cf7da47c9c8531caaa923ee740e293/node_modules/react-native-gesture-handler"
:path: "../node_modules/react-native-gesture-handler"
RNReanimated:
:path: "../node_modules/.pnpm/react-native-reanimated@4.1.6_@babel+core@7.28.6_react-native-worklets@0.5.1_@babel+cor_c7c888bd389fb93c9cfe2d3c1c8b0777/node_modules/react-native-reanimated"
:path: "../node_modules/react-native-reanimated"
RNScreens:
:path: "../node_modules/.pnpm/react-native-screens@4.16.0_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/react-native-screens"
:path: "../node_modules/react-native-screens"
RNSVG:
:path: "../node_modules/.pnpm/react-native-svg@15.12.1_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/react-native-svg"
:path: "../node_modules/react-native-svg"
RNWorklets:
:path: "../node_modules/.pnpm/react-native-worklets@0.5.1_@babel+core@7.28.6_react-native@0.81.5_@babel+core@7.28.6_@_40f69326ce21d3f9f6d74d3965fd9adf/node_modules/react-native-worklets"
:path: "../node_modules/react-native-worklets"
Yoga:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/yoga"
:path: "../node_modules/react-native/ReactCommon/yoga"
SPEC CHECKSUMS:
EXApplication: 1e98d4b1dccdf30627f92917f4b2c5a53c330e5f
@@ -2541,6 +2551,8 @@ SPEC CHECKSUMS:
expo-dev-menu: 05b18812110c175814c6af0d09dd658abcc5e00d
expo-dev-menu-interface: 600df12ea01efecdd822daaf13cc0ac091775533
ExpoAsset: f867e55ceb428aab99e1e8c082b5aee7c159ea18
ExpoCrypto: b6105ebaa15d6b38a811e71e43b52cd934945322
ExpoDevice: 6327c3c200816795708885adf540d26ecab83d1a
ExpoFileSystem: 858a44267a3e6e9057e0888ad7c7cfbf55d52063
ExpoFont: f543ce20a228dd702813668b1a07b46f51878d47
ExpoHead: 4425246bc93411f0fe7f6945f95f698e91db8780
@@ -2616,17 +2628,17 @@ SPEC CHECKSUMS:
React-timing: 6fa9883de2e41791e5dc4ec404e5e37f3f50e801
React-utils: 6e2035b53d087927768649a11a26c4e092448e34
ReactAppDependencyProvider: 1bcd3527ac0390a1c898c114f81ff954be35ed79
ReactCodegen: fffa79906f5866f6a5ab5d98480b375191190271
ReactCodegen: 7d4593f7591f002d137fe40cef3f6c11f13c88cc
ReactCommon: 08810150b1206cc44aecf5f6ae19af32f29151a8
ReactNativeDependencies: 71ce9c28beb282aa720ea7b46980fff9669f428a
RNCAsyncStorage: 3a4f5e2777dae1688b781a487923a08569e27fe4
RNGestureHandler: e0d0bce5599f6120b7adf90c38d2805e2935795f
RNReanimated: 9c6a550b41de91cf374e60afd79db93a362f1126
RNReanimated: e5c702a3e24cc1c68b2de67671713f35461678f4
RNScreens: d8d6f1792f6e7ac12b0190d33d8d390efc0c1845
RNSVG: 31d6639663c249b7d5abc9728dde2041eb2a3c34
RNWorklets: 1b50cb7595142f95e70518196ba247ad7f46a52e
RNWorklets: 76fce72926e28e304afb44f0da23b2d24f2c1fa0
Yoga: 5934998fbeaef7845dbf698f698518695ab4cd1a
PODFILE CHECKSUM: dfe3cc75dee014a0abd367bc9e1bdbab0ba64ee3
PODFILE CHECKSUM: c2c3838f0b2a579fef2350bff2ecaa005e27145d
COCOAPODS: 1.16.2

View File

@@ -12,6 +12,9 @@
1A1DE01D4133812B2E2BA692 /* libPods-client.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E3328F0E595C1F4A244DF238 /* libPods-client.a */; };
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; };
A1B2C3D4E5F60718293A4B5C /* EmotionWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B5B /* EmotionWidget.swift */; };
A8C1D2E3F4A5B6C7D8E9F0A2 /* AppGroupStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8C1D2E3F4A5B6C7D8E9F0A1 /* AppGroupStorage.swift */; };
A8C1D2E3F4A5B6C7D8E9F0A3 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EB3DAF802F2A4B8D00450593 /* WidgetKit.framework */; };
A8C1D2E3F4A5B6C7D8E9F0B2 /* AppGroupStorageBridge.m in Sources */ = {isa = PBXBuildFile; fileRef = A8C1D2E3F4A5B6C7D8E9F0B1 /* AppGroupStorageBridge.m */; };
B5A7FE9A125F7C79753EC5BF /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7DB40C26E3A46F6D06769EA /* ExpoModulesProvider.swift */; };
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
EB3DAF812F2A4B8E00450593 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EB3DAF802F2A4B8D00450593 /* WidgetKit.framework */; };
@@ -45,12 +48,14 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
13B07F961A680F5B00A75B9A /* client.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = client.app; sourceTree = BUILT_PRODUCTS_DIR; };
13B07F961A680F5B00A75B9A /* HeyMama.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = HeyMama.app; sourceTree = BUILT_PRODUCTS_DIR; };
13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = client/Images.xcassets; sourceTree = "<group>"; };
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = client/Info.plist; sourceTree = "<group>"; };
3C76CA16D0801CBF0D731C7C /* Pods-client.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-client.release.xcconfig"; path = "Target Support Files/Pods-client/Pods-client.release.xcconfig"; sourceTree = "<group>"; };
75F52ADE07CAE9D9736D7671 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = client/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
A1B2C3D4E5F60718293A4B5B /* EmotionWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "情绪小组件/EmotionWidget.swift"; sourceTree = "<group>"; };
A8C1D2E3F4A5B6C7D8E9F0A1 /* AppGroupStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppGroupStorage.swift; path = client/AppGroupStorage.swift; sourceTree = "<group>"; };
A8C1D2E3F4A5B6C7D8E9F0B1 /* AppGroupStorageBridge.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = AppGroupStorageBridge.m; path = client/AppGroupStorageBridge.m; sourceTree = "<group>"; };
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = client/SplashScreen.storyboard; sourceTree = "<group>"; };
BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = "<group>"; };
C7DB40C26E3A46F6D06769EA /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-client/ExpoModulesProvider.swift"; sourceTree = "<group>"; };
@@ -58,7 +63,8 @@
EB3DAF7F2F2A4B8D00450593 /* 情绪小组件Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "情绪小组件Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
EB3DAF802F2A4B8D00450593 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
EB3DAF822F2A4B8E00450593 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
EB3DAF9A2F2A4D0900450593 /* MindfulnessWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MindfulnessWidget.swift; sourceTree = "<group>"; };
EBEEC7562F31D82700C68C1A /* clientRelease.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; name = clientRelease.entitlements; path = client/clientRelease.entitlements; sourceTree = "<group>"; };
EBEEC7572F31D84B00C68C1A /* 情绪小组件ExtensionRelease.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "情绪小组件ExtensionRelease.entitlements"; sourceTree = "<group>"; };
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
F11748412D0307B40044C1D9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = client/AppDelegate.swift; sourceTree = "<group>"; };
F11748442D0722820044C1D9 /* client-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "client-Bridging-Header.h"; path = "client/client-Bridging-Header.h"; sourceTree = "<group>"; };
@@ -97,6 +103,7 @@
buildActionMask = 2147483647;
files = (
1A1DE01D4133812B2E2BA692 /* libPods-client.a in Frameworks */,
A8C1D2E3F4A5B6C7D8E9F0A3 /* WidgetKit.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -115,8 +122,10 @@
13B07FAE1A68108700A75B9A /* client */ = {
isa = PBXGroup;
children = (
EB3DAF9A2F2A4D0900450593 /* MindfulnessWidget.swift */,
EBEEC7562F31D82700C68C1A /* clientRelease.entitlements */,
F11748412D0307B40044C1D9 /* AppDelegate.swift */,
A8C1D2E3F4A5B6C7D8E9F0A1 /* AppGroupStorage.swift */,
A8C1D2E3F4A5B6C7D8E9F0B1 /* AppGroupStorageBridge.m */,
F11748442D0722820044C1D9 /* client-Bridging-Header.h */,
BB2F792B24A3F905000567C9 /* Supporting */,
13B07FB51A68108700A75B9A /* Images.xcassets */,
@@ -156,6 +165,7 @@
83CBB9F61A601CBA00E9B192 = {
isa = PBXGroup;
children = (
EBEEC7572F31D84B00C68C1A /* 情绪小组件ExtensionRelease.entitlements */,
13B07FAE1A68108700A75B9A /* client */,
832341AE1AAA6A7D00B99B32 /* Libraries */,
EB3DAF842F2A4B8E00450593 /* 情绪小组件 */,
@@ -173,7 +183,7 @@
83CBBA001A601CBA00E9B192 /* Products */ = {
isa = PBXGroup;
children = (
13B07F961A680F5B00A75B9A /* client.app */,
13B07F961A680F5B00A75B9A /* HeyMama.app */,
EB3DAF7F2F2A4B8D00450593 /* 情绪小组件Extension.appex */,
);
name = Products;
@@ -237,7 +247,7 @@
);
name = client;
productName = client;
productReference = 13B07F961A680F5B00A75B9A /* client.app */;
productReference = 13B07F961A680F5B00A75B9A /* HeyMama.app */;
productType = "com.apple.product-type.application";
};
EB3DAF7E2F2A4B8D00450593 /* 情绪小组件Extension */ = {
@@ -266,8 +276,12 @@
83CBB9F71A601CBA00E9B192 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
KnownAssetTags = (
New,
);
LastSwiftUpdateCheck = 2620;
LastUpgradeCheck = 1130;
LastUpgradeCheck = 2620;
TargetAttributes = {
13B07F861A680F5B00A75B9A = {
LastSwiftMigration = 1250;
@@ -368,6 +382,7 @@
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/EXConstants.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/ExpoConstants_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/EXNotifications/ExpoNotifications_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoDevice/ExpoDevice_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoFileSystem/ExpoFileSystem_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoLocalization/ExpoLocalization_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/RNCAsyncStorage/RNCAsyncStorage_resources.bundle",
@@ -383,6 +398,7 @@
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXConstants.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoConstants_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoNotifications_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoDevice_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoFileSystem_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoLocalization_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNCAsyncStorage_resources.bundle",
@@ -452,6 +468,8 @@
files = (
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */,
B5A7FE9A125F7C79753EC5BF /* ExpoModulesProvider.swift in Sources */,
A8C1D2E3F4A5B6C7D8E9F0A2 /* AppGroupStorage.swift in Sources */,
A8C1D2E3F4A5B6C7D8E9F0B2 /* AppGroupStorageBridge.m in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -479,10 +497,13 @@
baseConfigurationReference = FFF632A94C7A551AAA096858 /* Pods-client.debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = client/client.entitlements;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 4;
DEVELOPMENT_TEAM = WS92GPX9H2;
ENABLE_BITCODE = NO;
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = x86_64;
GCC_PREPROCESSOR_DEFINITIONS = (
"$(inherited)",
"FB_SONARKIT_ENABLED=1",
@@ -493,15 +514,16 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.0.1;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
"-lc++",
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
PRODUCT_BUNDLE_IDENTIFIER = com.anonymous.client;
PRODUCT_NAME = client;
PRODUCT_BUNDLE_IDENTIFIER = com.damer.mindfulness;
PRODUCT_NAME = HeyMama;
SKIP_INSTALL = NO;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
@@ -509,7 +531,7 @@
SWIFT_OBJC_BRIDGING_HEADER = "client/client-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TARGETED_DEVICE_FAMILY = 1;
VERSIONING_SYSTEM = "apple-generic";
};
name = Debug;
@@ -519,31 +541,37 @@
baseConfigurationReference = 3C76CA16D0801CBF0D731C7C /* Pods-client.release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = client/client.entitlements;
CURRENT_PROJECT_VERSION = 1;
CODE_SIGN_ENTITLEMENTS = client/clientRelease.entitlements;
CURRENT_PROJECT_VERSION = 4;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = WS92GPX9H2;
DWARF_DSYM_FILE_SHOULD_ACCOMPANY_PRODUCT = YES;
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = x86_64;
INFOPLIST_FILE = client/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.0.1;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
"-lc++",
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = com.anonymous.client;
PRODUCT_NAME = client;
PRODUCT_BUNDLE_IDENTIFIER = com.damer.mindfulness;
PRODUCT_NAME = HeyMama;
SKIP_INSTALL = NO;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_OBJC_BRIDGING_HEADER = "client/client-Bridging-Header.h";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TARGETED_DEVICE_FAMILY = 1;
VERSIONING_SYSTEM = "apple-generic";
};
name = Release;
@@ -571,6 +599,7 @@
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
@@ -602,9 +631,11 @@
);
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native";
ONLY_ACTIVE_ARCH = NO;
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos;
SKIP_INSTALL = NO;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
USE_HERMES = true;
@@ -634,6 +665,7 @@
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
@@ -657,9 +689,13 @@
"$(inherited)",
);
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
MTL_ENABLE_DEBUG_INFO = NO;
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native";
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos;
SKIP_INSTALL = NO;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
USE_HERMES = true;
VALIDATE_PRODUCT = YES;
@@ -679,9 +715,11 @@
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = "情绪小组件ExtensionRelease.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 4;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = WS92GPX9H2;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
@@ -695,11 +733,11 @@
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0.0;
MARKETING_VERSION = 1.0.1;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
PRODUCT_BUNDLE_IDENTIFIER = com.anonymous.client.emotionwidget;
PRODUCT_BUNDLE_IDENTIFIER = com.damer.mindfulness.emotionwidget;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -713,7 +751,7 @@
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TARGETED_DEVICE_FAMILY = 1;
};
name = Debug;
};
@@ -730,10 +768,12 @@
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = "情绪小组件ExtensionRelease.entitlements";
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 4;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = WS92GPX9H2;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
@@ -747,10 +787,10 @@
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0.0;
MARKETING_VERSION = 1.0.1;
MTL_FAST_MATH = YES;
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = com.anonymous.client.emotionwidget;
PRODUCT_BUNDLE_IDENTIFIER = com.damer.mindfulness.emotionwidget;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -763,7 +803,7 @@
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TARGETED_DEVICE_FAMILY = 1;
};
name = Release;
};

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1130"
version = "1.3">
LastUpgradeVersion = "2620"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
@@ -15,7 +15,7 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
BuildableName = "client.app"
BuildableName = "HeyMama.app"
BlueprintName = "client"
ReferencedContainer = "container:client.xcodeproj">
</BuildableReference>
@@ -26,19 +26,8 @@
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "00E356ED1AD99517003FC87E"
BuildableName = "clientTests.xctest"
BlueprintName = "clientTests"
ReferencedContainer = "container:client.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
@@ -55,7 +44,7 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
BuildableName = "client.app"
BuildableName = "HeyMama.app"
BlueprintName = "client"
ReferencedContainer = "container:client.xcodeproj">
</BuildableReference>
@@ -72,7 +61,7 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
BuildableName = "client.app"
BuildableName = "HeyMama.app"
BlueprintName = "client"
ReferencedContainer = "container:client.xcodeproj">
</BuildableReference>
@@ -83,6 +72,26 @@
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
customArchiveName = "Hey Mama"
revealArchiveInOrganizer = "YES">
<PostActions>
<ExecutionAction
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
<ActionContent
title = "&#x4fee;&#x590d;&#x5f52;&#x6863;&#x5934;&#x4fe1;&#x606f;&#xff08;&#x907f;&#x514d; Generic Xcode Archive&#xff09;"
scriptText = "bash &quot;${SRCROOT}/scripts/fix-xcarchive-header.sh&quot; &quot;${ARCHIVE_PATH}&quot;&#10;"
shellToInvoke = "/bin/sh">
<EnvironmentBuildable>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
BuildableName = "HeyMama.app"
BlueprintName = "client"
ReferencedContainer = "container:client.xcodeproj">
</BuildableReference>
</EnvironmentBuildable>
</ActionContent>
</ExecutionAction>
</PostActions>
</ArchiveAction>
</Scheme>

View File

@@ -0,0 +1,53 @@
import Foundation
import React
import WidgetKit
/**
* App Group RN Bridge
*
*
* - suiteNamegroup.com.damer.mindfulness App Widget Extension entitlements
* - 使 JSON JS
*/
@objc(AppGroupStorage)
final class AppGroupStorage: NSObject {
// AppGroupStorageBridge.m RCT_EXTERN_MODULE RN
// / RCTBridgeModule Archive
@objc static func requiresMainQueueSetup() -> Bool { false }
private let suiteName = "group.com.damer.mindfulness"
private func defaults() -> UserDefaults? {
UserDefaults(suiteName: suiteName)
}
@objc(setString:value:resolver:rejecter:)
func setString(_ key: String, value: String, resolver resolve: RCTPromiseResolveBlock, rejecter reject: RCTPromiseRejectBlock) {
guard let d = defaults() else {
reject("E_APP_GROUP", "无法初始化 App Group UserDefaultssuiteName=\(suiteName)", nil)
return
}
d.set(value, forKey: key)
resolve(nil)
}
@objc(getString:resolver:rejecter:)
func getString(_ key: String, resolver resolve: RCTPromiseResolveBlock, rejecter reject: RCTPromiseRejectBlock) {
guard let d = defaults() else {
reject("E_APP_GROUP", "无法初始化 App Group UserDefaultssuiteName=\(suiteName)", nil)
return
}
let v = d.string(forKey: key)
resolve(v)
}
/**
* Widget
*/
@objc(reloadAllTimelines:rejecter:)
func reloadAllTimelines(_ resolve: RCTPromiseResolveBlock, rejecter reject: RCTPromiseRejectBlock) {
WidgetCenter.shared.reloadAllTimelines()
resolve(nil)
}
}

View File

@@ -0,0 +1,26 @@
#import <React/RCTBridgeModule.h>
/**
* Swift
*
*
* - React Native Swift RCT_EXTERN_MODULE / RCT_EXTERN_METHOD
* - JS NativeModules.AppGroupStorage undefined
*/
@interface RCT_EXTERN_MODULE(AppGroupStorage, NSObject)
RCT_EXTERN_METHOD(setString:(NSString *)key
value:(NSString *)value
resolver:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)
RCT_EXTERN_METHOD(getString:(NSString *)key
resolver:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)
RCT_EXTERN_METHOD(reloadAllTimelines:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)
@end

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 142 KiB

View File

@@ -4,9 +4,9 @@
"color": {
"components": {
"alpha": "1.000",
"blue": "1.00000000000000",
"green": "1.00000000000000",
"red": "1.00000000000000"
"blue": "0.729411764705882",
"green": "0.823529411764706",
"red": "0.917647058823529"
},
"color-space": "srgb"
},

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 123 KiB

View File

@@ -1,81 +1,83 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>client</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>client</string>
<string>com.anonymous.client</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSMinimumSystemVersion</key>
<string>12.0</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<false/>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>NSUserActivityTypes</key>
<array>
<string>$(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route</string>
</array>
<key>RCTNewArchEnabled</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>SplashScreen</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UIRequiresFullScreen</key>
<false/>
<key>UIStatusBarStyle</key>
<string>UIStatusBarStyleDefault</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIUserInterfaceStyle</key>
<string>Automatic</string>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
</dict>
</plist>
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Hey Mama</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>client</string>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSMinimumSystemVersion</key>
<string>12.0</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSLocalNetworkUsageDescription</key>
<string>用于连接局域网服务以获取内容与同步数据(仅在需要访问内网地址时使用)。</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<false/>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>NSUserActivityTypes</key>
<array>
<string>$(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route</string>
</array>
<key>RCTNewArchEnabled</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>SplashScreen</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UIRequiresFullScreen</key>
<false/>
<key>UIStatusBarStyle</key>
<string>UIStatusBarStyleDefault</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIUserInterfaceStyle</key>
<string>Automatic</string>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
</dict>
</plist>

View File

@@ -22,6 +22,14 @@
<string>CA92.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>35F9.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryDiskSpace</string>
@@ -31,14 +39,6 @@
<string>85F4.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>35F9.1</string>
</array>
</dict>
</array>
<key>NSPrivacyCollectedDataTypes</key>
<array/>

View File

@@ -42,7 +42,7 @@
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor>
<namedColor name="SplashScreenBackground">
<color alpha="1.000" blue="1.00000000000000" green="1.00000000000000" red="1.00000000000000" customColorSpace="sRGB" colorSpace="custom"/>
<color alpha="1.000" blue="0.729411764705882" green="0.823529411764706" red="0.917647058823529" customColorSpace="sRGB" colorSpace="custom"/>
</namedColor>
</resources>
</document>

View File

@@ -1,3 +1,11 @@
//
// Use this file to import your target's public headers that you would like to expose to Swift.
//
// 说明:
// - 部分环境下仅 `import React` 可能无法在 Swift 中解析到 RCTBridge 等类型
// - 通过 Bridging Header 显式引入需要的 React 头文件,保证 AppDelegate.swift 可编译
#import <React/RCTBridge.h>
#import <React/RCTBridgeModule.h>
#import <React/RCTBundleURLProvider.h>
#import <React/RCTLinkingManager.h>

View File

@@ -3,6 +3,11 @@
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>development</string>
<string>production</string>
<!-- iOS 小组件需要通过 App Group 与主 App 共享数据 -->
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.damer.mindfulness</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>production</string>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.damer.mindfulness</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,133 @@
#!/usr/bin/env bash
set -euo pipefail
# 修复 Xcode Organizer 显示 “Generic Xcode Archive” 的问题:
# - 某些情况下 xcodebuild 生成的 .xcarchive/Info.plist 缺少 ApplicationProperties
# - Organizer 无法识别归档中的主 App即使 Products/Applications/*.app 存在)
#
# 说明:
# - 该脚本的核心作用是让 Organizer 能识别归档里的主 App从而出现“分发/上传 TestFlight”入口。
# - 这类问题通常发生在命令行/CI 归档xcodebuild archive或某些自定义归档流程中
# 导致 .xcarchive/Info.plist 缺少/不完整。
#
# 用法:
# ./scripts/fix-xcarchive-header.sh "/path/to/xxx.xcarchive"
ARCHIVE_PATH="${1:-}"
if [[ -z "$ARCHIVE_PATH" ]]; then
echo "用法: $0 \"/path/to/xxx.xcarchive\"" >&2
exit 2
fi
if [[ ! -d "$ARCHIVE_PATH" ]]; then
echo "错误:找不到归档目录:$ARCHIVE_PATH" >&2
exit 2
fi
ARCHIVE_INFO_PLIST="$ARCHIVE_PATH/Info.plist"
if [[ ! -f "$ARCHIVE_INFO_PLIST" ]]; then
echo "错误:找不到归档 Info.plist$ARCHIVE_INFO_PLIST" >&2
exit 2
fi
# 归档名:尽量从路径推导,避免依赖 Xcode 环境变量
archive_basename="$(/usr/bin/basename "$ARCHIVE_PATH")"
archive_name="${archive_basename%.xcarchive}"
# 取第一个 App归档里通常只有一个主 App
APP_PLIST="$(/usr/bin/find "$ARCHIVE_PATH/Products/Applications" -maxdepth 2 -name Info.plist -path "*.app/Info.plist" 2>/dev/null | /usr/bin/head -n 1 || true)"
if [[ -z "$APP_PLIST" ]]; then
echo "错误:归档中未找到 Products/Applications/*.app/Info.plist请先确保归档产出包含 .app" >&2
exit 2
fi
APP_DIR="$(/usr/bin/dirname "$APP_PLIST")"
APP_NAME="$(/usr/bin/basename "$APP_DIR")" # 例如 HeyMama.app
APP_REL_PATH="Applications/$APP_NAME"
bundle_id="$(/usr/bin/plutil -extract CFBundleIdentifier raw -o - "$APP_PLIST" 2>/dev/null || true)"
short_version="$(/usr/bin/plutil -extract CFBundleShortVersionString raw -o - "$APP_PLIST" 2>/dev/null || true)"
build_version="$(/usr/bin/plutil -extract CFBundleVersion raw -o - "$APP_PLIST" 2>/dev/null || true)"
display_name="$(/usr/bin/plutil -extract CFBundleDisplayName raw -o - "$APP_PLIST" 2>/dev/null || true)"
bundle_name="$(/usr/bin/plutil -extract CFBundleName raw -o - "$APP_PLIST" 2>/dev/null || true)"
# SchemeName 在 Organizer 中会用到,但在某些归档流程里会缺失
scheme_name="${SCHEME_NAME:-}"
if [[ -z "$scheme_name" ]]; then
scheme_name="${archive_name:-}"
fi
if [[ -z "$bundle_id" || -z "$short_version" || -z "$build_version" ]]; then
echo "错误:无法从 App Info.plist 读取 bundle/version/build$APP_PLIST" >&2
exit 2
fi
# 解析 embedded.mobileprovision若存在
profile_name=""
profile_uuid=""
team_id=""
provision_path="$APP_DIR/embedded.mobileprovision"
if [[ -f "$provision_path" ]]; then
decoded="$(/usr/bin/security cms -D -i "$provision_path" 2>/dev/null || true)"
if [[ -n "$decoded" ]]; then
# 使用 plutil 从 xml 中提取字段
profile_name="$(printf "%s" "$decoded" | /usr/bin/plutil -extract Name raw -o - - 2>/dev/null || true)"
profile_uuid="$(printf "%s" "$decoded" | /usr/bin/plutil -extract UUID raw -o - - 2>/dev/null || true)"
team_id="$(printf "%s" "$decoded" | /usr/bin/plutil -extract TeamIdentifier.0 raw -o - - 2>/dev/null || true)"
fi
fi
# 备份一份,防止误操作
cp -f "$ARCHIVE_INFO_PLIST" "$ARCHIVE_INFO_PLIST.bak"
# 修复归档根字段,避免 Organizer 仍然把它当 Generic Archive
# 参考:标准 .xcarchive/Info.plist 通常包含 Name / SchemeName / ArchiveVersion / CreationDate 等。
# 我们只在缺失时补齐,尽量不改动归档的其他内容。
if ! /usr/bin/plutil -extract Name xml1 -o - "$ARCHIVE_INFO_PLIST" >/dev/null 2>&1; then
/usr/bin/plutil -insert Name -string "${archive_name:-${display_name:-${bundle_name:-}}}" "$ARCHIVE_INFO_PLIST" 2>/dev/null || true
fi
if ! /usr/bin/plutil -extract SchemeName xml1 -o - "$ARCHIVE_INFO_PLIST" >/dev/null 2>&1; then
/usr/bin/plutil -insert SchemeName -string "${scheme_name:-${archive_name:-}}" "$ARCHIVE_INFO_PLIST" 2>/dev/null || true
fi
# 如果已有 ApplicationProperties直接更新关键字段即可
if /usr/bin/plutil -extract ApplicationProperties xml1 -o - "$ARCHIVE_INFO_PLIST" >/dev/null 2>&1; then
/usr/bin/plutil -replace ApplicationProperties.ApplicationPath -string "$APP_REL_PATH" "$ARCHIVE_INFO_PLIST"
/usr/bin/plutil -replace ApplicationProperties.CFBundleIdentifier -string "$bundle_id" "$ARCHIVE_INFO_PLIST"
/usr/bin/plutil -replace ApplicationProperties.CFBundleShortVersionString -string "$short_version" "$ARCHIVE_INFO_PLIST"
/usr/bin/plutil -replace ApplicationProperties.CFBundleVersion -string "$build_version" "$ARCHIVE_INFO_PLIST"
else
# 新增 ApplicationProperties注意plutil 的空字典/数组类型是 -dictionary / -array
/usr/bin/plutil -insert ApplicationProperties -dictionary "$ARCHIVE_INFO_PLIST"
/usr/bin/plutil -insert ApplicationProperties.ApplicationPath -string "$APP_REL_PATH" "$ARCHIVE_INFO_PLIST"
/usr/bin/plutil -insert ApplicationProperties.CFBundleIdentifier -string "$bundle_id" "$ARCHIVE_INFO_PLIST"
/usr/bin/plutil -insert ApplicationProperties.CFBundleShortVersionString -string "$short_version" "$ARCHIVE_INFO_PLIST"
/usr/bin/plutil -insert ApplicationProperties.CFBundleVersion -string "$build_version" "$ARCHIVE_INFO_PLIST"
/usr/bin/plutil -insert ApplicationProperties.Architectures -array "$ARCHIVE_INFO_PLIST"
/usr/bin/plutil -insert ApplicationProperties.Architectures.0 -string "arm64" "$ARCHIVE_INFO_PLIST"
fi
# 可选字段Provisioning Profile 信息(不保证一定存在)
if [[ -n "$profile_name" ]]; then
/usr/bin/plutil -replace ApplicationProperties.ProvisioningProfileName -string "$profile_name" "$ARCHIVE_INFO_PLIST" 2>/dev/null || \
/usr/bin/plutil -insert ApplicationProperties.ProvisioningProfileName -string "$profile_name" "$ARCHIVE_INFO_PLIST"
fi
if [[ -n "$profile_uuid" ]]; then
/usr/bin/plutil -replace ApplicationProperties.ProvisioningProfileUUID -string "$profile_uuid" "$ARCHIVE_INFO_PLIST" 2>/dev/null || \
/usr/bin/plutil -insert ApplicationProperties.ProvisioningProfileUUID -string "$profile_uuid" "$ARCHIVE_INFO_PLIST"
fi
if [[ -n "$team_id" ]]; then
/usr/bin/plutil -replace ApplicationProperties.Team -string "$team_id" "$ARCHIVE_INFO_PLIST" 2>/dev/null || \
/usr/bin/plutil -insert ApplicationProperties.Team -string "$team_id" "$ARCHIVE_INFO_PLIST"
fi
echo "已修复归档 header$ARCHIVE_INFO_PLIST"
echo "主 App$APP_REL_PATH"
echo "Bundle$bundle_id"
echo "Version/Build$short_version/$build_version"
echo "Name/SchemeName${archive_name:-} / ${scheme_name:-}"
# 轻量自检:确保关键字段存在(不强制失败,避免中断归档)
if ! /usr/bin/plutil -extract ApplicationProperties.ApplicationPath xml1 -o - "$ARCHIVE_INFO_PLIST" >/dev/null 2>&1; then
echo "警告:归档 Info.plist 仍缺少 ApplicationProperties.ApplicationPathOrganizer 可能仍显示 Generic Archive" >&2
fi

View File

@@ -1,190 +1,326 @@
import Foundation
import WidgetKit
import SwiftUI
// V1Small/Medium/Large + Home
// Daily Widget Reco App Group /v1/reco/widget
private let appGroupSuiteName = "group.com.damer.mindfulness"
private let keyWidgetConfig = "widget.config.v1"
private let keyWidgetUserProfile = "widget.userProfile.v1_2"
private let keyWidgetDailyReco = "widget.dailyReco.v1"
private let fallbackTextTC = "你已经很努力了,今天也值得被温柔对待。"
private let fallbackTextEN = "Youve been doing great — you deserve kindness today."
private func defaults() -> UserDefaults? {
UserDefaults(suiteName: appGroupSuiteName)
}
private func isoNow() -> String {
ISO8601DateFormatter().string(from: Date())
}
private func localDayKey(_ date: Date = Date()) -> String {
let fmt = DateFormatter()
fmt.calendar = Calendar.current
fmt.timeZone = TimeZone.current
fmt.dateFormat = "yyyy-MM-dd"
return fmt.string(from: date)
}
private func resolveLang() -> String {
// en/tc
let preferred = Locale.preferredLanguages.first?.lowercased() ?? "en"
return preferred.hasPrefix("zh") ? "tc" : "en"
}
private func resolveTitle(lang: String) -> String {
lang == "en" ? "Mindfulness" : "正念"
}
private func resolveFooterHint(lang: String) -> String {
lang == "en" ? "Tap to open the app" : "点我回到 App"
}
private func joinUrl(base: String, path: String) -> String {
let b = base.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: "/+$", with: "", options: .regularExpression)
if path.hasPrefix("/") { return "\(b)\(path)" }
return "\(b)/\(path)"
}
private func nextDailyRefreshDate(from now: Date) -> Date {
// 00:1001:00
var cal = Calendar.current
cal.timeZone = TimeZone.current
guard let tomorrow = cal.date(byAdding: .day, value: 1, to: now) else {
return now.addingTimeInterval(60 * 60 * 6)
}
let start = cal.startOfDay(for: tomorrow)
let minDate = cal.date(byAdding: .minute, value: 10, to: start) ?? start.addingTimeInterval(60 * 10)
let maxDate = cal.date(byAdding: .hour, value: 1, to: start) ?? start.addingTimeInterval(60 * 60)
let interval = max(0, maxDate.timeIntervalSince(minDate))
let jitter = interval > 0 ? Double.random(in: 0..<interval) : 0
return minDate.addingTimeInterval(jitter)
}
private func readJsonDict(forKey key: String) -> [String: Any]? {
guard let raw = defaults()?.string(forKey: key) else { return nil }
guard let data = raw.data(using: .utf8) else { return nil }
let obj = try? JSONSerialization.jsonObject(with: data, options: [])
return obj as? [String: Any]
}
private func writeJsonDict(_ dict: [String: Any], forKey key: String) {
guard let data = try? JSONSerialization.data(withJSONObject: dict, options: []) else { return }
guard let raw = String(data: data, encoding: .utf8) else { return }
defaults()?.set(raw, forKey: key)
}
private func readCachedText() -> (dayKey: String?, lang: String, text: String)? {
guard let d = readJsonDict(forKey: keyWidgetDailyReco) else { return nil }
let lang = (d["lang"] as? String) ?? resolveLang()
let dayKey = d["day_key"] as? String
if let item = d["item"] as? [String: Any], let text = item["text"] as? String, !text.isEmpty {
return (dayKey: dayKey, lang: lang, text: text)
}
return nil
}
private func readApiBaseUrl() -> String? {
guard let d = readJsonDict(forKey: keyWidgetConfig) else { return nil }
let base = d["apiBaseUrl"] as? String
return base?.trimmingCharacters(in: .whitespacesAndNewlines)
}
private func readUserProfileDict() -> [String: Any]? {
guard let d = readJsonDict(forKey: keyWidgetUserProfile) else { return nil }
return d["user_profile"] as? [String: Any]
}
private func saveDailyReco(lang: String, dayKey: String, contentId: Int, text: String, meta: [String: Any]?) {
var dict: [String: Any] = [
"schema_version": 1,
"saved_at": isoNow(),
"day_key": dayKey,
"lang": lang,
"source": "widget",
"item": [
"content_id": contentId,
"text": text
]
]
if let meta = meta { dict["meta"] = meta }
writeJsonDict(dict, forKey: keyWidgetDailyReco)
}
private func fetchDailyRecoFromServer() async -> (lang: String, text: String, contentId: Int, meta: [String: Any]?)? {
guard let baseUrl = readApiBaseUrl(), !baseUrl.isEmpty else { return nil }
guard let userProfile = readUserProfileDict() else { return nil }
let lang = resolveLang()
let urlStr = joinUrl(base: baseUrl, path: "/v1/reco/widget")
guard let url = URL(string: urlStr) else { return nil }
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.timeoutInterval = 12
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.setValue(lang, forHTTPHeaderField: "Accept-Language")
let body: [String: Any] = [
"k": 1,
"user_profile": userProfile,
"already_recommended_ids": [],
"touched_or_viewed_ids": []
]
req.httpBody = try? JSONSerialization.data(withJSONObject: body, options: [])
do {
let (data, res) = try await URLSession.shared.data(for: req)
guard let httpRes = res as? HTTPURLResponse, (200..<300).contains(httpRes.statusCode) else { return nil }
let obj = try JSONSerialization.jsonObject(with: data, options: [])
guard let root = obj as? [String: Any] else { return nil }
guard let items = root["items"] as? [[String: Any]], let first = items.first else { return nil }
guard let text = first["text"] as? String, !text.isEmpty else { return nil }
let contentId = (first["content_id"] as? Int) ?? Int((first["content_id"] as? NSNumber)?.intValue ?? -1)
if contentId < 0 { return nil }
let meta = root["meta"] as? [String: Any]
return (lang: lang, text: text, contentId: contentId, meta: meta)
} catch {
return nil
}
}
struct EmotionProvider: TimelineProvider {
func placeholder(in context: Context) -> EmotionEntry {
EmotionEntry(date: Date())
let lang = resolveLang()
return EmotionEntry(
date: Date(),
lang: lang,
title: resolveTitle(lang: lang),
text: lang == "en" ? fallbackTextEN : fallbackTextTC,
footerHint: resolveFooterHint(lang: lang)
)
}
func getSnapshot(in context: Context, completion: @escaping (EmotionEntry) -> ()) {
completion(EmotionEntry(date: Date()))
completion(placeholder(in: context))
}
func getTimeline(in context: Context, completion: @escaping (Timeline<EmotionEntry>) -> ()) {
// V1
let entry = EmotionEntry(date: Date())
let nextUpdate = Calendar.current.date(byAdding: .day, value: 7, to: Date())
?? Date().addingTimeInterval(60 * 60 * 24 * 7)
completion(Timeline(entries: [entry], policy: .after(nextUpdate)))
Task {
let lang = resolveLang()
let today = localDayKey(Date())
// 1)
if let cached = readCachedText(), cached.dayKey == today {
let entry = EmotionEntry(
date: Date(),
lang: cached.lang,
title: resolveTitle(lang: cached.lang),
text: cached.text,
footerHint: resolveFooterHint(lang: cached.lang)
)
completion(Timeline(entries: [entry], policy: .after(nextDailyRefreshDate(from: Date()))))
return
}
// 2) /
if let fetched = await fetchDailyRecoFromServer() {
saveDailyReco(lang: fetched.lang, dayKey: today, contentId: fetched.contentId, text: fetched.text, meta: fetched.meta)
let entry = EmotionEntry(
date: Date(),
lang: fetched.lang,
title: resolveTitle(lang: fetched.lang),
text: fetched.text,
footerHint: resolveFooterHint(lang: fetched.lang)
)
completion(Timeline(entries: [entry], policy: .after(nextDailyRefreshDate(from: Date()))))
return
}
// 3)
if let cached = readCachedText() {
let entry = EmotionEntry(
date: Date(),
lang: cached.lang,
title: resolveTitle(lang: cached.lang),
text: cached.text,
footerHint: resolveFooterHint(lang: cached.lang)
)
completion(Timeline(entries: [entry], policy: .after(nextDailyRefreshDate(from: Date()))))
return
}
let entry = EmotionEntry(
date: Date(),
lang: lang,
title: resolveTitle(lang: lang),
text: lang == "en" ? fallbackTextEN : fallbackTextTC,
footerHint: resolveFooterHint(lang: lang)
)
completion(Timeline(entries: [entry], policy: .after(nextDailyRefreshDate(from: Date()))))
}
}
}
struct EmotionEntry: TimelineEntry {
let date: Date
let lang: String
let title: String
let text: String
let footerHint: String
}
struct EmotionWidgetView: View {
var entry: EmotionProvider.Entry
@Environment(\.widgetFamily) var family
private let title = "正念"
private let text = "你已经很努力了,今天也值得被温柔对待。"
private let deepLink = URL(string: "client:///(app)/home")
private let widgetBackgroundColor = Color(red: 1.0, green: 250.0 / 255.0, blue: 229.0 / 255.0) // #FFFAE5
private let widgetTextColor = Color(red: 98.0 / 255.0, green: 59.0 / 255.0, blue: 59.0 / 255.0) // #623B3B
var body: some View {
// //
Text(entry.text)
.font(fontForFamily())
.foregroundColor(widgetTextColor)
.multilineTextAlignment(.leading)
.lineSpacing(lineSpacingForFamily())
.lineLimit(lineLimitForFamily())
.minimumScaleFactor(0.78)
.padding(paddingForFamily())
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
.widgetSolidBackground(widgetBackgroundColor)
.widgetURL(deepLink)
}
private func fontForFamily() -> Font {
switch family {
case .systemSmall:
smallView()
return .system(size: 16, weight: .semibold)
case .systemMedium:
mediumView()
return .system(size: 18, weight: .semibold)
case .systemLarge:
largeView()
return .system(size: 22, weight: .semibold)
default:
smallView()
return .system(size: 16, weight: .semibold)
}
}
// iOS 15
private func cardBackground(colors: [Color]) -> some View {
ZStack {
LinearGradient(
colors: colors,
startPoint: .topLeading,
endPoint: .bottomTrailing
)
//
RadialGradient(
gradient: Gradient(colors: [Color.white.opacity(0.16), Color.white.opacity(0.0)]),
center: .topTrailing,
startRadius: 10,
endRadius: 180
)
private func lineSpacingForFamily() -> CGFloat {
switch family {
case .systemLarge:
return 4
default:
return 3
}
.overlay(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.stroke(Color.white.opacity(0.14), lineWidth: 1)
)
.cornerRadius(18)
}
private func chip(_ text: String) -> some View {
Text(text)
.font(.system(size: 12, weight: .semibold))
.foregroundColor(Color.white.opacity(0.9))
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Color.white.opacity(0.14))
.cornerRadius(999)
private func lineLimitForFamily() -> Int {
switch family {
case .systemSmall:
return 5
case .systemMedium:
return 6
case .systemLarge:
return 8
default:
return 5
}
}
private func smallView() -> some View {
ZStack {
cardBackground(colors: [
Color(red: 0.06, green: 0.08, blue: 0.12),
Color(red: 0.13, green: 0.16, blue: 0.22),
])
VStack(alignment: .leading, spacing: 10) {
HStack {
chip(title)
Spacer(minLength: 0)
}
Text(text)
.font(.system(size: 15, weight: .semibold))
.foregroundColor(Color.white.opacity(0.92))
.lineSpacing(2)
.lineLimit(4)
Spacer(minLength: 0)
Text("点我回到 App")
.font(.system(size: 11, weight: .medium))
.foregroundColor(Color.white.opacity(0.65))
}
.padding(14)
private func paddingForFamily() -> CGFloat {
switch family {
case .systemSmall:
return 14
case .systemMedium:
return 16
case .systemLarge:
return 18
default:
return 14
}
.widgetURL(deepLink)
}
}
private func mediumView() -> some View {
ZStack {
cardBackground(colors: [
Color(red: 0.06, green: 0.08, blue: 0.12),
Color(red: 0.09, green: 0.11, blue: 0.17),
])
private struct WidgetSolidBackgroundModifier: ViewModifier {
let color: Color
HStack(alignment: .top, spacing: 14) {
VStack(alignment: .leading, spacing: 10) {
chip(title)
Text(text)
.font(.system(size: 17, weight: .semibold))
.foregroundColor(Color.white.opacity(0.92))
.lineSpacing(3)
.lineLimit(5)
Spacer(minLength: 0)
Text("轻轻呼吸,回到当下")
.font(.system(size: 12, weight: .medium))
.foregroundColor(Color.white.opacity(0.7))
}
//
VStack(alignment: .trailing, spacing: 8) {
Text(entry.date, style: .time)
.font(.system(size: 12, weight: .semibold))
.foregroundColor(Color.white.opacity(0.8))
Spacer(minLength: 0)
Text("今日")
.font(.system(size: 28, weight: .bold))
.foregroundColor(Color.white.opacity(0.12))
}
}
.padding(16)
func body(content: Content) -> some View {
if #available(iOSApplicationExtension 17.0, *) {
content.containerBackground(for: .widget) { color }
} else {
content
.background(color)
.ignoresSafeArea()
}
.widgetURL(deepLink)
}
}
private func largeView() -> some View {
ZStack {
cardBackground(colors: [
Color(red: 0.06, green: 0.08, blue: 0.12),
Color(red: 0.14, green: 0.18, blue: 0.28),
])
VStack(alignment: .leading, spacing: 14) {
HStack {
chip(title)
Spacer(minLength: 0)
Text(entry.date, style: .time)
.font(.system(size: 12, weight: .semibold))
.foregroundColor(Color.white.opacity(0.78))
}
Text(text)
.font(.system(size: 20, weight: .semibold))
.foregroundColor(Color.white.opacity(0.92))
.lineSpacing(4)
.lineLimit(8)
Spacer(minLength: 0)
HStack {
Text("点我回到 Home")
.font(.system(size: 12, weight: .medium))
.foregroundColor(Color.white.opacity(0.7))
Spacer(minLength: 0)
Text("🌿")
.font(.system(size: 18))
.opacity(0.9)
}
}
.padding(18)
}
.widgetURL(deepLink)
private extension View {
func widgetSolidBackground(_ color: Color) -> some View {
modifier(WidgetSolidBackgroundModifier(color: color))
}
}

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.damer.mindfulness</string>
</array>
</dict>
</plist>

1628
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,9 +4,14 @@
"version": "1.0.0",
"scripts": {
"start": "expo start",
"start:clean": "expo start -c",
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web"
"ios": "expo run:ios --scheme \"Hey Mama\"",
"ios:clean": "npm run clean:cache && npm run clean:ios-build && expo run:ios --scheme \"Hey Mama\"",
"web": "expo start --web",
"test": "vitest run",
"clean:cache": "rm -rf node_modules/.cache .expo 2>/dev/null; echo 'Cleared .expo and node_modules/.cache'",
"clean:ios-build": "rm -rf ~/Library/Developer/Xcode/DerivedData/client-* 2>/dev/null; echo 'Cleared Xcode DerivedData for client'"
},
"dependencies": {
"@expo/vector-icons": "^15.0.3",
@@ -14,7 +19,9 @@
"@react-navigation/native": "^7.1.8",
"expo": "~54.0.32",
"expo-constants": "~18.0.13",
"expo-crypto": "^15.0.8",
"expo-dev-client": "^6.0.20",
"expo-device": "^8.0.10",
"expo-font": "~14.0.11",
"expo-linear-gradient": "^15.0.8",
"expo-linking": "~8.0.11",
@@ -41,7 +48,8 @@
"devDependencies": {
"@types/react": "~19.1.0",
"react-test-renderer": "19.1.0",
"typescript": "~5.9.2"
"typescript": "~5.9.2",
"vitest": "^4.0.18"
},
"private": true
}

1252
client/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -20,14 +20,58 @@ function getOptionalEnv(name: string, fallback: string): string {
return process.env[name] ?? fallback;
}
export const APP_ENV = (getOptionalEnv('EXPO_PUBLIC_ENV', 'dev') as AppEnv) ?? 'dev';
export type AppRuntimeEnv = 'local' | 'dev' | 'prod';
export const API_BASE_URL = getRequiredEnv('EXPO_PUBLIC_API_BASE_URL');
/**
* Release/TestFlight 场景下如果未注入 EXPO_PUBLIC_ENV
* 默认回退到 prod避免误打到 localhost 导致真机“无法发起网络请求”)。
*/
const DEFAULT_RUNTIME_ENV: AppRuntimeEnv =
typeof __DEV__ !== 'undefined' && __DEV__ ? 'local' : 'prod';
export const APP_ENV = (getOptionalEnv('EXPO_PUBLIC_ENV', DEFAULT_RUNTIME_ENV) as AppRuntimeEnv) ?? DEFAULT_RUNTIME_ENV;
function getApiBaseUrl(env: AppRuntimeEnv): string {
// 向后兼容:若直接提供了 EXPO_PUBLIC_API_BASE_URL则优先使用不再强制要求 *_DEV/_PROD
const direct = process.env.EXPO_PUBLIC_API_BASE_URL;
if (direct && String(direct).trim()) return String(direct).trim();
// 约定local/dev/prod 三套域名分别配置, 便于后续直接切环境而不改代码
if (env === 'local') {
return getOptionalEnv('EXPO_PUBLIC_API_BASE_URL_LOCAL', 'http://localhost:8000');
}
if (env === 'dev') {
return getOptionalEnv('EXPO_PUBLIC_API_BASE_URL_DEV', getOptionalEnv('EXPO_PUBLIC_API_BASE_URL_LOCAL', 'https://api.damer.fun'));
}
return getOptionalEnv('EXPO_PUBLIC_API_BASE_URL_PROD', getOptionalEnv('EXPO_PUBLIC_API_BASE_URL_LOCAL', 'https://api.damer.fun'));
}
export const API_BASE_URL = getApiBaseUrl(APP_ENV);
/**
* 调试:打印环境变量注入结果(仅开发环境)
*
* 用途:排查「为什么 API_BASE_URL 不是预期值」的问题(例如 .env.local/命令行注入/缓存导致)。
*
* 注意:在某些测试环境(如 vitest里 `__DEV__` 可能不存在,需做兼容判断。
*/
if (typeof __DEV__ !== 'undefined' && __DEV__) {
const injected = {
EXPO_PUBLIC_ENV: process.env.EXPO_PUBLIC_ENV,
EXPO_PUBLIC_API_BASE_URL: process.env.EXPO_PUBLIC_API_BASE_URL,
EXPO_PUBLIC_API_BASE_URL_LOCAL: process.env.EXPO_PUBLIC_API_BASE_URL_LOCAL,
EXPO_PUBLIC_API_BASE_URL_DEV: process.env.EXPO_PUBLIC_API_BASE_URL_DEV,
EXPO_PUBLIC_API_BASE_URL_PROD: process.env.EXPO_PUBLIC_API_BASE_URL_PROD,
};
console.log('[Env] 注入的 EXPO_PUBLIC_*(用于 API_BASE_URL 计算):', injected);
console.log('[Env] 解析得到 APP_ENV:', APP_ENV);
console.log('[Env] 解析得到 API_BASE_URL:', API_BASE_URL);
}
/**
* 默认语言策略:
* - auto优先设备语言支持列表内时否则回退 zh-CN
* - zh-CN/en/es/pt/zh-TW固定默认语言仍允许用户在设置中手动切换并持久化
* - auto优先设备语言支持列表内时否则回退 en
* - en/zh-TW固定默认语言仍允许用户在设置中手动切换并持久化
*/
export const DEFAULT_LANGUAGE = getOptionalEnv('EXPO_PUBLIC_DEFAULT_LANGUAGE', 'auto');

View File

@@ -1,16 +1,16 @@
export type MockContentItem = {
id: string;
text: string;
textKey: string;
};
/**
* 本地 mock 内容(后续接后端时可替换)
*/
export const MOCK_CONTENT: MockContentItem[] = [
{ id: 'c1', text: '你已经很努力了,今天也值得被温柔对待。' },
{ id: 'c2', text: '深呼吸三次,把注意力带回当下。' },
{ id: 'c3', text: '允许自己慢一点,情绪会像云一样飘过。' },
{ id: 'c4', text: '你不需要完美,你已经足够好。' },
{ id: 'c5', text: '把手放在心口,对自己说一句:辛苦了。' }
{ id: 'c1', textKey: 'mock.c1' },
{ id: 'c2', textKey: 'mock.c2' },
{ id: 'c3', textKey: 'mock.c3' },
{ id: 'c4', textKey: 'mock.c4' },
{ id: 'c5', textKey: 'mock.c5' }
];

View File

@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest';
import type { UserProfileScoring } from '@/src/storage/appStorage';
import { advanceSuixinState, buildInitialSuixinState, computeSuixinSolidColor, pickSuixinBaseThemeId } from '../index';
import { computeTFromStep } from '../progress';
import { lerpHex } from '../colorMath';
function buildProfile(partial?: Partial<UserProfileScoring>): UserProfileScoring {
const base: UserProfileScoring = {
profile_version: 'v1.2',
profile_source: 'questionnaire',
profile_generated_at: '2026-01-30T00:00:00Z',
profile_confidence: 1.0,
profile_answered: { stage: true, emotion: true, context: true, need: true },
stage: { expecting: 1, parenting: 0, unknown: 0 },
emotion_score: 0.6,
context: {},
need: {},
rule_hits: [],
hard_rules: { forbidden_risk_flags: [], forbidden_content_predicates: [] },
};
return { ...base, ...(partial ?? {}) };
}
describe('suixinTheme', () => {
it('pickSuixinBaseThemeId: stage.unknown=1 → neutral', () => {
const p = buildProfile({ stage: { unknown: 1 } as any, need: { rest_balance: 1 } });
expect(pickSuixinBaseThemeId(p)).toBe('neutral');
});
it('pickSuixinBaseThemeId: need 为空 → neutral', () => {
const p = buildProfile({ need: {} });
expect(pickSuixinBaseThemeId(p)).toBe('neutral');
});
it('pickSuixinBaseThemeId: need 命中 → 对应 base theme', () => {
const p = buildProfile({ need: { rest_balance: 1 } });
expect(pickSuixinBaseThemeId(p)).toBe('rest_balance');
});
it('computeTFromStep: 始终在 [0,1] 且往返不突跳', () => {
const ts = Array.from({ length: 80 }).map((_, i) => computeTFromStep({ stepIndex: i, segments: 12, seed: 'boot' }));
for (const t of ts) {
expect(t).toBeGreaterThanOrEqual(0);
expect(t).toBeLessThanOrEqual(1);
}
// 往返波形:起点与一个周期后的 t 相同
expect(computeTFromStep({ stepIndex: 0, segments: 12, seed: 'boot' })).toBe(
computeTFromStep({ stepIndex: 22, segments: 12, seed: 'boot' }) // 2*(N-1)=22
);
});
it('lerpHex: t=0/1 输出边界色', () => {
expect(lerpHex('#000000', '#FFFFFF', 0)).toBe('#000000');
expect(lerpHex('#000000', '#FFFFFF', 1)).toBe('#FFFFFF');
});
it('buildInitialSuixinState: 生成可用状态并可推进', () => {
const p = buildProfile({ need: { emotional_support: 1 } });
const init = buildInitialSuixinState({ bootId: 'boot-1', profile: p, now: new Date('2026-02-05T00:00:00Z') });
expect(init.schema_version).toBe(1);
expect(init.base_theme_id).toBe('emotional_support');
expect(init.boot_id).toBe('boot-1');
expect(init.last_color).toMatch(/^#[0-9A-F]{6}$/);
const next = advanceSuixinState(init, new Date('2026-02-05T00:00:01Z'));
expect(next.step_index).toBe(1);
expect(next.base_theme_id).toBe(init.base_theme_id);
expect(next.last_color).toMatch(/^#[0-9A-F]{6}$/);
});
it('computeSuixinSolidColor: 输出为合法 hex', () => {
const c = computeSuixinSolidColor({ baseThemeId: 'neutral', stepIndex: 3, seed: 'boot' });
expect(c).toMatch(/^#[0-9A-F]{6}$/);
});
});

View File

@@ -0,0 +1,53 @@
function clamp01(t: number): number {
if (!Number.isFinite(t)) return 0;
return Math.min(1, Math.max(0, t));
}
type Rgb = { r: number; g: number; b: number };
function toByte(v: number): number {
if (!Number.isFinite(v)) return 0;
return Math.min(255, Math.max(0, Math.round(v)));
}
export function hexToRgb(hex: string): Rgb | null {
const h = String(hex || '').trim();
const m = /^#?([0-9a-fA-F]{6})$/.exec(h);
if (!m) return null;
const raw = m[1];
const n = parseInt(raw, 16);
// eslint-disable-next-line no-bitwise
const r = (n >> 16) & 0xff;
// eslint-disable-next-line no-bitwise
const g = (n >> 8) & 0xff;
// eslint-disable-next-line no-bitwise
const b = n & 0xff;
return { r, g, b };
}
export function rgbToHex(rgb: Rgb): string {
const r = toByte(rgb.r).toString(16).padStart(2, '0');
const g = toByte(rgb.g).toString(16).padStart(2, '0');
const b = toByte(rgb.b).toString(16).padStart(2, '0');
return `#${r}${g}${b}`.toUpperCase();
}
/**
* 仅允许线性插值Hard Rule
*/
export function lerpRgb(a: Rgb, b: Rgb, t: number): Rgb {
const tt = clamp01(t);
return {
r: a.r + (b.r - a.r) * tt,
g: a.g + (b.g - a.g) * tt,
b: a.b + (b.b - a.b) * tt,
};
}
export function lerpHex(topHex: string, bottomHex: string, t: number, fallbackHex = '#E8F1EC'): string {
const top = hexToRgb(topHex);
const bottom = hexToRgb(bottomHex);
if (!top || !bottom) return fallbackHex;
return rgbToHex(lerpRgb(top, bottom, t));
}

View File

@@ -0,0 +1,65 @@
import type { SuixinBaseThemeId, SuixinThemeStateV1, UserProfileScoring } from '@/src/storage/appStorage';
import { getThemeTriplet, NEUTRAL_THEME_COLORS } from './palette';
import { lerpHex } from './colorMath';
import { computeTFromStep } from './progress';
import { pickSuixinBaseThemeId } from './pickTheme';
export { NEUTRAL_THEME_COLORS } from './palette';
export { pickSuixinBaseThemeId } from './pickTheme';
/**
* 根据 base theme 与 step 计算当前背景纯色
*/
export function computeSuixinSolidColor(args: {
baseThemeId: SuixinBaseThemeId;
stepIndex: number;
seed: string;
}): string {
const [top, _mid, bottom] = getThemeTriplet(args.baseThemeId);
const t = computeTFromStep({ stepIndex: args.stepIndex, segments: 12, seed: args.seed });
return lerpHex(top, bottom, t, NEUTRAL_THEME_COLORS[1]);
}
/**
* 初始化一份随心状态(在冷启动会话内锁定 base theme
*/
export function buildInitialSuixinState(args: {
bootId: string;
profile: UserProfileScoring | null;
now?: Date;
}): SuixinThemeStateV1 {
const now = args.now ?? new Date();
const baseThemeId = pickSuixinBaseThemeId(args.profile);
const seed = args.bootId;
const step_index = 0;
const last_color = computeSuixinSolidColor({ baseThemeId, stepIndex: step_index, seed });
return {
schema_version: 1,
saved_at: now.toISOString(),
boot_id: args.bootId,
base_theme_id: baseThemeId,
seed,
step_index,
last_color,
};
}
/**
* 切换文案时推进一步(不跨主题)
*/
export function advanceSuixinState(prev: SuixinThemeStateV1, now?: Date): SuixinThemeStateV1 {
const nextStep = (Number.isFinite(prev.step_index) ? prev.step_index : 0) + 1;
const last_color = computeSuixinSolidColor({
baseThemeId: prev.base_theme_id,
stepIndex: nextStep,
seed: prev.seed,
});
return {
...prev,
saved_at: (now ?? new Date()).toISOString(),
step_index: nextStep,
last_color,
};
}

View File

@@ -0,0 +1,20 @@
import type { SuixinBaseThemeId } from '@/src/storage/appStorage';
/**
* 随心主题色盘(与设计说明文档保持一致)
*/
export const BASE_THEME_COLORS: Record<Exclude<SuixinBaseThemeId, 'neutral'>, [string, string, string]> = {
emotional_support: ['#F6DCE4', '#FFEFF4', '#FFF7FA'],
parenting_pressure: ['#D6EAF5', '#EEF6FB', '#F8FCFF'],
self_worth: ['#FFD8A8', '#FFE8C9', '#FFF6E5'],
anxiety_relief: ['#DFF3EA', '#ECFBF6', '#F6FFFB'],
rest_balance: ['#F2E6D8', '#FAF3EC', '#FFFDF9'],
};
export const NEUTRAL_THEME_COLORS: [string, string, string] = ['#F4F7F2', '#E8F1EC', '#EDF4F8'];
export function getThemeTriplet(themeId: SuixinBaseThemeId): [string, string, string] {
if (themeId === 'neutral') return NEUTRAL_THEME_COLORS;
return BASE_THEME_COLORS[themeId];
}

View File

@@ -0,0 +1,32 @@
import type { SuixinBaseThemeId, UserProfileScoring } from '@/src/storage/appStorage';
const ALL_NEED_THEME_IDS: ReadonlySet<string> = new Set([
'emotional_support',
'parenting_pressure',
'self_worth',
'anxiety_relief',
'rest_balance',
]);
/**
* Base Theme 选择Theme Picking
*
* Hard Rules对齐设计文档
* - mom_stage = unknown → 强制 Neutral
* - need 跳过/缺失 → Neutral
*/
export function pickSuixinBaseThemeId(profile: UserProfileScoring | null | undefined): SuixinBaseThemeId {
if (!profile) return 'neutral';
// Hard Ruleunknown → Neutral
if (profile.stage?.unknown === 1) return 'neutral';
const needObj = profile.need ?? {};
const keys = Object.keys(needObj);
if (keys.length === 0) return 'neutral';
const needId = keys[0];
if (!ALL_NEED_THEME_IDS.has(needId)) return 'neutral';
return needId as Exclude<SuixinBaseThemeId, 'neutral'>;
}

View File

@@ -0,0 +1,40 @@
function clampInt(n: number, min: number, max: number): number {
if (!Number.isFinite(n)) return min;
return Math.min(max, Math.max(min, Math.floor(n)));
}
function hashStringToInt32(input: string): number {
// 简单可复现 hash用于把 seed 映射为偏移量(不用于安全场景)
let h = 0;
for (let i = 0; i < input.length; i += 1) {
// eslint-disable-next-line no-bitwise
h = (h * 31 + input.charCodeAt(i)) | 0;
}
return h;
}
/**
* 生成 t ∈ [0,1],用于 Color(t)=lerp(top,bottom,t)
*
* 说明:
* - Home 没有 scroll用“切换文案 step_index”模拟连续流动
* - 采用往返波形,避免从 1 回到 0 的突跳
*/
export function computeTFromStep(args: {
stepIndex: number;
segments?: number; // N默认 12
seed?: string; // 允许用 seed 做初始相位偏移(同一次冷启动内稳定)
}): number {
const N = clampInt(args.segments ?? 12, 3, 60);
const stepIndex = clampInt(args.stepIndex, 0, 1_000_000_000);
const period = 2 * (N - 1);
const seed = String(args.seed ?? '');
const offset = seed ? Math.abs(hashStringToInt32(seed)) % period : 0;
const phase = (stepIndex + offset) % period;
const up = phase <= (N - 1);
const pos = up ? phase : period - phase;
return pos / (N - 1);
}

View File

@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest';
import { buildUserProfileFromQuestionnaire } from '../index';
import { mapOnboardingSelectionsToQuestionnaireAnswers } from '../onboardingMapping';
describe('Onboarding → UserProfileScoring 集成', () => {
it('完整作答Onboarding 选择能正确映射并生成画像', () => {
const selections = {
status: ['pregnant'],
emotion: ['calm'],
influence: ['work'],
support: ['balance'],
};
const answers = mapOnboardingSelectionsToQuestionnaireAnswers(selections);
expect(answers).toEqual({
mom_stage: 'expecting',
emotion: 'calm',
context: 'work',
need: 'rest_balance',
});
const p = buildUserProfileFromQuestionnaire(answers, {
generatedAt: '2026-01-30T00:00:00Z',
now: '2026-01-30T00:00:00Z',
});
expect(p.stage).toEqual({ expecting: 1, parenting: 0, unknown: 0 });
expect(p.emotion_score).toBe(0.8);
expect(p.context).toEqual({ work: 1 });
expect(p.need).toEqual({ rest_balance: 1 });
expect(p.profile_answered).toEqual({ stage: true, emotion: true, context: true, need: true });
});
it('全部跳过仍能生成最小可计算画像unknown=1', () => {
const answers = mapOnboardingSelectionsToQuestionnaireAnswers({});
expect(answers).toEqual({ mom_stage: null, emotion: null, context: null, need: null });
const p = buildUserProfileFromQuestionnaire(answers, {
generatedAt: '2026-01-30T00:00:00Z',
now: '2026-01-30T00:00:00Z',
});
expect(p.stage).toEqual({ unknown: 1 });
expect(p.emotion_score).toBeNull();
expect(p.context).toEqual({});
expect(p.need).toEqual({});
expect(p.profile_answered).toEqual({ stage: false, emotion: false, context: false, need: false });
});
});

View File

@@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest';
import {
buildUserProfileFromQuestionnaire,
computeProfileConfidence,
computeTimeConfidence,
normalizeAnswers,
} from '../index';
describe('userProfileScoring V1.2', () => {
it('normalizeAnswers: 非法值按跳过处理', () => {
// @ts-expect-error: 模拟非法输入
const out = normalizeAnswers({ mom_stage: 'xxx', emotion: 'yyy', context: 'zzz', need: 'ooo' });
expect(out).toEqual({ mom_stage: undefined, emotion: undefined, context: undefined, need: undefined });
});
it('computeTimeConfidence: 分段衰减', () => {
const gen = new Date('2026-01-01T00:00:00Z');
// 07 天1.0
expect(computeTimeConfidence(gen, new Date('2026-01-05T00:00:00Z'))).toBe(1.0);
// 30 天以上0.5
expect(computeTimeConfidence(gen, new Date('2026-02-15T00:00:00Z'))).toBe(0.5);
});
it('computeProfileConfidence: 完整度因子 + clamp', () => {
const confTime = 1.0;
// 全部跳过completion=0 → completionFactor=0.5 → 0.5
expect(
computeProfileConfidence(confTime, { stage: false, emotion: false, context: false, need: false })
).toBe(0.5);
// 全部作答completion=1 → completionFactor=1 → 1
expect(computeProfileConfidence(confTime, { stage: true, emotion: true, context: true, need: true })).toBe(1.0);
});
it('buildUserProfileFromQuestionnaire: 全部跳过输出最小可计算画像', () => {
const p = buildUserProfileFromQuestionnaire({}, { generatedAt: '2026-01-30T00:00:00Z', now: '2026-01-30T00:00:00Z' });
expect(p.profile_version).toBe('v1.2');
expect(p.profile_source).toBe('questionnaire');
expect(p.profile_answered).toEqual({ stage: false, emotion: false, context: false, need: false });
expect(p.stage).toEqual({ unknown: 1 });
expect(p.emotion_score).toBeNull();
expect(p.context).toEqual({});
expect(p.need).toEqual({});
// conf_time=1completionFactor=0.5
expect(p.profile_confidence).toBe(0.5);
// unknown 会命中 unsafe_for_stage_unknown并带跨维度谓词
expect(p.hard_rules.forbidden_risk_flags).toContain('unsafe_for_stage_unknown');
expect(p.hard_rules.forbidden_content_predicates.some((x) => x.id === 'unknown_block_parenting_pressure_personalized')).toBe(
true
);
});
it('buildUserProfileFromQuestionnaire: emotion<=0.2 命中 unsafe_for_emotion_low', () => {
const p = buildUserProfileFromQuestionnaire(
{ mom_stage: 'expecting', emotion: 'overwhelmed', context: 'health', need: 'anxiety_relief' },
{ generatedAt: '2026-01-30T00:00:00Z', now: '2026-01-30T00:00:00Z' }
);
expect(p.emotion_score).toBe(0.2);
expect(p.hard_rules.forbidden_risk_flags).toContain('unsafe_for_emotion_low');
});
});

View File

@@ -0,0 +1,19 @@
export type {
BuildUserProfileOptions,
QuestionnaireAnswersV1_2,
UserProfileV1_2,
UserProfileV1_2_Extended,
} from './types';
export type { OnboardingSelections } from './onboardingMapping';
export {
buildUserProfileFromQuestionnaire,
computeProfileAnswered,
computeProfileConfidence,
computeTimeConfidence,
normalizeAnswers,
} from './scoring';
export { mapOnboardingSelectionsToQuestionnaireAnswers } from './onboardingMapping';

View File

@@ -0,0 +1,65 @@
import type { QuestionnaireAnswersV1_2 } from './types';
/**
* Onboarding UI 的选项 ID → 标准问卷枚举(可跳过)
*
* 说明:
* - UI 侧每题目前是单选,但数据结构是 string[];这里取第 1 个作为答案
* - 不存在错误处理:未知/非法值统一按“跳过”处理(返回 null
*/
export type OnboardingSelections = Record<string, string[] | undefined>;
export function mapOnboardingSelectionsToQuestionnaireAnswers(
selections: OnboardingSelections
): QuestionnaireAnswersV1_2 {
return {
mom_stage: mapMomStage(selections.status?.[0]),
emotion: mapEmotion(selections.emotion?.[0]),
context: mapContext(selections.influence?.[0]),
need: mapNeed(selections.support?.[0]),
};
}
function mapMomStage(raw: string | undefined): QuestionnaireAnswersV1_2['mom_stage'] {
// 跳过null显式跳过
if (!raw) return null;
// UI id → 标准枚举
if (raw === 'pregnant') return 'expecting';
if (raw === 'has_kids') return 'parenting';
if (raw === 'no_fill') return 'unknown';
// 其他非法值:按跳过处理
return null;
}
function mapEmotion(raw: string | undefined): QuestionnaireAnswersV1_2['emotion'] {
if (!raw) return null;
// UI 选项:
// - happy/calm/stressed/low历史选项仍保留兼容
// - okay/tired新增选项
if (raw === 'happy') return 'joyful';
if (raw === 'calm') return 'calm';
if (raw === 'okay') return 'neutral';
if (raw === 'tired') return 'tired';
if (raw === 'stressed') return 'overwhelmed';
if (raw === 'low') return 'low';
return null;
}
function mapContext(raw: string | undefined): QuestionnaireAnswersV1_2['context'] {
if (!raw) return null;
// UI id 已与标准枚举一致family/work/relationship/friends/health
if (raw === 'family' || raw === 'work' || raw === 'relationship' || raw === 'friends' || raw === 'health') return raw;
return null;
}
function mapNeed(raw: string | undefined): QuestionnaireAnswersV1_2['need'] {
if (!raw) return null;
// UI id → 标准枚举
if (raw === 'emotional') return 'emotional_support';
if (raw === 'parenting') return 'parenting_pressure';
if (raw === 'self_worth') return 'self_worth';
if (raw === 'anxiety') return 'anxiety_relief';
if (raw === 'balance') return 'rest_balance';
return null;
}

View File

@@ -0,0 +1,233 @@
/**
* 用户画像打分User Profile ScoringV1.2
*
* 规则来源:
* - `spec_kit/User Profile Scoring/spec.md`
* - `设计说明文档/客戶端問卷打分規則.md`V1.2
*/
import type {
BuildUserProfileOptions,
ContextAnswer,
EmotionAnswer,
HardRules,
MomStageAnswer,
NeedAnswer,
ProfileAnswered,
QuestionnaireAnswersV1_2,
SparseOneHot,
UserProfileV1_2_Extended,
UserStageOneHot,
} from './types';
const MS_PER_DAY = 24 * 60 * 60 * 1000;
function clamp(value: number, min: number, max: number): number {
if (!Number.isFinite(value)) return min;
return Math.min(max, Math.max(min, value));
}
function toDate(value: Date | string | undefined): Date | null {
if (!value) return null;
if (value instanceof Date) return Number.isFinite(value.getTime()) ? value : null;
const d = new Date(value);
return Number.isFinite(d.getTime()) ? d : null;
}
function isMomStageAnswer(v: unknown): v is MomStageAnswer {
return v === 'expecting' || v === 'parenting' || v === 'unknown';
}
function isEmotionAnswer(v: unknown): v is EmotionAnswer {
return (
v === 'low' ||
v === 'overwhelmed' ||
v === 'tired' ||
v === 'neutral' ||
v === 'calm' ||
v === 'joyful'
);
}
function isContextAnswer(v: unknown): v is ContextAnswer {
return v === 'family' || v === 'work' || v === 'relationship' || v === 'friends' || v === 'health';
}
function isNeedAnswer(v: unknown): v is NeedAnswer {
return (
v === 'emotional_support' ||
v === 'parenting_pressure' ||
v === 'self_worth' ||
v === 'anxiety_relief' ||
v === 'rest_balance'
);
}
/**
* 归一化答案:非法值按“跳过”处理(归一化为 undefined
* - `null` 保留,表示显式跳过/无值
*/
export function normalizeAnswers(raw: QuestionnaireAnswersV1_2): QuestionnaireAnswersV1_2 {
const mom_stage =
raw.mom_stage === null ? null : isMomStageAnswer(raw.mom_stage) ? raw.mom_stage : undefined;
const emotion = raw.emotion === null ? null : isEmotionAnswer(raw.emotion) ? raw.emotion : undefined;
const context = raw.context === null ? null : isContextAnswer(raw.context) ? raw.context : undefined;
const need = raw.need === null ? null : isNeedAnswer(raw.need) ? raw.need : undefined;
return { mom_stage, emotion, context, need };
}
export function computeProfileAnswered(normalized: QuestionnaireAnswersV1_2): ProfileAnswered {
return {
stage: normalized.mom_stage !== undefined && normalized.mom_stage !== null,
emotion: normalized.emotion !== undefined && normalized.emotion !== null,
context: normalized.context !== undefined && normalized.context !== null,
need: normalized.need !== undefined && normalized.need !== null,
};
}
/**
* 时间衰减置信度conf_time
* - 07 天1.0
* - 730 天:线性衰减到 0.7(含第 30 天)
* - 30 天以上0.5
*/
export function computeTimeConfidence(generatedAt: Date, now: Date): number {
const deltaMs = now.getTime() - generatedAt.getTime();
if (!Number.isFinite(deltaMs) || deltaMs <= 0) return 1.0;
const days = deltaMs / MS_PER_DAY;
if (days <= 7) return 1.0;
if (days <= 30) {
const t = (days - 7) / (30 - 7); // 0..1
return 1.0 - 0.3 * t; // 1 -> 0.7
}
return 0.5;
}
/**
* V1.2profile_confidenceconf_U
* conf = clamp(conf_time * (0.5 + 0.5 * completion), 0.2, 1.0)
*/
export function computeProfileConfidence(confTime: number, answered: ProfileAnswered): number {
const answeredCount =
(answered.stage ? 1 : 0) + (answered.emotion ? 1 : 0) + (answered.context ? 1 : 0) + (answered.need ? 1 : 0);
const completion = answeredCount / 4;
const completionFactor = 0.5 + 0.5 * completion;
return clamp(confTime * completionFactor, 0.2, 1.0);
}
function buildStageOneHot(momStage: MomStageAnswer | null | undefined): UserStageOneHot {
// V1.2mom_stage 跳过按安全策略输出 unknown=1
if (momStage === null || momStage === undefined) {
return { unknown: 1 };
}
return {
expecting: momStage === 'expecting' ? 1 : 0,
parenting: momStage === 'parenting' ? 1 : 0,
unknown: momStage === 'unknown' ? 1 : 0,
};
}
function mapEmotionScore(emotion: EmotionAnswer | null | undefined): number | null {
if (emotion === null || emotion === undefined) return null;
switch (emotion) {
case 'low':
return 0.0;
case 'overwhelmed':
return 0.2;
case 'tired':
return 0.4;
case 'neutral':
return 0.6;
case 'calm':
return 0.8;
case 'joyful':
return 1.0;
}
}
function buildSparseOneHot(value: string | null | undefined): SparseOneHot {
if (value === null || value === undefined) return {};
return { [value]: 1 };
}
function computeRuleHitsAndHardRules(profile: {
stage: UserStageOneHot;
emotion_score: number | null;
}): { rule_hits: string[]; hard_rules: HardRules } {
const rule_hits: string[] = [];
const forbidden_risk_flags: string[] = [];
const stageUnknown = profile.stage.unknown === 1;
const stageParenting = profile.stage.parenting === 1;
if (stageUnknown) {
rule_hits.push('unsafe_for_stage_unknown');
forbidden_risk_flags.push('unsafe_for_stage_unknown');
}
if (stageParenting) {
rule_hits.push('unsafe_for_stage_parenting');
forbidden_risk_flags.push('unsafe_for_stage_parenting');
}
if (profile.emotion_score !== null && profile.emotion_score <= 0.2) {
rule_hits.push('unsafe_for_emotion_low');
forbidden_risk_flags.push('unsafe_for_emotion_low');
}
const forbidden_content_predicates = [];
if (stageUnknown) {
forbidden_content_predicates.push({
id: 'unknown_block_parenting_pressure_personalized',
when_user: { stage_unknown: true },
forbid_content: { need: 'parenting_pressure', personalization_power: 1 },
});
}
return {
rule_hits,
hard_rules: {
forbidden_risk_flags,
forbidden_content_predicates,
},
};
}
export function buildUserProfileFromQuestionnaire(
rawAnswers: QuestionnaireAnswersV1_2,
options: BuildUserProfileOptions = {}
): UserProfileV1_2_Extended {
const normalized = normalizeAnswers(rawAnswers);
const profile_answered = computeProfileAnswered(normalized);
const now = toDate(options.now) ?? new Date();
const generatedAt = toDate(options.generatedAt) ?? now;
const confTime = computeTimeConfidence(generatedAt, now);
const profile_confidence = computeProfileConfidence(confTime, profile_answered);
const stage = buildStageOneHot(normalized.mom_stage);
const emotion_score = mapEmotionScore(normalized.emotion);
const context = buildSparseOneHot(normalized.context);
const need = buildSparseOneHot(normalized.need);
const { rule_hits, hard_rules } = computeRuleHitsAndHardRules({ stage, emotion_score });
return {
profile_version: 'v1.2',
profile_source: 'questionnaire',
profile_generated_at: generatedAt.toISOString(),
profile_confidence,
profile_answered,
stage,
emotion_score,
context,
need,
rule_hits,
hard_rules,
};
}

View File

@@ -0,0 +1,95 @@
/**
* 用户画像打分User Profile ScoringV1.2 类型定义
*
* 说明:
* - 本模块用于:问卷答案(可跳过)→ 用户画像(可计算、可观测、可版本化)
* - 字段与规则以 `spec_kit/User Profile Scoring/spec.md`V1.2)为准
*/
export type MomStageAnswer = 'expecting' | 'parenting' | 'unknown';
export type EmotionAnswer = 'low' | 'overwhelmed' | 'tired' | 'neutral' | 'calm' | 'joyful';
export type ContextAnswer = 'family' | 'work' | 'relationship' | 'friends' | 'health';
export type NeedAnswer =
| 'emotional_support'
| 'parenting_pressure'
| 'self_worth'
| 'anxiety_relief'
| 'rest_balance';
/**
* V1.2:每题可跳过
* - `undefined`:字段缺失(可能是“没传”)
* - `null`:显式跳过/无值(例如 UI 明确传 null
*/
export type QuestionnaireAnswersV1_2 = {
mom_stage?: MomStageAnswer | null;
emotion?: EmotionAnswer | null;
context?: ContextAnswer | null;
need?: NeedAnswer | null;
};
export type ProfileAnswered = {
stage: boolean;
emotion: boolean;
context: boolean;
need: boolean;
};
export type UserStageOneHot = {
expecting?: 0 | 1;
parenting?: 0 | 1;
unknown: 0 | 1;
};
export type SparseOneHot = Record<string, 1>;
export type UserProfileV1_2 = {
profile_version: 'v1.2';
profile_source: 'questionnaire';
profile_generated_at: string; // ISO8601
profile_confidence: number; // 01
profile_answered: ProfileAnswered;
stage: UserStageOneHot;
emotion_score: number | null;
context: SparseOneHot;
need: SparseOneHot;
};
export type ForbiddenContentPredicate = {
/**
* 谓词 ID用于可观测与回归测试
*/
id: string;
/**
* 触发条件(用户侧)
* 说明:这里刻意保持为 object便于未来接入规则引擎时做 schema 对齐。
*/
when_user: Record<string, unknown>;
/**
* 禁推条件(内容侧)
* 说明:本模块不判断内容的 `personalization_power`,只输出可执行条件。
*/
forbid_content: Record<string, unknown>;
};
export type HardRules = {
forbidden_risk_flags: string[];
forbidden_content_predicates: ForbiddenContentPredicate[];
};
export type UserProfileV1_2_Extended = UserProfileV1_2 & {
rule_hits: string[];
hard_rules: HardRules;
};
export type BuildUserProfileOptions = {
/**
* 画像生成时间;不传则使用当前时间
*/
generatedAt?: Date | string;
/**
* 当前时间(用于计算 time decay不传则使用当前时间
*/
now?: Date | string;
};

View File

@@ -0,0 +1,25 @@
## 應用文案總表(請在對應 JSON 中修改)
### 語言與檔案對應(單一來源,避免多檔覆蓋)
| 語言 | 實際使用的檔案 | 說明 |
|------------|-----------------------------|------|
| **英文** | `locales/all.json``en` | 只改 all.json 的 `en` 區塊 |
| **繁體中文** | `locales/zh-TW.json` | **唯一來源**Onboarding / 開屏 consent 等繁中文案只改此檔 |
- 運行時 **繁中zh-TW只讀取 `zh-TW.json`**,不會讀取 `all.json` 的 zh-TW 區塊。
- 修改 JSON 後需**重新載入 App**(模擬器 `Cmd+R`)或重啟 Metro必要時加 `--clear`)才會看到新文案。
- **若修改 zh-TW.json 後開屏 consent 仍顯示舊文案**:請執行 `npm run clean:cache`,再以 `npm run start:clean` 啟動 Metro或先刪除模擬器上的 App 再執行 `npm run ios:clean`),確保吃到最新 bundle。
### 快速索引(高頻文案)
- **開屏 / 協議**`consent.*`**繁中只改 zh-TW.json**`consent.title``consent.subtitle``consent.subtitleSecondary`、agree, privacy, terms, notice…
- **Onboarding 問卷**`onboardingSurvey.steps.*`name.title, status.title, status.options.* …)
- **Onboarding 招呼語**`onboardingSurvey.greeting`Hi {{name}}
- **Home**`home.*`
- **Push 提示**`push.*`
- **主題**`theme.*`
- **我的 / Profile**`profile.*`
- **收藏**`favorites.*`
- **設定**`settings.*`
- **Mock 文案**`mock.*`

View File

@@ -3,31 +3,29 @@ import * as Localization from 'expo-localization';
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import en from './locales/en.json';
import es from './locales/es.json';
import pt from './locales/pt.json';
import zhCN from './locales/zh-CN.json';
import zhTW from './locales/zh-TW.json';
import { isTraditionalChineseLocaleTag } from './locale';
// 用 require 避免 TS 的 json module 配置差异导致无法编译
// 繁中zh-TW唯一來源locales/zh-TW.json,修改 Onboarding/開屏等繁中文案請只改該檔案
// 本 App 未載入 zh-CN.json若看到類似「你本就完美」「一切都会变好」等簡中 consent 文案,為舊 bundle 快取導致,請執行 clean:cache + start:clean 或卸載 App 重裝。
// eslint-disable-next-line @typescript-eslint/no-var-requires
const all = require('./locales/all.json') as { en: Record<string, unknown>; 'zh-TW': Record<string, unknown> };
// eslint-disable-next-line @typescript-eslint/no-var-requires
const zhTW = require('./locales/zh-TW.json') as Record<string, unknown>;
/**
* 语言码约定:
* - 简体中文zh-CN
* - 繁体中文zh-TW
* - 英语en
* - 西班牙语es
* - 葡萄牙语pt
*/
export type AppLanguage = 'zh-CN' | 'zh-TW' | 'en' | 'es' | 'pt';
export type AppLanguage = 'zh-TW' | 'en';
export const SUPPORTED_LANGUAGES: readonly AppLanguage[] = [
'zh-CN',
'zh-TW',
'en',
'es',
'pt',
] as const;
const DEFAULT_FALLBACK_LANGUAGE: AppLanguage = 'zh-CN';
const DEFAULT_FALLBACK_LANGUAGE: AppLanguage = 'en';
const STORAGE_KEY_LANGUAGE = 'settings.language';
function isSupportedLanguage(lang: string): lang is AppLanguage {
@@ -37,19 +35,8 @@ function isSupportedLanguage(lang: string): lang is AppLanguage {
function normalizeDeviceLanguageTagToAppLanguage(languageTag: string): AppLanguage {
const tag = languageTag.toLowerCase();
// 中文:优先区分繁简
if (tag.startsWith('zh')) {
// 常见繁体标记zh-TW / zh-HK / zh-Hant
if (tag.includes('tw') || tag.includes('hk') || tag.includes('hant')) {
return 'zh-TW';
}
return 'zh-CN';
}
// 其他语言:按前缀匹配
if (tag.startsWith('en')) return 'en';
if (tag.startsWith('es')) return 'es';
if (tag.startsWith('pt')) return 'pt';
if (isTraditionalChineseLocaleTag(tag)) return 'zh-TW';
return DEFAULT_FALLBACK_LANGUAGE;
}
@@ -87,7 +74,7 @@ export async function clearLanguagePreference(): Promise<void> {
* 语言选择优先级:
* 1) 用户设置(若存在)
* 2) 设备语言(在支持列表内时生效;否则会被 normalize 到默认回退)
* 3) 默认回退(zh-CN
* 3) 默认回退(en
*/
export async function initI18n(): Promise<void> {
if (i18n.isInitialized) return;
@@ -98,11 +85,9 @@ export async function initI18n(): Promise<void> {
await i18n.use(initReactI18next).init({
resources: {
'zh-CN': { translation: zhCN },
// 繁中唯一來源zh-TW.jsonall.json 的 zh-TW 區塊不會被載入)
'zh-TW': { translation: zhTW },
en: { translation: en },
es: { translation: es },
pt: { translation: pt },
en: { translation: all.en as any },
},
lng: initialLang,
fallbackLng: DEFAULT_FALLBACK_LANGUAGE,
@@ -111,6 +96,18 @@ export async function initI18n(): Promise<void> {
escapeValue: false,
},
});
// 臨時 debug確認實際使用的 language 與 consent.title 值(方便驗證繁中來自 zh-TW.json
if (typeof __DEV__ !== 'undefined' && __DEV__) {
const consentTitle = i18n.t('consent.title');
console.log(
'[i18n] 已初始化 language=',
i18n.language,
'| consent.title=',
consentTitle,
'| 繁中來源=zh-TW.json英文來源=all.json 的 en 區塊'
);
}
}
/**

35
client/src/i18n/locale.ts Normal file
View File

@@ -0,0 +1,35 @@
export type BackendLocale = 'en' | 'tc';
/**
* 判断一个 BCP-47 language tag 是否应视为「繁体中文」TC
*
* 规则(面向当前产品约束:只支持 EN/TC默认 EN
* - 仅在语言为中文zh且脚本为 Hant 或地区为 TW/HK/MO 时,判定为 TC
* - 兼容后端/历史写法:明确包含 tc 也视为 TC
* - 其他情况一律视为 EN
*/
export function isTraditionalChineseLocaleTag(languageTag: string): boolean {
const tag = (languageTag || '').trim().toLowerCase();
if (!tag) return false;
// 兼容:有些链路可能直接传 tc
const parts = tag.split(/[-_]/g).filter(Boolean);
if (parts.includes('tc')) return true;
const lang = parts[0];
if (lang !== 'zh') return false;
// 脚本zh-Hant / zh-Hant-TW / zh-Hant-HK ...
if (tag.includes('hant') || parts.includes('hant')) return true;
// 地区zh-TW / zh-HK / zh-MO
if (parts.includes('tw') || parts.includes('hk') || parts.includes('mo')) return true;
// 其他中文(如 zh / zh-CN / zh-Hans不属于 TC → 回退 EN
return false;
}
export function toBackendLocaleFromLanguageTag(languageTag: string | null | undefined): BackendLocale {
return isTraditionalChineseLocaleTag(languageTag ?? '') ? 'tc' : 'en';
}

View File

@@ -0,0 +1,348 @@
{
"en": {
"common": {
"ok": "OK",
"cancel": "Cancel",
"error": "Error",
"notice": "Notice",
"openLinkError": "Cannot open link",
"back": "Back",
"close": "Close"
},
"onboarding": {
"title": "Welcome",
"progress": "{{current}}/{{total}}",
"next": "Next",
"skip": "Skip",
"skipAll": "Skip",
"q1Title": "How are you feeling lately?",
"q1Desc": "No right or wrong. You can skip and adjust later.",
"q2Title": "What kind of support do you want?",
"q2Desc": "For example: gentle reminders, mindfulness, emotional support.",
"q3Title": "When do you need comfort the most?",
"q3Desc": "Morning, afternoon, late night, or specific moments.",
"q4Title": "A gentle sentence for yourself",
"q4Desc": "You can skip. Well stay with you along the way."
},
"onboardingSurvey": {
"greeting": "Hi {{name}},",
"steps": {
"name": { "title": "What should we call you?", "placeholder": "Mama" },
"status": {
"title": "Where are you at right now?",
"options": {
"pregnant": "Pregnant / Preparing",
"has_kids": "Parenting",
"no_fill": "Prefer not to say"
}
},
"emotion": {
"title": "How are you feeling right now?",
"options": {
"happy": "Joyful",
"calm": "Calm",
"okay": "Okay",
"tired": "Tired",
"stressed": "Overwhelmed",
"low": "Low"
}
},
"influence": {
"title": "Whats been influencing how you feel?",
"options": {
"family": "Family",
"work": "Work or study",
"relationship": "Relationship",
"friends": "Friends",
"health": "Health"
}
},
"support": {
"title": "What kind of support do you need most right now?",
"options": {
"emotional": "Emotional support",
"parenting": "Parenting pressure",
"self_worth": "Self-worth",
"anxiety": "Anxiety relief",
"balance": "Rest & balance"
}
},
"reminder": { "title": "How often would you like a gentle reminder?" }
}
},
"intent": {
"title": "What kind of help do you want?",
"love": "Love",
"life": "Life",
"travel": "Travel",
"work": "Career"
},
"push": {
"title": "Notifications",
"cardTitle": "Turn on gentle reminders",
"cardDesc": "Well send a short mindful phrase when you may need it. You can change this anytime in Settings.",
"enable": "Enable",
"later": "Later",
"loading": "Working…",
"errorTitle": "Notice",
"errorDesc": "Its okay if enabling fails. You can keep using the app."
},
"home": {
"title": "Mindfulness",
"like": "Like",
"dislike": "Dislike",
"favorites": "Favorites",
"settings": "Settings",
"theme": "Theme",
"profile": "Me"
},
"theme": {
"title": "Theme",
"scenery": "Scenery",
"color": "Color",
"suixin": "Ease"
},
"profile": {
"title": "Me",
"favorites": "My Likes",
"widget": "Widget",
"dailyReminder": "Daily Reminder",
"privacy": "Privacy Policy",
"terms": "Terms of Use",
"language": "Language",
"todoTitle": "Notice",
"todoDesc": "This feature is a placeholder for this iteration."
},
"dailyReminder": {
"title": "Daily Reminder",
"timesUnit": "times",
"timesUnitSingular": "time",
"pushLabel": "Push Reminder",
"ok": "Ok",
"minus": "Decrease",
"plus": "Increase"
},
"widget": {
"lockScreen": "Lock Screen Widget",
"homeScreen": "Home Screen Widget",
"howToTitle": "How to add the widget",
"howToDesc1": "Long-press on the Home Screen until the apps jiggle, then tap “+” in the top-left corner.",
"howToDesc2": "Search “Mindfulness”, choose a widget size you like, then tap “Add Widget”.",
"previewDate": "Thu, Jan 29",
"previewQuote": "Im proud of who I am, even while becoming who I want to be."
},
"favorites": {
"title": "Favorites",
"empty": "No favorites yet.",
"unknownText": "This quote is no longer available."
},
"settings": {
"title": "Settings",
"language": "Language",
"version": "Version",
"widgetTitle": "iOS Widget",
"widgetDesc": "Put gentle reminders on your home screen: long-press → tap “+” → search “Mindfulness” → add a size you like."
},
"consent": {
"title": "Hey mama.",
"subtitle": "Youre doing okay\nright now.",
"subtitleSecondary": "",
"agree": "Agree & Continue",
"privacy": "Privacy Policy",
"terms": "Terms of Use",
"notice": "By continuing, you agree to the Privacy Policy and Terms of Use.",
"noticeRich": "By continuing,\nyou agree to the <privacy>{{privacyLabel}}{{privacySuffix}}</privacy> and <terms>{{termsLabel}}{{termsSuffix}}</terms>.",
"linkUnavailable": "Failed to load the policy link. Please check your network and try again.",
"linkUnavailableDev": "Failed to load the policy link. Please check your network or API_BASE_URL: {{baseUrl}}",
"linkLoadingSuffix": " (loading…)"
},
"permissions": {
"notificationsDenied": "Notifications are denied. Please enable them in Settings."
},
"language": {
"zhTW": "繁體中文",
"en": "English"
},
"mock": {
"c1": "Youve been trying your best. You deserve kindness today.",
"c2": "Take three deep breaths and return to the present moment.",
"c3": "Its okay to slow down. Emotions pass like clouds.",
"c4": "You dont need to be perfect. You are enough.",
"c5": "Place a hand on your heart and say: You did well today."
}
},
"zh-TW": {
"common": {
"ok": "確定",
"cancel": "取消",
"back": "返回",
"error": "錯誤",
"notice": "提示",
"openLinkError": "無法打開鏈接",
"close": "關閉"
},
"onboarding": {
"title": "歡迎",
"progress": "{{current}}/{{total}}",
"next": "下一步",
"skip": "跳過",
"skipAll": "跳過",
"q1Title": "你最近的感受更接近哪一種?",
"q1Desc": "沒有對錯,你可以跳過,之後也能慢慢調整。",
"q2Title": "你更希望獲得哪種支持?",
"q2Desc": "例如:溫柔提醒、正念練習、情緒陪伴。",
"q3Title": "你通常在什麼時候最需要被安慰?",
"q3Desc": "例如:清晨、午后、深夜,或某些特定時刻。",
"q4Title": "給自己一句溫柔的話",
"q4Desc": "你可以直接跳過,我們會在之後繼續陪你。"
},
"onboardingSurvey": {
"greeting": "Hi {{name}}",
"steps": {
"name": { "title": "怎麼稱呼你呢?", "placeholder": "媽媽" },
"status": {
"title": "你現在正處在哪個階段呢?",
"options": {
"pregnant": "懷孕中/正在準備迎接寶寶",
"has_kids": "已經有孩子",
"no_fill": "我暫時不想說"
}
},
"emotion": {
"title": "今天的你,還好嗎?",
"options": {
"happy": "愉悅、滿足",
"calm": "平靜、安穩",
"okay": "還可以、普通",
"tired": "疲累、沒什麼力氣",
"stressed": "被壓得有點喘不過氣",
"low": "情緒低落"
}
},
"influence": {
"title": "是什麼影響了你最近的感受?",
"options": {
"family": "家庭與孩子",
"work": "工作或學習",
"relationship": "親密關係",
"friends": "朋友與人際",
"health": "身心健康"
}
},
"support": {
"title": "最需要什麼支持?",
"options": {
"emotional": "情緒支持",
"parenting": "育兒壓力",
"self_worth": "自我價值",
"anxiety": "焦慮舒緩",
"balance": "休息與平衡"
}
},
"reminder": { "title": "你需要每天幾次提醒?" }
}
},
"intent": {
"title": "你希望得到什麼幫助?",
"love": "愛情",
"life": "生活",
"travel": "旅遊",
"work": "職場"
},
"push": {
"title": "通知",
"cardTitle": "開啟溫柔提醒",
"cardDesc": "我們會在你需要的時候,送上一句正念短句或溫柔提醒(可隨時在設定中調整)。",
"enable": "立即開啟",
"later": "稍後",
"loading": "處理中…",
"errorTitle": "提示",
"errorDesc": "開啟失敗也沒關係,你仍然可以繼續使用應用。"
},
"home": {
"title": "正念",
"like": "喜歡",
"dislike": "不喜歡",
"favorites": "收藏",
"settings": "設定",
"theme": "主題",
"profile": "我的"
},
"theme": {
"title": "主題",
"scenery": "風景",
"color": "顏色",
"suixin": "隨心"
},
"profile": {
"title": "我的",
"favorites": "我的喜歡",
"widget": "小工具",
"dailyReminder": "每日提醒",
"privacy": "隱私政策",
"terms": "使用條款",
"language": "語言",
"todoTitle": "提示",
"todoDesc": "此功能本期先占位,後續迭代補齊。"
},
"dailyReminder": {
"title": "每日提醒",
"timesUnit": "次",
"timesUnitSingular": "次",
"pushLabel": "推送提醒",
"ok": "確定",
"minus": "減少次數",
"plus": "增加次數"
},
"widget": {
"lockScreen": "鎖屏小工具",
"homeScreen": "桌面小工具",
"howToTitle": "如何添加小工具",
"howToDesc1": "長按主畫面空白處進入編輯,點左上角「+」新增小工具。",
"howToDesc2": "搜尋「正念」,選擇喜歡的尺寸,點「加入小工具」。",
"previewDate": "1月29日週四 · 已至臘月十一",
"previewQuote": "我也對現在的自己感到滿意,即使我仍在努力成為想成為的人。"
},
"favorites": {
"title": "收藏夾",
"empty": "這裡還沒有收藏內容。",
"unknownText": "這條文案暫時無法顯示。"
},
"settings": {
"title": "設定",
"language": "語言",
"version": "版本",
"widgetTitle": "iOS 小工具",
"widgetDesc": "把溫柔提醒放到桌面上:長按主畫面 → 點「+」 → 搜尋「正念」 → 添加你喜歡的尺寸。"
},
"consent": {
"title": "我們知道,",
"subtitle": "當媽媽很不容易。",
"subtitleSecondary": "這裡給你一些溫柔的肯定與提醒",
"agree": "同意並繼續",
"privacy": "隱私協議",
"terms": "用戶使用協議",
"notice": "繼續使用即代表你同意《隱私協議》與《用戶使用協議》。",
"noticeRich": "繼續使用即代表你同意<privacy>《{{privacyLabel}}》{{privacySuffix}}</privacy>與<terms>《{{termsLabel}}》{{termsSuffix}}</terms>。",
"linkUnavailable": "協議鏈接載入失敗,請檢查網路後重試。",
"linkUnavailableDev": "協議鏈接載入失敗,請檢查網路或 API_BASE_URL 設定:{{baseUrl}}",
"linkLoadingSuffix": "(載入中…)"
},
"permissions": {
"notificationsDenied": "系統權限已被拒絕,請前往手機設定開啟通知。"
},
"language": {
"zhTW": "繁體中文",
"en": "English"
},
"mock": {
"c1": "你已經很努力了,今天也值得被溫柔對待。",
"c2": "深呼吸三次,把注意力帶回當下。",
"c3": "允許自己慢一點,情緒會像雲一樣飄過。",
"c4": "你不需要完美,你已經足夠好。",
"c5": "把手放在心口,對自己說一句:辛苦了。"
}
}
}

View File

@@ -11,7 +11,7 @@
"progress": "{{current}}/{{total}}",
"next": "Next",
"skip": "Skip",
"skipAll": "Skip onboarding",
"skipAll": "Skip",
"q1Title": "How are you feeling lately?",
"q1Desc": "No right or wrong. You can skip and adjust later.",
"q2Title": "What kind of support do you want?",
@@ -59,6 +59,7 @@
"dailyReminder": {
"title": "Daily Reminder",
"timesUnit": "times",
"timesUnitSingular": "time",
"pushLabel": "Push Reminder",
"ok": "Ok",
"minus": "Decrease",

View File

@@ -9,7 +9,7 @@
"progress": "{{current}}/{{total}}",
"next": "Siguiente",
"skip": "Saltar",
"skipAll": "Saltar introducción",
"skipAll": "Saltar",
"q1Title": "¿Cómo te sientes últimamente?",
"q1Desc": "No hay respuestas correctas. Puedes saltar y ajustar después.",
"q2Title": "¿Qué tipo de apoyo quieres?",
@@ -57,6 +57,7 @@
"dailyReminder": {
"title": "Recordatorio diario",
"timesUnit": "veces",
"timesUnitSingular": "vez",
"pushLabel": "Recordatorio Push",
"ok": "Ok",
"minus": "Disminuir",

View File

@@ -9,7 +9,7 @@
"progress": "{{current}}/{{total}}",
"next": "Próximo",
"skip": "Pular",
"skipAll": "Pular introdução",
"skipAll": "Pular",
"q1Title": "Como você tem se sentido ultimamente?",
"q1Desc": "Não há certo ou errado. Você pode pular e ajustar depois.",
"q2Title": "Que tipo de apoio você quer?",
@@ -57,6 +57,7 @@
"dailyReminder": {
"title": "Lembrete diário",
"timesUnit": "vezes",
"timesUnitSingular": "vez",
"pushLabel": "Lembrete Push",
"ok": "Ok",
"minus": "Diminuir",

View File

@@ -12,7 +12,7 @@
"progress": "{{current}}/{{total}}",
"next": "下一步",
"skip": "跳过",
"skipAll": "跳过整个引导",
"skipAll": "跳过",
"q1Title": "你最近的感受更接近哪一种?",
"q1Desc": "没有对错,你可以跳过,之后也可以慢慢调整。",
"q2Title": "你更希望获得哪种支持?",
@@ -30,7 +30,7 @@
"later": "稍后",
"loading": "处理中…",
"errorTitle": "提示",
"errorDesc": "开启失败也没关系,你仍然可以继续使用应用。"
"errorDesc": "开启失败,请稍后重试(模拟器可能无法获取推送 Token建议用真机测试。"
},
"home": {
"title": "正念",
@@ -60,6 +60,7 @@
"dailyReminder": {
"title": "每日提醒",
"timesUnit": "次",
"timesUnitSingular": "次",
"pushLabel": "推送提醒",
"ok": "确定",
"minus": "减少次数",

View File

@@ -2,14 +2,18 @@
"common": {
"ok": "確定",
"cancel": "取消",
"back": "返回"
"back": "返回",
"error": "錯誤",
"notice": "提示",
"openLinkError": "無法打開鏈接",
"close": "關閉"
},
"onboarding": {
"title": "歡迎",
"progress": "{{current}}/{{total}}",
"next": "下一步",
"skip": "跳過",
"skipAll": "跳過整個引導",
"skipAll": "跳過",
"q1Title": "你最近的感受更接近哪一種?",
"q1Desc": "沒有對錯,你可以跳過,之後也能慢慢調整。",
"q2Title": "你更希望獲得哪種支持?",
@@ -19,6 +23,64 @@
"q4Title": "給自己一句溫柔的話",
"q4Desc": "你可以直接跳過,我們會在之後繼續陪你。"
},
"onboardingSurvey": {
"greeting": "Hi {{name}}",
"steps": {
"name": {
"title": "怎麼稱呼你呢?",
"placeholder": "媽媽"
},
"status": {
"title": "你現在正處在哪個階段呢?",
"options": {
"pregnant": "懷孕中/正在準備迎接寶寶",
"has_kids": "已經有孩子",
"no_fill": "我暫時不想說"
}
},
"emotion": {
"title": "今天的你,還好嗎?",
"options": {
"happy": "愉悅、滿足",
"calm": "平靜、安穩",
"okay": "還可以、普通",
"tired": "疲累、沒什麼力氣",
"stressed": "被壓得有點喘不過氣",
"low": "情緒低落"
}
},
"influence": {
"title": "是什麼影響了你最近的感受?",
"options": {
"family": "家庭與孩子",
"work": "工作或學習",
"relationship": "親密關係",
"friends": "朋友與人際",
"health": "身心健康"
}
},
"support": {
"title": "最需要什麼支持?",
"options": {
"emotional": "情緒支持",
"parenting": "育兒壓力",
"self_worth": "自我價值",
"anxiety": "焦慮舒緩",
"balance": "休息與平衡"
}
},
"reminder": {
"title": "你希望一天收到幾次肯定語?"
}
}
},
"intent": {
"title": "你希望得到什麼幫助?",
"love": "愛情",
"life": "生活",
"travel": "旅遊",
"work": "職場"
},
"push": {
"title": "通知",
"cardTitle": "開啟溫柔提醒",
@@ -41,7 +103,8 @@
"theme": {
"title": "主題",
"scenery": "風景",
"color": "顏色"
"color": "顏色",
"suixin": "隨心"
},
"profile": {
"title": "我的",
@@ -57,6 +120,7 @@
"dailyReminder": {
"title": "每日提醒",
"timesUnit": "次",
"timesUnitSingular": "次",
"pushLabel": "推送提醒",
"ok": "確定",
"minus": "減少次數",
@@ -65,12 +129,16 @@
"widget": {
"lockScreen": "鎖屏小工具",
"homeScreen": "桌面小工具",
"howToTitle": "如何添加小工具",
"howToDesc1": "長按主畫面空白處進入編輯,點左上角「+」新增小工具。",
"howToDesc2": "搜尋「正念」,選擇喜歡的尺寸,點「加入小工具」。",
"previewDate": "1月29日週四 · 已至臘月十一",
"previewQuote": "我也對現在的自己感到滿意,即使我仍在努力成為想成為的人。"
},
"favorites": {
"title": "收藏夾",
"empty": "這裡還沒有收藏內容。"
"empty": "這裡還沒有收藏內容。",
"unknownText": "這條文案暫時無法顯示。"
},
"settings": {
"title": "設定",
@@ -80,9 +148,30 @@
"widgetDesc": "把溫柔提醒放到桌面上:長按主畫面 → 點「+」 → 搜尋「正念」 → 添加你喜歡的尺寸。"
},
"consent": {
"title": "我們知道,",
"subtitle": "當媽媽很不容易。",
"subtitleSecondary": "這裡給你一些溫柔的肯定與提醒",
"agree": "同意並繼續",
"privacy": "隱私協議",
"terms": "用戶使用協議"
"terms": "用戶使用協議",
"notice": "繼續使用即代表你同意《隱私協議》與《用戶使用協議》。",
"noticeRich": "繼續使用即代表你同意<privacy>《{{privacyLabel}}》{{privacySuffix}}</privacy>與<terms>《{{termsLabel}}》{{termsSuffix}}</terms>。",
"linkUnavailable": "協議鏈接載入失敗,請檢查網路後重試。",
"linkUnavailableDev": "協議鏈接載入失敗,請檢查網路或 API_BASE_URL 設定:{{baseUrl}}",
"linkLoadingSuffix": "(載入中…)"
},
"permissions": {
"notificationsDenied": "系統權限已被拒絕,請前往手機設定開啟通知。"
},
"language": {
"zhTW": "繁體中文",
"en": "English"
},
"mock": {
"c1": "你已經很努力了,今天也值得被溫柔對待。",
"c2": "深呼吸三次,把注意力帶回當下。",
"c3": "允許自己慢一點,情緒會像雲一樣飄過。",
"c4": "你不需要完美,你已經足夠好。",
"c5": "把手放在心口,對自己說一句:辛苦了。"
}
}
}

View File

@@ -0,0 +1,174 @@
import { API_BASE_URL } from '@/src/constants/env';
import type { UserProfileV1_2, UserProfileV1_2_Extended } from '@/src/features/userProfileScoring/types';
import { toBackendLocaleFromLanguageTag } from '@/src/i18n/locale';
import { fetchRecoWidget } from '@/src/services/recoApi';
import i18n from 'i18next';
import { getUserProfileScoring } from '@/src/storage/appStorage';
import { getLocalDayKey } from '@/src/utils/date';
import {
appGroupGetString,
appGroupReloadAllTimelines,
appGroupSetString,
isAppGroupStorageAvailable,
} from '@/src/services/appGroupStorage';
/**
* App Group 共享 keyApp ↔ Widget 共通)
*/
export const WIDGET_CONFIG_KEY = 'widget.config.v1';
export const WIDGET_USER_PROFILE_KEY = 'widget.userProfile.v1_2';
export const WIDGET_DAILY_RECO_KEY = 'widget.dailyReco.v1';
export type WidgetConfigV1 = {
schema_version: 1;
saved_at: string; // ISO8601
apiBaseUrl: string;
};
export type WidgetUserProfileV1_2 = {
schema_version: 1;
saved_at: string; // ISO8601
user_profile: UserProfileV1_2;
};
export type WidgetDailyRecoV1 = {
schema_version: 1;
saved_at: string; // ISO8601
day_key: string; // YYYY-MM-DD用户时区
lang: 'en' | 'tc';
item: null | {
content_id: number;
text: string;
final_score?: number;
fallback_level_final?: number;
};
meta?: Record<string, unknown>;
source?: 'app' | 'widget';
};
function safeJsonParse<T>(raw: string | null): T | null {
if (!raw) return null;
try {
return JSON.parse(raw) as T;
} catch {
return null;
}
}
function pickUserProfileV1_2(scoringProfile: UserProfileV1_2_Extended): UserProfileV1_2 {
return {
profile_version: scoringProfile.profile_version,
profile_source: scoringProfile.profile_source,
profile_generated_at: scoringProfile.profile_generated_at,
profile_confidence: scoringProfile.profile_confidence,
profile_answered: scoringProfile.profile_answered,
stage: scoringProfile.stage,
emotion_score: scoringProfile.emotion_score,
context: scoringProfile.context,
need: scoringProfile.need,
};
}
export async function syncWidgetConfig(): Promise<void> {
if (!isAppGroupStorageAvailable()) return;
const payload: WidgetConfigV1 = {
schema_version: 1,
saved_at: new Date().toISOString(),
apiBaseUrl: API_BASE_URL,
};
await appGroupSetString(WIDGET_CONFIG_KEY, JSON.stringify(payload));
}
export async function syncWidgetUserProfileFromScoring(scoringProfile: UserProfileV1_2_Extended): Promise<void> {
if (!isAppGroupStorageAvailable()) return;
const payload: WidgetUserProfileV1_2 = {
schema_version: 1,
saved_at: new Date().toISOString(),
user_profile: pickUserProfileV1_2(scoringProfile),
};
await appGroupSetString(WIDGET_USER_PROFILE_KEY, JSON.stringify(payload));
}
export async function syncWidgetUserProfileFromStorage(): Promise<void> {
const scoringProfile = await getUserProfileScoring();
if (!scoringProfile) return;
await syncWidgetUserProfileFromScoring(scoringProfile);
}
export async function getWidgetDailyRecoCache(): Promise<WidgetDailyRecoV1 | null> {
if (!isAppGroupStorageAvailable()) return null;
const raw = await appGroupGetString(WIDGET_DAILY_RECO_KEY);
return safeJsonParse<WidgetDailyRecoV1>(raw);
}
export async function setWidgetDailyRecoCache(cache: WidgetDailyRecoV1): Promise<void> {
if (!isAppGroupStorageAvailable()) return;
await appGroupSetString(WIDGET_DAILY_RECO_KEY, JSON.stringify(cache));
}
/**
* App 前台辅助刷新(不保证准点,但能提升更新及时性与一致性)
*
* 规则:
* - 若共享缓存 `day_key` 已是今天 → 不请求
* - 若缺少用户画像 → 不请求(交给 Widget 走兜底/下次重试)
* - 成功后写入共享缓存并触发 Widget reload系统仍可能延迟
*/
export async function ensureDailyWidgetRecoUpToDate(args?: {
reason?: string;
scoringProfile?: UserProfileV1_2_Extended | null;
}): Promise<void> {
if (!isAppGroupStorageAvailable()) return;
// 先确保 Widget 能拿到 baseURLdev/pro 切换时很关键)
await syncWidgetConfig();
const today = getLocalDayKey(new Date());
const cached = await getWidgetDailyRecoCache();
if (cached?.schema_version === 1 && cached.day_key === today && cached.item?.text) return;
const scoringProfile = args?.scoringProfile ?? (await getUserProfileScoring());
if (!scoringProfile) return;
// 同步画像给 Widget保证 Widget 独立拉取也有输入)
await syncWidgetUserProfileFromScoring(scoringProfile);
try {
const { items, meta } = await fetchRecoWidget({
k: 1,
user_profile: pickUserProfileV1_2(scoringProfile),
already_recommended_ids: [],
touched_or_viewed_ids: [],
});
const top = items?.[0];
if (!top?.text) return;
const lang = toBackendLocaleFromLanguageTag(i18n.language);
await setWidgetDailyRecoCache({
schema_version: 1,
saved_at: new Date().toISOString(),
day_key: today,
// 语言策略:与后端 Accept-Language 保持一致(目前只区分 en/tc
lang,
item: {
content_id: top.content_id,
text: top.text,
final_score: top.final_score,
fallback_level_final: top.fallback_level_final,
},
meta: meta as Record<string, unknown>,
source: 'app',
});
// 触发 Widget 刷新(系统仍可能延迟)
await appGroupReloadAllTimelines();
} catch (e) {
// 失败不阻塞主流程Widget 将使用缓存或兜底文案
if (typeof __DEV__ !== 'undefined' && __DEV__) {
console.log('[DailyWidgetReco] App 前台刷新失败:', args?.reason ?? 'unknown', e);
}
}
}

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';
import i18n from 'i18next';
import { buildAcceptLanguage } from '../legalApi';
describe('legalApi.buildAcceptLanguage', () => {
function setLang(lang: string) {
Object.defineProperty(i18n, 'language', {
value: lang,
configurable: true,
});
}
it('非中文语言回退为 en', () => {
setLang('en');
expect(buildAcceptLanguage()).toBe('en');
setLang('es');
expect(buildAcceptLanguage()).toBe('en');
});
it('简中/其他中文不支持时回退为 en', () => {
setLang('zh-CN');
expect(buildAcceptLanguage()).toBe('en');
setLang('zh');
expect(buildAcceptLanguage()).toBe('en');
});
it('繁体中文归一为 tc', () => {
setLang('zh-TW');
expect(buildAcceptLanguage()).toBe('tc');
});
it('显式 tc / hant 等归一为 tc', () => {
setLang('tc');
expect(buildAcceptLanguage()).toBe('tc');
setLang('zh-Hant');
expect(buildAcceptLanguage()).toBe('tc');
});
});

View File

@@ -0,0 +1,53 @@
import { NativeModules, Platform } from 'react-native';
type AppGroupStorageNativeModule = {
/**
* 写入 App Group 的 UserDefaults值为字符串通常是 JSON
*/
setString(key: string, value: string): Promise<void>;
/**
* 读取 App Group 的 UserDefaults值为字符串通常是 JSON
*/
getString(key: string): Promise<string | null>;
/**
* 触发 Widget 刷新iOS 系统仍可能延迟)
*/
reloadAllTimelines(): Promise<void>;
};
function getNativeModule(): AppGroupStorageNativeModule | null {
if (Platform.OS !== 'ios') return null;
const raw = (NativeModules as Record<string, unknown>)?.AppGroupStorage as unknown;
if (!raw || typeof raw !== 'object') return null;
// 注意:若 iOS 原生模块没有正确导出方法(例如缺少 RCT_EXTERN_METHOD 桥接)
// JS 侧可能能拿到模块对象,但方法会是 undefined这里需要做运行时校验避免崩溃。
const m = raw as Partial<AppGroupStorageNativeModule>;
if (typeof m.setString !== 'function') return null;
if (typeof m.getString !== 'function') return null;
if (typeof m.reloadAllTimelines !== 'function') return null;
return m as AppGroupStorageNativeModule;
}
export function isAppGroupStorageAvailable(): boolean {
return Boolean(getNativeModule());
}
export async function appGroupSetString(key: string, value: string): Promise<void> {
const m = getNativeModule();
if (!m) return;
await m.setString(key, value);
}
export async function appGroupGetString(key: string): Promise<string | null> {
const m = getNativeModule();
if (!m) return null;
return await m.getString(key);
}
export async function appGroupReloadAllTimelines(): Promise<void> {
const m = getNativeModule();
if (!m) return;
await m.reloadAllTimelines();
}

View File

@@ -0,0 +1,29 @@
import i18n from 'i18next';
import { httpJson } from '../utils/http';
import { toBackendLocaleFromLanguageTag } from '../i18n/locale';
export type LegalLinks = {
privacyPolicyUrl: string;
termsOfUseUrl: string;
resolvedLang: 'en' | 'tc';
};
export function buildAcceptLanguage(): 'en' | 'tc' {
// 当前多语言仅支持 EN / TC其他语言统一回退到 en
return toBackendLocaleFromLanguageTag(i18n.language);
}
export async function fetchLegalLinks(): Promise<LegalLinks> {
const headers: Record<string, string> = {
'Accept-Language': buildAcceptLanguage(),
};
return await httpJson<LegalLinks>({
path: '/v1/legal/links',
method: 'GET',
headers,
timeoutMs: 8_000,
debugLabel: 'LegalLinks',
});
}

View File

@@ -0,0 +1,209 @@
import i18n from 'i18next';
import Constants from 'expo-constants';
import * as Notifications from 'expo-notifications';
import { Platform } from 'react-native';
import { httpJson } from '../utils/http';
import { APP_ENV } from '../constants/env';
import { getDailyReminderSettings, getOrCreateClientUserId, getUserProfileScoring } from '../storage/appStorage';
import type { UserProfileScoring } from '../storage/appStorage';
import { toBackendLocaleFromLanguageTag } from '../i18n/locale';
export type PushEnv = 'dev' | 'prod';
export type PushPlatform = 'ios' | 'android';
export type PushDeviceMeta = {
model?: string;
os_version?: string;
app_version?: string;
locale?: string;
timezone?: string;
};
export type PushRegisterRequest = {
client_user_id: string;
platform: PushPlatform;
push_token: string;
app_id: string;
env: PushEnv;
device_meta?: PushDeviceMeta;
};
export type PushPreferencesRequest = {
client_user_id: string;
enabled: boolean;
times_per_day: number; // 05
timezone?: string;
locale?: string;
// 可选:用户画像(用于后端 Push 场景推荐文案生成)
user_profile?: UserProfileScoring;
};
export type PushPreferencesResponse = PushPreferencesRequest & {
updated_at?: string;
};
export function buildAcceptLanguage(): 'en' | 'tc' {
return toBackendLocaleFromLanguageTag(i18n.language);
}
function toPushEnv(appEnv: typeof APP_ENV): PushEnv {
// 客户端 APP_ENV: local/dev/prod → 后端推送 env: dev/prod
if (appEnv === 'prod') return 'prod';
return 'dev';
}
function pickAppId(): string {
// 优先按平台取 bundleId/package拿不到则回退到 slug
if (Platform.OS === 'ios') {
return (
Constants.expoConfig?.ios?.bundleIdentifier ||
Constants.easConfig?.projectId ||
Constants.expoConfig?.slug ||
'unknown'
);
}
if (Platform.OS === 'android') {
return (
Constants.expoConfig?.android?.package ||
Constants.easConfig?.projectId ||
Constants.expoConfig?.slug ||
'unknown'
);
}
return Constants.expoConfig?.slug || 'unknown';
}
function pickTimezone(): string | undefined {
try {
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
return tz && String(tz).trim() ? String(tz).trim() : undefined;
} catch {
return undefined;
}
}
function pickLocale(): string | undefined {
const lang = (i18n.language || '').trim();
return lang ? lang : undefined;
}
function pickPlatform(): PushPlatform {
return Platform.OS === 'ios' ? 'ios' : 'android';
}
function getExpoProjectId(): string | undefined {
// Expo 官方推荐读取 projectIdEAS/Dev Client 下通常需要)
return (
Constants.easConfig?.projectId ||
// 兼容 app.json / app.config.ts 的 extra.eas.projectId
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(Constants.expoConfig as any)?.extra?.eas?.projectId ||
// 兜底:某些运行时环境仍可直接读到 EXPO_PUBLIC_ 注入
process.env.EXPO_PUBLIC_EAS_PROJECT_ID ||
undefined
);
}
export async function getExpoPushTokenOrThrow(): Promise<string> {
const projectId = getExpoProjectId();
try {
const res = projectId
? await Notifications.getExpoPushTokenAsync({ projectId })
: await Notifications.getExpoPushTokenAsync();
return res.data;
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
const hint = projectId
? ''
: '(可能缺少 EAS projectId请在 .env.local 配置 EXPO_PUBLIC_EAS_PROJECT_ID或在 app.json/app.config.ts 的 extra.eas.projectId 写入后重试)';
throw new Error(`获取 Expo Push Token 失败:${msg}${hint}`);
}
}
export async function registerPushToken(args: { pushToken: string }): Promise<void> {
const clientUserId = await getOrCreateClientUserId();
const headers: Record<string, string> = {
'Accept-Language': buildAcceptLanguage(),
};
const deviceMeta: PushDeviceMeta = {
os_version: String(Platform.Version ?? ''),
app_version: Constants.expoConfig?.version,
locale: pickLocale(),
timezone: pickTimezone(),
};
const body: PushRegisterRequest = {
client_user_id: clientUserId,
platform: pickPlatform(),
push_token: args.pushToken,
app_id: pickAppId(),
env: toPushEnv(APP_ENV),
device_meta: deviceMeta,
};
await httpJson<void>({
path: '/v1/push/register',
method: 'POST',
headers,
body,
timeoutMs: 10_000,
debugLabel: 'PushRegister',
});
}
export async function setPushPreferences(args: { enabled: boolean; timesPerDay: number }): Promise<PushPreferencesResponse> {
const clientUserId = await getOrCreateClientUserId();
const tz = pickTimezone();
const locale = pickLocale();
const scoringProfile = await getUserProfileScoring().catch(() => null);
const headers: Record<string, string> = {
'Accept-Language': buildAcceptLanguage(),
};
const body: PushPreferencesRequest = {
client_user_id: clientUserId,
enabled: Boolean(args.enabled) && args.timesPerDay > 0,
times_per_day: Math.min(5, Math.max(0, Math.round(args.timesPerDay))),
timezone: tz,
locale,
user_profile: scoringProfile ?? undefined,
};
return await httpJson<PushPreferencesResponse>({
path: '/v1/push/preferences',
method: 'PUT',
headers,
body,
timeoutMs: 10_000,
debugLabel: 'PushPreferences',
});
}
export async function getPushPreferences(): Promise<PushPreferencesResponse> {
const clientUserId = await getOrCreateClientUserId();
const headers: Record<string, string> = {
'Accept-Language': buildAcceptLanguage(),
};
const qs = `client_user_id=${encodeURIComponent(clientUserId)}`;
return await httpJson<PushPreferencesResponse>({
path: `/v1/push/preferences?${qs}`,
method: 'GET',
headers,
timeoutMs: 8_000,
debugLabel: 'PushPreferencesGet',
});
}
// 预留:未来推送文案个性化需要上报用户画像时使用(本期先不强制依赖)
export async function setPushProfileHint(_profile: UserProfileScoring | null): Promise<void> {
// 本期不实现:后端通过现有推荐模块自行拉取/计算 push 文案
}
export async function syncPushPreferencesFromLocal(): Promise<void> {
const s = await getDailyReminderSettings();
await setPushPreferences({ enabled: s.pushEnabled, timesPerDay: s.timesPerDay });
}

View File

@@ -0,0 +1,80 @@
import i18n from 'i18next';
import type { UserProfileV1_2 } from '../features/userProfileScoring';
import { toBackendLocaleFromLanguageTag } from '../i18n/locale';
import { httpJson } from '../utils/http';
export type RecommendedItem = {
content_id: number;
text: string;
final_score: number;
fallback_level_final: number;
explanations?: Record<string, unknown> | null;
};
export type RecoMeta = Record<string, unknown>;
export type RecoEngineResult = {
items: RecommendedItem[];
meta: RecoMeta;
};
export type RecoRequest = {
k?: number;
user_profile: UserProfileV1_2;
already_recommended_ids?: Array<string | number>;
touched_or_viewed_ids?: Array<string | number>;
now?: string; // ISO8601可选
};
export async function fetchRecoFeed(req: RecoRequest): Promise<RecoEngineResult> {
const acceptLanguage = toBackendLocaleFromLanguageTag(i18n.language);
const headers: Record<string, string> = {
// 让后端做 locale 选择(目前后端只区分 en/tc
'Accept-Language': acceptLanguage,
};
const bodyObj = {
k: req.k,
user_profile: req.user_profile,
already_recommended_ids: req.already_recommended_ids ?? [],
touched_or_viewed_ids: req.touched_or_viewed_ids ?? [],
now: req.now,
};
return await httpJson<RecoEngineResult>({
path: '/v1/reco/feed',
method: 'POST',
headers,
body: bodyObj,
timeoutMs: 12_000,
debugLabel: 'Feed API',
});
}
export async function fetchRecoWidget(req: RecoRequest): Promise<RecoEngineResult> {
const acceptLanguage = toBackendLocaleFromLanguageTag(i18n.language);
const headers: Record<string, string> = {
// 让后端做 locale 选择(目前后端只区分 en/tc
'Accept-Language': acceptLanguage,
};
const bodyObj = {
k: req.k ?? 1,
user_profile: req.user_profile,
already_recommended_ids: req.already_recommended_ids ?? [],
touched_or_viewed_ids: req.touched_or_viewed_ids ?? [],
now: req.now,
};
return await httpJson<RecoEngineResult>({
path: '/v1/reco/widget',
method: 'POST',
headers,
body: bodyObj,
timeoutMs: 12_000,
debugLabel: 'Widget API',
});
}

View File

@@ -1,30 +1,105 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import * as Crypto from 'expo-crypto';
import type { UserProfileV1_2_Extended } from '@/src/features/userProfileScoring';
/**
* 本地存储 key 统一管理,避免 UI 里散落硬编码
*/
const KEY_CLIENT_USER_ID = 'client.userId';
const KEY_ONBOARDING_COMPLETED = 'onboarding.completed';
const KEY_PUSH_PROMPT_STATE = 'push.promptState';
const KEY_CONTENT_REACTIONS = 'content.reactions';
const KEY_FAVORITES_ITEMS = 'favorites.items';
const KEY_CONSENT_ACCEPTED = 'consent.accepted';
const KEY_USER_PROFILE = 'user.profile';
const KEY_USER_PROFILE_SCORING = 'user.profileScoring';
const KEY_RECO_FEED_CACHE = 'reco.feedCache';
const KEY_RECO_FEED_HISTORY = 'reco.feedHistory';
const KEY_UI_THEME_MODE = 'ui.theme.mode';
const KEY_UI_THEME_SUIXIN_STATE = 'ui.theme.suixin.state';
const KEY_DAILY_REMINDER_SETTINGS = 'dailyReminder.settings';
export type PushPromptState = 'enabled' | 'skipped' | 'unknown';
export type Reaction = 'like' | 'dislike';
export type ReactionsMap = Record<string, Reaction>;
export type ThemeMode = 'scenery' | 'color';
export type ThemeMode = 'scenery' | 'color' | 'suixin';
export type UserProfile = {
name?: string;
intents?: string[];
};
export type SuixinBaseThemeId =
| 'neutral'
| 'emotional_support'
| 'parenting_pressure'
| 'self_worth'
| 'anxiety_relief'
| 'rest_balance';
export type SuixinThemeStateV1 = {
schema_version: 1;
saved_at: string; // ISO8601
/**
* 冷启动会话标记(进程级)。
* 用于确保:仅在冷启动时重置 base theme/seed。
*/
boot_id: string;
base_theme_id: SuixinBaseThemeId;
seed: string;
step_index: number;
last_color: string; // "#RRGGBB"
};
/**
* 用户画像(问卷打分输出)
* 说明:用于推荐/Push/Widget 统一复用;结构以 `src/features/userProfileScoring` 输出为准。
*/
export type UserProfileScoring = UserProfileV1_2_Extended;
export type DailyReminderSettings = {
/**
* 每天推送次数:
* - 0关闭
* - 15每天推送 15 次
*/
timesPerDay: number;
/**
* 仅用于 UI 展示与交互(开关)。
* 后端偏好建议使用enabled = pushEnabled && timesPerDay > 0
*/
pushEnabled: boolean;
};
export type RecoFeedCacheItem = {
content_id: number;
text: string;
};
export type RecoFeedCache = {
saved_at: string; // ISO8601
/**
* 缓存文案的语言(后端目前只区分 en / tc
* - en: English
* - tc: 繁体中文
*/
lang?: 'en' | 'tc';
items: RecoFeedCacheItem[];
meta?: Record<string, unknown>;
};
/**
* Feed 链路可观测输入(用于下一次请求携带给后端)
*
* - already_recommended_ids本设备已下发过的内容避免重复下发
* - touched_or_viewed_ids本设备用户已看过/划过的内容(用于频控/去重/降重复)
*
* 说明:后端不需要“实时知道”,只要在下一次拉取时带上即可。
*/
export type RecoFeedHistory = {
updated_at: string; // ISO8601
already_recommended_ids: number[];
touched_or_viewed_ids: number[];
};
async function getJson<T>(key: string, fallback: T): Promise<T> {
const raw = await AsyncStorage.getItem(key);
@@ -40,6 +115,49 @@ async function setJson<T>(key: string, value: T): Promise<void> {
await AsyncStorage.setItem(key, JSON.stringify(value));
}
function looksLikeUuid(v: string): boolean {
// 宽松校验8-4-4-4-12不强依赖大小写
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v);
}
function uuidV4FromBytes(bytes: Uint8Array): string {
// RFC 4122 v4设置 version 与 variant
const b = new Uint8Array(bytes);
b[6] = (b[6] & 0x0f) | 0x40;
b[8] = (b[8] & 0x3f) | 0x80;
const hex = Array.from(b)
.map((x) => x.toString(16).padStart(2, '0'))
.join('');
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
async function generateUuidV4(): Promise<string> {
// 1) 优先使用运行时提供的 randomUUID如可用
const maybeCrypto = (globalThis as unknown as { crypto?: { randomUUID?: () => string } }).crypto;
if (maybeCrypto?.randomUUID) return maybeCrypto.randomUUID();
// 2) 使用 expo-crypto 生成安全随机数(推荐)
const bytes = await Crypto.getRandomBytesAsync(16);
return uuidV4FromBytes(bytes);
}
/**
* 获取或生成客户端用户标识UUID
*
* 说明:
* - `client_user_id` 用于与后端关联 Push Token 与用户推送偏好
* - 它是“安装实例标识”,不等同真实用户账号
*/
export async function getOrCreateClientUserId(): Promise<string> {
const raw = await AsyncStorage.getItem(KEY_CLIENT_USER_ID);
if (raw && looksLikeUuid(raw)) return raw;
const next = await generateUuidV4();
await AsyncStorage.setItem(KEY_CLIENT_USER_ID, next);
return next;
}
export async function getOnboardingCompleted(): Promise<boolean> {
const raw = await AsyncStorage.getItem(KEY_ONBOARDING_COMPLETED);
return raw === 'true';
@@ -72,6 +190,10 @@ export async function setReaction(contentId: string, reaction: Reaction): Promis
export type FavoriteItem = {
favId: string; // 唯一标识,支持重复点赞同一文案
id: string;
/**
* 收藏时的文案快照(强烈建议写入,避免后续 cache 覆盖导致无法还原文案)
*/
text?: string;
date: string;
themeMode: ThemeMode;
background: string; // 颜色值或图片路径
@@ -85,7 +207,7 @@ export async function addFavorite(item: FavoriteItem): Promise<void> {
const list = await getFavorites();
// 允许重复点赞,不再根据 id 去重
const newList = [item, ...list];
console.log('Adding to favorites, new list size:', newList.length);
console.log('Adding to favorites:', JSON.stringify(item));
await setJson(KEY_FAVORITES_ITEMS, newList);
}
@@ -106,7 +228,7 @@ export async function setConsentAccepted(accepted: boolean): Promise<void> {
export async function getThemeMode(): Promise<ThemeMode> {
const raw = await AsyncStorage.getItem(KEY_UI_THEME_MODE);
if (raw === 'scenery' || raw === 'color') return raw;
if (raw === 'scenery' || raw === 'color' || raw === 'suixin') return raw;
return 'scenery';
}
@@ -114,6 +236,28 @@ export async function setThemeMode(mode: ThemeMode): Promise<void> {
await AsyncStorage.setItem(KEY_UI_THEME_MODE, mode);
}
export async function getSuixinThemeState(): Promise<SuixinThemeStateV1 | null> {
const raw = await AsyncStorage.getItem(KEY_UI_THEME_SUIXIN_STATE);
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as Partial<SuixinThemeStateV1>;
if (parsed.schema_version !== 1) return null;
if (typeof parsed.boot_id !== 'string') return null;
if (typeof parsed.base_theme_id !== 'string') return null;
if (typeof parsed.seed !== 'string') return null;
if (typeof parsed.step_index !== 'number') return null;
if (typeof parsed.last_color !== 'string') return null;
if (typeof parsed.saved_at !== 'string') return null;
return parsed as SuixinThemeStateV1;
} catch {
return null;
}
}
export async function setSuixinThemeState(state: SuixinThemeStateV1): Promise<void> {
await setJson(KEY_UI_THEME_SUIXIN_STATE, state);
}
export async function getUserProfile(): Promise<UserProfile> {
return await getJson<UserProfile>(KEY_USER_PROFILE, {});
}
@@ -123,6 +267,103 @@ export async function setUserProfile(profile: UserProfile): Promise<void> {
await setJson(KEY_USER_PROFILE, { ...current, ...profile });
}
export async function getUserProfileScoring(): Promise<UserProfileScoring | null> {
const raw = await AsyncStorage.getItem(KEY_USER_PROFILE_SCORING);
if (!raw) return null;
try {
return JSON.parse(raw) as UserProfileScoring;
} catch {
return null;
}
}
export async function setUserProfileScoring(profile: UserProfileScoring): Promise<void> {
await setJson(KEY_USER_PROFILE_SCORING, profile);
}
export async function getRecoFeedCache(): Promise<RecoFeedCache | null> {
const raw = await AsyncStorage.getItem(KEY_RECO_FEED_CACHE);
if (!raw) return null;
try {
return JSON.parse(raw) as RecoFeedCache;
} catch {
return null;
}
}
export async function setRecoFeedCache(cache: RecoFeedCache): Promise<void> {
await setJson(KEY_RECO_FEED_CACHE, cache);
}
export async function getRecoFeedHistory(): Promise<RecoFeedHistory> {
const raw = await AsyncStorage.getItem(KEY_RECO_FEED_HISTORY);
if (!raw) {
return {
updated_at: new Date().toISOString(),
already_recommended_ids: [],
touched_or_viewed_ids: [],
};
}
try {
const parsed = JSON.parse(raw) as Partial<RecoFeedHistory>;
return {
updated_at: typeof parsed.updated_at === 'string' ? parsed.updated_at : new Date().toISOString(),
already_recommended_ids: Array.isArray(parsed.already_recommended_ids)
? parsed.already_recommended_ids.filter((x) => Number.isFinite(x)).map((x) => Number(x))
: [],
touched_or_viewed_ids: Array.isArray(parsed.touched_or_viewed_ids)
? parsed.touched_or_viewed_ids.filter((x) => Number.isFinite(x)).map((x) => Number(x))
: [],
};
} catch {
return {
updated_at: new Date().toISOString(),
already_recommended_ids: [],
touched_or_viewed_ids: [],
};
}
}
export async function setRecoFeedHistory(history: RecoFeedHistory): Promise<void> {
await setJson(KEY_RECO_FEED_HISTORY, history);
}
function uniqKeepLatest(list: number[], max: number): number[] {
const seen = new Set<number>();
const out: number[] = [];
for (let i = list.length - 1; i >= 0; i -= 1) {
const v = list[i];
if (!Number.isFinite(v)) continue;
if (seen.has(v)) continue;
seen.add(v);
out.push(v);
if (out.length >= max) break;
}
return out.reverse();
}
export async function recordRecoFeedServed(contentIds: number[]): Promise<void> {
if (!contentIds?.length) return;
const h = await getRecoFeedHistory();
const next = {
...h,
updated_at: new Date().toISOString(),
already_recommended_ids: uniqKeepLatest([...h.already_recommended_ids, ...contentIds], 500),
};
await setRecoFeedHistory(next);
}
export async function recordRecoFeedTouched(contentId: number): Promise<void> {
if (!Number.isFinite(contentId)) return;
const h = await getRecoFeedHistory();
const next = {
...h,
updated_at: new Date().toISOString(),
touched_or_viewed_ids: uniqKeepLatest([...h.touched_or_viewed_ids, contentId], 500),
};
await setRecoFeedHistory(next);
}
export async function getDailyReminderSettings(): Promise<DailyReminderSettings> {
const s = await getJson<DailyReminderSettings>(KEY_DAILY_REMINDER_SETTINGS, {
timesPerDay: 3,
@@ -130,13 +371,15 @@ export async function getDailyReminderSettings(): Promise<DailyReminderSettings>
});
const timesPerDay = Number.isFinite(s.timesPerDay) ? s.timesPerDay : 3;
return {
timesPerDay: Math.min(10, Math.max(1, Math.round(timesPerDay))),
// 需求050 表示关闭)
timesPerDay: Math.min(5, Math.max(0, Math.round(timesPerDay))),
pushEnabled: Boolean(s.pushEnabled),
};
}
export async function setDailyReminderSettings(settings: DailyReminderSettings): Promise<void> {
const timesPerDay = Math.min(10, Math.max(1, Math.round(settings.timesPerDay)));
// 需求050 表示关闭)
const timesPerDay = Math.min(5, Math.max(0, Math.round(settings.timesPerDay)));
await setJson(KEY_DAILY_REMINDER_SETTINGS, {
timesPerDay,
pushEnabled: Boolean(settings.pushEnabled),

View File

@@ -0,0 +1,16 @@
/**
* 冷启动会话标记(进程级、仅内存)。
*
* 目的:
* - 在“随心”主题中实现:仅在冷启动时重置 base theme/seed
* - 不落盘,避免污染 AsyncStorage
*/
let bootId: string | null = null;
export function getBootId(): string {
if (bootId) return bootId;
// 说明:无需加密强随机;只要在一次进程周期内稳定、不同冷启动尽量不同即可
bootId = `${Date.now()}_${Math.random().toString(16).slice(2)}`;
return bootId;
}

14
client/src/utils/date.ts Normal file
View File

@@ -0,0 +1,14 @@
/**
* 生成本地日维度 key用户时区YYYY-MM-DD
*
* 说明:
* - 使用 JS Date 的本地时间字段getFullYear/getMonth/getDate
* - 不依赖额外库,避免时区坑扩大化
*/
export function getLocalDayKey(date: Date = new Date()): string {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}

163
client/src/utils/http.ts Normal file
View File

@@ -0,0 +1,163 @@
import { API_BASE_URL } from '../constants/env';
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
export class HttpError extends Error {
readonly name = 'HttpError';
readonly url: string;
readonly status: number;
readonly statusText: string;
readonly responseText?: string;
constructor(args: { message: string; url: string; status: number; statusText: string; responseText?: string }) {
super(args.message);
this.url = args.url;
this.status = args.status;
this.statusText = args.statusText;
this.responseText = args.responseText;
}
}
export type HttpJsonOptions = {
/**
* 支持传入完整 URL 或以 / 开头的 path会自动拼到 API_BASE_URL
*/
path: string;
method?: HttpMethod;
headers?: Record<string, string>;
/**
* 将对象自动 JSON.stringifyGET 请求请不要传 body
*/
body?: unknown;
/**
* 超时(毫秒),默认 10 秒
*/
timeoutMs?: number;
/**
* 外部 signal例如上层取消请求
*/
signal?: AbortSignal;
/**
* 仅开发环境日志:便于联调排查
*/
debugLabel?: string;
};
function isAbsoluteUrl(path: string): boolean {
return /^https?:\/\//i.test(path);
}
function joinUrl(baseUrl: string, path: string): string {
const base = (baseUrl || '').replace(/\/+$/, '');
const p = (path || '').trim();
if (!p) return base;
if (p.startsWith('/')) return `${base}${p}`;
return `${base}/${p}`;
}
function createTimeoutAbortSignal(timeoutMs: number, external?: AbortSignal): AbortController {
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), timeoutMs);
// 避免 Node/Vitest 下悬挂定时器影响退出
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(t as any)?.unref?.();
if (external) {
if (external.aborted) {
controller.abort();
} else {
external.addEventListener(
'abort',
() => {
controller.abort();
},
{ once: true },
);
}
}
return controller;
}
function isDev(): boolean {
return typeof __DEV__ !== 'undefined' && __DEV__;
}
export async function httpJson<T>(opts: HttpJsonOptions): Promise<T> {
const method = opts.method ?? 'GET';
const timeoutMs = opts.timeoutMs ?? 10_000;
const url = isAbsoluteUrl(opts.path) ? opts.path : joinUrl(API_BASE_URL, opts.path);
const headers: Record<string, string> = {
...(opts.headers ?? {}),
};
const hasBody = typeof opts.body !== 'undefined' && opts.body !== null;
const body = hasBody ? JSON.stringify(opts.body) : undefined;
// 仅在有 body 时默认补齐 Content-Type避免 GET 请求无意义携带
if (hasBody && !headers['Content-Type']) {
headers['Content-Type'] = 'application/json';
}
if (isDev() && opts.debugLabel) {
console.log(`[${opts.debugLabel}] 请求地址:`, url);
console.log(`[${opts.debugLabel}] 请求方法:`, method);
console.log(`[${opts.debugLabel}] 请求头:`, headers);
if (hasBody) console.log(`[${opts.debugLabel}] 请求体:`, opts.body);
}
const controller = createTimeoutAbortSignal(timeoutMs, opts.signal);
let res: Response;
try {
res = await fetch(url, {
method,
headers,
body,
signal: controller.signal,
});
} catch (e) {
// RN 下 AbortError 文案不完全一致,这里统一对外语义
const msg = e instanceof Error ? e.message : String(e);
// 带上 URL便于在 TestFlight/Release 排查实际打到哪个地址(例如误打到 localhost
throw new Error(`网络请求失败:${msg}${url}`);
}
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new HttpError({
message: `HTTP 请求失败:${res.status} ${res.statusText} ${text}`.trim(),
url,
status: res.status,
statusText: res.statusText,
responseText: text,
});
}
// 204/205 无内容时不要强行 parse
if (res.status === 204 || res.status === 205) {
return undefined as unknown as T;
}
// 某些后端/网关会返回 200 但 body 为空;此时 res.json() 会抛错,导致客户端误判“失败”。
// 这里改为:先读 text空则返回 undefined非空再 parse JSON。
const text = await res.text().catch(() => '');
if (!text || !String(text).trim()) {
return undefined as unknown as T;
}
try {
return JSON.parse(text) as T;
} catch {
throw new HttpError({
message: `HTTP 响应不是合法 JSON${res.status} ${res.statusText} ${text}`.trim(),
url,
status: res.status,
statusText: res.statusText,
responseText: text,
});
}
}

View File

@@ -0,0 +1,89 @@
#!/usr/bin/env node
/**
* 把 SSH 私钥转换为“单行 base64”用于放入 Gitea Secrets例如 DEV_SSH_KEY_B64
*
* 用法:
* 1) 从文件读取:
* node scripts/ssh-key-to-b64.mjs ~/.ssh/deploy_key
*
* 2) 从 stdin 读取(直接粘贴私钥内容,结束后按 Ctrl+D
* node scripts/ssh-key-to-b64.mjs -
*
* 3) 输出同时复制到剪贴板(仅 macOS需系统自带 pbcopy
* node scripts/ssh-key-to-b64.mjs ~/.ssh/deploy_key --clipboard
*
* 注意:
* - 请不要把输出写入仓库或提交到 git。
* - 工作流会优先使用 *_SSH_KEY_B64更稳避免多行换行丢失
*/
import fs from 'node:fs';
import { spawnSync } from 'node:child_process';
function printHelp() {
console.log(`用法:
node scripts/ssh-key-to-b64.mjs <私钥文件路径> [--clipboard] [--stdout]
node scripts/ssh-key-to-b64.mjs - [--clipboard] [--stdout]
示例:
node scripts/ssh-key-to-b64.mjs ~/.ssh/deploy_key
node scripts/ssh-key-to-b64.mjs - --clipboard
node scripts/ssh-key-to-b64.mjs ~/.ssh/deploy_key --clipboard --stdout
`);
}
const args = process.argv.slice(2);
const clipboard = args.includes('--clipboard') || args.includes('-c');
const forceStdout = args.includes('--stdout');
const help = args.includes('--help') || args.includes('-h');
const input = args.find((a) => !a.startsWith('-'));
if (help || !input) {
printHelp();
process.exit(help ? 0 : 1);
}
function readAllStdin() {
return fs.readFileSync(0);
}
let buf;
try {
if (input === '-') {
buf = readAllStdin();
} else {
buf = fs.readFileSync(input);
}
} catch (e) {
console.error(`读取失败:${String(e?.message || e)}`);
process.exit(1);
}
// 重要:按“原始字节”做 base64避免任何编码/换行处理导致私钥内容变化
// 这样工作流解码后能 100% 还原原文件内容
const asText = buf.toString('utf8');
if (!asText.includes('BEGIN') || !asText.includes('PRIVATE KEY')) {
console.error('提示:输入内容看起来不像 SSH 私钥(未检测到 PRIVATE KEY 头部)。仍会继续转换,但请确认输入正确。');
}
const b64 = buf.toString('base64');
if (clipboard) {
if (process.platform !== 'darwin') {
console.error('当前不是 macOS无法使用 --clipboard需要 pbcopy。将仅输出到 stdout。');
} else {
const r = spawnSync('pbcopy', [], { input: b64, encoding: 'utf8' });
if (r.status !== 0) {
console.error(`复制到剪贴板失败pbcopy 退出码=${r.status}`);
} else {
console.error('已复制到剪贴板:请粘贴到 Gitea Secrets例如 DEV_SSH_KEY_B64。');
}
}
}
// 默认:如果使用了 --clipboard就不再把超长 base64 打到终端(避免影响后续命令输出)。
// 如需同时打印,请加 --stdout。
if (!clipboard || forceStdout || process.platform !== 'darwin') {
process.stdout.write(b64 + '\n');
}

20
server/.dockerignore Normal file
View File

@@ -0,0 +1,20 @@
.venv
__pycache__/
*.pyc
*.pyo
*.pyd
.pytest_cache/
.mypy_cache/
.ruff_cache/
# 本地/测试数据
.test.db
*.db
# 测试与开发脚本(按需移除)
tests/
.env*
# Git 元数据
.git/
.gitignore

Some files were not shown because too many files have changed in this diff Show More