Compare commits
33 Commits
8c02c5b9a0
...
v1.0.21
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fcf85a081 | ||
|
|
62fcc4bfce | ||
|
|
eef5210c99 | ||
|
|
402cbf90eb | ||
| decc7f9564 | |||
| 173cee75d5 | |||
|
|
076bd5636f | ||
|
|
154f347ddb | ||
|
|
dec3ac82e1 | ||
|
|
e552e22de9 | ||
|
|
1fbc0aa3f8 | ||
|
|
b5532df161 | ||
| 5515726465 | |||
|
|
ee2d9f44ea | ||
|
|
ce018880f4 | ||
|
|
b4ec17fcac | ||
|
|
aa4e1e9947 | ||
| 4578d503e7 | |||
|
|
f03d36b5e9 | ||
| 66241e5231 | |||
|
|
e980bd4e4d | ||
|
|
0b8bbebf6a | ||
|
|
1e1e49ea57 | ||
|
|
8e71503169 | ||
|
|
2b67a571bb | ||
|
|
8f84f25616 | ||
|
|
4c03fce720 | ||
|
|
c1c2c6197d | ||
| d742b398ef | |||
|
|
d525c6b939 | ||
|
|
c37bb2e2a5 | ||
|
|
4c450db9b2 | ||
|
|
3f91e734fa |
@@ -1,5 +1,69 @@
|
|||||||
请开始完成编码
|
请开始完成编码
|
||||||
客户端请按照标准的RN架构目录写代码
|
客户端请按照标准的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 架构目录写代码
|
后端请按照标准的python FastAPI 架构目录写代码
|
||||||
现在多语言仅支持 EN / TC
|
现在多语言仅支持 EN / TC
|
||||||
整个task.md执行完毕后需要在对应的overview.md标记,并且说明变更的文件名
|
整个task.md执行完毕后需要在对应的overview.md标记,并且说明变更的文件名
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
当前有一个很大的 spec.md(大需求规范),需要按业务逻辑拆分成多个子模块规范。
|
当前有一个很大的 spec.md(大需求规范),需要按业务逻辑合理拆分成多个子模块规范。
|
||||||
|
|
||||||
请按以下规则拆分:
|
请按以下规则拆分:
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ jobs:
|
|||||||
# 健康检查路径(未配置则默认 /health;如果你没有 health 接口,可改为 /docs 或 /)
|
# 健康检查路径(未配置则默认 /health;如果你没有 health 接口,可改为 /docs 或 /)
|
||||||
HEALTHCHECK_PATH: ${{ vars.HEALTHCHECK_PATH }}
|
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
|
# 可选:远端 env 文件路径(例如 /opt/mindfulness-server/.env.prod),存在则 docker run --env-file
|
||||||
REMOTE_ENV_FILE: ${{ vars.REMOTE_ENV_FILE }}
|
REMOTE_ENV_FILE: ${{ vars.REMOTE_ENV_FILE }}
|
||||||
|
|
||||||
@@ -258,11 +261,12 @@ jobs:
|
|||||||
GREEN_PORT="${GREEN_PORT:-8002}"
|
GREEN_PORT="${GREEN_PORT:-8002}"
|
||||||
CONTAINER_PORT="${CONTAINER_PORT:-8000}"
|
CONTAINER_PORT="${CONTAINER_PORT:-8000}"
|
||||||
HEALTHCHECK_PATH="${HEALTHCHECK_PATH:-/health}"
|
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})"
|
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 -- \
|
ssh -p "${SSH_PORT}" -i ~/.ssh/id_rsa -o StrictHostKeyChecking=yes -o IdentitiesOnly=yes "${SSH_USER}@${SSH_HOST}" bash -s -- \
|
||||||
"${DOCKER_IMAGE}" "${DEPLOY_TAG}" "${NGINX_UPSTREAM_FILE}" "${NGINX_UPSTREAM_NAME}" "${BLUE_PORT}" "${GREEN_PORT}" "${CONTAINER_PORT}" "${HEALTHCHECK_PATH}" "${REMOTE_ENV_FILE:-}" <<'REMOTE'
|
"${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
|
set -euo pipefail
|
||||||
|
|
||||||
IMAGE="$1"
|
IMAGE="$1"
|
||||||
@@ -273,7 +277,8 @@ jobs:
|
|||||||
GREEN_PORT="$6"
|
GREEN_PORT="$6"
|
||||||
CONTAINER_PORT="$7"
|
CONTAINER_PORT="$7"
|
||||||
HEALTHCHECK_PATH="$8"
|
HEALTHCHECK_PATH="$8"
|
||||||
REMOTE_ENV_FILE="$9"
|
SCHEDULER_HEALTHCHECK_PATH="$9"
|
||||||
|
REMOTE_ENV_FILE="${10}"
|
||||||
|
|
||||||
APP_DIR="/opt/mindfulness-server"
|
APP_DIR="/opt/mindfulness-server"
|
||||||
ACTIVE_FILE="${APP_DIR}/active_color"
|
ACTIVE_FILE="${APP_DIR}/active_color"
|
||||||
@@ -307,9 +312,18 @@ jobs:
|
|||||||
# 拉取镜像
|
# 拉取镜像
|
||||||
${SUDO} docker pull "${IMAGE}:${TAG}"
|
${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 "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=()
|
ENV_FILE_ARGS=()
|
||||||
if [[ -n "${REMOTE_ENV_FILE}" && -f "${REMOTE_ENV_FILE}" ]]; then
|
if [[ -n "${REMOTE_ENV_FILE}" && -f "${REMOTE_ENV_FILE}" ]]; then
|
||||||
ENV_FILE_ARGS=(--env-file "${REMOTE_ENV_FILE}")
|
ENV_FILE_ARGS=(--env-file "${REMOTE_ENV_FILE}")
|
||||||
@@ -318,11 +332,29 @@ jobs:
|
|||||||
echo "提示:REMOTE_ENV_FILE 已配置但文件不存在:${REMOTE_ENV_FILE}(将忽略 env-file)"
|
echo "提示:REMOTE_ENV_FILE 已配置但文件不存在:${REMOTE_ENV_FILE}(将忽略 env-file)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# API(对外暴露端口,仅该容器参与蓝绿切流)
|
||||||
${SUDO} docker run -d \
|
${SUDO} docker run -d \
|
||||||
--name "mindfulness-server-${NEW_COLOR}" \
|
--name "${API_NAME}" \
|
||||||
--restart=always \
|
--restart=always \
|
||||||
-p "${NEW_PORT}:${CONTAINER_PORT}" \
|
-p "${NEW_PORT}:${CONTAINER_PORT}" \
|
||||||
"${ENV_FILE_ARGS[@]}" \
|
"${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}"
|
"${IMAGE}:${TAG}"
|
||||||
|
|
||||||
# 健康检查
|
# 健康检查
|
||||||
@@ -350,8 +382,37 @@ jobs:
|
|||||||
|
|
||||||
if [[ "$i" -eq 30 ]]; then
|
if [[ "$i" -eq 30 ]]; then
|
||||||
echo "健康检查失败:新版本未就绪,回滚并退出"
|
echo "健康检查失败:新版本未就绪,回滚并退出"
|
||||||
${SUDO} docker logs --tail 200 "mindfulness-server-${NEW_COLOR}" || true
|
${SUDO} docker logs --tail 200 "${API_NAME}" || true
|
||||||
${SUDO} docker rm -f "mindfulness-server-${NEW_COLOR}" || 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
|
exit 1
|
||||||
fi
|
fi
|
||||||
sleep 2
|
sleep 2
|
||||||
@@ -360,24 +421,24 @@ jobs:
|
|||||||
# 切换 Nginx upstream(在同一个 conf 文件中通过 backup 做主备切换)
|
# 切换 Nginx upstream(在同一个 conf 文件中通过 backup 做主备切换)
|
||||||
if [[ ! -f "${UPSTREAM_FILE}" ]]; then
|
if [[ ! -f "${UPSTREAM_FILE}" ]]; then
|
||||||
echo "未找到 Nginx upstream 配置文件:${UPSTREAM_FILE}"
|
echo "未找到 Nginx upstream 配置文件:${UPSTREAM_FILE}"
|
||||||
${SUDO} docker rm -f "mindfulness-server-${NEW_COLOR}" || true
|
${SUDO} docker rm -f "${API_NAME}" "${WORKER_NAME}" "${BEAT_NAME}" >/dev/null 2>&1 || true
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! ${SUDO} grep -qE "upstream[[:space:]]+${UPSTREAM_NAME}[[:space:]]*\\{" "${UPSTREAM_FILE}"; then
|
if ! ${SUDO} grep -qE "upstream[[:space:]]+${UPSTREAM_NAME}[[:space:]]*\\{" "${UPSTREAM_FILE}"; then
|
||||||
echo "在 ${UPSTREAM_FILE} 中未找到 upstream:${UPSTREAM_NAME}"
|
echo "在 ${UPSTREAM_FILE} 中未找到 upstream:${UPSTREAM_NAME}"
|
||||||
${SUDO} docker rm -f "mindfulness-server-${NEW_COLOR}" || true
|
${SUDO} docker rm -f "${API_NAME}" "${WORKER_NAME}" "${BEAT_NAME}" >/dev/null 2>&1 || true
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! ${SUDO} grep -qE "server[[:space:]]+127\\.0\\.0\\.1:${BLUE_PORT}" "${UPSTREAM_FILE}"; then
|
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)"
|
echo "在 ${UPSTREAM_FILE} 中未找到 server 127.0.0.1:${BLUE_PORT}(请先按参考配置写入 upstream)"
|
||||||
${SUDO} docker rm -f "mindfulness-server-${NEW_COLOR}" || true
|
${SUDO} docker rm -f "${API_NAME}" "${WORKER_NAME}" "${BEAT_NAME}" >/dev/null 2>&1 || true
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
if ! ${SUDO} grep -qE "server[[:space:]]+127\\.0\\.0\\.1:${GREEN_PORT}" "${UPSTREAM_FILE}"; then
|
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)"
|
echo "在 ${UPSTREAM_FILE} 中未找到 server 127.0.0.1:${GREEN_PORT}(请先按参考配置写入 upstream)"
|
||||||
${SUDO} docker rm -f "mindfulness-server-${NEW_COLOR}" || true
|
${SUDO} docker rm -f "${API_NAME}" "${WORKER_NAME}" "${BEAT_NAME}" >/dev/null 2>&1 || true
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -416,7 +477,7 @@ jobs:
|
|||||||
echo "Nginx 配置校验失败,回滚 upstream 配置并退出"
|
echo "Nginx 配置校验失败,回滚 upstream 配置并退出"
|
||||||
${SUDO} cp -f "${BACKUP_FILE}" "${UPSTREAM_FILE}" || true
|
${SUDO} cp -f "${BACKUP_FILE}" "${UPSTREAM_FILE}" || true
|
||||||
${SUDO} nginx -t && ${SUDO} nginx -s reload || true
|
${SUDO} nginx -t && ${SUDO} nginx -s reload || true
|
||||||
${SUDO} docker rm -f "mindfulness-server-${NEW_COLOR}" || true
|
${SUDO} docker rm -f "${API_NAME}" "${WORKER_NAME}" "${BEAT_NAME}" >/dev/null 2>&1 || true
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -424,6 +485,11 @@ jobs:
|
|||||||
echo "${NEW_COLOR}" | ${SUDO} tee "${ACTIVE_FILE}" >/dev/null
|
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
|
${SUDO} docker rm -f "mindfulness-server-${OLD_COLOR}" >/dev/null 2>&1 || true
|
||||||
|
|
||||||
echo "部署完成:${NEW_COLOR} 已上线"
|
echo "部署完成:${NEW_COLOR} 已上线"
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
EXPO_PUBLIC_API_BASE_URL=http://localhost:8000
|
EXPO_PUBLIC_API_BASE_URL=http://localhost:8000
|
||||||
EXPO_PUBLIC_ENV=dev
|
EXPO_PUBLIC_ENV=dev
|
||||||
EXPO_PUBLIC_DEFAULT_LANGUAGE=auto
|
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": {
|
"expo": {
|
||||||
"name": "Hey Mama",
|
"name": "Dear Mama",
|
||||||
"slug": "client",
|
"slug": "client",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
@@ -9,9 +9,9 @@
|
|||||||
"userInterfaceStyle": "automatic",
|
"userInterfaceStyle": "automatic",
|
||||||
"newArchEnabled": true,
|
"newArchEnabled": true,
|
||||||
"splash": {
|
"splash": {
|
||||||
"image": "./assets/images/splash-icon.png",
|
"image": "./assets/images/Screen_page.png",
|
||||||
"resizeMode": "contain",
|
"resizeMode": "contain",
|
||||||
"backgroundColor": "#ffffff"
|
"backgroundColor": "#EAD2BA"
|
||||||
},
|
},
|
||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": true,
|
"supportsTablet": true,
|
||||||
@@ -23,7 +23,8 @@
|
|||||||
"backgroundColor": "#ffffff"
|
"backgroundColor": "#ffffff"
|
||||||
},
|
},
|
||||||
"edgeToEdgeEnabled": true,
|
"edgeToEdgeEnabled": true,
|
||||||
"predictiveBackGestureEnabled": false
|
"predictiveBackGestureEnabled": false,
|
||||||
|
"package": "com.damer.mindfulness"
|
||||||
},
|
},
|
||||||
"web": {
|
"web": {
|
||||||
"bundler": "metro",
|
"bundler": "metro",
|
||||||
@@ -35,6 +36,13 @@
|
|||||||
],
|
],
|
||||||
"experiments": {
|
"experiments": {
|
||||||
"typedRoutes": true
|
"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 { Stack } from 'expo-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { ensureDailyWidgetRecoUpToDate, syncWidgetConfig, syncWidgetUserProfileFromStorage } from '@/src/modules/dailyWidgetReco';
|
||||||
|
|
||||||
export default function AppLayout() {
|
export default function AppLayout() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 小组件数据同步(仅 iOS 生效;内部会判断原生模块是否可用)
|
||||||
|
// 目的:避免“仅 Onboarding 写入一次”导致老用户小组件一直显示兜底文案
|
||||||
|
void (async () => {
|
||||||
|
await syncWidgetConfig();
|
||||||
|
await syncWidgetUserProfileFromStorage();
|
||||||
|
await ensureDailyWidgetRecoUpToDate({ reason: 'app_start' });
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack
|
<Stack
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
@@ -13,6 +26,8 @@ export default function AppLayout() {
|
|||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="home"
|
name="home"
|
||||||
options={{
|
options={{
|
||||||
|
// Home 页不使用系统 Header,避免 iOS 原生导航栏自带的“毛玻璃/液玻璃”材质
|
||||||
|
headerShown: false,
|
||||||
// 卡片页标题按设计留空(右上角为 icon 按钮)
|
// 卡片页标题按设计留空(右上角为 icon 按钮)
|
||||||
title: '',
|
title: '',
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
|
|||||||
@@ -1,7 +1,18 @@
|
|||||||
import { useEffect, useLayoutEffect, useMemo, useState, useCallback, useRef } from 'react';
|
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 { 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, {
|
import Animated, {
|
||||||
Easing,
|
Easing,
|
||||||
runOnJS,
|
runOnJS,
|
||||||
@@ -24,9 +35,13 @@ import {
|
|||||||
getRecoFeedHistory,
|
getRecoFeedHistory,
|
||||||
recordRecoFeedServed,
|
recordRecoFeedServed,
|
||||||
type ThemeMode,
|
type ThemeMode,
|
||||||
|
getSuixinThemeState,
|
||||||
|
setSuixinThemeState,
|
||||||
|
type SuixinThemeStateV1,
|
||||||
} from '@/src/storage/appStorage';
|
} from '@/src/storage/appStorage';
|
||||||
|
|
||||||
import { fetchRecoFeed } from '@/src/services/recoApi';
|
import { fetchRecoFeed } from '@/src/services/recoApi';
|
||||||
|
import { toBackendLocaleFromLanguageTag } from '@/src/i18n/locale';
|
||||||
|
|
||||||
import ProfileModal from '@/components/home/ProfileModal';
|
import ProfileModal from '@/components/home/ProfileModal';
|
||||||
import ThemeModal from '@/components/home/ThemeModal';
|
import ThemeModal from '@/components/home/ThemeModal';
|
||||||
@@ -36,6 +51,12 @@ import MyIcon from '@/assets/images/home/my.svg';
|
|||||||
import LikeFilledIcon from '@/assets/images/home/like_filled.svg';
|
import LikeFilledIcon from '@/assets/images/home/like_filled.svg';
|
||||||
import LikeIcon from '@/assets/images/icon/like_icon.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');
|
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
|
||||||
|
|
||||||
// 预定义风景图列表
|
// 预定义风景图列表
|
||||||
@@ -77,10 +98,11 @@ type FeedItem = { content_id: string; text: string };
|
|||||||
export default function HomeScreen() {
|
export default function HomeScreen() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const isEnglish = i18n.language?.startsWith('en');
|
const isEnglish = i18n.language?.startsWith('en');
|
||||||
const recoLang: 'en' | 'tc' = i18n.language?.toLowerCase().startsWith('zh') ? 'tc' : 'en';
|
const recoLang: 'en' | 'tc' = toBackendLocaleFromLanguageTag(i18n.language);
|
||||||
const navigation = useNavigation();
|
const insets = useSafeAreaInsets();
|
||||||
const [index, setIndex] = useState(0);
|
const [index, setIndex] = useState(0);
|
||||||
const [themeMode, setThemeModeState] = useState<ThemeMode>('scenery');
|
const [themeMode, setThemeModeState] = useState<ThemeMode>('scenery');
|
||||||
|
const [suixinBgColor, setSuixinBgColor] = useState<string>(NEUTRAL_THEME_COLORS[1]);
|
||||||
const [themeOpen, setThemeOpen] = useState(false);
|
const [themeOpen, setThemeOpen] = useState(false);
|
||||||
const [profileOpen, setProfileOpen] = useState(false);
|
const [profileOpen, setProfileOpen] = useState(false);
|
||||||
const [profileName, setProfileName] = useState<string | undefined>(undefined);
|
const [profileName, setProfileName] = useState<string | undefined>(undefined);
|
||||||
@@ -88,17 +110,75 @@ export default function HomeScreen() {
|
|||||||
const [likeFilled, setLikeFilled] = useState(false);
|
const [likeFilled, setLikeFilled] = useState(false);
|
||||||
const [feedItems, setFeedItems] = useState<FeedItem[]>([]);
|
const [feedItems, setFeedItems] = useState<FeedItem[]>([]);
|
||||||
const [isFetching, setIsFetching] = useState(false);
|
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);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
busyRef.current = busy;
|
||||||
|
}, [busy]);
|
||||||
|
useEffect(() => {
|
||||||
|
indexRef.current = index;
|
||||||
|
}, [index]);
|
||||||
|
|
||||||
// 解决语言切换时重复触发拉取/清空导致“文案不停跳动”的问题:
|
// 解决语言切换时重复触发拉取/清空导致“文案不停跳动”的问题:
|
||||||
// 用 ref 持有最新状态,避免 useCallback 依赖 feedItems/isFetching 造成函数 identity 变化 → effect 重复执行
|
// 用 ref 持有最新状态,避免 useCallback 依赖 feedItems/isFetching 造成函数 identity 变化 → effect 重复执行
|
||||||
const feedItemsRef = useRef<FeedItem[]>([]);
|
const feedItemsRef = useRef<FeedItem[]>([]);
|
||||||
const isFetchingRef = useRef(false);
|
const isFetchingRef = useRef(false);
|
||||||
|
const themeModeRef = useRef<ThemeMode>('scenery');
|
||||||
|
const suixinStateRef = useRef<SuixinThemeStateV1 | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
feedItemsRef.current = feedItems;
|
feedItemsRef.current = feedItems;
|
||||||
}, [feedItems]);
|
}, [feedItems]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
isFetchingRef.current = isFetching;
|
isFetchingRef.current = isFetching;
|
||||||
}, [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
|
// 动画相关 Shared Values
|
||||||
const translateY = useSharedValue(0);
|
const translateY = useSharedValue(0);
|
||||||
@@ -115,6 +195,9 @@ export default function HomeScreen() {
|
|||||||
text: t(item.textKey)
|
text: t(item.textKey)
|
||||||
}));
|
}));
|
||||||
}, [feedItems, t]);
|
}, [feedItems, t]);
|
||||||
|
useEffect(() => {
|
||||||
|
currentFeedRef.current = currentFeed;
|
||||||
|
}, [currentFeed]);
|
||||||
|
|
||||||
const item = useMemo(() => {
|
const item = useMemo(() => {
|
||||||
const data = currentFeed[index % currentFeed.length];
|
const data = currentFeed[index % currentFeed.length];
|
||||||
@@ -124,6 +207,133 @@ export default function HomeScreen() {
|
|||||||
};
|
};
|
||||||
}, [currentFeed, index]);
|
}, [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 () => {
|
const fetchNewFeed = useCallback(async () => {
|
||||||
if (isFetchingRef.current) return;
|
if (isFetchingRef.current) return;
|
||||||
@@ -179,6 +389,14 @@ export default function HomeScreen() {
|
|||||||
setThemeModeState(mode);
|
setThemeModeState(mode);
|
||||||
setProfileName(profile.name);
|
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) {
|
if (cache && cache.items.length > 0 && (cache.lang ?? 'en') === recoLang) {
|
||||||
setFeedItems(cache.items.map((x) => ({ content_id: String(x.content_id), text: x.text })));
|
setFeedItems(cache.items.map((x) => ({ content_id: String(x.content_id), text: x.text })));
|
||||||
@@ -188,20 +406,28 @@ export default function HomeScreen() {
|
|||||||
setIndex(0);
|
setIndex(0);
|
||||||
fetchNewFeed();
|
fetchNewFeed();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Widget:前台辅助刷新(尽力而为)
|
||||||
|
// - 写入 App Group 的 dailyReco 缓存
|
||||||
|
// - 生成 wrapped_text_by_family,供 Widget 直接渲染
|
||||||
|
ensureDailyWidgetRecoUpToDate({ reason: 'home_focus' }).catch(() => {});
|
||||||
})();
|
})();
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [fetchNewFeed, recoLang])
|
}, [fetchNewFeed, recoLang, ensureSuixinReady])
|
||||||
);
|
);
|
||||||
|
|
||||||
const backgroundColor = useMemo(() => {
|
const backgroundColor = useMemo(() => {
|
||||||
|
if (themeMode === 'suixin') {
|
||||||
|
return suixinBgColor;
|
||||||
|
}
|
||||||
if (themeMode === 'color') {
|
if (themeMode === 'color') {
|
||||||
const colorIndex = Math.floor(index / 10) % THEME_COLORS.length;
|
const colorIndex = Math.floor(index / 10) % THEME_COLORS.length;
|
||||||
return THEME_COLORS[colorIndex];
|
return THEME_COLORS[colorIndex];
|
||||||
}
|
}
|
||||||
return '#F4D6C2'; // 风景模式下的默认底色(图片加载前显示)
|
return '#F4D6C2'; // 风景模式下的默认底色(图片加载前显示)
|
||||||
}, [themeMode, index]);
|
}, [themeMode, suixinBgColor, index]);
|
||||||
|
|
||||||
// 计算当前应该显示的风景图索引(滑动 10 次切换一张)
|
// 计算当前应该显示的风景图索引(滑动 10 次切换一张)
|
||||||
const natureImageIndex = useMemo(() => {
|
const natureImageIndex = useMemo(() => {
|
||||||
@@ -210,32 +436,6 @@ export default function HomeScreen() {
|
|||||||
|
|
||||||
const currentNatureImage = NATURE_IMAGES[natureImageIndex];
|
const currentNatureImage = NATURE_IMAGES[natureImageIndex];
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
navigation.setOptions({
|
|
||||||
headerShadowVisible: false,
|
|
||||||
// 为了让风景/颜色两种主题下“文案的视觉居中位置”一致,统一使用透明 Header
|
|
||||||
// 颜色主题下 Header 透明也不会影响观感(背景就是纯色)
|
|
||||||
headerStyle: { backgroundColor: 'transparent' },
|
|
||||||
headerTransparent: true,
|
|
||||||
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(() => ({
|
const textAnimatedStyle = useAnimatedStyle(() => ({
|
||||||
transform: [{ translateY: translateY.value }],
|
transform: [{ translateY: translateY.value }],
|
||||||
opacity: opacity.value,
|
opacity: opacity.value,
|
||||||
@@ -245,23 +445,60 @@ export default function HomeScreen() {
|
|||||||
transform: [{ scale: likeScale.value }],
|
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(() => {
|
const triggerNextContent = useCallback(() => {
|
||||||
if (busy) return;
|
if (busyRef.current) return;
|
||||||
setBusy(true);
|
setBusySafe(true);
|
||||||
|
|
||||||
|
// 注意:不要在 Reanimated worklet 回调里读取 React ref(例如 indexRef/currentFeedRef),会导致值不更新或异常
|
||||||
|
const nextIndex = indexRef.current + 1;
|
||||||
|
|
||||||
// 1. 当前文案向上移动并消失
|
// 1. 当前文案向上移动并消失
|
||||||
translateY.value = withTiming(-40, { duration: 300, easing: Easing.out(Easing.quad) });
|
translateY.value = withTiming(-40, { duration: 300, easing: Easing.out(Easing.quad) });
|
||||||
opacity.value = withTiming(0, { duration: 300 }, (finished) => {
|
opacity.value = withTiming(0, { duration: 300 }, (finished) => {
|
||||||
if (finished) {
|
if (finished) {
|
||||||
// 2. 切换数据索引
|
// 2. 切换数据索引
|
||||||
runOnJS(setIndex)(index + 1);
|
runOnJS(applyIndexChange)(nextIndex);
|
||||||
runOnJS(setLikeFilled)(false);
|
runOnJS(advanceSuixinOnNextContent)();
|
||||||
|
|
||||||
// 检查是否需要拉取新文案(当接近当前列表末尾时,例如还剩 5 条)
|
// 检查是否需要拉取新文案(注意:不要把匿名函数塞进 runOnJS,可能导致原生崩溃)
|
||||||
if (index + 5 >= currentFeed.length && !isFetching) {
|
runOnJS(maybeFetchNewFeedIfNeeded)(nextIndex);
|
||||||
runOnJS(fetchNewFeed)();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 准备下一条文案:先瞬移到下方 40pt
|
// 3. 准备下一条文案:先瞬移到下方 40pt
|
||||||
translateY.value = 40;
|
translateY.value = 40;
|
||||||
@@ -270,20 +507,51 @@ export default function HomeScreen() {
|
|||||||
translateY.value = withTiming(0, { duration: 400, easing: Easing.out(Easing.back(1)) });
|
translateY.value = withTiming(0, { duration: 400, easing: Easing.out(Easing.back(1)) });
|
||||||
opacity.value = withTiming(1, { duration: 400 }, (finished) => {
|
opacity.value = withTiming(1, { duration: 400 }, (finished) => {
|
||||||
if (finished) {
|
if (finished) {
|
||||||
runOnJS(setBusy)(false);
|
runOnJS(setBusySafe)(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [busy, index, currentFeed.length, isFetching, fetchNewFeed, 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);
|
const lastTapRef = useRef<number>(0);
|
||||||
|
|
||||||
// 使用 Ref 解决 PanResponder 闭包陷阱,确保手势回调能拿到最新的 state 和 function
|
// 使用 Ref 解决 PanResponder 闭包陷阱,确保手势回调能拿到最新的 state 和 function
|
||||||
const handlersRef = useRef({ onPressLike, triggerNextContent });
|
const handlersRef = useRef({ onPressLike, triggerNextContent, triggerPrevContent });
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
handlersRef.current = { onPressLike, triggerNextContent };
|
handlersRef.current = { onPressLike, triggerNextContent, triggerPrevContent };
|
||||||
}, [onPressLike, triggerNextContent]);
|
}, [onPressLike, triggerNextContent, triggerPrevContent]);
|
||||||
|
|
||||||
// 使用系统自带的 PanResponder 代替第三方手势库
|
// 使用系统自带的 PanResponder 代替第三方手势库
|
||||||
const panResponder = useRef(
|
const panResponder = useRef(
|
||||||
@@ -308,16 +576,32 @@ export default function HomeScreen() {
|
|||||||
}
|
}
|
||||||
lastTapRef.current = now;
|
lastTapRef.current = now;
|
||||||
|
|
||||||
// 2. 上滑逻辑判定
|
// 2. 上滑/下滑逻辑判定
|
||||||
if (gestureState.dy < -50) { // 上滑超过 50pt
|
if (gestureState.dy < -50) { // 上滑超过 50pt
|
||||||
runOnJS(handlersRef.current.triggerNextContent)();
|
runOnJS(handlersRef.current.triggerNextContent)();
|
||||||
|
} else if (gestureState.dy > 50) { // 下滑超过 50pt
|
||||||
|
runOnJS(handlersRef.current.triggerPrevContent)();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
).current;
|
).current;
|
||||||
|
|
||||||
async function onPressLike() {
|
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);
|
setLikeFilled(true);
|
||||||
|
|
||||||
// 1. 获取当前日期
|
// 1. 获取当前日期
|
||||||
@@ -327,24 +611,33 @@ export default function HomeScreen() {
|
|||||||
// 2. 保存到收藏夹,包含当前背景信息
|
// 2. 保存到收藏夹,包含当前背景信息
|
||||||
const favItem = {
|
const favItem = {
|
||||||
favId: String(Date.now()), // 生成唯一 ID
|
favId: String(Date.now()), // 生成唯一 ID
|
||||||
id: item.id,
|
id: likedItemId,
|
||||||
text: item.text,
|
text: likedItemText,
|
||||||
date: dateStr,
|
date: dateStr,
|
||||||
themeMode: themeMode,
|
themeMode: themeMode,
|
||||||
background: themeMode === 'scenery' ? String(natureImageIndex) : backgroundColor,
|
background: themeMode === 'scenery' ? String(natureImageIndex) : backgroundColor,
|
||||||
};
|
};
|
||||||
console.log('Home: Triggering addFavorite', JSON.stringify(favItem));
|
console.log('Home: Triggering addFavorite', JSON.stringify(favItem));
|
||||||
await addFavorite(favItem);
|
try {
|
||||||
|
await addFavorite(favItem);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Home: addFavorite 失败', error);
|
||||||
|
}
|
||||||
|
|
||||||
// 3. 记录到后端 Reaction(喜欢)
|
// 3. 记录到后端 Reaction(喜欢)
|
||||||
console.log('Home: Triggering setReaction', item.id);
|
console.log('Home: Triggering setReaction', item.id);
|
||||||
await setReaction(item.id, 'like');
|
try {
|
||||||
|
await setReaction(item.id, 'like');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Home: setReaction 失败', error);
|
||||||
|
}
|
||||||
|
|
||||||
// 4. 爱心缩放动画
|
// 4. 爱心缩放动画
|
||||||
likeScale.value = withSequence(
|
likeScale.value = withSequence(
|
||||||
withTiming(0.8, { duration: 100 }),
|
withTiming(0.8, { duration: 100 }),
|
||||||
withTiming(1.2, { duration: 150 }),
|
withTiming(1.2, { duration: 150 }),
|
||||||
withTiming(1, { duration: 100 }, (finished) => {
|
withTiming(1, { duration: 100 }, (finished) => {
|
||||||
|
runOnJS(setLikeInFlight)(false);
|
||||||
if (finished) {
|
if (finished) {
|
||||||
console.log('Home: Like animation finished, triggering next content');
|
console.log('Home: Like animation finished, triggering next content');
|
||||||
runOnJS(triggerNextContent)();
|
runOnJS(triggerNextContent)();
|
||||||
@@ -357,6 +650,13 @@ export default function HomeScreen() {
|
|||||||
setThemeModeState(next);
|
setThemeModeState(next);
|
||||||
await setThemeMode(next);
|
await setThemeMode(next);
|
||||||
setThemeOpen(false);
|
setThemeOpen(false);
|
||||||
|
|
||||||
|
// 切换到随心:不主动重算(除非冷启动会话变化/状态不存在),仅确保可用
|
||||||
|
if (next === 'suixin') {
|
||||||
|
await ensureSuixinReady().catch(() => {
|
||||||
|
setSuixinBgColor(NEUTRAL_THEME_COLORS[1]);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -368,9 +668,32 @@ export default function HomeScreen() {
|
|||||||
resizeMode="cover"
|
resizeMode="cover"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Animated.View style={[styles.card, textAnimatedStyle, themeMode === 'scenery' && styles.sceneryCard]}>
|
|
||||||
|
{/* 自绘顶部按钮:不使用系统 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]}>
|
<Text style={[styles.text, isEnglish && styles.textEnglish, themeMode === 'scenery' && styles.sceneryText]}>
|
||||||
{item.text}
|
{wrappedText || item.text}
|
||||||
</Text>
|
</Text>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
@@ -380,16 +703,17 @@ export default function HomeScreen() {
|
|||||||
onPress={onPressLike}
|
onPress={onPressLike}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={t('home.like')}
|
accessibilityLabel={t('home.like')}
|
||||||
hitSlop={20}
|
// 稍微增大可点击区域,提升单手操作成功率
|
||||||
|
hitSlop={24}
|
||||||
style={styles.reactionInner}
|
style={styles.reactionInner}
|
||||||
>
|
>
|
||||||
{likeFilled ? (
|
{likeFilled ? (
|
||||||
<LikeFilledIcon width={35} height={36} style={{ color: '#EA6969' }} />
|
<LikeFilledIcon width={40} height={41} color="#EA6969" />
|
||||||
) : (
|
) : (
|
||||||
<LikeIcon
|
<LikeIcon
|
||||||
width={35}
|
width={40}
|
||||||
height={36}
|
height={41}
|
||||||
style={{ color: themeMode === 'scenery' ? '#FFFFFF' : '#5E2A28' }}
|
color={themeMode === 'scenery' ? '#FFFFFF' : '#5E2A28'}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@@ -418,7 +742,8 @@ function CircleIconButton({
|
|||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
hitSlop={10}
|
// 稍微增大可点击区域,提升易用性
|
||||||
|
hitSlop={14}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={accessibilityLabel}
|
accessibilityLabel={accessibilityLabel}
|
||||||
style={styles.circleBtn}
|
style={styles.circleBtn}
|
||||||
@@ -435,15 +760,17 @@ const styles = StyleSheet.create({
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
headerRight: {
|
topRight: {
|
||||||
|
position: 'absolute',
|
||||||
|
right: 20,
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
gap: 10,
|
gap: 10,
|
||||||
paddingRight: 10,
|
zIndex: 30,
|
||||||
},
|
},
|
||||||
circleBtn: {
|
circleBtn: {
|
||||||
width: 34,
|
width: 40,
|
||||||
height: 34,
|
height: 40,
|
||||||
borderRadius: 17,
|
borderRadius: 20,
|
||||||
backgroundColor: 'rgba(255,255,255,0.75)',
|
backgroundColor: 'rgba(255,255,255,0.75)',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ export default function OnboardingLayout() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Stack.Screen name="onboarding" options={{ title: t('onboarding.title') }} />
|
<Stack.Screen name="onboarding" options={{ title: t('onboarding.title') }} />
|
||||||
<Stack.Screen name="push-prompt" options={{ title: t('push.title') }} />
|
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import * as Notifications from 'expo-notifications';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
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 { OnboardingLayout } from '@/components/onboarding/OnboardingLayout';
|
||||||
import { NameInputStep } from '@/components/onboarding/NameInputStep';
|
import { NameInputStep } from '@/components/onboarding/NameInputStep';
|
||||||
import { SelectionStep } from '@/components/onboarding/SelectionStep';
|
import { SelectionStep } from '@/components/onboarding/SelectionStep';
|
||||||
import { ReminderStep } from '@/components/onboarding/ReminderStep';
|
import { ReminderStep } from '@/components/onboarding/ReminderStep';
|
||||||
import { buildUserProfileFromQuestionnaire, mapOnboardingSelectionsToQuestionnaireAnswers } from '@/src/features/userProfileScoring';
|
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 { fetchRecoFeed } from '@/src/services/recoApi';
|
||||||
|
import { ensurePushTokenRegisteredIfPermitted, setPushPreferences } from '@/src/services/pushApi';
|
||||||
import {
|
import {
|
||||||
recordRecoFeedServed,
|
recordRecoFeedServed,
|
||||||
setOnboardingCompleted,
|
setOnboardingCompleted,
|
||||||
@@ -15,6 +20,7 @@ import {
|
|||||||
setDailyReminderSettings,
|
setDailyReminderSettings,
|
||||||
setUserProfileScoring,
|
setUserProfileScoring,
|
||||||
setRecoFeedCache,
|
setRecoFeedCache,
|
||||||
|
setPushPromptState,
|
||||||
} from '@/src/storage/appStorage';
|
} from '@/src/storage/appStorage';
|
||||||
|
|
||||||
type Step =
|
type Step =
|
||||||
@@ -25,7 +31,7 @@ type Step =
|
|||||||
const STEPS: Step[] = [
|
const STEPS: Step[] = [
|
||||||
{ id: 'name', type: 'name' },
|
{ id: 'name', type: 'name' },
|
||||||
{ id: 'status', type: 'selection', optionIds: ['pregnant', 'has_kids', 'no_fill'] },
|
{ id: 'status', type: 'selection', optionIds: ['pregnant', 'has_kids', 'no_fill'] },
|
||||||
{ id: 'emotion', type: 'selection', optionIds: ['happy', 'calm', 'stressed', 'low'] },
|
{ id: 'emotion', type: 'selection', optionIds: ['happy', 'calm', 'okay', 'tired', 'stressed', 'low'] },
|
||||||
{ id: 'influence', type: 'selection', optionIds: ['family', 'work', 'relationship', 'friends', 'health'] },
|
{ id: 'influence', type: 'selection', optionIds: ['family', 'work', 'relationship', 'friends', 'health'] },
|
||||||
{ id: 'support', type: 'selection', optionIds: ['emotional', 'parenting', 'self_worth', 'anxiety', 'balance'] },
|
{ id: 'support', type: 'selection', optionIds: ['emotional', 'parenting', 'self_worth', 'anxiety', 'balance'] },
|
||||||
{ id: 'reminder', type: 'reminder' },
|
{ id: 'reminder', type: 'reminder' },
|
||||||
@@ -38,6 +44,7 @@ export default function OnboardingScreen() {
|
|||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [selections, setSelections] = useState<Record<string, string[]>>({});
|
const [selections, setSelections] = useState<Record<string, string[]>>({});
|
||||||
const [reminderTimes, setReminderTimes] = useState(3);
|
const [reminderTimes, setReminderTimes] = useState(3);
|
||||||
|
const [finishing, setFinishing] = useState(false);
|
||||||
|
|
||||||
const currentStep = STEPS[stepIndex];
|
const currentStep = STEPS[stepIndex];
|
||||||
const currentTitle = useMemo(() => t(`onboardingSurvey.steps.${currentStep.id}.title`), [t, currentStep.id]);
|
const currentTitle = useMemo(() => t(`onboardingSurvey.steps.${currentStep.id}.title`), [t, currentStep.id]);
|
||||||
@@ -50,9 +57,11 @@ export default function OnboardingScreen() {
|
|||||||
}, [t, currentStep]);
|
}, [t, currentStep]);
|
||||||
|
|
||||||
async function onFinish() {
|
async function onFinish() {
|
||||||
// 请求推送权限
|
if (finishing) return;
|
||||||
const { status } = await Notifications.requestPermissionsAsync();
|
setFinishing(true);
|
||||||
const pushEnabled = status === 'granted';
|
try {
|
||||||
|
// 用户选择每日次数 > 0:在此页直接触发系统通知权限(已移除单独的 push 引导页)。
|
||||||
|
const wantsPush = reminderTimes > 0;
|
||||||
|
|
||||||
// 将 Onboarding 选择映射为标准问卷枚举(允许跳过)
|
// 将 Onboarding 选择映射为标准问卷枚举(允许跳过)
|
||||||
const answers = mapOnboardingSelectionsToQuestionnaireAnswers(selections);
|
const answers = mapOnboardingSelectionsToQuestionnaireAnswers(selections);
|
||||||
@@ -61,9 +70,16 @@ export default function OnboardingScreen() {
|
|||||||
const scoringProfile = buildUserProfileFromQuestionnaire(answers);
|
const scoringProfile = buildUserProfileFromQuestionnaire(answers);
|
||||||
await setUserProfileScoring(scoringProfile);
|
await setUserProfileScoring(scoringProfile);
|
||||||
|
|
||||||
|
// 同步到 App Group:供 iOS Widget 拉取与展示
|
||||||
|
await syncWidgetConfig();
|
||||||
|
await syncWidgetUserProfileFromScoring(scoringProfile);
|
||||||
|
|
||||||
|
// 可选:前台辅助拉取一次“每日推荐”,提升小组件首次展示的成功率与一致性(失败不阻塞)
|
||||||
|
await ensureDailyWidgetRecoUpToDate({ reason: 'onboarding_finish', scoringProfile });
|
||||||
|
|
||||||
// Onboarding 结束后预拉取一次 Feed 文案(失败不阻塞进入首页)
|
// Onboarding 结束后预拉取一次 Feed 文案(失败不阻塞进入首页)
|
||||||
try {
|
try {
|
||||||
const lang = i18n.language?.toLowerCase().startsWith('zh') ? 'tc' : 'en';
|
const lang = toBackendLocaleFromLanguageTag(i18n.language);
|
||||||
const { items, meta } = await fetchRecoFeed({
|
const { items, meta } = await fetchRecoFeed({
|
||||||
k: 30,
|
k: 30,
|
||||||
user_profile: {
|
user_profile: {
|
||||||
@@ -96,13 +112,67 @@ export default function OnboardingScreen() {
|
|||||||
});
|
});
|
||||||
await setDailyReminderSettings({
|
await setDailyReminderSettings({
|
||||||
timesPerDay: reminderTimes,
|
timesPerDay: reminderTimes,
|
||||||
pushEnabled: pushEnabled
|
// 这里表示“用户意愿”,不代表系统权限一定已 granted
|
||||||
|
pushEnabled: wantsPush,
|
||||||
});
|
});
|
||||||
await setOnboardingCompleted(true);
|
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();
|
||||||
|
// iOS 可能出现 provisional(临时授权),也应视为“已授权”
|
||||||
|
if (status !== 'granted' && status !== ('provisional' as any)) {
|
||||||
|
await setPushPromptState('skipped');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// iOS 模拟器通常无法获取 Expo Push Token(系统限制),此时不要提示“失败”,而是明确告知需要真机测试。
|
||||||
|
if (Device.osName === 'iOS' && !Device.isDevice) {
|
||||||
|
Alert.alert('提示', '当前为 iOS 模拟器,无法获取推送 Token。请使用真机测试推送功能。');
|
||||||
|
await setPushPromptState('unknown');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) 上报 token 到后端(幂等;失败才认为“推送开启失败”)
|
||||||
|
await ensurePushTokenRegisteredIfPermitted();
|
||||||
|
|
||||||
|
// 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 = () => {
|
const onNext = () => {
|
||||||
|
if (finishing) return;
|
||||||
if (stepIndex < STEPS.length - 1) {
|
if (stepIndex < STEPS.length - 1) {
|
||||||
setStepIndex(stepIndex + 1);
|
setStepIndex(stepIndex + 1);
|
||||||
} else {
|
} else {
|
||||||
@@ -111,19 +181,24 @@ export default function OnboardingScreen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onBack = () => {
|
const onBack = () => {
|
||||||
|
if (finishing) return;
|
||||||
if (stepIndex > 0) {
|
if (stepIndex > 0) {
|
||||||
setStepIndex(stepIndex - 1);
|
setStepIndex(stepIndex - 1);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSkip = async () => {
|
/** 只跳過當前這一步(不填/不選當前題,進入下一步) */
|
||||||
// 跳过整个 Onboarding:仍生成一个“全跳过”的最小画像,保证下游可用
|
const handleSkipCurrentStep = () => {
|
||||||
const scoringProfile = buildUserProfileFromQuestionnaire({});
|
if (finishing) return;
|
||||||
await setUserProfileScoring(scoringProfile);
|
if (currentStep.type === 'name') {
|
||||||
|
onNext();
|
||||||
// 标记已完成,避免下次启动再次进入 Onboarding
|
} else if (currentStep.type === 'selection') {
|
||||||
await setOnboardingCompleted(true);
|
setSelections((prev) => ({ ...prev, [currentStep.id]: [] }));
|
||||||
router.replace('/(app)/home');
|
onNext();
|
||||||
|
} else if (currentStep.type === 'reminder') {
|
||||||
|
setReminderTimes(0);
|
||||||
|
onFinish();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 题目为多选:点击切换选中状态
|
// 题目为多选:点击切换选中状态
|
||||||
@@ -138,6 +213,7 @@ export default function OnboardingScreen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSkipStep = () => {
|
const handleSkipStep = () => {
|
||||||
|
if (finishing) return;
|
||||||
setSelections((prev) => ({ ...prev, [currentStep.id]: [] }));
|
setSelections((prev) => ({ ...prev, [currentStep.id]: [] }));
|
||||||
onNext();
|
onNext();
|
||||||
};
|
};
|
||||||
@@ -147,9 +223,10 @@ export default function OnboardingScreen() {
|
|||||||
title={currentTitle}
|
title={currentTitle}
|
||||||
currentStep={stepIndex}
|
currentStep={stepIndex}
|
||||||
totalSteps={STEPS.length - 1}
|
totalSteps={STEPS.length - 1}
|
||||||
onSkip={onSkip}
|
onSkip={handleSkipCurrentStep}
|
||||||
onBack={onBack}
|
onBack={onBack}
|
||||||
showBackButton={stepIndex > 0}
|
showBackButton={stepIndex > 0}
|
||||||
|
userName={name}
|
||||||
>
|
>
|
||||||
{currentStep.type === 'name' && (
|
{currentStep.type === 'name' && (
|
||||||
<NameInputStep
|
<NameInputStep
|
||||||
@@ -170,9 +247,15 @@ export default function OnboardingScreen() {
|
|||||||
|
|
||||||
{currentStep.type === 'reminder' && (
|
{currentStep.type === 'reminder' && (
|
||||||
<ReminderStep
|
<ReminderStep
|
||||||
value={reminderTimes}
|
value={Math.max(1, reminderTimes)}
|
||||||
onChange={setReminderTimes}
|
onChange={setReminderTimes}
|
||||||
onFinish={onFinish}
|
onFinish={onFinish}
|
||||||
|
loading={finishing}
|
||||||
|
onSkip={() => {
|
||||||
|
// 跳过每日提醒:视为 0 次(关闭)
|
||||||
|
setReminderTimes(0);
|
||||||
|
onFinish();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</OnboardingLayout>
|
</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 { View, Text, StyleSheet, TouchableOpacity, Dimensions, Platform, Alert, Image } from 'react-native';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import * as WebBrowser from 'expo-web-browser';
|
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 { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { setConsentAccepted, getConsentAccepted } from '../../src/storage/appStorage';
|
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 组件
|
// 导入 SVG 组件
|
||||||
import FlowersBg from '../../assets/images/index/flowers_endbg.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');
|
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() {
|
export default function SplashScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const [showConsent, setShowConsent] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
checkConsent();
|
checkConsent();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
mountedRef.current = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const checkConsent = async () => {
|
const checkConsent = async () => {
|
||||||
const accepted = await getConsentAccepted();
|
const accepted = await getConsentAccepted();
|
||||||
setShowConsent(!accepted);
|
setShowConsent(!accepted);
|
||||||
if (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 bgDecorationTop = 363;
|
||||||
const bgDecorationHeight = height * 0.6;
|
const bgDecorationHeight = height * 0.6;
|
||||||
const contentTop = bgDecorationTop + (bgDecorationHeight * 0.25);
|
const contentTop = bgDecorationTop + (bgDecorationHeight * 0.25);
|
||||||
@@ -64,31 +147,58 @@ export default function SplashScreen() {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 文案内容 */}
|
{/* 文案内容:主標題兩行 + 可選二級標題(字號更小、顏色更淺);繁中為元件內常數,其餘用 i18n */}
|
||||||
<View style={[styles.contentContainer, { position: 'absolute', top: contentTop }]}>
|
<View style={[styles.contentContainer, { position: 'absolute', top: contentTop }]}>
|
||||||
<Text style={styles.titleText}>
|
<Text style={styles.titleText}>
|
||||||
You Are Perfect.{"\n"}
|
{title}
|
||||||
Everything{"\n"}
|
{'\n'}
|
||||||
Will Be Better.
|
{subtitle}
|
||||||
</Text>
|
</Text>
|
||||||
|
{subtitleSecondary ? (
|
||||||
|
<Text style={styles.consentSubtitleSecondary}>{subtitleSecondary}</Text>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<SafeAreaView style={styles.bottomContainer} edges={['bottom']}>
|
<SafeAreaView style={styles.bottomContainer} edges={['bottom']}>
|
||||||
{showConsent && (
|
{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} />
|
<WelcomeBtn width={87} height={57} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
<View style={styles.linksContainer}>
|
<Text style={styles.noticeText}>
|
||||||
<TouchableOpacity onPress={() => openLink('https://example.com/privacy')}>
|
<Trans
|
||||||
<Text style={styles.linkText}>{t('consent.privacy')}</Text>
|
i18nKey="consent.noticeRich"
|
||||||
</TouchableOpacity>
|
values={{
|
||||||
<View style={styles.divider} />
|
privacyLabel: t('consent.privacy'),
|
||||||
<TouchableOpacity onPress={() => openLink('https://example.com/terms')}>
|
termsLabel: t('consent.terms'),
|
||||||
<Text style={styles.linkText}>{t('consent.terms')}</Text>
|
privacySuffix: !links.privacy && linksLoading ? t('consent.linkLoadingSuffix') : '',
|
||||||
</TouchableOpacity>
|
termsSuffix: !links.terms && linksLoading ? t('consent.linkLoadingSuffix') : '',
|
||||||
</View>
|
}}
|
||||||
|
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>
|
</SafeAreaView>
|
||||||
@@ -127,6 +237,14 @@ const styles = StyleSheet.create({
|
|||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
fontFamily: Platform.OS === 'ios' ? 'STIX Two Text' : 'serif',
|
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: {
|
bottomContainer: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: 60,
|
bottom: 60,
|
||||||
@@ -137,19 +255,22 @@ const styles = StyleSheet.create({
|
|||||||
buttonWrapper: {
|
buttonWrapper: {
|
||||||
marginBottom: 40,
|
marginBottom: 40,
|
||||||
},
|
},
|
||||||
linksContainer: {
|
noticeText: {
|
||||||
flexDirection: 'row',
|
marginTop: 10,
|
||||||
alignItems: 'center',
|
paddingHorizontal: 28,
|
||||||
},
|
|
||||||
linkText: {
|
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: 'rgba(119, 47, 0, 0.5)', // 使用半透明的文字颜色
|
lineHeight: 16,
|
||||||
textDecorationLine: 'underline',
|
textAlign: 'center',
|
||||||
|
color: 'rgba(119, 47, 0, 0.45)',
|
||||||
},
|
},
|
||||||
divider: {
|
noticeLinkText: {
|
||||||
width: 1,
|
fontSize: 12,
|
||||||
height: 12,
|
// 颜色区分:协议链接更醒目
|
||||||
backgroundColor: 'rgba(119, 47, 0, 0.2)',
|
color: 'rgba(119, 47, 0, 0.75)',
|
||||||
marginHorizontal: 15,
|
textDecorationLine: 'underline',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
noticeLinkTextDisabled: {
|
||||||
|
opacity: 0.55,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,16 +4,23 @@ import { useFonts } from 'expo-font';
|
|||||||
import { Stack } from 'expo-router';
|
import { Stack } from 'expo-router';
|
||||||
import * as SplashScreen from 'expo-splash-screen';
|
import * as SplashScreen from 'expo-splash-screen';
|
||||||
import * as Notifications from 'expo-notifications';
|
import * as Notifications from 'expo-notifications';
|
||||||
import { useEffect, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import 'react-native-reanimated';
|
import 'react-native-reanimated';
|
||||||
|
import { Animated, AppState, Image, StyleSheet, View } from 'react-native';
|
||||||
|
|
||||||
import { useColorScheme } from '@/components/useColorScheme';
|
import { useColorScheme } from '@/components/useColorScheme';
|
||||||
import { initI18n } from '@/src/i18n';
|
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({
|
Notifications.setNotificationHandler({
|
||||||
handleNotification: async () => ({
|
handleNotification: async () => ({
|
||||||
shouldShowAlert: true,
|
shouldShowAlert: true,
|
||||||
|
// 新版 expo-notifications 类型要求显式返回 banner/list 行为
|
||||||
|
shouldShowBanner: true,
|
||||||
|
shouldShowList: true,
|
||||||
shouldPlaySound: false,
|
shouldPlaySound: false,
|
||||||
shouldSetBadge: false,
|
shouldSetBadge: false,
|
||||||
}),
|
}),
|
||||||
@@ -26,7 +33,8 @@ export {
|
|||||||
|
|
||||||
export const unstable_settings = {
|
export const unstable_settings = {
|
||||||
// Ensure that reloading on `/modal` keeps a back button present.
|
// 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.
|
// Prevent the splash screen from auto-hiding before asset loading is complete.
|
||||||
@@ -38,6 +46,10 @@ export default function RootLayout() {
|
|||||||
...FontAwesome.font,
|
...FontAwesome.font,
|
||||||
});
|
});
|
||||||
const [i18nReady, setI18nReady] = useState(false);
|
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.
|
// Expo Router uses Error Boundaries to catch errors in the navigation tree.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -54,25 +66,111 @@ export default function RootLayout() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 等字体与 i18n 都准备好后再隐藏启动页,避免文案闪烁
|
// 尽早生成 client_user_id,便于后续任意时刻与后端建立关联(Push Token/偏好等)
|
||||||
if (loaded && i18nReady) {
|
getOrCreateClientUserId()
|
||||||
SplashScreen.hideAsync();
|
.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(() => {
|
||||||
|
// 兜底:当用户在系统弹窗/系统设置里变更权限后,App 回到前台时再同步一次 token
|
||||||
|
const sub = AppState.addEventListener('change', (state) => {
|
||||||
|
if (state !== 'active') return;
|
||||||
|
ensurePushTokenRegisteredIfPermitted().catch(() => {
|
||||||
|
// ignore:不阻塞
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return () => sub.remove();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 字体与 i18n 都准备好后,允许渲染 App(原生 splash 的隐藏交给 onLayout,避免“硬切/闪白”)
|
||||||
|
if (loaded && i18nReady) setAppReady(true);
|
||||||
}, [loaded, i18nReady]);
|
}, [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) {
|
if (!loaded || !i18nReady) {
|
||||||
return null;
|
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() {
|
function RootLayoutNav() {
|
||||||
const colorScheme = useColorScheme();
|
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 (
|
return (
|
||||||
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
||||||
<Stack screenOptions={{ headerShown: false }}>
|
<Stack screenOptions={{ headerShown: false }}>
|
||||||
|
{/* 协议页分组(首次启动优先进入) */}
|
||||||
|
<Stack.Screen name="(splash)" />
|
||||||
|
|
||||||
{/* 启动分发页:根据 onboarding 状态跳转 */}
|
{/* 启动分发页:根据 onboarding 状态跳转 */}
|
||||||
<Stack.Screen name="index" />
|
<Stack.Screen name="index" />
|
||||||
|
|
||||||
@@ -89,3 +187,21 @@ function RootLayoutNav() {
|
|||||||
</ThemeProvider>
|
</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/images/Screen_page.png
Normal file
|
After Width: | Height: | Size: 152 KiB |
@@ -39,13 +39,17 @@ export default function DailyReminderModal({ visible, onClose }: Props) {
|
|||||||
}, [visible]);
|
}, [visible]);
|
||||||
|
|
||||||
function clamp(next: number) {
|
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() {
|
async function onOk() {
|
||||||
if (loading) return;
|
if (loading) return;
|
||||||
setLoading(true);
|
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);
|
await setDailyReminderSettings(next);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
onClose();
|
onClose();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Alert, FlatList, Image, Pressable, StyleSheet, Text, View, Platform, Di
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import { Switch } from 'react-native';
|
import { Switch } from 'react-native';
|
||||||
|
import * as WebBrowser from 'expo-web-browser';
|
||||||
import Animated, {
|
import Animated, {
|
||||||
Easing,
|
Easing,
|
||||||
FadeIn,
|
FadeIn,
|
||||||
@@ -39,6 +40,8 @@ import SelectedIcon from '@/assets/images/icon/selected_icon.svg';
|
|||||||
import QuestionIcon from '@/assets/images/home/Profile/widget/question_icon.svg';
|
import QuestionIcon from '@/assets/images/home/Profile/widget/question_icon.svg';
|
||||||
import * as Notifications from 'expo-notifications';
|
import * as Notifications from 'expo-notifications';
|
||||||
import { changeLanguage } from '@/src/i18n';
|
import { changeLanguage } from '@/src/i18n';
|
||||||
|
import { fetchLegalLinks } from '@/src/services/legalApi';
|
||||||
|
import { ensurePushTokenRegisteredIfPermitted, setPushPreferences } from '@/src/services/pushApi';
|
||||||
|
|
||||||
const { width } = Dimensions.get('window');
|
const { width } = Dimensions.get('window');
|
||||||
|
|
||||||
@@ -80,6 +83,7 @@ export default function ProfileModal({ visible, name: propName, onClose }: Props
|
|||||||
const [page, setPage] = useState<Page>('root');
|
const [page, setPage] = useState<Page>('root');
|
||||||
const [navDirection, setNavDirection] = useState<NavDirection>('forward');
|
const [navDirection, setNavDirection] = useState<NavDirection>('forward');
|
||||||
const [currentName, setCurrentName] = useState(propName);
|
const [currentName, setCurrentName] = useState(propName);
|
||||||
|
const [legalLinks, setLegalLinks] = useState<{ privacy?: string; terms?: string }>({});
|
||||||
const isRoot = page === 'root';
|
const isRoot = page === 'root';
|
||||||
|
|
||||||
// 当弹窗打开时,尝试从存储中获取最新的昵称,确保与 onboarding 同步
|
// 当弹窗打开时,尝试从存储中获取最新的昵称,确保与 onboarding 同步
|
||||||
@@ -90,6 +94,16 @@ export default function ProfileModal({ visible, name: propName, onClose }: Props
|
|||||||
setCurrentName(profile.name);
|
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 {
|
} else {
|
||||||
setNavDirection('back');
|
setNavDirection('back');
|
||||||
setPage('root');
|
setPage('root');
|
||||||
@@ -123,6 +137,21 @@ export default function ProfileModal({ visible, name: propName, onClose }: Props
|
|||||||
handleClose();
|
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(() => {
|
const title = useMemo(() => {
|
||||||
if (page === 'favorites') return t('profile.favorites');
|
if (page === 'favorites') return t('profile.favorites');
|
||||||
if (page === 'dailyReminder') return t('dailyReminder.title');
|
if (page === 'dailyReminder') return t('dailyReminder.title');
|
||||||
@@ -136,23 +165,13 @@ export default function ProfileModal({ visible, name: propName, onClose }: Props
|
|||||||
const duration = 220;
|
const duration = 220;
|
||||||
const easing = Easing.out(Easing.cubic);
|
const easing = Easing.out(Easing.cubic);
|
||||||
|
|
||||||
// 进入二级页:从右侧滑入;返回:从左侧滑入
|
// 需求:去掉左右滑动的切页动效,改为纯淡入淡出
|
||||||
const entering =
|
const entering = FadeIn.duration(duration).easing(easing);
|
||||||
navDirection === 'forward'
|
const exiting = FadeOut.duration(duration).easing(easing);
|
||||||
? SlideInRight.duration(duration).easing(easing)
|
|
||||||
: SlideInLeft.duration(duration).easing(easing);
|
|
||||||
|
|
||||||
// 离开:进入二级页时旧页面向左滑出;返回时旧页面向右滑出
|
|
||||||
const exiting =
|
|
||||||
navDirection === 'forward'
|
|
||||||
? SlideOutLeft.duration(duration).easing(easing)
|
|
||||||
: SlideOutRight.duration(duration).easing(easing);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
entering,
|
entering,
|
||||||
exiting,
|
exiting,
|
||||||
fadeIn: FadeIn.duration(duration).easing(easing),
|
|
||||||
fadeOut: FadeOut.duration(duration).easing(easing),
|
|
||||||
};
|
};
|
||||||
}, [navDirection]);
|
}, [navDirection]);
|
||||||
|
|
||||||
@@ -170,11 +189,6 @@ export default function ProfileModal({ visible, name: propName, onClose }: Props
|
|||||||
exiting={transition.exiting}
|
exiting={transition.exiting}
|
||||||
style={!isRoot ? { flex: 1 } : undefined}
|
style={!isRoot ? { flex: 1 } : undefined}
|
||||||
>
|
>
|
||||||
<Animated.View
|
|
||||||
entering={transition.fadeIn}
|
|
||||||
exiting={transition.fadeOut}
|
|
||||||
style={!isRoot ? { flex: 1 } : undefined}
|
|
||||||
>
|
|
||||||
{page === 'root' ? (
|
{page === 'root' ? (
|
||||||
<RootPage
|
<RootPage
|
||||||
name={currentName}
|
name={currentName}
|
||||||
@@ -182,6 +196,8 @@ export default function ProfileModal({ visible, name: propName, onClose }: Props
|
|||||||
onOpenWidget={() => go('widget', 'forward')}
|
onOpenWidget={() => go('widget', 'forward')}
|
||||||
onOpenDailyReminder={() => go('dailyReminder', 'forward')}
|
onOpenDailyReminder={() => go('dailyReminder', 'forward')}
|
||||||
onOpenLanguage={() => go('language', 'forward')}
|
onOpenLanguage={() => go('language', 'forward')}
|
||||||
|
onOpenPrivacy={() => openLink(legalLinks.privacy)}
|
||||||
|
onOpenTerms={() => openLink(legalLinks.terms)}
|
||||||
/>
|
/>
|
||||||
) : page === 'favorites' ? (
|
) : page === 'favorites' ? (
|
||||||
<FavoritesPage visible={visible} page={page} />
|
<FavoritesPage visible={visible} page={page} />
|
||||||
@@ -194,7 +210,6 @@ export default function ProfileModal({ visible, name: propName, onClose }: Props
|
|||||||
) : (
|
) : (
|
||||||
<WidgetPage onOpenHowTo={() => go('widgetHowTo', 'forward')} />
|
<WidgetPage onOpenHowTo={() => go('widgetHowTo', 'forward')} />
|
||||||
)}
|
)}
|
||||||
</Animated.View>
|
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</View>
|
</View>
|
||||||
</SheetModal>
|
</SheetModal>
|
||||||
@@ -211,12 +226,16 @@ function RootPage({
|
|||||||
onOpenWidget,
|
onOpenWidget,
|
||||||
onOpenDailyReminder,
|
onOpenDailyReminder,
|
||||||
onOpenLanguage,
|
onOpenLanguage,
|
||||||
|
onOpenPrivacy,
|
||||||
|
onOpenTerms,
|
||||||
}: {
|
}: {
|
||||||
name?: string;
|
name?: string;
|
||||||
onOpenFavorites: () => void;
|
onOpenFavorites: () => void;
|
||||||
onOpenWidget: () => void;
|
onOpenWidget: () => void;
|
||||||
onOpenDailyReminder: () => void;
|
onOpenDailyReminder: () => void;
|
||||||
onOpenLanguage: () => void;
|
onOpenLanguage: () => void;
|
||||||
|
onOpenPrivacy: () => void;
|
||||||
|
onOpenTerms: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
@@ -244,12 +263,12 @@ function RootPage({
|
|||||||
<ListItem
|
<ListItem
|
||||||
icon={<PrivacyIcon width={22} height={22} />}
|
icon={<PrivacyIcon width={22} height={22} />}
|
||||||
title={t('profile.privacy')}
|
title={t('profile.privacy')}
|
||||||
onPress={() => toastTodo(t)}
|
onPress={onOpenPrivacy}
|
||||||
/>
|
/>
|
||||||
<ListItem
|
<ListItem
|
||||||
icon={<TermsIcon width={22} height={22} />}
|
icon={<TermsIcon width={22} height={22} />}
|
||||||
title={t('profile.terms')}
|
title={t('profile.terms')}
|
||||||
onPress={() => toastTodo(t)}
|
onPress={onOpenTerms}
|
||||||
/>
|
/>
|
||||||
<ListItem
|
<ListItem
|
||||||
icon={<LanguageIcon width={22} height={22} />}
|
icon={<LanguageIcon width={22} height={22} />}
|
||||||
@@ -374,12 +393,14 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () =
|
|||||||
// 1. 获取本地存储设置
|
// 1. 获取本地存储设置
|
||||||
const s = await getDailyReminderSettings();
|
const s = await getDailyReminderSettings();
|
||||||
|
|
||||||
// 【测试模式】:强制模拟无权限状态
|
// 2. 获取系统通知权限(用于 UI 展示/引导)
|
||||||
const granted = false;
|
const settings = await Notifications.getPermissionsAsync();
|
||||||
|
const granted = settings.status === 'granted';
|
||||||
|
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setTimesPerDay(s.timesPerDay);
|
setTimesPerDay(s.timesPerDay);
|
||||||
setPushEnabled(granted);
|
// pushEnabled 表示用户意愿;若系统未授权则强制展示为关闭
|
||||||
|
setPushEnabled(Boolean(s.pushEnabled) && granted);
|
||||||
setHasSystemPermission(granted);
|
setHasSystemPermission(granted);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
})();
|
})();
|
||||||
@@ -409,9 +430,25 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () =
|
|||||||
// 调试:打印状态
|
// 调试:打印状态
|
||||||
console.log('Push Permission Status:', status);
|
console.log('Push Permission Status:', status);
|
||||||
|
|
||||||
if (status === 'granted') {
|
if (status === 'granted' || (status as any) === 'provisional') {
|
||||||
setPushEnabled(true);
|
setPushEnabled(true);
|
||||||
setHasSystemPermission(true);
|
setHasSystemPermission(true);
|
||||||
|
|
||||||
|
// 获取 token 并上报后端(幂等)
|
||||||
|
try {
|
||||||
|
await ensurePushTokenRegisteredIfPermitted();
|
||||||
|
// 偏好同步失败不应被用户感知为“开启失败”
|
||||||
|
// (常见现象:后端已接收 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 {
|
} else {
|
||||||
setPushEnabled(false);
|
setPushEnabled(false);
|
||||||
setHasSystemPermission(false);
|
setHasSystemPermission(false);
|
||||||
@@ -419,18 +456,42 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () =
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setPushEnabled(false);
|
setPushEnabled(false);
|
||||||
|
// 关闭时尝试同步到后端(不阻塞)
|
||||||
|
try {
|
||||||
|
await setPushPreferences({ enabled: false, timesPerDay: 0 });
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
function clamp(next: number) {
|
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() {
|
async function onOk() {
|
||||||
if (loading) return;
|
if (loading) return;
|
||||||
setLoading(true);
|
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);
|
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);
|
setLoading(false);
|
||||||
onDone();
|
onDone();
|
||||||
}
|
}
|
||||||
@@ -464,24 +525,22 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () =
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{!hasSystemPermission && (
|
<View style={styles.remindRow}>
|
||||||
<View style={styles.remindRow}>
|
<View style={styles.rowLeft}>
|
||||||
<View style={styles.rowLeft}>
|
<View style={styles.rowIcon}>
|
||||||
<View style={styles.rowIcon}>
|
<RemindIcon width={18} height={18} />
|
||||||
<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>
|
||||||
|
<Text style={styles.rowText}>{t('dailyReminder.pushLabel')}</Text>
|
||||||
</View>
|
</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}>
|
<Pressable onPress={onOk} disabled={loading} style={styles.okPressable}>
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
@@ -500,6 +559,8 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () =
|
|||||||
function WidgetPage({ onOpenHowTo }: { onOpenHowTo: () => void }) {
|
function WidgetPage({ onOpenHowTo }: { onOpenHowTo: () => void }) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const currentLang = i18n.language;
|
const currentLang = i18n.language;
|
||||||
|
// 需求:个人主页弹窗「小工具」页暂时隐藏锁屏小工具说明/入口
|
||||||
|
const showLockScreenWidget = false;
|
||||||
|
|
||||||
// 根据语言选择图片
|
// 根据语言选择图片
|
||||||
const widget1 = currentLang === 'en'
|
const widget1 = currentLang === 'en'
|
||||||
@@ -517,10 +578,12 @@ function WidgetPage({ onOpenHowTo }: { onOpenHowTo: () => void }) {
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
<View style={styles.widgetScroll}>
|
<View style={styles.widgetScroll}>
|
||||||
<Pressable style={styles.widgetItem} onPress={onOpenHowTo}>
|
{showLockScreenWidget ? (
|
||||||
<Image source={widget1} style={styles.widgetImg1} resizeMode="contain" />
|
<Pressable style={styles.widgetItem} onPress={onOpenHowTo}>
|
||||||
<Text style={styles.widgetLabel}>{t('widget.lockScreen')}</Text>
|
<Image source={widget1} style={styles.widgetImg1} resizeMode="contain" />
|
||||||
</Pressable>
|
<Text style={styles.widgetLabel}>{t('widget.lockScreen')}</Text>
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Pressable style={styles.widgetItem} onPress={onOpenHowTo}>
|
<Pressable style={styles.widgetItem} onPress={onOpenHowTo}>
|
||||||
<Image source={widget2} style={styles.widgetImg2} resizeMode="contain" />
|
<Image source={widget2} style={styles.widgetImg2} resizeMode="contain" />
|
||||||
@@ -537,7 +600,8 @@ function WidgetHowToPage() {
|
|||||||
const flatListRef = useRef<FlatList>(null);
|
const flatListRef = useRef<FlatList>(null);
|
||||||
const [activeIndex, setActiveIndex] = useState(0);
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
const [isManual, setIsManual] = useState(false);
|
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' ? [
|
const images = currentLang === 'en' ? [
|
||||||
{ id: '1', src: require('@/assets/images/home/Profile/widget/Widget_description1_en.png'), desc: t('widget.howToDesc1') },
|
{ id: '1', src: require('@/assets/images/home/Profile/widget/Widget_description1_en.png'), desc: t('widget.howToDesc1') },
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
|
|
||||||
import SheetModal from '@/components/ui/SheetModal';
|
import SheetModal from '@/components/ui/SheetModal';
|
||||||
|
|
||||||
export type ThemeMode = 'scenery' | 'color';
|
import type { ThemeMode } from '@/src/storage/appStorage';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@@ -16,7 +16,7 @@ type Props = {
|
|||||||
export default function ThemeModal({ visible, mode, onSelect, onClose }: Props) {
|
export default function ThemeModal({ visible, mode, onSelect, onClose }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
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}>
|
<View style={styles.row}>
|
||||||
<ThemeCard
|
<ThemeCard
|
||||||
title={t('theme.scenery')}
|
title={t('theme.scenery')}
|
||||||
@@ -41,6 +41,19 @@ export default function ThemeModal({ visible, mode, onSelect, onClose }: Props)
|
|||||||
style={styles.previewImage}
|
style={styles.previewImage}
|
||||||
/>
|
/>
|
||||||
</ThemeCard>
|
</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>
|
</View>
|
||||||
</SheetModal>
|
</SheetModal>
|
||||||
);
|
);
|
||||||
@@ -68,7 +81,12 @@ function ThemeCard({
|
|||||||
{children}
|
{children}
|
||||||
{/* 文案展示在图片中心 */}
|
{/* 文案展示在图片中心 */}
|
||||||
<View style={styles.textOverlay}>
|
<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}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -81,19 +99,21 @@ function ThemeCard({
|
|||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
row: {
|
row: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
gap: 30,
|
flexWrap: 'nowrap',
|
||||||
paddingHorizontal: 10,
|
gap: 12,
|
||||||
|
paddingHorizontal: 4,
|
||||||
paddingBottom: 50,
|
paddingBottom: 50,
|
||||||
paddingTop: 20,
|
paddingTop: 20,
|
||||||
justifyContent: 'center',
|
justifyContent: 'space-between',
|
||||||
},
|
},
|
||||||
cardContainer: {
|
cardContainer: {
|
||||||
alignItems: 'center',
|
flex: 1,
|
||||||
width: 143,
|
minWidth: 0,
|
||||||
|
alignItems: 'stretch',
|
||||||
},
|
},
|
||||||
previewWrapper: {
|
previewWrapper: {
|
||||||
width: 138,
|
width: '100%',
|
||||||
height: 203,
|
aspectRatio: 110 / 178,
|
||||||
borderRadius: 26,
|
borderRadius: 26,
|
||||||
padding: 6.5,
|
padding: 6.5,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export default function WidgetModal({ visible, onClose }: Props) {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
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.content}>
|
||||||
<View style={styles.row}>
|
<View style={styles.row}>
|
||||||
<PreviewCard label={t('widget.lockScreen')}>
|
<PreviewCard label={t('widget.lockScreen')}>
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, StyleSheet, TouchableOpacity } from 'react-native';
|
import { View, StyleSheet, TouchableOpacity, Text } from 'react-native';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { SerifText } from './SerifText';
|
import { OnboardingColors, OnboardingFont } from '@/constants/OnboardingTheme';
|
||||||
import { OnboardingColors } from '@/constants/OnboardingTheme';
|
|
||||||
|
|
||||||
export const INTENTS = [
|
export const INTENTS = [
|
||||||
{ id: 'love', labelKey: 'intent.love', icon: '❤️' },
|
{ id: 'love', labelKey: 'intent.love', icon: '❤️' },
|
||||||
@@ -20,7 +19,7 @@ export function IntentSelectionStep({ selectedIds, onToggle }: IntentSelectionSt
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<SerifText style={styles.title}>{t('intent.title')}</SerifText>
|
<Text style={styles.title}>{t('intent.title')}</Text>
|
||||||
|
|
||||||
<View style={styles.grid}>
|
<View style={styles.grid}>
|
||||||
{INTENTS.map((intent) => {
|
{INTENTS.map((intent) => {
|
||||||
@@ -35,10 +34,10 @@ export function IntentSelectionStep({ selectedIds, onToggle }: IntentSelectionSt
|
|||||||
onPress={() => onToggle(intent.id)}
|
onPress={() => onToggle(intent.id)}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<SerifText style={styles.icon}>{intent.icon}</SerifText>
|
<Text style={styles.icon}>{intent.icon}</Text>
|
||||||
<SerifText style={[styles.label, isSelected && styles.labelSelected]}>
|
<Text style={[styles.label, isSelected && styles.labelSelected]}>
|
||||||
{t(intent.labelKey)}
|
{t(intent.labelKey)}
|
||||||
</SerifText>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -56,6 +55,8 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: 24,
|
fontSize: 24,
|
||||||
marginBottom: 40,
|
marginBottom: 40,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
|
fontFamily: OnboardingFont.question,
|
||||||
|
color: OnboardingColors.textPrimary,
|
||||||
},
|
},
|
||||||
grid: {
|
grid: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -86,10 +87,12 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
icon: {
|
icon: {
|
||||||
fontSize: 32,
|
fontSize: 32,
|
||||||
|
fontFamily: OnboardingFont.question,
|
||||||
},
|
},
|
||||||
label: {
|
label: {
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
color: OnboardingColors.textPrimary,
|
color: OnboardingColors.textPrimary,
|
||||||
|
fontFamily: OnboardingFont.question,
|
||||||
},
|
},
|
||||||
labelSelected: {
|
labelSelected: {
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { View, StyleSheet, TextInput, Platform, Animated, TouchableOpacity, Dimensions, Text } from 'react-native';
|
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 { OnboardingColors } from '@/constants/OnboardingTheme';
|
||||||
import BtnNotClicked from '@/assets/images/icon/btn_Notclicked.svg';
|
import BtnNotClicked from '@/assets/images/icon/btn_Notclicked.svg';
|
||||||
import BtnClicked from '@/assets/images/icon/btn_clicked.svg';
|
import BtnClicked from '@/assets/images/icon/btn_clicked.svg';
|
||||||
import EnterLightIcon from '@/assets/images/icon/enter_Light_icon.svg';
|
import EnterLightIcon from '@/assets/images/icon/enter_Light_icon.svg';
|
||||||
|
|
||||||
const { height } = Dimensions.get('window');
|
|
||||||
|
|
||||||
interface NameInputStepProps {
|
interface NameInputStepProps {
|
||||||
value: string;
|
value: string;
|
||||||
onChangeText: (text: string) => void;
|
onChangeText: (text: string) => void;
|
||||||
@@ -14,10 +14,30 @@ interface NameInputStepProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function NameInputStep({ value, onChangeText, onNext }: NameInputStepProps) {
|
export function NameInputStep({ value, onChangeText, onNext }: NameInputStepProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
const [isFocused, setIsFocused] = useState(false);
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
const [keyboardHeight, setKeyboardHeight] = useState(0);
|
||||||
const blinkAnim = useRef(new Animated.Value(1)).current;
|
const blinkAnim = useRef(new Animated.Value(1)).current;
|
||||||
const hasInput = value.trim().length > 0;
|
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(() => {
|
useEffect(() => {
|
||||||
const animation = Animated.loop(
|
const animation = Animated.loop(
|
||||||
Animated.sequence([
|
Animated.sequence([
|
||||||
@@ -34,8 +54,14 @@ export function NameInputStep({ value, onChangeText, onNext }: NameInputStepProp
|
|||||||
return () => animation.stop();
|
return () => animation.stop();
|
||||||
}, [blinkAnim, isFocused]);
|
}, [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 (
|
return (
|
||||||
<View style={styles.container}>
|
<Pressable style={styles.container} onPress={Keyboard.dismiss} accessible={false}>
|
||||||
<View style={styles.inputCard}>
|
<View style={styles.inputCard}>
|
||||||
<View style={styles.inputWrapper}>
|
<View style={styles.inputWrapper}>
|
||||||
{/* 显示层:文案 + 跟随的光标 */}
|
{/* 显示层:文案 + 跟随的光标 */}
|
||||||
@@ -46,7 +72,7 @@ export function NameInputStep({ value, onChangeText, onNext }: NameInputStepProp
|
|||||||
(!isFocused && !hasInput) && { color: OnboardingColors.textSecondary }
|
(!isFocused && !hasInput) && { color: OnboardingColors.textSecondary }
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{hasInput ? value : (isFocused ? "" : "Mama")}
|
{hasInput ? value : isFocused ? '' : t('onboardingSurvey.steps.name.placeholder')}
|
||||||
</Text>
|
</Text>
|
||||||
{isFocused && (
|
{isFocused && (
|
||||||
<Animated.View style={[styles.cursorWrapper, { opacity: blinkAnim, marginLeft: 2 }]}>
|
<Animated.View style={[styles.cursorWrapper, { opacity: blinkAnim, marginLeft: 2 }]}>
|
||||||
@@ -65,20 +91,29 @@ export function NameInputStep({ value, onChangeText, onNext }: NameInputStepProp
|
|||||||
caretHidden={true}
|
caretHidden={true}
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
|
returnKeyType="done"
|
||||||
|
blurOnSubmit={true}
|
||||||
|
onSubmitEditing={() => {
|
||||||
|
Keyboard.dismiss();
|
||||||
|
// 不再自動跳頁,僅收起鍵盤;前進需點擊底部 ➡️
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.footer}>
|
<View style={[styles.footer, { bottom: footerBottom }]}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={onNext}
|
onPress={() => {
|
||||||
|
Keyboard.dismiss();
|
||||||
|
onNext();
|
||||||
|
}}
|
||||||
disabled={!hasInput}
|
disabled={!hasInput}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
>
|
>
|
||||||
{hasInput ? <BtnClicked width={87} height={57} /> : <BtnNotClicked width={87} height={57} />}
|
{hasInput ? <BtnClicked width={87} height={57} /> : <BtnNotClicked width={87} height={57} />}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</Pressable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,7 +165,6 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
footer: {
|
footer: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: height * 0.12,
|
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import React from 'react';
|
import React, { useRef, useEffect } from 'react';
|
||||||
import { View, StyleSheet, SafeAreaView, TouchableOpacity, StatusBar, Text, Image, Platform } from 'react-native';
|
import { View, StyleSheet, SafeAreaView, TouchableOpacity, StatusBar, Text, Image, Platform, Animated, Easing } from 'react-native';
|
||||||
import { OnboardingColors } from '@/constants/OnboardingTheme';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { OnboardingColors, OnboardingFont } from '@/constants/OnboardingTheme';
|
||||||
|
|
||||||
|
const TRANSITION_OFFSET = 24;
|
||||||
|
const TRANSITION_DURATION = 280;
|
||||||
|
|
||||||
interface OnboardingLayoutProps {
|
interface OnboardingLayoutProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -10,6 +14,8 @@ interface OnboardingLayoutProps {
|
|||||||
onSkip: () => void;
|
onSkip: () => void;
|
||||||
onBack?: () => void;
|
onBack?: () => void;
|
||||||
showBackButton?: boolean;
|
showBackButton?: boolean;
|
||||||
|
/** 用户名字,仅在名字步骤之后的第一个问题(currentStep === 1)且非空时显示招呼语 */
|
||||||
|
userName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function OnboardingLayout({
|
export function OnboardingLayout({
|
||||||
@@ -19,8 +25,48 @@ export function OnboardingLayout({
|
|||||||
totalSteps,
|
totalSteps,
|
||||||
onSkip,
|
onSkip,
|
||||||
onBack,
|
onBack,
|
||||||
showBackButton = false
|
showBackButton = false,
|
||||||
|
userName = '',
|
||||||
}: OnboardingLayoutProps) {
|
}: 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 (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<StatusBar barStyle="dark-content" />
|
<StatusBar barStyle="dark-content" />
|
||||||
@@ -39,7 +85,7 @@ export function OnboardingLayout({
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<TouchableOpacity onPress={onSkip} style={styles.skipButton}>
|
<TouchableOpacity onPress={onSkip} style={styles.skipButton}>
|
||||||
<Text style={styles.skipText}>skip</Text>
|
<Text style={styles.skipText}>{t('onboarding.skipAll')}</Text>
|
||||||
<Image
|
<Image
|
||||||
source={require('@/assets/images/icon/skip_icon.png')}
|
source={require('@/assets/images/icon/skip_icon.png')}
|
||||||
style={styles.skipIcon}
|
style={styles.skipIcon}
|
||||||
@@ -47,16 +93,29 @@ export function OnboardingLayout({
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Title & Progress Row */}
|
{/* Title & Progress Row(名字步骤后第一步且名字非空时显示招呼语 + 问题) */}
|
||||||
<View style={styles.titleRow}>
|
<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>
|
<Text style={styles.progressText}>({currentStep}/{totalSteps})</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content:step 切换时滑动 + 淡入 */}
|
||||||
<View style={styles.content}>
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.content,
|
||||||
|
{
|
||||||
|
opacity,
|
||||||
|
transform: [{ translateX }],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</View>
|
</Animated.View>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -112,18 +171,27 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: 'flex-end',
|
alignItems: 'flex-end',
|
||||||
paddingHorizontal: 20,
|
paddingHorizontal: 20,
|
||||||
marginTop: 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: {
|
questionTitle: {
|
||||||
fontSize: 22,
|
fontSize: 22,
|
||||||
color: OnboardingColors.questionTitle,
|
color: OnboardingColors.questionTitle,
|
||||||
fontFamily: Platform.OS === 'ios' ? 'PingFang TC' : 'sans-serif',
|
fontFamily: OnboardingFont.question,
|
||||||
flex: 1,
|
|
||||||
},
|
},
|
||||||
progressText: {
|
progressText: {
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
color: OnboardingColors.textProgress,
|
color: OnboardingColors.textProgress,
|
||||||
fontFamily: Platform.OS === 'ios' ? 'PingFang TC' : 'sans-serif',
|
fontFamily: OnboardingFont.question,
|
||||||
marginLeft: 10,
|
marginLeft: 10,
|
||||||
},
|
},
|
||||||
content: {
|
content: {
|
||||||
|
|||||||
@@ -1,20 +1,28 @@
|
|||||||
import React from 'react';
|
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 { OnboardingColors } from '@/constants/OnboardingTheme';
|
||||||
import AddIcon from '@/assets/images/icon/add_icon.svg';
|
import AddIcon from '@/assets/images/icon/add_icon.svg';
|
||||||
import ReduceIcon from '@/assets/images/icon/reduce_icon.svg';
|
import ReduceIcon from '@/assets/images/icon/reduce_icon.svg';
|
||||||
import BtnClicked from '@/assets/images/icon/btn_clicked.svg';
|
import BtnClicked from '@/assets/images/icon/btn_clicked.svg';
|
||||||
|
|
||||||
const { height } = Dimensions.get('window');
|
|
||||||
|
|
||||||
interface ReminderStepProps {
|
interface ReminderStepProps {
|
||||||
value: number;
|
value: number;
|
||||||
onChange: (value: number) => void;
|
onChange: (value: number) => void;
|
||||||
onFinish: () => 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 = () => {
|
const handleReduce = () => {
|
||||||
|
// 本页最小为 1;不接收提醒请使用右上角 Skip
|
||||||
if (value > 1) onChange(value - 1);
|
if (value > 1) onChange(value - 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -25,23 +33,38 @@ export function ReminderStep({ value, onChange, onFinish }: ReminderStepProps) {
|
|||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<View style={styles.counterContainer}>
|
<View style={styles.counterContainer}>
|
||||||
<TouchableOpacity onPress={handleReduce} activeOpacity={0.7}>
|
<TouchableOpacity onPress={handleReduce} disabled={loading} activeOpacity={0.7}>
|
||||||
<ReduceIcon width={47} height={47} />
|
<ReduceIcon width={47} height={47} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
<View style={styles.numberWrapper}>
|
<View style={styles.numberWrapper}>
|
||||||
<Text style={styles.numberText}>{value}</Text>
|
<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>
|
</View>
|
||||||
|
|
||||||
<TouchableOpacity onPress={handleAdd} activeOpacity={0.7}>
|
<TouchableOpacity onPress={handleAdd} disabled={loading} activeOpacity={0.7}>
|
||||||
<AddIcon width={47} height={47} />
|
<AddIcon width={47} height={47} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.footer}>
|
<View style={[styles.footer, { bottom: insets.bottom + 16 }]}>
|
||||||
<TouchableOpacity onPress={onFinish} activeOpacity={0.8}>
|
<TouchableOpacity onPress={onFinish} disabled={loading} activeOpacity={0.8}>
|
||||||
<BtnClicked width={87} height={57} />
|
<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>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -83,7 +106,22 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
footer: {
|
footer: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: height * 0.12,
|
|
||||||
alignItems: 'center',
|
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 React from 'react';
|
||||||
import { View, StyleSheet, TouchableOpacity, ScrollView, Dimensions } from 'react-native';
|
import { View, StyleSheet, TouchableOpacity, ScrollView, Text } from 'react-native';
|
||||||
import { OnboardingColors } from '@/constants/OnboardingTheme';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { SerifText } from './SerifText';
|
import { OnboardingColors, OnboardingFont } from '@/constants/OnboardingTheme';
|
||||||
import SelectedIcon from '@/assets/images/icon/selected_icon.svg';
|
|
||||||
import BtnNotClicked from '@/assets/images/icon/btn_Notclicked.svg';
|
import BtnNotClicked from '@/assets/images/icon/btn_Notclicked.svg';
|
||||||
import BtnClicked from '@/assets/images/icon/btn_clicked.svg';
|
import BtnClicked from '@/assets/images/icon/btn_clicked.svg';
|
||||||
|
|
||||||
const { height } = Dimensions.get('window');
|
|
||||||
|
|
||||||
interface Option {
|
interface Option {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -23,32 +20,36 @@ interface SelectionStepProps {
|
|||||||
|
|
||||||
export function SelectionStep({ options, selectedIds, onToggle, onNext, onSkip }: SelectionStepProps) {
|
export function SelectionStep({ options, selectedIds, onToggle, onNext, onSkip }: SelectionStepProps) {
|
||||||
const hasSelection = selectedIds.length > 0;
|
const hasSelection = selectedIds.length > 0;
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const footerBottom = insets.bottom + 16;
|
||||||
|
const footerButtonHeight = 57;
|
||||||
|
// 底部留白加大,避免最后一项与按钮边框视觉重叠
|
||||||
|
const footerPaddingBottom = footerBottom + footerButtonHeight + 40;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.optionsList}>
|
<ScrollView
|
||||||
|
style={styles.scroll}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
contentContainerStyle={[styles.optionsList, { paddingBottom: footerPaddingBottom }]}
|
||||||
|
>
|
||||||
{options.map((option) => {
|
{options.map((option) => {
|
||||||
const isSelected = selectedIds.includes(option.id);
|
const isSelected = selectedIds.includes(option.id);
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={option.id}
|
key={option.id}
|
||||||
style={styles.optionCard}
|
style={[styles.optionCard, isSelected && styles.optionCardSelected]}
|
||||||
onPress={() => onToggle(option.id)}
|
onPress={() => onToggle(option.id)}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<SerifText style={styles.optionText}>{option.label}</SerifText>
|
<Text style={styles.optionText}>{option.label}</Text>
|
||||||
{isSelected && (
|
|
||||||
<View style={styles.iconWrapper}>
|
|
||||||
<SelectedIcon width={20} height={20} />
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
{/* 底部按钮:距离底部 12% 高度 */}
|
{/* 底部按钮:距离底部 12% 高度 */}
|
||||||
<View style={styles.footer}>
|
<View style={[styles.footer, { bottom: footerBottom }]}>
|
||||||
<TouchableOpacity onPress={onNext} disabled={!hasSelection} activeOpacity={0.8}>
|
<TouchableOpacity onPress={onNext} disabled={!hasSelection} activeOpacity={0.8}>
|
||||||
{hasSelection ? <BtnClicked width={87} height={57} /> : <BtnNotClicked width={87} height={57} />}
|
{hasSelection ? <BtnClicked width={87} height={57} /> : <BtnNotClicked width={87} height={57} />}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -60,10 +61,13 @@ export function SelectionStep({ options, selectedIds, onToggle, onNext, onSkip }
|
|||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
paddingTop: 20,
|
paddingTop: 8,
|
||||||
|
},
|
||||||
|
scroll: {
|
||||||
|
flex: 1,
|
||||||
},
|
},
|
||||||
optionsList: {
|
optionsList: {
|
||||||
paddingBottom: 150, // 为底部按钮留出空间
|
// paddingBottom 由安全区 + 按钮高度动态计算,避免选项被遮住
|
||||||
},
|
},
|
||||||
optionCard: {
|
optionCard: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
@@ -72,7 +76,7 @@ const styles = StyleSheet.create({
|
|||||||
borderRadius: 20,
|
borderRadius: 20,
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'center',
|
||||||
paddingHorizontal: 24,
|
paddingHorizontal: 24,
|
||||||
marginBottom: 12,
|
marginBottom: 12,
|
||||||
shadowColor: '#000',
|
shadowColor: '#000',
|
||||||
@@ -81,18 +85,17 @@ const styles = StyleSheet.create({
|
|||||||
shadowRadius: 10,
|
shadowRadius: 10,
|
||||||
elevation: 2,
|
elevation: 2,
|
||||||
},
|
},
|
||||||
|
optionCardSelected: {
|
||||||
|
backgroundColor: OnboardingColors.cardSelected,
|
||||||
|
},
|
||||||
optionText: {
|
optionText: {
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
color: OnboardingColors.textPrimary,
|
color: OnboardingColors.textPrimary,
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
flex: 1,
|
fontFamily: OnboardingFont.question,
|
||||||
},
|
|
||||||
iconWrapper: {
|
|
||||||
marginLeft: 10,
|
|
||||||
},
|
},
|
||||||
footer: {
|
footer: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: height * 0.12,
|
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|||||||
@@ -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 = {
|
export const OnboardingColors = {
|
||||||
background: '#FFF4EA',
|
background: '#FFF4EA',
|
||||||
textPrimary: '#772F00',
|
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'
|
build_config.build_settings['DWARF_DSYM_FILE_SHOULD_ACCOMPANY_PRODUCT'] = 'YES'
|
||||||
end
|
end
|
||||||
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
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ PODS:
|
|||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- EXConstants (18.0.13):
|
- EXConstants (18.0.13):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
|
- EXJSONUtils (0.15.0)
|
||||||
|
- EXManifests (1.0.10):
|
||||||
|
- ExpoModulesCore
|
||||||
- EXNotifications (0.32.16):
|
- EXNotifications (0.32.16):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- Expo (54.0.32):
|
- Expo (54.0.32):
|
||||||
@@ -30,8 +33,183 @@ PODS:
|
|||||||
- ReactCommon/turbomodule/core
|
- ReactCommon/turbomodule/core
|
||||||
- ReactNativeDependencies
|
- ReactNativeDependencies
|
||||||
- Yoga
|
- 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):
|
- ExpoAsset (12.0.12):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
|
- ExpoCrypto (15.0.8):
|
||||||
|
- ExpoModulesCore
|
||||||
|
- ExpoDevice (8.0.10):
|
||||||
|
- ExpoModulesCore
|
||||||
- ExpoFileSystem (19.0.21):
|
- ExpoFileSystem (19.0.21):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- ExpoFont (14.0.11):
|
- ExpoFont (14.0.11):
|
||||||
@@ -74,6 +252,8 @@ PODS:
|
|||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- ExpoWebBrowser (15.0.10):
|
- ExpoWebBrowser (15.0.10):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
|
- EXUpdatesInterface (2.0.0):
|
||||||
|
- ExpoModulesCore
|
||||||
- FBLazyVector (0.81.5)
|
- FBLazyVector (0.81.5)
|
||||||
- hermes-engine (0.81.5):
|
- hermes-engine (0.81.5):
|
||||||
- hermes-engine/Pre-built (= 0.81.5)
|
- hermes-engine/Pre-built (= 0.81.5)
|
||||||
@@ -1798,6 +1978,28 @@ PODS:
|
|||||||
- ReactCommon/turbomodule/core
|
- ReactCommon/turbomodule/core
|
||||||
- ReactNativeDependencies
|
- ReactNativeDependencies
|
||||||
- Yoga
|
- 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):
|
- RNReanimated (4.1.6):
|
||||||
- hermes-engine
|
- hermes-engine
|
||||||
- RCTRequired
|
- RCTRequired
|
||||||
@@ -2038,281 +2240,319 @@ PODS:
|
|||||||
- Yoga (0.0.0)
|
- Yoga (0.0.0)
|
||||||
|
|
||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
- "EXApplication (from `../node_modules/.pnpm/expo-application@7.0.8_expo@54.0.32/node_modules/expo-application/ios`)"
|
- EXApplication (from `../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`)"
|
- EXConstants (from `../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`)"
|
- EXJSONUtils (from `../node_modules/expo-json-utils/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`)"
|
- EXManifests (from `../node_modules/expo-manifests/ios`)
|
||||||
- "ExpoAsset (from `../node_modules/.pnpm/expo-asset@12.0.12_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-asset/ios`)"
|
- EXNotifications (from `../node_modules/expo-notifications/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`)"
|
- Expo (from `../node_modules/expo`)
|
||||||
- "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`)"
|
- expo-dev-client (from `../node_modules/expo-dev-client/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`)"
|
- expo-dev-launcher (from `../node_modules/expo-dev-launcher`)
|
||||||
- "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`)"
|
- expo-dev-menu (from `../node_modules/expo-dev-menu`)
|
||||||
- "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`)"
|
- expo-dev-menu-interface (from `../node_modules/expo-dev-menu-interface/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`)"
|
- ExpoAsset (from `../node_modules/expo-asset/ios`)
|
||||||
- "ExpoLocalization (from `../node_modules/.pnpm/expo-localization@17.0.8_expo@54.0.32_react@19.1.0/node_modules/expo-localization/ios`)"
|
- ExpoCrypto (from `../node_modules/expo-crypto/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`)"
|
- ExpoDevice (from `../node_modules/expo-device/ios`)
|
||||||
- "ExpoSplashScreen (from `../node_modules/.pnpm/expo-splash-screen@31.0.13_expo@54.0.32/node_modules/expo-splash-screen/ios`)"
|
- ExpoFileSystem (from `../node_modules/expo-file-system/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`)"
|
- ExpoFont (from `../node_modules/expo-font/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`)"
|
- ExpoHead (from `../node_modules/expo-router/ios`)
|
||||||
- "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`)"
|
- ExpoKeepAwake (from `../node_modules/expo-keep-awake/ios`)
|
||||||
- "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`)"
|
- ExpoLinearGradient (from `../node_modules/expo-linear-gradient/ios`)
|
||||||
- "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`)"
|
- ExpoLinking (from `../node_modules/expo-linking/ios`)
|
||||||
- "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`)"
|
- ExpoLocalization (from `../node_modules/expo-localization/ios`)
|
||||||
- "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/`)"
|
- ExpoModulesCore (from `../node_modules/expo-modules-core`)
|
||||||
- "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`)"
|
- ExpoSplashScreen (from `../node_modules/expo-splash-screen/ios`)
|
||||||
- "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/`)"
|
- ExpoWebBrowser (from `../node_modules/expo-web-browser/ios`)
|
||||||
- "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`)"
|
- EXUpdatesInterface (from `../node_modules/expo-updates-interface/ios`)
|
||||||
- "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/`)"
|
- FBLazyVector (from `../node_modules/react-native/Libraries/FBLazyVector`)
|
||||||
- "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`)"
|
- hermes-engine (from `../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`)
|
||||||
- "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`)"
|
- RCTDeprecation (from `../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`)
|
||||||
- "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`)"
|
- RCTRequired (from `../node_modules/react-native/Libraries/Required`)
|
||||||
- "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`)"
|
- RCTTypeSafety (from `../node_modules/react-native/Libraries/TypeSafety`)
|
||||||
- "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 (from `../node_modules/react-native/`)
|
||||||
- "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-callinvoker (from `../node_modules/react-native/ReactCommon/callinvoker`)
|
||||||
- "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-Core (from `../node_modules/react-native/`)
|
||||||
- "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-Core-prebuilt (from `../node_modules/react-native/React-Core-prebuilt.podspec`)
|
||||||
- "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-Core/RCTWebSocket (from `../node_modules/react-native/`)
|
||||||
- "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-CoreModules (from `../node_modules/react-native/React/CoreModules`)
|
||||||
- "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-cxxreact (from `../node_modules/react-native/ReactCommon/cxxreact`)
|
||||||
- "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-debug (from `../node_modules/react-native/ReactCommon/react/debug`)
|
||||||
- "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-defaultsnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/defaults`)
|
||||||
- "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-domnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/dom`)
|
||||||
- "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-Fabric (from `../node_modules/react-native/ReactCommon`)
|
||||||
- "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-FabricComponents (from `../node_modules/react-native/ReactCommon`)
|
||||||
- "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-FabricImage (from `../node_modules/react-native/ReactCommon`)
|
||||||
- "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-featureflags (from `../node_modules/react-native/ReactCommon/react/featureflags`)
|
||||||
- "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-featureflagsnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/featureflags`)
|
||||||
- "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-graphics (from `../node_modules/react-native/ReactCommon/react/renderer/graphics`)
|
||||||
- "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-hermes (from `../node_modules/react-native/ReactCommon/hermes`)
|
||||||
- "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-idlecallbacksnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/idlecallbacks`)
|
||||||
- "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-ImageManager (from `../node_modules/react-native/ReactCommon/react/renderer/imagemanager/platform/ios`)
|
||||||
- "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-jserrorhandler (from `../node_modules/react-native/ReactCommon/jserrorhandler`)
|
||||||
- "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-jsi (from `../node_modules/react-native/ReactCommon/jsi`)
|
||||||
- "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-jsiexecutor (from `../node_modules/react-native/ReactCommon/jsiexecutor`)
|
||||||
- "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-jsinspector (from `../node_modules/react-native/ReactCommon/jsinspector-modern`)
|
||||||
- "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-jsinspectorcdp (from `../node_modules/react-native/ReactCommon/jsinspector-modern/cdp`)
|
||||||
- "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-jsinspectornetwork (from `../node_modules/react-native/ReactCommon/jsinspector-modern/network`)
|
||||||
- "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-jsinspectortracing (from `../node_modules/react-native/ReactCommon/jsinspector-modern/tracing`)
|
||||||
- "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-jsitooling (from `../node_modules/react-native/ReactCommon/jsitooling`)
|
||||||
- "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-jsitracing (from `../node_modules/react-native/ReactCommon/hermes/executor/`)
|
||||||
- "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-logger (from `../node_modules/react-native/ReactCommon/logger`)
|
||||||
- "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-Mapbuffer (from `../node_modules/react-native/ReactCommon`)
|
||||||
- "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-microtasksnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`)
|
||||||
- "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-native-safe-area-context (from `../node_modules/react-native-safe-area-context`)
|
||||||
- "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-NativeModulesApple (from `../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`)
|
||||||
- "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-oscompat (from `../node_modules/react-native/ReactCommon/oscompat`)
|
||||||
- "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-perflogger (from `../node_modules/react-native/ReactCommon/reactperflogger`)
|
||||||
- "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-performancetimeline (from `../node_modules/react-native/ReactCommon/react/performance/timeline`)
|
||||||
- "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-RCTActionSheet (from `../node_modules/react-native/Libraries/ActionSheetIOS`)
|
||||||
- "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-RCTAnimation (from `../node_modules/react-native/Libraries/NativeAnimation`)
|
||||||
- "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-RCTAppDelegate (from `../node_modules/react-native/Libraries/AppDelegate`)
|
||||||
- "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-RCTBlob (from `../node_modules/react-native/Libraries/Blob`)
|
||||||
- "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-RCTFabric (from `../node_modules/react-native/React`)
|
||||||
- "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-RCTFBReactNativeSpec (from `../node_modules/react-native/React`)
|
||||||
- "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-RCTImage (from `../node_modules/react-native/Libraries/Image`)
|
||||||
- "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-RCTLinking (from `../node_modules/react-native/Libraries/LinkingIOS`)
|
||||||
- "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-RCTNetwork (from `../node_modules/react-native/Libraries/Network`)
|
||||||
- "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-RCTRuntime (from `../node_modules/react-native/React/Runtime`)
|
||||||
- "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-RCTSettings (from `../node_modules/react-native/Libraries/Settings`)
|
||||||
- "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-RCTText (from `../node_modules/react-native/Libraries/Text`)
|
||||||
- "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-RCTVibration (from `../node_modules/react-native/Libraries/Vibration`)
|
||||||
- "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`)"
|
- 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`)
|
- ReactAppDependencyProvider (from `build/generated/ios`)
|
||||||
- ReactCodegen (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`)"
|
- ReactCommon/turbomodule/core (from `../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`)"
|
- ReactNativeDependencies (from `../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`)"
|
- "RNCAsyncStorage (from `../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`)"
|
- RNGestureHandler (from `../node_modules/react-native-gesture-handler`)
|
||||||
- "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`)"
|
- RNReanimated (from `../node_modules/react-native-reanimated`)
|
||||||
- "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`)"
|
- RNScreens (from `../node_modules/react-native-screens`)
|
||||||
- "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`)"
|
- RNSVG (from `../node_modules/react-native-svg`)
|
||||||
- "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`)"
|
- RNWorklets (from `../node_modules/react-native-worklets`)
|
||||||
|
- Yoga (from `../node_modules/react-native/ReactCommon/yoga`)
|
||||||
|
|
||||||
EXTERNAL SOURCES:
|
EXTERNAL SOURCES:
|
||||||
EXApplication:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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
|
:tag: hermes-2025-07-07-RNv0.81.0-e0fc67142ec0763c6b6153ca2bf96df815539782
|
||||||
RCTDeprecation:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
ReactAppDependencyProvider:
|
||||||
:path: build/generated/ios
|
:path: build/generated/ios
|
||||||
ReactCodegen:
|
ReactCodegen:
|
||||||
:path: build/generated/ios
|
:path: build/generated/ios
|
||||||
ReactCommon:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
SPEC CHECKSUMS:
|
||||||
EXApplication: 13420f8139864183f8a04fd6099077bdf8cfb186
|
EXApplication: 13420f8139864183f8a04fd6099077bdf8cfb186
|
||||||
EXConstants: 3feb66fd1d94202fc1f0946d74e029d8b224b60e
|
EXConstants: 3feb66fd1d94202fc1f0946d74e029d8b224b60e
|
||||||
|
EXJSONUtils: 1d3e4590438c3ee593684186007028a14b3686cd
|
||||||
|
EXManifests: 83ef0844fcf06d6099b12a7bdbd7d36fc0e1dd16
|
||||||
EXNotifications: 2a3feb7af6194828d9aafda72f63a9a03866230a
|
EXNotifications: 2a3feb7af6194828d9aafda72f63a9a03866230a
|
||||||
Expo: b8d64eb9a496ebe8c71e3dae7eeb7f394b146b80
|
Expo: b8d64eb9a496ebe8c71e3dae7eeb7f394b146b80
|
||||||
|
expo-dev-client: 12ef7d5b14d93e309922acea78dcd851db583a87
|
||||||
|
expo-dev-launcher: 47994056008ffdc30a6a5e328a375b3e30a8db05
|
||||||
|
expo-dev-menu: ea4fb803ace52e60d7cd8060c7cd379612a140b2
|
||||||
|
expo-dev-menu-interface: 600df12ea01efecdd822daaf13cc0ac091775533
|
||||||
ExpoAsset: d999f3bbd998a750f3b74cb913229848901b926b
|
ExpoAsset: d999f3bbd998a750f3b74cb913229848901b926b
|
||||||
|
ExpoCrypto: 4d23a9ff67c25e2ed23ca792d81e58817a7ea1b9
|
||||||
|
ExpoDevice: 0773c782b055558ca9b40b74aa4a8133a66cd0d2
|
||||||
ExpoFileSystem: aefcd337b94b874f88752ebefc52813b84992fad
|
ExpoFileSystem: aefcd337b94b874f88752ebefc52813b84992fad
|
||||||
ExpoFont: c625dbd97ed57e9089b172b2a7bb99003d074664
|
ExpoFont: c625dbd97ed57e9089b172b2a7bb99003d074664
|
||||||
ExpoHead: b691a2ed7ab02ed820b6c6468941832d34969c29
|
ExpoHead: b691a2ed7ab02ed820b6c6468941832d34969c29
|
||||||
@@ -2323,6 +2563,7 @@ SPEC CHECKSUMS:
|
|||||||
ExpoModulesCore: 77496909fd3c800f97f7f2007dd26aeac4bb3798
|
ExpoModulesCore: 77496909fd3c800f97f7f2007dd26aeac4bb3798
|
||||||
ExpoSplashScreen: 72fbc6dd9d6404dd9d0725a56c9ac1383bc0b14f
|
ExpoSplashScreen: 72fbc6dd9d6404dd9d0725a56c9ac1383bc0b14f
|
||||||
ExpoWebBrowser: 88b116cd378d9609c776c0903fe4070fca461588
|
ExpoWebBrowser: 88b116cd378d9609c776c0903fe4070fca461588
|
||||||
|
EXUpdatesInterface: 1436757deb0d574b84bba063bd024c315e0ec08b
|
||||||
FBLazyVector: e95a291ad2dadb88e42b06e0c5fb8262de53ec12
|
FBLazyVector: e95a291ad2dadb88e42b06e0c5fb8262de53ec12
|
||||||
hermes-engine: 9f4dfe93326146a1c99eb535b1cb0b857a3cd172
|
hermes-engine: 9f4dfe93326146a1c99eb535b1cb0b857a3cd172
|
||||||
RCTDeprecation: 943572d4be82d480a48f4884f670135ae30bf990
|
RCTDeprecation: 943572d4be82d480a48f4884f670135ae30bf990
|
||||||
@@ -2387,16 +2628,17 @@ SPEC CHECKSUMS:
|
|||||||
React-timing: 03c7217455d2bff459b27a3811be25796b600f47
|
React-timing: 03c7217455d2bff459b27a3811be25796b600f47
|
||||||
React-utils: 6d46795ae0444ec8a5d9a5f201157b286bf5250a
|
React-utils: 6d46795ae0444ec8a5d9a5f201157b286bf5250a
|
||||||
ReactAppDependencyProvider: c277c5b231881ad4f00cd59e3aa0671b99d7ebee
|
ReactAppDependencyProvider: c277c5b231881ad4f00cd59e3aa0671b99d7ebee
|
||||||
ReactCodegen: 88a1f4643f15841573f833b895bfa2a0c6cb4e7f
|
ReactCodegen: 4c44b74b77fc41ae25b9e2c7e9bd6e2bc772c23f
|
||||||
ReactCommon: e6e232202a447d353e5531f2be82f50f47cbaa9a
|
ReactCommon: e6e232202a447d353e5531f2be82f50f47cbaa9a
|
||||||
ReactNativeDependencies: 71ce9c28beb282aa720ea7b46980fff9669f428a
|
ReactNativeDependencies: 71ce9c28beb282aa720ea7b46980fff9669f428a
|
||||||
RNCAsyncStorage: e85a99325df9eb0191a6ee2b2a842644c7eb29f4
|
RNCAsyncStorage: e85a99325df9eb0191a6ee2b2a842644c7eb29f4
|
||||||
RNReanimated: 10415bc8396eaeac0d7b2c9a1538eae7e607ec9c
|
RNGestureHandler: 40c2d1c168e54715fe52e0fb16cb38c54611e4f3
|
||||||
|
RNReanimated: 43f611f1c85c90e0273df7399bf1536f8e2bd125
|
||||||
RNScreens: dd61bc3a3e6f6901ad833efa411917d44827cf51
|
RNScreens: dd61bc3a3e6f6901ad833efa411917d44827cf51
|
||||||
RNSVG: 2825ee146e0f6a16221e852299943e4cceef4528
|
RNSVG: 2825ee146e0f6a16221e852299943e4cceef4528
|
||||||
RNWorklets: 9ccdc8112b17af6eee2c85a233891cb80db150ad
|
RNWorklets: 28ee7370ca8da356fcc914e3e68b97e9752196d2
|
||||||
Yoga: 5934998fbeaef7845dbf698f698518695ab4cd1a
|
Yoga: 5934998fbeaef7845dbf698f698518695ab4cd1a
|
||||||
|
|
||||||
PODFILE CHECKSUM: 4d5c52f9fa870c1d398cf59e37c149f66700c061
|
PODFILE CHECKSUM: c2c3838f0b2a579fef2350bff2ecaa005e27145d
|
||||||
|
|
||||||
COCOAPODS: 1.16.2
|
COCOAPODS: 1.16.2
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
archiveVersion = 1;
|
archiveVersion = 1;
|
||||||
classes = {
|
classes = {
|
||||||
};
|
};
|
||||||
objectVersion = 70;
|
objectVersion = 56;
|
||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
@@ -11,9 +11,13 @@
|
|||||||
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
|
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
|
||||||
1A1DE01D4133812B2E2BA692 /* libPods-client.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E3328F0E595C1F4A244DF238 /* libPods-client.a */; };
|
1A1DE01D4133812B2E2BA692 /* libPods-client.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E3328F0E595C1F4A244DF238 /* libPods-client.a */; };
|
||||||
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; };
|
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; };
|
||||||
A1B2C3D4E5F60718293A4B5C /* 情绪小组件/EmotionWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B5B /* 情绪小组件/EmotionWidget.swift */; };
|
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 */; };
|
B5A7FE9A125F7C79753EC5BF /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7DB40C26E3A46F6D06769EA /* ExpoModulesProvider.swift */; };
|
||||||
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
|
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 */; };
|
EB3DAF812F2A4B8E00450593 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EB3DAF802F2A4B8D00450593 /* WidgetKit.framework */; };
|
||||||
EB3DAF832F2A4B8E00450593 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EB3DAF822F2A4B8E00450593 /* SwiftUI.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, ); }; };
|
EB3DAF942F2A4B8F00450593 /* 情绪小组件Extension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = EB3DAF7F2F2A4B8D00450593 /* 情绪小组件Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
@@ -45,20 +49,24 @@
|
|||||||
/* End PBXCopyFilesBuildPhase section */
|
/* End PBXCopyFilesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXFileReference 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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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; };
|
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; };
|
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; };
|
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; };
|
EB3DAF822F2A4B8E00450593 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
|
||||||
EB3DAF9A2F2A4D0900450593 /* MindfulnessWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MindfulnessWidget.swift; sourceTree = "<group>"; };
|
EBEEC7562F31D82700C68C1A /* clientRelease.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; name = clientRelease.entitlements; path = client/clientRelease.entitlements; sourceTree = "<group>"; };
|
||||||
|
EBEEC7572F31D84B00C68C1A /* 情绪小组件ExtensionRelease.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "情绪小组件ExtensionRelease.entitlements"; sourceTree = "<group>"; };
|
||||||
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
|
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>"; };
|
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>"; };
|
F11748442D0722820044C1D9 /* client-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "client-Bridging-Header.h"; path = "client/client-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||||
@@ -66,7 +74,7 @@
|
|||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
EB3DAF952F2A4B8F00450593 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
|
EB3DAF952F2A4B8F00450593 /* Exceptions for "情绪小组件" folder in "情绪小组件Extension" target */ = {
|
||||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||||
membershipExceptions = (
|
membershipExceptions = (
|
||||||
EmotionWidget.swift,
|
EmotionWidget.swift,
|
||||||
@@ -77,7 +85,18 @@
|
|||||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
EB3DAF842F2A4B8E00450593 /* 情绪小组件 */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (EB3DAF952F2A4B8F00450593 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = "情绪小组件"; sourceTree = "<group>"; };
|
EB3DAF842F2A4B8E00450593 /* 情绪小组件 */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
exceptions = (
|
||||||
|
EB3DAF952F2A4B8F00450593 /* Exceptions for "情绪小组件" folder in "情绪小组件Extension" target */,
|
||||||
|
);
|
||||||
|
explicitFileTypes = {
|
||||||
|
};
|
||||||
|
explicitFolders = (
|
||||||
|
);
|
||||||
|
path = "情绪小组件";
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -86,6 +105,7 @@
|
|||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
1A1DE01D4133812B2E2BA692 /* libPods-client.a in Frameworks */,
|
1A1DE01D4133812B2E2BA692 /* libPods-client.a in Frameworks */,
|
||||||
|
A8C1D2E3F4A5B6C7D8E9F0A3 /* WidgetKit.framework in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -104,13 +124,16 @@
|
|||||||
13B07FAE1A68108700A75B9A /* client */ = {
|
13B07FAE1A68108700A75B9A /* client */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
EB3DAF9A2F2A4D0900450593 /* MindfulnessWidget.swift */,
|
EBEEC7562F31D82700C68C1A /* clientRelease.entitlements */,
|
||||||
F11748412D0307B40044C1D9 /* AppDelegate.swift */,
|
F11748412D0307B40044C1D9 /* AppDelegate.swift */,
|
||||||
|
A8C1D2E3F4A5B6C7D8E9F0A1 /* AppGroupStorage.swift */,
|
||||||
|
A8C1D2E3F4A5B6C7D8E9F0B1 /* AppGroupStorageBridge.m */,
|
||||||
F11748442D0722820044C1D9 /* client-Bridging-Header.h */,
|
F11748442D0722820044C1D9 /* client-Bridging-Header.h */,
|
||||||
BB2F792B24A3F905000567C9 /* Supporting */,
|
BB2F792B24A3F905000567C9 /* Supporting */,
|
||||||
13B07FB51A68108700A75B9A /* Images.xcassets */,
|
13B07FB51A68108700A75B9A /* Images.xcassets */,
|
||||||
13B07FB61A68108700A75B9A /* Info.plist */,
|
13B07FB61A68108700A75B9A /* Info.plist */,
|
||||||
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */,
|
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */,
|
||||||
|
C0A1B2C3D4E5F60718293A4D /* Screen_page.png */,
|
||||||
75F52ADE07CAE9D9736D7671 /* PrivacyInfo.xcprivacy */,
|
75F52ADE07CAE9D9736D7671 /* PrivacyInfo.xcprivacy */,
|
||||||
);
|
);
|
||||||
name = client;
|
name = client;
|
||||||
@@ -145,6 +168,7 @@
|
|||||||
83CBB9F61A601CBA00E9B192 = {
|
83CBB9F61A601CBA00E9B192 = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
EBEEC7572F31D84B00C68C1A /* 情绪小组件ExtensionRelease.entitlements */,
|
||||||
13B07FAE1A68108700A75B9A /* client */,
|
13B07FAE1A68108700A75B9A /* client */,
|
||||||
832341AE1AAA6A7D00B99B32 /* Libraries */,
|
832341AE1AAA6A7D00B99B32 /* Libraries */,
|
||||||
EB3DAF842F2A4B8E00450593 /* 情绪小组件 */,
|
EB3DAF842F2A4B8E00450593 /* 情绪小组件 */,
|
||||||
@@ -162,7 +186,7 @@
|
|||||||
83CBBA001A601CBA00E9B192 /* Products */ = {
|
83CBBA001A601CBA00E9B192 /* Products */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
13B07F961A680F5B00A75B9A /* client.app */,
|
13B07F961A680F5B00A75B9A /* DearMama.app */,
|
||||||
EB3DAF7F2F2A4B8D00450593 /* 情绪小组件Extension.appex */,
|
EB3DAF7F2F2A4B8D00450593 /* 情绪小组件Extension.appex */,
|
||||||
);
|
);
|
||||||
name = Products;
|
name = Products;
|
||||||
@@ -189,7 +213,7 @@
|
|||||||
EB3DAFD42F2A5FC100450593 /* Recovered References */ = {
|
EB3DAFD42F2A5FC100450593 /* Recovered References */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
A1B2C3D4E5F60718293A4B5B /* 情绪小组件/EmotionWidget.swift */,
|
A1B2C3D4E5F60718293A4B5B /* EmotionWidget.swift */,
|
||||||
);
|
);
|
||||||
name = "Recovered References";
|
name = "Recovered References";
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -226,7 +250,7 @@
|
|||||||
);
|
);
|
||||||
name = client;
|
name = client;
|
||||||
productName = client;
|
productName = client;
|
||||||
productReference = 13B07F961A680F5B00A75B9A /* client.app */;
|
productReference = 13B07F961A680F5B00A75B9A /* DearMama.app */;
|
||||||
productType = "com.apple.product-type.application";
|
productType = "com.apple.product-type.application";
|
||||||
};
|
};
|
||||||
EB3DAF7E2F2A4B8D00450593 /* 情绪小组件Extension */ = {
|
EB3DAF7E2F2A4B8D00450593 /* 情绪小组件Extension */ = {
|
||||||
@@ -255,8 +279,12 @@
|
|||||||
83CBB9F71A601CBA00E9B192 /* Project object */ = {
|
83CBB9F71A601CBA00E9B192 /* Project object */ = {
|
||||||
isa = PBXProject;
|
isa = PBXProject;
|
||||||
attributes = {
|
attributes = {
|
||||||
|
BuildIndependentTargetsInParallel = YES;
|
||||||
|
KnownAssetTags = (
|
||||||
|
New,
|
||||||
|
);
|
||||||
LastSwiftUpdateCheck = 2620;
|
LastSwiftUpdateCheck = 2620;
|
||||||
LastUpgradeCheck = 1130;
|
LastUpgradeCheck = 2620;
|
||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
13B07F861A680F5B00A75B9A = {
|
13B07F861A680F5B00A75B9A = {
|
||||||
LastSwiftMigration = 1250;
|
LastSwiftMigration = 1250;
|
||||||
@@ -273,6 +301,7 @@
|
|||||||
knownRegions = (
|
knownRegions = (
|
||||||
en,
|
en,
|
||||||
Base,
|
Base,
|
||||||
|
"zh-Hant",
|
||||||
);
|
);
|
||||||
mainGroup = 83CBB9F61A601CBA00E9B192;
|
mainGroup = 83CBB9F61A601CBA00E9B192;
|
||||||
productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */;
|
productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */;
|
||||||
@@ -293,6 +322,7 @@
|
|||||||
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */,
|
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */,
|
||||||
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
|
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
|
||||||
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */,
|
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */,
|
||||||
|
C0A1B2C3D4E5F60718293A4E /* Screen_page.png in Resources */,
|
||||||
0BE245B56A79D95AB0A7B4BA /* PrivacyInfo.xcprivacy in Resources */,
|
0BE245B56A79D95AB0A7B4BA /* PrivacyInfo.xcprivacy in Resources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
@@ -357,12 +387,15 @@
|
|||||||
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/EXConstants.bundle",
|
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/EXConstants.bundle",
|
||||||
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/ExpoConstants_privacy.bundle",
|
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/ExpoConstants_privacy.bundle",
|
||||||
"${PODS_CONFIGURATION_BUILD_DIR}/EXNotifications/ExpoNotifications_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}/ExpoFileSystem/ExpoFileSystem_privacy.bundle",
|
||||||
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoLocalization/ExpoLocalization_privacy.bundle",
|
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoLocalization/ExpoLocalization_privacy.bundle",
|
||||||
"${PODS_CONFIGURATION_BUILD_DIR}/RNCAsyncStorage/RNCAsyncStorage_resources.bundle",
|
"${PODS_CONFIGURATION_BUILD_DIR}/RNCAsyncStorage/RNCAsyncStorage_resources.bundle",
|
||||||
"${PODS_CONFIGURATION_BUILD_DIR}/RNSVG/RNSVGFilters.bundle",
|
"${PODS_CONFIGURATION_BUILD_DIR}/RNSVG/RNSVGFilters.bundle",
|
||||||
"${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.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}/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";
|
name = "[CP] Copy Pods Resources";
|
||||||
outputPaths = (
|
outputPaths = (
|
||||||
@@ -370,12 +403,15 @@
|
|||||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXConstants.bundle",
|
"${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}/ExpoConstants_privacy.bundle",
|
||||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoNotifications_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}/ExpoFileSystem_privacy.bundle",
|
||||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoLocalization_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}/RNCAsyncStorage_resources.bundle",
|
||||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNSVGFilters.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-Core_privacy.bundle",
|
||||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_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;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
@@ -437,6 +473,8 @@
|
|||||||
files = (
|
files = (
|
||||||
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */,
|
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */,
|
||||||
B5A7FE9A125F7C79753EC5BF /* ExpoModulesProvider.swift in Sources */,
|
B5A7FE9A125F7C79753EC5BF /* ExpoModulesProvider.swift in Sources */,
|
||||||
|
A8C1D2E3F4A5B6C7D8E9F0A2 /* AppGroupStorage.swift in Sources */,
|
||||||
|
A8C1D2E3F4A5B6C7D8E9F0B2 /* AppGroupStorageBridge.m in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -444,7 +482,7 @@
|
|||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
A1B2C3D4E5F60718293A4B5C /* 情绪小组件/EmotionWidget.swift in Sources */,
|
A1B2C3D4E5F60718293A4B5C /* EmotionWidget.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -467,9 +505,10 @@
|
|||||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = client/client.entitlements;
|
CODE_SIGN_ENTITLEMENTS = client/client.entitlements;
|
||||||
CURRENT_PROJECT_VERSION = 3;
|
CURRENT_PROJECT_VERSION = 4;
|
||||||
DEVELOPMENT_TEAM = WS92GPX9H2;
|
DEVELOPMENT_TEAM = WS92GPX9H2;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
|
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = x86_64;
|
||||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"FB_SONARKIT_ENABLED=1",
|
"FB_SONARKIT_ENABLED=1",
|
||||||
@@ -480,7 +519,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0.0;
|
MARKETING_VERSION = 1.0.1;
|
||||||
OTHER_LDFLAGS = (
|
OTHER_LDFLAGS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"-ObjC",
|
"-ObjC",
|
||||||
@@ -488,7 +527,8 @@
|
|||||||
);
|
);
|
||||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
|
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.damer.mindfulness;
|
PRODUCT_BUNDLE_IDENTIFIER = com.damer.mindfulness;
|
||||||
PRODUCT_NAME = client;
|
PRODUCT_NAME = DearMama;
|
||||||
|
SKIP_INSTALL = NO;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
SUPPORTS_MACCATALYST = NO;
|
SUPPORTS_MACCATALYST = NO;
|
||||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
@@ -508,18 +548,19 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = client/client.entitlements;
|
CODE_SIGN_ENTITLEMENTS = client/clientRelease.entitlements;
|
||||||
CURRENT_PROJECT_VERSION = 3;
|
CURRENT_PROJECT_VERSION = 4;
|
||||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
DEVELOPMENT_TEAM = WS92GPX9H2;
|
DEVELOPMENT_TEAM = WS92GPX9H2;
|
||||||
DWARF_DSYM_FILE_SHOULD_ACCOMPANY_PRODUCT = YES;
|
DWARF_DSYM_FILE_SHOULD_ACCOMPANY_PRODUCT = YES;
|
||||||
|
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = x86_64;
|
||||||
INFOPLIST_FILE = client/Info.plist;
|
INFOPLIST_FILE = client/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0.0;
|
MARKETING_VERSION = 1.0.1;
|
||||||
OTHER_LDFLAGS = (
|
OTHER_LDFLAGS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"-ObjC",
|
"-ObjC",
|
||||||
@@ -527,7 +568,8 @@
|
|||||||
);
|
);
|
||||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
|
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.damer.mindfulness;
|
PRODUCT_BUNDLE_IDENTIFIER = com.damer.mindfulness;
|
||||||
PRODUCT_NAME = client;
|
PRODUCT_NAME = DearMama;
|
||||||
|
SKIP_INSTALL = NO;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
SUPPORTS_MACCATALYST = NO;
|
SUPPORTS_MACCATALYST = NO;
|
||||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
@@ -562,6 +604,7 @@
|
|||||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
@@ -593,9 +636,11 @@
|
|||||||
);
|
);
|
||||||
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
|
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
|
||||||
MTL_ENABLE_DEBUG_INFO = YES;
|
MTL_ENABLE_DEBUG_INFO = YES;
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = NO;
|
||||||
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native";
|
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
|
SKIP_INSTALL = NO;
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
|
||||||
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
|
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
|
||||||
USE_HERMES = true;
|
USE_HERMES = true;
|
||||||
@@ -625,6 +670,7 @@
|
|||||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
@@ -648,9 +694,13 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
);
|
);
|
||||||
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
|
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = 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 = YES;
|
||||||
|
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
|
SKIP_INSTALL = NO;
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
|
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
|
||||||
USE_HERMES = true;
|
USE_HERMES = true;
|
||||||
VALIDATE_PRODUCT = YES;
|
VALIDATE_PRODUCT = YES;
|
||||||
@@ -670,8 +720,9 @@
|
|||||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = "情绪小组件ExtensionRelease.entitlements";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 3;
|
CURRENT_PROJECT_VERSION = 4;
|
||||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
DEVELOPMENT_TEAM = WS92GPX9H2;
|
DEVELOPMENT_TEAM = WS92GPX9H2;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
@@ -687,7 +738,7 @@
|
|||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
MARKETING_VERSION = 1.0.0;
|
MARKETING_VERSION = 1.0.1;
|
||||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
|
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
|
||||||
@@ -722,9 +773,10 @@
|
|||||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = "情绪小组件ExtensionRelease.entitlements";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
CURRENT_PROJECT_VERSION = 3;
|
CURRENT_PROJECT_VERSION = 4;
|
||||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
DEVELOPMENT_TEAM = WS92GPX9H2;
|
DEVELOPMENT_TEAM = WS92GPX9H2;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
@@ -740,7 +792,7 @@
|
|||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
MARKETING_VERSION = 1.0.0;
|
MARKETING_VERSION = 1.0.1;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
|
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.damer.mindfulness.emotionwidget;
|
PRODUCT_BUNDLE_IDENTIFIER = com.damer.mindfulness.emotionwidget;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "1130"
|
LastUpgradeVersion = "2620"
|
||||||
version = "1.3">
|
version = "1.7">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
buildImplicitDependencies = "YES">
|
buildImplicitDependencies = "YES">
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
||||||
BuildableName = "client.app"
|
BuildableName = "DearMama.app"
|
||||||
BlueprintName = "client"
|
BlueprintName = "client"
|
||||||
ReferencedContainer = "container:client.xcodeproj">
|
ReferencedContainer = "container:client.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
@@ -26,19 +26,8 @@
|
|||||||
buildConfiguration = "Debug"
|
buildConfiguration = "Debug"
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
<Testables>
|
shouldAutocreateTestPlan = "YES">
|
||||||
<TestableReference
|
|
||||||
skipped = "NO">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "00E356ED1AD99517003FC87E"
|
|
||||||
BuildableName = "clientTests.xctest"
|
|
||||||
BlueprintName = "clientTests"
|
|
||||||
ReferencedContainer = "container:client.xcodeproj">
|
|
||||||
</BuildableReference>
|
|
||||||
</TestableReference>
|
|
||||||
</Testables>
|
|
||||||
</TestAction>
|
</TestAction>
|
||||||
<LaunchAction
|
<LaunchAction
|
||||||
buildConfiguration = "Debug"
|
buildConfiguration = "Debug"
|
||||||
@@ -55,7 +44,7 @@
|
|||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
||||||
BuildableName = "client.app"
|
BuildableName = "DearMama.app"
|
||||||
BlueprintName = "client"
|
BlueprintName = "client"
|
||||||
ReferencedContainer = "container:client.xcodeproj">
|
ReferencedContainer = "container:client.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
@@ -72,7 +61,7 @@
|
|||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
||||||
BuildableName = "client.app"
|
BuildableName = "DearMama.app"
|
||||||
BlueprintName = "client"
|
BlueprintName = "client"
|
||||||
ReferencedContainer = "container:client.xcodeproj">
|
ReferencedContainer = "container:client.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
@@ -83,6 +72,26 @@
|
|||||||
</AnalyzeAction>
|
</AnalyzeAction>
|
||||||
<ArchiveAction
|
<ArchiveAction
|
||||||
buildConfiguration = "Release"
|
buildConfiguration = "Release"
|
||||||
|
customArchiveName = "Dear Mama"
|
||||||
revealArchiveInOrganizer = "YES">
|
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>
|
</ArchiveAction>
|
||||||
</Scheme>
|
</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": {
|
"color": {
|
||||||
"components": {
|
"components": {
|
||||||
"alpha": "1.000",
|
"alpha": "1.000",
|
||||||
"blue": "1.00000000000000",
|
"blue": "0.729411764705882",
|
||||||
"green": "1.00000000000000",
|
"green": "0.823529411764706",
|
||||||
"red": "1.00000000000000"
|
"red": "0.917647058823529"
|
||||||
},
|
},
|
||||||
"color-space": "srgb"
|
"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>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>Hey Mama</string>
|
<string>Dear Mama</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
@@ -38,6 +38,8 @@
|
|||||||
<string>12.0</string>
|
<string>12.0</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>NSLocalNetworkUsageDescription</key>
|
||||||
|
<string>用于连接局域网服务以获取内容与同步数据(仅在需要访问内网地址时使用)。</string>
|
||||||
<key>NSAppTransportSecurity</key>
|
<key>NSAppTransportSecurity</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSAllowsArbitraryLoads</key>
|
<key>NSAllowsArbitraryLoads</key>
|
||||||
@@ -52,7 +54,7 @@
|
|||||||
<key>RCTNewArchEnabled</key>
|
<key>RCTNewArchEnabled</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>UILaunchStoryboardName</key>
|
<key>UILaunchStoryboardName</key>
|
||||||
<string></string>
|
<string>SplashScreen</string>
|
||||||
<key>UIRequiredDeviceCapabilities</key>
|
<key>UIRequiredDeviceCapabilities</key>
|
||||||
<array>
|
<array>
|
||||||
<string>arm64</string>
|
<string>arm64</string>
|
||||||
|
|||||||
@@ -22,6 +22,14 @@
|
|||||||
<string>CA92.1</string>
|
<string>CA92.1</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyAccessedAPIType</key>
|
||||||
|
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
|
||||||
|
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||||
|
<array>
|
||||||
|
<string>35F9.1</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSPrivacyAccessedAPIType</key>
|
<key>NSPrivacyAccessedAPIType</key>
|
||||||
<string>NSPrivacyAccessedAPICategoryDiskSpace</string>
|
<string>NSPrivacyAccessedAPICategoryDiskSpace</string>
|
||||||
@@ -31,14 +39,6 @@
|
|||||||
<string>85F4.1</string>
|
<string>85F4.1</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
<dict>
|
|
||||||
<key>NSPrivacyAccessedAPIType</key>
|
|
||||||
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
|
|
||||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
|
||||||
<array>
|
|
||||||
<string>35F9.1</string>
|
|
||||||
</array>
|
|
||||||
</dict>
|
|
||||||
</array>
|
</array>
|
||||||
<key>NSPrivacyCollectedDataTypes</key>
|
<key>NSPrivacyCollectedDataTypes</key>
|
||||||
<array/>
|
<array/>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
|
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
|
||||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
||||||
<subviews>
|
<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"/>
|
<rect key="frame" x="0" y="0" width="414" height="736"/>
|
||||||
</imageView>
|
</imageView>
|
||||||
</subviews>
|
</subviews>
|
||||||
@@ -37,12 +37,12 @@
|
|||||||
</scene>
|
</scene>
|
||||||
</scenes>
|
</scenes>
|
||||||
<resources>
|
<resources>
|
||||||
<image name="SplashScreenLegacy" width="414" height="736"/>
|
<image name="Screen_page" width="414" height="736"/>
|
||||||
<systemColor name="systemBackgroundColor">
|
<systemColor name="systemBackgroundColor">
|
||||||
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
</systemColor>
|
</systemColor>
|
||||||
<namedColor name="SplashScreenBackground">
|
<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>
|
</namedColor>
|
||||||
</resources>
|
</resources>
|
||||||
</document>
|
</document>
|
||||||
@@ -1,3 +1,11 @@
|
|||||||
//
|
//
|
||||||
// Use this file to import your target's public headers that you would like to expose to Swift.
|
// 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>
|
<dict>
|
||||||
<key>aps-environment</key>
|
<key>aps-environment</key>
|
||||||
<string>production</string>
|
<string>production</string>
|
||||||
|
<!-- iOS 小组件需要通过 App Group 与主 App 共享数据 -->
|
||||||
|
<key>com.apple.security.application-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>group.com.damer.mindfulness</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</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 WidgetKit
|
||||||
import SwiftUI
|
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 {
|
struct EmotionProvider: TimelineProvider {
|
||||||
func placeholder(in context: Context) -> EmotionEntry {
|
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) -> ()) {
|
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>) -> ()) {
|
func getTimeline(in context: Context, completion: @escaping (Timeline<EmotionEntry>) -> ()) {
|
||||||
// V1:内容写死,不做数据更新;给一个较长的刷新间隔(系统仍可能自行调度)
|
Task {
|
||||||
let entry = EmotionEntry(date: Date())
|
let lang = resolveLang()
|
||||||
let nextUpdate = Calendar.current.date(byAdding: .day, value: 7, to: Date())
|
let today = localDayKey(Date())
|
||||||
?? Date().addingTimeInterval(60 * 60 * 24 * 7)
|
|
||||||
completion(Timeline(entries: [entry], policy: .after(nextUpdate)))
|
// 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 {
|
struct EmotionEntry: TimelineEntry {
|
||||||
let date: Date
|
let date: Date
|
||||||
|
let lang: String
|
||||||
|
let title: String
|
||||||
|
let text: String
|
||||||
|
let footerHint: String
|
||||||
}
|
}
|
||||||
|
|
||||||
struct EmotionWidgetView: View {
|
struct EmotionWidgetView: View {
|
||||||
var entry: EmotionProvider.Entry
|
var entry: EmotionProvider.Entry
|
||||||
@Environment(\.widgetFamily) var family
|
@Environment(\.widgetFamily) var family
|
||||||
|
|
||||||
private let title = "正念"
|
|
||||||
private let text = "你已经很努力了,今天也值得被温柔对待。"
|
|
||||||
private let deepLink = URL(string: "client:///(app)/home")
|
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 {
|
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 {
|
switch family {
|
||||||
case .systemSmall:
|
case .systemSmall:
|
||||||
smallView()
|
return .system(size: 16, weight: .semibold)
|
||||||
case .systemMedium:
|
case .systemMedium:
|
||||||
mediumView()
|
return .system(size: 18, weight: .semibold)
|
||||||
case .systemLarge:
|
case .systemLarge:
|
||||||
largeView()
|
return .system(size: 22, weight: .semibold)
|
||||||
default:
|
default:
|
||||||
smallView()
|
return .system(size: 16, weight: .semibold)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 统一的“卡片背景”风格(iOS 15 兼容)
|
private func lineSpacingForFamily() -> CGFloat {
|
||||||
private func cardBackground(colors: [Color]) -> some View {
|
switch family {
|
||||||
ZStack {
|
case .systemLarge:
|
||||||
LinearGradient(
|
return 4
|
||||||
colors: colors,
|
default:
|
||||||
startPoint: .topLeading,
|
return 3
|
||||||
endPoint: .bottomTrailing
|
|
||||||
)
|
|
||||||
// 轻微光斑,增加层次
|
|
||||||
RadialGradient(
|
|
||||||
gradient: Gradient(colors: [Color.white.opacity(0.16), Color.white.opacity(0.0)]),
|
|
||||||
center: .topTrailing,
|
|
||||||
startRadius: 10,
|
|
||||||
endRadius: 180
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
|
||||||
.stroke(Color.white.opacity(0.14), lineWidth: 1)
|
|
||||||
)
|
|
||||||
.cornerRadius(18)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func chip(_ text: String) -> some View {
|
private func lineLimitForFamily() -> Int {
|
||||||
Text(text)
|
switch family {
|
||||||
.font(.system(size: 12, weight: .semibold))
|
case .systemSmall:
|
||||||
.foregroundColor(Color.white.opacity(0.9))
|
return 5
|
||||||
.padding(.horizontal, 10)
|
case .systemMedium:
|
||||||
.padding(.vertical, 6)
|
return 6
|
||||||
.background(Color.white.opacity(0.14))
|
case .systemLarge:
|
||||||
.cornerRadius(999)
|
return 8
|
||||||
|
default:
|
||||||
|
return 5
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func smallView() -> some View {
|
private func paddingForFamily() -> CGFloat {
|
||||||
ZStack {
|
switch family {
|
||||||
cardBackground(colors: [
|
case .systemSmall:
|
||||||
Color(red: 0.06, green: 0.08, blue: 0.12),
|
return 14
|
||||||
Color(red: 0.13, green: 0.16, blue: 0.22),
|
case .systemMedium:
|
||||||
])
|
return 16
|
||||||
|
case .systemLarge:
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
return 18
|
||||||
HStack {
|
default:
|
||||||
chip(title)
|
return 14
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
.widgetURL(deepLink)
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func mediumView() -> some View {
|
private struct WidgetSolidBackgroundModifier: ViewModifier {
|
||||||
ZStack {
|
let color: Color
|
||||||
cardBackground(colors: [
|
|
||||||
Color(red: 0.06, green: 0.08, blue: 0.12),
|
|
||||||
Color(red: 0.09, green: 0.11, blue: 0.17),
|
|
||||||
])
|
|
||||||
|
|
||||||
HStack(alignment: .top, spacing: 14) {
|
func body(content: Content) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
if #available(iOSApplicationExtension 17.0, *) {
|
||||||
chip(title)
|
content.containerBackground(for: .widget) { color }
|
||||||
Text(text)
|
} else {
|
||||||
.font(.system(size: 17, weight: .semibold))
|
content
|
||||||
.foregroundColor(Color.white.opacity(0.92))
|
.background(color)
|
||||||
.lineSpacing(3)
|
.ignoresSafeArea()
|
||||||
.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)
|
|
||||||
}
|
}
|
||||||
.widgetURL(deepLink)
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func largeView() -> some View {
|
private extension View {
|
||||||
ZStack {
|
func widgetSolidBackground(_ color: Color) -> some View {
|
||||||
cardBackground(colors: [
|
modifier(WidgetSolidBackgroundModifier(color: color))
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,8 +367,9 @@ struct EmotionWidget: Widget {
|
|||||||
StaticConfiguration(kind: kind, provider: EmotionProvider()) { entry in
|
StaticConfiguration(kind: kind, provider: EmotionProvider()) { entry in
|
||||||
EmotionWidgetView(entry: entry)
|
EmotionWidgetView(entry: entry)
|
||||||
}
|
}
|
||||||
.configurationDisplayName("情绪小组件")
|
// 名称/描述:支持多语言(使用 Widget Extension 自己的 Localizable.strings)
|
||||||
.description("一段温柔提醒,陪你回到当下。")
|
.configurationDisplayName("WIDGET_DISPLAY_NAME")
|
||||||
|
.description("WIDGET_DESCRIPTION")
|
||||||
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
|
.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 组件
|
// Metro 配置:支持 import 本地 .svg 为 React 组件
|
||||||
// 说明:Expo SDK 54 + react-native-svg-transformer 的常见配置方式
|
// 说明:Expo SDK 54 + react-native-svg-transformer 的常见配置方式
|
||||||
|
const path = require('path');
|
||||||
const { getDefaultConfig } = require('expo/metro-config');
|
const { getDefaultConfig } = require('expo/metro-config');
|
||||||
|
|
||||||
/** @type {import('expo/metro-config').MetroConfig} */
|
/** @type {import('expo/metro-config').MetroConfig} */
|
||||||
@@ -14,6 +15,10 @@ config.resolver = {
|
|||||||
...config.resolver,
|
...config.resolver,
|
||||||
assetExts: config.resolver.assetExts.filter((ext) => ext !== 'svg'),
|
assetExts: config.resolver.assetExts.filter((ext) => ext !== 'svg'),
|
||||||
sourceExts: [...config.resolver.sourceExts, '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;
|
module.exports = config;
|
||||||
|
|||||||
1644
client/package-lock.json
generated
@@ -4,10 +4,14 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
|
"start:clean": "expo start -c",
|
||||||
"android": "expo run:android",
|
"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",
|
"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": {
|
"dependencies": {
|
||||||
"@expo/vector-icons": "^15.0.3",
|
"@expo/vector-icons": "^15.0.3",
|
||||||
@@ -15,7 +19,9 @@
|
|||||||
"@react-navigation/native": "^7.1.8",
|
"@react-navigation/native": "^7.1.8",
|
||||||
"expo": "~54.0.32",
|
"expo": "~54.0.32",
|
||||||
"expo-constants": "~18.0.13",
|
"expo-constants": "~18.0.13",
|
||||||
|
"expo-crypto": "^15.0.8",
|
||||||
"expo-dev-client": "^6.0.20",
|
"expo-dev-client": "^6.0.20",
|
||||||
|
"expo-device": "^8.0.10",
|
||||||
"expo-font": "~14.0.11",
|
"expo-font": "~14.0.11",
|
||||||
"expo-linear-gradient": "^15.0.8",
|
"expo-linear-gradient": "^15.0.8",
|
||||||
"expo-linking": "~8.0.11",
|
"expo-linking": "~8.0.11",
|
||||||
@@ -25,6 +31,7 @@
|
|||||||
"expo-splash-screen": "~31.0.13",
|
"expo-splash-screen": "~31.0.13",
|
||||||
"expo-status-bar": "~3.0.9",
|
"expo-status-bar": "~3.0.9",
|
||||||
"expo-web-browser": "~15.0.10",
|
"expo-web-browser": "~15.0.10",
|
||||||
|
"grapheme-splitter": "^1.0.4",
|
||||||
"i18next": "^25.8.0",
|
"i18next": "^25.8.0",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
@@ -36,6 +43,7 @@
|
|||||||
"react-native-screens": "~4.16.0",
|
"react-native-screens": "~4.16.0",
|
||||||
"react-native-svg": "15.12.1",
|
"react-native-svg": "15.12.1",
|
||||||
"react-native-svg-transformer": "^1.5.3",
|
"react-native-svg-transformer": "^1.5.3",
|
||||||
|
"react-native-text-size": "^4.0.0-rc.1",
|
||||||
"react-native-web": "~0.21.0",
|
"react-native-web": "~0.21.0",
|
||||||
"react-native-worklets": "0.5.1"
|
"react-native-worklets": "0.5.1"
|
||||||
},
|
},
|
||||||
|
|||||||
32
client/pnpm-lock.yaml
generated
@@ -23,9 +23,15 @@ importers:
|
|||||||
expo-constants:
|
expo-constants:
|
||||||
specifier: ~18.0.13
|
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))
|
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:
|
expo-dev-client:
|
||||||
specifier: ^6.0.20
|
specifier: ^6.0.20
|
||||||
version: 6.0.20(expo@54.0.32)
|
version: 6.0.20(expo@54.0.32)
|
||||||
|
expo-device:
|
||||||
|
specifier: ^8.0.10
|
||||||
|
version: 8.0.10(expo@54.0.32)
|
||||||
expo-font:
|
expo-font:
|
||||||
specifier: ~14.0.11
|
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)
|
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: '*'
|
expo: '*'
|
||||||
react-native: '*'
|
react-native: '*'
|
||||||
|
|
||||||
|
expo-crypto@15.0.8:
|
||||||
|
resolution: {integrity: sha512-aF7A914TB66WIlTJvl5J6/itejfY78O7dq3ibvFltL9vnTALJ/7LYHvLT4fwmx9yUNS6ekLBtDGWivFWnj2Fcw==}
|
||||||
|
peerDependencies:
|
||||||
|
expo: '*'
|
||||||
|
|
||||||
expo-dev-client@6.0.20:
|
expo-dev-client@6.0.20:
|
||||||
resolution: {integrity: sha512-5XjoVlj1OxakNxy55j/AUaGPrDOlQlB6XdHLLWAw61w5ffSpUDHDnuZzKzs9xY1eIaogOqTOQaAzZ2ddBkdXLA==}
|
resolution: {integrity: sha512-5XjoVlj1OxakNxy55j/AUaGPrDOlQlB6XdHLLWAw61w5ffSpUDHDnuZzKzs9xY1eIaogOqTOQaAzZ2ddBkdXLA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -2249,6 +2260,11 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
expo: '*'
|
expo: '*'
|
||||||
|
|
||||||
|
expo-device@8.0.10:
|
||||||
|
resolution: {integrity: sha512-jd5BxjaF7382JkDMaC+P04aXXknB2UhWaVx5WiQKA05ugm/8GH5uaz9P9ckWdMKZGQVVEOC8MHaUADoT26KmFA==}
|
||||||
|
peerDependencies:
|
||||||
|
expo: '*'
|
||||||
|
|
||||||
expo-file-system@19.0.21:
|
expo-file-system@19.0.21:
|
||||||
resolution: {integrity: sha512-s3DlrDdiscBHtab/6W1osrjGL+C2bvoInPJD7sOwmxfJ5Woynv2oc+Fz1/xVXaE/V7HE/+xrHC/H45tu6lZzzg==}
|
resolution: {integrity: sha512-s3DlrDdiscBHtab/6W1osrjGL+C2bvoInPJD7sOwmxfJ5Woynv2oc+Fz1/xVXaE/V7HE/+xrHC/H45tu6lZzzg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -3811,6 +3827,10 @@ packages:
|
|||||||
engines: {node: '>=14.17'}
|
engines: {node: '>=14.17'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
ua-parser-js@0.7.41:
|
||||||
|
resolution: {integrity: sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
ua-parser-js@1.0.41:
|
ua-parser-js@1.0.41:
|
||||||
resolution: {integrity: sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==}
|
resolution: {integrity: sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -6537,6 +6557,11 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- 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):
|
expo-dev-client@6.0.20(expo@54.0.32):
|
||||||
dependencies:
|
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)
|
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: 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-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)):
|
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:
|
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)
|
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: {}
|
typescript@5.9.3: {}
|
||||||
|
|
||||||
|
ua-parser-js@0.7.41: {}
|
||||||
|
|
||||||
ua-parser-js@1.0.41: {}
|
ua-parser-js@1.0.41: {}
|
||||||
|
|
||||||
undici-types@7.16.0: {}
|
undici-types@7.16.0: {}
|
||||||
|
|||||||
@@ -22,7 +22,14 @@ function getOptionalEnv(name: string, fallback: string): string {
|
|||||||
|
|
||||||
export type AppRuntimeEnv = 'local' | 'dev' | 'prod';
|
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 {
|
function getApiBaseUrl(env: AppRuntimeEnv): string {
|
||||||
// 向后兼容:若直接提供了 EXPO_PUBLIC_API_BASE_URL,则优先使用(不再强制要求 *_DEV/_PROD)
|
// 向后兼容:若直接提供了 EXPO_PUBLIC_API_BASE_URL,则优先使用(不再强制要求 *_DEV/_PROD)
|
||||||
@@ -31,15 +38,35 @@ function getApiBaseUrl(env: AppRuntimeEnv): string {
|
|||||||
|
|
||||||
// 约定:local/dev/prod 三套域名分别配置, 便于后续直接切环境而不改代码
|
// 约定:local/dev/prod 三套域名分别配置, 便于后续直接切环境而不改代码
|
||||||
if (env === 'local') {
|
if (env === 'local') {
|
||||||
return getOptionalEnv('EXPO_PUBLIC_API_BASE_URL_LOCAL', 'https://api.damer.fun');
|
return getOptionalEnv('EXPO_PUBLIC_API_BASE_URL_LOCAL', 'http://localhost:8000');
|
||||||
}
|
}
|
||||||
if (env === 'dev') {
|
if (env === 'dev') {
|
||||||
return getOptionalEnv('EXPO_PUBLIC_API_BASE_URL_DEV', getOptionalEnv('EXPO_PUBLIC_API_BASE_URL_LOCAL', 'https://api.damer.fun'));
|
return getOptionalEnv('EXPO_PUBLIC_API_BASE_URL_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);
|
export const API_BASE_URL = getApiBaseUrl(APPpai qa
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调试:打印环境变量注入结果(仅开发环境)
|
||||||
|
*
|
||||||
|
* 用途:排查「为什么 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);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 默认语言策略:
|
* 默认语言策略:
|
||||||
|
|||||||
@@ -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)}`;
|
||||||
|
}
|
||||||
|
|
||||||
16
client/src/features/textWrap/measure/index.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export type {
|
||||||
|
ContextProfile,
|
||||||
|
FontSpec,
|
||||||
|
MeasureMeta,
|
||||||
|
MeasureReason,
|
||||||
|
MeasureResult,
|
||||||
|
MeasureWidthImpl,
|
||||||
|
TextWrapContext,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
export { MissingFontSpecError } from './errors';
|
||||||
|
export { assertFontSpecComplete, buildFontSpecKey } from './fontSpecKey';
|
||||||
|
export { defaultMeasureWidthImpl } from './measureWidthImpl';
|
||||||
|
export { measureWidthCached } from './measureWidthCached';
|
||||||
|
export { measureSliceWidthCached } from './measureSliceWidthCached';
|
||||||
|
|
||||||
105
client/src/features/textWrap/measure/measureSliceWidthCached.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import type { ContextProfile, FontSpec, MeasureResult, MeasureWidthImpl, TextWrapContext } from './types';
|
||||||
|
import type { Token } from '../core/types';
|
||||||
|
import { LruCache } from './cache';
|
||||||
|
import { assertFontSpecComplete, buildFontSpecKey } from './fontSpecKey';
|
||||||
|
import { joinTokens } from '../core/joinTokens';
|
||||||
|
import { measureWidthCached } from './measureWidthCached';
|
||||||
|
|
||||||
|
// 切片缓存:同一 (start,end,fontSpecKey,contextProfile) 的宽度只测一次
|
||||||
|
const sliceWidthCache = new LruCache<Promise<number>>(4000);
|
||||||
|
|
||||||
|
function fnv1a32(input: string): string {
|
||||||
|
// 32-bit FNV-1a(确定性、实现简单;用于缓存 key 避免过长)
|
||||||
|
let hash = 0x811c9dc5;
|
||||||
|
for (let i = 0; i < input.length; i++) {
|
||||||
|
hash ^= input.charCodeAt(i);
|
||||||
|
// hash *= 16777619(用位运算模拟 32-bit 溢出)
|
||||||
|
hash = (hash + (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24)) >>> 0;
|
||||||
|
}
|
||||||
|
return hash.toString(16).padStart(8, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSliceKey(args: {
|
||||||
|
contextProfile: ContextProfile;
|
||||||
|
fontSpecKey: string;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
sliceTextHash: string;
|
||||||
|
}): string {
|
||||||
|
// 注意:必须包含 sliceText 的信息,否则不同文本的相同 (start,end) 会发生跨文本串缓存
|
||||||
|
return `${args.contextProfile}|${args.fontSpecKey}|${args.start}|${args.end}|${args.sliceTextHash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测量 token 切片 `[start..end)` 的宽度(带缓存)。
|
||||||
|
*
|
||||||
|
* - 切片文本通过 `joinTokens()` 生成(保证与 core-contract 的重组口径一致)
|
||||||
|
* - 内部复用 `measureWidthCached` 的降级语义
|
||||||
|
*/
|
||||||
|
export async function measureSliceWidthCached(args: {
|
||||||
|
tokens: Token[];
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
context: TextWrapContext;
|
||||||
|
contextProfile: ContextProfile;
|
||||||
|
fontSpec?: Partial<FontSpec> | null;
|
||||||
|
measureWidthImpl?: MeasureWidthImpl;
|
||||||
|
widgetEnableMeasure?: boolean;
|
||||||
|
rawSeparators?: string[];
|
||||||
|
}): Promise<MeasureResult> {
|
||||||
|
const enabled =
|
||||||
|
typeof args.measureWidthImpl === 'function' && (args.context === 'APP' || args.widgetEnableMeasure === true);
|
||||||
|
|
||||||
|
if (!enabled) {
|
||||||
|
// 不启用测量时直接走 width=null(保持与 measureWidthCached 一致)
|
||||||
|
return { width: null, meta: { isApprox: true, reason: 'WIDTH_UNKNOWN' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
assertFontSpecComplete(args.fontSpec);
|
||||||
|
const fontSpecKey = buildFontSpecKey(args.fontSpec);
|
||||||
|
const sliceText = joinTokens(args.tokens, args.start, args.end, args.rawSeparators);
|
||||||
|
const key = buildSliceKey({
|
||||||
|
contextProfile: args.contextProfile,
|
||||||
|
fontSpecKey,
|
||||||
|
start: args.start,
|
||||||
|
end: args.end,
|
||||||
|
sliceTextHash: fnv1a32(sliceText),
|
||||||
|
});
|
||||||
|
|
||||||
|
const cached = sliceWidthCache.get(key);
|
||||||
|
if (cached) {
|
||||||
|
try {
|
||||||
|
const w = await cached;
|
||||||
|
return { width: w, meta: { isApprox: false } };
|
||||||
|
} catch {
|
||||||
|
return { width: null, meta: { isApprox: true, reason: 'MEASURE_FAILED' } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const promise = (async () => {
|
||||||
|
const res = await measureWidthCached({
|
||||||
|
text: sliceText,
|
||||||
|
context: args.context,
|
||||||
|
contextProfile: args.contextProfile,
|
||||||
|
fontSpec: args.fontSpec,
|
||||||
|
measureWidthImpl: args.measureWidthImpl,
|
||||||
|
widgetEnableMeasure: args.widgetEnableMeasure,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.width === null) {
|
||||||
|
throw new Error('切片测量失败或不可用。');
|
||||||
|
}
|
||||||
|
return res.width;
|
||||||
|
})();
|
||||||
|
|
||||||
|
sliceWidthCache.set(key, promise);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const w = await promise;
|
||||||
|
return { width: w, meta: { isApprox: false } };
|
||||||
|
} catch {
|
||||||
|
sliceWidthCache.delete(key);
|
||||||
|
return { width: null, meta: { isApprox: true, reason: 'MEASURE_FAILED' } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
110
client/src/features/textWrap/measure/measureWidthCached.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import type { ContextProfile, FontSpec, MeasureResult, MeasureWidthImpl, TextWrapContext } from './types';
|
||||||
|
import { LruCache } from './cache';
|
||||||
|
import { assertFontSpecComplete, buildFontSpecKey } from './fontSpecKey';
|
||||||
|
|
||||||
|
// 模块级常驻缓存(两级)
|
||||||
|
const textWidthCache = new LruCache<Promise<number>>(2000);
|
||||||
|
const loggedMeasureFailures = new Set<string>();
|
||||||
|
|
||||||
|
function buildTextKey(args: { contextProfile: ContextProfile; fontSpecKey: string; text: string }): string {
|
||||||
|
// key 拼接规则必须确定性:<contextProfile>|<fontSpecKey>|<text>
|
||||||
|
return `${args.contextProfile}|${args.fontSpecKey}|${args.text}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidWidth(v: unknown): v is number {
|
||||||
|
return typeof v === 'number' && Number.isFinite(v) && v >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测量文本宽度(带缓存)。
|
||||||
|
*
|
||||||
|
* 约束:
|
||||||
|
* - 若启用测量(提供 measureWidthImpl),fontSpec 必须完整;缺字段直接报错(简体中文)
|
||||||
|
* - 若不启用测量(measureWidthImpl 缺失或 context=WIDGET 且明确不启用),进入 approx mode:width=null
|
||||||
|
*/
|
||||||
|
export async function measureWidthCached(args: {
|
||||||
|
text: string;
|
||||||
|
context: TextWrapContext;
|
||||||
|
contextProfile: ContextProfile;
|
||||||
|
fontSpec?: Partial<FontSpec> | null;
|
||||||
|
measureWidthImpl?: MeasureWidthImpl;
|
||||||
|
/** WIDGET 场景是否启用测量;默认 false(未启用即 WIDTH_UNKNOWN) */
|
||||||
|
widgetEnableMeasure?: boolean;
|
||||||
|
}): Promise<MeasureResult> {
|
||||||
|
const text = String(args.text ?? '');
|
||||||
|
|
||||||
|
const enabled =
|
||||||
|
typeof args.measureWidthImpl === 'function' && (args.context === 'APP' || args.widgetEnableMeasure === true);
|
||||||
|
|
||||||
|
if (!enabled) {
|
||||||
|
return { width: null, meta: { isApprox: true, reason: 'WIDTH_UNKNOWN' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启用测量时:fontSpec 缺字段必须报错
|
||||||
|
const fontSpec = args.fontSpec;
|
||||||
|
assertFontSpecComplete(fontSpec);
|
||||||
|
const fontSpecKey = buildFontSpecKey(fontSpec);
|
||||||
|
const key = buildTextKey({ contextProfile: args.contextProfile, fontSpecKey, text });
|
||||||
|
|
||||||
|
const cached = textWidthCache.get(key);
|
||||||
|
if (cached) {
|
||||||
|
try {
|
||||||
|
const w = await cached;
|
||||||
|
return isValidWidth(w) ? { width: w, meta: { isApprox: false } } : { width: null, meta: { isApprox: true, reason: 'MEASURE_FAILED' } };
|
||||||
|
} catch {
|
||||||
|
if (__DEV__ && !loggedMeasureFailures.has(key)) {
|
||||||
|
loggedMeasureFailures.add(key);
|
||||||
|
console.log('[TextWrap][Measure] measureWidthCached: 命中缓存但 Promise 失败(MEASURE_FAILED)', {
|
||||||
|
context: args.context,
|
||||||
|
contextProfile: args.contextProfile,
|
||||||
|
fontSpec: args.fontSpec,
|
||||||
|
textPreview: text.slice(0, 80),
|
||||||
|
textLength: text.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { width: null, meta: { isApprox: true, reason: 'MEASURE_FAILED' } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const promise = (async () => {
|
||||||
|
try {
|
||||||
|
const w = await args.measureWidthImpl!({
|
||||||
|
text,
|
||||||
|
context: args.context,
|
||||||
|
contextProfile: args.contextProfile,
|
||||||
|
fontSpec,
|
||||||
|
});
|
||||||
|
if (!isValidWidth(w)) {
|
||||||
|
throw new Error('测量结果非法(必须为有限且非负的 number)。');
|
||||||
|
}
|
||||||
|
return w;
|
||||||
|
} catch (error) {
|
||||||
|
if (__DEV__ && !loggedMeasureFailures.has(key)) {
|
||||||
|
loggedMeasureFailures.add(key);
|
||||||
|
console.log('[TextWrap][Measure] measureWidthImpl 抛错(MEASURE_FAILED)', {
|
||||||
|
context: args.context,
|
||||||
|
contextProfile: args.contextProfile,
|
||||||
|
fontSpec,
|
||||||
|
textPreview: text.slice(0, 80),
|
||||||
|
textLength: text.length,
|
||||||
|
errorName: (error as any)?.name,
|
||||||
|
errorMessage: String((error as any)?.message ?? error),
|
||||||
|
errorStack: (error as any)?.stack,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
textWidthCache.set(key, promise);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const w = await promise;
|
||||||
|
return { width: w, meta: { isApprox: false } };
|
||||||
|
} catch {
|
||||||
|
// 若失败,删除缓存,避免缓存住失败结果
|
||||||
|
textWidthCache.delete(key);
|
||||||
|
return { width: null, meta: { isApprox: true, reason: 'MEASURE_FAILED' } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
66
client/src/features/textWrap/measure/measureWidthImpl.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import type { FontSpec, MeasureWidthImpl } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 默认测量实现(成熟方案):react-native-text-size
|
||||||
|
*
|
||||||
|
* 注意:
|
||||||
|
* - 该库的 `measure` 是 Promise 异步接口,本模块以 async 方式对齐。
|
||||||
|
* - 为避免在测试/Node 环境直接 require `react-native` 造成崩溃,这里使用“延迟加载 + 捕获异常”。
|
||||||
|
*/
|
||||||
|
|
||||||
|
type TextSizeMeasureParams = {
|
||||||
|
text: string;
|
||||||
|
width?: number;
|
||||||
|
fontFamily?: string;
|
||||||
|
fontSize?: number;
|
||||||
|
fontWeight?: string;
|
||||||
|
allowFontScaling?: boolean;
|
||||||
|
usePreciseWidth?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TextSizeMeasureResult = { width: number };
|
||||||
|
|
||||||
|
function loadReactNativeTextSize(): {
|
||||||
|
measure: (params: TextSizeMeasureParams) => Promise<TextSizeMeasureResult>;
|
||||||
|
} {
|
||||||
|
// 使用 require 确保 Metro 能解析并打包该原生模块(动态 import 在某些环境下无法被正确解析)
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-explicit-any
|
||||||
|
const mod: any = require('react-native-text-size');
|
||||||
|
return mod?.default ?? mod;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toTextSizeFontSpecs(fontSpec: FontSpec): Pick<TextSizeMeasureParams, 'fontFamily' | 'fontSize' | 'fontWeight'> {
|
||||||
|
return {
|
||||||
|
fontFamily: fontSpec.fontFamily,
|
||||||
|
fontSize: fontSpec.fontSize,
|
||||||
|
fontWeight: fontSpec.fontWeight,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 默认测量函数(可替换/可注入)。
|
||||||
|
* - width 约束设为极大值,避免自动换行影响“单行宽度”测量
|
||||||
|
* - usePreciseWidth=true,取更精确的宽度(开销更大,但对本算法更稳定)
|
||||||
|
*/
|
||||||
|
export const defaultMeasureWidthImpl: MeasureWidthImpl = async ({ text, fontSpec }) => {
|
||||||
|
const TextSize = loadReactNativeTextSize();
|
||||||
|
if (!TextSize || typeof TextSize.measure !== 'function') {
|
||||||
|
// 典型原因:在 Expo Go 中运行,或没有使用包含该原生模块的 Development Build。
|
||||||
|
// 这里抛出更明确的错误,方便上层捕获并在 meta.reason=MEASURE_FAILED 时看到根因。
|
||||||
|
throw new Error(
|
||||||
|
[
|
||||||
|
'react-native-text-size 原生模块不可用:TextSize.measure 不是函数。',
|
||||||
|
'请确认你不是在 Expo Go 里运行;需要使用包含该原生模块的 Development Build(expo-dev-client / expo run:ios / EAS dev build)。',
|
||||||
|
].join(' '),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const res = await TextSize.measure({
|
||||||
|
text,
|
||||||
|
width: 1_000_000_000,
|
||||||
|
usePreciseWidth: true,
|
||||||
|
allowFontScaling: true,
|
||||||
|
...toTextSizeFontSpecs(fontSpec),
|
||||||
|
});
|
||||||
|
return res.width;
|
||||||
|
};
|
||||||
|
|
||||||
48
client/src/features/textWrap/measure/types.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* Text Wrap - width-measurement
|
||||||
|
*
|
||||||
|
* 本模块负责:
|
||||||
|
* - 宽度测量(可注入实现)
|
||||||
|
* - 两级缓存(文本/切片)
|
||||||
|
* - 测量失败时的确定性降级(approx mode)
|
||||||
|
*
|
||||||
|
* 注意:真实测量(如 react-native-text-size)通常是异步 Promise,本模块统一使用 async 形式对齐实际能力。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type TextWrapContext = 'APP' | 'WIDGET';
|
||||||
|
|
||||||
|
export type FontSpec = {
|
||||||
|
fontSize: number;
|
||||||
|
fontFamily: string;
|
||||||
|
fontWeight: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用于区分不同测量语境(缓存隔离)。
|
||||||
|
* 口径建议:`APP|<platform>|<scale?>` 或 `WIDGET|<widgetSize?>`
|
||||||
|
*/
|
||||||
|
export type ContextProfile = string;
|
||||||
|
|
||||||
|
export type MeasureReason = 'WIDTH_UNKNOWN' | 'MEASURE_FAILED';
|
||||||
|
|
||||||
|
export type MeasureMeta = {
|
||||||
|
isApprox: boolean;
|
||||||
|
reason?: MeasureReason;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MeasureResult = {
|
||||||
|
width: number | null;
|
||||||
|
meta: MeasureMeta;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 可注入的测量实现(成熟方法推荐用原生/离屏测量库)。
|
||||||
|
* 约定:返回值必须是“有限且非负”的 number,否则视为测量失败。
|
||||||
|
*/
|
||||||
|
export type MeasureWidthImpl = (args: {
|
||||||
|
text: string;
|
||||||
|
context: TextWrapContext;
|
||||||
|
fontSpec: FontSpec;
|
||||||
|
contextProfile: ContextProfile;
|
||||||
|
}) => Promise<number>;
|
||||||
|
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type { MeasureWidthImpl } from '../../measure/types';
|
||||||
|
import { applyOverflowFallback } from '../fallback';
|
||||||
|
|
||||||
|
function t(text: string, start: number) {
|
||||||
|
return { text, start, end: start + text.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('textWrap overflow-fallback', () => {
|
||||||
|
it('SYSTEM_DEFAULT:仍返回单行,但 meta 标记 SYSTEM_DEFAULT', async () => {
|
||||||
|
const res = await applyOverflowFallback({
|
||||||
|
tokens: [t('I', 0), t('am', 2), t('tired', 5)],
|
||||||
|
lang: 'EN',
|
||||||
|
context: 'APP',
|
||||||
|
availableWidth: 10,
|
||||||
|
maxLines: 2,
|
||||||
|
overflowMode: 'SYSTEM_DEFAULT',
|
||||||
|
ellipsisToken: '…',
|
||||||
|
reason: 'NO_CANDIDATE',
|
||||||
|
partialLayout: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.lines.length).toBe(1);
|
||||||
|
expect(res.meta.fallback_type).toBe('SYSTEM_DEFAULT');
|
||||||
|
expect(res.meta.overflow_type).toBe('NONE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CLIP:截断到 maxLines', async () => {
|
||||||
|
const res = await applyOverflowFallback({
|
||||||
|
tokens: [t('a', 0), t('b', 2), t('c', 4)],
|
||||||
|
lang: 'EN',
|
||||||
|
context: 'APP',
|
||||||
|
availableWidth: 10,
|
||||||
|
maxLines: 2,
|
||||||
|
overflowMode: 'CLIP',
|
||||||
|
ellipsisToken: '…',
|
||||||
|
reason: 'TOO_LONG',
|
||||||
|
partialLayout: {
|
||||||
|
breaks: [1, 2],
|
||||||
|
lines: [
|
||||||
|
{ start: 0, end: 1, text: 'a' },
|
||||||
|
{ start: 1, end: 2, text: 'b' },
|
||||||
|
// 造一个“未覆盖到 N”的 partial,触发 overflow
|
||||||
|
{ start: 2, end: 2, text: 'c' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.lines).toEqual(['a', 'b']);
|
||||||
|
expect(res.meta.overflow_type).toBe('CLIP');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ELLIPSIS:清理末尾空白与 TC 末尾标点(不输出 \" …\" / \",…\")', async () => {
|
||||||
|
const res = await applyOverflowFallback({
|
||||||
|
tokens: [t('我', 0), t('好', 1), t('累', 2), t(',', 3)],
|
||||||
|
lang: 'TC',
|
||||||
|
context: 'WIDGET',
|
||||||
|
availableWidth: 10,
|
||||||
|
maxLines: 1,
|
||||||
|
overflowMode: 'ELLIPSIS',
|
||||||
|
ellipsisToken: '…',
|
||||||
|
reason: 'NO_CANDIDATE',
|
||||||
|
partialLayout: {
|
||||||
|
breaks: [],
|
||||||
|
// 造一个“未覆盖到 N”的 partial,触发 overflow
|
||||||
|
lines: [{ start: 0, end: 3, text: '我好累, ' }],
|
||||||
|
},
|
||||||
|
tcPunctuations: [','],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.lines[0]).toBe('我好累…');
|
||||||
|
expect(res.meta.overflow_type).toBe('ELLIPSIS');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ELLIPSIS:可测量时,超宽则按 token 回退直到不超宽', async () => {
|
||||||
|
// mock:含省略号时宽度=100,否则宽度=文本长度
|
||||||
|
const impl: MeasureWidthImpl = vi.fn(async ({ text }) => (text.includes('…') ? 100 : text.length));
|
||||||
|
|
||||||
|
const res = await applyOverflowFallback({
|
||||||
|
tokens: [t('I', 0), t('am', 2), t('so', 5), t('tired', 8)],
|
||||||
|
lang: 'EN',
|
||||||
|
context: 'APP',
|
||||||
|
availableWidth: 10,
|
||||||
|
maxLines: 1,
|
||||||
|
overflowMode: 'ELLIPSIS',
|
||||||
|
ellipsisToken: '…',
|
||||||
|
reason: 'NO_CANDIDATE',
|
||||||
|
partialLayout: {
|
||||||
|
breaks: [],
|
||||||
|
// 造一个“未覆盖到 N”的 partial,触发 overflow
|
||||||
|
lines: [{ start: 0, end: 3, text: 'I am so' }],
|
||||||
|
},
|
||||||
|
measure: {
|
||||||
|
context: 'APP',
|
||||||
|
contextProfile: 'APP|ios|ellipsis',
|
||||||
|
fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 },
|
||||||
|
measureWidthImpl: impl,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 因为任何带 ellipsis 的测量都超宽,这里会一路回退到空串 + ellipsis
|
||||||
|
expect(res.lines[0]).toBe('…');
|
||||||
|
expect(res.meta.overflow_type).toBe('ELLIPSIS');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
108
client/src/features/textWrap/overflow/ellipsis.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import type { Token } from '../core/types';
|
||||||
|
import { joinTokens } from '../core/joinTokens';
|
||||||
|
import { measureWidthCached } from '../measure/measureWidthCached';
|
||||||
|
|
||||||
|
import type { ApplyOverflowFallbackInput } from './types';
|
||||||
|
|
||||||
|
const DEFAULT_TC_PUNCTS = [',', '。', '!', '?', ';', ':', '、'];
|
||||||
|
|
||||||
|
export function cleanLineBeforeEllipsis(line: string, lang: 'TC' | 'EN', tcPunctuations?: string[]): string {
|
||||||
|
const s = String(line ?? '');
|
||||||
|
const trimmed = s.replace(/\s+$/g, '');
|
||||||
|
if (trimmed === '') return '';
|
||||||
|
|
||||||
|
if (lang === 'TC') {
|
||||||
|
const puncts = new Set(tcPunctuations && tcPunctuations.length > 0 ? tcPunctuations : DEFAULT_TC_PUNCTS);
|
||||||
|
const last = trimmed.slice(-1);
|
||||||
|
if (puncts.has(last)) return trimmed.slice(0, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function measureIfPossible(args: {
|
||||||
|
text: string;
|
||||||
|
input: ApplyOverflowFallbackInput;
|
||||||
|
}): Promise<number | null> {
|
||||||
|
const { input, text } = args;
|
||||||
|
if (!input.measure) return null;
|
||||||
|
|
||||||
|
const enabled =
|
||||||
|
typeof input.measure.measureWidthImpl === 'function' &&
|
||||||
|
(input.measure.context === 'APP' || input.measure.widgetEnableMeasure === true);
|
||||||
|
|
||||||
|
if (!enabled) return null;
|
||||||
|
|
||||||
|
const res = await measureWidthCached({
|
||||||
|
text,
|
||||||
|
context: input.measure.context,
|
||||||
|
contextProfile: input.measure.contextProfile,
|
||||||
|
fontSpec: input.measure.fontSpec,
|
||||||
|
measureWidthImpl: input.measure.measureWidthImpl,
|
||||||
|
widgetEnableMeasure: input.measure.widgetEnableMeasure,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.width;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 给最后一行加省略号,并在可测量时保证不超宽:
|
||||||
|
* - EN 回退单位:整词(token)
|
||||||
|
* - TC 回退单位:grapheme(token)
|
||||||
|
*/
|
||||||
|
export async function applyEllipsisToLastLine(args: {
|
||||||
|
input: ApplyOverflowFallbackInput;
|
||||||
|
baseLines: Array<{ start: number; end: number; text: string }>;
|
||||||
|
}): Promise<{ lines: string[] }> {
|
||||||
|
const { input } = args;
|
||||||
|
const maxLines = Math.max(1, input.maxLines | 0);
|
||||||
|
const lines = args.baseLines.slice(0, maxLines);
|
||||||
|
if (lines.length === 0) return { lines: [] };
|
||||||
|
|
||||||
|
const lastIdx = lines.length - 1;
|
||||||
|
const last = lines[lastIdx]!;
|
||||||
|
|
||||||
|
// 先按规则清理,再拼接 ellipsisToken
|
||||||
|
const cleaned = cleanLineBeforeEllipsis(last.text, input.lang, input.tcPunctuations);
|
||||||
|
let candidateText = `${cleaned}${input.ellipsisToken}`;
|
||||||
|
|
||||||
|
// 若无测量能力:直接输出(确定性)
|
||||||
|
const measured = await measureIfPossible({ text: candidateText, input });
|
||||||
|
if (measured === null) {
|
||||||
|
const out = lines.map((l, idx) => (idx === lastIdx ? candidateText : l.text));
|
||||||
|
return { lines: out };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 有测量能力:若超宽则按 token 回退
|
||||||
|
if (measured <= input.availableWidth) {
|
||||||
|
const out = lines.map((l, idx) => (idx === lastIdx ? candidateText : l.text));
|
||||||
|
return { lines: out };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回退:逐步减少最后一行 token 数再加省略号
|
||||||
|
let end = last.end;
|
||||||
|
const start = last.start;
|
||||||
|
let foundFit = false;
|
||||||
|
while (end > start) {
|
||||||
|
end -= 1;
|
||||||
|
const base = joinTokens(input.tokens, start, end);
|
||||||
|
const cleaned2 = cleanLineBeforeEllipsis(base, input.lang, input.tcPunctuations);
|
||||||
|
const nextCandidate = `${cleaned2}${input.ellipsisToken}`;
|
||||||
|
const w = await measureIfPossible({ text: nextCandidate, input });
|
||||||
|
if (w !== null && w <= input.availableWidth) {
|
||||||
|
candidateText = nextCandidate;
|
||||||
|
foundFit = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 若连“仅省略号”都无法满足宽度(极窄场景),也必须给出确定性输出:直接输出 ellipsisToken
|
||||||
|
// 注意:这可能仍然超宽,但已是最小可表达形式;上层可通过 meta 做治理。
|
||||||
|
if (!foundFit) {
|
||||||
|
candidateText = input.ellipsisToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = lines.map((l, idx) => (idx === lastIdx ? candidateText : l.text));
|
||||||
|
return { lines: out };
|
||||||
|
}
|
||||||
|
|
||||||
86
client/src/features/textWrap/overflow/fallback.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import type { Token } from '../core/types';
|
||||||
|
import { joinTokens } from '../core/joinTokens';
|
||||||
|
|
||||||
|
import { applyEllipsisToLastLine } from './ellipsis';
|
||||||
|
import type { ApplyOverflowFallbackInput, ApplyOverflowFallbackResult, PartialLayoutInput, PartialLayoutLine } from './types';
|
||||||
|
|
||||||
|
function nTokens(tokens: Token[]): number {
|
||||||
|
return Array.isArray(tokens) ? tokens.length : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function coversToEnd(partial: PartialLayoutInput | null | undefined, n: number): boolean {
|
||||||
|
if (!partial || !Array.isArray(partial.lines) || partial.lines.length === 0) return false;
|
||||||
|
const last = partial.lines[partial.lines.length - 1];
|
||||||
|
return (last?.end ?? -1) === n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLinesFromPartial(args: {
|
||||||
|
tokens: Token[];
|
||||||
|
partial?: PartialLayoutInput | null;
|
||||||
|
maxLines: number;
|
||||||
|
lang: 'TC' | 'EN';
|
||||||
|
}): Array<{ start: number; end: number; text: string }> {
|
||||||
|
const N = nTokens(args.tokens);
|
||||||
|
const maxLines = Math.max(1, args.maxLines | 0);
|
||||||
|
const rawSeparators = args.lang === 'TC' ? Array.from({ length: Math.max(0, N) }, () => '') : undefined;
|
||||||
|
|
||||||
|
if (args.partial && Array.isArray(args.partial.lines) && args.partial.lines.length > 0) {
|
||||||
|
const out: Array<{ start: number; end: number; text: string }> = [];
|
||||||
|
for (const line of args.partial.lines.slice(0, maxLines)) {
|
||||||
|
const start = Math.max(0, line.start | 0);
|
||||||
|
const end = Math.min(N, Math.max(start, line.end | 0));
|
||||||
|
const text = line.text ?? joinTokens(args.tokens, start, end, rawSeparators);
|
||||||
|
out.push({ start, end, text });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 若没有 partial:把全文当单行 best-effort
|
||||||
|
return [{ start: 0, end: N, text: joinTokens(args.tokens, 0, N, rawSeparators) }];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toWrapped(lines: string[]): { lines: string[]; wrappedText: string } {
|
||||||
|
const outLines = lines.filter((s) => s !== undefined) as string[];
|
||||||
|
return { lines: outLines, wrappedText: outLines.join('\n') };
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSingleLine(tokens: Token[]): { lines: string[]; wrappedText: string } {
|
||||||
|
const N = nTokens(tokens);
|
||||||
|
// 注意:这里不知道语言口径,因此 SYSTEM_DEFAULT 的单行文本在上层保证传入“原文”更稳妥。
|
||||||
|
// 为保持最小可用,这里仍使用默认 joinTokens(EN=空格 join,TC=grapheme 之间可能会有空格)。
|
||||||
|
const text = joinTokens(tokens, 0, N);
|
||||||
|
return { lines: [text], wrappedText: text };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyOverflowFallback(input: ApplyOverflowFallbackInput): Promise<ApplyOverflowFallbackResult> {
|
||||||
|
const tokens = input.tokens ?? [];
|
||||||
|
const N = nTokens(tokens);
|
||||||
|
const maxLines = Math.max(1, input.maxLines | 0);
|
||||||
|
|
||||||
|
const isOverflow = !coversToEnd(input.partialLayout, N);
|
||||||
|
|
||||||
|
// 若其实没有 overflow:直接返回(NONE)
|
||||||
|
if (!isOverflow) {
|
||||||
|
const base = buildLinesFromPartial({ tokens, partial: input.partialLayout, maxLines, lang: input.lang });
|
||||||
|
const { lines, wrappedText } = toWrapped(base.map((l) => l.text));
|
||||||
|
return { lines, wrappedText, meta: { fallback_type: 'NONE', overflow_type: 'NONE', reason: input.reason } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.overflowMode === 'SYSTEM_DEFAULT') {
|
||||||
|
const { lines, wrappedText } = toSingleLine(tokens);
|
||||||
|
return { lines, wrappedText, meta: { fallback_type: 'SYSTEM_DEFAULT', overflow_type: 'NONE', reason: input.reason } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.overflowMode === 'CLIP') {
|
||||||
|
const base = buildLinesFromPartial({ tokens, partial: input.partialLayout, maxLines, lang: input.lang });
|
||||||
|
const { lines, wrappedText } = toWrapped(base.slice(0, maxLines).map((l) => l.text));
|
||||||
|
return { lines, wrappedText, meta: { fallback_type: 'NONE', overflow_type: 'CLIP', reason: input.reason } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ELLIPSIS
|
||||||
|
const base = buildLinesFromPartial({ tokens, partial: input.partialLayout, maxLines, lang: input.lang });
|
||||||
|
const { lines } = await applyEllipsisToLastLine({ input, baseLines: base.slice(0, maxLines) });
|
||||||
|
const wrapped = toWrapped(lines);
|
||||||
|
return { ...wrapped, meta: { fallback_type: 'NONE', overflow_type: 'ELLIPSIS', reason: input.reason } };
|
||||||
|
}
|
||||||
|
|
||||||
15
client/src/features/textWrap/overflow/index.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
export type {
|
||||||
|
ApplyOverflowFallbackInput,
|
||||||
|
ApplyOverflowFallbackResult,
|
||||||
|
FallbackType,
|
||||||
|
OverflowMeasure,
|
||||||
|
OverflowMode,
|
||||||
|
OverflowReason,
|
||||||
|
OverflowType,
|
||||||
|
PartialLayoutInput,
|
||||||
|
PartialLayoutLine,
|
||||||
|
WrapTextMeta,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
export { applyOverflowFallback } from './fallback';
|
||||||
|
|
||||||