Skip to content

9. Dialoguer avec le serveur

Le serveur RdvMedecins est lancé (cf. chapitre précédent, avec [CAPTCHA_TEST=test] dans son fichier [.env] pour les tests). Les exemples 22 à 25 l'interrogent depuis le téléphone.

9.0.1. Où est le serveur ?

Une application web servie par le serveur lui-même n'a pas cette question à se poser : ses requêtes partent vers la même origine que la page. Une application MOBILE, si : il faut lui donner l'adresse complète du serveur. Or le téléphone n'est pas l'ordinateur qui fait tourner le serveur :

// =========================================================================
// commun/Config.kt - OÙ est le serveur JSON ? (exemples 22 à 25)
// -------------------------------------------------------------------------
// Une application web servie par le serveur lui-même n'a pas cette
// question à se poser : ses requêtes partent vers la MÊME origine que la
// page. Une application MOBILE, si : il faut lui donner l'adresse complète
// du serveur. Or le téléphone n'est pas l'ordinateur qui fait tourner le
// serveur :
//   - "localhost", pour le téléphone (ou l'émulateur), c'est LUI-MÊME. La
//     commande  adb reverse tcp:8080 tcp:8080  le corrige : elle relie le
//     port 8080 du téléphone au port 8080 de l'ordinateur, par le câble USB
//     (ou, pour l'émulateur, directement). C'est la solution retenue par
//     défaut : http://localhost:8080 ;
//   - l'émulateur Android voit aussi l'ordinateur à l'adresse spéciale
//     10.0.2.2 ;
//   - un vrai téléphone, relié au même réseau Wi-Fi que l'ordinateur, peut
//     utiliser l'adresse de l'ordinateur sur ce réseau (192.168.1.20...).
// L'adresse est fixée à la COMPILATION (BuildConfig, cf. app/build.gradle.kts) :
//     gradlew installDebug -Pserveur=http://10.0.2.2:8080
// =========================================================================
package fr.stahe.exemples.commun

import fr.stahe.exemples.BuildConfig

object Config {
    val urlServeur: String = BuildConfig.URL_SERVEUR
}
  • « localhost », pour le téléphone (ou l'émulateur), c'est LUI-MÊME. La commande [adb reverse tcp:8080 tcp:8080] relie le port 8080 du téléphone au port 8080 de l'ordinateur, par le câble USB (ou directement, pour l'émulateur). C'est la solution retenue par défaut : [http://localhost:8080]. La commande est à relancer à chaque démarrage de l'émulateur ;
  • l'émulateur voit aussi l'ordinateur à l'adresse spéciale [10.0.2.2] ;
  • un vrai téléphone relié au même réseau Wi-Fi que l'ordinateur peut utiliser l'adresse de l'ordinateur sur ce réseau ([192.168.1.20]...), à condition que le pare-feu de l'ordinateur laisse passer le port 8080.

L'adresse est un champ de la classe [BuildConfig], générée à la compilation (cf. [app/build.gradle.kts], exemple 01) ; on peut la changer sans toucher au code :

gradlew installDebug -Pserveur=http://10.0.2.2:8080

Enfin, le manifeste demande la permission [INTERNET] et autorise le trafic HTTP « en clair » ([usesCleartextTraffic]) : depuis [Android] 9, seul HTTPS est permis par défaut.

9.1. Exemple 22 : une première requête HTTP

On demande un captcha au serveur : [GET /api/captcha] renvoie [[ "id": "...", "svg": "<svg ...>" ]]. Le client HTTP est [OkHttp], le plus utilisé sur [Android] (l'équivalent du paquet [http] de [Flutter]).

// =========================================================================
// ex22/Ex22Activity.kt - une première requête HTTP vers le serveur
// -------------------------------------------------------------------------
// Le serveur JSON RdvMedecins doit être lancé (port 8080). On lui demande
// un captcha : GET /api/captcha -> { "id": "...", "svg": "<svg ...>" }.
//
//   - OkHttp, le client HTTP le plus utilisé sur Android : on décrit la
//     requête (Request), le client l'exécute (newCall(...).execute()) et
//     renvoie la réponse : son code de statut (code), ses entêtes
//     (headers), son corps (body) ;
//   - execute() BLOQUE jusqu'à la réponse : Android interdit de l'appeler
//     sur le fil d'exécution de l'écran. withContext(Dispatchers.IO)
//     l'exécute sur un autre fil, et la coroutine ATTEND le résultat ;
//   - Json.parseToJsonElement(texte) : le corps JSON -> des objets Kotlin ;
//   - l'adresse du serveur dépend de l'endroit où tourne l'application
//     (émulateur, téléphone...) : cf. commun/Config.kt ;
//   - trois états à afficher : en cours, réussi, en erreur ;
//   - l'image du captcha est du SVG : cf. commun/ImageSvg.kt.
// =========================================================================
package fr.stahe.exemples.ex22

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.border
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.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
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.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import fr.stahe.exemples.commun.Config
import fr.stahe.exemples.commun.ImageSvg
import fr.stahe.exemples.commun.PageExemple
import fr.stahe.exemples.commun.ThemeExemples
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.OkHttpClient
import okhttp3.Request

// ce que renvoie GET /api/captcha
data class Captcha(val id: String, val svg: String)

// un client HTTP : un objet coûteux à créer, qu'on crée UNE fois
val client = OkHttpClient()

// la requête : une fonction SUSPENDUE, qui renvoie le captcha ou lance une exception
suspend fun lireCaptcha(): Captcha = withContext(Dispatchers.IO) {
    val requete = Request.Builder().url("${Config.urlServeur}/api/captcha").build()
    client.newCall(requete).execute().use { reponse ->
        // OkHttp ne lance PAS d'exception pour une réponse d'erreur (4xx,
        // 5xx) : on teste le code de statut
        if (!reponse.isSuccessful) {
            throw Exception("le serveur a répondu ${reponse.code} ${reponse.message}")
        }
        val json = Json.parseToJsonElement(reponse.body!!.string()).jsonObject
        Captcha(json["id"]!!.jsonPrimitive.content, json["svg"]!!.jsonPrimitive.content)
    }
}

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

@Composable
fun PageCaptcha() {
    var captcha by remember { mutableStateOf<Captcha?>(null) }
    var enCours by remember { mutableStateOf(true) }
    var erreur by remember { mutableStateOf<String?>(null) }
    var nbRequetes by remember { mutableIntStateOf(0) }
    // le numéro de la DEMANDE : la changer relance l'effet ci-dessous
    var demande by remember { mutableIntStateOf(0) }

    // la requête, dès l'apparition de l'écran (demande = 0), puis à chaque
    // nouvelle demande. Si une demande arrive avant la réponse de la
    // précédente, la coroutine précédente est ANNULÉE : sa réponse, périmée,
    // ne sera jamais affichée
    LaunchedEffect(demande) {
        enCours = true
        erreur = null
        try {
            captcha = lireCaptcha()
            nbRequetes++
        } catch (e: CancellationException) {
            throw e // la coroutine a été annulée (nouvelle demande) : rien à afficher
        } catch (e: Exception) {
            // pas de réponse du tout (serveur arrêté, mauvaise adresse...), ou erreur
            erreur = e.message ?: e.toString()
            captcha = null
        }
        enCours = false
    }

    PageExemple("Exemple 22 - une première requête HTTP") {
        Text("Serveur : ${Config.urlServeur}", color = Color.Gray)
        Spacer(Modifier.height(12.dp))
        if (enCours) LinearProgressIndicator(Modifier.fillMaxWidth())
        erreur?.let {
            Card(colors = CardDefaults.cardColors(containerColor = Color(0xFFFFEBEE))) {
                Text("Impossible d'obtenir un captcha : $it.\nLe serveur JSON RdvMedecins est-il lancé (port 8080) ?", Modifier.padding(12.dp))
            }
        }
        val c = captcha
        if (c != null && !enCours) {
            ImageSvg(
                c.svg,
                hauteur = 60.dp,
                modifier = Modifier.border(1.dp, Color(0xFFE0E0E0), RoundedCornerShape(6.dp)).padding(4.dp),
            )
            Spacer(Modifier.height(8.dp))
            Text("id : ${c.id}", fontFamily = FontFamily.Monospace, fontSize = 11.sp)
        }
        Spacer(Modifier.height(12.dp))
        Button(onClick = { demande++ }, enabled = !enCours) { Text("Autre image") }
        Spacer(Modifier.height(8.dp))
        Text("$nbRequetes requête(s) réussie(s)", color = Color.Gray)
    }
}
  • ligne 66 : un client HTTP, objet coûteux à créer, qu'on crée UNE fois ;
  • lignes 69-71 : la requête, une fonction SUSPENDUE. On décrit la requête ([Request]), le client l'exécute ([newCall(...).execute()]) et renvoie la réponse : son code de statut, ses entêtes, son corps. [execute()] BLOQUE jusqu'à la réponse : [Android] interdit de l'appeler sur le fil d'exécution de l'écran ([NetworkOnMainThreadException]). [withContext(Dispatchers.IO)] l'exécute sur un fil réservé aux entrées-sorties, et la coroutine ATTEND le résultat ;
  • lignes 74-75 : [OkHttp] ne lance PAS d'exception pour une réponse d'erreur (4xx, 5xx) : on teste le code de statut. Il lance en revanche une [IOException] s'il n'y a pas de réponse du tout (serveur arrêté, mauvaise adresse) ;
  • lignes 77-78 : le corps JSON, lu « à la main » : [jsonObject], [jsonPrimitive.content] ;
  • lignes 103-116 : la requête est lancée par un [LaunchedEffect] dont la clé est un numéro de DEMANDE : le bouton « Autre image » l'incrémente, ce qui relance l'effet. Si une demande arrive avant la réponse de la précédente, la coroutine précédente est ANNULÉE : sa réponse, périmée, ne sera jamais affichée ;
  • lignes 122-136 : trois états à afficher : en cours (une barre de progression), en erreur, réussi.

L'image du captcha est du SVG, un format qu'[Android] ne sait pas afficher seul : la bibliothèque [AndroidSVG] la dessine dans une image ([Bitmap]) :

// =========================================================================
// commun/ImageSvg.kt - dessiner une image SVG donnée sous forme de texte
// -------------------------------------------------------------------------
// L'image du captcha est fabriquée par le serveur, au format SVG (un texte
// qui décrit un dessin). Android ne sait pas l'afficher seul : la
// bibliothèque AndroidSVG la dessine dans une image (un Bitmap), que
// Compose affiche avec le composant Image.
// =========================================================================
package fr.stahe.exemples.commun

import android.graphics.Bitmap
import android.graphics.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.caverock.androidsvg.SVG

@Composable
fun ImageSvg(svg: String, modifier: Modifier = Modifier, hauteur: Dp = 50.dp, description: String? = null) {
    // le dessin n'est refait que si le texte SVG change (remember(svg))
    val image = remember(svg) {
        val document = SVG.getFromString(svg)
        // dessiné 3 fois plus grand que sa taille "naturelle" : une image nette
        val echelle = 3f
        val largeur = (document.documentWidth.takeIf { it > 0 } ?: 150f)
        val haut = (document.documentHeight.takeIf { it > 0 } ?: 50f)
        val bitmap = Bitmap.createBitmap((largeur * echelle).toInt(), (haut * echelle).toInt(), Bitmap.Config.ARGB_8888)
        val toile = Canvas(bitmap)
        toile.scale(echelle, echelle)
        document.renderToCanvas(toile)
        bitmap.asImageBitmap()
    }
    Image(image, contentDescription = description, contentScale = ContentScale.Fit, modifier = modifier.height(hauteur))
}

Image

Image

Arrêtez le serveur, et appuyez sur « Autre image » : l'erreur s'affiche.

Le serveur dépose le jeton JWT dans un COOKIE. Dans un navigateur, c'est le navigateur qui range les cookies et les renvoie : le code de l'application n'a rien à faire. Sur un téléphone, il n'y a pas de navigateur : c'est à l'application de jouer son rôle. [OkHttp] le fait, si on lui fournit un « bocal à cookies » ([CookieJar]) :

// =========================================================================
// commun/ClientHttp.kt - un client HTTP qui gère les COOKIES
// -------------------------------------------------------------------------
// Le serveur RdvMedecins dépose le jeton JWT dans un COOKIE (httpOnly).
// Dans un navigateur, c'est le navigateur qui range les cookies et les
// renvoie avec chaque requête : le code de l'application n'a rien à faire.
// Sur un téléphone, il n'y a PAS de navigateur : c'est à l'application de
// jouer son rôle, c'est-à-dire :
//   1. lire l'entête "Set-Cookie" de chaque réponse, et ranger les cookies ;
//   2. les renvoyer dans l'entête "Cookie" de chaque requête.
// La bibliothèque OkHttp le fait pour nous, si on lui fournit un CookieJar
// (un "bocal à cookies") : une classe à deux fonctions, saveFromResponse
// (ranger) et loadForRequest (ressortir). Celui-ci range aussi les cookies
// sur l'appareil (SharedPreferences) : on reste connecté quand on arrête
// puis relance l'application.
// =========================================================================
package fr.stahe.exemples.commun

import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import java.util.concurrent.TimeUnit

class CookiesSurLAppareil(private val stockage: SharedPreferences) : CookieJar {
    // nom du cookie -> le cookie (sa valeur, son domaine, son expiration...)
    private val cookies = mutableMapOf<String, Cookie>()

    init {
        // les cookies mémorisés lors d'une utilisation précédente : chacun
        // est rangé sous la forme de son entête Set-Cookie
        val base = Config.urlServeur.toHttpUrl()
        for (texte in stockage.getStringSet(CLE, emptySet()).orEmpty()) {
            Cookie.parse(base, texte)?.let { cookies[it.name] = it }
        }
    }

    // la réponse apporte des cookies : on les range. Un cookie déjà expiré
    // (déconnexion : le serveur l'efface ainsi) est retiré
    @Synchronized
    override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
        for (c in cookies) {
            if (c.expiresAt < System.currentTimeMillis() || c.value.isEmpty()) {
                this.cookies.remove(c.name)
            } else {
                this.cookies[c.name] = c
            }
        }
        stockage.edit { putStringSet(CLE, this@CookiesSurLAppareil.cookies.values.map { it.toString() }.toSet()) }
    }

    // une requête part : les cookies qui la concernent (adresse, chemin), non expirés
    @Synchronized
    override fun loadForRequest(url: HttpUrl): List<Cookie> =
        cookies.values.filter { it.expiresAt >= System.currentTimeMillis() && it.matches(url) }

    companion object {
        private const val CLE = "rdvmedecins.cookies"
    }
}

