import { useMemo, useState } from 'react'; import { useRouter } from 'expo-router'; import { useTranslation } from 'react-i18next'; import { Alert } from 'react-native'; import * as Notifications from 'expo-notifications'; import * as Device from 'expo-device'; import { OnboardingLayout } from '@/components/onboarding/OnboardingLayout'; import { NameInputStep } from '@/components/onboarding/NameInputStep'; import { SelectionStep } from '@/components/onboarding/SelectionStep'; import { ReminderStep } from '@/components/onboarding/ReminderStep'; import { buildUserProfileFromQuestionnaire, mapOnboardingSelectionsToQuestionnaireAnswers } from '@/src/features/userProfileScoring'; import { ensureDailyWidgetRecoUpToDate, syncWidgetConfig, syncWidgetUserProfileFromScoring } from '@/src/modules/dailyWidgetReco'; import { toBackendLocaleFromLanguageTag } from '@/src/i18n/locale'; import { fetchRecoFeed } from '@/src/services/recoApi'; import { getExpoPushTokenOrThrow, registerPushToken, setPushPreferences } from '@/src/services/pushApi'; import { recordRecoFeedServed, setOnboardingCompleted, setUserProfile, setDailyReminderSettings, setUserProfileScoring, setRecoFeedCache, setPushPromptState, } from '@/src/storage/appStorage'; type Step = | { id: 'name'; type: 'name' } | { id: 'status' | 'emotion' | 'influence' | 'support'; type: 'selection'; optionIds: string[] } | { id: 'reminder'; type: 'reminder' }; const STEPS: Step[] = [ { id: 'name', type: 'name' }, { id: 'status', type: 'selection', optionIds: ['pregnant', 'has_kids', 'no_fill'] }, { id: 'emotion', type: 'selection', optionIds: ['happy', 'calm', 'okay', 'tired', 'stressed', 'low'] }, { id: 'influence', type: 'selection', optionIds: ['family', 'work', 'relationship', 'friends', 'health'] }, { id: 'support', type: 'selection', optionIds: ['emotional', 'parenting', 'self_worth', 'anxiety', 'balance'] }, { id: 'reminder', type: 'reminder' }, ]; export default function OnboardingScreen() { const router = useRouter(); const { t, i18n } = useTranslation(); const [stepIndex, setStepIndex] = useState(0); const [name, setName] = useState(''); const [selections, setSelections] = useState>({}); const [reminderTimes, setReminderTimes] = useState(3); const currentStep = STEPS[stepIndex]; const currentTitle = useMemo(() => t(`onboardingSurvey.steps.${currentStep.id}.title`), [t, currentStep.id]); const currentOptions = useMemo(() => { if (currentStep.type !== 'selection') return []; return currentStep.optionIds.map((optId) => ({ id: optId, label: t(`onboardingSurvey.steps.${currentStep.id}.options.${optId}`), })); }, [t, currentStep]); async function onFinish() { // 用户选择每日次数 > 0:在此页直接触发系统通知权限(已移除单独的 push 引导页)。 const wantsPush = reminderTimes > 0; // 将 Onboarding 选择映射为标准问卷枚举(允许跳过) const answers = mapOnboardingSelectionsToQuestionnaireAnswers(selections); // 生成用户画像(供推荐/Push/Widget 复用) const scoringProfile = buildUserProfileFromQuestionnaire(answers); await setUserProfileScoring(scoringProfile); // 同步到 App Group:供 iOS Widget 拉取与展示 await syncWidgetConfig(); await syncWidgetUserProfileFromScoring(scoringProfile); // 可选:前台辅助拉取一次“每日推荐”,提升小组件首次展示的成功率与一致性(失败不阻塞) await ensureDailyWidgetRecoUpToDate({ reason: 'onboarding_finish', scoringProfile }); // Onboarding 结束后预拉取一次 Feed 文案(失败不阻塞进入首页) try { const lang = toBackendLocaleFromLanguageTag(i18n.language); const { items, meta } = await fetchRecoFeed({ k: 30, user_profile: { profile_version: scoringProfile.profile_version, profile_source: scoringProfile.profile_source, profile_generated_at: scoringProfile.profile_generated_at, profile_confidence: scoringProfile.profile_confidence, profile_answered: scoringProfile.profile_answered, stage: scoringProfile.stage, emotion_score: scoringProfile.emotion_score, context: scoringProfile.context, need: scoringProfile.need, }, }); await setRecoFeedCache({ saved_at: new Date().toISOString(), lang, items: items.map((x) => ({ content_id: x.content_id, text: x.text })), meta: meta as Record, }); await recordRecoFeedServed(items.map((x) => x.content_id)); } catch { // 网络失败时使用首页本地 mock 兜底 } await setUserProfile({ name, intents: Object.values(selections).flat() }); await setDailyReminderSettings({ timesPerDay: reminderTimes, // 这里表示“用户意愿”,不代表系统权限一定已 granted pushEnabled: wantsPush, }); await setOnboardingCompleted(true); // 用户选择 0 次(关闭)或跳过:直接进入首页 if (!wantsPush) { await setPushPromptState('skipped'); router.replace('/(app)/home'); return; } // 用户想要 Push:请求系统权限并尽量完成 token/偏好上报(失败不阻塞进入首页) await setPushPromptState('unknown'); try { const { status } = await Notifications.requestPermissionsAsync(); if (status !== 'granted') { await setPushPromptState('skipped'); return; } // iOS 模拟器通常无法获取 Expo Push Token(系统限制),此时不要提示“失败”,而是明确告知需要真机测试。 if (Device.osName === 'iOS' && !Device.isDevice) { Alert.alert('提示', '当前为 iOS 模拟器,无法获取推送 Token。请使用真机测试推送功能。'); await setPushPromptState('unknown'); return; } // 1) 获取 Expo Push Token(失败才认为“推送开启失败”) const expoPushToken = await getExpoPushTokenOrThrow(); // 2) 上报 token 到后端(幂等;失败才认为“推送开启失败”) await registerPushToken({ pushToken: expoPushToken }); // 3) 上报推送偏好(幂等) // 注意:这一步失败时,后端仍可能已成功接收 token。 // 为避免出现“后端已接收 token 但前端弹窗提示失败”的错觉,这里改为:偏好同步失败不弹“开启失败”,仅记录并继续。 try { await setPushPreferences({ enabled: wantsPush, timesPerDay: reminderTimes }); } catch (e) { const msg = e instanceof Error ? e.message : String(e); console.warn('[PushPreferences] 同步失败(Onboarding,不阻塞)', msg); } await setPushPromptState('enabled'); } catch (e) { // 失败不阻塞进入首页;但这里给出更明确的文案(常见原因:模拟器/网络/后端异常) Alert.alert(t('push.errorTitle'), t('push.errorDesc')); await setPushPromptState('unknown'); } finally { router.replace('/(app)/home'); } } const onNext = () => { if (stepIndex < STEPS.length - 1) { setStepIndex(stepIndex + 1); } else { onFinish(); } }; const onBack = () => { if (stepIndex > 0) { setStepIndex(stepIndex - 1); } }; /** 只跳過當前這一步(不填/不選當前題,進入下一步) */ const handleSkipCurrentStep = () => { if (currentStep.type === 'name') { onNext(); } else if (currentStep.type === 'selection') { setSelections((prev) => ({ ...prev, [currentStep.id]: [] })); onNext(); } else if (currentStep.type === 'reminder') { setReminderTimes(0); onFinish(); } }; // 题目为多选:点击切换选中状态 const handleToggleSelection = (id: string) => { setSelections(prev => { const currentIds = prev[currentStep.id] || []; const nextIds = currentIds.includes(id) ? currentIds.filter(i => i !== id) : [...currentIds, id]; return { ...prev, [currentStep.id]: nextIds }; }); }; const handleSkipStep = () => { setSelections((prev) => ({ ...prev, [currentStep.id]: [] })); onNext(); }; return ( 0} userName={name} > {currentStep.type === 'name' && ( )} {currentStep.type === 'selection' && ( )} {currentStep.type === 'reminder' && ( { // 跳过每日提醒:视为 0 次(关闭) setReminderTimes(0); onFinish(); }} /> )} ); }