Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e552e22de9 | ||
|
|
1fbc0aa3f8 | ||
|
|
b5532df161 | ||
| 5515726465 | |||
|
|
ee2d9f44ea | ||
|
|
ce018880f4 | ||
|
|
b4ec17fcac | ||
|
|
aa4e1e9947 | ||
| 4578d503e7 | |||
|
|
f03d36b5e9 | ||
| 66241e5231 | |||
|
|
e980bd4e4d | ||
|
|
0b8bbebf6a | ||
|
|
1e1e49ea57 | ||
|
|
8e71503169 | ||
|
|
2b67a571bb | ||
|
|
8f84f25616 | ||
|
|
4c03fce720 | ||
|
|
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 | ||
|
|
996999453a | ||
|
|
3f91e734fa |
@@ -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标记,并且说明变更的文件名
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
当前有一个很大的 spec.md(大需求规范),需要按业务逻辑拆分成多个子模块规范。
|
||||
当前有一个很大的 spec.md(大需求规范),需要按业务逻辑合理拆分成多个子模块规范。
|
||||
|
||||
请按以下规则拆分:
|
||||
|
||||
|
||||
@@ -68,12 +68,13 @@ jobs:
|
||||
echo "IMAGE_TAG=${NEXT_TAG}" >> "$GITEA_ENV"
|
||||
fi
|
||||
|
||||
# 3️⃣ 设置 Docker 镜像名称
|
||||
# 3️⃣ 设置镜像仓库与镜像名称(自建 Registry / Docker Hub 都可)
|
||||
- name: Set image variables
|
||||
shell: bash
|
||||
run: |
|
||||
# 修改为你的 Docker Hub 仓库名,例如:yourname/mindfulness-server
|
||||
IMAGE_NAME=docker.damer.fun/damer/mindfulness-server
|
||||
# 直接写死:推送到自建仓库
|
||||
IMAGE_NAME="docker.damer.fun/damer/mindfulness-server"
|
||||
|
||||
if [[ -n "${GITHUB_ENV:-}" ]]; then
|
||||
echo "IMAGE_NAME=$IMAGE_NAME" >> "$GITHUB_ENV"
|
||||
fi
|
||||
@@ -81,12 +82,14 @@ jobs:
|
||||
echo "IMAGE_NAME=$IMAGE_NAME" >> "$GITEA_ENV"
|
||||
fi
|
||||
|
||||
# 4️⃣ 登录 Docker Hub
|
||||
- name: Login to Docker Hub
|
||||
# 4️⃣ 登录镜像仓库(自建 Registry / Docker Hub)
|
||||
- name: Login to Docker Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
# 与 IMAGE_NAME 的 registry 保持一致
|
||||
registry: docker.damer.fun
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
# 5️⃣ 构建 Docker 镜像(使用 server/ 作为构建上下文)
|
||||
- name: Build Docker Image
|
||||
@@ -94,7 +97,7 @@ jobs:
|
||||
run: |
|
||||
docker build -f server/Dockerfile -t "$IMAGE_NAME:$IMAGE_TAG" server
|
||||
|
||||
# 6️⃣ 推送 Docker 镜像到 Docker Hub
|
||||
# 6️⃣ 推送 Docker 镜像到镜像仓库
|
||||
- name: Push Docker Image
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
497
.gitea/workflows/server-deploy.yml
Normal file
@@ -0,0 +1,497 @@
|
||||
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 }}
|
||||
|
||||
# 定时服务健康检查路径(检查 Redis/Worker/Beat;未配置则默认 /v1/push/scheduler/health)
|
||||
SCHEDULER_HEALTHCHECK_PATH: ${{ vars.SCHEDULER_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}"
|
||||
SCHEDULER_HEALTHCHECK_PATH="${SCHEDULER_HEALTHCHECK_PATH:-/v1/push/scheduler/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}" "${SCHEDULER_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"
|
||||
SCHEDULER_HEALTHCHECK_PATH="$9"
|
||||
REMOTE_ENV_FILE="${10}"
|
||||
|
||||
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}"
|
||||
|
||||
# 启动新颜色容器(API + Worker + Beat)
|
||||
API_NAME="mindfulness-server-api-${NEW_COLOR}"
|
||||
WORKER_NAME="mindfulness-server-worker-${NEW_COLOR}"
|
||||
BEAT_NAME="mindfulness-server-beat-${NEW_COLOR}"
|
||||
|
||||
# 兼容旧命名:之前可能只有一个 mindfulness-server-blue/green
|
||||
${SUDO} docker rm -f "mindfulness-server-${NEW_COLOR}" >/dev/null 2>&1 || true
|
||||
|
||||
${SUDO} docker rm -f "${API_NAME}" >/dev/null 2>&1 || true
|
||||
${SUDO} docker rm -f "${WORKER_NAME}" >/dev/null 2>&1 || true
|
||||
${SUDO} docker rm -f "${BEAT_NAME}" >/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
|
||||
|
||||
# API(对外暴露端口,仅该容器参与蓝绿切流)
|
||||
${SUDO} docker run -d \
|
||||
--name "${API_NAME}" \
|
||||
--restart=always \
|
||||
-p "${NEW_PORT}:${CONTAINER_PORT}" \
|
||||
"${ENV_FILE_ARGS[@]}" \
|
||||
-e START_API=1 -e START_WORKER=0 -e START_BEAT=0 \
|
||||
"${IMAGE}:${TAG}"
|
||||
|
||||
# Worker(处理异步/ETA 任务,不暴露端口)
|
||||
${SUDO} docker run -d \
|
||||
--name "${WORKER_NAME}" \
|
||||
--restart=always \
|
||||
"${ENV_FILE_ARGS[@]}" \
|
||||
-e START_API=0 -e START_WORKER=1 -e START_BEAT=0 \
|
||||
"${IMAGE}:${TAG}"
|
||||
|
||||
# Beat(定时调度,不暴露端口)
|
||||
${SUDO} docker run -d \
|
||||
--name "${BEAT_NAME}" \
|
||||
--restart=always \
|
||||
"${ENV_FILE_ARGS[@]}" \
|
||||
-e START_API=0 -e START_WORKER=0 -e START_BEAT=1 \
|
||||
"${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 "${API_NAME}" || true
|
||||
${SUDO} docker rm -f "${API_NAME}" "${WORKER_NAME}" "${BEAT_NAME}" >/dev/null 2>&1 || true
|
||||
exit 1
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# 定时服务健康检查(确保 Redis/Worker/Beat 都 OK,避免“接口正常但定时任务没跑”)
|
||||
if [[ "${SCHEDULER_HEALTHCHECK_PATH}" != /* ]]; then
|
||||
SCHEDULER_HEALTHCHECK_PATH="/${SCHEDULER_HEALTHCHECK_PATH}"
|
||||
fi
|
||||
SCHED_URL="http://127.0.0.1:${NEW_PORT}${SCHEDULER_HEALTHCHECK_PATH}"
|
||||
echo "定时服务健康检查:${SCHED_URL}"
|
||||
|
||||
# Beat 心跳是按分钟刷新,这里最多等 90 秒(45*2s)
|
||||
for i in $(seq 1 45); do
|
||||
RES="$(curl -fsS "${SCHED_URL}" 2>/dev/null || true)"
|
||||
if [[ -n "${RES}" ]] \
|
||||
&& echo "${RES}" | grep -q '"redis":{"ok":true' \
|
||||
&& echo "${RES}" | grep -q '"worker":{"ok":true' \
|
||||
&& echo "${RES}" | grep -q '"beat":{"ok":true' ; then
|
||||
echo "定时服务健康检查通过:${RES}"
|
||||
break
|
||||
fi
|
||||
|
||||
if [[ "$i" -eq 45 ]]; then
|
||||
echo "定时服务健康检查失败:${RES}"
|
||||
echo "Worker/Beat 日志(各 120 行):"
|
||||
${SUDO} docker logs --tail 120 "${WORKER_NAME}" || true
|
||||
${SUDO} docker logs --tail 120 "${BEAT_NAME}" || true
|
||||
${SUDO} docker rm -f "${API_NAME}" "${WORKER_NAME}" "${BEAT_NAME}" >/dev/null 2>&1 || 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 "${API_NAME}" "${WORKER_NAME}" "${BEAT_NAME}" >/dev/null 2>&1 || 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 "${API_NAME}" "${WORKER_NAME}" "${BEAT_NAME}" >/dev/null 2>&1 || 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 "${API_NAME}" "${WORKER_NAME}" "${BEAT_NAME}" >/dev/null 2>&1 || 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 "${API_NAME}" "${WORKER_NAME}" "${BEAT_NAME}" >/dev/null 2>&1 || 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 "${API_NAME}" "${WORKER_NAME}" "${BEAT_NAME}" >/dev/null 2>&1 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 记录当前在线颜色
|
||||
echo "${NEW_COLOR}" | ${SUDO} tee "${ACTIVE_FILE}" >/dev/null
|
||||
|
||||
# 下线旧容器(切流后再停旧的)
|
||||
OLD_API_NAME="mindfulness-server-api-${OLD_COLOR}"
|
||||
OLD_WORKER_NAME="mindfulness-server-worker-${OLD_COLOR}"
|
||||
OLD_BEAT_NAME="mindfulness-server-beat-${OLD_COLOR}"
|
||||
${SUDO} docker rm -f "${OLD_API_NAME}" "${OLD_WORKER_NAME}" "${OLD_BEAT_NAME}" >/dev/null 2>&1 || true
|
||||
# 兼容旧命名
|
||||
${SUDO} docker rm -f "mindfulness-server-${OLD_COLOR}" >/dev/null 2>&1 || true
|
||||
|
||||
echo "部署完成:${NEW_COLOR} 已上线"
|
||||
REMOTE
|
||||
|
||||
@@ -13,7 +13,9 @@ git pull
|
||||
|
||||
# 创建自己的分支
|
||||
git checkout -b 姓名拼写
|
||||
# 生产密钥
|
||||
|
||||
ssh-keygen -t rsa -b 4096 -m PEM -N '' -f deploy_key_rsa
|
||||
# 目录结构
|
||||
|
||||
/mindfulness
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
EXPO_PUBLIC_API_BASE_URL=http://localhost:8000
|
||||
EXPO_PUBLIC_ENV=dev
|
||||
EXPO_PUBLIC_DEFAULT_LANGUAGE=auto
|
||||
#
|
||||
# Expo/EAS 项目 ID(UUID)。用于真机获取 Expo Push Token(expo-notifications)。
|
||||
# 获取方式:在 client 目录执行 `eas project:init` 或 `eas project:info` 查看。
|
||||
EXPO_PUBLIC_EAS_PROJECT_ID=c519f016-e5c8-426c-868f-5545dce8beef
|
||||
|
||||
1
client/.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
registry=https://registry.npmmirror.com
|
||||
33
client/app.config.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { ConfigContext, ExpoConfig } from 'expo/config';
|
||||
|
||||
/**
|
||||
* 运行时获取 Push Token(expo-notifications)在真机/Dev Client 场景下通常需要 projectId。
|
||||
*
|
||||
* 这里把 projectId 注入到 `extra.eas.projectId`:
|
||||
* - 开发/本地:从 `.env.local`(EXPO_PUBLIC_EAS_PROJECT_ID)读取并写入配置
|
||||
* - CI/EAS:也可通过环境变量注入(EXPO_PUBLIC_EAS_PROJECT_ID 或 EAS_PROJECT_ID)
|
||||
*/
|
||||
export default ({ config }: ConfigContext): ExpoConfig => {
|
||||
const projectId =
|
||||
process.env.EXPO_PUBLIC_EAS_PROJECT_ID ||
|
||||
// 兼容部分 CI/EAS 注入的变量名
|
||||
process.env.EAS_PROJECT_ID;
|
||||
|
||||
return {
|
||||
...config,
|
||||
// ExpoConfig 的类型要求 name 必填,避免 `...config` 的可选类型导致 tsc 报错
|
||||
name: config.name ?? 'client',
|
||||
// slug 在绝大多数场景也建议固定为非空字符串(保持与 app.json 一致)
|
||||
slug: config.slug ?? 'client',
|
||||
extra: {
|
||||
...(config.extra ?? {}),
|
||||
eas: {
|
||||
// 保留已有配置,再覆盖 projectId
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
...(((config.extra as any) ?? {}).eas ?? {}),
|
||||
projectId: projectId ?? (config.extra as any)?.eas?.projectId,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "client",
|
||||
"name": "Dear Mama",
|
||||
"slug": "client",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
@@ -9,9 +9,9 @@
|
||||
"userInterfaceStyle": "automatic",
|
||||
"newArchEnabled": true,
|
||||
"splash": {
|
||||
"image": "./assets/images/splash-icon.png",
|
||||
"image": "./assets/images/Screen_page.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff"
|
||||
"backgroundColor": "#EAD2BA"
|
||||
},
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
@@ -23,7 +23,8 @@
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"edgeToEdgeEnabled": true,
|
||||
"predictiveBackGestureEnabled": false
|
||||
"predictiveBackGestureEnabled": false,
|
||||
"package": "com.damer.mindfulness"
|
||||
},
|
||||
"web": {
|
||||
"bundler": "metro",
|
||||
@@ -35,6 +36,13 @@
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true
|
||||
}
|
||||
},
|
||||
"extra": {
|
||||
"eas": {
|
||||
"projectId": "c519f016-e5c8-426c-868f-5545dce8beef"
|
||||
},
|
||||
"router": {}
|
||||
},
|
||||
"owner": "damersu"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,18 @@
|
||||
import { useEffect, useLayoutEffect, useMemo, useState, useCallback, useRef } from 'react';
|
||||
import { StyleSheet, View, Dimensions, Text, Pressable, PanResponder, Animated as RNAnimated, ImageBackground } from 'react-native';
|
||||
import {
|
||||
StyleSheet,
|
||||
View,
|
||||
Dimensions,
|
||||
Text,
|
||||
Pressable,
|
||||
PanResponder,
|
||||
Animated as RNAnimated,
|
||||
ImageBackground,
|
||||
Platform,
|
||||
} 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,20 +25,23 @@ 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,
|
||||
getSuixinThemeState,
|
||||
setSuixinThemeState,
|
||||
type SuixinThemeStateV1,
|
||||
} from '@/src/storage/appStorage';
|
||||
|
||||
import { fetchRecoFeed } from '@/src/services/recoApi';
|
||||
import { toBackendLocaleFromLanguageTag } from '@/src/i18n/locale';
|
||||
|
||||
import ProfileModal from '@/components/home/ProfileModal';
|
||||
import ThemeModal from '@/components/home/ThemeModal';
|
||||
@@ -37,6 +51,12 @@ import MyIcon from '@/assets/images/home/my.svg';
|
||||
import LikeFilledIcon from '@/assets/images/home/like_filled.svg';
|
||||
import LikeIcon from '@/assets/images/icon/like_icon.svg';
|
||||
|
||||
import { getBootId } from '@/src/utils/bootSession';
|
||||
import { advanceSuixinState, buildInitialSuixinState, NEUTRAL_THEME_COLORS } from '@/src/features/suixinTheme';
|
||||
import { wrapText } from '@/src/features/textWrap';
|
||||
import { defaultMeasureWidthImpl } from '@/src/features/textWrap/measure';
|
||||
import { ensureDailyWidgetRecoUpToDate } from '@/src/modules/dailyWidgetReco';
|
||||
|
||||
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
|
||||
|
||||
// 预定义风景图列表
|
||||
@@ -73,100 +93,341 @@ const THEME_COLORS = [
|
||||
'#CBD9F2',
|
||||
];
|
||||
|
||||
type FeedItem = { content_id: string; text: string };
|
||||
|
||||
export default function HomeScreen() {
|
||||
const { t } = useTranslation();
|
||||
const navigation = useNavigation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const isEnglish = i18n.language?.startsWith('en');
|
||||
const recoLang: 'en' | 'tc' = toBackendLocaleFromLanguageTag(i18n.language);
|
||||
const insets = useSafeAreaInsets();
|
||||
const [index, setIndex] = useState(0);
|
||||
const [themeMode, setThemeModeState] = useState<ThemeMode>('scenery');
|
||||
const [suixinBgColor, setSuixinBgColor] = useState<string>(NEUTRAL_THEME_COLORS[1]);
|
||||
const [themeOpen, setThemeOpen] = useState(false);
|
||||
const [profileOpen, setProfileOpen] = useState(false);
|
||||
const [profileName, setProfileName] = useState<string | undefined>(undefined);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [likeFilled, setLikeFilled] = useState(false);
|
||||
const [feedItems, setFeedItems] = useState<Array<{ content_id: number; text: string }>>([]);
|
||||
const [feedItems, setFeedItems] = useState<FeedItem[]>([]);
|
||||
const [isFetching, setIsFetching] = useState(false);
|
||||
const [cardWidth, setCardWidth] = useState<number | null>(null);
|
||||
const [wrappedText, setWrappedText] = useState<string>('');
|
||||
const wrapLogRef = useRef<{ key: string } | null>(null);
|
||||
const busyRef = useRef(false);
|
||||
const indexRef = useRef(0);
|
||||
const currentFeedRef = useRef<FeedItem[]>([]);
|
||||
const likedIdsRef = useRef<Set<string>>(new Set());
|
||||
const likeInFlightRef = useRef(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;
|
||||
useEffect(() => {
|
||||
busyRef.current = busy;
|
||||
}, [busy]);
|
||||
useEffect(() => {
|
||||
indexRef.current = index;
|
||||
}, [index]);
|
||||
|
||||
// 解决语言切换时重复触发拉取/清空导致“文案不停跳动”的问题:
|
||||
// 用 ref 持有最新状态,避免 useCallback 依赖 feedItems/isFetching 造成函数 identity 变化 → effect 重复执行
|
||||
const feedItemsRef = useRef<FeedItem[]>([]);
|
||||
const isFetchingRef = useRef(false);
|
||||
const themeModeRef = useRef<ThemeMode>('scenery');
|
||||
const suixinStateRef = useRef<SuixinThemeStateV1 | null>(null);
|
||||
useEffect(() => {
|
||||
feedItemsRef.current = feedItems;
|
||||
}, [feedItems]);
|
||||
useEffect(() => {
|
||||
isFetchingRef.current = isFetching;
|
||||
}, [isFetching]);
|
||||
useEffect(() => {
|
||||
themeModeRef.current = themeMode;
|
||||
}, [themeMode]);
|
||||
|
||||
const ensureSuixinReady = useCallback(async () => {
|
||||
const bootId = getBootId();
|
||||
const stored = await getSuixinThemeState();
|
||||
if (stored && stored.boot_id === bootId) {
|
||||
suixinStateRef.current = stored;
|
||||
setSuixinBgColor(stored.last_color || NEUTRAL_THEME_COLORS[1]);
|
||||
return stored;
|
||||
}
|
||||
|
||||
const profile = await getUserProfileScoring();
|
||||
const next = buildInitialSuixinState({ bootId, profile });
|
||||
suixinStateRef.current = next;
|
||||
setSuixinBgColor(next.last_color || NEUTRAL_THEME_COLORS[1]);
|
||||
await setSuixinThemeState(next);
|
||||
return next;
|
||||
}, []);
|
||||
|
||||
const advanceSuixinOnNextContent = useCallback(async () => {
|
||||
if (themeModeRef.current !== 'suixin') return;
|
||||
|
||||
const bootId = getBootId();
|
||||
let current = suixinStateRef.current;
|
||||
if (!current) {
|
||||
current = await getSuixinThemeState();
|
||||
}
|
||||
|
||||
// 冷启动后首次触发/或状态丢失:先初始化
|
||||
if (!current || current.boot_id !== bootId) {
|
||||
await ensureSuixinReady();
|
||||
return;
|
||||
}
|
||||
|
||||
const next = advanceSuixinState(current);
|
||||
suixinStateRef.current = next;
|
||||
setSuixinBgColor(next.last_color || NEUTRAL_THEME_COLORS[1]);
|
||||
await setSuixinThemeState(next);
|
||||
}, [ensureSuixinReady]);
|
||||
|
||||
// 动画相关 Shared Values
|
||||
const translateY = useSharedValue(0);
|
||||
const opacity = useSharedValue(1);
|
||||
const likeScale = useSharedValue(1);
|
||||
|
||||
// 每次进入页面或页面获得焦点时刷新个人信息
|
||||
// 统一文案对象结构
|
||||
const currentFeed = useMemo(() => {
|
||||
if (feedItems.length > 0) {
|
||||
return feedItems;
|
||||
}
|
||||
return MOCK_CONTENT.map(item => ({
|
||||
content_id: item.id,
|
||||
text: t(item.textKey)
|
||||
}));
|
||||
}, [feedItems, t]);
|
||||
useEffect(() => {
|
||||
currentFeedRef.current = currentFeed;
|
||||
}, [currentFeed]);
|
||||
|
||||
const item = useMemo(() => {
|
||||
const data = currentFeed[index % currentFeed.length];
|
||||
return {
|
||||
id: String(data.content_id),
|
||||
text: data.text
|
||||
};
|
||||
}, [currentFeed, index]);
|
||||
|
||||
// Home 文案:使用自主换行算法(Text Wrap 模块)
|
||||
// - 通过 onLayout 获取容器宽度
|
||||
// - 注入真实测量实现,确保“宽度派”评分与实际渲染一致
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
// 未拿到宽度前先用原文(避免闪烁)
|
||||
if (!cardWidth || cardWidth <= 0) {
|
||||
if (__DEV__) {
|
||||
const key = `noWidth|${item.id}|${String(cardWidth)}`;
|
||||
if (wrapLogRef.current?.key !== key) {
|
||||
wrapLogRef.current = { key };
|
||||
console.log('[TextWrap][Home] cardWidth 未就绪,先回退原文', {
|
||||
itemId: item.id,
|
||||
lang: recoLang,
|
||||
cardWidth,
|
||||
themeMode,
|
||||
});
|
||||
}
|
||||
}
|
||||
setWrappedText(item.text);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
const paddingHorizontal = themeMode === 'scenery' ? 50 : 30;
|
||||
const availableWidth = Math.max(0, Math.floor(cardWidth - paddingHorizontal * 2));
|
||||
|
||||
const lang = recoLang === 'en' ? 'EN' : 'TC';
|
||||
|
||||
const fontFamily =
|
||||
lang === 'EN'
|
||||
? 'STIXTwoText'
|
||||
: Platform.select({
|
||||
ios: 'System',
|
||||
android: 'sans-serif',
|
||||
default: 'System',
|
||||
});
|
||||
|
||||
const fontSpec = {
|
||||
fontSize: 22,
|
||||
fontWeight: lang === 'EN' ? '600' : '700',
|
||||
fontFamily: String(fontFamily ?? 'System'),
|
||||
};
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
if (__DEV__) {
|
||||
const key = `start|${item.id}|${lang}|${availableWidth}|${themeMode}`;
|
||||
if (wrapLogRef.current?.key !== key) {
|
||||
wrapLogRef.current = { key };
|
||||
console.log('[TextWrap][Home] wrapText 开始', {
|
||||
itemId: item.id,
|
||||
lang,
|
||||
themeMode,
|
||||
cardWidth,
|
||||
paddingHorizontal,
|
||||
availableWidth,
|
||||
fontSpec,
|
||||
textPreview: String(item.text ?? '').slice(0, 80),
|
||||
textLength: String(item.text ?? '').length,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const res = await wrapText({
|
||||
text: item.text,
|
||||
lang,
|
||||
context: 'APP',
|
||||
availableWidth,
|
||||
maxLines: 3,
|
||||
overflowMode: 'CLIP',
|
||||
lineMode: 'AUTO',
|
||||
// Home:采用“更偏好语气停顿/更好看”的排版风格微调(不影响算法默认 v1)
|
||||
configVersion: 'v1-home',
|
||||
debug: __DEV__,
|
||||
fontSpec,
|
||||
contextProfile: `APP|${Platform.OS}|home|${lang}`,
|
||||
measureWidthImpl: defaultMeasureWidthImpl,
|
||||
scoringOverrides:
|
||||
lang === 'TC'
|
||||
? {
|
||||
// 更偏好在逗号/句号等处断行(即便宽度允许也不一定要塞满)
|
||||
weights: { R_PUNCT_BREAK: 180 },
|
||||
// 让“理想行宽”更短,避免宽屏下过度延后断行
|
||||
idealWidthRatio: { APP: 0.82 },
|
||||
// 更宽容短行(尤其是第一行在标点处停顿)
|
||||
minPreferredRatio: 0.45,
|
||||
shortLastLineRatio: 0.45,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
if (cancelled) return;
|
||||
if (__DEV__) {
|
||||
console.log('[TextWrap][Home] wrapText 成功', {
|
||||
itemId: item.id,
|
||||
wrappedText: res.wrappedText,
|
||||
linesCount: res.lines.length,
|
||||
meta: res.meta,
|
||||
});
|
||||
}
|
||||
setWrappedText(res.wrappedText);
|
||||
} catch (error) {
|
||||
// 任何异常都回退到原文,避免影响 Home 主流程
|
||||
if (__DEV__) {
|
||||
console.log('[TextWrap][Home] wrapText 异常,回退原文', {
|
||||
itemId: item.id,
|
||||
lang,
|
||||
availableWidth,
|
||||
fontSpec,
|
||||
errorName: (error as any)?.name,
|
||||
errorMessage: String((error as any)?.message ?? error),
|
||||
errorStack: (error as any)?.stack,
|
||||
});
|
||||
}
|
||||
if (cancelled) return;
|
||||
setWrappedText(item.text);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [item.text, cardWidth, recoLang, themeMode]);
|
||||
|
||||
// 异步拉取新文案
|
||||
const fetchNewFeed = useCallback(async () => {
|
||||
if (isFetchingRef.current) return;
|
||||
isFetchingRef.current = true;
|
||||
setIsFetching(true);
|
||||
try {
|
||||
const scoringProfile = await getUserProfileScoring();
|
||||
if (!scoringProfile) return;
|
||||
|
||||
const history = await getRecoFeedHistory();
|
||||
|
||||
const { items, meta } = await fetchRecoFeed({
|
||||
k: 30,
|
||||
user_profile: scoringProfile,
|
||||
already_recommended_ids: history.already_recommended_ids,
|
||||
touched_or_viewed_ids: history.touched_or_viewed_ids,
|
||||
});
|
||||
|
||||
if (items.length > 0) {
|
||||
const wasEmpty = feedItemsRef.current.length === 0;
|
||||
const newCache = {
|
||||
saved_at: new Date().toISOString(),
|
||||
lang: recoLang,
|
||||
items: items.map((x) => ({ content_id: x.content_id, text: x.text })),
|
||||
meta: meta as Record<string, unknown>,
|
||||
};
|
||||
await setRecoFeedCache(newCache);
|
||||
await recordRecoFeedServed(items.map((x) => x.content_id));
|
||||
setFeedItems(newCache.items.map((x) => ({ content_id: String(x.content_id), text: x.text })));
|
||||
// 如果当前是 mock 数据,切换到新数据的第一条
|
||||
if (wasEmpty) {
|
||||
setIndex(0);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch new feed:', error);
|
||||
} finally {
|
||||
isFetchingRef.current = false;
|
||||
setIsFetching(false);
|
||||
}
|
||||
}, [recoLang]);
|
||||
|
||||
// 每次进入页面或页面获得焦点时刷新个人信息和缓存文案
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const mode = await getThemeMode();
|
||||
const profile = await getUserProfile();
|
||||
const cache = await getRecoFeedCache();
|
||||
|
||||
if (cancelled) return;
|
||||
setThemeModeState(mode);
|
||||
setProfileName(profile.name);
|
||||
|
||||
// 随心:若当前主题为随心,进入 Home 时确保状态就绪(仅冷启动会话重算)
|
||||
if (mode === 'suixin') {
|
||||
ensureSuixinReady().catch(() => {
|
||||
// ignore:失败时回退默认中性底色
|
||||
setSuixinBgColor(NEUTRAL_THEME_COLORS[1]);
|
||||
});
|
||||
}
|
||||
|
||||
// 语言切换时:旧语言缓存不复用,触发重新拉取
|
||||
if (cache && cache.items.length > 0 && (cache.lang ?? 'en') === recoLang) {
|
||||
setFeedItems(cache.items.map((x) => ({ content_id: String(x.content_id), text: x.text })));
|
||||
} else {
|
||||
// 语言不匹配或没有缓存:先清空回落到本地 mock(会立即随语言切换),再拉取对应语言的推荐文案
|
||||
setFeedItems([]);
|
||||
setIndex(0);
|
||||
fetchNewFeed();
|
||||
}
|
||||
|
||||
// Widget:前台辅助刷新(尽力而为)
|
||||
// - 写入 App Group 的 dailyReco 缓存
|
||||
// - 生成 wrapped_text_by_family,供 Widget 直接渲染
|
||||
ensureDailyWidgetRecoUpToDate({ reason: 'home_focus' }).catch(() => {});
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [])
|
||||
}, [fetchNewFeed, recoLang, ensureSuixinReady])
|
||||
);
|
||||
|
||||
// 首次进入:先读缓存,再拉后端 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 scoring = await getUserProfileScoring();
|
||||
if (!scoring) return;
|
||||
|
||||
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 = useMemo(() => {
|
||||
if (themeMode === 'suixin') {
|
||||
return suixinBgColor;
|
||||
}
|
||||
if (themeMode === 'color') {
|
||||
const colorIndex = Math.floor(index / 10) % THEME_COLORS.length;
|
||||
return THEME_COLORS[colorIndex];
|
||||
}
|
||||
return '#F4D6C2'; // 风景模式下的默认底色(图片加载前显示)
|
||||
}, [themeMode, index]);
|
||||
}, [themeMode, suixinBgColor, index]);
|
||||
|
||||
// 计算当前应该显示的风景图索引(滑动 10 次切换一张)
|
||||
const natureImageIndex = useMemo(() => {
|
||||
@@ -175,30 +436,6 @@ export default function HomeScreen() {
|
||||
|
||||
const currentNatureImage = NATURE_IMAGES[natureImageIndex];
|
||||
|
||||
useLayoutEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerShadowVisible: false,
|
||||
headerStyle: { backgroundColor: themeMode === 'scenery' ? 'transparent' : backgroundColor },
|
||||
headerTransparent: themeMode === 'scenery',
|
||||
headerRight: () => (
|
||||
<View style={styles.headerRight}>
|
||||
<CircleIconButton
|
||||
onPress={() => setThemeOpen(true)}
|
||||
accessibilityLabel={t('home.theme')}
|
||||
>
|
||||
<ThemeIcon width={18} height={18} />
|
||||
</CircleIconButton>
|
||||
<CircleIconButton
|
||||
onPress={() => setProfileOpen(true)}
|
||||
accessibilityLabel={t('home.profile')}
|
||||
>
|
||||
<MyIcon width={18} height={18} />
|
||||
</CircleIconButton>
|
||||
</View>
|
||||
),
|
||||
});
|
||||
}, [backgroundColor, themeMode, navigation, t]);
|
||||
|
||||
const textAnimatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateY: translateY.value }],
|
||||
opacity: opacity.value,
|
||||
@@ -208,23 +445,60 @@ export default function HomeScreen() {
|
||||
transform: [{ scale: likeScale.value }],
|
||||
}));
|
||||
|
||||
const setBusySafe = useCallback((next: boolean) => {
|
||||
busyRef.current = next;
|
||||
setBusy(next);
|
||||
}, []);
|
||||
|
||||
const setLikeInFlight = useCallback((next: boolean) => {
|
||||
likeInFlightRef.current = next;
|
||||
}, []);
|
||||
|
||||
const syncLikeFilledByIndex = useCallback((nextIndex: number) => {
|
||||
const list = currentFeedRef.current;
|
||||
const len = list.length;
|
||||
if (!len) {
|
||||
setLikeFilled(false);
|
||||
return;
|
||||
}
|
||||
const safe = ((nextIndex % len) + len) % len;
|
||||
const nextId = String(list[safe]?.content_id);
|
||||
setLikeFilled(likedIdsRef.current.has(nextId));
|
||||
}, []);
|
||||
|
||||
const applyIndexChange = useCallback((nextIndex: number) => {
|
||||
indexRef.current = nextIndex;
|
||||
setIndex(nextIndex);
|
||||
syncLikeFilledByIndex(nextIndex);
|
||||
}, [syncLikeFilledByIndex]);
|
||||
|
||||
const maybeFetchNewFeedIfNeeded = useCallback((nextIndex: number) => {
|
||||
const len = currentFeedRef.current.length;
|
||||
if (!len) return;
|
||||
// 当接近当前列表末尾时(例如还剩 5 条)提前拉取
|
||||
if (nextIndex + 5 >= len && !isFetchingRef.current) {
|
||||
fetchNewFeed();
|
||||
}
|
||||
}, [fetchNewFeed]);
|
||||
|
||||
// 切换到下一条文案的统一动画逻辑
|
||||
const triggerNextContent = useCallback(() => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
if (busyRef.current) return;
|
||||
setBusySafe(true);
|
||||
|
||||
// 记录“看过/划过”的内容 id(用于下一次向后端请求时去重/频控)
|
||||
if (typeof currentContentId === 'number') {
|
||||
void recordRecoFeedTouched(currentContentId);
|
||||
}
|
||||
// 注意:不要在 Reanimated worklet 回调里读取 React ref(例如 indexRef/currentFeedRef),会导致值不更新或异常
|
||||
const nextIndex = indexRef.current + 1;
|
||||
|
||||
// 1. 当前文案向上移动并消失
|
||||
translateY.value = withTiming(-40, { duration: 300, easing: Easing.out(Easing.quad) });
|
||||
opacity.value = withTiming(0, { duration: 300 }, (finished) => {
|
||||
if (finished) {
|
||||
// 2. 切换数据索引
|
||||
runOnJS(setIndex)(index + 1);
|
||||
runOnJS(setLikeFilled)(false);
|
||||
runOnJS(applyIndexChange)(nextIndex);
|
||||
runOnJS(advanceSuixinOnNextContent)();
|
||||
|
||||
// 检查是否需要拉取新文案(注意:不要把匿名函数塞进 runOnJS,可能导致原生崩溃)
|
||||
runOnJS(maybeFetchNewFeedIfNeeded)(nextIndex);
|
||||
|
||||
// 3. 准备下一条文案:先瞬移到下方 40pt
|
||||
translateY.value = 40;
|
||||
@@ -233,20 +507,51 @@ export default function HomeScreen() {
|
||||
translateY.value = withTiming(0, { duration: 400, easing: Easing.out(Easing.back(1)) });
|
||||
opacity.value = withTiming(1, { duration: 400 }, (finished) => {
|
||||
if (finished) {
|
||||
runOnJS(setBusy)(false);
|
||||
runOnJS(setBusySafe)(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [busy, currentContentId, index, translateY, opacity]);
|
||||
}, [applyIndexChange, setBusySafe, translateY, opacity, advanceSuixinOnNextContent, maybeFetchNewFeedIfNeeded]);
|
||||
|
||||
// 切换到上一条文案的统一动画逻辑(下滑触发)
|
||||
const triggerPrevContent = useCallback(() => {
|
||||
if (busyRef.current) return;
|
||||
setBusySafe(true);
|
||||
|
||||
// 注意:同上,不要在 worklet 里读取 React ref
|
||||
const len = currentFeedRef.current.length;
|
||||
const raw = indexRef.current - 1;
|
||||
const nextIndex = len ? ((raw % len) + len) % len : Math.max(0, raw);
|
||||
|
||||
// 1. 当前文案向下移动并消失
|
||||
translateY.value = withTiming(40, { duration: 300, easing: Easing.out(Easing.quad) });
|
||||
opacity.value = withTiming(0, { duration: 300 }, (finished) => {
|
||||
if (finished) {
|
||||
// 2. 切换数据索引(循环回退)
|
||||
runOnJS(applyIndexChange)(nextIndex);
|
||||
|
||||
// 3. 准备上一条文案:先瞬移到上方 40pt
|
||||
translateY.value = -40;
|
||||
|
||||
// 4. 上一条文案向下移动到原位并显现
|
||||
translateY.value = withTiming(0, { duration: 400, easing: Easing.out(Easing.back(1)) });
|
||||
opacity.value = withTiming(1, { duration: 400 }, (finished) => {
|
||||
if (finished) {
|
||||
runOnJS(setBusySafe)(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [applyIndexChange, setBusySafe, translateY, opacity]);
|
||||
|
||||
const lastTapRef = useRef<number>(0);
|
||||
|
||||
// 使用 Ref 解决 PanResponder 闭包陷阱,确保手势回调能拿到最新的 state 和 function
|
||||
const handlersRef = useRef({ onPressLike, triggerNextContent });
|
||||
const handlersRef = useRef({ onPressLike, triggerNextContent, triggerPrevContent });
|
||||
useEffect(() => {
|
||||
handlersRef.current = { onPressLike, triggerNextContent };
|
||||
}, [onPressLike, triggerNextContent]);
|
||||
handlersRef.current = { onPressLike, triggerNextContent, triggerPrevContent };
|
||||
}, [onPressLike, triggerNextContent, triggerPrevContent]);
|
||||
|
||||
// 使用系统自带的 PanResponder 代替第三方手势库
|
||||
const panResponder = useRef(
|
||||
@@ -271,16 +576,32 @@ export default function HomeScreen() {
|
||||
}
|
||||
lastTapRef.current = now;
|
||||
|
||||
// 2. 上滑逻辑判定
|
||||
// 2. 上滑/下滑逻辑判定
|
||||
if (gestureState.dy < -50) { // 上滑超过 50pt
|
||||
runOnJS(handlersRef.current.triggerNextContent)();
|
||||
} else if (gestureState.dy > 50) { // 下滑超过 50pt
|
||||
runOnJS(handlersRef.current.triggerPrevContent)();
|
||||
}
|
||||
},
|
||||
})
|
||||
).current;
|
||||
|
||||
async function onPressLike() {
|
||||
if (busy) return;
|
||||
if (busyRef.current) return;
|
||||
if (likeInFlightRef.current) return;
|
||||
|
||||
// 已经喜欢过:不重复写入收藏,直接当作“下一条”
|
||||
if (likeFilled || likedIdsRef.current.has(item.id)) {
|
||||
triggerNextContent();
|
||||
return;
|
||||
}
|
||||
|
||||
likeInFlightRef.current = true;
|
||||
|
||||
const likedItemId = item.id;
|
||||
const likedItemText = item.text;
|
||||
// 先记下“已喜欢”,保证回退时能恢复点亮状态(即便异步保存稍后才完成)
|
||||
likedIdsRef.current.add(likedItemId);
|
||||
setLikeFilled(true);
|
||||
|
||||
// 1. 获取当前日期
|
||||
@@ -288,20 +609,37 @@ export default function HomeScreen() {
|
||||
const dateStr = `${now.getFullYear()}.${String(now.getMonth() + 1).padStart(2, '0')}.${String(now.getDate()).padStart(2, '0')}`;
|
||||
|
||||
// 2. 保存到收藏夹,包含当前背景信息
|
||||
await addFavorite({
|
||||
const favItem = {
|
||||
favId: String(Date.now()), // 生成唯一 ID
|
||||
id: item.id,
|
||||
id: likedItemId,
|
||||
text: likedItemText,
|
||||
date: dateStr,
|
||||
themeMode: themeMode,
|
||||
background: themeMode === 'scenery' ? String(natureImageIndex) : backgroundColor,
|
||||
});
|
||||
};
|
||||
console.log('Home: Triggering addFavorite', JSON.stringify(favItem));
|
||||
try {
|
||||
await addFavorite(favItem);
|
||||
} catch (error) {
|
||||
console.error('Home: addFavorite 失败', error);
|
||||
}
|
||||
|
||||
// 3. 爱心缩放动画
|
||||
// 3. 记录到后端 Reaction(喜欢)
|
||||
console.log('Home: Triggering setReaction', item.id);
|
||||
try {
|
||||
await setReaction(item.id, 'like');
|
||||
} catch (error) {
|
||||
console.error('Home: setReaction 失败', error);
|
||||
}
|
||||
|
||||
// 4. 爱心缩放动画
|
||||
likeScale.value = withSequence(
|
||||
withTiming(0.8, { duration: 100 }),
|
||||
withTiming(1.2, { duration: 150 }),
|
||||
withTiming(1, { duration: 100 }, (finished) => {
|
||||
runOnJS(setLikeInFlight)(false);
|
||||
if (finished) {
|
||||
console.log('Home: Like animation finished, triggering next content');
|
||||
runOnJS(triggerNextContent)();
|
||||
}
|
||||
})
|
||||
@@ -312,6 +650,13 @@ export default function HomeScreen() {
|
||||
setThemeModeState(next);
|
||||
await setThemeMode(next);
|
||||
setThemeOpen(false);
|
||||
|
||||
// 切换到随心:不主动重算(除非冷启动会话变化/状态不存在),仅确保可用
|
||||
if (next === 'suixin') {
|
||||
await ensureSuixinReady().catch(() => {
|
||||
setSuixinBgColor(NEUTRAL_THEME_COLORS[1]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -323,8 +668,33 @@ export default function HomeScreen() {
|
||||
resizeMode="cover"
|
||||
/>
|
||||
)}
|
||||
<Animated.View style={[styles.card, textAnimatedStyle, themeMode === 'scenery' && styles.sceneryCard]}>
|
||||
<Text style={[styles.text, themeMode === 'scenery' && styles.sceneryText]}>{item.text}</Text>
|
||||
|
||||
{/* 自绘顶部按钮:不使用系统 Header,彻底避免 iOS 导航栏的毛玻璃/液玻璃材质 */}
|
||||
<View style={[styles.topRight, { top: insets.top + 8 }]}>
|
||||
<CircleIconButton
|
||||
onPress={() => setThemeOpen(true)}
|
||||
accessibilityLabel={t('home.theme')}
|
||||
>
|
||||
<ThemeIcon width={20} height={20} />
|
||||
</CircleIconButton>
|
||||
<CircleIconButton
|
||||
onPress={() => setProfileOpen(true)}
|
||||
accessibilityLabel={t('home.profile')}
|
||||
>
|
||||
<MyIcon width={20} height={20} />
|
||||
</CircleIconButton>
|
||||
</View>
|
||||
|
||||
<Animated.View
|
||||
style={[styles.card, textAnimatedStyle, themeMode === 'scenery' && styles.sceneryCard]}
|
||||
onLayout={(e) => {
|
||||
const w = e.nativeEvent.layout.width;
|
||||
if (Number.isFinite(w) && w > 0) setCardWidth(w);
|
||||
}}
|
||||
>
|
||||
<Text style={[styles.text, isEnglish && styles.textEnglish, themeMode === 'scenery' && styles.sceneryText]}>
|
||||
{wrappedText || item.text}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
|
||||
<View style={styles.actions}>
|
||||
@@ -333,16 +703,17 @@ export default function HomeScreen() {
|
||||
onPress={onPressLike}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('home.like')}
|
||||
hitSlop={20}
|
||||
// 稍微增大可点击区域,提升单手操作成功率
|
||||
hitSlop={24}
|
||||
style={styles.reactionInner}
|
||||
>
|
||||
{likeFilled ? (
|
||||
<LikeFilledIcon width={35} height={36} style={{ color: '#EA6969' }} />
|
||||
<LikeFilledIcon width={40} height={41} color="#EA6969" />
|
||||
) : (
|
||||
<LikeIcon
|
||||
width={35}
|
||||
height={36}
|
||||
style={{ color: themeMode === 'scenery' ? '#FFFFFF' : '#5E2A28' }}
|
||||
width={40}
|
||||
height={41}
|
||||
color={themeMode === 'scenery' ? '#FFFFFF' : '#5E2A28'}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
@@ -371,7 +742,8 @@ function CircleIconButton({
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
hitSlop={10}
|
||||
// 稍微增大可点击区域,提升易用性
|
||||
hitSlop={14}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
style={styles.circleBtn}
|
||||
@@ -388,15 +760,17 @@ 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,
|
||||
height: 34,
|
||||
borderRadius: 17,
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: 'rgba(255,255,255,0.75)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
@@ -419,6 +793,11 @@ const styles = StyleSheet.create({
|
||||
fontWeight: '700',
|
||||
textAlign: 'center',
|
||||
},
|
||||
textEnglish: {
|
||||
fontFamily: 'STIXTwoText',
|
||||
// 英文字体观感更细一点,避免过粗
|
||||
fontWeight: '600',
|
||||
},
|
||||
sceneryCard: {
|
||||
// 风景模式下稍微收窄文案宽度,增加呼吸感
|
||||
paddingHorizontal: 50,
|
||||
|
||||
@@ -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,18 @@
|
||||
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 { toBackendLocaleFromLanguageTag } from '@/src/i18n/locale';
|
||||
import { fetchRecoFeed } from '@/src/services/recoApi';
|
||||
import { getExpoPushTokenOrThrow, registerPushToken, setPushPreferences } from '@/src/services/pushApi';
|
||||
import {
|
||||
recordRecoFeedServed,
|
||||
setOnboardingCompleted,
|
||||
@@ -14,71 +20,48 @@ 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', 'okay', 'tired', 'stressed', 'low'] },
|
||||
{ id: 'influence', type: 'selection', optionIds: ['family', 'work', 'relationship', 'friends', 'health'] },
|
||||
{ id: 'support', type: 'selection', optionIds: ['emotional', 'parenting', 'self_worth', 'anxiety', 'balance'] },
|
||||
{ id: 'reminder', type: 'reminder' },
|
||||
];
|
||||
|
||||
export default function OnboardingScreen() {
|
||||
const router = useRouter();
|
||||
const { t, i18n } = useTranslation();
|
||||
const [stepIndex, setStepIndex] = useState(0);
|
||||
const [name, setName] = useState('');
|
||||
const [selections, setSelections] = useState<Record<string, string[]>>({});
|
||||
const [reminderTimes, setReminderTimes] = useState(3);
|
||||
const [finishing, setFinishing] = useState(false);
|
||||
|
||||
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';
|
||||
if (finishing) return;
|
||||
setFinishing(true);
|
||||
try {
|
||||
// 用户选择每日次数 > 0:在此页直接触发系统通知权限(已移除单独的 push 引导页)。
|
||||
const wantsPush = reminderTimes > 0;
|
||||
|
||||
// 将 Onboarding 选择映射为标准问卷枚举(允许跳过)
|
||||
const answers = mapOnboardingSelectionsToQuestionnaireAnswers(selections);
|
||||
@@ -87,8 +70,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 = toBackendLocaleFromLanguageTag(i18n.language);
|
||||
const { items, meta } = await fetchRecoFeed({
|
||||
k: 30,
|
||||
user_profile: {
|
||||
@@ -106,6 +97,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,13 +112,68 @@ export default function OnboardingScreen() {
|
||||
});
|
||||
await setDailyReminderSettings({
|
||||
timesPerDay: reminderTimes,
|
||||
pushEnabled: pushEnabled
|
||||
// 这里表示“用户意愿”,不代表系统权限一定已 granted
|
||||
pushEnabled: wantsPush,
|
||||
});
|
||||
await setOnboardingCompleted(true);
|
||||
router.replace('/(app)/home');
|
||||
|
||||
// 用户选择 0 次(关闭)或跳过:直接进入首页
|
||||
if (!wantsPush) {
|
||||
await setPushPromptState('skipped');
|
||||
router.replace('/(app)/home');
|
||||
return;
|
||||
}
|
||||
|
||||
// 用户想要 Push:请求系统权限并尽量完成 token/偏好上报(失败不阻塞进入首页)
|
||||
await setPushPromptState('unknown');
|
||||
try {
|
||||
const { status } = await Notifications.requestPermissionsAsync();
|
||||
if (status !== 'granted') {
|
||||
await setPushPromptState('skipped');
|
||||
return;
|
||||
}
|
||||
|
||||
// iOS 模拟器通常无法获取 Expo Push Token(系统限制),此时不要提示“失败”,而是明确告知需要真机测试。
|
||||
if (Device.osName === 'iOS' && !Device.isDevice) {
|
||||
Alert.alert('提示', '当前为 iOS 模拟器,无法获取推送 Token。请使用真机测试推送功能。');
|
||||
await setPushPromptState('unknown');
|
||||
return;
|
||||
}
|
||||
|
||||
// 1) 获取 Expo Push Token(失败才认为“推送开启失败”)
|
||||
const expoPushToken = await getExpoPushTokenOrThrow();
|
||||
// 2) 上报 token 到后端(幂等;失败才认为“推送开启失败”)
|
||||
await registerPushToken({ pushToken: expoPushToken });
|
||||
|
||||
// 3) 上报推送偏好(幂等)
|
||||
// 注意:这一步失败时,后端仍可能已成功接收 token。
|
||||
// 为避免出现“后端已接收 token 但前端弹窗提示失败”的错觉,这里改为:偏好同步失败不弹“开启失败”,仅记录并继续。
|
||||
try {
|
||||
await setPushPreferences({ enabled: wantsPush, timesPerDay: reminderTimes });
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
console.warn('[PushPreferences] 同步失败(Onboarding,不阻塞)', msg);
|
||||
}
|
||||
|
||||
await setPushPromptState('enabled');
|
||||
} catch (e) {
|
||||
// 失败不阻塞进入首页;但这里给出更明确的文案(常见原因:模拟器/网络/后端异常)
|
||||
Alert.alert(t('push.errorTitle'), t('push.errorDesc'));
|
||||
await setPushPromptState('unknown');
|
||||
} finally {
|
||||
router.replace('/(app)/home');
|
||||
}
|
||||
} catch (e) {
|
||||
// 极端情况下(例如本地存储/初始化异常)避免卡死在 loading:提示并允许用户重试
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
console.warn('[OnboardingFinish] 异常:', msg);
|
||||
Alert.alert(t('push.errorTitle'), t('push.errorDesc'));
|
||||
setFinishing(false);
|
||||
}
|
||||
}
|
||||
|
||||
const onNext = () => {
|
||||
if (finishing) return;
|
||||
if (stepIndex < STEPS.length - 1) {
|
||||
setStepIndex(stepIndex + 1);
|
||||
} else {
|
||||
@@ -135,43 +182,52 @@ export default function OnboardingScreen() {
|
||||
};
|
||||
|
||||
const onBack = () => {
|
||||
if (finishing) return;
|
||||
if (stepIndex > 0) {
|
||||
setStepIndex(stepIndex - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const onSkip = async () => {
|
||||
// 跳过整个 Onboarding:仍生成一个“全跳过”的最小画像,保证下游可用
|
||||
const scoringProfile = buildUserProfileFromQuestionnaire({});
|
||||
await setUserProfileScoring(scoringProfile);
|
||||
|
||||
// 标记已完成,避免下次启动再次进入 Onboarding
|
||||
await setOnboardingCompleted(true);
|
||||
router.replace('/(app)/home');
|
||||
/** 只跳過當前這一步(不填/不選當前題,進入下一步) */
|
||||
const handleSkipCurrentStep = () => {
|
||||
if (finishing) return;
|
||||
if (currentStep.type === 'name') {
|
||||
onNext();
|
||||
} else if (currentStep.type === 'selection') {
|
||||
setSelections((prev) => ({ ...prev, [currentStep.id]: [] }));
|
||||
onNext();
|
||||
} else if (currentStep.type === 'reminder') {
|
||||
setReminderTimes(0);
|
||||
onFinish();
|
||||
}
|
||||
};
|
||||
|
||||
// 题目为单选:再次点击可取消;选择其他选项会替换为唯一选项
|
||||
// 题目为多选:点击切换选中状态
|
||||
const handleToggleSelection = (id: string) => {
|
||||
setSelections(prev => {
|
||||
const currentIds = prev[currentStep.id] || [];
|
||||
const nextIds = currentIds.includes(id) ? [] : [id];
|
||||
const nextIds = currentIds.includes(id)
|
||||
? currentIds.filter(i => i !== id)
|
||||
: [...currentIds, id];
|
||||
return { ...prev, [currentStep.id]: nextIds };
|
||||
});
|
||||
};
|
||||
|
||||
const handleSkipStep = () => {
|
||||
if (finishing) return;
|
||||
setSelections((prev) => ({ ...prev, [currentStep.id]: [] }));
|
||||
onNext();
|
||||
};
|
||||
|
||||
return (
|
||||
<OnboardingLayout
|
||||
title={currentStep.title}
|
||||
title={currentTitle}
|
||||
currentStep={stepIndex}
|
||||
totalSteps={STEPS.length - 1}
|
||||
onSkip={onSkip}
|
||||
onSkip={handleSkipCurrentStep}
|
||||
onBack={onBack}
|
||||
showBackButton={stepIndex > 0}
|
||||
userName={name}
|
||||
>
|
||||
{currentStep.type === 'name' && (
|
||||
<NameInputStep
|
||||
@@ -183,19 +239,24 @@ export default function OnboardingScreen() {
|
||||
|
||||
{currentStep.type === 'selection' && (
|
||||
<SelectionStep
|
||||
options={currentStep.options!}
|
||||
options={currentOptions}
|
||||
selectedIds={selections[currentStep.id] || []}
|
||||
onToggle={handleToggleSelection}
|
||||
onNext={onNext}
|
||||
onSkip={handleSkipStep}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep.type === 'reminder' && (
|
||||
<ReminderStep
|
||||
value={reminderTimes}
|
||||
value={Math.max(1, reminderTimes)}
|
||||
onChange={setReminderTimes}
|
||||
onFinish={onFinish}
|
||||
loading={finishing}
|
||||
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' }
|
||||
});
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, Dimensions, Platform, Alert, Image } from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import * as WebBrowser from 'expo-web-browser';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { setConsentAccepted, getConsentAccepted } from '../../src/storage/appStorage';
|
||||
import { fetchLegalLinks } from '@/src/services/legalApi';
|
||||
import { getOnboardingCompleted } from '@/src/storage/appStorage';
|
||||
import { API_BASE_URL } from '@/src/constants/env';
|
||||
import { isTraditionalChineseLocaleTag } from '@/src/i18n/locale';
|
||||
|
||||
// 导入 SVG 组件
|
||||
import FlowersBg from '../../assets/images/index/flowers_endbg.svg';
|
||||
@@ -12,20 +16,55 @@ import WelcomeBtn from '../../assets/images/index/welcome_btn.svg';
|
||||
|
||||
const { width, height } = Dimensions.get('window');
|
||||
|
||||
// 繁中開屏 consent 文案:寫死在元件內,避免 Metro/iOS bundle 快取導致永遠顯示舊文案。
|
||||
// 若需修改,請改這裡並同步 client/src/i18n/locales/zh-TW.json 的 consent 區塊。
|
||||
const ZH_TW_CONSENT = {
|
||||
title: '我們知道,',
|
||||
subtitle: '當媽媽很不容易。',
|
||||
subtitleSecondary: '這裡給你一些溫柔的肯定與提醒',
|
||||
};
|
||||
|
||||
export default function SplashScreen() {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const [showConsent, setShowConsent] = useState(false);
|
||||
|
||||
// 繁中時強制使用上方常數(含 zh-TW / zh-Hant / zh-Hant-TW),其餘用 i18n
|
||||
const isZhTW = isTraditionalChineseLocaleTag(i18n.language || '');
|
||||
const title = isZhTW ? ZH_TW_CONSENT.title : t('consent.title');
|
||||
const subtitle = isZhTW ? ZH_TW_CONSENT.subtitle : t('consent.subtitle');
|
||||
const subtitleSecondary = isZhTW ? ZH_TW_CONSENT.subtitleSecondary : t('consent.subtitleSecondary');
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof __DEV__ !== 'undefined' && __DEV__ && showConsent) {
|
||||
console.log('[i18n consent] language=', i18n.language, 'title=', title, 'subtitle=', subtitle);
|
||||
}
|
||||
}, [showConsent, i18n.language, title, subtitle]);
|
||||
const [links, setLinks] = useState<{ privacy?: string; terms?: string }>({});
|
||||
const [linksLoading, setLinksLoading] = useState(false);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
checkConsent();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const checkConsent = async () => {
|
||||
const accepted = await getConsentAccepted();
|
||||
setShowConsent(!accepted);
|
||||
if (accepted) {
|
||||
router.replace('/');
|
||||
// 已同意协议则直接分发到目标页,避免先回到 /(index)再二次跳转导致“闪一下”
|
||||
const completed = await getOnboardingCompleted();
|
||||
if (completed) {
|
||||
router.replace('/(app)/home');
|
||||
} else {
|
||||
router.replace('/(onboarding)/onboarding');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -44,6 +83,50 @@ export default function SplashScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
async function refreshLegalLinks(): Promise<{ privacy?: string; terms?: string }> {
|
||||
if (mountedRef.current) setLinksLoading(true);
|
||||
try {
|
||||
const res = await fetchLegalLinks();
|
||||
const next = { privacy: res.privacyPolicyUrl, terms: res.termsOfUseUrl };
|
||||
if (mountedRef.current) setLinks(next);
|
||||
return next;
|
||||
} catch (e) {
|
||||
// 不阻塞主流程:失败时不崩溃,链接入口仍可点(会提示)
|
||||
if (__DEV__) console.log('[LegalLinks] 拉取失败(splash):', e);
|
||||
if (mountedRef.current) setLinks({});
|
||||
return {};
|
||||
} finally {
|
||||
if (mountedRef.current) setLinksLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOpenLegal(type: 'privacy' | 'terms') {
|
||||
const currentUrl = type === 'privacy' ? links.privacy : links.terms;
|
||||
if (currentUrl) {
|
||||
await openLink(currentUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
// 链接还没拿到/拉取失败:点击时主动再拉一次,避免“点了没反应”
|
||||
const next = await refreshLegalLinks();
|
||||
const nextUrl = type === 'privacy' ? next.privacy : next.terms;
|
||||
if (nextUrl) {
|
||||
await openLink(nextUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
const msg =
|
||||
typeof __DEV__ !== 'undefined' && __DEV__
|
||||
? t('consent.linkUnavailableDev', { baseUrl: API_BASE_URL })
|
||||
: t('consent.linkUnavailable');
|
||||
Alert.alert(t('common.notice'), msg);
|
||||
}
|
||||
|
||||
// 拉取协议链接(由后端按语言下发;默认 EN)
|
||||
useEffect(() => {
|
||||
void refreshLegalLinks();
|
||||
}, []);
|
||||
|
||||
const bgDecorationTop = 363;
|
||||
const bgDecorationHeight = height * 0.6;
|
||||
const contentTop = bgDecorationTop + (bgDecorationHeight * 0.25);
|
||||
@@ -64,31 +147,58 @@ export default function SplashScreen() {
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* 文案内容 */}
|
||||
{/* 文案内容:主標題兩行 + 可選二級標題(字號更小、顏色更淺);繁中為元件內常數,其餘用 i18n */}
|
||||
<View style={[styles.contentContainer, { position: 'absolute', top: contentTop }]}>
|
||||
<Text style={styles.titleText}>
|
||||
You Are Perfect.{"\n"}
|
||||
Everything{"\n"}
|
||||
Will Be Better.
|
||||
{title}
|
||||
{'\n'}
|
||||
{subtitle}
|
||||
</Text>
|
||||
{subtitleSecondary ? (
|
||||
<Text style={styles.consentSubtitleSecondary}>{subtitleSecondary}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<SafeAreaView style={styles.bottomContainer} edges={['bottom']}>
|
||||
{showConsent && (
|
||||
<>
|
||||
<TouchableOpacity onPress={handleAgree} activeOpacity={0.8} style={styles.buttonWrapper}>
|
||||
<TouchableOpacity
|
||||
onPress={handleAgree}
|
||||
activeOpacity={0.8}
|
||||
style={styles.buttonWrapper}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('consent.agree')}
|
||||
>
|
||||
<WelcomeBtn width={87} height={57} />
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.linksContainer}>
|
||||
<TouchableOpacity onPress={() => openLink('https://example.com/privacy')}>
|
||||
<Text style={styles.linkText}>{t('consent.privacy')}</Text>
|
||||
</TouchableOpacity>
|
||||
<View style={styles.divider} />
|
||||
<TouchableOpacity onPress={() => openLink('https://example.com/terms')}>
|
||||
<Text style={styles.linkText}>{t('consent.terms')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<Text style={styles.noticeText}>
|
||||
<Trans
|
||||
i18nKey="consent.noticeRich"
|
||||
values={{
|
||||
privacyLabel: t('consent.privacy'),
|
||||
termsLabel: t('consent.terms'),
|
||||
privacySuffix: !links.privacy && linksLoading ? t('consent.linkLoadingSuffix') : '',
|
||||
termsSuffix: !links.terms && linksLoading ? t('consent.linkLoadingSuffix') : '',
|
||||
}}
|
||||
components={{
|
||||
privacy: (
|
||||
<Text
|
||||
style={[styles.noticeLinkText, !links.privacy && styles.noticeLinkTextDisabled]}
|
||||
onPress={() => void handleOpenLegal('privacy')}
|
||||
suppressHighlighting
|
||||
/>
|
||||
),
|
||||
terms: (
|
||||
<Text
|
||||
style={[styles.noticeLinkText, !links.terms && styles.noticeLinkTextDisabled]}
|
||||
onPress={() => void handleOpenLegal('terms')}
|
||||
suppressHighlighting
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
@@ -127,6 +237,14 @@ const styles = StyleSheet.create({
|
||||
fontWeight: '600',
|
||||
fontFamily: Platform.OS === 'ios' ? 'STIX Two Text' : 'serif',
|
||||
},
|
||||
consentSubtitleSecondary: {
|
||||
marginTop: 12,
|
||||
fontSize: 16,
|
||||
lineHeight: 22,
|
||||
color: 'rgba(119, 47, 0, 0.6)',
|
||||
textAlign: 'center',
|
||||
fontFamily: Platform.OS === 'ios' ? 'STIX Two Text' : 'serif',
|
||||
},
|
||||
bottomContainer: {
|
||||
position: 'absolute',
|
||||
bottom: 60,
|
||||
@@ -137,19 +255,22 @@ const styles = StyleSheet.create({
|
||||
buttonWrapper: {
|
||||
marginBottom: 40,
|
||||
},
|
||||
linksContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
linkText: {
|
||||
noticeText: {
|
||||
marginTop: 10,
|
||||
paddingHorizontal: 28,
|
||||
fontSize: 12,
|
||||
color: 'rgba(119, 47, 0, 0.5)', // 使用半透明的文字颜色
|
||||
textDecorationLine: 'underline',
|
||||
lineHeight: 16,
|
||||
textAlign: 'center',
|
||||
color: 'rgba(119, 47, 0, 0.45)',
|
||||
},
|
||||
divider: {
|
||||
width: 1,
|
||||
height: 12,
|
||||
backgroundColor: 'rgba(119, 47, 0, 0.2)',
|
||||
marginHorizontal: 15,
|
||||
noticeLinkText: {
|
||||
fontSize: 12,
|
||||
// 颜色区分:协议链接更醒目
|
||||
color: 'rgba(119, 47, 0, 0.75)',
|
||||
textDecorationLine: 'underline',
|
||||
fontWeight: '600',
|
||||
},
|
||||
noticeLinkTextDisabled: {
|
||||
opacity: 0.55,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -4,16 +4,23 @@ import { useFonts } from 'expo-font';
|
||||
import { Stack } from 'expo-router';
|
||||
import * as SplashScreen from 'expo-splash-screen';
|
||||
import * as Notifications from 'expo-notifications';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import 'react-native-reanimated';
|
||||
import { Animated, AppState, Image, StyleSheet, View } from 'react-native';
|
||||
|
||||
import { useColorScheme } from '@/components/useColorScheme';
|
||||
import { initI18n } from '@/src/i18n';
|
||||
import { ensureDailyWidgetRecoUpToDate, syncWidgetConfig, syncWidgetUserProfileFromStorage } from '@/src/modules/dailyWidgetReco';
|
||||
import { getOrCreateClientUserId } from '@/src/storage/appStorage';
|
||||
import { ensurePushTokenRegisteredIfPermitted } from '@/src/services/pushApi';
|
||||
|
||||
// 配置通知处理方式(即使不发送也建议配置,以确保权限接口正常)
|
||||
Notifications.setNotificationHandler({
|
||||
handleNotification: async () => ({
|
||||
shouldShowAlert: true,
|
||||
// 新版 expo-notifications 类型要求显式返回 banner/list 行为
|
||||
shouldShowBanner: true,
|
||||
shouldShowList: true,
|
||||
shouldPlaySound: false,
|
||||
shouldSetBadge: false,
|
||||
}),
|
||||
@@ -26,7 +33,8 @@ export {
|
||||
|
||||
export const unstable_settings = {
|
||||
// Ensure that reloading on `/modal` keeps a back button present.
|
||||
initialRouteName: 'index',
|
||||
// 让首次启动(未同意协议)直接进入协议页,避免先渲染 index 再跳转导致“闪一下”
|
||||
initialRouteName: '(splash)/splash',
|
||||
};
|
||||
|
||||
// Prevent the splash screen from auto-hiding before asset loading is complete.
|
||||
@@ -34,10 +42,14 @@ SplashScreen.preventAutoHideAsync();
|
||||
|
||||
export default function RootLayout() {
|
||||
const [loaded, error] = useFonts({
|
||||
SpaceMono: require('../assets/fonts/SpaceMono-Regular.ttf'),
|
||||
STIXTwoText: require('../assets/fonts/STIXTwoText-VariableFont_wght.ttf'),
|
||||
...FontAwesome.font,
|
||||
});
|
||||
const [i18nReady, setI18nReady] = useState(false);
|
||||
const [appReady, setAppReady] = useState(false);
|
||||
const [splashOverlayVisible, setSplashOverlayVisible] = useState(true);
|
||||
const splashOpacity = useRef(new Animated.Value(1)).current;
|
||||
const hasHiddenNativeSplashRef = useRef(false);
|
||||
|
||||
// Expo Router uses Error Boundaries to catch errors in the navigation tree.
|
||||
useEffect(() => {
|
||||
@@ -48,31 +60,106 @@ export default function RootLayout() {
|
||||
initI18n()
|
||||
.catch((e) => {
|
||||
// i18n 初始化失败不应阻塞 App 启动,先打印错误再继续
|
||||
console.error('i18n 初始化失败', e);
|
||||
console.error('i18n init failed', e);
|
||||
})
|
||||
.finally(() => setI18nReady(true));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// 等字体与 i18n 都准备好后再隐藏启动页,避免文案闪烁
|
||||
if (loaded && i18nReady) {
|
||||
SplashScreen.hideAsync();
|
||||
}
|
||||
// 尽早生成 client_user_id,便于后续任意时刻与后端建立关联(Push Token/偏好等)
|
||||
getOrCreateClientUserId()
|
||||
.then((id) => {
|
||||
if (__DEV__) console.log('[client_user_id]', id);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.warn('[client_user_id] 生成失败(不阻塞启动)', e);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// 只要系统通知权限已经 granted,就主动上报 Push Token(不依赖用户在“每日提醒”里点确认)
|
||||
ensurePushTokenRegisteredIfPermitted()
|
||||
.then((res) => {
|
||||
if (__DEV__) console.log('[push_token_sync]', res);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (__DEV__) console.warn('[push_token_sync] 失败(不阻塞启动)', e);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// 字体与 i18n 都准备好后,允许渲染 App(原生 splash 的隐藏交给 onLayout,避免“硬切/闪白”)
|
||||
if (loaded && i18nReady) setAppReady(true);
|
||||
}, [loaded, i18nReady]);
|
||||
|
||||
const onLayoutRootView = useCallback(() => {
|
||||
if (!appReady) return;
|
||||
if (hasHiddenNativeSplashRef.current) return;
|
||||
hasHiddenNativeSplashRef.current = true;
|
||||
|
||||
// 先隐藏原生 splash,再把同款覆盖层淡出,视觉上实现平滑过渡
|
||||
void SplashScreen.hideAsync().finally(() => {
|
||||
Animated.timing(splashOpacity, {
|
||||
toValue: 0,
|
||||
duration: 380,
|
||||
useNativeDriver: true,
|
||||
}).start(({ finished }) => {
|
||||
if (finished) setSplashOverlayVisible(false);
|
||||
});
|
||||
});
|
||||
}, [appReady, splashOpacity]);
|
||||
|
||||
const content = useMemo(() => {
|
||||
if (!appReady) return null;
|
||||
return <RootLayoutNav />;
|
||||
}, [appReady]);
|
||||
|
||||
if (!loaded || !i18nReady) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <RootLayoutNav />;
|
||||
return (
|
||||
<View style={styles.root} onLayout={onLayoutRootView}>
|
||||
{content}
|
||||
{splashOverlayVisible && (
|
||||
<Animated.View pointerEvents="none" style={[StyleSheet.absoluteFill, { opacity: splashOpacity }]}>
|
||||
<View style={styles.splashOverlay}>
|
||||
<Image
|
||||
source={require('../assets/images/Screen_page.png')}
|
||||
style={styles.splashImage}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</View>
|
||||
</Animated.View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function RootLayoutNav() {
|
||||
const colorScheme = useColorScheme();
|
||||
|
||||
useEffect(() => {
|
||||
// iOS 小组件:启动时把必要信息写入共享区,并尽力刷新一次“每日推荐”
|
||||
syncWidgetConfig().catch(() => {});
|
||||
syncWidgetUserProfileFromStorage().catch(() => {});
|
||||
ensureDailyWidgetRecoUpToDate({ reason: 'app_start' }).catch(() => {});
|
||||
|
||||
const sub = AppState.addEventListener('change', (state) => {
|
||||
if (state === 'active') {
|
||||
// App 回到前台时尝试刷新(失败不阻塞)
|
||||
ensureDailyWidgetRecoUpToDate({ reason: 'app_active' }).catch(() => {});
|
||||
}
|
||||
});
|
||||
return () => sub.remove();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
{/* 协议页分组(首次启动优先进入) */}
|
||||
<Stack.Screen name="(splash)" />
|
||||
|
||||
{/* 启动分发页:根据 onboarding 状态跳转 */}
|
||||
<Stack.Screen name="index" />
|
||||
|
||||
@@ -89,3 +176,21 @@ function RootLayoutNav() {
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: {
|
||||
flex: 1,
|
||||
},
|
||||
splashOverlay: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
// 与 app.json 的 expo.splash.backgroundColor 保持一致
|
||||
backgroundColor: '#EAD2BA',
|
||||
},
|
||||
splashImage: {
|
||||
// 覆盖层图片尺寸需与系统原生 Splash 的视觉一致,避免出现“缩小一下”的错觉
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
});
|
||||
|
||||
BIN
client/assets/fonts/STIXTwoText-VariableFont_wght.ttf
Normal file
BIN
client/assets/images/Screen_page.png
Normal file
|
After Width: | Height: | Size: 152 KiB |
@@ -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 |
@@ -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 { ensurePushTokenRegisteredIfPermitted, getExpoPushTokenOrThrow, registerPushToken, setPushPreferences } from '@/src/services/pushApi';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
|
||||
@@ -79,6 +83,7 @@ export default function ProfileModal({ visible, name: propName, onClose }: Props
|
||||
const [page, setPage] = useState<Page>('root');
|
||||
const [navDirection, setNavDirection] = useState<NavDirection>('forward');
|
||||
const [currentName, setCurrentName] = useState(propName);
|
||||
const [legalLinks, setLegalLinks] = useState<{ privacy?: string; terms?: string }>({});
|
||||
const isRoot = page === 'root';
|
||||
|
||||
// 当弹窗打开时,尝试从存储中获取最新的昵称,确保与 onboarding 同步
|
||||
@@ -89,6 +94,16 @@ export default function ProfileModal({ visible, name: propName, onClose }: Props
|
||||
setCurrentName(profile.name);
|
||||
}
|
||||
});
|
||||
|
||||
// 打开弹窗时拉取协议链接(由后端按语言下发;默认 EN)
|
||||
fetchLegalLinks()
|
||||
.then((res) => {
|
||||
setLegalLinks({ privacy: res.privacyPolicyUrl, terms: res.termsOfUseUrl });
|
||||
})
|
||||
.catch((e) => {
|
||||
if (__DEV__) console.log('[LegalLinks] 拉取失败(ProfileModal):', e);
|
||||
setLegalLinks({});
|
||||
});
|
||||
} else {
|
||||
setNavDirection('back');
|
||||
setPage('root');
|
||||
@@ -122,6 +137,21 @@ export default function ProfileModal({ visible, name: propName, onClose }: Props
|
||||
handleClose();
|
||||
}
|
||||
|
||||
const openLink = useCallback(
|
||||
async (url?: string) => {
|
||||
if (!url) {
|
||||
Alert.alert(t('common.notice'), t('consent.linkUnavailable'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await WebBrowser.openBrowserAsync(url);
|
||||
} catch (error) {
|
||||
Alert.alert(t('common.error'), t('common.openLinkError'));
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const title = useMemo(() => {
|
||||
if (page === 'favorites') return t('profile.favorites');
|
||||
if (page === 'dailyReminder') return t('dailyReminder.title');
|
||||
@@ -135,23 +165,13 @@ export default function ProfileModal({ visible, name: propName, onClose }: Props
|
||||
const duration = 220;
|
||||
const easing = Easing.out(Easing.cubic);
|
||||
|
||||
// 进入二级页:从右侧滑入;返回:从左侧滑入
|
||||
const entering =
|
||||
navDirection === 'forward'
|
||||
? SlideInRight.duration(duration).easing(easing)
|
||||
: SlideInLeft.duration(duration).easing(easing);
|
||||
|
||||
// 离开:进入二级页时旧页面向左滑出;返回时旧页面向右滑出
|
||||
const exiting =
|
||||
navDirection === 'forward'
|
||||
? SlideOutLeft.duration(duration).easing(easing)
|
||||
: SlideOutRight.duration(duration).easing(easing);
|
||||
// 需求:去掉左右滑动的切页动效,改为纯淡入淡出
|
||||
const entering = FadeIn.duration(duration).easing(easing);
|
||||
const exiting = FadeOut.duration(duration).easing(easing);
|
||||
|
||||
return {
|
||||
entering,
|
||||
exiting,
|
||||
fadeIn: FadeIn.duration(duration).easing(easing),
|
||||
fadeOut: FadeOut.duration(duration).easing(easing),
|
||||
};
|
||||
}, [navDirection]);
|
||||
|
||||
@@ -169,11 +189,6 @@ export default function ProfileModal({ visible, name: propName, onClose }: Props
|
||||
exiting={transition.exiting}
|
||||
style={!isRoot ? { flex: 1 } : undefined}
|
||||
>
|
||||
<Animated.View
|
||||
entering={transition.fadeIn}
|
||||
exiting={transition.fadeOut}
|
||||
style={!isRoot ? { flex: 1 } : undefined}
|
||||
>
|
||||
{page === 'root' ? (
|
||||
<RootPage
|
||||
name={currentName}
|
||||
@@ -181,6 +196,8 @@ export default function ProfileModal({ visible, name: propName, onClose }: Props
|
||||
onOpenWidget={() => go('widget', 'forward')}
|
||||
onOpenDailyReminder={() => go('dailyReminder', 'forward')}
|
||||
onOpenLanguage={() => go('language', 'forward')}
|
||||
onOpenPrivacy={() => openLink(legalLinks.privacy)}
|
||||
onOpenTerms={() => openLink(legalLinks.terms)}
|
||||
/>
|
||||
) : page === 'favorites' ? (
|
||||
<FavoritesPage visible={visible} page={page} />
|
||||
@@ -193,7 +210,6 @@ export default function ProfileModal({ visible, name: propName, onClose }: Props
|
||||
) : (
|
||||
<WidgetPage onOpenHowTo={() => go('widgetHowTo', 'forward')} />
|
||||
)}
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</SheetModal>
|
||||
@@ -210,12 +226,16 @@ function RootPage({
|
||||
onOpenWidget,
|
||||
onOpenDailyReminder,
|
||||
onOpenLanguage,
|
||||
onOpenPrivacy,
|
||||
onOpenTerms,
|
||||
}: {
|
||||
name?: string;
|
||||
onOpenFavorites: () => void;
|
||||
onOpenWidget: () => void;
|
||||
onOpenDailyReminder: () => void;
|
||||
onOpenLanguage: () => void;
|
||||
onOpenPrivacy: () => void;
|
||||
onOpenTerms: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
@@ -243,12 +263,12 @@ function RootPage({
|
||||
<ListItem
|
||||
icon={<PrivacyIcon width={22} height={22} />}
|
||||
title={t('profile.privacy')}
|
||||
onPress={() => toastTodo(t)}
|
||||
onPress={onOpenPrivacy}
|
||||
/>
|
||||
<ListItem
|
||||
icon={<TermsIcon width={22} height={22} />}
|
||||
title={t('profile.terms')}
|
||||
onPress={() => toastTodo(t)}
|
||||
onPress={onOpenTerms}
|
||||
/>
|
||||
<ListItem
|
||||
icon={<LanguageIcon width={22} height={22} />}
|
||||
@@ -271,15 +291,21 @@ function FavoritesPage({ visible, page }: { visible: boolean; page: Page }) {
|
||||
|
||||
async function refreshFavorites() {
|
||||
const storedFavs = await getFavorites();
|
||||
const map = new Map(MOCK_CONTENT.map((c) => [c.id, c.text]));
|
||||
|
||||
const list = storedFavs
|
||||
.map((fav) => ({
|
||||
...fav,
|
||||
text: map.get(fav.id) || ''
|
||||
}))
|
||||
.filter(item => item.text !== '');
|
||||
|
||||
const textMap = new Map<string, string>();
|
||||
|
||||
// 1) Mock 文案
|
||||
MOCK_CONTENT.forEach((c) => textMap.set(String(c.id), t(c.textKey)));
|
||||
|
||||
// 2) 后端推荐缓存文案(避免收藏后 cache 覆盖就丢文案)
|
||||
const cache = await getRecoFeedCache();
|
||||
cache?.items?.forEach((c) => textMap.set(String(c.content_id), c.text));
|
||||
|
||||
// 3) 组装:优先使用收藏时写入的 text,其次从 map 回填
|
||||
const list = storedFavs.map((fav) => ({
|
||||
...fav,
|
||||
text: fav.text || textMap.get(String(fav.id)) || t('favorites.unknownText'),
|
||||
}));
|
||||
|
||||
setFavorites(list);
|
||||
}
|
||||
|
||||
@@ -366,13 +392,15 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () =
|
||||
(async () => {
|
||||
// 1. 获取本地存储设置
|
||||
const s = await getDailyReminderSettings();
|
||||
|
||||
// 【测试模式】:强制模拟无权限状态
|
||||
const granted = false;
|
||||
|
||||
|
||||
// 2. 获取系统通知权限(用于 UI 展示/引导)
|
||||
const settings = await Notifications.getPermissionsAsync();
|
||||
const granted = settings.status === 'granted';
|
||||
|
||||
if (cancelled) return;
|
||||
setTimesPerDay(s.timesPerDay);
|
||||
setPushEnabled(granted);
|
||||
// pushEnabled 表示用户意愿;若系统未授权则强制展示为关闭
|
||||
setPushEnabled(Boolean(s.pushEnabled) && granted);
|
||||
setHasSystemPermission(granted);
|
||||
setLoading(false);
|
||||
})();
|
||||
@@ -390,7 +418,7 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () =
|
||||
if (settings.status === 'denied') {
|
||||
Alert.alert(
|
||||
t('common.notice'),
|
||||
"系统权限已被拒绝,请前往手机设置开启通知。"
|
||||
t('permissions.notificationsDenied')
|
||||
);
|
||||
setPushEnabled(false);
|
||||
return;
|
||||
@@ -405,6 +433,23 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () =
|
||||
if (status === 'granted') {
|
||||
setPushEnabled(true);
|
||||
setHasSystemPermission(true);
|
||||
|
||||
// 获取 token 并上报后端(幂等)
|
||||
try {
|
||||
const expoPushToken = await getExpoPushTokenOrThrow();
|
||||
await registerPushToken({ pushToken: expoPushToken });
|
||||
// 偏好同步失败不应被用户感知为“开启失败”
|
||||
// (常见现象:后端已接收 token,但偏好接口短暂失败/超时)
|
||||
try {
|
||||
await setPushPreferences({ enabled: true, timesPerDay });
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
console.warn('[PushPreferences] 同步失败(ProfileModal,不阻塞)', msg);
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
Alert.alert(t('common.notice'), msg);
|
||||
}
|
||||
} else {
|
||||
setPushEnabled(false);
|
||||
setHasSystemPermission(false);
|
||||
@@ -412,18 +457,42 @@ 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);
|
||||
|
||||
// 若系统权限已授予且用户意愿为开启:兜底同步一次 token(避免“没点开关/没触发 toggle 导致后端无 token”)
|
||||
if (nextEnabled) {
|
||||
ensurePushTokenRegisteredIfPermitted().catch(() => {
|
||||
// ignore:不阻塞保存
|
||||
});
|
||||
}
|
||||
|
||||
// 同步后端偏好(幂等;失败不阻塞)
|
||||
try {
|
||||
await setPushPreferences({ enabled: nextEnabled, timesPerDay: nextTimes });
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
console.warn('[PushPreferences] 同步失败', msg);
|
||||
}
|
||||
setLoading(false);
|
||||
onDone();
|
||||
}
|
||||
@@ -457,24 +526,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
|
||||
@@ -493,6 +560,8 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () =
|
||||
function WidgetPage({ onOpenHowTo }: { onOpenHowTo: () => void }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const currentLang = i18n.language;
|
||||
// 需求:个人主页弹窗「小工具」页暂时隐藏锁屏小工具说明/入口
|
||||
const showLockScreenWidget = false;
|
||||
|
||||
// 根据语言选择图片
|
||||
const widget1 = currentLang === 'en'
|
||||
@@ -510,10 +579,12 @@ function WidgetPage({ onOpenHowTo }: { onOpenHowTo: () => void }) {
|
||||
</Pressable>
|
||||
|
||||
<View style={styles.widgetScroll}>
|
||||
<Pressable style={styles.widgetItem} onPress={onOpenHowTo}>
|
||||
<Image source={widget1} style={styles.widgetImg1} resizeMode="contain" />
|
||||
<Text style={styles.widgetLabel}>{t('widget.lockScreen')}</Text>
|
||||
</Pressable>
|
||||
{showLockScreenWidget ? (
|
||||
<Pressable style={styles.widgetItem} onPress={onOpenHowTo}>
|
||||
<Image source={widget1} style={styles.widgetImg1} resizeMode="contain" />
|
||||
<Text style={styles.widgetLabel}>{t('widget.lockScreen')}</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
|
||||
<Pressable style={styles.widgetItem} onPress={onOpenHowTo}>
|
||||
<Image source={widget2} style={styles.widgetImg2} resizeMode="contain" />
|
||||
@@ -530,7 +601,8 @@ function WidgetHowToPage() {
|
||||
const flatListRef = useRef<FlatList>(null);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [isManual, setIsManual] = useState(false);
|
||||
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
// React Native 环境下 setInterval 返回值类型与 Node 不同,这里用 ReturnType 兼容
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const images = currentLang === 'en' ? [
|
||||
{ id: '1', src: require('@/assets/images/home/Profile/widget/Widget_description1_en.png'), desc: t('widget.howToDesc1') },
|
||||
@@ -607,12 +679,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 (
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import SheetModal from '@/components/ui/SheetModal';
|
||||
|
||||
export type ThemeMode = 'scenery' | 'color';
|
||||
import type { ThemeMode } from '@/src/storage/appStorage';
|
||||
|
||||
type Props = {
|
||||
visible: boolean;
|
||||
@@ -16,7 +16,7 @@ type Props = {
|
||||
export default function ThemeModal({ visible, mode, onSelect, onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<SheetModal visible={visible} title={t('theme.title')} onClose={onClose} height={350}>
|
||||
<SheetModal visible={visible} title={t('theme.title')} onClose={onClose} height={360}>
|
||||
<View style={styles.row}>
|
||||
<ThemeCard
|
||||
title={t('theme.scenery')}
|
||||
@@ -41,6 +41,19 @@ export default function ThemeModal({ visible, mode, onSelect, onClose }: Props)
|
||||
style={styles.previewImage}
|
||||
/>
|
||||
</ThemeCard>
|
||||
|
||||
<ThemeCard
|
||||
title={t('theme.suixin')}
|
||||
selected={mode === 'suixin'}
|
||||
onPress={() => onSelect('suixin')}
|
||||
>
|
||||
<Image
|
||||
// 占位:一期复用纯色预览图,后续可替换为专用资源
|
||||
source={require('../../assets/images/theme/theme_color.png')}
|
||||
resizeMode="cover"
|
||||
style={styles.previewImage}
|
||||
/>
|
||||
</ThemeCard>
|
||||
</View>
|
||||
</SheetModal>
|
||||
);
|
||||
@@ -68,7 +81,12 @@ function ThemeCard({
|
||||
{children}
|
||||
{/* 文案展示在图片中心 */}
|
||||
<View style={styles.textOverlay}>
|
||||
<Text style={[styles.overlayTitle, selected && styles.selectedOverlayTitle]}>
|
||||
<Text
|
||||
style={[styles.overlayTitle, selected && styles.selectedOverlayTitle]}
|
||||
numberOfLines={1}
|
||||
adjustsFontSizeToFit
|
||||
minimumFontScale={0.85}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -81,19 +99,21 @@ function ThemeCard({
|
||||
const styles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
gap: 30,
|
||||
paddingHorizontal: 10,
|
||||
flexWrap: 'nowrap',
|
||||
gap: 12,
|
||||
paddingHorizontal: 4,
|
||||
paddingBottom: 50,
|
||||
paddingTop: 20,
|
||||
justifyContent: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
cardContainer: {
|
||||
alignItems: 'center',
|
||||
width: 143,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
alignItems: 'stretch',
|
||||
},
|
||||
previewWrapper: {
|
||||
width: 138,
|
||||
height: 203,
|
||||
width: '100%',
|
||||
aspectRatio: 110 / 178,
|
||||
borderRadius: 26,
|
||||
padding: 6.5,
|
||||
justifyContent: 'center',
|
||||
|
||||
@@ -13,7 +13,7 @@ export default function WidgetModal({ visible, onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<SheetModal visible={visible} title={t('profile.widget')} onClose={onClose}>
|
||||
<SheetModal visible={visible} title={t('widget.howToTitle')} onClose={onClose}>
|
||||
<View style={styles.content}>
|
||||
<View style={styles.row}>
|
||||
<PreviewCard label={t('widget.lockScreen')}>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet, TouchableOpacity } from 'react-native';
|
||||
import { SerifText } from './SerifText';
|
||||
import { OnboardingColors } from '@/constants/OnboardingTheme';
|
||||
import { View, StyleSheet, TouchableOpacity, Text } from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { OnboardingColors, OnboardingFont } from '@/constants/OnboardingTheme';
|
||||
|
||||
export const INTENTS = [
|
||||
{ id: 'love', label: '爱情', icon: '❤️' },
|
||||
{ id: 'life', label: '生活', icon: '⛅' },
|
||||
{ id: 'travel', label: '旅游', icon: '🌴' },
|
||||
{ id: 'work', label: '职场', icon: '💼' },
|
||||
{ id: 'love', labelKey: 'intent.love', icon: '❤️' },
|
||||
{ id: 'life', labelKey: 'intent.life', icon: '⛅' },
|
||||
{ id: 'travel', labelKey: 'intent.travel', icon: '🌴' },
|
||||
{ id: 'work', labelKey: 'intent.work', icon: '💼' },
|
||||
];
|
||||
|
||||
interface IntentSelectionStepProps {
|
||||
@@ -16,9 +16,10 @@ interface IntentSelectionStepProps {
|
||||
}
|
||||
|
||||
export function IntentSelectionStep({ selectedIds, onToggle }: IntentSelectionStepProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<SerifText style={styles.title}>你希望得到什么帮助?</SerifText>
|
||||
<Text style={styles.title}>{t('intent.title')}</Text>
|
||||
|
||||
<View style={styles.grid}>
|
||||
{INTENTS.map((intent) => {
|
||||
@@ -33,10 +34,10 @@ export function IntentSelectionStep({ selectedIds, onToggle }: IntentSelectionSt
|
||||
onPress={() => onToggle(intent.id)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<SerifText style={styles.icon}>{intent.icon}</SerifText>
|
||||
<SerifText style={[styles.label, isSelected && styles.labelSelected]}>
|
||||
{intent.label}
|
||||
</SerifText>
|
||||
<Text style={styles.icon}>{intent.icon}</Text>
|
||||
<Text style={[styles.label, isSelected && styles.labelSelected]}>
|
||||
{t(intent.labelKey)}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
@@ -54,6 +55,8 @@ const styles = StyleSheet.create({
|
||||
fontSize: 24,
|
||||
marginBottom: 40,
|
||||
textAlign: 'center',
|
||||
fontFamily: OnboardingFont.question,
|
||||
color: OnboardingColors.textPrimary,
|
||||
},
|
||||
grid: {
|
||||
flexDirection: 'row',
|
||||
@@ -84,10 +87,12 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
icon: {
|
||||
fontSize: 32,
|
||||
fontFamily: OnboardingFont.question,
|
||||
},
|
||||
label: {
|
||||
fontSize: 18,
|
||||
color: OnboardingColors.textPrimary,
|
||||
fontFamily: OnboardingFont.question,
|
||||
},
|
||||
labelSelected: {
|
||||
fontWeight: 'bold',
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { View, StyleSheet, TextInput, Platform, Animated, TouchableOpacity, Dimensions, Text } from 'react-native';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { View, StyleSheet, TextInput, Platform, Animated, TouchableOpacity, Text, Keyboard, Pressable } from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { OnboardingColors } from '@/constants/OnboardingTheme';
|
||||
import BtnNotClicked from '@/assets/images/icon/btn_Notclicked.svg';
|
||||
import BtnClicked from '@/assets/images/icon/btn_clicked.svg';
|
||||
import EnterLightIcon from '@/assets/images/icon/enter_Light_icon.svg';
|
||||
|
||||
const { height } = Dimensions.get('window');
|
||||
|
||||
interface NameInputStepProps {
|
||||
value: string;
|
||||
onChangeText: (text: string) => void;
|
||||
@@ -14,10 +14,30 @@ interface NameInputStepProps {
|
||||
}
|
||||
|
||||
export function NameInputStep({ value, onChangeText, onNext }: NameInputStepProps) {
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [keyboardHeight, setKeyboardHeight] = useState(0);
|
||||
const blinkAnim = useRef(new Animated.Value(1)).current;
|
||||
const hasInput = value.trim().length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
|
||||
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
|
||||
|
||||
const subShow = Keyboard.addListener(showEvent, (e) => {
|
||||
setKeyboardHeight(e.endCoordinates?.height ?? 0);
|
||||
});
|
||||
const subHide = Keyboard.addListener(hideEvent, () => {
|
||||
setKeyboardHeight(0);
|
||||
});
|
||||
|
||||
return () => {
|
||||
subShow.remove();
|
||||
subHide.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const animation = Animated.loop(
|
||||
Animated.sequence([
|
||||
@@ -34,8 +54,14 @@ export function NameInputStep({ value, onChangeText, onNext }: NameInputStepProp
|
||||
return () => animation.stop();
|
||||
}, [blinkAnim, isFocused]);
|
||||
|
||||
const footerBottom = useMemo(() => {
|
||||
// iOS 的 keyboard height 通常已包含底部安全区,避免重复叠加
|
||||
const keyboardOffset = Math.max(0, keyboardHeight - insets.bottom);
|
||||
return 16 + insets.bottom + keyboardOffset;
|
||||
}, [insets.bottom, keyboardHeight]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Pressable style={styles.container} onPress={Keyboard.dismiss} accessible={false}>
|
||||
<View style={styles.inputCard}>
|
||||
<View style={styles.inputWrapper}>
|
||||
{/* 显示层:文案 + 跟随的光标 */}
|
||||
@@ -46,7 +72,7 @@ export function NameInputStep({ value, onChangeText, onNext }: NameInputStepProp
|
||||
(!isFocused && !hasInput) && { color: OnboardingColors.textSecondary }
|
||||
]}
|
||||
>
|
||||
{hasInput ? value : (isFocused ? "" : "Mama")}
|
||||
{hasInput ? value : isFocused ? '' : t('onboardingSurvey.steps.name.placeholder')}
|
||||
</Text>
|
||||
{isFocused && (
|
||||
<Animated.View style={[styles.cursorWrapper, { opacity: blinkAnim, marginLeft: 2 }]}>
|
||||
@@ -65,20 +91,29 @@ export function NameInputStep({ value, onChangeText, onNext }: NameInputStepProp
|
||||
caretHidden={true}
|
||||
autoCorrect={false}
|
||||
spellCheck={false}
|
||||
returnKeyType="done"
|
||||
blurOnSubmit={true}
|
||||
onSubmitEditing={() => {
|
||||
Keyboard.dismiss();
|
||||
// 不再自動跳頁,僅收起鍵盤;前進需點擊底部 ➡️
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<View style={[styles.footer, { bottom: footerBottom }]}>
|
||||
<TouchableOpacity
|
||||
onPress={onNext}
|
||||
onPress={() => {
|
||||
Keyboard.dismiss();
|
||||
onNext();
|
||||
}}
|
||||
disabled={!hasInput}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
{hasInput ? <BtnClicked width={87} height={57} /> : <BtnNotClicked width={87} height={57} />}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -130,7 +165,6 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
footer: {
|
||||
position: 'absolute',
|
||||
bottom: height * 0.12,
|
||||
alignItems: 'center',
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet, SafeAreaView, TouchableOpacity, StatusBar, Text, Image, Platform } from 'react-native';
|
||||
import { OnboardingColors } from '@/constants/OnboardingTheme';
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import { View, StyleSheet, SafeAreaView, TouchableOpacity, StatusBar, Text, Image, Platform, Animated, Easing } from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { OnboardingColors, OnboardingFont } from '@/constants/OnboardingTheme';
|
||||
|
||||
const TRANSITION_OFFSET = 24;
|
||||
const TRANSITION_DURATION = 280;
|
||||
|
||||
interface OnboardingLayoutProps {
|
||||
children: React.ReactNode;
|
||||
@@ -10,6 +14,8 @@ interface OnboardingLayoutProps {
|
||||
onSkip: () => void;
|
||||
onBack?: () => void;
|
||||
showBackButton?: boolean;
|
||||
/** 用户名字,仅在名字步骤之后的第一个问题(currentStep === 1)且非空时显示招呼语 */
|
||||
userName?: string;
|
||||
}
|
||||
|
||||
export function OnboardingLayout({
|
||||
@@ -19,8 +25,48 @@ export function OnboardingLayout({
|
||||
totalSteps,
|
||||
onSkip,
|
||||
onBack,
|
||||
showBackButton = false
|
||||
showBackButton = false,
|
||||
userName = '',
|
||||
}: OnboardingLayoutProps) {
|
||||
const { t } = useTranslation();
|
||||
const showGreeting = currentStep === 1 && userName.trim().length > 0;
|
||||
const displayName = userName.trim();
|
||||
const prevStepRef = useRef(currentStep);
|
||||
const isFirstRenderRef = useRef(true);
|
||||
const translateX = useRef(new Animated.Value(0)).current;
|
||||
const opacity = useRef(new Animated.Value(1)).current;
|
||||
|
||||
useEffect(() => {
|
||||
if (isFirstRenderRef.current) {
|
||||
isFirstRenderRef.current = false;
|
||||
prevStepRef.current = currentStep;
|
||||
return;
|
||||
}
|
||||
if (prevStepRef.current === currentStep) return;
|
||||
|
||||
const direction = currentStep > prevStepRef.current ? 'forward' : 'back';
|
||||
prevStepRef.current = currentStep;
|
||||
|
||||
const startX = direction === 'forward' ? TRANSITION_OFFSET : -TRANSITION_OFFSET;
|
||||
translateX.setValue(startX);
|
||||
opacity.setValue(0.72);
|
||||
|
||||
Animated.parallel([
|
||||
Animated.timing(translateX, {
|
||||
toValue: 0,
|
||||
duration: TRANSITION_DURATION,
|
||||
useNativeDriver: true,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
}),
|
||||
Animated.timing(opacity, {
|
||||
toValue: 1,
|
||||
duration: TRANSITION_DURATION,
|
||||
useNativeDriver: true,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
}),
|
||||
]).start();
|
||||
}, [currentStep, translateX, opacity]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<StatusBar barStyle="dark-content" />
|
||||
@@ -39,7 +85,7 @@ export function OnboardingLayout({
|
||||
</View>
|
||||
|
||||
<TouchableOpacity onPress={onSkip} style={styles.skipButton}>
|
||||
<Text style={styles.skipText}>skip</Text>
|
||||
<Text style={styles.skipText}>{t('onboarding.skipAll')}</Text>
|
||||
<Image
|
||||
source={require('@/assets/images/icon/skip_icon.png')}
|
||||
style={styles.skipIcon}
|
||||
@@ -47,16 +93,29 @@ export function OnboardingLayout({
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Title & Progress Row */}
|
||||
{/* Title & Progress Row(名字步骤后第一步且名字非空时显示招呼语 + 问题) */}
|
||||
<View style={styles.titleRow}>
|
||||
<Text style={styles.questionTitle}>{title}</Text>
|
||||
<View style={styles.titleBlock}>
|
||||
{showGreeting && (
|
||||
<Text style={styles.greetingText}>{t('onboardingSurvey.greeting', { name: displayName })}</Text>
|
||||
)}
|
||||
<Text style={styles.questionTitle}>{title}</Text>
|
||||
</View>
|
||||
<Text style={styles.progressText}>({currentStep}/{totalSteps})</Text>
|
||||
</View>
|
||||
|
||||
{/* Content */}
|
||||
<View style={styles.content}>
|
||||
{/* Content:step 切换时滑动 + 淡入 */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.content,
|
||||
{
|
||||
opacity,
|
||||
transform: [{ translateX }],
|
||||
},
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
</Animated.View>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
);
|
||||
@@ -112,18 +171,27 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'flex-end',
|
||||
paddingHorizontal: 20,
|
||||
marginTop: 20,
|
||||
marginBottom: 20,
|
||||
marginBottom: 8,
|
||||
},
|
||||
titleBlock: {
|
||||
flex: 1,
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
greetingText: {
|
||||
fontSize: 22,
|
||||
color: OnboardingColors.questionTitle,
|
||||
fontFamily: OnboardingFont.question,
|
||||
marginBottom: 4,
|
||||
},
|
||||
questionTitle: {
|
||||
fontSize: 22,
|
||||
color: OnboardingColors.questionTitle,
|
||||
fontFamily: Platform.OS === 'ios' ? 'PingFang TC' : 'sans-serif',
|
||||
flex: 1,
|
||||
fontFamily: OnboardingFont.question,
|
||||
},
|
||||
progressText: {
|
||||
fontSize: 18,
|
||||
color: OnboardingColors.textProgress,
|
||||
fontFamily: Platform.OS === 'ios' ? 'PingFang TC' : 'sans-serif',
|
||||
fontFamily: OnboardingFont.question,
|
||||
marginLeft: 10,
|
||||
},
|
||||
content: {
|
||||
|
||||
@@ -1,20 +1,28 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet, TouchableOpacity, Text, Platform, Dimensions } from 'react-native';
|
||||
import { View, StyleSheet, TouchableOpacity, Text, Platform, ActivityIndicator } from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { OnboardingColors } from '@/constants/OnboardingTheme';
|
||||
import AddIcon from '@/assets/images/icon/add_icon.svg';
|
||||
import ReduceIcon from '@/assets/images/icon/reduce_icon.svg';
|
||||
import BtnClicked from '@/assets/images/icon/btn_clicked.svg';
|
||||
|
||||
const { height } = Dimensions.get('window');
|
||||
|
||||
interface ReminderStepProps {
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
onFinish: () => void;
|
||||
onSkip?: () => void;
|
||||
/** 完成后请求通知权限时的加载态 */
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function ReminderStep({ value, onChange, onFinish }: ReminderStepProps) {
|
||||
export function ReminderStep({ value, onChange, onFinish, loading = false }: ReminderStepProps) {
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const handleReduce = () => {
|
||||
// 本页最小为 1;不接收提醒请使用右上角 Skip
|
||||
if (value > 1) onChange(value - 1);
|
||||
};
|
||||
|
||||
@@ -25,23 +33,38 @@ export function ReminderStep({ value, onChange, onFinish }: ReminderStepProps) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.counterContainer}>
|
||||
<TouchableOpacity onPress={handleReduce} activeOpacity={0.7}>
|
||||
<TouchableOpacity onPress={handleReduce} disabled={loading} activeOpacity={0.7}>
|
||||
<ReduceIcon width={47} height={47} />
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.numberWrapper}>
|
||||
<Text style={styles.numberText}>{value}</Text>
|
||||
<Text style={styles.unitText}>次</Text>
|
||||
<Text style={styles.unitText}>
|
||||
{value === 1 ? t('dailyReminder.timesUnitSingular') : t('dailyReminder.timesUnit')}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity onPress={handleAdd} activeOpacity={0.7}>
|
||||
<TouchableOpacity onPress={handleAdd} disabled={loading} activeOpacity={0.7}>
|
||||
<AddIcon width={47} height={47} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<TouchableOpacity onPress={onFinish} activeOpacity={0.8}>
|
||||
<BtnClicked width={87} height={57} />
|
||||
<View style={[styles.footer, { bottom: insets.bottom + 16 }]}>
|
||||
<TouchableOpacity onPress={onFinish} disabled={loading} activeOpacity={0.8}>
|
||||
<View style={styles.finishWrap}>
|
||||
{loading ? (
|
||||
<LinearGradient
|
||||
colors={['#F69F7B', '#F99CC0']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={[styles.loadingPill, styles.finishDisabled]}
|
||||
>
|
||||
<ActivityIndicator size="small" color="#FFFFFF" />
|
||||
</LinearGradient>
|
||||
) : (
|
||||
<BtnClicked width={87} height={57} />
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
@@ -83,7 +106,22 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
footer: {
|
||||
position: 'absolute',
|
||||
bottom: height * 0.12,
|
||||
alignItems: 'center',
|
||||
}
|
||||
},
|
||||
finishWrap: {
|
||||
width: 87,
|
||||
height: 57,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
finishDisabled: {
|
||||
opacity: 0.7,
|
||||
},
|
||||
loadingPill: {
|
||||
width: 87,
|
||||
height: 57,
|
||||
borderRadius: 28.5,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet, TouchableOpacity, ScrollView, Dimensions } from 'react-native';
|
||||
import { OnboardingColors } from '@/constants/OnboardingTheme';
|
||||
import { SerifText } from './SerifText';
|
||||
import SelectedIcon from '@/assets/images/icon/selected_icon.svg';
|
||||
import { View, StyleSheet, TouchableOpacity, ScrollView, Text } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { OnboardingColors, OnboardingFont } from '@/constants/OnboardingTheme';
|
||||
import BtnNotClicked from '@/assets/images/icon/btn_Notclicked.svg';
|
||||
import BtnClicked from '@/assets/images/icon/btn_clicked.svg';
|
||||
|
||||
const { height } = Dimensions.get('window');
|
||||
|
||||
interface Option {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -23,43 +20,39 @@ interface SelectionStepProps {
|
||||
|
||||
export function SelectionStep({ options, selectedIds, onToggle, onNext, onSkip }: SelectionStepProps) {
|
||||
const hasSelection = selectedIds.length > 0;
|
||||
const insets = useSafeAreaInsets();
|
||||
const footerBottom = insets.bottom + 16;
|
||||
const footerButtonHeight = 57;
|
||||
// 底部留白加大,避免最后一项与按钮边框视觉重叠
|
||||
const footerPaddingBottom = footerBottom + footerButtonHeight + 40;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.optionsList}>
|
||||
<ScrollView
|
||||
style={styles.scroll}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={[styles.optionsList, { paddingBottom: footerPaddingBottom }]}
|
||||
>
|
||||
{options.map((option) => {
|
||||
const isSelected = selectedIds.includes(option.id);
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={option.id}
|
||||
style={styles.optionCard}
|
||||
style={[styles.optionCard, isSelected && styles.optionCardSelected]}
|
||||
onPress={() => onToggle(option.id)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<SerifText style={styles.optionText}>{option.label}</SerifText>
|
||||
{isSelected && (
|
||||
<View style={styles.iconWrapper}>
|
||||
<SelectedIcon width={20} height={20} />
|
||||
</View>
|
||||
)}
|
||||
<Text style={styles.optionText}>{option.label}</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
|
||||
{/* 底部按钮:距离底部 12% 高度 */}
|
||||
<View style={styles.footer}>
|
||||
<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>
|
||||
<View style={[styles.footer, { bottom: footerBottom }]}>
|
||||
<TouchableOpacity onPress={onNext} disabled={!hasSelection} activeOpacity={0.8}>
|
||||
{hasSelection ? <BtnClicked width={87} height={57} /> : <BtnNotClicked width={87} height={57} />}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -68,10 +61,13 @@ export function SelectionStep({ options, selectedIds, onToggle, onNext, onSkip }
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
paddingTop: 20,
|
||||
paddingTop: 8,
|
||||
},
|
||||
scroll: {
|
||||
flex: 1,
|
||||
},
|
||||
optionsList: {
|
||||
paddingBottom: 150, // 为底部按钮留出空间
|
||||
// paddingBottom 由安全区 + 按钮高度动态计算,避免选项被遮住
|
||||
},
|
||||
optionCard: {
|
||||
width: '100%',
|
||||
@@ -80,7 +76,7 @@ const styles = StyleSheet.create({
|
||||
borderRadius: 20,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 24,
|
||||
marginBottom: 12,
|
||||
shadowColor: '#000',
|
||||
@@ -89,18 +85,17 @@ const styles = StyleSheet.create({
|
||||
shadowRadius: 10,
|
||||
elevation: 2,
|
||||
},
|
||||
optionCardSelected: {
|
||||
backgroundColor: OnboardingColors.cardSelected,
|
||||
},
|
||||
optionText: {
|
||||
fontSize: 18,
|
||||
color: OnboardingColors.textPrimary,
|
||||
fontWeight: '500',
|
||||
flex: 1,
|
||||
},
|
||||
iconWrapper: {
|
||||
marginLeft: 10,
|
||||
fontFamily: OnboardingFont.question,
|
||||
},
|
||||
footer: {
|
||||
position: 'absolute',
|
||||
bottom: height * 0.12,
|
||||
left: 0,
|
||||
right: 0,
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
/** 与 onboarding 问题标题一致的字体(PingFang TC / sans-serif) */
|
||||
export const OnboardingFont = {
|
||||
question: Platform.OS === 'ios' ? 'PingFang TC' : 'sans-serif',
|
||||
};
|
||||
|
||||
export const OnboardingColors = {
|
||||
background: '#FFF4EA',
|
||||
textPrimary: '#772F00',
|
||||
|
||||
21
client/eas.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"cli": {
|
||||
"version": ">= 16.32.0",
|
||||
"appVersionSource": "remote"
|
||||
},
|
||||
"build": {
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal"
|
||||
},
|
||||
"preview": {
|
||||
"distribution": "internal"
|
||||
},
|
||||
"production": {
|
||||
"autoIncrement": true
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {}
|
||||
}
|
||||
}
|
||||
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`
|
||||
|
||||
@@ -67,5 +67,84 @@ target 'client' do
|
||||
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,183 @@ 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
|
||||
- ExpoDevice (8.0.10):
|
||||
- ExpoModulesCore
|
||||
- ExpoFileSystem (19.0.21):
|
||||
- ExpoModulesCore
|
||||
- ExpoFont (14.0.11):
|
||||
@@ -74,6 +252,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 +1978,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
|
||||
@@ -2038,291 +2240,330 @@ PODS:
|
||||
- Yoga (0.0.0)
|
||||
|
||||
DEPENDENCIES:
|
||||
- "EXApplication (from `../node_modules/.pnpm/expo-application@7.0.8_expo@54.0.32/node_modules/expo-application/ios`)"
|
||||
- "EXConstants (from `../node_modules/.pnpm/expo-constants@18.0.13_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0_/node_modules/expo-constants/ios`)"
|
||||
- "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`)"
|
||||
- "ExpoAsset (from `../node_modules/.pnpm/expo-asset@12.0.12_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-asset/ios`)"
|
||||
- "ExpoFileSystem (from `../node_modules/.pnpm/expo-file-system@19.0.21_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0_/node_modules/expo-file-system/ios`)"
|
||||
- "ExpoFont (from `../node_modules/.pnpm/expo-font@14.0.11_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-font/ios`)"
|
||||
- "ExpoHead (from `../node_modules/.pnpm/expo-router@6.0.22_@expo+metro-runtime@6.1.2_@types+react@19.1.17_expo-constants@18.0.13_expo_rjurfbyy5kjn57nkkfxix5iqea/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`)"
|
||||
- "ExpoLocalization (from `../node_modules/.pnpm/expo-localization@17.0.8_expo@54.0.32_react@19.1.0/node_modules/expo-localization/ios`)"
|
||||
- "ExpoModulesCore (from `../node_modules/.pnpm/expo-modules-core@3.0.29_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-modules-core`)"
|
||||
- "ExpoSplashScreen (from `../node_modules/.pnpm/expo-splash-screen@31.0.13_expo@54.0.32/node_modules/expo-splash-screen/ios`)"
|
||||
- "ExpoWebBrowser (from `../node_modules/.pnpm/expo-web-browser@15.0.10_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0_/node_modules/expo-web-browser/ios`)"
|
||||
- "FBLazyVector (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/FBLazyVector`)"
|
||||
- "hermes-engine (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`)"
|
||||
- "RCTDeprecation (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`)"
|
||||
- "RCTRequired (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Required`)"
|
||||
- "RCTTypeSafety (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/TypeSafety`)"
|
||||
- "React (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/`)"
|
||||
- "React-callinvoker (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/callinvoker`)"
|
||||
- "React-Core (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/`)"
|
||||
- "React-Core-prebuilt (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/React-Core-prebuilt.podspec`)"
|
||||
- "React-Core/RCTWebSocket (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/`)"
|
||||
- "React-CoreModules (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/React/CoreModules`)"
|
||||
- "React-cxxreact (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/cxxreact`)"
|
||||
- "React-debug (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/debug`)"
|
||||
- "React-defaultsnativemodule (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/defaults`)"
|
||||
- "React-domnativemodule (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/dom`)"
|
||||
- "React-Fabric (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon`)"
|
||||
- "React-FabricComponents (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon`)"
|
||||
- "React-FabricImage (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon`)"
|
||||
- "React-featureflags (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/featureflags`)"
|
||||
- "React-featureflagsnativemodule (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/featureflags`)"
|
||||
- "React-graphics (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/renderer/graphics`)"
|
||||
- "React-hermes (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/hermes`)"
|
||||
- "React-idlecallbacksnativemodule (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/idlecallbacks`)"
|
||||
- "React-ImageManager (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/renderer/imagemanager/platform/ios`)"
|
||||
- "React-jserrorhandler (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jserrorhandler`)"
|
||||
- "React-jsi (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsi`)"
|
||||
- "React-jsiexecutor (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsiexecutor`)"
|
||||
- "React-jsinspector (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsinspector-modern`)"
|
||||
- "React-jsinspectorcdp (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsinspector-modern/cdp`)"
|
||||
- "React-jsinspectornetwork (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsinspector-modern/network`)"
|
||||
- "React-jsinspectortracing (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsinspector-modern/tracing`)"
|
||||
- "React-jsitooling (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsitooling`)"
|
||||
- "React-jsitracing (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/hermes/executor/`)"
|
||||
- "React-logger (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/logger`)"
|
||||
- "React-Mapbuffer (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon`)"
|
||||
- "React-microtasksnativemodule (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/microtasks`)"
|
||||
- "react-native-safe-area-context (from `../node_modules/.pnpm/react-native-safe-area-context@5.6.2_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1_azuxgonsvxb2yngtegtuvyxcpi/node_modules/react-native-safe-area-context`)"
|
||||
- "React-NativeModulesApple (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`)"
|
||||
- "React-oscompat (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/oscompat`)"
|
||||
- "React-perflogger (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/reactperflogger`)"
|
||||
- "React-performancetimeline (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/performance/timeline`)"
|
||||
- "React-RCTActionSheet (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/ActionSheetIOS`)"
|
||||
- "React-RCTAnimation (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/NativeAnimation`)"
|
||||
- "React-RCTAppDelegate (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/AppDelegate`)"
|
||||
- "React-RCTBlob (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Blob`)"
|
||||
- "React-RCTFabric (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/React`)"
|
||||
- "React-RCTFBReactNativeSpec (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/React`)"
|
||||
- "React-RCTImage (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Image`)"
|
||||
- "React-RCTLinking (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/LinkingIOS`)"
|
||||
- "React-RCTNetwork (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Network`)"
|
||||
- "React-RCTRuntime (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/React/Runtime`)"
|
||||
- "React-RCTSettings (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Settings`)"
|
||||
- "React-RCTText (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Text`)"
|
||||
- "React-RCTVibration (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Vibration`)"
|
||||
- "React-rendererconsistency (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/renderer/consistency`)"
|
||||
- "React-renderercss (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/renderer/css`)"
|
||||
- "React-rendererdebug (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/renderer/debug`)"
|
||||
- "React-RuntimeApple (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/runtime/platform/ios`)"
|
||||
- "React-RuntimeCore (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/runtime`)"
|
||||
- "React-runtimeexecutor (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/runtimeexecutor`)"
|
||||
- "React-RuntimeHermes (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/runtime`)"
|
||||
- "React-runtimescheduler (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/renderer/runtimescheduler`)"
|
||||
- "React-timing (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/timing`)"
|
||||
- "React-utils (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/utils`)"
|
||||
- EXApplication (from `../node_modules/expo-application/ios`)
|
||||
- EXConstants (from `../node_modules/expo-constants/ios`)
|
||||
- EXJSONUtils (from `../node_modules/expo-json-utils/ios`)
|
||||
- EXManifests (from `../node_modules/expo-manifests/ios`)
|
||||
- EXNotifications (from `../node_modules/expo-notifications/ios`)
|
||||
- Expo (from `../node_modules/expo`)
|
||||
- expo-dev-client (from `../node_modules/expo-dev-client/ios`)
|
||||
- expo-dev-launcher (from `../node_modules/expo-dev-launcher`)
|
||||
- expo-dev-menu (from `../node_modules/expo-dev-menu`)
|
||||
- expo-dev-menu-interface (from `../node_modules/expo-dev-menu-interface/ios`)
|
||||
- ExpoAsset (from `../node_modules/expo-asset/ios`)
|
||||
- ExpoCrypto (from `../node_modules/expo-crypto/ios`)
|
||||
- ExpoDevice (from `../node_modules/expo-device/ios`)
|
||||
- ExpoFileSystem (from `../node_modules/expo-file-system/ios`)
|
||||
- ExpoFont (from `../node_modules/expo-font/ios`)
|
||||
- ExpoHead (from `../node_modules/expo-router/ios`)
|
||||
- ExpoKeepAwake (from `../node_modules/expo-keep-awake/ios`)
|
||||
- ExpoLinearGradient (from `../node_modules/expo-linear-gradient/ios`)
|
||||
- ExpoLinking (from `../node_modules/expo-linking/ios`)
|
||||
- ExpoLocalization (from `../node_modules/expo-localization/ios`)
|
||||
- ExpoModulesCore (from `../node_modules/expo-modules-core`)
|
||||
- ExpoSplashScreen (from `../node_modules/expo-splash-screen/ios`)
|
||||
- ExpoWebBrowser (from `../node_modules/expo-web-browser/ios`)
|
||||
- EXUpdatesInterface (from `../node_modules/expo-updates-interface/ios`)
|
||||
- FBLazyVector (from `../node_modules/react-native/Libraries/FBLazyVector`)
|
||||
- hermes-engine (from `../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`)
|
||||
- RCTDeprecation (from `../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`)
|
||||
- RCTRequired (from `../node_modules/react-native/Libraries/Required`)
|
||||
- RCTTypeSafety (from `../node_modules/react-native/Libraries/TypeSafety`)
|
||||
- React (from `../node_modules/react-native/`)
|
||||
- React-callinvoker (from `../node_modules/react-native/ReactCommon/callinvoker`)
|
||||
- React-Core (from `../node_modules/react-native/`)
|
||||
- React-Core-prebuilt (from `../node_modules/react-native/React-Core-prebuilt.podspec`)
|
||||
- React-Core/RCTWebSocket (from `../node_modules/react-native/`)
|
||||
- React-CoreModules (from `../node_modules/react-native/React/CoreModules`)
|
||||
- React-cxxreact (from `../node_modules/react-native/ReactCommon/cxxreact`)
|
||||
- React-debug (from `../node_modules/react-native/ReactCommon/react/debug`)
|
||||
- React-defaultsnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/defaults`)
|
||||
- React-domnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/dom`)
|
||||
- React-Fabric (from `../node_modules/react-native/ReactCommon`)
|
||||
- React-FabricComponents (from `../node_modules/react-native/ReactCommon`)
|
||||
- React-FabricImage (from `../node_modules/react-native/ReactCommon`)
|
||||
- React-featureflags (from `../node_modules/react-native/ReactCommon/react/featureflags`)
|
||||
- React-featureflagsnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/featureflags`)
|
||||
- React-graphics (from `../node_modules/react-native/ReactCommon/react/renderer/graphics`)
|
||||
- React-hermes (from `../node_modules/react-native/ReactCommon/hermes`)
|
||||
- React-idlecallbacksnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/idlecallbacks`)
|
||||
- React-ImageManager (from `../node_modules/react-native/ReactCommon/react/renderer/imagemanager/platform/ios`)
|
||||
- React-jserrorhandler (from `../node_modules/react-native/ReactCommon/jserrorhandler`)
|
||||
- React-jsi (from `../node_modules/react-native/ReactCommon/jsi`)
|
||||
- React-jsiexecutor (from `../node_modules/react-native/ReactCommon/jsiexecutor`)
|
||||
- React-jsinspector (from `../node_modules/react-native/ReactCommon/jsinspector-modern`)
|
||||
- React-jsinspectorcdp (from `../node_modules/react-native/ReactCommon/jsinspector-modern/cdp`)
|
||||
- React-jsinspectornetwork (from `../node_modules/react-native/ReactCommon/jsinspector-modern/network`)
|
||||
- React-jsinspectortracing (from `../node_modules/react-native/ReactCommon/jsinspector-modern/tracing`)
|
||||
- React-jsitooling (from `../node_modules/react-native/ReactCommon/jsitooling`)
|
||||
- React-jsitracing (from `../node_modules/react-native/ReactCommon/hermes/executor/`)
|
||||
- React-logger (from `../node_modules/react-native/ReactCommon/logger`)
|
||||
- React-Mapbuffer (from `../node_modules/react-native/ReactCommon`)
|
||||
- React-microtasksnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`)
|
||||
- react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`)
|
||||
- React-NativeModulesApple (from `../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`)
|
||||
- React-oscompat (from `../node_modules/react-native/ReactCommon/oscompat`)
|
||||
- React-perflogger (from `../node_modules/react-native/ReactCommon/reactperflogger`)
|
||||
- React-performancetimeline (from `../node_modules/react-native/ReactCommon/react/performance/timeline`)
|
||||
- React-RCTActionSheet (from `../node_modules/react-native/Libraries/ActionSheetIOS`)
|
||||
- React-RCTAnimation (from `../node_modules/react-native/Libraries/NativeAnimation`)
|
||||
- React-RCTAppDelegate (from `../node_modules/react-native/Libraries/AppDelegate`)
|
||||
- React-RCTBlob (from `../node_modules/react-native/Libraries/Blob`)
|
||||
- React-RCTFabric (from `../node_modules/react-native/React`)
|
||||
- React-RCTFBReactNativeSpec (from `../node_modules/react-native/React`)
|
||||
- React-RCTImage (from `../node_modules/react-native/Libraries/Image`)
|
||||
- React-RCTLinking (from `../node_modules/react-native/Libraries/LinkingIOS`)
|
||||
- React-RCTNetwork (from `../node_modules/react-native/Libraries/Network`)
|
||||
- React-RCTRuntime (from `../node_modules/react-native/React/Runtime`)
|
||||
- React-RCTSettings (from `../node_modules/react-native/Libraries/Settings`)
|
||||
- React-RCTText (from `../node_modules/react-native/Libraries/Text`)
|
||||
- React-RCTVibration (from `../node_modules/react-native/Libraries/Vibration`)
|
||||
- React-rendererconsistency (from `../node_modules/react-native/ReactCommon/react/renderer/consistency`)
|
||||
- React-renderercss (from `../node_modules/react-native/ReactCommon/react/renderer/css`)
|
||||
- React-rendererdebug (from `../node_modules/react-native/ReactCommon/react/renderer/debug`)
|
||||
- React-RuntimeApple (from `../node_modules/react-native/ReactCommon/react/runtime/platform/ios`)
|
||||
- React-RuntimeCore (from `../node_modules/react-native/ReactCommon/react/runtime`)
|
||||
- React-runtimeexecutor (from `../node_modules/react-native/ReactCommon/runtimeexecutor`)
|
||||
- React-RuntimeHermes (from `../node_modules/react-native/ReactCommon/react/runtime`)
|
||||
- React-runtimescheduler (from `../node_modules/react-native/ReactCommon/react/renderer/runtimescheduler`)
|
||||
- React-timing (from `../node_modules/react-native/ReactCommon/react/timing`)
|
||||
- React-utils (from `../node_modules/react-native/ReactCommon/react/utils`)
|
||||
- ReactAppDependencyProvider (from `build/generated/ios`)
|
||||
- ReactCodegen (from `build/generated/ios`)
|
||||
- "ReactCommon/turbomodule/core (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon`)"
|
||||
- "ReactNativeDependencies (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec`)"
|
||||
- "RNCAsyncStorage (from `../node_modules/.pnpm/@react-native-async-storage+async-storage@2.2.0_react-native@0.81.5_@babel+core@7.28.6_@types_fp4qq3a7mejmut52v6jrlvxlzi/node_modules/@react-native-async-storage/async-storage`)"
|
||||
- "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`)"
|
||||
- "RNWorklets (from `../node_modules/.pnpm/react-native-worklets@0.5.1_@babel+core@7.28.6_react-native@0.81.5_@babel+core@7.28.6_@types+_5atwepuw3zy3crkgvetf35tkve/node_modules/react-native-worklets`)"
|
||||
- "Yoga (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/yoga`)"
|
||||
- ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`)
|
||||
- ReactNativeDependencies (from `../node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec`)
|
||||
- "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)"
|
||||
- RNGestureHandler (from `../node_modules/react-native-gesture-handler`)
|
||||
- RNReanimated (from `../node_modules/react-native-reanimated`)
|
||||
- RNScreens (from `../node_modules/react-native-screens`)
|
||||
- RNSVG (from `../node_modules/react-native-svg`)
|
||||
- RNWorklets (from `../node_modules/react-native-worklets`)
|
||||
- Yoga (from `../node_modules/react-native/ReactCommon/yoga`)
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
EXApplication:
|
||||
:path: "../node_modules/.pnpm/expo-application@7.0.8_expo@54.0.32/node_modules/expo-application/ios"
|
||||
:path: "../node_modules/expo-application/ios"
|
||||
EXConstants:
|
||||
:path: "../node_modules/.pnpm/expo-constants@18.0.13_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0_/node_modules/expo-constants/ios"
|
||||
:path: "../node_modules/expo-constants/ios"
|
||||
EXJSONUtils:
|
||||
:path: "../node_modules/expo-json-utils/ios"
|
||||
EXManifests:
|
||||
:path: "../node_modules/expo-manifests/ios"
|
||||
EXNotifications:
|
||||
:path: "../node_modules/.pnpm/expo-notifications@0.32.16_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@1_nvlvke5tn7wk5pigfsu7j4ieeq/node_modules/expo-notifications/ios"
|
||||
:path: "../node_modules/expo-notifications/ios"
|
||||
Expo:
|
||||
:path: "../node_modules/.pnpm/expo@54.0.32_@babel+core@7.28.6_@expo+metro-runtime@6.1.2_expo-router@6.0.22_react-native@0.8_7rhpxisdkrzvrgzbu7ct455kta/node_modules/expo"
|
||||
:path: "../node_modules/expo"
|
||||
expo-dev-client:
|
||||
:path: "../node_modules/expo-dev-client/ios"
|
||||
expo-dev-launcher:
|
||||
:path: "../node_modules/expo-dev-launcher"
|
||||
expo-dev-menu:
|
||||
:path: "../node_modules/expo-dev-menu"
|
||||
expo-dev-menu-interface:
|
||||
:path: "../node_modules/expo-dev-menu-interface/ios"
|
||||
ExpoAsset:
|
||||
:path: "../node_modules/.pnpm/expo-asset@12.0.12_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-asset/ios"
|
||||
:path: "../node_modules/expo-asset/ios"
|
||||
ExpoCrypto:
|
||||
:path: "../node_modules/expo-crypto/ios"
|
||||
ExpoDevice:
|
||||
:path: "../node_modules/expo-device/ios"
|
||||
ExpoFileSystem:
|
||||
:path: "../node_modules/.pnpm/expo-file-system@19.0.21_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0_/node_modules/expo-file-system/ios"
|
||||
:path: "../node_modules/expo-file-system/ios"
|
||||
ExpoFont:
|
||||
:path: "../node_modules/.pnpm/expo-font@14.0.11_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-font/ios"
|
||||
:path: "../node_modules/expo-font/ios"
|
||||
ExpoHead:
|
||||
:path: "../node_modules/.pnpm/expo-router@6.0.22_@expo+metro-runtime@6.1.2_@types+react@19.1.17_expo-constants@18.0.13_expo_rjurfbyy5kjn57nkkfxix5iqea/node_modules/expo-router/ios"
|
||||
:path: "../node_modules/expo-router/ios"
|
||||
ExpoKeepAwake:
|
||||
:path: "../node_modules/.pnpm/expo-keep-awake@15.0.8_expo@54.0.32_react@19.1.0/node_modules/expo-keep-awake/ios"
|
||||
:path: "../node_modules/expo-keep-awake/ios"
|
||||
ExpoLinearGradient:
|
||||
:path: "../node_modules/.pnpm/expo-linear-gradient@15.0.8_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@_e6k2hjkd5k4lph2ersbp3gfshy/node_modules/expo-linear-gradient/ios"
|
||||
:path: "../node_modules/expo-linear-gradient/ios"
|
||||
ExpoLinking:
|
||||
:path: "../node_modules/.pnpm/expo-linking@8.0.11_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-linking/ios"
|
||||
:path: "../node_modules/expo-linking/ios"
|
||||
ExpoLocalization:
|
||||
:path: "../node_modules/.pnpm/expo-localization@17.0.8_expo@54.0.32_react@19.1.0/node_modules/expo-localization/ios"
|
||||
:path: "../node_modules/expo-localization/ios"
|
||||
ExpoModulesCore:
|
||||
:path: "../node_modules/.pnpm/expo-modules-core@3.0.29_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-modules-core"
|
||||
:path: "../node_modules/expo-modules-core"
|
||||
ExpoSplashScreen:
|
||||
:path: "../node_modules/.pnpm/expo-splash-screen@31.0.13_expo@54.0.32/node_modules/expo-splash-screen/ios"
|
||||
:path: "../node_modules/expo-splash-screen/ios"
|
||||
ExpoWebBrowser:
|
||||
:path: "../node_modules/.pnpm/expo-web-browser@15.0.10_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0_/node_modules/expo-web-browser/ios"
|
||||
:path: "../node_modules/expo-web-browser/ios"
|
||||
EXUpdatesInterface:
|
||||
:path: "../node_modules/expo-updates-interface/ios"
|
||||
FBLazyVector:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/FBLazyVector"
|
||||
:path: "../node_modules/react-native/Libraries/FBLazyVector"
|
||||
hermes-engine:
|
||||
:podspec: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec"
|
||||
:podspec: "../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec"
|
||||
:tag: hermes-2025-07-07-RNv0.81.0-e0fc67142ec0763c6b6153ca2bf96df815539782
|
||||
RCTDeprecation:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation"
|
||||
:path: "../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation"
|
||||
RCTRequired:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Required"
|
||||
:path: "../node_modules/react-native/Libraries/Required"
|
||||
RCTTypeSafety:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/TypeSafety"
|
||||
:path: "../node_modules/react-native/Libraries/TypeSafety"
|
||||
React:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/"
|
||||
:path: "../node_modules/react-native/"
|
||||
React-callinvoker:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/callinvoker"
|
||||
:path: "../node_modules/react-native/ReactCommon/callinvoker"
|
||||
React-Core:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/"
|
||||
:path: "../node_modules/react-native/"
|
||||
React-Core-prebuilt:
|
||||
:podspec: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/React-Core-prebuilt.podspec"
|
||||
:podspec: "../node_modules/react-native/React-Core-prebuilt.podspec"
|
||||
React-CoreModules:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/React/CoreModules"
|
||||
:path: "../node_modules/react-native/React/CoreModules"
|
||||
React-cxxreact:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/cxxreact"
|
||||
:path: "../node_modules/react-native/ReactCommon/cxxreact"
|
||||
React-debug:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/debug"
|
||||
:path: "../node_modules/react-native/ReactCommon/react/debug"
|
||||
React-defaultsnativemodule:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/defaults"
|
||||
:path: "../node_modules/react-native/ReactCommon/react/nativemodule/defaults"
|
||||
React-domnativemodule:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/dom"
|
||||
:path: "../node_modules/react-native/ReactCommon/react/nativemodule/dom"
|
||||
React-Fabric:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon"
|
||||
:path: "../node_modules/react-native/ReactCommon"
|
||||
React-FabricComponents:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon"
|
||||
:path: "../node_modules/react-native/ReactCommon"
|
||||
React-FabricImage:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon"
|
||||
:path: "../node_modules/react-native/ReactCommon"
|
||||
React-featureflags:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/featureflags"
|
||||
:path: "../node_modules/react-native/ReactCommon/react/featureflags"
|
||||
React-featureflagsnativemodule:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/featureflags"
|
||||
:path: "../node_modules/react-native/ReactCommon/react/nativemodule/featureflags"
|
||||
React-graphics:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/renderer/graphics"
|
||||
:path: "../node_modules/react-native/ReactCommon/react/renderer/graphics"
|
||||
React-hermes:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/hermes"
|
||||
:path: "../node_modules/react-native/ReactCommon/hermes"
|
||||
React-idlecallbacksnativemodule:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/idlecallbacks"
|
||||
:path: "../node_modules/react-native/ReactCommon/react/nativemodule/idlecallbacks"
|
||||
React-ImageManager:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/renderer/imagemanager/platform/ios"
|
||||
:path: "../node_modules/react-native/ReactCommon/react/renderer/imagemanager/platform/ios"
|
||||
React-jserrorhandler:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jserrorhandler"
|
||||
:path: "../node_modules/react-native/ReactCommon/jserrorhandler"
|
||||
React-jsi:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsi"
|
||||
:path: "../node_modules/react-native/ReactCommon/jsi"
|
||||
React-jsiexecutor:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsiexecutor"
|
||||
:path: "../node_modules/react-native/ReactCommon/jsiexecutor"
|
||||
React-jsinspector:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsinspector-modern"
|
||||
:path: "../node_modules/react-native/ReactCommon/jsinspector-modern"
|
||||
React-jsinspectorcdp:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsinspector-modern/cdp"
|
||||
:path: "../node_modules/react-native/ReactCommon/jsinspector-modern/cdp"
|
||||
React-jsinspectornetwork:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsinspector-modern/network"
|
||||
:path: "../node_modules/react-native/ReactCommon/jsinspector-modern/network"
|
||||
React-jsinspectortracing:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsinspector-modern/tracing"
|
||||
:path: "../node_modules/react-native/ReactCommon/jsinspector-modern/tracing"
|
||||
React-jsitooling:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsitooling"
|
||||
:path: "../node_modules/react-native/ReactCommon/jsitooling"
|
||||
React-jsitracing:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/hermes/executor/"
|
||||
:path: "../node_modules/react-native/ReactCommon/hermes/executor/"
|
||||
React-logger:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/logger"
|
||||
:path: "../node_modules/react-native/ReactCommon/logger"
|
||||
React-Mapbuffer:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon"
|
||||
:path: "../node_modules/react-native/ReactCommon"
|
||||
React-microtasksnativemodule:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/microtasks"
|
||||
:path: "../node_modules/react-native/ReactCommon/react/nativemodule/microtasks"
|
||||
react-native-safe-area-context:
|
||||
:path: "../node_modules/.pnpm/react-native-safe-area-context@5.6.2_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1_azuxgonsvxb2yngtegtuvyxcpi/node_modules/react-native-safe-area-context"
|
||||
:path: "../node_modules/react-native-safe-area-context"
|
||||
React-NativeModulesApple:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios"
|
||||
:path: "../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios"
|
||||
React-oscompat:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/oscompat"
|
||||
:path: "../node_modules/react-native/ReactCommon/oscompat"
|
||||
React-perflogger:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/reactperflogger"
|
||||
:path: "../node_modules/react-native/ReactCommon/reactperflogger"
|
||||
React-performancetimeline:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/performance/timeline"
|
||||
:path: "../node_modules/react-native/ReactCommon/react/performance/timeline"
|
||||
React-RCTActionSheet:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/ActionSheetIOS"
|
||||
:path: "../node_modules/react-native/Libraries/ActionSheetIOS"
|
||||
React-RCTAnimation:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/NativeAnimation"
|
||||
:path: "../node_modules/react-native/Libraries/NativeAnimation"
|
||||
React-RCTAppDelegate:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/AppDelegate"
|
||||
:path: "../node_modules/react-native/Libraries/AppDelegate"
|
||||
React-RCTBlob:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Blob"
|
||||
:path: "../node_modules/react-native/Libraries/Blob"
|
||||
React-RCTFabric:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/React"
|
||||
:path: "../node_modules/react-native/React"
|
||||
React-RCTFBReactNativeSpec:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/React"
|
||||
:path: "../node_modules/react-native/React"
|
||||
React-RCTImage:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Image"
|
||||
:path: "../node_modules/react-native/Libraries/Image"
|
||||
React-RCTLinking:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/LinkingIOS"
|
||||
:path: "../node_modules/react-native/Libraries/LinkingIOS"
|
||||
React-RCTNetwork:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Network"
|
||||
:path: "../node_modules/react-native/Libraries/Network"
|
||||
React-RCTRuntime:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/React/Runtime"
|
||||
:path: "../node_modules/react-native/React/Runtime"
|
||||
React-RCTSettings:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Settings"
|
||||
:path: "../node_modules/react-native/Libraries/Settings"
|
||||
React-RCTText:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Text"
|
||||
:path: "../node_modules/react-native/Libraries/Text"
|
||||
React-RCTVibration:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Vibration"
|
||||
:path: "../node_modules/react-native/Libraries/Vibration"
|
||||
React-rendererconsistency:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/renderer/consistency"
|
||||
:path: "../node_modules/react-native/ReactCommon/react/renderer/consistency"
|
||||
React-renderercss:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/renderer/css"
|
||||
:path: "../node_modules/react-native/ReactCommon/react/renderer/css"
|
||||
React-rendererdebug:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/renderer/debug"
|
||||
:path: "../node_modules/react-native/ReactCommon/react/renderer/debug"
|
||||
React-RuntimeApple:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/runtime/platform/ios"
|
||||
:path: "../node_modules/react-native/ReactCommon/react/runtime/platform/ios"
|
||||
React-RuntimeCore:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/runtime"
|
||||
:path: "../node_modules/react-native/ReactCommon/react/runtime"
|
||||
React-runtimeexecutor:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/runtimeexecutor"
|
||||
:path: "../node_modules/react-native/ReactCommon/runtimeexecutor"
|
||||
React-RuntimeHermes:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/runtime"
|
||||
:path: "../node_modules/react-native/ReactCommon/react/runtime"
|
||||
React-runtimescheduler:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/renderer/runtimescheduler"
|
||||
:path: "../node_modules/react-native/ReactCommon/react/renderer/runtimescheduler"
|
||||
React-timing:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/timing"
|
||||
:path: "../node_modules/react-native/ReactCommon/react/timing"
|
||||
React-utils:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/utils"
|
||||
:path: "../node_modules/react-native/ReactCommon/react/utils"
|
||||
ReactAppDependencyProvider:
|
||||
:path: build/generated/ios
|
||||
ReactCodegen:
|
||||
:path: build/generated/ios
|
||||
ReactCommon:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon"
|
||||
:path: "../node_modules/react-native/ReactCommon"
|
||||
ReactNativeDependencies:
|
||||
:podspec: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec"
|
||||
:podspec: "../node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec"
|
||||
RNCAsyncStorage:
|
||||
:path: "../node_modules/.pnpm/@react-native-async-storage+async-storage@2.2.0_react-native@0.81.5_@babel+core@7.28.6_@types_fp4qq3a7mejmut52v6jrlvxlzi/node_modules/@react-native-async-storage/async-storage"
|
||||
:path: "../node_modules/@react-native-async-storage/async-storage"
|
||||
RNGestureHandler:
|
||||
:path: "../node_modules/react-native-gesture-handler"
|
||||
RNReanimated:
|
||||
:path: "../node_modules/.pnpm/react-native-reanimated@4.1.6_@babel+core@7.28.6_react-native-worklets@0.5.1_@babel+core@7.28_ky3sbxf6i7nkyacc2hzg3xcz4q/node_modules/react-native-reanimated"
|
||||
:path: "../node_modules/react-native-reanimated"
|
||||
RNScreens:
|
||||
:path: "../node_modules/.pnpm/react-native-screens@4.16.0_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/react-native-screens"
|
||||
:path: "../node_modules/react-native-screens"
|
||||
RNSVG:
|
||||
:path: "../node_modules/.pnpm/react-native-svg@15.12.1_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/react-native-svg"
|
||||
:path: "../node_modules/react-native-svg"
|
||||
RNWorklets:
|
||||
:path: "../node_modules/.pnpm/react-native-worklets@0.5.1_@babel+core@7.28.6_react-native@0.81.5_@babel+core@7.28.6_@types+_5atwepuw3zy3crkgvetf35tkve/node_modules/react-native-worklets"
|
||||
:path: "../node_modules/react-native-worklets"
|
||||
Yoga:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/yoga"
|
||||
:path: "../node_modules/react-native/ReactCommon/yoga"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
EXApplication: 13420f8139864183f8a04fd6099077bdf8cfb186
|
||||
EXConstants: 3feb66fd1d94202fc1f0946d74e029d8b224b60e
|
||||
EXNotifications: 2a3feb7af6194828d9aafda72f63a9a03866230a
|
||||
Expo: b8d64eb9a496ebe8c71e3dae7eeb7f394b146b80
|
||||
ExpoAsset: d999f3bbd998a750f3b74cb913229848901b926b
|
||||
ExpoFileSystem: aefcd337b94b874f88752ebefc52813b84992fad
|
||||
ExpoFont: c625dbd97ed57e9089b172b2a7bb99003d074664
|
||||
ExpoHead: b691a2ed7ab02ed820b6c6468941832d34969c29
|
||||
ExpoKeepAwake: 44bf6715bc1d2ddb17afe19d927cd039cda123f0
|
||||
ExpoLinearGradient: 814a21fc4056c3cf606e4f19e31e47074c5b5a86
|
||||
ExpoLinking: ebf543fd411d56375cb4eee07f6ab4e31c7ad959
|
||||
ExpoLocalization: 6ac6f326210f0a3141ef6f58ab8f8f4ed003b485
|
||||
ExpoModulesCore: 77496909fd3c800f97f7f2007dd26aeac4bb3798
|
||||
ExpoSplashScreen: 72fbc6dd9d6404dd9d0725a56c9ac1383bc0b14f
|
||||
ExpoWebBrowser: 88b116cd378d9609c776c0903fe4070fca461588
|
||||
EXApplication: 1e98d4b1dccdf30627f92917f4b2c5a53c330e5f
|
||||
EXConstants: fce59a631a06c4151602843667f7cfe35f81e271
|
||||
EXJSONUtils: 1d3e4590438c3ee593684186007028a14b3686cd
|
||||
EXManifests: a8d97683e5c7a3b026ffbd58559c64dc655b747b
|
||||
EXNotifications: 9eec98712cc814ceff916d876cb53859003b0597
|
||||
Expo: 4e503a041c59c4e34c8be262a135848ad5cd3710
|
||||
expo-dev-client: 425ee077d6754a98cfe3a2e2410d29b440b24c9d
|
||||
expo-dev-launcher: a4f4cdef064ab1fb8621e5b8c7c457cd6e9568c3
|
||||
expo-dev-menu: 05b18812110c175814c6af0d09dd658abcc5e00d
|
||||
expo-dev-menu-interface: 600df12ea01efecdd822daaf13cc0ac091775533
|
||||
ExpoAsset: f867e55ceb428aab99e1e8c082b5aee7c159ea18
|
||||
ExpoCrypto: b6105ebaa15d6b38a811e71e43b52cd934945322
|
||||
ExpoDevice: 6327c3c200816795708885adf540d26ecab83d1a
|
||||
ExpoFileSystem: 858a44267a3e6e9057e0888ad7c7cfbf55d52063
|
||||
ExpoFont: f543ce20a228dd702813668b1a07b46f51878d47
|
||||
ExpoHead: 4425246bc93411f0fe7f6945f95f698e91db8780
|
||||
ExpoKeepAwake: 55f75eca6499bb9e4231ebad6f3e9cb8f99c0296
|
||||
ExpoLinearGradient: 809102bdb979f590083af49f7fa4805cd931bd58
|
||||
ExpoLinking: 8f0aaf69aa56f832913030503b6263dc6f647f37
|
||||
ExpoLocalization: d9168d5300a5b03e5e78b986124d11fb6ec3ebbd
|
||||
ExpoModulesCore: f3da4f1ab5a8375d0beafab763739dbee8446583
|
||||
ExpoSplashScreen: bc3cffefca2716e5f22350ca109badd7e50ec14d
|
||||
ExpoWebBrowser: 17b064c621789e41d4816c95c93f429b84971f52
|
||||
EXUpdatesInterface: 5adf50cb41e079c861da6d9b4b954c3db9a50734
|
||||
FBLazyVector: e95a291ad2dadb88e42b06e0c5fb8262de53ec12
|
||||
hermes-engine: 9f4dfe93326146a1c99eb535b1cb0b857a3cd172
|
||||
RCTDeprecation: 943572d4be82d480a48f4884f670135ae30bf990
|
||||
@@ -2330,73 +2571,74 @@ SPEC CHECKSUMS:
|
||||
RCTTypeSafety: 16a4144ca3f959583ab019b57d5633df10b5e97c
|
||||
React: 914f8695f9bf38e6418228c2ffb70021e559f92f
|
||||
React-callinvoker: 1c0808402aee0c6d4a0d8e7220ce6547af9fba71
|
||||
React-Core: 4ae98f9e8135b8ddbd7c98730afb6fdae883db90
|
||||
React-Core-prebuilt: 8f4cca589c14e8cf8fc6db4587ef1c2056b5c151
|
||||
React-CoreModules: e878a90bb19b8f3851818af997dbae3b3b0a27ac
|
||||
React-cxxreact: 28af9844f6dc87be1385ab521fbfb3746f19563c
|
||||
React-Core: c61410ef0ca6055e204a963992e363227e0fd1c5
|
||||
React-Core-prebuilt: 02f0ad625ddd47463c009c2d0c5dd35c0d982599
|
||||
React-CoreModules: 1f6d1744b5f9f2ec684a4bb5ced25370f87e5382
|
||||
React-cxxreact: 3af79478e8187b63ffc22b794cd42d3fc1f1f2da
|
||||
React-debug: 6328c2228e268846161f10082e80dc69eac2e90a
|
||||
React-defaultsnativemodule: afc9d809ec75780f39464a6949c07987fbea488c
|
||||
React-domnativemodule: 91a233260411d41f27f67aa1358b7f9f0bfd101d
|
||||
React-Fabric: 21f349b5e93f305a3c38c885902683a9c79cf983
|
||||
React-FabricComponents: 47ac634cc9ecc64b30a9997192f510eebe4177e4
|
||||
React-FabricImage: 21873acd6d4a51a0b97c133141051c7acb11cc86
|
||||
React-featureflags: 653f469f0c3c9dc271d610373e3b6e66a9fd847d
|
||||
React-featureflagsnativemodule: c91a8a3880e0f4838286402241ead47db43aed28
|
||||
React-graphics: b4bdb0f635b8048c652a5d2b73eb8b1ddd950f24
|
||||
React-hermes: fcfad3b917400f49026f3232561e039c9d1c34bf
|
||||
React-idlecallbacksnativemodule: 8cb83207e39f8179ac1d344b6177c6ab3ccebcdc
|
||||
React-ImageManager: 396128004783fc510e629124dce682d38d1088e7
|
||||
React-jserrorhandler: b58b788d788cdbf8bda7db74a88ebfcffc8a0795
|
||||
React-jsi: d2c3f8555175371c02da6dfe7ed1b64b55a9d6c0
|
||||
React-jsiexecutor: ba537434eb45ee018b590ed7d29ee233fddb8669
|
||||
React-jsinspector: f21b6654baf96cb9f71748844a32468a5f73ad51
|
||||
React-jsinspectorcdp: 3f8be4830694c3c1c39442e50f8db877966d43f0
|
||||
React-jsinspectornetwork: 70e41469565712ad60e11d9c8b8f999b9f7f61eb
|
||||
React-jsinspectortracing: eccf9bfa4ec7f130d514f215cfb2222dc3c0e270
|
||||
React-jsitooling: b376a695f5a507627f7934748533b24eed1751ca
|
||||
React-jsitracing: 5c8c3273dda2d95191cc0612fb5e71c4d9018d2a
|
||||
React-logger: c3e2f8a2e284341205f61eef3d4677ab5a309dfd
|
||||
React-Mapbuffer: 603c18db65844bb81dbe62fee8fcc976eaeb7108
|
||||
React-microtasksnativemodule: d77e0c426fce34c23227394c96ca1033b30c813c
|
||||
react-native-safe-area-context: 53f796cb6c814661bbe99fbdfd0585d07b996cdd
|
||||
React-NativeModulesApple: 1664340b8750d64e0ef3907c5e53d9481f74bcbd
|
||||
React-defaultsnativemodule: d635ef36d755321e5d6fc065bd166b2c5a0e9833
|
||||
React-domnativemodule: dd28f6d96cd21236e020be2eff6fe0b7d4ec3b66
|
||||
React-Fabric: 2e32c3fdbb1fbcf5fde54607e3abe453c6652ce2
|
||||
React-FabricComponents: 5ed0cdb81f6b91656cb4d3be432feaa28a58071a
|
||||
React-FabricImage: 2bc714f818cb24e454f5d3961864373271b2faf8
|
||||
React-featureflags: 847642f41fa71ad4eec5e0351badebcad4fe6171
|
||||
React-featureflagsnativemodule: c868a544b2c626fa337bcbd364b1befe749f0d3f
|
||||
React-graphics: 192ec701def5b3f2a07db2814dfba5a44986cff6
|
||||
React-hermes: e875778b496c86d07ab2ccaa36a9505d248a254b
|
||||
React-idlecallbacksnativemodule: 4d57965cdf82c14ee3b337189836cd8491632b76
|
||||
React-ImageManager: bd0b99e370b13de82c9cd15f0f08144ff3de079e
|
||||
React-jserrorhandler: a2fdef4cbcfdcdf3fa9f5d1f7190f7fd4535248d
|
||||
React-jsi: 89d43d1e7d4d0663f8ba67e0b39eb4e4672c27de
|
||||
React-jsiexecutor: abe4874aaab90dfee5dec480680220b2f8af07e3
|
||||
React-jsinspector: a0b3e051aef842b0b2be2353790ae2b2a5a65a8f
|
||||
React-jsinspectorcdp: 6346013b2247c6263fbf5199adf4a8751e53bd89
|
||||
React-jsinspectornetwork: 26281aa50d49fc1ec93abf981d934698fa95714f
|
||||
React-jsinspectortracing: 55eedf6d57540507570259a778663b90060bbd6e
|
||||
React-jsitooling: 0e001113fa56d8498aa8ac28437ac0d36348e51a
|
||||
React-jsitracing: b713793eb8a5bbc4d86a84e9d9e5023c0f58cbaf
|
||||
React-logger: 50fdb9a8236da90c0b1072da5c32ee03aeb5bf28
|
||||
React-Mapbuffer: 9050ee10c19f4f7fca8963d0211b2854d624973e
|
||||
React-microtasksnativemodule: f775db9e991c6f3b8ccbc02bfcde22770f96e23b
|
||||
react-native-safe-area-context: 37e680fc4cace3c0030ee46e8987d24f5d3bdab2
|
||||
React-NativeModulesApple: 8969913947d5b576de4ed371a939455a8daf28aa
|
||||
React-oscompat: ce47230ed20185e91de62d8c6d139ae61763d09c
|
||||
React-perflogger: b1af3cfb3f095f819b2814910000392a8e17ba9f
|
||||
React-performancetimeline: f9ec65b77bcadbc7bd8b47a6f4b4b697da7b1490
|
||||
React-perflogger: 02b010e665772c7dcb859d85d44c1bfc5ac7c0e4
|
||||
React-performancetimeline: 130db956b5a83aa4fb41ddf5ae68da89f3fb1526
|
||||
React-RCTActionSheet: 0b14875b3963e9124a5a29a45bd1b22df8803916
|
||||
React-RCTAnimation: 60f6eca214a62b9673f64db6df3830cee902b5af
|
||||
React-RCTAppDelegate: 37734b39bac108af30a0fd9d3e1149ec68b82c28
|
||||
React-RCTBlob: 83fbcbd57755caf021787324aac2fe9b028cc264
|
||||
React-RCTFabric: a05cb1df484008db3753c8b4a71e4c6d9f1e43a6
|
||||
React-RCTFBReactNativeSpec: d58d7ae9447020bbbac651e3b0674422aba18266
|
||||
React-RCTImage: 47aba3be7c6c64f956b7918ab933769602406aac
|
||||
React-RCTLinking: 2dbaa4df2e4523f68baa07936bd8efdfa34d5f31
|
||||
React-RCTNetwork: 1fca7455f9dedf7de2b95bec438da06680f3b000
|
||||
React-RCTRuntime: 17819dd1dfc8613efaf4cbb9d8686baae4a83e5b
|
||||
React-RCTSettings: 01bf91c856862354d3d2f642ccb82f3697a4284a
|
||||
React-RCTText: cb576a3797dcb64933613c522296a07eaafc0461
|
||||
React-RCTVibration: 560af8c086741f3525b8456a482cdbe27f9d098e
|
||||
React-RCTAnimation: a7b90fd2af7bb9c084428867445a1481a8cb112e
|
||||
React-RCTAppDelegate: 3262bedd01263f140ec62b7989f4355f57cec016
|
||||
React-RCTBlob: c17531368702f1ebed5d0ada75a7cf5915072a53
|
||||
React-RCTFabric: 6409edd8cfdc3133b6cc75636d3b858fdb1d11ea
|
||||
React-RCTFBReactNativeSpec: c004b27b4fa3bd85878ad2cf53de3bbec85da797
|
||||
React-RCTImage: c68078a120d0123f4f07a5ac77bea3bb10242f32
|
||||
React-RCTLinking: cf8f9391fe7fe471f96da3a5f0435235eca18c5b
|
||||
React-RCTNetwork: ca31f7c879355760c2d9832a06ee35f517938a20
|
||||
React-RCTRuntime: a6cf4a1e42754fc87f493e538f2ac6b820e45418
|
||||
React-RCTSettings: e0e140b2ff4bf86d34e9637f6316848fc00be035
|
||||
React-RCTText: 75915bace6f7877c03a840cc7b6c622fb62bfa6b
|
||||
React-RCTVibration: 25f26b85e5e432bb3c256f8b384f9269e9529f25
|
||||
React-rendererconsistency: 2dac03f448ff337235fd5820b10f81633328870d
|
||||
React-renderercss: c5c6b7a15948dd28facca39a18ac269073718490
|
||||
React-rendererdebug: 3c9d5e1634273f5a24d84cc5669f290ce0bdc812
|
||||
React-RuntimeApple: 887637d1e12ea8262df7d32bc100467df2302613
|
||||
React-RuntimeCore: 91f779835dc4f8f84777fe5dd24f1a22f96454e4
|
||||
React-runtimeexecutor: 8bb6b738f37b0ada4a6269e6f8ab1133dea0285c
|
||||
React-RuntimeHermes: 4cb93de9fa8b1cc753d200dbe61a01b9ec5f5562
|
||||
React-runtimescheduler: 83dc28f530bfbd2fce84ed13aa7feebdc24e5af7
|
||||
React-timing: 03c7217455d2bff459b27a3811be25796b600f47
|
||||
React-utils: 6d46795ae0444ec8a5d9a5f201157b286bf5250a
|
||||
ReactAppDependencyProvider: c277c5b231881ad4f00cd59e3aa0671b99d7ebee
|
||||
ReactCodegen: 88a1f4643f15841573f833b895bfa2a0c6cb4e7f
|
||||
ReactCommon: e6e232202a447d353e5531f2be82f50f47cbaa9a
|
||||
React-renderercss: 477da167bb96b5ac86d30c5d295412fb853f5453
|
||||
React-rendererdebug: 2a1798c6f3ef5f22d466df24c33653edbabb5b89
|
||||
React-RuntimeApple: 28cf4d8eb18432f6a21abbed7d801ab7f6b6f0b4
|
||||
React-RuntimeCore: 41bf0fd56a00de5660f222415af49879fa49c4f0
|
||||
React-runtimeexecutor: 1afb774dde3011348e8334be69d2f57a359ea43e
|
||||
React-RuntimeHermes: f3b158ea40e8212b1a723a68b4315e7a495c5fc6
|
||||
React-runtimescheduler: 3e1e2bec7300bae512533107d8e54c6e5c63fe0f
|
||||
React-timing: 6fa9883de2e41791e5dc4ec404e5e37f3f50e801
|
||||
React-utils: 6e2035b53d087927768649a11a26c4e092448e34
|
||||
ReactAppDependencyProvider: 1bcd3527ac0390a1c898c114f81ff954be35ed79
|
||||
ReactCodegen: 7d4593f7591f002d137fe40cef3f6c11f13c88cc
|
||||
ReactCommon: 08810150b1206cc44aecf5f6ae19af32f29151a8
|
||||
ReactNativeDependencies: 71ce9c28beb282aa720ea7b46980fff9669f428a
|
||||
RNCAsyncStorage: e85a99325df9eb0191a6ee2b2a842644c7eb29f4
|
||||
RNReanimated: 10415bc8396eaeac0d7b2c9a1538eae7e607ec9c
|
||||
RNScreens: dd61bc3a3e6f6901ad833efa411917d44827cf51
|
||||
RNSVG: 2825ee146e0f6a16221e852299943e4cceef4528
|
||||
RNWorklets: 9ccdc8112b17af6eee2c85a233891cb80db150ad
|
||||
RNCAsyncStorage: 3a4f5e2777dae1688b781a487923a08569e27fe4
|
||||
RNGestureHandler: e0d0bce5599f6120b7adf90c38d2805e2935795f
|
||||
RNReanimated: e5c702a3e24cc1c68b2de67671713f35461678f4
|
||||
RNScreens: d8d6f1792f6e7ac12b0190d33d8d390efc0c1845
|
||||
RNSVG: 31d6639663c249b7d5abc9728dde2041eb2a3c34
|
||||
RNWorklets: 76fce72926e28e304afb44f0da23b2d24f2c1fa0
|
||||
Yoga: 5934998fbeaef7845dbf698f698518695ab4cd1a
|
||||
|
||||
PODFILE CHECKSUM: 4d5c52f9fa870c1d398cf59e37c149f66700c061
|
||||
PODFILE CHECKSUM: c2c3838f0b2a579fef2350bff2ecaa005e27145d
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 77;
|
||||
objectVersion = 56;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
@@ -12,13 +12,15 @@
|
||||
1A1DE01D4133812B2E2BA692 /* libPods-client.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E3328F0E595C1F4A244DF238 /* libPods-client.a */; };
|
||||
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; };
|
||||
A1B2C3D4E5F60718293A4B5C /* EmotionWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B5B /* EmotionWidget.swift */; };
|
||||
A8C1D2E3F4A5B6C7D8E9F0A2 /* AppGroupStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8C1D2E3F4A5B6C7D8E9F0A1 /* AppGroupStorage.swift */; };
|
||||
A8C1D2E3F4A5B6C7D8E9F0A3 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EB3DAF802F2A4B8D00450593 /* WidgetKit.framework */; };
|
||||
A8C1D2E3F4A5B6C7D8E9F0B2 /* AppGroupStorageBridge.m in Sources */ = {isa = PBXBuildFile; fileRef = A8C1D2E3F4A5B6C7D8E9F0B1 /* AppGroupStorageBridge.m */; };
|
||||
B5A7FE9A125F7C79753EC5BF /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7DB40C26E3A46F6D06769EA /* ExpoModulesProvider.swift */; };
|
||||
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
|
||||
C0A1B2C3D4E5F60718293A4E /* Screen_page.png in Resources */ = {isa = PBXBuildFile; fileRef = C0A1B2C3D4E5F60718293A4D /* Screen_page.png */; };
|
||||
EB3DAF812F2A4B8E00450593 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EB3DAF802F2A4B8D00450593 /* WidgetKit.framework */; };
|
||||
EB3DAF832F2A4B8E00450593 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EB3DAF822F2A4B8E00450593 /* SwiftUI.framework */; };
|
||||
EB3DAF942F2A4B8F00450593 /* 情绪小组件Extension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = EB3DAF7F2F2A4B8D00450593 /* 情绪小组件Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
EB630B302F30BE2700DC761A /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = EB630B2F2F30BE2700DC761A /* Assets.xcassets */; };
|
||||
EB630B312F30BE2700DC761A /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = EB630B2F2F30BE2700DC761A /* Assets.xcassets */; };
|
||||
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
@@ -47,21 +49,24 @@
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
13B07F961A680F5B00A75B9A /* client.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = client.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
13B07F961A680F5B00A75B9A /* DearMama.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DearMama.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>"; };
|
||||
C0A1B2C3D4E5F60718293A4D /* Screen_page.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = Screen_page.png; path = ../assets/images/Screen_page.png; 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>"; };
|
||||
E3328F0E595C1F4A244DF238 /* libPods-client.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-client.a"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
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>"; };
|
||||
EB630B2F2F30BE2700DC761A /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; 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>"; };
|
||||
@@ -100,6 +105,7 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
1A1DE01D4133812B2E2BA692 /* libPods-client.a in Frameworks */,
|
||||
A8C1D2E3F4A5B6C7D8E9F0A3 /* WidgetKit.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -118,13 +124,16 @@
|
||||
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 */,
|
||||
13B07FB61A68108700A75B9A /* Info.plist */,
|
||||
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */,
|
||||
C0A1B2C3D4E5F60718293A4D /* Screen_page.png */,
|
||||
75F52ADE07CAE9D9736D7671 /* PrivacyInfo.xcprivacy */,
|
||||
);
|
||||
name = client;
|
||||
@@ -159,7 +168,7 @@
|
||||
83CBB9F61A601CBA00E9B192 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
EB630B2F2F30BE2700DC761A /* Assets.xcassets */,
|
||||
EBEEC7572F31D84B00C68C1A /* 情绪小组件ExtensionRelease.entitlements */,
|
||||
13B07FAE1A68108700A75B9A /* client */,
|
||||
832341AE1AAA6A7D00B99B32 /* Libraries */,
|
||||
EB3DAF842F2A4B8E00450593 /* 情绪小组件 */,
|
||||
@@ -177,7 +186,7 @@
|
||||
83CBBA001A601CBA00E9B192 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
13B07F961A680F5B00A75B9A /* client.app */,
|
||||
13B07F961A680F5B00A75B9A /* DearMama.app */,
|
||||
EB3DAF7F2F2A4B8D00450593 /* 情绪小组件Extension.appex */,
|
||||
);
|
||||
name = Products;
|
||||
@@ -241,7 +250,7 @@
|
||||
);
|
||||
name = client;
|
||||
productName = client;
|
||||
productReference = 13B07F961A680F5B00A75B9A /* client.app */;
|
||||
productReference = 13B07F961A680F5B00A75B9A /* DearMama.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
EB3DAF7E2F2A4B8D00450593 /* 情绪小组件Extension */ = {
|
||||
@@ -270,8 +279,12 @@
|
||||
83CBB9F71A601CBA00E9B192 /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = YES;
|
||||
KnownAssetTags = (
|
||||
New,
|
||||
);
|
||||
LastSwiftUpdateCheck = 2620;
|
||||
LastUpgradeCheck = 1130;
|
||||
LastUpgradeCheck = 2620;
|
||||
TargetAttributes = {
|
||||
13B07F861A680F5B00A75B9A = {
|
||||
LastSwiftMigration = 1250;
|
||||
@@ -288,6 +301,7 @@
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
"zh-Hant",
|
||||
);
|
||||
mainGroup = 83CBB9F61A601CBA00E9B192;
|
||||
productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */;
|
||||
@@ -308,8 +322,8 @@
|
||||
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */,
|
||||
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
|
||||
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */,
|
||||
C0A1B2C3D4E5F60718293A4E /* Screen_page.png in Resources */,
|
||||
0BE245B56A79D95AB0A7B4BA /* PrivacyInfo.xcprivacy in Resources */,
|
||||
EB630B312F30BE2700DC761A /* Assets.xcassets in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -317,7 +331,6 @@
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
EB630B302F30BE2700DC761A /* Assets.xcassets in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -374,12 +387,15 @@
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/EXConstants.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/ExpoConstants_privacy.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/EXNotifications/ExpoNotifications_privacy.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoDevice/ExpoDevice_privacy.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoFileSystem/ExpoFileSystem_privacy.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoLocalization/ExpoLocalization_privacy.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/RNCAsyncStorage/RNCAsyncStorage_resources.bundle",
|
||||
"${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,12 +403,15 @@
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXConstants.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoConstants_privacy.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoNotifications_privacy.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoDevice_privacy.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoFileSystem_privacy.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoLocalization_privacy.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNCAsyncStorage_resources.bundle",
|
||||
"${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;
|
||||
@@ -454,6 +473,8 @@
|
||||
files = (
|
||||
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */,
|
||||
B5A7FE9A125F7C79753EC5BF /* ExpoModulesProvider.swift in Sources */,
|
||||
A8C1D2E3F4A5B6C7D8E9F0A2 /* AppGroupStorage.swift in Sources */,
|
||||
A8C1D2E3F4A5B6C7D8E9F0B2 /* AppGroupStorageBridge.m in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -484,9 +505,10 @@
|
||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = client/client.entitlements;
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
CURRENT_PROJECT_VERSION = 4;
|
||||
DEVELOPMENT_TEAM = WS92GPX9H2;
|
||||
ENABLE_BITCODE = NO;
|
||||
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = x86_64;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"$(inherited)",
|
||||
"FB_SONARKIT_ENABLED=1",
|
||||
@@ -497,7 +519,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
MARKETING_VERSION = 1.0.1;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
@@ -505,7 +527,8 @@
|
||||
);
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.damer.mindfulness;
|
||||
PRODUCT_NAME = client;
|
||||
PRODUCT_NAME = DearMama;
|
||||
SKIP_INSTALL = NO;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
@@ -525,18 +548,19 @@
|
||||
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 = 2;
|
||||
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.0;
|
||||
MARKETING_VERSION = 1.0.1;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
@@ -544,7 +568,8 @@
|
||||
);
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.damer.mindfulness;
|
||||
PRODUCT_NAME = client;
|
||||
PRODUCT_NAME = DearMama;
|
||||
SKIP_INSTALL = NO;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
@@ -579,6 +604,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;
|
||||
@@ -610,9 +636,11 @@
|
||||
);
|
||||
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native";
|
||||
ONLY_ACTIVE_ARCH = NO;
|
||||
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
|
||||
SDKROOT = iphoneos;
|
||||
SKIP_INSTALL = NO;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
|
||||
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
|
||||
USE_HERMES = true;
|
||||
@@ -642,6 +670,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;
|
||||
@@ -665,9 +694,13 @@
|
||||
"$(inherited)",
|
||||
);
|
||||
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native";
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
|
||||
SDKROOT = iphoneos;
|
||||
SKIP_INSTALL = NO;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
|
||||
USE_HERMES = true;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
@@ -687,8 +720,9 @@
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CODE_SIGN_ENTITLEMENTS = "情绪小组件ExtensionRelease.entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
CURRENT_PROJECT_VERSION = 4;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = WS92GPX9H2;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
@@ -704,7 +738,7 @@
|
||||
"@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";
|
||||
@@ -739,9 +773,10 @@
|
||||
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 = 2;
|
||||
CURRENT_PROJECT_VERSION = 4;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = WS92GPX9H2;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
@@ -757,7 +792,7 @@
|
||||
"@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.damer.mindfulness.emotionwidget;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1130"
|
||||
version = "1.3">
|
||||
LastUpgradeVersion = "2620"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
@@ -15,7 +15,7 @@
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
||||
BuildableName = "client.app"
|
||||
BuildableName = "DearMama.app"
|
||||
BlueprintName = "client"
|
||||
ReferencedContainer = "container:client.xcodeproj">
|
||||
</BuildableReference>
|
||||
@@ -26,19 +26,8 @@
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "00E356ED1AD99517003FC87E"
|
||||
BuildableName = "clientTests.xctest"
|
||||
BlueprintName = "clientTests"
|
||||
ReferencedContainer = "container:client.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
@@ -55,7 +44,7 @@
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
||||
BuildableName = "client.app"
|
||||
BuildableName = "DearMama.app"
|
||||
BlueprintName = "client"
|
||||
ReferencedContainer = "container:client.xcodeproj">
|
||||
</BuildableReference>
|
||||
@@ -72,7 +61,7 @@
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
||||
BuildableName = "client.app"
|
||||
BuildableName = "DearMama.app"
|
||||
BlueprintName = "client"
|
||||
ReferencedContainer = "container:client.xcodeproj">
|
||||
</BuildableReference>
|
||||
@@ -83,6 +72,26 @@
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
customArchiveName = "Dear Mama"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
<PostActions>
|
||||
<ExecutionAction
|
||||
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
|
||||
<ActionContent
|
||||
title = "修复归档头信息(避免 Generic Xcode Archive)"
|
||||
scriptText = "bash "${SRCROOT}/scripts/fix-xcarchive-header.sh" "${ARCHIVE_PATH}" "
|
||||
shellToInvoke = "/bin/sh">
|
||||
<EnvironmentBuildable>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
||||
BuildableName = "DearMama.app"
|
||||
BlueprintName = "client"
|
||||
ReferencedContainer = "container:client.xcodeproj">
|
||||
</BuildableReference>
|
||||
</EnvironmentBuildable>
|
||||
</ActionContent>
|
||||
</ExecutionAction>
|
||||
</PostActions>
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
53
client/ios/client/AppGroupStorage.swift
Normal file
@@ -0,0 +1,53 @@
|
||||
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 {
|
||||
// 通过 AppGroupStorageBridge.m 的 RCT_EXTERN_MODULE 导出到 RN
|
||||
// 这里不需要显式实现/遵循 RCTBridgeModule,避免某些 Archive 场景下找不到协议类型
|
||||
@objc static func requiresMainQueueSetup() -> Bool { false }
|
||||
|
||||
private let suiteName = "group.com.damer.mindfulness"
|
||||
|
||||
private func defaults() -> UserDefaults? {
|
||||
UserDefaults(suiteName: suiteName)
|
||||
}
|
||||
|
||||
@objc(setString:value:resolver:rejecter:)
|
||||
func setString(_ key: String, value: String, resolver resolve: RCTPromiseResolveBlock, rejecter reject: RCTPromiseRejectBlock) {
|
||||
guard let d = defaults() else {
|
||||
reject("E_APP_GROUP", "无法初始化 App Group 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
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
"color": {
|
||||
"components": {
|
||||
"alpha": "1.000",
|
||||
"blue": "1.00000000000000",
|
||||
"green": "1.00000000000000",
|
||||
"red": "1.00000000000000"
|
||||
"blue": "0.729411764705882",
|
||||
"green": "0.823529411764706",
|
||||
"red": "0.917647058823529"
|
||||
},
|
||||
"color-space": "srgb"
|
||||
},
|
||||
|
||||
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 123 KiB |
@@ -7,7 +7,7 @@
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>client</string>
|
||||
<string>Dear Mama</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
@@ -38,6 +38,8 @@
|
||||
<string>12.0</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>用于连接局域网服务以获取内容与同步数据(仅在需要访问内网地址时使用)。</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
@@ -52,7 +54,7 @@
|
||||
<key>RCTNewArchEnabled</key>
|
||||
<true/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string></string>
|
||||
<string>SplashScreen</string>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>arm64</string>
|
||||
|
||||
@@ -22,6 +22,14 @@
|
||||
<string>CA92.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>35F9.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryDiskSpace</string>
|
||||
@@ -31,14 +39,6 @@
|
||||
<string>85F4.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>35F9.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>NSPrivacyCollectedDataTypes</key>
|
||||
<array/>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
|
||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
||||
<subviews>
|
||||
<imageView id="EXPO-SplashScreen" userLabel="SplashScreenLegacy" image="SplashScreenLegacy" contentMode="scaleAspectFit" clipsSubviews="true" userInteractionEnabled="false" translatesAutoresizingMaskIntoConstraints="false">
|
||||
<imageView id="EXPO-SplashScreen" userLabel="Screen_page" image="Screen_page" contentMode="scaleAspectFit" clipsSubviews="true" userInteractionEnabled="false" translatesAutoresizingMaskIntoConstraints="false">
|
||||
<rect key="frame" x="0" y="0" width="414" height="736"/>
|
||||
</imageView>
|
||||
</subviews>
|
||||
@@ -37,12 +37,12 @@
|
||||
</scene>
|
||||
</scenes>
|
||||
<resources>
|
||||
<image name="SplashScreenLegacy" width="414" height="736"/>
|
||||
<image name="Screen_page" width="414" height="736"/>
|
||||
<systemColor name="systemBackgroundColor">
|
||||
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
</systemColor>
|
||||
<namedColor name="SplashScreenBackground">
|
||||
<color alpha="1.000" blue="1.00000000000000" green="1.00000000000000" red="1.00000000000000" customColorSpace="sRGB" colorSpace="custom"/>
|
||||
<color alpha="1.000" blue="0.729411764705882" green="0.823529411764706" red="0.917647058823529" customColorSpace="sRGB" colorSpace="custom"/>
|
||||
</namedColor>
|
||||
</resources>
|
||||
</document>
|
||||
@@ -1,3 +1,11 @@
|
||||
//
|
||||
// Use this file to import your target's public headers that you would like to expose to Swift.
|
||||
//
|
||||
|
||||
// 说明:
|
||||
// - 部分环境下仅 `import React` 可能无法在 Swift 中解析到 RCTBridge 等类型
|
||||
// - 通过 Bridging Header 显式引入需要的 React 头文件,保证 AppDelegate.swift 可编译
|
||||
#import <React/RCTBridge.h>
|
||||
#import <React/RCTBridgeModule.h>
|
||||
#import <React/RCTBundleURLProvider.h>
|
||||
#import <React/RCTLinkingManager.h>
|
||||
|
||||
@@ -4,5 +4,10 @@
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<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>
|
||||
133
client/ios/scripts/fix-xcarchive-header.sh
Executable file
@@ -0,0 +1,133 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# 修复 Xcode Organizer 显示 “Generic Xcode Archive” 的问题:
|
||||
# - 某些情况下 xcodebuild 生成的 .xcarchive/Info.plist 缺少 ApplicationProperties
|
||||
# - Organizer 无法识别归档中的主 App(即使 Products/Applications/*.app 存在)
|
||||
#
|
||||
# 说明:
|
||||
# - 该脚本的核心作用是让 Organizer 能识别归档里的主 App,从而出现“分发/上传 TestFlight”入口。
|
||||
# - 这类问题通常发生在命令行/CI 归档(xcodebuild archive)或某些自定义归档流程中,
|
||||
# 导致 .xcarchive/Info.plist 缺少/不完整。
|
||||
#
|
||||
# 用法:
|
||||
# ./scripts/fix-xcarchive-header.sh "/path/to/xxx.xcarchive"
|
||||
|
||||
ARCHIVE_PATH="${1:-}"
|
||||
if [[ -z "$ARCHIVE_PATH" ]]; then
|
||||
echo "用法: $0 \"/path/to/xxx.xcarchive\"" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ ! -d "$ARCHIVE_PATH" ]]; then
|
||||
echo "错误:找不到归档目录:$ARCHIVE_PATH" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
ARCHIVE_INFO_PLIST="$ARCHIVE_PATH/Info.plist"
|
||||
if [[ ! -f "$ARCHIVE_INFO_PLIST" ]]; then
|
||||
echo "错误:找不到归档 Info.plist:$ARCHIVE_INFO_PLIST" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# 归档名:尽量从路径推导,避免依赖 Xcode 环境变量
|
||||
archive_basename="$(/usr/bin/basename "$ARCHIVE_PATH")"
|
||||
archive_name="${archive_basename%.xcarchive}"
|
||||
|
||||
# 取第一个 App(归档里通常只有一个主 App)
|
||||
APP_PLIST="$(/usr/bin/find "$ARCHIVE_PATH/Products/Applications" -maxdepth 2 -name Info.plist -path "*.app/Info.plist" 2>/dev/null | /usr/bin/head -n 1 || true)"
|
||||
if [[ -z "$APP_PLIST" ]]; then
|
||||
echo "错误:归档中未找到 Products/Applications/*.app/Info.plist(请先确保归档产出包含 .app)" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
APP_DIR="$(/usr/bin/dirname "$APP_PLIST")"
|
||||
APP_NAME="$(/usr/bin/basename "$APP_DIR")" # 例如 DearMama.app
|
||||
APP_REL_PATH="Applications/$APP_NAME"
|
||||
|
||||
bundle_id="$(/usr/bin/plutil -extract CFBundleIdentifier raw -o - "$APP_PLIST" 2>/dev/null || true)"
|
||||
short_version="$(/usr/bin/plutil -extract CFBundleShortVersionString raw -o - "$APP_PLIST" 2>/dev/null || true)"
|
||||
build_version="$(/usr/bin/plutil -extract CFBundleVersion raw -o - "$APP_PLIST" 2>/dev/null || true)"
|
||||
display_name="$(/usr/bin/plutil -extract CFBundleDisplayName raw -o - "$APP_PLIST" 2>/dev/null || true)"
|
||||
bundle_name="$(/usr/bin/plutil -extract CFBundleName raw -o - "$APP_PLIST" 2>/dev/null || true)"
|
||||
|
||||
# SchemeName 在 Organizer 中会用到,但在某些归档流程里会缺失
|
||||
scheme_name="${SCHEME_NAME:-}"
|
||||
if [[ -z "$scheme_name" ]]; then
|
||||
scheme_name="${archive_name:-}"
|
||||
fi
|
||||
|
||||
if [[ -z "$bundle_id" || -z "$short_version" || -z "$build_version" ]]; then
|
||||
echo "错误:无法从 App Info.plist 读取 bundle/version/build:$APP_PLIST" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# 解析 embedded.mobileprovision(若存在)
|
||||
profile_name=""
|
||||
profile_uuid=""
|
||||
team_id=""
|
||||
provision_path="$APP_DIR/embedded.mobileprovision"
|
||||
if [[ -f "$provision_path" ]]; then
|
||||
decoded="$(/usr/bin/security cms -D -i "$provision_path" 2>/dev/null || true)"
|
||||
if [[ -n "$decoded" ]]; then
|
||||
# 使用 plutil 从 xml 中提取字段
|
||||
profile_name="$(printf "%s" "$decoded" | /usr/bin/plutil -extract Name raw -o - - 2>/dev/null || true)"
|
||||
profile_uuid="$(printf "%s" "$decoded" | /usr/bin/plutil -extract UUID raw -o - - 2>/dev/null || true)"
|
||||
team_id="$(printf "%s" "$decoded" | /usr/bin/plutil -extract TeamIdentifier.0 raw -o - - 2>/dev/null || true)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 备份一份,防止误操作
|
||||
cp -f "$ARCHIVE_INFO_PLIST" "$ARCHIVE_INFO_PLIST.bak"
|
||||
|
||||
# 修复归档根字段,避免 Organizer 仍然把它当 Generic Archive
|
||||
# 参考:标准 .xcarchive/Info.plist 通常包含 Name / SchemeName / ArchiveVersion / CreationDate 等。
|
||||
# 我们只在缺失时补齐,尽量不改动归档的其他内容。
|
||||
if ! /usr/bin/plutil -extract Name xml1 -o - "$ARCHIVE_INFO_PLIST" >/dev/null 2>&1; then
|
||||
/usr/bin/plutil -insert Name -string "${archive_name:-${display_name:-${bundle_name:-}}}" "$ARCHIVE_INFO_PLIST" 2>/dev/null || true
|
||||
fi
|
||||
if ! /usr/bin/plutil -extract SchemeName xml1 -o - "$ARCHIVE_INFO_PLIST" >/dev/null 2>&1; then
|
||||
/usr/bin/plutil -insert SchemeName -string "${scheme_name:-${archive_name:-}}" "$ARCHIVE_INFO_PLIST" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# 如果已有 ApplicationProperties,直接更新关键字段即可
|
||||
if /usr/bin/plutil -extract ApplicationProperties xml1 -o - "$ARCHIVE_INFO_PLIST" >/dev/null 2>&1; then
|
||||
/usr/bin/plutil -replace ApplicationProperties.ApplicationPath -string "$APP_REL_PATH" "$ARCHIVE_INFO_PLIST"
|
||||
/usr/bin/plutil -replace ApplicationProperties.CFBundleIdentifier -string "$bundle_id" "$ARCHIVE_INFO_PLIST"
|
||||
/usr/bin/plutil -replace ApplicationProperties.CFBundleShortVersionString -string "$short_version" "$ARCHIVE_INFO_PLIST"
|
||||
/usr/bin/plutil -replace ApplicationProperties.CFBundleVersion -string "$build_version" "$ARCHIVE_INFO_PLIST"
|
||||
else
|
||||
# 新增 ApplicationProperties(注意:plutil 的空字典/数组类型是 -dictionary / -array)
|
||||
/usr/bin/plutil -insert ApplicationProperties -dictionary "$ARCHIVE_INFO_PLIST"
|
||||
/usr/bin/plutil -insert ApplicationProperties.ApplicationPath -string "$APP_REL_PATH" "$ARCHIVE_INFO_PLIST"
|
||||
/usr/bin/plutil -insert ApplicationProperties.CFBundleIdentifier -string "$bundle_id" "$ARCHIVE_INFO_PLIST"
|
||||
/usr/bin/plutil -insert ApplicationProperties.CFBundleShortVersionString -string "$short_version" "$ARCHIVE_INFO_PLIST"
|
||||
/usr/bin/plutil -insert ApplicationProperties.CFBundleVersion -string "$build_version" "$ARCHIVE_INFO_PLIST"
|
||||
/usr/bin/plutil -insert ApplicationProperties.Architectures -array "$ARCHIVE_INFO_PLIST"
|
||||
/usr/bin/plutil -insert ApplicationProperties.Architectures.0 -string "arm64" "$ARCHIVE_INFO_PLIST"
|
||||
fi
|
||||
|
||||
# 可选字段:Provisioning Profile 信息(不保证一定存在)
|
||||
if [[ -n "$profile_name" ]]; then
|
||||
/usr/bin/plutil -replace ApplicationProperties.ProvisioningProfileName -string "$profile_name" "$ARCHIVE_INFO_PLIST" 2>/dev/null || \
|
||||
/usr/bin/plutil -insert ApplicationProperties.ProvisioningProfileName -string "$profile_name" "$ARCHIVE_INFO_PLIST"
|
||||
fi
|
||||
if [[ -n "$profile_uuid" ]]; then
|
||||
/usr/bin/plutil -replace ApplicationProperties.ProvisioningProfileUUID -string "$profile_uuid" "$ARCHIVE_INFO_PLIST" 2>/dev/null || \
|
||||
/usr/bin/plutil -insert ApplicationProperties.ProvisioningProfileUUID -string "$profile_uuid" "$ARCHIVE_INFO_PLIST"
|
||||
fi
|
||||
if [[ -n "$team_id" ]]; then
|
||||
/usr/bin/plutil -replace ApplicationProperties.Team -string "$team_id" "$ARCHIVE_INFO_PLIST" 2>/dev/null || \
|
||||
/usr/bin/plutil -insert ApplicationProperties.Team -string "$team_id" "$ARCHIVE_INFO_PLIST"
|
||||
fi
|
||||
|
||||
echo "已修复归档 header:$ARCHIVE_INFO_PLIST"
|
||||
echo "主 App:$APP_REL_PATH"
|
||||
echo "Bundle:$bundle_id"
|
||||
echo "Version/Build:$short_version/$build_version"
|
||||
echo "Name/SchemeName:${archive_name:-} / ${scheme_name:-}"
|
||||
|
||||
# 轻量自检:确保关键字段存在(不强制失败,避免中断归档)
|
||||
if ! /usr/bin/plutil -extract ApplicationProperties.ApplicationPath xml1 -o - "$ARCHIVE_INFO_PLIST" >/dev/null 2>&1; then
|
||||
echo "警告:归档 Info.plist 仍缺少 ApplicationProperties.ApplicationPath,Organizer 可能仍显示 Generic Archive" >&2
|
||||
fi
|
||||
@@ -1,190 +1,361 @@
|
||||
import Foundation
|
||||
import WidgetKit
|
||||
import SwiftUI
|
||||
|
||||
// V1:写死文案的小组件(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:根据设备语言选择
|
||||
// - 传统中文(Hant / TW / HK / MO)=> tc
|
||||
// - 其他语言(含简中 zh-Hans / zh-CN)=> en(默认)
|
||||
let preferred = (Locale.preferredLanguages.first ?? "en").lowercased()
|
||||
if preferred.hasPrefix("zh-hant") { return "tc" }
|
||||
if preferred.hasPrefix("zh-tw") { return "tc" }
|
||||
if preferred.hasPrefix("zh-hk") { return "tc" }
|
||||
if preferred.hasPrefix("zh-mo") { return "tc" }
|
||||
return "en"
|
||||
}
|
||||
|
||||
private func resolveTitle(lang: String) -> String {
|
||||
// 需求:品牌文案统一为 Dear Mama
|
||||
return "Dear Mama"
|
||||
}
|
||||
|
||||
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(family: WidgetFamily) -> (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] {
|
||||
if let text = pickWidgetText(item: item, family: family), !text.isEmpty {
|
||||
return (dayKey: dayKey, lang: lang, text: text)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func familyKey(_ family: WidgetFamily) -> String {
|
||||
switch family {
|
||||
case .systemSmall:
|
||||
return "small"
|
||||
case .systemMedium:
|
||||
return "medium"
|
||||
case .systemLarge:
|
||||
return "large"
|
||||
default:
|
||||
return "small"
|
||||
}
|
||||
}
|
||||
|
||||
private func pickWidgetText(item: [String: Any], family: WidgetFamily?) -> String? {
|
||||
// 优先使用 App 侧预换行的结果(wrapped_text_by_family),否则回退 raw text
|
||||
if let family = family,
|
||||
let wrappedByFamily = item["wrapped_text_by_family"] as? [String: Any] {
|
||||
let key = familyKey(family)
|
||||
if let v = wrappedByFamily[key] as? String, !v.isEmpty {
|
||||
return v
|
||||
}
|
||||
}
|
||||
if let raw = item["text"] as? String, !raw.isEmpty { return raw }
|
||||
return nil
|
||||
}
|
||||
|
||||
private func readApiBaseUrl() -> String? {
|
||||
guard let d = readJsonDict(forKey: keyWidgetConfig) else { return nil }
|
||||
let base = d["apiBaseUrl"] as? String
|
||||
return base?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private func readUserProfileDict() -> [String: Any]? {
|
||||
guard let d = readJsonDict(forKey: keyWidgetUserProfile) else { return nil }
|
||||
return d["user_profile"] as? [String: Any]
|
||||
}
|
||||
|
||||
private func saveDailyReco(lang: String, dayKey: String, contentId: Int, text: String, meta: [String: Any]?) {
|
||||
var dict: [String: Any] = [
|
||||
"schema_version": 1,
|
||||
"saved_at": isoNow(),
|
||||
"day_key": dayKey,
|
||||
"lang": lang,
|
||||
"source": "widget",
|
||||
"item": [
|
||||
"content_id": contentId,
|
||||
"text": text
|
||||
]
|
||||
]
|
||||
if let meta = meta { dict["meta"] = meta }
|
||||
writeJsonDict(dict, forKey: keyWidgetDailyReco)
|
||||
}
|
||||
|
||||
private func fetchDailyRecoFromServer() async -> (lang: String, text: String, contentId: Int, meta: [String: Any]?)? {
|
||||
guard let baseUrl = readApiBaseUrl(), !baseUrl.isEmpty else { return nil }
|
||||
guard let userProfile = readUserProfileDict() else { return nil }
|
||||
|
||||
let lang = resolveLang()
|
||||
let urlStr = joinUrl(base: baseUrl, path: "/v1/reco/widget")
|
||||
guard let url = URL(string: urlStr) else { return nil }
|
||||
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = "POST"
|
||||
req.timeoutInterval = 12
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
req.setValue(lang, forHTTPHeaderField: "Accept-Language")
|
||||
|
||||
let body: [String: Any] = [
|
||||
"k": 1,
|
||||
"user_profile": userProfile,
|
||||
"already_recommended_ids": [],
|
||||
"touched_or_viewed_ids": []
|
||||
]
|
||||
req.httpBody = try? JSONSerialization.data(withJSONObject: body, options: [])
|
||||
|
||||
do {
|
||||
let (data, res) = try await URLSession.shared.data(for: req)
|
||||
guard let httpRes = res as? HTTPURLResponse, (200..<300).contains(httpRes.statusCode) else { return nil }
|
||||
|
||||
let obj = try JSONSerialization.jsonObject(with: data, options: [])
|
||||
guard let root = obj as? [String: Any] else { return nil }
|
||||
guard let items = root["items"] as? [[String: Any]], let first = items.first else { return nil }
|
||||
guard let text = first["text"] as? String, !text.isEmpty else { return nil }
|
||||
let contentId = (first["content_id"] as? Int) ?? Int((first["content_id"] as? NSNumber)?.intValue ?? -1)
|
||||
if contentId < 0 { return nil }
|
||||
let meta = root["meta"] as? [String: Any]
|
||||
return (lang: lang, text: text, contentId: contentId, meta: meta)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
struct EmotionProvider: TimelineProvider {
|
||||
func placeholder(in context: Context) -> EmotionEntry {
|
||||
EmotionEntry(date: Date())
|
||||
let lang = resolveLang()
|
||||
return EmotionEntry(
|
||||
date: Date(),
|
||||
lang: lang,
|
||||
title: resolveTitle(lang: lang),
|
||||
text: lang == "en" ? fallbackTextEN : fallbackTextTC,
|
||||
footerHint: resolveFooterHint(lang: lang)
|
||||
)
|
||||
}
|
||||
|
||||
func getSnapshot(in context: Context, completion: @escaping (EmotionEntry) -> ()) {
|
||||
completion(EmotionEntry(date: Date()))
|
||||
completion(placeholder(in: context))
|
||||
}
|
||||
|
||||
func getTimeline(in context: Context, completion: @escaping (Timeline<EmotionEntry>) -> ()) {
|
||||
// V1:内容写死,不做数据更新;给一个较长的刷新间隔(系统仍可能自行调度)
|
||||
let entry = EmotionEntry(date: Date())
|
||||
let nextUpdate = Calendar.current.date(byAdding: .day, value: 7, to: Date())
|
||||
?? Date().addingTimeInterval(60 * 60 * 24 * 7)
|
||||
completion(Timeline(entries: [entry], policy: .after(nextUpdate)))
|
||||
Task {
|
||||
let lang = resolveLang()
|
||||
let today = localDayKey(Date())
|
||||
|
||||
// 1) 今日缓存优先
|
||||
if let cached = readCachedText(family: context.family), 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(family: context.family) {
|
||||
let entry = EmotionEntry(
|
||||
date: Date(),
|
||||
lang: cached.lang,
|
||||
title: resolveTitle(lang: cached.lang),
|
||||
text: cached.text,
|
||||
footerHint: resolveFooterHint(lang: cached.lang)
|
||||
)
|
||||
completion(Timeline(entries: [entry], policy: .after(nextDailyRefreshDate(from: Date()))))
|
||||
return
|
||||
}
|
||||
|
||||
let entry = EmotionEntry(
|
||||
date: Date(),
|
||||
lang: lang,
|
||||
title: resolveTitle(lang: lang),
|
||||
text: lang == "en" ? fallbackTextEN : fallbackTextTC,
|
||||
footerHint: resolveFooterHint(lang: lang)
|
||||
)
|
||||
completion(Timeline(entries: [entry], policy: .after(nextDailyRefreshDate(from: Date()))))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct EmotionEntry: TimelineEntry {
|
||||
let date: Date
|
||||
let lang: String
|
||||
let title: String
|
||||
let text: String
|
||||
let footerHint: String
|
||||
}
|
||||
|
||||
struct EmotionWidgetView: View {
|
||||
var entry: EmotionProvider.Entry
|
||||
@Environment(\.widgetFamily) var family
|
||||
|
||||
private let title = "正念"
|
||||
private let text = "你已经很努力了,今天也值得被温柔对待。"
|
||||
private let deepLink = URL(string: "client:///(app)/home")
|
||||
private let widgetBackgroundColor = Color(red: 1.0, green: 250.0 / 255.0, blue: 229.0 / 255.0) // #FFFAE5
|
||||
private let widgetTextColor = Color(red: 98.0 / 255.0, green: 59.0 / 255.0, blue: 59.0 / 255.0) // #623B3B
|
||||
|
||||
var body: some View {
|
||||
// 只显示一句话(不显示标题/提示/时间等装饰元素)
|
||||
Text(entry.text)
|
||||
.font(fontForFamily())
|
||||
.foregroundColor(widgetTextColor)
|
||||
.multilineTextAlignment(.leading)
|
||||
.lineSpacing(lineSpacingForFamily())
|
||||
.lineLimit(lineLimitForFamily())
|
||||
.minimumScaleFactor(0.78)
|
||||
.padding(paddingForFamily())
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
||||
.widgetSolidBackground(widgetBackgroundColor)
|
||||
.widgetURL(deepLink)
|
||||
}
|
||||
|
||||
private func fontForFamily() -> Font {
|
||||
switch family {
|
||||
case .systemSmall:
|
||||
smallView()
|
||||
return .system(size: 16, weight: .semibold)
|
||||
case .systemMedium:
|
||||
mediumView()
|
||||
return .system(size: 18, weight: .semibold)
|
||||
case .systemLarge:
|
||||
largeView()
|
||||
return .system(size: 22, weight: .semibold)
|
||||
default:
|
||||
smallView()
|
||||
return .system(size: 16, weight: .semibold)
|
||||
}
|
||||
}
|
||||
|
||||
// 统一的“卡片背景”风格(iOS 15 兼容)
|
||||
private func cardBackground(colors: [Color]) -> some View {
|
||||
ZStack {
|
||||
LinearGradient(
|
||||
colors: colors,
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
// 轻微光斑,增加层次
|
||||
RadialGradient(
|
||||
gradient: Gradient(colors: [Color.white.opacity(0.16), Color.white.opacity(0.0)]),
|
||||
center: .topTrailing,
|
||||
startRadius: 10,
|
||||
endRadius: 180
|
||||
)
|
||||
private func lineSpacingForFamily() -> CGFloat {
|
||||
switch family {
|
||||
case .systemLarge:
|
||||
return 4
|
||||
default:
|
||||
return 3
|
||||
}
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||
.stroke(Color.white.opacity(0.14), lineWidth: 1)
|
||||
)
|
||||
.cornerRadius(18)
|
||||
}
|
||||
|
||||
private func chip(_ text: String) -> some View {
|
||||
Text(text)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundColor(Color.white.opacity(0.9))
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.white.opacity(0.14))
|
||||
.cornerRadius(999)
|
||||
private func lineLimitForFamily() -> Int {
|
||||
switch family {
|
||||
case .systemSmall:
|
||||
return 5
|
||||
case .systemMedium:
|
||||
return 6
|
||||
case .systemLarge:
|
||||
return 8
|
||||
default:
|
||||
return 5
|
||||
}
|
||||
}
|
||||
|
||||
private func smallView() -> some View {
|
||||
ZStack {
|
||||
cardBackground(colors: [
|
||||
Color(red: 0.06, green: 0.08, blue: 0.12),
|
||||
Color(red: 0.13, green: 0.16, blue: 0.22),
|
||||
])
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
chip(title)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
|
||||
Text(text)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundColor(Color.white.opacity(0.92))
|
||||
.lineSpacing(2)
|
||||
.lineLimit(4)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
Text("点我回到 App")
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundColor(Color.white.opacity(0.65))
|
||||
}
|
||||
.padding(14)
|
||||
private func paddingForFamily() -> CGFloat {
|
||||
switch family {
|
||||
case .systemSmall:
|
||||
return 14
|
||||
case .systemMedium:
|
||||
return 16
|
||||
case .systemLarge:
|
||||
return 18
|
||||
default:
|
||||
return 14
|
||||
}
|
||||
.widgetURL(deepLink)
|
||||
}
|
||||
}
|
||||
|
||||
private func mediumView() -> some View {
|
||||
ZStack {
|
||||
cardBackground(colors: [
|
||||
Color(red: 0.06, green: 0.08, blue: 0.12),
|
||||
Color(red: 0.09, green: 0.11, blue: 0.17),
|
||||
])
|
||||
private struct WidgetSolidBackgroundModifier: ViewModifier {
|
||||
let color: Color
|
||||
|
||||
HStack(alignment: .top, spacing: 14) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
chip(title)
|
||||
Text(text)
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundColor(Color.white.opacity(0.92))
|
||||
.lineSpacing(3)
|
||||
.lineLimit(5)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
Text("轻轻呼吸,回到当下")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundColor(Color.white.opacity(0.7))
|
||||
}
|
||||
|
||||
// 右侧装饰区:让版面更饱满
|
||||
VStack(alignment: .trailing, spacing: 8) {
|
||||
Text(entry.date, style: .time)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundColor(Color.white.opacity(0.8))
|
||||
Spacer(minLength: 0)
|
||||
Text("今日")
|
||||
.font(.system(size: 28, weight: .bold))
|
||||
.foregroundColor(Color.white.opacity(0.12))
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
func body(content: Content) -> some View {
|
||||
if #available(iOSApplicationExtension 17.0, *) {
|
||||
content.containerBackground(for: .widget) { color }
|
||||
} else {
|
||||
content
|
||||
.background(color)
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
.widgetURL(deepLink)
|
||||
}
|
||||
}
|
||||
|
||||
private func largeView() -> some View {
|
||||
ZStack {
|
||||
cardBackground(colors: [
|
||||
Color(red: 0.06, green: 0.08, blue: 0.12),
|
||||
Color(red: 0.14, green: 0.18, blue: 0.28),
|
||||
])
|
||||
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack {
|
||||
chip(title)
|
||||
Spacer(minLength: 0)
|
||||
Text(entry.date, style: .time)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundColor(Color.white.opacity(0.78))
|
||||
}
|
||||
|
||||
Text(text)
|
||||
.font(.system(size: 20, weight: .semibold))
|
||||
.foregroundColor(Color.white.opacity(0.92))
|
||||
.lineSpacing(4)
|
||||
.lineLimit(8)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
HStack {
|
||||
Text("点我回到 Home")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundColor(Color.white.opacity(0.7))
|
||||
Spacer(minLength: 0)
|
||||
Text("🌿")
|
||||
.font(.system(size: 18))
|
||||
.opacity(0.9)
|
||||
}
|
||||
}
|
||||
.padding(18)
|
||||
}
|
||||
.widgetURL(deepLink)
|
||||
private extension View {
|
||||
func widgetSolidBackground(_ color: Color) -> some View {
|
||||
modifier(WidgetSolidBackgroundModifier(color: color))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,8 +367,9 @@ struct EmotionWidget: Widget {
|
||||
StaticConfiguration(kind: kind, provider: EmotionProvider()) { entry in
|
||||
EmotionWidgetView(entry: entry)
|
||||
}
|
||||
.configurationDisplayName("情绪小组件")
|
||||
.description("一段温柔提醒,陪你回到当下。")
|
||||
// 名称/描述:支持多语言(使用 Widget Extension 自己的 Localizable.strings)
|
||||
.configurationDisplayName("WIDGET_DISPLAY_NAME")
|
||||
.description("WIDGET_DESCRIPTION")
|
||||
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
|
||||
}
|
||||
}
|
||||
|
||||
3
client/ios/情绪小组件/en.lproj/Localizable.strings
Normal file
@@ -0,0 +1,3 @@
|
||||
"WIDGET_DISPLAY_NAME" = "Emotion Widget";
|
||||
"WIDGET_DESCRIPTION" = "A gentle reminder to return to the present.";
|
||||
|
||||
3
client/ios/情绪小组件/zh-Hant.lproj/Localizable.strings
Normal file
@@ -0,0 +1,3 @@
|
||||
"WIDGET_DISPLAY_NAME" = "情緒小組件";
|
||||
"WIDGET_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>
|
||||
@@ -1,5 +1,6 @@
|
||||
// Metro 配置:支持 import 本地 .svg 为 React 组件
|
||||
// 说明:Expo SDK 54 + react-native-svg-transformer 的常见配置方式
|
||||
const path = require('path');
|
||||
const { getDefaultConfig } = require('expo/metro-config');
|
||||
|
||||
/** @type {import('expo/metro-config').MetroConfig} */
|
||||
@@ -14,6 +15,10 @@ config.resolver = {
|
||||
...config.resolver,
|
||||
assetExts: config.resolver.assetExts.filter((ext) => ext !== 'svg'),
|
||||
sourceExts: [...config.resolver.sourceExts, 'svg'],
|
||||
// 确保 react-native-text-size 从项目 node_modules 解析(避免 Metro 解析不到)
|
||||
extraNodeModules: {
|
||||
'react-native-text-size': path.resolve(__dirname, 'node_modules/react-native-text-size'),
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
|
||||
1644
client/package-lock.json
generated
@@ -4,10 +4,14 @@
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"start:clean": "expo start -c",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"ios": "expo run:ios --scheme \"Dear Mama\"",
|
||||
"ios:clean": "npm run clean:cache && npm run clean:ios-build && expo run:ios --scheme \"Dear Mama\"",
|
||||
"web": "expo start --web",
|
||||
"test": "vitest run"
|
||||
"test": "vitest run",
|
||||
"clean:cache": "rm -rf node_modules/.cache .expo 2>/dev/null; echo 'Cleared .expo and node_modules/.cache'",
|
||||
"clean:ios-build": "rm -rf ~/Library/Developer/Xcode/DerivedData/client-* 2>/dev/null; echo 'Cleared Xcode DerivedData for client'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^15.0.3",
|
||||
@@ -15,7 +19,9 @@
|
||||
"@react-navigation/native": "^7.1.8",
|
||||
"expo": "~54.0.32",
|
||||
"expo-constants": "~18.0.13",
|
||||
"expo-crypto": "^15.0.8",
|
||||
"expo-dev-client": "^6.0.20",
|
||||
"expo-device": "^8.0.10",
|
||||
"expo-font": "~14.0.11",
|
||||
"expo-linear-gradient": "^15.0.8",
|
||||
"expo-linking": "~8.0.11",
|
||||
@@ -25,6 +31,7 @@
|
||||
"expo-splash-screen": "~31.0.13",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"expo-web-browser": "~15.0.10",
|
||||
"grapheme-splitter": "^1.0.4",
|
||||
"i18next": "^25.8.0",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
@@ -36,6 +43,7 @@
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-svg": "15.12.1",
|
||||
"react-native-svg-transformer": "^1.5.3",
|
||||
"react-native-text-size": "^4.0.0-rc.1",
|
||||
"react-native-web": "~0.21.0",
|
||||
"react-native-worklets": "0.5.1"
|
||||
},
|
||||
|
||||
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: {}
|
||||
|
||||
@@ -22,7 +22,14 @@ function getOptionalEnv(name: string, fallback: string): string {
|
||||
|
||||
export type AppRuntimeEnv = 'local' | 'dev' | 'prod';
|
||||
|
||||
export const APP_ENV = (getOptionalEnv('EXPO_PUBLIC_ENV', 'local') as AppRuntimeEnv) ?? 'local';
|
||||
/**
|
||||
* Release/TestFlight 场景下如果未注入 EXPO_PUBLIC_ENV,
|
||||
* 默认回退到 prod(避免误打到 localhost 导致真机“无法发起网络请求”)。
|
||||
*/
|
||||
const DEFAULT_RUNTIME_ENV: AppRuntimeEnv =
|
||||
typeof __DEV__ !== 'undefined' && __DEV__ ? 'local' : 'dev';
|
||||
|
||||
export const APP_ENV = (getOptionalEnv('EXPO_PUBLIC_ENV', DEFAULT_RUNTIME_ENV) as AppRuntimeEnv) ?? DEFAULT_RUNTIME_ENV;
|
||||
|
||||
function getApiBaseUrl(env: AppRuntimeEnv): string {
|
||||
// 向后兼容:若直接提供了 EXPO_PUBLIC_API_BASE_URL,则优先使用(不再强制要求 *_DEV/_PROD)
|
||||
@@ -34,13 +41,33 @@ function getApiBaseUrl(env: AppRuntimeEnv): string {
|
||||
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' }
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { UserProfileScoring } from '@/src/storage/appStorage';
|
||||
|
||||
import { advanceSuixinState, buildInitialSuixinState, computeSuixinSolidColor, pickSuixinBaseThemeId } from '../index';
|
||||
import { computeTFromStep } from '../progress';
|
||||
import { lerpHex } from '../colorMath';
|
||||
|
||||
function buildProfile(partial?: Partial<UserProfileScoring>): UserProfileScoring {
|
||||
const base: UserProfileScoring = {
|
||||
profile_version: 'v1.2',
|
||||
profile_source: 'questionnaire',
|
||||
profile_generated_at: '2026-01-30T00:00:00Z',
|
||||
profile_confidence: 1.0,
|
||||
profile_answered: { stage: true, emotion: true, context: true, need: true },
|
||||
stage: { expecting: 1, parenting: 0, unknown: 0 },
|
||||
emotion_score: 0.6,
|
||||
context: {},
|
||||
need: {},
|
||||
rule_hits: [],
|
||||
hard_rules: { forbidden_risk_flags: [], forbidden_content_predicates: [] },
|
||||
};
|
||||
return { ...base, ...(partial ?? {}) };
|
||||
}
|
||||
|
||||
describe('suixinTheme', () => {
|
||||
it('pickSuixinBaseThemeId: stage.unknown=1 → neutral', () => {
|
||||
const p = buildProfile({ stage: { unknown: 1 } as any, need: { rest_balance: 1 } });
|
||||
expect(pickSuixinBaseThemeId(p)).toBe('neutral');
|
||||
});
|
||||
|
||||
it('pickSuixinBaseThemeId: need 为空 → neutral', () => {
|
||||
const p = buildProfile({ need: {} });
|
||||
expect(pickSuixinBaseThemeId(p)).toBe('neutral');
|
||||
});
|
||||
|
||||
it('pickSuixinBaseThemeId: need 命中 → 对应 base theme', () => {
|
||||
const p = buildProfile({ need: { rest_balance: 1 } });
|
||||
expect(pickSuixinBaseThemeId(p)).toBe('rest_balance');
|
||||
});
|
||||
|
||||
it('computeTFromStep: 始终在 [0,1] 且往返不突跳', () => {
|
||||
const ts = Array.from({ length: 80 }).map((_, i) => computeTFromStep({ stepIndex: i, segments: 12, seed: 'boot' }));
|
||||
for (const t of ts) {
|
||||
expect(t).toBeGreaterThanOrEqual(0);
|
||||
expect(t).toBeLessThanOrEqual(1);
|
||||
}
|
||||
// 往返波形:起点与一个周期后的 t 相同
|
||||
expect(computeTFromStep({ stepIndex: 0, segments: 12, seed: 'boot' })).toBe(
|
||||
computeTFromStep({ stepIndex: 22, segments: 12, seed: 'boot' }) // 2*(N-1)=22
|
||||
);
|
||||
});
|
||||
|
||||
it('lerpHex: t=0/1 输出边界色', () => {
|
||||
expect(lerpHex('#000000', '#FFFFFF', 0)).toBe('#000000');
|
||||
expect(lerpHex('#000000', '#FFFFFF', 1)).toBe('#FFFFFF');
|
||||
});
|
||||
|
||||
it('buildInitialSuixinState: 生成可用状态并可推进', () => {
|
||||
const p = buildProfile({ need: { emotional_support: 1 } });
|
||||
const init = buildInitialSuixinState({ bootId: 'boot-1', profile: p, now: new Date('2026-02-05T00:00:00Z') });
|
||||
expect(init.schema_version).toBe(1);
|
||||
expect(init.base_theme_id).toBe('emotional_support');
|
||||
expect(init.boot_id).toBe('boot-1');
|
||||
expect(init.last_color).toMatch(/^#[0-9A-F]{6}$/);
|
||||
|
||||
const next = advanceSuixinState(init, new Date('2026-02-05T00:00:01Z'));
|
||||
expect(next.step_index).toBe(1);
|
||||
expect(next.base_theme_id).toBe(init.base_theme_id);
|
||||
expect(next.last_color).toMatch(/^#[0-9A-F]{6}$/);
|
||||
});
|
||||
|
||||
it('computeSuixinSolidColor: 输出为合法 hex', () => {
|
||||
const c = computeSuixinSolidColor({ baseThemeId: 'neutral', stepIndex: 3, seed: 'boot' });
|
||||
expect(c).toMatch(/^#[0-9A-F]{6}$/);
|
||||
});
|
||||
});
|
||||
|
||||
53
client/src/features/suixinTheme/colorMath.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
function clamp01(t: number): number {
|
||||
if (!Number.isFinite(t)) return 0;
|
||||
return Math.min(1, Math.max(0, t));
|
||||
}
|
||||
|
||||
type Rgb = { r: number; g: number; b: number };
|
||||
|
||||
function toByte(v: number): number {
|
||||
if (!Number.isFinite(v)) return 0;
|
||||
return Math.min(255, Math.max(0, Math.round(v)));
|
||||
}
|
||||
|
||||
export function hexToRgb(hex: string): Rgb | null {
|
||||
const h = String(hex || '').trim();
|
||||
const m = /^#?([0-9a-fA-F]{6})$/.exec(h);
|
||||
if (!m) return null;
|
||||
const raw = m[1];
|
||||
const n = parseInt(raw, 16);
|
||||
// eslint-disable-next-line no-bitwise
|
||||
const r = (n >> 16) & 0xff;
|
||||
// eslint-disable-next-line no-bitwise
|
||||
const g = (n >> 8) & 0xff;
|
||||
// eslint-disable-next-line no-bitwise
|
||||
const b = n & 0xff;
|
||||
return { r, g, b };
|
||||
}
|
||||
|
||||
export function rgbToHex(rgb: Rgb): string {
|
||||
const r = toByte(rgb.r).toString(16).padStart(2, '0');
|
||||
const g = toByte(rgb.g).toString(16).padStart(2, '0');
|
||||
const b = toByte(rgb.b).toString(16).padStart(2, '0');
|
||||
return `#${r}${g}${b}`.toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅允许线性插值(Hard Rule)
|
||||
*/
|
||||
export function lerpRgb(a: Rgb, b: Rgb, t: number): Rgb {
|
||||
const tt = clamp01(t);
|
||||
return {
|
||||
r: a.r + (b.r - a.r) * tt,
|
||||
g: a.g + (b.g - a.g) * tt,
|
||||
b: a.b + (b.b - a.b) * tt,
|
||||
};
|
||||
}
|
||||
|
||||
export function lerpHex(topHex: string, bottomHex: string, t: number, fallbackHex = '#E8F1EC'): string {
|
||||
const top = hexToRgb(topHex);
|
||||
const bottom = hexToRgb(bottomHex);
|
||||
if (!top || !bottom) return fallbackHex;
|
||||
return rgbToHex(lerpRgb(top, bottom, t));
|
||||
}
|
||||
|
||||
65
client/src/features/suixinTheme/index.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { SuixinBaseThemeId, SuixinThemeStateV1, UserProfileScoring } from '@/src/storage/appStorage';
|
||||
|
||||
import { getThemeTriplet, NEUTRAL_THEME_COLORS } from './palette';
|
||||
import { lerpHex } from './colorMath';
|
||||
import { computeTFromStep } from './progress';
|
||||
import { pickSuixinBaseThemeId } from './pickTheme';
|
||||
|
||||
export { NEUTRAL_THEME_COLORS } from './palette';
|
||||
export { pickSuixinBaseThemeId } from './pickTheme';
|
||||
|
||||
/**
|
||||
* 根据 base theme 与 step 计算当前背景纯色
|
||||
*/
|
||||
export function computeSuixinSolidColor(args: {
|
||||
baseThemeId: SuixinBaseThemeId;
|
||||
stepIndex: number;
|
||||
seed: string;
|
||||
}): string {
|
||||
const [top, _mid, bottom] = getThemeTriplet(args.baseThemeId);
|
||||
const t = computeTFromStep({ stepIndex: args.stepIndex, segments: 12, seed: args.seed });
|
||||
return lerpHex(top, bottom, t, NEUTRAL_THEME_COLORS[1]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化一份随心状态(在冷启动会话内锁定 base theme)
|
||||
*/
|
||||
export function buildInitialSuixinState(args: {
|
||||
bootId: string;
|
||||
profile: UserProfileScoring | null;
|
||||
now?: Date;
|
||||
}): SuixinThemeStateV1 {
|
||||
const now = args.now ?? new Date();
|
||||
const baseThemeId = pickSuixinBaseThemeId(args.profile);
|
||||
const seed = args.bootId;
|
||||
const step_index = 0;
|
||||
const last_color = computeSuixinSolidColor({ baseThemeId, stepIndex: step_index, seed });
|
||||
return {
|
||||
schema_version: 1,
|
||||
saved_at: now.toISOString(),
|
||||
boot_id: args.bootId,
|
||||
base_theme_id: baseThemeId,
|
||||
seed,
|
||||
step_index,
|
||||
last_color,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换文案时推进一步(不跨主题)
|
||||
*/
|
||||
export function advanceSuixinState(prev: SuixinThemeStateV1, now?: Date): SuixinThemeStateV1 {
|
||||
const nextStep = (Number.isFinite(prev.step_index) ? prev.step_index : 0) + 1;
|
||||
const last_color = computeSuixinSolidColor({
|
||||
baseThemeId: prev.base_theme_id,
|
||||
stepIndex: nextStep,
|
||||
seed: prev.seed,
|
||||
});
|
||||
return {
|
||||
...prev,
|
||||
saved_at: (now ?? new Date()).toISOString(),
|
||||
step_index: nextStep,
|
||||
last_color,
|
||||
};
|
||||
}
|
||||
|
||||
20
client/src/features/suixinTheme/palette.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { SuixinBaseThemeId } from '@/src/storage/appStorage';
|
||||
|
||||
/**
|
||||
* 随心主题色盘(与设计说明文档保持一致)
|
||||
*/
|
||||
export const BASE_THEME_COLORS: Record<Exclude<SuixinBaseThemeId, 'neutral'>, [string, string, string]> = {
|
||||
emotional_support: ['#F6DCE4', '#FFEFF4', '#FFF7FA'],
|
||||
parenting_pressure: ['#D6EAF5', '#EEF6FB', '#F8FCFF'],
|
||||
self_worth: ['#FFD8A8', '#FFE8C9', '#FFF6E5'],
|
||||
anxiety_relief: ['#DFF3EA', '#ECFBF6', '#F6FFFB'],
|
||||
rest_balance: ['#F2E6D8', '#FAF3EC', '#FFFDF9'],
|
||||
};
|
||||
|
||||
export const NEUTRAL_THEME_COLORS: [string, string, string] = ['#F4F7F2', '#E8F1EC', '#EDF4F8'];
|
||||
|
||||
export function getThemeTriplet(themeId: SuixinBaseThemeId): [string, string, string] {
|
||||
if (themeId === 'neutral') return NEUTRAL_THEME_COLORS;
|
||||
return BASE_THEME_COLORS[themeId];
|
||||
}
|
||||
|
||||
32
client/src/features/suixinTheme/pickTheme.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { SuixinBaseThemeId, UserProfileScoring } from '@/src/storage/appStorage';
|
||||
|
||||
const ALL_NEED_THEME_IDS: ReadonlySet<string> = new Set([
|
||||
'emotional_support',
|
||||
'parenting_pressure',
|
||||
'self_worth',
|
||||
'anxiety_relief',
|
||||
'rest_balance',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Base Theme 选择(Theme Picking)
|
||||
*
|
||||
* Hard Rules(对齐设计文档):
|
||||
* - mom_stage = unknown → 强制 Neutral
|
||||
* - need 跳过/缺失 → Neutral
|
||||
*/
|
||||
export function pickSuixinBaseThemeId(profile: UserProfileScoring | null | undefined): SuixinBaseThemeId {
|
||||
if (!profile) return 'neutral';
|
||||
|
||||
// Hard Rule:unknown → Neutral
|
||||
if (profile.stage?.unknown === 1) return 'neutral';
|
||||
|
||||
const needObj = profile.need ?? {};
|
||||
const keys = Object.keys(needObj);
|
||||
if (keys.length === 0) return 'neutral';
|
||||
|
||||
const needId = keys[0];
|
||||
if (!ALL_NEED_THEME_IDS.has(needId)) return 'neutral';
|
||||
return needId as Exclude<SuixinBaseThemeId, 'neutral'>;
|
||||
}
|
||||
|
||||
40
client/src/features/suixinTheme/progress.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
function clampInt(n: number, min: number, max: number): number {
|
||||
if (!Number.isFinite(n)) return min;
|
||||
return Math.min(max, Math.max(min, Math.floor(n)));
|
||||
}
|
||||
|
||||
function hashStringToInt32(input: string): number {
|
||||
// 简单可复现 hash:用于把 seed 映射为偏移量(不用于安全场景)
|
||||
let h = 0;
|
||||
for (let i = 0; i < input.length; i += 1) {
|
||||
// eslint-disable-next-line no-bitwise
|
||||
h = (h * 31 + input.charCodeAt(i)) | 0;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 t ∈ [0,1],用于 Color(t)=lerp(top,bottom,t)
|
||||
*
|
||||
* 说明:
|
||||
* - Home 没有 scroll,用“切换文案 step_index”模拟连续流动
|
||||
* - 采用往返波形,避免从 1 回到 0 的突跳
|
||||
*/
|
||||
export function computeTFromStep(args: {
|
||||
stepIndex: number;
|
||||
segments?: number; // N,默认 12
|
||||
seed?: string; // 允许用 seed 做初始相位偏移(同一次冷启动内稳定)
|
||||
}): number {
|
||||
const N = clampInt(args.segments ?? 12, 3, 60);
|
||||
const stepIndex = clampInt(args.stepIndex, 0, 1_000_000_000);
|
||||
const period = 2 * (N - 1);
|
||||
|
||||
const seed = String(args.seed ?? '');
|
||||
const offset = seed ? Math.abs(hashStringToInt32(seed)) % period : 0;
|
||||
|
||||
const phase = (stepIndex + offset) % period;
|
||||
const up = phase <= (N - 1);
|
||||
const pos = up ? phase : period - phase;
|
||||
return pos / (N - 1);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { wrapText } from '../index';
|
||||
|
||||
describe('textWrap integration wrapText', () => {
|
||||
it('SYSTEM_DEFAULT:meta 标记 SYSTEM_DEFAULT 且仍返回 lines/wrappedText', async () => {
|
||||
const res = await wrapText({
|
||||
text: 'I am so tired',
|
||||
lang: 'EN',
|
||||
context: 'APP',
|
||||
availableWidth: 1,
|
||||
maxLines: 1,
|
||||
overflowMode: 'SYSTEM_DEFAULT',
|
||||
// 不提供测量能力,逼迫走兜底
|
||||
fontSpec: null,
|
||||
measureWidthImpl: undefined,
|
||||
});
|
||||
|
||||
expect(res.lines.length).toBeGreaterThan(0);
|
||||
expect(res.wrappedText.length).toBeGreaterThan(0);
|
||||
expect(res.meta?.fallback_type).toBe('SYSTEM_DEFAULT');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { generateBreakpoints } from '../index';
|
||||
|
||||
function t(text: string, start: number): { text: string; start: number; end: number } {
|
||||
return { text, start, end: start + text.length };
|
||||
}
|
||||
|
||||
describe('textWrap breakpoint-candidates', () => {
|
||||
it('EN: tokens=[I,am,so,tired] -> pos=[1,2,3](升序)', () => {
|
||||
const tokens = [t('I', 0), t('am', 2), t('so', 5), t('tired', 8)];
|
||||
const { breakpoints, meta } = generateBreakpoints({
|
||||
tokens: tokens as any,
|
||||
lang: 'EN',
|
||||
maxLines: 2,
|
||||
config: { tcMaxCandidateBreaks: 80, tcPunctuations: [',', '。'], balanceRange: 3 },
|
||||
});
|
||||
|
||||
expect(breakpoints.map((b) => b.pos)).toEqual([1, 2, 3]);
|
||||
expect(meta.originalCount).toBe(3);
|
||||
expect(meta.finalCount).toBe(3);
|
||||
});
|
||||
|
||||
it('TC: 标点后断点(,)应生成 pos=i+1', () => {
|
||||
const tokens = [t('我', 0), t('好', 1), t('累', 2), t(',', 3), t('😮💨', 4)];
|
||||
const { breakpoints } = generateBreakpoints({
|
||||
tokens: tokens as any,
|
||||
lang: 'TC',
|
||||
maxLines: 2,
|
||||
config: { tcMaxCandidateBreaks: 80, tcPunctuations: [','], balanceRange: 0 },
|
||||
});
|
||||
|
||||
expect(breakpoints.some((b) => b.kind === 'PUNCT' && b.pos === 4)).toBe(true);
|
||||
});
|
||||
|
||||
it('TC: forbiddenBreakRanges(闭区间)命中必须剔除(含 start/end)', () => {
|
||||
const tokens = [t('我', 0), t('好', 1), t('累', 2), t(',', 3), t('😮💨', 4)];
|
||||
const { breakpoints } = generateBreakpoints({
|
||||
tokens: tokens as any,
|
||||
lang: 'TC',
|
||||
maxLines: 2,
|
||||
constraints: { forbiddenBreakRanges: [{ start: 4, end: 4 }] },
|
||||
config: { tcMaxCandidateBreaks: 80, tcPunctuations: [','], balanceRange: 0 },
|
||||
});
|
||||
|
||||
expect(breakpoints.some((b) => b.pos === 4)).toBe(false);
|
||||
});
|
||||
|
||||
it('TC: 去重规则:同 pos 优先保留 priority 更高者;同 priority 按 kind 序', () => {
|
||||
// 构造:同一个 pos=2 同时来自 SPACE(priority=20) 和 BALANCE(priority=5),必须保留 SPACE
|
||||
const tokens = [t('我', 0), t(' ', 1), t('好', 2)];
|
||||
const { breakpoints } = generateBreakpoints({
|
||||
tokens: tokens as any,
|
||||
lang: 'TC',
|
||||
maxLines: 2,
|
||||
config: { tcMaxCandidateBreaks: 80, tcPunctuations: [], balanceRange: 2 },
|
||||
});
|
||||
|
||||
const b2 = breakpoints.find((b) => b.pos === 2);
|
||||
expect(b2?.kind).toBe('SPACE');
|
||||
expect(b2?.priority).toBe(20);
|
||||
});
|
||||
|
||||
it('TC: 裁剪上限生效且输出按 pos 升序(确定性)', () => {
|
||||
const tokens = Array.from({ length: 60 }).map((_, i) => t('哈', i));
|
||||
const res = generateBreakpoints({
|
||||
tokens: tokens as any,
|
||||
lang: 'TC',
|
||||
maxLines: 3,
|
||||
config: { tcMaxCandidateBreaks: 8, tcPunctuations: [], balanceRange: 6 },
|
||||
});
|
||||
|
||||
expect(res.breakpoints.length).toBe(8);
|
||||
// pos 升序
|
||||
for (let i = 1; i < res.breakpoints.length; i++) {
|
||||
expect(res.breakpoints[i]!.pos).toBeGreaterThanOrEqual(res.breakpoints[i - 1]!.pos);
|
||||
}
|
||||
// 确定性
|
||||
const res2 = generateBreakpoints({
|
||||
tokens: tokens as any,
|
||||
lang: 'TC',
|
||||
maxLines: 3,
|
||||
config: { tcMaxCandidateBreaks: 8, tcPunctuations: [], balanceRange: 6 },
|
||||
});
|
||||
expect(res).toEqual(res2);
|
||||
});
|
||||
|
||||
it('边界:N<=1 时不输出任何候选断点', () => {
|
||||
const { breakpoints } = generateBreakpoints({
|
||||
tokens: [t('我', 0)] as any,
|
||||
lang: 'TC',
|
||||
maxLines: 2,
|
||||
config: { tcMaxCandidateBreaks: 80, tcPunctuations: [','], balanceRange: 3 },
|
||||
});
|
||||
expect(breakpoints).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
22
client/src/features/textWrap/breakpoints/enCandidates.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { Breakpoint } from './types';
|
||||
import type { Token } from '../core/types';
|
||||
|
||||
/**
|
||||
* EN 候选断点生成(极简派)
|
||||
*
|
||||
* 口径:
|
||||
* - tokens 仅为 WORD(不包含 SPACE token)
|
||||
* - 候选断点只生成在 `pos ∈ [1, N-1]`
|
||||
* - kind 固定为 SPACE,priority 固定为 10
|
||||
*/
|
||||
export function generateEnCandidates(tokens: Token[]): Breakpoint[] {
|
||||
const n = tokens.length;
|
||||
if (n <= 1) return [];
|
||||
|
||||
const out: Breakpoint[] = [];
|
||||
for (let pos = 1; pos <= n - 1; pos++) {
|
||||
out.push({ pos, kind: 'SPACE', priority: 10 });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
89
client/src/features/textWrap/breakpoints/filterAndDedup.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import type { Breakpoint, BreakpointKind, BreakpointMeta, BreakpointConstraints } from './types';
|
||||
|
||||
const KIND_ORDER: Record<BreakpointKind, number> = {
|
||||
PUNCT: 0,
|
||||
SPACE: 1,
|
||||
BALANCE: 2,
|
||||
OTHER: 3,
|
||||
};
|
||||
|
||||
function betterBreakpoint(a: Breakpoint, b: Breakpoint): Breakpoint {
|
||||
if (a.priority !== b.priority) return a.priority > b.priority ? a : b;
|
||||
const ka = KIND_ORDER[a.kind] ?? 999;
|
||||
const kb = KIND_ORDER[b.kind] ?? 999;
|
||||
if (ka !== kb) return ka < kb ? a : b;
|
||||
// 完全相同优先级时,稳定选择 pos 更小者(但同 pos 才会进入该比较)
|
||||
return a;
|
||||
}
|
||||
|
||||
/**
|
||||
* forbiddenBreakRanges 过滤(闭区间口径)
|
||||
* - start <= pos && pos <= end 命中则剔除
|
||||
*/
|
||||
export function filterForbiddenBreakRanges(
|
||||
breakpoints: Breakpoint[],
|
||||
constraints: BreakpointConstraints | undefined
|
||||
): Breakpoint[] {
|
||||
const ranges = constraints?.forbiddenBreakRanges;
|
||||
if (!ranges || ranges.length === 0) return breakpoints;
|
||||
|
||||
return breakpoints.filter((bp) => {
|
||||
for (const r of ranges) {
|
||||
const s = r.start | 0;
|
||||
const e = r.end | 0;
|
||||
if (s <= bp.pos && bp.pos <= e) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 去重:同 pos 只保留一个 breakpoint(priority 更高者优先;同 priority 按 kind 固定序)
|
||||
*/
|
||||
export function dedupByPos(breakpoints: Breakpoint[]): Breakpoint[] {
|
||||
const map = new Map<number, Breakpoint>();
|
||||
for (const bp of breakpoints) {
|
||||
const prev = map.get(bp.pos);
|
||||
if (!prev) {
|
||||
map.set(bp.pos, bp);
|
||||
continue;
|
||||
}
|
||||
map.set(bp.pos, betterBreakpoint(prev, bp));
|
||||
}
|
||||
return Array.from(map.values());
|
||||
}
|
||||
|
||||
export function distanceToNearestIdeal(pos: number, ideals: number[] | undefined): number {
|
||||
if (!ideals || ideals.length === 0) return 1_000_000_000;
|
||||
let best = 1_000_000_000;
|
||||
for (const p of ideals) {
|
||||
const d = Math.abs(pos - p);
|
||||
if (d < best) best = d;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* TC 裁剪(确定性排序后截断)
|
||||
* - priority desc
|
||||
* - distToIdeal asc
|
||||
* - pos asc
|
||||
*/
|
||||
export function pruneTcCandidates(breakpoints: Breakpoint[], ideals: number[], tcMaxCandidateBreaks: number): Breakpoint[] {
|
||||
if (breakpoints.length <= tcMaxCandidateBreaks) return breakpoints;
|
||||
|
||||
const sorted = [...breakpoints].sort((a, b) => {
|
||||
if (a.priority !== b.priority) return b.priority - a.priority;
|
||||
const da = distanceToNearestIdeal(a.pos, ideals);
|
||||
const db = distanceToNearestIdeal(b.pos, ideals);
|
||||
if (da !== db) return da - db;
|
||||
return a.pos - b.pos;
|
||||
});
|
||||
|
||||
return sorted.slice(0, tcMaxCandidateBreaks);
|
||||
}
|
||||
|
||||
export function buildMeta(originalCount: number, finalCount: number): BreakpointMeta {
|
||||
return { originalCount, finalCount, pruned: finalCount < originalCount };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { Breakpoint, BreakpointMeta, GenerateBreakpointsInput } from './types';
|
||||
import { generateEnCandidates } from './enCandidates';
|
||||
import { generateTcCandidates } from './tcCandidates';
|
||||
import { buildMeta, dedupByPos, filterForbiddenBreakRanges, pruneTcCandidates } from './filterAndDedup';
|
||||
|
||||
/**
|
||||
* 生成候选断点(确定性)
|
||||
*
|
||||
* 统一口径:
|
||||
* - 只输出 `pos ∈ [1, N-1]`
|
||||
* - forbiddenBreakRanges 使用闭区间过滤:start <= pos && pos <= end
|
||||
* - 去重按 priority 优先;priority 相同按 kind 固定序:PUNCT > SPACE > BALANCE > OTHER
|
||||
* - TC 超过上限时按固定排序裁剪,然后最终按 pos 升序输出
|
||||
*/
|
||||
export function generateBreakpoints(input: GenerateBreakpointsInput): { breakpoints: Breakpoint[]; meta: BreakpointMeta } {
|
||||
const tokens = input.tokens ?? [];
|
||||
const n = tokens.length;
|
||||
const lang = input.lang;
|
||||
|
||||
if (n <= 1) {
|
||||
return { breakpoints: [], meta: buildMeta(0, 0) };
|
||||
}
|
||||
|
||||
let raw: Breakpoint[] = [];
|
||||
let ideals: number[] = [];
|
||||
|
||||
if (lang === 'EN') {
|
||||
raw = generateEnCandidates(tokens);
|
||||
} else {
|
||||
const out = generateTcCandidates(tokens, input.maxLines, {
|
||||
tcPunctuations: input.config.tcPunctuations,
|
||||
balanceRange: input.config.balanceRange,
|
||||
});
|
||||
raw = out.candidates;
|
||||
ideals = out.idealPositions;
|
||||
}
|
||||
|
||||
const originalCount = raw.length;
|
||||
|
||||
// 过滤 forbiddenBreakRanges(闭区间)
|
||||
let filtered = filterForbiddenBreakRanges(raw, input.constraints);
|
||||
|
||||
// 去重(同 pos 保留最佳)
|
||||
filtered = dedupByPos(filtered);
|
||||
|
||||
// TC 裁剪(规模上限)
|
||||
if (lang === 'TC') {
|
||||
const max = Math.max(0, input.config.tcMaxCandidateBreaks | 0);
|
||||
if (max > 0) {
|
||||
filtered = pruneTcCandidates(filtered, ideals, max);
|
||||
}
|
||||
}
|
||||
|
||||
// 最终按 pos 升序输出
|
||||
filtered.sort((a, b) => a.pos - b.pos);
|
||||
|
||||
return { breakpoints: filtered, meta: buildMeta(originalCount, filtered.length) };
|
||||
}
|
||||
|
||||
11
client/src/features/textWrap/breakpoints/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export type {
|
||||
Breakpoint,
|
||||
BreakpointConfig,
|
||||
BreakpointConstraints,
|
||||
BreakpointKind,
|
||||
BreakpointMeta,
|
||||
GenerateBreakpointsInput,
|
||||
} from './types';
|
||||
|
||||
export { generateBreakpoints } from './generateBreakpoints';
|
||||
|
||||
78
client/src/features/textWrap/breakpoints/tcCandidates.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { Breakpoint } from './types';
|
||||
import type { Token } from '../core/types';
|
||||
|
||||
type TcCandidateConfig = {
|
||||
tcPunctuations: string[];
|
||||
balanceRange: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* 计算 BALANCE 的 idealPos 列表(确定性简化版)。
|
||||
*
|
||||
* - N=tokens.length
|
||||
* - targetLines=min(maxLines,N)
|
||||
* - lineIndex in 1..targetLines-1:
|
||||
* idealPos=round(N*lineIndex/targetLines)
|
||||
*/
|
||||
export function computeTcIdealPositions(tokens: Token[], maxLines: number): number[] {
|
||||
const n = tokens.length;
|
||||
const targetLines = Math.max(1, Math.min(maxLines | 0, n));
|
||||
const ideals: number[] = [];
|
||||
|
||||
for (let lineIndex = 1; lineIndex <= targetLines - 1; lineIndex++) {
|
||||
const idealPos = Math.round((n * lineIndex) / targetLines);
|
||||
ideals.push(idealPos);
|
||||
}
|
||||
|
||||
// 去重并排序(稳定)
|
||||
ideals.sort((a, b) => a - b);
|
||||
return ideals.filter((v, idx) => idx === 0 || v !== ideals[idx - 1]);
|
||||
}
|
||||
|
||||
/**
|
||||
* TC 候选断点生成:
|
||||
* - 标点后:kind=PUNCT,priority=30
|
||||
* - 空格后:kind=SPACE,priority=20
|
||||
* - BALANCE:kind=BALANCE,priority=5(围绕 idealPos ± balanceRange)
|
||||
*
|
||||
* 注意:BALANCE 断点允许生成在短语 span 内,是否可用交给评分阶段强惩罚淘汰。
|
||||
*/
|
||||
export function generateTcCandidates(tokens: Token[], maxLines: number, config: TcCandidateConfig): { candidates: Breakpoint[]; idealPositions: number[] } {
|
||||
const n = tokens.length;
|
||||
if (n <= 1) return { candidates: [], idealPositions: [] };
|
||||
|
||||
const punctSet = new Set(config.tcPunctuations);
|
||||
const candidates: Breakpoint[] = [];
|
||||
|
||||
// PUNCT / SPACE(扫描 token)
|
||||
for (let i = 0; i < n; i++) {
|
||||
const t = tokens[i]?.text ?? '';
|
||||
const pos = i + 1;
|
||||
|
||||
// 只允许行内断点
|
||||
if (pos < 1 || pos > n - 1) continue;
|
||||
|
||||
if (punctSet.has(t)) {
|
||||
candidates.push({ pos, kind: 'PUNCT', priority: 30 });
|
||||
}
|
||||
|
||||
if (t === ' ') {
|
||||
candidates.push({ pos, kind: 'SPACE', priority: 20 });
|
||||
}
|
||||
}
|
||||
|
||||
// BALANCE
|
||||
const idealPositions = computeTcIdealPositions(tokens, maxLines);
|
||||
const range = Math.max(0, config.balanceRange | 0);
|
||||
if (range > 0 && idealPositions.length > 0) {
|
||||
for (const ideal of idealPositions) {
|
||||
for (let pos = ideal - range; pos <= ideal + range; pos++) {
|
||||
if (pos < 1 || pos > n - 1) continue;
|
||||
candidates.push({ pos, kind: 'BALANCE', priority: 5 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { candidates, idealPositions };
|
||||
}
|
||||
|
||||
37
client/src/features/textWrap/breakpoints/types.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { Token } from '../core/types';
|
||||
|
||||
export type BreakpointKind = 'PUNCT' | 'SPACE' | 'BALANCE' | 'OTHER';
|
||||
|
||||
export type Breakpoint = {
|
||||
/** 断点位置(token 边界):切分为 [0..pos) + [pos..N) */
|
||||
pos: number;
|
||||
kind: BreakpointKind;
|
||||
/** 候选优先级(用于去重/裁剪阶段),越大越优先 */
|
||||
priority: number;
|
||||
};
|
||||
|
||||
export type BreakpointMeta = {
|
||||
pruned: boolean;
|
||||
originalCount: number;
|
||||
finalCount: number;
|
||||
};
|
||||
|
||||
export type BreakpointConstraints = {
|
||||
protectedPhrases?: string[];
|
||||
forbiddenBreakRanges?: Array<{ start: number; end: number }>;
|
||||
};
|
||||
|
||||
export type BreakpointConfig = {
|
||||
tcMaxCandidateBreaks: number;
|
||||
tcPunctuations: string[];
|
||||
balanceRange: number;
|
||||
};
|
||||
|
||||
export type GenerateBreakpointsInput = {
|
||||
tokens: Token[];
|
||||
lang: 'TC' | 'EN';
|
||||
maxLines: number;
|
||||
constraints?: BreakpointConstraints;
|
||||
config: BreakpointConfig;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
DEFAULT_PUNCTUATION_STRIP_SET_EN,
|
||||
compareBreaksLexicographically,
|
||||
joinTokens,
|
||||
matchENKeyword,
|
||||
normalizeENKeyword,
|
||||
normalizeWhitespace,
|
||||
tokenizeEN,
|
||||
} from '../index';
|
||||
|
||||
describe('textWrap core-contract', () => {
|
||||
it('normalizeWhitespace(NORMALIZE): 折叠空白 + 去首尾', () => {
|
||||
const out = normalizeWhitespace(' a b \n c ', 'NORMALIZE');
|
||||
expect(out.normalizedText).toBe('a b c');
|
||||
expect(out.hadMultiWhitespace).toBe(true);
|
||||
});
|
||||
|
||||
it('tokenizeEN: 只按空白分词(极简派,不拆标点)', () => {
|
||||
const { normalizedText } = normalizeWhitespace('I am so tired.', 'NORMALIZE');
|
||||
const tokens = tokenizeEN(normalizedText);
|
||||
expect(tokens.map((t) => t.text)).toEqual(['I', 'am', 'so', 'tired.']);
|
||||
});
|
||||
|
||||
it('joinTokens: 默认用单空格重组', () => {
|
||||
const { normalizedText } = normalizeWhitespace('I am so tired', 'NORMALIZE');
|
||||
const tokens = tokenizeEN(normalizedText);
|
||||
expect(joinTokens(tokens, 0, 2)).toBe('I am');
|
||||
expect(joinTokens(tokens, 2, 4)).toBe('so tired');
|
||||
});
|
||||
|
||||
it('normalizeENKeyword/matchENKeyword: 全词等值匹配,不做 substring', () => {
|
||||
const config = { punctuationStripSetEN: DEFAULT_PUNCTUATION_STRIP_SET_EN };
|
||||
|
||||
expect(normalizeENKeyword('BUT,', config)).toBe('but');
|
||||
expect(matchENKeyword('but,', 'but', config)).toBe(true);
|
||||
expect(matchENKeyword('rebuttal', 'but', config)).toBe(false);
|
||||
});
|
||||
|
||||
it('compareBreaksLexicographically: 字典序 + 短数组优先', () => {
|
||||
expect(compareBreaksLexicographically([2], [3])).toBeLessThan(0);
|
||||
expect(compareBreaksLexicographically([2], [2, 5])).toBeLessThan(0);
|
||||
expect(compareBreaksLexicographically([2, 3], [2])).toBeGreaterThan(0);
|
||||
expect(compareBreaksLexicographically([2, 3], [2, 3])).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
25
client/src/features/textWrap/core/compare.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* breaks[] 字典序比较(确定性口径)
|
||||
*
|
||||
* 规则(必须):
|
||||
* - 从 index=0 起逐项比较:
|
||||
* - 首个不同元素更小者视为更小
|
||||
* - 若公共前缀完全相同:
|
||||
* - 更短的数组视为更小
|
||||
*/
|
||||
export function compareBreaksLexicographically(a: number[], b: number[]): number {
|
||||
const aa = Array.isArray(a) ? a : [];
|
||||
const bb = Array.isArray(b) ? b : [];
|
||||
|
||||
const n = Math.min(aa.length, bb.length);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const x = aa[i]!;
|
||||
const y = bb[i]!;
|
||||
if (x === y) continue;
|
||||
return x < y ? -1 : 1;
|
||||
}
|
||||
|
||||
if (aa.length === bb.length) return 0;
|
||||
return aa.length < bb.length ? -1 : 1;
|
||||
}
|
||||
|
||||
68
client/src/features/textWrap/core/enKeyword.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { CoreConfig } from './types';
|
||||
|
||||
/**
|
||||
* EN 常见标点(默认集合)
|
||||
*
|
||||
* 说明:此集合只用于“关键词命中判定”的两端 strip,不影响 tokenize(仍为极简派)。
|
||||
* 必须全端一致;后续若扩展,也必须通过 configVersion 管理。
|
||||
*/
|
||||
export const DEFAULT_PUNCTUATION_STRIP_SET_EN: string[] = [
|
||||
',',
|
||||
'.',
|
||||
'!',
|
||||
'?',
|
||||
':',
|
||||
';',
|
||||
'"',
|
||||
"'",
|
||||
'…',
|
||||
'—',
|
||||
'–',
|
||||
'(',
|
||||
')',
|
||||
'[',
|
||||
']',
|
||||
'{',
|
||||
'}',
|
||||
];
|
||||
|
||||
function buildStripSet(config?: Pick<CoreConfig, 'punctuationStripSetEN'>): Set<string> {
|
||||
const list = config?.punctuationStripSetEN?.length ? config.punctuationStripSetEN : DEFAULT_PUNCTUATION_STRIP_SET_EN;
|
||||
return new Set(list);
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化一个 token,用于 EN 关键词命中(全词等值匹配)。
|
||||
*
|
||||
* 口径(必须):
|
||||
* - lowercase
|
||||
* - strip 两端常见标点(可重复剥离,例如 `"\"but,\""`)
|
||||
* - 不允许 substring/contains
|
||||
*/
|
||||
export function normalizeENKeyword(tokenText: string, config?: Pick<CoreConfig, 'punctuationStripSetEN'>): string {
|
||||
const stripSet = buildStripSet(config);
|
||||
|
||||
let s = String(tokenText ?? '').toLowerCase();
|
||||
if (!s) return '';
|
||||
|
||||
// 仅剥离两端的“常见标点”,内部字符不处理
|
||||
let start = 0;
|
||||
let end = s.length;
|
||||
|
||||
while (start < end && stripSet.has(s[start]!)) start++;
|
||||
while (end > start && stripSet.has(s[end - 1]!)) end--;
|
||||
|
||||
// 如果剥离后还剩空白,再做一次 trim(避免 `"but "` 这种输入)
|
||||
return s.slice(start, end).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* EN 关键词命中:全词等值匹配(禁止 substring)。
|
||||
*/
|
||||
export function matchENKeyword(tokenText: string, keyword: string, config?: Pick<CoreConfig, 'punctuationStripSetEN'>): boolean {
|
||||
const a = normalizeENKeyword(tokenText, config);
|
||||
const b = normalizeENKeyword(keyword, config);
|
||||
if (!a || !b) return false;
|
||||
return a === b;
|
||||
}
|
||||
|
||||
8
client/src/features/textWrap/core/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export type { CoreConfig, Lang, NormalizeWhitespaceResult, Token, WhitespacePolicy } from './types';
|
||||
|
||||
export { normalizeWhitespace } from './normalizeWhitespace';
|
||||
export { tokenizeEN } from './tokenizeEN';
|
||||
export { joinTokens } from './joinTokens';
|
||||
export { DEFAULT_PUNCTUATION_STRIP_SET_EN, matchENKeyword, normalizeENKeyword } from './enKeyword';
|
||||
export { compareBreaksLexicographically } from './compare';
|
||||
|
||||
37
client/src/features/textWrap/core/joinTokens.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { Token } from './types';
|
||||
|
||||
/**
|
||||
* 从 token 区间 `[start..end)` 重组回文本(跨端口径)。
|
||||
*
|
||||
* - 默认:用单空格 join(适用于 whitespacePolicy=NORMALIZE)
|
||||
* - 若传入 rawSeparators:按原始分隔符拼接(适用于 whitespacePolicy=PRESERVE)
|
||||
*
|
||||
* 说明:
|
||||
* - `start/end` 为半开区间
|
||||
* - 空区间返回空字符串;上层需要用硬约束避免空行
|
||||
*/
|
||||
export function joinTokens(tokens: Token[], start: number, end: number, rawSeparators?: string[]): string {
|
||||
const s = Math.max(0, start | 0);
|
||||
const e = Math.max(0, end | 0);
|
||||
if (!Array.isArray(tokens) || tokens.length === 0) return '';
|
||||
if (s >= e) return '';
|
||||
|
||||
const slice = tokens.slice(s, e).map((t) => t.text);
|
||||
if (!rawSeparators) {
|
||||
return slice.join(' ');
|
||||
}
|
||||
|
||||
// rawSeparators[i] 表示 tokens[i] 与 tokens[i+1] 之间的分隔符
|
||||
// 这里只实现最小可用:若 separators 缺失则回退到单空格。
|
||||
let out = '';
|
||||
for (let idx = s; idx < e; idx++) {
|
||||
const t = tokens[idx];
|
||||
if (!t) continue;
|
||||
out += t.text;
|
||||
if (idx < e - 1) {
|
||||
out += rawSeparators[idx] ?? ' ';
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
28
client/src/features/textWrap/core/normalizeWhitespace.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { NormalizeWhitespaceResult, WhitespacePolicy } from './types';
|
||||
|
||||
/**
|
||||
* 空白归一化(跨端口径)
|
||||
*
|
||||
* - NORMALIZE:折叠连续空白为单空格,并去首尾空白
|
||||
* - PRESERVE:仅去首尾空白(内部空白保持原样)
|
||||
*
|
||||
* 注意:该函数会改变输入文本(至少会 trim),必须作为“算法契约”的一部分固定下来。
|
||||
*/
|
||||
export function normalizeWhitespace(text: string, policy: WhitespacePolicy = 'NORMALIZE'): NormalizeWhitespaceResult {
|
||||
const input = String(text ?? '');
|
||||
|
||||
if (policy === 'PRESERVE') {
|
||||
const trimmed = input.trim();
|
||||
// 只要发生 trim,或内部存在非单空格的分隔(如 \n/\t/多个空格),都算 hadMultiWhitespace
|
||||
const hadMultiWhitespace = trimmed !== input || /[\t\r\n]/.test(trimmed) || / +/.test(trimmed);
|
||||
return { normalizedText: trimmed, hadMultiWhitespace };
|
||||
}
|
||||
|
||||
// NORMALIZE:把任意连续空白折叠为 1 个空格,并去首尾空白
|
||||
// 说明:这里使用 \s+ 覆盖空格/换行/制表等;跨端需要保证采用同等语义的实现。
|
||||
const trimmed = input.trim();
|
||||
const collapsed = trimmed.replace(/\s+/g, ' ');
|
||||
const hadMultiWhitespace = collapsed !== input;
|
||||
return { normalizedText: collapsed, hadMultiWhitespace };
|
||||
}
|
||||
|
||||
38
client/src/features/textWrap/core/tokenizeEN.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { Token } from './types';
|
||||
|
||||
/**
|
||||
* EN tokenize(极简派)
|
||||
*
|
||||
* 口径:
|
||||
* - tokens 只包含 WORD,不生成 SPACE token
|
||||
* - 标点视为“词内字符”,不额外拆分(例如 "tired."、"Wait..."、"hello—world" 都是一个 token)
|
||||
* - 断点只允许发生在“词与词之间”(由上层生成 breakpoint 时遵守)
|
||||
*
|
||||
* start/end 索引以 normalizedText 为基准(建议先调用 normalizeWhitespace)。
|
||||
*/
|
||||
export function tokenizeEN(normalizedText: string): Token[] {
|
||||
const s = String(normalizedText ?? '');
|
||||
if (!s) return [];
|
||||
|
||||
const tokens: Token[] = [];
|
||||
|
||||
// 由于 NORMALIZE 模式下空白都折叠为单空格,这里按空格扫描即可。
|
||||
// 为了稳健性,也允许出现意外多空格(会跳过空段)。
|
||||
let i = 0;
|
||||
const n = s.length;
|
||||
|
||||
while (i < n) {
|
||||
// 跳过空格(或其他空白字符)
|
||||
while (i < n && /\s/.test(s[i]!)) i++;
|
||||
if (i >= n) break;
|
||||
|
||||
const start = i;
|
||||
while (i < n && !/\s/.test(s[i]!)) i++;
|
||||
const end = i;
|
||||
|
||||
tokens.push({ text: s.slice(start, end), start, end });
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
40
client/src/features/textWrap/core/types.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Text Wrap - core-contract
|
||||
*
|
||||
* 本文件定义“跨端一致的基础口径”所需的最小类型集合。
|
||||
* 注意:这里的索引(start/end)默认以 normalizedText(归一化后的文本)为基准。
|
||||
*/
|
||||
|
||||
export type Lang = 'TC' | 'EN';
|
||||
|
||||
export type WhitespacePolicy = 'NORMALIZE' | 'PRESERVE';
|
||||
|
||||
export type Token = {
|
||||
/** token 的原始文本(EN:词;TC:字符簇) */
|
||||
text: string;
|
||||
/** token 在 normalizedText 中的起始索引(包含) */
|
||||
start: number;
|
||||
/** token 在 normalizedText 中的结束索引(不包含) */
|
||||
end: number;
|
||||
};
|
||||
|
||||
export type NormalizeWhitespaceResult = {
|
||||
/**
|
||||
* 归一化后的文本。
|
||||
* - NORMALIZE:折叠连续空白为单空格,并去首尾空白
|
||||
* - PRESERVE:仅去首尾空白(内部空白保持原样)
|
||||
*/
|
||||
normalizedText: string;
|
||||
/** 是否发生过“空白折叠/trim/非单空格分隔”等(用于后续 meta 打点) */
|
||||
hadMultiWhitespace: boolean;
|
||||
};
|
||||
|
||||
export type CoreConfig = {
|
||||
whitespacePolicy: WhitespacePolicy;
|
||||
/**
|
||||
* EN 关键词命中:两端可剥离的常见标点集合(必须全端一致)。
|
||||
* 来源建议与默认值参考:`设计说明文档/文档换行算法.md`(v1.2.1)2.3.1A-1
|
||||
*/
|
||||
punctuationStripSetEN: string[];
|
||||
};
|
||||
|
||||
97
client/src/features/textWrap/golden/__tests__/golden.test.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { Token } from '../../core/types';
|
||||
import { normalizeWhitespace, tokenizeEN } from '../../core/index';
|
||||
import { segmentGraphemes } from '../../grapheme/index';
|
||||
import { wrapText } from '../../index';
|
||||
import { GOLDEN_CASES } from '../fixtures';
|
||||
|
||||
function tokenizeTC(normalizedText: string): Token[] {
|
||||
const { clusters } = segmentGraphemes(normalizedText, 'PREFERRED');
|
||||
const tokens: Token[] = [];
|
||||
let idx = 0;
|
||||
for (const c of clusters) {
|
||||
const start = idx;
|
||||
idx += c.length;
|
||||
tokens.push({ text: c, start, end: idx });
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
async function wrapTextHarness(args: {
|
||||
text: string;
|
||||
lang: 'TC' | 'EN';
|
||||
context: 'APP' | 'WIDGET';
|
||||
availableWidth: number;
|
||||
maxLines: number;
|
||||
}): Promise<{ ok: true; lines: string[]; wrappedText: string } | { ok: false; detail: any }> {
|
||||
const out = await wrapText({
|
||||
text: args.text,
|
||||
lang: args.lang,
|
||||
context: args.context,
|
||||
availableWidth: args.availableWidth,
|
||||
maxLines: args.maxLines,
|
||||
fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 },
|
||||
measureWidthImpl: async ({ text }) => text.length,
|
||||
contextProfile: `${args.context}|golden`,
|
||||
lineMode: 'AUTO',
|
||||
debug: true,
|
||||
});
|
||||
|
||||
// wrapText 统一返回成功形态;若进入 SYSTEM_DEFAULT 等兜底,meta 会标记
|
||||
return { ok: true, lines: out.lines, wrappedText: out.wrappedText };
|
||||
}
|
||||
|
||||
describe('textWrap golden-tests', () => {
|
||||
for (const c of GOLDEN_CASES) {
|
||||
it(`golden:${c.id}`, async () => {
|
||||
const res = await wrapTextHarness({
|
||||
text: c.text,
|
||||
lang: c.lang,
|
||||
context: c.context,
|
||||
availableWidth: c.availableWidth,
|
||||
maxLines: c.maxLines,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`golden case failed: ${c.id}\n${JSON.stringify(res.detail, null, 2)}`);
|
||||
}
|
||||
if (res.ok) {
|
||||
expect(res.lines).toEqual(c.expected.lines);
|
||||
expect(res.wrappedText).toBe(c.expected.wrappedText);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it('property: 确定性(同输入多次调用一致)', async () => {
|
||||
const args = { text: 'I am so tired', lang: 'EN' as const, context: 'APP' as const, availableWidth: 6, maxLines: 2 };
|
||||
const a = await wrapTextHarness(args);
|
||||
const b = await wrapTextHarness(args);
|
||||
const c = await wrapTextHarness(args);
|
||||
expect(a).toEqual(b);
|
||||
expect(b).toEqual(c);
|
||||
});
|
||||
|
||||
it('property: 近似单调性(availableWidth 变小不会让最大行宽变大)', async () => {
|
||||
const base = { text: 'I am so tired', lang: 'EN' as const, context: 'APP' as const, maxLines: 2 };
|
||||
const wide = await wrapTextHarness({ ...base, availableWidth: 20 });
|
||||
const narrow = await wrapTextHarness({ ...base, availableWidth: 7 });
|
||||
|
||||
if (!wide.ok || !narrow.ok) {
|
||||
throw new Error(`property failed: expected both ok\nwide=${JSON.stringify(wide)}\nnarrow=${JSON.stringify(narrow)}`);
|
||||
}
|
||||
|
||||
const maxW = (lines: string[]) => Math.max(...lines.map((s) => s.length));
|
||||
expect(maxW(narrow.lines)).toBeLessThanOrEqual(maxW(wide.lines));
|
||||
});
|
||||
|
||||
it('property: maxLines 不变差(maxLines 增加不应从可解变无解)', async () => {
|
||||
const base = { text: 'I am so tired', lang: 'EN' as const, context: 'APP' as const, availableWidth: 7 };
|
||||
const a = await wrapTextHarness({ ...base, maxLines: 2 });
|
||||
const b = await wrapTextHarness({ ...base, maxLines: 3 });
|
||||
|
||||
expect(a.ok).toBe(true);
|
||||
expect(b.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
56
client/src/features/textWrap/golden/fixtures.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
export type GoldenCase = {
|
||||
id: string;
|
||||
text: string;
|
||||
lang: 'TC' | 'EN';
|
||||
context: 'APP' | 'WIDGET';
|
||||
availableWidth: number;
|
||||
maxLines: number;
|
||||
expected: { lines: string[]; wrappedText: string };
|
||||
};
|
||||
|
||||
/**
|
||||
* Golden fixtures(首版最小可运行集)
|
||||
*
|
||||
* 说明:
|
||||
* - APP:测量 mock=string.length,因此 availableWidth 也是“字符数单位”
|
||||
* - WIDGET:widthMode=APPROX,因此 availableWidth 是“token 数单位”
|
||||
*/
|
||||
export const GOLDEN_CASES: GoldenCase[] = [
|
||||
{
|
||||
id: 'en_app_simple_2lines',
|
||||
text: 'I am so tired',
|
||||
lang: 'EN',
|
||||
context: 'APP',
|
||||
availableWidth: 7,
|
||||
maxLines: 2,
|
||||
expected: { lines: ['I am so', 'tired'], wrappedText: 'I am so\ntired' },
|
||||
},
|
||||
{
|
||||
id: 'en_app_punct_keyword',
|
||||
text: 'but, still ok',
|
||||
lang: 'EN',
|
||||
context: 'APP',
|
||||
availableWidth: 9,
|
||||
maxLines: 2,
|
||||
expected: { lines: ['but,', 'still ok'], wrappedText: 'but,\nstill ok' },
|
||||
},
|
||||
{
|
||||
id: 'tc_app_punct_2lines',
|
||||
text: '我好累,😮💨',
|
||||
lang: 'TC',
|
||||
context: 'APP',
|
||||
availableWidth: 6,
|
||||
maxLines: 2,
|
||||
expected: { lines: ['我好累,', '😮💨'], wrappedText: '我好累,\n😮💨' },
|
||||
},
|
||||
{
|
||||
id: 'tc_widget_approx_2lines',
|
||||
text: '我好累',
|
||||
lang: 'TC',
|
||||
context: 'WIDGET',
|
||||
availableWidth: 2,
|
||||
maxLines: 2,
|
||||
expected: { lines: ['我', '好累'], wrappedText: '我\n好累' },
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { segmentGraphemes } from '../index';
|
||||
|
||||
const REQUIRED_SINGLE_CLUSTER_CASES: Array<{ name: string; input: string }> = [
|
||||
{ name: 'ZWJ family', input: '👨👩👧👦' },
|
||||
{ name: 'flag', input: '🇸🇬' },
|
||||
{ name: 'skin tone', input: '👍🏽' },
|
||||
{ name: 'ZWJ + variation', input: '😮💨' },
|
||||
{ name: 'combining mark', input: 'e\u0301' }, // é(组合形式)
|
||||
];
|
||||
|
||||
describe('textWrap grapheme-segmentation', () => {
|
||||
it('基础性质:空字符串 -> []', () => {
|
||||
const out = segmentGraphemes('', 'PREFERRED');
|
||||
expect(out.clusters).toEqual([]);
|
||||
expect(out.clusters.join('')).toBe('');
|
||||
});
|
||||
|
||||
it('基础性质:可逆性(clusters.join("") === input)', () => {
|
||||
const input = '我好累😮💨';
|
||||
const out = segmentGraphemes(input, 'PREFERRED');
|
||||
expect(out.clusters.join('')).toBe(input);
|
||||
});
|
||||
|
||||
it('基础性质:确定性(同输入同输出)', () => {
|
||||
const input = '👨👩👧👦🇸🇬👍🏽😮💨e\u0301';
|
||||
const a = segmentGraphemes(input, 'PREFERRED');
|
||||
const b = segmentGraphemes(input, 'PREFERRED');
|
||||
expect(a).toEqual(b);
|
||||
});
|
||||
|
||||
it('必测样例:在 PREFERRED 下必须“不拆”为单个 cluster', () => {
|
||||
for (const c of REQUIRED_SINGLE_CLUSTER_CASES) {
|
||||
const out = segmentGraphemes(c.input, 'PREFERRED');
|
||||
expect(out.clusters.join('')).toBe(c.input);
|
||||
expect(out.clusters.length).toBe(1);
|
||||
expect(out.clusters[0]).toBe(c.input);
|
||||
}
|
||||
});
|
||||
|
||||
it('必测样例:在强制 FALLBACK 下必须“不拆”为单个 cluster', () => {
|
||||
for (const c of REQUIRED_SINGLE_CLUSTER_CASES) {
|
||||
const out = segmentGraphemes(c.input, 'FALLBACK');
|
||||
expect(out.meta.strategy).toBe('FALLBACK');
|
||||
expect(out.meta.hadFallback).toBe(true);
|
||||
expect(out.clusters.join('')).toBe(c.input);
|
||||
expect(out.clusters.length).toBe(1);
|
||||
expect(out.clusters[0]).toBe(c.input);
|
||||
}
|
||||
});
|
||||
|
||||
it('跨策略一致性:若 Intl.Segmenter 可用,则 PREFERRED 与 FALLBACK 输出应一致', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const hasIntl = typeof (globalThis as any)?.Intl?.Segmenter === 'function';
|
||||
if (!hasIntl) return;
|
||||
|
||||
for (const c of REQUIRED_SINGLE_CLUSTER_CASES) {
|
||||
const preferred = segmentGraphemes(c.input, 'PREFERRED');
|
||||
const fallback = segmentGraphemes(c.input, 'FALLBACK');
|
||||
expect(preferred.clusters).toEqual(fallback.clusters);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
9
client/src/features/textWrap/grapheme/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export type {
|
||||
GraphemeSegmentationMeta,
|
||||
GraphemeSegmentationMode,
|
||||
GraphemeSegmentationResult,
|
||||
GraphemeSegmentationStrategy,
|
||||
} from './types';
|
||||
|
||||
export { segmentGraphemes } from './segmentGraphemes';
|
||||
|
||||
42
client/src/features/textWrap/grapheme/segmentGraphemes.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { GraphemeSegmentationMode, GraphemeSegmentationResult } from './types';
|
||||
import { canUseIntlSegmenter, segmentWithIntlSegmenter } from './strategies/intlSegmenter';
|
||||
import { segmentWithFallback } from './strategies/fallback';
|
||||
|
||||
/**
|
||||
* 把字符串切分为 grapheme clusters(字符簇)。
|
||||
*
|
||||
* 契约(必须):
|
||||
* - clusters.join('') === text(不允许丢字符/改顺序)
|
||||
* - text=='' 时 clusters==[]
|
||||
* - 任何异常都必须降级到 fallback(保证可用性)
|
||||
*/
|
||||
export function segmentGraphemes(text: string, mode: GraphemeSegmentationMode = 'PREFERRED'): GraphemeSegmentationResult {
|
||||
const input = String(text ?? '');
|
||||
if (input === '') {
|
||||
return { clusters: [], meta: { strategy: 'FALLBACK', hadFallback: mode === 'FALLBACK' } };
|
||||
}
|
||||
|
||||
// 强制 fallback
|
||||
if (mode === 'FALLBACK') {
|
||||
const clusters = segmentWithFallback(input);
|
||||
return { clusters, meta: { strategy: 'FALLBACK', hadFallback: true } };
|
||||
}
|
||||
|
||||
// 优先 Intl.Segmenter
|
||||
if (canUseIntlSegmenter()) {
|
||||
try {
|
||||
const clusters = segmentWithIntlSegmenter(input);
|
||||
// 防御:必须可逆
|
||||
if (clusters.length > 0 && clusters.join('') === input) {
|
||||
return { clusters, meta: { strategy: 'INTL_SEGMENTER', hadFallback: false } };
|
||||
}
|
||||
// 若结果异常(空/丢字符),走 fallback
|
||||
} catch {
|
||||
// ignore -> fallback
|
||||
}
|
||||
}
|
||||
|
||||
const clusters = segmentWithFallback(input);
|
||||
return { clusters, meta: { strategy: 'FALLBACK', hadFallback: true } };
|
||||
}
|
||||
|
||||
81
client/src/features/textWrap/grapheme/strategies/fallback.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import GraphemeSplitter from 'grapheme-splitter';
|
||||
|
||||
/**
|
||||
* fallback 策略:使用 grapheme-splitter 分割 grapheme clusters。
|
||||
*
|
||||
* 选择原因:
|
||||
* - 避免手写不完整的 Unicode 规则导致“漏拆/误拆”
|
||||
* - 作为 Intl.Segmenter 不可用时的稳定兜底
|
||||
*
|
||||
* 注意:
|
||||
* - grapheme-splitter 对少数较新的 emoji ZWJ 序列支持可能不完整(例如 `😮💨`)。
|
||||
* - 为满足本项目“至少不拆 ZWJ sequence/VS16/肤色修饰符/组合字符”的最低要求,这里做一层确定性的后处理合并。
|
||||
*/
|
||||
export function segmentWithFallback(text: string): string[] {
|
||||
const splitter = new GraphemeSplitter();
|
||||
const raw = splitter.splitGraphemes(text);
|
||||
return mergeFallbackClusters(raw);
|
||||
}
|
||||
|
||||
const ZWJ = '\u200D';
|
||||
|
||||
function firstCodePoint(s: string): number | null {
|
||||
if (!s) return null;
|
||||
const cp = s.codePointAt(0);
|
||||
return typeof cp === 'number' ? cp : null;
|
||||
}
|
||||
|
||||
function isVariationSelector(cp: number): boolean {
|
||||
// VS15/VS16
|
||||
return cp === 0xfe0e || cp === 0xfe0f;
|
||||
}
|
||||
|
||||
function isSkinToneModifier(cp: number): boolean {
|
||||
return cp >= 0x1f3fb && cp <= 0x1f3ff;
|
||||
}
|
||||
|
||||
function isCombiningMark(cp: number): boolean {
|
||||
// 常见 combining marks 范围(覆盖 `e\u0301` 等)
|
||||
return (
|
||||
(cp >= 0x0300 && cp <= 0x036f) ||
|
||||
(cp >= 0x1ab0 && cp <= 0x1aff) ||
|
||||
(cp >= 0x1dc0 && cp <= 0x1dff) ||
|
||||
(cp >= 0x20d0 && cp <= 0x20ff) ||
|
||||
(cp >= 0xfe20 && cp <= 0xfe2f)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 对 fallback 输出做“最低可用”合并:
|
||||
* - 若上一个 cluster 以 ZWJ 结尾,则必须与下一个合并(ZWJ sequence)
|
||||
* - 若下一个 cluster 以 VS/肤色修饰符/combining mark 开头,也与上一个合并
|
||||
*
|
||||
* 目标:满足文档列出的“不拆”组合要求,且行为完全确定性。
|
||||
*/
|
||||
function mergeFallbackClusters(raw: string[]): string[] {
|
||||
if (!raw.length) return raw;
|
||||
|
||||
const out: string[] = [];
|
||||
let buf = raw[0] ?? '';
|
||||
|
||||
for (let i = 1; i < raw.length; i++) {
|
||||
const next = raw[i] ?? '';
|
||||
const nextCp = firstCodePoint(next);
|
||||
|
||||
const shouldMerge =
|
||||
buf.endsWith(ZWJ) ||
|
||||
(nextCp !== null && (isVariationSelector(nextCp) || isSkinToneModifier(nextCp) || isCombiningMark(nextCp)));
|
||||
|
||||
if (shouldMerge) {
|
||||
buf += next;
|
||||
continue;
|
||||
}
|
||||
|
||||
out.push(buf);
|
||||
buf = next;
|
||||
}
|
||||
|
||||
out.push(buf);
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Intl.Segmenter 策略(优先)
|
||||
*
|
||||
* 注意:不同 JS 引擎/版本对 Intl.Segmenter 的支持可能不同,调用方必须捕获异常并降级。
|
||||
*/
|
||||
|
||||
export function canUseIntlSegmenter(): boolean {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const Seg = (globalThis as any)?.Intl?.Segmenter;
|
||||
return typeof Seg === 'function';
|
||||
}
|
||||
|
||||
export function segmentWithIntlSegmenter(text: string): string[] {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const Segmenter = (globalThis as any).Intl.Segmenter as new (locales?: string | string[], options?: any) => any;
|
||||
|
||||
// 用 zh-Hant 仅用于选择合适的 locale;grapheme 分割应与语言本身关系不大,但保持固定输入更易对齐跨端。
|
||||
const seg = new Segmenter('zh-Hant', { granularity: 'grapheme' });
|
||||
const it = seg.segment(text);
|
||||
|
||||
const clusters: string[] = [];
|
||||
for (const part of it) {
|
||||
// part: { segment: string, index: number, input: string, isWordLike?: boolean }
|
||||
clusters.push(part.segment);
|
||||
}
|
||||
return clusters;
|
||||
}
|
||||
|
||||
21
client/src/features/textWrap/grapheme/types.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Text Wrap - grapheme-segmentation
|
||||
*
|
||||
* 本模块只负责把字符串切成“字符簇(grapheme clusters)”数组。
|
||||
* 后续 TC 换行断点只能发生在 clusters 边界。
|
||||
*/
|
||||
|
||||
export type GraphemeSegmentationMode = 'PREFERRED' | 'FALLBACK';
|
||||
|
||||
export type GraphemeSegmentationStrategy = 'INTL_SEGMENTER' | 'FALLBACK';
|
||||
|
||||
export type GraphemeSegmentationMeta = {
|
||||
strategy: GraphemeSegmentationStrategy;
|
||||
hadFallback: boolean;
|
||||
};
|
||||
|
||||
export type GraphemeSegmentationResult = {
|
||||
clusters: string[];
|
||||
meta: GraphemeSegmentationMeta;
|
||||
};
|
||||
|
||||
4
client/src/features/textWrap/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export type { WrapTextConstraints, WrapTextInput, WrapTextMeta, WrapTextOutput } from './types';
|
||||
|
||||
export { wrapText } from './wrapText';
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { MeasureWidthImpl } from '../types';
|
||||
import { MissingFontSpecError } from '../errors';
|
||||
import { buildFontSpecKey } from '../fontSpecKey';
|
||||
import { measureSliceWidthCached } from '../measureSliceWidthCached';
|
||||
import { measureWidthCached } from '../measureWidthCached';
|
||||
|
||||
describe('textWrap width-measurement', () => {
|
||||
it('buildFontSpecKey: 缺字段必须报错(简体中文)', () => {
|
||||
expect(() => buildFontSpecKey({ fontFamily: 'PingFangSC-Regular', fontWeight: '400' } as any)).toThrow(
|
||||
MissingFontSpecError
|
||||
);
|
||||
});
|
||||
|
||||
it('measureWidthCached: 未提供测量能力 -> approx + WIDTH_UNKNOWN', async () => {
|
||||
const res = await measureWidthCached({
|
||||
text: 'hello',
|
||||
context: 'WIDGET',
|
||||
contextProfile: 'WIDGET',
|
||||
fontSpec: null,
|
||||
measureWidthImpl: undefined,
|
||||
});
|
||||
expect(res.width).toBeNull();
|
||||
expect(res.meta).toEqual({ isApprox: true, reason: 'WIDTH_UNKNOWN' });
|
||||
});
|
||||
|
||||
it('measureWidthCached: 同 key 命中缓存(不会重复调用底层测量)', async () => {
|
||||
const impl: MeasureWidthImpl = vi.fn(async ({ text }) => text.length);
|
||||
|
||||
const a = await measureWidthCached({
|
||||
text: 'abc',
|
||||
context: 'APP',
|
||||
contextProfile: 'APP|ios',
|
||||
fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 },
|
||||
measureWidthImpl: impl,
|
||||
});
|
||||
const b = await measureWidthCached({
|
||||
text: 'abc',
|
||||
context: 'APP',
|
||||
contextProfile: 'APP|ios',
|
||||
fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 },
|
||||
measureWidthImpl: impl,
|
||||
});
|
||||
|
||||
expect(a.width).toBe(3);
|
||||
expect(b.width).toBe(3);
|
||||
expect(impl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('measureWidthCached: 测量抛错 -> approx + MEASURE_FAILED', async () => {
|
||||
const impl: MeasureWidthImpl = vi.fn(async () => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
|
||||
const res = await measureWidthCached({
|
||||
text: 'abc',
|
||||
context: 'APP',
|
||||
contextProfile: 'APP|ios|throw',
|
||||
fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 },
|
||||
measureWidthImpl: impl,
|
||||
});
|
||||
|
||||
expect(res.width).toBeNull();
|
||||
expect(res.meta).toEqual({ isApprox: true, reason: 'MEASURE_FAILED' });
|
||||
});
|
||||
|
||||
it('measureSliceWidthCached: 同 sliceKey 命中缓存', async () => {
|
||||
const impl: MeasureWidthImpl = vi.fn(async ({ text }) => text.length);
|
||||
|
||||
const tokens = [
|
||||
{ text: 'I', start: 0, end: 1 },
|
||||
{ text: 'am', start: 2, end: 4 },
|
||||
{ text: 'tired', start: 5, end: 10 },
|
||||
];
|
||||
|
||||
const a = await measureSliceWidthCached({
|
||||
tokens: tokens as any,
|
||||
start: 0,
|
||||
end: 2,
|
||||
context: 'APP',
|
||||
contextProfile: 'APP|ios',
|
||||
fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 },
|
||||
measureWidthImpl: impl,
|
||||
});
|
||||
const b = await measureSliceWidthCached({
|
||||
tokens: tokens as any,
|
||||
start: 0,
|
||||
end: 2,
|
||||
context: 'APP',
|
||||
contextProfile: 'APP|ios',
|
||||
fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 },
|
||||
measureWidthImpl: impl,
|
||||
});
|
||||
|
||||
expect(a.width).toBe('I am'.length);
|
||||
expect(b.width).toBe('I am'.length);
|
||||
expect(impl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
62
client/src/features/textWrap/measure/cache.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Text Wrap - width-measurement 缓存
|
||||
*
|
||||
* 要求:
|
||||
* - 模块级常驻缓存(跨调用复用)
|
||||
* - 有容量上限(避免内存无限增长)
|
||||
* - Key 必须确定性(由上层拼接传入)
|
||||
*
|
||||
* 说明:
|
||||
* - 这里实现一个最小 LRU:Map 维护插入顺序;get 时“刷新”为最新。
|
||||
* - 缓存 value 允许是 Promise,以便并发请求去重(同 key 只测一次)。
|
||||
*/
|
||||
|
||||
export class LruCache<V> {
|
||||
private readonly maxSize: number;
|
||||
private readonly map: Map<string, V>;
|
||||
|
||||
constructor(maxSize: number) {
|
||||
if (!Number.isFinite(maxSize) || maxSize <= 0) {
|
||||
throw new Error('LRU 缓存 maxSize 必须为正数。');
|
||||
}
|
||||
this.maxSize = Math.floor(maxSize);
|
||||
this.map = new Map();
|
||||
}
|
||||
|
||||
get(key: string): V | undefined {
|
||||
const v = this.map.get(key);
|
||||
if (v === undefined) return undefined;
|
||||
// 刷新为最新
|
||||
this.map.delete(key);
|
||||
this.map.set(key, v);
|
||||
return v;
|
||||
}
|
||||
|
||||
set(key: string, value: V): void {
|
||||
if (this.map.has(key)) {
|
||||
this.map.delete(key);
|
||||
}
|
||||
this.map.set(key, value);
|
||||
|
||||
if (this.map.size > this.maxSize) {
|
||||
// 淘汰最旧的
|
||||
const oldestKey = this.map.keys().next().value as string | undefined;
|
||||
if (oldestKey !== undefined) {
|
||||
this.map.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
delete(key: string): void {
|
||||
this.map.delete(key);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.map.clear();
|
||||
}
|
||||
|
||||
size(): number {
|
||||
return this.map.size;
|
||||
}
|
||||
}
|
||||
|
||||
19
client/src/features/textWrap/measure/errors.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Text Wrap - width-measurement 错误定义
|
||||
*
|
||||
* 约束:fontSpec 缺字段必须直接报错(简体中文),禁止隐式默认值。
|
||||
*/
|
||||
|
||||
export class MissingFontSpecError extends Error {
|
||||
readonly name = 'MissingFontSpecError';
|
||||
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export function buildMissingFontSpecMessage(missingFields: string[]): string {
|
||||
const fields = missingFields.join(', ');
|
||||
return `fontSpec 缺少必填字段:${fields}。请在调用 wrapText() 时补齐 fontSpec(fontFamily/fontWeight/fontSize),禁止在测量层使用隐式默认值。`;
|
||||
}
|
||||
|
||||
32
client/src/features/textWrap/measure/fontSpecKey.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { FontSpec } from './types';
|
||||
import { MissingFontSpecError, buildMissingFontSpecMessage } from './errors';
|
||||
|
||||
/**
|
||||
* 校验 fontSpec 必填字段。
|
||||
* 约束:缺失字段必须报错(简体中文),避免跨端漂移。
|
||||
*/
|
||||
export function assertFontSpecComplete(fontSpec: Partial<FontSpec> | undefined | null): asserts fontSpec is FontSpec {
|
||||
if (!fontSpec) {
|
||||
throw new MissingFontSpecError(buildMissingFontSpecMessage(['fontFamily', 'fontWeight', 'fontSize']));
|
||||
}
|
||||
|
||||
const missing: string[] = [];
|
||||
if (!fontSpec.fontFamily) missing.push('fontFamily');
|
||||
if (!fontSpec.fontWeight) missing.push('fontWeight');
|
||||
if (fontSpec.fontSize === undefined || fontSpec.fontSize === null) missing.push('fontSize');
|
||||
|
||||
if (missing.length) throw new MissingFontSpecError(buildMissingFontSpecMessage(missing));
|
||||
if (!Number.isFinite(fontSpec.fontSize)) {
|
||||
throw new MissingFontSpecError('fontSpec.fontSize 必须是有限数值(number)。请检查调用 wrapText() 时传入的 fontSize。');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 fontSpecKey(确定性)。
|
||||
* 规则:`fontFamily|fontWeight|fontSize`(顺序固定)
|
||||
*/
|
||||
export function buildFontSpecKey(fontSpec: Partial<FontSpec> | undefined | null): string {
|
||||
assertFontSpecComplete(fontSpec);
|
||||
return `${fontSpec.fontFamily}|${fontSpec.fontWeight}|${String(fontSpec.fontSize)}`;
|
||||
}
|
||||
|
||||