Aller au contenu principal

Afficher le classement avec FlatList

Appeler la fonction meilleurs_scores depuis l'application et afficher les meilleures parties dans une liste filtrable.

Objectifs de la séance​

  • appeler une fonction SQL (RPC) depuis un service et convertir ses lignes avec vérification ;
  • écrire un hook useClassement qui évite les réponses dans le désordre et permet d'actualiser ;
  • afficher une liste avec FlatList (data, renderItem, keyExtractor, séparateurs, liste vide) ;
  • ajouter des filtres (thème et niveau) dont les valeurs initiales viennent de l'adresse de l'écran ;
  • relier le nouvel écran à l'accueil et à l'écran de résultat.

Petite vérification​

Avant de commencer, vérifiez si vous savez...

  1. dans l'éditeur SQL de Supabase, exécuter select * from public.meilleurs_scores(null, null, 5); et lire les noms des colonnes renvoyées
  2. exécuter select * from public.meilleurs_scores('reseaux', 1, 5); et vérifier que seules des parties « Réseaux et Wi-Fi » en niveau Débutant apparaissent (par exemple, celle que vous avez jouée au tutoriel précédent)
  3. exécuter select count(*) from public.meilleurs_scores(null, null, 100000); et dire si le résultat peut dépasser 50
📌 Réponse attendue

TP pour réfléchir et résoudre des problèmes​

Votre mission : créer l'écran Classement. Il appelle la fonction SQL, affiche une ligne par partie (rang, pseudo, score, temps) et propose des filtres. On l'atteint depuis l'accueil, et depuis l'écran de résultat (avec le thème et le niveau de la partie qui vient de se terminer). Le serveur de développement doit être lancé (npx expo start, touche w). Vous êtes toujours sur la branche feature/classement.

Étape 1 — Décrire une ligne du classement​

Dans src/types/quiz.ts, ajoutez à la fin du fichier le type d'une ligne du classement. Les noms suivent la convention de l'application (camelCase), alors que la base utilise snake_case. Complétez la propriété qui contient la durée de la partie en secondes :


Bonne pratique - Un type par forme de donnée

Le type LigneClassement décrit une ligne ; la liste complète sera un LigneClassement[]. Le jour où la fonction SQL gagne une colonne, TypeScript vous indique tous les endroits à modifier.

Étape 2 — Convertir une ligne reçue du serveur​

Une fonction SQL renvoie du JSON « brut » (type unknown) : on ne le suppose jamais correct. Dans src/logic/conversions.ts, ajoutez LigneClassement à l'import des types (en haut du fichier, par ordre alphabétique), puis ajoutez à la fin du fichier la fonction de conversion. Complétez la vérification de nb_questions :

Ajoutez aussi un test à la fin de tests/conversions.test.ts (sans oublier d'ajouter convertirLigneClassement à la liste des fonctions importées en haut du fichier), puis lancez npm test :

test("convertirLigneClassement transforme une ligne du classement", () => {
const ligne = convertirLigneClassement({
pseudo: "Bob",
score: 3,
nb_questions: 5,
duree_secondes: 40,
terminee_le: "2026-10-26T09:06:32+00:00",
});
assert.equal(ligne?.nbQuestions, 5);
assert.equal(convertirLigneClassement({ pseudo: "Bob" }), null);
});

Bonne pratique - Écarter une ligne plutôt que planter

Une ligne mal formée est convertie en null et sera ignorée par le service : le classement s'affiche sans elle, au lieu de faire planter tout l'écran. La même idée a été appliquée aux questions et aux thèmes.

Étape 3 — Appeler la fonction depuis un service​

Créez src/services/classementService.ts. Comme tous les services, c'est le seul fichier qui parle à Supabase pour le classement. Complétez le paramètre qui transmet le thème choisi (le mot "tous" de l'application devient null pour le serveur) :


Bonne pratique - Deux messages d'erreur : un pour la console, un pour le joueur

