Skip to content

5. La navigation

Une application a plusieurs écrans. Sur [Android], avec [Compose], on n'ouvre pas une activité par écran : une SEULE activité affiche, tour à tour, les écrans de l'application, sous le contrôle d'un ROUTEUR. C'est la bibliothèque [navigation-compose] de [Jetpack], l'équivalent du paquet [go_router] de [Flutter].

5.1. Exemple 17 : le routeur

Le routeur associe une ROUTE à chaque écran, et gère une PILE d'écrans : on empile un écran ([navigate]), le bouton Retour du téléphone le dépile ([popBackStack]). Les routes de l'exemple :

// =========================================================================
// ex17/Routes.kt - les ROUTES de l'application
// -------------------------------------------------------------------------
// Une application a plusieurs écrans (des "pages"). Le ROUTEUR associe une
// ROUTE à chaque écran. La bibliothèque Navigation de Jetpack (navigation-
// compose) gère une PILE d'écrans : on empile un écran (navigate), le
// bouton Retour du téléphone le dépile (popBackStack).
// Une route est un objet Kotlin, @Serializable :
//   - un "object" pour un écran sans paramètre (Accueil) ;
//   - une "data class" pour un écran avec paramètres : DetailMedecin(id = 2).
// Le type des paramètres est vérifié à la compilation : DetailMedecin("2")
// serait refusé. (Navigation sait ranger ces objets dans la pile, d'où
// @Serializable.)
// Chaque écran peut aussi avoir une ADRESSE, un "lien profond" (deep link)
// du type app://exemples/medecins/2 : c'est ce qui se rapproche le plus,
// sur Android, des adresses du web (cf. Ex17Activity.kt).
// =========================================================================
package fr.stahe.exemples.ex17

import kotlinx.serialization.Serializable

@Serializable
object Accueil

@Serializable
object ListeMedecins

// un paramètre de route
@Serializable
data class DetailMedecin(val id: Int)

// un paramètre FACULTATIF (avec une valeur par défaut) : ?q=... dans l'adresse
@Serializable
data class Recherche(val q: String = "")

@Serializable
object APropos

// une ancienne adresse, qui REDIRIGE vers la liste des médecins
@Serializable
object Docteurs

// l'écran des adresses inconnues
@Serializable
data class PageIntrouvable(val adresse: String)

// le début de toutes les adresses de cet exemple
const val BASE = "app://exemples"
  • lignes 23-30 : une route est un objet [Kotlin] [@Serializable] : un [object] pour un écran sans paramètre ; une [data class] pour un écran avec paramètres : [DetailMedecin(id = 2)]. Le type des paramètres est vérifié à la compilation ([DetailMedecin("2")] serait refusé) ; [@Serializable] : [Navigation] sait ranger ces objets dans la pile, et les reconstituer si l'activité est recréée ;
  • ligne 34 : un paramètre facultatif, avec une valeur par défaut ;
  • ligne 48 : chaque écran peut avoir une ADRESSE, un « lien profond » (deep link) de la forme [app://exemples/medecins/2] : c'est ce qui se rapproche le plus, sur [Android], des adresses du web.

Des données « en dur », pour ne pas dépendre d'un serveur :

// ex17/Donnees.kt - des données "en dur", pour ne pas dépendre d'un serveur dans cet exemple
package fr.stahe.exemples.ex17

data class Medecin(val id: Int, val titre: String, val prenom: String, val nom: String, val specialite: String)

val medecins = listOf(
    Medecin(1, "Mme", "Marie", "PELISSIER", "généraliste"),
    Medecin(2, "Mr", "Paul", "TATOU", "pédiatre"),
    Medecin(3, "Melle", "Sophie", "DUPONT", "généraliste"),
)

Le graphe de navigation : une entrée par route, et l'écran à afficher :

// =========================================================================
// ex17/Ex17Activity.kt - le NavHost : les routes et leurs écrans
// -------------------------------------------------------------------------
//   - rememberNavController() : le contrôleur de navigation (la pile) ;
//   - NavHost(nav, startDestination = Accueil) { ... } : l'écran de la
//     route courante. Le bloc décrit le GRAPHE de navigation : une
//     entrée composable<Route> { ... } par route ;
//   - entree.toRoute<DetailMedecin>() : l'objet route de l'écran, avec
//     ses paramètres ;
//   - deepLinks : l'adresse de l'écran. Grâce au filtre d'intentions de
//     l'activité (AndroidManifest.xml), une adresse app://exemples/...
//     peut même être ouverte de l'extérieur de l'application :
//         adb shell am start -a android.intent.action.VIEW -d "app://exemples/medecins/2"
// =========================================================================
package fr.stahe.exemples.ex17

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navDeepLink
import androidx.navigation.toRoute
import fr.stahe.exemples.commun.ThemeExemples
import fr.stahe.exemples.ex17.pages.PageAPropos
import fr.stahe.exemples.ex17.pages.PageAccueil
import fr.stahe.exemples.ex17.pages.PageDetailMedecin
import fr.stahe.exemples.ex17.pages.PageListeMedecins
import fr.stahe.exemples.ex17.pages.PageRecherche
import fr.stahe.exemples.ex17.pages.PageIntrouvable as EcranIntrouvable

class Ex17Activity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent { ThemeExemples { Application() } }
    }
}