// le client HTTP des exemples : un seul pour toute l'application
fun creerClientHttp(context: Context): OkHttpClient = OkHttpClient.Builder()
    .cookieJar(CookiesSurLAppareil(context.getSharedPreferences("exemples", Context.MODE_PRIVATE)))
    // pas de réponse au bout de 10 secondes : la requête échoue
    .connectTimeout(10, TimeUnit.SECONDS)
    .readTimeout(10, TimeUnit.SECONDS)
    .build()
  • ligne 29 : un [CookieJar] a deux fonctions : [saveFromResponse] (ranger les cookies d'une réponse) et [loadForRequest] (ressortir ceux d'une requête) ;
  • lignes 33-38 : au démarrage, les cookies mémorisés lors d'une utilisation précédente, rangés sur l'appareil ([SharedPreferences], un petit fichier clé → valeur, cf. exemple 20) sous la forme de leur entête [Set-Cookie] ;
  • lignes 44-53 : un cookie déjà expiré, ou vide, est retiré : c'est ainsi que le serveur efface le cookie à la déconnexion ; [@Synchronized] : [OkHttp] peut appeler ces fonctions depuis plusieurs fils à la fois ;
  • lignes 67-72 : le client HTTP des exemples, avec son bocal et des délais d'attente.

Le client est créé une fois pour toute l'application, dans l'objet [Application] (déclaré dans le manifeste), qu'[Android] crée au lancement, avant toute activité :

// =========================================================================
// ExemplesApplication.kt - l'objet "application"
// -------------------------------------------------------------------------
// Android crée UN objet Application au lancement de l'application, avant
// toute activité, et le garde jusqu'à son arrêt (il est déclaré dans
// AndroidManifest.xml, android:name=".ExemplesApplication"). C'est
// l'endroit où ranger ce qui doit être partagé par TOUTES les activités :
// ici, le client HTTP et son bocal à cookies (exemples 23 à 25). Une
// activité l'obtient par (application as ExemplesApplication).api.
// =========================================================================
package fr.stahe.exemples

import android.app.Application
import fr.stahe.exemples.commun.Api
import fr.stahe.exemples.commun.creerClientHttp
import okhttp3.OkHttpClient

class ExemplesApplication : Application() {
    // by lazy : créés à la première demande
    val clientHttp: OkHttpClient by lazy { creerClientHttp(this) }
    val api: Api by lazy { Api(clientHttp) }
}

Une action asynchrone (se connecter, enregistrer...) a un état que l'écran doit montrer : est-elle en cours ? s'est-elle terminée par une erreur ? Une petite classe s'en charge :

// =========================================================================
// ex23/Commande.kt - une ACTION, son état, son résultat
// -------------------------------------------------------------------------
// Une action asynchrone (se connecter, enregistrer...) a un état que
// l'écran doit montrer : est-elle EN COURS (bouton désactivé) ? s'est-elle
// terminée par une ERREUR (message) ? C'est le modèle "Command", réduit à
// l'essentiel :
//
//     val connexion = remember { Commande() }
//     portee.launch { connexion.executer { seConnecter() } }   // lance l'action
//     connexion.enCours, connexion.erreur                      // son état
//
// Ses deux propriétés sont des états observés par Compose : l'écran se
// redessine quand l'action démarre et quand elle se termine.
// =========================================================================
package fr.stahe.exemples.ex23

import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import kotlinx.coroutines.CancellationException

class Commande {
    var enCours by mutableStateOf(false)
        private set
    var erreur by mutableStateOf<String?>(null)
        private set

    // exécute l'action, si une autre n'est pas déjà en cours
    suspend fun executer(action: suspend () -> Unit) {
        if (enCours) return // pas de double envoi (double appui sur le bouton)
        enCours = true
        erreur = null
        try {
            action()
        } catch (e: CancellationException) {
            throw e // une coroutine annulée doit le rester
        } catch (e: Exception) {
            erreur = e.message ?: e.toString() // l'erreur est GARDÉE, pour être affichée
        } finally {
            // finally : exécuté dans TOUS les cas, succès ou erreur
            enCours = false
        }
    }

    fun effacerErreur() {
        erreur = null
    }
}
  • ligne 31 : pas de double envoi (double appui sur le bouton) ;
  • lignes 40-42 : [finally] : exécuté dans TOUS les cas, succès ou erreur.