Le détail technique (error.code, error.message) va dans la console, pour vous. Le joueur ne voit qu'une phrase simple : « Impossible de charger le classement. » Il n'a pas besoin de connaître le nom de vos tables.

Étape 4 — Écrire le hook useClassement​

Créez src/hooks/useClassement.ts. Il reprend le principe de useNombreQuestionsDisponibles : une requête annulable et un résultat mémorisé avec sa clé, pour ne jamais afficher le classement d'anciens filtres. Une nouveauté : la clé contient aussi un numéro de tentative, que le bouton « Actualiser » incrémente pour relancer le chargement. Complétez la clé :

Complétez maintenant la fonction actualiser. Elle augmente la tentative de 1, avec une mise à jour fonctionnelle (la valeur précédente est fournie par React) :


Bonne pratique - Un état déduit, pas stocké

Le hook ne stocke pas « chargement » : il le déduit (dernierResultat absent, ou clé différente). Dès que les filtres changent, la clé change, et l'écran repasse tout seul en « chargement » sans aucun setState supplémentaire.

Étape 5 — Dessiner une ligne du classement​

Créez src/components/LigneClassementVue.tsx : une ligne affiche le rang, le pseudo, le score (3/5) et le temps. Complétez la ligne qui affiche la durée avec formaterDuree (la fonction écrite au tutoriel 4) :


Bonne pratique - Un pseudo trop long ne casse pas la mise en page

numberOfLines={1} coupe le texte avec « ... » au lieu de le passer sur deux lignes, et flex: 1 donne au pseudo toute la place restante entre le rang et le score.

Étape 6 — Afficher la liste avec FlatList​

Créez src/app/classement.tsx. Écrivez d'abord le début : les imports, puis la lecture des filtres (étape 7), puis le corps de l'écran. L'écran distingue les trois états du chargement : chargement, erreur (avec un bouton « Réessayer ») et succès, où la liste apparaît. Complétez la propriété qui fournit une clé unique à chaque ligne :

Complétez maintenant la propriété qui affiche un message lorsque la liste est vide (aucune partie pour les filtres choisis) :


Bonne pratique - Prévoir le cas « aucune donnée »

Un classement vide est un cas normal (premier joueur, filtre rare). Un message clair vaut mieux qu'un écran blanc, qui laisse croire à un bogue.

Étape 7 — Ajouter les filtres et lire les paramètres initiaux​