@Composable
fun Application() {
    val nav = rememberNavController()
    Coquille(nav) {
        NavHost(navController = nav, startDestination = Accueil) {
            composable<Accueil>(deepLinks = listOf(navDeepLink<Accueil>(basePath = "$BASE/accueil"))) {
                PageAccueil(onLien = { nav.ouvrirLien(it) }, onRedirection = { nav.navigate(Docteurs) })
            }
            composable<ListeMedecins>(deepLinks = listOf(navDeepLink<ListeMedecins>(basePath = "$BASE/medecins"))) {
                // navigate : l'écran du médecin est EMPILÉ au-dessus de la liste
                PageListeMedecins(onVoir = { id -> nav.navigate(DetailMedecin(id)) })
            }
            // app://exemples/medecins/2 : le paramètre id est pris dans l'adresse
            composable<DetailMedecin>(deepLinks = listOf(navDeepLink<DetailMedecin>(basePath = "$BASE/medecins"))) { entree ->
                val route = entree.toRoute<DetailMedecin>()
                PageDetailMedecin(
                    id = route.id,
                    // popBackStack : dépiler l'écran (comme le bouton Retour) ; s'il
                    // n'y a rien dessous (écran ouvert par une adresse), aller à la liste
                    onRetour = { if (!nav.popBackStack()) nav.navigate(ListeMedecins) },
                    // REMPLACER l'écran courant (pas d'empilement) : on dépile le
                    // médecin courant (inclusive) avant d'empiler le suivant
                    onSuivant = { suivant ->
                        nav.navigate(DetailMedecin(suivant)) { popUpTo<DetailMedecin> { inclusive = true } }
                    },
                )
            }
            // app://exemples/recherche?q=pédiatre
            composable<Recherche>(deepLinks = listOf(navDeepLink<Recherche>(basePath = "$BASE/recherche"))) { entree ->
                PageRecherche(q = entree.toRoute<Recherche>().q, onRechercher = { saisie -> nav.navigate(Recherche(saisie)) })
            }
            composable<APropos> { PageAPropos() }
            // une REDIRECTION : l'écran n'affiche rien, il remplace aussitôt
            // sa propre route par une autre
            composable<Docteurs> {
                LaunchedEffect(Unit) {
                    nav.navigate(ListeMedecins) { popUpTo<Docteurs> { inclusive = true } }
                }
            }
            composable<PageIntrouvable> { entree ->
                EcranIntrouvable(adresse = entree.toRoute<PageIntrouvable>().adresse, onAccueil = { nav.allerA(Accueil) })
            }
        }
    }
}
  • ligne 34 : [import ... as EcranIntrouvable] : un alias, car la fonction [PageIntrouvable] des écrans a le même nom que la route [PageIntrouvable] ;
  • lignes 46-48 : [rememberNavController()] : le contrôleur de navigation (la pile) ; [NavHost(nav, startDestination = Accueil)] affiche l'écran de la route courante ;
  • lignes 49-54 : une entrée [composable<Route>] par route, avec son adresse ([deepLinks]). Les écrans ne connaissent pas le routeur : ils reçoivent des FONCTIONS pour naviguer ([onLien], [onVoir]...) ;
  • lignes 57-58 : [entree.toRoute<DetailMedecin>()] : l'objet route, avec ses paramètres. Avec [navDeepLink<DetailMedecin>(basePath = ...)], le paramètre [id] est pris dans l'adresse ([.../medecins/2]) ;
  • ligne 63 : [popBackStack()] dépile l'écran, comme le bouton Retour, et renvoie [false] s'il n'y a rien dessous (écran ouvert par une adresse) ;
  • lignes 66-67 : REMPLACER l'écran courant : on dépile le médecin courant ([popUpTo<DetailMedecin> [ inclusive = true ]]) avant d'empiler le suivant ;
  • lignes 78-80 : une REDIRECTION : l'écran n'affiche rien, il remplace aussitôt sa route par une autre. La navigation est un « effet » ([LaunchedEffect]) : on ne navigue jamais PENDANT l'affichage.

