Skip to content

Frontend - Application Mobile React Native

React Native 0.81 Expo SDK 54 TypeScript Zustand

L’application mobile Mindlet est développée avec React Native et Expo, permettant un déploiement cross-platform sur iOS et Android à partir d’une base de code unique. L’architecture suit le pattern Feature-based Architecture avec une séparation claire entre la logique métier et la présentation.

  • 📚 Gestion des collections et cartes d’apprentissage
  • 🎴 Sessions d’étude avec 9 types de cartes interactives
  • 🤖 Génération IA de cartes depuis documents, images, audio
  • 💬 Messagerie temps réel avec notifications push
  • 📊 Suivi de progression et statistiques d’apprentissage
  • 🔐 Authentification multi-providers (Email, Google, Apple)
ComposantTechnologieRôle
FrameworkReact Native 0.81Base cross-platform
ToolchainExpo SDK 54Build, déploiement, OTA
LangageTypeScript 5.9Typage statique strict
État serveurTanStack Query 5Cache, fetching, mutations
État localZustand 5État global léger
NavigationExpo Router 6Routing file-based
StylingNativeWind 4Tailwind CSS pour RN
FormulairesReact Hook Form + ZodValidation type-safe
WebSocketLaravel Echo + PusherTemps réel
API Clientopenapi-fetchClient typé depuis OpenAPI

L’application suit une Feature-based Architecture où chaque fonctionnalité est encapsulée dans un module autonome :

Architecture Feature-based
100%
flowchart TB
    subgraph "app/ (Expo Router)"
        ROOT["_layout.tsx"]
        AUTH["(auth)/"]
        TABS["(tabs)/"]
        SCREENS["Écrans"]
    end

    subgraph "src/features/"
        AI["ai/"]
        CARDS["cards/"]
        COLL["collections/"]
        STUDY["study/"]
        MSG["messages/"]
    end

    subgraph "src/components/"
        UI["ui/"]
        FEAT["features/"]
        PROV["providers/"]
    end

    subgraph "src/stores/"
        AUTH_ST["auth.ts"]
        AI_ST["aiGeneration.ts"]
        ECHO_ST["echo.ts"]
    end

    subgraph "src/lib/"
        API["api/client.ts"]
        QC["queryClient.ts"]
        STORAGE["storage.ts"]
    end

    ROOT --> AUTH & TABS
    TABS --> SCREENS
    SCREENS --> AI & CARDS & COLL & STUDY
    AI --> API
    CARDS --> API
    SCREENS --> UI & FEAT
    PROV --> ECHO_ST
    API --> AUTH_ST

    style ROOT fill:#1565c0,stroke:#1976d2
    style AI fill:#2e7d32,stroke:#388e3c
    style API fill:#e65100,stroke:#f57c00
    style AUTH_ST fill:#7b1fa2,stroke:#9c27b0
mobile/
├── app/ # Expo Router (file-based routing)
│ ├── _layout.tsx # Layout racine + providers
│ ├── (root)/ # Routes avec guards d'auth
│ │ ├── _layout.tsx # Vérification authentification
│ │ ├── (app)/ # App authentifiée
│ │ │ ├── (tabs)/ # Navigation par onglets
│ │ │ │ ├── feed/ # Fil d'actualité
│ │ │ │ ├── learn/ # Sessions d'étude
│ │ │ │ ├── collections/ # Gestion des collections
│ │ │ │ └── profile/ # Profil utilisateur
│ │ │ └── messages/ # Messagerie
│ │ └── auth/ # Login, Register
│ └── onboarding/ # Onboarding post-inscription
├── src/
│ ├── components/ # Composants réutilisables
│ │ ├── ui/ # Primitives UI (Button, Input...)
│ │ ├── common/ # Composants partagés
│ │ ├── features/ # Composants par domaine
│ │ ├── providers/ # Providers (WebSocket, Theme)
│ │ └── layout/ # Layouts communs
│ ├── features/ # Modules métier
│ │ ├── ai/ # Génération IA
│ │ ├── cards/ # CRUD cartes
│ │ ├── collections/ # CRUD collections
│ │ ├── study/ # Sessions d'étude
│ │ ├── messages/ # Messagerie
│ │ ├── posts/ # Réseau social
│ │ ├── progression/ # Suivi progrès
│ │ └── users/ # Profils utilisateurs
│ ├── stores/ # État global Zustand
│ ├── hooks/ # Hooks personnalisés
│ ├── lib/ # Configuration, utilitaires
│ │ └── api/ # Client API OpenAPI
│ └── types/ # Types TypeScript globaux
└── assets/ # Images, polices