L'écran de connexion :

// =========================================================================
// ex23/Ex23Activity.kt - se connecter au serveur RdvMedecins
// -------------------------------------------------------------------------
//   1. au démarrage : GET /api/auth/moi - sommes-nous déjà connectés ?
//      (l'application a pu être arrêtée ; le serveur, lui, lit le cookie
//      qu'on lui présente) ;
//   2. connexion : POST /api/auth/login, corps JSON { login, password,
//      captchaId, captcha }. En cas de succès, le serveur envoie le cookie
//      du jeton JWT (entête Set-Cookie) ;
//   3. requête protégée : GET /api/medecins. Le cookie l'accompagne ;
//   4. déconnexion : POST /api/auth/logout - le serveur efface le cookie.
// C'est le client HTTP de l'application (commun/ClientHttp.kt), avec son
// bocal à cookies, qui range le cookie et le renvoie.
// =========================================================================
package fr.stahe.exemples.ex23

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
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.PasswordVisualTransformation
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import fr.stahe.exemples.ExemplesApplication
import fr.stahe.exemples.commun.Config
import fr.stahe.exemples.commun.ImageSvg
import fr.stahe.exemples.commun.PageExemple
import fr.stahe.exemples.commun.ThemeExemples
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody

// les quelques messages dont cet exemple a besoin (l'exemple 25 utilisera des dictionnaires)
val messages = mapOf(
    "LOGIN.ERROR" to "Login ou mot de passe incorrect.",
    "ERRORS.WRONG_CAPTCHA" to "Les caractères recopiés ne correspondent pas à l'image.",
    "ERRORS.TOO_MANY_ATTEMPTS" to "Trop de tentatives infructueuses. Réessayez plus tard.",
)

// une réponse qui n'est pas un 2xx devient une exception, qui garde son code et son corps
class ReponseEnErreur(val code: Int, val corps: String) : Exception("le serveur a répondu $code")

// une réponse HTTP réduite à ce qui nous intéresse
data class Reponse(val code: Int, val corps: String)

class Ex23Activity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        // le client HTTP de l'application, avec son bocal à cookies
        val client = (application as ExemplesApplication).clientHttp
        setContent { ThemeExemples { PageConnexion(client) } }
    }
}

// exécuter une requête, sur le fil des entrées-sorties
suspend fun envoyer(client: OkHttpClient, requete: Request): Reponse = withContext(Dispatchers.IO) {
    client.newCall(requete).execute().use { Reponse(it.code, it.body?.string().orEmpty()) }
}