L'écran doit pouvoir s'ouvrir avec un thème et un niveau déjà choisis (depuis l'écran de résultat). Les fonctions de lecture validée lireTheme, lireNiveau et premiereValeur, écrites au tutoriel 5 dans src/logic/parametresPartie.ts, n'étaient utilisées que dans ce fichier : il faut les exporter. Complétez le mot-clé qui rend lireTheme utilisable depuis un autre fichier :

Dans l'écran, les filtres sont deux états (useState). Leur valeur initiale vient de l'adresse (/classement?theme=...&niveau=...), validée par lireTheme ; si le paramètre est absent ou falsifié, on prend "tous". Complétez le calcul de la valeur initiale du thème :


Bonne pratique - Valider même ce qu'on construit soi-même

Une adresse peut être saisie à la main (sur le Web) : /classement?theme=pirate ne doit pas casser l'écran. lireTheme renvoie null pour un thème inconnu, et ?? "tous" retombe sur un choix valide.

Voici l'écran complet. Les boutons de filtres réutilisent ChoixOption (du générateur) et la liste des niveaux CHOIX_NIVEAUX ; les thèmes viennent de useThemes, déjà utilisé par le générateur :

import { useLocalSearchParams } from "expo-router";
import { useState } from "react";
import { FlatList, StyleSheet, Text, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";

import BoutonPrincipal from "@/components/BoutonPrincipal";
import Chargement from "@/components/Chargement";
import ChoixOption from "@/components/ChoixOption";
import LigneClassementVue from "@/components/LigneClassementVue";
import MessageErreur from "@/components/MessageErreur";
import { COULEURS } from "@/constants/couleurs";
import { CHOIX_NIVEAUX } from "@/constants/generateur";
import { useClassement } from "@/hooks/useClassement";
import { useThemes } from "@/hooks/useThemes";
import { lireNiveau, lireTheme, premiereValeur } from "@/logic/parametresPartie";
import type { FiltreNiveau, FiltreTheme } from "@/types/quiz";

// Écran du classement (route "/classement?theme=...&niveau=...")
export default function EcranClassement() {
// Filtres initiaux facultatifs (ex. : depuis l'écran de résultat)
const parametres = useLocalSearchParams<{ theme?: string; niveau?: string }>();
const [theme, setTheme] = useState<FiltreTheme>(
() => lireTheme(premiereValeur(parametres.theme)) ?? "tous",
);
const [niveau, setNiveau] = useState<FiltreNiveau>(
() => lireNiveau(premiereValeur(parametres.niveau)) ?? "tous",
);

const { etatThemes } = useThemes();
const { etatClassement, actualiser } = useClassement(theme, niveau);

return (
<SafeAreaView style={styles.ecran} edges={["bottom"]}>
<View style={styles.contenu}>
<View style={styles.ligne}>
<ChoixOption
libelle="Tous les thèmes"
estSelectionne={theme === "tous"}
onPress={() => setTheme("tous")}
/>
{etatThemes.statut === "succes" &&
etatThemes.donnees.map((t) => (
<ChoixOption
key={t.code}
libelle={t.libelle}
estSelectionne={theme === t.code}
onPress={() => setTheme(t.code)}
/>
))}
</View>
<View style={styles.ligne}>
{CHOIX_NIVEAUX.map((choix) => (
<ChoixOption
key={choix.libelle}
libelle={choix.libelle}
estSelectionne={niveau === choix.valeur}
onPress={() => setNiveau(choix.valeur)}
/>
))}
</View>

{etatClassement.statut === "chargement" && (
<Chargement message="Chargement du classement..." />
)}
{etatClassement.statut === "erreur" && (
<MessageErreur
message={etatClassement.message}
texteBouton="Réessayer"
onPress={actualiser}
/>
)}
{etatClassement.statut === "succes" && (
// FlatList : liste performante, n'affiche que les lignes visibles
<FlatList
data={etatClassement.donnees}
// La liste est remplacée en entier à chaque chargement :
// le rang (index) suffit comme clé unique
keyExtractor={(_ligne, index) => String(index)}
renderItem={({ item, index }) => (
<LigneClassementVue rang={index + 1} ligne={item} />
)}
ItemSeparatorComponent={() => <View style={styles.separateur} />}
ListEmptyComponent={
<Text style={styles.vide}>Aucune partie pour ces réglages : soyez le premier !</Text>
}
style={styles.liste}
/>
)}

<BoutonPrincipal
texte="Actualiser"
onPress={actualiser}
desactive={etatClassement.statut === "chargement"}
/>
</View>
</SafeAreaView>
);
}

const styles = StyleSheet.create({
ecran: {
flex: 1,
backgroundColor: COULEURS.fond,
},
contenu: {
flex: 1,
padding: 16,
gap: 12,
width: "100%",
maxWidth: 600,
alignSelf: "center",
},
ligne: {
flexDirection: "row",
flexWrap: "wrap",
gap: 8,
},
liste: {
flex: 1,
},
separateur: {
height: 8,
},
vide: {
fontSize: 16,
color: COULEURS.texteSecondaire,
textAlign: "center",
padding: 24,
},
});

Étape 8 — Relier l'écran à l'application​

Trois petites modifications. D'abord, déclarez l'écran dans src/app/_layout.tsx, à la suite des autres :

Ensuite, dans src/app/index.tsx, ajoutez un bouton Classement juste après « Créer un quiz » (dans le bloc affiché quand le joueur a un pseudo) :

<BoutonPrincipal
texte="Classement"
onPress={() => router.push("/classement")}
/>

Enfin, dans src/components/ResultatServeur.tsx, ajoutez un bouton Voir le classement entre « Rejouer » et « Nouveau quiz ». Il ouvre le classement avec le thème et le niveau de la partie terminée. Les paramètres d'adresse sont toujours des chaînes : le niveau (qui peut être un nombre) est converti avec String.


Bonne pratique - Régénérer les routes typées

Expo Router connaît la liste de vos écrans grâce à des types générés automatiquement. Si l'éditeur souligne "/classement" en rouge, relancez npx expo start : le fichier de types est recalculé et l'erreur disparaît.

Étape 9 — Tester l'écran​

Lancez l'application, choisissez un pseudo si besoin, puis :

  1. cliquez sur Classement depuis l'accueil : la liste affiche vos parties (« Tous les thèmes », « Tous ») ;
  2. choisissez « Réseaux et Wi-Fi » et « Débutant » : seules les parties correspondantes restent ;
  3. changez très vite de filtre plusieurs fois : la liste affichée correspond toujours au dernier filtre choisi ;
  4. jouez une partie, puis cliquez sur Voir le classement depuis l'écran de résultat : les filtres sont déjà réglés sur le thème et le niveau de la partie ;
  5. ouvrez à la main /classement?theme=pirate&niveau=9 dans le navigateur : l'écran s'affiche avec « Tous ».

Résultat attendu (une seule partie jouée en Réseaux et Wi-Fi, niveau Débutant) :

L&#39;écran du classement avec ses filtres

Vérifiez enfin que le code est correct, puis enregistrez votre travail :

npx tsc --noEmit
npm test

Bonne pratique - Tester les cas qui cassent

Changer vite de filtre (étape 3 du test) et ouvrir une adresse falsifiée (étape 5) sont des tests de robustesse : ils vérifient les deux protections de cette séance (la clé du résultat et la validation du paramètre).

Étape 10 — Fusionner la branche du tutoriel​

Le tutoriel est terminé : sur GitHub, ouvrez une Pull Request de feature/classement vers main (bouton Compare & pull request, qui apparaît en haut de la page du dépôt), relisez les fichiers modifiés (onglet Files changed), puis cliquez sur Merge pull request. Revenez ensuite sur main en local et récupérez la fusion :


Bonne pratique - main doit toujours fonctionner

La Pull Request est le moment de relire son travail comme si c'était celui d'un autre. Une fois fusionnée, la branche feature/classement ne sert plus : vous pouvez la supprimer (bouton Delete branch sur GitHub).

📌 Une solution

Notions théoriques​

FlatList : afficher une longue liste efficacement​

Au tutoriel 3, vous avez affiché une liste avec map : toutes les lignes sont créées d'un coup. C'est parfait pour 4 réponses, mais un classement peut compter des centaines de lignes. FlatList est le composant fait pour cela : il ne crée que les lignes visibles à l'écran (et quelques-unes autour), et en crée de nouvelles au fur et à mesure du défilement.

PropriétéRôle
datale tableau des éléments à afficher
renderItemune fonction qui reçoit { item, index } et renvoie le composant d'une ligne
keyExtractorune fonction qui renvoie une clé unique (texte) pour chaque ligne
ItemSeparatorComponentle composant affiché entre deux lignes
ListEmptyComponentle composant affiché quand data est vide

FlatList défile toute seule : il ne faut donc pas la placer dans un ScrollView. Pour qu'elle occupe l'espace restant de l'écran (et pousse le bouton « Actualiser » en bas), on lui donne flex: 1.

La clé d'une ligne​

Pour retrouver quelles lignes ont changé, React a besoin d'une clé unique et stable (voir key au tutoriel 3). Dans les listes de thèmes, on utilisait l'identifiant (t.code). Ici, une ligne du classement n'a pas d'identifiant (on a volontairement refusé de renvoyer l'uuid) : on utilise donc son rang, String(index). C'est acceptable parce que la liste est remplacée en entier à chaque chargement et qu'elle n'est ni triée ni modifiée côté application. Pour une liste que l'utilisateur peut réordonner ou dont on supprime des éléments, l'index serait un mauvais choix.

Trois états et un résultat mémorisé avec sa clé​

Le hook useClassement applique les patrons vus au tutoriel 7 :

  • l'état EtatChargement (chargement, erreur, succès) évite d'afficher deux choses à la fois ;
  • AbortController annule la requête de l'ancien filtre quand le joueur change de filtre ou quitte l'écran ;
  • la clé ("reseaux/1/0") accompagne le résultat mémorisé : si elle ne correspond plus aux filtres actuels, l'écran affiche « chargement » plutôt qu'un classement périmé ;
  • le numéro de tentative, dans la clé, permet de forcer un nouveau chargement (« Actualiser », « Réessayer ») sans changer de filtre.

Paramètres initiaux d'un écran​

Un écran peut recevoir des paramètres d'adresse (/classement?theme=reseaux&niveau=1). Il les lit avec useLocalSearchParams, qui renvoie des chaînes (ou undefined). Comme pour l'écran de partie, on ne leur fait pas confiance : lireTheme et lireNiveau valident la valeur, et on retombe sur "tous" si elle est absente ou fausse. La valeur initiale n'est calculée qu'une fois, grâce à la fonction passée à useState(() => ...) : ensuite, c'est le joueur qui décide en cliquant sur les filtres.


Test de mémorisation/compréhension​


Pourquoi utiliser FlatList plutôt que map pour afficher un long classement ?


Quelle propriété de FlatList reçoit une fonction qui dessine UNE ligne ?


Pourquoi le rang (index) peut-il servir de clé dans le classement ?


À quoi sert le numéro de tentative dans la clé du hook useClassement ?


Que se passe-t-il si on ouvre /classement?theme=pirate&niveau=9 ?


Pourquoi convertit-on le niveau avec String(config.niveau) avant de naviguer ?



Exercices complémentaires​

Exercice A : mettre en avant le podium​

Dans LigneClassementVue, affichez le rang d'un autre style pour les 3 premiers (par exemple en orange, avec COULEURS ou une couleur de votre choix). Aide : un style supplémentaire dans StyleSheet.create, appliqué avec rang <= 3 && styles.podium.

Exercice B : la date de la partie​

La ligne du classement contient termineeLe (une date au format ISO) que l'écran n'affiche pas. Affichez-la sous le pseudo au format 26/10/2026. Aide : new Date(ligne.termineeLe).toLocaleDateString("fr-FR").

Exercice C : tirer pour actualiser​

FlatList accepte refreshing (un booléen) et onRefresh (une fonction) : c'est le geste « tirer vers le bas pour actualiser » des téléphones. Branchez-les sur etatClassement.statut === "chargement" et sur actualiser.


Ce qu'il faut retenir​

NotionRésumé
supabase.rpc("nom", { ... })Appelle une fonction SQL ; les noms des paramètres sont ceux de la fonction (p_...).
Conversion vérifiéeLes lignes reçues sont de type unknown : on les vérifie, et on écarte celles qui sont incorrectes.
FlatListListe performante qui ne crée que les lignes visibles : data, renderItem, keyExtractor.
ItemSeparatorComponent, ListEmptyComponentSéparateur entre les lignes et message de liste vide.
Clé du résultat + AbortControllerEmpêchent d'afficher le classement d'anciens filtres.
Numéro de tentativeDans la clé, il force un nouveau chargement sans changer les filtres.
Paramètres initiaux validésuseLocalSearchParams + lireTheme/lireNiveau + valeur par défaut "tous".
Pull RequestFin de tutoriel : relecture, fusion dans main, puis git switch main et git pull.

Aperçu de la prochaine séance​

Votre application est fonctionnelle : on peut s'inscrire, jouer, obtenir un score fiable et se comparer aux autres. Avant de la montrer au monde, il faut s'assurer qu'elle résiste : réseau coupé, double clic, adresse falsifiée, clé oubliée dans le code... Dans le tutoriel suivant, vous allez rédiger un cahier de recette, lancer des vérifications automatiques, puis publier l'application sur le Web et en construire une version Android.