Objectif : Organiser le code par domaine métier plutôt que par type technique.

Chaque feature module est auto-contenu et expose une API publique claire :

src/features/cards/
├── types.ts # Types TypeScript du domaine
├── queries.ts # React Query hooks (GET)
├── mutations.ts # React Query hooks (POST, PUT, DELETE)
├── queryKeys.ts # Clés de cache standardisées
├── schema.ts # Schémas Zod de validation
├── useCardForm.ts # Hook personnalisé formulaire
└── index.ts # Exports publics

Exemple : src/features/cards/queries.ts

import { apiClient } from '@/lib/api/client'
import { getAuthHeader } from '@/lib/api/utils'
import { cardsQueryKeys } from './queryKeys'
/**
* Hook pour récupérer les cartes d'une collection.
*
* Utilise React Query pour :
* - Cache automatique (staleTime: 10min)
* - Refetch en arrière-plan
* - Gestion des erreurs
*/
export const useCardsList = (collectionId: string) => {
return apiClient.useQuery(
'get',
'/v1/collections/{collection}/cards',
{
headers: getAuthHeader(),
params: {
path: { collection: collectionId },
},
},
{
queryKey: cardsQueryKeys.list(collectionId),
enabled: !!collectionId,
}
)
}
/**
* Hook pour récupérer les types de cartes disponibles.
*/
export const useAvailableCardTypes = () => {
return apiClient.useQuery(
'get',
'/v1/cards/types',
{ headers: getAuthHeader() },
{ queryKey: cardsQueryKeys.types() }
)
}

Exemple : src/features/cards/queryKeys.ts

export const cardsQueryKeys = {
// Clé de base pour toutes les requêtes cards
base: () => ['cards'] as const,
// Liste des cartes d'une collection
list: (collectionId: string) =>
[...cardsQueryKeys.base(), 'list', collectionId] as const,
// Détail d'une carte
detail: (cardId: string) =>
[...cardsQueryKeys.base(), 'detail', cardId] as const,
// Types de cartes disponibles
types: () =>
[...cardsQueryKeys.base(), 'types'] as const,
}

React Query (TanStack Query) gère l’état serveur avec un système de cache intelligent :

Flux React Query
100%
flowchart LR
    subgraph "Component"
        HOOK["useCardsList()"]
    end

    subgraph "React Query"
        CACHE["Cache"]
        FETCH["Fetcher"]
        BG["Background Refetch"]
    end

    subgraph "API"
        SERVER["Backend Laravel"]
    end

    HOOK -->|1. Check cache| CACHE
    CACHE -->|2a. Cache hit| HOOK
    CACHE -->|2b. Cache miss| FETCH
    FETCH -->|3. Request| SERVER
    SERVER -->|4. Response| FETCH
    FETCH -->|5. Update cache| CACHE
    BG -.->|Stale? Refetch| FETCH

    style CACHE fill:#2e7d32,stroke:#388e3c
    style SERVER fill:#1565c0,stroke:#1976d2