@Composable
fun PageConnexion(client: OkHttpClient) {
    var utilisateur by remember { mutableStateOf<JsonObject?>(null) } // null : personne n'est connecté
    var medecins by remember { mutableStateOf<JsonArray?>(null) }
    var captcha by remember { mutableStateOf<JsonObject?>(null) }
    var pret by remember { mutableStateOf(false) } // la vérification de départ est-elle terminée ?
    var erreurServeur by remember { mutableStateOf<String?>(null) }
    val connexion = remember { Commande() }
    var login by remember { mutableStateOf("admin") }
    var password by remember { mutableStateOf("") }
    var reponseCaptcha by remember { mutableStateOf("") }
    val portee = rememberCoroutineScope()

    fun url(chemin: String) = "${Config.urlServeur}/api$chemin"

    // une requête GET dont on veut le corps JSON ; une exception si ce n'est pas un 2xx
    suspend fun lire(chemin: String): JsonElement {
        val r = envoyer(client, Request.Builder().url(url(chemin)).build())
        if (r.code >= 300) throw ReponseEnErreur(r.code, r.corps)
        return Json.parseToJsonElement(r.corps)
    }

    // un POST avec un corps JSON
    suspend fun poster(chemin: String, corps: JsonObject): Reponse =
        envoyer(client, Request.Builder().url(url(chemin)).post(corps.toString().toRequestBody("application/json".toMediaType())).build())

    suspend fun chargerCaptcha() {
        captcha = lire("/captcha").jsonObject
        reponseCaptcha = ""
    }

    suspend fun chargerMedecins() {
        medecins = lire("/medecins").jsonArray
    }

    // 1. qui est connecté ? 200 -> l'utilisateur ; 401 -> personne
    LaunchedEffect(Unit) {
        try {
            utilisateur = lire("/auth/moi").jsonObject
            chargerMedecins()
        } catch (e: ReponseEnErreur) {
            // "catch (e: Type)" : n'attrape QUE ce type d'exception
            if (e.code == 401) chargerCaptcha() else erreurServeur = "Le serveur a répondu ${e.code}."
        } catch (e: java.io.IOException) {
            erreurServeur = "Le serveur JSON RdvMedecins est-il lancé (${Config.urlServeur}) ?"
        }
        pret = true
    }

    // 2. la connexion : une action exécutée par la Commande, qui mémorise son erreur
    suspend fun seConnecter() {
        val r = poster(
            "/auth/login",
            buildJsonObject {
                put("login", login.trim())
                put("password", password)
                put("captchaId", captcha?.get("id")?.jsonPrimitive?.content)
                put("captcha", reponseCaptcha)
            },
        )
        if (r.code != 200) {
            // le corps JSON de la réponse d'erreur : { statusCode, cle, ... }
            val cle = Json.parseToJsonElement(r.corps).jsonObject["cle"]!!.jsonPrimitive.content
            chargerCaptcha() // chaque image ne sert qu'une fois
            throw Exception("${r.code} - ${messages[cle] ?: cle}")
        }
        password = ""
        utilisateur = Json.parseToJsonElement(r.corps).jsonObject
        chargerMedecins()
    }

    // 4. la déconnexion
    suspend fun seDeconnecter() {
        poster("/auth/logout", JsonObject(emptyMap()))
        utilisateur = null
        medecins = null
        chargerCaptcha()
    }

    // un texte JSON -> sa valeur, sans les guillemets
    fun JsonObject.texte(cle: String) = this[cle]?.jsonPrimitive?.content.orEmpty()

    PageExemple("Exemple 23 - connexion au serveur") {
        val u = utilisateur
        when {
            !pret -> Text("Vérification de la session...", color = Color.Gray)
            // connecté
            u != null -> Card(Modifier.fillMaxWidth()) {
                Column(Modifier.padding(12.dp)) {
                    Text(
                        buildAnnotatedString {
                            append("Connecté : ")
                            withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(u.texte("nom")) }
                            append(" (${u.texte("login")}), rôle ${u.texte("role")}")
                        },
                    )
                    Spacer(Modifier.height(8.dp))
                    Text("GET /api/medecins", style = MaterialTheme.typography.titleSmall)
                    for (m in medecins.orEmpty()) {
                        val o = m.jsonObject
                        Text("• ${o.texte("titre")} ${o.texte("prenom")} ${o.texte("nom")}")
                    }
                    Spacer(Modifier.height(8.dp))
                    Text("Arrêtez puis relancez l'application : vous restez connecté.", color = Color.Gray)
                    Spacer(Modifier.height(8.dp))
                    OutlinedButton(onClick = { portee.launch { seDeconnecter() } }) { Text("Se déconnecter") }
                }
            }
            // pas connecté : le formulaire
            erreurServeur == null -> Card(Modifier.fillMaxWidth()) {
                Column(Modifier.padding(12.dp)) {
                    OutlinedTextField(login, { login = it }, label = { Text("Login") }, singleLine = true, modifier = Modifier.fillMaxWidth())
                    OutlinedTextField(
                        password,
                        { password = it },
                        label = { Text("Mot de passe") },
                        singleLine = true,
                        visualTransformation = PasswordVisualTransformation(),
                        modifier = Modifier.fillMaxWidth(),
                    )
                    Spacer(Modifier.height(12.dp))
                    Row(verticalAlignment = Alignment.CenterVertically) {
                        captcha?.let { ImageSvg(it.texte("svg"), hauteur = 50.dp) }
                        TextButton(onClick = { portee.launch { runCatching { chargerCaptcha() } } }) { Text("Autre image") }
                    }
                    OutlinedTextField(
                        reponseCaptcha,
                        { reponseCaptcha = it },
                        label = { Text("Recopiez les caractères de l'image") },
                        singleLine = true,
                        modifier = Modifier.fillMaxWidth(),
                    )
                    Spacer(Modifier.height(16.dp))
                    // ce morceau suit l'état de la commande
                    Button(onClick = { portee.launch { connexion.executer { seConnecter() } } }, enabled = !connexion.enCours) {
                        Text(if (connexion.enCours) "Connexion..." else "Se connecter")
                    }
                    connexion.erreur?.let { Text(it, color = Color.Red, modifier = Modifier.padding(top = 12.dp)) }
                    Spacer(Modifier.height(8.dp))
                    Text("Comptes : admin, pelissier, martin... (mot de passe = login).", color = Color.Gray, fontSize = 12.sp)
                }
            }
        }
        erreurServeur?.let {
            Card(colors = CardDefaults.cardColors(containerColor = Color(0xFFFFEBEE))) { Text(it, Modifier.padding(12.dp)) }
        }
    }
}
  • ligne 92 : le client HTTP de l'application ([application as ExemplesApplication] : une conversion de type) ;
  • lignes 138-148 : au démarrage, [GET /api/auth/moi] : sommes-nous déjà connectés ? L'application a pu être arrêtée ; le cookie, lui, a été gardé sur l'appareil. [catch (e: ReponseEnErreur)] n'attrape QUE ce type d'exception ; un 401 signifie « personne » ;
  • lignes 152-170 : [POST /api/auth/login], corps JSON fabriqué par [buildJsonObject]. En cas d'échec, le corps de la réponse donne la clé de l'erreur ; un nouveau captcha est demandé (chaque image ne sert qu'une fois) ;
  • lignes 174-178 : la déconnexion, [POST /api/auth/logout] : le serveur efface le cookie ;
  • ligne 182 : une fonction d'extension LOCALE, pour lire un texte d'un objet JSON ;
  • lignes 236-239 : le bouton suit l'état de la commande.

Image

Image

Image

Arrêtez l'application (en la retirant des applications récentes), puis relancez l'exemple : vous êtes toujours connecté.

9.3. Exemple 24 : une couche d'accès à l'API

L'exemple 23 répète, pour chaque requête, les mêmes gestes : adresse du serveur, préfixe [/api], corps JSON, test du code de statut, lecture du corps d'erreur. On les regroupe en trois couches. La première exécute une requête et convertit TOUTE erreur en une seule classe d'exception :

// =========================================================================
// commun/ErreurApi.kt - UNE classe pour toutes les erreurs de l'API
// -------------------------------------------------------------------------
// Toutes les réponses d'erreur du serveur RdvMedecins ont la même forme :
//   { "statusCode": 409, "cle": "ERRORS.LOGIN_TAKEN", "params": {...}, "champs": {...} }
// Mais une requête peut aussi échouer AUTREMENT : pas de réponse du tout
// (réseau coupé, serveur arrêté, mauvaise adresse), ou une réponse qui ne
// vient pas de notre serveur (une page d'erreur d'un proxy). On convertit
// tout cela en UNE classe d'exception, ErreurApi, qui porte la clé de
// traduction envoyée par le serveur (et, s'il y en a, ses paramètres et
// l'erreur de chaque champ).
// =========================================================================
package fr.stahe.exemples.commun

import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonObject

class ErreurApi(
    val statusCode: Int, // 0 : le serveur n'a pas répondu
    val cle: String, // ex : "ERRORS.LOGIN_TAKEN"
    val params: Map<String, String> = emptyMap(),
    val champs: Map<String, String> = emptyMap(),
) : Exception("$statusCode $cle") {

    override fun toString() = "$statusCode $cle"

    // companion object : ce qui appartient à la CLASSE (les "static" de Java)
    companion object {
        // une réponse d'erreur (4xx, 5xx) -> une ErreurApi
        fun depuisReponse(statusCode: Int, corps: String): ErreurApi {
            // runCatching : le résultat, ou null si le corps n'est pas du JSON
            // (ce n'est alors pas notre serveur qui a répondu)
            val json = runCatching { Json.parseToJsonElement(corps).jsonObject }.getOrNull()
            val cle = (json?.get("cle") as? JsonPrimitive)?.content
            if (json == null || cle == null) {
                return ErreurApi(statusCode, if (statusCode >= 500) "ERRORS.SERVER_UNREACHABLE" else "ERRORS.SERVER_ERROR")
            }
            return ErreurApi(statusCode, cle, chaines(json["params"]), chaines(json["champs"]))
        }

        // un objet JSON { "nom": "ERRORS..." } -> Map<String, String> (vide sinon)
        private fun chaines(json: Any?): Map<String, String> =
            (json as? JsonObject)?.mapValues { (_, v) -> (v as? JsonPrimitive)?.content ?: v.toString() } ?: emptyMap()
    }
}
// =========================================================================
// commun/Api.kt - des requêtes vers l'API, en une ligne
// -------------------------------------------------------------------------
// Dans l'exemple 23, chaque requête répète les mêmes gestes : adresse du
// serveur, préfixe /api, corps JSON, test du code de statut, lecture du
// corps d'erreur... On les regroupe ici :
//
//   val json = api.get("/medecins")
//   api.post("/rendez-vous", buildJsonObject { put("jour", jour); put("idCreneau", 3) })
//
//   - en cas de succès, la fonction renvoie le corps JSON (un JsonElement :
//     un objet, un tableau...), ou null pour un corps vide ;
//   - en cas d'échec, elle lance une ErreurApi.
// Ce sont des fonctions SUSPENDUES : elles attendent la réponse du serveur
// sans bloquer le téléphone. La requête elle-même est exécutée sur un fil
// d'exécution réservé aux entrées-sorties (Dispatchers.IO) : Android
// interdit les accès au réseau sur le fil de l'écran.
// Toutes les requêtes passent par la fonction requete : c'est l'endroit où
// l'on traiterait un problème COMMUN à toutes (cf. l'étude de cas et la
// réponse 401). Utilisé par les exemples 24 et 25.
// =========================================================================
package fr.stahe.exemples.commun

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.IOException

class Api(private val client: OkHttpClient) {

    suspend fun requete(methode: String, url: String, corps: JsonElement? = null): JsonElement? =
        withContext(Dispatchers.IO) {
            // un corps JSON (le serveur refuse un POST qui n'en est pas un)
            val corpsHttp = corps?.toString()?.toRequestBody("application/json".toMediaType())
            val requete = Request.Builder().url("${Config.urlServeur}/api$url").method(methode, corpsHttp).build()
            try {
                // use : la réponse est fermée (libérée) à la fin du bloc
                client.newCall(requete).execute().use { reponse ->
                    val texte = reponse.body?.string().orEmpty()
                    if (!reponse.isSuccessful) throw ErreurApi.depuisReponse(reponse.code, texte)
                    if (texte.isEmpty()) null else Json.parseToJsonElement(texte)
                }
            } catch (e: IOException) {
                // AUCUNE réponse (réseau, serveur arrêté, mauvaise adresse)
                throw ErreurApi(0, "ERRORS.SERVER_UNREACHABLE")
            }
        }

    suspend fun get(url: String) = requete("GET", url)

    // un POST a toujours un corps JSON : {} par défaut
    suspend fun post(url: String, corps: JsonElement = JsonObject(emptyMap())) = requete("POST", url, corps)

    suspend fun put(url: String, corps: JsonElement) = requete("PUT", url, corps)

    suspend fun delete(url: String, corps: JsonElement? = null) = requete("DELETE", url, corps)
}
  • lignes 37-47 : en cas de succès, la fonction renvoie le corps JSON ([JsonElement]), ou [null] pour un corps vide ; en cas d'échec, une [ErreurApi] ;
  • lignes 55-62 : des raccourcis par méthode HTTP.

Les objets JSON échangés deviennent des classes [Kotlin] [@Serializable] :

// =========================================================================
// commun/Types.kt - les objets JSON échangés avec l'API RdvMedecins
// (cf. le chapitre sur la boîte noire, qui décrit chaque route)
// -------------------------------------------------------------------------
// Chaque classe est @Serializable : le plugin kotlinx.serialization écrit
// pour elle le code qui la fabrique à partir de JSON. La lecture VÉRIFIE la
// forme du JSON reçu : un champ manquant ou d'un mauvais type lance une
// exception dès la réception, et non plus tard, au milieu de l'affichage.
//     val u = json.decodeFromJsonElement<Utilisateur>(reponse)
// Un champ FACULTATIF a une valeur par défaut (login: String? = null).
// =========================================================================
package fr.stahe.exemples.commun

import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.serializer

enum class Role { ADMIN, DOCTOR, USER }

// POST /api/auth/login, GET /api/auth/moi
@Serializable
data class Utilisateur(val login: String, val nom: String, val role: Role)

// GET /api/captcha
@Serializable
data class Captcha(val id: String, val svg: String)

// un médecin ou un client (GET /api/medecins, GET /api/clients...)
@Serializable
data class Personne(
    val id: Int,
    val version: Int,
    val titre: String,
    val prenom: String,
    val nom: String,
    val login: String? = null, // seulement pour l'ADMIN
) {
    val nomComplet get() = "$titre $prenom $nom"
}

// le nom d'une personne, sans rien d'autre
@Serializable
data class Identite(val titre: String, val prenom: String, val nom: String) {
    override fun toString() = "$titre $prenom $nom"
}

// une ligne de GET /api/agenda
@Serializable
data class CreneauAgenda(
    val id: Int,
    val hdebut: Int,
    val mdebut: Int,
    val hfin: Int,
    val mfin: Int,
    val libre: Boolean,
    val patient: Identite?,
    val moi: Boolean,
    val reservable: Boolean,
    val idRv: Int?,
) {
    // "8:00 - 8:20"
    val horaire: String
        get() {
            fun hm(h: Int, m: Int) = "$h:${m.toString().padStart(2, '0')}"
            return "${hm(hdebut, mdebut)} - ${hm(hfin, mfin)}"
        }
}

// GET /api/agenda?idMedecin=...&jour=...
@Serializable
data class Agenda(val medecin: Personne, val jour: String, val creneaux: List<CreneauAgenda>, val info: String?)

// le lecteur JSON : ignoreUnknownKeys, les champs que le client n'utilise
// pas sont ignorés (le serveur peut en ajouter sans casser le client)
val jsonApi = Json { ignoreUnknownKeys = true }

// JsonElement -> objet Kotlin : une fonction générique "réifiée" (le type T
// est connu à l'exécution) ; serializer<T>() : le code écrit par le plugin
inline fun <reified T> JsonElement?.lire(): T =
    jsonApi.decodeFromJsonElement(serializer<T>(), this ?: throw IllegalStateException("réponse vide"))
  • ligne 37 : un champ facultatif a une valeur par défaut ; [Identite?] : un objet, ou [null] ;
  • ligne 76 : [ignoreUnknownKeys] : les champs que le client n'utilise pas sont ignorés (le serveur peut en ajouter sans casser le client) ;
  • lignes 80-81 : une fonction d'extension GÉNÉRIQUE et « réifiée » ([reified] : le type [T] est connu à l'exécution, ce qu'autorise [inline]). [serializer<T>()] : le code écrit par le plugin pour [T]. La lecture VÉRIFIE la forme du JSON : un champ manquant ou d'un mauvais type lance une exception dès la réception, et non plus tard, au milieu de l'affichage.

La deuxième couche : une fonction typée par route de l'API. Les écrans ne connaissent plus ni les URL, ni [OkHttp], ni le JSON. C'est l'équivalent, côté client, de la couche DAO côté serveur ; dans le guide d'architecture d'[Android], un « repository » :

// =========================================================================
// ex24/RdvMedecins.kt - la couche d'accès à l'API
// -------------------------------------------------------------------------
// Une fonction par route de l'API, typée. Les écrans ne connaissent ni les
// URL, ni OkHttp, ni le format JSON : ils appellent des fonctions qui
// renvoient des objets Kotlin (Personne, Agenda...).
// C'est l'équivalent, côté client, de la couche DAO côté serveur : si l'API
// change (une URL, un nom de champ), on ne modifie que ce fichier. Dans le
// guide d'architecture d'Android, c'est un "repository".
// =========================================================================
package fr.stahe.exemples.ex24

import android.net.Uri
import fr.stahe.exemples.commun.Agenda
import fr.stahe.exemples.commun.Api
import fr.stahe.exemples.commun.Personne
import fr.stahe.exemples.commun.lire
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put

class RdvMedecins(private val api: Api) {
    suspend fun getMedecins(): List<Personne> = api.get("/medecins").lire()

    suspend fun getClients(): List<Personne> = api.get("/clients").lire()

    // Uri.Builder fabrique "idMedecin=1&jour=2030-01-15", en encodant ce qu'il faut
    suspend fun getAgenda(idMedecin: Int, jour: String): Agenda {
        val parametres = Uri.Builder()
            .appendQueryParameter("idMedecin", "$idMedecin")
            .appendQueryParameter("jour", jour)
            .build().encodedQuery
        return api.get("/agenda?$parametres").lire()
    }

    // idClient : facultatif ; la clé n'est placée dans le JSON que s'il n'est pas null
    suspend fun reserver(jour: String, idCreneau: Int, idClient: Int? = null) {
        api.post(
            "/rendez-vous",
            buildJsonObject {
                put("jour", jour)
                put("idCreneau", idCreneau)
                if (idClient != null) put("idClient", idClient)
            },
        )
    }

    suspend fun annuler(idRv: Int) {
        api.delete("/rendez-vous/$idRv")
    }
}
  • ligne 22 : [api.get("/medecins").lire()] : le type attendu, [List<Personne>], est déduit du type de retour de la fonction ;
  • lignes 28-31 : [Uri.Builder] fabrique la chaîne de paramètres, en encodant ce qu'il faut.

La troisième couche : l'état et les actions de l'écran, dans un [ViewModel] (cf. exemple 20). Cette fois, l'état est fait de propriétés [Compose] ([mutableStateOf]), plus simples à lire qu'un [StateFlow] quand seul l'écran les observe :

// =========================================================================
// ex24/AgendaModele.kt - le "modèle de vue" de l'écran
// -------------------------------------------------------------------------
// Le guide d'architecture d'Android recommande de séparer :
//   - la VUE : les fonctions @Composable, qui affichent et transmettent les
//     actions de l'utilisateur ;
//   - le MODÈLE DE VUE (ViewModel) : l'état de l'écran (le médecin choisi,
//     le jour, l'agenda reçu...), les actions (choisir, réserver, annuler),
//     et les appels à la couche d'accès à l'API.
// La vue n'a plus de logique : elle est simple à lire ; le modèle de vue
// n'a pas de composants : il est simple à tester. Et il survit à la
// recréation de l'activité (rotation de l'écran).
// Les actions lancent leurs coroutines dans viewModelScope : elles sont
// annulées si le ViewModel est détruit (activité définitivement fermée).
//
// Il fait aussi ce qu'on n'a pas envie de réécrire à chaque écran :
//   - un CACHE : l'agenda de chaque (médecin, jour) déjà lu est gardé ; y
//     revenir l'affiche aussitôt, pendant qu'on le relit en arrière-plan ;
//   - les réponses PÉRIMÉES sont ignorées : une nouvelle lecture ANNULE la
//     précédente (cf. exemple 19).
// =========================================================================
package fr.stahe.exemples.ex24

import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import fr.stahe.exemples.commun.Agenda
import fr.stahe.exemples.commun.CreneauAgenda
import fr.stahe.exemples.commun.ErreurApi
import fr.stahe.exemples.commun.Personne
import fr.stahe.exemples.commun.Role
import fr.stahe.exemples.commun.Utilisateur
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import java.time.LocalDate

// un message affiché à l'utilisateur
data class Message(val succes: Boolean, val texte: String)

class AgendaModele(private val rdvmedecins: RdvMedecins) : ViewModel() {
    // ---- l'état : des états Compose, modifiables seulement ici ----
    var utilisateur by mutableStateOf<Utilisateur?>(null)
        private set
    var medecins by mutableStateOf(listOf<Personne>())
        private set
    var clients by mutableStateOf(listOf<Personne>())
        private set
    var idMedecin by mutableStateOf<Int?>(null)
        private set
    var jour by mutableStateOf(LocalDate.now().plusDays(1).toString()) // demain, "2026-10-06"
        private set
    var idClient by mutableStateOf<Int?>(null)
        private set
    var agenda by mutableStateOf<Agenda?>(null)
        private set
    var chargement by mutableStateOf(false)
        private set
    var message by mutableStateOf<Message?>(null)
        private set
    private val cache = mutableMapOf<String, Agenda>()
    private var lecture: Job? = null

    // ---- des valeurs calculées ----
    val connecte get() = utilisateur != null
    val gestionnaire get() = utilisateur.let { it != null && it.role != Role.USER }

    // ---- les actions ----
    fun changerUtilisateur(u: Utilisateur?) {
        utilisateur = u
        medecins = emptyList()
        clients = emptyList()
        agenda = null
        idMedecin = null
        cache.clear() // les droits ont changé : on oublie tout
        if (u == null) return
        viewModelScope.launch {
            try {
                medecins = rdvmedecins.getMedecins()
                if (gestionnaire) clients = rdvmedecins.getClients() // un USER recevrait 403
            } catch (e: ErreurApi) {
                message = Message(false, e.toString())
            }
        }
    }

    fun choisirMedecin(id: Int?) {
        idMedecin = id
        message = null
        lireAgenda()
    }

    fun choisirJour(d: LocalDate) {
        jour = d.toString()
        message = null
        lireAgenda()
    }

    fun choisirClient(id: Int?) {
        idClient = id
    }

    fun reserver(c: CreneauAgenda) = agir("Créneau ${c.horaire} réservé.") {
        rdvmedecins.reserver(jour, c.id, if (gestionnaire) idClient else null)
    }

    fun annuler(c: CreneauAgenda) = agir("Rendez-vous de ${c.horaire} annulé.") { rdvmedecins.annuler(c.idRv!!) }

    // une action, puis TOUJOURS une relecture de l'agenda : c'est le SERVEUR qui fait foi
    private fun agir(succes: String, action: suspend () -> Unit) {
        viewModelScope.launch {
            message = try {
                action()
                Message(true, succes)
            } catch (e: ErreurApi) {
                Message(false, e.toString())
            }
            lireAgenda()
        }
    }

    private fun lireAgenda() {
        val id = idMedecin ?: return
        val cle = "$id/$jour"
        // une lecture plus ancienne est encore en cours : on l'annule
        lecture?.cancel()
        // le cache d'abord (ou rien), puis la lecture
        agenda = cache[cle]
        chargement = true
        lecture = viewModelScope.launch {
            try {
                val lu = rdvmedecins.getAgenda(id, jour)
                cache[cle] = lu
                agenda = lu
            } catch (e: ErreurApi) {
                agenda = null
                message = Message(false, e.toString())
            }
            chargement = false
        }
    }
}
  • lignes 44-62 : l'état, modifiable seulement ici ;
  • lignes 70-83 : un changement d'utilisateur : tout est oublié (les droits ont changé), puis rechargé. [viewModelScope.launch] : une coroutine qui vit aussi longtemps que le [ViewModel] ;
  • lignes 111-119 : chaque action se termine TOUJOURS par une relecture de l'agenda : c'est le SERVEUR qui fait foi ;
  • lignes 123-140 : la lecture de l'agenda : une lecture plus ancienne encore en cours est annulée ; le cache donne une réponse immédiate, remplacée par celle du serveur.

La vue, qui ne fait qu'afficher le modèle. La connexion est confiée à un composant réutilisable, la barre [commun/BarreConnexion.kt] (le formulaire de l'exemple 23, réécrit avec [Api]) :

