Compare commits
35 Commits
4469945e92
...
v1.0.8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ddee6b8f8 | ||
|
|
aa530b0ce3 | ||
| cdd0ac32de | |||
|
|
d98ec76dc0 | ||
| 3c35e14c3d | |||
|
|
c0deea9318 | ||
| 915e995ab7 | |||
|
|
0e42e6f2a9 | ||
| 39e4bcab6c | |||
|
|
69a1046ff4 | ||
| ceaf459d97 | |||
|
|
d9a5dbafd6 | ||
|
|
86e4853709 | ||
|
|
2adf2475fa | ||
| 9dbba04408 | |||
|
|
64b8352ad3 | ||
|
|
4b739dd194 | ||
|
|
240cdda68f | ||
|
|
ce48e54c03 | ||
|
|
d045237952 | ||
|
|
228fd7fd84 | ||
| 3587a24115 | |||
|
|
6dc4e2b943 | ||
|
|
936094211b | ||
|
|
be38d817d5 | ||
|
|
58d17fc39f | ||
| 502a6ac500 | |||
| f49cbb7186 | |||
|
|
814b96edb6 | ||
|
|
868c5cac40 | ||
|
|
e675cbbbfb | ||
| c4c7d7d251 | |||
| 7e4074d457 | |||
|
|
a9ea31b235 | ||
|
|
0c71494815 |
@@ -1,4 +1,5 @@
|
|||||||
请开始完成编码
|
请开始完成编码
|
||||||
客户端请按照标准的RN架构目录写代码
|
客户端请按照标准的RN架构目录写代码
|
||||||
后端请按照标准的python FastAPI 架构目录写代码
|
后端请按照标准的python FastAPI 架构目录写代码
|
||||||
现在多语言仅支持 EN / TC
|
现在多语言仅支持 EN / TC
|
||||||
|
整个task.md执行完毕后需要在对应的overview.md标记,并且说明变更的文件名
|
||||||
|
|||||||
@@ -9,6 +9,6 @@
|
|||||||
- 输入/输出定义
|
- 输入/输出定义
|
||||||
- 验收标准(可验证)
|
- 验收标准(可验证)
|
||||||
3. 拆分后输出一个 `modules/` 目录结构列表,并为每个模块生成对应 spec 内容。
|
3. 拆分后输出一个 `modules/` 目录结构列表,并为每个模块生成对应 spec 内容。
|
||||||
4. 保留大 spec.md 的高层背景/总览到 overview 部分。
|
4. 保留大 spec.md 的高层背景/总览到 overview 部分,并标明各个模块的实现顺序。
|
||||||
5. 子模块之间按逻辑关系关联。
|
5. 子模块之间按逻辑关系关联。
|
||||||
6. 不生成 plan.md 或 tasks.md,仅拆出子模块 spec。
|
6. 不生成 plan.md 或 tasks.md,仅拆出子模块 spec。
|
||||||
|
|||||||
@@ -2,3 +2,4 @@
|
|||||||
根据对应的plan.md 生成task.md
|
根据对应的plan.md 生成task.md
|
||||||
任务清单详细可执行
|
任务清单详细可执行
|
||||||
执行完要标记
|
执行完要标记
|
||||||
|
整个task.md执行完毕后需要在对应的overview.md标记
|
||||||
|
|||||||
1
.cursor/commands/myspec.test.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
使用测试工具完成集成测试,并给我一份简单的测试报告
|
||||||
@@ -28,4 +28,5 @@ modules/ 可嵌套 modules/,每层都独立规范。
|
|||||||
输出时根据这个结构生成内容时,请保持文件职责清晰。
|
输出时根据这个结构生成内容时,请保持文件职责清晰。
|
||||||
简短记录项目的该层每个spec的内容 ,每次编码完成后更新overview.md
|
简短记录项目的该层每个spec的内容 ,每次编码完成后更新overview.md
|
||||||
可以通过nvm 切换node版本
|
可以通过nvm 切换node版本
|
||||||
|
在对数据库操作中,禁止执行破坏性操作,如果必须请让我同意,并回复:允许操作数据库
|
||||||
|
|
||||||
|
|||||||
5
.gitea/workflows/README.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
docker exec -it gitea-runner bash
|
||||||
|
# 然后在容器里安装 Node.js
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
|
||||||
|
apt-get install -y nodejs
|
||||||
|
node -v
|
||||||
104
.gitea/workflows/server-build.yml
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
name: Build and Push Server Docker Image
|
||||||
|
|
||||||
|
# 手动触发 workflow:从哪个分支运行,就打包哪个分支的代码
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
# 1️⃣ Checkout 仓库代码
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
# 需要能 push tag(请在仓库 Secrets 配置 RUNNER_TOKEN)
|
||||||
|
token: ${{ secrets.RUNNER_TOKEN }}
|
||||||
|
persist-credentials: true
|
||||||
|
|
||||||
|
# 2️⃣ 自动递增 tag 并推送回 Gitea 仓库(默认按 vX.Y.Z 的 patch +1)
|
||||||
|
- name: Auto bump tag and push to repository
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# 配置提交信息(用于创建注释 tag)
|
||||||
|
git config user.name "gitea-actions"
|
||||||
|
git config user.email "actions@local"
|
||||||
|
|
||||||
|
# 确保本地有最新 tags
|
||||||
|
git fetch --tags --force
|
||||||
|
|
||||||
|
# 取最新的 semver tag(vX.Y.Z),按版本号排序
|
||||||
|
LATEST_TAG="$(git tag --list 'v*' --sort=-v:refname | head -n 1 || true)"
|
||||||
|
echo "LATEST_TAG=${LATEST_TAG}"
|
||||||
|
|
||||||
|
if [[ -z "${LATEST_TAG}" ]]; then
|
||||||
|
NEXT_TAG="v1.0.0"
|
||||||
|
else
|
||||||
|
if [[ "${LATEST_TAG}" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
|
||||||
|
MAJOR="${BASH_REMATCH[1]}"
|
||||||
|
MINOR="${BASH_REMATCH[2]}"
|
||||||
|
PATCH="${BASH_REMATCH[3]}"
|
||||||
|
NEXT_TAG="v${MAJOR}.${MINOR}.$((PATCH + 1))"
|
||||||
|
else
|
||||||
|
# 如果最新 tag 不符合 vX.Y.Z,回退到 v1.0.0,避免误解析
|
||||||
|
NEXT_TAG="v1.0.0"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "NEXT_TAG=${NEXT_TAG}"
|
||||||
|
|
||||||
|
# 如果 tag 已存在则直接复用(避免重复运行失败)
|
||||||
|
if git rev-parse -q --verify "refs/tags/${NEXT_TAG}" >/dev/null; then
|
||||||
|
echo "Tag ${NEXT_TAG} 已存在,跳过创建。"
|
||||||
|
else
|
||||||
|
git tag -a "${NEXT_TAG}" -m "Release ${NEXT_TAG}"
|
||||||
|
git push origin "${NEXT_TAG}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 输出给后续步骤使用
|
||||||
|
if [[ -n "${GITHUB_ENV:-}" ]]; then
|
||||||
|
echo "IMAGE_TAG=${NEXT_TAG}" >> "$GITHUB_ENV"
|
||||||
|
fi
|
||||||
|
# 兼容部分 Gitea Runner 环境变量命名
|
||||||
|
if [[ -n "${GITEA_ENV:-}" ]]; then
|
||||||
|
echo "IMAGE_TAG=${NEXT_TAG}" >> "$GITEA_ENV"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3️⃣ 设置镜像仓库与镜像名称(自建 Registry / Docker Hub 都可)
|
||||||
|
- name: Set image variables
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
# 直接写死:推送到自建仓库
|
||||||
|
IMAGE_NAME="docker.damer.fun/damer/mindfulness-server"
|
||||||
|
|
||||||
|
if [[ -n "${GITHUB_ENV:-}" ]]; then
|
||||||
|
echo "IMAGE_NAME=$IMAGE_NAME" >> "$GITHUB_ENV"
|
||||||
|
fi
|
||||||
|
if [[ -n "${GITEA_ENV:-}" ]]; then
|
||||||
|
echo "IMAGE_NAME=$IMAGE_NAME" >> "$GITEA_ENV"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 4️⃣ 登录镜像仓库(自建 Registry / Docker Hub)
|
||||||
|
- name: Login to Docker Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
# 与 IMAGE_NAME 的 registry 保持一致
|
||||||
|
registry: docker.damer.fun
|
||||||
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_TOKEN }}
|
||||||
|
|
||||||
|
# 5️⃣ 构建 Docker 镜像(使用 server/ 作为构建上下文)
|
||||||
|
- name: Build Docker Image
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
docker build -f server/Dockerfile -t "$IMAGE_NAME:$IMAGE_TAG" server
|
||||||
|
|
||||||
|
# 6️⃣ 推送 Docker 镜像到镜像仓库
|
||||||
|
- name: Push Docker Image
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
docker push "$IMAGE_NAME:$IMAGE_TAG"
|
||||||
4
.gitignore
vendored
@@ -4,6 +4,10 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
*.pem
|
*.pem
|
||||||
|
|
||||||
|
# Python(运行产物)
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
|
||||||
# Node / JS
|
# Node / JS
|
||||||
node_modules/
|
node_modules/
|
||||||
npm-debug.*
|
npm-debug.*
|
||||||
|
|||||||
11
README.md
@@ -3,6 +3,17 @@
|
|||||||
本项目面向宝妈群体,提供情绪价值与正念练习支持。
|
本项目面向宝妈群体,提供情绪价值与正念练习支持。
|
||||||
客户端采用 React Native + Expo,后端采用 Python + FastAPI,数据库 MySQL,任务调度 Celery,推送/定时等功能完整支持。
|
客户端采用 React Native + Expo,后端采用 Python + FastAPI,数据库 MySQL,任务调度 Celery,推送/定时等功能完整支持。
|
||||||
|
|
||||||
|
# git提交
|
||||||
|
git add .
|
||||||
|
git commit -m '备注'
|
||||||
|
git push origin
|
||||||
|
|
||||||
|
获取最新分支
|
||||||
|
git pull
|
||||||
|
|
||||||
|
# 创建自己的分支
|
||||||
|
git checkout -b 姓名拼写
|
||||||
|
|
||||||
# 目录结构
|
# 目录结构
|
||||||
|
|
||||||
/mindfulness
|
/mindfulness
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
},
|
},
|
||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": true,
|
"supportsTablet": true,
|
||||||
"bundleIdentifier": "com.anonymous.client"
|
"bundleIdentifier": "com.damer.mindfulness"
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { useEffect, useLayoutEffect, useMemo, useState } from 'react';
|
import { useEffect, useLayoutEffect, useMemo, useState, useCallback, useRef } from 'react';
|
||||||
import { StyleSheet, View } from 'react-native';
|
import { StyleSheet, View, Dimensions, Text, Pressable, PanResponder, Animated as RNAnimated, ImageBackground } from 'react-native';
|
||||||
import { Pressable } from 'react-native';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useNavigation } from 'expo-router';
|
import { useNavigation, useFocusEffect } from 'expo-router';
|
||||||
import Animated, {
|
import Animated, {
|
||||||
Easing,
|
Easing,
|
||||||
runOnJS,
|
runOnJS,
|
||||||
@@ -15,21 +14,64 @@ import Animated, {
|
|||||||
import { MOCK_CONTENT } from '@/src/constants/mockContent';
|
import { MOCK_CONTENT } from '@/src/constants/mockContent';
|
||||||
import {
|
import {
|
||||||
addFavorite,
|
addFavorite,
|
||||||
|
getRecoFeedCache,
|
||||||
|
getRecoFeedHistory,
|
||||||
getThemeMode,
|
getThemeMode,
|
||||||
getUserProfile,
|
getUserProfile,
|
||||||
|
getUserProfileScoring,
|
||||||
|
recordRecoFeedServed,
|
||||||
|
recordRecoFeedTouched,
|
||||||
|
setRecoFeedCache,
|
||||||
setReaction,
|
setReaction,
|
||||||
setThemeMode,
|
setThemeMode,
|
||||||
|
type RecoFeedCacheItem,
|
||||||
type ThemeMode,
|
type ThemeMode,
|
||||||
} from '@/src/storage/appStorage';
|
} from '@/src/storage/appStorage';
|
||||||
|
import { fetchRecoFeed } from '@/src/services/recoApi';
|
||||||
|
|
||||||
import ProfileModal from '@/components/home/ProfileModal';
|
import ProfileModal from '@/components/home/ProfileModal';
|
||||||
import ThemeModal from '@/components/home/ThemeModal';
|
import ThemeModal from '@/components/home/ThemeModal';
|
||||||
|
|
||||||
import ThemeIcon from '@/assets/images/home/theme.svg';
|
import ThemeIcon from '@/assets/images/home/theme.svg';
|
||||||
import MyIcon from '@/assets/images/home/my.svg';
|
import MyIcon from '@/assets/images/home/my.svg';
|
||||||
import LikeOutlineIcon from '@/assets/images/home/like.svg';
|
|
||||||
import LikeFilledIcon from '@/assets/images/home/like_filled.svg';
|
import LikeFilledIcon from '@/assets/images/home/like_filled.svg';
|
||||||
import HateIcon from '@/assets/images/home/hate.svg';
|
import LikeIcon from '@/assets/images/icon/like_icon.svg';
|
||||||
|
|
||||||
|
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
|
||||||
|
|
||||||
|
// 预定义风景图列表
|
||||||
|
const NATURE_IMAGES = [
|
||||||
|
require('@/assets/theme/nature/1.png'),
|
||||||
|
require('@/assets/theme/nature/2.png'),
|
||||||
|
require('@/assets/theme/nature/3.png'),
|
||||||
|
require('@/assets/theme/nature/4.png'),
|
||||||
|
require('@/assets/theme/nature/5.png'),
|
||||||
|
require('@/assets/theme/nature/6.png'),
|
||||||
|
require('@/assets/theme/nature/7.png'),
|
||||||
|
require('@/assets/theme/nature/8.png'),
|
||||||
|
require('@/assets/theme/nature/9.png'),
|
||||||
|
require('@/assets/theme/nature/10.png'),
|
||||||
|
require('@/assets/theme/nature/11.png'),
|
||||||
|
require('@/assets/theme/nature/12.png'),
|
||||||
|
require('@/assets/theme/nature/13.png'),
|
||||||
|
require('@/assets/theme/nature/14.png'),
|
||||||
|
require('@/assets/theme/nature/15.png'),
|
||||||
|
require('@/assets/theme/nature/17.png'),
|
||||||
|
require('@/assets/theme/nature/18.png'),
|
||||||
|
require('@/assets/theme/nature/19.png'),
|
||||||
|
require('@/assets/theme/nature/20.png'),
|
||||||
|
require('@/assets/theme/nature/22.png'),
|
||||||
|
];
|
||||||
|
|
||||||
|
// 预定义颜色列表
|
||||||
|
const THEME_COLORS = [
|
||||||
|
'#F7D9BF',
|
||||||
|
'#CBF2D8',
|
||||||
|
'#F5CDDE',
|
||||||
|
'#F2ECCB',
|
||||||
|
'#E2CBF2',
|
||||||
|
'#CBD9F2',
|
||||||
|
];
|
||||||
|
|
||||||
export default function HomeScreen() {
|
export default function HomeScreen() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -41,29 +83,103 @@ export default function HomeScreen() {
|
|||||||
const [profileName, setProfileName] = useState<string | undefined>(undefined);
|
const [profileName, setProfileName] = useState<string | undefined>(undefined);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [likeFilled, setLikeFilled] = useState(false);
|
const [likeFilled, setLikeFilled] = useState(false);
|
||||||
|
const [feedItems, setFeedItems] = useState<Array<{ content_id: number; text: string }>>([]);
|
||||||
|
|
||||||
const item = useMemo(() => MOCK_CONTENT[index % MOCK_CONTENT.length], [index]);
|
const currentList = feedItems.length > 0 ? feedItems : MOCK_CONTENT;
|
||||||
|
const item = useMemo(() => currentList[index % currentList.length], [currentList, index]);
|
||||||
|
const currentContentId = typeof (item as any)?.content_id === 'number' ? Number((item as any).content_id) : null;
|
||||||
|
|
||||||
|
// 动画相关 Shared Values
|
||||||
|
const translateY = useSharedValue(0);
|
||||||
|
const opacity = useSharedValue(1);
|
||||||
|
const likeScale = useSharedValue(1);
|
||||||
|
|
||||||
|
// 每次进入页面或页面获得焦点时刷新个人信息
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
const mode = await getThemeMode();
|
||||||
|
const profile = await getUserProfile();
|
||||||
|
if (cancelled) return;
|
||||||
|
setThemeModeState(mode);
|
||||||
|
setProfileName(profile.name);
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [])
|
||||||
|
);
|
||||||
|
|
||||||
|
// 首次进入:先读缓存,再拉后端 feed(失败则保持 mock/缓存)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
const mode = await getThemeMode();
|
const cache = await getRecoFeedCache();
|
||||||
const profile = await getUserProfile();
|
if (!cancelled && cache?.items?.length) {
|
||||||
if (cancelled) return;
|
setFeedItems(cache.items.map((x: RecoFeedCacheItem) => ({ content_id: x.content_id, text: x.text })));
|
||||||
setThemeModeState(mode);
|
}
|
||||||
setProfileName(profile.name);
|
|
||||||
|
const scoring = await getUserProfileScoring();
|
||||||
|
if (!scoring) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const hist = await getRecoFeedHistory();
|
||||||
|
const out = await fetchRecoFeed({
|
||||||
|
k: 30,
|
||||||
|
user_profile: {
|
||||||
|
profile_version: scoring.profile_version,
|
||||||
|
profile_source: scoring.profile_source,
|
||||||
|
profile_generated_at: scoring.profile_generated_at,
|
||||||
|
profile_confidence: scoring.profile_confidence,
|
||||||
|
profile_answered: scoring.profile_answered,
|
||||||
|
stage: scoring.stage,
|
||||||
|
emotion_score: scoring.emotion_score,
|
||||||
|
context: scoring.context,
|
||||||
|
need: scoring.need,
|
||||||
|
},
|
||||||
|
already_recommended_ids: hist.already_recommended_ids,
|
||||||
|
touched_or_viewed_ids: hist.touched_or_viewed_ids,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!cancelled && out.items?.length) {
|
||||||
|
setFeedItems(out.items.map((x) => ({ content_id: x.content_id, text: x.text })));
|
||||||
|
await setRecoFeedCache({
|
||||||
|
saved_at: new Date().toISOString(),
|
||||||
|
items: out.items.map((x) => ({ content_id: x.content_id, text: x.text })),
|
||||||
|
meta: out.meta as Record<string, unknown>,
|
||||||
|
});
|
||||||
|
await recordRecoFeedServed(out.items.map((x) => x.content_id));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 忽略:保持缓存/本地 mock
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const backgroundColor = themeMode === 'color' ? '#F3D0E1' : '#F4D6C2';
|
const backgroundColor = useMemo(() => {
|
||||||
|
if (themeMode === 'color') {
|
||||||
|
const colorIndex = Math.floor(index / 10) % THEME_COLORS.length;
|
||||||
|
return THEME_COLORS[colorIndex];
|
||||||
|
}
|
||||||
|
return '#F4D6C2'; // 风景模式下的默认底色(图片加载前显示)
|
||||||
|
}, [themeMode, index]);
|
||||||
|
|
||||||
|
// 计算当前应该显示的风景图索引(滑动 10 次切换一张)
|
||||||
|
const natureImageIndex = useMemo(() => {
|
||||||
|
return Math.floor(index / 10) % NATURE_IMAGES.length;
|
||||||
|
}, [index]);
|
||||||
|
|
||||||
|
const currentNatureImage = NATURE_IMAGES[natureImageIndex];
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerStyle: { backgroundColor },
|
headerStyle: { backgroundColor: themeMode === 'scenery' ? 'transparent' : backgroundColor },
|
||||||
|
headerTransparent: themeMode === 'scenery',
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
<View style={styles.headerRight}>
|
<View style={styles.headerRight}>
|
||||||
<CircleIconButton
|
<CircleIconButton
|
||||||
@@ -81,62 +197,117 @@ export default function HomeScreen() {
|
|||||||
</View>
|
</View>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
}, [backgroundColor, navigation, t]);
|
}, [backgroundColor, themeMode, navigation, t]);
|
||||||
|
|
||||||
const likeScale = useSharedValue(1);
|
const textAnimatedStyle = useAnimatedStyle(() => ({
|
||||||
const hateScale = useSharedValue(1);
|
transform: [{ translateY: translateY.value }],
|
||||||
|
opacity: opacity.value,
|
||||||
|
}));
|
||||||
|
|
||||||
const likeAnimatedStyle = useAnimatedStyle(() => ({
|
const likeAnimatedStyle = useAnimatedStyle(() => ({
|
||||||
transform: [{ scale: likeScale.value }],
|
transform: [{ scale: likeScale.value }],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const hateAnimatedStyle = useAnimatedStyle(() => ({
|
// 切换到下一条文案的统一动画逻辑
|
||||||
transform: [{ scale: hateScale.value }],
|
const triggerNextContent = useCallback(() => {
|
||||||
}));
|
if (busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
|
||||||
function playLikeAnimationAndThen(next: () => void) {
|
// 记录“看过/划过”的内容 id(用于下一次向后端请求时去重/频控)
|
||||||
|
if (typeof currentContentId === 'number') {
|
||||||
|
void recordRecoFeedTouched(currentContentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 当前文案向上移动并消失
|
||||||
|
translateY.value = withTiming(-40, { duration: 300, easing: Easing.out(Easing.quad) });
|
||||||
|
opacity.value = withTiming(0, { duration: 300 }, (finished) => {
|
||||||
|
if (finished) {
|
||||||
|
// 2. 切换数据索引
|
||||||
|
runOnJS(setIndex)(index + 1);
|
||||||
|
runOnJS(setLikeFilled)(false);
|
||||||
|
|
||||||
|
// 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(setBusy)(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [busy, currentContentId, index, translateY, opacity]);
|
||||||
|
|
||||||
|
const lastTapRef = useRef<number>(0);
|
||||||
|
|
||||||
|
// 使用 Ref 解决 PanResponder 闭包陷阱,确保手势回调能拿到最新的 state 和 function
|
||||||
|
const handlersRef = useRef({ onPressLike, triggerNextContent });
|
||||||
|
useEffect(() => {
|
||||||
|
handlersRef.current = { onPressLike, triggerNextContent };
|
||||||
|
}, [onPressLike, triggerNextContent]);
|
||||||
|
|
||||||
|
// 使用系统自带的 PanResponder 代替第三方手势库
|
||||||
|
const panResponder = useRef(
|
||||||
|
PanResponder.create({
|
||||||
|
onStartShouldSetPanResponder: () => true, // 允许开始捕获
|
||||||
|
onMoveShouldSetPanResponder: (_, gestureState) => {
|
||||||
|
// 只有当垂直滑动距离大于 20 时才接管位移手势
|
||||||
|
return Math.abs(gestureState.dy) > 20;
|
||||||
|
},
|
||||||
|
onPanResponderRelease: (_, gestureState) => {
|
||||||
|
const now = Date.now();
|
||||||
|
const DOUBLE_TAP_DELAY = 300;
|
||||||
|
|
||||||
|
// 1. 双击逻辑判定
|
||||||
|
if (now - lastTapRef.current < DOUBLE_TAP_DELAY) {
|
||||||
|
// 判定为双击
|
||||||
|
if (Math.abs(gestureState.dx) < 10 && Math.abs(gestureState.dy) < 10) {
|
||||||
|
runOnJS(handlersRef.current.onPressLike)();
|
||||||
|
lastTapRef.current = 0; // 重置
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastTapRef.current = now;
|
||||||
|
|
||||||
|
// 2. 上滑逻辑判定
|
||||||
|
if (gestureState.dy < -50) { // 上滑超过 50pt
|
||||||
|
runOnJS(handlersRef.current.triggerNextContent)();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).current;
|
||||||
|
|
||||||
|
async function onPressLike() {
|
||||||
|
if (busy) return;
|
||||||
setLikeFilled(true);
|
setLikeFilled(true);
|
||||||
|
|
||||||
|
// 1. 获取当前日期
|
||||||
|
const now = new Date();
|
||||||
|
const dateStr = `${now.getFullYear()}.${String(now.getMonth() + 1).padStart(2, '0')}.${String(now.getDate()).padStart(2, '0')}`;
|
||||||
|
|
||||||
|
// 2. 保存到收藏夹,包含当前背景信息
|
||||||
|
await addFavorite({
|
||||||
|
favId: String(Date.now()), // 生成唯一 ID
|
||||||
|
id: item.id,
|
||||||
|
date: dateStr,
|
||||||
|
themeMode: themeMode,
|
||||||
|
background: themeMode === 'scenery' ? String(natureImageIndex) : backgroundColor,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. 爱心缩放动画
|
||||||
likeScale.value = withSequence(
|
likeScale.value = withSequence(
|
||||||
withTiming(0.92, { duration: 90, easing: Easing.out(Easing.cubic) }),
|
withTiming(0.8, { duration: 100 }),
|
||||||
withTiming(1.14, { duration: 120, easing: Easing.out(Easing.cubic) }),
|
withTiming(1.2, { duration: 150 }),
|
||||||
withTiming(1, { duration: 140, easing: Easing.out(Easing.cubic) }, (finished) => {
|
withTiming(1, { duration: 100 }, (finished) => {
|
||||||
if (finished) runOnJS(next)();
|
if (finished) {
|
||||||
|
runOnJS(triggerNextContent)();
|
||||||
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function playHateAnimationAndThen(next: () => void) {
|
|
||||||
hateScale.value = withSequence(
|
|
||||||
withTiming(0.92, { duration: 90, easing: Easing.out(Easing.cubic) }),
|
|
||||||
withTiming(1.06, { duration: 110, easing: Easing.out(Easing.cubic) }),
|
|
||||||
withTiming(1, { duration: 120, easing: Easing.out(Easing.cubic) }, (finished) => {
|
|
||||||
if (finished) runOnJS(next)();
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onPressLike() {
|
|
||||||
if (busy) return;
|
|
||||||
setBusy(true);
|
|
||||||
playLikeAnimationAndThen(async () => {
|
|
||||||
await setReaction(item.id, 'like');
|
|
||||||
await addFavorite(item.id);
|
|
||||||
setIndex((i) => i + 1);
|
|
||||||
setTimeout(() => setLikeFilled(false), 220);
|
|
||||||
setBusy(false);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function onPressHate() {
|
|
||||||
if (busy) return;
|
|
||||||
setBusy(true);
|
|
||||||
playHateAnimationAndThen(async () => {
|
|
||||||
await setReaction(item.id, 'dislike');
|
|
||||||
setIndex((i) => i + 1);
|
|
||||||
setBusy(false);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onSelectTheme(next: ThemeMode) {
|
async function onSelectTheme(next: ThemeMode) {
|
||||||
setThemeModeState(next);
|
setThemeModeState(next);
|
||||||
await setThemeMode(next);
|
await setThemeMode(next);
|
||||||
@@ -144,40 +315,35 @@ export default function HomeScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.container, { backgroundColor }]}>
|
<View style={[styles.container, { backgroundColor }]} {...panResponder.panHandlers}>
|
||||||
<View style={styles.card}>
|
{themeMode === 'scenery' && (
|
||||||
<Animated.Text style={styles.text}>{item.text}</Animated.Text>
|
<ImageBackground
|
||||||
</View>
|
source={currentNatureImage}
|
||||||
|
style={StyleSheet.absoluteFill}
|
||||||
|
resizeMode="cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Animated.View style={[styles.card, textAnimatedStyle, themeMode === 'scenery' && styles.sceneryCard]}>
|
||||||
|
<Text style={[styles.text, themeMode === 'scenery' && styles.sceneryText]}>{item.text}</Text>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
<View style={styles.actions}>
|
<View style={styles.actions}>
|
||||||
<Animated.View style={[styles.reactionButton, hateAnimatedStyle]}>
|
|
||||||
<Pressable
|
|
||||||
onPress={onPressHate}
|
|
||||||
onPressIn={() => (hateScale.value = withTiming(0.92, { duration: 80 }))}
|
|
||||||
onPressOut={() => (hateScale.value = withTiming(1, { duration: 120 }))}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={t('home.dislike')}
|
|
||||||
hitSlop={10}
|
|
||||||
style={styles.reactionInner}
|
|
||||||
>
|
|
||||||
<HateIcon width={30} height={30} />
|
|
||||||
</Pressable>
|
|
||||||
</Animated.View>
|
|
||||||
|
|
||||||
<Animated.View style={[styles.reactionButton, likeAnimatedStyle]}>
|
<Animated.View style={[styles.reactionButton, likeAnimatedStyle]}>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={onPressLike}
|
onPress={onPressLike}
|
||||||
onPressIn={() => (likeScale.value = withTiming(0.92, { duration: 80 }))}
|
|
||||||
onPressOut={() => (likeScale.value = withTiming(1, { duration: 120 }))}
|
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={t('home.like')}
|
accessibilityLabel={t('home.like')}
|
||||||
hitSlop={10}
|
hitSlop={20}
|
||||||
style={styles.reactionInner}
|
style={styles.reactionInner}
|
||||||
>
|
>
|
||||||
{likeFilled ? (
|
{likeFilled ? (
|
||||||
<LikeFilledIcon width={30} height={30} />
|
<LikeFilledIcon width={35} height={36} style={{ color: '#EA6969' }} />
|
||||||
) : (
|
) : (
|
||||||
<LikeOutlineIcon width={30} height={30} />
|
<LikeIcon
|
||||||
|
width={35}
|
||||||
|
height={36}
|
||||||
|
style={{ color: themeMode === 'scenery' ? '#FFFFFF' : '#5E2A28' }}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
@@ -220,7 +386,7 @@ const styles = StyleSheet.create({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
padding: 20,
|
padding: 20,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
gap: 16,
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
headerRight: {
|
headerRight: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -236,37 +402,48 @@ const styles = StyleSheet.create({
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
card: {
|
card: {
|
||||||
borderRadius: 16,
|
position: 'absolute',
|
||||||
padding: 20,
|
top: 0,
|
||||||
backgroundColor: 'rgba(255,255,255,0.65)',
|
left: 0,
|
||||||
borderWidth: StyleSheet.hairlineWidth,
|
right: 0,
|
||||||
borderColor: '#E5E7EB',
|
bottom: 0,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 30,
|
||||||
|
zIndex: 5, // 降低层级,防止遮挡底部按钮
|
||||||
},
|
},
|
||||||
text: {
|
text: {
|
||||||
fontSize: 20,
|
fontSize: 22,
|
||||||
lineHeight: 28,
|
lineHeight: 32,
|
||||||
color: '#5E2A28',
|
color: '#5E2A28',
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
sceneryCard: {
|
||||||
|
// 风景模式下稍微收窄文案宽度,增加呼吸感
|
||||||
|
paddingHorizontal: 50,
|
||||||
|
},
|
||||||
|
sceneryText: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
textShadowColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
textShadowOffset: { width: 0, height: 1 },
|
||||||
|
textShadowRadius: 4,
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: SCREEN_HEIGHT * 0.16,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
gap: 12,
|
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
zIndex: 20, // 提升层级,确保在最顶层可点击
|
||||||
},
|
},
|
||||||
reactionButton: {
|
reactionButton: {
|
||||||
width: 58,
|
|
||||||
height: 58,
|
|
||||||
borderRadius: 29,
|
|
||||||
backgroundColor: 'rgba(255,255,255,0.75)',
|
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
reactionInner: {
|
reactionInner: {
|
||||||
width: 58,
|
|
||||||
height: 58,
|
|
||||||
borderRadius: 29,
|
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,66 +1,201 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
|
import * as Notifications from 'expo-notifications';
|
||||||
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 { IntentSelectionStep } from '@/components/onboarding/IntentSelectionStep';
|
import { SelectionStep } from '@/components/onboarding/SelectionStep';
|
||||||
import { setOnboardingCompleted, setUserProfile } from '@/src/storage/appStorage';
|
import { ReminderStep } from '@/components/onboarding/ReminderStep';
|
||||||
|
import { buildUserProfileFromQuestionnaire, mapOnboardingSelectionsToQuestionnaireAnswers } from '@/src/features/userProfileScoring';
|
||||||
|
import { fetchRecoFeed } from '@/src/services/recoApi';
|
||||||
|
import {
|
||||||
|
recordRecoFeedServed,
|
||||||
|
setOnboardingCompleted,
|
||||||
|
setUserProfile,
|
||||||
|
setDailyReminderSettings,
|
||||||
|
setUserProfileScoring,
|
||||||
|
setRecoFeedCache,
|
||||||
|
} from '@/src/storage/appStorage';
|
||||||
|
|
||||||
|
const STEPS = [
|
||||||
|
{ id: 'name', type: 'name', title: '我可以怎么称呼你?' },
|
||||||
|
{
|
||||||
|
id: 'status',
|
||||||
|
type: 'selection',
|
||||||
|
title: '媽媽的狀態?',
|
||||||
|
options: [
|
||||||
|
{ id: 'pregnant', label: '懷孕中/準備成為媽媽' },
|
||||||
|
{ id: 'has_kids', label: '已經有孩子' },
|
||||||
|
{ id: 'no_fill', label: '不想填寫' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'emotion',
|
||||||
|
type: 'selection',
|
||||||
|
title: '當下情緒狀態?',
|
||||||
|
options: [
|
||||||
|
{ id: 'happy', label: '愉悅、滿足' },
|
||||||
|
{ id: 'calm', label: '平靜、安穩' },
|
||||||
|
{ id: 'stressed', label: '被壓得有點喘不過氣' },
|
||||||
|
{ id: 'low', label: '情緒低落' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'influence',
|
||||||
|
type: 'selection',
|
||||||
|
title: '是什麼影響了你最近的感受?',
|
||||||
|
options: [
|
||||||
|
{ id: 'family', label: '家庭與孩子' },
|
||||||
|
{ id: 'work', label: '工作或學習' },
|
||||||
|
{ id: 'relationship', label: '親密關係' },
|
||||||
|
{ id: 'friends', label: '朋友與人際' },
|
||||||
|
{ id: 'health', label: '身心健康' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'support',
|
||||||
|
type: 'selection',
|
||||||
|
title: '最需要什麼支持?',
|
||||||
|
options: [
|
||||||
|
{ id: 'emotional', label: '情緒支持' },
|
||||||
|
{ id: 'parenting', label: '育兒壓力' },
|
||||||
|
{ id: 'self_worth', label: '自我價值' },
|
||||||
|
{ id: 'anxiety', label: '焦慮舒緩' },
|
||||||
|
{ id: 'balance', label: '休息與平衡' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ id: 'reminder', type: 'reminder', title: '你需要每天几次提醒?' },
|
||||||
|
];
|
||||||
|
|
||||||
export default function OnboardingScreen() {
|
export default function OnboardingScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [step, setStep] = useState(0);
|
const [stepIndex, setStepIndex] = useState(0);
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [intents, setIntents] = useState<string[]>([]);
|
const [selections, setSelections] = useState<Record<string, string[]>>({});
|
||||||
|
const [reminderTimes, setReminderTimes] = useState(3);
|
||||||
|
|
||||||
|
const currentStep = STEPS[stepIndex];
|
||||||
|
|
||||||
async function onFinish() {
|
async function onFinish() {
|
||||||
// Save whatever input we have
|
// 请求推送权限
|
||||||
await setUserProfile({ name, intents });
|
const { status } = await Notifications.requestPermissionsAsync();
|
||||||
|
const pushEnabled = status === 'granted';
|
||||||
|
|
||||||
|
// 将 Onboarding 选择映射为标准问卷枚举(允许跳过)
|
||||||
|
const answers = mapOnboardingSelectionsToQuestionnaireAnswers(selections);
|
||||||
|
|
||||||
|
// 生成用户画像(供推荐/Push/Widget 复用)
|
||||||
|
const scoringProfile = buildUserProfileFromQuestionnaire(answers);
|
||||||
|
await setUserProfileScoring(scoringProfile);
|
||||||
|
|
||||||
|
// Onboarding 结束后预拉取一次 Feed 文案(失败不阻塞进入首页)
|
||||||
|
try {
|
||||||
|
const { items, meta } = await fetchRecoFeed({
|
||||||
|
k: 30,
|
||||||
|
user_profile: {
|
||||||
|
profile_version: scoringProfile.profile_version,
|
||||||
|
profile_source: scoringProfile.profile_source,
|
||||||
|
profile_generated_at: scoringProfile.profile_generated_at,
|
||||||
|
profile_confidence: scoringProfile.profile_confidence,
|
||||||
|
profile_answered: scoringProfile.profile_answered,
|
||||||
|
stage: scoringProfile.stage,
|
||||||
|
emotion_score: scoringProfile.emotion_score,
|
||||||
|
context: scoringProfile.context,
|
||||||
|
need: scoringProfile.need,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await setRecoFeedCache({
|
||||||
|
saved_at: new Date().toISOString(),
|
||||||
|
items: items.map((x) => ({ content_id: x.content_id, text: x.text })),
|
||||||
|
meta: meta as Record<string, unknown>,
|
||||||
|
});
|
||||||
|
await recordRecoFeedServed(items.map((x) => x.content_id));
|
||||||
|
} catch {
|
||||||
|
// 网络失败时使用首页本地 mock 兜底
|
||||||
|
}
|
||||||
|
|
||||||
|
await setUserProfile({
|
||||||
|
name,
|
||||||
|
intents: Object.values(selections).flat()
|
||||||
|
});
|
||||||
|
await setDailyReminderSettings({
|
||||||
|
timesPerDay: reminderTimes,
|
||||||
|
pushEnabled: pushEnabled
|
||||||
|
});
|
||||||
await setOnboardingCompleted(true);
|
await setOnboardingCompleted(true);
|
||||||
router.replace('/(onboarding)/push-prompt');
|
router.replace('/(app)/home');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onNext() {
|
const onNext = () => {
|
||||||
if (step === 0) {
|
if (stepIndex < STEPS.length - 1) {
|
||||||
setStep(1);
|
setStepIndex(stepIndex + 1);
|
||||||
} else {
|
} else {
|
||||||
await onFinish();
|
onFinish();
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
async function onSkip() {
|
const onBack = () => {
|
||||||
// Skip logic: move to next step regardless of input
|
if (stepIndex > 0) {
|
||||||
if (step === 0) {
|
setStepIndex(stepIndex - 1);
|
||||||
setStep(1);
|
|
||||||
} else {
|
|
||||||
await onFinish();
|
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
// Step 0: Next button requires input
|
const onSkip = async () => {
|
||||||
// Step 1: Next button always enabled (can proceed with empty selection)
|
// 跳过整个 Onboarding:仍生成一个“全跳过”的最小画像,保证下游可用
|
||||||
const nextEnabled = step === 0 ? name.trim().length > 0 : true;
|
const scoringProfile = buildUserProfileFromQuestionnaire({});
|
||||||
|
await setUserProfileScoring(scoringProfile);
|
||||||
|
|
||||||
|
// 标记已完成,避免下次启动再次进入 Onboarding
|
||||||
|
await setOnboardingCompleted(true);
|
||||||
|
router.replace('/(app)/home');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 题目为单选:再次点击可取消;选择其他选项会替换为唯一选项
|
||||||
|
const handleToggleSelection = (id: string) => {
|
||||||
|
setSelections(prev => {
|
||||||
|
const currentIds = prev[currentStep.id] || [];
|
||||||
|
const nextIds = currentIds.includes(id) ? [] : [id];
|
||||||
|
return { ...prev, [currentStep.id]: nextIds };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSkipStep = () => {
|
||||||
|
setSelections((prev) => ({ ...prev, [currentStep.id]: [] }));
|
||||||
|
onNext();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OnboardingLayout
|
<OnboardingLayout
|
||||||
|
title={currentStep.title}
|
||||||
|
currentStep={stepIndex}
|
||||||
|
totalSteps={STEPS.length - 1}
|
||||||
onSkip={onSkip}
|
onSkip={onSkip}
|
||||||
onNext={onNext}
|
onBack={onBack}
|
||||||
nextEnabled={nextEnabled}
|
showBackButton={stepIndex > 0}
|
||||||
>
|
>
|
||||||
{step === 0 ? (
|
{currentStep.type === 'name' && (
|
||||||
<NameInputStep
|
<NameInputStep
|
||||||
value={name}
|
value={name}
|
||||||
onChangeText={setName}
|
onChangeText={setName}
|
||||||
onSubmitEditing={name.trim().length > 0 ? onNext : undefined}
|
onNext={onNext}
|
||||||
/>
|
/>
|
||||||
) : (
|
)}
|
||||||
<IntentSelectionStep
|
|
||||||
selectedIds={intents}
|
{currentStep.type === 'selection' && (
|
||||||
onToggle={(id) => {
|
<SelectionStep
|
||||||
setIntents(prev =>
|
options={currentStep.options!}
|
||||||
prev.includes(id)
|
selectedIds={selections[currentStep.id] || []}
|
||||||
? prev.filter(i => i !== id)
|
onToggle={handleToggleSelection}
|
||||||
: [...prev, id]
|
onNext={onNext}
|
||||||
);
|
onSkip={handleSkipStep}
|
||||||
}}
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentStep.type === 'reminder' && (
|
||||||
|
<ReminderStep
|
||||||
|
value={reminderTimes}
|
||||||
|
onChange={setReminderTimes}
|
||||||
|
onFinish={onFinish}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</OnboardingLayout>
|
</OnboardingLayout>
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { View, Text, Image, StyleSheet, TouchableOpacity, Dimensions, Platform, Alert } from 'react-native';
|
import { View, Text, StyleSheet, TouchableOpacity, Dimensions, Platform, Alert, Image } from 'react-native';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
|
||||||
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 { 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';
|
||||||
|
|
||||||
|
// 导入 SVG 组件
|
||||||
|
import FlowersBg from '../../assets/images/index/flowers_endbg.svg';
|
||||||
|
import WelcomeBtn from '../../assets/images/index/welcome_btn.svg';
|
||||||
|
|
||||||
const { width, height } = Dimensions.get('window');
|
const { width, height } = Dimensions.get('window');
|
||||||
|
|
||||||
export default function SplashScreen() {
|
export default function SplashScreen() {
|
||||||
@@ -22,18 +25,15 @@ export default function SplashScreen() {
|
|||||||
const accepted = await getConsentAccepted();
|
const accepted = await getConsentAccepted();
|
||||||
setShowConsent(!accepted);
|
setShowConsent(!accepted);
|
||||||
if (accepted) {
|
if (accepted) {
|
||||||
// 如果已经同意过,直接跳转到首页分发
|
router.replace('/');
|
||||||
// 注意:这里需要与 app/index.tsx 配合,如果 app/index.tsx 已经判断了 consent=true 不会跳过来,
|
|
||||||
// 那么这里其实是防守。
|
|
||||||
// 但如果用户是通过 deep link 或其他方式强制进入 splash,这个逻辑会把他们送走。
|
|
||||||
router.replace('/');
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAgree = async () => {
|
const handleAgree = async () => {
|
||||||
await setConsentAccepted(true);
|
await setConsentAccepted(true);
|
||||||
setShowConsent(false);
|
setShowConsent(false);
|
||||||
router.replace('/');
|
// 跳转到 onboarding 流程
|
||||||
|
router.replace('/(onboarding)/onboarding');
|
||||||
};
|
};
|
||||||
|
|
||||||
const openLink = async (url: string) => {
|
const openLink = async (url: string) => {
|
||||||
@@ -44,31 +44,40 @@ export default function SplashScreen() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const bgDecorationTop = 363;
|
||||||
|
const bgDecorationHeight = height * 0.6;
|
||||||
|
const contentTop = bgDecorationTop + (bgDecorationHeight * 0.25);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<Image
|
{/* 中间的背景装饰 SVG (现在放在上面,作为上层) */}
|
||||||
source={require('../../assets/images/index/index_flowers.png')}
|
<View style={[styles.bgDecorationContainer, { top: bgDecorationTop }]}>
|
||||||
style={styles.image}
|
<FlowersBg width={width + 10} height={bgDecorationHeight} />
|
||||||
resizeMode="contain"
|
</View>
|
||||||
/>
|
|
||||||
|
|
||||||
<View style={styles.contentContainer}>
|
{/* 顶部的花图片 (现在放在下面,作为下层) */}
|
||||||
<Text style={styles.title}>{t('consent.title')}</Text>
|
<View style={styles.topImageContainer}>
|
||||||
<Text style={styles.subtitle}>{t('consent.subtitle')}</Text>
|
<Image
|
||||||
|
source={require('../../assets/images/index/index_flowers.png')}
|
||||||
|
style={styles.topImage}
|
||||||
|
resizeMode="contain"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 文案内容 */}
|
||||||
|
<View style={[styles.contentContainer, { position: 'absolute', top: contentTop }]}>
|
||||||
|
<Text style={styles.titleText}>
|
||||||
|
You Are Perfect.{"\n"}
|
||||||
|
Everything{"\n"}
|
||||||
|
Will Be Better.
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<SafeAreaView style={styles.bottomContainer} edges={['bottom']}>
|
<SafeAreaView style={styles.bottomContainer} edges={['bottom']}>
|
||||||
{showConsent && (
|
{showConsent && (
|
||||||
<>
|
<>
|
||||||
<TouchableOpacity onPress={handleAgree} activeOpacity={0.8}>
|
<TouchableOpacity onPress={handleAgree} activeOpacity={0.8} style={styles.buttonWrapper}>
|
||||||
<LinearGradient
|
<WelcomeBtn width={87} height={57} />
|
||||||
colors={['#F69F7B', '#F99CC0']}
|
|
||||||
start={{ x: 0, y: 0 }}
|
|
||||||
end={{ x: 1, y: 0 }}
|
|
||||||
style={styles.button}
|
|
||||||
>
|
|
||||||
<Text style={styles.buttonText}>{t('consent.agree')}</Text>
|
|
||||||
</LinearGradient>
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
<View style={styles.linksContainer}>
|
<View style={styles.linksContainer}>
|
||||||
@@ -90,74 +99,57 @@ export default function SplashScreen() {
|
|||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: '#FFF9F0', // 假设的背景色,根据图片调整
|
backgroundColor: '#F5D3B5', // 匹配 Figma 背景色
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
image: {
|
topImageContainer: {
|
||||||
width: width * 0.8,
|
marginTop: 60,
|
||||||
height: height * 0.5,
|
zIndex: 1, // 降低层级
|
||||||
marginTop: height * 0.1,
|
},
|
||||||
|
topImage: {
|
||||||
|
width: 308,
|
||||||
|
height: 354,
|
||||||
|
},
|
||||||
|
bgDecorationContainer: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: -3,
|
||||||
|
zIndex: 2, // 提高层级,使其覆盖在图片之上
|
||||||
},
|
},
|
||||||
contentContainer: {
|
contentContainer: {
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
marginTop: 20,
|
zIndex: 3,
|
||||||
paddingHorizontal: 30,
|
|
||||||
},
|
},
|
||||||
title: {
|
titleText: {
|
||||||
fontSize: 24,
|
fontSize: 30,
|
||||||
fontWeight: 'bold',
|
lineHeight: 42,
|
||||||
color: '#4A3427', // 深褐色
|
color: '#772F00', // 匹配 Figma 文字颜色
|
||||||
marginBottom: 10,
|
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
fontFamily: Platform.OS === 'ios' ? 'Georgia' : 'serif', // 尝试匹配衬线体
|
fontWeight: '600',
|
||||||
},
|
fontFamily: Platform.OS === 'ios' ? 'STIX Two Text' : 'serif',
|
||||||
subtitle: {
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
color: '#4A3427', // 深褐色
|
|
||||||
textAlign: 'center',
|
|
||||||
lineHeight: 24,
|
|
||||||
fontFamily: Platform.OS === 'ios' ? 'Georgia' : 'serif',
|
|
||||||
},
|
},
|
||||||
bottomContainer: {
|
bottomContainer: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: 0,
|
bottom: 60,
|
||||||
width: '100%',
|
width: '100%',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingBottom: 20,
|
zIndex: 4,
|
||||||
},
|
},
|
||||||
button: {
|
buttonWrapper: {
|
||||||
width: width * 0.8,
|
marginBottom: 40,
|
||||||
paddingVertical: 16,
|
|
||||||
borderRadius: 30,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
marginBottom: 20,
|
|
||||||
shadowColor: '#F69F7B',
|
|
||||||
shadowOffset: { width: 0, height: 4 },
|
|
||||||
shadowOpacity: 0.3,
|
|
||||||
shadowRadius: 8,
|
|
||||||
elevation: 5,
|
|
||||||
},
|
|
||||||
buttonText: {
|
|
||||||
color: '#FFF',
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
},
|
||||||
linksContainer: {
|
linksContainer: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
marginBottom: 10,
|
|
||||||
},
|
},
|
||||||
linkText: {
|
linkText: {
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: '#999',
|
color: 'rgba(119, 47, 0, 0.5)', // 使用半透明的文字颜色
|
||||||
textDecorationLine: 'underline',
|
textDecorationLine: 'underline',
|
||||||
},
|
},
|
||||||
divider: {
|
divider: {
|
||||||
width: 1,
|
width: 1,
|
||||||
height: 12,
|
height: 12,
|
||||||
backgroundColor: '#CCC',
|
backgroundColor: 'rgba(119, 47, 0, 0.2)',
|
||||||
marginHorizontal: 15,
|
marginHorizontal: 15,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,12 +3,22 @@ import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native
|
|||||||
import { useFonts } from 'expo-font';
|
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 { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import 'react-native-reanimated';
|
import 'react-native-reanimated';
|
||||||
|
|
||||||
import { useColorScheme } from '@/components/useColorScheme';
|
import { useColorScheme } from '@/components/useColorScheme';
|
||||||
import { initI18n } from '@/src/i18n';
|
import { initI18n } from '@/src/i18n';
|
||||||
|
|
||||||
|
// 配置通知处理方式(即使不发送也建议配置,以确保权限接口正常)
|
||||||
|
Notifications.setNotificationHandler({
|
||||||
|
handleNotification: async () => ({
|
||||||
|
shouldShowAlert: true,
|
||||||
|
shouldPlaySound: false,
|
||||||
|
shouldSetBadge: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
export {
|
export {
|
||||||
// Catch any errors thrown by the Layout component.
|
// Catch any errors thrown by the Layout component.
|
||||||
ErrorBoundary,
|
ErrorBoundary,
|
||||||
|
|||||||
@@ -22,9 +22,17 @@ export default function Index() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 检查 Onboarding
|
// 2. 检查 Onboarding 是否已完成
|
||||||
const completed = await getOnboardingCompleted();
|
const completed = await getOnboardingCompleted();
|
||||||
router.replace(completed ? '/(app)/home' : '/(onboarding)/onboarding');
|
if (cancelled) return;
|
||||||
|
|
||||||
|
if (completed) {
|
||||||
|
// 如果已经完成过流程,直接进 Home
|
||||||
|
router.replace('/(app)/home');
|
||||||
|
} else {
|
||||||
|
// 如果是首次进入(或未完成流程),进入 Onboarding
|
||||||
|
router.replace('/(onboarding)/onboarding');
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
@@ -41,4 +49,3 @@ export default function Index() {
|
|||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: { flex: 1, alignItems: 'center', justifyContent: 'center' },
|
container: { flex: 1, alignItems: 'center', justifyContent: 'center' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 93 KiB |
53
client/assets/images/home/Profile/Default_avatar.svg
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
client/assets/images/home/Profile/widget/Widget1_en.png
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
client/assets/images/home/Profile/widget/Widget1_tw.png
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
client/assets/images/home/Profile/widget/Widget2_en.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
client/assets/images/home/Profile/widget/Widget2_tw.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 42 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="19" height="19" viewBox="0 0 19 19" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M8.52529 12.1958C8.05105 12.1958 7.62296 11.8613 7.62737 11.3871C7.63044 11.0573 7.68303 10.7472 7.78512 10.4569C7.98347 9.93995 8.27273 9.47781 8.65289 9.0705C9.04959 8.64752 9.47934 8.26371 9.94215 7.91906C10.4215 7.55875 10.876 7.20627 11.3058 6.86162C11.7355 6.51697 12.0909 6.16449 12.3719 5.80418C12.6529 5.4282 12.7934 5.02089 12.7934 4.58225C12.7934 4.03394 12.6529 3.5483 12.3719 3.12533C12.0909 2.70235 11.6777 2.37337 11.1322 2.13838C10.5868 1.88773 9.8843 1.7624 9.02479 1.7624C8.18182 1.7624 7.36364 1.94256 6.57025 2.30287C6.01404 2.54963 5.47478 2.89275 4.95246 3.33224C4.59297 3.63473 4.06302 3.65873 3.7099 3.34884C3.32525 3.01128 3.30306 2.41578 3.69475 2.08642C4.33235 1.55029 5.01812 1.10547 5.75207 0.751958C6.76033 0.250653 7.93388 0 9.27273 0C10.4463 0 11.4545 0.180157 12.2975 0.54047C13.157 0.900783 13.8182 1.40992 14.281 2.06789C14.7603 2.71018 15 3.48564 15 4.39426C15 4.98956 14.8595 5.51436 14.5785 5.96867C14.2975 6.42298 13.9339 6.83812 13.4876 7.2141C13.0579 7.59008 12.6033 7.95822 12.124 8.31854C11.6446 8.66318 11.1983 9.0235 10.7851 9.39948C10.3884 9.77546 10.0826 10.1906 9.86777 10.6449C9.81955 10.7554 9.78061 10.8695 9.75095 10.9874C9.59709 11.5988 9.15576 12.1958 8.52529 12.1958ZM8.77686 18C8.33058 18 7.95041 17.859 7.63636 17.577C7.32231 17.295 7.16529 16.9191 7.16529 16.4491C7.16529 15.9791 7.32231 15.6031 7.63636 15.3211C7.95041 15.0235 8.33058 14.8747 8.77686 14.8747C9.20661 14.8747 9.57851 15.0235 9.89256 15.3211C10.2066 15.6031 10.3636 15.9791 10.3636 16.4491C10.3636 16.9191 10.2066 17.295 9.89256 17.577C9.57851 17.859 9.20661 18 8.77686 18Z" fill="#404040"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
3
client/assets/images/icon/Push_icon.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 22.7325C12.5691 22.7303 13.1259 22.5663 13.6053 22.2595C14.0847 21.9527 14.467 21.5159 14.7075 21H9.29246C9.53293 21.5159 9.91518 21.9527 10.3946 22.2595C10.874 22.5663 11.4308 22.7303 12 22.7325ZM21.75 16.7325C20.1075 14.835 19.8 12.9825 19.8 10.7325C19.7892 8.98974 19.1911 7.30149 18.1024 5.94058C17.0137 4.57967 15.4979 3.62559 13.8 3.23252C13.8039 3.16258 13.8039 3.09247 13.8 3.02252C13.8 2.52524 13.6024 2.04833 13.2508 1.6967C12.8992 1.34507 12.4222 1.14752 11.925 1.14752C11.4277 1.14752 10.9508 1.34507 10.5991 1.6967C10.2475 2.04833 10.05 2.52524 10.05 3.02252C10.046 3.09247 10.046 3.16258 10.05 3.23252C8.35205 3.62559 6.83626 4.57967 5.74753 5.94058C4.6588 7.30149 4.06073 8.98974 4.04996 10.7325C4.2115 12.9108 3.51125 15.0654 2.09996 16.7325C1.41746 17.6625 1.83746 19.5 3.31496 19.5H20.685C22.1625 19.5 22.59 17.6625 21.75 16.725V16.7325ZM15.75 13.5H13.5V15.75C13.5 16.1478 13.3419 16.5294 13.0606 16.8107C12.7793 17.092 12.3978 17.25 12 17.25C11.6021 17.25 11.2206 17.092 10.9393 16.8107C10.658 16.5294 10.5 16.1478 10.5 15.75V13.5H8.24996C7.85214 13.5 7.4706 13.342 7.1893 13.0607C6.908 12.7794 6.74996 12.3978 6.74996 12C6.74996 11.6022 6.908 11.2207 7.1893 10.9394C7.4706 10.6581 7.85214 10.5 8.24996 10.5H10.5V8.25002C10.5 7.8522 10.658 7.47067 10.9393 7.18936C11.2206 6.90806 11.6021 6.75002 12 6.75002C12.3978 6.75002 12.7793 6.90806 13.0606 7.18936C13.3419 7.47067 13.5 7.8522 13.5 8.25002V10.5H15.75C16.1478 10.5 16.5293 10.6581 16.8106 10.9394C17.0919 11.2207 17.25 11.6022 17.25 12C17.25 12.3978 17.0919 12.7794 16.8106 13.0607C16.5293 13.342 16.1478 13.5 15.75 13.5Z" fill="#F59250"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
3
client/assets/images/icon/Terms_icon.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M8.535 7.5H15.4575C15.681 7.50404 15.8971 7.4196 16.0586 7.26508C16.2201 7.11056 16.3141 6.89847 16.32 6.675V4.9125C16.3102 4.35536 16.1907 3.80561 15.9682 3.29472C15.7457 2.78384 15.4246 2.32185 15.0234 1.93521C14.6221 1.54857 14.1485 1.24487 13.6297 1.0415C13.1109 0.838125 12.5571 0.73907 12 0.750004C10.8773 0.725863 9.79101 1.14831 8.9796 1.92456C8.1682 2.70081 7.69808 3.76738 7.6725 4.89V6.675C7.6784 6.89847 7.77235 7.11056 7.93389 7.26508C8.09544 7.4196 8.31149 7.50404 8.535 7.5ZM12 3C12.2967 3 12.5867 3.08798 12.8334 3.2528C13.08 3.41762 13.2723 3.65189 13.3858 3.92598C13.4994 4.20007 13.5291 4.50167 13.4712 4.79264C13.4133 5.08361 13.2704 5.35089 13.0607 5.56066C12.8509 5.77044 12.5836 5.9133 12.2926 5.97118C12.0017 6.02906 11.7001 5.99935 11.426 5.88582C11.1519 5.77229 10.9176 5.58003 10.7528 5.33336C10.588 5.08669 10.5 4.79668 10.5 4.5C10.5 4.10218 10.658 3.72065 10.9393 3.43934C11.2206 3.15804 11.6022 3 12 3ZM18.96 3.75H17.73C17.7929 4.08894 17.8255 4.43279 17.8275 4.7775V6.615C17.8295 6.92687 17.7699 7.23605 17.6521 7.52484C17.5344 7.81362 17.3608 8.07633 17.1413 8.2979C16.9218 8.51946 16.6608 8.69554 16.3731 8.81602C16.0855 8.9365 15.7769 8.99902 15.465 9H8.535C8.22313 8.99902 7.91453 8.9365 7.62687 8.81602C7.33921 8.69554 7.07816 8.51946 6.85869 8.2979C6.63921 8.07633 6.46562 7.81362 6.34787 7.52484C6.23012 7.23605 6.17052 6.92687 6.1725 6.615V4.7775C6.17451 4.43279 6.20713 4.08894 6.27 3.75H5.04C4.30065 3.75198 3.59216 4.04656 3.06936 4.56936C2.54656 5.09216 2.25198 5.80066 2.25 6.54V20.46C2.25198 21.1993 2.54656 21.9078 3.06936 22.4306C3.59216 22.9534 4.30065 23.248 5.04 23.25H18.96C19.6993 23.248 20.4078 22.9534 20.9306 22.4306C21.4534 21.9078 21.748 21.1993 21.75 20.46V6.54C21.748 5.80066 21.4534 5.09216 20.9306 4.56936C20.4078 4.04656 19.6993 3.75198 18.96 3.75ZM18.345 15.5775L16.095 19.3275C15.995 19.4947 15.8532 19.6331 15.6836 19.7291C15.514 19.8251 15.3224 19.8754 15.1275 19.875H14.9625C14.743 19.8416 14.5383 19.7441 14.3741 19.5946C14.21 19.4451 14.0937 19.2504 14.04 19.035L13.29 15.9825L11.6475 19.26C11.5538 19.4462 11.4102 19.6028 11.2328 19.7121C11.0553 19.8215 10.851 19.8794 10.6425 19.8794C10.434 19.8794 10.2297 19.8215 10.0522 19.7121C9.87476 19.6028 9.73118 19.4462 9.6375 19.26L8.25 16.5L7.8225 17.1375C7.6574 17.3861 7.4003 17.559 7.10774 17.6181C6.81518 17.6772 6.51114 17.6176 6.2625 17.4525C6.01386 17.2874 5.84099 17.0303 5.78191 16.7377C5.72284 16.4452 5.7824 16.1411 5.9475 15.8925L7.4475 13.6425C7.55672 13.4785 7.70712 13.346 7.88364 13.2583C8.06016 13.1707 8.2566 13.131 8.45331 13.1431C8.65001 13.1553 8.84008 13.2189 9.00446 13.3276C9.16885 13.4363 9.3018 13.5863 9.39 13.7625L10.635 16.2525L12.63 12.2625C12.7326 12.0561 12.8961 11.8862 13.0984 11.7757C13.3008 11.6653 13.5321 11.6197 13.7612 11.645C13.9903 11.6704 14.2061 11.7655 14.3794 11.9175C14.5527 12.0696 14.675 12.2711 14.73 12.495L15.57 15.8625L16.425 14.4375C16.5857 14.198 16.8322 14.0294 17.1137 13.9664C17.3952 13.9035 17.6901 13.9511 17.9375 14.0993C18.1849 14.2476 18.3659 14.4852 18.4431 14.763C18.5203 15.0409 18.4879 15.3379 18.3525 15.5925L18.345 15.5775Z" fill="#88D081"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.2 KiB |
4
client/assets/images/icon/add_icon.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="47" height="47" viewBox="0 0 47 47" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="23.5" cy="23.5" r="23.5" fill="#F5D3B5"/>
|
||||||
|
<path d="M23.5 14.1562C23.9799 14.1563 24.3689 14.5455 24.3691 15.0254V22.6309H31.9746C32.4544 22.6311 32.8437 23.0202 32.8438 23.5C32.8438 23.9799 32.4545 24.3689 31.9746 24.3691H24.3691V31.9746C24.3689 32.4545 23.9799 32.8438 23.5 32.8438C23.0201 32.8437 22.6311 32.4544 22.6309 31.9746V24.3691H15.0254C14.5455 24.3689 14.1562 23.9799 14.1562 23.5C14.1563 23.0202 14.5456 22.6311 15.0254 22.6309H22.6309V15.0254C22.6311 14.5456 23.0201 14.1563 23.5 14.1562Z" fill="#8F521B"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 635 B |
BIN
client/assets/images/icon/back_icon.png
Normal file
|
After Width: | Height: | Size: 739 B |
5
client/assets/images/icon/btn_Notclicked.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg width="87" height="57" viewBox="0 0 87 57" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M0 28.5C0 12.7599 12.7599 0 28.5 0H58.5C74.2401 0 87 12.7599 87 28.5C87 44.2401 74.2401 57 58.5 57H28.5C12.7599 57 0 44.2401 0 28.5Z" fill="#F2DDCA"/>
|
||||||
|
<path d="M32 29L54 29" stroke="white" stroke-width="3" stroke-linecap="round"/>
|
||||||
|
<path d="M49 22L55.2929 28.2929C55.6834 28.6834 55.6834 29.3166 55.2929 29.7071L49 36" stroke="white" stroke-width="3" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 487 B |
11
client/assets/images/icon/btn_clicked.svg
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<svg width="87" height="57" viewBox="0 0 87 57" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M0 28.5C0 12.7599 12.7599 0 28.5 0H58.5C74.2401 0 87 12.7599 87 28.5C87 44.2401 74.2401 57 58.5 57H28.5C12.7599 57 0 44.2401 0 28.5Z" fill="url(#paint0_linear_32_2902)"/>
|
||||||
|
<path d="M32 29L54 29" stroke="white" stroke-width="3" stroke-linecap="round"/>
|
||||||
|
<path d="M49 22L55.2929 28.2929C55.6834 28.6834 55.6834 29.3166 55.2929 29.7071L49 36" stroke="white" stroke-width="3" stroke-linecap="round"/>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="paint0_linear_32_2902" x1="0" y1="28.5" x2="87" y2="28.5" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#F69F7B"/>
|
||||||
|
<stop offset="1" stop-color="#F99CC0"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 719 B |
3
client/assets/images/icon/enter_Light_icon.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="3" height="27" viewBox="0 0 3 27" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M1.5 1.5L1.5 25.5" stroke="#F89DB4" stroke-width="3" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 188 B |
3
client/assets/images/icon/language_icon.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M8.49467 13.5033C8.78067 16.7229 9.77067 19.726 11.428 21.4441L11.9707 22L12.5133 21.4441C14.2073 19.7044 15.19 16.7013 15.4467 13.5033C14.3027 13.5827 13.122 13.626 11.9413 13.626C10.7607 13.626 9.63867 13.5899 8.49467 13.5033ZM15.5933 10.9694C15.5053 7.22276 14.486 3.5483 12.5427 1.58474L12 1L11.4573 1.55586C9.514 3.55552 8.524 7.22276 8.40667 10.9405C10.7955 11.1313 13.1957 11.141 15.586 10.9694H15.5933ZM22.4427 9.52561L22.7873 9.35957C22.4041 7.50584 21.5342 5.78328 20.2643 4.36346C18.9944 2.94363 17.3687 1.87607 15.5493 1.2671C17.1553 3.72155 18.0133 7.16501 18.1307 10.6518C19.614 10.4828 21.0657 10.1086 22.4427 9.54005V9.52561ZM18.0867 13.2362C17.9566 16.2416 17.0777 19.1695 15.5273 21.7618C17.6163 21.0689 19.4457 19.7741 20.7781 18.0454C22.1106 16.3167 22.8847 14.2337 23 12.0667C21.415 12.6465 19.7657 13.0391 18.0867 13.2362ZM1 12.0667C1.11534 14.2337 1.88942 16.3167 3.22187 18.0454C4.55432 19.7741 6.38366 21.0689 8.47267 21.7618C6.92232 19.1695 6.0434 16.2416 5.91333 13.2362C4.23432 13.0391 2.58501 12.6465 1 12.0667ZM5.84 10.6879C5.97933 7.17944 6.86667 3.72877 8.45067 1.2671C6.6331 1.87484 5.00872 2.94021 3.73896 4.35734C2.46919 5.77447 1.59825 7.49403 1.21267 9.34513L1.55733 9.51117C2.93096 10.102 4.38274 10.4982 5.86933 10.6879H5.84Z" fill="#EEB054"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
3
client/assets/images/icon/like_icon.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="35" height="36" viewBox="0 0 35 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M25.0879 6C26.9887 6 28.7592 6.93575 30.0762 8.42188C31.3956 9.91097 32.2001 11.89 32.2002 13.8408C32.2002 18.2337 29.1054 22.3696 25.5225 25.499C23.7556 27.0422 21.9254 28.2909 20.4199 29.1494C19.6667 29.5789 19.0062 29.904 18.4863 30.1182C18.2262 30.2253 18.0121 30.3001 17.8467 30.3467C17.6728 30.3956 17.5993 30.4002 17.5996 30.4004C17.5928 30.3997 17.5178 30.3932 17.3525 30.3467C17.1871 30.3001 16.973 30.2253 16.7129 30.1182C16.193 29.904 15.5333 29.5788 14.7803 29.1494C13.2748 28.2909 11.4447 27.0423 9.67773 25.499C6.0947 22.3696 3 18.2338 3 13.8408C3.00008 11.8901 3.80381 9.91108 5.12207 8.42188C6.43787 6.93557 8.20587 6.00033 10.1006 6C11.5129 6.00117 12.8927 6.41742 14.0635 7.19434C14.7589 7.65582 15.3618 8.2322 15.8486 8.89355C16.2951 9.50017 17.0032 9.73135 17.5996 9.73145C18.1961 9.73144 18.905 9.5003 19.3516 8.89355C19.8376 8.23336 20.4389 7.65737 21.1328 7.19629C22.3012 6.4199 23.6779 6.00339 25.0879 6Z" stroke="#5E2A28" stroke-width="2"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
3
client/assets/images/icon/mylike_icon.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="37" height="33" viewBox="0 0 37 33" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M27.38 0C25.4752 0.00420958 23.6144 0.597538 22.0335 1.70479C21.0003 2.42849 20.117 3.35044 19.4272 4.41503C19.0322 5.02468 17.9678 5.02469 17.5728 4.41504C16.8819 3.34867 15.9968 2.42544 14.9614 1.70122C13.3781 0.593805 11.5146 0.00166536 9.60767 0C4.30433 0 0 5.51285 0 11.0514C0 23.1565 16.0333 33 18.5 33C20.9667 33 37 23.1565 37 11.0514C37 5.51285 32.6957 0 27.38 0Z" fill="#FE3C3C" fill-opacity="0.76"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 522 B |
3
client/assets/images/icon/next_icon.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="6" height="10" viewBox="0 0 6 10" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M1 1L4.29289 4.29289C4.68342 4.68342 4.68342 5.31658 4.29289 5.70711L1 9" stroke="#C7B4A1" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 243 B |
3
client/assets/images/icon/privacy_icon.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M21.48 5.6925L21.27 4.035L12 0.75L2.73003 4.035L2.52003 5.685C2.44503 6.24 0.915026 19.2525 11.0775 22.935L12 23.25L12.915 22.92C23.0475 19.275 21.555 6.2475 21.48 5.6925ZM17.85 9.1425L12.8925 17.0925L12.825 17.1975C12.5406 17.6509 12.0883 17.9733 11.5669 18.0941C11.0455 18.215 10.4974 18.1245 10.0425 17.8425C9.83101 17.7097 9.64547 17.5394 9.49503 17.34L6.29253 13.0725C6.01107 12.791 5.85294 12.4093 5.85294 12.0112C5.85294 11.6132 6.01107 11.2315 6.29253 10.95C6.57399 10.6685 6.95573 10.5104 7.35378 10.5104C7.75182 10.5104 8.13357 10.6685 8.41503 10.95L10.7625 12.75L15.45 7.3875C15.6936 7.0877 16.0435 6.89345 16.4268 6.84521C16.8101 6.79696 17.1972 6.89845 17.5075 7.12853C17.8178 7.3586 18.0274 7.69953 18.0926 8.08029C18.1578 8.46105 18.0736 8.85228 17.8575 9.1725L17.85 9.1425Z" fill="#879CE7"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 920 B |
4
client/assets/images/icon/reduce_icon.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="47" height="47" viewBox="0 0 47 47" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="23.5" cy="23.5" r="23.5" fill="#F5D3B5"/>
|
||||||
|
<path d="M31.9746 22.6309C32.4544 22.6311 32.8437 23.0202 32.8438 23.5C32.8438 23.9799 32.4545 24.3689 31.9746 24.3691H15.0254C14.5455 24.3689 14.1562 23.9799 14.1562 23.5C14.1563 23.0202 14.5456 22.6311 15.0254 22.6309H31.9746Z" fill="#8F521B"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 404 B |
3
client/assets/images/icon/selected_icon.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M8.34273 11.3513C8.03868 11.81 7.74043 12.2721 7.4301 12.7472C7.33503 12.6924 7.25878 12.6515 7.1859 12.6053C6.46053 12.148 5.7395 11.684 5.00979 11.2335C4.19176 10.7281 3.36408 10.2386 2.42298 9.9833C1.52725 9.74047 0.938938 9.90444 0.473698 10.5824C0.296096 10.8411 0.157103 11.1426 0.0721628 11.4446C-0.0759998 11.9701 -0.00843386 12.4745 0.391171 12.8943C2.3197 14.9191 4.2656 16.9271 6.37607 18.7687L6.41256 18.8006C6.69623 19.0482 6.98003 19.296 7.2776 19.526C7.88183 19.9939 8.57921 20.0718 9.30747 19.9468C9.84414 19.8545 10.3417 19.6592 10.7481 19.2799C11.2181 18.8408 11.5632 18.323 11.7915 17.7186C12.1829 16.6809 12.5477 15.6298 13.0072 14.622C14.4999 11.3489 16.2624 8.22294 18.2995 5.25424C21.1869 1.88662 20.2691 -2.14591 16.5163 1.34116C13.3774 4.3416 10.7346 7.74499 8.34273 11.3513Z" fill="#20BD40"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 931 B |
BIN
client/assets/images/icon/skip_icon.png
Normal file
|
After Width: | Height: | Size: 255 B |
6
client/assets/images/icon/weight_icon.svg
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<svg width="39" height="39" viewBox="0 0 39 39" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M3.28564 2.592C3.28564 1.16048 4.44612 0 5.87764 0H16.5581C17.9896 0 19.1501 1.16048 19.1501 2.592V13.2725C19.1501 14.704 17.9896 15.8645 16.5581 15.8645H5.87764C4.44612 15.8645 3.28564 14.704 3.28564 13.2725V2.592Z" fill="#A360F5"/>
|
||||||
|
<path d="M22.811 2.592C22.811 1.16048 23.9715 0 25.403 0H36.0835C37.515 0 38.6755 1.16048 38.6755 2.592V13.2725C38.6755 14.704 37.515 15.8645 36.0835 15.8645H25.403C23.9715 15.8645 22.811 14.704 22.811 13.2725V2.592Z" fill="#FB84D3"/>
|
||||||
|
<path d="M1.83282 29.2908C0.820581 28.2786 0.820582 26.6374 1.83282 25.6252L9.38505 18.073C10.3973 17.0607 12.0385 17.0607 13.0507 18.073L20.6029 25.6252C21.6152 26.6374 21.6152 28.2786 20.6029 29.2908L13.0507 36.8431C12.0384 37.8553 10.3973 37.8553 9.38505 36.8431L1.83282 29.2908Z" fill="#FB84D3"/>
|
||||||
|
<path d="M22.811 22.1182C22.811 20.6867 23.9715 19.5262 25.403 19.5262H36.0835C37.515 19.5262 38.6755 20.6867 38.6755 22.1182V32.7986C38.6755 34.2302 37.515 35.3906 36.0835 35.3906H25.403C23.9715 35.3906 22.811 34.2302 22.811 32.7986V22.1182Z" fill="#FCB473"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
3
client/assets/images/index/flowers_endbg.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="375" height="449" viewBox="0 0 375 449" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M187.5 64.2824C257.79 -1.71779 343.473 -4.39648 378 2.69061V456.404H-3V2.69061C31.5272 -4.39653 117.21 -1.71782 187.5 64.2824Z" fill="#FFF4EA"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 301 B |
11
client/assets/images/index/welcome_btn.svg
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<svg width="87" height="57" viewBox="0 0 87 57" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M0 28.5C0 12.7599 12.7599 0 28.5 0H58.5C74.2401 0 87 12.7599 87 28.5C87 44.2401 74.2401 57 58.5 57H28.5C12.7599 57 0 44.2401 0 28.5Z" fill="url(#paint0_linear_32_2902)"/>
|
||||||
|
<path d="M32 29L54 29" stroke="white" stroke-width="3" stroke-linecap="round"/>
|
||||||
|
<path d="M49 22L55.2929 28.2929C55.6834 28.6834 55.6834 29.3166 55.2929 29.7071L49 36" stroke="white" stroke-width="3" stroke-linecap="round"/>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="paint0_linear_32_2902" x1="0" y1="28.5" x2="87" y2="28.5" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#F69F7B"/>
|
||||||
|
<stop offset="1" stop-color="#F99CC0"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 719 B |
BIN
client/assets/images/theme/theme_color.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
client/assets/images/theme/theme_landscape.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
client/assets/theme/nature/1.png
Normal file
|
After Width: | Height: | Size: 358 KiB |
BIN
client/assets/theme/nature/10.png
Normal file
|
After Width: | Height: | Size: 629 KiB |
BIN
client/assets/theme/nature/11.png
Normal file
|
After Width: | Height: | Size: 532 KiB |
BIN
client/assets/theme/nature/12.png
Normal file
|
After Width: | Height: | Size: 127 KiB |
BIN
client/assets/theme/nature/13.png
Normal file
|
After Width: | Height: | Size: 449 KiB |
BIN
client/assets/theme/nature/14.png
Normal file
|
After Width: | Height: | Size: 525 KiB |
BIN
client/assets/theme/nature/15.png
Normal file
|
After Width: | Height: | Size: 693 KiB |
BIN
client/assets/theme/nature/17.png
Normal file
|
After Width: | Height: | Size: 458 KiB |
BIN
client/assets/theme/nature/18.png
Normal file
|
After Width: | Height: | Size: 593 KiB |
BIN
client/assets/theme/nature/19.png
Normal file
|
After Width: | Height: | Size: 414 KiB |
BIN
client/assets/theme/nature/2.png
Normal file
|
After Width: | Height: | Size: 381 KiB |
BIN
client/assets/theme/nature/20.png
Normal file
|
After Width: | Height: | Size: 461 KiB |
BIN
client/assets/theme/nature/22.png
Normal file
|
After Width: | Height: | Size: 606 KiB |
BIN
client/assets/theme/nature/3.png
Normal file
|
After Width: | Height: | Size: 384 KiB |
BIN
client/assets/theme/nature/4.png
Normal file
|
After Width: | Height: | Size: 388 KiB |
BIN
client/assets/theme/nature/5.png
Normal file
|
After Width: | Height: | Size: 236 KiB |
BIN
client/assets/theme/nature/6.png
Normal file
|
After Width: | Height: | Size: 721 KiB |
BIN
client/assets/theme/nature/7.png
Normal file
|
After Width: | Height: | Size: 358 KiB |
BIN
client/assets/theme/nature/8.png
Normal file
|
After Width: | Height: | Size: 415 KiB |
BIN
client/assets/theme/nature/9.png
Normal file
|
After Width: | Height: | Size: 214 KiB |
@@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState, useRef, useCallback } from 'react';
|
||||||
import { Alert, FlatList, Image, Pressable, StyleSheet, Text, View } from 'react-native';
|
import { Alert, FlatList, Image, Pressable, StyleSheet, Text, View, Platform, Dimensions } from 'react-native';
|
||||||
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';
|
||||||
@@ -11,6 +11,7 @@ import Animated, {
|
|||||||
SlideInRight,
|
SlideInRight,
|
||||||
SlideOutLeft,
|
SlideOutLeft,
|
||||||
SlideOutRight,
|
SlideOutRight,
|
||||||
|
Layout,
|
||||||
} from 'react-native-reanimated';
|
} from 'react-native-reanimated';
|
||||||
|
|
||||||
import SheetModal from '@/components/ui/SheetModal';
|
import SheetModal from '@/components/ui/SheetModal';
|
||||||
@@ -20,16 +21,25 @@ import {
|
|||||||
getDailyReminderSettings,
|
getDailyReminderSettings,
|
||||||
getFavorites,
|
getFavorites,
|
||||||
setDailyReminderSettings,
|
setDailyReminderSettings,
|
||||||
|
removeFavorite,
|
||||||
|
getUserProfile,
|
||||||
type DailyReminderSettings,
|
type DailyReminderSettings,
|
||||||
|
type FavoriteItem,
|
||||||
} from '@/src/storage/appStorage';
|
} from '@/src/storage/appStorage';
|
||||||
|
|
||||||
import AvatarIcon from '@/assets/images/home/Profile/Default_avatar.png';
|
import AvatarIcon from '@/assets/images/home/Profile/Default_avatar.svg';
|
||||||
import MyLikeIcon from '@/assets/images/home/Profile/mylike.svg';
|
import MyLikeIcon from '@/assets/images/icon/mylike_icon.svg';
|
||||||
import SmallComponentIcon from '@/assets/images/home/Profile/small_component.svg';
|
import SmallComponentIcon from '@/assets/images/icon/weight_icon.svg';
|
||||||
import RemindIcon from '@/assets/images/home/Profile/remind.svg';
|
import RemindIcon from '@/assets/images/icon/Push_icon.svg';
|
||||||
import PrivacyIcon from '@/assets/images/home/Profile/privacy Policy.svg';
|
import PrivacyIcon from '@/assets/images/icon/privacy_icon.svg';
|
||||||
import TermsIcon from '@/assets/images/home/Profile/terms_of_use.svg';
|
import TermsIcon from '@/assets/images/icon/Terms_icon.svg';
|
||||||
import LanguageIcon from '@/assets/images/home/Profile/language.svg';
|
import LanguageIcon from '@/assets/images/icon/language_icon.svg';
|
||||||
|
import SelectedIcon from '@/assets/images/icon/selected_icon.svg';
|
||||||
|
import QuestionIcon from '@/assets/images/home/Profile/widget/question_icon.svg';
|
||||||
|
import * as Notifications from 'expo-notifications';
|
||||||
|
import { changeLanguage } from '@/src/i18n';
|
||||||
|
|
||||||
|
const { width } = Dimensions.get('window');
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@@ -37,24 +47,61 @@ type Props = {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Page = 'root' | 'favorites' | 'dailyReminder' | 'widget';
|
type Page = 'root' | 'favorites' | 'dailyReminder' | 'widget' | 'language' | 'widgetHowTo';
|
||||||
type NavDirection = 'forward' | 'back';
|
type NavDirection = 'forward' | 'back';
|
||||||
|
|
||||||
export default function ProfileModal({ visible, name, onClose }: Props) {
|
const NATURE_IMAGES = [
|
||||||
|
require('@/assets/theme/nature/1.png'),
|
||||||
|
require('@/assets/theme/nature/2.png'),
|
||||||
|
require('@/assets/theme/nature/3.png'),
|
||||||
|
require('@/assets/theme/nature/4.png'),
|
||||||
|
require('@/assets/theme/nature/5.png'),
|
||||||
|
require('@/assets/theme/nature/6.png'),
|
||||||
|
require('@/assets/theme/nature/7.png'),
|
||||||
|
require('@/assets/theme/nature/8.png'),
|
||||||
|
require('@/assets/theme/nature/9.png'),
|
||||||
|
require('@/assets/theme/nature/10.png'),
|
||||||
|
require('@/assets/theme/nature/11.png'),
|
||||||
|
require('@/assets/theme/nature/12.png'),
|
||||||
|
require('@/assets/theme/nature/13.png'),
|
||||||
|
require('@/assets/theme/nature/14.png'),
|
||||||
|
require('@/assets/theme/nature/15.png'),
|
||||||
|
require('@/assets/theme/nature/17.png'),
|
||||||
|
require('@/assets/theme/nature/18.png'),
|
||||||
|
require('@/assets/theme/nature/19.png'),
|
||||||
|
require('@/assets/theme/nature/20.png'),
|
||||||
|
require('@/assets/theme/nature/22.png'),
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function ProfileModal({ visible, name: propName, onClose }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
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 isRoot = page === 'root';
|
const isRoot = page === 'root';
|
||||||
|
|
||||||
// 关闭弹窗时重置为首页,避免下次打开停留在二级页
|
// 当弹窗打开时,尝试从存储中获取最新的昵称,确保与 onboarding 同步
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!visible) {
|
if (visible) {
|
||||||
|
getUserProfile().then(profile => {
|
||||||
|
if (profile.name) {
|
||||||
|
setCurrentName(profile.name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
setNavDirection('back');
|
setNavDirection('back');
|
||||||
setPage('root');
|
setPage('root');
|
||||||
}
|
}
|
||||||
}, [visible]);
|
}, [visible]);
|
||||||
|
|
||||||
|
// 同步外部 propName 的变化
|
||||||
|
useEffect(() => {
|
||||||
|
if (propName) {
|
||||||
|
setCurrentName(propName);
|
||||||
|
}
|
||||||
|
}, [propName]);
|
||||||
|
|
||||||
function go(next: Page, direction: NavDirection) {
|
function go(next: Page, direction: NavDirection) {
|
||||||
setNavDirection(direction);
|
setNavDirection(direction);
|
||||||
setPage(next);
|
setPage(next);
|
||||||
@@ -79,6 +126,8 @@ export default function ProfileModal({ visible, name, onClose }: Props) {
|
|||||||
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');
|
||||||
if (page === 'widget') return t('profile.widget');
|
if (page === 'widget') return t('profile.widget');
|
||||||
|
if (page === 'language') return t('profile.language');
|
||||||
|
if (page === 'widgetHowTo') return t('widget.howToTitle');
|
||||||
return t('profile.title');
|
return t('profile.title');
|
||||||
}, [page, t]);
|
}, [page, t]);
|
||||||
|
|
||||||
@@ -107,42 +156,46 @@ export default function ProfileModal({ visible, name, onClose }: Props) {
|
|||||||
}, [navDirection]);
|
}, [navDirection]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SheetModal visible={visible} title={title} onClose={handleSheetClose}>
|
<SheetModal
|
||||||
{!isRoot && (
|
visible={visible}
|
||||||
<Pressable
|
title={title}
|
||||||
onPress={() => go('root', 'back')}
|
onClose={handleSheetClose}
|
||||||
hitSlop={8}
|
leftIcon={!isRoot ? require('@/assets/images/icon/back_icon.png') : undefined}
|
||||||
accessibilityRole="button"
|
>
|
||||||
accessibilityLabel={t('common.back')}
|
<View style={[styles.pageWrap, !isRoot && { flex: 1 }]}>
|
||||||
style={styles.backRow}
|
<Animated.View
|
||||||
|
key={page}
|
||||||
|
entering={transition.entering}
|
||||||
|
exiting={transition.exiting}
|
||||||
|
style={!isRoot ? { flex: 1 } : undefined}
|
||||||
>
|
>
|
||||||
<Text style={styles.backText}>‹ {t('common.back')}</Text>
|
<Animated.View
|
||||||
</Pressable>
|
entering={transition.fadeIn}
|
||||||
)}
|
exiting={transition.fadeOut}
|
||||||
|
style={!isRoot ? { flex: 1 } : undefined}
|
||||||
<Animated.View
|
>
|
||||||
key={page}
|
|
||||||
entering={transition.entering}
|
|
||||||
exiting={transition.exiting}
|
|
||||||
style={styles.pageWrap}
|
|
||||||
>
|
|
||||||
<Animated.View entering={transition.fadeIn} exiting={transition.fadeOut}>
|
|
||||||
{page === 'root' ? (
|
{page === 'root' ? (
|
||||||
<RootPage
|
<RootPage
|
||||||
name={name}
|
name={currentName}
|
||||||
onOpenFavorites={() => go('favorites', 'forward')}
|
onOpenFavorites={() => go('favorites', 'forward')}
|
||||||
onOpenWidget={() => go('widget', 'forward')}
|
onOpenWidget={() => go('widget', 'forward')}
|
||||||
onOpenDailyReminder={() => go('dailyReminder', 'forward')}
|
onOpenDailyReminder={() => go('dailyReminder', 'forward')}
|
||||||
|
onOpenLanguage={() => go('language', 'forward')}
|
||||||
/>
|
/>
|
||||||
) : page === 'favorites' ? (
|
) : page === 'favorites' ? (
|
||||||
<FavoritesPage visible={visible} />
|
<FavoritesPage visible={visible} page={page} />
|
||||||
) : page === 'dailyReminder' ? (
|
) : page === 'dailyReminder' ? (
|
||||||
<DailyReminderPage visible={visible} onDone={() => go('root', 'back')} />
|
<DailyReminderPage visible={visible} onDone={() => go('root', 'back')} />
|
||||||
|
) : page === 'language' ? (
|
||||||
|
<LanguagePage />
|
||||||
|
) : page === 'widgetHowTo' ? (
|
||||||
|
<WidgetHowToPage />
|
||||||
) : (
|
) : (
|
||||||
<WidgetPage />
|
<WidgetPage onOpenHowTo={() => go('widgetHowTo', 'forward')} />
|
||||||
)}
|
)}
|
||||||
|
</Animated.View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</Animated.View>
|
</View>
|
||||||
</SheetModal>
|
</SheetModal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -156,17 +209,19 @@ function RootPage({
|
|||||||
onOpenFavorites,
|
onOpenFavorites,
|
||||||
onOpenWidget,
|
onOpenWidget,
|
||||||
onOpenDailyReminder,
|
onOpenDailyReminder,
|
||||||
|
onOpenLanguage,
|
||||||
}: {
|
}: {
|
||||||
name?: string;
|
name?: string;
|
||||||
onOpenFavorites: () => void;
|
onOpenFavorites: () => void;
|
||||||
onOpenWidget: () => void;
|
onOpenWidget: () => void;
|
||||||
onOpenDailyReminder: () => void;
|
onOpenDailyReminder: () => void;
|
||||||
|
onOpenLanguage: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Image source={AvatarIcon} style={styles.avatarImage} resizeMode="contain" />
|
<AvatarIcon width={234} height={183} />
|
||||||
<Text style={styles.name}>{name || 'Hali'}</Text>
|
<Text style={styles.name}>{name || 'Hali'}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -198,48 +253,98 @@ function RootPage({
|
|||||||
<ListItem
|
<ListItem
|
||||||
icon={<LanguageIcon width={22} height={22} />}
|
icon={<LanguageIcon width={22} height={22} />}
|
||||||
title={t('profile.language')}
|
title={t('profile.language')}
|
||||||
onPress={() => toastTodo(t)}
|
onPress={onOpenLanguage}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FavoritesPage({ visible }: { visible: boolean }) {
|
function FavoritesPage({ visible, page }: { visible: boolean; page: Page }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [ids, setIds] = useState<string[]>([]);
|
const [favorites, setFavorites] = useState<(FavoriteItem & { text: string })[]>([]);
|
||||||
|
|
||||||
// 每次进入该页刷新一次,确保展示最新“喜欢”
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!visible) return;
|
if (!visible) return;
|
||||||
let cancelled = false;
|
refreshFavorites();
|
||||||
(async () => {
|
}, [visible, page]); // 当页面切换回 favorites 时也刷新一次
|
||||||
const list = await getFavorites();
|
|
||||||
if (!cancelled) setIds(list);
|
|
||||||
})();
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [visible]);
|
|
||||||
|
|
||||||
const items = useMemo(() => {
|
async function refreshFavorites() {
|
||||||
const map = new Map(MOCK_CONTENT.map((c) => [c.id, c]));
|
const storedFavs = await getFavorites();
|
||||||
return ids.map((id) => map.get(id)).filter(Boolean) as { id: string; text: string }[];
|
const map = new Map(MOCK_CONTENT.map((c) => [c.id, c.text]));
|
||||||
}, [ids]);
|
|
||||||
|
const list = storedFavs
|
||||||
|
.map((fav) => ({
|
||||||
|
...fav,
|
||||||
|
text: map.get(fav.id) || ''
|
||||||
|
}))
|
||||||
|
.filter(item => item.text !== '');
|
||||||
|
|
||||||
|
setFavorites(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRemove(favId: string) {
|
||||||
|
// 1. 调用存储层移除收藏
|
||||||
|
await removeFavorite(favId);
|
||||||
|
// 2. 更新本地状态
|
||||||
|
setFavorites(prev => prev.filter(item => item.favId !== favId));
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.favContainer}>
|
<View style={styles.favContainer}>
|
||||||
{items.length === 0 ? (
|
{favorites.length === 0 ? (
|
||||||
<Text style={styles.favEmpty}>{t('favorites.empty')}</Text>
|
<Text style={styles.favEmpty}>{t('favorites.empty')}</Text>
|
||||||
) : (
|
) : (
|
||||||
<FlatList
|
<FlatList
|
||||||
data={items}
|
data={favorites}
|
||||||
keyExtractor={(it) => it.id}
|
keyExtractor={(it) => it.favId}
|
||||||
contentContainerStyle={styles.favList}
|
contentContainerStyle={styles.favList}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
<View style={styles.favRow}>
|
<Animated.View
|
||||||
<Text style={styles.favText}>{item.text}</Text>
|
entering={FadeIn.duration(300)}
|
||||||
</View>
|
exiting={FadeOut.duration(200)}
|
||||||
|
layout={Layout.duration(300).easing(Easing.out(Easing.quad))}
|
||||||
|
style={styles.favCard}
|
||||||
|
>
|
||||||
|
<View style={styles.favLeft}>
|
||||||
|
<Text style={styles.favDate}>{item.date}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.favRight}>
|
||||||
|
<View style={[
|
||||||
|
styles.favThumb,
|
||||||
|
item.themeMode === 'scenery' ? {} : { backgroundColor: item.background }
|
||||||
|
]}>
|
||||||
|
{item.themeMode === 'scenery' ? (
|
||||||
|
<View style={StyleSheet.absoluteFill}>
|
||||||
|
<Image
|
||||||
|
source={NATURE_IMAGES[parseInt(item.background)]}
|
||||||
|
style={{
|
||||||
|
width: width * 0.6,
|
||||||
|
height: 800, // 假设原图较高,设置一个较大的高度
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0, // 关键:将图片底部对齐容器底部
|
||||||
|
}}
|
||||||
|
resizeMode="cover"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
<Text style={[
|
||||||
|
styles.favThumbText,
|
||||||
|
item.themeMode === 'scenery' && { color: '#FFFFFF', textShadowColor: 'rgba(0,0,0,0.5)', textShadowOffset: {width:0, height:1}, textShadowRadius: 3 }
|
||||||
|
]} numberOfLines={4}>
|
||||||
|
{item.text}
|
||||||
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => handleRemove(item.favId)}
|
||||||
|
style={styles.favRemoveBtn}
|
||||||
|
hitSlop={10}
|
||||||
|
>
|
||||||
|
<MyLikeIcon width={37 * 0.7} height={33 * 0.7} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -252,16 +357,23 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () =
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [timesPerDay, setTimesPerDay] = useState(3);
|
const [timesPerDay, setTimesPerDay] = useState(3);
|
||||||
const [pushEnabled, setPushEnabled] = useState(false);
|
const [pushEnabled, setPushEnabled] = useState(false);
|
||||||
|
const [hasSystemPermission, setHasSystemPermission] = useState<boolean | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
if (!visible) return;
|
if (!visible) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
(async () => {
|
(async () => {
|
||||||
|
// 1. 获取本地存储设置
|
||||||
const s = await getDailyReminderSettings();
|
const s = await getDailyReminderSettings();
|
||||||
|
|
||||||
|
// 【测试模式】:强制模拟无权限状态
|
||||||
|
const granted = false;
|
||||||
|
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setTimesPerDay(s.timesPerDay);
|
setTimesPerDay(s.timesPerDay);
|
||||||
setPushEnabled(s.pushEnabled);
|
setPushEnabled(granted);
|
||||||
|
setHasSystemPermission(granted);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
})();
|
})();
|
||||||
return () => {
|
return () => {
|
||||||
@@ -269,6 +381,40 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () =
|
|||||||
};
|
};
|
||||||
}, [visible]);
|
}, [visible]);
|
||||||
|
|
||||||
|
const handleTogglePush = async (value: boolean) => {
|
||||||
|
if (value) {
|
||||||
|
// 获取当前权限状态
|
||||||
|
const settings = await Notifications.getPermissionsAsync();
|
||||||
|
|
||||||
|
// 如果已经拒绝,弹窗提示
|
||||||
|
if (settings.status === 'denied') {
|
||||||
|
Alert.alert(
|
||||||
|
t('common.notice'),
|
||||||
|
"系统权限已被拒绝,请前往手机设置开启通知。"
|
||||||
|
);
|
||||||
|
setPushEnabled(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试申请权限
|
||||||
|
const { status } = await Notifications.requestPermissionsAsync();
|
||||||
|
|
||||||
|
// 调试:打印状态
|
||||||
|
console.log('Push Permission Status:', status);
|
||||||
|
|
||||||
|
if (status === 'granted') {
|
||||||
|
setPushEnabled(true);
|
||||||
|
setHasSystemPermission(true);
|
||||||
|
} else {
|
||||||
|
setPushEnabled(false);
|
||||||
|
setHasSystemPermission(false);
|
||||||
|
Alert.alert(t('common.notice'), t('dailyReminder.permissionDenied'));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setPushEnabled(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
function clamp(next: number) {
|
function clamp(next: number) {
|
||||||
return Math.min(10, Math.max(1, next));
|
return Math.min(10, Math.max(1, next));
|
||||||
}
|
}
|
||||||
@@ -311,15 +457,24 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () =
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.remindRow}>
|
{!hasSystemPermission && (
|
||||||
<View style={styles.rowLeft}>
|
<View style={styles.remindRow}>
|
||||||
<View style={styles.rowIcon}>
|
<View style={styles.rowLeft}>
|
||||||
<RemindIcon width={18} height={18} />
|
<View style={styles.rowIcon}>
|
||||||
|
<RemindIcon width={18} height={18} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.rowText}>{t('dailyReminder.pushLabel')}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.rowRight}>
|
||||||
|
<Switch
|
||||||
|
value={pushEnabled}
|
||||||
|
onValueChange={handleTogglePush}
|
||||||
|
trackColor={{ false: '#D1D1D6', true: '#4CD964' }}
|
||||||
|
ios_backgroundColor="#D1D1D6"
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.rowText}>{t('dailyReminder.pushLabel')}</Text>
|
|
||||||
</View>
|
</View>
|
||||||
<Switch value={pushEnabled} onValueChange={setPushEnabled} />
|
)}
|
||||||
</View>
|
|
||||||
|
|
||||||
<Pressable onPress={onOk} disabled={loading} style={styles.okPressable}>
|
<Pressable onPress={onOk} disabled={loading} style={styles.okPressable}>
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
@@ -335,34 +490,150 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () =
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function WidgetPage() {
|
function WidgetPage({ onOpenHowTo }: { onOpenHowTo: () => void }) {
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
const currentLang = i18n.language;
|
||||||
|
|
||||||
|
// 根据语言选择图片
|
||||||
|
const widget1 = currentLang === 'en'
|
||||||
|
? require('@/assets/images/home/Profile/widget/Widget1_en.png')
|
||||||
|
: require('@/assets/images/home/Profile/widget/Widget1_tw.png');
|
||||||
|
|
||||||
|
const widget2 = currentLang === 'en'
|
||||||
|
? require('@/assets/images/home/Profile/widget/Widget2_en.png')
|
||||||
|
: require('@/assets/images/home/Profile/widget/Widget2_tw.png');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.widgetContent}>
|
<View style={styles.widgetContent}>
|
||||||
<View style={styles.widgetRow}>
|
<Pressable style={styles.questionBtn} onPress={onOpenHowTo}>
|
||||||
<View style={styles.widgetCard}>
|
<QuestionIcon width={19} height={19} />
|
||||||
<View style={[styles.widgetPreviewInner, styles.widgetPreviewLock]}>
|
</Pressable>
|
||||||
<Text style={styles.widgetPreviewDate} numberOfLines={1}>
|
|
||||||
{t('widget.previewDate')}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.widgetPreviewTime} numberOfLines={1}>
|
|
||||||
13:45
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Text style={styles.widgetCardLabel}>{t('widget.lockScreen')}</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.widgetCard}>
|
<View style={styles.widgetScroll}>
|
||||||
<View style={[styles.widgetPreviewInner, styles.widgetPreviewHome]}>
|
<Pressable style={styles.widgetItem} onPress={onOpenHowTo}>
|
||||||
<Text style={styles.widgetPreviewQuote} numberOfLines={3}>
|
<Image source={widget1} style={styles.widgetImg1} resizeMode="contain" />
|
||||||
{t('widget.previewQuote')}
|
<Text style={styles.widgetLabel}>{t('widget.lockScreen')}</Text>
|
||||||
</Text>
|
</Pressable>
|
||||||
</View>
|
|
||||||
<Text style={styles.widgetCardLabel}>{t('widget.homeScreen')}</Text>
|
<Pressable style={styles.widgetItem} onPress={onOpenHowTo}>
|
||||||
</View>
|
<Image source={widget2} style={styles.widgetImg2} resizeMode="contain" />
|
||||||
|
<Text style={styles.widgetLabel}>{t('widget.homeScreen')}</Text>
|
||||||
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
<Text style={styles.widgetDesc}>{t('settings.widgetDesc')}</Text>
|
function WidgetHowToPage() {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const currentLang = i18n.language;
|
||||||
|
const flatListRef = useRef<FlatList>(null);
|
||||||
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
|
const [isManual, setIsManual] = useState(false);
|
||||||
|
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
const images = currentLang === 'en' ? [
|
||||||
|
{ id: '1', src: require('@/assets/images/home/Profile/widget/Widget_description1_en.png'), desc: t('widget.howToDesc1') },
|
||||||
|
{ id: '2', src: require('@/assets/images/home/Profile/widget/Widget_description2_en.png'), desc: t('widget.howToDesc2') },
|
||||||
|
] : [
|
||||||
|
{ id: '1', src: require('@/assets/images/home/Profile/widget/Widget_description1_tw.png'), desc: t('widget.howToDesc1') },
|
||||||
|
{ id: '2', src: require('@/assets/images/home/Profile/widget/Widget_description2_tw.png'), desc: t('widget.howToDesc2') },
|
||||||
|
];
|
||||||
|
|
||||||
|
const startAutoPlay = useCallback(() => {
|
||||||
|
if (timerRef.current) clearInterval(timerRef.current);
|
||||||
|
timerRef.current = setInterval(() => {
|
||||||
|
if (!isManual) {
|
||||||
|
const nextIndex = (activeIndex + 1) % images.length;
|
||||||
|
flatListRef.current?.scrollToIndex({ index: nextIndex, animated: true });
|
||||||
|
setActiveIndex(nextIndex);
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
}, [activeIndex, isManual, images.length]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
startAutoPlay();
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) clearInterval(timerRef.current);
|
||||||
|
};
|
||||||
|
}, [startAutoPlay]);
|
||||||
|
|
||||||
|
const onScroll = (event: any) => {
|
||||||
|
const x = event.nativeEvent.contentOffset.x;
|
||||||
|
const index = Math.round(x / (width - 32));
|
||||||
|
if (index !== activeIndex) {
|
||||||
|
setActiveIndex(index);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onScrollBeginDrag = () => {
|
||||||
|
setIsManual(true);
|
||||||
|
if (timerRef.current) clearInterval(timerRef.current);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.howToPage}>
|
||||||
|
<FlatList
|
||||||
|
ref={flatListRef}
|
||||||
|
data={images}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
horizontal
|
||||||
|
pagingEnabled
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
onScroll={onScroll}
|
||||||
|
onScrollBeginDrag={onScrollBeginDrag}
|
||||||
|
scrollEventThrottle={16}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<View style={styles.howToSlide}>
|
||||||
|
<Image source={item.src} style={styles.howToImg} resizeMode="contain" />
|
||||||
|
<Text style={styles.howToDesc}>{item.desc}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={styles.pagination}>
|
||||||
|
{images.map((_, i) => (
|
||||||
|
<View
|
||||||
|
key={i}
|
||||||
|
style={[
|
||||||
|
styles.dot,
|
||||||
|
i === activeIndex ? styles.dotActive : styles.dotInactive
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LanguagePage() {
|
||||||
|
const { i18n } = useTranslation();
|
||||||
|
const currentLang = i18n.language;
|
||||||
|
|
||||||
|
const languages = [
|
||||||
|
{ id: 'zh-TW', label: '繁体' },
|
||||||
|
{ id: 'en', label: 'English' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.langPage}>
|
||||||
|
<View style={styles.langList}>
|
||||||
|
{languages.map((lang, index) => (
|
||||||
|
<Pressable
|
||||||
|
key={lang.id}
|
||||||
|
style={[
|
||||||
|
styles.langItem,
|
||||||
|
index < languages.length - 1 && styles.langItemBorder
|
||||||
|
]}
|
||||||
|
onPress={() => changeLanguage(lang.id as any)}
|
||||||
|
>
|
||||||
|
<Text style={styles.langText}>{lang.label}</Text>
|
||||||
|
{currentLang === lang.id && (
|
||||||
|
<SelectedIcon width={20} height={20} />
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -442,26 +713,31 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
quickCard: {
|
quickCard: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
borderRadius: 16,
|
borderRadius: 20,
|
||||||
padding: 14,
|
padding: 14,
|
||||||
backgroundColor: 'rgba(244,214,194,0.65)',
|
backgroundColor: 'rgba(244,214,194,0.65)',
|
||||||
|
height: 142,
|
||||||
|
justifyContent: 'space-between',
|
||||||
},
|
},
|
||||||
quickTitle: {
|
quickTitle: {
|
||||||
color: '#5E2A28',
|
color: '#482A0D',
|
||||||
fontSize: 14,
|
fontSize: 15,
|
||||||
fontWeight: '700',
|
fontWeight: '500',
|
||||||
marginBottom: 10,
|
},
|
||||||
|
quickIconWrap: {
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
flex: 1
|
||||||
},
|
},
|
||||||
quickIconWrap: { alignItems: 'center', justifyContent: 'center', flex: 1 },
|
|
||||||
list: {
|
list: {
|
||||||
borderRadius: 16,
|
borderRadius: 20,
|
||||||
backgroundColor: 'rgba(255,255,255,0.75)',
|
backgroundColor: 'rgba(255,255,255,0.75)',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
},
|
},
|
||||||
item: {
|
item: {
|
||||||
height: 52,
|
height: 52,
|
||||||
paddingHorizontal: 14,
|
paddingHorizontal: 18,
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
@@ -471,55 +747,83 @@ const styles = StyleSheet.create({
|
|||||||
itemLeft: {
|
itemLeft: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 10,
|
gap: 12,
|
||||||
},
|
},
|
||||||
itemIcon: {
|
itemIcon: {
|
||||||
width: 28,
|
width: 24,
|
||||||
height: 28,
|
height: 24,
|
||||||
borderRadius: 10,
|
|
||||||
backgroundColor: 'rgba(244,214,194,0.55)',
|
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
itemText: {
|
itemText: {
|
||||||
color: '#5E2A28',
|
color: '#522B09',
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontWeight: '600',
|
fontWeight: '500',
|
||||||
},
|
},
|
||||||
chevron: {
|
chevron: {
|
||||||
color: 'rgba(94,42,40,0.45)',
|
color: '#C7B4A1',
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
marginTop: -2,
|
marginTop: -2,
|
||||||
},
|
},
|
||||||
|
|
||||||
favContainer: {
|
favContainer: {
|
||||||
borderRadius: 16,
|
flex: 1,
|
||||||
backgroundColor: 'rgba(255,255,255,0.75)',
|
minHeight: 500, // 确保容器有足够高度
|
||||||
overflow: 'hidden',
|
|
||||||
marginBottom: 8,
|
|
||||||
},
|
},
|
||||||
favEmpty: {
|
favEmpty: {
|
||||||
color: 'rgba(94,42,40,0.55)',
|
color: 'rgba(94,42,40,0.55)',
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
paddingVertical: 26,
|
paddingVertical: 100,
|
||||||
paddingHorizontal: 14,
|
|
||||||
},
|
},
|
||||||
favList: {
|
favList: {
|
||||||
paddingBottom: 10,
|
paddingHorizontal: 20,
|
||||||
|
paddingBottom: 80,
|
||||||
},
|
},
|
||||||
favRow: {
|
favCard: {
|
||||||
paddingHorizontal: 14,
|
flexDirection: 'row',
|
||||||
paddingVertical: 14,
|
alignItems: 'center',
|
||||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
marginBottom: 24,
|
||||||
borderBottomColor: 'rgba(94,42,40,0.10)',
|
|
||||||
backgroundColor: 'rgba(255,255,255,0.15)',
|
|
||||||
},
|
},
|
||||||
favText: {
|
favLeft: {
|
||||||
color: '#5E2A28',
|
width: 90,
|
||||||
|
},
|
||||||
|
favDate: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#772F00',
|
||||||
|
fontFamily: Platform.OS === 'ios' ? 'STIX Two Text' : 'serif',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
favRight: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
favThumb: {
|
||||||
|
backgroundColor: '#FFF4EA',
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 20,
|
||||||
|
width: width * 0.6,
|
||||||
|
height: 161,
|
||||||
|
justifyContent: 'center',
|
||||||
|
position: 'relative',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(119, 47, 0, 0.05)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
favThumbText: {
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
lineHeight: 22,
|
lineHeight: 22,
|
||||||
fontWeight: '600',
|
color: '#5E2A28',
|
||||||
|
fontWeight: '500',
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
favRemoveBtn: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 15,
|
||||||
|
right: 15,
|
||||||
|
width: 37,
|
||||||
|
height: 33,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
|
|
||||||
counter: {
|
counter: {
|
||||||
@@ -563,15 +867,22 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
remindRow: {
|
remindRow: {
|
||||||
height: 54,
|
height: 54,
|
||||||
borderRadius: 16,
|
borderRadius: 20,
|
||||||
backgroundColor: 'rgba(255,255,255,0.75)',
|
backgroundColor: 'rgba(255,255,255,0.75)',
|
||||||
paddingHorizontal: 14,
|
paddingHorizontal: 14,
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
marginBottom: 18,
|
marginBottom: 18,
|
||||||
|
width: width - 40, // 屏幕宽度减去左右各 20pt
|
||||||
|
alignSelf: 'center',
|
||||||
},
|
},
|
||||||
rowLeft: { flexDirection: 'row', alignItems: 'center', gap: 10 },
|
rowLeft: { flexDirection: 'row', alignItems: 'center', gap: 10 },
|
||||||
|
rowRight: {
|
||||||
|
height: '100%',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
rowIcon: {
|
rowIcon: {
|
||||||
width: 28,
|
width: 28,
|
||||||
height: 28,
|
height: 28,
|
||||||
@@ -587,6 +898,7 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
okPressable: {
|
okPressable: {
|
||||||
marginBottom: 6,
|
marginBottom: 6,
|
||||||
|
marginTop: 40, // 增加顶部间距以撑开高度
|
||||||
},
|
},
|
||||||
okBtn: {
|
okBtn: {
|
||||||
height: 52,
|
height: 52,
|
||||||
@@ -602,61 +914,104 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
|
|
||||||
widgetContent: {
|
widgetContent: {
|
||||||
paddingBottom: 18,
|
|
||||||
gap: 14,
|
|
||||||
},
|
|
||||||
widgetRow: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
gap: 12,
|
|
||||||
},
|
|
||||||
widgetCard: {
|
|
||||||
flex: 1,
|
flex: 1,
|
||||||
borderRadius: 16,
|
paddingTop: 10,
|
||||||
overflow: 'hidden',
|
|
||||||
backgroundColor: 'rgba(255,255,255,0.75)',
|
|
||||||
borderWidth: StyleSheet.hairlineWidth,
|
|
||||||
borderColor: 'rgba(94,42,40,0.12)',
|
|
||||||
},
|
},
|
||||||
widgetPreviewInner: {
|
questionBtn: {
|
||||||
height: 120,
|
position: 'absolute',
|
||||||
padding: 12,
|
right: 20,
|
||||||
justifyContent: 'center',
|
top: -34, // 放在标题栏右侧
|
||||||
|
zIndex: 10,
|
||||||
},
|
},
|
||||||
widgetPreviewLock: {
|
widgetScroll: {
|
||||||
backgroundColor: 'rgba(244,214,194,0.65)',
|
alignItems: 'center',
|
||||||
|
gap: 30,
|
||||||
|
paddingBottom: 40,
|
||||||
},
|
},
|
||||||
widgetPreviewHome: {
|
widgetItem: {
|
||||||
backgroundColor: 'rgba(243,208,225,0.55)',
|
alignItems: 'center',
|
||||||
|
width: '100%',
|
||||||
},
|
},
|
||||||
widgetPreviewDate: {
|
widgetImg1: {
|
||||||
color: 'rgba(94,42,40,0.70)',
|
width: width * 0.9,
|
||||||
fontSize: 12,
|
height: (width * 0.9) * (156 / 311),
|
||||||
fontWeight: '600',
|
|
||||||
marginBottom: 6,
|
|
||||||
},
|
},
|
||||||
widgetPreviewTime: {
|
widgetImg2: {
|
||||||
color: '#ffffff',
|
width: width * 0.9,
|
||||||
fontSize: 44,
|
height: (width * 0.9) * (175 / 311),
|
||||||
fontWeight: '800',
|
|
||||||
letterSpacing: 1,
|
|
||||||
},
|
},
|
||||||
widgetPreviewQuote: {
|
widgetLabel: {
|
||||||
color: '#5E2A28',
|
marginTop: 12,
|
||||||
fontSize: 14,
|
fontSize: 15,
|
||||||
lineHeight: 20,
|
color: 'rgba(94, 42, 40, 0.45)',
|
||||||
fontWeight: '700',
|
fontWeight: '500',
|
||||||
},
|
},
|
||||||
widgetCardLabel: {
|
howToPage: {
|
||||||
paddingVertical: 10,
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingTop: 20,
|
||||||
|
},
|
||||||
|
howToSlide: {
|
||||||
|
width: width - 32, // 减去 SheetModal 的 paddingHorizontal: 16 * 2
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
howToImg: {
|
||||||
|
width: width * 0.9,
|
||||||
|
height: (width * 0.9) * (234 / 326),
|
||||||
|
marginBottom: 40,
|
||||||
|
},
|
||||||
|
howToDesc: {
|
||||||
|
fontSize: 15,
|
||||||
|
lineHeight: 25,
|
||||||
|
color: '#522B09',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
color: '#5E2A28',
|
fontWeight: '500',
|
||||||
fontSize: 13,
|
paddingHorizontal: 20,
|
||||||
fontWeight: '700',
|
|
||||||
},
|
},
|
||||||
widgetDesc: {
|
pagination: {
|
||||||
color: 'rgba(94,42,40,0.75)',
|
flexDirection: 'row',
|
||||||
fontSize: 13,
|
position: 'absolute',
|
||||||
lineHeight: 18,
|
top: (width * 0.9) * (234 / 326) + 35, // 根据新的图片高度动态计算
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
dot: {
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
dotActive: {
|
||||||
|
backgroundColor: '#EEB054',
|
||||||
|
},
|
||||||
|
dotInactive: {
|
||||||
|
backgroundColor: 'rgba(238, 176, 84, 0.3)',
|
||||||
|
},
|
||||||
|
langPage: {
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingTop: 10,
|
||||||
|
},
|
||||||
|
langList: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 20,
|
||||||
|
overflow: 'hidden',
|
||||||
|
width: width - 40, // 屏幕宽度减去左右各 20pt
|
||||||
|
alignSelf: 'center',
|
||||||
|
},
|
||||||
|
langItem: {
|
||||||
|
height: 62,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
},
|
||||||
|
langItemBorder: {
|
||||||
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderBottomColor: 'rgba(94,42,40,0.1)',
|
||||||
|
},
|
||||||
|
langText: {
|
||||||
|
fontSize: 15,
|
||||||
|
color: '#522B09',
|
||||||
|
fontWeight: '500',
|
||||||
|
textTransform: 'capitalize',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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}>
|
<SheetModal visible={visible} title={t('theme.title')} onClose={onClose} height={350}>
|
||||||
<View style={styles.row}>
|
<View style={styles.row}>
|
||||||
<ThemeCard
|
<ThemeCard
|
||||||
title={t('theme.scenery')}
|
title={t('theme.scenery')}
|
||||||
@@ -24,7 +24,7 @@ export default function ThemeModal({ visible, mode, onSelect, onClose }: Props)
|
|||||||
onPress={() => onSelect('scenery')}
|
onPress={() => onSelect('scenery')}
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
source={require('../../assets/images/index/index_flowers.png')}
|
source={require('../../assets/images/theme/theme_landscape.png')}
|
||||||
resizeMode="cover"
|
resizeMode="cover"
|
||||||
style={styles.previewImage}
|
style={styles.previewImage}
|
||||||
/>
|
/>
|
||||||
@@ -35,7 +35,11 @@ export default function ThemeModal({ visible, mode, onSelect, onClose }: Props)
|
|||||||
selected={mode === 'color'}
|
selected={mode === 'color'}
|
||||||
onPress={() => onSelect('color')}
|
onPress={() => onSelect('color')}
|
||||||
>
|
>
|
||||||
<View style={styles.colorPreview} />
|
<Image
|
||||||
|
source={require('../../assets/images/theme/theme_color.png')}
|
||||||
|
resizeMode="cover"
|
||||||
|
style={styles.previewImage}
|
||||||
|
/>
|
||||||
</ThemeCard>
|
</ThemeCard>
|
||||||
</View>
|
</View>
|
||||||
</SheetModal>
|
</SheetModal>
|
||||||
@@ -56,11 +60,20 @@ function ThemeCard({
|
|||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
style={[styles.card, selected ? styles.cardSelected : styles.cardUnselected]}
|
style={styles.cardContainer}
|
||||||
hitSlop={6}
|
hitSlop={6}
|
||||||
>
|
>
|
||||||
<View style={styles.preview}>{children}</View>
|
<View style={[styles.previewWrapper, selected && styles.selectedWrapper]}>
|
||||||
<Text style={styles.cardTitle}>{title}</Text>
|
<View style={styles.previewInner}>
|
||||||
|
{children}
|
||||||
|
{/* 文案展示在图片中心 */}
|
||||||
|
<View style={styles.textOverlay}>
|
||||||
|
<Text style={[styles.overlayTitle, selected && styles.selectedOverlayTitle]}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -68,43 +81,57 @@ function ThemeCard({
|
|||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
row: {
|
row: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
gap: 14,
|
gap: 30,
|
||||||
paddingBottom: 18,
|
paddingHorizontal: 10,
|
||||||
|
paddingBottom: 50,
|
||||||
|
paddingTop: 20,
|
||||||
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
card: {
|
cardContainer: {
|
||||||
flex: 1,
|
alignItems: 'center',
|
||||||
borderRadius: 18,
|
width: 143,
|
||||||
padding: 10,
|
|
||||||
backgroundColor: 'rgba(255,255,255,0.55)',
|
|
||||||
},
|
},
|
||||||
cardSelected: {
|
previewWrapper: {
|
||||||
borderWidth: 2,
|
width: 138,
|
||||||
borderColor: '#F99CC0',
|
height: 203,
|
||||||
|
borderRadius: 26,
|
||||||
|
padding: 6.5,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
cardUnselected: {
|
selectedWrapper: {
|
||||||
borderWidth: StyleSheet.hairlineWidth,
|
borderWidth: 4,
|
||||||
borderColor: 'rgba(94,42,40,0.18)',
|
borderColor: '#E7837A',
|
||||||
|
borderRadius: 26,
|
||||||
},
|
},
|
||||||
preview: {
|
previewInner: {
|
||||||
height: 96,
|
width: '100%',
|
||||||
borderRadius: 14,
|
height: '100%',
|
||||||
|
borderRadius: 21,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
backgroundColor: '#fff',
|
backgroundColor: '#F5F5F5',
|
||||||
marginBottom: 10,
|
position: 'relative',
|
||||||
},
|
},
|
||||||
previewImage: {
|
previewImage: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
},
|
},
|
||||||
colorPreview: {
|
textOverlay: {
|
||||||
flex: 1,
|
...StyleSheet.absoluteFillObject,
|
||||||
backgroundColor: '#F3D0E1',
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.05)', // 轻微遮罩增加文字可读性
|
||||||
},
|
},
|
||||||
cardTitle: {
|
overlayTitle: {
|
||||||
color: '#5E2A28',
|
color: '#FFFFFF',
|
||||||
fontSize: 14,
|
fontSize: 15,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
|
textShadowColor: 'rgba(0, 0, 0, 0.3)',
|
||||||
|
textShadowOffset: { width: 0, height: 1 },
|
||||||
|
textShadowRadius: 3,
|
||||||
|
},
|
||||||
|
selectedOverlayTitle: {
|
||||||
|
color: '#FFFFFF',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,32 +1,82 @@
|
|||||||
import React from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { View, StyleSheet, TextInput, Platform } from 'react-native';
|
import { View, StyleSheet, TextInput, Platform, Animated, TouchableOpacity, Dimensions, Text } from 'react-native';
|
||||||
import { SerifText } from './SerifText';
|
|
||||||
import { OnboardingColors } from '@/constants/OnboardingTheme';
|
import { OnboardingColors } from '@/constants/OnboardingTheme';
|
||||||
|
import BtnNotClicked from '@/assets/images/icon/btn_Notclicked.svg';
|
||||||
|
import BtnClicked from '@/assets/images/icon/btn_clicked.svg';
|
||||||
|
import EnterLightIcon from '@/assets/images/icon/enter_Light_icon.svg';
|
||||||
|
|
||||||
|
const { height } = Dimensions.get('window');
|
||||||
|
|
||||||
interface NameInputStepProps {
|
interface NameInputStepProps {
|
||||||
value: string;
|
value: string;
|
||||||
onChangeText: (text: string) => void;
|
onChangeText: (text: string) => void;
|
||||||
onSubmitEditing?: () => void;
|
onNext: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NameInputStep({ value, onChangeText, onSubmitEditing }: NameInputStepProps) {
|
export function NameInputStep({ value, onChangeText, onNext }: NameInputStepProps) {
|
||||||
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
const blinkAnim = useRef(new Animated.Value(1)).current;
|
||||||
|
const hasInput = value.trim().length > 0;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const animation = Animated.loop(
|
||||||
|
Animated.sequence([
|
||||||
|
Animated.timing(blinkAnim, { toValue: 0, duration: 500, useNativeDriver: true }),
|
||||||
|
Animated.timing(blinkAnim, { toValue: 1, duration: 500, useNativeDriver: true }),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
if (isFocused) {
|
||||||
|
animation.start();
|
||||||
|
} else {
|
||||||
|
animation.stop();
|
||||||
|
blinkAnim.setValue(0);
|
||||||
|
}
|
||||||
|
return () => animation.stop();
|
||||||
|
}, [blinkAnim, isFocused]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<SerifText style={styles.title}>我可以怎么称呼你?</SerifText>
|
<View style={styles.inputCard}>
|
||||||
|
<View style={styles.inputWrapper}>
|
||||||
<View style={styles.inputContainer}>
|
{/* 显示层:文案 + 跟随的光标 */}
|
||||||
<TextInput
|
<View style={styles.displayLayer}>
|
||||||
style={styles.input}
|
<Text
|
||||||
value={value}
|
style={[
|
||||||
onChangeText={onChangeText}
|
styles.displayText,
|
||||||
placeholder="Hao"
|
(!isFocused && !hasInput) && { color: OnboardingColors.textSecondary }
|
||||||
placeholderTextColor={OnboardingColors.textSecondary}
|
]}
|
||||||
selectionColor={OnboardingColors.cursor}
|
>
|
||||||
autoFocus
|
{hasInput ? value : (isFocused ? "" : "Mama")}
|
||||||
onSubmitEditing={onSubmitEditing}
|
</Text>
|
||||||
returnKeyType="done"
|
{isFocused && (
|
||||||
textAlign="center"
|
<Animated.View style={[styles.cursorWrapper, { opacity: blinkAnim, marginLeft: 2 }]}>
|
||||||
/>
|
<EnterLightIcon width={3} height={27} />
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 交互层:隐藏的输入框 */}
|
||||||
|
<TextInput
|
||||||
|
style={styles.hiddenInput}
|
||||||
|
value={value}
|
||||||
|
onChangeText={onChangeText}
|
||||||
|
onFocus={() => setIsFocused(true)}
|
||||||
|
onBlur={() => setIsFocused(false)}
|
||||||
|
caretHidden={true}
|
||||||
|
autoCorrect={false}
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.footer}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onNext}
|
||||||
|
disabled={!hasInput}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
{hasInput ? <BtnClicked width={87} height={57} /> : <BtnNotClicked width={87} height={57} />}
|
||||||
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -34,31 +84,53 @@ export function NameInputStep({ value, onChangeText, onSubmitEditing }: NameInpu
|
|||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
|
flex: 1,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
width: '100%',
|
paddingTop: 20,
|
||||||
},
|
},
|
||||||
title: {
|
inputCard: {
|
||||||
fontSize: 24,
|
width: 335,
|
||||||
marginBottom: 40,
|
height: 75,
|
||||||
textAlign: 'center',
|
|
||||||
},
|
|
||||||
inputContainer: {
|
|
||||||
width: '100%',
|
|
||||||
backgroundColor: OnboardingColors.cardBackground,
|
backgroundColor: OnboardingColors.cardBackground,
|
||||||
borderRadius: 20,
|
borderRadius: 20,
|
||||||
paddingVertical: 20,
|
justifyContent: 'center',
|
||||||
paddingHorizontal: 24,
|
alignItems: 'center',
|
||||||
shadowColor: '#000',
|
shadowColor: '#000',
|
||||||
shadowOffset: { width: 0, height: 2 },
|
shadowOffset: { width: 0, height: 2 },
|
||||||
shadowOpacity: 0.05,
|
shadowOpacity: 0.05,
|
||||||
shadowRadius: 10,
|
shadowRadius: 10,
|
||||||
elevation: 2,
|
elevation: 2,
|
||||||
},
|
},
|
||||||
input: {
|
inputWrapper: {
|
||||||
fontSize: 24,
|
width: '100%',
|
||||||
fontFamily: Platform.select({ ios: 'Georgia', android: 'serif', default: 'serif' }),
|
height: '100%',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
displayLayer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
displayText: {
|
||||||
|
fontSize: 22,
|
||||||
|
fontFamily: Platform.select({ ios: 'STIX Two Text', android: 'serif', default: 'serif' }),
|
||||||
|
fontWeight: '600',
|
||||||
color: OnboardingColors.textPrimary,
|
color: OnboardingColors.textPrimary,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
padding: 0, // remove default padding
|
|
||||||
},
|
},
|
||||||
|
hiddenInput: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
color: 'transparent', // 文字透明,只负责输入逻辑
|
||||||
|
fontSize: 22,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
cursorWrapper: {
|
||||||
|
// 默认居中显示时,光标在左侧
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: height * 0.12,
|
||||||
|
alignItems: 'center',
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,45 +1,61 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, StyleSheet, SafeAreaView, TouchableOpacity, StatusBar } from 'react-native';
|
import { View, StyleSheet, SafeAreaView, TouchableOpacity, StatusBar, Text, Image, Platform } from 'react-native';
|
||||||
import { OnboardingColors } from '@/constants/OnboardingTheme';
|
import { OnboardingColors } from '@/constants/OnboardingTheme';
|
||||||
import { SerifText } from './SerifText';
|
|
||||||
import { NextButton } from './NextButton';
|
|
||||||
|
|
||||||
interface OnboardingLayoutProps {
|
interface OnboardingLayoutProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
onSkip?: () => void;
|
title?: string;
|
||||||
onNext?: () => void;
|
currentStep: number;
|
||||||
nextEnabled?: boolean;
|
totalSteps: number;
|
||||||
showNextButton?: boolean;
|
onSkip: () => void;
|
||||||
|
onBack?: () => void;
|
||||||
|
showBackButton?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function OnboardingLayout({
|
export function OnboardingLayout({
|
||||||
children,
|
children,
|
||||||
|
title,
|
||||||
|
currentStep,
|
||||||
|
totalSteps,
|
||||||
onSkip,
|
onSkip,
|
||||||
onNext,
|
onBack,
|
||||||
nextEnabled = true,
|
showBackButton = false
|
||||||
showNextButton = true
|
|
||||||
}: OnboardingLayoutProps) {
|
}: OnboardingLayoutProps) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<StatusBar barStyle="dark-content" />
|
<StatusBar barStyle="dark-content" />
|
||||||
<SafeAreaView style={styles.safeArea}>
|
<SafeAreaView style={styles.safeArea}>
|
||||||
|
{/* Header: Back & Skip */}
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<View style={styles.spacer} />
|
<View style={styles.headerLeft}>
|
||||||
{onSkip && (
|
{showBackButton && onBack && (
|
||||||
<TouchableOpacity onPress={onSkip} style={styles.skipButton}>
|
<TouchableOpacity onPress={onBack} style={styles.iconButton}>
|
||||||
<SerifText style={styles.skipText}>Skip {'->'}</SerifText>
|
<Image
|
||||||
</TouchableOpacity>
|
source={require('@/assets/images/icon/back_icon.png')}
|
||||||
)}
|
style={styles.backIcon}
|
||||||
</View>
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
<View style={styles.content}>
|
)}
|
||||||
{children}
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity onPress={onSkip} style={styles.skipButton}>
|
||||||
|
<Text style={styles.skipText}>skip</Text>
|
||||||
|
<Image
|
||||||
|
source={require('@/assets/images/icon/skip_icon.png')}
|
||||||
|
style={styles.skipIcon}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.footer}>
|
{/* Title & Progress Row */}
|
||||||
{showNextButton && onNext && (
|
<View style={styles.titleRow}>
|
||||||
<NextButton onPress={onNext} disabled={!nextEnabled} />
|
<Text style={styles.questionTitle}>{title}</Text>
|
||||||
)}
|
<Text style={styles.progressText}>({currentStep}/{totalSteps})</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<View style={styles.content}>
|
||||||
|
{children}
|
||||||
</View>
|
</View>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
</View>
|
</View>
|
||||||
@@ -58,27 +74,60 @@ const styles = StyleSheet.create({
|
|||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingHorizontal: 24,
|
paddingHorizontal: 20,
|
||||||
paddingVertical: 12,
|
height: 44,
|
||||||
},
|
},
|
||||||
spacer: {
|
headerLeft: {
|
||||||
width: 60, // Balance the skip button width approximately
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
iconButton: {
|
||||||
|
padding: 8,
|
||||||
|
},
|
||||||
|
backIcon: {
|
||||||
|
width: 19,
|
||||||
|
height: 19,
|
||||||
|
resizeMode: 'contain',
|
||||||
},
|
},
|
||||||
skipButton: {
|
skipButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
padding: 8,
|
padding: 8,
|
||||||
},
|
},
|
||||||
skipText: {
|
skipText: {
|
||||||
fontSize: 16,
|
fontSize: 13,
|
||||||
color: OnboardingColors.textPrimary,
|
color: OnboardingColors.textMuted,
|
||||||
|
fontFamily: Platform.OS === 'ios' ? 'STIX Two Text' : 'serif',
|
||||||
|
marginRight: 4,
|
||||||
|
},
|
||||||
|
skipIcon: {
|
||||||
|
width: 10,
|
||||||
|
height: 4,
|
||||||
|
resizeMode: 'contain',
|
||||||
|
},
|
||||||
|
titleRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
marginTop: 20,
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
questionTitle: {
|
||||||
|
fontSize: 22,
|
||||||
|
color: OnboardingColors.questionTitle,
|
||||||
|
fontFamily: Platform.OS === 'ios' ? 'PingFang TC' : 'sans-serif',
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
progressText: {
|
||||||
|
fontSize: 18,
|
||||||
|
color: OnboardingColors.textProgress,
|
||||||
|
fontFamily: Platform.OS === 'ios' ? 'PingFang TC' : 'sans-serif',
|
||||||
|
marginLeft: 10,
|
||||||
},
|
},
|
||||||
content: {
|
content: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
justifyContent: 'center',
|
paddingHorizontal: 20,
|
||||||
paddingHorizontal: 24,
|
|
||||||
},
|
|
||||||
footer: {
|
|
||||||
alignItems: 'center',
|
|
||||||
paddingBottom: 40,
|
|
||||||
minHeight: 100, // Reserve space for button
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
89
client/components/onboarding/ReminderStep.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { View, StyleSheet, TouchableOpacity, Text, Platform, Dimensions } from 'react-native';
|
||||||
|
import { OnboardingColors } from '@/constants/OnboardingTheme';
|
||||||
|
import AddIcon from '@/assets/images/icon/add_icon.svg';
|
||||||
|
import ReduceIcon from '@/assets/images/icon/reduce_icon.svg';
|
||||||
|
import BtnClicked from '@/assets/images/icon/btn_clicked.svg';
|
||||||
|
|
||||||
|
const { height } = Dimensions.get('window');
|
||||||
|
|
||||||
|
interface ReminderStepProps {
|
||||||
|
value: number;
|
||||||
|
onChange: (value: number) => void;
|
||||||
|
onFinish: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReminderStep({ value, onChange, onFinish }: ReminderStepProps) {
|
||||||
|
const handleReduce = () => {
|
||||||
|
if (value > 1) onChange(value - 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
if (value < 5) onChange(value + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.counterContainer}>
|
||||||
|
<TouchableOpacity onPress={handleReduce} activeOpacity={0.7}>
|
||||||
|
<ReduceIcon width={47} height={47} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<View style={styles.numberWrapper}>
|
||||||
|
<Text style={styles.numberText}>{value}</Text>
|
||||||
|
<Text style={styles.unitText}>次</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity onPress={handleAdd} activeOpacity={0.7}>
|
||||||
|
<AddIcon width={47} height={47} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.footer}>
|
||||||
|
<TouchableOpacity onPress={onFinish} activeOpacity={0.8}>
|
||||||
|
<BtnClicked width={87} height={57} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingTop: 20,
|
||||||
|
},
|
||||||
|
counterContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '100%',
|
||||||
|
marginTop: 40,
|
||||||
|
},
|
||||||
|
numberWrapper: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
marginHorizontal: 40,
|
||||||
|
},
|
||||||
|
numberText: {
|
||||||
|
fontSize: 107,
|
||||||
|
fontFamily: Platform.OS === 'ios' ? 'STIX Two Text' : 'serif',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: OnboardingColors.textPrimary,
|
||||||
|
lineHeight: 120,
|
||||||
|
},
|
||||||
|
unitText: {
|
||||||
|
fontSize: 17,
|
||||||
|
fontFamily: Platform.OS === 'ios' ? 'STIX Two Text' : 'serif',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: OnboardingColors.textPrimary,
|
||||||
|
marginBottom: 20,
|
||||||
|
marginLeft: 4,
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: height * 0.12,
|
||||||
|
alignItems: 'center',
|
||||||
|
}
|
||||||
|
});
|
||||||
123
client/components/onboarding/SelectionStep.tsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { View, StyleSheet, TouchableOpacity, ScrollView, Dimensions } from 'react-native';
|
||||||
|
import { OnboardingColors } from '@/constants/OnboardingTheme';
|
||||||
|
import { SerifText } from './SerifText';
|
||||||
|
import SelectedIcon from '@/assets/images/icon/selected_icon.svg';
|
||||||
|
import BtnNotClicked from '@/assets/images/icon/btn_Notclicked.svg';
|
||||||
|
import BtnClicked from '@/assets/images/icon/btn_clicked.svg';
|
||||||
|
|
||||||
|
const { height } = Dimensions.get('window');
|
||||||
|
|
||||||
|
interface Option {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SelectionStepProps {
|
||||||
|
options: Option[];
|
||||||
|
selectedIds: string[];
|
||||||
|
onToggle: (id: string) => void;
|
||||||
|
onNext: () => void;
|
||||||
|
onSkip?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SelectionStep({ options, selectedIds, onToggle, onNext, onSkip }: SelectionStepProps) {
|
||||||
|
const hasSelection = selectedIds.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.optionsList}>
|
||||||
|
{options.map((option) => {
|
||||||
|
const isSelected = selectedIds.includes(option.id);
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={option.id}
|
||||||
|
style={styles.optionCard}
|
||||||
|
onPress={() => onToggle(option.id)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<SerifText style={styles.optionText}>{option.label}</SerifText>
|
||||||
|
{isSelected && (
|
||||||
|
<View style={styles.iconWrapper}>
|
||||||
|
<SelectedIcon width={20} height={20} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* 底部按钮:距离底部 12% 高度 */}
|
||||||
|
<View style={styles.footer}>
|
||||||
|
<View style={styles.footerRow}>
|
||||||
|
{onSkip && (
|
||||||
|
<TouchableOpacity onPress={onSkip} activeOpacity={0.8} style={styles.skipBtn}>
|
||||||
|
<SerifText style={styles.skipText}>跳过</SerifText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TouchableOpacity onPress={onNext} disabled={!hasSelection} activeOpacity={0.8}>
|
||||||
|
{hasSelection ? <BtnClicked width={87} height={57} /> : <BtnNotClicked width={87} height={57} />}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
paddingTop: 20,
|
||||||
|
},
|
||||||
|
optionsList: {
|
||||||
|
paddingBottom: 150, // 为底部按钮留出空间
|
||||||
|
},
|
||||||
|
optionCard: {
|
||||||
|
width: '100%',
|
||||||
|
height: 75,
|
||||||
|
backgroundColor: OnboardingColors.cardBackground,
|
||||||
|
borderRadius: 20,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
marginBottom: 12,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.05,
|
||||||
|
shadowRadius: 10,
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
optionText: {
|
||||||
|
fontSize: 18,
|
||||||
|
color: OnboardingColors.textPrimary,
|
||||||
|
fontWeight: '500',
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
iconWrapper: {
|
||||||
|
marginLeft: 10,
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: height * 0.12,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
footerRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 16,
|
||||||
|
},
|
||||||
|
skipBtn: {
|
||||||
|
paddingVertical: 10,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.04)',
|
||||||
|
},
|
||||||
|
skipText: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: OnboardingColors.textMuted,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState, useRef } from 'react';
|
||||||
import { Modal, Pressable, StyleSheet, Text, View } from 'react-native';
|
import { Modal, Pressable, StyleSheet, Text, View, PanResponder, Animated as RNAnimated, Dimensions, Image, ImageSourcePropType } from 'react-native';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import Animated, {
|
import Animated, {
|
||||||
Easing,
|
Easing,
|
||||||
@@ -9,26 +9,35 @@ import Animated, {
|
|||||||
withTiming,
|
withTiming,
|
||||||
} from 'react-native-reanimated';
|
} from 'react-native-reanimated';
|
||||||
|
|
||||||
|
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
|
||||||
|
const FIXED_TOP_GAP = 100; // 统一距离顶部的高度
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
title?: string;
|
title?: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
leftIcon?: ImageSourcePropType; // 新增:支持自定义左侧图标
|
||||||
|
height?: number; // 新增:支持自定义高度
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 通用底部上拉弹窗(Sheet)
|
* 通用底部上拉弹窗(Sheet)
|
||||||
* - 内容区背景色固定:#FAF3EC
|
* - 内容区背景色固定:#FAF3EC
|
||||||
* - 关闭方式:点 X(本期不要求点遮罩关闭)
|
* - 高度固定:默认距离顶部固定间距,也支持传入指定高度
|
||||||
*/
|
*/
|
||||||
export default function SheetModal({ visible, title, onClose, children }: Props) {
|
export default function SheetModal({ visible, title, onClose, children, leftIcon, height: customHeight }: Props) {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const progress = useSharedValue(0); // 0: 关闭, 1: 打开
|
const progress = useSharedValue(0); // 0: 关闭, 1: 打开
|
||||||
|
const dragY = useSharedValue(0); // 拖拽位移
|
||||||
|
|
||||||
|
const sheetHeight = customHeight || (SCREEN_HEIGHT - FIXED_TOP_GAP);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
|
dragY.value = 0;
|
||||||
progress.value = withTiming(1, { duration: 260, easing: Easing.out(Easing.cubic) });
|
progress.value = withTiming(1, { duration: 260, easing: Easing.out(Easing.cubic) });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -40,46 +49,88 @@ export default function SheetModal({ visible, title, onClose, children }: Props)
|
|||||||
if (finished) runOnJS(setMounted)(false);
|
if (finished) runOnJS(setMounted)(false);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}, [visible, mounted, progress]);
|
}, [visible, mounted, progress, dragY]);
|
||||||
|
|
||||||
|
// 使用 PanResponder 处理下滑手势
|
||||||
|
const panResponder = useRef(
|
||||||
|
PanResponder.create({
|
||||||
|
onStartShouldSetPanResponder: () => false,
|
||||||
|
onMoveShouldSetPanResponder: () => false, // 暂时屏蔽下拉关闭手势,解决滑动冲突
|
||||||
|
onPanResponderMove: (_, gestureState) => {
|
||||||
|
if (gestureState.dy > 0) {
|
||||||
|
dragY.value = gestureState.dy;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onPanResponderRelease: (_, gestureState) => {
|
||||||
|
if (gestureState.dy > 80 || gestureState.vy > 0.5) {
|
||||||
|
runOnJS(onClose)();
|
||||||
|
} else {
|
||||||
|
dragY.value = withTiming(0, {
|
||||||
|
duration: 300,
|
||||||
|
easing: Easing.out(Easing.back(1))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onPanResponderTerminate: () => {
|
||||||
|
dragY.value = withTiming(0, { duration: 200 });
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).current;
|
||||||
|
|
||||||
const overlayStyle = useAnimatedStyle(() => {
|
const overlayStyle = useAnimatedStyle(() => {
|
||||||
return { opacity: 0.4 * progress.value };
|
return { opacity: 0.4 * progress.value };
|
||||||
});
|
});
|
||||||
|
|
||||||
const sheetStyle = useAnimatedStyle(() => {
|
const sheetStyle = useAnimatedStyle(() => {
|
||||||
const translateY = (1 - progress.value) * 380;
|
const baseTranslateY = (1 - progress.value) * sheetHeight; // 基础位移
|
||||||
return { transform: [{ translateY }] };
|
return {
|
||||||
|
transform: [{ translateY: baseTranslateY + dragY.value }]
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const containerPaddingBottom = useMemo(() => Math.max(insets.bottom, 12), [insets.bottom]);
|
const containerPaddingBottom = useMemo(() => Math.max(insets.bottom, 80), [insets.bottom]); // 增加底部间距至 80,约占 350 高度的 22%,确保内容不被截断并留出足够呼吸感
|
||||||
|
|
||||||
// 注意:Modal 的 visible 必须为 true 才会渲染,因此用 mounted 保持退场动画
|
// 注意:Modal 的 visible 必须为 true 才会渲染,因此用 mounted 保持退场动画
|
||||||
return (
|
return (
|
||||||
<Modal transparent visible={mounted} animationType="none" onRequestClose={onClose}>
|
<Modal transparent visible={mounted} animationType="none" onRequestClose={onClose}>
|
||||||
<View style={styles.root}>
|
<View style={styles.root}>
|
||||||
<Animated.View style={[styles.overlay, overlayStyle]} />
|
<Pressable
|
||||||
|
style={StyleSheet.absoluteFill}
|
||||||
|
onPress={onClose}
|
||||||
|
>
|
||||||
|
<Animated.View style={[styles.overlay, overlayStyle]} />
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
<Animated.View
|
<Animated.View
|
||||||
|
{...panResponder.panHandlers}
|
||||||
style={[
|
style={[
|
||||||
styles.sheet,
|
styles.sheet,
|
||||||
sheetStyle,
|
sheetStyle,
|
||||||
{
|
{
|
||||||
|
height: sheetHeight,
|
||||||
paddingBottom: containerPaddingBottom,
|
paddingBottom: containerPaddingBottom,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
|
<View style={styles.handleContainer}>
|
||||||
|
<View style={styles.handle} />
|
||||||
|
</View>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Text style={styles.title} numberOfLines={1}>
|
<Text style={styles.title} numberOfLines={1}>
|
||||||
{title ?? ''}
|
{title ?? ''}
|
||||||
</Text>
|
</Text>
|
||||||
<Pressable
|
<Pressable
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel="关闭"
|
accessibilityLabel={leftIcon ? "返回" : "关闭"}
|
||||||
onPress={onClose}
|
onPress={onClose}
|
||||||
hitSlop={10}
|
hitSlop={10}
|
||||||
style={styles.close}
|
style={styles.close}
|
||||||
>
|
>
|
||||||
<Text style={styles.closeText}>×</Text>
|
{leftIcon ? (
|
||||||
|
<Image source={leftIcon} style={styles.backIcon} />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.closeText}>×</Text>
|
||||||
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -103,9 +154,19 @@ const styles = StyleSheet.create({
|
|||||||
backgroundColor: '#FAF3EC',
|
backgroundColor: '#FAF3EC',
|
||||||
borderTopLeftRadius: 24,
|
borderTopLeftRadius: 24,
|
||||||
borderTopRightRadius: 24,
|
borderTopRightRadius: 24,
|
||||||
paddingTop: 14,
|
paddingTop: 8,
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
},
|
},
|
||||||
|
handleContainer: {
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 8,
|
||||||
|
},
|
||||||
|
handle: {
|
||||||
|
width: 40,
|
||||||
|
height: 5,
|
||||||
|
borderRadius: 2.5,
|
||||||
|
backgroundColor: 'rgba(94,42,40,0.15)',
|
||||||
|
},
|
||||||
header: {
|
header: {
|
||||||
height: 44,
|
height: 44,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
@@ -131,8 +192,13 @@ const styles = StyleSheet.create({
|
|||||||
lineHeight: 28,
|
lineHeight: 28,
|
||||||
fontWeight: '400',
|
fontWeight: '400',
|
||||||
},
|
},
|
||||||
|
backIcon: {
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
resizeMode: 'contain',
|
||||||
|
},
|
||||||
body: {
|
body: {
|
||||||
paddingTop: 10,
|
paddingTop: 10,
|
||||||
|
flex: 1,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
export const OnboardingColors = {
|
export const OnboardingColors = {
|
||||||
background: '#FFF4EA',
|
background: '#FFF4EA',
|
||||||
textPrimary: '#4A3B32',
|
textPrimary: '#772F00',
|
||||||
textSecondary: '#8C8C8C',
|
textSecondary: '#DED2CA', // 默认 Mama 字体颜色
|
||||||
|
textMuted: '#A27854', // Skip 按钮颜色
|
||||||
|
textProgress: '#D4B08E', // (0/5) 颜色
|
||||||
|
questionTitle: '#B8504D', // 问题标题颜色
|
||||||
|
buttonNotClicked: '#F2DDCA',
|
||||||
buttonStart: '#F69F7B',
|
buttonStart: '#F69F7B',
|
||||||
buttonEnd: '#F99CC0',
|
buttonEnd: '#F99CC0',
|
||||||
cardBackground: '#FFFFFF',
|
cardBackground: '#FFFFFF',
|
||||||
cursor: '#F99CC0',
|
cardSelected: '#FFD8E2', // 选中后的背景色
|
||||||
|
cursor: '#F89DB4',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -59,5 +59,13 @@ target 'client' do
|
|||||||
:mac_catalyst_enabled => false,
|
:mac_catalyst_enabled => false,
|
||||||
:ccache_enabled => ccache_enabled?(podfile_properties),
|
:ccache_enabled => ccache_enabled?(podfile_properties),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 生成并随归档产物携带 dSYM(用于崩溃符号化与 Upload Symbols Failed 修复)
|
||||||
|
installer.pods_project.targets.each do |target|
|
||||||
|
target.build_configurations.each do |build_config|
|
||||||
|
build_config.build_settings['DEBUG_INFORMATION_FORMAT'] = 'dwarf-with-dsym'
|
||||||
|
build_config.build_settings['DWARF_DSYM_FILE_SHOULD_ACCOMPANY_PRODUCT'] = 'YES'
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -2397,6 +2397,6 @@ SPEC CHECKSUMS:
|
|||||||
RNWorklets: 9ccdc8112b17af6eee2c85a233891cb80db150ad
|
RNWorklets: 9ccdc8112b17af6eee2c85a233891cb80db150ad
|
||||||
Yoga: 5934998fbeaef7845dbf698f698518695ab4cd1a
|
Yoga: 5934998fbeaef7845dbf698f698518695ab4cd1a
|
||||||
|
|
||||||
PODFILE CHECKSUM: dfe3cc75dee014a0abd367bc9e1bdbab0ba64ee3
|
PODFILE CHECKSUM: 4d5c52f9fa870c1d398cf59e37c149f66700c061
|
||||||
|
|
||||||
COCOAPODS: 1.16.2
|
COCOAPODS: 1.16.2
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
archiveVersion = 1;
|
archiveVersion = 1;
|
||||||
classes = {
|
classes = {
|
||||||
};
|
};
|
||||||
objectVersion = 56;
|
objectVersion = 77;
|
||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
@@ -17,6 +17,8 @@
|
|||||||
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, ); }; };
|
||||||
|
EB630B302F30BE2700DC761A /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = EB630B2F2F30BE2700DC761A /* Assets.xcassets */; };
|
||||||
|
EB630B312F30BE2700DC761A /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = EB630B2F2F30BE2700DC761A /* Assets.xcassets */; };
|
||||||
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; };
|
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
@@ -59,6 +61,7 @@
|
|||||||
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>"; };
|
EB3DAF9A2F2A4D0900450593 /* MindfulnessWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MindfulnessWidget.swift; sourceTree = "<group>"; };
|
||||||
|
EB630B2F2F30BE2700DC761A /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
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>"; };
|
||||||
@@ -156,6 +159,7 @@
|
|||||||
83CBB9F61A601CBA00E9B192 = {
|
83CBB9F61A601CBA00E9B192 = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
EB630B2F2F30BE2700DC761A /* Assets.xcassets */,
|
||||||
13B07FAE1A68108700A75B9A /* client */,
|
13B07FAE1A68108700A75B9A /* client */,
|
||||||
832341AE1AAA6A7D00B99B32 /* Libraries */,
|
832341AE1AAA6A7D00B99B32 /* Libraries */,
|
||||||
EB3DAF842F2A4B8E00450593 /* 情绪小组件 */,
|
EB3DAF842F2A4B8E00450593 /* 情绪小组件 */,
|
||||||
@@ -305,6 +309,7 @@
|
|||||||
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
|
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
|
||||||
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */,
|
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */,
|
||||||
0BE245B56A79D95AB0A7B4BA /* PrivacyInfo.xcprivacy in Resources */,
|
0BE245B56A79D95AB0A7B4BA /* PrivacyInfo.xcprivacy in Resources */,
|
||||||
|
EB630B312F30BE2700DC761A /* Assets.xcassets in Resources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -312,6 +317,7 @@
|
|||||||
isa = PBXResourcesBuildPhase;
|
isa = PBXResourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
EB630B302F30BE2700DC761A /* Assets.xcassets in Resources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -475,9 +481,11 @@
|
|||||||
baseConfigurationReference = FFF632A94C7A551AAA096858 /* Pods-client.debug.xcconfig */;
|
baseConfigurationReference = FFF632A94C7A551AAA096858 /* Pods-client.debug.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
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 = 1;
|
CURRENT_PROJECT_VERSION = 2;
|
||||||
|
DEVELOPMENT_TEAM = WS92GPX9H2;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
@@ -489,14 +497,14 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0.0;
|
||||||
OTHER_LDFLAGS = (
|
OTHER_LDFLAGS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"-ObjC",
|
"-ObjC",
|
||||||
"-lc++",
|
"-lc++",
|
||||||
);
|
);
|
||||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
|
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.anonymous.client;
|
PRODUCT_BUNDLE_IDENTIFIER = com.damer.mindfulness;
|
||||||
PRODUCT_NAME = client;
|
PRODUCT_NAME = client;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
SUPPORTS_MACCATALYST = NO;
|
SUPPORTS_MACCATALYST = NO;
|
||||||
@@ -505,7 +513,7 @@
|
|||||||
SWIFT_OBJC_BRIDGING_HEADER = "client/client-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "client/client-Bridging-Header.h";
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = 1;
|
||||||
VERSIONING_SYSTEM = "apple-generic";
|
VERSIONING_SYSTEM = "apple-generic";
|
||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
@@ -515,23 +523,27 @@
|
|||||||
baseConfigurationReference = 3C76CA16D0801CBF0D731C7C /* Pods-client.release.xcconfig */;
|
baseConfigurationReference = 3C76CA16D0801CBF0D731C7C /* Pods-client.release.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
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 = 1;
|
CURRENT_PROJECT_VERSION = 2;
|
||||||
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
|
DEVELOPMENT_TEAM = WS92GPX9H2;
|
||||||
|
DWARF_DSYM_FILE_SHOULD_ACCOMPANY_PRODUCT = YES;
|
||||||
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;
|
MARKETING_VERSION = 1.0.0;
|
||||||
OTHER_LDFLAGS = (
|
OTHER_LDFLAGS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"-ObjC",
|
"-ObjC",
|
||||||
"-lc++",
|
"-lc++",
|
||||||
);
|
);
|
||||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
|
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.anonymous.client;
|
PRODUCT_BUNDLE_IDENTIFIER = com.damer.mindfulness;
|
||||||
PRODUCT_NAME = client;
|
PRODUCT_NAME = client;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
SUPPORTS_MACCATALYST = NO;
|
SUPPORTS_MACCATALYST = NO;
|
||||||
@@ -539,7 +551,7 @@
|
|||||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "client/client-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "client/client-Bridging-Header.h";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = 1;
|
||||||
VERSIONING_SYSTEM = "apple-generic";
|
VERSIONING_SYSTEM = "apple-generic";
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
@@ -676,8 +688,9 @@
|
|||||||
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_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 2;
|
||||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
|
DEVELOPMENT_TEAM = WS92GPX9H2;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -695,7 +708,7 @@
|
|||||||
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";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.anonymous.client.emotionwidget;
|
PRODUCT_BUNDLE_IDENTIFIER = com.damer.mindfulness.emotionwidget;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
@@ -709,7 +722,7 @@
|
|||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = 1;
|
||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
};
|
};
|
||||||
@@ -728,8 +741,9 @@
|
|||||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 2;
|
||||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
|
DEVELOPMENT_TEAM = WS92GPX9H2;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -746,7 +760,7 @@
|
|||||||
MARKETING_VERSION = 1.0.0;
|
MARKETING_VERSION = 1.0.0;
|
||||||
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.anonymous.client.emotionwidget;
|
PRODUCT_BUNDLE_IDENTIFIER = com.damer.mindfulness.emotionwidget;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
@@ -759,7 +773,7 @@
|
|||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = 1;
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
};
|
};
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 142 KiB |
@@ -1,81 +1,81 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?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">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>client</string>
|
<string>client</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
<key>CFBundleInfoDictionaryVersion</key>
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
<string>6.0</string>
|
<string>6.0</string>
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
<string>$(PRODUCT_NAME)</string>
|
<string>$(PRODUCT_NAME)</string>
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.0.0</string>
|
<string>$(MARKETING_VERSION)</string>
|
||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleURLTypes</key>
|
<key>CFBundleURLTypes</key>
|
||||||
<array>
|
<array>
|
||||||
<dict>
|
<dict>
|
||||||
<key>CFBundleURLSchemes</key>
|
<key>CFBundleURLSchemes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>client</string>
|
<string>client</string>
|
||||||
<string>com.anonymous.client</string>
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>1</string>
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
<key>LSMinimumSystemVersion</key>
|
<key>LSMinimumSystemVersion</key>
|
||||||
<string>12.0</string>
|
<string>12.0</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>NSAppTransportSecurity</key>
|
<key>NSAppTransportSecurity</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSAllowsArbitraryLoads</key>
|
<key>NSAllowsArbitraryLoads</key>
|
||||||
<false/>
|
<false/>
|
||||||
<key>NSAllowsLocalNetworking</key>
|
<key>NSAllowsLocalNetworking</key>
|
||||||
<true/>
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
<key>NSUserActivityTypes</key>
|
<key>NSUserActivityTypes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route</string>
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route</string>
|
||||||
</array>
|
</array>
|
||||||
<key>RCTNewArchEnabled</key>
|
<key>RCTNewArchEnabled</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>UILaunchStoryboardName</key>
|
<key>UILaunchStoryboardName</key>
|
||||||
<string>SplashScreen</string>
|
<string></string>
|
||||||
<key>UIRequiredDeviceCapabilities</key>
|
<key>UIRequiredDeviceCapabilities</key>
|
||||||
<array>
|
<array>
|
||||||
<string>arm64</string>
|
<string>arm64</string>
|
||||||
</array>
|
</array>
|
||||||
<key>UIRequiresFullScreen</key>
|
<key>UIRequiresFullScreen</key>
|
||||||
<false/>
|
<false/>
|
||||||
<key>UIStatusBarStyle</key>
|
<key>UIStatusBarStyle</key>
|
||||||
<string>UIStatusBarStyleDefault</string>
|
<string>UIStatusBarStyleDefault</string>
|
||||||
<key>UISupportedInterfaceOrientations</key>
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
<array>
|
<array>
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
</array>
|
</array>
|
||||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||||
<array>
|
<array>
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
</array>
|
</array>
|
||||||
<key>UIUserInterfaceStyle</key>
|
<key>UIUserInterfaceStyle</key>
|
||||||
<string>Automatic</string>
|
<string>Automatic</string>
|
||||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||||
<false/>
|
<false/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>aps-environment</key>
|
<key>aps-environment</key>
|
||||||
<string>development</string>
|
<string>production</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
"android": "expo run:android",
|
"android": "expo run:android",
|
||||||
"ios": "expo run:ios",
|
"ios": "expo run:ios",
|
||||||
"web": "expo start --web",
|
"web": "expo start --web",
|
||||||
"postinstall": "node ./scripts/postinstall-fix-metro.js"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/vector-icons": "^15.0.3",
|
"@expo/vector-icons": "^15.0.3",
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
"@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-dev-client": "^6.0.20",
|
||||||
"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,11 +26,11 @@
|
|||||||
"expo-status-bar": "~3.0.9",
|
"expo-status-bar": "~3.0.9",
|
||||||
"expo-web-browser": "~15.0.10",
|
"expo-web-browser": "~15.0.10",
|
||||||
"i18next": "^25.8.0",
|
"i18next": "^25.8.0",
|
||||||
"pnpm": "^10.28.2",
|
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-i18next": "^16.5.4",
|
"react-i18next": "^16.5.4",
|
||||||
"react-native": "0.81.5",
|
"react-native": "0.81.5",
|
||||||
|
"react-native-gesture-handler": "^2.30.0",
|
||||||
"react-native-reanimated": "~4.1.1",
|
"react-native-reanimated": "~4.1.1",
|
||||||
"react-native-safe-area-context": "~5.6.0",
|
"react-native-safe-area-context": "~5.6.0",
|
||||||
"react-native-screens": "~4.16.0",
|
"react-native-screens": "~4.16.0",
|
||||||
@@ -41,7 +42,8 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "~19.1.0",
|
"@types/react": "~19.1.0",
|
||||||
"react-test-renderer": "19.1.0",
|
"react-test-renderer": "19.1.0",
|
||||||
"typescript": "~5.9.2"
|
"typescript": "~5.9.2",
|
||||||
|
"vitest": "^4.0.18"
|
||||||
},
|
},
|
||||||
"private": true
|
"private": true
|
||||||
}
|
}
|
||||||
|
|||||||
1090
client/pnpm-lock.yaml
generated
@@ -20,14 +20,31 @@ function getOptionalEnv(name: string, fallback: string): string {
|
|||||||
return process.env[name] ?? fallback;
|
return process.env[name] ?? fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const APP_ENV = (getOptionalEnv('EXPO_PUBLIC_ENV', 'dev') as AppEnv) ?? 'dev';
|
export type AppRuntimeEnv = 'local' | 'dev' | 'prod';
|
||||||
|
|
||||||
export const API_BASE_URL = getRequiredEnv('EXPO_PUBLIC_API_BASE_URL');
|
export const APP_ENV = (getOptionalEnv('EXPO_PUBLIC_ENV', 'local') as AppRuntimeEnv) ?? 'local';
|
||||||
|
|
||||||
|
function getApiBaseUrl(env: AppRuntimeEnv): string {
|
||||||
|
// 向后兼容:若直接提供了 EXPO_PUBLIC_API_BASE_URL,则优先使用(不再强制要求 *_DEV/_PROD)
|
||||||
|
const direct = process.env.EXPO_PUBLIC_API_BASE_URL;
|
||||||
|
if (direct && String(direct).trim()) return String(direct).trim();
|
||||||
|
|
||||||
|
// 约定:local/dev/prod 三套域名分别配置, 便于后续直接切环境而不改代码
|
||||||
|
if (env === 'local') {
|
||||||
|
return getOptionalEnv('EXPO_PUBLIC_API_BASE_URL_LOCAL', 'http://localhost:8000');
|
||||||
|
}
|
||||||
|
if (env === 'dev') {
|
||||||
|
return getOptionalEnv('EXPO_PUBLIC_API_BASE_URL_DEV', getOptionalEnv('EXPO_PUBLIC_API_BASE_URL_LOCAL', 'http://localhost:8000'));
|
||||||
|
}
|
||||||
|
return getOptionalEnv('EXPO_PUBLIC_API_BASE_URL_PROD', getOptionalEnv('EXPO_PUBLIC_API_BASE_URL_LOCAL', 'http://localhost:8000'));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const API_BASE_URL = getApiBaseUrl(APP_ENV);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 默认语言策略:
|
* 默认语言策略:
|
||||||
* - auto:优先设备语言(支持列表内时),否则回退 zh-CN
|
* - auto:优先设备语言(支持列表内时),否则回退 en
|
||||||
* - zh-CN/en/es/pt/zh-TW:固定默认语言(仍允许用户在设置中手动切换并持久化)
|
* - en/zh-TW:固定默认语言(仍允许用户在设置中手动切换并持久化)
|
||||||
*/
|
*/
|
||||||
export const DEFAULT_LANGUAGE = getOptionalEnv('EXPO_PUBLIC_DEFAULT_LANGUAGE', 'auto');
|
export const DEFAULT_LANGUAGE = getOptionalEnv('EXPO_PUBLIC_DEFAULT_LANGUAGE', 'auto');
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { buildUserProfileFromQuestionnaire } from '../index';
|
||||||
|
import { mapOnboardingSelectionsToQuestionnaireAnswers } from '../onboardingMapping';
|
||||||
|
|
||||||
|
describe('Onboarding → UserProfileScoring 集成', () => {
|
||||||
|
it('完整作答:Onboarding 选择能正确映射并生成画像', () => {
|
||||||
|
const selections = {
|
||||||
|
status: ['pregnant'],
|
||||||
|
emotion: ['calm'],
|
||||||
|
influence: ['work'],
|
||||||
|
support: ['balance'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const answers = mapOnboardingSelectionsToQuestionnaireAnswers(selections);
|
||||||
|
expect(answers).toEqual({
|
||||||
|
mom_stage: 'expecting',
|
||||||
|
emotion: 'calm',
|
||||||
|
context: 'work',
|
||||||
|
need: 'rest_balance',
|
||||||
|
});
|
||||||
|
|
||||||
|
const p = buildUserProfileFromQuestionnaire(answers, {
|
||||||
|
generatedAt: '2026-01-30T00:00:00Z',
|
||||||
|
now: '2026-01-30T00:00:00Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(p.stage).toEqual({ expecting: 1, parenting: 0, unknown: 0 });
|
||||||
|
expect(p.emotion_score).toBe(0.8);
|
||||||
|
expect(p.context).toEqual({ work: 1 });
|
||||||
|
expect(p.need).toEqual({ rest_balance: 1 });
|
||||||
|
expect(p.profile_answered).toEqual({ stage: true, emotion: true, context: true, need: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('全部跳过:仍能生成最小可计算画像(unknown=1)', () => {
|
||||||
|
const answers = mapOnboardingSelectionsToQuestionnaireAnswers({});
|
||||||
|
expect(answers).toEqual({ mom_stage: null, emotion: null, context: null, need: null });
|
||||||
|
|
||||||
|
const p = buildUserProfileFromQuestionnaire(answers, {
|
||||||
|
generatedAt: '2026-01-30T00:00:00Z',
|
||||||
|
now: '2026-01-30T00:00:00Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(p.stage).toEqual({ unknown: 1 });
|
||||||
|
expect(p.emotion_score).toBeNull();
|
||||||
|
expect(p.context).toEqual({});
|
||||||
|
expect(p.need).toEqual({});
|
||||||
|
expect(p.profile_answered).toEqual({ stage: false, emotion: false, context: false, need: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildUserProfileFromQuestionnaire,
|
||||||
|
computeProfileConfidence,
|
||||||
|
computeTimeConfidence,
|
||||||
|
normalizeAnswers,
|
||||||
|
} from '../index';
|
||||||
|
|
||||||
|
describe('userProfileScoring V1.2', () => {
|
||||||
|
it('normalizeAnswers: 非法值按跳过处理', () => {
|
||||||
|
// @ts-expect-error: 模拟非法输入
|
||||||
|
const out = normalizeAnswers({ mom_stage: 'xxx', emotion: 'yyy', context: 'zzz', need: 'ooo' });
|
||||||
|
expect(out).toEqual({ mom_stage: undefined, emotion: undefined, context: undefined, need: undefined });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('computeTimeConfidence: 分段衰减', () => {
|
||||||
|
const gen = new Date('2026-01-01T00:00:00Z');
|
||||||
|
|
||||||
|
// 0–7 天:1.0
|
||||||
|
expect(computeTimeConfidence(gen, new Date('2026-01-05T00:00:00Z'))).toBe(1.0);
|
||||||
|
|
||||||
|
// 30 天以上:0.5
|
||||||
|
expect(computeTimeConfidence(gen, new Date('2026-02-15T00:00:00Z'))).toBe(0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('computeProfileConfidence: 完整度因子 + clamp', () => {
|
||||||
|
const confTime = 1.0;
|
||||||
|
|
||||||
|
// 全部跳过:completion=0 → completionFactor=0.5 → 0.5
|
||||||
|
expect(
|
||||||
|
computeProfileConfidence(confTime, { stage: false, emotion: false, context: false, need: false })
|
||||||
|
).toBe(0.5);
|
||||||
|
|
||||||
|
// 全部作答:completion=1 → completionFactor=1 → 1
|
||||||
|
expect(computeProfileConfidence(confTime, { stage: true, emotion: true, context: true, need: true })).toBe(1.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('buildUserProfileFromQuestionnaire: 全部跳过输出最小可计算画像', () => {
|
||||||
|
const p = buildUserProfileFromQuestionnaire({}, { generatedAt: '2026-01-30T00:00:00Z', now: '2026-01-30T00:00:00Z' });
|
||||||
|
|
||||||
|
expect(p.profile_version).toBe('v1.2');
|
||||||
|
expect(p.profile_source).toBe('questionnaire');
|
||||||
|
|
||||||
|
expect(p.profile_answered).toEqual({ stage: false, emotion: false, context: false, need: false });
|
||||||
|
expect(p.stage).toEqual({ unknown: 1 });
|
||||||
|
expect(p.emotion_score).toBeNull();
|
||||||
|
expect(p.context).toEqual({});
|
||||||
|
expect(p.need).toEqual({});
|
||||||
|
|
||||||
|
// conf_time=1,completionFactor=0.5
|
||||||
|
expect(p.profile_confidence).toBe(0.5);
|
||||||
|
|
||||||
|
// unknown 会命中 unsafe_for_stage_unknown,并带跨维度谓词
|
||||||
|
expect(p.hard_rules.forbidden_risk_flags).toContain('unsafe_for_stage_unknown');
|
||||||
|
expect(p.hard_rules.forbidden_content_predicates.some((x) => x.id === 'unknown_block_parenting_pressure_personalized')).toBe(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('buildUserProfileFromQuestionnaire: emotion<=0.2 命中 unsafe_for_emotion_low', () => {
|
||||||
|
const p = buildUserProfileFromQuestionnaire(
|
||||||
|
{ mom_stage: 'expecting', emotion: 'overwhelmed', context: 'health', need: 'anxiety_relief' },
|
||||||
|
{ generatedAt: '2026-01-30T00:00:00Z', now: '2026-01-30T00:00:00Z' }
|
||||||
|
);
|
||||||
|
expect(p.emotion_score).toBe(0.2);
|
||||||
|
expect(p.hard_rules.forbidden_risk_flags).toContain('unsafe_for_emotion_low');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
19
client/src/features/userProfileScoring/index.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
export type {
|
||||||
|
BuildUserProfileOptions,
|
||||||
|
QuestionnaireAnswersV1_2,
|
||||||
|
UserProfileV1_2,
|
||||||
|
UserProfileV1_2_Extended,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
export type { OnboardingSelections } from './onboardingMapping';
|
||||||
|
|
||||||
|
export {
|
||||||
|
buildUserProfileFromQuestionnaire,
|
||||||
|
computeProfileAnswered,
|
||||||
|
computeProfileConfidence,
|
||||||
|
computeTimeConfidence,
|
||||||
|
normalizeAnswers,
|
||||||
|
} from './scoring';
|
||||||
|
|
||||||
|
export { mapOnboardingSelectionsToQuestionnaireAnswers } from './onboardingMapping';
|
||||||
|
|
||||||
61
client/src/features/userProfileScoring/onboardingMapping.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import type { QuestionnaireAnswersV1_2 } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Onboarding UI 的选项 ID → 标准问卷枚举(可跳过)
|
||||||
|
*
|
||||||
|
* 说明:
|
||||||
|
* - UI 侧每题目前是单选,但数据结构是 string[];这里取第 1 个作为答案
|
||||||
|
* - 不存在错误处理:未知/非法值统一按“跳过”处理(返回 null)
|
||||||
|
*/
|
||||||
|
export type OnboardingSelections = Record<string, string[] | undefined>;
|
||||||
|
|
||||||
|
export function mapOnboardingSelectionsToQuestionnaireAnswers(
|
||||||
|
selections: OnboardingSelections
|
||||||
|
): QuestionnaireAnswersV1_2 {
|
||||||
|
return {
|
||||||
|
mom_stage: mapMomStage(selections.status?.[0]),
|
||||||
|
emotion: mapEmotion(selections.emotion?.[0]),
|
||||||
|
context: mapContext(selections.influence?.[0]),
|
||||||
|
need: mapNeed(selections.support?.[0]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapMomStage(raw: string | undefined): QuestionnaireAnswersV1_2['mom_stage'] {
|
||||||
|
// 跳过:null(显式跳过)
|
||||||
|
if (!raw) return null;
|
||||||
|
// UI id → 标准枚举
|
||||||
|
if (raw === 'pregnant') return 'expecting';
|
||||||
|
if (raw === 'has_kids') return 'parenting';
|
||||||
|
if (raw === 'no_fill') return 'unknown';
|
||||||
|
// 其他非法值:按跳过处理
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapEmotion(raw: string | undefined): QuestionnaireAnswersV1_2['emotion'] {
|
||||||
|
if (!raw) return null;
|
||||||
|
// UI 当前选项:happy/calm/stressed/low
|
||||||
|
if (raw === 'happy') return 'joyful';
|
||||||
|
if (raw === 'calm') return 'calm';
|
||||||
|
if (raw === 'stressed') return 'overwhelmed';
|
||||||
|
if (raw === 'low') return 'low';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapContext(raw: string | undefined): QuestionnaireAnswersV1_2['context'] {
|
||||||
|
if (!raw) return null;
|
||||||
|
// UI id 已与标准枚举一致:family/work/relationship/friends/health
|
||||||
|
if (raw === 'family' || raw === 'work' || raw === 'relationship' || raw === 'friends' || raw === 'health') return raw;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapNeed(raw: string | undefined): QuestionnaireAnswersV1_2['need'] {
|
||||||
|
if (!raw) return null;
|
||||||
|
// UI id → 标准枚举
|
||||||
|
if (raw === 'emotional') return 'emotional_support';
|
||||||
|
if (raw === 'parenting') return 'parenting_pressure';
|
||||||
|
if (raw === 'self_worth') return 'self_worth';
|
||||||
|
if (raw === 'anxiety') return 'anxiety_relief';
|
||||||
|
if (raw === 'balance') return 'rest_balance';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
233
client/src/features/userProfileScoring/scoring.ts
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
/**
|
||||||
|
* 用户画像打分(User Profile Scoring)V1.2
|
||||||
|
*
|
||||||
|
* 规则来源:
|
||||||
|
* - `spec_kit/User Profile Scoring/spec.md`
|
||||||
|
* - `设计说明文档/客戶端問卷打分規則.md`(V1.2)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
BuildUserProfileOptions,
|
||||||
|
ContextAnswer,
|
||||||
|
EmotionAnswer,
|
||||||
|
HardRules,
|
||||||
|
MomStageAnswer,
|
||||||
|
NeedAnswer,
|
||||||
|
ProfileAnswered,
|
||||||
|
QuestionnaireAnswersV1_2,
|
||||||
|
SparseOneHot,
|
||||||
|
UserProfileV1_2_Extended,
|
||||||
|
UserStageOneHot,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number): number {
|
||||||
|
if (!Number.isFinite(value)) return min;
|
||||||
|
return Math.min(max, Math.max(min, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDate(value: Date | string | undefined): Date | null {
|
||||||
|
if (!value) return null;
|
||||||
|
if (value instanceof Date) return Number.isFinite(value.getTime()) ? value : null;
|
||||||
|
const d = new Date(value);
|
||||||
|
return Number.isFinite(d.getTime()) ? d : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMomStageAnswer(v: unknown): v is MomStageAnswer {
|
||||||
|
return v === 'expecting' || v === 'parenting' || v === 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEmotionAnswer(v: unknown): v is EmotionAnswer {
|
||||||
|
return (
|
||||||
|
v === 'low' ||
|
||||||
|
v === 'overwhelmed' ||
|
||||||
|
v === 'tired' ||
|
||||||
|
v === 'neutral' ||
|
||||||
|
v === 'calm' ||
|
||||||
|
v === 'joyful'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isContextAnswer(v: unknown): v is ContextAnswer {
|
||||||
|
return v === 'family' || v === 'work' || v === 'relationship' || v === 'friends' || v === 'health';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNeedAnswer(v: unknown): v is NeedAnswer {
|
||||||
|
return (
|
||||||
|
v === 'emotional_support' ||
|
||||||
|
v === 'parenting_pressure' ||
|
||||||
|
v === 'self_worth' ||
|
||||||
|
v === 'anxiety_relief' ||
|
||||||
|
v === 'rest_balance'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 归一化答案:非法值按“跳过”处理(归一化为 undefined)
|
||||||
|
* - `null` 保留,表示显式跳过/无值
|
||||||
|
*/
|
||||||
|
export function normalizeAnswers(raw: QuestionnaireAnswersV1_2): QuestionnaireAnswersV1_2 {
|
||||||
|
const mom_stage =
|
||||||
|
raw.mom_stage === null ? null : isMomStageAnswer(raw.mom_stage) ? raw.mom_stage : undefined;
|
||||||
|
const emotion = raw.emotion === null ? null : isEmotionAnswer(raw.emotion) ? raw.emotion : undefined;
|
||||||
|
const context = raw.context === null ? null : isContextAnswer(raw.context) ? raw.context : undefined;
|
||||||
|
const need = raw.need === null ? null : isNeedAnswer(raw.need) ? raw.need : undefined;
|
||||||
|
|
||||||
|
return { mom_stage, emotion, context, need };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeProfileAnswered(normalized: QuestionnaireAnswersV1_2): ProfileAnswered {
|
||||||
|
return {
|
||||||
|
stage: normalized.mom_stage !== undefined && normalized.mom_stage !== null,
|
||||||
|
emotion: normalized.emotion !== undefined && normalized.emotion !== null,
|
||||||
|
context: normalized.context !== undefined && normalized.context !== null,
|
||||||
|
need: normalized.need !== undefined && normalized.need !== null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 时间衰减置信度(conf_time)
|
||||||
|
* - 0–7 天:1.0
|
||||||
|
* - 7–30 天:线性衰减到 0.7(含第 30 天)
|
||||||
|
* - 30 天以上:0.5
|
||||||
|
*/
|
||||||
|
export function computeTimeConfidence(generatedAt: Date, now: Date): number {
|
||||||
|
const deltaMs = now.getTime() - generatedAt.getTime();
|
||||||
|
if (!Number.isFinite(deltaMs) || deltaMs <= 0) return 1.0;
|
||||||
|
|
||||||
|
const days = deltaMs / MS_PER_DAY;
|
||||||
|
if (days <= 7) return 1.0;
|
||||||
|
if (days <= 30) {
|
||||||
|
const t = (days - 7) / (30 - 7); // 0..1
|
||||||
|
return 1.0 - 0.3 * t; // 1 -> 0.7
|
||||||
|
}
|
||||||
|
return 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V1.2:profile_confidence(conf_U)
|
||||||
|
* conf = clamp(conf_time * (0.5 + 0.5 * completion), 0.2, 1.0)
|
||||||
|
*/
|
||||||
|
export function computeProfileConfidence(confTime: number, answered: ProfileAnswered): number {
|
||||||
|
const answeredCount =
|
||||||
|
(answered.stage ? 1 : 0) + (answered.emotion ? 1 : 0) + (answered.context ? 1 : 0) + (answered.need ? 1 : 0);
|
||||||
|
const completion = answeredCount / 4;
|
||||||
|
const completionFactor = 0.5 + 0.5 * completion;
|
||||||
|
return clamp(confTime * completionFactor, 0.2, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStageOneHot(momStage: MomStageAnswer | null | undefined): UserStageOneHot {
|
||||||
|
// V1.2:mom_stage 跳过按安全策略输出 unknown=1
|
||||||
|
if (momStage === null || momStage === undefined) {
|
||||||
|
return { unknown: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
expecting: momStage === 'expecting' ? 1 : 0,
|
||||||
|
parenting: momStage === 'parenting' ? 1 : 0,
|
||||||
|
unknown: momStage === 'unknown' ? 1 : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapEmotionScore(emotion: EmotionAnswer | null | undefined): number | null {
|
||||||
|
if (emotion === null || emotion === undefined) return null;
|
||||||
|
switch (emotion) {
|
||||||
|
case 'low':
|
||||||
|
return 0.0;
|
||||||
|
case 'overwhelmed':
|
||||||
|
return 0.2;
|
||||||
|
case 'tired':
|
||||||
|
return 0.4;
|
||||||
|
case 'neutral':
|
||||||
|
return 0.6;
|
||||||
|
case 'calm':
|
||||||
|
return 0.8;
|
||||||
|
case 'joyful':
|
||||||
|
return 1.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSparseOneHot(value: string | null | undefined): SparseOneHot {
|
||||||
|
if (value === null || value === undefined) return {};
|
||||||
|
return { [value]: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeRuleHitsAndHardRules(profile: {
|
||||||
|
stage: UserStageOneHot;
|
||||||
|
emotion_score: number | null;
|
||||||
|
}): { rule_hits: string[]; hard_rules: HardRules } {
|
||||||
|
const rule_hits: string[] = [];
|
||||||
|
const forbidden_risk_flags: string[] = [];
|
||||||
|
|
||||||
|
const stageUnknown = profile.stage.unknown === 1;
|
||||||
|
const stageParenting = profile.stage.parenting === 1;
|
||||||
|
|
||||||
|
if (stageUnknown) {
|
||||||
|
rule_hits.push('unsafe_for_stage_unknown');
|
||||||
|
forbidden_risk_flags.push('unsafe_for_stage_unknown');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stageParenting) {
|
||||||
|
rule_hits.push('unsafe_for_stage_parenting');
|
||||||
|
forbidden_risk_flags.push('unsafe_for_stage_parenting');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profile.emotion_score !== null && profile.emotion_score <= 0.2) {
|
||||||
|
rule_hits.push('unsafe_for_emotion_low');
|
||||||
|
forbidden_risk_flags.push('unsafe_for_emotion_low');
|
||||||
|
}
|
||||||
|
|
||||||
|
const forbidden_content_predicates = [];
|
||||||
|
if (stageUnknown) {
|
||||||
|
forbidden_content_predicates.push({
|
||||||
|
id: 'unknown_block_parenting_pressure_personalized',
|
||||||
|
when_user: { stage_unknown: true },
|
||||||
|
forbid_content: { need: 'parenting_pressure', personalization_power: 1 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
rule_hits,
|
||||||
|
hard_rules: {
|
||||||
|
forbidden_risk_flags,
|
||||||
|
forbidden_content_predicates,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildUserProfileFromQuestionnaire(
|
||||||
|
rawAnswers: QuestionnaireAnswersV1_2,
|
||||||
|
options: BuildUserProfileOptions = {}
|
||||||
|
): UserProfileV1_2_Extended {
|
||||||
|
const normalized = normalizeAnswers(rawAnswers);
|
||||||
|
const profile_answered = computeProfileAnswered(normalized);
|
||||||
|
|
||||||
|
const now = toDate(options.now) ?? new Date();
|
||||||
|
const generatedAt = toDate(options.generatedAt) ?? now;
|
||||||
|
|
||||||
|
const confTime = computeTimeConfidence(generatedAt, now);
|
||||||
|
const profile_confidence = computeProfileConfidence(confTime, profile_answered);
|
||||||
|
|
||||||
|
const stage = buildStageOneHot(normalized.mom_stage);
|
||||||
|
const emotion_score = mapEmotionScore(normalized.emotion);
|
||||||
|
const context = buildSparseOneHot(normalized.context);
|
||||||
|
const need = buildSparseOneHot(normalized.need);
|
||||||
|
|
||||||
|
const { rule_hits, hard_rules } = computeRuleHitsAndHardRules({ stage, emotion_score });
|
||||||
|
|
||||||
|
return {
|
||||||
|
profile_version: 'v1.2',
|
||||||
|
profile_source: 'questionnaire',
|
||||||
|
profile_generated_at: generatedAt.toISOString(),
|
||||||
|
profile_confidence,
|
||||||
|
profile_answered,
|
||||||
|
stage,
|
||||||
|
emotion_score,
|
||||||
|
context,
|
||||||
|
need,
|
||||||
|
rule_hits,
|
||||||
|
hard_rules,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
95
client/src/features/userProfileScoring/types.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* 用户画像打分(User Profile Scoring)V1.2 类型定义
|
||||||
|
*
|
||||||
|
* 说明:
|
||||||
|
* - 本模块用于:问卷答案(可跳过)→ 用户画像(可计算、可观测、可版本化)
|
||||||
|
* - 字段与规则以 `spec_kit/User Profile Scoring/spec.md`(V1.2)为准
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type MomStageAnswer = 'expecting' | 'parenting' | 'unknown';
|
||||||
|
export type EmotionAnswer = 'low' | 'overwhelmed' | 'tired' | 'neutral' | 'calm' | 'joyful';
|
||||||
|
export type ContextAnswer = 'family' | 'work' | 'relationship' | 'friends' | 'health';
|
||||||
|
export type NeedAnswer =
|
||||||
|
| 'emotional_support'
|
||||||
|
| 'parenting_pressure'
|
||||||
|
| 'self_worth'
|
||||||
|
| 'anxiety_relief'
|
||||||
|
| 'rest_balance';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V1.2:每题可跳过
|
||||||
|
* - `undefined`:字段缺失(可能是“没传”)
|
||||||
|
* - `null`:显式跳过/无值(例如 UI 明确传 null)
|
||||||
|
*/
|
||||||
|
export type QuestionnaireAnswersV1_2 = {
|
||||||
|
mom_stage?: MomStageAnswer | null;
|
||||||
|
emotion?: EmotionAnswer | null;
|
||||||
|
context?: ContextAnswer | null;
|
||||||
|
need?: NeedAnswer | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProfileAnswered = {
|
||||||
|
stage: boolean;
|
||||||
|
emotion: boolean;
|
||||||
|
context: boolean;
|
||||||
|
need: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UserStageOneHot = {
|
||||||
|
expecting?: 0 | 1;
|
||||||
|
parenting?: 0 | 1;
|
||||||
|
unknown: 0 | 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SparseOneHot = Record<string, 1>;
|
||||||
|
|
||||||
|
export type UserProfileV1_2 = {
|
||||||
|
profile_version: 'v1.2';
|
||||||
|
profile_source: 'questionnaire';
|
||||||
|
profile_generated_at: string; // ISO8601
|
||||||
|
profile_confidence: number; // 0–1
|
||||||
|
profile_answered: ProfileAnswered;
|
||||||
|
stage: UserStageOneHot;
|
||||||
|
emotion_score: number | null;
|
||||||
|
context: SparseOneHot;
|
||||||
|
need: SparseOneHot;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ForbiddenContentPredicate = {
|
||||||
|
/**
|
||||||
|
* 谓词 ID:用于可观测与回归测试
|
||||||
|
*/
|
||||||
|
id: string;
|
||||||
|
/**
|
||||||
|
* 触发条件(用户侧)
|
||||||
|
* 说明:这里刻意保持为 object,便于未来接入规则引擎时做 schema 对齐。
|
||||||
|
*/
|
||||||
|
when_user: Record<string, unknown>;
|
||||||
|
/**
|
||||||
|
* 禁推条件(内容侧)
|
||||||
|
* 说明:本模块不判断内容的 `personalization_power`,只输出可执行条件。
|
||||||
|
*/
|
||||||
|
forbid_content: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HardRules = {
|
||||||
|
forbidden_risk_flags: string[];
|
||||||
|
forbidden_content_predicates: ForbiddenContentPredicate[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UserProfileV1_2_Extended = UserProfileV1_2 & {
|
||||||
|
rule_hits: string[];
|
||||||
|
hard_rules: HardRules;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BuildUserProfileOptions = {
|
||||||
|
/**
|
||||||
|
* 画像生成时间;不传则使用当前时间
|
||||||
|
*/
|
||||||
|
generatedAt?: Date | string;
|
||||||
|
/**
|
||||||
|
* 当前时间(用于计算 time decay);不传则使用当前时间
|
||||||
|
*/
|
||||||
|
now?: Date | string;
|
||||||
|
};
|
||||||
|
|
||||||
@@ -4,30 +4,21 @@ import i18n from 'i18next';
|
|||||||
import { initReactI18next } from 'react-i18next';
|
import { initReactI18next } from 'react-i18next';
|
||||||
|
|
||||||
import en from './locales/en.json';
|
import en from './locales/en.json';
|
||||||
import es from './locales/es.json';
|
|
||||||
import pt from './locales/pt.json';
|
|
||||||
import zhCN from './locales/zh-CN.json';
|
|
||||||
import zhTW from './locales/zh-TW.json';
|
import zhTW from './locales/zh-TW.json';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 语言码约定:
|
* 语言码约定:
|
||||||
* - 简体中文:zh-CN
|
|
||||||
* - 繁体中文:zh-TW
|
* - 繁体中文:zh-TW
|
||||||
* - 英语:en
|
* - 英语:en
|
||||||
* - 西班牙语:es
|
|
||||||
* - 葡萄牙语:pt
|
|
||||||
*/
|
*/
|
||||||
export type AppLanguage = 'zh-CN' | 'zh-TW' | 'en' | 'es' | 'pt';
|
export type AppLanguage = 'zh-TW' | 'en';
|
||||||
|
|
||||||
export const SUPPORTED_LANGUAGES: readonly AppLanguage[] = [
|
export const SUPPORTED_LANGUAGES: readonly AppLanguage[] = [
|
||||||
'zh-CN',
|
|
||||||
'zh-TW',
|
'zh-TW',
|
||||||
'en',
|
'en',
|
||||||
'es',
|
|
||||||
'pt',
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const DEFAULT_FALLBACK_LANGUAGE: AppLanguage = 'zh-CN';
|
const DEFAULT_FALLBACK_LANGUAGE: AppLanguage = 'en';
|
||||||
const STORAGE_KEY_LANGUAGE = 'settings.language';
|
const STORAGE_KEY_LANGUAGE = 'settings.language';
|
||||||
|
|
||||||
function isSupportedLanguage(lang: string): lang is AppLanguage {
|
function isSupportedLanguage(lang: string): lang is AppLanguage {
|
||||||
@@ -37,19 +28,13 @@ function isSupportedLanguage(lang: string): lang is AppLanguage {
|
|||||||
function normalizeDeviceLanguageTagToAppLanguage(languageTag: string): AppLanguage {
|
function normalizeDeviceLanguageTagToAppLanguage(languageTag: string): AppLanguage {
|
||||||
const tag = languageTag.toLowerCase();
|
const tag = languageTag.toLowerCase();
|
||||||
|
|
||||||
// 中文:优先区分繁简
|
// 中文:当前仅支持繁体中文(zh-TW)
|
||||||
if (tag.startsWith('zh')) {
|
if (tag.startsWith('zh')) {
|
||||||
// 常见繁体标记:zh-TW / zh-HK / zh-Hant
|
return 'zh-TW';
|
||||||
if (tag.includes('tw') || tag.includes('hk') || tag.includes('hant')) {
|
|
||||||
return 'zh-TW';
|
|
||||||
}
|
|
||||||
return 'zh-CN';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 其他语言:按前缀匹配
|
// 其他语言:按前缀匹配(当前仅支持英文)
|
||||||
if (tag.startsWith('en')) return 'en';
|
if (tag.startsWith('en')) return 'en';
|
||||||
if (tag.startsWith('es')) return 'es';
|
|
||||||
if (tag.startsWith('pt')) return 'pt';
|
|
||||||
|
|
||||||
return DEFAULT_FALLBACK_LANGUAGE;
|
return DEFAULT_FALLBACK_LANGUAGE;
|
||||||
}
|
}
|
||||||
@@ -87,7 +72,7 @@ export async function clearLanguagePreference(): Promise<void> {
|
|||||||
* 语言选择优先级:
|
* 语言选择优先级:
|
||||||
* 1) 用户设置(若存在)
|
* 1) 用户设置(若存在)
|
||||||
* 2) 设备语言(在支持列表内时生效;否则会被 normalize 到默认回退)
|
* 2) 设备语言(在支持列表内时生效;否则会被 normalize 到默认回退)
|
||||||
* 3) 默认回退(zh-CN)
|
* 3) 默认回退(en)
|
||||||
*/
|
*/
|
||||||
export async function initI18n(): Promise<void> {
|
export async function initI18n(): Promise<void> {
|
||||||
if (i18n.isInitialized) return;
|
if (i18n.isInitialized) return;
|
||||||
@@ -98,11 +83,8 @@ export async function initI18n(): Promise<void> {
|
|||||||
|
|
||||||
await i18n.use(initReactI18next).init({
|
await i18n.use(initReactI18next).init({
|
||||||
resources: {
|
resources: {
|
||||||
'zh-CN': { translation: zhCN },
|
|
||||||
'zh-TW': { translation: zhTW },
|
'zh-TW': { translation: zhTW },
|
||||||
en: { translation: en },
|
en: { translation: en },
|
||||||
es: { translation: es },
|
|
||||||
pt: { translation: pt },
|
|
||||||
},
|
},
|
||||||
lng: initialLang,
|
lng: initialLang,
|
||||||
fallbackLng: DEFAULT_FALLBACK_LANGUAGE,
|
fallbackLng: DEFAULT_FALLBACK_LANGUAGE,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"ok": "确定",
|
"ok": "确定",
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
"error": "错误",
|
"error": "错误",
|
||||||
|
"notice": "提示",
|
||||||
"openLinkError": "无法打开链接",
|
"openLinkError": "无法打开链接",
|
||||||
"back": "返回"
|
"back": "返回"
|
||||||
},
|
},
|
||||||
|
|||||||
63
client/src/services/recoApi.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import i18n from 'i18next';
|
||||||
|
|
||||||
|
import { API_BASE_URL } from '@/src/constants/env';
|
||||||
|
import type { UserProfileV1_2 } from '@/src/features/userProfileScoring';
|
||||||
|
|
||||||
|
export type RecommendedItem = {
|
||||||
|
content_id: number;
|
||||||
|
text: string;
|
||||||
|
final_score: number;
|
||||||
|
fallback_level_final: number;
|
||||||
|
explanations?: Record<string, unknown> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RecoMeta = Record<string, unknown>;
|
||||||
|
|
||||||
|
export type RecoEngineResult = {
|
||||||
|
items: RecommendedItem[];
|
||||||
|
meta: RecoMeta;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RecoRequest = {
|
||||||
|
k?: number;
|
||||||
|
user_profile: UserProfileV1_2;
|
||||||
|
already_recommended_ids?: Array<string | number>;
|
||||||
|
touched_or_viewed_ids?: Array<string | number>;
|
||||||
|
now?: string; // ISO8601(可选)
|
||||||
|
};
|
||||||
|
|
||||||
|
function withTimeout(ms: number): AbortController {
|
||||||
|
const controller = new AbortController();
|
||||||
|
setTimeout(() => controller.abort(), ms);
|
||||||
|
return controller;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchRecoFeed(req: RecoRequest): Promise<RecoEngineResult> {
|
||||||
|
const controller = withTimeout(12_000);
|
||||||
|
const url = `${API_BASE_URL}/v1/reco/feed`;
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
// 让后端做 locale 选择(目前后端只区分 en/tc)
|
||||||
|
'Accept-Language': i18n.language || 'en',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
k: req.k,
|
||||||
|
user_profile: req.user_profile,
|
||||||
|
already_recommended_ids: req.already_recommended_ids ?? [],
|
||||||
|
touched_or_viewed_ids: req.touched_or_viewed_ids ?? [],
|
||||||
|
now: req.now,
|
||||||
|
}),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
throw new Error(`推荐接口请求失败:${res.status} ${res.statusText} ${text}`.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await res.json()) as RecoEngineResult;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import type { UserProfileV1_2_Extended } from '@/src/features/userProfileScoring';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 本地存储 key 统一管理,避免 UI 里散落硬编码
|
* 本地存储 key 统一管理,避免 UI 里散落硬编码
|
||||||
@@ -9,6 +10,9 @@ const KEY_CONTENT_REACTIONS = 'content.reactions';
|
|||||||
const KEY_FAVORITES_ITEMS = 'favorites.items';
|
const KEY_FAVORITES_ITEMS = 'favorites.items';
|
||||||
const KEY_CONSENT_ACCEPTED = 'consent.accepted';
|
const KEY_CONSENT_ACCEPTED = 'consent.accepted';
|
||||||
const KEY_USER_PROFILE = 'user.profile';
|
const KEY_USER_PROFILE = 'user.profile';
|
||||||
|
const KEY_USER_PROFILE_SCORING = 'user.profileScoring';
|
||||||
|
const KEY_RECO_FEED_CACHE = 'reco.feedCache';
|
||||||
|
const KEY_RECO_FEED_HISTORY = 'reco.feedHistory';
|
||||||
const KEY_UI_THEME_MODE = 'ui.theme.mode';
|
const KEY_UI_THEME_MODE = 'ui.theme.mode';
|
||||||
const KEY_DAILY_REMINDER_SETTINGS = 'dailyReminder.settings';
|
const KEY_DAILY_REMINDER_SETTINGS = 'dailyReminder.settings';
|
||||||
|
|
||||||
@@ -20,11 +24,42 @@ export type UserProfile = {
|
|||||||
name?: string;
|
name?: string;
|
||||||
intents?: string[];
|
intents?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户画像(问卷打分输出)
|
||||||
|
* 说明:用于推荐/Push/Widget 统一复用;结构以 `src/features/userProfileScoring` 输出为准。
|
||||||
|
*/
|
||||||
|
export type UserProfileScoring = UserProfileV1_2_Extended;
|
||||||
export type DailyReminderSettings = {
|
export type DailyReminderSettings = {
|
||||||
timesPerDay: number;
|
timesPerDay: number;
|
||||||
pushEnabled: boolean;
|
pushEnabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type RecoFeedCacheItem = {
|
||||||
|
content_id: number;
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RecoFeedCache = {
|
||||||
|
saved_at: string; // ISO8601
|
||||||
|
items: RecoFeedCacheItem[];
|
||||||
|
meta?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feed 链路可观测输入(用于下一次请求携带给后端)
|
||||||
|
*
|
||||||
|
* - already_recommended_ids:本设备已下发过的内容(避免重复下发)
|
||||||
|
* - touched_or_viewed_ids:本设备用户已看过/划过的内容(用于频控/去重/降重复)
|
||||||
|
*
|
||||||
|
* 说明:后端不需要“实时知道”,只要在下一次拉取时带上即可。
|
||||||
|
*/
|
||||||
|
export type RecoFeedHistory = {
|
||||||
|
updated_at: string; // ISO8601
|
||||||
|
already_recommended_ids: number[];
|
||||||
|
touched_or_viewed_ids: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
async function getJson<T>(key: string, fallback: T): Promise<T> {
|
async function getJson<T>(key: string, fallback: T): Promise<T> {
|
||||||
const raw = await AsyncStorage.getItem(key);
|
const raw = await AsyncStorage.getItem(key);
|
||||||
@@ -69,14 +104,30 @@ export async function setReaction(contentId: string, reaction: Reaction): Promis
|
|||||||
await setJson(KEY_CONTENT_REACTIONS, next);
|
await setJson(KEY_CONTENT_REACTIONS, next);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getFavorites(): Promise<string[]> {
|
export type FavoriteItem = {
|
||||||
return await getJson<string[]>(KEY_FAVORITES_ITEMS, []);
|
favId: string; // 唯一标识,支持重复点赞同一文案
|
||||||
|
id: string;
|
||||||
|
date: string;
|
||||||
|
themeMode: ThemeMode;
|
||||||
|
background: string; // 颜色值或图片路径
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getFavorites(): Promise<FavoriteItem[]> {
|
||||||
|
return await getJson<FavoriteItem[]>(KEY_FAVORITES_ITEMS, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addFavorite(contentId: string): Promise<void> {
|
export async function addFavorite(item: FavoriteItem): Promise<void> {
|
||||||
const list = await getFavorites();
|
const list = await getFavorites();
|
||||||
if (list.includes(contentId)) return;
|
// 允许重复点赞,不再根据 id 去重
|
||||||
await setJson(KEY_FAVORITES_ITEMS, [...list, contentId]);
|
const newList = [item, ...list];
|
||||||
|
console.log('Adding to favorites, new list size:', newList.length);
|
||||||
|
await setJson(KEY_FAVORITES_ITEMS, newList);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeFavorite(favId: string): Promise<void> {
|
||||||
|
const list = await getFavorites();
|
||||||
|
const next = list.filter(item => item.favId !== favId);
|
||||||
|
await setJson(KEY_FAVORITES_ITEMS, next);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getConsentAccepted(): Promise<boolean> {
|
export async function getConsentAccepted(): Promise<boolean> {
|
||||||
@@ -107,6 +158,103 @@ export async function setUserProfile(profile: UserProfile): Promise<void> {
|
|||||||
await setJson(KEY_USER_PROFILE, { ...current, ...profile });
|
await setJson(KEY_USER_PROFILE, { ...current, ...profile });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getUserProfileScoring(): Promise<UserProfileScoring | null> {
|
||||||
|
const raw = await AsyncStorage.getItem(KEY_USER_PROFILE_SCORING);
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as UserProfileScoring;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setUserProfileScoring(profile: UserProfileScoring): Promise<void> {
|
||||||
|
await setJson(KEY_USER_PROFILE_SCORING, profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRecoFeedCache(): Promise<RecoFeedCache | null> {
|
||||||
|
const raw = await AsyncStorage.getItem(KEY_RECO_FEED_CACHE);
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as RecoFeedCache;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setRecoFeedCache(cache: RecoFeedCache): Promise<void> {
|
||||||
|
await setJson(KEY_RECO_FEED_CACHE, cache);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRecoFeedHistory(): Promise<RecoFeedHistory> {
|
||||||
|
const raw = await AsyncStorage.getItem(KEY_RECO_FEED_HISTORY);
|
||||||
|
if (!raw) {
|
||||||
|
return {
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
already_recommended_ids: [],
|
||||||
|
touched_or_viewed_ids: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as Partial<RecoFeedHistory>;
|
||||||
|
return {
|
||||||
|
updated_at: typeof parsed.updated_at === 'string' ? parsed.updated_at : new Date().toISOString(),
|
||||||
|
already_recommended_ids: Array.isArray(parsed.already_recommended_ids)
|
||||||
|
? parsed.already_recommended_ids.filter((x) => Number.isFinite(x)).map((x) => Number(x))
|
||||||
|
: [],
|
||||||
|
touched_or_viewed_ids: Array.isArray(parsed.touched_or_viewed_ids)
|
||||||
|
? parsed.touched_or_viewed_ids.filter((x) => Number.isFinite(x)).map((x) => Number(x))
|
||||||
|
: [],
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
already_recommended_ids: [],
|
||||||
|
touched_or_viewed_ids: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setRecoFeedHistory(history: RecoFeedHistory): Promise<void> {
|
||||||
|
await setJson(KEY_RECO_FEED_HISTORY, history);
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqKeepLatest(list: number[], max: number): number[] {
|
||||||
|
const seen = new Set<number>();
|
||||||
|
const out: number[] = [];
|
||||||
|
for (let i = list.length - 1; i >= 0; i -= 1) {
|
||||||
|
const v = list[i];
|
||||||
|
if (!Number.isFinite(v)) continue;
|
||||||
|
if (seen.has(v)) continue;
|
||||||
|
seen.add(v);
|
||||||
|
out.push(v);
|
||||||
|
if (out.length >= max) break;
|
||||||
|
}
|
||||||
|
return out.reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recordRecoFeedServed(contentIds: number[]): Promise<void> {
|
||||||
|
if (!contentIds?.length) return;
|
||||||
|
const h = await getRecoFeedHistory();
|
||||||
|
const next = {
|
||||||
|
...h,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
already_recommended_ids: uniqKeepLatest([...h.already_recommended_ids, ...contentIds], 500),
|
||||||
|
};
|
||||||
|
await setRecoFeedHistory(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recordRecoFeedTouched(contentId: number): Promise<void> {
|
||||||
|
if (!Number.isFinite(contentId)) return;
|
||||||
|
const h = await getRecoFeedHistory();
|
||||||
|
const next = {
|
||||||
|
...h,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
touched_or_viewed_ids: uniqKeepLatest([...h.touched_or_viewed_ids, contentId], 500),
|
||||||
|
};
|
||||||
|
await setRecoFeedHistory(next);
|
||||||
|
}
|
||||||
|
|
||||||
export async function getDailyReminderSettings(): Promise<DailyReminderSettings> {
|
export async function getDailyReminderSettings(): Promise<DailyReminderSettings> {
|
||||||
const s = await getJson<DailyReminderSettings>(KEY_DAILY_REMINDER_SETTINGS, {
|
const s = await getJson<DailyReminderSettings>(KEY_DAILY_REMINDER_SETTINGS, {
|
||||||
timesPerDay: 3,
|
timesPerDay: 3,
|
||||||
|
|||||||
20
server/.dockerignore
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
.venv
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
|
||||||
|
# 本地/测试数据
|
||||||
|
.test.db
|
||||||
|
*.db
|
||||||
|
|
||||||
|
# 测试与开发脚本(按需移除)
|
||||||
|
tests/
|
||||||
|
.env*
|
||||||
|
|
||||||
|
# Git 元数据
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
20
server/.env.dev
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# 运行环境:dev 或 prod
|
||||||
|
APP_ENV=dev
|
||||||
|
|
||||||
|
# Web 服务
|
||||||
|
APP_NAME=mindfulness-server
|
||||||
|
APP_HOST=0.0.0.0
|
||||||
|
APP_PORT=8000
|
||||||
|
|
||||||
|
# 数据库(dev 指向 mindfulness_dev;prod 指向 mindfulness)
|
||||||
|
DATABASE_URL=mysql+aiomysql://damer:damer@43.163.242.87:3306/mindfulness_dev?charset=utf8mb4
|
||||||
|
|
||||||
|
# Redis(使用 ACL 用户;并确保应用侧 key 带 dev:/pro: 前缀)
|
||||||
|
REDIS_URL=redis://dev_damer:damer@43.163.242.87:6379/0
|
||||||
|
|
||||||
|
# Celery(默认不启用结果存储,避免 Redis 内存压力)
|
||||||
|
CELERY_BROKER_URL=redis://dev_damer:damer@43.163.242.87:6379/0
|
||||||
|
# CELERY_RESULT_BACKEND=redis://<REDIS_USER>:<REDIS_PASSWORD>@<REDIS_HOST>:6379/0
|
||||||
|
|
||||||
|
# 推送(Expo)
|
||||||
|
# EXPO_ACCESS_TOKEN=
|
||||||
20
server/.env.prod
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# 运行环境:dev 或 prod
|
||||||
|
APP_ENV=prod
|
||||||
|
|
||||||
|
# Web 服务
|
||||||
|
APP_NAME=mindfulness-server
|
||||||
|
APP_HOST=0.0.0.0
|
||||||
|
APP_PORT=8000
|
||||||
|
|
||||||
|
# 数据库(dev 指向 mindfulness_dev;prod 指向 mindfulness)
|
||||||
|
DATABASE_URL=mysql+aiomysql://damer:damer@43.163.242.87:3306/mindfulness?charset=utf8mb4
|
||||||
|
|
||||||
|
# Redis(使用 ACL 用户;并确保应用侧 key 带 dev:/pro: 前缀)
|
||||||
|
REDIS_URL=redis://prod_damer:damer@43.163.242.87:6379/0
|
||||||
|
|
||||||
|
# Celery(默认不启用结果存储,避免 Redis 内存压力)
|
||||||
|
CELERY_BROKER_URL=redis://prod_damer:damer@43.163.242.87:6379/0
|
||||||
|
# CELERY_RESULT_BACKEND=redis://<REDIS_USER>:<REDIS_PASSWORD>@<REDIS_HOST>:6379/0
|
||||||
|
|
||||||
|
# 推送(Expo)
|
||||||
|
# EXPO_ACCESS_TOKEN=
|
||||||
BIN
server/.test.db
Normal file
27
server/Dockerfile
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# 运行时基础环境
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 系统依赖(按需扩展;多数依赖为纯 Python)
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends build-essential curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# 先复制依赖清单以利用 Docker layer cache
|
||||||
|
COPY requirements.txt /app/requirements.txt
|
||||||
|
RUN python -m pip install -U pip \
|
||||||
|
&& pip install --no-cache-dir -r /app/requirements.txt
|
||||||
|
|
||||||
|
# 复制后端代码与迁移配置
|
||||||
|
COPY app /app/app
|
||||||
|
COPY alembic /app/alembic
|
||||||
|
COPY alembic.ini /app/alembic.ini
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# 生产镜像默认不开启 reload
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
39
server/alembic.ini
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
[alembic]
|
||||||
|
script_location = alembic
|
||||||
|
|
||||||
|
# 注意:实际连接串由 alembic/env.py 从环境变量 DATABASE_URL 注入
|
||||||
|
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
|
||||||
40
server/alembic/README.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# Alembic(数据库迁移)
|
||||||
|
|
||||||
|
## 1. 前置
|
||||||
|
|
||||||
|
- 在 `server/` 下准备 `.env.dev`(或系统环境变量),至少包含:
|
||||||
|
- `DATABASE_URL=mysql+aiomysql://...`
|
||||||
|
|
||||||
|
> 注意:本仓库推荐使用 Python 虚拟环境(venv)。示例以 `server/.venv` 为准。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 安装依赖(一次性)
|
||||||
|
|
||||||
|
在仓库根目录:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m venv server/.venv
|
||||||
|
source server/.venv/bin/activate
|
||||||
|
pip install -r server/requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 常用命令
|
||||||
|
|
||||||
|
在 `server/` 目录运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source .venv/bin/activate
|
||||||
|
alembic -c alembic.ini history
|
||||||
|
alembic -c alembic.ini upgrade head
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 说明
|
||||||
|
|
||||||
|
- 连接串由 `alembic/env.py` 从环境变量 `DATABASE_URL`(或 `app/core/config.py`)读取。
|
||||||
|
- 初始迁移版本为:`0001_init_content_tables`(创建推荐系统最小内容表与画像表)。
|
||||||
|
|
||||||