Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e980bd4e4d | ||
|
|
0b8bbebf6a | ||
|
|
1e1e49ea57 | ||
|
|
8e71503169 | ||
|
|
2b67a571bb | ||
|
|
8f84f25616 | ||
|
|
4c03fce720 |
@@ -1,3 +1,7 @@
|
|||||||
EXPO_PUBLIC_API_BASE_URL=http://localhost:8000
|
EXPO_PUBLIC_API_BASE_URL=http://localhost:8000
|
||||||
EXPO_PUBLIC_ENV=dev
|
EXPO_PUBLIC_ENV=dev
|
||||||
EXPO_PUBLIC_DEFAULT_LANGUAGE=auto
|
EXPO_PUBLIC_DEFAULT_LANGUAGE=auto
|
||||||
|
#
|
||||||
|
# Expo/EAS 项目 ID(UUID)。用于真机获取 Expo Push Token(expo-notifications)。
|
||||||
|
# 获取方式:在 client 目录执行 `eas project:init` 或 `eas project:info` 查看。
|
||||||
|
EXPO_PUBLIC_EAS_PROJECT_ID=c519f016-e5c8-426c-868f-5545dce8beef
|
||||||
|
|||||||
1
client/.npmrc
Normal file
@@ -0,0 +1 @@
|
|||||||
|
registry=https://registry.npmmirror.com
|
||||||
30
client/app.config.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import type { ConfigContext, ExpoConfig } from 'expo/config';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 运行时获取 Push Token(expo-notifications)在真机/Dev Client 场景下通常需要 projectId。
|
||||||
|
*
|
||||||
|
* 这里把 projectId 注入到 `extra.eas.projectId`:
|
||||||
|
* - 开发/本地:从 `.env.local`(EXPO_PUBLIC_EAS_PROJECT_ID)读取并写入配置
|
||||||
|
* - CI/EAS:也可通过环境变量注入(EXPO_PUBLIC_EAS_PROJECT_ID 或 EAS_PROJECT_ID)
|
||||||
|
*/
|
||||||
|
export default ({ config }: ConfigContext): ExpoConfig => {
|
||||||
|
const projectId =
|
||||||
|
process.env.EXPO_PUBLIC_EAS_PROJECT_ID ||
|
||||||
|
// 兼容部分 CI/EAS 注入的变量名
|
||||||
|
process.env.EAS_PROJECT_ID ||
|
||||||
|
undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
extra: {
|
||||||
|
...(config.extra ?? {}),
|
||||||
|
eas: {
|
||||||
|
// 保留已有配置,再覆盖 projectId
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
...(((config.extra as any) ?? {}).eas ?? {}),
|
||||||
|
projectId: projectId ?? (config.extra as any)?.eas?.projectId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
@@ -9,9 +9,9 @@
|
|||||||
"userInterfaceStyle": "automatic",
|
"userInterfaceStyle": "automatic",
|
||||||
"newArchEnabled": true,
|
"newArchEnabled": true,
|
||||||
"splash": {
|
"splash": {
|
||||||
"image": "./assets/images/splash-icon.png",
|
"image": "./assets/images/splashScreen.png",
|
||||||
"resizeMode": "contain",
|
"resizeMode": "contain",
|
||||||
"backgroundColor": "#ffffff"
|
"backgroundColor": "#EAD2BA"
|
||||||
},
|
},
|
||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": true,
|
"supportsTablet": true,
|
||||||
@@ -23,7 +23,8 @@
|
|||||||
"backgroundColor": "#ffffff"
|
"backgroundColor": "#ffffff"
|
||||||
},
|
},
|
||||||
"edgeToEdgeEnabled": true,
|
"edgeToEdgeEnabled": true,
|
||||||
"predictiveBackGestureEnabled": false
|
"predictiveBackGestureEnabled": false,
|
||||||
|
"package": "com.damer.mindfulness"
|
||||||
},
|
},
|
||||||
"web": {
|
"web": {
|
||||||
"bundler": "metro",
|
"bundler": "metro",
|
||||||
@@ -35,6 +36,13 @@
|
|||||||
],
|
],
|
||||||
"experiments": {
|
"experiments": {
|
||||||
"typedRoutes": true
|
"typedRoutes": true
|
||||||
}
|
},
|
||||||
|
"extra": {
|
||||||
|
"eas": {
|
||||||
|
"projectId": "c519f016-e5c8-426c-868f-5545dce8beef"
|
||||||
|
},
|
||||||
|
"router": {}
|
||||||
|
},
|
||||||
|
"owner": "damersu"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,9 +25,13 @@ import {
|
|||||||
getRecoFeedHistory,
|
getRecoFeedHistory,
|
||||||
recordRecoFeedServed,
|
recordRecoFeedServed,
|
||||||
type ThemeMode,
|
type ThemeMode,
|
||||||
|
getSuixinThemeState,
|
||||||
|
setSuixinThemeState,
|
||||||
|
type SuixinThemeStateV1,
|
||||||
} from '@/src/storage/appStorage';
|
} from '@/src/storage/appStorage';
|
||||||
|
|
||||||
import { fetchRecoFeed } from '@/src/services/recoApi';
|
import { fetchRecoFeed } from '@/src/services/recoApi';
|
||||||
|
import { toBackendLocaleFromLanguageTag } from '@/src/i18n/locale';
|
||||||
|
|
||||||
import ProfileModal from '@/components/home/ProfileModal';
|
import ProfileModal from '@/components/home/ProfileModal';
|
||||||
import ThemeModal from '@/components/home/ThemeModal';
|
import ThemeModal from '@/components/home/ThemeModal';
|
||||||
@@ -37,6 +41,9 @@ import MyIcon from '@/assets/images/home/my.svg';
|
|||||||
import LikeFilledIcon from '@/assets/images/home/like_filled.svg';
|
import LikeFilledIcon from '@/assets/images/home/like_filled.svg';
|
||||||
import LikeIcon from '@/assets/images/icon/like_icon.svg';
|
import LikeIcon from '@/assets/images/icon/like_icon.svg';
|
||||||
|
|
||||||
|
import { getBootId } from '@/src/utils/bootSession';
|
||||||
|
import { advanceSuixinState, buildInitialSuixinState, NEUTRAL_THEME_COLORS } from '@/src/features/suixinTheme';
|
||||||
|
|
||||||
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
|
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
|
||||||
|
|
||||||
// 预定义风景图列表
|
// 预定义风景图列表
|
||||||
@@ -78,10 +85,11 @@ type FeedItem = { content_id: string; text: string };
|
|||||||
export default function HomeScreen() {
|
export default function HomeScreen() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const isEnglish = i18n.language?.startsWith('en');
|
const isEnglish = i18n.language?.startsWith('en');
|
||||||
const recoLang: 'en' | 'tc' = i18n.language?.toLowerCase().startsWith('zh') ? 'tc' : 'en';
|
const recoLang: 'en' | 'tc' = toBackendLocaleFromLanguageTag(i18n.language);
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [index, setIndex] = useState(0);
|
const [index, setIndex] = useState(0);
|
||||||
const [themeMode, setThemeModeState] = useState<ThemeMode>('scenery');
|
const [themeMode, setThemeModeState] = useState<ThemeMode>('scenery');
|
||||||
|
const [suixinBgColor, setSuixinBgColor] = useState<string>(NEUTRAL_THEME_COLORS[1]);
|
||||||
const [themeOpen, setThemeOpen] = useState(false);
|
const [themeOpen, setThemeOpen] = useState(false);
|
||||||
const [profileOpen, setProfileOpen] = useState(false);
|
const [profileOpen, setProfileOpen] = useState(false);
|
||||||
const [profileName, setProfileName] = useState<string | undefined>(undefined);
|
const [profileName, setProfileName] = useState<string | undefined>(undefined);
|
||||||
@@ -94,12 +102,55 @@ export default function HomeScreen() {
|
|||||||
// 用 ref 持有最新状态,避免 useCallback 依赖 feedItems/isFetching 造成函数 identity 变化 → effect 重复执行
|
// 用 ref 持有最新状态,避免 useCallback 依赖 feedItems/isFetching 造成函数 identity 变化 → effect 重复执行
|
||||||
const feedItemsRef = useRef<FeedItem[]>([]);
|
const feedItemsRef = useRef<FeedItem[]>([]);
|
||||||
const isFetchingRef = useRef(false);
|
const isFetchingRef = useRef(false);
|
||||||
|
const themeModeRef = useRef<ThemeMode>('scenery');
|
||||||
|
const suixinStateRef = useRef<SuixinThemeStateV1 | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
feedItemsRef.current = feedItems;
|
feedItemsRef.current = feedItems;
|
||||||
}, [feedItems]);
|
}, [feedItems]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
isFetchingRef.current = isFetching;
|
isFetchingRef.current = isFetching;
|
||||||
}, [isFetching]);
|
}, [isFetching]);
|
||||||
|
useEffect(() => {
|
||||||
|
themeModeRef.current = themeMode;
|
||||||
|
}, [themeMode]);
|
||||||
|
|
||||||
|
const ensureSuixinReady = useCallback(async () => {
|
||||||
|
const bootId = getBootId();
|
||||||
|
const stored = await getSuixinThemeState();
|
||||||
|
if (stored && stored.boot_id === bootId) {
|
||||||
|
suixinStateRef.current = stored;
|
||||||
|
setSuixinBgColor(stored.last_color || NEUTRAL_THEME_COLORS[1]);
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = await getUserProfileScoring();
|
||||||
|
const next = buildInitialSuixinState({ bootId, profile });
|
||||||
|
suixinStateRef.current = next;
|
||||||
|
setSuixinBgColor(next.last_color || NEUTRAL_THEME_COLORS[1]);
|
||||||
|
await setSuixinThemeState(next);
|
||||||
|
return next;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const advanceSuixinOnNextContent = useCallback(async () => {
|
||||||
|
if (themeModeRef.current !== 'suixin') return;
|
||||||
|
|
||||||
|
const bootId = getBootId();
|
||||||
|
let current = suixinStateRef.current;
|
||||||
|
if (!current) {
|
||||||
|
current = await getSuixinThemeState();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 冷启动后首次触发/或状态丢失:先初始化
|
||||||
|
if (!current || current.boot_id !== bootId) {
|
||||||
|
await ensureSuixinReady();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = advanceSuixinState(current);
|
||||||
|
suixinStateRef.current = next;
|
||||||
|
setSuixinBgColor(next.last_color || NEUTRAL_THEME_COLORS[1]);
|
||||||
|
await setSuixinThemeState(next);
|
||||||
|
}, [ensureSuixinReady]);
|
||||||
|
|
||||||
// 动画相关 Shared Values
|
// 动画相关 Shared Values
|
||||||
const translateY = useSharedValue(0);
|
const translateY = useSharedValue(0);
|
||||||
@@ -180,6 +231,14 @@ export default function HomeScreen() {
|
|||||||
setThemeModeState(mode);
|
setThemeModeState(mode);
|
||||||
setProfileName(profile.name);
|
setProfileName(profile.name);
|
||||||
|
|
||||||
|
// 随心:若当前主题为随心,进入 Home 时确保状态就绪(仅冷启动会话重算)
|
||||||
|
if (mode === 'suixin') {
|
||||||
|
ensureSuixinReady().catch(() => {
|
||||||
|
// ignore:失败时回退默认中性底色
|
||||||
|
setSuixinBgColor(NEUTRAL_THEME_COLORS[1]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 语言切换时:旧语言缓存不复用,触发重新拉取
|
// 语言切换时:旧语言缓存不复用,触发重新拉取
|
||||||
if (cache && cache.items.length > 0 && (cache.lang ?? 'en') === recoLang) {
|
if (cache && cache.items.length > 0 && (cache.lang ?? 'en') === recoLang) {
|
||||||
setFeedItems(cache.items.map((x) => ({ content_id: String(x.content_id), text: x.text })));
|
setFeedItems(cache.items.map((x) => ({ content_id: String(x.content_id), text: x.text })));
|
||||||
@@ -193,16 +252,19 @@ export default function HomeScreen() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [fetchNewFeed, recoLang])
|
}, [fetchNewFeed, recoLang, ensureSuixinReady])
|
||||||
);
|
);
|
||||||
|
|
||||||
const backgroundColor = useMemo(() => {
|
const backgroundColor = useMemo(() => {
|
||||||
|
if (themeMode === 'suixin') {
|
||||||
|
return suixinBgColor;
|
||||||
|
}
|
||||||
if (themeMode === 'color') {
|
if (themeMode === 'color') {
|
||||||
const colorIndex = Math.floor(index / 10) % THEME_COLORS.length;
|
const colorIndex = Math.floor(index / 10) % THEME_COLORS.length;
|
||||||
return THEME_COLORS[colorIndex];
|
return THEME_COLORS[colorIndex];
|
||||||
}
|
}
|
||||||
return '#F4D6C2'; // 风景模式下的默认底色(图片加载前显示)
|
return '#F4D6C2'; // 风景模式下的默认底色(图片加载前显示)
|
||||||
}, [themeMode, index]);
|
}, [themeMode, suixinBgColor, index]);
|
||||||
|
|
||||||
// 计算当前应该显示的风景图索引(滑动 10 次切换一张)
|
// 计算当前应该显示的风景图索引(滑动 10 次切换一张)
|
||||||
const natureImageIndex = useMemo(() => {
|
const natureImageIndex = useMemo(() => {
|
||||||
@@ -232,6 +294,7 @@ export default function HomeScreen() {
|
|||||||
// 2. 切换数据索引
|
// 2. 切换数据索引
|
||||||
runOnJS(setIndex)(index + 1);
|
runOnJS(setIndex)(index + 1);
|
||||||
runOnJS(setLikeFilled)(false);
|
runOnJS(setLikeFilled)(false);
|
||||||
|
runOnJS(advanceSuixinOnNextContent)();
|
||||||
|
|
||||||
// 检查是否需要拉取新文案(当接近当前列表末尾时,例如还剩 5 条)
|
// 检查是否需要拉取新文案(当接近当前列表末尾时,例如还剩 5 条)
|
||||||
if (index + 5 >= currentFeed.length && !isFetching) {
|
if (index + 5 >= currentFeed.length && !isFetching) {
|
||||||
@@ -332,6 +395,13 @@ export default function HomeScreen() {
|
|||||||
setThemeModeState(next);
|
setThemeModeState(next);
|
||||||
await setThemeMode(next);
|
await setThemeMode(next);
|
||||||
setThemeOpen(false);
|
setThemeOpen(false);
|
||||||
|
|
||||||
|
// 切换到随心:不主动重算(除非冷启动会话变化/状态不存在),仅确保可用
|
||||||
|
if (next === 'suixin') {
|
||||||
|
await ensureSuixinReady().catch(() => {
|
||||||
|
setSuixinBgColor(NEUTRAL_THEME_COLORS[1]);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { SelectionStep } from '@/components/onboarding/SelectionStep';
|
|||||||
import { ReminderStep } from '@/components/onboarding/ReminderStep';
|
import { ReminderStep } from '@/components/onboarding/ReminderStep';
|
||||||
import { buildUserProfileFromQuestionnaire, mapOnboardingSelectionsToQuestionnaireAnswers } from '@/src/features/userProfileScoring';
|
import { buildUserProfileFromQuestionnaire, mapOnboardingSelectionsToQuestionnaireAnswers } from '@/src/features/userProfileScoring';
|
||||||
import { ensureDailyWidgetRecoUpToDate, syncWidgetConfig, syncWidgetUserProfileFromScoring } from '@/src/modules/dailyWidgetReco';
|
import { ensureDailyWidgetRecoUpToDate, syncWidgetConfig, syncWidgetUserProfileFromScoring } from '@/src/modules/dailyWidgetReco';
|
||||||
|
import { toBackendLocaleFromLanguageTag } from '@/src/i18n/locale';
|
||||||
import { fetchRecoFeed } from '@/src/services/recoApi';
|
import { fetchRecoFeed } from '@/src/services/recoApi';
|
||||||
import { getExpoPushTokenOrThrow, registerPushToken, setPushPreferences } from '@/src/services/pushApi';
|
import { getExpoPushTokenOrThrow, registerPushToken, setPushPreferences } from '@/src/services/pushApi';
|
||||||
import {
|
import {
|
||||||
@@ -30,7 +31,7 @@ type Step =
|
|||||||
const STEPS: Step[] = [
|
const STEPS: Step[] = [
|
||||||
{ id: 'name', type: 'name' },
|
{ id: 'name', type: 'name' },
|
||||||
{ id: 'status', type: 'selection', optionIds: ['pregnant', 'has_kids', 'no_fill'] },
|
{ id: 'status', type: 'selection', optionIds: ['pregnant', 'has_kids', 'no_fill'] },
|
||||||
{ id: 'emotion', type: 'selection', optionIds: ['happy', 'calm', 'stressed', 'low'] },
|
{ id: 'emotion', type: 'selection', optionIds: ['happy', 'calm', 'okay', 'tired', 'stressed', 'low'] },
|
||||||
{ id: 'influence', type: 'selection', optionIds: ['family', 'work', 'relationship', 'friends', 'health'] },
|
{ id: 'influence', type: 'selection', optionIds: ['family', 'work', 'relationship', 'friends', 'health'] },
|
||||||
{ id: 'support', type: 'selection', optionIds: ['emotional', 'parenting', 'self_worth', 'anxiety', 'balance'] },
|
{ id: 'support', type: 'selection', optionIds: ['emotional', 'parenting', 'self_worth', 'anxiety', 'balance'] },
|
||||||
{ id: 'reminder', type: 'reminder' },
|
{ id: 'reminder', type: 'reminder' },
|
||||||
@@ -74,7 +75,7 @@ export default function OnboardingScreen() {
|
|||||||
|
|
||||||
// Onboarding 结束后预拉取一次 Feed 文案(失败不阻塞进入首页)
|
// Onboarding 结束后预拉取一次 Feed 文案(失败不阻塞进入首页)
|
||||||
try {
|
try {
|
||||||
const lang = i18n.language?.toLowerCase().startsWith('zh') ? 'tc' : 'en';
|
const lang = toBackendLocaleFromLanguageTag(i18n.language);
|
||||||
const { items, meta } = await fetchRecoFeed({
|
const { items, meta } = await fetchRecoFeed({
|
||||||
k: 30,
|
k: 30,
|
||||||
user_profile: {
|
user_profile: {
|
||||||
@@ -135,12 +136,20 @@ export default function OnboardingScreen() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1) 获取 Expo Push Token
|
// 1) 获取 Expo Push Token(失败才认为“推送开启失败”)
|
||||||
const expoPushToken = await getExpoPushTokenOrThrow();
|
const expoPushToken = await getExpoPushTokenOrThrow();
|
||||||
// 2) 上报 token 到后端(幂等)
|
// 2) 上报 token 到后端(幂等;失败才认为“推送开启失败”)
|
||||||
await registerPushToken({ pushToken: expoPushToken });
|
await registerPushToken({ pushToken: expoPushToken });
|
||||||
|
|
||||||
// 3) 上报推送偏好(幂等)
|
// 3) 上报推送偏好(幂等)
|
||||||
|
// 注意:这一步失败时,后端仍可能已成功接收 token。
|
||||||
|
// 为避免出现“后端已接收 token 但前端弹窗提示失败”的错觉,这里改为:偏好同步失败不弹“开启失败”,仅记录并继续。
|
||||||
|
try {
|
||||||
await setPushPreferences({ enabled: wantsPush, timesPerDay: reminderTimes });
|
await setPushPreferences({ enabled: wantsPush, timesPerDay: reminderTimes });
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
console.warn('[PushPreferences] 同步失败(Onboarding,不阻塞)', msg);
|
||||||
|
}
|
||||||
|
|
||||||
await setPushPromptState('enabled');
|
await setPushPromptState('enabled');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { View, Text, StyleSheet, TouchableOpacity, Dimensions, Platform, Alert, Image } from 'react-native';
|
import { View, Text, StyleSheet, TouchableOpacity, Dimensions, Platform, Alert, Image } from 'react-native';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import * as WebBrowser from 'expo-web-browser';
|
import * as WebBrowser from 'expo-web-browser';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { Trans, useTranslation } from 'react-i18next';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { setConsentAccepted, getConsentAccepted } from '../../src/storage/appStorage';
|
import { setConsentAccepted, getConsentAccepted } from '../../src/storage/appStorage';
|
||||||
import { fetchLegalLinks } from '@/src/services/legalApi';
|
import { fetchLegalLinks } from '@/src/services/legalApi';
|
||||||
|
import { getOnboardingCompleted } from '@/src/storage/appStorage';
|
||||||
|
import { API_BASE_URL } from '@/src/constants/env';
|
||||||
|
|
||||||
// 导入 SVG 组件
|
// 导入 SVG 组件
|
||||||
import FlowersBg from '../../assets/images/index/flowers_endbg.svg';
|
import FlowersBg from '../../assets/images/index/flowers_endbg.svg';
|
||||||
@@ -18,16 +20,30 @@ export default function SplashScreen() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [showConsent, setShowConsent] = useState(false);
|
const [showConsent, setShowConsent] = useState(false);
|
||||||
const [links, setLinks] = useState<{ privacy?: string; terms?: string }>({});
|
const [links, setLinks] = useState<{ privacy?: string; terms?: string }>({});
|
||||||
|
const [linksLoading, setLinksLoading] = useState(false);
|
||||||
|
const mountedRef = useRef(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
checkConsent();
|
checkConsent();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
mountedRef.current = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const checkConsent = async () => {
|
const checkConsent = async () => {
|
||||||
const accepted = await getConsentAccepted();
|
const accepted = await getConsentAccepted();
|
||||||
setShowConsent(!accepted);
|
setShowConsent(!accepted);
|
||||||
if (accepted) {
|
if (accepted) {
|
||||||
router.replace('/');
|
// 已同意协议则直接分发到目标页,避免先回到 /(index)再二次跳转导致“闪一下”
|
||||||
|
const completed = await getOnboardingCompleted();
|
||||||
|
if (completed) {
|
||||||
|
router.replace('/(app)/home');
|
||||||
|
} else {
|
||||||
|
router.replace('/(onboarding)/onboarding');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -46,23 +62,48 @@ export default function SplashScreen() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 拉取协议链接(由后端按语言下发;默认 EN)
|
async function refreshLegalLinks(): Promise<{ privacy?: string; terms?: string }> {
|
||||||
useEffect(() => {
|
if (mountedRef.current) setLinksLoading(true);
|
||||||
let cancelled = false;
|
|
||||||
(async () => {
|
|
||||||
try {
|
try {
|
||||||
const res = await fetchLegalLinks();
|
const res = await fetchLegalLinks();
|
||||||
if (cancelled) return;
|
const next = { privacy: res.privacyPolicyUrl, terms: res.termsOfUseUrl };
|
||||||
setLinks({ privacy: res.privacyPolicyUrl, terms: res.termsOfUseUrl });
|
if (mountedRef.current) setLinks(next);
|
||||||
|
return next;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 不阻塞主流程:失败时不崩溃,链接入口可不展示
|
// 不阻塞主流程:失败时不崩溃,链接入口仍可点(会提示)
|
||||||
if (__DEV__) console.log('[LegalLinks] 拉取失败(splash):', e);
|
if (__DEV__) console.log('[LegalLinks] 拉取失败(splash):', e);
|
||||||
if (!cancelled) setLinks({});
|
if (mountedRef.current) setLinks({});
|
||||||
|
return {};
|
||||||
|
} finally {
|
||||||
|
if (mountedRef.current) setLinksLoading(false);
|
||||||
}
|
}
|
||||||
})();
|
}
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
async function handleOpenLegal(type: 'privacy' | 'terms') {
|
||||||
};
|
const currentUrl = type === 'privacy' ? links.privacy : links.terms;
|
||||||
|
if (currentUrl) {
|
||||||
|
await openLink(currentUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 链接还没拿到/拉取失败:点击时主动再拉一次,避免“点了没反应”
|
||||||
|
const next = await refreshLegalLinks();
|
||||||
|
const nextUrl = type === 'privacy' ? next.privacy : next.terms;
|
||||||
|
if (nextUrl) {
|
||||||
|
await openLink(nextUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const msg =
|
||||||
|
typeof __DEV__ !== 'undefined' && __DEV__
|
||||||
|
? t('consent.linkUnavailableDev', { baseUrl: API_BASE_URL })
|
||||||
|
: t('consent.linkUnavailable');
|
||||||
|
Alert.alert(t('common.notice'), msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拉取协议链接(由后端按语言下发;默认 EN)
|
||||||
|
useEffect(() => {
|
||||||
|
void refreshLegalLinks();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const bgDecorationTop = 363;
|
const bgDecorationTop = 363;
|
||||||
@@ -88,34 +129,52 @@ export default function SplashScreen() {
|
|||||||
{/* 文案内容 */}
|
{/* 文案内容 */}
|
||||||
<View style={[styles.contentContainer, { position: 'absolute', top: contentTop }]}>
|
<View style={[styles.contentContainer, { position: 'absolute', top: contentTop }]}>
|
||||||
<Text style={styles.titleText}>
|
<Text style={styles.titleText}>
|
||||||
You Are Perfect.{"\n"}
|
{t('consent.title')}
|
||||||
Everything{"\n"}
|
{'\n'}
|
||||||
Will Be Better.
|
{t('consent.subtitle')}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<SafeAreaView style={styles.bottomContainer} edges={['bottom']}>
|
<SafeAreaView style={styles.bottomContainer} edges={['bottom']}>
|
||||||
{showConsent && (
|
{showConsent && (
|
||||||
<>
|
<>
|
||||||
<TouchableOpacity onPress={handleAgree} activeOpacity={0.8} style={styles.buttonWrapper}>
|
<TouchableOpacity
|
||||||
|
onPress={handleAgree}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
style={styles.buttonWrapper}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={t('consent.agree')}
|
||||||
|
>
|
||||||
<WelcomeBtn width={87} height={57} />
|
<WelcomeBtn width={87} height={57} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
<View style={styles.linksContainer}>
|
<Text style={styles.noticeText}>
|
||||||
<TouchableOpacity
|
<Trans
|
||||||
disabled={!links.privacy}
|
i18nKey="consent.noticeRich"
|
||||||
onPress={() => (links.privacy ? openLink(links.privacy) : undefined)}
|
values={{
|
||||||
>
|
privacyLabel: t('consent.privacy'),
|
||||||
<Text style={styles.linkText}>{t('consent.privacy')}</Text>
|
termsLabel: t('consent.terms'),
|
||||||
</TouchableOpacity>
|
privacySuffix: !links.privacy && linksLoading ? t('consent.linkLoadingSuffix') : '',
|
||||||
<View style={styles.divider} />
|
termsSuffix: !links.terms && linksLoading ? t('consent.linkLoadingSuffix') : '',
|
||||||
<TouchableOpacity
|
}}
|
||||||
disabled={!links.terms}
|
components={{
|
||||||
onPress={() => (links.terms ? openLink(links.terms) : undefined)}
|
privacy: (
|
||||||
>
|
<Text
|
||||||
<Text style={styles.linkText}>{t('consent.terms')}</Text>
|
style={[styles.noticeLinkText, !links.privacy && styles.noticeLinkTextDisabled]}
|
||||||
</TouchableOpacity>
|
onPress={() => void handleOpenLegal('privacy')}
|
||||||
</View>
|
suppressHighlighting
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
terms: (
|
||||||
|
<Text
|
||||||
|
style={[styles.noticeLinkText, !links.terms && styles.noticeLinkTextDisabled]}
|
||||||
|
onPress={() => void handleOpenLegal('terms')}
|
||||||
|
suppressHighlighting
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Text>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
@@ -164,19 +223,22 @@ const styles = StyleSheet.create({
|
|||||||
buttonWrapper: {
|
buttonWrapper: {
|
||||||
marginBottom: 40,
|
marginBottom: 40,
|
||||||
},
|
},
|
||||||
linksContainer: {
|
noticeText: {
|
||||||
flexDirection: 'row',
|
marginTop: 10,
|
||||||
alignItems: 'center',
|
paddingHorizontal: 28,
|
||||||
},
|
|
||||||
linkText: {
|
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: 'rgba(119, 47, 0, 0.5)', // 使用半透明的文字颜色
|
lineHeight: 16,
|
||||||
textDecorationLine: 'underline',
|
textAlign: 'center',
|
||||||
|
color: 'rgba(119, 47, 0, 0.45)',
|
||||||
},
|
},
|
||||||
divider: {
|
noticeLinkText: {
|
||||||
width: 1,
|
fontSize: 12,
|
||||||
height: 12,
|
// 颜色区分:协议链接更醒目
|
||||||
backgroundColor: 'rgba(119, 47, 0, 0.2)',
|
color: 'rgba(119, 47, 0, 0.75)',
|
||||||
marginHorizontal: 15,
|
textDecorationLine: 'underline',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
noticeLinkTextDisabled: {
|
||||||
|
opacity: 0.55,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import { useFonts } from 'expo-font';
|
|||||||
import { Stack } from 'expo-router';
|
import { Stack } from 'expo-router';
|
||||||
import * as SplashScreen from 'expo-splash-screen';
|
import * as SplashScreen from 'expo-splash-screen';
|
||||||
import * as Notifications from 'expo-notifications';
|
import * as Notifications from 'expo-notifications';
|
||||||
import { useEffect, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import 'react-native-reanimated';
|
import 'react-native-reanimated';
|
||||||
import { AppState } from 'react-native';
|
import { Animated, AppState, Image, StyleSheet, View } from 'react-native';
|
||||||
|
|
||||||
import { useColorScheme } from '@/components/useColorScheme';
|
import { useColorScheme } from '@/components/useColorScheme';
|
||||||
import { initI18n } from '@/src/i18n';
|
import { initI18n } from '@/src/i18n';
|
||||||
@@ -17,6 +17,9 @@ import { getOrCreateClientUserId } from '@/src/storage/appStorage';
|
|||||||
Notifications.setNotificationHandler({
|
Notifications.setNotificationHandler({
|
||||||
handleNotification: async () => ({
|
handleNotification: async () => ({
|
||||||
shouldShowAlert: true,
|
shouldShowAlert: true,
|
||||||
|
// 新版 expo-notifications 类型要求显式返回 banner/list 行为
|
||||||
|
shouldShowBanner: true,
|
||||||
|
shouldShowList: true,
|
||||||
shouldPlaySound: false,
|
shouldPlaySound: false,
|
||||||
shouldSetBadge: false,
|
shouldSetBadge: false,
|
||||||
}),
|
}),
|
||||||
@@ -29,7 +32,8 @@ export {
|
|||||||
|
|
||||||
export const unstable_settings = {
|
export const unstable_settings = {
|
||||||
// Ensure that reloading on `/modal` keeps a back button present.
|
// Ensure that reloading on `/modal` keeps a back button present.
|
||||||
initialRouteName: 'index',
|
// 让首次启动(未同意协议)直接进入协议页,避免先渲染 index 再跳转导致“闪一下”
|
||||||
|
initialRouteName: '(splash)/splash',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Prevent the splash screen from auto-hiding before asset loading is complete.
|
// Prevent the splash screen from auto-hiding before asset loading is complete.
|
||||||
@@ -41,6 +45,10 @@ export default function RootLayout() {
|
|||||||
...FontAwesome.font,
|
...FontAwesome.font,
|
||||||
});
|
});
|
||||||
const [i18nReady, setI18nReady] = useState(false);
|
const [i18nReady, setI18nReady] = useState(false);
|
||||||
|
const [appReady, setAppReady] = useState(false);
|
||||||
|
const [splashOverlayVisible, setSplashOverlayVisible] = useState(true);
|
||||||
|
const splashOpacity = useRef(new Animated.Value(1)).current;
|
||||||
|
const hasHiddenNativeSplashRef = useRef(false);
|
||||||
|
|
||||||
// Expo Router uses Error Boundaries to catch errors in the navigation tree.
|
// Expo Router uses Error Boundaries to catch errors in the navigation tree.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -68,17 +76,52 @@ export default function RootLayout() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 等字体与 i18n 都准备好后再隐藏启动页,避免文案闪烁
|
// 字体与 i18n 都准备好后,允许渲染 App(原生 splash 的隐藏交给 onLayout,避免“硬切/闪白”)
|
||||||
if (loaded && i18nReady) {
|
if (loaded && i18nReady) setAppReady(true);
|
||||||
SplashScreen.hideAsync();
|
|
||||||
}
|
|
||||||
}, [loaded, i18nReady]);
|
}, [loaded, i18nReady]);
|
||||||
|
|
||||||
|
const onLayoutRootView = useCallback(() => {
|
||||||
|
if (!appReady) return;
|
||||||
|
if (hasHiddenNativeSplashRef.current) return;
|
||||||
|
hasHiddenNativeSplashRef.current = true;
|
||||||
|
|
||||||
|
// 先隐藏原生 splash,再把同款覆盖层淡出,视觉上实现平滑过渡
|
||||||
|
void SplashScreen.hideAsync().finally(() => {
|
||||||
|
Animated.timing(splashOpacity, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 380,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start(({ finished }) => {
|
||||||
|
if (finished) setSplashOverlayVisible(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [appReady, splashOpacity]);
|
||||||
|
|
||||||
|
const content = useMemo(() => {
|
||||||
|
if (!appReady) return null;
|
||||||
|
return <RootLayoutNav />;
|
||||||
|
}, [appReady]);
|
||||||
|
|
||||||
if (!loaded || !i18nReady) {
|
if (!loaded || !i18nReady) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <RootLayoutNav />;
|
return (
|
||||||
|
<View style={styles.root} onLayout={onLayoutRootView}>
|
||||||
|
{content}
|
||||||
|
{splashOverlayVisible && (
|
||||||
|
<Animated.View pointerEvents="none" style={[StyleSheet.absoluteFill, { opacity: splashOpacity }]}>
|
||||||
|
<View style={styles.splashOverlay}>
|
||||||
|
<Image
|
||||||
|
source={require('../assets/images/splashScreen.png')}
|
||||||
|
style={styles.splashImage}
|
||||||
|
resizeMode="contain"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RootLayoutNav() {
|
function RootLayoutNav() {
|
||||||
@@ -102,6 +145,9 @@ function RootLayoutNav() {
|
|||||||
return (
|
return (
|
||||||
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
||||||
<Stack screenOptions={{ headerShown: false }}>
|
<Stack screenOptions={{ headerShown: false }}>
|
||||||
|
{/* 协议页分组(首次启动优先进入) */}
|
||||||
|
<Stack.Screen name="(splash)" />
|
||||||
|
|
||||||
{/* 启动分发页:根据 onboarding 状态跳转 */}
|
{/* 启动分发页:根据 onboarding 状态跳转 */}
|
||||||
<Stack.Screen name="index" />
|
<Stack.Screen name="index" />
|
||||||
|
|
||||||
@@ -118,3 +164,21 @@ function RootLayoutNav() {
|
|||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
splashOverlay: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
// 与 app.json 的 expo.splash.backgroundColor 保持一致
|
||||||
|
backgroundColor: '#EAD2BA',
|
||||||
|
},
|
||||||
|
splashImage: {
|
||||||
|
// 覆盖层图片尺寸需与系统原生 Splash 的视觉一致,避免出现“缩小一下”的错觉
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
BIN
client/assets/images/splashScreen.png
Normal file
|
After Width: | Height: | Size: 126 KiB |
@@ -139,7 +139,10 @@ export default function ProfileModal({ visible, name: propName, onClose }: Props
|
|||||||
|
|
||||||
const openLink = useCallback(
|
const openLink = useCallback(
|
||||||
async (url?: string) => {
|
async (url?: string) => {
|
||||||
if (!url) return;
|
if (!url) {
|
||||||
|
Alert.alert(t('common.notice'), t('consent.linkUnavailable'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await WebBrowser.openBrowserAsync(url);
|
await WebBrowser.openBrowserAsync(url);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -162,23 +165,13 @@ export default function ProfileModal({ visible, name: propName, onClose }: Props
|
|||||||
const duration = 220;
|
const duration = 220;
|
||||||
const easing = Easing.out(Easing.cubic);
|
const easing = Easing.out(Easing.cubic);
|
||||||
|
|
||||||
// 进入二级页:从右侧滑入;返回:从左侧滑入
|
// 需求:去掉左右滑动的切页动效,改为纯淡入淡出
|
||||||
const entering =
|
const entering = FadeIn.duration(duration).easing(easing);
|
||||||
navDirection === 'forward'
|
const exiting = FadeOut.duration(duration).easing(easing);
|
||||||
? SlideInRight.duration(duration).easing(easing)
|
|
||||||
: SlideInLeft.duration(duration).easing(easing);
|
|
||||||
|
|
||||||
// 离开:进入二级页时旧页面向左滑出;返回时旧页面向右滑出
|
|
||||||
const exiting =
|
|
||||||
navDirection === 'forward'
|
|
||||||
? SlideOutLeft.duration(duration).easing(easing)
|
|
||||||
: SlideOutRight.duration(duration).easing(easing);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
entering,
|
entering,
|
||||||
exiting,
|
exiting,
|
||||||
fadeIn: FadeIn.duration(duration).easing(easing),
|
|
||||||
fadeOut: FadeOut.duration(duration).easing(easing),
|
|
||||||
};
|
};
|
||||||
}, [navDirection]);
|
}, [navDirection]);
|
||||||
|
|
||||||
@@ -195,11 +188,6 @@ export default function ProfileModal({ visible, name: propName, onClose }: Props
|
|||||||
entering={transition.entering}
|
entering={transition.entering}
|
||||||
exiting={transition.exiting}
|
exiting={transition.exiting}
|
||||||
style={!isRoot ? { flex: 1 } : undefined}
|
style={!isRoot ? { flex: 1 } : undefined}
|
||||||
>
|
|
||||||
<Animated.View
|
|
||||||
entering={transition.fadeIn}
|
|
||||||
exiting={transition.fadeOut}
|
|
||||||
style={!isRoot ? { flex: 1 } : undefined}
|
|
||||||
>
|
>
|
||||||
{page === 'root' ? (
|
{page === 'root' ? (
|
||||||
<RootPage
|
<RootPage
|
||||||
@@ -223,7 +211,6 @@ export default function ProfileModal({ visible, name: propName, onClose }: Props
|
|||||||
<WidgetPage onOpenHowTo={() => go('widgetHowTo', 'forward')} />
|
<WidgetPage onOpenHowTo={() => go('widgetHowTo', 'forward')} />
|
||||||
)}
|
)}
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</Animated.View>
|
|
||||||
</View>
|
</View>
|
||||||
</SheetModal>
|
</SheetModal>
|
||||||
);
|
);
|
||||||
@@ -451,7 +438,14 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () =
|
|||||||
try {
|
try {
|
||||||
const expoPushToken = await getExpoPushTokenOrThrow();
|
const expoPushToken = await getExpoPushTokenOrThrow();
|
||||||
await registerPushToken({ pushToken: expoPushToken });
|
await registerPushToken({ pushToken: expoPushToken });
|
||||||
|
// 偏好同步失败不应被用户感知为“开启失败”
|
||||||
|
// (常见现象:后端已接收 token,但偏好接口短暂失败/超时)
|
||||||
|
try {
|
||||||
await setPushPreferences({ enabled: true, timesPerDay });
|
await setPushPreferences({ enabled: true, timesPerDay });
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
console.warn('[PushPreferences] 同步失败(ProfileModal,不阻塞)', msg);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
Alert.alert(t('common.notice'), msg);
|
Alert.alert(t('common.notice'), msg);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
|
|
||||||
import SheetModal from '@/components/ui/SheetModal';
|
import SheetModal from '@/components/ui/SheetModal';
|
||||||
|
|
||||||
export type ThemeMode = 'scenery' | 'color';
|
import type { ThemeMode } from '@/src/storage/appStorage';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@@ -16,7 +16,7 @@ type Props = {
|
|||||||
export default function ThemeModal({ visible, mode, onSelect, onClose }: Props) {
|
export default function ThemeModal({ visible, mode, onSelect, onClose }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<SheetModal visible={visible} title={t('theme.title')} onClose={onClose} height={350}>
|
<SheetModal visible={visible} title={t('theme.title')} onClose={onClose} height={360}>
|
||||||
<View style={styles.row}>
|
<View style={styles.row}>
|
||||||
<ThemeCard
|
<ThemeCard
|
||||||
title={t('theme.scenery')}
|
title={t('theme.scenery')}
|
||||||
@@ -41,6 +41,19 @@ export default function ThemeModal({ visible, mode, onSelect, onClose }: Props)
|
|||||||
style={styles.previewImage}
|
style={styles.previewImage}
|
||||||
/>
|
/>
|
||||||
</ThemeCard>
|
</ThemeCard>
|
||||||
|
|
||||||
|
<ThemeCard
|
||||||
|
title={t('theme.suixin')}
|
||||||
|
selected={mode === 'suixin'}
|
||||||
|
onPress={() => onSelect('suixin')}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
// 占位:一期复用纯色预览图,后续可替换为专用资源
|
||||||
|
source={require('../../assets/images/theme/theme_color.png')}
|
||||||
|
resizeMode="cover"
|
||||||
|
style={styles.previewImage}
|
||||||
|
/>
|
||||||
|
</ThemeCard>
|
||||||
</View>
|
</View>
|
||||||
</SheetModal>
|
</SheetModal>
|
||||||
);
|
);
|
||||||
@@ -68,7 +81,12 @@ function ThemeCard({
|
|||||||
{children}
|
{children}
|
||||||
{/* 文案展示在图片中心 */}
|
{/* 文案展示在图片中心 */}
|
||||||
<View style={styles.textOverlay}>
|
<View style={styles.textOverlay}>
|
||||||
<Text style={[styles.overlayTitle, selected && styles.selectedOverlayTitle]}>
|
<Text
|
||||||
|
style={[styles.overlayTitle, selected && styles.selectedOverlayTitle]}
|
||||||
|
numberOfLines={1}
|
||||||
|
adjustsFontSizeToFit
|
||||||
|
minimumFontScale={0.85}
|
||||||
|
>
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -81,19 +99,21 @@ function ThemeCard({
|
|||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
row: {
|
row: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
gap: 30,
|
flexWrap: 'nowrap',
|
||||||
paddingHorizontal: 10,
|
gap: 12,
|
||||||
|
paddingHorizontal: 4,
|
||||||
paddingBottom: 50,
|
paddingBottom: 50,
|
||||||
paddingTop: 20,
|
paddingTop: 20,
|
||||||
justifyContent: 'center',
|
justifyContent: 'space-between',
|
||||||
},
|
},
|
||||||
cardContainer: {
|
cardContainer: {
|
||||||
alignItems: 'center',
|
flex: 1,
|
||||||
width: 143,
|
minWidth: 0,
|
||||||
|
alignItems: 'stretch',
|
||||||
},
|
},
|
||||||
previewWrapper: {
|
previewWrapper: {
|
||||||
width: 138,
|
width: '100%',
|
||||||
height: 203,
|
aspectRatio: 110 / 178,
|
||||||
borderRadius: 26,
|
borderRadius: 26,
|
||||||
padding: 6.5,
|
padding: 6.5,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { View, StyleSheet, TextInput, Platform, Animated, TouchableOpacity, Dimensions, Text } from 'react-native';
|
import { View, StyleSheet, TextInput, Platform, Animated, TouchableOpacity, Text, Keyboard, Pressable } from 'react-native';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { OnboardingColors } from '@/constants/OnboardingTheme';
|
import { OnboardingColors } from '@/constants/OnboardingTheme';
|
||||||
import BtnNotClicked from '@/assets/images/icon/btn_Notclicked.svg';
|
import BtnNotClicked from '@/assets/images/icon/btn_Notclicked.svg';
|
||||||
import BtnClicked from '@/assets/images/icon/btn_clicked.svg';
|
import BtnClicked from '@/assets/images/icon/btn_clicked.svg';
|
||||||
import EnterLightIcon from '@/assets/images/icon/enter_Light_icon.svg';
|
import EnterLightIcon from '@/assets/images/icon/enter_Light_icon.svg';
|
||||||
|
|
||||||
const { height } = Dimensions.get('window');
|
|
||||||
|
|
||||||
interface NameInputStepProps {
|
interface NameInputStepProps {
|
||||||
value: string;
|
value: string;
|
||||||
onChangeText: (text: string) => void;
|
onChangeText: (text: string) => void;
|
||||||
@@ -14,10 +14,30 @@ interface NameInputStepProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function NameInputStep({ value, onChangeText, onNext }: NameInputStepProps) {
|
export function NameInputStep({ value, onChangeText, onNext }: NameInputStepProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
const [isFocused, setIsFocused] = useState(false);
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
const [keyboardHeight, setKeyboardHeight] = useState(0);
|
||||||
const blinkAnim = useRef(new Animated.Value(1)).current;
|
const blinkAnim = useRef(new Animated.Value(1)).current;
|
||||||
const hasInput = value.trim().length > 0;
|
const hasInput = value.trim().length > 0;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
|
||||||
|
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
|
||||||
|
|
||||||
|
const subShow = Keyboard.addListener(showEvent, (e) => {
|
||||||
|
setKeyboardHeight(e.endCoordinates?.height ?? 0);
|
||||||
|
});
|
||||||
|
const subHide = Keyboard.addListener(hideEvent, () => {
|
||||||
|
setKeyboardHeight(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
subShow.remove();
|
||||||
|
subHide.remove();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const animation = Animated.loop(
|
const animation = Animated.loop(
|
||||||
Animated.sequence([
|
Animated.sequence([
|
||||||
@@ -34,8 +54,14 @@ export function NameInputStep({ value, onChangeText, onNext }: NameInputStepProp
|
|||||||
return () => animation.stop();
|
return () => animation.stop();
|
||||||
}, [blinkAnim, isFocused]);
|
}, [blinkAnim, isFocused]);
|
||||||
|
|
||||||
|
const footerBottom = useMemo(() => {
|
||||||
|
// iOS 的 keyboard height 通常已包含底部安全区,避免重复叠加
|
||||||
|
const keyboardOffset = Math.max(0, keyboardHeight - insets.bottom);
|
||||||
|
return 16 + insets.bottom + keyboardOffset;
|
||||||
|
}, [insets.bottom, keyboardHeight]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<Pressable style={styles.container} onPress={Keyboard.dismiss} accessible={false}>
|
||||||
<View style={styles.inputCard}>
|
<View style={styles.inputCard}>
|
||||||
<View style={styles.inputWrapper}>
|
<View style={styles.inputWrapper}>
|
||||||
{/* 显示层:文案 + 跟随的光标 */}
|
{/* 显示层:文案 + 跟随的光标 */}
|
||||||
@@ -46,7 +72,7 @@ export function NameInputStep({ value, onChangeText, onNext }: NameInputStepProp
|
|||||||
(!isFocused && !hasInput) && { color: OnboardingColors.textSecondary }
|
(!isFocused && !hasInput) && { color: OnboardingColors.textSecondary }
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{hasInput ? value : (isFocused ? "" : "Mama")}
|
{hasInput ? value : isFocused ? '' : t('onboardingSurvey.steps.name.placeholder')}
|
||||||
</Text>
|
</Text>
|
||||||
{isFocused && (
|
{isFocused && (
|
||||||
<Animated.View style={[styles.cursorWrapper, { opacity: blinkAnim, marginLeft: 2 }]}>
|
<Animated.View style={[styles.cursorWrapper, { opacity: blinkAnim, marginLeft: 2 }]}>
|
||||||
@@ -65,20 +91,30 @@ export function NameInputStep({ value, onChangeText, onNext }: NameInputStepProp
|
|||||||
caretHidden={true}
|
caretHidden={true}
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
|
returnKeyType="done"
|
||||||
|
blurOnSubmit={true}
|
||||||
|
onSubmitEditing={() => {
|
||||||
|
Keyboard.dismiss();
|
||||||
|
// 有输入时,“完成”直接进入下一步,避免真机卡在键盘上
|
||||||
|
if (value.trim().length > 0) onNext();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.footer}>
|
<View style={[styles.footer, { bottom: footerBottom }]}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={onNext}
|
onPress={() => {
|
||||||
|
Keyboard.dismiss();
|
||||||
|
onNext();
|
||||||
|
}}
|
||||||
disabled={!hasInput}
|
disabled={!hasInput}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
>
|
>
|
||||||
{hasInput ? <BtnClicked width={87} height={57} /> : <BtnNotClicked width={87} height={57} />}
|
{hasInput ? <BtnClicked width={87} height={57} /> : <BtnNotClicked width={87} height={57} />}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</Pressable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,7 +166,6 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
footer: {
|
footer: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: height * 0.12,
|
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, StyleSheet, SafeAreaView, TouchableOpacity, StatusBar, Text, Image, Platform } from 'react-native';
|
import { View, StyleSheet, SafeAreaView, TouchableOpacity, StatusBar, Text, Image, Platform } from 'react-native';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { OnboardingColors } from '@/constants/OnboardingTheme';
|
import { OnboardingColors } from '@/constants/OnboardingTheme';
|
||||||
|
|
||||||
interface OnboardingLayoutProps {
|
interface OnboardingLayoutProps {
|
||||||
@@ -21,6 +22,7 @@ export function OnboardingLayout({
|
|||||||
onBack,
|
onBack,
|
||||||
showBackButton = false
|
showBackButton = false
|
||||||
}: OnboardingLayoutProps) {
|
}: OnboardingLayoutProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<StatusBar barStyle="dark-content" />
|
<StatusBar barStyle="dark-content" />
|
||||||
@@ -39,7 +41,7 @@ export function OnboardingLayout({
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<TouchableOpacity onPress={onSkip} style={styles.skipButton}>
|
<TouchableOpacity onPress={onSkip} style={styles.skipButton}>
|
||||||
<Text style={styles.skipText}>skip</Text>
|
<Text style={styles.skipText}>{t('onboarding.skipAll')}</Text>
|
||||||
<Image
|
<Image
|
||||||
source={require('@/assets/images/icon/skip_icon.png')}
|
source={require('@/assets/images/icon/skip_icon.png')}
|
||||||
style={styles.skipIcon}
|
style={styles.skipIcon}
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, StyleSheet, TouchableOpacity, Text, Platform, Dimensions } from 'react-native';
|
import { View, StyleSheet, TouchableOpacity, Text, Platform } from 'react-native';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { OnboardingColors } from '@/constants/OnboardingTheme';
|
import { OnboardingColors } from '@/constants/OnboardingTheme';
|
||||||
import AddIcon from '@/assets/images/icon/add_icon.svg';
|
import AddIcon from '@/assets/images/icon/add_icon.svg';
|
||||||
import ReduceIcon from '@/assets/images/icon/reduce_icon.svg';
|
import ReduceIcon from '@/assets/images/icon/reduce_icon.svg';
|
||||||
import BtnClicked from '@/assets/images/icon/btn_clicked.svg';
|
import BtnClicked from '@/assets/images/icon/btn_clicked.svg';
|
||||||
|
|
||||||
const { height } = Dimensions.get('window');
|
|
||||||
|
|
||||||
interface ReminderStepProps {
|
interface ReminderStepProps {
|
||||||
value: number;
|
value: number;
|
||||||
onChange: (value: number) => void;
|
onChange: (value: number) => void;
|
||||||
@@ -17,6 +16,7 @@ interface ReminderStepProps {
|
|||||||
|
|
||||||
export function ReminderStep({ value, onChange, onFinish, onSkip }: ReminderStepProps) {
|
export function ReminderStep({ value, onChange, onFinish, onSkip }: ReminderStepProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
const handleReduce = () => {
|
const handleReduce = () => {
|
||||||
// 允许 0~5;0 表示关闭每日提醒
|
// 允许 0~5;0 表示关闭每日提醒
|
||||||
@@ -44,7 +44,7 @@ export function ReminderStep({ value, onChange, onFinish, onSkip }: ReminderStep
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.footer}>
|
<View style={[styles.footer, { bottom: insets.bottom + 16 }]}>
|
||||||
<TouchableOpacity onPress={onFinish} activeOpacity={0.8}>
|
<TouchableOpacity onPress={onFinish} activeOpacity={0.8}>
|
||||||
<BtnClicked width={87} height={57} />
|
<BtnClicked width={87} height={57} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -92,7 +92,6 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
footer: {
|
footer: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: height * 0.12,
|
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
}
|
}
|
||||||
,
|
,
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, StyleSheet, TouchableOpacity, ScrollView, Dimensions } from 'react-native';
|
import { View, StyleSheet, TouchableOpacity, ScrollView } from 'react-native';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { OnboardingColors } from '@/constants/OnboardingTheme';
|
import { OnboardingColors } from '@/constants/OnboardingTheme';
|
||||||
import { SerifText } from './SerifText';
|
import { SerifText } from './SerifText';
|
||||||
import SelectedIcon from '@/assets/images/icon/selected_icon.svg';
|
import SelectedIcon from '@/assets/images/icon/selected_icon.svg';
|
||||||
import BtnNotClicked from '@/assets/images/icon/btn_Notclicked.svg';
|
import BtnNotClicked from '@/assets/images/icon/btn_Notclicked.svg';
|
||||||
import BtnClicked from '@/assets/images/icon/btn_clicked.svg';
|
import BtnClicked from '@/assets/images/icon/btn_clicked.svg';
|
||||||
|
|
||||||
const { height } = Dimensions.get('window');
|
|
||||||
|
|
||||||
interface Option {
|
interface Option {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -23,10 +22,18 @@ interface SelectionStepProps {
|
|||||||
|
|
||||||
export function SelectionStep({ options, selectedIds, onToggle, onNext, onSkip }: SelectionStepProps) {
|
export function SelectionStep({ options, selectedIds, onToggle, onNext, onSkip }: SelectionStepProps) {
|
||||||
const hasSelection = selectedIds.length > 0;
|
const hasSelection = selectedIds.length > 0;
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const footerBottom = insets.bottom + 16;
|
||||||
|
const footerButtonHeight = 57;
|
||||||
|
const footerPaddingBottom = footerBottom + footerButtonHeight + 24;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.optionsList}>
|
<ScrollView
|
||||||
|
style={styles.scroll}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
contentContainerStyle={[styles.optionsList, { paddingBottom: footerPaddingBottom }]}
|
||||||
|
>
|
||||||
{options.map((option) => {
|
{options.map((option) => {
|
||||||
const isSelected = selectedIds.includes(option.id);
|
const isSelected = selectedIds.includes(option.id);
|
||||||
return (
|
return (
|
||||||
@@ -48,7 +55,7 @@ export function SelectionStep({ options, selectedIds, onToggle, onNext, onSkip }
|
|||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
{/* 底部按钮:距离底部 12% 高度 */}
|
{/* 底部按钮:距离底部 12% 高度 */}
|
||||||
<View style={styles.footer}>
|
<View style={[styles.footer, { bottom: footerBottom }]}>
|
||||||
<TouchableOpacity onPress={onNext} disabled={!hasSelection} activeOpacity={0.8}>
|
<TouchableOpacity onPress={onNext} disabled={!hasSelection} activeOpacity={0.8}>
|
||||||
{hasSelection ? <BtnClicked width={87} height={57} /> : <BtnNotClicked width={87} height={57} />}
|
{hasSelection ? <BtnClicked width={87} height={57} /> : <BtnNotClicked width={87} height={57} />}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -62,8 +69,11 @@ const styles = StyleSheet.create({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
paddingTop: 20,
|
paddingTop: 20,
|
||||||
},
|
},
|
||||||
|
scroll: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
optionsList: {
|
optionsList: {
|
||||||
paddingBottom: 150, // 为底部按钮留出空间
|
// paddingBottom 由安全区 + 按钮高度动态计算,避免选项被遮住
|
||||||
},
|
},
|
||||||
optionCard: {
|
optionCard: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
@@ -92,7 +102,6 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
footer: {
|
footer: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: height * 0.12,
|
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|||||||
@@ -208,6 +208,8 @@ PODS:
|
|||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- ExpoCrypto (15.0.8):
|
- ExpoCrypto (15.0.8):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
|
- ExpoDevice (8.0.10):
|
||||||
|
- ExpoModulesCore
|
||||||
- ExpoFileSystem (19.0.21):
|
- ExpoFileSystem (19.0.21):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- ExpoFont (14.0.11):
|
- ExpoFont (14.0.11):
|
||||||
@@ -2250,6 +2252,7 @@ DEPENDENCIES:
|
|||||||
- "expo-dev-menu-interface (from `../node_modules/.pnpm/expo-dev-menu-interface@2.0.0_expo@54.0.32/node_modules/expo-dev-menu-interface/ios`)"
|
- "expo-dev-menu-interface (from `../node_modules/.pnpm/expo-dev-menu-interface@2.0.0_expo@54.0.32/node_modules/expo-dev-menu-interface/ios`)"
|
||||||
- "ExpoAsset (from `../node_modules/.pnpm/expo-asset@12.0.12_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-asset/ios`)"
|
- "ExpoAsset (from `../node_modules/.pnpm/expo-asset@12.0.12_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-asset/ios`)"
|
||||||
- "ExpoCrypto (from `../node_modules/.pnpm/expo-crypto@15.0.8_expo@54.0.32/node_modules/expo-crypto/ios`)"
|
- "ExpoCrypto (from `../node_modules/.pnpm/expo-crypto@15.0.8_expo@54.0.32/node_modules/expo-crypto/ios`)"
|
||||||
|
- "ExpoDevice (from `../node_modules/.pnpm/expo-device@8.0.10_expo@54.0.32/node_modules/expo-device/ios`)"
|
||||||
- "ExpoFileSystem (from `../node_modules/.pnpm/expo-file-system@19.0.21_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0_/node_modules/expo-file-system/ios`)"
|
- "ExpoFileSystem (from `../node_modules/.pnpm/expo-file-system@19.0.21_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0_/node_modules/expo-file-system/ios`)"
|
||||||
- "ExpoFont (from `../node_modules/.pnpm/expo-font@14.0.11_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-font/ios`)"
|
- "ExpoFont (from `../node_modules/.pnpm/expo-font@14.0.11_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-font/ios`)"
|
||||||
- "ExpoHead (from `../node_modules/.pnpm/expo-router@6.0.22_@expo+metro-runtime@6.1.2_@types+react@19.1.17_expo-constants@18.0.13_expo_mxedi6ntnfsoyp6zijog4pvdsy/node_modules/expo-router/ios`)"
|
- "ExpoHead (from `../node_modules/.pnpm/expo-router@6.0.22_@expo+metro-runtime@6.1.2_@types+react@19.1.17_expo-constants@18.0.13_expo_mxedi6ntnfsoyp6zijog4pvdsy/node_modules/expo-router/ios`)"
|
||||||
@@ -2362,6 +2365,8 @@ EXTERNAL SOURCES:
|
|||||||
:path: "../node_modules/.pnpm/expo-asset@12.0.12_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-asset/ios"
|
:path: "../node_modules/.pnpm/expo-asset@12.0.12_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-asset/ios"
|
||||||
ExpoCrypto:
|
ExpoCrypto:
|
||||||
:path: "../node_modules/.pnpm/expo-crypto@15.0.8_expo@54.0.32/node_modules/expo-crypto/ios"
|
:path: "../node_modules/.pnpm/expo-crypto@15.0.8_expo@54.0.32/node_modules/expo-crypto/ios"
|
||||||
|
ExpoDevice:
|
||||||
|
:path: "../node_modules/.pnpm/expo-device@8.0.10_expo@54.0.32/node_modules/expo-device/ios"
|
||||||
ExpoFileSystem:
|
ExpoFileSystem:
|
||||||
:path: "../node_modules/.pnpm/expo-file-system@19.0.21_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0_/node_modules/expo-file-system/ios"
|
:path: "../node_modules/.pnpm/expo-file-system@19.0.21_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0_/node_modules/expo-file-system/ios"
|
||||||
ExpoFont:
|
ExpoFont:
|
||||||
@@ -2547,6 +2552,7 @@ SPEC CHECKSUMS:
|
|||||||
expo-dev-menu-interface: 600df12ea01efecdd822daaf13cc0ac091775533
|
expo-dev-menu-interface: 600df12ea01efecdd822daaf13cc0ac091775533
|
||||||
ExpoAsset: d999f3bbd998a750f3b74cb913229848901b926b
|
ExpoAsset: d999f3bbd998a750f3b74cb913229848901b926b
|
||||||
ExpoCrypto: 4d23a9ff67c25e2ed23ca792d81e58817a7ea1b9
|
ExpoCrypto: 4d23a9ff67c25e2ed23ca792d81e58817a7ea1b9
|
||||||
|
ExpoDevice: 0773c782b055558ca9b40b74aa4a8133a66cd0d2
|
||||||
ExpoFileSystem: aefcd337b94b874f88752ebefc52813b84992fad
|
ExpoFileSystem: aefcd337b94b874f88752ebefc52813b84992fad
|
||||||
ExpoFont: c625dbd97ed57e9089b172b2a7bb99003d074664
|
ExpoFont: c625dbd97ed57e9089b172b2a7bb99003d074664
|
||||||
ExpoHead: b691a2ed7ab02ed820b6c6468941832d34969c29
|
ExpoHead: b691a2ed7ab02ed820b6c6468941832d34969c29
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
archiveVersion = 1;
|
archiveVersion = 1;
|
||||||
classes = {
|
classes = {
|
||||||
};
|
};
|
||||||
objectVersion = 77;
|
objectVersion = 70;
|
||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
@@ -11,10 +11,10 @@
|
|||||||
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
|
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
|
||||||
1A1DE01D4133812B2E2BA692 /* libPods-client.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E3328F0E595C1F4A244DF238 /* libPods-client.a */; };
|
1A1DE01D4133812B2E2BA692 /* libPods-client.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E3328F0E595C1F4A244DF238 /* libPods-client.a */; };
|
||||||
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; };
|
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; };
|
||||||
A1B2C3D4E5F60718293A4B5C /* EmotionWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B5B /* EmotionWidget.swift */; };
|
A1B2C3D4E5F60718293A4B5C /* 情绪小组件/EmotionWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B5B /* 情绪小组件/EmotionWidget.swift */; };
|
||||||
A8C1D2E3F4A5B6C7D8E9F0A2 /* AppGroupStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8C1D2E3F4A5B6C7D8E9F0A1 /* AppGroupStorage.swift */; };
|
A8C1D2E3F4A5B6C7D8E9F0A2 /* AppGroupStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8C1D2E3F4A5B6C7D8E9F0A1 /* AppGroupStorage.swift */; };
|
||||||
A8C1D2E3F4A5B6C7D8E9F0B2 /* AppGroupStorageBridge.m in Sources */ = {isa = PBXBuildFile; fileRef = A8C1D2E3F4A5B6C7D8E9F0B1 /* AppGroupStorageBridge.m */; };
|
|
||||||
A8C1D2E3F4A5B6C7D8E9F0A3 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EB3DAF802F2A4B8D00450593 /* WidgetKit.framework */; };
|
A8C1D2E3F4A5B6C7D8E9F0A3 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EB3DAF802F2A4B8D00450593 /* WidgetKit.framework */; };
|
||||||
|
A8C1D2E3F4A5B6C7D8E9F0B2 /* AppGroupStorageBridge.m in Sources */ = {isa = PBXBuildFile; fileRef = A8C1D2E3F4A5B6C7D8E9F0B1 /* AppGroupStorageBridge.m */; };
|
||||||
B5A7FE9A125F7C79753EC5BF /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7DB40C26E3A46F6D06769EA /* ExpoModulesProvider.swift */; };
|
B5A7FE9A125F7C79753EC5BF /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7DB40C26E3A46F6D06769EA /* ExpoModulesProvider.swift */; };
|
||||||
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
|
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
|
||||||
EB3DAF812F2A4B8E00450593 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EB3DAF802F2A4B8D00450593 /* WidgetKit.framework */; };
|
EB3DAF812F2A4B8E00450593 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EB3DAF802F2A4B8D00450593 /* WidgetKit.framework */; };
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = client/Info.plist; sourceTree = "<group>"; };
|
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = client/Info.plist; sourceTree = "<group>"; };
|
||||||
3C76CA16D0801CBF0D731C7C /* Pods-client.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-client.release.xcconfig"; path = "Target Support Files/Pods-client/Pods-client.release.xcconfig"; sourceTree = "<group>"; };
|
3C76CA16D0801CBF0D731C7C /* Pods-client.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-client.release.xcconfig"; path = "Target Support Files/Pods-client/Pods-client.release.xcconfig"; sourceTree = "<group>"; };
|
||||||
75F52ADE07CAE9D9736D7671 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = client/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
75F52ADE07CAE9D9736D7671 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = client/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
||||||
A1B2C3D4E5F60718293A4B5B /* EmotionWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "情绪小组件/EmotionWidget.swift"; sourceTree = "<group>"; };
|
A1B2C3D4E5F60718293A4B5B /* 情绪小组件/EmotionWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "情绪小组件/EmotionWidget.swift"; sourceTree = "<group>"; };
|
||||||
A8C1D2E3F4A5B6C7D8E9F0A1 /* AppGroupStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppGroupStorage.swift; path = client/AppGroupStorage.swift; sourceTree = "<group>"; };
|
A8C1D2E3F4A5B6C7D8E9F0A1 /* AppGroupStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppGroupStorage.swift; path = client/AppGroupStorage.swift; sourceTree = "<group>"; };
|
||||||
A8C1D2E3F4A5B6C7D8E9F0B1 /* AppGroupStorageBridge.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = AppGroupStorageBridge.m; path = client/AppGroupStorageBridge.m; sourceTree = "<group>"; };
|
A8C1D2E3F4A5B6C7D8E9F0B1 /* AppGroupStorageBridge.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = AppGroupStorageBridge.m; path = client/AppGroupStorageBridge.m; sourceTree = "<group>"; };
|
||||||
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = client/SplashScreen.storyboard; sourceTree = "<group>"; };
|
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = client/SplashScreen.storyboard; sourceTree = "<group>"; };
|
||||||
@@ -72,7 +72,7 @@
|
|||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
EB3DAF952F2A4B8F00450593 /* Exceptions for "情绪小组件" folder in "情绪小组件Extension" target */ = {
|
EB3DAF952F2A4B8F00450593 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
|
||||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||||
membershipExceptions = (
|
membershipExceptions = (
|
||||||
EmotionWidget.swift,
|
EmotionWidget.swift,
|
||||||
@@ -83,18 +83,7 @@
|
|||||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
EB3DAF842F2A4B8E00450593 /* 情绪小组件 */ = {
|
EB3DAF842F2A4B8E00450593 /* 情绪小组件 */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (EB3DAF952F2A4B8F00450593 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = "情绪小组件"; sourceTree = "<group>"; };
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
|
||||||
exceptions = (
|
|
||||||
EB3DAF952F2A4B8F00450593 /* Exceptions for "情绪小组件" folder in "情绪小组件Extension" target */,
|
|
||||||
);
|
|
||||||
explicitFileTypes = {
|
|
||||||
};
|
|
||||||
explicitFolders = (
|
|
||||||
);
|
|
||||||
path = "情绪小组件";
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -210,7 +199,7 @@
|
|||||||
EB3DAFD42F2A5FC100450593 /* Recovered References */ = {
|
EB3DAFD42F2A5FC100450593 /* Recovered References */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
A1B2C3D4E5F60718293A4B5B /* EmotionWidget.swift */,
|
A1B2C3D4E5F60718293A4B5B /* 情绪小组件/EmotionWidget.swift */,
|
||||||
);
|
);
|
||||||
name = "Recovered References";
|
name = "Recovered References";
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -382,6 +371,7 @@
|
|||||||
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/EXConstants.bundle",
|
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/EXConstants.bundle",
|
||||||
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/ExpoConstants_privacy.bundle",
|
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/ExpoConstants_privacy.bundle",
|
||||||
"${PODS_CONFIGURATION_BUILD_DIR}/EXNotifications/ExpoNotifications_privacy.bundle",
|
"${PODS_CONFIGURATION_BUILD_DIR}/EXNotifications/ExpoNotifications_privacy.bundle",
|
||||||
|
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoDevice/ExpoDevice_privacy.bundle",
|
||||||
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoFileSystem/ExpoFileSystem_privacy.bundle",
|
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoFileSystem/ExpoFileSystem_privacy.bundle",
|
||||||
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoLocalization/ExpoLocalization_privacy.bundle",
|
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoLocalization/ExpoLocalization_privacy.bundle",
|
||||||
"${PODS_CONFIGURATION_BUILD_DIR}/RNCAsyncStorage/RNCAsyncStorage_resources.bundle",
|
"${PODS_CONFIGURATION_BUILD_DIR}/RNCAsyncStorage/RNCAsyncStorage_resources.bundle",
|
||||||
@@ -397,6 +387,7 @@
|
|||||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXConstants.bundle",
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXConstants.bundle",
|
||||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoConstants_privacy.bundle",
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoConstants_privacy.bundle",
|
||||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoNotifications_privacy.bundle",
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoNotifications_privacy.bundle",
|
||||||
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoDevice_privacy.bundle",
|
||||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoFileSystem_privacy.bundle",
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoFileSystem_privacy.bundle",
|
||||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoLocalization_privacy.bundle",
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoLocalization_privacy.bundle",
|
||||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNCAsyncStorage_resources.bundle",
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNCAsyncStorage_resources.bundle",
|
||||||
@@ -475,7 +466,7 @@
|
|||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
A1B2C3D4E5F60718293A4B5C /* EmotionWidget.swift in Sources */,
|
A1B2C3D4E5F60718293A4B5C /* 情绪小组件/EmotionWidget.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -499,6 +490,7 @@
|
|||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = client/client.entitlements;
|
CODE_SIGN_ENTITLEMENTS = client/client.entitlements;
|
||||||
CURRENT_PROJECT_VERSION = 4;
|
CURRENT_PROJECT_VERSION = 4;
|
||||||
|
DEVELOPMENT_TEAM = WS92GPX9H2;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = x86_64;
|
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = x86_64;
|
||||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||||
@@ -712,9 +704,11 @@
|
|||||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = "情绪小组件ExtensionRelease.entitlements";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 4;
|
CURRENT_PROJECT_VERSION = 4;
|
||||||
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;
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<Scheme
|
|
||||||
LastUpgradeVersion = "2620"
|
|
||||||
version = "1.7">
|
|
||||||
<BuildAction
|
|
||||||
parallelizeBuildables = "YES"
|
|
||||||
buildImplicitDependencies = "YES"
|
|
||||||
buildArchitectures = "Automatic">
|
|
||||||
<BuildActionEntries>
|
|
||||||
<BuildActionEntry
|
|
||||||
buildForTesting = "YES"
|
|
||||||
buildForRunning = "YES"
|
|
||||||
buildForProfiling = "YES"
|
|
||||||
buildForArchiving = "YES"
|
|
||||||
buildForAnalyzing = "YES">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
|
||||||
BuildableName = "HeyMama.app"
|
|
||||||
BlueprintName = "client"
|
|
||||||
ReferencedContainer = "container:client.xcodeproj">
|
|
||||||
</BuildableReference>
|
|
||||||
</BuildActionEntry>
|
|
||||||
</BuildActionEntries>
|
|
||||||
</BuildAction>
|
|
||||||
<TestAction
|
|
||||||
buildConfiguration = "Debug"
|
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
|
||||||
shouldAutocreateTestPlan = "YES">
|
|
||||||
</TestAction>
|
|
||||||
<LaunchAction
|
|
||||||
buildConfiguration = "Debug"
|
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
|
||||||
launchStyle = "0"
|
|
||||||
useCustomWorkingDirectory = "NO"
|
|
||||||
ignoresPersistentStateOnLaunch = "NO"
|
|
||||||
debugDocumentVersioning = "YES"
|
|
||||||
debugServiceExtension = "internal"
|
|
||||||
allowLocationSimulation = "YES">
|
|
||||||
<BuildableProductRunnable
|
|
||||||
runnableDebuggingMode = "0">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
|
||||||
BuildableName = "HeyMama.app"
|
|
||||||
BlueprintName = "client"
|
|
||||||
ReferencedContainer = "container:client.xcodeproj">
|
|
||||||
</BuildableReference>
|
|
||||||
</BuildableProductRunnable>
|
|
||||||
</LaunchAction>
|
|
||||||
<ProfileAction
|
|
||||||
buildConfiguration = "Release"
|
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
|
||||||
savedToolIdentifier = ""
|
|
||||||
useCustomWorkingDirectory = "NO"
|
|
||||||
debugDocumentVersioning = "YES">
|
|
||||||
<BuildableProductRunnable
|
|
||||||
runnableDebuggingMode = "0">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
|
||||||
BuildableName = "HeyMama.app"
|
|
||||||
BlueprintName = "client"
|
|
||||||
ReferencedContainer = "container:client.xcodeproj">
|
|
||||||
</BuildableReference>
|
|
||||||
</BuildableProductRunnable>
|
|
||||||
</ProfileAction>
|
|
||||||
<AnalyzeAction
|
|
||||||
buildConfiguration = "Debug">
|
|
||||||
</AnalyzeAction>
|
|
||||||
<ArchiveAction
|
|
||||||
buildConfiguration = "Release"
|
|
||||||
customArchiveName = "Hey Mama"
|
|
||||||
revealArchiveInOrganizer = "YES">
|
|
||||||
</ArchiveAction>
|
|
||||||
</Scheme>
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<Scheme
|
|
||||||
LastUpgradeVersion = "2620"
|
|
||||||
version = "1.7">
|
|
||||||
<BuildAction
|
|
||||||
parallelizeBuildables = "YES"
|
|
||||||
buildImplicitDependencies = "YES"
|
|
||||||
buildArchitectures = "Automatic">
|
|
||||||
<BuildActionEntries>
|
|
||||||
<BuildActionEntry
|
|
||||||
buildForTesting = "YES"
|
|
||||||
buildForRunning = "YES"
|
|
||||||
buildForProfiling = "YES"
|
|
||||||
buildForArchiving = "YES"
|
|
||||||
buildForAnalyzing = "YES">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
|
||||||
BuildableName = "HeyMama.app"
|
|
||||||
BlueprintName = "client"
|
|
||||||
ReferencedContainer = "container:client.xcodeproj">
|
|
||||||
</BuildableReference>
|
|
||||||
</BuildActionEntry>
|
|
||||||
</BuildActionEntries>
|
|
||||||
</BuildAction>
|
|
||||||
<TestAction
|
|
||||||
buildConfiguration = "Debug"
|
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
|
||||||
shouldAutocreateTestPlan = "YES">
|
|
||||||
</TestAction>
|
|
||||||
<LaunchAction
|
|
||||||
buildConfiguration = "Debug"
|
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
|
||||||
launchStyle = "0"
|
|
||||||
useCustomWorkingDirectory = "NO"
|
|
||||||
ignoresPersistentStateOnLaunch = "NO"
|
|
||||||
debugDocumentVersioning = "YES"
|
|
||||||
debugServiceExtension = "internal"
|
|
||||||
allowLocationSimulation = "YES">
|
|
||||||
<BuildableProductRunnable
|
|
||||||
runnableDebuggingMode = "0">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
|
||||||
BuildableName = "HeyMama.app"
|
|
||||||
BlueprintName = "client"
|
|
||||||
ReferencedContainer = "container:client.xcodeproj">
|
|
||||||
</BuildableReference>
|
|
||||||
</BuildableProductRunnable>
|
|
||||||
</LaunchAction>
|
|
||||||
<ProfileAction
|
|
||||||
buildConfiguration = "Release"
|
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
|
||||||
savedToolIdentifier = ""
|
|
||||||
useCustomWorkingDirectory = "NO"
|
|
||||||
debugDocumentVersioning = "YES">
|
|
||||||
<BuildableProductRunnable
|
|
||||||
runnableDebuggingMode = "0">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
|
||||||
BuildableName = "HeyMama.app"
|
|
||||||
BlueprintName = "client"
|
|
||||||
ReferencedContainer = "container:client.xcodeproj">
|
|
||||||
</BuildableReference>
|
|
||||||
</BuildableProductRunnable>
|
|
||||||
</ProfileAction>
|
|
||||||
<AnalyzeAction
|
|
||||||
buildConfiguration = "Debug">
|
|
||||||
</AnalyzeAction>
|
|
||||||
<ArchiveAction
|
|
||||||
buildConfiguration = "Release"
|
|
||||||
revealArchiveInOrganizer = "YES">
|
|
||||||
</ArchiveAction>
|
|
||||||
</Scheme>
|
|
||||||
@@ -1,34 +1,11 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "2620"
|
LastUpgradeVersion = "2620"
|
||||||
version = "2.2">
|
version = "1.7">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "NO"
|
parallelizeBuildables = "YES"
|
||||||
buildImplicitDependencies = "YES">
|
buildImplicitDependencies = "YES">
|
||||||
<BuildActionEntries>
|
<BuildActionEntries>
|
||||||
<BuildActionEntry
|
|
||||||
buildForTesting = "YES"
|
|
||||||
buildForRunning = "YES"
|
|
||||||
buildForProfiling = "YES"
|
|
||||||
buildForArchiving = "YES"
|
|
||||||
buildForAnalyzing = "YES">
|
|
||||||
<AutocreatedTestPlanReference>
|
|
||||||
</AutocreatedTestPlanReference>
|
|
||||||
</BuildActionEntry>
|
|
||||||
<BuildActionEntry
|
|
||||||
buildForTesting = "YES"
|
|
||||||
buildForRunning = "YES"
|
|
||||||
buildForProfiling = "YES"
|
|
||||||
buildForArchiving = "YES"
|
|
||||||
buildForAnalyzing = "YES">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "E99E52E7F84CEF53B06494B581AFB6E4"
|
|
||||||
BuildableName = "libPods-client.a"
|
|
||||||
BlueprintName = "Pods-client"
|
|
||||||
ReferencedContainer = "container:Pods/Pods.xcodeproj">
|
|
||||||
</BuildableReference>
|
|
||||||
</BuildActionEntry>
|
|
||||||
<BuildActionEntry
|
<BuildActionEntry
|
||||||
buildForTesting = "YES"
|
buildForTesting = "YES"
|
||||||
buildForRunning = "YES"
|
buildForRunning = "YES"
|
||||||
@@ -95,6 +72,26 @@
|
|||||||
</AnalyzeAction>
|
</AnalyzeAction>
|
||||||
<ArchiveAction
|
<ArchiveAction
|
||||||
buildConfiguration = "Release"
|
buildConfiguration = "Release"
|
||||||
|
customArchiveName = "Hey Mama"
|
||||||
revealArchiveInOrganizer = "YES">
|
revealArchiveInOrganizer = "YES">
|
||||||
|
<PostActions>
|
||||||
|
<ExecutionAction
|
||||||
|
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
|
||||||
|
<ActionContent
|
||||||
|
title = "修复归档头信息(避免 Generic Xcode Archive)"
|
||||||
|
scriptText = "bash "${SRCROOT}/scripts/fix-xcarchive-header.sh" "${ARCHIVE_PATH}" "
|
||||||
|
shellToInvoke = "/bin/sh">
|
||||||
|
<EnvironmentBuildable>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
||||||
|
BuildableName = "HeyMama.app"
|
||||||
|
BlueprintName = "client"
|
||||||
|
ReferencedContainer = "container:client.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</EnvironmentBuildable>
|
||||||
|
</ActionContent>
|
||||||
|
</ExecutionAction>
|
||||||
|
</PostActions>
|
||||||
</ArchiveAction>
|
</ArchiveAction>
|
||||||
</Scheme>
|
</Scheme>
|
||||||
|
|||||||
@@ -10,14 +10,10 @@ import WidgetKit
|
|||||||
* - 值统一使用字符串(通常是 JSON),由 JS 侧负责序列化与反序列化
|
* - 值统一使用字符串(通常是 JSON),由 JS 侧负责序列化与反序列化
|
||||||
*/
|
*/
|
||||||
@objc(AppGroupStorage)
|
@objc(AppGroupStorage)
|
||||||
final class AppGroupStorage: NSObject, RCTBridgeModule {
|
final class AppGroupStorage: NSObject {
|
||||||
static func moduleName() -> String! {
|
// 通过 AppGroupStorageBridge.m 的 RCT_EXTERN_MODULE 导出到 RN
|
||||||
"AppGroupStorage"
|
// 这里不需要显式实现/遵循 RCTBridgeModule,避免某些 Archive 场景下找不到协议类型
|
||||||
}
|
@objc static func requiresMainQueueSetup() -> Bool { false }
|
||||||
|
|
||||||
static func requiresMainQueueSetup() -> Bool {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
private let suiteName = "group.com.damer.mindfulness"
|
private let suiteName = "group.com.damer.mindfulness"
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,9 @@
|
|||||||
"color": {
|
"color": {
|
||||||
"components": {
|
"components": {
|
||||||
"alpha": "1.000",
|
"alpha": "1.000",
|
||||||
"blue": "1.00000000000000",
|
"blue": "0.729411764705882",
|
||||||
"green": "1.00000000000000",
|
"green": "0.823529411764706",
|
||||||
"red": "1.00000000000000"
|
"red": "0.917647058823529"
|
||||||
},
|
},
|
||||||
"color-space": "srgb"
|
"color-space": "srgb"
|
||||||
},
|
},
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 123 KiB |
@@ -38,6 +38,8 @@
|
|||||||
<string>12.0</string>
|
<string>12.0</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>NSLocalNetworkUsageDescription</key>
|
||||||
|
<string>用于连接局域网服务以获取内容与同步数据(仅在需要访问内网地址时使用)。</string>
|
||||||
<key>NSAppTransportSecurity</key>
|
<key>NSAppTransportSecurity</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSAllowsArbitraryLoads</key>
|
<key>NSAllowsArbitraryLoads</key>
|
||||||
|
|||||||
@@ -22,6 +22,14 @@
|
|||||||
<string>CA92.1</string>
|
<string>CA92.1</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyAccessedAPIType</key>
|
||||||
|
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
|
||||||
|
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||||
|
<array>
|
||||||
|
<string>35F9.1</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSPrivacyAccessedAPIType</key>
|
<key>NSPrivacyAccessedAPIType</key>
|
||||||
<string>NSPrivacyAccessedAPICategoryDiskSpace</string>
|
<string>NSPrivacyAccessedAPICategoryDiskSpace</string>
|
||||||
@@ -31,14 +39,6 @@
|
|||||||
<string>85F4.1</string>
|
<string>85F4.1</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
<dict>
|
|
||||||
<key>NSPrivacyAccessedAPIType</key>
|
|
||||||
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
|
|
||||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
|
||||||
<array>
|
|
||||||
<string>35F9.1</string>
|
|
||||||
</array>
|
|
||||||
</dict>
|
|
||||||
</array>
|
</array>
|
||||||
<key>NSPrivacyCollectedDataTypes</key>
|
<key>NSPrivacyCollectedDataTypes</key>
|
||||||
<array/>
|
<array/>
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
</systemColor>
|
</systemColor>
|
||||||
<namedColor name="SplashScreenBackground">
|
<namedColor name="SplashScreenBackground">
|
||||||
<color alpha="1.000" blue="1.00000000000000" green="1.00000000000000" red="1.00000000000000" customColorSpace="sRGB" colorSpace="custom"/>
|
<color alpha="1.000" blue="0.729411764705882" green="0.823529411764706" red="0.917647058823529" customColorSpace="sRGB" colorSpace="custom"/>
|
||||||
</namedColor>
|
</namedColor>
|
||||||
</resources>
|
</resources>
|
||||||
</document>
|
</document>
|
||||||
@@ -6,5 +6,6 @@
|
|||||||
// - 部分环境下仅 `import React` 可能无法在 Swift 中解析到 RCTBridge 等类型
|
// - 部分环境下仅 `import React` 可能无法在 Swift 中解析到 RCTBridge 等类型
|
||||||
// - 通过 Bridging Header 显式引入需要的 React 头文件,保证 AppDelegate.swift 可编译
|
// - 通过 Bridging Header 显式引入需要的 React 头文件,保证 AppDelegate.swift 可编译
|
||||||
#import <React/RCTBridge.h>
|
#import <React/RCTBridge.h>
|
||||||
|
#import <React/RCTBridgeModule.h>
|
||||||
#import <React/RCTBundleURLProvider.h>
|
#import <React/RCTBundleURLProvider.h>
|
||||||
#import <React/RCTLinkingManager.h>
|
#import <React/RCTLinkingManager.h>
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ set -euo pipefail
|
|||||||
# - 某些情况下 xcodebuild 生成的 .xcarchive/Info.plist 缺少 ApplicationProperties
|
# - 某些情况下 xcodebuild 生成的 .xcarchive/Info.plist 缺少 ApplicationProperties
|
||||||
# - Organizer 无法识别归档中的主 App(即使 Products/Applications/*.app 存在)
|
# - Organizer 无法识别归档中的主 App(即使 Products/Applications/*.app 存在)
|
||||||
#
|
#
|
||||||
|
# 说明:
|
||||||
|
# - 该脚本的核心作用是让 Organizer 能识别归档里的主 App,从而出现“分发/上传 TestFlight”入口。
|
||||||
|
# - 这类问题通常发生在命令行/CI 归档(xcodebuild archive)或某些自定义归档流程中,
|
||||||
|
# 导致 .xcarchive/Info.plist 缺少/不完整。
|
||||||
|
#
|
||||||
# 用法:
|
# 用法:
|
||||||
# ./scripts/fix-xcarchive-header.sh "/path/to/xxx.xcarchive"
|
# ./scripts/fix-xcarchive-header.sh "/path/to/xxx.xcarchive"
|
||||||
|
|
||||||
@@ -25,6 +30,10 @@ if [[ ! -f "$ARCHIVE_INFO_PLIST" ]]; then
|
|||||||
exit 2
|
exit 2
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# 归档名:尽量从路径推导,避免依赖 Xcode 环境变量
|
||||||
|
archive_basename="$(/usr/bin/basename "$ARCHIVE_PATH")"
|
||||||
|
archive_name="${archive_basename%.xcarchive}"
|
||||||
|
|
||||||
# 取第一个 App(归档里通常只有一个主 App)
|
# 取第一个 App(归档里通常只有一个主 App)
|
||||||
APP_PLIST="$(/usr/bin/find "$ARCHIVE_PATH/Products/Applications" -maxdepth 2 -name Info.plist -path "*.app/Info.plist" 2>/dev/null | /usr/bin/head -n 1 || true)"
|
APP_PLIST="$(/usr/bin/find "$ARCHIVE_PATH/Products/Applications" -maxdepth 2 -name Info.plist -path "*.app/Info.plist" 2>/dev/null | /usr/bin/head -n 1 || true)"
|
||||||
if [[ -z "$APP_PLIST" ]]; then
|
if [[ -z "$APP_PLIST" ]]; then
|
||||||
@@ -39,6 +48,14 @@ APP_REL_PATH="Applications/$APP_NAME"
|
|||||||
bundle_id="$(/usr/bin/plutil -extract CFBundleIdentifier raw -o - "$APP_PLIST" 2>/dev/null || true)"
|
bundle_id="$(/usr/bin/plutil -extract CFBundleIdentifier raw -o - "$APP_PLIST" 2>/dev/null || true)"
|
||||||
short_version="$(/usr/bin/plutil -extract CFBundleShortVersionString raw -o - "$APP_PLIST" 2>/dev/null || true)"
|
short_version="$(/usr/bin/plutil -extract CFBundleShortVersionString raw -o - "$APP_PLIST" 2>/dev/null || true)"
|
||||||
build_version="$(/usr/bin/plutil -extract CFBundleVersion raw -o - "$APP_PLIST" 2>/dev/null || true)"
|
build_version="$(/usr/bin/plutil -extract CFBundleVersion raw -o - "$APP_PLIST" 2>/dev/null || true)"
|
||||||
|
display_name="$(/usr/bin/plutil -extract CFBundleDisplayName raw -o - "$APP_PLIST" 2>/dev/null || true)"
|
||||||
|
bundle_name="$(/usr/bin/plutil -extract CFBundleName raw -o - "$APP_PLIST" 2>/dev/null || true)"
|
||||||
|
|
||||||
|
# SchemeName 在 Organizer 中会用到,但在某些归档流程里会缺失
|
||||||
|
scheme_name="${SCHEME_NAME:-}"
|
||||||
|
if [[ -z "$scheme_name" ]]; then
|
||||||
|
scheme_name="${archive_name:-}"
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ -z "$bundle_id" || -z "$short_version" || -z "$build_version" ]]; then
|
if [[ -z "$bundle_id" || -z "$short_version" || -z "$build_version" ]]; then
|
||||||
echo "错误:无法从 App Info.plist 读取 bundle/version/build:$APP_PLIST" >&2
|
echo "错误:无法从 App Info.plist 读取 bundle/version/build:$APP_PLIST" >&2
|
||||||
@@ -63,6 +80,16 @@ fi
|
|||||||
# 备份一份,防止误操作
|
# 备份一份,防止误操作
|
||||||
cp -f "$ARCHIVE_INFO_PLIST" "$ARCHIVE_INFO_PLIST.bak"
|
cp -f "$ARCHIVE_INFO_PLIST" "$ARCHIVE_INFO_PLIST.bak"
|
||||||
|
|
||||||
|
# 修复归档根字段,避免 Organizer 仍然把它当 Generic Archive
|
||||||
|
# 参考:标准 .xcarchive/Info.plist 通常包含 Name / SchemeName / ArchiveVersion / CreationDate 等。
|
||||||
|
# 我们只在缺失时补齐,尽量不改动归档的其他内容。
|
||||||
|
if ! /usr/bin/plutil -extract Name xml1 -o - "$ARCHIVE_INFO_PLIST" >/dev/null 2>&1; then
|
||||||
|
/usr/bin/plutil -insert Name -string "${archive_name:-${display_name:-${bundle_name:-}}}" "$ARCHIVE_INFO_PLIST" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
if ! /usr/bin/plutil -extract SchemeName xml1 -o - "$ARCHIVE_INFO_PLIST" >/dev/null 2>&1; then
|
||||||
|
/usr/bin/plutil -insert SchemeName -string "${scheme_name:-${archive_name:-}}" "$ARCHIVE_INFO_PLIST" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
# 如果已有 ApplicationProperties,直接更新关键字段即可
|
# 如果已有 ApplicationProperties,直接更新关键字段即可
|
||||||
if /usr/bin/plutil -extract ApplicationProperties xml1 -o - "$ARCHIVE_INFO_PLIST" >/dev/null 2>&1; then
|
if /usr/bin/plutil -extract ApplicationProperties xml1 -o - "$ARCHIVE_INFO_PLIST" >/dev/null 2>&1; then
|
||||||
/usr/bin/plutil -replace ApplicationProperties.ApplicationPath -string "$APP_REL_PATH" "$ARCHIVE_INFO_PLIST"
|
/usr/bin/plutil -replace ApplicationProperties.ApplicationPath -string "$APP_REL_PATH" "$ARCHIVE_INFO_PLIST"
|
||||||
@@ -98,3 +125,9 @@ echo "已修复归档 header:$ARCHIVE_INFO_PLIST"
|
|||||||
echo "主 App:$APP_REL_PATH"
|
echo "主 App:$APP_REL_PATH"
|
||||||
echo "Bundle:$bundle_id"
|
echo "Bundle:$bundle_id"
|
||||||
echo "Version/Build:$short_version/$build_version"
|
echo "Version/Build:$short_version/$build_version"
|
||||||
|
echo "Name/SchemeName:${archive_name:-} / ${scheme_name:-}"
|
||||||
|
|
||||||
|
# 轻量自检:确保关键字段存在(不强制失败,避免中断归档)
|
||||||
|
if ! /usr/bin/plutil -extract ApplicationProperties.ApplicationPath xml1 -o - "$ARCHIVE_INFO_PLIST" >/dev/null 2>&1; then
|
||||||
|
echo "警告:归档 Info.plist 仍缺少 ApplicationProperties.ApplicationPath,Organizer 可能仍显示 Generic Archive" >&2
|
||||||
|
fi
|
||||||
|
|||||||
@@ -237,50 +237,24 @@ struct EmotionWidgetView: View {
|
|||||||
var entry: EmotionProvider.Entry
|
var entry: EmotionProvider.Entry
|
||||||
@Environment(\.widgetFamily) var family
|
@Environment(\.widgetFamily) var family
|
||||||
private let deepLink = URL(string: "client:///(app)/home")
|
private let deepLink = URL(string: "client:///(app)/home")
|
||||||
|
private let widgetBackgroundColor = Color(red: 1.0, green: 250.0 / 255.0, blue: 229.0 / 255.0) // #FFFAE5
|
||||||
|
private let widgetTextColor = Color(red: 98.0 / 255.0, green: 59.0 / 255.0, blue: 59.0 / 255.0) // #623B3B
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
|
||||||
cardBackground(colors: [
|
|
||||||
Color(red: 0.06, green: 0.08, blue: 0.12),
|
|
||||||
Color(red: 0.14, green: 0.18, blue: 0.28),
|
|
||||||
])
|
|
||||||
|
|
||||||
// 只显示一句话(不显示标题/提示/时间等装饰元素)
|
// 只显示一句话(不显示标题/提示/时间等装饰元素)
|
||||||
Text(entry.text)
|
Text(entry.text)
|
||||||
.font(fontForFamily())
|
.font(fontForFamily())
|
||||||
.foregroundColor(Color.white.opacity(0.92))
|
.foregroundColor(widgetTextColor)
|
||||||
.multilineTextAlignment(.leading)
|
.multilineTextAlignment(.leading)
|
||||||
.lineSpacing(lineSpacingForFamily())
|
.lineSpacing(lineSpacingForFamily())
|
||||||
.lineLimit(lineLimitForFamily())
|
.lineLimit(lineLimitForFamily())
|
||||||
.minimumScaleFactor(0.78)
|
.minimumScaleFactor(0.78)
|
||||||
.padding(paddingForFamily())
|
.padding(paddingForFamily())
|
||||||
}
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
||||||
|
.widgetSolidBackground(widgetBackgroundColor)
|
||||||
.widgetURL(deepLink)
|
.widgetURL(deepLink)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 统一的“卡片背景”风格(iOS 15 兼容)
|
|
||||||
private func cardBackground(colors: [Color]) -> some View {
|
|
||||||
ZStack {
|
|
||||||
LinearGradient(
|
|
||||||
colors: colors,
|
|
||||||
startPoint: .topLeading,
|
|
||||||
endPoint: .bottomTrailing
|
|
||||||
)
|
|
||||||
// 轻微光斑,增加层次
|
|
||||||
RadialGradient(
|
|
||||||
gradient: Gradient(colors: [Color.white.opacity(0.16), Color.white.opacity(0.0)]),
|
|
||||||
center: .topTrailing,
|
|
||||||
startRadius: 10,
|
|
||||||
endRadius: 180
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
|
||||||
.stroke(Color.white.opacity(0.14), lineWidth: 1)
|
|
||||||
)
|
|
||||||
.cornerRadius(18)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func fontForFamily() -> Font {
|
private func fontForFamily() -> Font {
|
||||||
switch family {
|
switch family {
|
||||||
case .systemSmall:
|
case .systemSmall:
|
||||||
@@ -330,6 +304,26 @@ struct EmotionWidgetView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct WidgetSolidBackgroundModifier: ViewModifier {
|
||||||
|
let color: Color
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
if #available(iOSApplicationExtension 17.0, *) {
|
||||||
|
content.containerBackground(for: .widget) { color }
|
||||||
|
} else {
|
||||||
|
content
|
||||||
|
.background(color)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension View {
|
||||||
|
func widgetSolidBackground(_ color: Color) -> some View {
|
||||||
|
modifier(WidgetSolidBackgroundModifier(color: color))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct EmotionWidget: Widget {
|
struct EmotionWidget: Widget {
|
||||||
let kind: String = "EmotionWidget"
|
let kind: String = "EmotionWidget"
|
||||||
|
|||||||
@@ -22,7 +22,14 @@ function getOptionalEnv(name: string, fallback: string): string {
|
|||||||
|
|
||||||
export type AppRuntimeEnv = 'local' | 'dev' | 'prod';
|
export type AppRuntimeEnv = 'local' | 'dev' | 'prod';
|
||||||
|
|
||||||
export const APP_ENV = (getOptionalEnv('EXPO_PUBLIC_ENV', 'local') as AppRuntimeEnv) ?? 'local';
|
/**
|
||||||
|
* Release/TestFlight 场景下如果未注入 EXPO_PUBLIC_ENV,
|
||||||
|
* 默认回退到 prod(避免误打到 localhost 导致真机“无法发起网络请求”)。
|
||||||
|
*/
|
||||||
|
const DEFAULT_RUNTIME_ENV: AppRuntimeEnv =
|
||||||
|
typeof __DEV__ !== 'undefined' && __DEV__ ? 'local' : 'prod';
|
||||||
|
|
||||||
|
export const APP_ENV = (getOptionalEnv('EXPO_PUBLIC_ENV', DEFAULT_RUNTIME_ENV) as AppRuntimeEnv) ?? DEFAULT_RUNTIME_ENV;
|
||||||
|
|
||||||
function getApiBaseUrl(env: AppRuntimeEnv): string {
|
function getApiBaseUrl(env: AppRuntimeEnv): string {
|
||||||
// 向后兼容:若直接提供了 EXPO_PUBLIC_API_BASE_URL,则优先使用(不再强制要求 *_DEV/_PROD)
|
// 向后兼容:若直接提供了 EXPO_PUBLIC_API_BASE_URL,则优先使用(不再强制要求 *_DEV/_PROD)
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import type { UserProfileScoring } from '@/src/storage/appStorage';
|
||||||
|
|
||||||
|
import { advanceSuixinState, buildInitialSuixinState, computeSuixinSolidColor, pickSuixinBaseThemeId } from '../index';
|
||||||
|
import { computeTFromStep } from '../progress';
|
||||||
|
import { lerpHex } from '../colorMath';
|
||||||
|
|
||||||
|
function buildProfile(partial?: Partial<UserProfileScoring>): UserProfileScoring {
|
||||||
|
const base: UserProfileScoring = {
|
||||||
|
profile_version: 'v1.2',
|
||||||
|
profile_source: 'questionnaire',
|
||||||
|
profile_generated_at: '2026-01-30T00:00:00Z',
|
||||||
|
profile_confidence: 1.0,
|
||||||
|
profile_answered: { stage: true, emotion: true, context: true, need: true },
|
||||||
|
stage: { expecting: 1, parenting: 0, unknown: 0 },
|
||||||
|
emotion_score: 0.6,
|
||||||
|
context: {},
|
||||||
|
need: {},
|
||||||
|
rule_hits: [],
|
||||||
|
hard_rules: { forbidden_risk_flags: [], forbidden_content_predicates: [] },
|
||||||
|
};
|
||||||
|
return { ...base, ...(partial ?? {}) };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('suixinTheme', () => {
|
||||||
|
it('pickSuixinBaseThemeId: stage.unknown=1 → neutral', () => {
|
||||||
|
const p = buildProfile({ stage: { unknown: 1 } as any, need: { rest_balance: 1 } });
|
||||||
|
expect(pickSuixinBaseThemeId(p)).toBe('neutral');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pickSuixinBaseThemeId: need 为空 → neutral', () => {
|
||||||
|
const p = buildProfile({ need: {} });
|
||||||
|
expect(pickSuixinBaseThemeId(p)).toBe('neutral');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pickSuixinBaseThemeId: need 命中 → 对应 base theme', () => {
|
||||||
|
const p = buildProfile({ need: { rest_balance: 1 } });
|
||||||
|
expect(pickSuixinBaseThemeId(p)).toBe('rest_balance');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('computeTFromStep: 始终在 [0,1] 且往返不突跳', () => {
|
||||||
|
const ts = Array.from({ length: 80 }).map((_, i) => computeTFromStep({ stepIndex: i, segments: 12, seed: 'boot' }));
|
||||||
|
for (const t of ts) {
|
||||||
|
expect(t).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(t).toBeLessThanOrEqual(1);
|
||||||
|
}
|
||||||
|
// 往返波形:起点与一个周期后的 t 相同
|
||||||
|
expect(computeTFromStep({ stepIndex: 0, segments: 12, seed: 'boot' })).toBe(
|
||||||
|
computeTFromStep({ stepIndex: 22, segments: 12, seed: 'boot' }) // 2*(N-1)=22
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lerpHex: t=0/1 输出边界色', () => {
|
||||||
|
expect(lerpHex('#000000', '#FFFFFF', 0)).toBe('#000000');
|
||||||
|
expect(lerpHex('#000000', '#FFFFFF', 1)).toBe('#FFFFFF');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('buildInitialSuixinState: 生成可用状态并可推进', () => {
|
||||||
|
const p = buildProfile({ need: { emotional_support: 1 } });
|
||||||
|
const init = buildInitialSuixinState({ bootId: 'boot-1', profile: p, now: new Date('2026-02-05T00:00:00Z') });
|
||||||
|
expect(init.schema_version).toBe(1);
|
||||||
|
expect(init.base_theme_id).toBe('emotional_support');
|
||||||
|
expect(init.boot_id).toBe('boot-1');
|
||||||
|
expect(init.last_color).toMatch(/^#[0-9A-F]{6}$/);
|
||||||
|
|
||||||
|
const next = advanceSuixinState(init, new Date('2026-02-05T00:00:01Z'));
|
||||||
|
expect(next.step_index).toBe(1);
|
||||||
|
expect(next.base_theme_id).toBe(init.base_theme_id);
|
||||||
|
expect(next.last_color).toMatch(/^#[0-9A-F]{6}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('computeSuixinSolidColor: 输出为合法 hex', () => {
|
||||||
|
const c = computeSuixinSolidColor({ baseThemeId: 'neutral', stepIndex: 3, seed: 'boot' });
|
||||||
|
expect(c).toMatch(/^#[0-9A-F]{6}$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
53
client/src/features/suixinTheme/colorMath.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
function clamp01(t: number): number {
|
||||||
|
if (!Number.isFinite(t)) return 0;
|
||||||
|
return Math.min(1, Math.max(0, t));
|
||||||
|
}
|
||||||
|
|
||||||
|
type Rgb = { r: number; g: number; b: number };
|
||||||
|
|
||||||
|
function toByte(v: number): number {
|
||||||
|
if (!Number.isFinite(v)) return 0;
|
||||||
|
return Math.min(255, Math.max(0, Math.round(v)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hexToRgb(hex: string): Rgb | null {
|
||||||
|
const h = String(hex || '').trim();
|
||||||
|
const m = /^#?([0-9a-fA-F]{6})$/.exec(h);
|
||||||
|
if (!m) return null;
|
||||||
|
const raw = m[1];
|
||||||
|
const n = parseInt(raw, 16);
|
||||||
|
// eslint-disable-next-line no-bitwise
|
||||||
|
const r = (n >> 16) & 0xff;
|
||||||
|
// eslint-disable-next-line no-bitwise
|
||||||
|
const g = (n >> 8) & 0xff;
|
||||||
|
// eslint-disable-next-line no-bitwise
|
||||||
|
const b = n & 0xff;
|
||||||
|
return { r, g, b };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rgbToHex(rgb: Rgb): string {
|
||||||
|
const r = toByte(rgb.r).toString(16).padStart(2, '0');
|
||||||
|
const g = toByte(rgb.g).toString(16).padStart(2, '0');
|
||||||
|
const b = toByte(rgb.b).toString(16).padStart(2, '0');
|
||||||
|
return `#${r}${g}${b}`.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仅允许线性插值(Hard Rule)
|
||||||
|
*/
|
||||||
|
export function lerpRgb(a: Rgb, b: Rgb, t: number): Rgb {
|
||||||
|
const tt = clamp01(t);
|
||||||
|
return {
|
||||||
|
r: a.r + (b.r - a.r) * tt,
|
||||||
|
g: a.g + (b.g - a.g) * tt,
|
||||||
|
b: a.b + (b.b - a.b) * tt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lerpHex(topHex: string, bottomHex: string, t: number, fallbackHex = '#E8F1EC'): string {
|
||||||
|
const top = hexToRgb(topHex);
|
||||||
|
const bottom = hexToRgb(bottomHex);
|
||||||
|
if (!top || !bottom) return fallbackHex;
|
||||||
|
return rgbToHex(lerpRgb(top, bottom, t));
|
||||||
|
}
|
||||||
|
|
||||||
65
client/src/features/suixinTheme/index.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import type { SuixinBaseThemeId, SuixinThemeStateV1, UserProfileScoring } from '@/src/storage/appStorage';
|
||||||
|
|
||||||
|
import { getThemeTriplet, NEUTRAL_THEME_COLORS } from './palette';
|
||||||
|
import { lerpHex } from './colorMath';
|
||||||
|
import { computeTFromStep } from './progress';
|
||||||
|
import { pickSuixinBaseThemeId } from './pickTheme';
|
||||||
|
|
||||||
|
export { NEUTRAL_THEME_COLORS } from './palette';
|
||||||
|
export { pickSuixinBaseThemeId } from './pickTheme';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据 base theme 与 step 计算当前背景纯色
|
||||||
|
*/
|
||||||
|
export function computeSuixinSolidColor(args: {
|
||||||
|
baseThemeId: SuixinBaseThemeId;
|
||||||
|
stepIndex: number;
|
||||||
|
seed: string;
|
||||||
|
}): string {
|
||||||
|
const [top, _mid, bottom] = getThemeTriplet(args.baseThemeId);
|
||||||
|
const t = computeTFromStep({ stepIndex: args.stepIndex, segments: 12, seed: args.seed });
|
||||||
|
return lerpHex(top, bottom, t, NEUTRAL_THEME_COLORS[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化一份随心状态(在冷启动会话内锁定 base theme)
|
||||||
|
*/
|
||||||
|
export function buildInitialSuixinState(args: {
|
||||||
|
bootId: string;
|
||||||
|
profile: UserProfileScoring | null;
|
||||||
|
now?: Date;
|
||||||
|
}): SuixinThemeStateV1 {
|
||||||
|
const now = args.now ?? new Date();
|
||||||
|
const baseThemeId = pickSuixinBaseThemeId(args.profile);
|
||||||
|
const seed = args.bootId;
|
||||||
|
const step_index = 0;
|
||||||
|
const last_color = computeSuixinSolidColor({ baseThemeId, stepIndex: step_index, seed });
|
||||||
|
return {
|
||||||
|
schema_version: 1,
|
||||||
|
saved_at: now.toISOString(),
|
||||||
|
boot_id: args.bootId,
|
||||||
|
base_theme_id: baseThemeId,
|
||||||
|
seed,
|
||||||
|
step_index,
|
||||||
|
last_color,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换文案时推进一步(不跨主题)
|
||||||
|
*/
|
||||||
|
export function advanceSuixinState(prev: SuixinThemeStateV1, now?: Date): SuixinThemeStateV1 {
|
||||||
|
const nextStep = (Number.isFinite(prev.step_index) ? prev.step_index : 0) + 1;
|
||||||
|
const last_color = computeSuixinSolidColor({
|
||||||
|
baseThemeId: prev.base_theme_id,
|
||||||
|
stepIndex: nextStep,
|
||||||
|
seed: prev.seed,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
saved_at: (now ?? new Date()).toISOString(),
|
||||||
|
step_index: nextStep,
|
||||||
|
last_color,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
20
client/src/features/suixinTheme/palette.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import type { SuixinBaseThemeId } from '@/src/storage/appStorage';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 随心主题色盘(与设计说明文档保持一致)
|
||||||
|
*/
|
||||||
|
export const BASE_THEME_COLORS: Record<Exclude<SuixinBaseThemeId, 'neutral'>, [string, string, string]> = {
|
||||||
|
emotional_support: ['#F6DCE4', '#FFEFF4', '#FFF7FA'],
|
||||||
|
parenting_pressure: ['#D6EAF5', '#EEF6FB', '#F8FCFF'],
|
||||||
|
self_worth: ['#FFD8A8', '#FFE8C9', '#FFF6E5'],
|
||||||
|
anxiety_relief: ['#DFF3EA', '#ECFBF6', '#F6FFFB'],
|
||||||
|
rest_balance: ['#F2E6D8', '#FAF3EC', '#FFFDF9'],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NEUTRAL_THEME_COLORS: [string, string, string] = ['#F4F7F2', '#E8F1EC', '#EDF4F8'];
|
||||||
|
|
||||||
|
export function getThemeTriplet(themeId: SuixinBaseThemeId): [string, string, string] {
|
||||||
|
if (themeId === 'neutral') return NEUTRAL_THEME_COLORS;
|
||||||
|
return BASE_THEME_COLORS[themeId];
|
||||||
|
}
|
||||||
|
|
||||||
32
client/src/features/suixinTheme/pickTheme.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import type { SuixinBaseThemeId, UserProfileScoring } from '@/src/storage/appStorage';
|
||||||
|
|
||||||
|
const ALL_NEED_THEME_IDS: ReadonlySet<string> = new Set([
|
||||||
|
'emotional_support',
|
||||||
|
'parenting_pressure',
|
||||||
|
'self_worth',
|
||||||
|
'anxiety_relief',
|
||||||
|
'rest_balance',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base Theme 选择(Theme Picking)
|
||||||
|
*
|
||||||
|
* Hard Rules(对齐设计文档):
|
||||||
|
* - mom_stage = unknown → 强制 Neutral
|
||||||
|
* - need 跳过/缺失 → Neutral
|
||||||
|
*/
|
||||||
|
export function pickSuixinBaseThemeId(profile: UserProfileScoring | null | undefined): SuixinBaseThemeId {
|
||||||
|
if (!profile) return 'neutral';
|
||||||
|
|
||||||
|
// Hard Rule:unknown → Neutral
|
||||||
|
if (profile.stage?.unknown === 1) return 'neutral';
|
||||||
|
|
||||||
|
const needObj = profile.need ?? {};
|
||||||
|
const keys = Object.keys(needObj);
|
||||||
|
if (keys.length === 0) return 'neutral';
|
||||||
|
|
||||||
|
const needId = keys[0];
|
||||||
|
if (!ALL_NEED_THEME_IDS.has(needId)) return 'neutral';
|
||||||
|
return needId as Exclude<SuixinBaseThemeId, 'neutral'>;
|
||||||
|
}
|
||||||
|
|
||||||
40
client/src/features/suixinTheme/progress.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
function clampInt(n: number, min: number, max: number): number {
|
||||||
|
if (!Number.isFinite(n)) return min;
|
||||||
|
return Math.min(max, Math.max(min, Math.floor(n)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashStringToInt32(input: string): number {
|
||||||
|
// 简单可复现 hash:用于把 seed 映射为偏移量(不用于安全场景)
|
||||||
|
let h = 0;
|
||||||
|
for (let i = 0; i < input.length; i += 1) {
|
||||||
|
// eslint-disable-next-line no-bitwise
|
||||||
|
h = (h * 31 + input.charCodeAt(i)) | 0;
|
||||||
|
}
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成 t ∈ [0,1],用于 Color(t)=lerp(top,bottom,t)
|
||||||
|
*
|
||||||
|
* 说明:
|
||||||
|
* - Home 没有 scroll,用“切换文案 step_index”模拟连续流动
|
||||||
|
* - 采用往返波形,避免从 1 回到 0 的突跳
|
||||||
|
*/
|
||||||
|
export function computeTFromStep(args: {
|
||||||
|
stepIndex: number;
|
||||||
|
segments?: number; // N,默认 12
|
||||||
|
seed?: string; // 允许用 seed 做初始相位偏移(同一次冷启动内稳定)
|
||||||
|
}): number {
|
||||||
|
const N = clampInt(args.segments ?? 12, 3, 60);
|
||||||
|
const stepIndex = clampInt(args.stepIndex, 0, 1_000_000_000);
|
||||||
|
const period = 2 * (N - 1);
|
||||||
|
|
||||||
|
const seed = String(args.seed ?? '');
|
||||||
|
const offset = seed ? Math.abs(hashStringToInt32(seed)) % period : 0;
|
||||||
|
|
||||||
|
const phase = (stepIndex + offset) % period;
|
||||||
|
const up = phase <= (N - 1);
|
||||||
|
const pos = up ? phase : period - phase;
|
||||||
|
return pos / (N - 1);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -33,9 +33,13 @@ function mapMomStage(raw: string | undefined): QuestionnaireAnswersV1_2['mom_sta
|
|||||||
|
|
||||||
function mapEmotion(raw: string | undefined): QuestionnaireAnswersV1_2['emotion'] {
|
function mapEmotion(raw: string | undefined): QuestionnaireAnswersV1_2['emotion'] {
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
// UI 当前选项:happy/calm/stressed/low
|
// UI 选项:
|
||||||
|
// - happy/calm/stressed/low:历史选项(仍保留兼容)
|
||||||
|
// - okay/tired:新增选项
|
||||||
if (raw === 'happy') return 'joyful';
|
if (raw === 'happy') return 'joyful';
|
||||||
if (raw === 'calm') return 'calm';
|
if (raw === 'calm') return 'calm';
|
||||||
|
if (raw === 'okay') return 'neutral';
|
||||||
|
if (raw === 'tired') return 'tired';
|
||||||
if (raw === 'stressed') return 'overwhelmed';
|
if (raw === 'stressed') return 'overwhelmed';
|
||||||
if (raw === 'low') return 'low';
|
if (raw === 'low') return 'low';
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import * as Localization from 'expo-localization';
|
|||||||
import i18n from 'i18next';
|
import i18n from 'i18next';
|
||||||
import { initReactI18next } from 'react-i18next';
|
import { initReactI18next } from 'react-i18next';
|
||||||
|
|
||||||
|
import { isTraditionalChineseLocaleTag } from './locale';
|
||||||
|
|
||||||
// 用 require 避免 TS 的 json module 配置差异导致无法编译
|
// 用 require 避免 TS 的 json module 配置差异导致无法编译
|
||||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
const all = require('./locales/all.json') as { en: Record<string, unknown>; 'zh-TW': Record<string, unknown> };
|
const all = require('./locales/all.json') as { en: Record<string, unknown>; 'zh-TW': Record<string, unknown> };
|
||||||
@@ -29,13 +31,8 @@ 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')) {
|
|
||||||
return 'zh-TW';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 其他语言:按前缀匹配(当前仅支持英文)
|
|
||||||
if (tag.startsWith('en')) return 'en';
|
if (tag.startsWith('en')) return 'en';
|
||||||
|
if (isTraditionalChineseLocaleTag(tag)) return 'zh-TW';
|
||||||
|
|
||||||
return DEFAULT_FALLBACK_LANGUAGE;
|
return DEFAULT_FALLBACK_LANGUAGE;
|
||||||
}
|
}
|
||||||
|
|||||||
35
client/src/i18n/locale.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
export type BackendLocale = 'en' | 'tc';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断一个 BCP-47 language tag 是否应视为「繁体中文」(TC)。
|
||||||
|
*
|
||||||
|
* 规则(面向当前产品约束:只支持 EN/TC,默认 EN):
|
||||||
|
* - 仅在语言为中文(zh)且脚本为 Hant 或地区为 TW/HK/MO 时,判定为 TC
|
||||||
|
* - 兼容后端/历史写法:明确包含 tc 也视为 TC
|
||||||
|
* - 其他情况一律视为 EN
|
||||||
|
*/
|
||||||
|
export function isTraditionalChineseLocaleTag(languageTag: string): boolean {
|
||||||
|
const tag = (languageTag || '').trim().toLowerCase();
|
||||||
|
if (!tag) return false;
|
||||||
|
|
||||||
|
// 兼容:有些链路可能直接传 tc
|
||||||
|
const parts = tag.split(/[-_]/g).filter(Boolean);
|
||||||
|
if (parts.includes('tc')) return true;
|
||||||
|
|
||||||
|
const lang = parts[0];
|
||||||
|
if (lang !== 'zh') return false;
|
||||||
|
|
||||||
|
// 脚本:zh-Hant / zh-Hant-TW / zh-Hant-HK ...
|
||||||
|
if (tag.includes('hant') || parts.includes('hant')) return true;
|
||||||
|
|
||||||
|
// 地区:zh-TW / zh-HK / zh-MO
|
||||||
|
if (parts.includes('tw') || parts.includes('hk') || parts.includes('mo')) return true;
|
||||||
|
|
||||||
|
// 其他中文(如 zh / zh-CN / zh-Hans)不属于 TC → 回退 EN
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toBackendLocaleFromLanguageTag(languageTag: string | null | undefined): BackendLocale {
|
||||||
|
return isTraditionalChineseLocaleTag(languageTag ?? '') ? 'tc' : 'en';
|
||||||
|
}
|
||||||
|
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
"ok": "OK",
|
"ok": "OK",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"error": "Error",
|
"error": "Error",
|
||||||
|
"notice": "Notice",
|
||||||
"openLinkError": "Cannot open link",
|
"openLinkError": "Cannot open link",
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
"close": "Close"
|
"close": "Close"
|
||||||
@@ -25,39 +26,41 @@
|
|||||||
},
|
},
|
||||||
"onboardingSurvey": {
|
"onboardingSurvey": {
|
||||||
"steps": {
|
"steps": {
|
||||||
"name": { "title": "What should I call you?" },
|
"name": { "title": "What do you want to be called?", "placeholder": "Mama" },
|
||||||
"status": {
|
"status": {
|
||||||
"title": "Your current stage?",
|
"title": "Which stage of motherhood are you in?",
|
||||||
"options": {
|
"options": {
|
||||||
"pregnant": "Pregnant / preparing for motherhood",
|
"pregnant": "Pregnant / Preparing",
|
||||||
"has_kids": "Already have kids",
|
"has_kids": "Parenting",
|
||||||
"no_fill": "Prefer not to say"
|
"no_fill": "Prefer not to say"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"emotion": {
|
"emotion": {
|
||||||
"title": "How are you feeling right now?",
|
"title": "How are you feeling right now?",
|
||||||
"options": {
|
"options": {
|
||||||
"happy": "Happy / satisfied",
|
"happy": "Joyful",
|
||||||
"calm": "Calm / grounded",
|
"calm": "Calm",
|
||||||
"stressed": "Stressed / overwhelmed",
|
"okay": "Okay",
|
||||||
"low": "Down / low mood"
|
"tired": "Tired",
|
||||||
|
"stressed": "Overwhelmed",
|
||||||
|
"low": "Low"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"influence": {
|
"influence": {
|
||||||
"title": "What has been affecting you lately?",
|
"title": "What’s been influencing how you feel?",
|
||||||
"options": {
|
"options": {
|
||||||
"family": "Family & kids",
|
"family": "Family",
|
||||||
"work": "Work or study",
|
"work": "Work or study",
|
||||||
"relationship": "Intimate relationship",
|
"relationship": "Relationship",
|
||||||
"friends": "Friends & social life",
|
"friends": "Friends",
|
||||||
"health": "Mental & physical health"
|
"health": "Health"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"support": {
|
"support": {
|
||||||
"title": "What support do you need most?",
|
"title": "What kind of support do you need most right now?",
|
||||||
"options": {
|
"options": {
|
||||||
"emotional": "Emotional support",
|
"emotional": "Emotional support",
|
||||||
"parenting": "Parenting stress",
|
"parenting": "Parenting pressure",
|
||||||
"self_worth": "Self-worth",
|
"self_worth": "Self-worth",
|
||||||
"anxiety": "Anxiety relief",
|
"anxiety": "Anxiety relief",
|
||||||
"balance": "Rest & balance"
|
"balance": "Rest & balance"
|
||||||
@@ -95,7 +98,8 @@
|
|||||||
"theme": {
|
"theme": {
|
||||||
"title": "Theme",
|
"title": "Theme",
|
||||||
"scenery": "Scenery",
|
"scenery": "Scenery",
|
||||||
"color": "Color"
|
"color": "Color",
|
||||||
|
"suixin": "Ease"
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"title": "Me",
|
"title": "Me",
|
||||||
@@ -119,6 +123,9 @@
|
|||||||
"widget": {
|
"widget": {
|
||||||
"lockScreen": "Lock Screen Widget",
|
"lockScreen": "Lock Screen Widget",
|
||||||
"homeScreen": "Home Screen Widget",
|
"homeScreen": "Home Screen Widget",
|
||||||
|
"howToTitle": "How to add the widget",
|
||||||
|
"howToDesc1": "Long-press on the Home Screen until the apps jiggle, then tap “+” in the top-left corner.",
|
||||||
|
"howToDesc2": "Search “Mindfulness”, choose a widget size you like, then tap “Add Widget”.",
|
||||||
"previewDate": "Thu, Jan 29",
|
"previewDate": "Thu, Jan 29",
|
||||||
"previewQuote": "I’m proud of who I am, even while becoming who I want to be."
|
"previewQuote": "I’m proud of who I am, even while becoming who I want to be."
|
||||||
},
|
},
|
||||||
@@ -136,10 +143,15 @@
|
|||||||
},
|
},
|
||||||
"consent": {
|
"consent": {
|
||||||
"title": "You Are Perfect.",
|
"title": "You Are Perfect.",
|
||||||
"subtitle": "Everything Will Be Better.",
|
"subtitle": "Everything\nWill Be Better.",
|
||||||
"agree": "Agree & Continue",
|
"agree": "Agree & Continue",
|
||||||
"privacy": "Privacy Policy",
|
"privacy": "Privacy Policy",
|
||||||
"terms": "Terms of Use"
|
"terms": "Terms of Use",
|
||||||
|
"notice": "By continuing, you agree to the Privacy Policy and Terms of Use.",
|
||||||
|
"noticeRich": "By continuing, you agree to the <privacy>{{privacyLabel}}{{privacySuffix}}</privacy> and <terms>{{termsLabel}}{{termsSuffix}}</terms>.",
|
||||||
|
"linkUnavailable": "Failed to load the policy link. Please check your network and try again.",
|
||||||
|
"linkUnavailableDev": "Failed to load the policy link. Please check your network or API_BASE_URL: {{baseUrl}}",
|
||||||
|
"linkLoadingSuffix": " (loading…)"
|
||||||
},
|
},
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"notificationsDenied": "Notifications are denied. Please enable them in Settings."
|
"notificationsDenied": "Notifications are denied. Please enable them in Settings."
|
||||||
@@ -161,6 +173,9 @@
|
|||||||
"ok": "確定",
|
"ok": "確定",
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
"back": "返回",
|
"back": "返回",
|
||||||
|
"error": "錯誤",
|
||||||
|
"notice": "提示",
|
||||||
|
"openLinkError": "無法打開鏈接",
|
||||||
"close": "關閉"
|
"close": "關閉"
|
||||||
},
|
},
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
@@ -180,7 +195,7 @@
|
|||||||
},
|
},
|
||||||
"onboardingSurvey": {
|
"onboardingSurvey": {
|
||||||
"steps": {
|
"steps": {
|
||||||
"name": { "title": "我可以怎麼稱呼你?" },
|
"name": { "title": "我可以怎麼稱呼你?", "placeholder": "媽媽" },
|
||||||
"status": {
|
"status": {
|
||||||
"title": "媽媽的狀態?",
|
"title": "媽媽的狀態?",
|
||||||
"options": {
|
"options": {
|
||||||
@@ -194,6 +209,8 @@
|
|||||||
"options": {
|
"options": {
|
||||||
"happy": "愉悅、滿足",
|
"happy": "愉悅、滿足",
|
||||||
"calm": "平靜、安穩",
|
"calm": "平靜、安穩",
|
||||||
|
"okay": "還可以、普通",
|
||||||
|
"tired": "疲累、沒什麼力氣",
|
||||||
"stressed": "被壓得有點喘不過氣",
|
"stressed": "被壓得有點喘不過氣",
|
||||||
"low": "情緒低落"
|
"low": "情緒低落"
|
||||||
}
|
}
|
||||||
@@ -250,7 +267,8 @@
|
|||||||
"theme": {
|
"theme": {
|
||||||
"title": "主題",
|
"title": "主題",
|
||||||
"scenery": "風景",
|
"scenery": "風景",
|
||||||
"color": "顏色"
|
"color": "顏色",
|
||||||
|
"suixin": "隨心"
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"title": "我的",
|
"title": "我的",
|
||||||
@@ -274,6 +292,9 @@
|
|||||||
"widget": {
|
"widget": {
|
||||||
"lockScreen": "鎖屏小工具",
|
"lockScreen": "鎖屏小工具",
|
||||||
"homeScreen": "桌面小工具",
|
"homeScreen": "桌面小工具",
|
||||||
|
"howToTitle": "如何添加小工具",
|
||||||
|
"howToDesc1": "長按主畫面空白處進入編輯,點左上角「+」新增小工具。",
|
||||||
|
"howToDesc2": "搜尋「正念」,選擇喜歡的尺寸,點「加入小工具」。",
|
||||||
"previewDate": "1月29日週四 · 已至臘月十一",
|
"previewDate": "1月29日週四 · 已至臘月十一",
|
||||||
"previewQuote": "我也對現在的自己感到滿意,即使我仍在努力成為想成為的人。"
|
"previewQuote": "我也對現在的自己感到滿意,即使我仍在努力成為想成為的人。"
|
||||||
},
|
},
|
||||||
@@ -290,9 +311,16 @@
|
|||||||
"widgetDesc": "把溫柔提醒放到桌面上:長按主畫面 → 點「+」 → 搜尋「正念」 → 添加你喜歡的尺寸。"
|
"widgetDesc": "把溫柔提醒放到桌面上:長按主畫面 → 點「+」 → 搜尋「正念」 → 添加你喜歡的尺寸。"
|
||||||
},
|
},
|
||||||
"consent": {
|
"consent": {
|
||||||
|
"title": "你很完美。",
|
||||||
|
"subtitle": "一切\n都會更好。",
|
||||||
"agree": "同意並繼續",
|
"agree": "同意並繼續",
|
||||||
"privacy": "隱私協議",
|
"privacy": "隱私協議",
|
||||||
"terms": "用戶使用協議"
|
"terms": "用戶使用協議",
|
||||||
|
"notice": "繼續使用即代表你同意《隱私協議》與《用戶使用協議》。",
|
||||||
|
"noticeRich": "繼續使用即代表你同意<privacy>《{{privacyLabel}}》{{privacySuffix}}</privacy>與<terms>《{{termsLabel}}》{{termsSuffix}}</terms>。",
|
||||||
|
"linkUnavailable": "協議鏈接載入失敗,請檢查網路後重試。",
|
||||||
|
"linkUnavailableDev": "協議鏈接載入失敗,請檢查網路或 API_BASE_URL 設定:{{baseUrl}}",
|
||||||
|
"linkLoadingSuffix": "(載入中…)"
|
||||||
},
|
},
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"notificationsDenied": "系統權限已被拒絕,請前往手機設定開啟通知。"
|
"notificationsDenied": "系統權限已被拒絕,請前往手機設定開啟通知。"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { API_BASE_URL } from '@/src/constants/env';
|
import { API_BASE_URL } from '@/src/constants/env';
|
||||||
import type { UserProfileV1_2, UserProfileV1_2_Extended } from '@/src/features/userProfileScoring/types';
|
import type { UserProfileV1_2, UserProfileV1_2_Extended } from '@/src/features/userProfileScoring/types';
|
||||||
|
import { toBackendLocaleFromLanguageTag } from '@/src/i18n/locale';
|
||||||
import { fetchRecoWidget } from '@/src/services/recoApi';
|
import { fetchRecoWidget } from '@/src/services/recoApi';
|
||||||
import i18n from 'i18next';
|
import i18n from 'i18next';
|
||||||
import { getUserProfileScoring } from '@/src/storage/appStorage';
|
import { getUserProfileScoring } from '@/src/storage/appStorage';
|
||||||
@@ -144,7 +145,7 @@ export async function ensureDailyWidgetRecoUpToDate(args?: {
|
|||||||
const top = items?.[0];
|
const top = items?.[0];
|
||||||
if (!top?.text) return;
|
if (!top?.text) return;
|
||||||
|
|
||||||
const lang = i18n.language?.toLowerCase().startsWith('zh') ? 'tc' : 'en';
|
const lang = toBackendLocaleFromLanguageTag(i18n.language);
|
||||||
await setWidgetDailyRecoCache({
|
await setWidgetDailyRecoCache({
|
||||||
schema_version: 1,
|
schema_version: 1,
|
||||||
saved_at: new Date().toISOString(),
|
saved_at: new Date().toISOString(),
|
||||||
|
|||||||
@@ -20,10 +20,15 @@ describe('legalApi.buildAcceptLanguage', () => {
|
|||||||
expect(buildAcceptLanguage()).toBe('en');
|
expect(buildAcceptLanguage()).toBe('en');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('任意 zh* 归一为 tc', () => {
|
it('简中/其他中文不支持时回退为 en', () => {
|
||||||
setLang('zh-CN');
|
setLang('zh-CN');
|
||||||
expect(buildAcceptLanguage()).toBe('tc');
|
expect(buildAcceptLanguage()).toBe('en');
|
||||||
|
|
||||||
|
setLang('zh');
|
||||||
|
expect(buildAcceptLanguage()).toBe('en');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('繁体中文归一为 tc', () => {
|
||||||
setLang('zh-TW');
|
setLang('zh-TW');
|
||||||
expect(buildAcceptLanguage()).toBe('tc');
|
expect(buildAcceptLanguage()).toBe('tc');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import i18n from 'i18next';
|
import i18n from 'i18next';
|
||||||
|
|
||||||
import { httpJson } from '../utils/http';
|
import { httpJson } from '../utils/http';
|
||||||
|
import { toBackendLocaleFromLanguageTag } from '../i18n/locale';
|
||||||
|
|
||||||
export type LegalLinks = {
|
export type LegalLinks = {
|
||||||
privacyPolicyUrl: string;
|
privacyPolicyUrl: string;
|
||||||
@@ -9,17 +10,8 @@ export type LegalLinks = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function buildAcceptLanguage(): 'en' | 'tc' {
|
export function buildAcceptLanguage(): 'en' | 'tc' {
|
||||||
const lang = (i18n.language || '').trim();
|
// 当前多语言仅支持 EN / TC;其他语言统一回退到 en
|
||||||
const lower = lang.toLowerCase();
|
return toBackendLocaleFromLanguageTag(i18n.language);
|
||||||
|
|
||||||
// 当前多语言仅支持 EN / TC(与 reco 链路一致);其他语言统一回退到 en
|
|
||||||
if (lower.startsWith('zh')) {
|
|
||||||
return 'tc';
|
|
||||||
}
|
|
||||||
if (lower.includes('tc') || lower.includes('hant') || lower.includes('hk') || lower.includes('mo') || lower.includes('tw')) {
|
|
||||||
return 'tc';
|
|
||||||
}
|
|
||||||
return 'en';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchLegalLinks(): Promise<LegalLinks> {
|
export async function fetchLegalLinks(): Promise<LegalLinks> {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { httpJson } from '../utils/http';
|
|||||||
import { APP_ENV } from '../constants/env';
|
import { APP_ENV } from '../constants/env';
|
||||||
import { getDailyReminderSettings, getOrCreateClientUserId, getUserProfileScoring } from '../storage/appStorage';
|
import { getDailyReminderSettings, getOrCreateClientUserId, getUserProfileScoring } from '../storage/appStorage';
|
||||||
import type { UserProfileScoring } from '../storage/appStorage';
|
import type { UserProfileScoring } from '../storage/appStorage';
|
||||||
|
import { toBackendLocaleFromLanguageTag } from '../i18n/locale';
|
||||||
|
|
||||||
export type PushEnv = 'dev' | 'prod';
|
export type PushEnv = 'dev' | 'prod';
|
||||||
|
|
||||||
@@ -44,11 +45,7 @@ export type PushPreferencesResponse = PushPreferencesRequest & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function buildAcceptLanguage(): 'en' | 'tc' {
|
export function buildAcceptLanguage(): 'en' | 'tc' {
|
||||||
const lang = (i18n.language || '').trim();
|
return toBackendLocaleFromLanguageTag(i18n.language);
|
||||||
const lower = lang.toLowerCase();
|
|
||||||
if (lower.startsWith('zh')) return 'tc';
|
|
||||||
if (lower.includes('tc') || lower.includes('hant') || lower.includes('hk') || lower.includes('mo') || lower.includes('tw')) return 'tc';
|
|
||||||
return 'en';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toPushEnv(appEnv: typeof APP_ENV): PushEnv {
|
function toPushEnv(appEnv: typeof APP_ENV): PushEnv {
|
||||||
@@ -103,6 +100,8 @@ function getExpoProjectId(): string | undefined {
|
|||||||
// 兼容 app.json / app.config.ts 的 extra.eas.projectId
|
// 兼容 app.json / app.config.ts 的 extra.eas.projectId
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
(Constants.expoConfig as any)?.extra?.eas?.projectId ||
|
(Constants.expoConfig as any)?.extra?.eas?.projectId ||
|
||||||
|
// 兜底:某些运行时环境仍可直接读到 EXPO_PUBLIC_ 注入
|
||||||
|
process.env.EXPO_PUBLIC_EAS_PROJECT_ID ||
|
||||||
undefined
|
undefined
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -118,7 +117,7 @@ export async function getExpoPushTokenOrThrow(): Promise<string> {
|
|||||||
const msg = e instanceof Error ? e.message : String(e);
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
const hint = projectId
|
const hint = projectId
|
||||||
? ''
|
? ''
|
||||||
: '(可能缺少 EAS projectId,建议在 app.json 的 extra.eas.projectId 配置后重试)';
|
: '(可能缺少 EAS projectId:请在 .env.local 配置 EXPO_PUBLIC_EAS_PROJECT_ID,或在 app.json/app.config.ts 的 extra.eas.projectId 写入后重试)';
|
||||||
throw new Error(`获取 Expo Push Token 失败:${msg}${hint}`);
|
throw new Error(`获取 Expo Push Token 失败:${msg}${hint}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import i18n from 'i18next';
|
import i18n from 'i18next';
|
||||||
|
|
||||||
import type { UserProfileV1_2 } from '../features/userProfileScoring';
|
import type { UserProfileV1_2 } from '../features/userProfileScoring';
|
||||||
|
import { toBackendLocaleFromLanguageTag } from '../i18n/locale';
|
||||||
import { httpJson } from '../utils/http';
|
import { httpJson } from '../utils/http';
|
||||||
|
|
||||||
export type RecommendedItem = {
|
export type RecommendedItem = {
|
||||||
@@ -27,7 +28,7 @@ export type RecoRequest = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function fetchRecoFeed(req: RecoRequest): Promise<RecoEngineResult> {
|
export async function fetchRecoFeed(req: RecoRequest): Promise<RecoEngineResult> {
|
||||||
const acceptLanguage = i18n.language?.toLowerCase().startsWith('zh') ? 'tc' : 'en';
|
const acceptLanguage = toBackendLocaleFromLanguageTag(i18n.language);
|
||||||
|
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
// 让后端做 locale 选择(目前后端只区分 en/tc)
|
// 让后端做 locale 选择(目前后端只区分 en/tc)
|
||||||
@@ -52,7 +53,7 @@ export async function fetchRecoFeed(req: RecoRequest): Promise<RecoEngineResult>
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchRecoWidget(req: RecoRequest): Promise<RecoEngineResult> {
|
export async function fetchRecoWidget(req: RecoRequest): Promise<RecoEngineResult> {
|
||||||
const acceptLanguage = i18n.language?.toLowerCase().startsWith('zh') ? 'tc' : 'en';
|
const acceptLanguage = toBackendLocaleFromLanguageTag(i18n.language);
|
||||||
|
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
// 让后端做 locale 选择(目前后端只区分 en/tc)
|
// 让后端做 locale 选择(目前后端只区分 en/tc)
|
||||||
|
|||||||
@@ -16,17 +16,40 @@ const KEY_USER_PROFILE_SCORING = 'user.profileScoring';
|
|||||||
const KEY_RECO_FEED_CACHE = 'reco.feedCache';
|
const KEY_RECO_FEED_CACHE = 'reco.feedCache';
|
||||||
const KEY_RECO_FEED_HISTORY = 'reco.feedHistory';
|
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_UI_THEME_SUIXIN_STATE = 'ui.theme.suixin.state';
|
||||||
const KEY_DAILY_REMINDER_SETTINGS = 'dailyReminder.settings';
|
const KEY_DAILY_REMINDER_SETTINGS = 'dailyReminder.settings';
|
||||||
|
|
||||||
export type PushPromptState = 'enabled' | 'skipped' | 'unknown';
|
export type PushPromptState = 'enabled' | 'skipped' | 'unknown';
|
||||||
export type Reaction = 'like' | 'dislike';
|
export type Reaction = 'like' | 'dislike';
|
||||||
export type ReactionsMap = Record<string, Reaction>;
|
export type ReactionsMap = Record<string, Reaction>;
|
||||||
export type ThemeMode = 'scenery' | 'color';
|
export type ThemeMode = 'scenery' | 'color' | 'suixin';
|
||||||
export type UserProfile = {
|
export type UserProfile = {
|
||||||
name?: string;
|
name?: string;
|
||||||
intents?: string[];
|
intents?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SuixinBaseThemeId =
|
||||||
|
| 'neutral'
|
||||||
|
| 'emotional_support'
|
||||||
|
| 'parenting_pressure'
|
||||||
|
| 'self_worth'
|
||||||
|
| 'anxiety_relief'
|
||||||
|
| 'rest_balance';
|
||||||
|
|
||||||
|
export type SuixinThemeStateV1 = {
|
||||||
|
schema_version: 1;
|
||||||
|
saved_at: string; // ISO8601
|
||||||
|
/**
|
||||||
|
* 冷启动会话标记(进程级)。
|
||||||
|
* 用于确保:仅在冷启动时重置 base theme/seed。
|
||||||
|
*/
|
||||||
|
boot_id: string;
|
||||||
|
base_theme_id: SuixinBaseThemeId;
|
||||||
|
seed: string;
|
||||||
|
step_index: number;
|
||||||
|
last_color: string; // "#RRGGBB"
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用户画像(问卷打分输出)
|
* 用户画像(问卷打分输出)
|
||||||
* 说明:用于推荐/Push/Widget 统一复用;结构以 `src/features/userProfileScoring` 输出为准。
|
* 说明:用于推荐/Push/Widget 统一复用;结构以 `src/features/userProfileScoring` 输出为准。
|
||||||
@@ -205,7 +228,7 @@ export async function setConsentAccepted(accepted: boolean): Promise<void> {
|
|||||||
|
|
||||||
export async function getThemeMode(): Promise<ThemeMode> {
|
export async function getThemeMode(): Promise<ThemeMode> {
|
||||||
const raw = await AsyncStorage.getItem(KEY_UI_THEME_MODE);
|
const raw = await AsyncStorage.getItem(KEY_UI_THEME_MODE);
|
||||||
if (raw === 'scenery' || raw === 'color') return raw;
|
if (raw === 'scenery' || raw === 'color' || raw === 'suixin') return raw;
|
||||||
return 'scenery';
|
return 'scenery';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,6 +236,28 @@ export async function setThemeMode(mode: ThemeMode): Promise<void> {
|
|||||||
await AsyncStorage.setItem(KEY_UI_THEME_MODE, mode);
|
await AsyncStorage.setItem(KEY_UI_THEME_MODE, mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getSuixinThemeState(): Promise<SuixinThemeStateV1 | null> {
|
||||||
|
const raw = await AsyncStorage.getItem(KEY_UI_THEME_SUIXIN_STATE);
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as Partial<SuixinThemeStateV1>;
|
||||||
|
if (parsed.schema_version !== 1) return null;
|
||||||
|
if (typeof parsed.boot_id !== 'string') return null;
|
||||||
|
if (typeof parsed.base_theme_id !== 'string') return null;
|
||||||
|
if (typeof parsed.seed !== 'string') return null;
|
||||||
|
if (typeof parsed.step_index !== 'number') return null;
|
||||||
|
if (typeof parsed.last_color !== 'string') return null;
|
||||||
|
if (typeof parsed.saved_at !== 'string') return null;
|
||||||
|
return parsed as SuixinThemeStateV1;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setSuixinThemeState(state: SuixinThemeStateV1): Promise<void> {
|
||||||
|
await setJson(KEY_UI_THEME_SUIXIN_STATE, state);
|
||||||
|
}
|
||||||
|
|
||||||
export async function getUserProfile(): Promise<UserProfile> {
|
export async function getUserProfile(): Promise<UserProfile> {
|
||||||
return await getJson<UserProfile>(KEY_USER_PROFILE, {});
|
return await getJson<UserProfile>(KEY_USER_PROFILE, {});
|
||||||
}
|
}
|
||||||
|
|||||||
16
client/src/utils/bootSession.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* 冷启动会话标记(进程级、仅内存)。
|
||||||
|
*
|
||||||
|
* 目的:
|
||||||
|
* - 在“随心”主题中实现:仅在冷启动时重置 base theme/seed
|
||||||
|
* - 不落盘,避免污染 AsyncStorage
|
||||||
|
*/
|
||||||
|
let bootId: string | null = null;
|
||||||
|
|
||||||
|
export function getBootId(): string {
|
||||||
|
if (bootId) return bootId;
|
||||||
|
// 说明:无需加密强随机;只要在一次进程周期内稳定、不同冷启动尽量不同即可
|
||||||
|
bootId = `${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||||
|
return bootId;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -122,7 +122,8 @@ export async function httpJson<T>(opts: HttpJsonOptions): Promise<T> {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
// RN 下 AbortError 文案不完全一致,这里统一对外语义
|
// RN 下 AbortError 文案不完全一致,这里统一对外语义
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
throw new Error(`网络请求失败:${msg}`);
|
// 带上 URL,便于在 TestFlight/Release 排查实际打到哪个地址(例如误打到 localhost)
|
||||||
|
throw new Error(`网络请求失败:${msg}(${url})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -141,6 +142,22 @@ export async function httpJson<T>(opts: HttpJsonOptions): Promise<T> {
|
|||||||
return undefined as unknown as T;
|
return undefined as unknown as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (await res.json()) as T;
|
// 某些后端/网关会返回 200 但 body 为空;此时 res.json() 会抛错,导致客户端误判“失败”。
|
||||||
|
// 这里改为:先读 text,空则返回 undefined;非空再 parse JSON。
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
if (!text || !String(text).trim()) {
|
||||||
|
return undefined as unknown as T;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(text) as T;
|
||||||
|
} catch {
|
||||||
|
throw new HttpError({
|
||||||
|
message: `HTTP 响应不是合法 JSON:${res.status} ${res.statusText} ${text}`.trim(),
|
||||||
|
url,
|
||||||
|
status: res.status,
|
||||||
|
statusText: res.statusText,
|
||||||
|
responseText: text,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ RUN python -m pip install -U pip \
|
|||||||
COPY app /app/app
|
COPY app /app/app
|
||||||
COPY alembic /app/alembic
|
COPY alembic /app/alembic
|
||||||
COPY alembic.ini /app/alembic.ini
|
COPY alembic.ini /app/alembic.ini
|
||||||
|
COPY docker-entrypoint.sh /app/docker-entrypoint.sh
|
||||||
|
|
||||||
|
RUN chmod +x /app/docker-entrypoint.sh
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
@@ -29,4 +32,7 @@ EXPOSE 8000
|
|||||||
# - 参考文档:server/README.md
|
# - 参考文档:server/README.md
|
||||||
|
|
||||||
# 生产镜像默认不开启 reload
|
# 生产镜像默认不开启 reload
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
# 默认行为:只启动 API(与之前一致)
|
||||||
|
# 如需同时启动定时推送相关进程(Celery Worker/Beat),可在运行时注入:
|
||||||
|
# -e START_ALL=1
|
||||||
|
ENTRYPOINT ["/app/docker-entrypoint.sh"]
|
||||||
|
|||||||
88
server/docker-entrypoint.sh
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
# 容器入口:
|
||||||
|
# - 默认只启动 API(与原 Dockerfile 行为一致)
|
||||||
|
# - 如需测试“定时推送”,可额外启动 Celery Worker + Beat
|
||||||
|
#
|
||||||
|
# 环境变量:
|
||||||
|
# - START_API=1|0(默认 1)
|
||||||
|
# - START_WORKER=1|0(默认 0)
|
||||||
|
# - START_BEAT=1|0(默认 0)
|
||||||
|
# - START_ALL=1(等价于 START_WORKER=1 + START_BEAT=1)
|
||||||
|
# - HOST / PORT(API 监听地址,默认 0.0.0.0:8000)
|
||||||
|
|
||||||
|
log() {
|
||||||
|
echo "[entrypoint] $*"
|
||||||
|
}
|
||||||
|
|
||||||
|
START_API="${START_API:-1}"
|
||||||
|
START_WORKER="${START_WORKER:-0}"
|
||||||
|
START_BEAT="${START_BEAT:-0}"
|
||||||
|
|
||||||
|
if [ "${START_ALL:-0}" = "1" ]; then
|
||||||
|
START_WORKER="1"
|
||||||
|
START_BEAT="1"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Beat 只负责“投递任务到队列”,真正执行仍需要 Worker。
|
||||||
|
# 说明:
|
||||||
|
# - 单容器模式:若启动了 API(START_API=1)且同时启用了 Beat,则自动补齐 Worker,避免误配导致“只排程不执行”。
|
||||||
|
# - 多容器模式:允许单独启动 beat 容器(START_API=0, START_BEAT=1),不做自动补齐。
|
||||||
|
if [ "$START_API" = "1" ] && [ "$START_BEAT" = "1" ] && [ "$START_WORKER" != "1" ]; then
|
||||||
|
log "提示:已启用 START_BEAT=1(且 START_API=1),自动同时启用 START_WORKER=1(否则队列无人执行)。"
|
||||||
|
START_WORKER="1"
|
||||||
|
fi
|
||||||
|
|
||||||
|
PIDS=""
|
||||||
|
|
||||||
|
stop_children() {
|
||||||
|
# 温和退出
|
||||||
|
for pid in $PIDS; do
|
||||||
|
kill -TERM "$pid" >/dev/null 2>&1 || true
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
on_term() {
|
||||||
|
log "收到退出信号,正在停止子进程..."
|
||||||
|
stop_children
|
||||||
|
# 等待子进程退出,避免残留
|
||||||
|
wait >/dev/null 2>&1 || true
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
trap on_term INT TERM
|
||||||
|
|
||||||
|
if [ "$START_WORKER" = "1" ]; then
|
||||||
|
log "启动 Celery Worker:celery -A app.worker:celery_app worker -l info"
|
||||||
|
celery -A app.worker:celery_app worker -l info &
|
||||||
|
PIDS="$PIDS $!"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$START_BEAT" = "1" ]; then
|
||||||
|
log "启动 Celery Beat:celery -A app.worker:celery_app beat -l info"
|
||||||
|
celery -A app.worker:celery_app beat -l info &
|
||||||
|
PIDS="$PIDS $!"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$START_API" = "1" ]; then
|
||||||
|
HOST="${HOST:-0.0.0.0}"
|
||||||
|
PORT="${PORT:-8000}"
|
||||||
|
log "启动 API:uvicorn app.main:app --host $HOST --port $PORT"
|
||||||
|
uvicorn app.main:app --host "$HOST" --port "$PORT" &
|
||||||
|
API_PID="$!"
|
||||||
|
PIDS="$PIDS $API_PID"
|
||||||
|
|
||||||
|
# 以 API 生命周期为准:API 退出则容器退出,并清理其他进程
|
||||||
|
wait "$API_PID"
|
||||||
|
CODE="$?"
|
||||||
|
log "API 已退出(code=$CODE),正在停止其他进程..."
|
||||||
|
stop_children
|
||||||
|
wait >/dev/null 2>&1 || true
|
||||||
|
exit "$CODE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 未启动 API:就阻塞等待其他进程(一般用于仅跑 worker/beat 的容器)
|
||||||
|
log "未启动 API,等待后台进程..."
|
||||||
|
wait
|
||||||
|
|
||||||
@@ -7,28 +7,38 @@ set -euo pipefail
|
|||||||
# - 自动启动 uvicorn(默认开启 --reload)
|
# - 自动启动 uvicorn(默认开启 --reload)
|
||||||
#
|
#
|
||||||
# 用法示例:
|
# 用法示例:
|
||||||
# ./run.sh # 默认 host=0.0.0.0 port=8000 env=dev reload=on
|
# ./run.sh # 默认一键启动:API + Celery Worker + Celery Beat
|
||||||
# ./run.sh --env prod # 使用 .env.prod(若存在且可被 source)
|
# ./run.sh --env prod # 使用 .env.prod(若存在且可被 source)
|
||||||
# ./run.sh --port 9000 # 改端口
|
# ./run.sh --port 9000 # 改端口
|
||||||
# ./run.sh --no-reload # 关闭热更新
|
# ./run.sh --no-reload # 关闭热更新
|
||||||
|
# ./run.sh --with-worker --with-beat # 同时启动 Celery Worker + Beat(用于定时推送)
|
||||||
|
# ./run.sh --all # 等价于 --with-worker --with-beat
|
||||||
|
# START_ALL=1 ./run.sh # 用环境变量一键启动(适合写到脚本/别名里)
|
||||||
|
# ./run.sh --api-only # 只启动 API(不启动 Worker/Beat)
|
||||||
# ./run.sh --install-only # 只安装依赖,不启动
|
# ./run.sh --install-only # 只安装依赖,不启动
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<'EOF'
|
cat <<'EOF'
|
||||||
用法:
|
用法:
|
||||||
./run.sh [--env dev|prod] [--host 0.0.0.0] [--port 8000] [--no-reload] [--skip-install] [--install-only]
|
./run.sh [--env dev|prod] [--host 0.0.0.0] [--port 8000] [--no-reload] [--api-only] [--with-worker] [--with-beat] [--all] [--skip-install] [--install-only]
|
||||||
|
|
||||||
参数:
|
参数:
|
||||||
--env dev|prod 优先尝试加载 .env.dev 或 .env.prod(如果存在)。
|
--env dev|prod 优先尝试加载 .env.dev 或 .env.prod(如果存在)。
|
||||||
--host <host> uvicorn host(默认 0.0.0.0)
|
--host <host> uvicorn host(默认 0.0.0.0)
|
||||||
--port <port> uvicorn port(默认 8000)
|
--port <port> uvicorn port(默认 8000)
|
||||||
--no-reload 关闭 uvicorn --reload
|
--no-reload 关闭 uvicorn --reload
|
||||||
|
--api-only 只启动 API(不启动 Worker/Beat)
|
||||||
|
--with-worker 同时启动 Celery Worker(处理异步/ETA 任务)
|
||||||
|
--with-beat 同时启动 Celery Beat(定时调度,例如每日生成推送排程)
|
||||||
|
--all 同时启动 Worker + Beat(等价于 --with-worker --with-beat)
|
||||||
--skip-install 跳过依赖安装(默认会安装/更新 requirements.txt)
|
--skip-install 跳过依赖安装(默认会安装/更新 requirements.txt)
|
||||||
--install-only 只安装依赖,不启动服务
|
--install-only 只安装依赖,不启动服务
|
||||||
-h, --help 显示帮助
|
-h, --help 显示帮助
|
||||||
|
|
||||||
说明:
|
说明:
|
||||||
- 若你的 .env.* 不是 shell 可 source 的格式(例如包含空格/特殊字符未加引号),建议改成 KEY=value 形式。
|
- 若你的 .env.* 不是 shell 可 source 的格式(例如包含空格/特殊字符未加引号),建议改成 KEY=value 形式。
|
||||||
|
- 仅启动 API 并不会生成 `push_send_log`;要测试“定时推送”,需要 Beat 调度 `tasks.push.generate_daily_schedule`,并由 Worker 执行后续 ETA 任务。
|
||||||
|
- 也可以用环境变量一键启动:START_ALL=1 ./run.sh
|
||||||
- 启动后访问:
|
- 启动后访问:
|
||||||
/healthz 健康检查
|
/healthz 健康检查
|
||||||
/docs OpenAPI 文档
|
/docs OpenAPI 文档
|
||||||
@@ -45,6 +55,10 @@ PORT="8000"
|
|||||||
RELOAD="1"
|
RELOAD="1"
|
||||||
SKIP_INSTALL="0"
|
SKIP_INSTALL="0"
|
||||||
INSTALL_ONLY="0"
|
INSTALL_ONLY="0"
|
||||||
|
API_ONLY="0"
|
||||||
|
# 默认:一键启动(满足“bash run.sh 就全部启动”)
|
||||||
|
WITH_WORKER="1"
|
||||||
|
WITH_BEAT="1"
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
@@ -64,6 +78,25 @@ while [[ $# -gt 0 ]]; do
|
|||||||
RELOAD="0"
|
RELOAD="0"
|
||||||
shift 1
|
shift 1
|
||||||
;;
|
;;
|
||||||
|
--api-only)
|
||||||
|
API_ONLY="1"
|
||||||
|
WITH_WORKER="0"
|
||||||
|
WITH_BEAT="0"
|
||||||
|
shift 1
|
||||||
|
;;
|
||||||
|
--with-worker)
|
||||||
|
WITH_WORKER="1"
|
||||||
|
shift 1
|
||||||
|
;;
|
||||||
|
--with-beat)
|
||||||
|
WITH_BEAT="1"
|
||||||
|
shift 1
|
||||||
|
;;
|
||||||
|
--all)
|
||||||
|
WITH_WORKER="1"
|
||||||
|
WITH_BEAT="1"
|
||||||
|
shift 1
|
||||||
|
;;
|
||||||
--skip-install)
|
--skip-install)
|
||||||
SKIP_INSTALL="1"
|
SKIP_INSTALL="1"
|
||||||
shift 1
|
shift 1
|
||||||
@@ -99,6 +132,28 @@ if [[ -f "$ENV_FILE" ]]; then
|
|||||||
set +a
|
set +a
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# 允许通过环境变量一键开启(适合写到别名/CI 脚本里)
|
||||||
|
if [[ "${START_ALL:-0}" == "1" ]]; then
|
||||||
|
WITH_WORKER="1"
|
||||||
|
WITH_BEAT="1"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 允许通过环境变量强制只启动 API
|
||||||
|
if [[ "${START_API_ONLY:-0}" == "1" ]]; then
|
||||||
|
API_ONLY="1"
|
||||||
|
WITH_WORKER="0"
|
||||||
|
WITH_BEAT="0"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 让 API/Celery 统一使用同一个 APP_ENV(影响 Redis key 前缀、定时任务配置等)
|
||||||
|
export APP_ENV="${APP_ENV:-$ENV_NAME}"
|
||||||
|
|
||||||
|
# Beat 只负责“投递任务到队列”,真正执行仍需要 Worker;这里自动补齐,避免误用。
|
||||||
|
if [[ "$WITH_BEAT" == "1" && "$WITH_WORKER" == "0" ]]; then
|
||||||
|
echo "提示:已启用 --with-beat,自动同时启用 --with-worker(否则队列无人执行)。"
|
||||||
|
WITH_WORKER="1"
|
||||||
|
fi
|
||||||
|
|
||||||
# 选择 python 命令(优先 python3)
|
# 选择 python 命令(优先 python3)
|
||||||
PY_BIN=""
|
PY_BIN=""
|
||||||
if command -v python3 >/dev/null 2>&1; then
|
if command -v python3 >/dev/null 2>&1; then
|
||||||
@@ -140,6 +195,45 @@ if [[ "$RELOAD" == "1" ]]; then
|
|||||||
UVICORN_ARGS+=(--reload)
|
UVICORN_ARGS+=(--reload)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [[ "$WITH_WORKER" == "0" && "$WITH_BEAT" == "0" ]]; then
|
||||||
echo "启动服务:uvicorn ${UVICORN_ARGS[*]}"
|
echo "启动服务:uvicorn ${UVICORN_ARGS[*]}"
|
||||||
exec uvicorn "${UVICORN_ARGS[@]}"
|
exec uvicorn "${UVICORN_ARGS[@]}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
PIDS=()
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
# 避免重复清理导致脚本退出码被覆盖
|
||||||
|
set +e
|
||||||
|
if [[ ${#PIDS[@]} -gt 0 ]]; then
|
||||||
|
echo ""
|
||||||
|
echo "正在停止后台进程..."
|
||||||
|
# 先尝试温和退出
|
||||||
|
for pid in "${PIDS[@]}"; do
|
||||||
|
kill -TERM "$pid" >/dev/null 2>&1 || true
|
||||||
|
done
|
||||||
|
# 等待一点时间,再强制杀掉仍存活的(防止残留)
|
||||||
|
sleep 1
|
||||||
|
for pid in "${PIDS[@]}"; do
|
||||||
|
kill -KILL "$pid" >/dev/null 2>&1 || true
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
|
if [[ "$WITH_WORKER" == "1" ]]; then
|
||||||
|
echo "启动 Celery Worker:celery -A app.worker:celery_app worker -l info"
|
||||||
|
celery -A app.worker:celery_app worker -l info &
|
||||||
|
PIDS+=("$!")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$WITH_BEAT" == "1" ]]; then
|
||||||
|
echo "启动 Celery Beat:celery -A app.worker:celery_app beat -l info"
|
||||||
|
celery -A app.worker:celery_app beat -l info &
|
||||||
|
PIDS+=("$!")
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "启动服务:uvicorn ${UVICORN_ARGS[*]}"
|
||||||
|
uvicorn "${UVICORN_ARGS[@]}"
|
||||||
|
|
||||||
|
|||||||
236
spec_kit/SuixinTheme/plan.md
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
# 「随心」主题(Suixin Theme)技术计划(plan)
|
||||||
|
|
||||||
|
## 0. 目标回顾
|
||||||
|
|
||||||
|
在 Home 现有「风景 / 纯色」主题基础上新增第三种主题「随心」:
|
||||||
|
|
||||||
|
- **输入**:问卷生成的用户画像 `U`(本地存储)
|
||||||
|
- **输出**:Home 背景推荐颜色(以纯色为主)
|
||||||
|
- **规则**:复用「个性化背景颜色推荐算法」的 Base Theme/Neutral Theme 与 Hard Rules
|
||||||
|
- **计算时机**:
|
||||||
|
- **冷启动(App 进程级)**:计算一次并锁定 Base Theme
|
||||||
|
- **切换文案(Home 上滑切下一条)**:在同一 Base Theme 内更新一次“当前颜色”
|
||||||
|
- **持久化**:主题选择与「随心」计算状态均持久化(避免回到 Home/重进页面时丢失)
|
||||||
|
- **多语言**:TC(zh-TW)+ EN
|
||||||
|
|
||||||
|
## 1. 现状梳理(与改动点)
|
||||||
|
|
||||||
|
### 1.1 现有主题切换
|
||||||
|
|
||||||
|
- `ThemeMode` 当前为 `'scenery' | 'color'`
|
||||||
|
- `ThemeModal` 弹窗提供 2 个卡片切换
|
||||||
|
- `Home` 根据 `themeMode`:
|
||||||
|
- `scenery`:背景图 + 默认底色
|
||||||
|
- `color`:从 `THEME_COLORS` 按 `index` 轮换纯色
|
||||||
|
- `ui.theme.mode` 已在 `AsyncStorage` 持久化
|
||||||
|
|
||||||
|
### 1.2 用户画像输入已就绪
|
||||||
|
|
||||||
|
客户端已将问卷映射为 `UserProfileV1_2(_Extended)` 并持久化(`user.profileScoring`),关键字段:
|
||||||
|
|
||||||
|
- `stage.unknown`(Hard Rule:unknown → Neutral)
|
||||||
|
- `need`(稀疏 one-hot:`{ [needTag]: 1 }` 或 `{}`)
|
||||||
|
- `emotion_score: number | null`
|
||||||
|
- `profile_confidence: number`
|
||||||
|
- `profile_answered`
|
||||||
|
|
||||||
|
## 2. 技术方案总览
|
||||||
|
|
||||||
|
### 2.1 「随心」算法在 Home 的落地形态
|
||||||
|
|
||||||
|
Home 不具备“长文案阅读页的 scroll”,因此采用**“渐变单点采样”**来复用算法的连续插值模型:
|
||||||
|
|
||||||
|
- Base Theme 仍按 `need / stage` 选定并锁定(Theme Lock)
|
||||||
|
- 每次切换文案时,生成一个 \(t \in [0, 1]\),并计算:
|
||||||
|
|
||||||
|
\[
|
||||||
|
Color(t) = lerp(Color\_top, Color\_bottom, t)
|
||||||
|
\]
|
||||||
|
|
||||||
|
- 输出为单一 `hex` 纯色,作为 Home `backgroundColor`
|
||||||
|
- 全程 **不跨 need、不跨主题色系**,仅在同主题内移动
|
||||||
|
|
||||||
|
### 2.2 持久化与“只在冷启动/切换文案时计算”
|
||||||
|
|
||||||
|
为同时满足“持久化”与“冷启动时计算”:
|
||||||
|
|
||||||
|
- **持久化内容**:锁定的 `base_theme_id` + 用于生成 \(t\) 的 `seed` + 当前 `step_index` + `last_color`
|
||||||
|
- **冷启动计算**:当检测到“新一轮 App 启动会话”时,重新选择并锁定 `base_theme_id`,并重置/更新 `seed` 与 `step_index`
|
||||||
|
- **切换文案计算**:仅递增 `step_index`,在同一 `base_theme_id` 下更新 `last_color`
|
||||||
|
|
||||||
|
> 说明:冷启动检测以“进程级首次进入 Home”为准(工程实现阶段会在 `_layout` 或全局单例中生成 boot 标记)。
|
||||||
|
|
||||||
|
## 3. 数据结构与存储设计
|
||||||
|
|
||||||
|
### 3.1 扩展主题枚举
|
||||||
|
|
||||||
|
- 将 `ThemeMode` 扩展为:`'scenery' | 'color' | 'suixin'`
|
||||||
|
- 存储 key:沿用 `ui.theme.mode`
|
||||||
|
|
||||||
|
### 3.2 新增「随心」状态存储
|
||||||
|
|
||||||
|
新增本地存储 key(建议):
|
||||||
|
|
||||||
|
- `ui.theme.suixin.state`
|
||||||
|
|
||||||
|
数据结构(建议):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type SuixinThemeStateV1 = {
|
||||||
|
schema_version: 1;
|
||||||
|
saved_at: string; // ISO8601
|
||||||
|
base_theme_id: 'neutral' | 'emotional_support' | 'parenting_pressure' | 'self_worth' | 'anxiety_relief' | 'rest_balance';
|
||||||
|
seed: string; // 用于生成 t 的稳定种子(可由 profile + 日期等派生)
|
||||||
|
step_index: number; // 每切换一条文案 +1
|
||||||
|
last_color: string; // "#RRGGBB"
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 冷启动会话标记(Boot ID)
|
||||||
|
|
||||||
|
为实现“仅冷启动时重置 base theme/seed”,新增一个进程级 boot 标记(实现二选一):
|
||||||
|
|
||||||
|
- **方案 A(推荐)**:在 `app/_layout.tsx` 首次挂载时生成 `boot_id` 并写入内存单例(不落盘)
|
||||||
|
- **方案 B**:写入 `AsyncStorage`(例如 `app.boot.lastSeenAt`)并结合“本次运行内存标记”判定首次进入 Home
|
||||||
|
|
||||||
|
计划优先采用方案 A:逻辑清晰且不污染存储。
|
||||||
|
|
||||||
|
## 4. 颜色算法实现细节(Home 版本)
|
||||||
|
|
||||||
|
### 4.1 主题色盘常量
|
||||||
|
|
||||||
|
在客户端新增一个颜色模块(例如 `client/src/features/suixinTheme/`),内置:
|
||||||
|
|
||||||
|
- Base Theme(5 套)+ Neutral(1 套)
|
||||||
|
- 与设计文档保持一致的 `hex` 值
|
||||||
|
|
||||||
|
### 4.2 Base Theme 选择(锁定)
|
||||||
|
|
||||||
|
输入:`UserProfileScoring`
|
||||||
|
|
||||||
|
输出:`base_theme_id`
|
||||||
|
|
||||||
|
规则:
|
||||||
|
|
||||||
|
- 若 `stage.unknown === 1` → `neutral`
|
||||||
|
- 若 `need` 为空 `{}` → `neutral`
|
||||||
|
- 否则取 `Object.keys(need)[0]`:
|
||||||
|
- 若 key 在枚举内 → 对应 Base Theme
|
||||||
|
- 否则 → `neutral`
|
||||||
|
|
||||||
|
### 4.3 t 的生成与“低感知变化”
|
||||||
|
|
||||||
|
为了让“切换文案”带来“流动感”但不跳变,采用**小步进**策略:
|
||||||
|
|
||||||
|
- 定义 `N = 12`(可调):表示从 \(0 \to 1\) 的分段数
|
||||||
|
- 每次切换文案:`step_index += 1`
|
||||||
|
- 计算:`t = (step_index % N) / (N - 1)`
|
||||||
|
|
||||||
|
> 该策略保证 \(t\) 在 \([0, 1]\) 内缓慢移动;到达 1 后回到 0 会有一次跳变。为进一步降低跳变,可改为往返波形:
|
||||||
|
>
|
||||||
|
> - `phase = step_index % (2*(N-1))`
|
||||||
|
> - `t = phase <= (N-1) ? phase/(N-1) : (2*(N-1)-phase)/(N-1)`
|
||||||
|
|
||||||
|
实现阶段默认采用**往返波形**,避免回卷突跳。
|
||||||
|
|
||||||
|
### 4.4 lerp 计算(严格线性)
|
||||||
|
|
||||||
|
- `lerp` 仅允许线性插值
|
||||||
|
- 颜色空间:先使用 sRGB 的逐通道线性插值(实现简单、可控);若后续需要更自然,可升级到线性空间插值,但仍保持线性模型
|
||||||
|
|
||||||
|
### 4.5 emotion/confidence 的约束接入
|
||||||
|
|
||||||
|
本期按“安全优先”策略落地:
|
||||||
|
|
||||||
|
- 若 `emotion_score === null` 或 `emotion_score <= 0.3`:输出不做任何微扰(纯 lerp 结果)
|
||||||
|
- 亮度微扰(\(\Delta L \le \pm 2\%\))与饱和度上限为可选增强;若落地,将以 `profile_confidence` 作为开关条件,并确保不改变色系
|
||||||
|
|
||||||
|
## 5. UI 与交互实现计划
|
||||||
|
|
||||||
|
### 5.1 ThemeModal:新增第三个主题卡片
|
||||||
|
|
||||||
|
- 在 `client/components/home/ThemeModal.tsx`:
|
||||||
|
- `ThemeMode` 扩展为包含 `'suixin'`
|
||||||
|
- 新增 `ThemeCard`:标题使用 i18n(如 `t('theme.suixin')`)
|
||||||
|
- 布局改造:由 2 卡横排改为 **3 卡自适应**(`flexWrap` 或减小 gap/宽度),确保小屏不溢出
|
||||||
|
- 预览图:一期可复用 `theme_color.png` 作为占位;若有设计资源再替换为 `theme_suixin.png`
|
||||||
|
|
||||||
|
### 5.2 Home:新增主题分支与颜色计算时机
|
||||||
|
|
||||||
|
在 `client/app/(app)/home.tsx`:
|
||||||
|
|
||||||
|
- 将 `themeMode === 'suixin'` 作为第三分支:
|
||||||
|
- 背景为纯色(`backgroundColor = suixinColor`)
|
||||||
|
- 不显示风景图
|
||||||
|
- **冷启动**:Home 首次进入时,读取用户画像与 `suixin.state`:
|
||||||
|
- 若检测到新 boot 会话:重算并写入 `suixin.state`
|
||||||
|
- 否则:直接使用持久化的 `last_color`
|
||||||
|
- **切换文案**:在现有 `triggerNextContent` 成功切换索引后:
|
||||||
|
- 若当前主题为 `suixin`:递增 `step_index`,计算新的 `last_color`,并持久化
|
||||||
|
|
||||||
|
### 5.3 收藏(Favorites)背景记录兼容
|
||||||
|
|
||||||
|
`FavoriteItem.background` 当前对 `color` 存 `hex`,对 `scenery` 存图片索引。
|
||||||
|
|
||||||
|
- `suixin` 同样存 `hex`,与 `color` 分支一致即可
|
||||||
|
|
||||||
|
## 6. i18n 计划(TC / EN)
|
||||||
|
|
||||||
|
在 `client/src/i18n/locales/all.json` 增加:
|
||||||
|
|
||||||
|
- `theme.suixin`
|
||||||
|
- (可选)`theme.suixinDesc`(若 UI 后续展示描述)
|
||||||
|
|
||||||
|
英文命名采用语义化方案(本计划建议):
|
||||||
|
|
||||||
|
- EN:`theme.suixin = "Ease"`
|
||||||
|
- TC:`theme.suixin = "隨心"`
|
||||||
|
|
||||||
|
> 若后续品牌希望保留音译,也可改为 EN=`Suixin`,不影响技术实现。
|
||||||
|
|
||||||
|
## 7. 兼容性与迁移
|
||||||
|
|
||||||
|
- `ThemeMode` 的存储值新增 `'suixin'`:
|
||||||
|
- 旧版本只会存 `'scenery'|'color'`,升级后兼容
|
||||||
|
- 若读取到未知值,继续回退 `'scenery'`
|
||||||
|
- 新增 `ui.theme.suixin.state`:
|
||||||
|
- 若不存在,首次进入随心主题时初始化
|
||||||
|
|
||||||
|
## 8. 测试计划(最小可回归)
|
||||||
|
|
||||||
|
### 8.1 单元测试(推荐)
|
||||||
|
|
||||||
|
为颜色算法模块增加用例(可放在 `client/src/features/suixinTheme/__tests__/`):
|
||||||
|
|
||||||
|
- `stage.unknown=1` → 必选 `neutral`
|
||||||
|
- `need={}` → 必选 `neutral`
|
||||||
|
- `need={rest_balance:1}` → 选 `rest_balance` Base Theme
|
||||||
|
- `step_index` 递增 → `t` 按往返波形变化且始终在 \([0,1]\)
|
||||||
|
- `emotion_score=null` / `<=0.3` → 不触发微扰逻辑
|
||||||
|
|
||||||
|
### 8.2 手动验收(与 spec 对齐)
|
||||||
|
|
||||||
|
- ThemeModal 能看到第三个主题并可切换
|
||||||
|
- 冷启动进入 Home:随心背景根据画像选定主题色系
|
||||||
|
- 上滑切换文案:背景色在同主题内缓慢变化(无跨主题跳色)
|
||||||
|
- `stage.unknown=1` 或 `need` 跳过:背景为 Neutral Theme
|
||||||
|
- 切换语言:主题名称在 TC/EN 下正确显示
|
||||||
|
|
||||||
|
## 9. 风险与对策
|
||||||
|
|
||||||
|
- **三卡布局拥挤**:采用 `flexWrap`/缩小卡片尺寸,必要时改为横向滚动
|
||||||
|
- **“持久化”与“冷启动重算”矛盾**:以“状态落盘 + 冷启动重置 base theme/seed”方式兼容两者
|
||||||
|
- **颜色可读性风险**:一期先用主题中间色/插值结果,避免过饱和;必要时增加对比度检查(后续迭代)
|
||||||
|
|
||||||
|
## 10. 里程碑拆分(实现顺序)
|
||||||
|
|
||||||
|
- **M1:基础接入**
|
||||||
|
- 扩展 `ThemeMode`,ThemeModal 增加第三项与 i18n
|
||||||
|
- Home 增加 `suixin` 分支,背景可显示(先用 neutral 兜底)
|
||||||
|
- **M2:算法落地 + 持久化**
|
||||||
|
- 新增 suixin 颜色模块(Base/Neutral、pickTheme、lerp、t 生成)
|
||||||
|
- 新增 `suixin.state` 存取与冷启动/切换文案更新
|
||||||
|
- **M3:回归与体验优化**
|
||||||
|
- 收藏背景记录兼容
|
||||||
|
- 测试补齐与边界修正(unknown/跳过/缺画像)
|
||||||
|
|
||||||
161
spec_kit/SuixinTheme/spec.md
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
# 「随心」主题(Suixin Theme)高层规范(spec)
|
||||||
|
|
||||||
|
## 1. 背景与动机
|
||||||
|
|
||||||
|
当前首页(Home)支持两种主题:
|
||||||
|
|
||||||
|
- **风景**:使用预置风景图作为背景
|
||||||
|
- **纯色**:使用预置颜色列表轮换作为背景
|
||||||
|
|
||||||
|
现在新增第三种主题 **「随心」**,其核心是:**背景颜色随用户画像个性化**,并遵循既有的「个性化背景颜色推荐算法」规则与硬约束(Hard Rules)。
|
||||||
|
|
||||||
|
## 2. 目标(Goals)
|
||||||
|
|
||||||
|
- **新增主题**:在现有「风景 / 纯色」基础上新增 **「随心」** 主题,并与现有主题切换入口保持一致。
|
||||||
|
- **个性化颜色**:基于用户完成问卷后生成的用户画像 `U`,输出 Home 背景的推荐颜色(或渐变颜色组),形成“更贴合此刻”的视觉陪伴。
|
||||||
|
- **稳定与不冒犯**:严格遵循硬规则(例如 `mom_stage=unknown` 强制 Neutral Theme),并在一次 session 内保持稳定,避免跳色造成打扰。
|
||||||
|
- **多语言**:支持 **繁体中文(TC / zh-TW)** 与 **英文(EN)** 的主题名称与 UI 文案展示。
|
||||||
|
|
||||||
|
## 3. 非目标(Non-Goals)
|
||||||
|
|
||||||
|
- **不用于转化**:随心主题不承担 CTA/转化引导职责,不为“制造变化”而变化。
|
||||||
|
- **不新增色系数量**:不新增主题色系数量,复用既定 Base Theme(5 套)+ Neutral Theme(1 套)。
|
||||||
|
- **不做心理诊断**:颜色不用于推断用户心理状态,只用于提升阅读与停留的舒适度。
|
||||||
|
|
||||||
|
## 4. 适用范围(Scope)
|
||||||
|
|
||||||
|
### 4.1 适用页面
|
||||||
|
|
||||||
|
- **首页 Home 背景(主题模式为「随心」时)**:输出为“纯色背景”或“轻量渐变背景”(实现形态由工程实现阶段确定,但必须遵循硬约束与稳定性规则)。
|
||||||
|
|
||||||
|
### 4.2 不适用页面
|
||||||
|
|
||||||
|
- 首页列表/卡片/CTA 组件背景(不在本需求范围)
|
||||||
|
- 任何需要高对比/强引导的交互区域(避免降低可用性)
|
||||||
|
|
||||||
|
## 5. 用户体验与交互
|
||||||
|
|
||||||
|
### 5.1 主题切换入口与位置
|
||||||
|
|
||||||
|
- **切换位置**:与现有主题切换位置一致(即当前 Home 右上角主题按钮打开的主题选择弹窗/面板)。
|
||||||
|
- **切换项**:在「风景」「纯色」旁新增第三项「随心」。
|
||||||
|
|
||||||
|
### 5.2 主题命名与多语言(TC / EN)
|
||||||
|
|
||||||
|
#### i18n Key 建议(示例)
|
||||||
|
|
||||||
|
- `home.theme.scenery`
|
||||||
|
- `home.theme.color`
|
||||||
|
- `home.theme.suixin`
|
||||||
|
- `home.theme.suixinDesc`(可选:主题描述,用于解释“随心=按问卷画像推荐颜色”)
|
||||||
|
|
||||||
|
#### 文案建议
|
||||||
|
|
||||||
|
- **TC(zh-TW)**
|
||||||
|
- `home.theme.suixin`: 隨心
|
||||||
|
- `home.theme.suixinDesc`: 依照你的問卷狀態,推薦舒適的背景色
|
||||||
|
- **EN**
|
||||||
|
- `home.theme.suixin`: Suixin
|
||||||
|
- `home.theme.suixinDesc`: A cozy background color, tailored from your questionnaire
|
||||||
|
|
||||||
|
> 说明:主题名「随心」作为品牌/概念名,EN 采用音译 `Suixin`,避免语义误解(如 “Random”)。
|
||||||
|
|
||||||
|
## 6. 输入输出(与问卷画像的对接)
|
||||||
|
|
||||||
|
### 6.1 输入:用户画像 `U`
|
||||||
|
|
||||||
|
随心主题的颜色推荐以客户端本地存储的用户画像为输入(来源:问卷完成后生成的画像)。
|
||||||
|
|
||||||
|
必须使用字段(与现有实现对齐):
|
||||||
|
|
||||||
|
- `U.stage.unknown`:用于 Hard Rule(unknown → Neutral Theme)
|
||||||
|
- `U.need`:用于选择 Base Theme(稀疏 one-hot,例如 `{ "rest_balance": 1 }`;若为空 `{}` 视为“need 跳过”)
|
||||||
|
- `U.emotion_score`:用于动态强度/亮度扰动的约束(可为 `null`)
|
||||||
|
- `U.profile_confidence`:用于个性化强度(可信度低则更保守)
|
||||||
|
- `U.profile_answered`:用于判断题目是否跳过(避免伪精确)
|
||||||
|
|
||||||
|
### 6.2 输出:Home 背景推荐颜色
|
||||||
|
|
||||||
|
输出形态需支持两类(工程阶段二选一或混合):
|
||||||
|
|
||||||
|
- **纯色输出(推荐优先)**:输出单一 `hex` 颜色作为背景色
|
||||||
|
- **轻量渐变输出(可选增强)**:输出 2~3 个 `hex` 颜色作为背景渐变 stops(必须连续、低感知变化)
|
||||||
|
|
||||||
|
## 7. 颜色算法规则(复用现有文档,Home 场景化)
|
||||||
|
|
||||||
|
### 7.1 主题色系(Base Theme / Neutral Theme)
|
||||||
|
|
||||||
|
Base Theme(5 套,不新增):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"emotional_support": ["#F6DCE4", "#FFEFF4", "#FFF7FA"],
|
||||||
|
"parenting_pressure": ["#D6EAF5", "#EEF6FB", "#F8FCFF"],
|
||||||
|
"self_worth": ["#FFD8A8", "#FFE8C9", "#FFF6E5"],
|
||||||
|
"anxiety_relief": ["#DFF3EA", "#ECFBF6", "#F6FFFB"],
|
||||||
|
"rest_balance": ["#F2E6D8", "#FAF3EC", "#FFFDF9"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Neutral Theme(1 套):
|
||||||
|
|
||||||
|
```json
|
||||||
|
["#F4F7F2", "#E8F1EC", "#EDF4F8"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 Home 场景的主题选择规则(Theme Picking)
|
||||||
|
|
||||||
|
- **Hard Rule**:若 `U.stage.unknown = 1` → **强制 Neutral Theme**
|
||||||
|
- 若 `U.need` 为空对象 `{}`(need 跳过/缺失)→ **使用 Neutral Theme**
|
||||||
|
- 否则:从 `U.need` 取出被选中的 need tag(稀疏 one-hot 的 key),映射到对应 Base Theme
|
||||||
|
|
||||||
|
### 7.3 Home 场景的颜色输出规则(Solid/Gradient)
|
||||||
|
|
||||||
|
Home 没有“长文案滚动阅读”的 scroll,因此需要将「连续渐变」规则做“等价映射”:
|
||||||
|
|
||||||
|
- **纯色输出(默认)**:使用所选主题的中间色(例如 `theme[1]`)作为背景色,保证稳定、可读、低感知。
|
||||||
|
- **轻量渐变输出(可选)**:使用主题的 `theme[0]` 与 `theme[2]` 作为 top/bottom,保持同主题内部变化;渐变 stops 仅允许线性分布,不允许 easing/bounce。
|
||||||
|
|
||||||
|
> 备注:是否启用渐变由实现阶段决定;即便启用,也必须遵循「同主题内部变化」与「连续」的约束。
|
||||||
|
|
||||||
|
### 7.4 情绪与置信度调节(强度而非色系)
|
||||||
|
|
||||||
|
复用既有规则精神:`emotion_score` 与 `profile_confidence` **只影响强度**,不得导致色系切换。
|
||||||
|
|
||||||
|
- `emotion_score ≤ 0.3`:禁止任何动态增强(保持最稳定的纯色/静态渐变)
|
||||||
|
- `emotion_score ∈ [0.3, 0.6]` 且 `profile_confidence ≥ 0.6`:允许极弱亮度微扰(\(\Delta L \le \pm 2\%\)),用于降低“模板感”
|
||||||
|
- `profile_confidence ≤ 0.4`:最大饱和度不超过 60%(若实现包含饱和度调节)
|
||||||
|
|
||||||
|
## 8. 稳定性与 Session 规则(Home 版本)
|
||||||
|
|
||||||
|
为避免“背景跳色”,随心主题必须具备 **Theme Lock**:
|
||||||
|
|
||||||
|
- **锁定时机**:用户进入 Home 且主题模式为「随心」
|
||||||
|
- **锁定内容**:锁定 Base Theme(或 Neutral Theme)选择结果;必要时也锁定最终输出颜色/渐变 stops
|
||||||
|
- **解锁时机**:
|
||||||
|
- 用户离开 Home(或 app 重启,按实现策略)
|
||||||
|
- 用户主动切换主题模式(从随心切换到风景/纯色,再切回时可重新计算)
|
||||||
|
- **Session 内禁止重新采样**:不得因为画像更新、拉取新文案、上下滑动切换文案而切换 Base Theme
|
||||||
|
|
||||||
|
## 9. 边界条件与兜底
|
||||||
|
|
||||||
|
- **用户未完成问卷 / 跳过全部题目**:画像中 `stage.unknown=1` 且 `need={}`,必须输出 Neutral Theme(稳定、安全)。
|
||||||
|
- **emotion_score 为 null**:视为不确定 → 禁止动态增强,输出稳定纯色/静态渐变。
|
||||||
|
- **非法/未知 need key**:按跳过处理 → Neutral Theme。
|
||||||
|
|
||||||
|
## 10. 验收标准(Acceptance Criteria)
|
||||||
|
|
||||||
|
- **入口一致**:Home 的主题切换入口不变位置;新增「随心」选项可选中并持久化。
|
||||||
|
- **多语言正确**:TC 与 EN 下,「随心」主题名称与描述文案正确展示(不出现缺失 key)。
|
||||||
|
- **规则一致**:
|
||||||
|
- `stage.unknown=1` 时必为 Neutral Theme
|
||||||
|
- `need` 缺失/跳过时必为 Neutral Theme
|
||||||
|
- 不允许跨 need 插值/切换
|
||||||
|
- **稳定性**:一次 Home session 内,不因切换文案/刷新/拉取推荐而改变随心主题色系(Theme Lock 生效)。
|
||||||
|
|
||||||
|
## 11. 依赖与关联模块
|
||||||
|
|
||||||
|
- **用户画像来源**:客户端 `User Profile Scoring`(问卷完成后生成 `U` 并写入本地存储)
|
||||||
|
- **颜色算法来源**:`设计说明文档/个性化背景颜色推荐算法.md`(规则与 Hard Rules)
|
||||||
|
- **UI 入口**:Home 顶部主题切换弹窗(与现有位置一致)
|
||||||
|
|
||||||
152
spec_kit/SuixinTheme/tasks.md
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
# 「随心」主题(Suixin Theme)任务清单(tasks)
|
||||||
|
|
||||||
|
> 说明:
|
||||||
|
>
|
||||||
|
> - 本清单基于 `spec_kit/SuixinTheme/plan.md` 拆分为可执行任务。
|
||||||
|
> - 执行过程中:完成一项就在对应条目打勾(`[x]`),并补充必要的实现备注/PR 链接(如有)。
|
||||||
|
> - **当本 tasks 全部完成后**,需要回到 `spec_kit/overview.md` 在 `SuixinTheme` 条目下标记“已完成编码(阶段性/全部)”。
|
||||||
|
|
||||||
|
## 0. 准备与基线确认
|
||||||
|
|
||||||
|
- [x] **T0.1 确认现有主题切换链路位置与文件**
|
||||||
|
- **涉及文件**:`client/components/home/ThemeModal.tsx`、`client/app/(app)/home.tsx`、`client/src/storage/appStorage.ts`
|
||||||
|
- **验收**:确认 `ThemeMode` 当前仅 `scenery/color`,并确认 `Home` 背景分支逻辑位置(方便插入 `suixin` 分支)
|
||||||
|
|
||||||
|
- [x] **T0.2 确认用户画像可在 Home 获取**
|
||||||
|
- **涉及文件**:`client/src/storage/appStorage.ts`、`client/src/features/userProfileScoring/*`
|
||||||
|
- **验收**:`getUserProfileScoring()` 在 Home 已可读到 `stage/need/emotion_score/profile_confidence/profile_answered`
|
||||||
|
|
||||||
|
## 1. 数据与存储层改造(ThemeMode + suixin state)
|
||||||
|
|
||||||
|
- [x] **T1.1 扩展 `ThemeMode` 枚举支持 `suixin`**
|
||||||
|
- **涉及文件**:`client/src/storage/appStorage.ts`(类型 + `getThemeMode/setThemeMode` 兼容)
|
||||||
|
- **要点**:
|
||||||
|
- 新类型:`'scenery' | 'color' | 'suixin'`
|
||||||
|
- `getThemeMode()` 读取到未知值时回退 `scenery`(保持兼容)
|
||||||
|
- **验收**:TypeScript 编译无类型报错;旧存储值仍可正常读取
|
||||||
|
|
||||||
|
- [x] **T1.2 新增本地存储:`ui.theme.suixin.state`**
|
||||||
|
- **涉及文件**:`client/src/storage/appStorage.ts`
|
||||||
|
- **新增内容**:
|
||||||
|
- `type SuixinThemeStateV1`
|
||||||
|
- `getSuixinThemeState()` / `setSuixinThemeState()`(建议)
|
||||||
|
- **验收**:能读写该 key;结构包含 `base_theme_id/seed/step_index/last_color`
|
||||||
|
|
||||||
|
## 2. 「随心」颜色算法模块(纯函数 + 可测试)
|
||||||
|
|
||||||
|
- [x] **T2.1 新增 `suixinTheme` 模块目录与色盘常量**
|
||||||
|
- **建议路径**:`client/src/features/suixinTheme/`
|
||||||
|
- **新增文件建议**:
|
||||||
|
- `palette.ts`:Base Theme(5)+ Neutral(1)常量
|
||||||
|
- `types.ts`:`BaseThemeId`、`SuixinThemeStateV1`(若不放在 storage)
|
||||||
|
- **验收**:色值与 `设计说明文档/个性化背景颜色推荐算法.md` 完全一致
|
||||||
|
|
||||||
|
- [x] **T2.2 实现 Base Theme 选择(Theme Picking)**
|
||||||
|
- **建议文件**:`client/src/features/suixinTheme/pickTheme.ts`
|
||||||
|
- **规则**(必须对齐文档 Hard Rules):
|
||||||
|
- `stage.unknown === 1` → `neutral`
|
||||||
|
- `need` 为空 `{}` → `neutral`
|
||||||
|
- 否则取 `Object.keys(need)[0]`,未知 key → `neutral`
|
||||||
|
- **验收**:不同画像输入下输出主题 id 符合预期
|
||||||
|
|
||||||
|
- [x] **T2.3 实现线性 `lerp`(仅线性,禁止 easing)**
|
||||||
|
- **建议文件**:`client/src/features/suixinTheme/colorMath.ts`
|
||||||
|
- **要求**:
|
||||||
|
- `hex ↔ rgb` 转换
|
||||||
|
- `lerpRgb(a,b,t)`:\(t\in[0,1]\) clamp
|
||||||
|
- 输出标准 `#RRGGBB`
|
||||||
|
- **验收**:插值边界 t=0/1 输出正确;中间值可复现、无跳段
|
||||||
|
|
||||||
|
- [x] **T2.4 实现 \(t\) 生成(切文案步进 + 往返波形)**
|
||||||
|
- **建议文件**:`client/src/features/suixinTheme/progress.ts`
|
||||||
|
- **要求**:
|
||||||
|
- `N=12` 可配置常量
|
||||||
|
- 采用往返波形,避免回卷突跳
|
||||||
|
- **验收**:连续 step_index 下 \(t\) 始终在 \([0,1]\),且相邻变化幅度稳定
|
||||||
|
|
||||||
|
- [x] **T2.5(可选增强)按 emotion/confidence 控制“动态增强开关”**
|
||||||
|
- **说明**:一期允许先不做亮度微扰,只实现“禁动态开关”
|
||||||
|
- **对齐点**:
|
||||||
|
- 文档:`emotion_score ≤ 0.2` 禁止任何扰动
|
||||||
|
- `emotion_score=null` 视为不确定,同样禁止扰动
|
||||||
|
- **验收**:低情绪/不确定时不触发增强分支(一期未实现亮度微扰,默认不启用任何扰动)
|
||||||
|
|
||||||
|
## 3. UI:ThemeModal 增加「随心」入口
|
||||||
|
|
||||||
|
- [x] **T3.1 ThemeModal 新增第三个主题卡片**
|
||||||
|
- **涉及文件**:`client/components/home/ThemeModal.tsx`
|
||||||
|
- **要点**:
|
||||||
|
- `ThemeMode` 类型同步为包含 `suixin`
|
||||||
|
- 新增 `ThemeCard`:`onPress={() => onSelect('suixin')}`
|
||||||
|
- 布局改为 3 卡可展示(`flexWrap`/调整 gap/尺寸),避免小屏溢出
|
||||||
|
- 预览图占位(可先复用 `theme_color.png` 或新增 `theme_suixin.png`)
|
||||||
|
- **验收**:弹窗可见第三项;选中态边框正确;无布局溢出
|
||||||
|
|
||||||
|
## 4. i18n:新增主题文案(TC/EN)
|
||||||
|
|
||||||
|
- [x] **T4.1 增加 `theme.suixin` 翻译键**
|
||||||
|
- **涉及文件**:`client/src/i18n/locales/all.json`
|
||||||
|
- **文案建议**:
|
||||||
|
- `zh-TW`: `隨心`
|
||||||
|
- `en`: `Ease`(语义化命名;如需改音译,后续可替换)
|
||||||
|
- **验收**:切换语言后 ThemeModal 的第三项标题正确显示,不出现缺失 key
|
||||||
|
|
||||||
|
## 5. Home:随心主题渲染 + 冷启动/切文案计算
|
||||||
|
|
||||||
|
- [x] **T5.1 Home 增加 `suixin` 分支并使用 `backgroundColor`**
|
||||||
|
- **涉及文件**:`client/app/(app)/home.tsx`
|
||||||
|
- **要点**:
|
||||||
|
- `themeMode === 'suixin'` 时,不渲染风景图
|
||||||
|
- 背景色来自 suixin 状态(`last_color`)或初始化计算结果
|
||||||
|
- **验收**:选择随心主题后背景变为算法输出色;切回风景/纯色逻辑不受影响
|
||||||
|
|
||||||
|
- [x] **T5.2 冷启动(进程级)计算并锁定 Base Theme**
|
||||||
|
- **涉及文件**:`client/app/_layout.tsx`(或新增全局单例模块)、`client/app/(app)/home.tsx`
|
||||||
|
- **要点**:
|
||||||
|
- 生成一次 `boot_id`(仅内存)用于判断“本次进程首次进入 Home”
|
||||||
|
- 首次进入 Home 且 theme=suixin:根据画像选 `base_theme_id` 并初始化 `seed/step_index/last_color`
|
||||||
|
- 写入 `ui.theme.suixin.state` 持久化
|
||||||
|
- **验收**:同一次运行内多次进入 Home 不重复“冷启动重算”;重启 App 后会重算一次
|
||||||
|
|
||||||
|
- [x] **T5.3 切换文案时更新 suixin 颜色(不跨主题)**
|
||||||
|
- **涉及文件**:`client/app/(app)/home.tsx`
|
||||||
|
- **要点**:
|
||||||
|
- 在 `triggerNextContent` 切换 index 后:若 theme=suixin,`step_index += 1` → 计算 \(t\) → `lerp` 得到新 `last_color`
|
||||||
|
- 持久化更新 state
|
||||||
|
- **验收**:上滑切下一条文案时背景色小幅变化;Base Theme 不变(同一色系内变化)
|
||||||
|
|
||||||
|
## 6. 收藏背景记录兼容
|
||||||
|
|
||||||
|
- [x] **T6.1 收藏逻辑兼容 suixin**
|
||||||
|
- **涉及文件**:`client/app/(app)/home.tsx`(`favItem.background` 写入)
|
||||||
|
- **规则**:
|
||||||
|
- `suixin` 与 `color` 一致:保存 `hex` 到 `background`
|
||||||
|
- **验收**:收藏后在 Favorites 列表缩略卡片可正确显示背景色
|
||||||
|
|
||||||
|
## 7. 测试与回归
|
||||||
|
|
||||||
|
- [x] **T7.1(推荐)为 suixin 模块补单测**
|
||||||
|
- **建议路径**:`client/src/features/suixinTheme/__tests__/`
|
||||||
|
- **覆盖点**:
|
||||||
|
- `stage.unknown=1` → neutral
|
||||||
|
- `need={}` → neutral
|
||||||
|
- `need={rest_balance:1}` → rest_balance
|
||||||
|
- 往返波形 \(t\) 的边界与范围
|
||||||
|
- `lerp` 边界与格式
|
||||||
|
- **验收**:测试通过,避免回归
|
||||||
|
|
||||||
|
- [x] **T7.2 手动验收(按 spec)**
|
||||||
|
- **验收清单**:
|
||||||
|
- ThemeModal:三主题可切换,选中态正确
|
||||||
|
- 随心:冷启动首次进入 Home 生效;切文案时同主题内变色
|
||||||
|
- Hard Rules:unknown / need 跳过 → Neutral
|
||||||
|
- 多语言:TC/EN 标题正确
|
||||||
|
- **备注**:已完成自动化回归(`vitest` + `tsc`);如需视觉确认可在模拟器/真机打开 ThemeModal 与 Home 做肉眼验收
|
||||||
|
|
||||||
|
## 8. 收尾:overview.md 标记
|
||||||
|
|
||||||
|
- [x] **T8.1 tasks 全部完成后更新 `spec_kit/overview.md`**
|
||||||
|
- **位置**:`## SuixinTheme` 条目
|
||||||
|
- **内容**:补充“已完成编码(全部)”与关键变更文件清单(可选)
|
||||||
|
- **验收**:overview 总览可读、可追踪
|
||||||
|
|
||||||
@@ -88,6 +88,7 @@
|
|||||||
- iOS 构建号已提升到 `2`,并将 `client/ios/client/Info.plist` 改为自动跟随 `MARKETING_VERSION` / `CURRENT_PROJECT_VERSION`
|
- iOS 构建号已提升到 `2`,并将 `client/ios/client/Info.plist` 改为自动跟随 `MARKETING_VERSION` / `CURRENT_PROJECT_VERSION`
|
||||||
- 推送 entitlements 的 `aps-environment` 已切到 `production`(用于 TestFlight/线上包)
|
- 推送 entitlements 的 `aps-environment` 已切到 `production`(用于 TestFlight/线上包)
|
||||||
- 清理未接入编译的 WidgetKit 骨架残留:移除磁盘上的 `client/ios/MindfulnessWidget/` 文件,并从 `client/ios/client.xcodeproj/project.pbxproj` 删除对应工程引用(避免 Xcode 显示幽灵文件)
|
- 清理未接入编译的 WidgetKit 骨架残留:移除磁盘上的 `client/ios/MindfulnessWidget/` 文件,并从 `client/ios/client.xcodeproj/project.pbxproj` 删除对应工程引用(避免 Xcode 显示幽灵文件)
|
||||||
|
- 修复 Xcode Archive 偶发显示 “Generic Xcode Archive”:在共享 scheme `Hey Mama` 的 Archive Post-actions 自动补齐 `.xcarchive/Info.plist` 的 `ApplicationProperties`,并在缺失时补齐 `Name`/`SchemeName` + 自检提示(根治 Organizer 无法识别主 App、无法分发/上传 TestFlight 的问题)
|
||||||
|
|
||||||
## Splash Consent
|
## Splash Consent
|
||||||
|
|
||||||
@@ -97,6 +98,8 @@
|
|||||||
- `spec_kit/Splash Consent/spec.md`
|
- `spec_kit/Splash Consent/spec.md`
|
||||||
- `spec_kit/Splash Consent/plan.md`
|
- `spec_kit/Splash Consent/plan.md`
|
||||||
- `spec_kit/Splash Consent/tasks.md`
|
- `spec_kit/Splash Consent/tasks.md`
|
||||||
|
- **近期变更**:
|
||||||
|
- 启动流程优化:将 Expo Router 初始路由调整为协议页 `/(splash)/splash`,并在协议页已同意时直接分发到 `/(app)/home` 或 `/(onboarding)/onboarding`,避免系统开屏结束后先渲染 `index`(转圈页)再跳协议页导致的“闪一下”
|
||||||
|
|
||||||
## Policy Links
|
## Policy Links
|
||||||
|
|
||||||
@@ -165,3 +168,30 @@
|
|||||||
- `spec_kit/User Profile Scoring/spec.md`
|
- `spec_kit/User Profile Scoring/spec.md`
|
||||||
- **已完成编码(阶段性)**:
|
- **已完成编码(阶段性)**:
|
||||||
- 客户端 Onboarding 完成时已收集问卷答案并生成用户画像,写入本地存储供推荐/Push/Widget 复用
|
- 客户端 Onboarding 完成时已收集问卷答案并生成用户画像,写入本地存储供推荐/Push/Widget 复用
|
||||||
|
|
||||||
|
## SuixinTheme
|
||||||
|
|
||||||
|
- **目标**:在 Home 现有「风景 / 纯色」主题基础上新增「随心」主题,背景颜色根据用户问卷画像个性化推荐
|
||||||
|
- **核心范围**:复用既有 Base Theme(5 套)+ Neutral(1 套),按 `need/stage/emotion/confidence` 选色并做 session 锁定(Theme Lock),支持 TC/EN 文案
|
||||||
|
- **阶段产物**:
|
||||||
|
- `spec_kit/SuixinTheme/spec.md`
|
||||||
|
- `spec_kit/SuixinTheme/plan.md`
|
||||||
|
- `spec_kit/SuixinTheme/tasks.md`
|
||||||
|
- **已完成编码(全部)**:
|
||||||
|
- 客户端新增第三主题 `suixin`(随心/Ease),与现有主题切换入口一致
|
||||||
|
- Home:冷启动会话内锁定 Base Theme;切换文案时在同主题内线性插值输出纯色背景,并持久化 `ui.theme.suixin.state`
|
||||||
|
- i18n:新增 `theme.suixin`(TC/EN)
|
||||||
|
- 测试:新增随心模块单测(Vitest)并通过;`tsc --noEmit` 通过
|
||||||
|
- **变更文件**:
|
||||||
|
- `client/src/storage/appStorage.ts`
|
||||||
|
- `client/app/(app)/home.tsx`
|
||||||
|
- `client/components/home/ThemeModal.tsx`
|
||||||
|
- `client/src/i18n/locales/all.json`
|
||||||
|
- `client/src/utils/bootSession.ts`
|
||||||
|
- `client/src/features/suixinTheme/palette.ts`
|
||||||
|
- `client/src/features/suixinTheme/colorMath.ts`
|
||||||
|
- `client/src/features/suixinTheme/progress.ts`
|
||||||
|
- `client/src/features/suixinTheme/pickTheme.ts`
|
||||||
|
- `client/src/features/suixinTheme/index.ts`
|
||||||
|
- `client/src/features/suixinTheme/__tests__/suixinTheme.test.ts`
|
||||||
|
- `client/app/_layout.tsx`
|
||||||
|
|||||||
184
设计说明文档/个性化背景颜色推荐算法.md
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
🎨 个性化文案背景颜色推荐算法
|
||||||
|
V1.2(文案滑动优化版)
|
||||||
|
|
||||||
|
适用范围(明确收敛)
|
||||||
|
|
||||||
|
✅ 仅用于「文案阅读页」
|
||||||
|
|
||||||
|
✅ 支持上下滑动阅读
|
||||||
|
|
||||||
|
❌ 不用于首页 / 列表 / 卡片 / CTA
|
||||||
|
|
||||||
|
❌ 不承担转化或引导职责
|
||||||
|
|
||||||
|
一、设计目标(V1.2 更新)
|
||||||
|
|
||||||
|
在 V1.1 基础上,新增以下目标:
|
||||||
|
|
||||||
|
阅读优先:颜色永远服务于“读下去”,而非“制造变化”
|
||||||
|
|
||||||
|
滑动即流动:通过连续渐变营造沉浸感,而非主题切换
|
||||||
|
|
||||||
|
低感知变化:用户能感到“舒服”,但不意识到颜色在变
|
||||||
|
|
||||||
|
核心原则不变:
|
||||||
|
不通过颜色制造新的情绪判断
|
||||||
|
|
||||||
|
二、颜色数量策略(V1.2 明确约束)
|
||||||
|
2.1 颜色主题数量(不变)
|
||||||
|
|
||||||
|
Need Theme:5 套(不新增)
|
||||||
|
|
||||||
|
Neutral Theme:1 套
|
||||||
|
|
||||||
|
总计:6 套背景主题
|
||||||
|
|
||||||
|
❗️V1.2 明确约束:
|
||||||
|
在一次文案阅读 session 内,不允许切换主题色系
|
||||||
|
|
||||||
|
三、Base Theme(保持不变,仅重申)
|
||||||
|
{
|
||||||
|
"emotional_support": ["#F6DCE4", "#FFEFF4", "#FFF7FA"],
|
||||||
|
"parenting_pressure": ["#D6EAF5", "#EEF6FB", "#F8FCFF"],
|
||||||
|
"self_worth": ["#FFD8A8", "#FFE8C9", "#FFF6E5"],
|
||||||
|
"anxiety_relief": ["#DFF3EA", "#ECFBF6", "#F6FFFB"],
|
||||||
|
"rest_balance": ["#F2E6D8", "#FAF3EC", "#FFFDF9"]
|
||||||
|
}
|
||||||
|
|
||||||
|
Neutral Theme:
|
||||||
|
["#F4F7F2", "#E8F1EC", "#EDF4F8"]
|
||||||
|
|
||||||
|
四、V1.2 新增:滑动专用渐变规则(重点修改)
|
||||||
|
4.1 滑动只允许「同主题内部变化」
|
||||||
|
|
||||||
|
禁止行为(V1.2 明令禁止):
|
||||||
|
|
||||||
|
❌ 滑动到不同文案 → 切换 Base Theme
|
||||||
|
|
||||||
|
❌ 根据滑动进度改变 need / 情绪语义
|
||||||
|
|
||||||
|
❌ 滑动触发颜色“跳段”
|
||||||
|
|
||||||
|
允许行为:
|
||||||
|
|
||||||
|
✅ 同一 Base Theme 内做连续插值
|
||||||
|
|
||||||
|
✅ 渐变重心随 scroll 微移
|
||||||
|
|
||||||
|
4.2 渐变插值模型(保持线性)
|
||||||
|
Color(t) = lerp(Color_top, Color_bottom, t)
|
||||||
|
t = scroll_offset / content_height
|
||||||
|
t ∈ [0,1]
|
||||||
|
|
||||||
|
|
||||||
|
约束(Hard Rule):
|
||||||
|
|
||||||
|
仅允许 线性 lerp
|
||||||
|
|
||||||
|
禁止 easing / bounce / overshoot
|
||||||
|
|
||||||
|
禁止非连续函数
|
||||||
|
|
||||||
|
五、V1.2 新增:渐变“质感层”增强(不增加颜色数量)
|
||||||
|
|
||||||
|
⚠️ 本节为「可选增强」,不影响语义判断
|
||||||
|
|
||||||
|
5.1 渐变重心微移(推荐)
|
||||||
|
|
||||||
|
随 scroll,渐变中段色的占比 ±5% 内浮动
|
||||||
|
|
||||||
|
不改变颜色值,仅改变 stop 分布
|
||||||
|
|
||||||
|
目的:
|
||||||
|
|
||||||
|
提升阅读流动感
|
||||||
|
|
||||||
|
避免“静态模板感”
|
||||||
|
|
||||||
|
5.2 亮度微扰(极弱)
|
||||||
|
ΔL ≤ ±2%
|
||||||
|
|
||||||
|
|
||||||
|
触发条件:
|
||||||
|
|
||||||
|
emotion_score ∈ [0.3, 0.6]
|
||||||
|
|
||||||
|
profile_confidence ≥ 0.6
|
||||||
|
|
||||||
|
⚠️ emotion ≤ 0.3 时 禁止任何亮度扰动
|
||||||
|
|
||||||
|
六、Session 稳定性规则(V1.2 新增)
|
||||||
|
6.1 主题锁定(Theme Lock)
|
||||||
|
|
||||||
|
在以下条件下 锁定 Base Theme:
|
||||||
|
|
||||||
|
用户进入文案页
|
||||||
|
|
||||||
|
直到退出文案页或 session 结束
|
||||||
|
|
||||||
|
即使:
|
||||||
|
|
||||||
|
用户画像更新
|
||||||
|
|
||||||
|
滑动到新文案
|
||||||
|
|
||||||
|
➡ Base Theme 不变
|
||||||
|
|
||||||
|
6.2 Session 内禁止重新采样
|
||||||
|
|
||||||
|
不允许重新随机
|
||||||
|
|
||||||
|
不允许重新计算 need
|
||||||
|
|
||||||
|
不允许跨 need 插值
|
||||||
|
|
||||||
|
七、Emotion / Confidence 调节(保持 V1.1)
|
||||||
|
|
||||||
|
本节逻辑不变,仅声明适用范围
|
||||||
|
|
||||||
|
emotion_score
|
||||||
|
→ 仅影响 饱和度 / 亮度 / 动态强度
|
||||||
|
|
||||||
|
profile_confidence
|
||||||
|
→ 仅影响 个性化混合比例
|
||||||
|
|
||||||
|
不允许:
|
||||||
|
|
||||||
|
emotion 导致色系改变
|
||||||
|
|
||||||
|
confidence 导致主题切换
|
||||||
|
|
||||||
|
八、Hard Visual Rules(V1.2 汇总)
|
||||||
|
条件 强制规则
|
||||||
|
mom_stage = unknown 强制 Neutral Theme
|
||||||
|
emotion_score ≤ 0.2 禁止高饱和 / 禁止动态
|
||||||
|
profile_confidence ≤ 0.4 最大饱和度 ≤ 60%
|
||||||
|
文案页 session 中 禁止 Base Theme 切换
|
||||||
|
|
||||||
|
Hard Rules 优先于任何计算结果。
|
||||||
|
|
||||||
|
九、V1.2 Pipeline(更新版)
|
||||||
|
进入文案页
|
||||||
|
↓
|
||||||
|
读取用户画像 U
|
||||||
|
↓
|
||||||
|
need → Base Theme(或 Neutral)
|
||||||
|
↓
|
||||||
|
锁定 Base Theme(session)
|
||||||
|
↓
|
||||||
|
emotion / confidence → 强度调节
|
||||||
|
↓
|
||||||
|
scroll → 线性渐变插值 + 微质感
|
||||||
|
↓
|
||||||
|
输出连续背景颜色
|
||||||
|
|
||||||
|
十、V1.2 设计总结(评审友好版)
|
||||||
|
|
||||||
|
颜色数量不多,是刻意选择
|
||||||
|
|
||||||
|
变化来自滑动,不来自判断
|
||||||
|
|
||||||
|
颜色不“解释”用户,只“陪伴”用户
|
||||||
|
|
||||||
|
在文案阅读场景中,
|
||||||
|
稳定本身就是高级体验。
|
||||||