src/lib/queryClient.ts
import { QueryClient } from '@tanstack/react-query'
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
// Données considérées fraîches pendant 10 minutes
staleTime: 1000 * 60 * 10,
// Cache conservé 60 minutes après unmount
gcTime: 1000 * 60 * 60,
// Pas de refetch au mount si données en cache
refetchOnMount: false,
// Non applicable sur mobile
refetchOnWindowFocus: false,
// Refetch quand réseau revient
refetchOnReconnect: true,
// 2 tentatives en cas d'erreur
retry: 2,
},
mutations: {
retry: 1,
},
},
})
src/features/cards/mutations.ts
/**
* Mutation pour créer une carte.
*
* Après succès, invalide le cache de la liste des cartes
* pour forcer un refetch.
*/
export const useCreateCard = () => {
return apiClient.useMutation(
'post',
'/v1/collections/{collection}/cards',
{
onSuccess: (_, variables) => {
// Invalide la liste de cette collection
queryClient.invalidateQueries({
queryKey: cardsQueryKeys.list(variables.params.path.collection),
})
// Notification de succès
Toast.show({
type: 'success',
text1: 'Carte créée',
})
},
onError: (error) => {
Toast.show({
type: 'error',
text1: 'Erreur',
text2: getErrorMessage(error),
})
},
}
)
}
/**
* Mutation pour archiver une carte.
*
* Mise à jour optimiste du cache avant la réponse serveur.
*/
export const useArchiveCard = () => {
return apiClient.useMutation(
'post',
'/v1/cards/{card}/archive',
{
// Mise à jour optimiste
onMutate: async (variables) => {
const cardId = variables.params.path.card
// Annuler les requêtes en cours
await queryClient.cancelQueries({
queryKey: cardsQueryKeys.detail(cardId),
})
// Snapshot pour rollback
const previousCard = queryClient.getQueryData(
cardsQueryKeys.detail(cardId)
)
// Mise à jour optimiste
queryClient.setQueryData(
cardsQueryKeys.detail(cardId),
(old: Card) => ({ ...old, archived_at: new Date().toISOString() })
)
return { previousCard }
},
// Rollback en cas d'erreur
onError: (_, variables, context) => {
if (context?.previousCard) {
queryClient.setQueryData(
cardsQueryKeys.detail(variables.params.path.card),
context.previousCard
)
}
},
// Revalidation finale
onSettled: (_, __, variables) => {
queryClient.invalidateQueries({
queryKey: cardsQueryKeys.detail(variables.params.path.card),
})
},
}
)
}

L’application utilise Laravel Echo avec Pusher pour les fonctionnalités temps réel :

Architecture WebSocket
100%
flowchart TB
    subgraph "Mobile App"
        ECHO["Echo Store"]
        AI_LIST["AiWebSocketListener"]
        MSG_LIST["useMessagingWebSocket"]
    end

    subgraph "Backend"
        REVERB["Laravel Reverb"]
        EVENTS["Events"]
    end

    subgraph "Canaux"
        PRIV["private-users.{userId}"]
        CONV["private-conversations.{id}"]
    end

    ECHO -->|subscribe| PRIV & CONV
    EVENTS -->|broadcast| REVERB
    REVERB --> PRIV & CONV
    PRIV --> AI_LIST
    CONV --> MSG_LIST

    style REVERB fill:#2e7d32,stroke:#388e3c
    style PRIV fill:#e65100,stroke:#f57c00
