264 lines
9.8 KiB
TypeScript
264 lines
9.8 KiB
TypeScript
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 { ensurePushTokenRegisteredIfPermitted, 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<Record<string, string[]>>({});
|
||
const [reminderTimes, setReminderTimes] = useState(3);
|
||
const [finishing, setFinishing] = useState(false);
|
||
|
||
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() {
|
||
if (finishing) return;
|
||
setFinishing(true);
|
||
try {
|
||
// 用户选择每日次数 > 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<string, unknown>,
|
||
});
|
||
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();
|
||
// iOS 可能出现 provisional(临时授权),也应视为“已授权”
|
||
if (status !== 'granted' && status !== ('provisional' as any)) {
|
||
await setPushPromptState('skipped');
|
||
return;
|
||
}
|
||
|
||
// iOS 模拟器通常无法获取 Expo Push Token(系统限制),此时不要提示“失败”,而是明确告知需要真机测试。
|
||
if (Device.osName === 'iOS' && !Device.isDevice) {
|
||
Alert.alert('提示', '当前为 iOS 模拟器,无法获取推送 Token。请使用真机测试推送功能。');
|
||
await setPushPromptState('unknown');
|
||
return;
|
||
}
|
||
|
||
// 1) 上报 token 到后端(幂等;失败才认为“推送开启失败”)
|
||
await ensurePushTokenRegisteredIfPermitted();
|
||
|
||
// 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');
|
||
}
|
||
} catch (e) {
|
||
// 极端情况下(例如本地存储/初始化异常)避免卡死在 loading:提示并允许用户重试
|
||
const msg = e instanceof Error ? e.message : String(e);
|
||
console.warn('[OnboardingFinish] 异常:', msg);
|
||
Alert.alert(t('push.errorTitle'), t('push.errorDesc'));
|
||
setFinishing(false);
|
||
}
|
||
}
|
||
|
||
const onNext = () => {
|
||
if (finishing) return;
|
||
if (stepIndex < STEPS.length - 1) {
|
||
setStepIndex(stepIndex + 1);
|
||
} else {
|
||
onFinish();
|
||
}
|
||
};
|
||
|
||
const onBack = () => {
|
||
if (finishing) return;
|
||
if (stepIndex > 0) {
|
||
setStepIndex(stepIndex - 1);
|
||
}
|
||
};
|
||
|
||
/** 只跳過當前這一步(不填/不選當前題,進入下一步) */
|
||
const handleSkipCurrentStep = () => {
|
||
if (finishing) return;
|
||
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 = () => {
|
||
if (finishing) return;
|
||
setSelections((prev) => ({ ...prev, [currentStep.id]: [] }));
|
||
onNext();
|
||
};
|
||
|
||
return (
|
||
<OnboardingLayout
|
||
title={currentTitle}
|
||
currentStep={stepIndex}
|
||
totalSteps={STEPS.length - 1}
|
||
onSkip={handleSkipCurrentStep}
|
||
onBack={onBack}
|
||
showBackButton={stepIndex > 0}
|
||
userName={name}
|
||
>
|
||
{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={Math.max(1, reminderTimes)}
|
||
onChange={setReminderTimes}
|
||
onFinish={onFinish}
|
||
loading={finishing}
|
||
onSkip={() => {
|
||
// 跳过每日提醒:视为 0 次(关闭)
|
||
setReminderTimes(0);
|
||
onFinish();
|
||
}}
|
||
/>
|
||
)}
|
||
</OnboardingLayout>
|
||
);
|
||
}
|