Compare commits
42 Commits
3587a24115
...
v1.0.12
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1c2c6197d | ||
| d742b398ef | |||
|
|
d525c6b939 | ||
|
|
c37bb2e2a5 | ||
|
|
4c450db9b2 | ||
| 8c02c5b9a0 | |||
|
|
90cbde1bac | ||
|
|
bb7a4c7361 | ||
|
|
17d19f9172 | ||
|
|
b35b71470d | ||
|
|
ccdc6350a1 | ||
|
|
7fea799087 | ||
|
|
e6b64b39f2 | ||
|
|
a457186f46 | ||
|
|
2d5b8b094f | ||
|
|
ef7c4e774d | ||
|
|
cc36301638 | ||
|
|
5dd632d4d3 | ||
|
|
4d70f16b69 | ||
|
|
2ddee6b8f8 | ||
|
|
aa530b0ce3 | ||
| cdd0ac32de | |||
|
|
d98ec76dc0 | ||
| 3c35e14c3d | |||
|
|
c0deea9318 | ||
| 915e995ab7 | |||
|
|
0e42e6f2a9 | ||
| 39e4bcab6c | |||
|
|
69a1046ff4 | ||
| ceaf459d97 | |||
|
|
d9a5dbafd6 | ||
|
|
996999453a | ||
|
|
3f91e734fa | ||
|
|
86e4853709 | ||
|
|
2adf2475fa | ||
| 9dbba04408 | |||
|
|
64b8352ad3 | ||
|
|
4b739dd194 | ||
|
|
240cdda68f | ||
|
|
ce48e54c03 | ||
|
|
d045237952 | ||
|
|
228fd7fd84 |
@@ -1,5 +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
|
||||
整个task.md执行完毕后需要在对应的overview.md标记,并且说明变更的文件名
|
||||
|
||||
5
.gitea/workflows/README.md
Normal 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
|
||||
104
.gitea/workflows/server-build.yml
Normal 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 tag(vX.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"
|
||||
431
.gitea/workflows/server-deploy.yml
Normal 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 }}
|
||||
|
||||
# SSH:dev 用 secrets,pro 用 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
@@ -4,6 +4,10 @@
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# Python(运行产物)
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
# Node / JS
|
||||
node_modules/
|
||||
npm-debug.*
|
||||
|
||||
@@ -13,7 +13,9 @@ git pull
|
||||
|
||||
# 创建自己的分支
|
||||
git checkout -b 姓名拼写
|
||||
# 生产密钥
|
||||
|
||||
ssh-keygen -t rsa -b 4096 -m PEM -N '' -f deploy_key_rsa
|
||||
# 目录结构
|
||||
|
||||
/mindfulness
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "Hey Mama",
|
||||
"slug": "hey-mama",
|
||||
"slug": "client",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "heymama",
|
||||
"scheme": "client",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"newArchEnabled": true,
|
||||
"splash": {
|
||||
@@ -15,7 +15,7 @@
|
||||
},
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "com.heymama.app"
|
||||
"bundleIdentifier": "com.damer.mindfulness"
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useLayoutEffect, useMemo, useState, useCallback, useRef } from 'react';
|
||||
import { StyleSheet, View, Dimensions, Text, Pressable, PanResponder, Animated as RNAnimated } from 'react-native';
|
||||
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,
|
||||
@@ -14,19 +15,18 @@ import Animated, {
|
||||
import { MOCK_CONTENT } from '@/src/constants/mockContent';
|
||||
import {
|
||||
addFavorite,
|
||||
getRecoFeedCache,
|
||||
getRecoFeedHistory,
|
||||
getThemeMode,
|
||||
getUserProfile,
|
||||
getUserProfileScoring,
|
||||
recordRecoFeedServed,
|
||||
recordRecoFeedTouched,
|
||||
setRecoFeedCache,
|
||||
setReaction,
|
||||
setThemeMode,
|
||||
type RecoFeedCacheItem,
|
||||
getRecoFeedCache,
|
||||
setRecoFeedCache,
|
||||
getUserProfileScoring,
|
||||
getRecoFeedHistory,
|
||||
recordRecoFeedServed,
|
||||
type ThemeMode,
|
||||
} from '@/src/storage/appStorage';
|
||||
|
||||
import { fetchRecoFeed } from '@/src/services/recoApi';
|
||||
|
||||
import ProfileModal from '@/components/home/ProfileModal';
|
||||
@@ -39,9 +39,47 @@ import LikeIcon from '@/assets/images/icon/like_icon.svg';
|
||||
|
||||
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
|
||||
|
||||
// 预定义风景图列表
|
||||
const NATURE_IMAGES = [
|
||||
require('@/assets/theme/nature/1.png'),
|
||||
require('@/assets/theme/nature/2.png'),
|
||||
require('@/assets/theme/nature/3.png'),
|
||||
require('@/assets/theme/nature/4.png'),
|
||||
require('@/assets/theme/nature/5.png'),
|
||||
require('@/assets/theme/nature/6.png'),
|
||||
require('@/assets/theme/nature/7.png'),
|
||||
require('@/assets/theme/nature/8.png'),
|
||||
require('@/assets/theme/nature/9.png'),
|
||||
require('@/assets/theme/nature/10.png'),
|
||||
require('@/assets/theme/nature/11.png'),
|
||||
require('@/assets/theme/nature/12.png'),
|
||||
require('@/assets/theme/nature/13.png'),
|
||||
require('@/assets/theme/nature/14.png'),
|
||||
require('@/assets/theme/nature/15.png'),
|
||||
require('@/assets/theme/nature/17.png'),
|
||||
require('@/assets/theme/nature/18.png'),
|
||||
require('@/assets/theme/nature/19.png'),
|
||||
require('@/assets/theme/nature/20.png'),
|
||||
require('@/assets/theme/nature/22.png'),
|
||||
];
|
||||
|
||||
// 预定义颜色列表
|
||||
const THEME_COLORS = [
|
||||
'#F7D9BF',
|
||||
'#CBF2D8',
|
||||
'#F5CDDE',
|
||||
'#F2ECCB',
|
||||
'#E2CBF2',
|
||||
'#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' = i18n.language?.toLowerCase().startsWith('zh') ? 'tc' : 'en';
|
||||
const insets = useSafeAreaInsets();
|
||||
const [index, setIndex] = useState(0);
|
||||
const [themeMode, setThemeModeState] = useState<ThemeMode>('scenery');
|
||||
const [themeOpen, setThemeOpen] = useState(false);
|
||||
@@ -49,107 +87,129 @@ export default function HomeScreen() {
|
||||
const [profileName, setProfileName] = useState<string | undefined>(undefined);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [likeFilled, setLikeFilled] = useState(false);
|
||||
const [feedItems, setFeedItems] = useState<Array<{ content_id: number; text: string }>>([]);
|
||||
const [feedItems, setFeedItems] = useState<FeedItem[]>([]);
|
||||
const [isFetching, setIsFetching] = useState(false);
|
||||
|
||||
const currentList = feedItems.length > 0 ? feedItems : MOCK_CONTENT;
|
||||
const item = useMemo(() => currentList[index % currentList.length], [currentList, index]);
|
||||
const currentContentId = typeof (item as any)?.content_id === 'number' ? Number((item as any).content_id) : null;
|
||||
// 解决语言切换时重复触发拉取/清空导致“文案不停跳动”的问题:
|
||||
// 用 ref 持有最新状态,避免 useCallback 依赖 feedItems/isFetching 造成函数 identity 变化 → effect 重复执行
|
||||
const feedItemsRef = useRef<FeedItem[]>([]);
|
||||
const isFetchingRef = useRef(false);
|
||||
useEffect(() => {
|
||||
feedItemsRef.current = feedItems;
|
||||
}, [feedItems]);
|
||||
useEffect(() => {
|
||||
isFetchingRef.current = isFetching;
|
||||
}, [isFetching]);
|
||||
|
||||
// 动画相关 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);
|
||||
|
||||
// 语言切换时:旧语言缓存不复用,触发重新拉取
|
||||
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])
|
||||
);
|
||||
|
||||
// 首次进入:先读缓存,再拉后端 feed(失败则保持 mock/缓存)
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const cache = await getRecoFeedCache();
|
||||
if (!cancelled && cache?.items?.length) {
|
||||
setFeedItems(cache.items.map((x: RecoFeedCacheItem) => ({ content_id: x.content_id, text: x.text })));
|
||||
}
|
||||
const backgroundColor = useMemo(() => {
|
||||
if (themeMode === 'color') {
|
||||
const colorIndex = Math.floor(index / 10) % THEME_COLORS.length;
|
||||
return THEME_COLORS[colorIndex];
|
||||
}
|
||||
return '#F4D6C2'; // 风景模式下的默认底色(图片加载前显示)
|
||||
}, [themeMode, index]);
|
||||
|
||||
const scoring = await getUserProfileScoring();
|
||||
if (!scoring) return;
|
||||
// 计算当前应该显示的风景图索引(滑动 10 次切换一张)
|
||||
const natureImageIndex = useMemo(() => {
|
||||
return Math.floor(index / 10) % NATURE_IMAGES.length;
|
||||
}, [index]);
|
||||
|
||||
try {
|
||||
const hist = await getRecoFeedHistory();
|
||||
const out = await fetchRecoFeed({
|
||||
k: 30,
|
||||
user_profile: {
|
||||
profile_version: scoring.profile_version,
|
||||
profile_source: scoring.profile_source,
|
||||
profile_generated_at: scoring.profile_generated_at,
|
||||
profile_confidence: scoring.profile_confidence,
|
||||
profile_answered: scoring.profile_answered,
|
||||
stage: scoring.stage,
|
||||
emotion_score: scoring.emotion_score,
|
||||
context: scoring.context,
|
||||
need: scoring.need,
|
||||
},
|
||||
already_recommended_ids: hist.already_recommended_ids,
|
||||
touched_or_viewed_ids: hist.touched_or_viewed_ids,
|
||||
});
|
||||
|
||||
if (!cancelled && out.items?.length) {
|
||||
setFeedItems(out.items.map((x) => ({ content_id: x.content_id, text: x.text })));
|
||||
await setRecoFeedCache({
|
||||
saved_at: new Date().toISOString(),
|
||||
items: out.items.map((x) => ({ content_id: x.content_id, text: x.text })),
|
||||
meta: out.meta as Record<string, unknown>,
|
||||
});
|
||||
await recordRecoFeedServed(out.items.map((x) => x.content_id));
|
||||
}
|
||||
} catch {
|
||||
// 忽略:保持缓存/本地 mock
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const backgroundColor = themeMode === 'color' ? '#F3D0E1' : '#F4D6C2';
|
||||
|
||||
useLayoutEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerShadowVisible: false,
|
||||
headerStyle: { backgroundColor },
|
||||
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, navigation, t]);
|
||||
const currentNatureImage = NATURE_IMAGES[natureImageIndex];
|
||||
|
||||
const textAnimatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateY: translateY.value }],
|
||||
@@ -165,11 +225,6 @@ export default function HomeScreen() {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
|
||||
// 记录“看过/划过”的内容 id(用于下一次向后端请求时去重/频控)
|
||||
if (typeof currentContentId === 'number') {
|
||||
void recordRecoFeedTouched(currentContentId);
|
||||
}
|
||||
|
||||
// 1. 当前文案向上移动并消失
|
||||
translateY.value = withTiming(-40, { duration: 300, easing: Easing.out(Easing.quad) });
|
||||
opacity.value = withTiming(0, { duration: 300 }, (finished) => {
|
||||
@@ -178,6 +233,11 @@ export default function HomeScreen() {
|
||||
runOnJS(setIndex)(index + 1);
|
||||
runOnJS(setLikeFilled)(false);
|
||||
|
||||
// 检查是否需要拉取新文案(当接近当前列表末尾时,例如还剩 5 条)
|
||||
if (index + 5 >= currentFeed.length && !isFetching) {
|
||||
runOnJS(fetchNewFeed)();
|
||||
}
|
||||
|
||||
// 3. 准备下一条文案:先瞬移到下方 40pt
|
||||
translateY.value = 40;
|
||||
|
||||
@@ -190,7 +250,7 @@ export default function HomeScreen() {
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [busy, currentContentId, index, translateY, opacity]);
|
||||
}, [busy, index, currentFeed.length, isFetching, fetchNewFeed, translateY, opacity]);
|
||||
|
||||
const lastTapRef = useRef<number>(0);
|
||||
|
||||
@@ -240,19 +300,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({
|
||||
id: typeof currentContentId === 'number' ? String(currentContentId) : (item as any).id,
|
||||
const favItem = {
|
||||
favId: String(Date.now()), // 生成唯一 ID
|
||||
id: item.id,
|
||||
text: item.text,
|
||||
date: dateStr,
|
||||
themeMode: themeMode,
|
||||
background: backgroundColor, // 目前存储的是颜色值
|
||||
});
|
||||
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)();
|
||||
}
|
||||
})
|
||||
@@ -267,8 +336,34 @@ export default function HomeScreen() {
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor }]} {...panResponder.panHandlers}>
|
||||
<Animated.View style={[styles.card, textAnimatedStyle]}>
|
||||
<Text style={styles.text}>{item.text}</Text>
|
||||
{themeMode === 'scenery' && (
|
||||
<ImageBackground
|
||||
source={currentNatureImage}
|
||||
style={StyleSheet.absoluteFill}
|
||||
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, isEnglish && styles.textEnglish, themeMode === 'scenery' && styles.sceneryText]}>
|
||||
{item.text}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
|
||||
<View style={styles.actions}>
|
||||
@@ -281,9 +376,13 @@ export default function HomeScreen() {
|
||||
style={styles.reactionInner}
|
||||
>
|
||||
{likeFilled ? (
|
||||
<LikeFilledIcon width={35} height={36} />
|
||||
<LikeFilledIcon width={35} height={36} color="#EA6969" />
|
||||
) : (
|
||||
<LikeIcon width={35} height={36} />
|
||||
<LikeIcon
|
||||
width={35}
|
||||
height={36}
|
||||
color={themeMode === 'scenery' ? '#FFFFFF' : '#5E2A28'}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
@@ -328,10 +427,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,
|
||||
@@ -342,9 +443,15 @@ const styles = StyleSheet.create({
|
||||
justifyContent: 'center',
|
||||
},
|
||||
card: {
|
||||
paddingHorizontal: 30,
|
||||
alignItems: 'center',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 30,
|
||||
zIndex: 5, // 降低层级,防止遮挡底部按钮
|
||||
},
|
||||
text: {
|
||||
fontSize: 22,
|
||||
@@ -353,6 +460,21 @@ const styles = StyleSheet.create({
|
||||
fontWeight: '700',
|
||||
textAlign: 'center',
|
||||
},
|
||||
textEnglish: {
|
||||
fontFamily: 'STIXTwoText',
|
||||
// 英文字体观感更细一点,避免过粗
|
||||
fontWeight: '600',
|
||||
},
|
||||
sceneryCard: {
|
||||
// 风景模式下稍微收窄文案宽度,增加呼吸感
|
||||
paddingHorizontal: 50,
|
||||
},
|
||||
sceneryText: {
|
||||
color: '#FFFFFF',
|
||||
textShadowColor: 'rgba(0, 0, 0, 0.5)',
|
||||
textShadowOffset: { width: 0, height: 1 },
|
||||
textShadowRadius: 4,
|
||||
},
|
||||
actions: {
|
||||
position: 'absolute',
|
||||
bottom: SCREEN_HEIGHT * 0.16,
|
||||
@@ -360,6 +482,7 @@ const styles = StyleSheet.create({
|
||||
right: 0,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
zIndex: 20, // 提升层级,确保在最顶层可点击
|
||||
},
|
||||
reactionButton: {
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
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 { buildUserProfileFromQuestionnaire, mapOnboardingSelectionsToQuestionnaireAnswers } from '@/src/features/userProfileScoring';
|
||||
import { ensureDailyWidgetRecoUpToDate, syncWidgetConfig, syncWidgetUserProfileFromScoring } from '@/src/modules/dailyWidgetReco';
|
||||
import { fetchRecoFeed } from '@/src/services/recoApi';
|
||||
import { getExpoPushTokenOrThrow, registerPushToken, setPushPreferences } from '@/src/services/pushApi';
|
||||
import {
|
||||
recordRecoFeedServed,
|
||||
setOnboardingCompleted,
|
||||
@@ -14,71 +19,44 @@ import {
|
||||
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', '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);
|
||||
@@ -87,8 +65,16 @@ export default function OnboardingScreen() {
|
||||
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 = i18n.language?.toLowerCase().startsWith('zh') ? 'tc' : 'en';
|
||||
const { items, meta } = await fetchRecoFeed({
|
||||
k: 30,
|
||||
user_profile: {
|
||||
@@ -106,6 +92,7 @@ export default function OnboardingScreen() {
|
||||
|
||||
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>,
|
||||
});
|
||||
@@ -120,10 +107,49 @@ 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) 上报推送偏好(幂等)
|
||||
await setPushPreferences({ enabled: wantsPush, timesPerDay: reminderTimes });
|
||||
|
||||
await setPushPromptState('enabled');
|
||||
} catch (e) {
|
||||
// 失败不阻塞进入首页;但这里给出更明确的文案(常见原因:模拟器/网络/后端异常)
|
||||
Alert.alert(t('push.errorTitle'), t('push.errorDesc'));
|
||||
await setPushPromptState('unknown');
|
||||
} finally {
|
||||
router.replace('/(app)/home');
|
||||
}
|
||||
}
|
||||
|
||||
const onNext = () => {
|
||||
@@ -140,22 +166,27 @@ export default function OnboardingScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
const onSkip = () => {
|
||||
const onSkip = async () => {
|
||||
// 跳过整个 Onboarding:仍生成一个“全跳过”的最小画像,保证下游可用
|
||||
const scoringProfile = buildUserProfileFromQuestionnaire({});
|
||||
void setUserProfileScoring(scoringProfile);
|
||||
await setUserProfileScoring(scoringProfile);
|
||||
|
||||
// 同步到 App Group:供 iOS Widget 使用(失败不阻塞)
|
||||
await syncWidgetConfig();
|
||||
await syncWidgetUserProfileFromScoring(scoringProfile);
|
||||
|
||||
// 标记已完成,避免下次启动再次进入 Onboarding
|
||||
void setOnboardingCompleted(true);
|
||||
|
||||
await setOnboardingCompleted(true);
|
||||
router.replace('/(app)/home');
|
||||
};
|
||||
|
||||
// 题目为单选:再次点击可取消;选择其他选项会替换为唯一选项
|
||||
// 题目为多选:点击切换选中状态
|
||||
const handleToggleSelection = (id: string) => {
|
||||
setSelections(prev => {
|
||||
const currentIds = prev[currentStep.id] || [];
|
||||
const nextIds = currentIds.includes(id) ? [] : [id];
|
||||
const nextIds = currentIds.includes(id)
|
||||
? currentIds.filter(i => i !== id)
|
||||
: [...currentIds, id];
|
||||
return { ...prev, [currentStep.id]: nextIds };
|
||||
});
|
||||
};
|
||||
@@ -167,7 +198,7 @@ export default function OnboardingScreen() {
|
||||
|
||||
return (
|
||||
<OnboardingLayout
|
||||
title={currentStep.title}
|
||||
title={currentTitle}
|
||||
currentStep={stepIndex}
|
||||
totalSteps={STEPS.length - 1}
|
||||
onSkip={onSkip}
|
||||
@@ -184,11 +215,10 @@ export default function OnboardingScreen() {
|
||||
|
||||
{currentStep.type === 'selection' && (
|
||||
<SelectionStep
|
||||
options={currentStep.options!}
|
||||
options={currentOptions}
|
||||
selectedIds={selections[currentStep.id] || []}
|
||||
onToggle={handleToggleSelection}
|
||||
onNext={onNext}
|
||||
onSkip={handleSkipStep}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -197,6 +227,11 @@ export default function OnboardingScreen() {
|
||||
value={reminderTimes}
|
||||
onChange={setReminderTimes}
|
||||
onFinish={onFinish}
|
||||
onSkip={() => {
|
||||
// 跳过每日提醒:视为 0 次(关闭)
|
||||
setReminderTimes(0);
|
||||
onFinish();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</OnboardingLayout>
|
||||
|
||||
@@ -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' }
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as WebBrowser from 'expo-web-browser';
|
||||
import { 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';
|
||||
|
||||
// 导入 SVG 组件
|
||||
import FlowersBg from '../../assets/images/index/flowers_endbg.svg';
|
||||
@@ -16,6 +17,7 @@ export default function SplashScreen() {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const [showConsent, setShowConsent] = useState(false);
|
||||
const [links, setLinks] = useState<{ privacy?: string; terms?: string }>({});
|
||||
|
||||
useEffect(() => {
|
||||
checkConsent();
|
||||
@@ -44,6 +46,25 @@ export default function SplashScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
// 拉取协议链接(由后端按语言下发;默认 EN)
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetchLegalLinks();
|
||||
if (cancelled) return;
|
||||
setLinks({ privacy: res.privacyPolicyUrl, terms: res.termsOfUseUrl });
|
||||
} catch (e) {
|
||||
// 不阻塞主流程:失败时不崩溃,链接入口可不展示
|
||||
if (__DEV__) console.log('[LegalLinks] 拉取失败(splash):', e);
|
||||
if (!cancelled) setLinks({});
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const bgDecorationTop = 363;
|
||||
const bgDecorationHeight = height * 0.6;
|
||||
const contentTop = bgDecorationTop + (bgDecorationHeight * 0.25);
|
||||
@@ -81,11 +102,17 @@ export default function SplashScreen() {
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.linksContainer}>
|
||||
<TouchableOpacity onPress={() => openLink('https://example.com/privacy')}>
|
||||
<TouchableOpacity
|
||||
disabled={!links.privacy}
|
||||
onPress={() => (links.privacy ? openLink(links.privacy) : undefined)}
|
||||
>
|
||||
<Text style={styles.linkText}>{t('consent.privacy')}</Text>
|
||||
</TouchableOpacity>
|
||||
<View style={styles.divider} />
|
||||
<TouchableOpacity onPress={() => openLink('https://example.com/terms')}>
|
||||
<TouchableOpacity
|
||||
disabled={!links.terms}
|
||||
onPress={() => (links.terms ? openLink(links.terms) : undefined)}
|
||||
>
|
||||
<Text style={styles.linkText}>{t('consent.terms')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
@@ -6,9 +6,12 @@ import * as SplashScreen from 'expo-splash-screen';
|
||||
import * as Notifications from 'expo-notifications';
|
||||
import { useEffect, useState } from 'react';
|
||||
import 'react-native-reanimated';
|
||||
import { AppState } 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({
|
||||
@@ -34,7 +37,7 @@ 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);
|
||||
@@ -48,11 +51,22 @@ export default function RootLayout() {
|
||||
initI18n()
|
||||
.catch((e) => {
|
||||
// i18n 初始化失败不应阻塞 App 启动,先打印错误再继续
|
||||
console.error('i18n 初始化失败', e);
|
||||
console.error('i18n init failed', e);
|
||||
})
|
||||
.finally(() => setI18nReady(true));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// 尽早生成 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 都准备好后再隐藏启动页,避免文案闪烁
|
||||
if (loaded && i18nReady) {
|
||||
@@ -70,6 +84,21 @@ export default function RootLayout() {
|
||||
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 }}>
|
||||
|
||||
@@ -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,9 +13,6 @@ export default function Index() {
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
// 注意:不要在启动时无条件清空存储,否则 Onboarding/画像等数据无法持久化。
|
||||
// 如需调试重置,请在开发期手动清空或自行加调试开关。
|
||||
|
||||
// 1. 检查是否同意协议
|
||||
const consentAccepted = await getConsentAccepted();
|
||||
if (cancelled) return;
|
||||
@@ -26,9 +22,17 @@ export default function Index() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 检查 Onboarding
|
||||
// 2. 检查 Onboarding 是否已完成
|
||||
const completed = await getOnboardingCompleted();
|
||||
router.replace(completed ? '/(app)/home' : '/(onboarding)/onboarding');
|
||||
if (cancelled) return;
|
||||
|
||||
if (completed) {
|
||||
// 如果已经完成过流程,直接进 Home
|
||||
router.replace('/(app)/home');
|
||||
} else {
|
||||
// 如果是首次进入(或未完成流程),进入 Onboarding
|
||||
router.replace('/(onboarding)/onboarding');
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
@@ -45,4 +49,3 @@ export default function Index() {
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, alignItems: 'center', justifyContent: 'center' },
|
||||
});
|
||||
|
||||
|
||||
BIN
client/assets/fonts/STIXTwoText-VariableFont_wght.ttf
Normal 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 |
@@ -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 |
@@ -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 |
BIN
client/assets/theme/nature/1.png
Normal file
|
After Width: | Height: | Size: 358 KiB |
BIN
client/assets/theme/nature/10.png
Normal file
|
After Width: | Height: | Size: 629 KiB |
BIN
client/assets/theme/nature/11.png
Normal file
|
After Width: | Height: | Size: 532 KiB |
BIN
client/assets/theme/nature/12.png
Normal file
|
After Width: | Height: | Size: 127 KiB |
BIN
client/assets/theme/nature/13.png
Normal file
|
After Width: | Height: | Size: 449 KiB |
BIN
client/assets/theme/nature/14.png
Normal file
|
After Width: | Height: | Size: 525 KiB |
BIN
client/assets/theme/nature/15.png
Normal file
|
After Width: | Height: | Size: 693 KiB |
BIN
client/assets/theme/nature/17.png
Normal file
|
After Width: | Height: | Size: 458 KiB |
BIN
client/assets/theme/nature/18.png
Normal file
|
After Width: | Height: | Size: 593 KiB |
BIN
client/assets/theme/nature/19.png
Normal file
|
After Width: | Height: | Size: 414 KiB |
BIN
client/assets/theme/nature/2.png
Normal file
|
After Width: | Height: | Size: 381 KiB |
BIN
client/assets/theme/nature/20.png
Normal file
|
After Width: | Height: | Size: 461 KiB |
BIN
client/assets/theme/nature/22.png
Normal file
|
After Width: | Height: | Size: 606 KiB |
BIN
client/assets/theme/nature/3.png
Normal file
|
After Width: | Height: | Size: 384 KiB |
BIN
client/assets/theme/nature/4.png
Normal file
|
After Width: | Height: | Size: 388 KiB |
BIN
client/assets/theme/nature/5.png
Normal file
|
After Width: | Height: | Size: 236 KiB |
BIN
client/assets/theme/nature/6.png
Normal file
|
After Width: | Height: | Size: 721 KiB |
BIN
client/assets/theme/nature/7.png
Normal file
|
After Width: | Height: | Size: 358 KiB |
BIN
client/assets/theme/nature/8.png
Normal file
|
After Width: | Height: | Size: 415 KiB |
BIN
client/assets/theme/nature/9.png
Normal file
|
After Width: | Height: | Size: 214 KiB |
@@ -39,13 +39,17 @@ export default function DailyReminderModal({ visible, onClose }: Props) {
|
||||
}, [visible]);
|
||||
|
||||
function clamp(next: number) {
|
||||
return Math.min(10, Math.max(1, next));
|
||||
// 需求:0~5(0 表示关闭)
|
||||
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();
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -50,12 +54,36 @@ type Props = {
|
||||
type Page = 'root' | 'favorites' | 'dailyReminder' | 'widget' | 'language' | 'widgetHowTo';
|
||||
type NavDirection = 'forward' | 'back';
|
||||
|
||||
const NATURE_IMAGES = [
|
||||
require('@/assets/theme/nature/1.png'),
|
||||
require('@/assets/theme/nature/2.png'),
|
||||
require('@/assets/theme/nature/3.png'),
|
||||
require('@/assets/theme/nature/4.png'),
|
||||
require('@/assets/theme/nature/5.png'),
|
||||
require('@/assets/theme/nature/6.png'),
|
||||
require('@/assets/theme/nature/7.png'),
|
||||
require('@/assets/theme/nature/8.png'),
|
||||
require('@/assets/theme/nature/9.png'),
|
||||
require('@/assets/theme/nature/10.png'),
|
||||
require('@/assets/theme/nature/11.png'),
|
||||
require('@/assets/theme/nature/12.png'),
|
||||
require('@/assets/theme/nature/13.png'),
|
||||
require('@/assets/theme/nature/14.png'),
|
||||
require('@/assets/theme/nature/15.png'),
|
||||
require('@/assets/theme/nature/17.png'),
|
||||
require('@/assets/theme/nature/18.png'),
|
||||
require('@/assets/theme/nature/19.png'),
|
||||
require('@/assets/theme/nature/20.png'),
|
||||
require('@/assets/theme/nature/22.png'),
|
||||
];
|
||||
|
||||
export default function ProfileModal({ visible, name: propName, onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
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 同步
|
||||
@@ -66,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');
|
||||
@@ -99,6 +137,18 @@ export default function ProfileModal({ visible, name: propName, onClose }: Props
|
||||
handleClose();
|
||||
}
|
||||
|
||||
const openLink = useCallback(
|
||||
async (url?: string) => {
|
||||
if (!url) 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');
|
||||
@@ -158,6 +208,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} />
|
||||
@@ -187,12 +239,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 (
|
||||
@@ -220,12 +276,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} />}
|
||||
@@ -248,23 +304,29 @@ 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);
|
||||
}
|
||||
|
||||
async function handleRemove(id: string) {
|
||||
async function handleRemove(favId: string) {
|
||||
// 1. 调用存储层移除收藏
|
||||
await removeFavorite(id);
|
||||
await removeFavorite(favId);
|
||||
// 2. 更新本地状态
|
||||
setFavorites(prev => prev.filter(item => item.id !== id));
|
||||
setFavorites(prev => prev.filter(item => item.favId !== favId));
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -274,7 +336,7 @@ function FavoritesPage({ visible, page }: { visible: boolean; page: Page }) {
|
||||
) : (
|
||||
<FlatList
|
||||
data={favorites}
|
||||
keyExtractor={(it) => it.id}
|
||||
keyExtractor={(it) => it.favId}
|
||||
contentContainerStyle={styles.favList}
|
||||
showsVerticalScrollIndicator={false}
|
||||
renderItem={({ item }) => (
|
||||
@@ -290,11 +352,30 @@ function FavoritesPage({ visible, page }: { visible: boolean; page: Page }) {
|
||||
<View style={styles.favRight}>
|
||||
<View style={[
|
||||
styles.favThumb,
|
||||
{ backgroundColor: item.background } // 动态同步 Home 页的背景
|
||||
item.themeMode === 'scenery' ? {} : { backgroundColor: item.background }
|
||||
]}>
|
||||
<Text style={styles.favThumbText} numberOfLines={4}>{item.text}</Text>
|
||||
{item.themeMode === 'scenery' ? (
|
||||
<View style={StyleSheet.absoluteFill}>
|
||||
<Image
|
||||
source={NATURE_IMAGES[parseInt(item.background)]}
|
||||
style={{
|
||||
width: width * 0.6,
|
||||
height: 800, // 假设原图较高,设置一个较大的高度
|
||||
position: 'absolute',
|
||||
bottom: 0, // 关键:将图片底部对齐容器底部
|
||||
}}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
<Text style={[
|
||||
styles.favThumbText,
|
||||
item.themeMode === 'scenery' && { color: '#FFFFFF', textShadowColor: 'rgba(0,0,0,0.5)', textShadowOffset: {width:0, height:1}, textShadowRadius: 3 }
|
||||
]} numberOfLines={4}>
|
||||
{item.text}
|
||||
</Text>
|
||||
<Pressable
|
||||
onPress={() => handleRemove(item.id)}
|
||||
onPress={() => handleRemove(item.favId)}
|
||||
style={styles.favRemoveBtn}
|
||||
hitSlop={10}
|
||||
>
|
||||
@@ -324,13 +405,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);
|
||||
})();
|
||||
@@ -348,7 +431,7 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () =
|
||||
if (settings.status === 'denied') {
|
||||
Alert.alert(
|
||||
t('common.notice'),
|
||||
"系统权限已被拒绝,请前往手机设置开启通知。"
|
||||
t('permissions.notificationsDenied')
|
||||
);
|
||||
setPushEnabled(false);
|
||||
return;
|
||||
@@ -363,6 +446,16 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () =
|
||||
if (status === 'granted') {
|
||||
setPushEnabled(true);
|
||||
setHasSystemPermission(true);
|
||||
|
||||
// 获取 token 并上报后端(幂等)
|
||||
try {
|
||||
const expoPushToken = await getExpoPushTokenOrThrow();
|
||||
await registerPushToken({ pushToken: expoPushToken });
|
||||
await setPushPreferences({ enabled: true, timesPerDay });
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
Alert.alert(t('common.notice'), msg);
|
||||
}
|
||||
} else {
|
||||
setPushEnabled(false);
|
||||
setHasSystemPermission(false);
|
||||
@@ -370,18 +463,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));
|
||||
// 需求:0~5(0 表示关闭)
|
||||
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();
|
||||
}
|
||||
@@ -415,24 +525,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
|
||||
@@ -488,7 +596,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') },
|
||||
@@ -565,12 +674,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 (
|
||||
@@ -765,6 +874,7 @@ const styles = StyleSheet.create({
|
||||
position: 'relative',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(119, 47, 0, 0.05)',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
favThumbText: {
|
||||
fontSize: 15,
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet, TouchableOpacity } from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SerifText } from './SerifText';
|
||||
import { OnboardingColors } 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 +17,10 @@ interface IntentSelectionStepProps {
|
||||
}
|
||||
|
||||
export function IntentSelectionStep({ selectedIds, onToggle }: IntentSelectionStepProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<SerifText style={styles.title}>你希望得到什么帮助?</SerifText>
|
||||
<SerifText style={styles.title}>{t('intent.title')}</SerifText>
|
||||
|
||||
<View style={styles.grid}>
|
||||
{INTENTS.map((intent) => {
|
||||
@@ -35,7 +37,7 @@ export function IntentSelectionStep({ selectedIds, onToggle }: IntentSelectionSt
|
||||
>
|
||||
<SerifText style={styles.icon}>{intent.icon}</SerifText>
|
||||
<SerifText style={[styles.label, isSelected && styles.labelSelected]}>
|
||||
{intent.label}
|
||||
{t(intent.labelKey)}
|
||||
</SerifText>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet, TouchableOpacity, Text, Platform, Dimensions } from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { OnboardingColors } from '@/constants/OnboardingTheme';
|
||||
import AddIcon from '@/assets/images/icon/add_icon.svg';
|
||||
import ReduceIcon from '@/assets/images/icon/reduce_icon.svg';
|
||||
@@ -11,11 +12,15 @@ interface ReminderStepProps {
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
onFinish: () => void;
|
||||
onSkip: () => void;
|
||||
}
|
||||
|
||||
export function ReminderStep({ value, onChange, onFinish }: ReminderStepProps) {
|
||||
export function ReminderStep({ value, onChange, onFinish, onSkip }: ReminderStepProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleReduce = () => {
|
||||
if (value > 1) onChange(value - 1);
|
||||
// 允许 0~5;0 表示关闭每日提醒
|
||||
if (value > 0) onChange(value - 1);
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
@@ -31,7 +36,7 @@ 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}>{t('dailyReminder.timesUnit')}</Text>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity onPress={handleAdd} activeOpacity={0.7}>
|
||||
@@ -43,6 +48,10 @@ export function ReminderStep({ value, onChange, onFinish }: ReminderStepProps) {
|
||||
<TouchableOpacity onPress={onFinish} activeOpacity={0.8}>
|
||||
<BtnClicked width={87} height={57} />
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity onPress={onSkip} activeOpacity={0.8} style={styles.skipBtn}>
|
||||
<Text style={styles.skipText}>{t('onboarding.skip')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -86,4 +95,16 @@ const styles = StyleSheet.create({
|
||||
bottom: height * 0.12,
|
||||
alignItems: 'center',
|
||||
}
|
||||
,
|
||||
skipBtn: {
|
||||
marginTop: 14,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 18,
|
||||
},
|
||||
skipText: {
|
||||
color: OnboardingColors.textPrimary,
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
opacity: 0.85,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -49,17 +49,9 @@ export function SelectionStep({ options, selectedIds, onToggle, onNext, onSkip }
|
||||
|
||||
{/* 底部按钮:距离底部 12% 高度 */}
|
||||
<View style={styles.footer}>
|
||||
<View style={styles.footerRow}>
|
||||
{onSkip && (
|
||||
<TouchableOpacity onPress={onSkip} activeOpacity={0.8} style={styles.skipBtn}>
|
||||
<SerifText style={styles.skipText}>跳过</SerifText>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
<TouchableOpacity onPress={onNext} disabled={!hasSelection} activeOpacity={0.8}>
|
||||
{hasSelection ? <BtnClicked width={87} height={57} /> : <BtnNotClicked width={87} height={57} />}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<TouchableOpacity onPress={onNext} disabled={!hasSelection} activeOpacity={0.8}>
|
||||
{hasSelection ? <BtnClicked width={87} height={57} /> : <BtnNotClicked width={87} height={57} />}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
8
client/global.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* React Native 全局常量声明。
|
||||
*
|
||||
* 说明:`__DEV__` 在运行时由 RN 注入,用于区分开发/生产环境。
|
||||
* 这里补充 TypeScript 声明,避免在代码里使用时出现类型报错。
|
||||
*/
|
||||
declare const __DEV__: boolean;
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import WidgetKit
|
||||
import SwiftUI
|
||||
|
||||
// 统一的 Widget Extension 入口(模块内只能有一个 @main)
|
||||
@main
|
||||
struct MindfulnessWidgetBundle: WidgetBundle {
|
||||
var body: some Widget {
|
||||
MindfulnessWidget()
|
||||
EmotionWidget()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
# MindfulnessWidget(WidgetKit 扩展骨架)
|
||||
|
||||
本目录提供 iOS Widget(V1 写死文案)的 SwiftUI 代码骨架。
|
||||
|
||||
注意:**仅把文件放进仓库还不够**,你还需要在 Xcode 中创建 Widget Extension target,并把这些文件加入 target。
|
||||
|
||||
## 目标
|
||||
|
||||
- 支持 Small/Medium/Large 三种尺寸
|
||||
- 展示写死文案
|
||||
- 点击小组件跳转到 App 的 Home:`client:///(app)/home`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,6 +3,9 @@ PODS:
|
||||
- ExpoModulesCore
|
||||
- EXConstants (18.0.13):
|
||||
- ExpoModulesCore
|
||||
- EXJSONUtils (0.15.0)
|
||||
- EXManifests (1.0.10):
|
||||
- ExpoModulesCore
|
||||
- EXNotifications (0.32.16):
|
||||
- ExpoModulesCore
|
||||
- Expo (54.0.32):
|
||||
@@ -30,8 +33,181 @@ PODS:
|
||||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- Yoga
|
||||
- expo-dev-client (6.0.20):
|
||||
- EXManifests
|
||||
- expo-dev-launcher
|
||||
- expo-dev-menu
|
||||
- expo-dev-menu-interface
|
||||
- EXUpdatesInterface
|
||||
- expo-dev-launcher (6.0.20):
|
||||
- EXManifests
|
||||
- expo-dev-launcher/Main (= 6.0.20)
|
||||
- expo-dev-menu
|
||||
- expo-dev-menu-interface
|
||||
- ExpoModulesCore
|
||||
- EXUpdatesInterface
|
||||
- hermes-engine
|
||||
- RCTRequired
|
||||
- RCTTypeSafety
|
||||
- React-Core
|
||||
- React-Core-prebuilt
|
||||
- React-debug
|
||||
- React-Fabric
|
||||
- React-featureflags
|
||||
- React-graphics
|
||||
- React-ImageManager
|
||||
- React-jsi
|
||||
- React-jsinspector
|
||||
- React-NativeModulesApple
|
||||
- React-RCTAppDelegate
|
||||
- React-RCTFabric
|
||||
- React-renderercss
|
||||
- React-rendererdebug
|
||||
- React-utils
|
||||
- ReactAppDependencyProvider
|
||||
- ReactCodegen
|
||||
- ReactCommon/turbomodule/bridging
|
||||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- Yoga
|
||||
- expo-dev-launcher/Main (6.0.20):
|
||||
- EXManifests
|
||||
- expo-dev-launcher/Unsafe
|
||||
- expo-dev-menu
|
||||
- expo-dev-menu-interface
|
||||
- ExpoModulesCore
|
||||
- EXUpdatesInterface
|
||||
- hermes-engine
|
||||
- RCTRequired
|
||||
- RCTTypeSafety
|
||||
- React-Core
|
||||
- React-Core-prebuilt
|
||||
- React-debug
|
||||
- React-Fabric
|
||||
- React-featureflags
|
||||
- React-graphics
|
||||
- React-ImageManager
|
||||
- React-jsi
|
||||
- React-jsinspector
|
||||
- React-NativeModulesApple
|
||||
- React-RCTAppDelegate
|
||||
- React-RCTFabric
|
||||
- React-renderercss
|
||||
- React-rendererdebug
|
||||
- React-utils
|
||||
- ReactAppDependencyProvider
|
||||
- ReactCodegen
|
||||
- ReactCommon/turbomodule/bridging
|
||||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- Yoga
|
||||
- expo-dev-launcher/Unsafe (6.0.20):
|
||||
- EXManifests
|
||||
- expo-dev-menu
|
||||
- expo-dev-menu-interface
|
||||
- ExpoModulesCore
|
||||
- EXUpdatesInterface
|
||||
- hermes-engine
|
||||
- RCTRequired
|
||||
- RCTTypeSafety
|
||||
- React-Core
|
||||
- React-Core-prebuilt
|
||||
- React-debug
|
||||
- React-Fabric
|
||||
- React-featureflags
|
||||
- React-graphics
|
||||
- React-ImageManager
|
||||
- React-jsi
|
||||
- React-jsinspector
|
||||
- React-NativeModulesApple
|
||||
- React-RCTAppDelegate
|
||||
- React-RCTFabric
|
||||
- React-renderercss
|
||||
- React-rendererdebug
|
||||
- React-utils
|
||||
- ReactAppDependencyProvider
|
||||
- ReactCodegen
|
||||
- ReactCommon/turbomodule/bridging
|
||||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- Yoga
|
||||
- expo-dev-menu (7.0.18):
|
||||
- expo-dev-menu/Main (= 7.0.18)
|
||||
- expo-dev-menu/ReactNativeCompatibles (= 7.0.18)
|
||||
- hermes-engine
|
||||
- RCTRequired
|
||||
- RCTTypeSafety
|
||||
- React-Core
|
||||
- React-Core-prebuilt
|
||||
- React-debug
|
||||
- React-Fabric
|
||||
- React-featureflags
|
||||
- React-graphics
|
||||
- React-ImageManager
|
||||
- React-jsi
|
||||
- React-NativeModulesApple
|
||||
- React-RCTFabric
|
||||
- React-renderercss
|
||||
- React-rendererdebug
|
||||
- React-utils
|
||||
- ReactCodegen
|
||||
- ReactCommon/turbomodule/bridging
|
||||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- Yoga
|
||||
- expo-dev-menu-interface (2.0.0)
|
||||
- expo-dev-menu/Main (7.0.18):
|
||||
- EXManifests
|
||||
- expo-dev-menu-interface
|
||||
- ExpoModulesCore
|
||||
- hermes-engine
|
||||
- RCTRequired
|
||||
- RCTTypeSafety
|
||||
- React-Core
|
||||
- React-Core-prebuilt
|
||||
- React-debug
|
||||
- React-Fabric
|
||||
- React-featureflags
|
||||
- React-graphics
|
||||
- React-ImageManager
|
||||
- React-jsi
|
||||
- React-jsinspector
|
||||
- React-NativeModulesApple
|
||||
- React-RCTFabric
|
||||
- React-renderercss
|
||||
- React-rendererdebug
|
||||
- React-utils
|
||||
- ReactCodegen
|
||||
- ReactCommon/turbomodule/bridging
|
||||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- Yoga
|
||||
- expo-dev-menu/ReactNativeCompatibles (7.0.18):
|
||||
- hermes-engine
|
||||
- RCTRequired
|
||||
- RCTTypeSafety
|
||||
- React-Core
|
||||
- React-Core-prebuilt
|
||||
- React-debug
|
||||
- React-Fabric
|
||||
- React-featureflags
|
||||
- React-graphics
|
||||
- React-ImageManager
|
||||
- React-jsi
|
||||
- React-NativeModulesApple
|
||||
- React-RCTFabric
|
||||
- React-renderercss
|
||||
- React-rendererdebug
|
||||
- React-utils
|
||||
- ReactCodegen
|
||||
- ReactCommon/turbomodule/bridging
|
||||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- Yoga
|
||||
- ExpoAsset (12.0.12):
|
||||
- ExpoModulesCore
|
||||
- ExpoCrypto (15.0.8):
|
||||
- ExpoModulesCore
|
||||
- ExpoFileSystem (19.0.21):
|
||||
- ExpoModulesCore
|
||||
- ExpoFont (14.0.11):
|
||||
@@ -74,6 +250,8 @@ PODS:
|
||||
- ExpoModulesCore
|
||||
- ExpoWebBrowser (15.0.10):
|
||||
- ExpoModulesCore
|
||||
- EXUpdatesInterface (2.0.0):
|
||||
- ExpoModulesCore
|
||||
- FBLazyVector (0.81.5)
|
||||
- hermes-engine (0.81.5):
|
||||
- hermes-engine/Pre-built (= 0.81.5)
|
||||
@@ -1798,6 +1976,28 @@ PODS:
|
||||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- Yoga
|
||||
- RNGestureHandler (2.30.0):
|
||||
- hermes-engine
|
||||
- RCTRequired
|
||||
- RCTTypeSafety
|
||||
- React-Core
|
||||
- React-Core-prebuilt
|
||||
- React-debug
|
||||
- React-Fabric
|
||||
- React-featureflags
|
||||
- React-graphics
|
||||
- React-ImageManager
|
||||
- React-jsi
|
||||
- React-NativeModulesApple
|
||||
- React-RCTFabric
|
||||
- React-renderercss
|
||||
- React-rendererdebug
|
||||
- React-utils
|
||||
- ReactCodegen
|
||||
- ReactCommon/turbomodule/bridging
|
||||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- Yoga
|
||||
- RNReanimated (4.1.6):
|
||||
- hermes-engine
|
||||
- RCTRequired
|
||||
@@ -2040,12 +2240,19 @@ PODS:
|
||||
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+react@1_nvlvke5tn7wk5pigfsu7j4ieeq/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-native@0.8_7rhpxisdkrzvrgzbu7ct455kta/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`)"
|
||||
- "ExpoCrypto (from `../node_modules/.pnpm/expo-crypto@15.0.8_expo@54.0.32/node_modules/expo-crypto/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.13_expo_rjurfbyy5kjn57nkkfxix5iqea/node_modules/expo-router/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.13_expo_mxedi6ntnfsoyp6zijog4pvdsy/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+react@_e6k2hjkd5k4lph2ersbp3gfshy/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`)"
|
||||
@@ -2053,6 +2260,7 @@ DEPENDENCIES:
|
||||
- "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`)"
|
||||
@@ -2122,6 +2330,7 @@ DEPENDENCIES:
|
||||
- "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_@types_fp4qq3a7mejmut52v6jrlvxlzi/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@19.1._tylda4qoo2jtxaj3472gn4luma/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+core@7.28_ky3sbxf6i7nkyacc2hzg3xcz4q/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`)"
|
||||
@@ -2133,18 +2342,32 @@ EXTERNAL SOURCES:
|
||||
:path: "../node_modules/.pnpm/expo-application@7.0.8_expo@54.0.32/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"
|
||||
EXJSONUtils:
|
||||
:path: "../node_modules/.pnpm/expo-json-utils@0.15.0/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"
|
||||
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+react@1_nvlvke5tn7wk5pigfsu7j4ieeq/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-native@0.8_7rhpxisdkrzvrgzbu7ct455kta/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"
|
||||
expo-dev-launcher:
|
||||
:path: "../node_modules/.pnpm/expo-dev-launcher@6.0.20_expo@54.0.32/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"
|
||||
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"
|
||||
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"
|
||||
ExpoCrypto:
|
||||
:path: "../node_modules/.pnpm/expo-crypto@15.0.8_expo@54.0.32/node_modules/expo-crypto/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"
|
||||
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"
|
||||
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.13_expo_rjurfbyy5kjn57nkkfxix5iqea/node_modules/expo-router/ios"
|
||||
:path: "../node_modules/.pnpm/expo-router@6.0.22_@expo+metro-runtime@6.1.2_@types+react@19.1.17_expo-constants@18.0.13_expo_mxedi6ntnfsoyp6zijog4pvdsy/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"
|
||||
ExpoLinearGradient:
|
||||
@@ -2159,6 +2382,8 @@ EXTERNAL SOURCES:
|
||||
:path: "../node_modules/.pnpm/expo-splash-screen@31.0.13_expo@54.0.32/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"
|
||||
EXUpdatesInterface:
|
||||
:path: "../node_modules/.pnpm/expo-updates-interface@2.0.0_expo@54.0.32/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"
|
||||
hermes-engine:
|
||||
@@ -2296,6 +2521,8 @@ EXTERNAL SOURCES:
|
||||
: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"
|
||||
RNCAsyncStorage:
|
||||
:path: "../node_modules/.pnpm/@react-native-async-storage+async-storage@2.2.0_react-native@0.81.5_@babel+core@7.28.6_@types_fp4qq3a7mejmut52v6jrlvxlzi/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@19.1._tylda4qoo2jtxaj3472gn4luma/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+core@7.28_ky3sbxf6i7nkyacc2hzg3xcz4q/node_modules/react-native-reanimated"
|
||||
RNScreens:
|
||||
@@ -2310,9 +2537,16 @@ EXTERNAL SOURCES:
|
||||
SPEC CHECKSUMS:
|
||||
EXApplication: 13420f8139864183f8a04fd6099077bdf8cfb186
|
||||
EXConstants: 3feb66fd1d94202fc1f0946d74e029d8b224b60e
|
||||
EXJSONUtils: 1d3e4590438c3ee593684186007028a14b3686cd
|
||||
EXManifests: 83ef0844fcf06d6099b12a7bdbd7d36fc0e1dd16
|
||||
EXNotifications: 2a3feb7af6194828d9aafda72f63a9a03866230a
|
||||
Expo: b8d64eb9a496ebe8c71e3dae7eeb7f394b146b80
|
||||
expo-dev-client: 12ef7d5b14d93e309922acea78dcd851db583a87
|
||||
expo-dev-launcher: 47994056008ffdc30a6a5e328a375b3e30a8db05
|
||||
expo-dev-menu: ea4fb803ace52e60d7cd8060c7cd379612a140b2
|
||||
expo-dev-menu-interface: 600df12ea01efecdd822daaf13cc0ac091775533
|
||||
ExpoAsset: d999f3bbd998a750f3b74cb913229848901b926b
|
||||
ExpoCrypto: 4d23a9ff67c25e2ed23ca792d81e58817a7ea1b9
|
||||
ExpoFileSystem: aefcd337b94b874f88752ebefc52813b84992fad
|
||||
ExpoFont: c625dbd97ed57e9089b172b2a7bb99003d074664
|
||||
ExpoHead: b691a2ed7ab02ed820b6c6468941832d34969c29
|
||||
@@ -2323,6 +2557,7 @@ SPEC CHECKSUMS:
|
||||
ExpoModulesCore: 77496909fd3c800f97f7f2007dd26aeac4bb3798
|
||||
ExpoSplashScreen: 72fbc6dd9d6404dd9d0725a56c9ac1383bc0b14f
|
||||
ExpoWebBrowser: 88b116cd378d9609c776c0903fe4070fca461588
|
||||
EXUpdatesInterface: 1436757deb0d574b84bba063bd024c315e0ec08b
|
||||
FBLazyVector: e95a291ad2dadb88e42b06e0c5fb8262de53ec12
|
||||
hermes-engine: 9f4dfe93326146a1c99eb535b1cb0b857a3cd172
|
||||
RCTDeprecation: 943572d4be82d480a48f4884f670135ae30bf990
|
||||
@@ -2391,12 +2626,13 @@ SPEC CHECKSUMS:
|
||||
ReactCommon: e6e232202a447d353e5531f2be82f50f47cbaa9a
|
||||
ReactNativeDependencies: 71ce9c28beb282aa720ea7b46980fff9669f428a
|
||||
RNCAsyncStorage: e85a99325df9eb0191a6ee2b2a842644c7eb29f4
|
||||
RNGestureHandler: 40c2d1c168e54715fe52e0fb16cb38c54611e4f3
|
||||
RNReanimated: 10415bc8396eaeac0d7b2c9a1538eae7e607ec9c
|
||||
RNScreens: dd61bc3a3e6f6901ad833efa411917d44827cf51
|
||||
RNSVG: 2825ee146e0f6a16221e852299943e4cceef4528
|
||||
RNWorklets: 9ccdc8112b17af6eee2c85a233891cb80db150ad
|
||||
Yoga: 5934998fbeaef7845dbf698f698518695ab4cd1a
|
||||
|
||||
PODFILE CHECKSUM: dfe3cc75dee014a0abd367bc9e1bdbab0ba64ee3
|
||||
PODFILE CHECKSUM: c2c3838f0b2a579fef2350bff2ecaa005e27145d
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 56;
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
@@ -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 */; };
|
||||
A8C1D2E3F4A5B6C7D8E9F0B2 /* AppGroupStorageBridge.m in Sources */ = {isa = PBXBuildFile; fileRef = A8C1D2E3F4A5B6C7D8E9F0B1 /* AppGroupStorageBridge.m */; };
|
||||
A8C1D2E3F4A5B6C7D8E9F0A3 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EB3DAF802F2A4B8D00450593 /* WidgetKit.framework */; };
|
||||
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;
|
||||
@@ -374,6 +388,8 @@
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/RNSVG/RNSVGFilters.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/expo-dev-launcher/EXDevLauncher.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/expo-dev-menu/EXDevMenu.bundle",
|
||||
);
|
||||
name = "[CP] Copy Pods Resources";
|
||||
outputPaths = (
|
||||
@@ -387,6 +403,8 @@
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNSVGFilters.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-Core_privacy.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXDevLauncher.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXDevMenu.bundle",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
@@ -448,6 +466,8 @@
|
||||
files = (
|
||||
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */,
|
||||
B5A7FE9A125F7C79753EC5BF /* ExpoModulesProvider.swift in Sources */,
|
||||
A8C1D2E3F4A5B6C7D8E9F0A2 /* AppGroupStorage.swift in Sources */,
|
||||
A8C1D2E3F4A5B6C7D8E9F0B2 /* AppGroupStorageBridge.m in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -475,10 +495,12 @@
|
||||
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;
|
||||
ENABLE_BITCODE = NO;
|
||||
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = x86_64;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"$(inherited)",
|
||||
"FB_SONARKIT_ENABLED=1",
|
||||
@@ -489,15 +511,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;
|
||||
@@ -505,7 +528,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;
|
||||
@@ -515,31 +538,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;
|
||||
@@ -567,6 +596,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;
|
||||
@@ -598,9 +628,11 @@
|
||||
);
|
||||
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
ONLY_ACTIVE_ARCH = 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";
|
||||
SDKROOT = iphoneos;
|
||||
SKIP_INSTALL = NO;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
|
||||
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
|
||||
USE_HERMES = true;
|
||||
@@ -630,6 +662,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;
|
||||
@@ -653,9 +686,13 @@
|
||||
"$(inherited)",
|
||||
);
|
||||
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
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";
|
||||
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;
|
||||
@@ -676,7 +713,7 @@
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 4;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
@@ -691,11 +728,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;
|
||||
@@ -709,7 +746,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;
|
||||
};
|
||||
@@ -726,10 +763,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;
|
||||
@@ -743,10 +782,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;
|
||||
@@ -759,7 +798,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;
|
||||
};
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1130"
|
||||
version = "1.3">
|
||||
LastUpgradeVersion = "2620"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
buildImplicitDependencies = "YES"
|
||||
buildArchitectures = "Automatic">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
@@ -15,7 +16,7 @@
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
||||
BuildableName = "client.app"
|
||||
BuildableName = "HeyMama.app"
|
||||
BlueprintName = "client"
|
||||
ReferencedContainer = "container:client.xcodeproj">
|
||||
</BuildableReference>
|
||||
@@ -26,19 +27,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 +45,7 @@
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
||||
BuildableName = "client.app"
|
||||
BuildableName = "HeyMama.app"
|
||||
BlueprintName = "client"
|
||||
ReferencedContainer = "container:client.xcodeproj">
|
||||
</BuildableReference>
|
||||
@@ -72,7 +62,7 @@
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
||||
BuildableName = "client.app"
|
||||
BuildableName = "HeyMama.app"
|
||||
BlueprintName = "client"
|
||||
ReferencedContainer = "container:client.xcodeproj">
|
||||
</BuildableReference>
|
||||
@@ -83,6 +73,7 @@
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
customArchiveName = "Hey Mama"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -0,0 +1,78 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2620"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES"
|
||||
buildArchitectures = "Automatic">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
||||
BuildableName = "HeyMama.app"
|
||||
BlueprintName = "client"
|
||||
ReferencedContainer = "container:client.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
||||
BuildableName = "HeyMama.app"
|
||||
BlueprintName = "client"
|
||||
ReferencedContainer = "container:client.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
||||
BuildableName = "HeyMama.app"
|
||||
BlueprintName = "client"
|
||||
ReferencedContainer = "container:client.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -0,0 +1,100 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2620"
|
||||
version = "2.2">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "NO"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<AutocreatedTestPlanReference>
|
||||
</AutocreatedTestPlanReference>
|
||||
</BuildActionEntry>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "E99E52E7F84CEF53B06494B581AFB6E4"
|
||||
BuildableName = "libPods-client.a"
|
||||
BlueprintName = "Pods-client"
|
||||
ReferencedContainer = "container:Pods/Pods.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
||||
BuildableName = "HeyMama.app"
|
||||
BlueprintName = "client"
|
||||
ReferencedContainer = "container:client.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
||||
BuildableName = "HeyMama.app"
|
||||
BlueprintName = "client"
|
||||
ReferencedContainer = "container:client.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
||||
BuildableName = "HeyMama.app"
|
||||
BlueprintName = "client"
|
||||
ReferencedContainer = "container:client.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
57
client/ios/client/AppGroupStorage.swift
Normal file
@@ -0,0 +1,57 @@
|
||||
import Foundation
|
||||
import React
|
||||
import WidgetKit
|
||||
|
||||
/**
|
||||
* App Group 共享存储(RN Bridge)
|
||||
*
|
||||
* 约定:
|
||||
* - suiteName:group.com.damer.mindfulness(已在主 App 与 Widget Extension 的 entitlements 配置)
|
||||
* - 值统一使用字符串(通常是 JSON),由 JS 侧负责序列化与反序列化
|
||||
*/
|
||||
@objc(AppGroupStorage)
|
||||
final class AppGroupStorage: NSObject, RCTBridgeModule {
|
||||
static func moduleName() -> String! {
|
||||
"AppGroupStorage"
|
||||
}
|
||||
|
||||
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 UserDefaults(suiteName=\(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 UserDefaults(suiteName=\(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)
|
||||
}
|
||||
}
|
||||
|
||||
26
client/ios/client/AppGroupStorageBridge.m
Normal 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
|
||||
|
||||
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 142 KiB |
@@ -1,81 +1,81 @@
|
||||
<?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>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>
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
//
|
||||
// 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/RCTBundleURLProvider.h>
|
||||
#import <React/RCTLinkingManager.h>
|
||||
|
||||
@@ -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>
|
||||
12
client/ios/client/clientRelease.entitlements
Normal 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>
|
||||
100
client/ios/scripts/fix-xcarchive-header.sh
Executable file
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# 修复 Xcode Organizer 显示 “Generic Xcode Archive” 的问题:
|
||||
# - 某些情况下 xcodebuild 生成的 .xcarchive/Info.plist 缺少 ApplicationProperties
|
||||
# - Organizer 无法识别归档中的主 App(即使 Products/Applications/*.app 存在)
|
||||
#
|
||||
# 用法:
|
||||
# ./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
|
||||
|
||||
# 取第一个 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)"
|
||||
|
||||
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"
|
||||
|
||||
# 如果已有 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"
|
||||
@@ -1,94 +1,333 @@
|
||||
import Foundation
|
||||
import WidgetKit
|
||||
import SwiftUI
|
||||
|
||||
// V2:纯色背景 + 随机文案小组件(Small/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 = "You’ve 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:10~01: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 {
|
||||
private let quotes = [
|
||||
"你已经很努力了,今天也值得被温柔对待。",
|
||||
"轻轻呼吸,感受当下的每一刻。",
|
||||
"所有的压力,都会在深呼吸中慢慢消散。",
|
||||
"给生活一点留白,给自己一点温柔。",
|
||||
"不要走得太快,等一等落下的灵魂。",
|
||||
"世界虽嘈杂,但你可以拥有一颗宁静的心。",
|
||||
"每一个瞬间,都是生命最好的安排。",
|
||||
"抱抱自己,辛苦了,亲爱的。",
|
||||
"慢一点也没关系,只要你在前行。",
|
||||
"今天,你对自己微笑了吗?",
|
||||
"愿你历经山河,仍觉得人间值得。",
|
||||
"心简单,世界就简单;心平顺,生活就平顺。",
|
||||
"即使生活偶尔晦暗,你也要成为自己的光。",
|
||||
"别让琐事挤走生活的快乐,别让压力消磨奋斗的激情。"
|
||||
]
|
||||
|
||||
func placeholder(in context: Context) -> EmotionEntry {
|
||||
EmotionEntry(date: Date(), text: quotes[0])
|
||||
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) -> ()) {
|
||||
let entry = EmotionEntry(date: Date(), text: quotes.randomElement() ?? quotes[0])
|
||||
completion(entry)
|
||||
completion(placeholder(in: context))
|
||||
}
|
||||
|
||||
func getTimeline(in context: Context, completion: @escaping (Timeline<EmotionEntry>) -> ()) {
|
||||
var entries: [EmotionEntry] = []
|
||||
let currentDate = Date()
|
||||
|
||||
// 生成未来 24 小时的 6 个条目,每 4 小时更换一次随机文案
|
||||
for hourOffset in 0..<6 {
|
||||
let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset * 4, to: currentDate)!
|
||||
let entry = EmotionEntry(date: entryDate, text: quotes.randomElement() ?? quotes[0])
|
||||
entries.append(entry)
|
||||
}
|
||||
Task {
|
||||
let lang = resolveLang()
|
||||
let today = localDayKey(Date())
|
||||
|
||||
let timeline = Timeline(entries: entries, policy: .atEnd)
|
||||
completion(timeline)
|
||||
// 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 deepLink = URL(string: "client:///(app)/home")
|
||||
|
||||
// 背景色 #F7D9BF
|
||||
private let backgroundColor = Color(red: 247/255, green: 217/255, blue: 191/255)
|
||||
// 文本颜色(深咖色,适合搭配浅橘色背景)
|
||||
private let textColor = Color(red: 74/255, green: 52/255, blue: 40/255)
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .center, spacing: 0) {
|
||||
Spacer(minLength: 0)
|
||||
|
||||
ZStack {
|
||||
cardBackground(colors: [
|
||||
Color(red: 0.06, green: 0.08, blue: 0.12),
|
||||
Color(red: 0.14, green: 0.18, blue: 0.28),
|
||||
])
|
||||
|
||||
// 只显示一句话(不显示标题/提示/时间等装饰元素)
|
||||
Text(entry.text)
|
||||
.font(.system(size: family == .systemSmall ? 17 : 20, weight: .medium))
|
||||
.foregroundColor(textColor)
|
||||
.lineSpacing(6)
|
||||
.multilineTextAlignment(.center)
|
||||
.minimumScaleFactor(0.7)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
if family != .systemSmall {
|
||||
Text("Hey Mama")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundColor(textColor.opacity(0.3))
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
.font(fontForFamily())
|
||||
.foregroundColor(Color.white.opacity(0.92))
|
||||
.multilineTextAlignment(.leading)
|
||||
.lineSpacing(lineSpacingForFamily())
|
||||
.lineLimit(lineLimitForFamily())
|
||||
.minimumScaleFactor(0.78)
|
||||
.padding(paddingForFamily())
|
||||
}
|
||||
.padding(family == .systemSmall ? 16 : 24)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity) // 强制撑开容器
|
||||
.background(backgroundColor) // 将背景色直接应用到容器上
|
||||
.widgetURL(deepLink)
|
||||
}
|
||||
|
||||
// 统一的“卡片背景”风格(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
|
||||
)
|
||||
}
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||
.stroke(Color.white.opacity(0.14), lineWidth: 1)
|
||||
)
|
||||
.cornerRadius(18)
|
||||
}
|
||||
|
||||
private func fontForFamily() -> Font {
|
||||
switch family {
|
||||
case .systemSmall:
|
||||
return .system(size: 16, weight: .semibold)
|
||||
case .systemMedium:
|
||||
return .system(size: 18, weight: .semibold)
|
||||
case .systemLarge:
|
||||
return .system(size: 22, weight: .semibold)
|
||||
default:
|
||||
return .system(size: 16, weight: .semibold)
|
||||
}
|
||||
}
|
||||
|
||||
private func lineSpacingForFamily() -> CGFloat {
|
||||
switch family {
|
||||
case .systemLarge:
|
||||
return 4
|
||||
default:
|
||||
return 3
|
||||
}
|
||||
}
|
||||
|
||||
private func lineLimitForFamily() -> Int {
|
||||
switch family {
|
||||
case .systemSmall:
|
||||
return 5
|
||||
case .systemMedium:
|
||||
return 6
|
||||
case .systemLarge:
|
||||
return 8
|
||||
default:
|
||||
return 5
|
||||
}
|
||||
}
|
||||
|
||||
private func paddingForFamily() -> CGFloat {
|
||||
switch family {
|
||||
case .systemSmall:
|
||||
return 14
|
||||
case .systemMedium:
|
||||
return 16
|
||||
case .systemLarge:
|
||||
return 18
|
||||
default:
|
||||
return 14
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@main
|
||||
@@ -97,13 +336,7 @@ struct EmotionWidget: Widget {
|
||||
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: kind, provider: EmotionProvider()) { entry in
|
||||
if #available(iOS 17.0, *) {
|
||||
EmotionWidgetView(entry: entry)
|
||||
.containerBackground(Color(red: 247/255, green: 217/255, blue: 191/255), for: .widget)
|
||||
} else {
|
||||
EmotionWidgetView(entry: entry)
|
||||
.background(Color(red: 247/255, green: 217/255, blue: 191/255))
|
||||
}
|
||||
EmotionWidgetView(entry: entry)
|
||||
}
|
||||
.configurationDisplayName("情绪小组件")
|
||||
.description("一段温柔提醒,陪你回到当下。")
|
||||
|
||||
10
client/ios/情绪小组件ExtensionRelease.entitlements
Normal 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>
|
||||
@@ -5,7 +5,7 @@
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"ios": "expo run:ios --scheme \"Hey Mama\"",
|
||||
"web": "expo start --web",
|
||||
"test": "vitest run"
|
||||
},
|
||||
@@ -15,7 +15,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",
|
||||
|
||||
32
client/pnpm-lock.yaml
generated
@@ -23,9 +23,15 @@ importers:
|
||||
expo-constants:
|
||||
specifier: ~18.0.13
|
||||
version: 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))
|
||||
expo-crypto:
|
||||
specifier: ^15.0.8
|
||||
version: 15.0.8(expo@54.0.32)
|
||||
expo-dev-client:
|
||||
specifier: ^6.0.20
|
||||
version: 6.0.20(expo@54.0.32)
|
||||
expo-device:
|
||||
specifier: ^8.0.10
|
||||
version: 8.0.10(expo@54.0.32)
|
||||
expo-font:
|
||||
specifier: ~14.0.11
|
||||
version: 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)
|
||||
@@ -2229,6 +2235,11 @@ packages:
|
||||
expo: '*'
|
||||
react-native: '*'
|
||||
|
||||
expo-crypto@15.0.8:
|
||||
resolution: {integrity: sha512-aF7A914TB66WIlTJvl5J6/itejfY78O7dq3ibvFltL9vnTALJ/7LYHvLT4fwmx9yUNS6ekLBtDGWivFWnj2Fcw==}
|
||||
peerDependencies:
|
||||
expo: '*'
|
||||
|
||||
expo-dev-client@6.0.20:
|
||||
resolution: {integrity: sha512-5XjoVlj1OxakNxy55j/AUaGPrDOlQlB6XdHLLWAw61w5ffSpUDHDnuZzKzs9xY1eIaogOqTOQaAzZ2ddBkdXLA==}
|
||||
peerDependencies:
|
||||
@@ -2249,6 +2260,11 @@ packages:
|
||||
peerDependencies:
|
||||
expo: '*'
|
||||
|
||||
expo-device@8.0.10:
|
||||
resolution: {integrity: sha512-jd5BxjaF7382JkDMaC+P04aXXknB2UhWaVx5WiQKA05ugm/8GH5uaz9P9ckWdMKZGQVVEOC8MHaUADoT26KmFA==}
|
||||
peerDependencies:
|
||||
expo: '*'
|
||||
|
||||
expo-file-system@19.0.21:
|
||||
resolution: {integrity: sha512-s3DlrDdiscBHtab/6W1osrjGL+C2bvoInPJD7sOwmxfJ5Woynv2oc+Fz1/xVXaE/V7HE/+xrHC/H45tu6lZzzg==}
|
||||
peerDependencies:
|
||||
@@ -3811,6 +3827,10 @@ packages:
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
ua-parser-js@0.7.41:
|
||||
resolution: {integrity: sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg==}
|
||||
hasBin: true
|
||||
|
||||
ua-parser-js@1.0.41:
|
||||
resolution: {integrity: sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==}
|
||||
hasBin: true
|
||||
@@ -6537,6 +6557,11 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
expo-crypto@15.0.8(expo@54.0.32):
|
||||
dependencies:
|
||||
base64-js: 1.5.1
|
||||
expo: 54.0.32(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.22)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
|
||||
expo-dev-client@6.0.20(expo@54.0.32):
|
||||
dependencies:
|
||||
expo: 54.0.32(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.22)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
@@ -6566,6 +6591,11 @@ snapshots:
|
||||
expo: 54.0.32(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.22)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
expo-dev-menu-interface: 2.0.0(expo@54.0.32)
|
||||
|
||||
expo-device@8.0.10(expo@54.0.32):
|
||||
dependencies:
|
||||
expo: 54.0.32(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.22)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
ua-parser-js: 0.7.41
|
||||
|
||||
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)):
|
||||
dependencies:
|
||||
expo: 54.0.32(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.22)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
@@ -8282,6 +8312,8 @@ snapshots:
|
||||
|
||||
typescript@5.9.3: {}
|
||||
|
||||
ua-parser-js@0.7.41: {}
|
||||
|
||||
ua-parser-js@1.0.41: {}
|
||||
|
||||
undici-types@7.16.0: {}
|
||||
|
||||
@@ -29,18 +29,38 @@ function getApiBaseUrl(env: AppRuntimeEnv): string {
|
||||
const direct = process.env.EXPO_PUBLIC_API_BASE_URL;
|
||||
if (direct && String(direct).trim()) return String(direct).trim();
|
||||
|
||||
// 约定:local/dev/prod 三套域名分别配置,便于后续直接切环境而不改代码
|
||||
// 约定: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', 'http://localhost:8000'));
|
||||
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', 'http://localhost:8000'));
|
||||
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:优先设备语言(支持列表内时),否则回退 en
|
||||
|
||||
@@ -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' }
|
||||
];
|
||||
|
||||
|
||||
22
client/src/i18n/ALL_COPY.md
Normal file
@@ -0,0 +1,22 @@
|
||||
## 应用文案总表(请在此文件对应的 JSON 中修改)
|
||||
|
||||
**单一文案源文件**:`client/src/i18n/locales/all.json`
|
||||
|
||||
- **English**:`all.json` 的 `en`
|
||||
- **繁体中文**:`all.json` 的 `zh-TW`
|
||||
|
||||
> 说明:项目运行时只读取 `all.json`;请不要再改 `locales/en.json`、`locales/zh-TW.json`(它们已不再作为运行时数据源)。
|
||||
|
||||
### 快速索引(高频文案)
|
||||
|
||||
- **Home**:`home.*`
|
||||
- **Push 提示**:`push.*`
|
||||
- **主题**:`theme.*`
|
||||
- **我的/Profile**:`profile.*`
|
||||
- **收藏**:`favorites.*`
|
||||
- **设置**:`settings.*`
|
||||
- **Onboarding(问卷)**:`onboardingSurvey.steps.*`
|
||||
- **Onboarding(兴趣)**:`intent.*`
|
||||
- **Mock 文案**:`mock.*`
|
||||
|
||||
|
||||
@@ -3,8 +3,9 @@ import * as Localization from 'expo-localization';
|
||||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
|
||||
import en from './locales/en.json';
|
||||
import zhTW from './locales/zh-TW.json';
|
||||
// 用 require 避免 TS 的 json module 配置差异导致无法编译
|
||||
// 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> };
|
||||
|
||||
/**
|
||||
* 语言码约定:
|
||||
@@ -83,8 +84,8 @@ export async function initI18n(): Promise<void> {
|
||||
|
||||
await i18n.use(initReactI18next).init({
|
||||
resources: {
|
||||
'zh-TW': { translation: zhTW },
|
||||
en: { translation: en },
|
||||
'zh-TW': { translation: all['zh-TW'] as any },
|
||||
en: { translation: all.en as any },
|
||||
},
|
||||
lng: initialLang,
|
||||
fallbackLng: DEFAULT_FALLBACK_LANGUAGE,
|
||||
|
||||
314
client/src/i18n/locales/all.json
Normal file
@@ -0,0 +1,314 @@
|
||||
{
|
||||
"en": {
|
||||
"common": {
|
||||
"ok": "OK",
|
||||
"cancel": "Cancel",
|
||||
"error": "Error",
|
||||
"openLinkError": "Cannot open link",
|
||||
"back": "Back",
|
||||
"close": "Close"
|
||||
},
|
||||
"onboarding": {
|
||||
"title": "Welcome",
|
||||
"progress": "{{current}}/{{total}}",
|
||||
"next": "Next",
|
||||
"skip": "Skip",
|
||||
"skipAll": "Skip onboarding",
|
||||
"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. We’ll stay with you along the way."
|
||||
},
|
||||
"onboardingSurvey": {
|
||||
"steps": {
|
||||
"name": { "title": "What should I call you?" },
|
||||
"status": {
|
||||
"title": "Your current stage?",
|
||||
"options": {
|
||||
"pregnant": "Pregnant / preparing for motherhood",
|
||||
"has_kids": "Already have kids",
|
||||
"no_fill": "Prefer not to say"
|
||||
}
|
||||
},
|
||||
"emotion": {
|
||||
"title": "How are you feeling right now?",
|
||||
"options": {
|
||||
"happy": "Happy / satisfied",
|
||||
"calm": "Calm / grounded",
|
||||
"stressed": "Stressed / overwhelmed",
|
||||
"low": "Down / low mood"
|
||||
}
|
||||
},
|
||||
"influence": {
|
||||
"title": "What has been affecting you lately?",
|
||||
"options": {
|
||||
"family": "Family & kids",
|
||||
"work": "Work or study",
|
||||
"relationship": "Intimate relationship",
|
||||
"friends": "Friends & social life",
|
||||
"health": "Mental & physical health"
|
||||
}
|
||||
},
|
||||
"support": {
|
||||
"title": "What support do you need most?",
|
||||
"options": {
|
||||
"emotional": "Emotional support",
|
||||
"parenting": "Parenting stress",
|
||||
"self_worth": "Self-worth",
|
||||
"anxiety": "Anxiety relief",
|
||||
"balance": "Rest & balance"
|
||||
}
|
||||
},
|
||||
"reminder": { "title": "How many reminders do you want per day?" }
|
||||
}
|
||||
},
|
||||
"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": "We’ll 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": "It’s 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"
|
||||
},
|
||||
"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",
|
||||
"pushLabel": "Push Reminder",
|
||||
"ok": "Ok",
|
||||
"minus": "Decrease",
|
||||
"plus": "Increase"
|
||||
},
|
||||
"widget": {
|
||||
"lockScreen": "Lock Screen Widget",
|
||||
"homeScreen": "Home Screen Widget",
|
||||
"previewDate": "Thu, Jan 29",
|
||||
"previewQuote": "I’m 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": "You Are Perfect.",
|
||||
"subtitle": "Everything Will Be Better.",
|
||||
"agree": "Agree & Continue",
|
||||
"privacy": "Privacy Policy",
|
||||
"terms": "Terms of Use"
|
||||
},
|
||||
"permissions": {
|
||||
"notificationsDenied": "Notifications are denied. Please enable them in Settings."
|
||||
},
|
||||
"language": {
|
||||
"zhTW": "繁體中文",
|
||||
"en": "English"
|
||||
},
|
||||
"mock": {
|
||||
"c1": "You’ve been trying your best. You deserve kindness today.",
|
||||
"c2": "Take three deep breaths and return to the present moment.",
|
||||
"c3": "It’s okay to slow down. Emotions pass like clouds.",
|
||||
"c4": "You don’t 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": "返回",
|
||||
"close": "關閉"
|
||||
},
|
||||
"onboarding": {
|
||||
"title": "歡迎",
|
||||
"progress": "{{current}}/{{total}}",
|
||||
"next": "下一步",
|
||||
"skip": "跳過",
|
||||
"skipAll": "跳過整個引導",
|
||||
"q1Title": "你最近的感受更接近哪一種?",
|
||||
"q1Desc": "沒有對錯,你可以跳過,之後也能慢慢調整。",
|
||||
"q2Title": "你更希望獲得哪種支持?",
|
||||
"q2Desc": "例如:溫柔提醒、正念練習、情緒陪伴。",
|
||||
"q3Title": "你通常在什麼時候最需要被安慰?",
|
||||
"q3Desc": "例如:清晨、午后、深夜,或某些特定時刻。",
|
||||
"q4Title": "給自己一句溫柔的話",
|
||||
"q4Desc": "你可以直接跳過,我們會在之後繼續陪你。"
|
||||
},
|
||||
"onboardingSurvey": {
|
||||
"steps": {
|
||||
"name": { "title": "我可以怎麼稱呼你?" },
|
||||
"status": {
|
||||
"title": "媽媽的狀態?",
|
||||
"options": {
|
||||
"pregnant": "懷孕中/準備成為媽媽",
|
||||
"has_kids": "已經有孩子",
|
||||
"no_fill": "不想填寫"
|
||||
}
|
||||
},
|
||||
"emotion": {
|
||||
"title": "當下情緒狀態?",
|
||||
"options": {
|
||||
"happy": "愉悅、滿足",
|
||||
"calm": "平靜、安穩",
|
||||
"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": "顏色"
|
||||
},
|
||||
"profile": {
|
||||
"title": "我的",
|
||||
"favorites": "我的喜歡",
|
||||
"widget": "小工具",
|
||||
"dailyReminder": "每日提醒",
|
||||
"privacy": "隱私政策",
|
||||
"terms": "使用條款",
|
||||
"language": "語言",
|
||||
"todoTitle": "提示",
|
||||
"todoDesc": "此功能本期先占位,後續迭代補齊。"
|
||||
},
|
||||
"dailyReminder": {
|
||||
"title": "每日提醒",
|
||||
"timesUnit": "次",
|
||||
"pushLabel": "推送提醒",
|
||||
"ok": "確定",
|
||||
"minus": "減少次數",
|
||||
"plus": "增加次數"
|
||||
},
|
||||
"widget": {
|
||||
"lockScreen": "鎖屏小工具",
|
||||
"homeScreen": "桌面小工具",
|
||||
"previewDate": "1月29日週四 · 已至臘月十一",
|
||||
"previewQuote": "我也對現在的自己感到滿意,即使我仍在努力成為想成為的人。"
|
||||
},
|
||||
"favorites": {
|
||||
"title": "收藏夾",
|
||||
"empty": "這裡還沒有收藏內容。",
|
||||
"unknownText": "這條文案暫時無法顯示。"
|
||||
},
|
||||
"settings": {
|
||||
"title": "設定",
|
||||
"language": "語言",
|
||||
"version": "版本",
|
||||
"widgetTitle": "iOS 小工具",
|
||||
"widgetDesc": "把溫柔提醒放到桌面上:長按主畫面 → 點「+」 → 搜尋「正念」 → 添加你喜歡的尺寸。"
|
||||
},
|
||||
"consent": {
|
||||
"agree": "同意並繼續",
|
||||
"privacy": "隱私協議",
|
||||
"terms": "用戶使用協議"
|
||||
},
|
||||
"permissions": {
|
||||
"notificationsDenied": "系統權限已被拒絕,請前往手機設定開啟通知。"
|
||||
},
|
||||
"language": {
|
||||
"zhTW": "繁體中文",
|
||||
"en": "English"
|
||||
},
|
||||
"mock": {
|
||||
"c1": "你已經很努力了,今天也值得被溫柔對待。",
|
||||
"c2": "深呼吸三次,把注意力帶回當下。",
|
||||
"c3": "允許自己慢一點,情緒會像雲一樣飄過。",
|
||||
"c4": "你不需要完美,你已經足夠好。",
|
||||
"c5": "把手放在心口,對自己說一句:辛苦了。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"later": "稍后",
|
||||
"loading": "处理中…",
|
||||
"errorTitle": "提示",
|
||||
"errorDesc": "开启失败也没关系,你仍然可以继续使用应用。"
|
||||
"errorDesc": "开启失败,请稍后重试(模拟器可能无法获取推送 Token,建议用真机测试)。"
|
||||
},
|
||||
"home": {
|
||||
"title": "正念",
|
||||
|
||||
173
client/src/modules/dailyWidgetReco/index.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { API_BASE_URL } from '@/src/constants/env';
|
||||
import type { UserProfileV1_2, UserProfileV1_2_Extended } from '@/src/features/userProfileScoring/types';
|
||||
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 共享 key(App ↔ 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 能拿到 baseURL(dev/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 = i18n.language?.toLowerCase().startsWith('zh') ? 'tc' : 'en';
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
39
client/src/services/__tests__/legalApi.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
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('任意 zh* 归一为 tc', () => {
|
||||
setLang('zh-CN');
|
||||
expect(buildAcceptLanguage()).toBe('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');
|
||||
});
|
||||
});
|
||||
|
||||
53
client/src/services/appGroupStorage.ts
Normal 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();
|
||||
}
|
||||
|
||||
37
client/src/services/legalApi.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import i18n from 'i18next';
|
||||
|
||||
import { httpJson } from '../utils/http';
|
||||
|
||||
export type LegalLinks = {
|
||||
privacyPolicyUrl: string;
|
||||
termsOfUseUrl: string;
|
||||
resolvedLang: 'en' | 'tc';
|
||||
};
|
||||
|
||||
export function buildAcceptLanguage(): 'en' | 'tc' {
|
||||
const lang = (i18n.language || '').trim();
|
||||
const lower = lang.toLowerCase();
|
||||
|
||||
// 当前多语言仅支持 EN / TC(与 reco 链路一致);其他语言统一回退到 en
|
||||
if (lower.startsWith('zh')) {
|
||||
return 'tc';
|
||||
}
|
||||
if (lower.includes('tc') || lower.includes('hant') || lower.includes('hk') || lower.includes('mo') || lower.includes('tw')) {
|
||||
return 'tc';
|
||||
}
|
||||
return 'en';
|
||||
}
|
||||
|
||||
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',
|
||||
});
|
||||
}
|
||||
|
||||
210
client/src/services/pushApi.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
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';
|
||||
|
||||
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; // 0~5
|
||||
timezone?: string;
|
||||
locale?: string;
|
||||
// 可选:用户画像(用于后端 Push 场景推荐文案生成)
|
||||
user_profile?: UserProfileScoring;
|
||||
};
|
||||
|
||||
export type PushPreferencesResponse = PushPreferencesRequest & {
|
||||
updated_at?: string;
|
||||
};
|
||||
|
||||
export function buildAcceptLanguage(): 'en' | 'tc' {
|
||||
const lang = (i18n.language || '').trim();
|
||||
const lower = lang.toLowerCase();
|
||||
if (lower.startsWith('zh')) return 'tc';
|
||||
if (lower.includes('tc') || lower.includes('hant') || lower.includes('hk') || lower.includes('mo') || lower.includes('tw')) return 'tc';
|
||||
return 'en';
|
||||
}
|
||||
|
||||
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 官方推荐读取 projectId(EAS/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 ||
|
||||
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,建议在 app.json 的 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 });
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import i18n from 'i18next';
|
||||
|
||||
import { API_BASE_URL } from '@/src/constants/env';
|
||||
import type { UserProfileV1_2 } from '@/src/features/userProfileScoring';
|
||||
import type { UserProfileV1_2 } from '../features/userProfileScoring';
|
||||
import { httpJson } from '../utils/http';
|
||||
|
||||
export type RecommendedItem = {
|
||||
content_id: number;
|
||||
@@ -26,38 +26,54 @@ export type RecoRequest = {
|
||||
now?: string; // ISO8601(可选)
|
||||
};
|
||||
|
||||
function withTimeout(ms: number): AbortController {
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => controller.abort(), ms);
|
||||
return controller;
|
||||
}
|
||||
|
||||
export async function fetchRecoFeed(req: RecoRequest): Promise<RecoEngineResult> {
|
||||
const controller = withTimeout(12_000);
|
||||
const url = `${API_BASE_URL}/v1/reco/feed`;
|
||||
const acceptLanguage = i18n.language?.toLowerCase().startsWith('zh') ? 'tc' : 'en';
|
||||
|
||||
const res = await fetch(url, {
|
||||
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: {
|
||||
'Content-Type': 'application/json',
|
||||
// 让后端做 locale 选择(目前后端只区分 en/tc)
|
||||
'Accept-Language': i18n.language || 'en',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
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,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
headers,
|
||||
body: bodyObj,
|
||||
timeoutMs: 12_000,
|
||||
debugLabel: 'Feed API',
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchRecoWidget(req: RecoRequest): Promise<RecoEngineResult> {
|
||||
const acceptLanguage = i18n.language?.toLowerCase().startsWith('zh') ? 'tc' : 'en';
|
||||
|
||||
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',
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`推荐接口请求失败:${res.status} ${res.statusText} ${text}`.trim());
|
||||
}
|
||||
|
||||
return (await res.json()) as RecoEngineResult;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
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';
|
||||
@@ -31,7 +33,16 @@ export type UserProfile = {
|
||||
*/
|
||||
export type UserProfileScoring = UserProfileV1_2_Extended;
|
||||
export type DailyReminderSettings = {
|
||||
/**
|
||||
* 每天推送次数:
|
||||
* - 0:关闭
|
||||
* - 1~5:每天推送 1~5 次
|
||||
*/
|
||||
timesPerDay: number;
|
||||
/**
|
||||
* 仅用于 UI 展示与交互(开关)。
|
||||
* 后端偏好建议使用:enabled = pushEnabled && timesPerDay > 0
|
||||
*/
|
||||
pushEnabled: boolean;
|
||||
};
|
||||
|
||||
@@ -42,6 +53,12 @@ export type RecoFeedCacheItem = {
|
||||
|
||||
export type RecoFeedCache = {
|
||||
saved_at: string; // ISO8601
|
||||
/**
|
||||
* 缓存文案的语言(后端目前只区分 en / tc)
|
||||
* - en: English
|
||||
* - tc: 繁体中文
|
||||
*/
|
||||
lang?: 'en' | 'tc';
|
||||
items: RecoFeedCacheItem[];
|
||||
meta?: Record<string, unknown>;
|
||||
};
|
||||
@@ -75,6 +92,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';
|
||||
@@ -105,7 +165,12 @@ 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; // 颜色值或图片路径
|
||||
@@ -117,15 +182,15 @@ export async function getFavorites(): Promise<FavoriteItem[]> {
|
||||
|
||||
export async function addFavorite(item: FavoriteItem): Promise<void> {
|
||||
const list = await getFavorites();
|
||||
if (list.some(i => i.id === item.id)) return;
|
||||
// 允许重复点赞,不再根据 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);
|
||||
}
|
||||
|
||||
export async function removeFavorite(contentId: string): Promise<void> {
|
||||
export async function removeFavorite(favId: string): Promise<void> {
|
||||
const list = await getFavorites();
|
||||
const next = list.filter(item => item.id !== contentId);
|
||||
const next = list.filter(item => item.favId !== favId);
|
||||
await setJson(KEY_FAVORITES_ITEMS, next);
|
||||
}
|
||||
|
||||
@@ -261,13 +326,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))),
|
||||
// 需求:0~5(0 表示关闭)
|
||||
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)));
|
||||
// 需求:0~5(0 表示关闭)
|
||||
const timesPerDay = Math.min(5, Math.max(0, Math.round(settings.timesPerDay)));
|
||||
await setJson(KEY_DAILY_REMINDER_SETTINGS, {
|
||||
timesPerDay,
|
||||
pushEnabled: Boolean(settings.pushEnabled),
|
||||
|
||||
14
client/src/utils/date.ts
Normal 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}`;
|
||||
}
|
||||
|
||||
146
client/src/utils/http.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
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.stringify;GET 请求请不要传 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);
|
||||
throw new Error(`网络请求失败:${msg}`);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
89
scripts/ssh-key-to-b64.mjs
Normal 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
@@ -0,0 +1,20 @@
|
||||
.venv
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
|
||||
# 本地/测试数据
|
||||
.test.db
|
||||
*.db
|
||||
|
||||
# 测试与开发脚本(按需移除)
|
||||
tests/
|
||||
.env*
|
||||
|
||||
# Git 元数据
|
||||
.git/
|
||||
.gitignore
|
||||
32
server/Dockerfile
Normal file
@@ -0,0 +1,32 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
# 运行时基础环境
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 系统依赖(按需扩展;多数依赖为纯 Python)
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends build-essential curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 先复制依赖清单以利用 Docker layer cache
|
||||
COPY requirements.txt /app/requirements.txt
|
||||
RUN python -m pip install -U pip \
|
||||
&& pip install --no-cache-dir -r /app/requirements.txt
|
||||
|
||||
# 复制后端代码与迁移配置
|
||||
COPY app /app/app
|
||||
COPY alembic /app/alembic
|
||||
COPY alembic.ini /app/alembic.ini
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
# 注意:
|
||||
# - 镜像内不会打包 `.env.dev/.env.prod`(避免把敏感信息烘焙进镜像)
|
||||
# - 运行容器时请通过 `--env-file` 或 `-e` 注入 DATABASE_URL / REDIS_URL / CELERY_BROKER_URL
|
||||
# - 参考文档:server/README.md
|
||||
|
||||
# 生产镜像默认不开启 reload
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -82,6 +82,11 @@ CELERY_BROKER_URL=redis://dev_user:devpassword@127.0.0.1:6379/0
|
||||
|
||||
`.env.prod` 同理,替换为生产环境地址与密钥即可。
|
||||
|
||||
补充:
|
||||
|
||||
- 仓库内提供了一个不包含真实值的模板文件 `server/env.example`,可复制为 `.env.dev/.env.prod` 后再填写。
|
||||
- **Docker 不会自动读取 `.env.*`**,容器运行时需要通过 `--env-file` 或 `-e` 注入环境变量(见下方 Docker 运行)。
|
||||
|
||||
### 1.1 MySQL 命名与 dev/pro 区分(约定)
|
||||
|
||||
- **生产库(prod)**:`mindfulness`
|
||||
@@ -146,6 +151,38 @@ uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
- OpenAPI 文档:`/docs`
|
||||
- ReDoc:`/redoc`
|
||||
|
||||
## Docker 运行
|
||||
|
||||
后端启动至少需要以下 3 个环境变量:
|
||||
|
||||
- `DATABASE_URL`
|
||||
- `REDIS_URL`
|
||||
- `CELERY_BROKER_URL`
|
||||
|
||||
### 方式 A:使用 env 文件(推荐)
|
||||
|
||||
1) 在宿主机准备 `server/.env.prod`(或 `.env.dev`),内容为 `KEY=value`:
|
||||
|
||||
- 可从 `server/env.example` 复制后填写
|
||||
|
||||
2) 运行容器时通过 `--env-file` 注入:
|
||||
|
||||
```bash
|
||||
docker run --rm -p 8000:8000 \
|
||||
--env-file server/.env.prod \
|
||||
mindfulness-server:latest
|
||||
```
|
||||
|
||||
### 方式 B:直接用 -e 注入
|
||||
|
||||
```bash
|
||||
docker run --rm -p 8000:8000 \
|
||||
-e DATABASE_URL="mysql+aiomysql://用户名:密码@mysql:3306/mindfulness?charset=utf8mb4" \
|
||||
-e REDIS_URL="redis://:密码@redis:6379/0" \
|
||||
-e CELERY_BROKER_URL="redis://:密码@redis:6379/0" \
|
||||
mindfulness-server:latest
|
||||
```
|
||||
|
||||
## 数据库迁移(Alembic)
|
||||
|
||||
> 若你采用 Alembic:建议把迁移脚本放在 `server/alembic/`,并在 `alembic.ini` 中配置数据库连接(或从环境变量读取)。
|
||||
|
||||
123
server/alembic/versions/0002_init_push_tables.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""init push tables
|
||||
|
||||
Revision ID: 0002_init_push_tables
|
||||
Revises: 0001_init_content_tables
|
||||
Create Date: 2026-02-03
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import mysql
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "0002_init_push_tables"
|
||||
down_revision = "0001_init_content_tables"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# push_tokens
|
||||
op.create_table(
|
||||
"push_tokens",
|
||||
sa.Column(
|
||||
"id",
|
||||
mysql.BIGINT(unsigned=True),
|
||||
primary_key=True,
|
||||
autoincrement=True,
|
||||
comment="主键",
|
||||
),
|
||||
sa.Column("client_user_id", sa.String(length=64), nullable=False, comment="客户端用户标识(UUID)"),
|
||||
sa.Column(
|
||||
"platform",
|
||||
sa.Enum("ios", "android", name="push_platform"),
|
||||
nullable=False,
|
||||
comment="平台",
|
||||
),
|
||||
sa.Column("push_token", sa.String(length=255), nullable=False, comment="Expo Push Token"),
|
||||
sa.Column("app_id", sa.String(length=255), nullable=False, comment="bundle id / package name(用于隔离)"),
|
||||
sa.Column(
|
||||
"env",
|
||||
sa.Enum("dev", "prod", name="push_env"),
|
||||
nullable=False,
|
||||
comment="环境隔离",
|
||||
),
|
||||
sa.Column(
|
||||
"is_active",
|
||||
sa.Boolean(),
|
||||
server_default=sa.text("1"),
|
||||
nullable=False,
|
||||
comment="是否有效(发送失败且不可恢复时置为 false)",
|
||||
),
|
||||
sa.Column("last_seen_at", sa.DateTime(), server_default=sa.func.now(), nullable=False, comment="最后一次上报时间"),
|
||||
sa.UniqueConstraint("env", "app_id", "push_token", name="uniq_env_app_token"),
|
||||
mysql_charset="utf8mb4",
|
||||
)
|
||||
op.create_index("idx_push_tokens_client_user_id", "push_tokens", ["client_user_id"], unique=False)
|
||||
op.create_index("idx_push_tokens_is_active", "push_tokens", ["is_active"], unique=False)
|
||||
|
||||
# push_preferences
|
||||
op.create_table(
|
||||
"push_preferences",
|
||||
sa.Column("client_user_id", sa.String(length=64), primary_key=True, comment="客户端用户标识(UUID)"),
|
||||
sa.Column(
|
||||
"enabled",
|
||||
sa.Boolean(),
|
||||
server_default=sa.text("0"),
|
||||
nullable=False,
|
||||
comment="是否开启每日提醒(enabled=false 或 times_per_day=0 均视为关闭)",
|
||||
),
|
||||
sa.Column(
|
||||
"times_per_day",
|
||||
sa.SmallInteger(),
|
||||
server_default="0",
|
||||
nullable=False,
|
||||
comment="每天推送次数(0~5)",
|
||||
),
|
||||
sa.Column("timezone", sa.String(length=64), nullable=True, comment="IANA 时区(例如 Asia/Shanghai),来自客户端上报"),
|
||||
sa.Column("locale", sa.String(length=32), nullable=True, comment="客户端语言(例如 zh-CN/en/zh-TW),用于文案语言选择"),
|
||||
sa.Column("user_profile_json", sa.JSON(), nullable=True, comment="用户画像(V1.2;可选)。用于 Push 场景推荐文案生成。"),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False, comment="更新时间"),
|
||||
mysql_charset="utf8mb4",
|
||||
)
|
||||
|
||||
# push_send_log
|
||||
op.create_table(
|
||||
"push_send_log",
|
||||
sa.Column(
|
||||
"id",
|
||||
mysql.BIGINT(unsigned=True),
|
||||
primary_key=True,
|
||||
autoincrement=True,
|
||||
comment="主键",
|
||||
),
|
||||
sa.Column("client_user_id", sa.String(length=64), nullable=False, comment="客户端用户标识(UUID)"),
|
||||
sa.Column("local_date", sa.Date(), nullable=False, comment="用户时区的本地日期(用于幂等)"),
|
||||
sa.Column("slot_index", sa.SmallInteger(), nullable=False, comment="当天第几条(1..times_per_day)"),
|
||||
sa.Column("scheduled_at", sa.DateTime(), nullable=False, comment="计划发送时间(UTC)"),
|
||||
sa.Column("sent_at", sa.DateTime(), nullable=True, comment="实际发送时间(UTC)"),
|
||||
sa.Column("status", sa.String(length=16), server_default="scheduled", nullable=False, comment="scheduled/sent/failed"),
|
||||
sa.Column("error", sa.Text(), nullable=True, comment="失败原因(可选)"),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False, comment="创建时间"),
|
||||
sa.UniqueConstraint("client_user_id", "local_date", "slot_index", name="uniq_user_date_slot"),
|
||||
mysql_charset="utf8mb4",
|
||||
)
|
||||
op.create_index("idx_push_log_user_date", "push_send_log", ["client_user_id", "local_date"], unique=False)
|
||||
op.create_index("idx_push_log_status", "push_send_log", ["status"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("idx_push_log_status", table_name="push_send_log")
|
||||
op.drop_index("idx_push_log_user_date", table_name="push_send_log")
|
||||
op.drop_table("push_send_log")
|
||||
|
||||
op.drop_table("push_preferences")
|
||||
|
||||
op.drop_index("idx_push_tokens_is_active", table_name="push_tokens")
|
||||
op.drop_index("idx_push_tokens_client_user_id", table_name="push_tokens")
|
||||
op.drop_table("push_tokens")
|
||||
|
||||
@@ -47,6 +47,7 @@ class FixedWindowRateLimiter:
|
||||
|
||||
|
||||
_reco_rate_limiter = FixedWindowRateLimiter(limit=10, window_seconds=60)
|
||||
_push_rate_limiter = FixedWindowRateLimiter(limit=30, window_seconds=60)
|
||||
|
||||
|
||||
async def rate_limit_reco_by_ip(request: Request) -> None:
|
||||
@@ -60,3 +61,19 @@ async def rate_limit_reco_by_ip(request: Request) -> None:
|
||||
|
||||
_reco_rate_limiter.allow(key=ip, now_ts=time.time())
|
||||
|
||||
|
||||
async def rate_limit_push_by_ip(request: Request) -> None:
|
||||
"""
|
||||
推送相关接口限流:按 IP,1 分钟 30 次。
|
||||
|
||||
说明:
|
||||
- register/preferences 等接口可能在客户端反复重试
|
||||
- 本期先用内存固定窗口限流做基础保护
|
||||
"""
|
||||
|
||||
ip = "unknown"
|
||||
if request.client and request.client.host:
|
||||
ip = str(request.client.host)
|
||||
|
||||
_push_rate_limiter.allow(key=ip, now_ts=time.time())
|
||||
|
||||
|
||||