La « coquille » de l'application : la barre du haut, la barre d'onglets du bas, typique d'un mobile, et, entre les deux, l'écran de la route courante :

// =========================================================================
// ex17/Coquille.kt - la "coquille" de l'application
// -------------------------------------------------------------------------
// La barre du haut, la barre de navigation du bas, et, entre les deux,
// l'écran de la route courante (contenu : le NavHost, cf. Ex17Activity.kt).
//   - nav.currentBackStackEntryAsState() : l'écran du DESSUS de la pile,
//     un état observé (la coquille est recomposée à chaque navigation) ;
//   - destination.hasRoute<ListeMedecins>() : l'écran est-il celui de
//     cette route ? ; entree.toRoute<DetailMedecin>() : l'objet route, et
//     ses paramètres ;
//   - navigate(route) { ... } : aller à une route ; les options du bloc
//     disent quoi faire de la pile (cf. allerA).
// =========================================================================
@file:OptIn(ExperimentalMaterial3Api::class)

package fr.stahe.exemples.ex17

import android.net.Uri
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavDestination
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavHostController
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.toRoute

// un onglet : la route où il mène, son libellé, son icône, et le test qui
// dit s'il est actif pour un écran donné
class Onglet(val route: Any, val libelle: String, val icone: ImageVector, val actif: (NavDestination) -> Boolean)

val onglets = listOf(
    Onglet(Accueil, "Accueil", Icons.Default.Home) { it.hasRoute<Accueil>() },
    // l'onglet Médecins reste actif sur l'écran d'un médecin
    Onglet(ListeMedecins, "Médecins", Icons.Default.Person) { it.hasRoute<ListeMedecins>() || it.hasRoute<DetailMedecin>() },
    Onglet(Recherche("généraliste"), "Recherche", Icons.Default.Search) { it.hasRoute<Recherche>() },
    Onglet(APropos, "À propos", Icons.Default.Info) { it.hasRoute<APropos>() },
)

// l'adresse de l'écran courant, pour l'afficher
fun adresseDe(entree: NavBackStackEntry?): String {
    val d = entree?.destination ?: return ""
    return when {
        d.hasRoute<Accueil>() -> "/"
        d.hasRoute<ListeMedecins>() -> "/medecins"
        d.hasRoute<DetailMedecin>() -> "/medecins/${entree.toRoute<DetailMedecin>().id}"
        d.hasRoute<Recherche>() -> "/recherche?q=${entree.toRoute<Recherche>().q}"
        d.hasRoute<APropos>() -> "/a-propos"
        d.hasRoute<PageIntrouvable>() -> entree.toRoute<PageIntrouvable>().adresse
        else -> "?"
    }
}

// aller à un onglet : la pile est REMPLACÉE par celle de cet onglet
fun NavHostController.allerA(route: Any) {
    navigate(route) {
        // on dépile tout, jusqu'au premier écran (exclu)...
        popUpTo(graph.findStartDestination().id)
        // ... et l'on n'empile pas deux fois le même écran
        launchSingleTop = true
    }
}

// suivre un LIEN (une adresse) ; une adresse inconnue mène à PageIntrouvable
fun NavHostController.ouvrirLien(adresse: String) {
    try {
        navigate(Uri.parse("$BASE$adresse"))
    } catch (e: IllegalArgumentException) {
        navigate(PageIntrouvable(adresse))
    }
}

