feat(onboarding): back-button between steps + language switcher on welcome
Back-Button: - OnboardingNavContext liefert der Shell einen optionalen goBack-Handler (kein prop-drilling durch 8 Slides). - OnboardingShell: chevron-back links neben der Progress-Bar wenn goBack gesetzt ist. - Controller: goToLinearPrevious() + BACK_ALLOWED-Liste. Back nur auf privacy/nickname/diga_choice/plan/payment — NICHT welcome (erste), done (final), diga_code (eigener onBack), protection (Backend-Step + Permission-Flow). Language-Switcher: - WelcomeSlide: 4 Sprach-Pills (DE/EN/FR/AR) oben rechts. User kommt während Onboarding nicht zu Settings — sonst kein Weg die Sprache zu wechseln. setLanguage persistiert + flippt RTL für AR.
This commit is contained in:
parent
34005803da
commit
312c668ae9
@ -11,6 +11,7 @@ import { PlanSlide } from '../../components/onboarding/slides/PlanSlide';
|
||||
import { PaymentSlide } from '../../components/onboarding/slides/PaymentSlide';
|
||||
import { ProtectionSlide } from '../../components/onboarding/slides/ProtectionSlide';
|
||||
import { DoneSlide } from '../../components/onboarding/slides/DoneSlide';
|
||||
import { OnboardingNavProvider } from '../../components/onboarding/OnboardingNavContext';
|
||||
|
||||
/**
|
||||
* Duo-Style Onboarding — single route, state-machine intern.
|
||||
@ -97,6 +98,18 @@ export default function OnboardingScreen() {
|
||||
setSlide(LINEAR_ORDER[idx + 1]);
|
||||
}
|
||||
|
||||
function goToLinearPrevious() {
|
||||
const idx = LINEAR_ORDER.indexOf(slide);
|
||||
if (idx <= 0) return;
|
||||
setSlide(LINEAR_ORDER[idx - 1]);
|
||||
}
|
||||
|
||||
// Back erlaubt nur auf reinen Info-/Auswahl-Slides. NICHT auf:
|
||||
// welcome (erste), done (final), diga_code (hat eigenen onBack),
|
||||
// protection (interne Phasen + persistierter Backend-Step + Permission-Flow).
|
||||
const BACK_ALLOWED: Slide[] = ['privacy', 'nickname', 'diga_choice', 'plan', 'payment'];
|
||||
const canGoBack = BACK_ALLOWED.includes(slide);
|
||||
|
||||
function exitToApp() {
|
||||
router.replace('/(app)');
|
||||
}
|
||||
@ -135,42 +148,50 @@ export default function OnboardingScreen() {
|
||||
|
||||
// Slide-Dispatch ────────────────────────────────────────────────────────────
|
||||
|
||||
switch (slide) {
|
||||
case 'welcome':
|
||||
return <WelcomeSlide onNext={goToLinearNext} current={current} total={total} />;
|
||||
case 'privacy':
|
||||
return <PrivacySlide onNext={goToLinearNext} current={current} total={total} />;
|
||||
case 'nickname':
|
||||
return <NicknameSlide onNext={goToLinearNext} current={current} total={total} />;
|
||||
case 'diga_choice':
|
||||
return (
|
||||
<DigaChoiceSlide
|
||||
onYes={onDigaYes}
|
||||
onNo={onDigaNo}
|
||||
current={current}
|
||||
total={total}
|
||||
/>
|
||||
);
|
||||
case 'diga_code':
|
||||
return (
|
||||
<DigaCodeSlide
|
||||
onSuccess={onDigaCodeSuccess}
|
||||
onBack={() => setSlide('diga_choice')}
|
||||
current={current}
|
||||
total={total}
|
||||
/>
|
||||
);
|
||||
case 'plan':
|
||||
return <PlanSlide onChosen={onPlanChosen} current={current} total={total} />;
|
||||
case 'payment':
|
||||
return (
|
||||
<PaymentSlide onCompleted={goToLinearNext} current={current} total={total} />
|
||||
);
|
||||
case 'protection':
|
||||
return (
|
||||
<ProtectionSlide onDone={goToLinearNext} current={current} total={total} />
|
||||
);
|
||||
case 'done':
|
||||
return <DoneSlide onEnter={exitToApp} current={current} total={total} />;
|
||||
function renderSlide() {
|
||||
switch (slide) {
|
||||
case 'welcome':
|
||||
return <WelcomeSlide onNext={goToLinearNext} current={current} total={total} />;
|
||||
case 'privacy':
|
||||
return <PrivacySlide onNext={goToLinearNext} current={current} total={total} />;
|
||||
case 'nickname':
|
||||
return <NicknameSlide onNext={goToLinearNext} current={current} total={total} />;
|
||||
case 'diga_choice':
|
||||
return (
|
||||
<DigaChoiceSlide
|
||||
onYes={onDigaYes}
|
||||
onNo={onDigaNo}
|
||||
current={current}
|
||||
total={total}
|
||||
/>
|
||||
);
|
||||
case 'diga_code':
|
||||
return (
|
||||
<DigaCodeSlide
|
||||
onSuccess={onDigaCodeSuccess}
|
||||
onBack={() => setSlide('diga_choice')}
|
||||
current={current}
|
||||
total={total}
|
||||
/>
|
||||
);
|
||||
case 'plan':
|
||||
return <PlanSlide onChosen={onPlanChosen} current={current} total={total} />;
|
||||
case 'payment':
|
||||
return (
|
||||
<PaymentSlide onCompleted={goToLinearNext} current={current} total={total} />
|
||||
);
|
||||
case 'protection':
|
||||
return (
|
||||
<ProtectionSlide onDone={goToLinearNext} current={current} total={total} />
|
||||
);
|
||||
case 'done':
|
||||
return <DoneSlide onEnter={exitToApp} current={current} total={total} />;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<OnboardingNavProvider goBack={canGoBack ? goToLinearPrevious : null}>
|
||||
{renderSlide()}
|
||||
</OnboardingNavProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@ -0,0 +1,30 @@
|
||||
import { createContext, useContext, type ReactNode } from 'react';
|
||||
|
||||
/**
|
||||
* Stellt der OnboardingShell einen optionalen Back-Handler bereit, ohne
|
||||
* `onBack` durch alle 8 Slide-Komponenten prop-drillen zu müssen.
|
||||
*
|
||||
* Controller (app/onboarding/index.tsx) entscheidet pro Slide ob ein Back
|
||||
* möglich ist (welcome = erste Slide, done = final → kein Back) und liefert
|
||||
* entsprechend `goBack` oder `null`.
|
||||
*/
|
||||
const OnboardingBackContext = createContext<(() => void) | null>(null);
|
||||
|
||||
export function OnboardingNavProvider({
|
||||
goBack,
|
||||
children,
|
||||
}: {
|
||||
goBack: (() => void) | null;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<OnboardingBackContext.Provider value={goBack}>
|
||||
{children}
|
||||
</OnboardingBackContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/** Gibt den Back-Handler zurück, oder null wenn die aktuelle Slide kein Back erlaubt. */
|
||||
export function useOnboardingBack(): (() => void) | null {
|
||||
return useContext(OnboardingBackContext);
|
||||
}
|
||||
@ -1,8 +1,10 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { ScrollView, View } from 'react-native';
|
||||
import { ScrollView, TouchableOpacity, View } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useColors } from '../../lib/theme';
|
||||
import { SlideProgress } from './SlideProgress';
|
||||
import { useOnboardingBack } from './OnboardingNavContext';
|
||||
|
||||
/**
|
||||
* Layout-Wrapper für alle Onboarding-Slides.
|
||||
@ -32,6 +34,7 @@ export function OnboardingShell({
|
||||
}) {
|
||||
const colors = useColors();
|
||||
const insets = useSafeAreaInsets();
|
||||
const goBack = useOnboardingBack();
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: colors.bg }}>
|
||||
@ -40,9 +43,31 @@ export function OnboardingShell({
|
||||
paddingTop: insets.top + 12,
|
||||
paddingHorizontal: 20,
|
||||
paddingBottom: 8,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<SlideProgress current={current} total={total} />
|
||||
{goBack ? (
|
||||
<TouchableOpacity
|
||||
onPress={goBack}
|
||||
activeOpacity={0.6}
|
||||
hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 10,
|
||||
backgroundColor: colors.surfaceElevated,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Ionicons name="chevron-back" size={20} color={colors.text} />
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
<View style={{ flex: 1 }}>
|
||||
<SlideProgress current={current} total={total} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
|
||||
@ -1,11 +1,59 @@
|
||||
import { Text, View } from 'react-native';
|
||||
import { Text, TouchableOpacity, View } from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useColors } from '../../../lib/theme';
|
||||
import { useLanguageStore, type AppLanguage } from '../../../stores/language';
|
||||
import { OnboardingShell } from '../OnboardingShell';
|
||||
import { LyraBubble } from '../LyraBubble';
|
||||
import { CTABar } from '../CTABar';
|
||||
|
||||
const LANGS: { code: AppLanguage; label: string }[] = [
|
||||
{ code: 'de', label: 'DE' },
|
||||
{ code: 'en', label: 'EN' },
|
||||
{ code: 'fr', label: 'FR' },
|
||||
{ code: 'ar', label: 'AR' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Kompakter Sprach-Umschalter — nur auf der Welcome-Slide. Während des
|
||||
* Onboardings kommt der User nicht zu Settings, kann die Sprache also sonst
|
||||
* nirgends ändern. setLanguage persistiert in AsyncStorage + flippt RTL für AR.
|
||||
*/
|
||||
function LanguagePills({ colors }: { colors: import('../../../lib/theme').ColorScheme }) {
|
||||
const language = useLanguageStore((s) => s.language);
|
||||
const setLanguage = useLanguageStore((s) => s.setLanguage);
|
||||
return (
|
||||
<View style={{ flexDirection: 'row', gap: 8, justifyContent: 'flex-end' }}>
|
||||
{LANGS.map((l) => {
|
||||
const active = language === l.code;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={l.code}
|
||||
onPress={() => setLanguage(l.code)}
|
||||
activeOpacity={0.7}
|
||||
style={{
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 8,
|
||||
backgroundColor: active ? colors.brandOrange : colors.surfaceElevated,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontFamily: 'Nunito_700Bold',
|
||||
fontSize: 13,
|
||||
color: active ? '#ffffff' : colors.textMuted,
|
||||
}}
|
||||
>
|
||||
{l.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Slide 1: Lyra stellt sich vor + erklärt die Mission in einem Satz.
|
||||
* Hat den langen Empathie-Touch, weil's der erste Eindruck nach Signup ist.
|
||||
@ -28,6 +76,10 @@ export function WelcomeSlide({
|
||||
total={total}
|
||||
cta={<CTABar primaryLabel={t('onboarding.welcome.cta_primary')} onPrimary={onNext} />}
|
||||
>
|
||||
<View style={{ marginBottom: 16 }}>
|
||||
<LanguagePills colors={colors} />
|
||||
</View>
|
||||
|
||||
<LyraBubble text={t('onboarding.lyra.welcome.body')} emotion="happy" />
|
||||
|
||||
<View style={{ marginTop: 28, gap: 12 }}>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user