Files
mindfulness/client/app/(onboarding)/onboarding.tsx

181 lines
6.1 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMemo, useState } from 'react';
import { useRouter } from 'expo-router';
import * as Notifications from 'expo-notifications';
import { useTranslation } from 'react-i18next';
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 { fetchRecoFeed } from '@/src/services/recoApi';
import {
recordRecoFeedServed,
setOnboardingCompleted,
setUserProfile,
setDailyReminderSettings,
setUserProfileScoring,
setRecoFeedCache,
} 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', '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<Record<string, string[]>>({});
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() {
// 请求推送权限
const { status } = await Notifications.requestPermissionsAsync();
const pushEnabled = status === 'granted';
// 将 Onboarding 选择映射为标准问卷枚举(允许跳过)
const answers = mapOnboardingSelectionsToQuestionnaireAnswers(selections);
// 生成用户画像(供推荐/Push/Widget 复用)
const scoringProfile = buildUserProfileFromQuestionnaire(answers);
await setUserProfileScoring(scoringProfile);
// Onboarding 结束后预拉取一次 Feed 文案(失败不阻塞进入首页)
try {
const lang = i18n.language?.toLowerCase().startsWith('zh') ? 'tc' : 'en';
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<string, unknown>,
});
await recordRecoFeedServed(items.map((x) => x.content_id));
} catch {
// 网络失败时使用首页本地 mock 兜底
}
await setUserProfile({
name,
intents: Object.values(selections).flat()
});
await setDailyReminderSettings({
timesPerDay: reminderTimes,
pushEnabled: pushEnabled
});
await setOnboardingCompleted(true);
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 onSkip = async () => {
// 跳过整个 Onboarding仍生成一个“全跳过”的最小画像保证下游可用
const scoringProfile = buildUserProfileFromQuestionnaire({});
await setUserProfileScoring(scoringProfile);
// 标记已完成,避免下次启动再次进入 Onboarding
await setOnboardingCompleted(true);
router.replace('/(app)/home');
};
// 题目为多选:点击切换选中状态
const handleToggleSelection = (id: string) => {
setSelections(prev => {
const currentIds = prev[currentStep.id] || [];
const nextIds = currentIds.includes(id)
? currentIds.filter(i => i !== id)
: [...currentIds, id];
return { ...prev, [currentStep.id]: nextIds };
});
};
const handleSkipStep = () => {
setSelections((prev) => ({ ...prev, [currentStep.id]: [] }));
onNext();
};
return (
<OnboardingLayout
title={currentTitle}
currentStep={stepIndex}
totalSteps={STEPS.length - 1}
onSkip={onSkip}
onBack={onBack}
showBackButton={stepIndex > 0}
>
{currentStep.type === 'name' && (
<NameInputStep
value={name}
onChangeText={setName}
onNext={onNext}
/>
)}
{currentStep.type === 'selection' && (
<SelectionStep
options={currentOptions}
selectedIds={selections[currentStep.id] || []}
onToggle={handleToggleSelection}
onNext={onNext}
/>
)}
{currentStep.type === 'reminder' && (
<ReminderStep
value={reminderTimes}
onChange={setReminderTimes}
onFinish={onFinish}
/>
)}
</OnboardingLayout>
);
}