@Composable
fun Coquille(nav: NavHostController, contenu: @Composable () -> Unit) {
    val entree by nav.currentBackStackEntryAsState()
    val destination = entree?.destination
    Scaffold(
        topBar = { TopAppBar(title = { Text("Exemple 17 - le routeur") }) },
        // NavigationBar : la barre d'onglets du bas, typique d'un mobile
        bottomBar = {
            NavigationBar {
                for (o in onglets) {
                    NavigationBarItem(
                        selected = destination != null && o.actif(destination),
                        onClick = { nav.allerA(o.route) },
                        icon = { Icon(o.icone, contentDescription = null) },
                        label = { Text(o.libelle) },
                    )
                }
            }
        },
    ) { marges ->
        Column(Modifier.padding(marges).fillMaxSize()) {
            Text(
                "adresse courante : ${adresseDe(entree)}",
                fontFamily = FontFamily.Monospace,
                fontSize = 12.sp,
                modifier = Modifier.fillMaxWidth().background(Color(0xFFF5F5F5)).padding(8.dp),
            )
            Box(Modifier.padding(16.dp).weight(1f)) { contenu() }
        }
    }
}
  • lignes 55-62 : les onglets. [hasRoute<ListeMedecins>()] : l'écran est-il celui de cette route ? L'onglet « Médecins » reste actif sur l'écran d'un médecin ;
  • lignes 66-75 : l'adresse de l'écran courant, pour l'afficher ;
  • lignes 80-85 : aller à un onglet : la pile est REMPLACÉE ([popUpTo] le premier écran), et l'on n'empile pas deux fois le même écran ([launchSingleTop]) ;
  • lignes 90-94 : suivre une adresse : [navigate(Uri)] ; une adresse inconnue lève une exception : on affiche alors l'écran des pages introuvables ;
  • lignes 100-101 : [currentBackStackEntryAsState()] : l'écran du DESSUS de la pile, un état observé : la coquille est recomposée à chaque navigation ;
  • lignes 106-112 : la barre d'onglets ([NavigationBar]).

Les écrans :

// =========================================================================
// ex17/pages/Pages.kt - les écrans de l'exemple 17
// -------------------------------------------------------------------------
// Les écrans ne connaissent pas le routeur : ils reçoivent leurs paramètres
// (id, q...) et des FONCTIONS pour naviguer (onVoir, onRetour...). C'est le
// NavHost (Ex17Activity.kt) qui relie ces fonctions aux routes. Un écran
// reste ainsi un composant ordinaire, facile à afficher seul (dans un
// aperçu d'Android Studio, dans un test).
// =========================================================================
@file:OptIn(ExperimentalLayoutApi::class)

package fr.stahe.exemples.ex17.pages

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.foundation.clickable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import fr.stahe.exemples.commun.TitreTache
import fr.stahe.exemples.ex17.medecins

@Composable
fun PageAccueil(onLien: (String) -> Unit, onRedirection: () -> Unit) {
    TitreTache("Accueil")
    Column {
        Text(
            "Bienvenue. Touchez les onglets : l'écran change, et son adresse aussi. Le bouton Retour " +
                "du téléphone dépile les écrans empilés. Une adresse peut aussi être ouverte de " +
                "l'extérieur de l'application (cf. le cours).",
        )
        Spacer(Modifier.height(16.dp))
        // des liens vers des adresses particulières
        FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
            OutlinedButton(onClick = onRedirection) { Text("/docteurs (redirection)") }
            OutlinedButton(onClick = { onLien("/page-inconnue") }) { Text("Lien cassé") }
        }
    }
}

@Composable
fun PageListeMedecins(onVoir: (Int) -> Unit) {
    TitreTache("Médecins")
    Column {
        for (m in medecins) {
            ListItem(
                headlineContent = { Text("${m.titre} ${m.prenom} ${m.nom}") },
                trailingContent = { Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null) },
                // le médecin est EMPILÉ ; le bouton Retour y revient
                modifier = Modifier.clickable { onVoir(m.id) },
            )
        }
    }
}

// une page à paramètre : le routeur lui passe id, un Int
@Composable
fun PageDetailMedecin(id: Int, onRetour: () -> Unit, onSuivant: (Int) -> Unit) {
    // find : le premier médecin qui convient, ou null
    val medecin = medecins.find { it.id == id }
    if (medecin == null) {
        Text("Le médecin n° $id n'existe pas.", color = Color.Red)
        return
    }
    val suivant = medecin.id % medecins.size + 1
    TitreTache("${medecin.prenom} ${medecin.nom}")
    Column {
        Text("${medecin.titre} ${medecin.prenom} ${medecin.nom}", style = MaterialTheme.typography.titleLarge)
        Text("Spécialité : ${medecin.specialite}")
        Spacer(Modifier.height(16.dp))
        FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
            OutlinedButton(onClick = onRetour) { Text("Retour") }
            Button(onClick = { onSuivant(suivant) }) { Text("Médecin suivant") }
        }
    }
}