src/stores/echo.ts
import Echo from 'laravel-echo'
import Pusher from 'pusher-js'
interface EchoState {
echo: Echo<'reverb'> | null
isConnected: boolean
connectionState: 'connecting' | 'connected' | 'disconnected' | 'failed'
init: () => void
disconnect: () => void
}
export const useEchoStore = create<EchoState>((set, get) => ({
echo: null,
isConnected: false,
connectionState: 'disconnected',
init: () => {
const accessToken = useAuthStore.getState().accessToken
if (!accessToken || get().echo) return
// Client Pusher configuré pour Reverb
const pusherClient = new Pusher(REVERB_APP_KEY, {
cluster: 'mt1',
wsHost: REVERB_HOST,
wsPort: REVERB_PORT,
forceTLS: REVERB_SCHEME === 'https',
enabledTransports: ['ws', 'wss'],
// Authentification des canaux privés
authEndpoint: `${API_URL}/broadcasting/auth`,
auth: {
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
},
// Configuration heartbeat
activityTimeout: 10000, // Ping si inactif 10s
pongTimeout: 5000, // Attente pong 5s
})
const echo = new Echo({
broadcaster: 'reverb',
key: REVERB_APP_KEY,
client: pusherClient,
})
// Écoute des changements de connexion
pusherClient.connection.bind('state_change', (states) => {
set({
connectionState: states.current,
isConnected: states.current === 'connected',
})
})
set({ echo })
},
disconnect: () => {
get().echo?.disconnect()
set({ echo: null, isConnected: false })
},
}))
src/components/providers/AiWebSocketListener.tsx
import { useEffect, useRef } from 'react'
import { useEchoStore } from '@/stores/echo'
import { useAiGenerationStore } from '@/stores/aiGeneration'
import { collectionsQueryKeys } from '@/features/collections'
/**
* Composant invisible qui écoute les événements de génération IA.
*
* Gère :
* - Mise à jour du store de génération
* - Invalidation du cache React Query
* - Notifications utilisateur
* - Déduplication des événements
*/
export function AiWebSocketListener() {
const echo = useEchoStore((s) => s.echo)
const userId = useAuthStore((s) => s.user?.id)
const { setGenerationStarted, setGenerationCompleted, setGenerationFailed } =
useAiGenerationStore()
// Déduplication des événements (le backend peut envoyer plusieurs fois)
const processedRef = useRef<Set<string>>(new Set())
useEffect(() => {
if (!echo || !userId) return
const channel = echo.private(`users.${userId}`)
// Événement : génération démarrée
channel.listen('.card.generation.started', (event) => {
if (processedRef.current.has(event.generation_id)) return
processedRef.current.add(event.generation_id)
setGenerationStarted(event.generation_id, event.collection_id)
})
// Événement : génération terminée
channel.listen('.card.generation.completed', (event) => {
if (processedRef.current.has(`${event.generation_id}-completed`)) return
processedRef.current.add(`${event.generation_id}-completed`)
setGenerationCompleted(event.generation_id)
// Invalider le cache des collections
queryClient.invalidateQueries({
queryKey: collectionsQueryKeys.list(),
})
// Notification locale
Notifications.scheduleNotificationAsync({
content: {
title: 'Cartes générées !',
body: `${event.total_cards_persisted} cartes ont été créées.`,
},
trigger: null, // Immédiat
})
})
// Événement : génération échouée
channel.listen('.card.generation.failed', (event) => {
setGenerationFailed(event.generation_id, event.reason)
Toast.show({
type: 'error',
text1: 'Génération échouée',
text2: event.reason,
})
})
return () => {
channel.stopListening('.card.generation.started')
channel.stopListening('.card.generation.completed')
channel.stopListening('.card.generation.failed')
}
}, [echo, userId])
return null // Composant invisible
}

Le hook useStudySession orchestre les sessions d’apprentissage avec gestion locale des réponses :

Flux session d'étude
100%
sequenceDiagram
    participant U as Utilisateur
    participant H as useStudySession
    participant S as Store Local
    participant API as Backend
    participant WS as WebSocket

    U->>H: startSession(params)
    H->>API: POST /study-sessions
    API-->>H: { session, cards }
    H->>S: Stocker session et cartes

    loop Pour chaque carte
        U->>H: recordAnswer(score)
        H->>S: Ajouter à answers[]
        Note over S: Réponses bufferisées localement
    end

    U->>H: completeSession()
    H->>API: POST /sessions/{id}/review (batch)
    API-->>H: Succès
    H->>S: Clear session
    H-->>WS: Events progression