// =========================================================================
// commun/BarreConnexion.kt - connexion / déconnexion, en une barre
// -------------------------------------------------------------------------
// Le formulaire de l'exemple 23, réduit à une barre et réécrit avec Api.kt.
// Le composant appelle onUtilisateurChange avec l'utilisateur connecté (ou
// null) chaque fois que celui-ci change - y compris au démarrage, après
// avoir interrogé GET /api/auth/moi. Utilisé par les exemples 24 et 25.
// =========================================================================
package fr.stahe.exemples.commun

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ExitToApp
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put

@Composable
fun BarreConnexion(api: Api, onUtilisateurChange: (Utilisateur?) -> Unit) {
    var utilisateur by remember { mutableStateOf<Utilisateur?>(null) }
    var captcha by remember { mutableStateOf<Captcha?>(null) }
    var login by rememberSaveable { mutableStateOf("admin") }
    var password by rememberSaveable { mutableStateOf("") }
    var reponseCaptcha by rememberSaveable { mutableStateOf("") }
    var erreur by remember { mutableStateOf<String?>(null) }
    val portee = rememberCoroutineScope()

    fun changer(u: Utilisateur?) {
        utilisateur = u
        onUtilisateurChange(u)
    }

    suspend fun nouveauCaptcha() {
        captcha = api.get("/captcha").lire<Captcha>()
        reponseCaptcha = ""
    }

    // au démarrage : sommes-nous déjà connectés ?
    LaunchedEffect(Unit) {
        try {
            changer(api.get("/auth/moi").lire<Utilisateur>())
        } catch (e: ErreurApi) {
            changer(null) // 401 : personne n'est connecté
            try {
                nouveauCaptcha()
            } catch (e: ErreurApi) {
                erreur = "Le serveur JSON est-il lancé (${Config.urlServeur}) ?"
            }
        }
    }

    suspend fun seConnecter() {
        erreur = null
        try {
            val corps = buildJsonObject {
                put("login", login.trim())
                put("password", password)
                put("captchaId", captcha?.id)
                put("captcha", reponseCaptcha)
            }
            val u = api.post("/auth/login", corps).lire<Utilisateur>()
            password = ""
            changer(u)
        } catch (e: ErreurApi) {
            erreur = e.toString() // "401 LOGIN.ERROR"
            runCatching { nouveauCaptcha() }
        }
    }

    suspend fun seDeconnecter() {
        runCatching { api.post("/auth/logout") }
        changer(null)
        runCatching { nouveauCaptcha() }
    }

    Card(colors = CardDefaults.cardColors(containerColor = Color(0xFFECEFF1))) {
        val u = utilisateur
        if (u != null) {
            Row(Modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically) {
                Icon(Icons.Default.Person, contentDescription = null)
                Spacer(Modifier.width(8.dp))
                Text("${u.nom} (${u.role})", Modifier.weight(1f))
                TextButton(onClick = { portee.launch { seDeconnecter() } }) {
                    Icon(Icons.AutoMirrored.Filled.ExitToApp, contentDescription = null)
                    Text("Se déconnecter")
                }
            }
        } else {
            Column(Modifier.padding(8.dp)) {
                Row {
                    OutlinedTextField(login, { login = it }, label = { Text("login") }, singleLine = true, modifier = Modifier.weight(1f))
                    Spacer(Modifier.width(8.dp))
                    OutlinedTextField(
                        password,
                        { password = it },
                        label = { Text("mot de passe") },
                        singleLine = true,
                        visualTransformation = PasswordVisualTransformation(),
                        modifier = Modifier.weight(1f),
                    )
                }
                Row(verticalAlignment = Alignment.CenterVertically) {
                    // l'image SVG du captcha, fabriquée par NOTRE serveur (cf. exemple 22)
                    captcha?.let { ImageSvg(it.svg, hauteur = 40.dp) }
                    Spacer(Modifier.width(8.dp))
                    OutlinedTextField(reponseCaptcha, { reponseCaptcha = it }, label = { Text("captcha") }, singleLine = true, modifier = Modifier.weight(1f))
                    IconButton(onClick = { portee.launch { seConnecter() } }) {
                        Icon(Icons.Default.Check, contentDescription = "Se connecter")
                    }
                }
                erreur?.let { Text(it, color = Color.Red) }
            }
        }
    }
}
// =========================================================================
// ex24/Ex24Activity.kt - un agenda, avec la couche d'accès à l'API
// -------------------------------------------------------------------------
// Connectez-vous (barre du haut), choisissez un médecin et un jour :
// l'agenda s'affiche. ADMIN et DOCTOR peuvent réserver pour un patient et
// annuler ; un patient (martin...) réserve pour lui-même. Chaque action
// se termine par une relecture de l'agenda.
// Trois couches, du bas vers le haut :
//   Api (commun/Api.kt)               les requêtes HTTP, les erreurs ;
//   RdvMedecins (RdvMedecins.kt)      une fonction typée par route de l'API ;
//   AgendaModele (AgendaModele.kt)    l'état et les actions de l'écran ;
// et la vue, ci-dessous, qui ne fait qu'afficher le modèle.
// =========================================================================
@file:OptIn(ExperimentalMaterial3Api::class)

