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 { PaymentSlide } from '../../components/onboarding/slides/PaymentSlide';
|
||||||
import { ProtectionSlide } from '../../components/onboarding/slides/ProtectionSlide';
|
import { ProtectionSlide } from '../../components/onboarding/slides/ProtectionSlide';
|
||||||
import { DoneSlide } from '../../components/onboarding/slides/DoneSlide';
|
import { DoneSlide } from '../../components/onboarding/slides/DoneSlide';
|
||||||
|
import { OnboardingNavProvider } from '../../components/onboarding/OnboardingNavContext';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Duo-Style Onboarding — single route, state-machine intern.
|
* Duo-Style Onboarding — single route, state-machine intern.
|
||||||
@ -97,6 +98,18 @@ export default function OnboardingScreen() {
|
|||||||
setSlide(LINEAR_ORDER[idx + 1]);
|
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() {
|
function exitToApp() {
|
||||||
router.replace('/(app)');
|
router.replace('/(app)');
|
||||||
}
|
}
|
||||||
@ -135,42 +148,50 @@ export default function OnboardingScreen() {
|
|||||||
|
|
||||||
// Slide-Dispatch ────────────────────────────────────────────────────────────
|
// Slide-Dispatch ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
switch (slide) {
|
function renderSlide() {
|
||||||
case 'welcome':
|
switch (slide) {
|
||||||
return <WelcomeSlide onNext={goToLinearNext} current={current} total={total} />;
|
case 'welcome':
|
||||||
case 'privacy':
|
return <WelcomeSlide onNext={goToLinearNext} current={current} total={total} />;
|
||||||
return <PrivacySlide onNext={goToLinearNext} current={current} total={total} />;
|
case 'privacy':
|
||||||
case 'nickname':
|
return <PrivacySlide onNext={goToLinearNext} current={current} total={total} />;
|
||||||
return <NicknameSlide onNext={goToLinearNext} current={current} total={total} />;
|
case 'nickname':
|
||||||
case 'diga_choice':
|
return <NicknameSlide onNext={goToLinearNext} current={current} total={total} />;
|
||||||
return (
|
case 'diga_choice':
|
||||||
<DigaChoiceSlide
|
return (
|
||||||
onYes={onDigaYes}
|
<DigaChoiceSlide
|
||||||
onNo={onDigaNo}
|
onYes={onDigaYes}
|
||||||
current={current}
|
onNo={onDigaNo}
|
||||||
total={total}
|
current={current}
|
||||||
/>
|
total={total}
|
||||||
);
|
/>
|
||||||
case 'diga_code':
|
);
|
||||||
return (
|
case 'diga_code':
|
||||||
<DigaCodeSlide
|
return (
|
||||||
onSuccess={onDigaCodeSuccess}
|
<DigaCodeSlide
|
||||||
onBack={() => setSlide('diga_choice')}
|
onSuccess={onDigaCodeSuccess}
|
||||||
current={current}
|
onBack={() => setSlide('diga_choice')}
|
||||||
total={total}
|
current={current}
|
||||||
/>
|
total={total}
|
||||||
);
|
/>
|
||||||
case 'plan':
|
);
|
||||||
return <PlanSlide onChosen={onPlanChosen} current={current} total={total} />;
|
case 'plan':
|
||||||
case 'payment':
|
return <PlanSlide onChosen={onPlanChosen} current={current} total={total} />;
|
||||||
return (
|
case 'payment':
|
||||||
<PaymentSlide onCompleted={goToLinearNext} current={current} total={total} />
|
return (
|
||||||
);
|
<PaymentSlide onCompleted={goToLinearNext} current={current} total={total} />
|
||||||
case 'protection':
|
);
|
||||||
return (
|
case 'protection':
|
||||||
<ProtectionSlide onDone={goToLinearNext} current={current} total={total} />
|
return (
|
||||||
);
|
<ProtectionSlide onDone={goToLinearNext} current={current} total={total} />
|
||||||
case 'done':
|
);
|
||||||
return <DoneSlide onEnter={exitToApp} 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 { 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 { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useColors } from '../../lib/theme';
|
import { useColors } from '../../lib/theme';
|
||||||
import { SlideProgress } from './SlideProgress';
|
import { SlideProgress } from './SlideProgress';
|
||||||
|
import { useOnboardingBack } from './OnboardingNavContext';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Layout-Wrapper für alle Onboarding-Slides.
|
* Layout-Wrapper für alle Onboarding-Slides.
|
||||||
@ -32,6 +34,7 @@ export function OnboardingShell({
|
|||||||
}) {
|
}) {
|
||||||
const colors = useColors();
|
const colors = useColors();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
const goBack = useOnboardingBack();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ flex: 1, backgroundColor: colors.bg }}>
|
<View style={{ flex: 1, backgroundColor: colors.bg }}>
|
||||||
@ -40,9 +43,31 @@ export function OnboardingShell({
|
|||||||
paddingTop: insets.top + 12,
|
paddingTop: insets.top + 12,
|
||||||
paddingHorizontal: 20,
|
paddingHorizontal: 20,
|
||||||
paddingBottom: 8,
|
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>
|
</View>
|
||||||
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
|
|||||||
@ -1,11 +1,59 @@
|
|||||||
import { Text, View } from 'react-native';
|
import { Text, TouchableOpacity, View } from 'react-native';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useColors } from '../../../lib/theme';
|
import { useColors } from '../../../lib/theme';
|
||||||
|
import { useLanguageStore, type AppLanguage } from '../../../stores/language';
|
||||||
import { OnboardingShell } from '../OnboardingShell';
|
import { OnboardingShell } from '../OnboardingShell';
|
||||||
import { LyraBubble } from '../LyraBubble';
|
import { LyraBubble } from '../LyraBubble';
|
||||||
import { CTABar } from '../CTABar';
|
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.
|
* 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.
|
* Hat den langen Empathie-Touch, weil's der erste Eindruck nach Signup ist.
|
||||||
@ -28,6 +76,10 @@ export function WelcomeSlide({
|
|||||||
total={total}
|
total={total}
|
||||||
cta={<CTABar primaryLabel={t('onboarding.welcome.cta_primary')} onPrimary={onNext} />}
|
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" />
|
<LyraBubble text={t('onboarding.lyra.welcome.body')} emotion="happy" />
|
||||||
|
|
||||||
<View style={{ marginTop: 28, gap: 12 }}>
|
<View style={{ marginTop: 28, gap: 12 }}>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user