src/features/study/useStudySession.ts
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
interface StudySessionStore {
session: StudySession | null
cards: Card[]
currentIndex: number
startedAt: number | null // Timestamp début
elapsedMs: number // Temps total écoulé
cardStartedAt: number | null // Timestamp carte courante
answers: ReviewAnswer[] // Buffer local des réponses
startSession: (params: StartSessionParams) => Promise<void>
resumeSession: (sessionId: string) => Promise<void>
recordAnswer: (score: CardProgressionScore, userAnswer?: string) => void
completeSession: () => Promise<SessionSummary>
abandonSession: () => Promise<void>
}
const useStudySessionStore = create<StudySessionStore>()(
persist(
(set, get) => ({
session: null,
cards: [],
currentIndex: 0,
startedAt: null,
elapsedMs: 0,
cardStartedAt: null,
answers: [],
/**
* Démarre une nouvelle session d'étude.
*/
startSession: async (params) => {
const response = await client.POST('/v1/study-sessions', {
body: params,
})
if (response.error) throw new Error(response.error.message)
const { session, cards } = response.data!
const now = Date.now()
set({
session,
cards,
currentIndex: 0,
startedAt: now,
elapsedMs: 0,
cardStartedAt: now,
answers: [],
})
},
/**
* Enregistre une réponse localement.
*
* Les réponses sont bufferisées et envoyées en batch
* à la fin de la session pour éviter les requêtes multiples.
*/
recordAnswer: (score, userAnswer) => {
const { cards, currentIndex, cardStartedAt, answers } = get()
const currentCard = cards[currentIndex]
if (!currentCard || !cardStartedAt) return
// Vérifier si déjà répondu (éviter doublons)
if (answers.some((a) => a.card_id === currentCard.id)) {
console.warn('Card already answered')
return
}
const timeSpentMs = Date.now() - cardStartedAt
const answer: ReviewAnswer = {
card_id: currentCard.id,
score,
user_answer: userAnswer,
time_spent_ms: timeSpentMs,
}
set({
answers: [...answers, answer],
currentIndex: currentIndex + 1,
cardStartedAt: Date.now(), // Reset timer pour prochaine carte
})
},
/**
* Termine la session et envoie toutes les réponses.
*/
completeSession: async () => {
const { session, answers, startedAt, elapsedMs } = get()
if (!session) throw new Error('No active session')
// Calcul temps total
const totalTimeMs = startedAt
? elapsedMs + (Date.now() - startedAt)
: elapsedMs
// Envoi batch des réponses
if (answers.length > 0) {
await client.POST('/v1/study-sessions/{session}/review', {
params: { path: { session: session.id } },
body: { cards: answers },
})
}
// Terminer la session
const response = await client.POST(
'/v1/study-sessions/{session}/complete',
{
params: { path: { session: session.id } },
body: { total_time_ms: totalTimeMs },
}
)
// Reset du store
set({
session: null,
cards: [],
currentIndex: 0,
startedAt: null,
elapsedMs: 0,
answers: [],
})
// Planifier rappel de révision dans 3h
await Notifications.scheduleNotificationAsync({
content: {
title: 'Temps de réviser !',
body: 'Continuez votre apprentissage',
},
trigger: { seconds: 3 * 60 * 60 },
})
return response.data!
},
}),
{
name: 'study-session',
storage: createJSONStorage(() => AsyncStorage),
// Persister pour reprise après crash
partialize: (state) => ({
session: state.session,
cards: state.cards,
currentIndex: state.currentIndex,
elapsedMs: state.elapsedMs,
answers: state.answers,
}),
}
)
)
/**
* Hook public avec méthodes utilitaires.
*/
export function useStudySession() {
const store = useStudySessionStore()
const currentCard = store.cards[store.currentIndex] ?? null
const progress = store.cards.length
? (store.currentIndex / store.cards.length) * 100
: 0
const isComplete = store.currentIndex >= store.cards.length
return {
...store,
currentCard,
progress,
isComplete,
}
}

L’application utilise NativeWind (Tailwind CSS pour React Native) avec le pattern CVA (Class Variance Authority) :