// =========================================================================
// une page pilotée par sa route : Recherche(q = "pédiatre"). Rechercher,
// c'est ALLER à la route Recherche(q = <saisie>) : la page affiche ce que
// dit la route, et rien d'autre (le bouton Retour revient à la recherche
// précédente).
// =========================================================================
@Composable
fun PageRecherche(q: String, onRechercher: (String) -> Unit) {
    TitreTache("Recherche")
    val resultats = medecins.filter { "${it.prenom} ${it.nom} ${it.specialite}".lowercase().contains(q.lowercase()) }
    // la saisie démarre avec la valeur de la route ; rememberSaveable(q) :
    // si la route change (bouton Retour), la saisie repart de la nouvelle valeur
    var saisie by rememberSaveable(q) { mutableStateOf(q) }
    Column {
        OutlinedTextField(
            value = saisie,
            onValueChange = { saisie = it },
            placeholder = { Text("nom ou spécialité") },
            trailingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
            singleLine = true,
            // la touche "rechercher" du clavier
            keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
            keyboardActions = KeyboardActions(onSearch = { onRechercher(saisie.trim()) }),
            modifier = Modifier.fillMaxWidth(),
        )
        Spacer(Modifier.height(8.dp))
        for (m in resultats) {
            ListItem(headlineContent = { Text("${m.prenom} ${m.nom} (${m.specialite})") })
        }
        if (resultats.isEmpty()) {
            ListItem(headlineContent = { Text("Aucun résultat.", color = Color.Gray) })
        }
    }
}

@Composable
fun PageAPropos() {
    TitreTache("À propos")
    Text(
        "Sur un téléphone, tout le code de l'application est dans son fichier .apk : il n'y a rien " +
            "à télécharger à la première visite d'un écran (pour une très grosse application, Google " +
            "Play sait livrer certains modules à la demande : Play Feature Delivery).",
    )
}

@Composable
fun PageIntrouvable(adresse: String, onAccueil: () -> Unit) {
    TitreTache("Page introuvable")
    Column {
        Text("Aucune page à l'adresse $adresse.", color = Color.Red)
        Spacer(Modifier.height(16.dp))
        Button(onClick = onAccueil) { Text("Retour à l'accueil") }
    }
}
  • ligne 73 : [Modifier.clickable] : toute la ligne est un lien ;
  • lignes 108-113 : une page pilotée par sa route : rechercher, c'est ALLER à la route [Recherche(q = saisie)] ; la page affiche ce que dit la route. [rememberSaveable(q)] : si la route change (bouton Retour), la saisie repart de la nouvelle valeur.

Image

Image

Image

Image

Pour qu'une adresse puisse être ouverte DE L'EXTÉRIEUR de l'application (un lien dans un courriel, une autre application), l'activité déclare dans le manifeste un filtre d'intentions :

1
2
3
4
5
6
7
        <activity android:name=".ex17.Ex17Activity" android:exported="true" android:windowSoftInputMode="adjustResize">
            <intent-filter>
                <action android:name="android.intent.action.VIEW" />
                <category android:name="android.intent.category.DEFAULT" />
                <data android:scheme="app" android:host="exemples" />
            </intent-filter>
        </activity>

On peut alors l'essayer depuis une fenêtre de commandes du PC, avec l'outil [adb] du SDK [Android] (émulateur lancé) :

adb shell am start -a android.intent.action.VIEW -d "app://exemples/medecins/2"

L'exemple 17 s'ouvre directement sur l'écran du médecin n° 2 ; « Retour » mène alors à la liste ([popBackStack()] a renvoyé [false]).

5.2. Exemple 18 : les gardes de navigation

Certains écrans ne sont permis qu'à un utilisateur connecté, ou qu'à certains rôles. L'utilisateur connecté, simulé ici sans serveur, est rangé dans un objet unique :