package fr.stahe.exemples.ex24

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AddCircle
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.DateRange
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SuggestionChip
import androidx.compose.material3.SuggestionChipDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberDatePickerState
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import fr.stahe.exemples.ExemplesApplication
import fr.stahe.exemples.commun.Api
import fr.stahe.exemples.commun.BarreConnexion
import fr.stahe.exemples.commun.CreneauAgenda
import fr.stahe.exemples.commun.ListeDeroulante
import fr.stahe.exemples.commun.PageExemple
import fr.stahe.exemples.commun.ThemeExemples
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneOffset

class Ex24Activity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        val api = (application as ExemplesApplication).api
        setContent { ThemeExemples { PageAgenda(api) } }
    }
}

@Composable
fun PageAgenda(api: Api) {
    // viewModel { ... } : le modèle de l'activité, créé par ce bloc à la
    // première demande (il a besoin d'un paramètre : la couche d'accès à l'API)
    val modele = viewModel { AgendaModele(RdvMedecins(api)) }
    var calendrierOuvert by remember { mutableStateOf(false) }

    PageExemple("Exemple 24 - une couche d'accès à l'API") {
        BarreConnexion(api, onUtilisateurChange = { modele.changerUtilisateur(it) })
        if (modele.connecte) {
            ListeDeroulante(
                libelle = "Médecin",
                valeur = modele.idMedecin,
                choix = listOf<Int?>(null) + modele.medecins.map { it.id },
                texte = { id -> modele.medecins.find { it.id == id }?.nomComplet ?: "-- choisir un médecin --" },
                onChange = { modele.choisirMedecin(it) },
                modifier = Modifier.fillMaxWidth(),
            )
            Row(verticalAlignment = Alignment.CenterVertically) {
                Text("Jour : ${modele.jour}")
                IconButton(onClick = { calendrierOuvert = true }) { Icon(Icons.Default.DateRange, contentDescription = "choisir le jour") }
            }
            if (modele.gestionnaire) {
                ListeDeroulante(
                    libelle = "Patient",
                    valeur = modele.idClient,
                    choix = listOf<Int?>(null) + modele.clients.map { it.id },
                    texte = { id -> modele.clients.find { it.id == id }?.nomComplet ?: "-- patient à inscrire --" },
                    onChange = { modele.choisirClient(it) },
                    modifier = Modifier.fillMaxWidth(),
                )
            }
            modele.message?.let { m ->
                Card(colors = CardDefaults.cardColors(containerColor = if (m.succes) Color(0xFFE8F5E9) else Color(0xFFFFEBEE))) {
                    Text(m.texte, Modifier.padding(10.dp))
                }
            }
            if (modele.chargement) LinearProgressIndicator(Modifier.fillMaxWidth())
            modele.agenda?.let { agenda ->
                // info : la clé envoyée par le serveur quand un patient a déjà un rendez-vous à venir
                agenda.info?.let { Text(it, color = Color(0xFF1976D2)) }
                Text(
                    "Agenda du ${agenda.jour} - ${agenda.medecin.nomComplet}",
                    style = MaterialTheme.typography.titleSmall,
                    modifier = Modifier.padding(vertical = 8.dp),
                )
                for (c in agenda.creneaux) {
                    LigneCreneau(c, onReserver = { modele.reserver(c) }, onAnnuler = { modele.annuler(c) })
                }
            }
        }
    }

    if (calendrierOuvert) {
        val etat = rememberDatePickerState(
            initialSelectedDateMillis = LocalDate.parse(modele.jour).atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli(),
        )
        DatePickerDialog(
            onDismissRequest = { calendrierOuvert = false },
            confirmButton = {
                TextButton(onClick = {
                    etat.selectedDateMillis?.let { modele.choisirJour(Instant.ofEpochMilli(it).atZone(ZoneOffset.UTC).toLocalDate()) }
                    calendrierOuvert = false
                }) { Text("OK") }
            },
        ) { DatePicker(state = etat) }
    }
}

