feat(auth): onboarding flow + local auth (no backend)

Full local onboarding + auth stack (AsyncStorage only) :

types/auth.ts : User + AuthState
services/auth/
  - authStorage.ts : AsyncStorage wrapper avec clés vineye:auth:{user,onboarding-done,terms-accepted-at}
  - randomUser.ts : generateGuestUser() avec préfixes localisés FR/EN
    (Sommelier/Vendangeur/Caviste/... + suffix Anonyme/Anonymous + #XXXX)
  - authValidation.ts : Zod schema factory pour name/email
contexts/AuthContext.tsx : Provider + hook useAuth() avec login(),
  loginAsGuest(), logout(), resetAccount(), acceptTerms(),
  completeOnboarding(), isLoading hydraté au mount

components/onboarding/ :
  - OnboardingButton : variants primary/secondary, loading, disabled
  - TermsCheckbox : checkbox custom Reanimated avec scale + opacity anim
  - EmailNameForm : form contrôlé avec validation Zod live + erreurs
    affichées sous chaque champ après touch

screens/onboarding/ :
  - WelcomeScreen : logo + 3 feature cards (Camera/Leaf/Brain) FadeInDown
    stagger + CTA "Commencer"
  - TermsScreen : 5 sections CGU (usage, dataCollected avec mention
    explicite de la géoloc, responsibility, intellectualProperty, contact)
    + footer fixe avec checkbox + bouton continuer disabled tant que pas
    coché
  - AuthChoiceScreen : EmailNameForm + séparateur "ou" + bouton secondary
    "Continuer en invité"

navigation/ :
  - OnboardingNavigator : Stack Welcome -> Terms -> AuthChoice (animation
    fade)
  - RootNavigator : switch dynamique via useAuth().isOnboardingComplete ;
    isLoading -> ActivityIndicator centré ; sinon render conditionnel
    Onboarding ou Main

App.tsx : AuthProvider wrap autour de NetworkProvider

deps : zod ^4.4.1, expo-crypto ~15.0.9 (pour Crypto.randomUUID())

i18n FR + EN : blocs onboarding.{welcome,terms,authChoice} + auth.errors
+ settings.{account,language} (utilisés par le commit suivant)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yanis 2026-05-01 09:33:27 +02:00
parent bdbdcd7b85
commit 05e28b3ebc
19 changed files with 1060 additions and 12 deletions

View file

@ -8,6 +8,7 @@ import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { SafeAreaProvider } from 'react-native-safe-area-context'; import { SafeAreaProvider } from 'react-native-safe-area-context';
import { Toaster } from 'sonner-native'; import { Toaster } from 'sonner-native';
import { PortalHost } from '@rn-primitives/portal'; import { PortalHost } from '@rn-primitives/portal';
import { AuthProvider } from '@/contexts/AuthContext';
import { NetworkProvider } from '@/contexts/NetworkContext'; import { NetworkProvider } from '@/contexts/NetworkContext';
import { NetworkToastWatcher } from '@/contexts/ToastContext'; import { NetworkToastWatcher } from '@/contexts/ToastContext';
import RootNavigator from '@/navigation/RootNavigator'; import RootNavigator from '@/navigation/RootNavigator';
@ -22,14 +23,16 @@ export default function App() {
return ( return (
<GestureHandlerRootView style={{ flex: 1 }}> <GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaProvider> <SafeAreaProvider>
<NetworkProvider> <AuthProvider>
<NetworkToastWatcher> <NetworkProvider>
<StatusBar style="dark" translucent backgroundColor="transparent" /> <NetworkToastWatcher>
<RootNavigator /> <StatusBar style="dark" translucent backgroundColor="transparent" />
<PortalHost /> <RootNavigator />
<Toaster position="bottom-center" offset={120} /> <PortalHost />
</NetworkToastWatcher> <Toaster position="bottom-center" offset={120} />
</NetworkProvider> </NetworkToastWatcher>
</NetworkProvider>
</AuthProvider>
</SafeAreaProvider> </SafeAreaProvider>
</GestureHandlerRootView> </GestureHandlerRootView>
); );

View file

@ -24,6 +24,7 @@
"expo": "~54.0.33", "expo": "~54.0.33",
"expo-camera": "~17.0.10", "expo-camera": "~17.0.10",
"expo-constants": "~18.0.13", "expo-constants": "~18.0.13",
"expo-crypto": "~15.0.9",
"expo-dev-client": "~6.0.21", "expo-dev-client": "~6.0.21",
"expo-haptics": "~15.0.8", "expo-haptics": "~15.0.8",
"expo-image": "~3.0.11", "expo-image": "~3.0.11",
@ -54,7 +55,8 @@
"react-native-worklets": "0.5.1", "react-native-worklets": "0.5.1",
"sonner-native": "^0.24.0", "sonner-native": "^0.24.0",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tailwindcss": "3.4.17" "tailwindcss": "3.4.17",
"zod": "^4.4.1"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "~19.1.0", "@types/react": "~19.1.0",

View file

@ -53,6 +53,9 @@ importers:
expo-constants: expo-constants:
specifier: ~18.0.13 specifier: ~18.0.13
version: 18.0.13(expo@54.0.33(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0)) version: 18.0.13(expo@54.0.33(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))
expo-crypto:
specifier: ~15.0.9
version: 15.0.9(expo@54.0.33(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))
expo-dev-client: expo-dev-client:
specifier: ~6.0.21 specifier: ~6.0.21
version: 6.0.21(expo@54.0.33(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)) version: 6.0.21(expo@54.0.33(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))
@ -146,6 +149,9 @@ importers:
tailwindcss: tailwindcss:
specifier: 3.4.17 specifier: 3.4.17
version: 3.4.17 version: 3.4.17
zod:
specifier: ^4.4.1
version: 4.4.1
devDependencies: devDependencies:
'@types/react': '@types/react':
specifier: ~19.1.0 specifier: ~19.1.0
@ -1729,6 +1735,11 @@ packages:
expo: '*' expo: '*'
react-native: '*' react-native: '*'
expo-crypto@15.0.9:
resolution: {integrity: sha512-SNWKa2fXx7v9gkp1h/7nqXY5XN7qgNDn3yRc2aO0gWGbeMbvob/haMxxsPFe9f51aqH5NjNCqHf2kvLhvAd8KQ==}
peerDependencies:
expo: '*'
expo-dev-client@6.0.21: expo-dev-client@6.0.21:
resolution: {integrity: sha512-SWI6HD0pa4eJujkYFkvvpezUE1zmJXGLu+34azpu7+QJgO+FLutDYDj8BSTdeH/NYDEClDFjCGqVMcWETvmsCQ==} resolution: {integrity: sha512-SWI6HD0pa4eJujkYFkvvpezUE1zmJXGLu+34azpu7+QJgO+FLutDYDj8BSTdeH/NYDEClDFjCGqVMcWETvmsCQ==}
peerDependencies: peerDependencies:
@ -3559,6 +3570,9 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'} engines: {node: '>=10'}
zod@4.4.1:
resolution: {integrity: sha512-a6ENMBBGZBsnlSebQ/eKCguSBeGKSf4O7BPnqVPmYGtpBYI7VSqoVqw+QcB7kPRjbqPwhYTpFbVj/RqNz/CT0Q==}
zustand@5.0.12: zustand@5.0.12:
resolution: {integrity: sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==} resolution: {integrity: sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==}
engines: {node: '>=12.20.0'} engines: {node: '>=12.20.0'}
@ -4829,9 +4843,7 @@ snapshots:
metro-runtime: 0.83.5 metro-runtime: 0.83.5
transitivePeerDependencies: transitivePeerDependencies:
- '@babel/core' - '@babel/core'
- bufferutil
- supports-color - supports-color
- utf-8-validate
optional: true optional: true
'@react-native/normalize-colors@0.74.89': {} '@react-native/normalize-colors@0.74.89': {}
@ -5597,6 +5609,11 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
expo-crypto@15.0.9(expo@54.0.33(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)):
dependencies:
base64-js: 1.5.1
expo: 54.0.33(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
expo-dev-client@6.0.21(expo@54.0.33(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)): expo-dev-client@6.0.21(expo@54.0.33(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)):
dependencies: dependencies:
expo: 54.0.33(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) expo: 54.0.33(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
@ -7574,6 +7591,8 @@ snapshots:
yocto-queue@0.1.0: {} yocto-queue@0.1.0: {}
zod@4.4.1: {}
zustand@5.0.12(@types/react@19.1.17)(react@19.1.0)(use-sync-external-store@1.6.0(react@19.1.0)): zustand@5.0.12(@types/react@19.1.17)(react@19.1.0)(use-sync-external-store@1.6.0(react@19.1.0)):
optionalDependencies: optionalDependencies:
'@types/react': 19.1.17 '@types/react': 19.1.17

View file

@ -0,0 +1,128 @@
import { useEffect, useMemo, useState } from 'react';
import { TextInput, View } from 'react-native';
import { useTranslation } from 'react-i18next';
import { User as UserIcon, Mail } from 'lucide-react-native';
import { Text } from '@/components/ui/text';
import { OnboardingButton } from '@/components/onboarding/OnboardingButton';
import { userFormSchema, type UserFormInput } from '@/services/auth/authValidation';
import { colors } from '@/theme/colors';
interface EmailNameFormProps {
onSubmit: (values: UserFormInput) => void | Promise<void>;
submitting?: boolean;
}
interface FieldErrors {
name?: string;
email?: string;
}
export function EmailNameForm({
onSubmit,
submitting = false,
}: EmailNameFormProps) {
const { t } = useTranslation();
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [errors, setErrors] = useState<FieldErrors>({});
const [touched, setTouched] = useState<{ name: boolean; email: boolean }>({
name: false,
email: false,
});
const schema = useMemo(() => userFormSchema(t), [t]);
// Live validation
useEffect(() => {
const result = schema.safeParse({ name, email });
if (result.success) {
setErrors({});
} else {
const next: FieldErrors = {};
for (const issue of result.error.issues) {
const key = issue.path[0];
if ((key === 'name' || key === 'email') && !next[key]) {
next[key] = issue.message;
}
}
setErrors(next);
}
}, [name, email, schema]);
const isValid = Object.keys(errors).length === 0 && name.length > 0 && email.length > 0;
function handleSubmit() {
setTouched({ name: true, email: true });
if (!isValid) return;
void onSubmit({ name: name.trim(), email: email.trim() });
}
return (
<View className="gap-3 w-full">
{/* Name */}
<View>
<View className="flex-row items-center gap-3 bg-white rounded-2xl px-4 py-4 border border-gray-100">
<UserIcon size={18} color={colors.neutral[500]} />
<TextInput
value={name}
onChangeText={setName}
onBlur={() => setTouched((p) => ({ ...p, name: true }))}
placeholder={t('onboarding.authChoice.namePlaceholder')}
placeholderTextColor={colors.neutral[400]}
autoCapitalize="words"
autoCorrect={false}
maxLength={50}
className="flex-1 text-[15px] text-[#1A1A1A]"
style={{
paddingVertical: 0,
textAlignVertical: 'center',
includeFontPadding: false,
}}
/>
</View>
{touched.name && errors.name ? (
<Text className="mt-1 ml-1 text-xs text-red-600">{errors.name}</Text>
) : null}
</View>
{/* Email */}
<View>
<View className="flex-row items-center gap-3 bg-white rounded-2xl px-4 py-4 border border-gray-100">
<Mail size={18} color={colors.neutral[500]} />
<TextInput
value={email}
onChangeText={setEmail}
onBlur={() => setTouched((p) => ({ ...p, email: true }))}
placeholder={t('onboarding.authChoice.emailPlaceholder')}
placeholderTextColor={colors.neutral[400]}
keyboardType="email-address"
autoCapitalize="none"
autoCorrect={false}
maxLength={128}
className="flex-1 text-[15px] text-[#1A1A1A]"
style={{
paddingVertical: 0,
textAlignVertical: 'center',
includeFontPadding: false,
}}
/>
</View>
{touched.email && errors.email ? (
<Text className="mt-1 ml-1 text-xs text-red-600">{errors.email}</Text>
) : null}
</View>
<View className="mt-2">
<OnboardingButton
variant="primary"
disabled={!isValid}
loading={submitting}
onPress={handleSubmit}
>
{t('onboarding.authChoice.createAccount')}
</OnboardingButton>
</View>
</View>
);
}

View file

@ -0,0 +1,49 @@
import { ActivityIndicator, Pressable } from 'react-native';
import { Text } from '@/components/ui/text';
interface OnboardingButtonProps {
variant?: 'primary' | 'secondary';
disabled?: boolean;
loading?: boolean;
onPress: () => void;
children: React.ReactNode;
}
export function OnboardingButton({
variant = 'primary',
disabled = false,
loading = false,
onPress,
children,
}: OnboardingButtonProps) {
const isInactive = disabled || loading;
const baseClass = 'h-14 rounded-2xl items-center justify-center w-full';
const variantClass =
variant === 'primary'
? 'bg-[#2D6A4F]'
: 'bg-white border border-gray-200';
const labelClass =
variant === 'primary'
? 'text-white font-semibold text-[15px]'
: 'text-[#1A1A1A] font-semibold text-[15px]';
return (
<Pressable
onPress={onPress}
disabled={isInactive}
className={`${baseClass} ${variantClass} ${
isInactive ? 'opacity-40' : 'active:opacity-80'
}`}
>
{loading ? (
<ActivityIndicator
size="small"
color={variant === 'primary' ? '#FFFFFF' : '#1A1A1A'}
/>
) : (
<Text className={labelClass}>{children}</Text>
)}
</Pressable>
);
}

View file

@ -0,0 +1,53 @@
import { useEffect } from 'react';
import { Pressable, View } from 'react-native';
import Animated, {
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated';
import { Check } from 'lucide-react-native';
import { Text } from '@/components/ui/text';
interface TermsCheckboxProps {
checked: boolean;
onChange: (next: boolean) => void;
label: string;
}
export function TermsCheckbox({ checked, onChange, label }: TermsCheckboxProps) {
const scale = useSharedValue(checked ? 1 : 0);
const opacity = useSharedValue(checked ? 1 : 0);
useEffect(() => {
scale.value = withTiming(checked ? 1 : 0, { duration: 200 });
opacity.value = withTiming(checked ? 1 : 0, { duration: 200 });
}, [checked, scale, opacity]);
const checkAnim = useAnimatedStyle(() => ({
opacity: opacity.value,
transform: [{ scale: scale.value }],
}));
return (
<Pressable
onPress={() => onChange(!checked)}
className="flex-row items-center gap-3 active:opacity-70"
>
<View
className={`w-6 h-6 rounded-md border-2 items-center justify-center ${
checked
? 'bg-[#2D6A4F] border-[#2D6A4F]'
: 'bg-white border-gray-300'
}`}
>
<Animated.View style={checkAnim}>
<Check size={16} color="#FFFFFF" strokeWidth={3} />
</Animated.View>
</View>
<Text className="flex-1 text-[14px] text-[#1A1A1A] leading-5">
{label}
</Text>
</Pressable>
);
}

View file

@ -0,0 +1,121 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useState,
} from 'react';
import * as Crypto from 'expo-crypto';
import * as authStorage from '@/services/auth/authStorage';
import { generateGuestUser } from '@/services/auth/randomUser';
import type { User } from '@/types/auth';
interface AuthContextValue {
user: User | null;
isAuthenticated: boolean;
isOnboardingComplete: boolean;
hasAcceptedTerms: boolean;
isLoading: boolean;
login: (name: string, email: string) => Promise<void>;
loginAsGuest: () => Promise<void>;
logout: () => Promise<void>;
resetAccount: () => Promise<void>;
acceptTerms: () => Promise<void>;
completeOnboarding: () => Promise<void>;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [isOnboardingComplete, setIsOnboardingComplete] = useState(false);
const [hasAcceptedTerms, setHasAcceptedTerms] = useState(false);
const [isLoading, setIsLoading] = useState(true);
// Hydrate from AsyncStorage on mount
useEffect(() => {
let alive = true;
(async () => {
try {
const [storedUser, onboarding, terms] = await Promise.all([
authStorage.getUser(),
authStorage.getOnboardingStatus(),
authStorage.getTermsAcceptance(),
]);
if (!alive) return;
setUser(storedUser);
setIsOnboardingComplete(onboarding);
setHasAcceptedTerms(terms.accepted);
} finally {
if (alive) setIsLoading(false);
}
})();
return () => {
alive = false;
};
}, []);
const login = useCallback(async (name: string, email: string) => {
const newUser: User = {
id: Crypto.randomUUID(),
name: name.trim(),
email: email.trim(),
isGuest: false,
createdAt: new Date().toISOString(),
};
await authStorage.saveUser(newUser);
setUser(newUser);
}, []);
const loginAsGuest = useCallback(async () => {
const newUser = generateGuestUser();
await authStorage.saveUser(newUser);
setUser(newUser);
}, []);
const logout = useCallback(async () => {
await authStorage.resetAuth();
setUser(null);
setIsOnboardingComplete(false);
setHasAcceptedTerms(false);
}, []);
const resetAccount = useCallback(async () => {
await logout();
}, [logout]);
const acceptTerms = useCallback(async () => {
await authStorage.setTermsAccepted();
setHasAcceptedTerms(true);
}, []);
const completeOnboarding = useCallback(async () => {
await authStorage.setOnboardingComplete();
setIsOnboardingComplete(true);
}, []);
const value: AuthContextValue = {
user,
isAuthenticated: !!user,
isOnboardingComplete,
hasAcceptedTerms,
isLoading,
login,
loginAsGuest,
logout,
resetAccount,
acceptTerms,
completeOnboarding,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) {
throw new Error('useAuth must be used within an <AuthProvider>');
}
return ctx;
}

View file

@ -523,6 +523,76 @@
"label": "Push notifications", "label": "Push notifications",
"enabled": "Notifications enabled", "enabled": "Notifications enabled",
"disabled": "Notifications disabled" "disabled": "Notifications disabled"
},
"language": {
"title": "Choose language",
"subtitle": "Select the application language."
},
"account": {
"sectionTitle": "Account",
"guestBadge": "Guest",
"resetAction": "Restart with a new account",
"resetConfirmTitle": "Restart?",
"resetConfirmMessage": "Your current account will be deleted. You will be redirected to the login screen to create a new account or continue as a guest.",
"resetConfirmCancel": "Cancel",
"resetConfirmOk": "Yes, log me out"
}
},
"auth": {
"errors": {
"nameTooShort": "Name must be at least 2 characters",
"nameTooLong": "Name is too long (max 50 characters)",
"emailInvalid": "Invalid email"
}
},
"onboarding": {
"welcome": {
"title": "Welcome to VinEye",
"subtitle": "AI for your vineyard. Identify diseases with a single scan.",
"feature1Title": "Quick scan",
"feature1Desc": "Photograph a leaf, get a diagnosis in seconds.",
"feature2Title": "Wine library",
"feature2Desc": "Diseases, grape varieties and practical guides at your fingertips.",
"feature3Title": "100% private",
"feature3Desc": "Your data stays on your phone, never sent to the internet.",
"cta": "Get started"
},
"terms": {
"title": "Terms of Use",
"checkboxLabel": "I have read and accept the terms of use",
"continueButton": "Continue",
"usage": {
"title": "1. Use of the application",
"body": "VinEye is a tool to help identify grapevine diseases. Results are indicative and based on a computer vision model. They do not replace a professional diagnosis from a certified œnologist or agronomist."
},
"dataCollected": {
"title": "2. Data collected",
"body": "VinEye runs 100% locally on your phone. Your name, email and scan history are stored only on your device and are never transmitted to the internet. If you enable geolocation when scanning, the GPS coordinates are saved locally with the scan so you can find your plants on the map."
},
"responsibility": {
"title": "3. Responsibility",
"body": "Diagnostics provided by VinEye are indicative. The publisher cannot be held responsible for treatment, removal or phytosanitary management decisions made on the basis of this information. For critical decisions, consult a professional."
},
"intellectualProperty": {
"title": "4. Intellectual property",
"body": "The VinEye app, its design, texts, AI model and logo are protected by intellectual property law. Any reproduction or commercial use without written authorization is prohibited."
},
"contact": {
"title": "5. Contact",
"body": "For any question, suggestion or bug report, contact the team at the email address indicated in the Help section. User feedback is essential to the continuous improvement of the application."
}
},
"authChoice": {
"title": "Create your identity",
"subtitle": "Your information stays on your phone, it is not sent to the internet.",
"nameLabel": "Name",
"namePlaceholder": "Your name or nickname",
"emailLabel": "Email",
"emailPlaceholder": "you@example.com",
"createAccount": "Create my account",
"or": "or",
"continueAsGuest": "Continue as guest",
"footerHint": "You can change your identity later in the settings."
} }
}, },
"achievements": { "achievements": {

View file

@ -523,6 +523,76 @@
"label": "Notifications push", "label": "Notifications push",
"enabled": "Notifications activées", "enabled": "Notifications activées",
"disabled": "Notifications désactivées" "disabled": "Notifications désactivées"
},
"language": {
"title": "Choisir la langue",
"subtitle": "Sélectionnez la langue de l'application."
},
"account": {
"sectionTitle": "Compte",
"guestBadge": "Invité",
"resetAction": "Recommencer avec un nouveau compte",
"resetConfirmTitle": "Recommencer ?",
"resetConfirmMessage": "Votre compte actuel sera supprimé. Vous serez redirigé vers l'écran de connexion pour créer un nouveau compte ou continuer en invité.",
"resetConfirmCancel": "Annuler",
"resetConfirmOk": "Oui, me déconnecter"
}
},
"auth": {
"errors": {
"nameTooShort": "Le nom doit faire au moins 2 caractères",
"nameTooLong": "Le nom est trop long (50 caractères max)",
"emailInvalid": "Email invalide"
}
},
"onboarding": {
"welcome": {
"title": "Bienvenue sur VinEye",
"subtitle": "L'IA au service de votre vigne. Identifiez les maladies en un scan.",
"feature1Title": "Scan rapide",
"feature1Desc": "Photographiez une feuille, obtenez un diagnostic en quelques secondes.",
"feature2Title": "Bibliothèque viticole",
"feature2Desc": "Maladies, cépages et conseils pratiques au creux de votre main.",
"feature3Title": "100% privé",
"feature3Desc": "Vos données restent sur votre téléphone, jamais envoyées sur internet.",
"cta": "Commencer"
},
"terms": {
"title": "Conditions d'utilisation",
"checkboxLabel": "J'ai lu et j'accepte les conditions d'utilisation",
"continueButton": "Continuer",
"usage": {
"title": "1. Utilisation de l'application",
"body": "VinEye est une application d'aide à l'identification de maladies de la vigne. Les résultats fournis sont indicatifs et basés sur un modèle de vision par ordinateur. Ils ne remplacent pas un diagnostic professionnel par un œnologue ou un agronome certifié."
},
"dataCollected": {
"title": "2. Données collectées",
"body": "VinEye fonctionne 100% en local sur votre téléphone. Votre nom, email et historique de scans sont stockés uniquement sur votre appareil et ne sont jamais transmis sur internet. Si vous activez la géolocalisation lors d'un scan, ses coordonnées GPS sont enregistrées localement avec le scan pour vous permettre de retrouver vos plantes sur la carte."
},
"responsibility": {
"title": "3. Responsabilité",
"body": "Les diagnostics fournis par VinEye sont indicatifs. L'éditeur ne saurait être tenu responsable des décisions de traitement, d'arrachage, ou de gestion phytosanitaire prises sur la base de ces informations. Pour toute décision critique, consultez un professionnel."
},
"intellectualProperty": {
"title": "4. Propriété intellectuelle",
"body": "L'application VinEye, son design, ses textes, son modèle d'IA et son logo sont protégés par le droit de la propriété intellectuelle. Toute reproduction ou utilisation commerciale sans autorisation écrite est interdite."
},
"contact": {
"title": "5. Contact",
"body": "Pour toute question, suggestion ou signalement de bug, contactez l'équipe à l'adresse email indiquée dans la rubrique Aide. Les retours utilisateurs sont essentiels à l'amélioration continue de l'application."
}
},
"authChoice": {
"title": "Créez votre identité",
"subtitle": "Vos informations restent sur votre téléphone, elles ne sont pas envoyées sur internet.",
"nameLabel": "Nom",
"namePlaceholder": "Votre nom ou pseudo",
"emailLabel": "Email",
"emailPlaceholder": "vous@exemple.com",
"createAccount": "Créer mon compte",
"or": "ou",
"continueAsGuest": "Continuer en invité",
"footerHint": "Vous pourrez changer d'identité plus tard dans les paramètres."
} }
}, },
"achievements": { "achievements": {

View file

@ -0,0 +1,25 @@
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import WelcomeScreen from '@/screens/onboarding/WelcomeScreen';
import TermsScreen from '@/screens/onboarding/TermsScreen';
import AuthChoiceScreen from '@/screens/onboarding/AuthChoiceScreen';
import type { OnboardingParamList } from '@/types/navigation';
const Stack = createNativeStackNavigator<OnboardingParamList>();
export default function OnboardingNavigator() {
return (
<Stack.Navigator
initialRouteName="Welcome"
screenOptions={{
headerShown: false,
animation: 'fade',
animationDuration: 300,
}}
>
<Stack.Screen name="Welcome" component={WelcomeScreen} />
<Stack.Screen name="Terms" component={TermsScreen} />
<Stack.Screen name="AuthChoice" component={AuthChoiceScreen} />
</Stack.Navigator>
);
}

View file

@ -1,3 +1,4 @@
import { ActivityIndicator, View } from 'react-native';
import { createNativeStackNavigator } from '@react-navigation/native-stack'; import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { NavigationContainer } from '@react-navigation/native'; import { NavigationContainer } from '@react-navigation/native';
@ -11,16 +12,36 @@ import DiseaseDetailScreen from '@/screens/DiseaseDetailScreen';
import GuideDetailScreen from '@/screens/GuideDetailScreen'; import GuideDetailScreen from '@/screens/GuideDetailScreen';
import ScanDetailScreen from '@/screens/ScanDetailScreen'; import ScanDetailScreen from '@/screens/ScanDetailScreen';
import BottomTabNavigator from './BottomTabNavigator'; import BottomTabNavigator from './BottomTabNavigator';
import OnboardingNavigator from './OnboardingNavigator';
import linking from './linking'; import linking from './linking';
import { useAuth } from '@/contexts/AuthContext';
import { colors } from '@/theme/colors';
import type { RootStackParamList } from '@/types/navigation'; import type { RootStackParamList } from '@/types/navigation';
const Stack = createNativeStackNavigator<RootStackParamList>(); const Stack = createNativeStackNavigator<RootStackParamList>();
export default function RootNavigator() { export default function RootNavigator() {
const { isLoading, isOnboardingComplete } = useAuth();
if (isLoading) {
return (
<View
style={{
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#F8F9FB',
}}
>
<ActivityIndicator size="large" color={colors.primary[700]} />
</View>
);
}
return ( return (
<NavigationContainer linking={linking}> <NavigationContainer linking={linking}>
<Stack.Navigator <Stack.Navigator
initialRouteName="Splash" initialRouteName={isOnboardingComplete ? 'Splash' : 'Onboarding'}
screenOptions={{ screenOptions={{
headerShown: false, headerShown: false,
animation: 'fade', animation: 'fade',
@ -29,6 +50,9 @@ export default function RootNavigator() {
gestureDirection: 'horizontal', gestureDirection: 'horizontal',
}} }}
> >
{!isOnboardingComplete && (
<Stack.Screen name="Onboarding" component={OnboardingNavigator} />
)}
<Stack.Screen name="Splash" component={SplashScreen} /> <Stack.Screen name="Splash" component={SplashScreen} />
<Stack.Screen name="Main" component={BottomTabNavigator} /> <Stack.Screen name="Main" component={BottomTabNavigator} />
<Stack.Screen <Stack.Screen

View file

@ -0,0 +1,118 @@
import { useState } from 'react';
import { View, ScrollView, Pressable, KeyboardAvoidingView, Platform } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useTranslation } from 'react-i18next';
import { ChevronLeft } from 'lucide-react-native';
import Animated, { FadeIn } from 'react-native-reanimated';
import { Text } from '@/components/ui/text';
import { EmailNameForm } from '@/components/onboarding/EmailNameForm';
import { OnboardingButton } from '@/components/onboarding/OnboardingButton';
import { useAuth } from '@/contexts/AuthContext';
import type { OnboardingParamList } from '@/types/navigation';
import { colors } from '@/theme/colors';
type Nav = NativeStackNavigationProp<OnboardingParamList, 'AuthChoice'>;
export default function AuthChoiceScreen() {
const { t } = useTranslation();
const navigation = useNavigation<Nav>();
const { login, loginAsGuest, completeOnboarding } = useAuth();
const [creating, setCreating] = useState(false);
const [guestLoading, setGuestLoading] = useState(false);
async function handleCreateAccount({
name,
email,
}: {
name: string;
email: string;
}) {
if (creating) return;
setCreating(true);
try {
await login(name, email);
await completeOnboarding();
} finally {
setCreating(false);
}
}
async function handleGuestLogin() {
if (guestLoading) return;
setGuestLoading(true);
try {
await loginAsGuest();
await completeOnboarding();
} finally {
setGuestLoading(false);
}
}
return (
<SafeAreaView className="flex-1 bg-[#F8F9FB]" edges={['top', 'bottom']}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
style={{ flex: 1 }}
>
{/* Header */}
<View className="flex-row items-center px-4 py-3 gap-2">
<Pressable
onPress={() => navigation.goBack()}
hitSlop={12}
className="w-10 h-10 rounded-full items-center justify-center bg-white border border-gray-200 active:opacity-70"
>
<ChevronLeft size={20} color={colors.neutral[800]} />
</Pressable>
</View>
<ScrollView
className="flex-1"
keyboardShouldPersistTaps="handled"
contentContainerStyle={{ padding: 24, paddingTop: 8 }}
showsVerticalScrollIndicator={false}
>
<Animated.View entering={FadeIn.duration(400)}>
<Text className="text-2xl font-bold text-[#1A1A1A]">
{t('onboarding.authChoice.title')}
</Text>
<Text className="mt-2 text-sm text-[#8E8E93] leading-5">
{t('onboarding.authChoice.subtitle')}
</Text>
<View className="mt-6">
<EmailNameForm
onSubmit={handleCreateAccount}
submitting={creating}
/>
</View>
{/* Séparateur */}
<View className="flex-row items-center my-6 gap-3">
<View className="flex-1 h-px bg-gray-200" />
<Text className="text-xs uppercase tracking-[1px] text-[#8E8E93]">
{t('onboarding.authChoice.or')}
</Text>
<View className="flex-1 h-px bg-gray-200" />
</View>
<OnboardingButton
variant="secondary"
loading={guestLoading}
onPress={handleGuestLogin}
>
{t('onboarding.authChoice.continueAsGuest')}
</OnboardingButton>
<Text className="mt-4 text-xs text-[#8E8E93] text-center leading-4">
{t('onboarding.authChoice.footerHint')}
</Text>
</Animated.View>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
}

View file

@ -0,0 +1,106 @@
import { useState } from 'react';
import { View, ScrollView, Pressable } from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useTranslation } from 'react-i18next';
import { ChevronLeft } from 'lucide-react-native';
import Animated, { FadeIn } from 'react-native-reanimated';
import { Text } from '@/components/ui/text';
import { OnboardingButton } from '@/components/onboarding/OnboardingButton';
import { TermsCheckbox } from '@/components/onboarding/TermsCheckbox';
import { useAuth } from '@/contexts/AuthContext';
import type { OnboardingParamList } from '@/types/navigation';
import { colors } from '@/theme/colors';
type Nav = NativeStackNavigationProp<OnboardingParamList, 'Terms'>;
const SECTION_KEYS = [
'usage',
'dataCollected',
'responsibility',
'intellectualProperty',
'contact',
] as const;
export default function TermsScreen() {
const { t } = useTranslation();
const navigation = useNavigation<Nav>();
const insets = useSafeAreaInsets();
const { acceptTerms } = useAuth();
const [accepted, setAccepted] = useState(false);
const [submitting, setSubmitting] = useState(false);
async function handleContinue() {
if (!accepted || submitting) return;
setSubmitting(true);
try {
await acceptTerms();
navigation.navigate('AuthChoice');
} finally {
setSubmitting(false);
}
}
return (
<SafeAreaView className="flex-1 bg-[#F8F9FB]" edges={['top']}>
{/* Header */}
<View className="flex-row items-center px-4 py-3 gap-2">
<Pressable
onPress={() => navigation.goBack()}
hitSlop={12}
className="w-10 h-10 rounded-full items-center justify-center bg-white border border-gray-200 active:opacity-70"
>
<ChevronLeft size={20} color={colors.neutral[800]} />
</Pressable>
<Text className="flex-1 text-[18px] font-bold text-[#1A1A1A] ml-1">
{t('onboarding.terms.title')}
</Text>
</View>
{/* Sections scrollable */}
<ScrollView
className="flex-1 px-5"
contentContainerStyle={{ paddingTop: 8, paddingBottom: 220 }}
showsVerticalScrollIndicator={false}
>
<Animated.View entering={FadeIn.duration(400)} className="gap-5">
{SECTION_KEYS.map((key) => (
<View key={key}>
<Text className="text-[16px] font-bold text-[#1A1A1A] mb-2">
{t(`onboarding.terms.${key}.title`)}
</Text>
<Text className="text-[14px] text-[#4A4A4A] leading-[22px]">
{t(`onboarding.terms.${key}.body`)}
</Text>
</View>
))}
</Animated.View>
</ScrollView>
{/* Footer fixe */}
<View
className="absolute bottom-0 left-0 right-0 bg-white border-t border-gray-100 px-5 pt-4"
style={{ paddingBottom: insets.bottom + 12 }}
>
<View className="mb-4">
<TermsCheckbox
checked={accepted}
onChange={setAccepted}
label={t('onboarding.terms.checkboxLabel')}
/>
</View>
<OnboardingButton
variant="primary"
disabled={!accepted}
loading={submitting}
onPress={handleContinue}
>
{t('onboarding.terms.continueButton')}
</OnboardingButton>
</View>
</SafeAreaView>
);
}

View file

@ -0,0 +1,102 @@
import { View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useTranslation } from 'react-i18next';
import { Image } from 'expo-image';
import { Camera, Leaf, Brain } from 'lucide-react-native';
import Animated, { FadeIn, FadeInDown } from 'react-native-reanimated';
import { Text } from '@/components/ui/text';
import { OnboardingButton } from '@/components/onboarding/OnboardingButton';
import type { OnboardingParamList } from '@/types/navigation';
import { colors } from '@/theme/colors';
type Nav = NativeStackNavigationProp<OnboardingParamList, 'Welcome'>;
const LOGO = require('../../assets/images/icon.png');
export default function WelcomeScreen() {
const { t } = useTranslation();
const navigation = useNavigation<Nav>();
const features = [
{
icon: Camera,
title: t('onboarding.welcome.feature1Title'),
desc: t('onboarding.welcome.feature1Desc'),
},
{
icon: Leaf,
title: t('onboarding.welcome.feature2Title'),
desc: t('onboarding.welcome.feature2Desc'),
},
{
icon: Brain,
title: t('onboarding.welcome.feature3Title'),
desc: t('onboarding.welcome.feature3Desc'),
},
];
return (
<SafeAreaView className="flex-1 bg-[#F8F9FB]" edges={['top', 'bottom']}>
<View className="flex-1 px-6 pt-8">
<Animated.View
entering={FadeIn.duration(500)}
className="items-center mb-8"
>
<Image
source={LOGO}
style={{ width: 96, height: 96 }}
contentFit="contain"
/>
</Animated.View>
<Animated.View entering={FadeInDown.delay(80).duration(500)}>
<Text className="text-3xl font-bold text-[#1A1A1A] text-center">
{t('onboarding.welcome.title')}
</Text>
<Text className="mt-3 text-base text-[#8E8E93] text-center leading-6">
{t('onboarding.welcome.subtitle')}
</Text>
</Animated.View>
<View className="mt-10 gap-3">
{features.map((f, i) => {
const Icon = f.icon;
return (
<Animated.View
key={f.title}
entering={FadeInDown.delay(160 + i * 90).duration(500)}
className="flex-row items-center gap-4 bg-white rounded-3xl p-5 border border-gray-100"
>
<View className="w-12 h-12 rounded-2xl bg-[#E8F5E9] items-center justify-center">
<Icon size={22} color={colors.primary[700]} strokeWidth={2.2} />
</View>
<View className="flex-1">
<Text className="text-[15px] font-semibold text-[#1A1A1A]">
{f.title}
</Text>
<Text className="mt-0.5 text-[13px] text-[#8E8E93] leading-5">
{f.desc}
</Text>
</View>
</Animated.View>
);
})}
</View>
<View className="flex-1" />
<Animated.View entering={FadeInDown.delay(500).duration(500)}>
<OnboardingButton
variant="primary"
onPress={() => navigation.navigate('Terms')}
>
{t('onboarding.welcome.cta')}
</OnboardingButton>
</Animated.View>
</View>
</SafeAreaView>
);
}

View file

@ -0,0 +1,76 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { User } from '@/types/auth';
const KEYS = {
USER: 'vineye:auth:user',
ONBOARDING: 'vineye:auth:onboarding-done',
TERMS: 'vineye:auth:terms-accepted-at',
} as const;
// === User ===
export async function getUser(): Promise<User | null> {
try {
const raw = await AsyncStorage.getItem(KEYS.USER);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<User>;
if (
typeof parsed.id !== 'string' ||
typeof parsed.name !== 'string' ||
typeof parsed.isGuest !== 'boolean' ||
typeof parsed.createdAt !== 'string'
) {
return null;
}
return {
id: parsed.id,
name: parsed.name,
email: typeof parsed.email === 'string' ? parsed.email : null,
isGuest: parsed.isGuest,
createdAt: parsed.createdAt,
};
} catch {
return null;
}
}
export async function saveUser(user: User): Promise<void> {
await AsyncStorage.setItem(KEYS.USER, JSON.stringify(user));
}
export async function clearUser(): Promise<void> {
await AsyncStorage.removeItem(KEYS.USER);
}
// === Onboarding ===
export async function getOnboardingStatus(): Promise<boolean> {
const v = await AsyncStorage.getItem(KEYS.ONBOARDING);
return v === 'true';
}
export async function setOnboardingComplete(): Promise<void> {
await AsyncStorage.setItem(KEYS.ONBOARDING, 'true');
}
// === Terms ===
export async function getTermsAcceptance(): Promise<{
accepted: boolean;
acceptedAt: string | null;
}> {
const v = await AsyncStorage.getItem(KEYS.TERMS);
if (!v) return { accepted: false, acceptedAt: null };
return { accepted: true, acceptedAt: v };
}
export async function setTermsAccepted(): Promise<void> {
await AsyncStorage.setItem(KEYS.TERMS, new Date().toISOString());
}
// === Reset ===
export async function resetAuth(): Promise<void> {
await AsyncStorage.multiRemove([KEYS.USER, KEYS.ONBOARDING, KEYS.TERMS]);
}

View file

@ -0,0 +1,17 @@
import { z } from 'zod';
export function userFormSchema(t: (key: string) => string) {
return z.object({
name: z
.string()
.trim()
.min(2, { message: t('auth.errors.nameTooShort') })
.max(50, { message: t('auth.errors.nameTooLong') }),
email: z
.string()
.trim()
.email({ message: t('auth.errors.emailInvalid') }),
});
}
export type UserFormInput = z.infer<ReturnType<typeof userFormSchema>>;

View file

@ -0,0 +1,41 @@
import * as Crypto from 'expo-crypto';
import i18next from 'i18next';
import type { User } from '@/types/auth';
const PREFIXES_FR = [
'Sommelier',
'Vendangeur',
'Caviste',
'Œnologue',
'Vigneron',
'Amateur',
'Maître de Chai',
'Tonnelier',
];
const PREFIXES_EN = [
'Sommelier',
'Wine Lover',
'Vintner',
'Cellar Master',
'Grape Picker',
'Wine Taster',
];
export function generateGuestUser(): User {
const lang = i18next.language?.startsWith('fr') ? 'fr' : 'en';
const prefixes = lang === 'fr' ? PREFIXES_FR : PREFIXES_EN;
const suffix = lang === 'fr' ? 'Anonyme' : 'Anonymous';
const prefix = prefixes[Math.floor(Math.random() * prefixes.length)];
const number = String(Math.floor(Math.random() * 10000)).padStart(4, '0');
return {
id: Crypto.randomUUID(),
name: `${prefix} ${suffix} #${number}`,
email: null,
isGuest: true,
createdAt: new Date().toISOString(),
};
}

17
VinEye/src/types/auth.ts Normal file
View file

@ -0,0 +1,17 @@
export interface User {
/** UUID v4 généré via expo-crypto. */
id: string;
name: string;
/** null pour les invités (compte généré sans email). */
email: string | null;
isGuest: boolean;
/** ISO 8601. */
createdAt: string;
}
export interface AuthState {
user: User | null;
isAuthenticated: boolean;
isOnboardingComplete: boolean;
hasAcceptedTerms: boolean;
}

View file

@ -9,8 +9,15 @@ export type BottomTabParamList = {
Map: { focusScanId?: string } | undefined; Map: { focusScanId?: string } | undefined;
}; };
export type OnboardingParamList = {
Welcome: undefined;
Terms: undefined;
AuthChoice: undefined;
};
export type RootStackParamList = { export type RootStackParamList = {
Splash: undefined; Splash: undefined;
Onboarding: NavigatorScreenParams<OnboardingParamList>;
Main: NavigatorScreenParams<BottomTabParamList>; Main: NavigatorScreenParams<BottomTabParamList>;
Result: { detection: Detection }; Result: { detection: Detection };
Search: { fromMap?: boolean } | undefined; Search: { fromMap?: boolean } | undefined;