tailwind.config.js
module.exports = {
content: [
'./app/**/*.{js,jsx,ts,tsx}',
'./src/**/*.{js,jsx,ts,tsx}',
],
presets: [require('nativewind/preset')],
theme: {
extend: {
colors: {
// Palette Mindlet
primary: 'hsl(var(--primary))', // Vert menthe #1FDB99
secondary: 'hsl(var(--secondary))', // Violet #9758E4
accent: 'hsl(var(--accent))', // Bleu clair #85B8EF
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
muted: 'hsl(var(--muted))',
destructive: 'hsl(var(--destructive))',
},
fontFamily: {
saeada: ['LT Saeada', 'system-ui'],
raleway: ['Raleway', 'system-ui'],
},
borderRadius: {
DEFAULT: 'var(--radius)',
},
},
},
}
src/components/ui/button.tsx
import { cva, type VariantProps } from 'class-variance-authority'
import { TouchableOpacity, Text, type TouchableOpacityProps } from 'react-native'
import { cn } from '@/lib/utils'
/**
* Variants du bouton définis avec CVA.
*
* Permet une API déclarative pour les différents styles :
* <Button variant="destructive" size="lg" />
*/
const buttonVariants = cva(
// Classes de base
'group flex-row items-center justify-center gap-2 rounded-xl',
{
variants: {
variant: {
default: 'bg-primary active:bg-primary/90',
destructive: 'bg-destructive active:bg-destructive/90',
outline: 'border-2 border-primary bg-transparent active:bg-primary/10',
secondary: 'bg-secondary active:bg-secondary/90',
ghost: 'bg-transparent active:bg-muted',
link: 'bg-transparent underline',
},
size: {
default: 'h-12 px-6 py-3',
sm: 'h-9 px-4 py-2',
lg: 'h-14 px-8 py-4',
icon: 'h-12 w-12',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
)
const textVariants = cva('font-semibold', {
variants: {
variant: {
default: 'text-primary-foreground',
destructive: 'text-destructive-foreground',
outline: 'text-primary',
secondary: 'text-secondary-foreground',
ghost: 'text-foreground',
link: 'text-primary',
},
size: {
default: 'text-base',
sm: 'text-sm',
lg: 'text-lg',
icon: 'text-base',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
})
interface ButtonProps
extends TouchableOpacityProps,
VariantProps<typeof buttonVariants> {
title: string
loading?: boolean
}
export function Button({
title,
variant,
size,
disabled,
loading,
className,
...props
}: ButtonProps) {
return (
<TouchableOpacity
disabled={disabled || loading}
className={cn(
buttonVariants({ variant, size }),
(disabled || loading) && 'opacity-50',
className
)}
{...props}
>
{loading ? (
<ActivityIndicator color="currentColor" />
) : (
<Text className={textVariants({ variant, size })}>{title}</Text>
)}
</TouchableOpacity>
)
}

Expo Router utilise un système de file-based routing similaire à Next.js :

app/
├── _layout.tsx # Root layout (providers globaux)
├── (root)/
│ ├── _layout.tsx # Auth guard
│ ├── auth/
│ │ ├── login.tsx # /auth/login
│ │ └── register.tsx # /auth/register
│ └── (app)/
│ ├── _layout.tsx # WebSocket, listeners
│ ├── (tabs)/
│ │ ├── _layout.tsx # Tab bar configuration
│ │ ├── feed/
│ │ │ └── index.tsx # /feed
│ │ ├── learn/
│ │ │ ├── index.tsx # /learn
│ │ │ └── study/
│ │ │ └── session.tsx # /learn/study/session
│ │ ├── collections/
│ │ │ ├── index.tsx # /collections
│ │ │ ├── [collectionId]/
│ │ │ │ ├── index.tsx # /collections/[id]
│ │ │ │ └── cards/
│ │ │ │ └── [cardId]/
│ │ │ │ └── edit.tsx # /collections/[id]/cards/[cardId]/edit
│ │ └── profile/
│ │ └── index.tsx # /profile
│ └── messages/
│ ├── index.tsx # /messages
│ └── [id]/
│ └── index.tsx # /messages/[id]
└── onboarding/
└── status.tsx # /onboarding/status
// app/(root)/_layout.tsx
import { Redirect, Stack } from 'expo-router'
import { useAuthStore } from '@/stores/auth'
export default function RootLayout() {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
const hasHydrated = useAuthStore((s) => s.hasHydrated)
const isLoading = useAuthStore((s) => s.isLoading)
// Attendre hydratation du store
if (!hasHydrated || isLoading) {
return null // Ou splash screen
}
// Redirection si non authentifié
if (!isAuthenticated) {
return <Redirect href="/auth/login" />
}
return (
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="(app)" />
</Stack>
)
}
// app/(root)/(app)/(tabs)/_layout.tsx
import { Tabs } from 'expo-router'
import { Home, BookOpen, FolderOpen, User } from 'lucide-react-native'
export default function TabsLayout() {
return (
<Tabs
screenOptions={{
headerShown: false,
tabBarActiveTintColor: '#1FDB99', // Primary color
tabBarInactiveTintColor: '#9CA3AF',
tabBarStyle: {
backgroundColor: '#FFFFFF',
borderTopWidth: 0,
elevation: 10,
height: 60,
paddingBottom: 8,
},
}}
>
<Tabs.Screen
name="feed"
options={{
title: 'Fil',
tabBarIcon: ({ color, size }) => (
<Home color={color} size={size} />
),
}}
/>
<Tabs.Screen
name="learn"
options={{
title: 'Apprendre',
tabBarIcon: ({ color, size }) => (
<BookOpen color={color} size={size} />
),
}}
/>
<Tabs.Screen
name="collections"
options={{
title: 'Collections',
tabBarIcon: ({ color, size }) => (
<FolderOpen color={color} size={size} />
),
}}
/>
<Tabs.Screen
name="profile"
options={{
title: 'Profil',
tabBarIcon: ({ color, size }) => (
<User color={color} size={size} />
),
}}
/>
</Tabs>
)
}

L’application supporte plusieurs méthodes d’authentification :

// src/stores/auth.ts (extrait)
signInWithGoogle: async () => {
// Configuration OAuth avec PKCE
const request = new AuthRequest({
clientId: Platform.OS === 'ios'
? GOOGLE_IOS_CLIENT_ID
: GOOGLE_ANDROID_CLIENT_ID,
scopes: ['openid', 'profile', 'email'],
redirectUri: makeRedirectUri({
scheme: 'app.mindlet.mobile',
preferLocalhost: false,
}),
responseType: ResponseType.Code,
usePKCE: true, // Sécurité : Proof Key for Code Exchange
})
// Ouverture du navigateur OAuth
const result = await request.promptAsync(googleDiscovery)
if (result.type !== 'success' || !result.params.code) {
throw new Error('Google sign-in cancelled')
}
// Échange du code sur le backend
const response = await client.POST('/auth/oauth/callback', {
body: {
code: result.params.code,
code_verifier: request.codeVerifier, // PKCE
provider: 'google',
platform: Platform.OS,
},
})
if (response.error) throw new Error(response.error.message)
// Stockage et mise à jour du state
const { access_token, refresh_token, user } = response.data!
// ...
}

L’application utilise un système de stockage à deux niveaux :

src/lib/storage.ts
import * as SecureStore from 'expo-secure-store'
import AsyncStorage from '@react-native-async-storage/async-storage'
import { Platform } from 'react-native'
type StorageKey =
| 'accessToken'
| 'refreshToken'
| 'user'
| 'theme'
| 'environment'
// Clés sensibles → SecureStore (chiffré)
const SECURE_KEYS: StorageKey[] = ['accessToken', 'refreshToken']
export const storage = {
/**
* Récupère une valeur du stockage approprié.
*/
getItem: async (key: StorageKey): Promise<string | null> => {
if (Platform.OS === 'web') {
return localStorage.getItem(key)
}
if (SECURE_KEYS.includes(key)) {
return SecureStore.getItemAsync(key)
}
return AsyncStorage.getItem(key)
},
/**
* Stocke une valeur dans le stockage approprié.
*/
setItem: async (key: StorageKey, value: string): Promise<void> => {
if (Platform.OS === 'web') {
localStorage.setItem(key, value)
return
}
if (SECURE_KEYS.includes(key)) {
await SecureStore.setItemAsync(key, value)
return
}
await AsyncStorage.setItem(key, value)
},
/**
* Supprime une valeur.
*/
removeItem: async (key: StorageKey): Promise<void> => {
if (Platform.OS === 'web') {
localStorage.removeItem(key)
return
}
if (SECURE_KEYS.includes(key)) {
await SecureStore.deleteItemAsync(key)
return
}
await AsyncStorage.removeItem(key)
},
/**
* Efface tout le stockage (logout).
*/
clear: async (): Promise<void> => {
if (Platform.OS === 'web') {
localStorage.clear()
return
}
await Promise.all([
SecureStore.deleteItemAsync('accessToken'),
SecureStore.deleteItemAsync('refreshToken'),
AsyncStorage.clear(),
])
},
}
CatégorieTechnologies
CoreReact Native 0.81, Expo SDK 54, TypeScript 5.9
StateZustand 5 (local), TanStack Query 5 (serveur)
APIopenapi-fetch, openapi-react-query
NavigationExpo Router 6 (file-based)
StylingNativeWind 4, Tailwind CSS, CVA
Real-timeLaravel Echo, Pusher, WebSocket
AuthOAuth 2.0 (Google, Apple), PKCE
StorageSecureStore (tokens), AsyncStorage (données)
FormsReact Hook Form, Zod
MonitoringSentry

Backend API

Découvrez l’API Laravel qui alimente l’application mobile.

Voir le Backend →


Application mobile conçue pour l’expérience utilisateur et la performance.