// une ligne de l'agenda : l'horaire, l'occupant, les boutons permis
@Composable
fun LigneCreneau(c: CreneauAgenda, onReserver: () -> Unit, onAnnuler: () -> Unit) {
    ListItem(
        leadingContent = { Text(c.horaire, Modifier.width(96.dp)) },
        headlineContent = {
            if (c.libre) {
                SuggestionChip(
                    onClick = {},
                    label = { Text("Libre") },
                    colors = SuggestionChipDefaults.suggestionChipColors(containerColor = Color(0xFFC8E6C9)),
                )
            } else {
                SuggestionChip(
                    onClick = {},
                    label = { Text(c.patient?.toString() ?: "Réservé") },
                    colors = SuggestionChipDefaults.suggestionChipColors(
                        containerColor = if (c.moi) Color(0xFFC5CAE9) else Color(0xFFE0E0E0),
                    ),
                )
            }
        },
        // les boutons suivent les droits calculés par le serveur
        trailingContent = {
            when {
                c.reservable -> IconButton(onClick = onReserver) {
                    Icon(Icons.Default.AddCircle, contentDescription = "Réserver", tint = Color(0xFF2E7D32))
                }
                c.idRv != null -> IconButton(onClick = onAnnuler) {
                    Icon(Icons.Default.Close, contentDescription = "Annuler", tint = Color.Red)
                }
            }
        },
    )
}
  • ligne 79 : [viewModel [ ... ]] : le modèle, créé par ce bloc à la première demande (il a besoin d'un paramètre) ;
  • lignes 86-90 : la liste des médecins : ses choix sont des [Int?], [null] pour « -- choisir un médecin -- » ;
  • lignes 169-172 : les boutons de chaque ligne suivent les droits CALCULÉS PAR LE SERVEUR ([reservable], [idRv]).

Image

Image

Image

À gauche et au milieu, l'ADMIN réserve le premier créneau pour Jean MARTIN ; à droite, connecté comme [martin] : le serveur a mis [reservable] à [false] pour tous les créneaux, car ce patient a déjà un rendez-vous à venir, et l'a dit par la clé [AGENDA.ALREADY_BOOKED_INFO]. Cette clé brute n'est pas très parlante : l'exemple 25 traduit les clés du serveur.

9.4. Exemple 25 : les erreurs renvoyées par le serveur

Le serveur valide TOUT ce qu'il reçoit (cf. exemple 07), et répond, en cas de refus, par une erreur JSON qui donne la clé du message général et celle de l'erreur de CHAQUE champ :

1
2
3
400 { cle: 'ERRORS.INVALID_FORM', champs: { nom: 'ERRORS.LASTNAME_REQUIRED' } }
409 { cle: 'ERRORS.PATIENT_ALREADY_EXISTS', champs: { prenom: ..., nom: ... } }
409 { cle: 'ERRORS.CONCURRENT_UPDATE' }

Ces clés sont nombreuses. Les clients [Vue.js], [Angular], [React] et [Flutter] du serveur les traduisent avec des dictionnaires JSON, un par langue. Plutôt que de recopier ces centaines de textes dans des [strings.xml] (et d'écrire la table clé → ressource de l'exemple 21), on réutilise ces dictionnaires tels quels, rangés dans le dossier [assets/] de l'application : des fichiers livrés avec elle, qu'on lit par leur chemin ([assets/i18n/ex25_fr.json], [ex25_en.json]) :

// =========================================================================
// ex25/Dictionnaire.kt - les dictionnaires partagés avec les autres clients
// -------------------------------------------------------------------------
// Le serveur RdvMedecins ne renvoie que des CLÉS ("ERRORS.LOGIN_TAKEN"),
// en grand nombre : les messages d'erreur de chaque champ, de chaque
// route... Les clients web (Vue.js, Angular, React) et Flutter de ce
// serveur les traduisent avec des DICTIONNAIRES JSON, un par langue, où
// chaque texte a sa clé :
//     { "ERRORS": { "LOGIN_TAKEN": "Ce login est déjà utilisé..." } }
// Plutôt que de recopier ces centaines de textes dans des strings.xml (et
// d'écrire la table clé -> ressource de l'exemple 21), on réutilise ces
// dictionnaires tels quels. Ils sont rangés dans le dossier assets/ de
// l'application : des fichiers livrés avec elle, qu'on lit par leur chemin.
//   - t("FORM.AJOUTE", mapOf("nom" to "Marie DUPONT")) : le texte de la
//     langue courante, dont les paramètres {{nom}} sont remplacés ;
//   - une clé inconnue s'affiche telle quelle : on voit ce qui manque.
// La langue reste celle de l'APPLICATION (exemple 21) : le dictionnaire
// est choisi d'après elle.
// =========================================================================
package fr.stahe.exemples.ex25

import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive

class Dictionnaire(context: Context, fichier: String) {
    // le contenu du fichier JSON, lu une fois pour toutes
    private val racine: JsonObject = context.assets.open(fichier).bufferedReader().use { lecteur ->
        Json.parseToJsonElement(lecteur.readText()) as JsonObject
    }

    // "FORM.AJOUTE" -> racine["FORM"]["AJOUTE"] ; null si absente
    private fun chercher(cle: String): String? {
        var noeud: JsonElement? = racine
        for (partie in cle.split('.')) {
            noeud = (noeud as? JsonObject)?.get(partie)
        }
        return (noeud as? JsonPrimitive)?.takeIf { it.isString }?.content
    }

    fun t(cle: String, params: Map<String, String> = emptyMap()): String {
        val texte = chercher(cle) ?: return cle
        // {{nom}} -> la valeur du paramètre nom (ou {{nom}} s'il n'est pas fourni)
        return Regex("\\{\\{(\\w+)\\}\\}").replace(texte) { m -> params[m.groupValues[1]] ?: m.value }
    }
}

// le dictionnaire de la langue courante : assets/i18n/<prefixe>_<langue>.json
@Composable
fun rememberDictionnaire(prefixe: String): Dictionnaire {
    val context = LocalContext.current
    val langue = LocalConfiguration.current.locales[0].language.let { if (it == "en") "en" else "fr" }
    // relu seulement si la langue change
    return remember(langue) { Dictionnaire(context, "i18n/${prefixe}_$langue.json") }
}
  • lignes 34-35 : le fichier JSON, lu une fois pour toutes ([context.assets.open]) ;
  • lignes 39-48 : ["FORM.AJOUTE"] → [racine["FORM"]["AJOUTE"]] ; une clé inconnue s'affiche telle quelle : on voit ce qui manque ;
  • ligne 50 : les paramètres [{[nom]}] sont remplacés par une expression régulière ;
  • lignes 56-60 : le dictionnaire de la langue de l'APPLICATION (exemple 21), relu seulement si elle change.

Le modèle de l'écran : ce formulaire ne fait VOLONTAIREMENT aucune validation sur le téléphone : tout est envoyé au serveur.

// =========================================================================
// ex25/ClientsModele.kt - l'état de l'écran des clients
// -------------------------------------------------------------------------
// Ce formulaire ne fait VOLONTAIREMENT aucune validation sur le téléphone :
// tout est envoyé au serveur, qui répond, en cas de refus, par une erreur
// JSON (convertie en ErreurApi par commun/Api.kt) :
//
//   400 { cle: 'ERRORS.INVALID_FORM', champs: { nom: 'ERRORS.LASTNAME_REQUIRED' } }
//   409 { cle: 'ERRORS.PATIENT_ALREADY_EXISTS', champs: { prenom: ..., nom: ... } }
//   409 { cle: 'ERRORS.CONCURRENT_UPDATE' }         (verrou optimiste)
//
// L'erreur de CHAQUE champ (ErreurApi.champs) est rangée dans l'état et
// affichée sous le champ ; son message est la CLÉ envoyée par le serveur,
// que l'écran traduit. Modifier un champ efface son erreur.
// L'état est dans un ViewModel : changer de langue RECRÉE l'activité
// (cf. exemple 21), et la saisie en cours ne doit pas être perdue.
// =========================================================================
package fr.stahe.exemples.ex25

import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import fr.stahe.exemples.commun.Api
import fr.stahe.exemples.commun.ErreurApi
import fr.stahe.exemples.commun.Personne
import fr.stahe.exemples.commun.Role
import fr.stahe.exemples.commun.Utilisateur
import fr.stahe.exemples.commun.lire
import fr.stahe.exemples.ex23.Commande
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put

// un message à traduire : une clé et ses paramètres
data class MessageCle(val cle: String, val params: Map<String, String> = emptyMap())

class ClientsModele(private val api: Api) : ViewModel() {
    var utilisateur by mutableStateOf<Utilisateur?>(null)
        private set
    var clients by mutableStateOf(listOf<Personne>())
        private set

    // le formulaire : idEdite == null -> création, sinon modification
    var idEdite by mutableStateOf<Int?>(null)
        private set
    private var version = 0

    // les valeurs saisies, par nom de champ (les noms attendus par le serveur)
    var saisie by mutableStateOf(vide)
        private set

    // l'erreur de chaque champ (une CLÉ de traduction), renvoyée par le serveur
    var erreurs by mutableStateOf(mapOf<String, String>())
        private set

    // le message général de la dernière erreur, et celui d'un succès
    var erreurPage by mutableStateOf<ErreurApi?>(null)
        private set
    var succes by mutableStateOf<MessageCle?>(null)
        private set

    // l'envoi au serveur : une Commande (exemple 23), qui dit s'il est en cours
    val envoi = Commande()

    val estAdmin get() = utilisateur?.role == Role.ADMIN

    suspend fun surConnexion(u: Utilisateur?) {
        utilisateur = u
        clients = emptyList()
        if (u?.role == Role.ADMIN) recharger()
    }

    private suspend fun recharger() {
        clients = try {
            api.get("/clients").lire()
        } catch (e: ErreurApi) {
            emptyList()
        }
    }

    private fun effacerMessages() {
        erreurs = emptyMap()
        erreurPage = null
        succes = null
    }

    // un champ modifié : sa valeur change, son erreur disparaît
    fun modifier(champ: String, valeur: String) {
        saisie = saisie + (champ to valeur)
        erreurs = erreurs - champ
    }

    fun nouveau() {
        idEdite = null
        version = 0
        saisie = vide
        effacerMessages()
    }

    fun editer(c: Personne) {
        idEdite = c.id
        // la VERSION lue avec la fiche repart avec la modification : si
        // quelqu'un a modifié la fiche entre-temps, le serveur répondra 409
        // CONCURRENT_UPDATE
        version = c.version
        saisie = mapOf("titre" to c.titre, "prenom" to c.prenom, "nom" to c.nom, "login" to (c.login ?: ""), "password" to "")
        effacerMessages()
    }

    suspend fun enregistrer() {
        effacerMessages()
        val corps = buildJsonObject {
            for ((champ, valeur) in saisie) put(champ, valeur)
            put("version", version)
        }
        try {
            val id = idEdite
            val client: Personne = (if (id == null) api.post("/clients", corps) else api.put("/clients/$id", corps)).lire()
            val cle = if (id == null) "FORM.AJOUTE" else "FORM.MODIFIE"
            nouveau() // formulaire vidé, messages effacés...
            // ... sauf celui-ci
            succes = MessageCle(cle, mapOf("nom" to "${client.prenom} ${client.nom}", "login" to (client.login ?: "")))
            recharger()
        } catch (e: ErreurApi) {
            erreurPage = e // le message général
            erreurs = e.champs // l'erreur de chaque champ
        }
    }

    suspend fun supprimer(c: Personne) {
        effacerMessages()
        try {
            api.delete("/clients/${c.id}")
            succes = MessageCle("FORM.SUPPRIME", mapOf("nom" to "${c.prenom} ${c.nom}"))
        } catch (e: ErreurApi) {
            erreurPage = e
        }
        recharger()
    }

    companion object {
        val vide = mapOf("titre" to "Mr", "prenom" to "", "nom" to "", "login" to "", "password" to "")
    }
}
  • lignes 49-53 : la saisie, par nom de champ (les noms attendus par le serveur), et l'erreur de chaque champ (une CLÉ de traduction) ;
  • lignes 88-90 : modifier un champ efface son erreur ;
  • ligne 105 : la VERSION lue avec la fiche repart avec la modification : si quelqu'un a modifié la fiche entre-temps, le serveur répond 409 [CONCURRENT_UPDATE] (le verrou optimiste) ;
  • lignes 110-126 : l'enregistrement : en cas d'erreur, le message général et l'erreur de chaque champ ([e.champs]) sont rangés dans l'état.

L'écran :

// =========================================================================
// ex25/Ex25Activity.kt - afficher les erreurs du SERVEUR
// -------------------------------------------------------------------------
// La gestion des clients (compte admin), cf. ClientsModele.kt. L'écran
// affiche, sous chaque champ, l'erreur que le serveur y a attachée,
// traduite par les dictionnaires (Dictionnaire.kt), et le message général
// en bas du formulaire.
// Essayez : un nom vide, un titre "Dr", un homonyme (Alice DURAND), un
// login déjà pris (martin), la même fiche modifiée sur deux appareils...
// =========================================================================
package fr.stahe.exemples.ex25

import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
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.remember
import androidx.compose.runtime.rememberCoroutineScope
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.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.os.LocaleListCompat
import androidx.lifecycle.viewmodel.compose.viewModel
import fr.stahe.exemples.ExemplesApplication
import fr.stahe.exemples.commun.Api
import fr.stahe.exemples.commun.BarreConnexion
import fr.stahe.exemples.commun.ListeDeroulante
import fr.stahe.exemples.commun.PageExemple
import fr.stahe.exemples.commun.ThemeExemples
import fr.stahe.exemples.ex16.FenetreConfirmation
import fr.stahe.exemples.ex16.rememberConfirmation
import kotlinx.coroutines.launch

class Ex25Activity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        val api = (application as ExemplesApplication).api
        setContent { ThemeExemples { PageClients(api) } }
    }
}