// =========================================================================
// ex18/Session.kt - une session SIMULÉE (sans serveur)
// -------------------------------------------------------------------------
// L'utilisateur connecté doit être connu des ÉCRANS (la barre du haut,
// l'agenda) et des GARDES du routeur. On le range dans un objet UNIQUE
// (object : une classe qui n'a qu'une instance, créée au premier usage) :
//   - une propriété d'état, observée par Compose (mutableStateOf) : tout
//     composant qui la lit est recomposé quand elle change ;
//   - des fonctions pour la modifier.
// (L'exemple 23 fera la même chose avec un vrai serveur.)
// =========================================================================
package fr.stahe.exemples.ex18

import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue

enum class Role { ADMIN, DOCTOR, USER }

data class Utilisateur(val login: String, val role: Role)

// trois comptes "en dur" (mot de passe = login)
private val comptes = mapOf("admin" to Role.ADMIN, "pelissier" to Role.DOCTOR, "martin" to Role.USER)

object Session {
    // l'état : null, personne n'est connecté. private set : modifiable seulement ici
    var utilisateur by mutableStateOf<Utilisateur?>(null)
        private set

    fun connecter(login: String, password: String): Boolean {
        val role = comptes[login]
        if (role == null || password != login) {
            return false
        }
        utilisateur = Utilisateur(login, role)
        return true
    }

    fun deconnecter() {
        utilisateur = null
    }
}
  • lignes 25-26 : [object] : une classe qui n'a qu'une instance, créée au premier usage ; sa propriété [utilisateur] est un état observé : tout composant qui la lit est recomposé quand elle change.

Les routes, et les gardes :

// =========================================================================
// ex18/Routes.kt - des routes PROTÉGÉES par des gardes
// -------------------------------------------------------------------------
// Un GARDE est une fonction qui reçoit l'utilisateur connecté (ou null) et
// renvoie :
//   - null : l'écran peut s'afficher ;
//   - une route : l'écran est REMPLACÉ par cette route (une redirection).
// Le composant Protege(...) applique les gardes d'un écran. Comme il LIT
// l'utilisateur de la session (un état observé), il est réévalué à chaque
// connexion ou déconnexion : un écran qui n'est plus permis est quitté.
//
// C'est l'équivalent, côté client, des gardes NestJS (JwtAuthGuard,
// RolesGuard) - avec une différence FONDAMENTALE : un garde du client n'est
// qu'un confort. L'application est entre les mains de l'utilisateur, qui
// peut la modifier à volonté. La vraie protection des données reste celle
// du SERVEUR (cf. chapitre sur la boîte noire).
// =========================================================================
package fr.stahe.exemples.ex18

import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.navigation.NavHostController
import kotlinx.serialization.Serializable

// refus : déposé par le garde roleRequis quand le rôle ne convient pas
@Serializable
data class Accueil(val refus: String? = null)

// redirect : l'adresse de l'écran qu'on voulait ouvrir
@Serializable
data class Connexion(val redirect: String? = null)

@Serializable
object Agenda

@Serializable
object Administration

const val BASE = "app://exemples18"

typealias Garde = (Utilisateur?) -> Any?

// le garde "connexion requise" : pas connecté -> à la page de connexion, en
// retenant l'adresse de l'écran demandé
fun connexionRequise(adresse: String): Garde = { u -> if (u == null) Connexion(redirect = adresse) else null }

// le garde "rôle requis" : connecté, mais pas avec le bon rôle -> retour à
// l'accueil, avec un motif
fun roleRequis(adresse: String, vararg roles: Role): Garde = { u ->
    if (u == null || u.role !in roles) Accueil(refus = adresse) else null
}

// les gardes, dans l'ordre : le premier qui renvoie une route l'emporte
@Composable
fun Protege(nav: NavHostController, vararg gardes: Garde, contenu: @Composable () -> Unit) {
    val redirection = gardes.firstNotNullOfOrNull { garde -> garde(Session.utilisateur) }
    if (redirection == null) {
        contenu()
    } else {
        // la navigation est un "effet" : on ne navigue pas PENDANT l'affichage
        LaunchedEffect(redirection) {
            val ici = nav.currentBackStackEntry?.destination?.id ?: return@LaunchedEffect
            // l'écran refusé est remplacé (inclusive) par la redirection
            nav.navigate(redirection) { popUpTo(ici) { inclusive = true } }
        }
    }
}
  • ligne 41 : un GARDE est une fonction qui reçoit l'utilisateur (ou [null]) et renvoie [null] (l'écran peut s'afficher) ou une ROUTE (l'écran est remplacé par cette route) ;
  • lignes 45-50 : deux fabriques de gardes : « connexion requise » renvoie à la connexion en retenant l'adresse demandée ; « rôle requis » renvoie à l'accueil avec un motif ;
  • lignes 55-64 : [Protege] applique les gardes d'un écran : [firstNotNullOfOrNull], le premier résultat non [null]. Comme il LIT la session, il est réévalué à chaque connexion ou déconnexion : un écran qui n'est plus permis est quitté.

