11 Commits

Author SHA1 Message Date
雷汀岚
ce018880f4 fix(i18n): 繁中開屏 consent 文案不生效 - 寫死繁中文案、清理腳本、文件
- splash: 繁中 consent 使用元件內 ZH_TW_CONSENT,避免 bundle 快取
- splash: 使用 isTraditionalChineseLocaleTag 判斷繁中
- i18n: 註解與 __DEV__ debug log
- package: clean:cache, start:clean, ios:clean, clean:ios-build
- ALL_COPY: 故障排除與 consent 只改 zh-TW.json 說明
- spec_kit: Splash Consent overflow 記錄排查結論

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 23:05:17 +08:00
雷汀岚
b4ec17fcac onboarding: 转场动画、名字步取消自动跳页、标题个性化招呼语、Skip/提醒步等文案与交互优化
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 17:33:53 +08:00
雷汀岚
aa4e1e9947 onboarding: 选项字体与问题一致、底部间距、提醒页文案与移除底部 Skip
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 17:11:13 +08:00
66241e5231 Merge pull request 'damer' (#17) from damer into main
Reviewed-on: #17
2026-02-09 06:49:41 +00:00
吕新雨
e980bd4e4d fix:更新容器启动 2026-02-09 14:47:17 +08:00
吕新雨
0b8bbebf6a fix:增加定时推动 2026-02-09 11:52:11 +08:00
吕新雨
1e1e49ea57 fix:同意隐私 2026-02-05 16:33:58 +08:00
吕新雨
8e71503169 fix:修复 2026-02-05 16:06:49 +08:00
吕新雨
2b67a571bb f 2026-02-05 02:02:14 +08:00
吕新雨
8f84f25616 IOS小组件/文案 2026-02-05 01:49:29 +08:00
吕新雨
4c03fce720 APP-PUSH和纯色小组件 2026-02-05 01:14:13 +08:00
69 changed files with 4229 additions and 828 deletions

View File

@@ -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 项目 IDUUID。用于真机获取 Expo Push Tokenexpo-notifications
# 获取方式:在 client 目录执行 `eas project:init` 或 `eas project:info` 查看。
EXPO_PUBLIC_EAS_PROJECT_ID=c519f016-e5c8-426c-868f-5545dce8beef

1
client/.npmrc Normal file
View File

@@ -0,0 +1 @@
registry=https://registry.npmmirror.com

30
client/app.config.ts Normal file
View File

@@ -0,0 +1,30 @@
import type { ConfigContext, ExpoConfig } from 'expo/config';
/**
* 运行时获取 Push Tokenexpo-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,
},
},
};
};

View File

@@ -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"
} }
} }

View File

@@ -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 (

View File

@@ -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) {
@@ -166,18 +175,17 @@ export default function OnboardingScreen() {
} }
}; };
const onSkip = async () => { /** 只跳過當前這一步(不填/不選當前題,進入下一步) */
// 跳过整个 Onboarding仍生成一个“全跳过”的最小画像保证下游可用 const handleSkipCurrentStep = () => {
const scoringProfile = buildUserProfileFromQuestionnaire({}); if (currentStep.type === 'name') {
await setUserProfileScoring(scoringProfile); onNext();
} else if (currentStep.type === 'selection') {
// 同步到 App Group供 iOS Widget 使用(失败不阻塞) setSelections((prev) => ({ ...prev, [currentStep.id]: [] }));
await syncWidgetConfig(); onNext();
await syncWidgetUserProfileFromScoring(scoringProfile); } else if (currentStep.type === 'reminder') {
setReminderTimes(0);
// 标记已完成,避免下次启动再次进入 Onboarding onFinish();
await setOnboardingCompleted(true); }
router.replace('/(app)/home');
}; };
// 题目为多选:点击切换选中状态 // 题目为多选:点击切换选中状态
@@ -201,9 +209,10 @@ export default function OnboardingScreen() {
title={currentTitle} title={currentTitle}
currentStep={stepIndex} currentStep={stepIndex}
totalSteps={STEPS.length - 1} totalSteps={STEPS.length - 1}
onSkip={onSkip} onSkip={handleSkipCurrentStep}
onBack={onBack} onBack={onBack}
showBackButton={stepIndex > 0} showBackButton={stepIndex > 0}
userName={name}
> >
{currentStep.type === 'name' && ( {currentStep.type === 'name' && (
<NameInputStep <NameInputStep
@@ -224,7 +233,7 @@ export default function OnboardingScreen() {
{currentStep.type === 'reminder' && ( {currentStep.type === 'reminder' && (
<ReminderStep <ReminderStep
value={reminderTimes} value={Math.max(1, reminderTimes)}
onChange={setReminderTimes} onChange={setReminderTimes}
onFinish={onFinish} onFinish={onFinish}
onSkip={() => { onSkip={() => {

View File

@@ -1,11 +1,14 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Dimensions, Platform, Alert, Image } from 'react-native'; import { View, Text, StyleSheet, TouchableOpacity, Dimensions, Platform, Alert, Image } from 'react-native';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import * as WebBrowser from 'expo-web-browser'; import * as WebBrowser from 'expo-web-browser';
import { useTranslation } from 'react-i18next'; import { Trans, useTranslation } from 'react-i18next';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { setConsentAccepted, getConsentAccepted } from '../../src/storage/appStorage'; import { setConsentAccepted, getConsentAccepted } from '../../src/storage/appStorage';
import { fetchLegalLinks } from '@/src/services/legalApi'; import { fetchLegalLinks } from '@/src/services/legalApi';
import { getOnboardingCompleted } from '@/src/storage/appStorage';
import { API_BASE_URL } from '@/src/constants/env';
import { isTraditionalChineseLocaleTag } from '@/src/i18n/locale';
// 导入 SVG 组件 // 导入 SVG 组件
import FlowersBg from '../../assets/images/index/flowers_endbg.svg'; import FlowersBg from '../../assets/images/index/flowers_endbg.svg';
@@ -13,21 +16,55 @@ import WelcomeBtn from '../../assets/images/index/welcome_btn.svg';
const { width, height } = Dimensions.get('window'); const { width, height } = Dimensions.get('window');
// 繁中開屏 consent 文案:寫死在元件內,避免 Metro/iOS bundle 快取導致永遠顯示舊文案。
// 若需修改,請改這裡並同步 client/src/i18n/locales/zh-TW.json 的 consent 區塊。
const ZH_TW_CONSENT = {
title: '我們知道,',
subtitle: '當媽媽很不容易。',
subtitleSecondary: '這裡給你一些溫柔的肯定與提醒',
};
export default function SplashScreen() { export default function SplashScreen() {
const router = useRouter(); const router = useRouter();
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const [showConsent, setShowConsent] = useState(false); const [showConsent, setShowConsent] = useState(false);
// 繁中時強制使用上方常數(含 zh-TW / zh-Hant / zh-Hant-TW其餘用 i18n
const isZhTW = isTraditionalChineseLocaleTag(i18n.language || '');
const title = isZhTW ? ZH_TW_CONSENT.title : t('consent.title');
const subtitle = isZhTW ? ZH_TW_CONSENT.subtitle : t('consent.subtitle');
const subtitleSecondary = isZhTW ? ZH_TW_CONSENT.subtitleSecondary : t('consent.subtitleSecondary');
useEffect(() => {
if (typeof __DEV__ !== 'undefined' && __DEV__ && showConsent) {
console.log('[i18n consent] language=', i18n.language, 'title=', title, 'subtitle=', subtitle);
}
}, [showConsent, i18n.language, title, subtitle]);
const [links, setLinks] = useState<{ privacy?: string; terms?: string }>({}); const [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 +83,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;
@@ -85,37 +147,58 @@ export default function SplashScreen() {
/> />
</View> </View>
{/* 文案内容 */} {/* 文案内容:主標題兩行 + 可選二級標題(字號更小、顏色更淺);繁中為元件內常數,其餘用 i18n */}
<View style={[styles.contentContainer, { position: 'absolute', top: contentTop }]}> <View style={[styles.contentContainer, { position: 'absolute', top: contentTop }]}>
<Text style={styles.titleText}> <Text style={styles.titleText}>
You Are Perfect.{"\n"} {title}
Everything{"\n"} {'\n'}
Will Be Better. {subtitle}
</Text> </Text>
{subtitleSecondary ? (
<Text style={styles.consentSubtitleSecondary}>{subtitleSecondary}</Text>
) : null}
</View> </View>
<SafeAreaView style={styles.bottomContainer} edges={['bottom']}> <SafeAreaView style={styles.bottomContainer} edges={['bottom']}>
{showConsent && ( {showConsent && (
<> <>
<TouchableOpacity onPress={handleAgree} activeOpacity={0.8} style={styles.buttonWrapper}> <TouchableOpacity
onPress={handleAgree}
activeOpacity={0.8}
style={styles.buttonWrapper}
accessibilityRole="button"
accessibilityLabel={t('consent.agree')}
>
<WelcomeBtn width={87} height={57} /> <WelcomeBtn width={87} height={57} />
</TouchableOpacity> </TouchableOpacity>
<View style={styles.linksContainer}> <Text style={styles.noticeText}>
<TouchableOpacity <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>
@@ -154,6 +237,14 @@ const styles = StyleSheet.create({
fontWeight: '600', fontWeight: '600',
fontFamily: Platform.OS === 'ios' ? 'STIX Two Text' : 'serif', fontFamily: Platform.OS === 'ios' ? 'STIX Two Text' : 'serif',
}, },
consentSubtitleSecondary: {
marginTop: 12,
fontSize: 16,
lineHeight: 22,
color: 'rgba(119, 47, 0, 0.6)',
textAlign: 'center',
fontFamily: Platform.OS === 'ios' ? 'STIX Two Text' : 'serif',
},
bottomContainer: { bottomContainer: {
position: 'absolute', position: 'absolute',
bottom: 60, bottom: 60,
@@ -164,19 +255,22 @@ const styles = StyleSheet.create({
buttonWrapper: { buttonWrapper: {
marginBottom: 40, marginBottom: 40,
}, },
linksContainer: { noticeText: {
flexDirection: 'row', marginTop: 10,
alignItems: 'center', paddingHorizontal: 28,
},
linkText: {
fontSize: 12, fontSize: 12,
color: 'rgba(119, 47, 0, 0.5)', // 使用半透明的文字颜色 lineHeight: 16,
textDecorationLine: 'underline', textAlign: 'center',
color: 'rgba(119, 47, 0, 0.45)',
}, },
divider: { noticeLinkText: {
width: 1, fontSize: 12,
height: 12, // 颜色区分:协议链接更醒目
backgroundColor: 'rgba(119, 47, 0, 0.2)', color: 'rgba(119, 47, 0, 0.75)',
marginHorizontal: 15, textDecorationLine: 'underline',
fontWeight: '600',
},
noticeLinkTextDisabled: {
opacity: 0.55,
}, },
}); });

View File

@@ -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%',
},
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

View File

@@ -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);

View File

@@ -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',

View File

@@ -1,8 +1,7 @@
import React from 'react'; import React from 'react';
import { View, StyleSheet, TouchableOpacity } from 'react-native'; import { View, StyleSheet, TouchableOpacity, Text } from 'react-native';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { SerifText } from './SerifText'; import { OnboardingColors, OnboardingFont } from '@/constants/OnboardingTheme';
import { OnboardingColors } from '@/constants/OnboardingTheme';
export const INTENTS = [ export const INTENTS = [
{ id: 'love', labelKey: 'intent.love', icon: '❤️' }, { id: 'love', labelKey: 'intent.love', icon: '❤️' },
@@ -20,7 +19,7 @@ export function IntentSelectionStep({ selectedIds, onToggle }: IntentSelectionSt
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<View style={styles.container}> <View style={styles.container}>
<SerifText style={styles.title}>{t('intent.title')}</SerifText> <Text style={styles.title}>{t('intent.title')}</Text>
<View style={styles.grid}> <View style={styles.grid}>
{INTENTS.map((intent) => { {INTENTS.map((intent) => {
@@ -35,10 +34,10 @@ export function IntentSelectionStep({ selectedIds, onToggle }: IntentSelectionSt
onPress={() => onToggle(intent.id)} onPress={() => onToggle(intent.id)}
activeOpacity={0.7} activeOpacity={0.7}
> >
<SerifText style={styles.icon}>{intent.icon}</SerifText> <Text style={styles.icon}>{intent.icon}</Text>
<SerifText style={[styles.label, isSelected && styles.labelSelected]}> <Text style={[styles.label, isSelected && styles.labelSelected]}>
{t(intent.labelKey)} {t(intent.labelKey)}
</SerifText> </Text>
</TouchableOpacity> </TouchableOpacity>
); );
})} })}
@@ -56,6 +55,8 @@ const styles = StyleSheet.create({
fontSize: 24, fontSize: 24,
marginBottom: 40, marginBottom: 40,
textAlign: 'center', textAlign: 'center',
fontFamily: OnboardingFont.question,
color: OnboardingColors.textPrimary,
}, },
grid: { grid: {
flexDirection: 'row', flexDirection: 'row',
@@ -86,10 +87,12 @@ const styles = StyleSheet.create({
}, },
icon: { icon: {
fontSize: 32, fontSize: 32,
fontFamily: OnboardingFont.question,
}, },
label: { label: {
fontSize: 18, fontSize: 18,
color: OnboardingColors.textPrimary, color: OnboardingColors.textPrimary,
fontFamily: OnboardingFont.question,
}, },
labelSelected: { labelSelected: {
fontWeight: 'bold', fontWeight: 'bold',

View File

@@ -1,12 +1,12 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react';
import { View, StyleSheet, TextInput, Platform, Animated, TouchableOpacity, Dimensions, Text } from 'react-native'; import { View, StyleSheet, TextInput, Platform, Animated, TouchableOpacity, Text, Keyboard, Pressable } from 'react-native';
import { useTranslation } from 'react-i18next';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { OnboardingColors } from '@/constants/OnboardingTheme'; import { OnboardingColors } from '@/constants/OnboardingTheme';
import BtnNotClicked from '@/assets/images/icon/btn_Notclicked.svg'; import BtnNotClicked from '@/assets/images/icon/btn_Notclicked.svg';
import BtnClicked from '@/assets/images/icon/btn_clicked.svg'; import BtnClicked from '@/assets/images/icon/btn_clicked.svg';
import EnterLightIcon from '@/assets/images/icon/enter_Light_icon.svg'; import EnterLightIcon from '@/assets/images/icon/enter_Light_icon.svg';
const { height } = Dimensions.get('window');
interface NameInputStepProps { interface NameInputStepProps {
value: string; value: string;
onChangeText: (text: string) => void; onChangeText: (text: string) => void;
@@ -14,10 +14,30 @@ interface NameInputStepProps {
} }
export function NameInputStep({ value, onChangeText, onNext }: NameInputStepProps) { export function NameInputStep({ value, onChangeText, onNext }: NameInputStepProps) {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const [isFocused, setIsFocused] = useState(false); const [isFocused, setIsFocused] = useState(false);
const [keyboardHeight, setKeyboardHeight] = useState(0);
const blinkAnim = useRef(new Animated.Value(1)).current; const blinkAnim = useRef(new Animated.Value(1)).current;
const hasInput = value.trim().length > 0; const hasInput = value.trim().length > 0;
useEffect(() => {
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
const subShow = Keyboard.addListener(showEvent, (e) => {
setKeyboardHeight(e.endCoordinates?.height ?? 0);
});
const subHide = Keyboard.addListener(hideEvent, () => {
setKeyboardHeight(0);
});
return () => {
subShow.remove();
subHide.remove();
};
}, []);
useEffect(() => { useEffect(() => {
const animation = Animated.loop( const animation = Animated.loop(
Animated.sequence([ Animated.sequence([
@@ -34,8 +54,14 @@ export function NameInputStep({ value, onChangeText, onNext }: NameInputStepProp
return () => animation.stop(); return () => animation.stop();
}, [blinkAnim, isFocused]); }, [blinkAnim, isFocused]);
const footerBottom = useMemo(() => {
// iOS 的 keyboard height 通常已包含底部安全区,避免重复叠加
const keyboardOffset = Math.max(0, keyboardHeight - insets.bottom);
return 16 + insets.bottom + keyboardOffset;
}, [insets.bottom, keyboardHeight]);
return ( return (
<View style={styles.container}> <Pressable style={styles.container} onPress={Keyboard.dismiss} accessible={false}>
<View style={styles.inputCard}> <View style={styles.inputCard}>
<View style={styles.inputWrapper}> <View style={styles.inputWrapper}>
{/* 显示层:文案 + 跟随的光标 */} {/* 显示层:文案 + 跟随的光标 */}
@@ -46,7 +72,7 @@ export function NameInputStep({ value, onChangeText, onNext }: NameInputStepProp
(!isFocused && !hasInput) && { color: OnboardingColors.textSecondary } (!isFocused && !hasInput) && { color: OnboardingColors.textSecondary }
]} ]}
> >
{hasInput ? value : (isFocused ? "" : "Mama")} {hasInput ? value : isFocused ? '' : t('onboardingSurvey.steps.name.placeholder')}
</Text> </Text>
{isFocused && ( {isFocused && (
<Animated.View style={[styles.cursorWrapper, { opacity: blinkAnim, marginLeft: 2 }]}> <Animated.View style={[styles.cursorWrapper, { opacity: blinkAnim, marginLeft: 2 }]}>
@@ -65,20 +91,29 @@ export function NameInputStep({ value, onChangeText, onNext }: NameInputStepProp
caretHidden={true} caretHidden={true}
autoCorrect={false} autoCorrect={false}
spellCheck={false} spellCheck={false}
returnKeyType="done"
blurOnSubmit={true}
onSubmitEditing={() => {
Keyboard.dismiss();
// 不再自動跳頁,僅收起鍵盤;前進需點擊底部 ➡️
}}
/> />
</View> </View>
</View> </View>
<View style={styles.footer}> <View style={[styles.footer, { bottom: footerBottom }]}>
<TouchableOpacity <TouchableOpacity
onPress={onNext} onPress={() => {
Keyboard.dismiss();
onNext();
}}
disabled={!hasInput} disabled={!hasInput}
activeOpacity={0.8} activeOpacity={0.8}
> >
{hasInput ? <BtnClicked width={87} height={57} /> : <BtnNotClicked width={87} height={57} />} {hasInput ? <BtnClicked width={87} height={57} /> : <BtnNotClicked width={87} height={57} />}
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </Pressable>
); );
} }
@@ -130,7 +165,6 @@ const styles = StyleSheet.create({
}, },
footer: { footer: {
position: 'absolute', position: 'absolute',
bottom: height * 0.12,
alignItems: 'center', alignItems: 'center',
} }
}); });

View File

@@ -1,6 +1,10 @@
import React from 'react'; import React, { useRef, useEffect } from 'react';
import { View, StyleSheet, SafeAreaView, TouchableOpacity, StatusBar, Text, Image, Platform } from 'react-native'; import { View, StyleSheet, SafeAreaView, TouchableOpacity, StatusBar, Text, Image, Platform, Animated, Easing } from 'react-native';
import { OnboardingColors } from '@/constants/OnboardingTheme'; import { useTranslation } from 'react-i18next';
import { OnboardingColors, OnboardingFont } from '@/constants/OnboardingTheme';
const TRANSITION_OFFSET = 24;
const TRANSITION_DURATION = 280;
interface OnboardingLayoutProps { interface OnboardingLayoutProps {
children: React.ReactNode; children: React.ReactNode;
@@ -10,6 +14,8 @@ interface OnboardingLayoutProps {
onSkip: () => void; onSkip: () => void;
onBack?: () => void; onBack?: () => void;
showBackButton?: boolean; showBackButton?: boolean;
/** 用户名字仅在名字步骤之后的第一个问题currentStep === 1且非空时显示招呼语 */
userName?: string;
} }
export function OnboardingLayout({ export function OnboardingLayout({
@@ -19,8 +25,48 @@ export function OnboardingLayout({
totalSteps, totalSteps,
onSkip, onSkip,
onBack, onBack,
showBackButton = false showBackButton = false,
userName = '',
}: OnboardingLayoutProps) { }: OnboardingLayoutProps) {
const { t } = useTranslation();
const showGreeting = currentStep === 1 && userName.trim().length > 0;
const displayName = userName.trim();
const prevStepRef = useRef(currentStep);
const isFirstRenderRef = useRef(true);
const translateX = useRef(new Animated.Value(0)).current;
const opacity = useRef(new Animated.Value(1)).current;
useEffect(() => {
if (isFirstRenderRef.current) {
isFirstRenderRef.current = false;
prevStepRef.current = currentStep;
return;
}
if (prevStepRef.current === currentStep) return;
const direction = currentStep > prevStepRef.current ? 'forward' : 'back';
prevStepRef.current = currentStep;
const startX = direction === 'forward' ? TRANSITION_OFFSET : -TRANSITION_OFFSET;
translateX.setValue(startX);
opacity.setValue(0.72);
Animated.parallel([
Animated.timing(translateX, {
toValue: 0,
duration: TRANSITION_DURATION,
useNativeDriver: true,
easing: Easing.out(Easing.cubic),
}),
Animated.timing(opacity, {
toValue: 1,
duration: TRANSITION_DURATION,
useNativeDriver: true,
easing: Easing.out(Easing.cubic),
}),
]).start();
}, [currentStep, translateX, opacity]);
return ( return (
<View style={styles.container}> <View style={styles.container}>
<StatusBar barStyle="dark-content" /> <StatusBar barStyle="dark-content" />
@@ -39,7 +85,7 @@ export function OnboardingLayout({
</View> </View>
<TouchableOpacity onPress={onSkip} style={styles.skipButton}> <TouchableOpacity onPress={onSkip} style={styles.skipButton}>
<Text style={styles.skipText}>skip</Text> <Text style={styles.skipText}>{t('onboarding.skipAll')}</Text>
<Image <Image
source={require('@/assets/images/icon/skip_icon.png')} source={require('@/assets/images/icon/skip_icon.png')}
style={styles.skipIcon} style={styles.skipIcon}
@@ -47,16 +93,29 @@ export function OnboardingLayout({
</TouchableOpacity> </TouchableOpacity>
</View> </View>
{/* Title & Progress Row */} {/* Title & Progress Row(名字步骤后第一步且名字非空时显示招呼语 + 问题) */}
<View style={styles.titleRow}> <View style={styles.titleRow}>
<View style={styles.titleBlock}>
{showGreeting && (
<Text style={styles.greetingText}>{t('onboardingSurvey.greeting', { name: displayName })}</Text>
)}
<Text style={styles.questionTitle}>{title}</Text> <Text style={styles.questionTitle}>{title}</Text>
</View>
<Text style={styles.progressText}>({currentStep}/{totalSteps})</Text> <Text style={styles.progressText}>({currentStep}/{totalSteps})</Text>
</View> </View>
{/* Content */} {/* Contentstep 切换时滑动 + 淡入 */}
<View style={styles.content}> <Animated.View
style={[
styles.content,
{
opacity,
transform: [{ translateX }],
},
]}
>
{children} {children}
</View> </Animated.View>
</SafeAreaView> </SafeAreaView>
</View> </View>
); );
@@ -112,18 +171,27 @@ const styles = StyleSheet.create({
alignItems: 'flex-end', alignItems: 'flex-end',
paddingHorizontal: 20, paddingHorizontal: 20,
marginTop: 20, marginTop: 20,
marginBottom: 20, marginBottom: 8,
},
titleBlock: {
flex: 1,
justifyContent: 'flex-end',
},
greetingText: {
fontSize: 22,
color: OnboardingColors.questionTitle,
fontFamily: OnboardingFont.question,
marginBottom: 4,
}, },
questionTitle: { questionTitle: {
fontSize: 22, fontSize: 22,
color: OnboardingColors.questionTitle, color: OnboardingColors.questionTitle,
fontFamily: Platform.OS === 'ios' ? 'PingFang TC' : 'sans-serif', fontFamily: OnboardingFont.question,
flex: 1,
}, },
progressText: { progressText: {
fontSize: 18, fontSize: 18,
color: OnboardingColors.textProgress, color: OnboardingColors.textProgress,
fontFamily: Platform.OS === 'ios' ? 'PingFang TC' : 'sans-serif', fontFamily: OnboardingFont.question,
marginLeft: 10, marginLeft: 10,
}, },
content: { content: {

View File

@@ -1,26 +1,26 @@
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;
onFinish: () => void; onFinish: () => void;
onSkip: () => void; onSkip?: () => void;
} }
export function ReminderStep({ value, onChange, onFinish, onSkip }: ReminderStepProps) { export function ReminderStep({ value, onChange, onFinish }: ReminderStepProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const insets = useSafeAreaInsets();
const handleReduce = () => { const handleReduce = () => {
// 允许 050 表示关闭每日提醒 // 本页最小为 1不接收提醒请使用右上角 Skip
if (value > 0) onChange(value - 1); if (value > 1) onChange(value - 1);
}; };
const handleAdd = () => { const handleAdd = () => {
@@ -36,7 +36,9 @@ export function ReminderStep({ value, onChange, onFinish, onSkip }: ReminderStep
<View style={styles.numberWrapper}> <View style={styles.numberWrapper}>
<Text style={styles.numberText}>{value}</Text> <Text style={styles.numberText}>{value}</Text>
<Text style={styles.unitText}>{t('dailyReminder.timesUnit')}</Text> <Text style={styles.unitText}>
{value === 1 ? t('dailyReminder.timesUnitSingular') : t('dailyReminder.timesUnit')}
</Text>
</View> </View>
<TouchableOpacity onPress={handleAdd} activeOpacity={0.7}> <TouchableOpacity onPress={handleAdd} activeOpacity={0.7}>
@@ -44,14 +46,10 @@ 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>
<TouchableOpacity onPress={onSkip} activeOpacity={0.8} style={styles.skipBtn}>
<Text style={styles.skipText}>{t('onboarding.skip')}</Text>
</TouchableOpacity>
</View> </View>
</View> </View>
); );
@@ -92,19 +90,6 @@ const styles = StyleSheet.create({
}, },
footer: { footer: {
position: 'absolute', position: 'absolute',
bottom: height * 0.12,
alignItems: 'center', alignItems: 'center',
}
,
skipBtn: {
marginTop: 14,
paddingVertical: 10,
paddingHorizontal: 18,
},
skipText: {
color: OnboardingColors.textPrimary,
fontSize: 15,
fontWeight: '600',
opacity: 0.85,
}, },
}); });

View File

@@ -1,13 +1,10 @@
import React from 'react'; import React from 'react';
import { View, StyleSheet, TouchableOpacity, ScrollView, Dimensions } from 'react-native'; import { View, StyleSheet, TouchableOpacity, ScrollView, Text } from 'react-native';
import { OnboardingColors } from '@/constants/OnboardingTheme'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { SerifText } from './SerifText'; import { OnboardingColors, OnboardingFont } from '@/constants/OnboardingTheme';
import SelectedIcon from '@/assets/images/icon/selected_icon.svg';
import BtnNotClicked from '@/assets/images/icon/btn_Notclicked.svg'; import BtnNotClicked from '@/assets/images/icon/btn_Notclicked.svg';
import BtnClicked from '@/assets/images/icon/btn_clicked.svg'; import BtnClicked from '@/assets/images/icon/btn_clicked.svg';
const { height } = Dimensions.get('window');
interface Option { interface Option {
id: string; id: string;
label: string; label: string;
@@ -23,32 +20,36 @@ interface SelectionStepProps {
export function SelectionStep({ options, selectedIds, onToggle, onNext, onSkip }: SelectionStepProps) { export function SelectionStep({ options, selectedIds, onToggle, onNext, onSkip }: SelectionStepProps) {
const hasSelection = selectedIds.length > 0; const hasSelection = selectedIds.length > 0;
const insets = useSafeAreaInsets();
const footerBottom = insets.bottom + 16;
const footerButtonHeight = 57;
// 底部留白加大,避免最后一项与按钮边框视觉重叠
const footerPaddingBottom = footerBottom + footerButtonHeight + 40;
return ( return (
<View style={styles.container}> <View style={styles.container}>
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.optionsList}> <ScrollView
style={styles.scroll}
showsVerticalScrollIndicator={false}
contentContainerStyle={[styles.optionsList, { paddingBottom: footerPaddingBottom }]}
>
{options.map((option) => { {options.map((option) => {
const isSelected = selectedIds.includes(option.id); const isSelected = selectedIds.includes(option.id);
return ( return (
<TouchableOpacity <TouchableOpacity
key={option.id} key={option.id}
style={styles.optionCard} style={[styles.optionCard, isSelected && styles.optionCardSelected]}
onPress={() => onToggle(option.id)} onPress={() => onToggle(option.id)}
activeOpacity={0.7} activeOpacity={0.7}
> >
<SerifText style={styles.optionText}>{option.label}</SerifText> <Text style={styles.optionText}>{option.label}</Text>
{isSelected && (
<View style={styles.iconWrapper}>
<SelectedIcon width={20} height={20} />
</View>
)}
</TouchableOpacity> </TouchableOpacity>
); );
})} })}
</ScrollView> </ScrollView>
{/* 底部按钮:距离底部 12% 高度 */} {/* 底部按钮:距离底部 12% 高度 */}
<View style={styles.footer}> <View style={[styles.footer, { bottom: footerBottom }]}>
<TouchableOpacity onPress={onNext} disabled={!hasSelection} activeOpacity={0.8}> <TouchableOpacity onPress={onNext} disabled={!hasSelection} activeOpacity={0.8}>
{hasSelection ? <BtnClicked width={87} height={57} /> : <BtnNotClicked width={87} height={57} />} {hasSelection ? <BtnClicked width={87} height={57} /> : <BtnNotClicked width={87} height={57} />}
</TouchableOpacity> </TouchableOpacity>
@@ -60,10 +61,13 @@ export function SelectionStep({ options, selectedIds, onToggle, onNext, onSkip }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
paddingTop: 20, paddingTop: 8,
},
scroll: {
flex: 1,
}, },
optionsList: { optionsList: {
paddingBottom: 150, // 为底部按钮留出空间 // paddingBottom 由安全区 + 按钮高度动态计算,避免选项被遮住
}, },
optionCard: { optionCard: {
width: '100%', width: '100%',
@@ -72,7 +76,7 @@ const styles = StyleSheet.create({
borderRadius: 20, borderRadius: 20,
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'center',
paddingHorizontal: 24, paddingHorizontal: 24,
marginBottom: 12, marginBottom: 12,
shadowColor: '#000', shadowColor: '#000',
@@ -81,18 +85,17 @@ const styles = StyleSheet.create({
shadowRadius: 10, shadowRadius: 10,
elevation: 2, elevation: 2,
}, },
optionCardSelected: {
backgroundColor: OnboardingColors.cardSelected,
},
optionText: { optionText: {
fontSize: 18, fontSize: 18,
color: OnboardingColors.textPrimary, color: OnboardingColors.textPrimary,
fontWeight: '500', fontWeight: '500',
flex: 1, fontFamily: OnboardingFont.question,
},
iconWrapper: {
marginLeft: 10,
}, },
footer: { footer: {
position: 'absolute', position: 'absolute',
bottom: height * 0.12,
left: 0, left: 0,
right: 0, right: 0,
alignItems: 'center', alignItems: 'center',

View File

@@ -1,3 +1,10 @@
import { Platform } from 'react-native';
/** 与 onboarding 问题标题一致的字体PingFang TC / sans-serif */
export const OnboardingFont = {
question: Platform.OS === 'ios' ? 'PingFang TC' : 'sans-serif',
};
export const OnboardingColors = { export const OnboardingColors = {
background: '#FFF4EA', background: '#FFF4EA',
textPrimary: '#772F00', textPrimary: '#772F00',

View File

@@ -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):
@@ -2238,326 +2240,330 @@ PODS:
- Yoga (0.0.0) - Yoga (0.0.0)
DEPENDENCIES: DEPENDENCIES:
- "EXApplication (from `../node_modules/.pnpm/expo-application@7.0.8_expo@54.0.32/node_modules/expo-application/ios`)" - EXApplication (from `../node_modules/expo-application/ios`)
- "EXConstants (from `../node_modules/.pnpm/expo-constants@18.0.13_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0_/node_modules/expo-constants/ios`)" - EXConstants (from `../node_modules/expo-constants/ios`)
- "EXJSONUtils (from `../node_modules/.pnpm/expo-json-utils@0.15.0/node_modules/expo-json-utils/ios`)" - EXJSONUtils (from `../node_modules/expo-json-utils/ios`)
- "EXManifests (from `../node_modules/.pnpm/expo-manifests@1.0.10_expo@54.0.32/node_modules/expo-manifests/ios`)" - EXManifests (from `../node_modules/expo-manifests/ios`)
- "EXNotifications (from `../node_modules/.pnpm/expo-notifications@0.32.16_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@1_nvlvke5tn7wk5pigfsu7j4ieeq/node_modules/expo-notifications/ios`)" - EXNotifications (from `../node_modules/expo-notifications/ios`)
- "Expo (from `../node_modules/.pnpm/expo@54.0.32_@babel+core@7.28.6_@expo+metro-runtime@6.1.2_expo-router@6.0.22_react-native@0.8_7rhpxisdkrzvrgzbu7ct455kta/node_modules/expo`)" - Expo (from `../node_modules/expo`)
- "expo-dev-client (from `../node_modules/.pnpm/expo-dev-client@6.0.20_expo@54.0.32/node_modules/expo-dev-client/ios`)" - expo-dev-client (from `../node_modules/expo-dev-client/ios`)
- "expo-dev-launcher (from `../node_modules/.pnpm/expo-dev-launcher@6.0.20_expo@54.0.32/node_modules/expo-dev-launcher`)" - expo-dev-launcher (from `../node_modules/expo-dev-launcher`)
- "expo-dev-menu (from `../node_modules/.pnpm/expo-dev-menu@7.0.18_expo@54.0.32/node_modules/expo-dev-menu`)" - expo-dev-menu (from `../node_modules/expo-dev-menu`)
- "expo-dev-menu-interface (from `../node_modules/.pnpm/expo-dev-menu-interface@2.0.0_expo@54.0.32/node_modules/expo-dev-menu-interface/ios`)" - expo-dev-menu-interface (from `../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/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/expo-crypto/ios`)
- "ExpoFileSystem (from `../node_modules/.pnpm/expo-file-system@19.0.21_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0_/node_modules/expo-file-system/ios`)" - ExpoDevice (from `../node_modules/expo-device/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`)" - ExpoFileSystem (from `../node_modules/expo-file-system/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`)" - ExpoFont (from `../node_modules/expo-font/ios`)
- "ExpoKeepAwake (from `../node_modules/.pnpm/expo-keep-awake@15.0.8_expo@54.0.32_react@19.1.0/node_modules/expo-keep-awake/ios`)" - ExpoHead (from `../node_modules/expo-router/ios`)
- "ExpoLinearGradient (from `../node_modules/.pnpm/expo-linear-gradient@15.0.8_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@_e6k2hjkd5k4lph2ersbp3gfshy/node_modules/expo-linear-gradient/ios`)" - ExpoKeepAwake (from `../node_modules/expo-keep-awake/ios`)
- "ExpoLinking (from `../node_modules/.pnpm/expo-linking@8.0.11_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-linking/ios`)" - ExpoLinearGradient (from `../node_modules/expo-linear-gradient/ios`)
- "ExpoLocalization (from `../node_modules/.pnpm/expo-localization@17.0.8_expo@54.0.32_react@19.1.0/node_modules/expo-localization/ios`)" - ExpoLinking (from `../node_modules/expo-linking/ios`)
- "ExpoModulesCore (from `../node_modules/.pnpm/expo-modules-core@3.0.29_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-modules-core`)" - ExpoLocalization (from `../node_modules/expo-localization/ios`)
- "ExpoSplashScreen (from `../node_modules/.pnpm/expo-splash-screen@31.0.13_expo@54.0.32/node_modules/expo-splash-screen/ios`)" - ExpoModulesCore (from `../node_modules/expo-modules-core`)
- "ExpoWebBrowser (from `../node_modules/.pnpm/expo-web-browser@15.0.10_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0_/node_modules/expo-web-browser/ios`)" - ExpoSplashScreen (from `../node_modules/expo-splash-screen/ios`)
- "EXUpdatesInterface (from `../node_modules/.pnpm/expo-updates-interface@2.0.0_expo@54.0.32/node_modules/expo-updates-interface/ios`)" - ExpoWebBrowser (from `../node_modules/expo-web-browser/ios`)
- "FBLazyVector (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/FBLazyVector`)" - EXUpdatesInterface (from `../node_modules/expo-updates-interface/ios`)
- "hermes-engine (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`)" - FBLazyVector (from `../node_modules/react-native/Libraries/FBLazyVector`)
- "RCTDeprecation (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`)" - hermes-engine (from `../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`)
- "RCTRequired (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Required`)" - RCTDeprecation (from `../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`)
- "RCTTypeSafety (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/TypeSafety`)" - RCTRequired (from `../node_modules/react-native/Libraries/Required`)
- "React (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/`)" - RCTTypeSafety (from `../node_modules/react-native/Libraries/TypeSafety`)
- "React-callinvoker (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/callinvoker`)" - React (from `../node_modules/react-native/`)
- "React-Core (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/`)" - React-callinvoker (from `../node_modules/react-native/ReactCommon/callinvoker`)
- "React-Core-prebuilt (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/React-Core-prebuilt.podspec`)" - React-Core (from `../node_modules/react-native/`)
- "React-Core/RCTWebSocket (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/`)" - React-Core-prebuilt (from `../node_modules/react-native/React-Core-prebuilt.podspec`)
- "React-CoreModules (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/React/CoreModules`)" - React-Core/RCTWebSocket (from `../node_modules/react-native/`)
- "React-cxxreact (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/cxxreact`)" - React-CoreModules (from `../node_modules/react-native/React/CoreModules`)
- "React-debug (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/debug`)" - React-cxxreact (from `../node_modules/react-native/ReactCommon/cxxreact`)
- "React-defaultsnativemodule (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/defaults`)" - React-debug (from `../node_modules/react-native/ReactCommon/react/debug`)
- "React-domnativemodule (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/dom`)" - React-defaultsnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/defaults`)
- "React-Fabric (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon`)" - React-domnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/dom`)
- "React-FabricComponents (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon`)" - React-Fabric (from `../node_modules/react-native/ReactCommon`)
- "React-FabricImage (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon`)" - React-FabricComponents (from `../node_modules/react-native/ReactCommon`)
- "React-featureflags (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/featureflags`)" - React-FabricImage (from `../node_modules/react-native/ReactCommon`)
- "React-featureflagsnativemodule (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/featureflags`)" - React-featureflags (from `../node_modules/react-native/ReactCommon/react/featureflags`)
- "React-graphics (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/renderer/graphics`)" - React-featureflagsnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/featureflags`)
- "React-hermes (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/hermes`)" - React-graphics (from `../node_modules/react-native/ReactCommon/react/renderer/graphics`)
- "React-idlecallbacksnativemodule (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/idlecallbacks`)" - React-hermes (from `../node_modules/react-native/ReactCommon/hermes`)
- "React-ImageManager (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/renderer/imagemanager/platform/ios`)" - React-idlecallbacksnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/idlecallbacks`)
- "React-jserrorhandler (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jserrorhandler`)" - React-ImageManager (from `../node_modules/react-native/ReactCommon/react/renderer/imagemanager/platform/ios`)
- "React-jsi (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsi`)" - React-jserrorhandler (from `../node_modules/react-native/ReactCommon/jserrorhandler`)
- "React-jsiexecutor (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsiexecutor`)" - React-jsi (from `../node_modules/react-native/ReactCommon/jsi`)
- "React-jsinspector (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsinspector-modern`)" - React-jsiexecutor (from `../node_modules/react-native/ReactCommon/jsiexecutor`)
- "React-jsinspectorcdp (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsinspector-modern/cdp`)" - React-jsinspector (from `../node_modules/react-native/ReactCommon/jsinspector-modern`)
- "React-jsinspectornetwork (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsinspector-modern/network`)" - React-jsinspectorcdp (from `../node_modules/react-native/ReactCommon/jsinspector-modern/cdp`)
- "React-jsinspectortracing (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsinspector-modern/tracing`)" - React-jsinspectornetwork (from `../node_modules/react-native/ReactCommon/jsinspector-modern/network`)
- "React-jsitooling (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsitooling`)" - React-jsinspectortracing (from `../node_modules/react-native/ReactCommon/jsinspector-modern/tracing`)
- "React-jsitracing (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/hermes/executor/`)" - React-jsitooling (from `../node_modules/react-native/ReactCommon/jsitooling`)
- "React-logger (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/logger`)" - React-jsitracing (from `../node_modules/react-native/ReactCommon/hermes/executor/`)
- "React-Mapbuffer (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon`)" - React-logger (from `../node_modules/react-native/ReactCommon/logger`)
- "React-microtasksnativemodule (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/microtasks`)" - React-Mapbuffer (from `../node_modules/react-native/ReactCommon`)
- "react-native-safe-area-context (from `../node_modules/.pnpm/react-native-safe-area-context@5.6.2_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1_azuxgonsvxb2yngtegtuvyxcpi/node_modules/react-native-safe-area-context`)" - React-microtasksnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`)
- "React-NativeModulesApple (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`)" - react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`)
- "React-oscompat (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/oscompat`)" - React-NativeModulesApple (from `../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`)
- "React-perflogger (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/reactperflogger`)" - React-oscompat (from `../node_modules/react-native/ReactCommon/oscompat`)
- "React-performancetimeline (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/performance/timeline`)" - React-perflogger (from `../node_modules/react-native/ReactCommon/reactperflogger`)
- "React-RCTActionSheet (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/ActionSheetIOS`)" - React-performancetimeline (from `../node_modules/react-native/ReactCommon/react/performance/timeline`)
- "React-RCTAnimation (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/NativeAnimation`)" - React-RCTActionSheet (from `../node_modules/react-native/Libraries/ActionSheetIOS`)
- "React-RCTAppDelegate (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/AppDelegate`)" - React-RCTAnimation (from `../node_modules/react-native/Libraries/NativeAnimation`)
- "React-RCTBlob (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Blob`)" - React-RCTAppDelegate (from `../node_modules/react-native/Libraries/AppDelegate`)
- "React-RCTFabric (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/React`)" - React-RCTBlob (from `../node_modules/react-native/Libraries/Blob`)
- "React-RCTFBReactNativeSpec (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/React`)" - React-RCTFabric (from `../node_modules/react-native/React`)
- "React-RCTImage (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Image`)" - React-RCTFBReactNativeSpec (from `../node_modules/react-native/React`)
- "React-RCTLinking (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/LinkingIOS`)" - React-RCTImage (from `../node_modules/react-native/Libraries/Image`)
- "React-RCTNetwork (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Network`)" - React-RCTLinking (from `../node_modules/react-native/Libraries/LinkingIOS`)
- "React-RCTRuntime (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/React/Runtime`)" - React-RCTNetwork (from `../node_modules/react-native/Libraries/Network`)
- "React-RCTSettings (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Settings`)" - React-RCTRuntime (from `../node_modules/react-native/React/Runtime`)
- "React-RCTText (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Text`)" - React-RCTSettings (from `../node_modules/react-native/Libraries/Settings`)
- "React-RCTVibration (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Vibration`)" - React-RCTText (from `../node_modules/react-native/Libraries/Text`)
- "React-rendererconsistency (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/renderer/consistency`)" - React-RCTVibration (from `../node_modules/react-native/Libraries/Vibration`)
- "React-renderercss (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/renderer/css`)" - React-rendererconsistency (from `../node_modules/react-native/ReactCommon/react/renderer/consistency`)
- "React-rendererdebug (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/renderer/debug`)" - React-renderercss (from `../node_modules/react-native/ReactCommon/react/renderer/css`)
- "React-RuntimeApple (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/runtime/platform/ios`)" - React-rendererdebug (from `../node_modules/react-native/ReactCommon/react/renderer/debug`)
- "React-RuntimeCore (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/runtime`)" - React-RuntimeApple (from `../node_modules/react-native/ReactCommon/react/runtime/platform/ios`)
- "React-runtimeexecutor (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/runtimeexecutor`)" - React-RuntimeCore (from `../node_modules/react-native/ReactCommon/react/runtime`)
- "React-RuntimeHermes (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/runtime`)" - React-runtimeexecutor (from `../node_modules/react-native/ReactCommon/runtimeexecutor`)
- "React-runtimescheduler (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/renderer/runtimescheduler`)" - React-RuntimeHermes (from `../node_modules/react-native/ReactCommon/react/runtime`)
- "React-timing (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/timing`)" - React-runtimescheduler (from `../node_modules/react-native/ReactCommon/react/renderer/runtimescheduler`)
- "React-utils (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/utils`)" - React-timing (from `../node_modules/react-native/ReactCommon/react/timing`)
- React-utils (from `../node_modules/react-native/ReactCommon/react/utils`)
- ReactAppDependencyProvider (from `build/generated/ios`) - ReactAppDependencyProvider (from `build/generated/ios`)
- ReactCodegen (from `build/generated/ios`) - ReactCodegen (from `build/generated/ios`)
- "ReactCommon/turbomodule/core (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon`)" - ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`)
- "ReactNativeDependencies (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec`)" - ReactNativeDependencies (from `../node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec`)
- "RNCAsyncStorage (from `../node_modules/.pnpm/@react-native-async-storage+async-storage@2.2.0_react-native@0.81.5_@babel+core@7.28.6_@types_fp4qq3a7mejmut52v6jrlvxlzi/node_modules/@react-native-async-storage/async-storage`)" - "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)"
- "RNGestureHandler (from `../node_modules/.pnpm/react-native-gesture-handler@2.30.0_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1._tylda4qoo2jtxaj3472gn4luma/node_modules/react-native-gesture-handler`)" - RNGestureHandler (from `../node_modules/react-native-gesture-handler`)
- "RNReanimated (from `../node_modules/.pnpm/react-native-reanimated@4.1.6_@babel+core@7.28.6_react-native-worklets@0.5.1_@babel+core@7.28_ky3sbxf6i7nkyacc2hzg3xcz4q/node_modules/react-native-reanimated`)" - RNReanimated (from `../node_modules/react-native-reanimated`)
- "RNScreens (from `../node_modules/.pnpm/react-native-screens@4.16.0_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/react-native-screens`)" - RNScreens (from `../node_modules/react-native-screens`)
- "RNSVG (from `../node_modules/.pnpm/react-native-svg@15.12.1_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/react-native-svg`)" - RNSVG (from `../node_modules/react-native-svg`)
- "RNWorklets (from `../node_modules/.pnpm/react-native-worklets@0.5.1_@babel+core@7.28.6_react-native@0.81.5_@babel+core@7.28.6_@types+_5atwepuw3zy3crkgvetf35tkve/node_modules/react-native-worklets`)" - RNWorklets (from `../node_modules/react-native-worklets`)
- "Yoga (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/yoga`)" - Yoga (from `../node_modules/react-native/ReactCommon/yoga`)
EXTERNAL SOURCES: EXTERNAL SOURCES:
EXApplication: EXApplication:
:path: "../node_modules/.pnpm/expo-application@7.0.8_expo@54.0.32/node_modules/expo-application/ios" :path: "../node_modules/expo-application/ios"
EXConstants: EXConstants:
:path: "../node_modules/.pnpm/expo-constants@18.0.13_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0_/node_modules/expo-constants/ios" :path: "../node_modules/expo-constants/ios"
EXJSONUtils: EXJSONUtils:
:path: "../node_modules/.pnpm/expo-json-utils@0.15.0/node_modules/expo-json-utils/ios" :path: "../node_modules/expo-json-utils/ios"
EXManifests: EXManifests:
:path: "../node_modules/.pnpm/expo-manifests@1.0.10_expo@54.0.32/node_modules/expo-manifests/ios" :path: "../node_modules/expo-manifests/ios"
EXNotifications: EXNotifications:
:path: "../node_modules/.pnpm/expo-notifications@0.32.16_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@1_nvlvke5tn7wk5pigfsu7j4ieeq/node_modules/expo-notifications/ios" :path: "../node_modules/expo-notifications/ios"
Expo: Expo:
:path: "../node_modules/.pnpm/expo@54.0.32_@babel+core@7.28.6_@expo+metro-runtime@6.1.2_expo-router@6.0.22_react-native@0.8_7rhpxisdkrzvrgzbu7ct455kta/node_modules/expo" :path: "../node_modules/expo"
expo-dev-client: expo-dev-client:
:path: "../node_modules/.pnpm/expo-dev-client@6.0.20_expo@54.0.32/node_modules/expo-dev-client/ios" :path: "../node_modules/expo-dev-client/ios"
expo-dev-launcher: expo-dev-launcher:
:path: "../node_modules/.pnpm/expo-dev-launcher@6.0.20_expo@54.0.32/node_modules/expo-dev-launcher" :path: "../node_modules/expo-dev-launcher"
expo-dev-menu: expo-dev-menu:
:path: "../node_modules/.pnpm/expo-dev-menu@7.0.18_expo@54.0.32/node_modules/expo-dev-menu" :path: "../node_modules/expo-dev-menu"
expo-dev-menu-interface: expo-dev-menu-interface:
:path: "../node_modules/.pnpm/expo-dev-menu-interface@2.0.0_expo@54.0.32/node_modules/expo-dev-menu-interface/ios" :path: "../node_modules/expo-dev-menu-interface/ios"
ExpoAsset: ExpoAsset:
:path: "../node_modules/.pnpm/expo-asset@12.0.12_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-asset/ios" :path: "../node_modules/expo-asset/ios"
ExpoCrypto: ExpoCrypto:
:path: "../node_modules/.pnpm/expo-crypto@15.0.8_expo@54.0.32/node_modules/expo-crypto/ios" :path: "../node_modules/expo-crypto/ios"
ExpoDevice:
:path: "../node_modules/expo-device/ios"
ExpoFileSystem: ExpoFileSystem:
:path: "../node_modules/.pnpm/expo-file-system@19.0.21_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0_/node_modules/expo-file-system/ios" :path: "../node_modules/expo-file-system/ios"
ExpoFont: ExpoFont:
:path: "../node_modules/.pnpm/expo-font@14.0.11_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-font/ios" :path: "../node_modules/expo-font/ios"
ExpoHead: ExpoHead:
:path: "../node_modules/.pnpm/expo-router@6.0.22_@expo+metro-runtime@6.1.2_@types+react@19.1.17_expo-constants@18.0.13_expo_mxedi6ntnfsoyp6zijog4pvdsy/node_modules/expo-router/ios" :path: "../node_modules/expo-router/ios"
ExpoKeepAwake: ExpoKeepAwake:
:path: "../node_modules/.pnpm/expo-keep-awake@15.0.8_expo@54.0.32_react@19.1.0/node_modules/expo-keep-awake/ios" :path: "../node_modules/expo-keep-awake/ios"
ExpoLinearGradient: ExpoLinearGradient:
:path: "../node_modules/.pnpm/expo-linear-gradient@15.0.8_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@_e6k2hjkd5k4lph2ersbp3gfshy/node_modules/expo-linear-gradient/ios" :path: "../node_modules/expo-linear-gradient/ios"
ExpoLinking: ExpoLinking:
:path: "../node_modules/.pnpm/expo-linking@8.0.11_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-linking/ios" :path: "../node_modules/expo-linking/ios"
ExpoLocalization: ExpoLocalization:
:path: "../node_modules/.pnpm/expo-localization@17.0.8_expo@54.0.32_react@19.1.0/node_modules/expo-localization/ios" :path: "../node_modules/expo-localization/ios"
ExpoModulesCore: ExpoModulesCore:
:path: "../node_modules/.pnpm/expo-modules-core@3.0.29_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-modules-core" :path: "../node_modules/expo-modules-core"
ExpoSplashScreen: ExpoSplashScreen:
:path: "../node_modules/.pnpm/expo-splash-screen@31.0.13_expo@54.0.32/node_modules/expo-splash-screen/ios" :path: "../node_modules/expo-splash-screen/ios"
ExpoWebBrowser: ExpoWebBrowser:
:path: "../node_modules/.pnpm/expo-web-browser@15.0.10_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0_/node_modules/expo-web-browser/ios" :path: "../node_modules/expo-web-browser/ios"
EXUpdatesInterface: EXUpdatesInterface:
:path: "../node_modules/.pnpm/expo-updates-interface@2.0.0_expo@54.0.32/node_modules/expo-updates-interface/ios" :path: "../node_modules/expo-updates-interface/ios"
FBLazyVector: FBLazyVector:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/FBLazyVector" :path: "../node_modules/react-native/Libraries/FBLazyVector"
hermes-engine: hermes-engine:
:podspec: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec" :podspec: "../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec"
:tag: hermes-2025-07-07-RNv0.81.0-e0fc67142ec0763c6b6153ca2bf96df815539782 :tag: hermes-2025-07-07-RNv0.81.0-e0fc67142ec0763c6b6153ca2bf96df815539782
RCTDeprecation: RCTDeprecation:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation" :path: "../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation"
RCTRequired: RCTRequired:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Required" :path: "../node_modules/react-native/Libraries/Required"
RCTTypeSafety: RCTTypeSafety:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/TypeSafety" :path: "../node_modules/react-native/Libraries/TypeSafety"
React: React:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/" :path: "../node_modules/react-native/"
React-callinvoker: React-callinvoker:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/callinvoker" :path: "../node_modules/react-native/ReactCommon/callinvoker"
React-Core: React-Core:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/" :path: "../node_modules/react-native/"
React-Core-prebuilt: React-Core-prebuilt:
:podspec: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/React-Core-prebuilt.podspec" :podspec: "../node_modules/react-native/React-Core-prebuilt.podspec"
React-CoreModules: React-CoreModules:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/React/CoreModules" :path: "../node_modules/react-native/React/CoreModules"
React-cxxreact: React-cxxreact:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/cxxreact" :path: "../node_modules/react-native/ReactCommon/cxxreact"
React-debug: React-debug:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/debug" :path: "../node_modules/react-native/ReactCommon/react/debug"
React-defaultsnativemodule: React-defaultsnativemodule:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/defaults" :path: "../node_modules/react-native/ReactCommon/react/nativemodule/defaults"
React-domnativemodule: React-domnativemodule:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/dom" :path: "../node_modules/react-native/ReactCommon/react/nativemodule/dom"
React-Fabric: React-Fabric:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon" :path: "../node_modules/react-native/ReactCommon"
React-FabricComponents: React-FabricComponents:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon" :path: "../node_modules/react-native/ReactCommon"
React-FabricImage: React-FabricImage:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon" :path: "../node_modules/react-native/ReactCommon"
React-featureflags: React-featureflags:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/featureflags" :path: "../node_modules/react-native/ReactCommon/react/featureflags"
React-featureflagsnativemodule: React-featureflagsnativemodule:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/featureflags" :path: "../node_modules/react-native/ReactCommon/react/nativemodule/featureflags"
React-graphics: React-graphics:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/renderer/graphics" :path: "../node_modules/react-native/ReactCommon/react/renderer/graphics"
React-hermes: React-hermes:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/hermes" :path: "../node_modules/react-native/ReactCommon/hermes"
React-idlecallbacksnativemodule: React-idlecallbacksnativemodule:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/idlecallbacks" :path: "../node_modules/react-native/ReactCommon/react/nativemodule/idlecallbacks"
React-ImageManager: React-ImageManager:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/renderer/imagemanager/platform/ios" :path: "../node_modules/react-native/ReactCommon/react/renderer/imagemanager/platform/ios"
React-jserrorhandler: React-jserrorhandler:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jserrorhandler" :path: "../node_modules/react-native/ReactCommon/jserrorhandler"
React-jsi: React-jsi:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsi" :path: "../node_modules/react-native/ReactCommon/jsi"
React-jsiexecutor: React-jsiexecutor:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsiexecutor" :path: "../node_modules/react-native/ReactCommon/jsiexecutor"
React-jsinspector: React-jsinspector:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsinspector-modern" :path: "../node_modules/react-native/ReactCommon/jsinspector-modern"
React-jsinspectorcdp: React-jsinspectorcdp:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsinspector-modern/cdp" :path: "../node_modules/react-native/ReactCommon/jsinspector-modern/cdp"
React-jsinspectornetwork: React-jsinspectornetwork:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsinspector-modern/network" :path: "../node_modules/react-native/ReactCommon/jsinspector-modern/network"
React-jsinspectortracing: React-jsinspectortracing:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsinspector-modern/tracing" :path: "../node_modules/react-native/ReactCommon/jsinspector-modern/tracing"
React-jsitooling: React-jsitooling:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsitooling" :path: "../node_modules/react-native/ReactCommon/jsitooling"
React-jsitracing: React-jsitracing:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/hermes/executor/" :path: "../node_modules/react-native/ReactCommon/hermes/executor/"
React-logger: React-logger:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/logger" :path: "../node_modules/react-native/ReactCommon/logger"
React-Mapbuffer: React-Mapbuffer:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon" :path: "../node_modules/react-native/ReactCommon"
React-microtasksnativemodule: React-microtasksnativemodule:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/microtasks" :path: "../node_modules/react-native/ReactCommon/react/nativemodule/microtasks"
react-native-safe-area-context: react-native-safe-area-context:
:path: "../node_modules/.pnpm/react-native-safe-area-context@5.6.2_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1_azuxgonsvxb2yngtegtuvyxcpi/node_modules/react-native-safe-area-context" :path: "../node_modules/react-native-safe-area-context"
React-NativeModulesApple: React-NativeModulesApple:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios" :path: "../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios"
React-oscompat: React-oscompat:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/oscompat" :path: "../node_modules/react-native/ReactCommon/oscompat"
React-perflogger: React-perflogger:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/reactperflogger" :path: "../node_modules/react-native/ReactCommon/reactperflogger"
React-performancetimeline: React-performancetimeline:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/performance/timeline" :path: "../node_modules/react-native/ReactCommon/react/performance/timeline"
React-RCTActionSheet: React-RCTActionSheet:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/ActionSheetIOS" :path: "../node_modules/react-native/Libraries/ActionSheetIOS"
React-RCTAnimation: React-RCTAnimation:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/NativeAnimation" :path: "../node_modules/react-native/Libraries/NativeAnimation"
React-RCTAppDelegate: React-RCTAppDelegate:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/AppDelegate" :path: "../node_modules/react-native/Libraries/AppDelegate"
React-RCTBlob: React-RCTBlob:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Blob" :path: "../node_modules/react-native/Libraries/Blob"
React-RCTFabric: React-RCTFabric:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/React" :path: "../node_modules/react-native/React"
React-RCTFBReactNativeSpec: React-RCTFBReactNativeSpec:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/React" :path: "../node_modules/react-native/React"
React-RCTImage: React-RCTImage:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Image" :path: "../node_modules/react-native/Libraries/Image"
React-RCTLinking: React-RCTLinking:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/LinkingIOS" :path: "../node_modules/react-native/Libraries/LinkingIOS"
React-RCTNetwork: React-RCTNetwork:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Network" :path: "../node_modules/react-native/Libraries/Network"
React-RCTRuntime: React-RCTRuntime:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/React/Runtime" :path: "../node_modules/react-native/React/Runtime"
React-RCTSettings: React-RCTSettings:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Settings" :path: "../node_modules/react-native/Libraries/Settings"
React-RCTText: React-RCTText:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Text" :path: "../node_modules/react-native/Libraries/Text"
React-RCTVibration: React-RCTVibration:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Vibration" :path: "../node_modules/react-native/Libraries/Vibration"
React-rendererconsistency: React-rendererconsistency:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/renderer/consistency" :path: "../node_modules/react-native/ReactCommon/react/renderer/consistency"
React-renderercss: React-renderercss:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/renderer/css" :path: "../node_modules/react-native/ReactCommon/react/renderer/css"
React-rendererdebug: React-rendererdebug:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/renderer/debug" :path: "../node_modules/react-native/ReactCommon/react/renderer/debug"
React-RuntimeApple: React-RuntimeApple:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/runtime/platform/ios" :path: "../node_modules/react-native/ReactCommon/react/runtime/platform/ios"
React-RuntimeCore: React-RuntimeCore:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/runtime" :path: "../node_modules/react-native/ReactCommon/react/runtime"
React-runtimeexecutor: React-runtimeexecutor:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/runtimeexecutor" :path: "../node_modules/react-native/ReactCommon/runtimeexecutor"
React-RuntimeHermes: React-RuntimeHermes:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/runtime" :path: "../node_modules/react-native/ReactCommon/react/runtime"
React-runtimescheduler: React-runtimescheduler:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/renderer/runtimescheduler" :path: "../node_modules/react-native/ReactCommon/react/renderer/runtimescheduler"
React-timing: React-timing:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/timing" :path: "../node_modules/react-native/ReactCommon/react/timing"
React-utils: React-utils:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/utils" :path: "../node_modules/react-native/ReactCommon/react/utils"
ReactAppDependencyProvider: ReactAppDependencyProvider:
:path: build/generated/ios :path: build/generated/ios
ReactCodegen: ReactCodegen:
:path: build/generated/ios :path: build/generated/ios
ReactCommon: ReactCommon:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon" :path: "../node_modules/react-native/ReactCommon"
ReactNativeDependencies: ReactNativeDependencies:
:podspec: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec" :podspec: "../node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec"
RNCAsyncStorage: RNCAsyncStorage:
:path: "../node_modules/.pnpm/@react-native-async-storage+async-storage@2.2.0_react-native@0.81.5_@babel+core@7.28.6_@types_fp4qq3a7mejmut52v6jrlvxlzi/node_modules/@react-native-async-storage/async-storage" :path: "../node_modules/@react-native-async-storage/async-storage"
RNGestureHandler: RNGestureHandler:
:path: "../node_modules/.pnpm/react-native-gesture-handler@2.30.0_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1._tylda4qoo2jtxaj3472gn4luma/node_modules/react-native-gesture-handler" :path: "../node_modules/react-native-gesture-handler"
RNReanimated: RNReanimated:
:path: "../node_modules/.pnpm/react-native-reanimated@4.1.6_@babel+core@7.28.6_react-native-worklets@0.5.1_@babel+core@7.28_ky3sbxf6i7nkyacc2hzg3xcz4q/node_modules/react-native-reanimated" :path: "../node_modules/react-native-reanimated"
RNScreens: RNScreens:
:path: "../node_modules/.pnpm/react-native-screens@4.16.0_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/react-native-screens" :path: "../node_modules/react-native-screens"
RNSVG: RNSVG:
:path: "../node_modules/.pnpm/react-native-svg@15.12.1_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/react-native-svg" :path: "../node_modules/react-native-svg"
RNWorklets: RNWorklets:
:path: "../node_modules/.pnpm/react-native-worklets@0.5.1_@babel+core@7.28.6_react-native@0.81.5_@babel+core@7.28.6_@types+_5atwepuw3zy3crkgvetf35tkve/node_modules/react-native-worklets" :path: "../node_modules/react-native-worklets"
Yoga: Yoga:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/yoga" :path: "../node_modules/react-native/ReactCommon/yoga"
SPEC CHECKSUMS: SPEC CHECKSUMS:
EXApplication: 13420f8139864183f8a04fd6099077bdf8cfb186 EXApplication: 1e98d4b1dccdf30627f92917f4b2c5a53c330e5f
EXConstants: 3feb66fd1d94202fc1f0946d74e029d8b224b60e EXConstants: fce59a631a06c4151602843667f7cfe35f81e271
EXJSONUtils: 1d3e4590438c3ee593684186007028a14b3686cd EXJSONUtils: 1d3e4590438c3ee593684186007028a14b3686cd
EXManifests: 83ef0844fcf06d6099b12a7bdbd7d36fc0e1dd16 EXManifests: a8d97683e5c7a3b026ffbd58559c64dc655b747b
EXNotifications: 2a3feb7af6194828d9aafda72f63a9a03866230a EXNotifications: 9eec98712cc814ceff916d876cb53859003b0597
Expo: b8d64eb9a496ebe8c71e3dae7eeb7f394b146b80 Expo: 4e503a041c59c4e34c8be262a135848ad5cd3710
expo-dev-client: 12ef7d5b14d93e309922acea78dcd851db583a87 expo-dev-client: 425ee077d6754a98cfe3a2e2410d29b440b24c9d
expo-dev-launcher: 47994056008ffdc30a6a5e328a375b3e30a8db05 expo-dev-launcher: a4f4cdef064ab1fb8621e5b8c7c457cd6e9568c3
expo-dev-menu: ea4fb803ace52e60d7cd8060c7cd379612a140b2 expo-dev-menu: 05b18812110c175814c6af0d09dd658abcc5e00d
expo-dev-menu-interface: 600df12ea01efecdd822daaf13cc0ac091775533 expo-dev-menu-interface: 600df12ea01efecdd822daaf13cc0ac091775533
ExpoAsset: d999f3bbd998a750f3b74cb913229848901b926b ExpoAsset: f867e55ceb428aab99e1e8c082b5aee7c159ea18
ExpoCrypto: 4d23a9ff67c25e2ed23ca792d81e58817a7ea1b9 ExpoCrypto: b6105ebaa15d6b38a811e71e43b52cd934945322
ExpoFileSystem: aefcd337b94b874f88752ebefc52813b84992fad ExpoDevice: 6327c3c200816795708885adf540d26ecab83d1a
ExpoFont: c625dbd97ed57e9089b172b2a7bb99003d074664 ExpoFileSystem: 858a44267a3e6e9057e0888ad7c7cfbf55d52063
ExpoHead: b691a2ed7ab02ed820b6c6468941832d34969c29 ExpoFont: f543ce20a228dd702813668b1a07b46f51878d47
ExpoKeepAwake: 44bf6715bc1d2ddb17afe19d927cd039cda123f0 ExpoHead: 4425246bc93411f0fe7f6945f95f698e91db8780
ExpoLinearGradient: 814a21fc4056c3cf606e4f19e31e47074c5b5a86 ExpoKeepAwake: 55f75eca6499bb9e4231ebad6f3e9cb8f99c0296
ExpoLinking: ebf543fd411d56375cb4eee07f6ab4e31c7ad959 ExpoLinearGradient: 809102bdb979f590083af49f7fa4805cd931bd58
ExpoLocalization: 6ac6f326210f0a3141ef6f58ab8f8f4ed003b485 ExpoLinking: 8f0aaf69aa56f832913030503b6263dc6f647f37
ExpoModulesCore: 77496909fd3c800f97f7f2007dd26aeac4bb3798 ExpoLocalization: d9168d5300a5b03e5e78b986124d11fb6ec3ebbd
ExpoSplashScreen: 72fbc6dd9d6404dd9d0725a56c9ac1383bc0b14f ExpoModulesCore: f3da4f1ab5a8375d0beafab763739dbee8446583
ExpoWebBrowser: 88b116cd378d9609c776c0903fe4070fca461588 ExpoSplashScreen: bc3cffefca2716e5f22350ca109badd7e50ec14d
EXUpdatesInterface: 1436757deb0d574b84bba063bd024c315e0ec08b ExpoWebBrowser: 17b064c621789e41d4816c95c93f429b84971f52
EXUpdatesInterface: 5adf50cb41e079c861da6d9b4b954c3db9a50734
FBLazyVector: e95a291ad2dadb88e42b06e0c5fb8262de53ec12 FBLazyVector: e95a291ad2dadb88e42b06e0c5fb8262de53ec12
hermes-engine: 9f4dfe93326146a1c99eb535b1cb0b857a3cd172 hermes-engine: 9f4dfe93326146a1c99eb535b1cb0b857a3cd172
RCTDeprecation: 943572d4be82d480a48f4884f670135ae30bf990 RCTDeprecation: 943572d4be82d480a48f4884f670135ae30bf990
@@ -2565,72 +2571,72 @@ SPEC CHECKSUMS:
RCTTypeSafety: 16a4144ca3f959583ab019b57d5633df10b5e97c RCTTypeSafety: 16a4144ca3f959583ab019b57d5633df10b5e97c
React: 914f8695f9bf38e6418228c2ffb70021e559f92f React: 914f8695f9bf38e6418228c2ffb70021e559f92f
React-callinvoker: 1c0808402aee0c6d4a0d8e7220ce6547af9fba71 React-callinvoker: 1c0808402aee0c6d4a0d8e7220ce6547af9fba71
React-Core: 4ae98f9e8135b8ddbd7c98730afb6fdae883db90 React-Core: c61410ef0ca6055e204a963992e363227e0fd1c5
React-Core-prebuilt: 8f4cca589c14e8cf8fc6db4587ef1c2056b5c151 React-Core-prebuilt: 02f0ad625ddd47463c009c2d0c5dd35c0d982599
React-CoreModules: e878a90bb19b8f3851818af997dbae3b3b0a27ac React-CoreModules: 1f6d1744b5f9f2ec684a4bb5ced25370f87e5382
React-cxxreact: 28af9844f6dc87be1385ab521fbfb3746f19563c React-cxxreact: 3af79478e8187b63ffc22b794cd42d3fc1f1f2da
React-debug: 6328c2228e268846161f10082e80dc69eac2e90a React-debug: 6328c2228e268846161f10082e80dc69eac2e90a
React-defaultsnativemodule: afc9d809ec75780f39464a6949c07987fbea488c React-defaultsnativemodule: d635ef36d755321e5d6fc065bd166b2c5a0e9833
React-domnativemodule: 91a233260411d41f27f67aa1358b7f9f0bfd101d React-domnativemodule: dd28f6d96cd21236e020be2eff6fe0b7d4ec3b66
React-Fabric: 21f349b5e93f305a3c38c885902683a9c79cf983 React-Fabric: 2e32c3fdbb1fbcf5fde54607e3abe453c6652ce2
React-FabricComponents: 47ac634cc9ecc64b30a9997192f510eebe4177e4 React-FabricComponents: 5ed0cdb81f6b91656cb4d3be432feaa28a58071a
React-FabricImage: 21873acd6d4a51a0b97c133141051c7acb11cc86 React-FabricImage: 2bc714f818cb24e454f5d3961864373271b2faf8
React-featureflags: 653f469f0c3c9dc271d610373e3b6e66a9fd847d React-featureflags: 847642f41fa71ad4eec5e0351badebcad4fe6171
React-featureflagsnativemodule: c91a8a3880e0f4838286402241ead47db43aed28 React-featureflagsnativemodule: c868a544b2c626fa337bcbd364b1befe749f0d3f
React-graphics: b4bdb0f635b8048c652a5d2b73eb8b1ddd950f24 React-graphics: 192ec701def5b3f2a07db2814dfba5a44986cff6
React-hermes: fcfad3b917400f49026f3232561e039c9d1c34bf React-hermes: e875778b496c86d07ab2ccaa36a9505d248a254b
React-idlecallbacksnativemodule: 8cb83207e39f8179ac1d344b6177c6ab3ccebcdc React-idlecallbacksnativemodule: 4d57965cdf82c14ee3b337189836cd8491632b76
React-ImageManager: 396128004783fc510e629124dce682d38d1088e7 React-ImageManager: bd0b99e370b13de82c9cd15f0f08144ff3de079e
React-jserrorhandler: b58b788d788cdbf8bda7db74a88ebfcffc8a0795 React-jserrorhandler: a2fdef4cbcfdcdf3fa9f5d1f7190f7fd4535248d
React-jsi: d2c3f8555175371c02da6dfe7ed1b64b55a9d6c0 React-jsi: 89d43d1e7d4d0663f8ba67e0b39eb4e4672c27de
React-jsiexecutor: ba537434eb45ee018b590ed7d29ee233fddb8669 React-jsiexecutor: abe4874aaab90dfee5dec480680220b2f8af07e3
React-jsinspector: f21b6654baf96cb9f71748844a32468a5f73ad51 React-jsinspector: a0b3e051aef842b0b2be2353790ae2b2a5a65a8f
React-jsinspectorcdp: 3f8be4830694c3c1c39442e50f8db877966d43f0 React-jsinspectorcdp: 6346013b2247c6263fbf5199adf4a8751e53bd89
React-jsinspectornetwork: 70e41469565712ad60e11d9c8b8f999b9f7f61eb React-jsinspectornetwork: 26281aa50d49fc1ec93abf981d934698fa95714f
React-jsinspectortracing: eccf9bfa4ec7f130d514f215cfb2222dc3c0e270 React-jsinspectortracing: 55eedf6d57540507570259a778663b90060bbd6e
React-jsitooling: b376a695f5a507627f7934748533b24eed1751ca React-jsitooling: 0e001113fa56d8498aa8ac28437ac0d36348e51a
React-jsitracing: 5c8c3273dda2d95191cc0612fb5e71c4d9018d2a React-jsitracing: b713793eb8a5bbc4d86a84e9d9e5023c0f58cbaf
React-logger: c3e2f8a2e284341205f61eef3d4677ab5a309dfd React-logger: 50fdb9a8236da90c0b1072da5c32ee03aeb5bf28
React-Mapbuffer: 603c18db65844bb81dbe62fee8fcc976eaeb7108 React-Mapbuffer: 9050ee10c19f4f7fca8963d0211b2854d624973e
React-microtasksnativemodule: d77e0c426fce34c23227394c96ca1033b30c813c React-microtasksnativemodule: f775db9e991c6f3b8ccbc02bfcde22770f96e23b
react-native-safe-area-context: 53f796cb6c814661bbe99fbdfd0585d07b996cdd react-native-safe-area-context: 37e680fc4cace3c0030ee46e8987d24f5d3bdab2
React-NativeModulesApple: 1664340b8750d64e0ef3907c5e53d9481f74bcbd React-NativeModulesApple: 8969913947d5b576de4ed371a939455a8daf28aa
React-oscompat: ce47230ed20185e91de62d8c6d139ae61763d09c React-oscompat: ce47230ed20185e91de62d8c6d139ae61763d09c
React-perflogger: b1af3cfb3f095f819b2814910000392a8e17ba9f React-perflogger: 02b010e665772c7dcb859d85d44c1bfc5ac7c0e4
React-performancetimeline: f9ec65b77bcadbc7bd8b47a6f4b4b697da7b1490 React-performancetimeline: 130db956b5a83aa4fb41ddf5ae68da89f3fb1526
React-RCTActionSheet: 0b14875b3963e9124a5a29a45bd1b22df8803916 React-RCTActionSheet: 0b14875b3963e9124a5a29a45bd1b22df8803916
React-RCTAnimation: 60f6eca214a62b9673f64db6df3830cee902b5af React-RCTAnimation: a7b90fd2af7bb9c084428867445a1481a8cb112e
React-RCTAppDelegate: 37734b39bac108af30a0fd9d3e1149ec68b82c28 React-RCTAppDelegate: 3262bedd01263f140ec62b7989f4355f57cec016
React-RCTBlob: 83fbcbd57755caf021787324aac2fe9b028cc264 React-RCTBlob: c17531368702f1ebed5d0ada75a7cf5915072a53
React-RCTFabric: a05cb1df484008db3753c8b4a71e4c6d9f1e43a6 React-RCTFabric: 6409edd8cfdc3133b6cc75636d3b858fdb1d11ea
React-RCTFBReactNativeSpec: d58d7ae9447020bbbac651e3b0674422aba18266 React-RCTFBReactNativeSpec: c004b27b4fa3bd85878ad2cf53de3bbec85da797
React-RCTImage: 47aba3be7c6c64f956b7918ab933769602406aac React-RCTImage: c68078a120d0123f4f07a5ac77bea3bb10242f32
React-RCTLinking: 2dbaa4df2e4523f68baa07936bd8efdfa34d5f31 React-RCTLinking: cf8f9391fe7fe471f96da3a5f0435235eca18c5b
React-RCTNetwork: 1fca7455f9dedf7de2b95bec438da06680f3b000 React-RCTNetwork: ca31f7c879355760c2d9832a06ee35f517938a20
React-RCTRuntime: 17819dd1dfc8613efaf4cbb9d8686baae4a83e5b React-RCTRuntime: a6cf4a1e42754fc87f493e538f2ac6b820e45418
React-RCTSettings: 01bf91c856862354d3d2f642ccb82f3697a4284a React-RCTSettings: e0e140b2ff4bf86d34e9637f6316848fc00be035
React-RCTText: cb576a3797dcb64933613c522296a07eaafc0461 React-RCTText: 75915bace6f7877c03a840cc7b6c622fb62bfa6b
React-RCTVibration: 560af8c086741f3525b8456a482cdbe27f9d098e React-RCTVibration: 25f26b85e5e432bb3c256f8b384f9269e9529f25
React-rendererconsistency: 2dac03f448ff337235fd5820b10f81633328870d React-rendererconsistency: 2dac03f448ff337235fd5820b10f81633328870d
React-renderercss: c5c6b7a15948dd28facca39a18ac269073718490 React-renderercss: 477da167bb96b5ac86d30c5d295412fb853f5453
React-rendererdebug: 3c9d5e1634273f5a24d84cc5669f290ce0bdc812 React-rendererdebug: 2a1798c6f3ef5f22d466df24c33653edbabb5b89
React-RuntimeApple: 887637d1e12ea8262df7d32bc100467df2302613 React-RuntimeApple: 28cf4d8eb18432f6a21abbed7d801ab7f6b6f0b4
React-RuntimeCore: 91f779835dc4f8f84777fe5dd24f1a22f96454e4 React-RuntimeCore: 41bf0fd56a00de5660f222415af49879fa49c4f0
React-runtimeexecutor: 8bb6b738f37b0ada4a6269e6f8ab1133dea0285c React-runtimeexecutor: 1afb774dde3011348e8334be69d2f57a359ea43e
React-RuntimeHermes: 4cb93de9fa8b1cc753d200dbe61a01b9ec5f5562 React-RuntimeHermes: f3b158ea40e8212b1a723a68b4315e7a495c5fc6
React-runtimescheduler: 83dc28f530bfbd2fce84ed13aa7feebdc24e5af7 React-runtimescheduler: 3e1e2bec7300bae512533107d8e54c6e5c63fe0f
React-timing: 03c7217455d2bff459b27a3811be25796b600f47 React-timing: 6fa9883de2e41791e5dc4ec404e5e37f3f50e801
React-utils: 6d46795ae0444ec8a5d9a5f201157b286bf5250a React-utils: 6e2035b53d087927768649a11a26c4e092448e34
ReactAppDependencyProvider: c277c5b231881ad4f00cd59e3aa0671b99d7ebee ReactAppDependencyProvider: 1bcd3527ac0390a1c898c114f81ff954be35ed79
ReactCodegen: 88a1f4643f15841573f833b895bfa2a0c6cb4e7f ReactCodegen: 7d4593f7591f002d137fe40cef3f6c11f13c88cc
ReactCommon: e6e232202a447d353e5531f2be82f50f47cbaa9a ReactCommon: 08810150b1206cc44aecf5f6ae19af32f29151a8
ReactNativeDependencies: 71ce9c28beb282aa720ea7b46980fff9669f428a ReactNativeDependencies: 71ce9c28beb282aa720ea7b46980fff9669f428a
RNCAsyncStorage: e85a99325df9eb0191a6ee2b2a842644c7eb29f4 RNCAsyncStorage: 3a4f5e2777dae1688b781a487923a08569e27fe4
RNGestureHandler: 40c2d1c168e54715fe52e0fb16cb38c54611e4f3 RNGestureHandler: e0d0bce5599f6120b7adf90c38d2805e2935795f
RNReanimated: 10415bc8396eaeac0d7b2c9a1538eae7e607ec9c RNReanimated: e5c702a3e24cc1c68b2de67671713f35461678f4
RNScreens: dd61bc3a3e6f6901ad833efa411917d44827cf51 RNScreens: d8d6f1792f6e7ac12b0190d33d8d390efc0c1845
RNSVG: 2825ee146e0f6a16221e852299943e4cceef4528 RNSVG: 31d6639663c249b7d5abc9728dde2041eb2a3c34
RNWorklets: 9ccdc8112b17af6eee2c85a233891cb80db150ad RNWorklets: 76fce72926e28e304afb44f0da23b2d24f2c1fa0
Yoga: 5934998fbeaef7845dbf698f698518695ab4cd1a Yoga: 5934998fbeaef7845dbf698f698518695ab4cd1a
PODFILE CHECKSUM: c2c3838f0b2a579fef2350bff2ecaa005e27145d PODFILE CHECKSUM: c2c3838f0b2a579fef2350bff2ecaa005e27145d

View File

@@ -3,7 +3,7 @@
archiveVersion = 1; archiveVersion = 1;
classes = { classes = {
}; };
objectVersion = 77; objectVersion = 56;
objects = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
@@ -13,8 +13,8 @@
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 */; };
@@ -382,6 +382,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 +398,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",
@@ -499,6 +501,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 = (
@@ -629,7 +632,7 @@
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\""; LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
MTL_ENABLE_DEBUG_INFO = YES; MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = NO; ONLY_ACTIVE_ARCH = NO;
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native"; REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos; SDKROOT = iphoneos;
SKIP_INSTALL = NO; SKIP_INSTALL = NO;
STRING_CATALOG_GENERATE_SYMBOLS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -688,7 +691,7 @@
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\""; LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
MTL_ENABLE_DEBUG_INFO = YES; MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native"; REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos; SDKROOT = iphoneos;
SKIP_INSTALL = NO; SKIP_INSTALL = NO;
STRING_CATALOG_GENERATE_SYMBOLS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -712,9 +715,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;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 = "&#x4fee;&#x590d;&#x5f52;&#x6863;&#x5934;&#x4fe1;&#x606f;&#xff08;&#x907f;&#x514d; Generic Xcode Archive&#xff09;"
scriptText = "bash &quot;${SRCROOT}/scripts/fix-xcarchive-header.sh&quot; &quot;${ARCHIVE_PATH}&quot;&#10;"
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>

View File

@@ -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"

View File

@@ -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"
}, },

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 123 KiB

View File

@@ -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>

View File

@@ -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/>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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.ApplicationPathOrganizer 可能仍显示 Generic Archive" >&2
fi

View File

@@ -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"

1628
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,10 +4,14 @@
"version": "1.0.0", "version": "1.0.0",
"scripts": { "scripts": {
"start": "expo start", "start": "expo start",
"start:clean": "expo start -c",
"android": "expo run:android", "android": "expo run:android",
"ios": "expo run:ios --scheme \"Hey Mama\"", "ios": "expo run:ios --scheme \"Hey Mama\"",
"ios:clean": "npm run clean:cache && npm run clean:ios-build && expo run:ios --scheme \"Hey Mama\"",
"web": "expo start --web", "web": "expo start --web",
"test": "vitest run" "test": "vitest run",
"clean:cache": "rm -rf node_modules/.cache .expo 2>/dev/null; echo 'Cleared .expo and node_modules/.cache'",
"clean:ios-build": "rm -rf ~/Library/Developer/Xcode/DerivedData/client-* 2>/dev/null; echo 'Cleared Xcode DerivedData for client'"
}, },
"dependencies": { "dependencies": {
"@expo/vector-icons": "^15.0.3", "@expo/vector-icons": "^15.0.3",

View File

@@ -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

View File

@@ -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}$/);
});
});

View 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));
}

View 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,
};
}

View 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];
}

View 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 Ruleunknown → 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'>;
}

View 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);
}

View File

@@ -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;

View File

@@ -1,22 +1,25 @@
## 用文案表(请在此文件对应的 JSON 中修改) ## 用文案表(請在對應 JSON 中修改)
**单一文案源文件**`client/src/i18n/locales/all.json` ### 語言與檔案對應(單一來源,避免多檔覆蓋)
- **English**`all.json``en` | 語言 | 實際使用的檔案 | 說明 |
- **繁体中文**`all.json``zh-TW` |------------|-----------------------------|------|
| **英文** | `locales/all.json``en` | 只改 all.json 的 `en` 區塊 |
| **繁體中文** | `locales/zh-TW.json` | **唯一來源**Onboarding / 開屏 consent 等繁中文案只改此檔 |
> 说明:项目运行时只读取 `all.json`;请不要再改 `locales/en.json`、`locales/zh-TW.json`(它们已不再作为运行时数据源) - 運行時 **繁中zh-TW只讀取 `zh-TW.json`**,不會讀取 `all.json` 的 zh-TW 區塊
- 修改 JSON 後需**重新載入 App**(模擬器 `Cmd+R`)或重啟 Metro必要時加 `--clear`)才會看到新文案。
- **若修改 zh-TW.json 後開屏 consent 仍顯示舊文案**:請執行 `npm run clean:cache`,再以 `npm run start:clean` 啟動 Metro或先刪除模擬器上的 App 再執行 `npm run ios:clean`),確保吃到最新 bundle。
### 快速索引(高文案) ### 快速索引(高文案)
- **開屏 / 協議**`consent.*`**繁中只改 zh-TW.json**`consent.title``consent.subtitle``consent.subtitleSecondary`、agree, privacy, terms, notice…
- **Onboarding 問卷**`onboardingSurvey.steps.*`name.title, status.title, status.options.* …)
- **Onboarding 招呼語**`onboardingSurvey.greeting`Hi {{name}}
- **Home**`home.*` - **Home**`home.*`
- **Push 提示**`push.*` - **Push 提示**`push.*`
- **主**`theme.*` - **主**`theme.*`
- **我的 / Profile**`profile.*` - **我的 / Profile**`profile.*`
- **收藏**`favorites.*` - **收藏**`favorites.*`
- **设置**`settings.*` - **設定**`settings.*`
- **Onboarding问卷**`onboardingSurvey.steps.*`
- **Onboarding兴趣**`intent.*`
- **Mock 文案**`mock.*` - **Mock 文案**`mock.*`

View File

@@ -3,9 +3,15 @@ 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 配置差异导致无法编译
// 繁中zh-TW唯一來源locales/zh-TW.json修改 Onboarding/開屏等繁中文案請只改該檔案
// 本 App 未載入 zh-CN.json若看到類似「你本就完美」「一切都会变好」等簡中 consent 文案,為舊 bundle 快取導致,請執行 clean:cache + start:clean 或卸載 App 重裝。
// 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> };
// eslint-disable-next-line @typescript-eslint/no-var-requires
const zhTW = require('./locales/zh-TW.json') as Record<string, unknown>;
/** /**
* 语言码约定: * 语言码约定:
@@ -29,13 +35,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;
} }
@@ -84,7 +85,8 @@ export async function initI18n(): Promise<void> {
await i18n.use(initReactI18next).init({ await i18n.use(initReactI18next).init({
resources: { resources: {
'zh-TW': { translation: all['zh-TW'] as any }, // 繁中唯一來源zh-TW.jsonall.json 的 zh-TW 區塊不會被載入)
'zh-TW': { translation: zhTW },
en: { translation: all.en as any }, en: { translation: all.en as any },
}, },
lng: initialLang, lng: initialLang,
@@ -94,6 +96,18 @@ export async function initI18n(): Promise<void> {
escapeValue: false, escapeValue: false,
}, },
}); });
// 臨時 debug確認實際使用的 language 與 consent.title 值(方便驗證繁中來自 zh-TW.json
if (typeof __DEV__ !== 'undefined' && __DEV__) {
const consentTitle = i18n.t('consent.title');
console.log(
'[i18n] 已初始化 language=',
i18n.language,
'| consent.title=',
consentTitle,
'| 繁中來源=zh-TW.json英文來源=all.json 的 en 區塊'
);
}
} }
/** /**

35
client/src/i18n/locale.ts Normal file
View 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';
}

View File

@@ -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"
@@ -13,7 +14,7 @@
"progress": "{{current}}/{{total}}", "progress": "{{current}}/{{total}}",
"next": "Next", "next": "Next",
"skip": "Skip", "skip": "Skip",
"skipAll": "Skip onboarding", "skipAll": "Skip",
"q1Title": "How are you feeling lately?", "q1Title": "How are you feeling lately?",
"q1Desc": "No right or wrong. You can skip and adjust later.", "q1Desc": "No right or wrong. You can skip and adjust later.",
"q2Title": "What kind of support do you want?", "q2Title": "What kind of support do you want?",
@@ -24,46 +25,49 @@
"q4Desc": "You can skip. Well stay with you along the way." "q4Desc": "You can skip. Well stay with you along the way."
}, },
"onboardingSurvey": { "onboardingSurvey": {
"greeting": "Hi {{name}},",
"steps": { "steps": {
"name": { "title": "What should I call you?" }, "name": { "title": "What should we call you?", "placeholder": "Mama" },
"status": { "status": {
"title": "Your current stage?", "title": "Where are you at right now?",
"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": "Whats 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"
} }
}, },
"reminder": { "title": "How many reminders do you want per day?" } "reminder": { "title": "How often would you like a gentle reminder?" }
} }
}, },
"intent": { "intent": {
@@ -95,7 +99,8 @@
"theme": { "theme": {
"title": "Theme", "title": "Theme",
"scenery": "Scenery", "scenery": "Scenery",
"color": "Color" "color": "Color",
"suixin": "Ease"
}, },
"profile": { "profile": {
"title": "Me", "title": "Me",
@@ -111,6 +116,7 @@
"dailyReminder": { "dailyReminder": {
"title": "Daily Reminder", "title": "Daily Reminder",
"timesUnit": "times", "timesUnit": "times",
"timesUnitSingular": "time",
"pushLabel": "Push Reminder", "pushLabel": "Push Reminder",
"ok": "Ok", "ok": "Ok",
"minus": "Decrease", "minus": "Decrease",
@@ -119,6 +125,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": "Im proud of who I am, even while becoming who I want to be." "previewQuote": "Im proud of who I am, even while becoming who I want to be."
}, },
@@ -135,11 +144,17 @@
"widgetDesc": "Put gentle reminders on your home screen: long-press → tap “+” → search “Mindfulness” → add a size you like." "widgetDesc": "Put gentle reminders on your home screen: long-press → tap “+” → search “Mindfulness” → add a size you like."
}, },
"consent": { "consent": {
"title": "You Are Perfect.", "title": "Hey mama.",
"subtitle": "Everything Will Be Better.", "subtitle": "Youre doing okay\nright now.",
"subtitleSecondary": "",
"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,\nyou 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 +176,9 @@
"ok": "確定", "ok": "確定",
"cancel": "取消", "cancel": "取消",
"back": "返回", "back": "返回",
"error": "錯誤",
"notice": "提示",
"openLinkError": "無法打開鏈接",
"close": "關閉" "close": "關閉"
}, },
"onboarding": { "onboarding": {
@@ -168,7 +186,7 @@
"progress": "{{current}}/{{total}}", "progress": "{{current}}/{{total}}",
"next": "下一步", "next": "下一步",
"skip": "跳過", "skip": "跳過",
"skipAll": "跳過整個引導", "skipAll": "跳過",
"q1Title": "你最近的感受更接近哪一種?", "q1Title": "你最近的感受更接近哪一種?",
"q1Desc": "沒有對錯,你可以跳過,之後也能慢慢調整。", "q1Desc": "沒有對錯,你可以跳過,之後也能慢慢調整。",
"q2Title": "你更希望獲得哪種支持?", "q2Title": "你更希望獲得哪種支持?",
@@ -179,21 +197,24 @@
"q4Desc": "你可以直接跳過,我們會在之後繼續陪你。" "q4Desc": "你可以直接跳過,我們會在之後繼續陪你。"
}, },
"onboardingSurvey": { "onboardingSurvey": {
"greeting": "Hi {{name}}",
"steps": { "steps": {
"name": { "title": "我可以怎麼稱呼你" }, "name": { "title": "怎麼稱呼你呢?", "placeholder": "媽媽" },
"status": { "status": {
"title": "媽媽的狀態", "title": "你現在正處在哪個階段呢",
"options": { "options": {
"pregnant": "懷孕中/準備成為媽媽", "pregnant": "懷孕中/正在準備迎接寶寶",
"has_kids": "已經有孩子", "has_kids": "已經有孩子",
"no_fill": "不想填寫" "no_fill": "我暫時不想說"
} }
}, },
"emotion": { "emotion": {
"title": "當下情緒狀態", "title": "今天的你,還好嗎",
"options": { "options": {
"happy": "愉悅、滿足", "happy": "愉悅、滿足",
"calm": "平靜、安穩", "calm": "平靜、安穩",
"okay": "還可以、普通",
"tired": "疲累、沒什麼力氣",
"stressed": "被壓得有點喘不過氣", "stressed": "被壓得有點喘不過氣",
"low": "情緒低落" "low": "情緒低落"
} }
@@ -250,7 +271,8 @@
"theme": { "theme": {
"title": "主題", "title": "主題",
"scenery": "風景", "scenery": "風景",
"color": "顏色" "color": "顏色",
"suixin": "隨心"
}, },
"profile": { "profile": {
"title": "我的", "title": "我的",
@@ -266,6 +288,7 @@
"dailyReminder": { "dailyReminder": {
"title": "每日提醒", "title": "每日提醒",
"timesUnit": "次", "timesUnit": "次",
"timesUnitSingular": "次",
"pushLabel": "推送提醒", "pushLabel": "推送提醒",
"ok": "確定", "ok": "確定",
"minus": "減少次數", "minus": "減少次數",
@@ -274,6 +297,9 @@
"widget": { "widget": {
"lockScreen": "鎖屏小工具", "lockScreen": "鎖屏小工具",
"homeScreen": "桌面小工具", "homeScreen": "桌面小工具",
"howToTitle": "如何添加小工具",
"howToDesc1": "長按主畫面空白處進入編輯,點左上角「+」新增小工具。",
"howToDesc2": "搜尋「正念」,選擇喜歡的尺寸,點「加入小工具」。",
"previewDate": "1月29日週四 · 已至臘月十一", "previewDate": "1月29日週四 · 已至臘月十一",
"previewQuote": "我也對現在的自己感到滿意,即使我仍在努力成為想成為的人。" "previewQuote": "我也對現在的自己感到滿意,即使我仍在努力成為想成為的人。"
}, },
@@ -290,9 +316,17 @@
"widgetDesc": "把溫柔提醒放到桌面上:長按主畫面 → 點「+」 → 搜尋「正念」 → 添加你喜歡的尺寸。" "widgetDesc": "把溫柔提醒放到桌面上:長按主畫面 → 點「+」 → 搜尋「正念」 → 添加你喜歡的尺寸。"
}, },
"consent": { "consent": {
"title": "我們知道,",
"subtitle": "當媽媽很不容易。",
"subtitleSecondary": "這裡給你一些溫柔的肯定與提醒",
"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": "系統權限已被拒絕,請前往手機設定開啟通知。"

View File

@@ -11,7 +11,7 @@
"progress": "{{current}}/{{total}}", "progress": "{{current}}/{{total}}",
"next": "Next", "next": "Next",
"skip": "Skip", "skip": "Skip",
"skipAll": "Skip onboarding", "skipAll": "Skip",
"q1Title": "How are you feeling lately?", "q1Title": "How are you feeling lately?",
"q1Desc": "No right or wrong. You can skip and adjust later.", "q1Desc": "No right or wrong. You can skip and adjust later.",
"q2Title": "What kind of support do you want?", "q2Title": "What kind of support do you want?",
@@ -59,6 +59,7 @@
"dailyReminder": { "dailyReminder": {
"title": "Daily Reminder", "title": "Daily Reminder",
"timesUnit": "times", "timesUnit": "times",
"timesUnitSingular": "time",
"pushLabel": "Push Reminder", "pushLabel": "Push Reminder",
"ok": "Ok", "ok": "Ok",
"minus": "Decrease", "minus": "Decrease",

View File

@@ -9,7 +9,7 @@
"progress": "{{current}}/{{total}}", "progress": "{{current}}/{{total}}",
"next": "Siguiente", "next": "Siguiente",
"skip": "Saltar", "skip": "Saltar",
"skipAll": "Saltar introducción", "skipAll": "Saltar",
"q1Title": "¿Cómo te sientes últimamente?", "q1Title": "¿Cómo te sientes últimamente?",
"q1Desc": "No hay respuestas correctas. Puedes saltar y ajustar después.", "q1Desc": "No hay respuestas correctas. Puedes saltar y ajustar después.",
"q2Title": "¿Qué tipo de apoyo quieres?", "q2Title": "¿Qué tipo de apoyo quieres?",
@@ -57,6 +57,7 @@
"dailyReminder": { "dailyReminder": {
"title": "Recordatorio diario", "title": "Recordatorio diario",
"timesUnit": "veces", "timesUnit": "veces",
"timesUnitSingular": "vez",
"pushLabel": "Recordatorio Push", "pushLabel": "Recordatorio Push",
"ok": "Ok", "ok": "Ok",
"minus": "Disminuir", "minus": "Disminuir",

View File

@@ -9,7 +9,7 @@
"progress": "{{current}}/{{total}}", "progress": "{{current}}/{{total}}",
"next": "Próximo", "next": "Próximo",
"skip": "Pular", "skip": "Pular",
"skipAll": "Pular introdução", "skipAll": "Pular",
"q1Title": "Como você tem se sentido ultimamente?", "q1Title": "Como você tem se sentido ultimamente?",
"q1Desc": "Não há certo ou errado. Você pode pular e ajustar depois.", "q1Desc": "Não há certo ou errado. Você pode pular e ajustar depois.",
"q2Title": "Que tipo de apoio você quer?", "q2Title": "Que tipo de apoio você quer?",
@@ -57,6 +57,7 @@
"dailyReminder": { "dailyReminder": {
"title": "Lembrete diário", "title": "Lembrete diário",
"timesUnit": "vezes", "timesUnit": "vezes",
"timesUnitSingular": "vez",
"pushLabel": "Lembrete Push", "pushLabel": "Lembrete Push",
"ok": "Ok", "ok": "Ok",
"minus": "Diminuir", "minus": "Diminuir",

View File

@@ -12,7 +12,7 @@
"progress": "{{current}}/{{total}}", "progress": "{{current}}/{{total}}",
"next": "下一步", "next": "下一步",
"skip": "跳过", "skip": "跳过",
"skipAll": "跳过整个引导", "skipAll": "跳过",
"q1Title": "你最近的感受更接近哪一种?", "q1Title": "你最近的感受更接近哪一种?",
"q1Desc": "没有对错,你可以跳过,之后也可以慢慢调整。", "q1Desc": "没有对错,你可以跳过,之后也可以慢慢调整。",
"q2Title": "你更希望获得哪种支持?", "q2Title": "你更希望获得哪种支持?",
@@ -60,6 +60,7 @@
"dailyReminder": { "dailyReminder": {
"title": "每日提醒", "title": "每日提醒",
"timesUnit": "次", "timesUnit": "次",
"timesUnitSingular": "次",
"pushLabel": "推送提醒", "pushLabel": "推送提醒",
"ok": "确定", "ok": "确定",
"minus": "减少次数", "minus": "减少次数",

View File

@@ -2,14 +2,18 @@
"common": { "common": {
"ok": "確定", "ok": "確定",
"cancel": "取消", "cancel": "取消",
"back": "返回" "back": "返回",
"error": "錯誤",
"notice": "提示",
"openLinkError": "無法打開鏈接",
"close": "關閉"
}, },
"onboarding": { "onboarding": {
"title": "歡迎", "title": "歡迎",
"progress": "{{current}}/{{total}}", "progress": "{{current}}/{{total}}",
"next": "下一步", "next": "下一步",
"skip": "跳過", "skip": "跳過",
"skipAll": "跳過整個引導", "skipAll": "跳過",
"q1Title": "你最近的感受更接近哪一種?", "q1Title": "你最近的感受更接近哪一種?",
"q1Desc": "沒有對錯,你可以跳過,之後也能慢慢調整。", "q1Desc": "沒有對錯,你可以跳過,之後也能慢慢調整。",
"q2Title": "你更希望獲得哪種支持?", "q2Title": "你更希望獲得哪種支持?",
@@ -19,6 +23,64 @@
"q4Title": "給自己一句溫柔的話", "q4Title": "給自己一句溫柔的話",
"q4Desc": "你可以直接跳過,我們會在之後繼續陪你。" "q4Desc": "你可以直接跳過,我們會在之後繼續陪你。"
}, },
"onboardingSurvey": {
"greeting": "Hi {{name}}",
"steps": {
"name": {
"title": "怎麼稱呼你呢?",
"placeholder": "媽媽"
},
"status": {
"title": "你現在正處在哪個階段呢?",
"options": {
"pregnant": "懷孕中/正在準備迎接寶寶",
"has_kids": "已經有孩子",
"no_fill": "我暫時不想說"
}
},
"emotion": {
"title": "今天的你,還好嗎?",
"options": {
"happy": "愉悅、滿足",
"calm": "平靜、安穩",
"okay": "還可以、普通",
"tired": "疲累、沒什麼力氣",
"stressed": "被壓得有點喘不過氣",
"low": "情緒低落"
}
},
"influence": {
"title": "是什麼影響了你最近的感受?",
"options": {
"family": "家庭與孩子",
"work": "工作或學習",
"relationship": "親密關係",
"friends": "朋友與人際",
"health": "身心健康"
}
},
"support": {
"title": "最需要什麼支持?",
"options": {
"emotional": "情緒支持",
"parenting": "育兒壓力",
"self_worth": "自我價值",
"anxiety": "焦慮舒緩",
"balance": "休息與平衡"
}
},
"reminder": {
"title": "你希望一天收到幾次肯定語?"
}
}
},
"intent": {
"title": "你希望得到什麼幫助?",
"love": "愛情",
"life": "生活",
"travel": "旅遊",
"work": "職場"
},
"push": { "push": {
"title": "通知", "title": "通知",
"cardTitle": "開啟溫柔提醒", "cardTitle": "開啟溫柔提醒",
@@ -41,7 +103,8 @@
"theme": { "theme": {
"title": "主題", "title": "主題",
"scenery": "風景", "scenery": "風景",
"color": "顏色" "color": "顏色",
"suixin": "隨心"
}, },
"profile": { "profile": {
"title": "我的", "title": "我的",
@@ -57,6 +120,7 @@
"dailyReminder": { "dailyReminder": {
"title": "每日提醒", "title": "每日提醒",
"timesUnit": "次", "timesUnit": "次",
"timesUnitSingular": "次",
"pushLabel": "推送提醒", "pushLabel": "推送提醒",
"ok": "確定", "ok": "確定",
"minus": "減少次數", "minus": "減少次數",
@@ -65,12 +129,16 @@
"widget": { "widget": {
"lockScreen": "鎖屏小工具", "lockScreen": "鎖屏小工具",
"homeScreen": "桌面小工具", "homeScreen": "桌面小工具",
"howToTitle": "如何添加小工具",
"howToDesc1": "長按主畫面空白處進入編輯,點左上角「+」新增小工具。",
"howToDesc2": "搜尋「正念」,選擇喜歡的尺寸,點「加入小工具」。",
"previewDate": "1月29日週四 · 已至臘月十一", "previewDate": "1月29日週四 · 已至臘月十一",
"previewQuote": "我也對現在的自己感到滿意,即使我仍在努力成為想成為的人。" "previewQuote": "我也對現在的自己感到滿意,即使我仍在努力成為想成為的人。"
}, },
"favorites": { "favorites": {
"title": "收藏夾", "title": "收藏夾",
"empty": "這裡還沒有收藏內容。" "empty": "這裡還沒有收藏內容。",
"unknownText": "這條文案暫時無法顯示。"
}, },
"settings": { "settings": {
"title": "設定", "title": "設定",
@@ -80,9 +148,30 @@
"widgetDesc": "把溫柔提醒放到桌面上:長按主畫面 → 點「+」 → 搜尋「正念」 → 添加你喜歡的尺寸。" "widgetDesc": "把溫柔提醒放到桌面上:長按主畫面 → 點「+」 → 搜尋「正念」 → 添加你喜歡的尺寸。"
}, },
"consent": { "consent": {
"title": "我們知道,",
"subtitle": "當媽媽很不容易。",
"subtitleSecondary": "這裡給你一些溫柔的肯定與提醒",
"agree": "同意並繼續", "agree": "同意並繼續",
"privacy": "隱私協議", "privacy": "隱私協議",
"terms": "用戶使用協議" "terms": "用戶使用協議",
"notice": "繼續使用即代表你同意《隱私協議》與《用戶使用協議》。",
"noticeRich": "繼續使用即代表你同意<privacy>《{{privacyLabel}}》{{privacySuffix}}</privacy>與<terms>《{{termsLabel}}》{{termsSuffix}}</terms>。",
"linkUnavailable": "協議鏈接載入失敗,請檢查網路後重試。",
"linkUnavailableDev": "協議鏈接載入失敗,請檢查網路或 API_BASE_URL 設定:{{baseUrl}}",
"linkLoadingSuffix": "(載入中…)"
},
"permissions": {
"notificationsDenied": "系統權限已被拒絕,請前往手機設定開啟通知。"
},
"language": {
"zhTW": "繁體中文",
"en": "English"
},
"mock": {
"c1": "你已經很努力了,今天也值得被溫柔對待。",
"c2": "深呼吸三次,把注意力帶回當下。",
"c3": "允許自己慢一點,情緒會像雲一樣飄過。",
"c4": "你不需要完美,你已經足夠好。",
"c5": "把手放在心口,對自己說一句:辛苦了。"
} }
} }

View File

@@ -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(),

View File

@@ -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');
}); });

View File

@@ -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> {

View File

@@ -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}`);
} }
} }

View File

@@ -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

View File

@@ -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, {});
} }

View 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;
}

View File

@@ -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,
});
}
} }

View File

@@ -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"]

View 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 / PORTAPI 监听地址,默认 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。
# 说明:
# - 单容器模式:若启动了 APISTART_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 Workercelery -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 Beatcelery -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 "启动 APIuvicorn 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

View File

@@ -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 Workercelery -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 Beatcelery -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[@]}"

View File

@@ -0,0 +1,31 @@
# Splash Consent 補充說明
## i18n 開屏 consent 文案不生效(排查與修復記錄)
### 現象
- iOS 模擬器繁中開屏一直顯示舊文案(如「你很完美。」「一切 都會更好。」)
- 修改 zh-TW.json 的 consent.title/subtitle 後重跑 `npm run ios` 仍不生效
- 專案內搜尋「你很完美」找不到(舊文案來自快取)
### 排查結論
1. **舊文案來源**
- 專案內**沒有**「你很完美」「一切 都會更好」的完整句
- `zh-CN.json` 有近似句「你本就完美。」「一切都会变好。」,但 **App 的 i18n 未載入 zh-CN**,僅載入 `zh-TW`zh-TW.json`en`all.json 的 en 區塊)
- 結論:舊文案來自 **Metro / JS bundle 或 iOS 建置快取**(曾打包進去的舊 JSON
2. **語言與載入順序**`client/src/i18n/index.ts`
- `resources``zh-TW``zh-TW.json``en``all.json``en`
- **all.json 的 zh-TW 區塊不會被載入**,繁中唯一來源為 `zh-TW.json`
- 裝置語言經 `expo-localization.getLocales()[0].languageTag` 取得,經 `normalizeDeviceLanguageTagToAppLanguage` 對應到 `zh-TW``en`zh-Hant / zh-TW / zh-HK 等均對應 zh-TW
3. **修復與預防**
- 已加臨時 debug logi18n 初始化與開屏 consent 畫面會印出 `language``consent.title`(僅 __DEV__
- 已加 `clean:cache``start:clean``ios:clean` script若改 zh-TW 仍不生效,請執行清理後重啟或卸載 App 重裝
- 繁中 consent 文案**只改** `client/src/i18n/locales/zh-TW.json``consent.title``consent.subtitle``consent.subtitleSecondary`
### 修改的檔案(本次修復)
- `client/src/i18n/index.ts`:註解 + __DEV__ 下印出 language 與 consent.title
- `client/app/(splash)/splash.tsx`useTranslation 取 i18n + __DEV__ 下印出 consent 畫面時的 language / title / subtitle
- `client/package.json``start:clean``ios:clean``clean:cache`
- `client/src/i18n/ALL_COPY.md`:故障排除與 consent 繁中只改 zh-TW.json 的說明

View 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/重进页面时丢失)
- **多语言**TCzh-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 Ruleunknown → 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 Theme5 套)+ Neutral1 套)
- 与设计文档保持一致的 `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/跳过/缺画像)

View 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 Theme5 套)+ Neutral Theme1 套)。
- **不做心理诊断**:颜色不用于推断用户心理状态,只用于提升阅读与停留的舒适度。
## 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`(可选:主题描述,用于解释“随心=按问卷画像推荐颜色”)
#### 文案建议
- **TCzh-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 Ruleunknown → 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` 颜色作为背景色
- **轻量渐变输出(可选增强)**:输出 23 个 `hex` 颜色作为背景渐变 stops必须连续、低感知变化
## 7. 颜色算法规则复用现有文档Home 场景化)
### 7.1 主题色系Base Theme / Neutral Theme
Base Theme5 套,不新增):
```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 Theme1 套):
```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 顶部主题切换弹窗(与现有位置一致)

View 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 Theme5+ Neutral1常量
- `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. UIThemeModal 增加「随心」入口
- [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 Rulesunknown / need 跳过 → Neutral
- 多语言TC/EN 标题正确
- **备注**:已完成自动化回归(`vitest` + `tsc`);如需视觉确认可在模拟器/真机打开 ThemeModal 与 Home 做肉眼验收
## 8. 收尾overview.md 标记
- [x] **T8.1 tasks 全部完成后更新 `spec_kit/overview.md`**
- **位置**`## SuixinTheme` 条目
- **内容**:补充“已完成编码(全部)”与关键变更文件清单(可选)
- **验收**overview 总览可读、可追踪

View File

@@ -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 Theme5 套)+ Neutral1 套),按 `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`

View File

@@ -0,0 +1,184 @@
🎨 个性化文案背景颜色推荐算法
V1.2(文案滑动优化版)
适用范围(明确收敛)
✅ 仅用于「文案阅读页」
✅ 支持上下滑动阅读
❌ 不用于首页 / 列表 / 卡片 / CTA
❌ 不承担转化或引导职责
一、设计目标V1.2 更新)
在 V1.1 基础上,新增以下目标:
阅读优先:颜色永远服务于“读下去”,而非“制造变化”
滑动即流动:通过连续渐变营造沉浸感,而非主题切换
低感知变化:用户能感到“舒服”,但不意识到颜色在变
核心原则不变:
不通过颜色制造新的情绪判断
二、颜色数量策略V1.2 明确约束)
2.1 颜色主题数量(不变)
Need Theme5 套(不新增)
Neutral Theme1 套
总计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 RulesV1.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 Themesession
emotion / confidence → 强度调节
scroll → 线性渐变插值 + 微质感
输出连续背景颜色
十、V1.2 设计总结(评审友好版)
颜色数量不多,是刻意选择
变化来自滑动,不来自判断
颜色不“解释”用户,只“陪伴”用户
在文案阅读场景中,
稳定本身就是高级体验。