@Composable
fun PageClients(api: Api) {
    val modele = viewModel { ClientsModele(api) }
    val dico = rememberDictionnaire("ex25")
    // un raccourci : t("CLE") appelle dico.t("CLE")
    fun t(cle: String, params: Map<String, String> = emptyMap()) = dico.t(cle, params)
    val confirmation = rememberConfirmation()
    val portee = rememberCoroutineScope()
    var menuLangue by remember { mutableStateOf(false) }

    // un champ de saisie, avec son erreur (une clé du serveur) traduite
    @Composable
    fun Champ(champ: String, secret: Boolean = false) {
        val erreur = modele.erreurs[champ]
        OutlinedTextField(
            value = modele.saisie[champ].orEmpty(),
            onValueChange = { modele.modifier(champ, it) },
            label = { Text(t("FORM.${champ.uppercase()}")) },
            singleLine = true,
            isError = erreur != null,
            supportingText = erreur?.let { e -> { Text(t(e)) } },
            visualTransformation = if (secret) PasswordVisualTransformation() else VisualTransformation.None,
            modifier = Modifier.fillMaxWidth(),
        )
    }

    PageExemple(
        titre = t("FORM.TITRE_PAGE"),
        // la langue : un menu
        actions = {
            IconButton(onClick = { menuLangue = true }) { Icon(Icons.Default.MoreVert, contentDescription = "langue") }
            DropdownMenu(expanded = menuLangue, onDismissRequest = { menuLangue = false }) {
                for (l in listOf("fr", "en")) {
                    DropdownMenuItem(text = { Text(l.uppercase()) }, onClick = {
                        menuLangue = false
                        AppCompatDelegate.setApplicationLocales(LocaleListCompat.forLanguageTags(l))
                    })
                }
            }
        },
    ) {
        BarreConnexion(api, onUtilisateurChange = { u -> portee.launch { modele.surConnexion(u) } })
        if (modele.utilisateur != null && !modele.estAdmin) {
            Card(colors = CardDefaults.cardColors(containerColor = Color(0xFFFFF8E1))) {
                Text(t("FORM.ADMIN_SEULEMENT"), Modifier.padding(12.dp))
            }
        }
        if (modele.estAdmin) {
            for (c in modele.clients) {
                ListItem(
                    headlineContent = { Text(c.nomComplet) },
                    supportingContent = { Text(c.login ?: "") },
                    trailingContent = {
                        Row {
                            IconButton(onClick = { modele.editer(c) }) { Icon(Icons.Default.Edit, contentDescription = t("FORM.MODIFIER")) }
                            IconButton(onClick = {
                                portee.launch {
                                    val nom = "${c.prenom} ${c.nom}"
                                    if (confirmation.confirmer(t("FORM.CONFIRMER_SUPPRESSION", mapOf("nom" to nom)))) modele.supprimer(c)
                                }
                            }) {
                                Icon(Icons.Outlined.Delete, contentDescription = t("FORM.SUPPRIMER"), tint = Color.Red)
                            }
                        }
                    },
                )
            }
            Card(Modifier.fillMaxWidth()) {
                Column(Modifier.padding(12.dp)) {
                    Text(t(if (modele.idEdite == null) "FORM.TITRE_AJOUT" else "FORM.TITRE_MODIF"), style = MaterialTheme.typography.titleMedium)
                    // un titre "Dr" que le serveur refusera : pour voir ERRORS.INVALID_TITLE
                    ListeDeroulante(
                        libelle = t("FORM.TITRE"),
                        valeur = modele.saisie["titre"].orEmpty(),
                        choix = listOf("Mr", "Mme", "Melle", "Dr"),
                        onChange = { modele.modifier("titre", it) },
                        erreur = modele.erreurs["titre"]?.let { t(it) },
                        modifier = Modifier.width(200.dp),
                    )
                    Champ("prenom")
                    Champ("nom")
                    Champ("login")
                    Champ("password", secret = true)
                    Row(Modifier.padding(top = 12.dp)) {
                        Button(onClick = { portee.launch { modele.envoi.executer { modele.enregistrer() } } }, enabled = !modele.envoi.enCours) {
                            Text(t("FORM.ENREGISTRER"))
                        }
                        Spacer(Modifier.width(8.dp))
                        OutlinedButton(onClick = { modele.nouveau() }) { Text(t("FORM.ANNULER")) }
                    }
                    modele.succes?.let { s ->
                        Text(t(s.cle, s.params), color = Color(0xFF2E7D32), modifier = Modifier.padding(top = 12.dp))
                    }
                    // le message général, et le statut HTTP, pour comprendre
                    modele.erreurPage?.let { e ->
                        Text(
                            buildAnnotatedString {
                                withStyle(SpanStyle(color = Color.Red)) { append(t(e.cle, e.params)) }
                                withStyle(SpanStyle(color = Color.Gray, fontSize = 12.sp)) { append("  ($e)") }
                            },
                            modifier = Modifier.padding(top = 12.dp),
                        )
                    }
                }
            }
        }
    }
    FenetreConfirmation(confirmation)
}
  • lignes 79-81 : le dictionnaire, et un raccourci [t(...)] ;
  • lignes 88-96 : un champ et son erreur, une clé du serveur, traduite ;
  • lignes 106-111 : la langue, dans un menu de la barre du haut ([DropdownMenu]) ;
  • lignes 170-174 : le message général, et le statut HTTP, pour comprendre.

Image

Image

Image

À gauche, un enregistrement avec un prénom et un nom vides : erreur 400 ; au milieu, un homonyme d'Alice DURAND : erreur 409 ; à droite, la même erreur en anglais : la saisie en cours a survécu à la recréation de l'activité, grâce au [ViewModel].