C'est l'équivalent, côté client, des gardes de [NestJS] ([JwtAuthGuard], [RolesGuard]), avec une différence FONDAMENTALE : un garde du client n'est qu'un confort. L'application est entre les mains de l'utilisateur ; la vraie protection des données reste celle du SERVEUR.

// =========================================================================
// ex18/Ex18Activity.kt - le graphe de navigation, avec ses gardes
// -------------------------------------------------------------------------
// Les écrans protégés sont enveloppés par Protege(nav, gardes...) : leurs
// gardes sont évalués à l'ouverture de l'écran... et de nouveau à chaque
// changement de la session.
// Après la connexion, on renvoie l'utilisateur à l'écran qu'il voulait
// ouvrir (son adresse, rangée par le garde dans Connexion.redirect), ou à
// l'accueil.
// =========================================================================
package fr.stahe.exemples.ex18

import android.net.Uri
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.Composable
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navDeepLink
import androidx.navigation.navOptions
import androidx.navigation.toRoute
import fr.stahe.exemples.commun.ThemeExemples

class Ex18Activity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent { ThemeExemples { Application() } }
    }
}

@Composable
fun Application() {
    val nav = rememberNavController()
    Coquille(nav) {
        NavHost(navController = nav, startDestination = Accueil()) {
            composable<Accueil> { entree -> PageAccueil(refus = entree.toRoute<Accueil>().refus) }
            composable<Connexion> { entree ->
                PageConnexion(
                    redirection = entree.toRoute<Connexion>().redirect,
                    onConnecte = { r ->
                        // on n'accepte qu'une adresse de CETTE application
                        // la pile est vidée (popUpTo le graphe entier) : la connexion
                        // n'y reste pas, Retour ne la rouvrira pas
                        val viderLaPile = navOptions { popUpTo(nav.graph.id) { inclusive = true } }
                        if (r != null && r.startsWith("$BASE/")) {
                            nav.navigate(Uri.parse(r), viderLaPile)
                        } else {
                            nav.navigate(Accueil(), viderLaPile)
                        }
                    },
                )
            }
            composable<Agenda>(deepLinks = listOf(navDeepLink<Agenda>(basePath = "$BASE/agenda"))) {
                Protege(nav, connexionRequise("$BASE/agenda")) { PageAgenda() }
            }
            composable<Administration>(deepLinks = listOf(navDeepLink<Administration>(basePath = "$BASE/administration"))) {
                Protege(nav, connexionRequise("$BASE/administration"), roleRequis("/administration", Role.ADMIN)) {
                    PageAdministration()
                }
            }
        }
    }
}
  • lignes 44-52 : après la connexion, on va à l'adresse retenue par le garde (seulement si c'est une adresse de CETTE application), sinon à l'accueil ; [navOptions { popUpTo(nav.graph.id) [ inclusive = true ] }] vide la pile : Retour ne rouvrira pas la connexion ;
  • lignes 58-62 : les écrans protégés, enveloppés par [Protege].

La coquille et les écrans :

// =========================================================================
// ex18/Coquille.kt - les liens, et l'utilisateur connecté
// -------------------------------------------------------------------------
// La barre du haut lit Session.utilisateur : elle est recomposée à chaque
// connexion et déconnexion.
// =========================================================================
@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)

package fr.stahe.exemples.ex18

import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ExitToApp
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SuggestionChip
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavHostController
import androidx.navigation.compose.currentBackStackEntryAsState

