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:
chahinebrini 2026-05-20 04:20:22 +02:00
parent 34005803da
commit 312c668ae9
4 changed files with 168 additions and 40 deletions

View File

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

View File

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

View File

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

View File

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