@Composable
fun Coquille(nav: NavHostController, contenu: @Composable () -> Unit) {
    val destination = nav.currentBackStackEntryAsState().value?.destination
    val u = Session.utilisateur

    // un lien : en gras s'il désigne l'écran affiché
    @Composable
    fun Lien(route: Any, actif: Boolean, libelle: String) {
        TextButton(onClick = { nav.navigate(route) { launchSingleTop = true } }) {
            Text(libelle, fontWeight = if (actif) FontWeight.Bold else FontWeight.Normal)
        }
    }

    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text("Exemple 18 - les gardes") },
                actions = {
                    if (u == null) {
                        TextButton(onClick = { nav.navigate(Connexion()) }) { Text("connexion") }
                    } else {
                        SuggestionChip(onClick = {}, label = { Text("${u.login} ${u.role}") })
                        IconButton(onClick = {
                            Session.deconnecter()
                            nav.navigate(Accueil()) { popUpTo(nav.graph.id) { inclusive = true } }
                        }) {
                            Icon(Icons.AutoMirrored.Filled.ExitToApp, contentDescription = "déconnexion")
                        }
                    }
                },
            )
        },
    ) { marges ->
        Column(Modifier.padding(marges).fillMaxSize()) {
            FlowRow {
                Lien(Accueil(), destination?.hasRoute<Accueil>() == true, "Accueil")
                Lien(Agenda, destination?.hasRoute<Agenda>() == true, "Agenda (connecté)")
                Lien(Administration, destination?.hasRoute<Administration>() == true, "Administration (ADMIN)")
            }
            HorizontalDivider()
            Box(Modifier.padding(16.dp).weight(1f)) { contenu() }
        }
    }
}
// =========================================================================
// ex18/Pages.kt - les écrans de l'exemple 18
// =========================================================================
package fr.stahe.exemples.ex18

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp

@Composable
fun PageAccueil(refus: String?) {
    Column {
        if (refus != null) {
            Card(colors = CardDefaults.cardColors(containerColor = Color(0xFFFFEBEE))) {
                Text("Votre rôle ne permet pas d'ouvrir $refus.", Modifier.padding(12.dp))
            }
            Spacer(Modifier.height(8.dp))
        }
        Text("Page publique. Essayez les deux autres liens, connecté ou non (admin, pelissier, martin - mot de passe = login).")
    }
}

@Composable
fun PageAgenda() {
    Text(
        buildAnnotatedString {
            append("L'agenda de ")
            withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(Session.utilisateur?.login ?: "") }
            append(" : cette page exige d'être connecté.")
        },
    )
}

@Composable
fun PageAdministration() {
    Text("Administration : page réservée au rôle ADMIN.")
}

// =========================================================================
// la connexion. Après une connexion réussie, onConnecte reçoit l'adresse de
// l'écran qu'on voulait ouvrir (paramètre redirect déposé par le garde),
// ou null
// =========================================================================
@Composable
fun PageConnexion(redirection: String?, onConnecte: (String?) -> Unit) {
    var login by remember { mutableStateOf("") }
    var password by remember { mutableStateOf("") }
    var erreur by remember { mutableStateOf(false) }

    fun valider() {
        if (!Session.connecter(login.trim(), password)) {
            erreur = true
            return
        }
        onConnecte(redirection)
    }

    Column {
        if (redirection != null) {
            Card(colors = CardDefaults.cardColors(containerColor = Color(0xFFFFF8E1))) {
                Text("Connectez-vous pour ouvrir ${redirection.removePrefix(BASE)}.", Modifier.padding(12.dp))
            }
        }
        OutlinedTextField(value = login, onValueChange = { login = it }, label = { Text("login") }, singleLine = true, modifier = Modifier.fillMaxWidth())
        OutlinedTextField(
            value = password,
            onValueChange = { password = it },
            label = { Text("mot de passe") },
            singleLine = true,
            visualTransformation = PasswordVisualTransformation(),
            keyboardOptions = KeyboardOptions(imeAction = ImeAction.Go),
            keyboardActions = KeyboardActions(onGo = { valider() }),
            modifier = Modifier.fillMaxWidth(),
        )
        Spacer(Modifier.height(16.dp))
        Button(onClick = { valider() }) { Text("Se connecter") }
        if (erreur) {
            Text("Login ou mot de passe incorrect.", color = Color.Red, modifier = Modifier.padding(top = 8.dp))
        }
    }
}

Image

Image

Image

À gauche, « Administration » sans être connecté : le garde a mené à la connexion ; au milieu, connecté comme [martin] (rôle USER) : retour à l'accueil, avec le motif ; à droite, connecté comme [admin].