Skip to content

11. Étude de cas : le client Kotlin de l'application RdvMedecins

11.1. Le problème

L'application [RdvMedecins] permet de prendre des rendez-vous dans un cabinet médical. Le cours [NestJS] l'a écrite sous la forme d'une application HTML, dont le serveur fabriquait les pages ; les cours [Vue.js], [Angular] et [React] en ont écrit des clients web, et le cours [Flutter] un client mobile, qui fabriquent les MÊMES écrans à partir des données JSON du serveur présenté au chapitre « Le serveur RdvMedecins, une boîte noire ». Ce chapitre en écrit un client [Android] natif, en [Kotlin] et [Jetpack Compose].

Le client doit offrir tout ce qu'offraient les autres :

  • un écran de connexion, protégé par un captcha, avec la possibilité de créer un compte de patient ;
  • l'agenda d'un médecin pour un jour donné, avec réservation (fenêtre de choix du patient) et annulation ;
  • pour l'administrateur, la gestion des médecins et des clients (liste, ajout, modification, suppression) ;
  • pour le patient, son espace (son rendez-vous à venir, qu'il peut annuler) et la gestion de son compte ;
  • deux langues, le français et l'anglais ;
  • les mêmes écrans que les clients web ([/login], [/agenda], [/medecins], [/mon-espace]...), qui deviennent ici des ROUTES de la navigation.

Une application mobile ajoute ses propres exigences : un écran étroit (pas de tableau à cinq colonnes), un clavier qui cache la moitié de l'écran, un bouton Retour, la rotation de l'écran et le changement de langue qui RECRÉENT l'activité, un gestionnaire de mots de passe, et l'absence de navigateur pour garder le cookie du jeton.

11.2. Installation et exécution

Le serveur JSON doit être installé et lancé (cf. chapitre « Le serveur RdvMedecins, une boîte noire »). On n'a PAS besoin du mode test du captcha : on peut laisser [CAPTCHA_TEST] vide dans le fichier [.env] du serveur.

Dans [Android Studio], [File / Open] ouvre le dossier [rdvmedecins_kotlin] ; [Gradle] télécharge les bibliothèques (la première fois, quelques minutes). L'émulateur lancé, une fenêtre de commandes relie son port 8080 à celui de l'ordinateur :

adb reverse tcp:8080 tcp:8080

puis le bouton [Run] installe et lance l'application. On peut aussi tout faire dans une fenêtre de commandes ouverte sur le dossier [rdvmedecins_kotlin] :

1
2
3
gradlew installDebug
gradlew installDebug -Pserveur=http://10.0.2.2:8080
gradlew installDebug -Pserveur=http://192.168.1.20:8080
  • la première ligne compile et installe l'application sur l'appareil connecté (émulateur ou téléphone), qui joint le serveur à l'adresse [http://localhost:8080] grâce à [adb reverse] ;
  • la deuxième, pour l'émulateur, sans [adb reverse] : l'émulateur voit l'ordinateur à l'adresse [10.0.2.2] ;
  • la troisième, pour un vrai téléphone relié en Wi-Fi au même réseau que l'ordinateur : on donne l'adresse de l'ordinateur sur ce réseau ([ipconfig] sous [Windows]) ; le pare-feu de [Windows] doit laisser passer les connexions entrantes sur le port 8080. Un téléphone branché en USB (avec le « débogage USB » activé dans ses options pour les développeurs) peut aussi utiliser [adb reverse].

Les comptes de démonstration sont rappelés sur l'écran de connexion (mot de passe = login).

11.3. Utilisation de l'application

Les copies d'écran de cette section sont celles de l'émulateur d'un Pixel 8 (1080 × 2400 pixels, 412 × 915 dp). Elles ont été fabriquées automatiquement, par un test instrumenté ([app/src/androidTest/.../CapturesCas.kt], cf. annexes) qui parcourt l'application comme un utilisateur.

11.3.1. L'écran de connexion

L'écran de connexion ; un mauvais mot de passe : le message s'affiche en bas de l'écran, et une nouvelle image est proposée (chacune ne sert qu'une fois) :

Image

Image

11.3.2. L'agenda, vu par l'administrateur et les médecins

Connecté comme [admin], on arrive sur l'agenda. On choisit un médecin et un jour (le jour, dans un calendrier), puis « Voir l'agenda » :

Image

Image

Image

« Réserver » ouvre la fenêtre de réservation, une route de la navigation affichée par-dessus l'agenda ; le rendez-vous est enregistré ; « Annuler » demande confirmation :

Image

Image

Image

Un médecin ([pelissier]) arrive directement sur SON agenda du jour :

Image

11.3.3. L'espace du patient

Un patient ([martin]) arrive sur « Mon espace ». Dans un agenda, il réserve pour lui-même, sans choisir de patient :

Image

Image

Image

Son rendez-vous apparaît sous son nom ; il n'a plus de bouton « Réserver » (un seul rendez-vous à venir), et un message lui dit pourquoi. Son espace montre le rendez-vous. « Gérer mon compte » : toute modification exige le mot de passe actuel ; les erreurs sont celles du serveur :

Image

Image

Image

Le menu d'un patient n'a pas d'entrée « Médecins ». S'il ouvrait malgré tout cet écran (par un lien), la navigation lui afficherait une page d'erreur 403 (cf. [navigation/Navigation.kt]).

11.3.4. La gestion des médecins et des clients

La liste des médecins ; un ajout mal rempli ; un ajout réussi :

Image

Image

Image

Un homonyme ; une suppression réussie :

Image

Image

11.3.5. La création d'un compte

Sur l'écran de connexion, on recopie le captcha puis on touche « Créer un compte client ». Le formulaire, refusé par le serveur ; une fois le compte créé, retour à l'écran de connexion, login pré-rempli :

Image

Image

11.3.6. En anglais, sur un grand écran

Le menu FR / EN de la barre du haut change la langue de l'application : l'activité est recréée, et TOUT est retraduit, y compris le calendrier et les dates. Sur un écran large (une tablette, le téléphone à l'horizontale), le menu reste affiché à gauche :

Image

Image

11.3.7. Session expirée

Si le jeton expire (ou si le cookie disparaît), la requête suivante reçoit 401 ; l'application renvoie à l'écran de connexion. Après la connexion, elle ramènera l'utilisateur à l'écran qu'il voulait voir :

Image

11.4. L'architecture du client

rdvmedecins_kotlin/
├── settings.gradle.kts, build.gradle.kts    le projet Gradle
├── gradle/libs.versions.toml               les versions des bibliothèques
└── app/
    ├── build.gradle.kts                    le module de l'application
    └── src/
        ├── androidTest/                    le test qui fabrique les copies d'écran
        └── main/
            ├── AndroidManifest.xml
            ├── assets/i18n/                les dictionnaires fr.json, en.json
            ├── res/                        icône, nom, thème, langues
            └── java/fr/stahe/rdvmedecins/
                ├── RdvMedecinsApplication.kt  les objets partagés
                ├── MainActivity.kt         l'unique activité
                ├── Config.kt               l'adresse du serveur
                ├── Formatage.kt            les heures et les jours
                ├── api/                    la couche d'accès à l'API
                ├── etat/                   session, messages, cache
                ├── i18n/                   les langues
                ├── navigation/             les écrans, leurs routes, leurs droits
                ├── widgets/                le gabarit et les composants réutilisables
                └── pages/                  les écrans

C'est l'architecture recommandée par le guide d'[Android] (« Guide to app architecture »), en plus simple :

  • une couche données ([api/]) : la seule à connaître [OkHttp], les URL et le JSON ;
  • une couche état ([etat/]) : des objets dont les propriétés sont des états [Compose], créés une fois par l'objet [Application] et fournis à tous les écrans ([LocalDependances]) ;
  • une couche interface ([pages/], [widgets/]) : des fonctions [@Composable], qui affichent l'état et transmettent les actions de l'utilisateur.

Le tableau suivant met en regard l'application HTML du cours [NestJS], le client [Flutter] et ce client :

Application HTML (NestJS)
Client Flutter
Client Kotlin
le serveur fabrique chaque page ([res.render])
des widgets fabriquent les écrans
des fonctions [@Composable] fabriquent les écrans
[views/layout.hbs] et ses partiels
[gabarit.dart] (une [ShellRoute])
[Gabarit.kt], autour du [NavHost]
les routes des contrôleurs
[go_router]
[navigation-compose], routes [@Serializable]
[JwtAuthGuard], [RolesGuard]
la fonction [redirect] du routeur
[Protege] et un effet qui observe la session
le cookie du jeton, rangé par le navigateur
un client HTTP qui gère les cookies
le [CookieJar] d'[OkHttp]
message flash dans un cookie
un [ChangeNotifier], [Messages]
une classe à états [Compose], [Messages]
dictionnaires lus par le serveur
[Traductions] (JSON)
[Traductions] (les MÊMES fichiers JSON)
langue mémorisée dans un cookie
dans [shared_preferences]
par [Android] ([setApplicationLocales])
les données déjà lues
[Cache] (quelques lignes)
[Cache] (quelques lignes, des [Deferred])
formulaire refusé : le serveur réaffiche la page
réponse 400 / 409, erreurs sous les champs
idem

Tous les fichiers du client sont présentés ci-dessous. Ils sont abondamment commentés : leurs commentaires font partie du cours.

11.5. La configuration du projet

11.5.1. settings.gradle.kts, build.gradle.kts

// =========================================================================
// settings.gradle.kts - la configuration GLOBALE du projet Gradle
// (généré par Android Studio ; seul rootProject.name a été modifié)
// =========================================================================

// où Gradle cherche ses PLUGINS (le plugin Android, le plugin Compose...)
pluginManagement {
    repositories {
        google {
            content {
                includeGroupByRegex("com\\.android.*")
                includeGroupByRegex("com\\.google.*")
                includeGroupByRegex("androidx.*")
            }
        }
        mavenCentral()
        gradlePluginPortal()
    }
}
plugins {
    id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
}
// où Gradle cherche les BIBLIOTHÈQUES : google() pour AndroidX et Compose,
// mavenCentral() pour OkHttp, kotlinx.serialization, AndroidSVG
dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
    repositories {
        google()
        mavenCentral()
    }
}

rootProject.name = "rdvmedecins_kotlin"
// un seul module : l'application, dans le dossier app/
include(":app")
// =========================================================================
// build.gradle.kts (racine) - déclare les plugins Gradle utilisés par les
// modules du projet, sans les appliquer ici ("apply false") : c'est
// app/build.gradle.kts qui les applique.
// =========================================================================
plugins {
    alias(libs.plugins.android.application) apply false
    alias(libs.plugins.kotlin.compose) apply false
    alias(libs.plugins.kotlin.serialization) apply false
}

Ceux des exemples (cf. exemple 01).

11.5.2. gradle/libs.versions.toml

# =========================================================================
# gradle/libs.versions.toml - le "catalogue de versions" du projet
# -------------------------------------------------------------------------
# TOUTES les versions des plugins et des bibliothèques, en un seul endroit.
# Les fichiers build.gradle.kts y font référence par des noms
# (libs.okhttp, libs.plugins.kotlin.compose...). C'est l'équivalent de la
# section dependencies du pubspec.yaml de Flutter.
# =========================================================================

[versions]
agp = "9.4.1"
kotlin = "2.2.10"
coreKtx = "1.17.0"
lifecycle = "2.9.4"
activityCompose = "1.11.0"
composeBom = "2026.02.01"
appcompat = "1.7.1"
navigation = "2.9.5"
okhttp = "4.12.0"
kotlinxSerializationJson = "1.9.0"
androidsvg = "1.4"
junit = "4.13.2"
junitVersion = "1.3.0"
espressoCore = "3.7.0"
testRunner = "1.7.0"

[libraries]
# --- générées par Android Studio (modèle "Empty Activity") ---
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "testRunner" }
# --- ajoutées pour RdvMedecins ---
# les icônes Material de base (Add, Delete, Search...)
androidx-compose-material-icons-core = { group = "androidx.compose.material", name = "material-icons-core" }
# le cycle de vie de l'activité vu de Compose 
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
# viewModel() dans une fonction @Composable 
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
# la langue PROPRE à l'application 
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
# le routeur 
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" }
# le client HTTP 
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
# la (dé)sérialisation JSON 
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
# le dessin d'une image SVG (le captcha)
androidsvg = { group = "com.caverock", name = "androidsvg-aar", version.ref = "androidsvg" }

[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
# génère le code de (dé)sérialisation des classes @Serializable
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }

Les bibliothèques des exemples, moins [DataStore] : la langue est mémorisée par [Android] lui-même, et le jeton par le bocal à cookies.

11.5.3. app/build.gradle.kts

// =========================================================================
// app/build.gradle.kts - la configuration du module "app" : le client
// Android de RdvMedecins. Généré par Android Studio (modèle "Empty
// Activity"), puis complété (lignes marquées "ajout").
// =========================================================================
plugins {
    alias(libs.plugins.android.application)
    alias(libs.plugins.kotlin.compose)
    // ajout : le plugin kotlinx.serialization (classes @Serializable)
    alias(libs.plugins.kotlin.serialization)
}

android {
    namespace = "fr.stahe.rdvmedecins"
    compileSdk {
        version = release(37)
    }

    defaultConfig {
        applicationId = "fr.stahe.rdvmedecins"
        minSdk = 26
        targetSdk = 37
        versionCode = 1
        versionName = "1.0"
        // ajout : l'adresse du serveur JSON, modifiable à la compilation :
        //     gradlew installDebug -Pserveur=http://192.168.1.20:8080
        val serveur = providers.gradleProperty("serveur").getOrElse("http://localhost:8080")
        buildConfigField("String", "URL_SERVEUR", "\"$serveur\"")

        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
    }

    buildTypes {
        release {
            optimization {
                enable = false
            }
        }
    }
    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_11
        targetCompatibility = JavaVersion.VERSION_11
    }
    buildFeatures {
        compose = true
        // ajout : la classe BuildConfig (BuildConfig.URL_SERVEUR)
        buildConfig = true
    }
}

dependencies {
    implementation(platform(libs.androidx.compose.bom))
    implementation(libs.androidx.activity.compose)
    implementation(libs.androidx.compose.material3)
    implementation(libs.androidx.compose.ui)
    implementation(libs.androidx.compose.ui.graphics)
    implementation(libs.androidx.compose.ui.tooling.preview)
    implementation(libs.androidx.core.ktx)
    implementation(libs.androidx.lifecycle.runtime.ktx)
    // ajouts pour RdvMedecins
    implementation(libs.androidx.compose.material.icons.core)
    implementation(libs.androidx.lifecycle.runtime.compose)
    implementation(libs.androidx.lifecycle.viewmodel.compose)
    implementation(libs.androidx.appcompat)
    implementation(libs.androidx.navigation.compose)
    implementation(libs.okhttp)
    implementation(libs.kotlinx.serialization.json)
    implementation(libs.androidsvg)
    // les tests : la fabrication des copies d'écran du cours (dossier androidTest)
    testImplementation(libs.junit)
    androidTestImplementation(platform(libs.androidx.compose.bom))
    androidTestImplementation(libs.androidx.compose.ui.test.junit4)
    androidTestImplementation(libs.androidx.espresso.core)
    androidTestImplementation(libs.androidx.junit)
    androidTestImplementation(libs.androidx.test.runner)
    debugImplementation(libs.androidx.compose.ui.test.manifest)
    debugImplementation(libs.androidx.compose.ui.tooling)
}

Celui des exemples, avec un autre identifiant d'application ([fr.stahe.rdvmedecins]) : les deux applications peuvent être installées côte à côte. Le bloc [buildTypes] décrit la version de PRODUCTION (cf. chapitre « Déploiement »).

11.5.4. AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>
<!-- =====================================================================
     AndroidManifest.xml - la "carte d'identité" de l'application
     ===================================================================== -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <!-- l'accès au réseau (le serveur JSON) -->
    <uses-permission android:name="android.permission.INTERNET" />

    <!-- android:name : la classe Application (RdvMedecinsApplication.kt).
         usesCleartextTraffic : le HTTP "en clair" du serveur de développement
         (à retirer pour un serveur de production en https, cf. Déploiement).
         localeConfig : les langues proposées par l'application -->
    <application
        android:name=".RdvMedecinsApplication"
        android:allowBackup="true"
        android:dataExtractionRules="@xml/data_extraction_rules"
        android:fullBackupContent="@xml/backup_rules"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:localeConfig="@xml/locales_config"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.RdvMedecins"
        android:usesCleartextTraffic="true"
        tools:targetApi="33">

        <!-- l'unique activité. windowSoftInputMode="adjustResize" : le
             clavier réduit l'écran au lieu de le cacher -->
        <activity
            android:name=".MainActivity"
            android:exported="true"
            android:windowSoftInputMode="adjustResize">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>

        <!-- avant Android 13 : AppCompat mémorise la langue choisie -->
        <service
            android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
            android:enabled="false"
            android:exported="false">
            <meta-data
                android:name="autoStoreLocales"
                android:value="true" />
        </service>
    </application>

</manifest>
  • une seule activité, [MainActivity] : tous les écrans sont des fonctions [@Composable] ;
  • [windowSoftInputMode="adjustResize"] : quand le clavier apparaît, l'écran rétrécit (au lieu d'être caché par le clavier) ;
  • [android:localeConfig] et le service d'[AppCompat] : la langue propre à l'application (cf. exemple 21).

11.5.5. Les ressources

<?xml version="1.0" encoding="utf-8"?>
<!-- le nom de l'application (sous son icône). Les textes de l'interface,
     eux, sont dans les dictionnaires assets/i18n/fr.json et en.json -->
<resources>
    <string name="app_name">RdvMedecins</string>
</resources>
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <!-- le thème "Android", visible seulement pendant le lancement ; un
         thème AppCompat, qu'exige AppCompatActivity (cf. MainActivity.kt) -->
    <style name="Theme.RdvMedecins" parent="Theme.AppCompat.DayNight.NoActionBar" />
</resources>
<?xml version="1.0" encoding="utf-8"?>
<!-- =====================================================================
     res/xml/locales_config.xml - les langues de l'application (exemple 21)
     ............................................................................
     Déclarées dans AndroidManifest.xml (android:localeConfig). Depuis
     Android 13, l'utilisateur peut aussi choisir la langue de CHAQUE
     application dans les réglages du téléphone : Paramètres > Système >
     Langues > Langues des applications.
     ===================================================================== -->
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
    <locale android:name="fr" />
    <locale android:name="en" />
</locale-config>

Les textes de l'application ne sont PAS dans [strings.xml] (seul le nom de l'application y est) : ils sont dans les dictionnaires JSON [assets/i18n/fr.json] et [en.json], ceux des clients [Vue.js], [Angular], [React] et [Flutter]. Un extrait :

{
  "APP": {
    "TITLE": "RdvMedecins",
    "LOGOUT": "Se déconnecter",
    "MENU_AGENDA": "Agenda",
    "MENU_DOCTORS": "Médecins",
    "MENU_PATIENTS": "Clients",
    "MENU_MY_SPACE": "Mon espace",
    "LOADING": "Chargement...",
    "LANGUAGE": "Langue",
    "MENU_ACCOUNT": "Mon compte",
    "CONNECTED_AS": "Connecté : {{nom}}",
    "RETRY": "Réessayer"
  },
  "LOGIN": {
    "TITLE": "Connexion",
    "LOGIN_LABEL": "Login",
    "PASSWORD_LABEL": "Mot de passe",
    "SUBMIT": "Se connecter",
    "ERROR": "Login ou mot de passe incorrect.",
    "DEMO_ACCOUNTS": "Comptes de démonstration (mot de passe = login) :",
    "DEMO_ADMIN": "rôle ADMIN : rendez-vous + gestion des médecins et des clients",
    "DEMO_DOCTOR": "rôle DOCTOR : prise et annulation de rendez-vous",
    "DEMO_USER": "rôle USER (patients) : réservation pour eux-mêmes, gestion de leur compte",
    "CREATE_ACCOUNT": "Créer un compte client",
    "CREATE_ACCOUNT_HELP": "Pour créer un compte, recopiez d'abord les caractères de l'image."
  },
  "DOCTOR_DAY_PICKER": {
    "DOCTOR_LABEL": "Médecin",
    "CHOOSE_DOCTOR": "-- choisir un médecin --",
    "DAY_LABEL": "Jour",
    "VIEW_AGENDA": "Voir l'agenda",
    "CHOOSE_DAY": "Choisir le jour"
  },
  "AGENDA": {
    "PAGE_TITLE": "Agenda",
    "TITLE_PREFIX": "Agenda du",
    "COLUMN_SLOT": "Créneau",
    "COLUMN_STATUS": "Statut",
    "FREE": "Libre",

11.6. Le démarrage de l'application

11.6.1. Config.kt

// =========================================================================
// Config.kt - OÙ est le serveur JSON ?
// -------------------------------------------------------------------------
// Une application mobile n'a pas d'"origine" : il faut lui donner l'adresse
// complète du serveur (cf. exemple 22 et commun/Config.kt des exemples) :
//   - par défaut http://localhost:8080, avec la commande
//         adb reverse tcp:8080 tcp:8080
//     qui relie le port 8080 du téléphone (ou de l'émulateur) à celui de
//     l'ordinateur ;
//   - ou une autre adresse, fixée à la compilation :
//         gradlew installDebug -Pserveur=http://10.0.2.2:8080        (émulateur)
//         gradlew installDebug -Pserveur=http://192.168.1.20:8080    (téléphone en Wi-Fi)
// =========================================================================
package fr.stahe.rdvmedecins

object Config {
    val urlServeur: String = BuildConfig.URL_SERVEUR
}

L'adresse du serveur, fixée à la compilation (cf. exemple 22).

11.6.2. RdvMedecinsApplication.kt

// =========================================================================
// RdvMedecinsApplication.kt - les objets partagés par toute l'application
// -------------------------------------------------------------------------
// Android crée UN objet Application au lancement, avant toute activité, et
// le garde jusqu'à l'arrêt de l'application. On y fabrique, une fois pour
// toutes, les objets partagés par tous les écrans :
//   - le client HTTP (et son bocal à cookies), la couche d'accès à l'API ;
//   - la session (qui est connecté ?), les messages, le cache des données.
// Ils survivent ainsi à la recréation de l'activité (rotation, changement
// de langue). Les écrans les obtiennent par LocalDependances (cf.
// MainActivity.kt) : c'est une "injection de dépendances" écrite à la main.
// Deux mécanismes relient ces objets entre eux :
//   1. la réponse 401 d'une requête protégée (session expirée) : on oublie
//      l'utilisateur et l'on prépare le message ; la navigation, qui observe
//      la session, renvoie alors à l'écran de connexion ;
//   2. un changement d'utilisateur : les données lues pour l'utilisateur
//      précédent ne valent plus rien, le cache est vidé.
// =========================================================================
package fr.stahe.rdvmedecins

import android.app.Application
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.staticCompositionLocalOf
import fr.stahe.rdvmedecins.api.ClientApi
import fr.stahe.rdvmedecins.api.RdvMedecinsApi
import fr.stahe.rdvmedecins.api.creerClientHttp
import fr.stahe.rdvmedecins.etat.Cache
import fr.stahe.rdvmedecins.etat.Messages
import fr.stahe.rdvmedecins.etat.Session
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch

class RdvMedecinsApplication : Application() {
    // une portée de coroutines qui vit aussi longtemps que l'application
    val portee = MainScope()
    lateinit var api: RdvMedecinsApi
        private set
    lateinit var session: Session
        private set
    val messages = Messages()
    val cache = Cache(portee)

    override fun onCreate() {
        super.onCreate()
        val client = ClientApi(creerClientHttp(this))
        api = RdvMedecinsApi(client)
        session = Session(api)
        // 1. la session expirée
        client.onSessionExpiree = {
            session.oublier()
            messages.flash("ERRORS.SESSION_EXPIRED", succes = false)
        }
        // 2. l'utilisateur change : snapshotFlow transforme un état Compose en Flow
        portee.launch {
            snapshotFlow { session.utilisateur }.collect { cache.vider() }
        }
    }
}

// l'objet Application, fourni à tous les écrans
val LocalDependances = staticCompositionLocalOf<RdvMedecinsApplication> { error("aucune dépendance fournie") }
  • lignes 35-41 : les objets partagés, créés une fois. [lateinit var] : une propriété non [null], initialisée plus tard (dans [onCreate]) ; [MainScope()] : une portée de coroutines qui vit aussi longtemps que l'application ;
  • lignes 49-51 : la session expirée : la fonction confiée au client de l'API (qui ne connaît rien d'autre) oublie l'utilisateur et prépare le message ;
  • ligne 55 : [snapshotFlow] transforme un état [Compose] en [Flow] (cf. exemple 19) : à chaque changement d'utilisateur, le cache est vidé ;
  • ligne 61 : l'objet [Application], fourni à tous les écrans par une [CompositionLocal] (cf. exemple 14) : une « injection de dépendances » écrite à la main.

11.6.3. MainActivity.kt

// =========================================================================
// MainActivity.kt - l'unique activité de l'application
// -------------------------------------------------------------------------
// Tous les écrans sont des fonctions @Composable, affichées par le NavHost
// (cf. navigation/Navigation.kt) dans CETTE activité. Elle fournit à tous
// les écrans (CompositionLocalProvider) :
//   - les objets partagés de l'application (LocalDependances) ;
//   - les traductions de la langue courante (LocalTraductions).
// C'est une AppCompatActivity : elle sait appliquer la langue choisie par
// l'utilisateur (cf. exemple 21), même avant Android 13.
// =========================================================================
package fr.stahe.rdvmedecins

import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.graphics.Color
import fr.stahe.rdvmedecins.i18n.LocalTraductions
import fr.stahe.rdvmedecins.i18n.rememberTraductions
import fr.stahe.rdvmedecins.navigation.Navigation

// les couleurs : le bleu des autres clients de RdvMedecins (Bootstrap)
private val couleurs = lightColorScheme(
    primary = Color(0xFF0D6EFD),
    onPrimary = Color.White,
    primaryContainer = Color(0xFFD8E2FF),
    onPrimaryContainer = Color(0xFF001A41),
    secondaryContainer = Color(0xFFDAE2F9),
    onSecondaryContainer = Color(0xFF131C2B),
    surface = Color(0xFFFAF9FD),
)

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        val dependances = application as RdvMedecinsApplication
        setContent {
            MaterialTheme(colorScheme = couleurs) {
                CompositionLocalProvider(
                    LocalDependances provides dependances,
                    LocalTraductions provides rememberTraductions(),
                ) {
                    Navigation()
                }
            }
        }
    }
}
  • lignes 27-34 : les couleurs du thème, le bleu des autres clients ;
  • lignes 37-48 : une [AppCompatActivity] (la langue, cf. exemple 21), qui fournit à tous les écrans les objets partagés et les traductions, puis affiche la navigation.

11.6.4. Formatage.kt

// =========================================================================
// Formatage.kt - l'affichage des heures et des jours
// -------------------------------------------------------------------------
// Le serveur envoie des données BRUTES (hdebut: 8, mdebut: 0 ; jour:
// "2026-10-05") ; la mise en forme, qui dépend de la langue, est l'affaire
// du client (cf. exemple 09).
// =========================================================================
package fr.stahe.rdvmedecins

import fr.stahe.rdvmedecins.api.Creneau
import fr.stahe.rdvmedecins.api.CreneauAgenda
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.Locale

// "8:05" plutôt que "8:5"
fun heure(h: Int, m: Int) = "$h:${m.toString().padStart(2, '0')}"

// "8:00 - 8:20"
fun horaire(c: Creneau) = "${heure(c.hdebut, c.mdebut)} - ${heure(c.hfin, c.mfin)}"
fun horaire(c: CreneauAgenda) = "${heure(c.hdebut, c.mdebut)} - ${heure(c.hfin, c.mfin)}"

fun aujourdhui(): String = LocalDate.now().toString() // "yyyy-MM-dd", le format de l'API

// vrai si jour est une date qui existe, au format yyyy-MM-dd ("2026-02-30" : non)
fun estUneDateValide(jour: String): Boolean =
    Regex("^\\d{4}-\\d{2}-\\d{2}$").matches(jour) && runCatching { LocalDate.parse(jour) }.isSuccess

// "lundi 5 octobre 2026" / "Monday, October 5, 2026"
fun jourLong(jour: String, langue: String): String =
    LocalDate.parse(jour).format(DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).withLocale(Locale.forLanguageTag(langue)))

// le calendrier de Material travaille en millisecondes (UTC)
fun versMillis(jour: String): Long = LocalDate.parse(jour).atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
fun depuisMillis(ms: Long): String = Instant.ofEpochMilli(ms).atZone(ZoneOffset.UTC).toLocalDate().toString()

Le serveur envoie des données BRUTES ([hdebut: 8], [jour: "2026-10-05"]) : leur mise en forme, qui dépend de la langue, est l'affaire du client (cf. exemple 09).

11.7. La couche d'accès à l'API

11.7.1. api/ClientHttp.kt

// =========================================================================
// api/ClientHttp.kt - un client HTTP qui gère les COOKIES
// -------------------------------------------------------------------------
// Repris de l'exemple 23 (commun/ClientHttp.kt). Le serveur RdvMedecins
// dépose le jeton JWT dans un cookie httpOnly. Sur un téléphone, il n'y a
// pas de navigateur pour le garder : c'est le "bocal à cookies" (CookieJar)
// du client OkHttp qui le range, et le renvoie avec chaque requête. Les
// cookies sont aussi rangés sur l'appareil (SharedPreferences) : on reste
// connecté quand on arrête puis relance l'application.
// Deux cookies circulent : le jeton (rdvmedecins_jwt, chemin /api) et le
// laissez-passer de la création de compte (chemin /api/inscription).
// =========================================================================
package fr.stahe.rdvmedecins.api

import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit
import fr.stahe.rdvmedecins.Config
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
    private val cookies = mutableMapOf<String, Cookie>()

    init {
        // les cookies mémorisés lors d'une utilisation précédente
        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 expiré (le
    // serveur efface ainsi le jeton à la déconnexion) 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, 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"
    }
}

fun creerClientHttp(context: Context): OkHttpClient = OkHttpClient.Builder()
    .cookieJar(CookiesSurLAppareil(context.getSharedPreferences("rdvmedecins", Context.MODE_PRIVATE)))
    .connectTimeout(10, TimeUnit.SECONDS)
    .readTimeout(10, TimeUnit.SECONDS)
    .build()

Le client HTTP de l'exemple 23, et son bocal à cookies. Deux cookies circulent : le jeton ([rdvmedecins_jwt]) et le laissez-passer de la création de compte.

11.7.2. api/ErreurApi.kt

// =========================================================================
// api/ErreurApi.kt - UNE classe pour toutes les erreurs de l'API
// -------------------------------------------------------------------------
// Reprise de l'exemple 24 (commun/ErreurApi.kt). Toutes les réponses
// d'erreur du serveur ont la même forme :
//   { "statusCode": 409, "cle": "ERRORS.LOGIN_TAKEN", "params": {...}, "champs": {...} }
// Une requête sans réponse (réseau, serveur arrêté) ou une réponse qui ne
// vient pas de notre serveur deviennent, elles aussi, une ErreurApi.
// =========================================================================
package fr.stahe.rdvmedecins.api

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 {
        fun depuisReponse(statusCode: Int, corps: String): ErreurApi {
            val json = runCatching { Json.parseToJsonElement(corps).jsonObject }.getOrNull()
            val cle = (json?.get("cle") as? JsonPrimitive)?.content
            if (json == null || cle == null) {
                // pas de clé : le proxy ou le réseau ont répondu à la place du serveur
                return ErreurApi(statusCode, if (statusCode >= 500) "ERRORS.SERVER_UNREACHABLE" else "ERRORS.SERVER_ERROR")
            }
            return ErreurApi(statusCode, cle, chaines(json["params"]), chaines(json["champs"]))
        }

        private fun chaines(json: Any?): Map<String, String> =
            (json as? JsonObject)?.mapValues { (_, v) -> (v as? JsonPrimitive)?.content ?: v.toString() } ?: emptyMap()
    }
}

11.7.3. api/ClientApi.kt

// =========================================================================
// api/ClientApi.kt - TOUTES les requêtes vers l'API passent par ici
// -------------------------------------------------------------------------
// Repris de l'exemple 24 (commun/Api.kt), avec une nouveauté : la gestion
// centralisée de la réponse 401.
//
// Un 401 peut arriver n'importe quand : le jeton a expiré (2 heures), le
// compte a été supprimé, le serveur a redémarré avec une autre clé
// secrète... Plutôt que de traiter ce cas dans chaque écran, on le traite
// ICI, une fois. Mais ce fichier ne connaît ni la navigation ni les
// messages (il ne dépend de rien) : il appelle une fonction qu'on lui a
// confiée au démarrage (onSessionExpiree, cf. RdvMedecinsApplication.kt),
// qui oublie l'utilisateur ; la navigation, qui observe la session, renvoie
// alors à l'écran de connexion.
//
// Certaines requêtes traitent elles-mêmes leur 401 (la connexion, GET
// /api/auth/moi...) : elles sont "publiques" (cf. RdvMedecinsApi.kt).
// =========================================================================
package fr.stahe.rdvmedecins.api

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

class ClientApi(private val client: OkHttpClient) {
    // ce qu'il faut faire quand le serveur répond 401 à une requête protégée
    var onSessionExpiree: () -> Unit = {}

    // publique : true pour les routes qui traitent elles-mêmes le 401.
    // withContext(Dispatchers.Main.immediate) : la fonction se termine
    // TOUJOURS sur le fil principal, celui de l'écran, quel que soit le fil
    // de l'appelant (dans une application, c'est déjà le cas : rien ne
    // change ; dans un test instrumenté, l'appelant peut être ailleurs). On
    // peut donc, au retour, naviguer ou toucher à l'état sans précaution
    suspend fun requete(methode: String, url: String, corps: JsonElement? = null, publique: Boolean = false): JsonElement? =
        withContext(Dispatchers.Main.immediate) { executer(methode, url, corps, publique) }

    private suspend fun executer(methode: String, url: String, corps: JsonElement?, publique: Boolean): JsonElement? {
        val reponse = withContext(Dispatchers.IO) {
            val corpsHttp = corps?.toString()?.toRequestBody("application/json".toMediaType())
            val requete = Request.Builder().url("${Config.urlServeur}/api$url").method(methode, corpsHttp).build()
            try {
                client.newCall(requete).execute().use { it.code to it.body?.string().orEmpty() }
            } catch (e: IOException) {
                // aucune réponse : réseau coupé, serveur arrêté, mauvaise adresse
                throw ErreurApi(0, "ERRORS.SERVER_UNREACHABLE")
            }
        }
        // déstructuration de la paire (code, texte)
        val (code, texte) = reponse
        if (code >= 400) {
            val erreur = ErreurApi.depuisReponse(code, texte)
            if (erreur.statusCode == 401 && !publique) {
                // de retour sur le fil de l'écran : on peut toucher à l'état
                onSessionExpiree()
                // l'écran qui attendait la réponse affichera, lui aussi, CE message
                throw ErreurApi(401, "ERRORS.SESSION_EXPIRED")
            }
            throw erreur
        }
        return if (texte.isEmpty()) null else Json.parseToJsonElement(texte)
    }
}
  • lignes 42-43 : la fonction se termine TOUJOURS sur le fil principal, celui de l'écran ([Dispatchers.Main.immediate]) : au retour, on peut naviguer ou toucher à l'état sans précaution ;
  • lignes 46-53 : la requête, sur le fil des entrées-sorties (cf. exemple 24) ;
  • lignes 60-64 : la nouveauté : un 401 sur une route protégée, c'est une session expirée. On le traite ICI, une fois pour toutes les requêtes : [onSessionExpiree()], puis une [ErreurApi] dont la clé est le message à afficher.

11.7.4. api/Types.kt

// =========================================================================
// api/Types.kt - les objets JSON échangés avec le serveur
// -------------------------------------------------------------------------
// La traduction, en classes Kotlin @Serializable, de l'API décrite au
// chapitre "Le serveur, une boîte noire" (cf. commun/Types.kt des
// exemples). La lecture d'un JSON qui n'a pas la forme attendue échoue
// dès sa réception.
// =========================================================================
package fr.stahe.rdvmedecins.api

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, PUT /api/mon-compte
@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 titre, un prénom, un nom
@Serializable
data class Identite(val titre: String, val prenom: String, val nom: String) {
    val libelle get() = "$titre $prenom $nom" // "Mme Alice DURAND"
}

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

// un créneau horaire
@Serializable
data class Creneau(val id: Int, val hdebut: Int, val mdebut: Int, val hfin: Int, val mfin: Int)

// une ligne de l'agenda, avec les DROITS de l'utilisateur connecté
@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?, // null : libre, ou occupé par un autre patient
    val moi: Boolean, // le rendez-vous du patient connecté
    val reservable: Boolean,
    val idRv: Int?, // renseigné si l'utilisateur peut annuler
)

// GET /api/agenda?idMedecin=...&jour=...
@Serializable
data class Agenda(
    val medecin: Personne,
    val jour: String, // "yyyy-MM-dd"
    val creneaux: List<CreneauAgenda>,
    val info: String?, // clé de traduction (patient qui a déjà un rendez-vous)
)

// un rendez-vous de GET /api/mon-espace
@Serializable
data class RendezVous(val id: Int, val jour: String, val creneau: Creneau, val medecin: Personne)

// GET /api/mon-espace
@Serializable
data class MonEspace(val patient: Identite, val rendezVous: List<RendezVous>)

// GET /api/mon-compte
@Serializable
data class MonCompte(val titre: String, val prenom: String, val nom: String, val login: String, val version: Int)

val jsonApi = Json { ignoreUnknownKeys = true }

// JsonElement -> objet Kotlin
inline fun <reified T> JsonElement?.lire(): T =
    jsonApi.decodeFromJsonElement(serializer<T>(), this ?: throw IllegalStateException("réponse vide"))

11.7.5. api/RdvMedecinsApi.kt

// =========================================================================
// api/RdvMedecinsApi.kt - une fonction par route de l'API
// -------------------------------------------------------------------------
// La "couche d'accès aux données" du client (un "repository") : les écrans
// appellent ces fonctions, typées, sans rien savoir des URL, d'OkHttp ni
// du JSON. Si l'API change, seul ce fichier change.
// Chaque fonction est suspendue ; en cas d'échec, elle lance une ErreurApi.
// Les données des formulaires sont de simples Map<String, Any?> : leurs
// clés sont les noms des champs attendus par le serveur - et ceux des
// erreurs qu'il renvoie (ErreurApi.champs).
// =========================================================================
package fr.stahe.rdvmedecins.api

import android.net.Uri
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.boolean
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive

// /api/medecins ou /api/clients : les deux se gèrent de la même façon
enum class TypePersonnes(val chemin: String) { MEDECINS("medecins"), CLIENTS("clients") }

typealias Donnees = Map<String, Any?>

// des données de formulaire -> un objet JSON
fun Donnees.versJson(): JsonObject = JsonObject(
    mapValues { (_, v) ->
        when (v) {
            null -> JsonNull
            is Number -> JsonPrimitive(v)
            is Boolean -> JsonPrimitive(v)
            else -> JsonPrimitive(v.toString())
        }
    },
)

class RdvMedecinsApi(private val client: ClientApi) {
    private suspend fun get(url: String, publique: Boolean = false) = client.requete("GET", url, publique = publique)

    private suspend fun post(url: String, corps: JsonElement, publique: Boolean = false) =
        client.requete("POST", url, corps, publique)

    // ------------------------------------------------ connexion (routes publiques)
    suspend fun captcha(): Captcha = get("/captcha", publique = true).lire()

    suspend fun moi(): Utilisateur = get("/auth/moi", publique = true).lire()

    suspend fun connecter(login: String, password: String, captchaId: String, captcha: String): Utilisateur =
        post(
            "/auth/login",
            mapOf("login" to login, "password" to password, "captchaId" to captchaId, "captcha" to captcha).versJson(),
            publique = true,
        ).lire()

    suspend fun deconnecter() {
        post("/auth/logout", JsonObject(emptyMap()), publique = true)
    }

    // ------------------------------------------------ création de compte (publique)
    suspend fun accesInscription(captchaId: String, captcha: String) {
        post("/inscription/acces", mapOf("captchaId" to captchaId, "captcha" to captcha).versJson(), publique = true)
    }

    suspend fun etatAccesInscription(): Boolean =
        get("/inscription/acces", publique = true)?.jsonObject?.get("valide")?.jsonPrimitive?.boolean ?: false

    // renvoie le login du compte créé
    suspend fun inscrire(donnees: Donnees): String =
        post("/inscription", donnees.versJson(), publique = true)!!.jsonObject["login"]!!.jsonPrimitive.content

    // ------------------------------------------------ agenda et rendez-vous
    // idMedecin null : pour un médecin connecté, SON agenda
    suspend fun agenda(idMedecin: Int?, jour: String): Agenda {
        val parametres = Uri.Builder().appendQueryParameter("jour", jour).apply {
            if (idMedecin != null) appendQueryParameter("idMedecin", "$idMedecin")
        }.build().encodedQuery
        return get("/agenda?$parametres").lire()
    }

    suspend fun reserver(jour: String, idCreneau: Int, idClient: Int?) {
        post("/rendez-vous", buildMap { put("jour", jour); put("idCreneau", idCreneau); if (idClient != null) put("idClient", idClient) }.versJson())
    }

    suspend fun annulerRv(idRv: Int) {
        client.requete("DELETE", "/rendez-vous/$idRv")
    }

    // ------------------------------------------------ médecins et clients
    suspend fun lister(type: TypePersonnes): List<Personne> = get("/${type.chemin}").lire()

    suspend fun lire(type: TypePersonnes, id: Int): Personne = get("/${type.chemin}/$id").lire()

    suspend fun ajouter(type: TypePersonnes, donnees: Donnees): Personne = post("/${type.chemin}", donnees.versJson()).lire()

    suspend fun modifier(type: TypePersonnes, id: Int, donnees: Donnees): Personne =
        client.requete("PUT", "/${type.chemin}/$id", donnees.versJson()).lire()

    suspend fun supprimer(type: TypePersonnes, id: Int) {
        client.requete("DELETE", "/${type.chemin}/$id")
    }

    // ------------------------------------------------ l'espace du patient
    suspend fun monEspace(): MonEspace = get("/mon-espace").lire()

    suspend fun monCompte(): MonCompte = get("/mon-compte").lire()

    suspend fun modifierMonCompte(donnees: Donnees): Utilisateur = client.requete("PUT", "/mon-compte", donnees.versJson()).lire()

    suspend fun supprimerMonCompte(motDePasseActuel: String) {
        client.requete("DELETE", "/mon-compte", mapOf("motDePasseActuel" to motDePasseActuel).versJson())
    }
}
  • ligne 24 : médecins ou clients : une énumération, avec le chemin de l'API de chacun ;
  • lignes 26-29 : les données d'un formulaire : une [Map] dont les clés sont les noms des champs attendus par le serveur, convertie en JSON ;
  • lignes 40-110 : une fonction par route de l'API, groupées par thème ; les routes qui traitent elles-mêmes le 401 sont « publiques ».

11.8. Les langues

11.8.1. i18n/Traductions.kt

// =========================================================================
// i18n/Traductions.kt - les dictionnaires, la langue courante
// -------------------------------------------------------------------------
// Repris de l'exemple 25 (Dictionnaire.kt). Les dictionnaires
// (assets/i18n/fr.json, en.json) sont ceux des clients Vue.js, Angular,
// React et Flutter : mêmes clés, mêmes conventions ({{param}}). Le serveur
// ne renvoie que des CLÉS ("ERRORS.LOGIN_TAKEN") : c'est ici qu'elles
// deviennent du texte.
//   - t("CLE", mapOf("param" to valeur)) : le texte de la langue courante ;
//   - changerLangue("en") : la langue PROPRE à l'application (cf. exemple
//     21), mémorisée par Android ; l'activité est recréée dans la nouvelle
//     langue : nos textes, mais aussi ceux d'Android (le calendrier), les
//     dates et les nombres.
// =========================================================================
package fr.stahe.rdvmedecins.i18n

import android.content.Context
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.core.os.LocaleListCompat
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive

val langues = listOf("fr", "en")

class Traductions(context: Context, val langue: String) {
    private val racine: JsonObject = context.assets.open("i18n/$langue.json").bufferedReader().use {
        Json.parseToJsonElement(it.readText()) as JsonObject
    }

    // "LOGIN.TITLE" -> racine["LOGIN"]["TITLE"] ; 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
    }

    // une clé inconnue s'affiche telle quelle : on voit tout de suite ce qui manque
    fun t(cle: String, params: Map<String, String> = emptyMap()): String {
        val texte = chercher(cle) ?: return cle
        return Regex("\\{\\{(\\w+)\\}\\}").replace(texte) { m -> params[m.groupValues[1]] ?: m.value }
    }
}

// la langue de l'application : Android la mémorise, et recrée l'activité
fun changerLangue(langue: String) {
    AppCompatDelegate.setApplicationLocales(LocaleListCompat.forLanguageTags(langue))
}

// les traductions, fournies à tous les écrans (cf. MainActivity.kt)
val LocalTraductions = staticCompositionLocalOf<Traductions> { error("aucune traduction fournie") }

// les traductions de la langue de la configuration courante
@Composable
fun rememberTraductions(): Traductions {
    val context = LocalContext.current
    val langue = LocalConfiguration.current.locales[0].language.let { if (it in langues) it else "fr" }
    return remember(langue) { Traductions(context, langue) }
}

// un raccourci, dans une fonction @Composable : t("LOGIN.TITLE")
@Composable
fun t(cle: String, params: Map<String, String> = emptyMap()): String = LocalTraductions.current.t(cle, params)
  • lignes 32-45 : le dictionnaire de l'exemple 25, avec les MÊMES fichiers que les autres clients ;
  • lignes 57-64 : les traductions de la langue courante, fournies à tous les écrans (cf. [MainActivity.kt]) ; le dictionnaire n'est relu que si la langue change ;
  • ligne 69 : un raccourci [@Composable], [t("LOGIN.TITLE")]. Comme toute fonction [@Composable], on ne peut l'appeler que DANS une autre : dans une lambda ordinaire (un [onClick], le [texte] d'une liste déroulante), on traduit d'abord, ou l'on utilise [LocalTraductions.current.t(...)].

11.9. L'état partagé

11.9.1. etat/Session.kt

// =========================================================================
// etat/Session.kt - QUI est connecté ?
// -------------------------------------------------------------------------
// L'utilisateur connecté (ou null), dans un état observé par Compose
// (mutableStateOf) : les écrans qui le lisent sont recomposés quand il
// change, et la navigation (cf. navigation/Navigation.kt) l'observe pour
// quitter un écran qui n'est plus permis.
//
// Le jeton JWT lui-même n'est PAS ici : il est dans un cookie, rangé par
// le client HTTP. La session ne garde que la DESCRIPTION de l'utilisateur
// ({ login, nom, role }).
//
// Au démarrage de l'application, la session est vide : verifier() demande
// au serveur, une seule fois, qui est connecté (GET /api/auth/moi).
// =========================================================================
package fr.stahe.rdvmedecins.etat

import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import fr.stahe.rdvmedecins.api.RdvMedecinsApi
import fr.stahe.rdvmedecins.api.Role
import fr.stahe.rdvmedecins.api.Utilisateur

class Session(private val api: RdvMedecinsApi) {
    var utilisateur by mutableStateOf<Utilisateur?>(null)
        private set

    // vrai après une déconnexion VOULUE (bouton, suppression du compte) ;
    // faux après une session expirée. La navigation en tient compte
    var departVolontaire = false
        private set

    // l'écran qu'on voulait voir quand la session a expiré (une route) :
    // on y reviendra après la connexion
    var ecranDemande: Any? = null

    // l'écran affiché (une route), noté par chaque écran protégé
    var ecranCourant: Any? = null

    // la question "qui est connecté ?" a-t-elle déjà été posée ?
    var verifiee by mutableStateOf(false)
        private set

    // ne questionne le serveur que la première fois
    suspend fun verifier() {
        if (verifiee) return
        utilisateur = try {
            api.moi()
        } catch (e: Exception) {
            null // 401 : personne ; ou serveur injoignable
        }
        verifiee = true
    }

    suspend fun connecter(login: String, password: String, captchaId: String, captcha: String) {
        changer(api.connecter(login, password, captchaId, captcha))
    }

    // l'utilisateur a changé de nom ou de login (PUT /api/mon-compte)
    fun mettreAJour(u: Utilisateur) = changer(u)

    suspend fun deconnecter() {
        try {
            api.deconnecter()
        } finally {
            oublier(volontaire = true) // même si le serveur ne répond pas
        }
    }

    // sans rien demander au serveur : session expirée, compte supprimé
    fun oublier(volontaire: Boolean = false) = changer(null, volontaire)

    private fun changer(u: Utilisateur?, volontaire: Boolean = false) {
        departVolontaire = volontaire
        utilisateur = u
    }
}

// ce qu'on déduit de l'utilisateur : des propriétés d'EXTENSION du type
// Utilisateur? (elles s'appliquent aussi à null)
val Utilisateur?.estAdmin get() = this?.role == Role.ADMIN
val Utilisateur?.estPatient get() = this?.role == Role.USER

// ADMIN et DOCTOR : prennent et annulent les rendez-vous de n'importe quel patient
val Utilisateur?.estGestionnaire get() = this?.role == Role.ADMIN || this?.role == Role.DOCTOR
  • lignes 26-27 : l'utilisateur, un état observé : la barre du haut, le menu et la navigation le lisent ;
  • lignes 31-39 : ce qu'il faut pour revenir, après une session expirée, à l'écran qu'on voulait voir ;
  • lignes 82-86 : des propriétés d'EXTENSION du type [Utilisateur?] : elles s'appliquent aussi à [null] ([this?.role]).

11.9.2. etat/Messages.kt

// =========================================================================
// etat/Messages.kt - les messages affichés à l'utilisateur
// -------------------------------------------------------------------------
// Même présentation que les autres clients de RdvMedecins :
//   - un SUCCÈS s'affiche en HAUT de l'écran (bandeau vert) ;
//   - une ERREUR en BAS (bandeau rose), toujours visible.
// Un message est une CLÉ de traduction (+ ses paramètres) : il se retraduit
// donc tout seul si l'utilisateur change de langue.
//
// Les messages "flash" : une action qui se termine par un changement
// d'écran (réservation, ajout d'un médecin...) dépose son message avec
// flash() ; il s'affichera sur l'écran SUIVANT, puis disparaîtra à la
// navigation d'après. C'est la navigation qui appelle nouvellePage() à
// chaque changement d'écran (cf. navigation/Navigation.kt).
// =========================================================================
package fr.stahe.rdvmedecins.etat

import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import fr.stahe.rdvmedecins.api.ErreurApi

data class Message(val cle: String, val params: Map<String, String> = emptyMap())

class Messages {
    var succes by mutableStateOf<Message?>(null)
        private set
    var erreur by mutableStateOf<Message?>(null)
        private set

    // les messages à afficher sur l'écran suivant
    private var succesEnAttente: Message? = null
    private var erreurEnAttente: Message? = null

    fun afficherSucces(cle: String, params: Map<String, String> = emptyMap()) {
        succes = Message(cle, params)
        erreur = null
    }

    fun afficherErreur(cle: String, params: Map<String, String> = emptyMap()) {
        erreur = Message(cle, params)
        succes = null
    }

    fun afficherErreurApi(e: ErreurApi) = afficherErreur(e.cle, e.params)

    fun effacer() {
        succes = null
        erreur = null
    }

    // pour l'écran SUIVANT
    fun flash(cle: String, params: Map<String, String> = emptyMap(), succes: Boolean = true) {
        if (succes) succesEnAttente = Message(cle, params) else erreurEnAttente = Message(cle, params)
    }

    // un nouvel écran : les messages du précédent disparaissent, les
    // messages flash apparaissent
    fun nouvellePage() {
        succes = succesEnAttente
        erreur = erreurEnAttente
        succesEnAttente = null
        erreurEnAttente = null
    }
}

11.9.3. etat/Cache.kt

// =========================================================================
// etat/Cache.kt - les données déjà lues sur le serveur
// -------------------------------------------------------------------------
// La liste des médecins ne change presque jamais : inutile de la relire à
// chaque visite de l'agenda. Le cache garde le résultat de chaque lecture,
// sous une CLÉ ("medecins", "agenda/1/2026-10-05"...) :
//
//     val medecins = cache.lire("medecins") { api.lister(TypePersonnes.MEDECINS) }
//
// renvoie le résultat déjà gardé, ou lance la lecture et garde son
// résultat. Deux demandes simultanées de la même clé partagent la même
// lecture (un Deferred : un résultat à venir). Une lecture en échec n'est
// pas gardée : la suivante réessaiera.
// Après une modification, on INVALIDE les clés concernées :
// cache.invalider("agenda"). La propriété version, un état observé,
// change alors : les écrans qui la lisent (même cachés sous une fenêtre)
// relisent leurs données. C'est, en quelques lignes, ce que fait la
// bibliothèque TanStack Query du client React.
// =========================================================================
package fr.stahe.rdvmedecins.etat

import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.setValue
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async

class Cache(private val portee: CoroutineScope) {
    private val lectures = mutableMapOf<String, Deferred<Any?>>()

    var version by mutableIntStateOf(0)
        private set

    @Suppress("UNCHECKED_CAST")
    suspend fun <T> lire(cle: String, charger: suspend () -> T): T {
        val lecture = lectures.getOrPut(cle) { portee.async { charger() } }
        try {
            return lecture.await() as T
        } catch (e: Exception) {
            // en cas d'échec, on oublie cette lecture (la prochaine demande réessaiera)
            if (lectures[cle] === lecture) lectures.remove(cle)
            throw e
        }
    }

    // oublie toutes les clés qui commencent par prefixe, et prévient les écrans
    fun invalider(prefixe: String) {
        lectures.keys.removeAll { it.startsWith(prefixe) }
        version++
    }

    // changement d'utilisateur : on oublie tout, SANS prévenir (les écrans de
    // l'utilisateur précédent vont disparaître, ils ne doivent rien relire)
    fun vider() = lectures.clear()
}
  • lignes 36-39 : [getOrPut] : la lecture déjà lancée pour cette clé, sinon une nouvelle. [async] lance une coroutine qui produit un [Deferred], un résultat à venir : deux demandes simultanées attendent ([await]) la MÊME lecture ;
  • lignes 48-50 : [invalider] : les écrans qui lisent [version] dans la clé d'un [LaunchedEffect] relisent leurs données.

11.10. La navigation

11.10.1. navigation/Ecrans.kt

// =========================================================================
// navigation/Ecrans.kt - les ROUTES de l'application (cf. exemple 17)
// -------------------------------------------------------------------------
// Les mêmes écrans que les autres clients (/login, /agenda, /medecins,
// /mon-espace...), décrits par des objets Kotlin @Serializable, rangés
// dans un objet Ecrans : Ecrans.Agenda(idMedecin = 1, jour = "2026-10-05").
// TOUT l'état d'un écran est dans sa route (le médecin, le jour...) : le
// bouton Retour du téléphone le restitue tel quel.
// La fenêtre de réservation est elle aussi une route (Ecrans.Reservation) :
// une destination de type "dialog", affichée PAR-DESSUS l'agenda ; le
// bouton Retour la ferme.
// =========================================================================
package fr.stahe.rdvmedecins.navigation

import fr.stahe.rdvmedecins.api.Role
import fr.stahe.rdvmedecins.api.TypePersonnes
import fr.stahe.rdvmedecins.api.Utilisateur
import kotlinx.serialization.Serializable

object Ecrans {
    // le premier écran : le temps de demander au serveur qui est connecté
    @Serializable
    object Demarrage

    // login : pré-rempli juste après la création d'un compte
    @Serializable
    data class Login(val login: String = "")

    // jour : tel quel, peut-être invalide (il est vérifié par l'écran)
    @Serializable
    data class Agenda(val idMedecin: Int? = null, val jour: String? = null)

    @Serializable
    data class Reservation(val idMedecin: Int?, val jour: String, val idCreneau: Int)

    // les écrans de gestion des médecins OU des clients (ADMIN)
    @Serializable
    data class Personnes(val type: TypePersonnes)

    // id null : création ; sinon, modification
    @Serializable
    data class Personne(val type: TypePersonnes, val id: Int? = null)

    @Serializable
    object Inscription

    @Serializable
    object MonEspace

    @Serializable
    object MonCompte
}

// les rôles permis pour un écran protégé (null : tous les rôles connectés)
val rolesAdmin = setOf(Role.ADMIN)
val rolesPatient = setOf(Role.USER)

// la page d'accueil de chaque rôle, juste après la connexion
fun accueil(u: Utilisateur): Any = if (u.role == Role.USER) Ecrans.MonEspace else Ecrans.Agenda()

Les routes de l'application, rangées dans un objet [Ecrans]. TOUT l'état d'un écran est dans sa route : le bouton Retour du téléphone le restitue tel quel. La fenêtre de réservation est elle aussi une route.

11.10.2. navigation/Navigation.kt

// =========================================================================
// navigation/Navigation.kt - les écrans de l'application et leurs droits
// -------------------------------------------------------------------------
// Le NavHost (cf. exemple 17) affiche l'écran de la route courante, dans le
// Gabarit (barre du haut, menu, messages).
//
// Les GARDES (cf. exemple 18) :
//   - un écran protégé est enveloppé par Protege(route, roles) : il ne
//     s'affiche que pour un utilisateur connecté, du bon rôle (sinon,
//     l'écran d'erreur 403) ;
//   - un effet observe la SESSION : un utilisateur qui disparaît (déconnexion,
//     session expirée) renvoie à l'écran de connexion ; un utilisateur qui
//     apparaît (connexion) quitte l'écran de connexion pour l'écran demandé
//     avant l'expiration, sinon pour la page d'accueil de son rôle.
// RAPPEL : ces contrôles ne sont qu'un CONFORT (ne pas afficher un écran
// inutile). La protection des données est assurée par le serveur, qui
// revérifie le jeton et le rôle à CHAQUE requête.
//
// Les messages "flash" : à chaque nouvel écran, Messages.nouvellePage().
// =========================================================================
package fr.stahe.rdvmedecins.navigation

import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.navigation.NavDestination
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.dialog
import androidx.navigation.compose.rememberNavController
import androidx.navigation.toRoute
import fr.stahe.rdvmedecins.LocalDependances
import fr.stahe.rdvmedecins.api.Role
import fr.stahe.rdvmedecins.pages.PageAgenda
import fr.stahe.rdvmedecins.pages.PageConnexion
import fr.stahe.rdvmedecins.pages.PageDemarrage
import fr.stahe.rdvmedecins.pages.PageErreur
import fr.stahe.rdvmedecins.pages.PageEspacePatient
import fr.stahe.rdvmedecins.pages.PageInscription
import fr.stahe.rdvmedecins.pages.PageMonCompte
import fr.stahe.rdvmedecins.pages.PagePersonneFormulaire
import fr.stahe.rdvmedecins.pages.PagePersonnes
import fr.stahe.rdvmedecins.widgets.FenetreReservation
import fr.stahe.rdvmedecins.widgets.Gabarit
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map

// les écrans publics (sans connexion)
fun estPublic(d: NavDestination) =
    d.hasRoute<Ecrans.Login>() || d.hasRoute<Ecrans.Inscription>() || d.hasRoute<Ecrans.Demarrage>()

// aller à un écran en VIDANT la pile : Retour ne ramènera pas à l'écran précédent
fun NavHostController.allerA(route: Any) {
    navigate(route) {
        popUpTo(graph.id) { inclusive = true }
        launchSingleTop = true
    }
}

@Composable
fun Navigation() {
    val nav = rememberNavController()
    val d = LocalDependances.current
    val session = d.session
    val utilisateur = session.utilisateur

    // les messages flash : à chaque nouvel écran (une nouvelle entrée de la pile)
    LaunchedEffect(nav) {
        nav.currentBackStackEntryFlow.map { it.id }.distinctUntilChanged().collect { d.messages.nouvellePage() }
    }

    // la session change : relancé à chaque changement d'utilisateur
    LaunchedEffect(utilisateur, session.verifiee) {
        if (!session.verifiee) return@LaunchedEffect
        val ici = nav.currentBackStackEntry?.destination ?: return@LaunchedEffect
        if (utilisateur == null && !estPublic(ici)) {
            // session expirée : on retient l'écran courant, pour y revenir
            session.ecranDemande = if (session.departVolontaire) null else session.ecranCourant
            nav.allerA(Ecrans.Login())
        } else if (utilisateur != null && (ici.hasRoute<Ecrans.Login>() || ici.hasRoute<Ecrans.Demarrage>())) {
            // connecté : l'écran de connexion n'a plus de sens
            val cible = session.ecranDemande ?: accueil(utilisateur)
            session.ecranDemande = null
            nav.allerA(cible)
        }
    }

    Gabarit(nav) {
        NavHost(navController = nav, startDestination = Ecrans.Demarrage) {
            composable<Ecrans.Demarrage> { PageDemarrage(onPersonne = { nav.allerA(Ecrans.Login()) }) }
            composable<Ecrans.Login> { e ->
                PageConnexion(login = e.toRoute<Ecrans.Login>().login.take(30), onInscription = { nav.navigate(Ecrans.Inscription) })
            }
            composable<Ecrans.Agenda> { e ->
                val route = e.toRoute<Ecrans.Agenda>()
                Protege(route) {
                    PageAgenda(
                        idMedecin = route.idMedecin,
                        jour = route.jour,
                        // voir un autre agenda : la route courante est REMPLACÉE
                        onVoir = { id, jour ->
                            nav.navigate(Ecrans.Agenda(id, jour)) { popUpTo<Ecrans.Agenda> { inclusive = true } }
                        },
                        // réserver : la fenêtre est EMPILÉE au-dessus de l'agenda
                        onReserver = { id, jour, idCreneau -> nav.navigate(Ecrans.Reservation(id, jour, idCreneau)) },
                        onMonEspace = { nav.allerA(Ecrans.MonEspace) },
                    )
                }
            }
            dialog<Ecrans.Reservation> { e ->
                val route = e.toRoute<Ecrans.Reservation>()
                Protege(route) { FenetreReservation(route, onFermer = { nav.popBackStack() }) }
            }
            composable<Ecrans.Personnes> { e ->
                val route = e.toRoute<Ecrans.Personnes>()
                Protege(route, rolesAdmin) {
                    PagePersonnes(
                        type = route.type,
                        onAjouter = { nav.navigate(Ecrans.Personne(route.type)) },
                        onModifier = { id -> nav.navigate(Ecrans.Personne(route.type, id)) },
                    )
                }
            }
            composable<Ecrans.Personne> { e ->
                val route = e.toRoute<Ecrans.Personne>()
                Protege(route, rolesAdmin) {
                    // retour à la liste : on dépile le formulaire
                    PagePersonneFormulaire(type = route.type, id = route.id, onListe = {
                        if (!nav.popBackStack()) nav.allerA(Ecrans.Personnes(route.type))
                    })
                }
            }
            composable<Ecrans.Inscription> {
                PageInscription(onConnexion = { login -> nav.allerA(Ecrans.Login(login)) })
            }
            composable<Ecrans.MonEspace> {
                Protege(Ecrans.MonEspace, rolesPatient) {
                    PageEspacePatient(
                        onMonCompte = { nav.navigate(Ecrans.MonCompte) },
                        onAgenda = { id, jour -> nav.navigate(Ecrans.Agenda(id, jour)) },
                    )
                }
            }
            composable<Ecrans.MonCompte> {
                Protege(Ecrans.MonCompte, rolesPatient) {
                    PageMonCompte(onMonEspace = { nav.allerA(Ecrans.MonEspace) })
                }
            }
        }
    }
}

// un écran protégé : connecté, et, si roles n'est pas null, avec l'un de ces rôles
@Composable
fun Protege(route: Any, roles: Set<Role>? = null, contenu: @Composable () -> Unit) {
    val session = LocalDependances.current.session
    val u = session.utilisateur ?: return // pas connecté : la navigation renvoie à la connexion
    // l'écran affiché, retenu pour y revenir après une session expirée
    LaunchedEffect(route) { session.ecranCourant = route }
    if (roles != null && u.role !in roles) {
        PageErreur(code = 403)
    } else {
        contenu()
    }
}
  • lignes 54-57 : aller à un écran en VIDANT la pile : Retour ne ramènera pas à l'écran précédent (on ne revient pas à l'écran de connexion après s'être connecté) ;
  • lignes 69-70 : les messages flash : [currentBackStackEntryFlow], le [Flow] des écrans successifs ;
  • lignes 74-85 : LE garde de l'application : un effet relancé à chaque changement d'utilisateur. Plus personne sur un écran protégé : à la connexion (en retenant l'écran courant si la session a expiré) ; quelqu'un sur l'écran de connexion : à l'écran demandé, sinon à l'accueil de son rôle ;
  • lignes 89-113 : le graphe de navigation, dans le gabarit ; [dialog<Ecrans.Reservation>] : une destination affichée PAR-DESSUS l'écran précédent, que le bouton Retour ferme ;
  • lignes 156-164 : [Protege] : pas connecté, rien (l'effet ci-dessus renvoie à la connexion) ; pas le bon rôle, l'écran d'erreur 403.

11.11. Les composants réutilisables

11.11.1. widgets/Gabarit.kt

// =========================================================================
// widgets/Gabarit.kt - le GABARIT commun à tous les écrans
// -------------------------------------------------------------------------
// Il enveloppe le NavHost (cf. navigation/Navigation.kt) :
//   - la barre du haut : titre, langue (FR / EN), déconnexion ;
//   - le menu latéral ("tiroir", ModalNavigationDrawer), ouvert par le
//     bouton ☰ : les écrans permis au rôle de l'utilisateur. "Médecins" et
//     "Clients" n'apparaissent que pour l'ADMIN : un simple confort
//     d'affichage, la vraie protection étant celle du serveur ;
//   - un message de succès en HAUT ;
//   - l'écran courant (contenu) ;
//   - la zone d'erreur en BAS.
// Sur un grand écran (tablette, téléphone à l'horizontale), le menu est
// affiché en permanence à gauche : un NavigationRail à la place du tiroir.
// =========================================================================
@file:OptIn(ExperimentalMaterial3Api::class)

package fr.stahe.rdvmedecins.widgets

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ExitToApp
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.DateRange
import androidx.compose.material.icons.filled.Face
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.NavigationRail
import androidx.compose.material3.NavigationRailItem
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.material3.TopAppBarDefaults
import androidx.compose.material3.VerticalDivider
import androidx.compose.material3.rememberDrawerState
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.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.navigation.NavDestination
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavHostController
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.toRoute
import fr.stahe.rdvmedecins.LocalDependances
import fr.stahe.rdvmedecins.api.TypePersonnes
import fr.stahe.rdvmedecins.api.Utilisateur
import fr.stahe.rdvmedecins.etat.estAdmin
import fr.stahe.rdvmedecins.etat.estPatient
import fr.stahe.rdvmedecins.i18n.LocalTraductions
import fr.stahe.rdvmedecins.i18n.changerLangue
import fr.stahe.rdvmedecins.i18n.langues
import fr.stahe.rdvmedecins.i18n.t
import fr.stahe.rdvmedecins.navigation.Ecrans
import fr.stahe.rdvmedecins.navigation.allerA
import kotlinx.coroutines.launch

// une entrée du menu : la route où elle mène, la clé de son libellé, son
// icône, et le test qui dit si elle est active pour un écran donné
class EntreeMenu(val route: Any, val cle: String, val icone: ImageVector, val active: (NavDestination, Any?) -> Boolean)

fun entreesMenu(u: Utilisateur?): List<EntreeMenu> {
    if (u == null) return emptyList()
    // buildList : on remplit une liste, avec des if
    return buildList {
        if (u.estPatient) add(EntreeMenu(Ecrans.MonEspace, "APP.MENU_MY_SPACE", Icons.Default.Home) { d, _ -> d.hasRoute<Ecrans.MonEspace>() })
        add(EntreeMenu(Ecrans.Agenda(), "APP.MENU_AGENDA", Icons.Default.DateRange) { d, _ -> d.hasRoute<Ecrans.Agenda>() })
        if (u.estAdmin) {
            add(EntreeMenu(Ecrans.Personnes(TypePersonnes.MEDECINS), "APP.MENU_DOCTORS", Icons.Default.Face) { _, type -> type == TypePersonnes.MEDECINS })
            add(EntreeMenu(Ecrans.Personnes(TypePersonnes.CLIENTS), "APP.MENU_PATIENTS", Icons.Default.Person) { _, type -> type == TypePersonnes.CLIENTS })
        }
        if (u.estPatient) add(EntreeMenu(Ecrans.MonCompte, "APP.MENU_ACCOUNT", Icons.Default.AccountCircle) { d, _ -> d.hasRoute<Ecrans.MonCompte>() })
    }
}

@Composable
fun Gabarit(nav: NavHostController, contenu: @Composable () -> Unit) {
    val d = LocalDependances.current
    val u = d.session.utilisateur
    val langue = LocalTraductions.current.langue
    val portee = rememberCoroutineScope()
    val entrees = entreesMenu(u)
    // l'écran courant, et le type de personnes s'il s'agit d'une liste ou d'un formulaire
    val entree by nav.currentBackStackEntryAsState()
    val destination = entree?.destination
    val type: TypePersonnes? = when {
        destination?.hasRoute<Ecrans.Personnes>() == true -> entree?.toRoute<Ecrans.Personnes>()?.type
        destination?.hasRoute<Ecrans.Personne>() == true -> entree?.toRoute<Ecrans.Personne>()?.type
        else -> null
    }
    fun estActive(e: EntreeMenu) = destination != null && e.active(destination, type)
    // un grand écran : au moins 800 dp de large
    val grandEcran = LocalConfiguration.current.screenWidthDp >= 800
    val tiroir = rememberDrawerState(DrawerValue.Closed)
    var menuLangue by remember { mutableStateOf(false) }

    val page = @Composable {
        Scaffold(
            topBar = {
                TopAppBar(
                    title = { Text(t("APP.TITLE")) },
                    navigationIcon = {
                        if (u != null && !grandEcran) {
                            IconButton(onClick = { portee.launch { tiroir.open() } }) { Icon(Icons.Default.Menu, contentDescription = "menu") }
                        }
                    },
                    colors = TopAppBarDefaults.topAppBarColors(
                        containerColor = MaterialTheme.colorScheme.primary,
                        titleContentColor = MaterialTheme.colorScheme.onPrimary,
                        navigationIconContentColor = MaterialTheme.colorScheme.onPrimary,
                        actionIconContentColor = MaterialTheme.colorScheme.onPrimary,
                    ),
                    actions = {
                        // FR / EN : un menu ; l'activité est recréée dans la nouvelle langue
                        TextButton(onClick = { menuLangue = true }) {
                            Text(langue.uppercase(), color = MaterialTheme.colorScheme.onPrimary, fontWeight = FontWeight.Bold)
                        }
                        DropdownMenu(expanded = menuLangue, onDismissRequest = { menuLangue = false }) {
                            for (l in langues) {
                                DropdownMenuItem(text = { Text(l.uppercase()) }, onClick = {
                                    menuLangue = false
                                    changerLangue(l)
                                })
                            }
                        }
                        if (u != null) {
                            // la session se vide ; la navigation, qui l'observe, affiche l'écran de connexion
                            IconButton(onClick = { portee.launch { runCatching { d.session.deconnecter() } } }) {
                                Icon(Icons.AutoMirrored.Filled.ExitToApp, contentDescription = t("APP.LOGOUT"))
                            }
                        }
                    },
                )
            },
        ) { marges ->
            Row(Modifier.padding(marges).consumeWindowInsets(marges).fillMaxSize()) {
                // sur un grand écran : le menu, en permanence
                if (u != null && grandEcran) {
                    NavigationRail {
                        for (e in entrees) {
                            NavigationRailItem(
                                selected = estActive(e),
                                onClick = { nav.allerA(e.route) },
                                icon = { Icon(e.icone, contentDescription = null) },
                                label = { Text(t(e.cle)) },
                            )
                        }
                    }
                    VerticalDivider()
                }
                Column(Modifier.weight(1f).fillMaxSize().imePadding()) {
                    MessageSucces()
                    Column(Modifier.weight(1f)) { contenu() }
                    ZoneErreur()
                }
            }
        }
    }

    if (u == null || grandEcran) {
        page()
    } else {
        // le tiroir : seulement connecté, et seulement sur un petit écran
        ModalNavigationDrawer(
            drawerState = tiroir,
            drawerContent = {
                ModalDrawerSheet {
                    // l'en-tête : qui est connecté
                    Column(Modifier.padding(start = 28.dp, top = 16.dp, end = 16.dp, bottom = 10.dp)) {
                        Text(u.nom, style = MaterialTheme.typography.titleMedium)
                        SuggestionChip(onClick = {}, label = { Text(u.role.name) })
                    }
                    for (e in entrees) {
                        NavigationDrawerItem(
                            label = { Text(t(e.cle)) },
                            icon = { Icon(e.icone, contentDescription = null) },
                            selected = estActive(e),
                            onClick = {
                                portee.launch { tiroir.close() }
                                nav.allerA(e.route)
                            },
                            modifier = Modifier.padding(horizontal = 12.dp),
                        )
                    }
                }
            },
        ) { page() }
    }
}

// le message de succès, en haut
@Composable
private fun MessageSucces() {
    val messages = LocalDependances.current.messages
    val succes = messages.succes ?: return
    ListItem(
        colors = ListItemDefaults.colors(containerColor = Color(0xFFE8F5E9)),
        leadingContent = { Icon(Icons.Default.CheckCircle, contentDescription = null, tint = Color(0xFF2E7D32)) },
        headlineContent = { Text(t(succes.cle, succes.params), color = Color(0xFF1B5E20)) },
        trailingContent = { IconButton(onClick = { messages.effacer() }) { Icon(Icons.Default.Close, contentDescription = null) } },
    )
}

// la zone d'erreur, en bas : toujours visible, même si l'écran défile
@Composable
private fun ZoneErreur() {
    val messages = LocalDependances.current.messages
    val erreur = messages.erreur ?: return
    ListItem(
        colors = ListItemDefaults.colors(containerColor = Color(0xFFF8D7DA)),
        leadingContent = { Icon(Icons.Default.Warning, contentDescription = null, tint = Color(0xFFC62828)) },
        headlineContent = { Text(t(erreur.cle, erreur.params), color = Color(0xFFB71C1C)) },
        trailingContent = { IconButton(onClick = { messages.effacer() }) { Icon(Icons.Default.Close, contentDescription = null) } },
    )
}
  • lignes 95-97 : les entrées du menu, selon le rôle ([buildList] : on remplit une liste, avec des [if]) ;
  • lignes 13-174 : un grand écran, au moins 800 dp de large : le menu y est un [NavigationRail], affiché en permanence ; sur un téléphone en hauteur, un tiroir ([ModalNavigationDrawer]) ouvert par le bouton ☰ ;
  • lignes 228-241 : les messages : le succès en haut, l'erreur en bas.

11.11.2. widgets/ChampSaisie.kt

// =========================================================================
// widgets/ChampSaisie.kt - un champ de formulaire complet
// -------------------------------------------------------------------------
// Libellé + champ + message d'aide OU message d'erreur. Tous les
// formulaires de l'application l'utilisent :
//
//   ChampSaisie(libelle = "PERSONNES.LASTNAME_LABEL", longueurMax = 20,
//               valeur = saisie["nom"], onChange = { modifier("nom", it) },
//               erreur = envoi.erreurs["nom"])
//
// C'est un composant CONTRÔLÉ (cf. exemple 11) : la valeur appartient au
// formulaire parent. libelle, aide et erreur sont des CLÉS de traduction ;
// params, les paramètres de leurs messages ({{min}}...).
// =========================================================================
package fr.stahe.rdvmedecins.widgets

import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.ContentType
import androidx.compose.ui.semantics.contentType
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import fr.stahe.rdvmedecins.i18n.t

@Composable
fun ChampSaisie(
    libelle: String,
    valeur: String,
    onChange: (String) -> Unit,
    erreur: String? = null,
    aide: String? = null,
    params: Map<String, String> = emptyMap(),
    secret: Boolean = false, // un mot de passe : caractères masqués
    longueurMax: Int? = null,
    // pour le gestionnaire de mots de passe du téléphone (identifiant, mot de passe...)
    typeContenu: ContentType? = null,
) {
    val messageErreur = erreur?.let { t(it, params) }
    val messageAide = aide?.let { t(it, params) }
    OutlinedTextField(
        value = valeur,
        // le champ refuse les caractères de trop
        onValueChange = { v -> if (longueurMax == null || v.length <= longueurMax) onChange(v) },
        label = { Text(t(libelle)) },
        singleLine = true,
        isError = messageErreur != null,
        // l'erreur, sinon l'aide
        supportingText = (messageErreur ?: messageAide)?.let { m -> { Text(m) } },
        visualTransformation = if (secret) PasswordVisualTransformation() else VisualTransformation.None,
        keyboardOptions = KeyboardOptions(keyboardType = if (secret) KeyboardType.Password else KeyboardType.Text),
        modifier = Modifier
            .fillMaxWidth()
            .padding(bottom = 4.dp)
            .semantics { if (typeContenu != null) contentType = typeContenu },
    )
}

11.11.3. widgets/ChampsPersonne.kt

// =========================================================================
// widgets/ChampsPersonne.kt - titre, prénom, nom
// -------------------------------------------------------------------------
// Les champs d'identité, communs à trois formulaires : médecin / client de
// l'ADMIN, "Créer un compte client" et "Gérer mon compte". Le parent lui
// passe les valeurs, les erreurs, et la fonction qui modifie un champ :
//     ChampsPersonne(valeurs = saisie, erreurs = envoi.erreurs, onChange = ::modifier)
// =========================================================================
package fr.stahe.rdvmedecins.widgets

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import fr.stahe.rdvmedecins.i18n.t

val titres = listOf("Mr", "Mme", "Melle")

@Composable
fun ChampsPersonne(valeurs: Map<String, Any?>, erreurs: Map<String, String>, onChange: (String, String) -> Unit) {
    Column {
        ListeDeroulante(
            libelle = t("PERSONNES.TITLE_LABEL"),
            valeur = valeurs["titre"] as String,
            choix = titres,
            onChange = { onChange("titre", it) },
            erreur = erreurs["titre"]?.let { t(it) },
            modifier = Modifier.width(180.dp),
        )
        ChampSaisie(
            libelle = "PERSONNES.FIRSTNAME_LABEL",
            longueurMax = 20,
            valeur = valeurs["prenom"] as String,
            onChange = { onChange("prenom", it) },
            erreur = erreurs["prenom"],
        )
        ChampSaisie(
            libelle = "PERSONNES.LASTNAME_LABEL",
            longueurMax = 20,
            valeur = valeurs["nom"] as String,
            onChange = { onChange("nom", it) },
            erreur = erreurs["nom"],
        )
    }
}

11.11.4. widgets/EnvoiFormulaire.kt

// =========================================================================
// widgets/EnvoiFormulaire.kt - envoyer un formulaire au serveur
// -------------------------------------------------------------------------
// Tous les formulaires de l'application suivent le même schéma que
// l'exemple 25 : aucune règle n'est vérifiée sur le téléphone (elles vivent
// en UN seul endroit, le serveur), et en cas de refus :
//   - l'erreur de chaque champ (ErreurApi.champs) est affichée sous le champ ;
//   - le message général (ErreurApi.cle) s'affiche en bas de l'écran.
// Cette classe regroupe ces gestes (c'est la Commande de l'exemple 23,
// complétée des erreurs des champs). Un écran en crée une par formulaire :
//
//     val envoi = remember { EnvoiFormulaire(messages) }
//     if (envoi.envoyer { api.inscrire(saisie) }) { ... }
//     ChampSaisie(..., erreur = envoi.erreurs["nom"])
// =========================================================================
package fr.stahe.rdvmedecins.widgets

import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import fr.stahe.rdvmedecins.api.ErreurApi
import fr.stahe.rdvmedecins.etat.Messages

class EnvoiFormulaire(private val messages: Messages) {
    // l'erreur de chaque champ : une CLÉ de traduction
    var erreurs by mutableStateOf(mapOf<String, String>())
        private set

    // les paramètres des messages d'erreur ({{min}}...), renvoyés par le serveur
    var params by mutableStateOf(mapOf<String, String>())
        private set
    var enCours by mutableStateOf(false)
        private set

    // exécute l'envoi ; renvoie true s'il a réussi. En cas d'ErreurApi, les
    // erreurs sont affichées et on renvoie false
    suspend fun envoyer(action: suspend () -> Unit): Boolean {
        if (enCours) return false // pas de double envoi
        erreurs = emptyMap()
        params = emptyMap()
        enCours = true
        try {
            action()
            return true
        } catch (e: ErreurApi) {
            messages.afficherErreurApi(e)
            erreurs = e.champs
            params = e.params
            return false
        } finally {
            enCours = false
        }
    }

    // l'utilisateur modifie un champ : son erreur disparaît
    fun effacerErreur(champ: String) {
        if (champ in erreurs) erreurs = erreurs - champ
    }
}

La [Commande] de l'exemple 23, complétée des erreurs des champs : tous les formulaires de l'application l'utilisent.

11.11.5. widgets/Confirmation.kt

// =========================================================================
// widgets/Confirmation.kt - « Êtes-vous sûr ? »
// -------------------------------------------------------------------------
// Repris de l'exemple 16. Le message est une CLÉ de traduction :
//     if (confirmation.confirmer("AGENDA.CANCEL_CONFIRM")) { ... }
// "Non, revenir" plutôt qu'"Annuler" : la question est parfois « Annuler ce
// rendez-vous ? », et un bouton "Annuler" y serait ambigu.
// Une nouvelle question efface les messages de l'action précédente.
// =========================================================================
package fr.stahe.rdvmedecins.widgets

import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.graphics.Color
import fr.stahe.rdvmedecins.etat.Messages
import fr.stahe.rdvmedecins.i18n.t
import kotlinx.coroutines.CompletableDeferred

class Confirmation(private val messages: Messages) {
    var question by mutableStateOf<String?>(null)
        private set
    private var reponse: CompletableDeferred<Boolean>? = null

    suspend fun confirmer(cle: String): Boolean {
        messages.effacer()
        val attente = CompletableDeferred<Boolean>()
        reponse = attente
        question = cle
        try {
            return attente.await()
        } finally {
            question = null
        }
    }

    fun repondre(oui: Boolean) {
        reponse?.complete(oui)
    }
}

@Composable
fun rememberConfirmation(messages: Messages): Confirmation = remember { Confirmation(messages) }

@Composable
fun FenetreConfirmation(confirmation: Confirmation) {
    val cle = confirmation.question ?: return
    AlertDialog(
        onDismissRequest = { confirmation.repondre(false) },
        title = { Text(t("CONFIRMATION.TITLE")) },
        text = { Text(t(cle)) },
        dismissButton = { TextButton(onClick = { confirmation.repondre(false) }) { Text(t("CONFIRMATION.NO")) } },
        confirmButton = {
            Button(
                onClick = { confirmation.repondre(true) },
                colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFC62828)),
            ) { Text(t("CONFIRMATION.YES")) }
        },
    )
}

11.11.6. widgets/ChampCaptcha.kt

// =========================================================================
// widgets/ChampCaptcha.kt - l'image du captcha et le champ de saisie
// -------------------------------------------------------------------------
// Un composant d'affichage : l'image (svg), le bouton "Autre image"
// (onRecharger), et le champ, CONTRÔLÉ par le formulaire parent (valeur,
// onChange). L'image et son identifiant sont demandés au serveur par
// l'écran de connexion, qui en a besoin pour envoyer la réponse.
// =========================================================================
package fr.stahe.rdvmedecins.widgets

import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import fr.stahe.rdvmedecins.i18n.t

@Composable
fun ChampCaptcha(svg: String?, valeur: String, onChange: (String) -> Unit, onRecharger: () -> Unit, erreur: String? = null) {
    Column {
        Text(t("CAPTCHA.LABEL"), style = MaterialTheme.typography.bodySmall)
        Row(verticalAlignment = Alignment.CenterVertically) {
            Box(
                contentAlignment = Alignment.Center,
                modifier = Modifier
                    .size(160.dp, 54.dp)
                    .background(Color(0xFFF5F7FA), RoundedCornerShape(6.dp))
                    .border(1.dp, Color(0xFFE0E0E0), RoundedCornerShape(6.dp)),
            ) {
                // description : ce que lit un lecteur d'écran à la place de l'image
                if (svg != null) ImageSvg(svg, hauteur = 50.dp, description = t("CAPTCHA.IMAGE_ALT"))
            }
            TextButton(onClick = onRecharger) {
                Icon(Icons.Default.Refresh, contentDescription = null)
                Text(t("CAPTCHA.NEW_IMAGE"))
            }
        }
        ChampSaisie(libelle = "CAPTCHA.LABEL", aide = "CAPTCHA.HELP", longueurMax = 10, valeur = valeur, onChange = onChange, erreur = erreur)
    }
}

11.11.7. widgets/ImageSvg.kt

// =========================================================================
// widgets/ImageSvg.kt (repris des exemples, 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.rdvmedecins.widgets

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))
}

11.11.8. widgets/ListeDeroulante.kt

// =========================================================================
// widgets/ListeDeroulante.kt (repris des exemples, commun/ListeDeroulante.kt) - une liste déroulante, prête à l'emploi
// -------------------------------------------------------------------------
// Material 3 pour Compose n'a pas de composant "liste déroulante" d'un
// seul tenant : on l'assemble avec trois éléments - ExposedDropdownMenuBox,
// un champ de texte en lecture seule, et le menu ExposedDropdownMenu. On
// le fait ici une fois pour toutes (exemples 05 à 08, 24, 25) :
//
//     ListeDeroulante("Pays", valeur = pays, choix = listOf("fr", "be", "ch"),
//                     texte = { nomsPays[it] }, onChange = { pays = it })
//
// La fonction est GÉNÉRIQUE (<T>) : les choix peuvent être des chaînes,
// des nombres, des médecins... texte dit comment AFFICHER un choix. Un choix
// peut être null ("-- choisir --") si T est un type "nullable" (Int?).
// =========================================================================
@file:OptIn(ExperimentalMaterial3Api::class)

package fr.stahe.rdvmedecins.widgets

import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
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

@Composable
fun <T> ListeDeroulante(
    libelle: String,
    valeur: T,
    choix: List<T>,
    onChange: (T) -> Unit,
    modifier: Modifier = Modifier,
    texte: (T) -> String = { it.toString() },
    // un message d'erreur sous le champ (cf. exemples 07 et 25)
    erreur: String? = null,
) {
    // le menu est-il ouvert ? un état PROPRE à chaque liste déroulante
    var ouvert by remember { mutableStateOf(false) }
    ExposedDropdownMenuBox(expanded = ouvert, onExpandedChange = { ouvert = it }, modifier = modifier) {
        // le champ qui affiche le choix courant ; menuAnchor en fait le point
        // d'ancrage du menu (le toucher ouvre le menu)
        OutlinedTextField(
            value = texte(valeur),
            onValueChange = {},
            readOnly = true,
            singleLine = true,
            label = { Text(libelle) },
            trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = ouvert) },
            isError = erreur != null,
            supportingText = if (erreur == null) null else { { Text(erreur) } },
            modifier = Modifier.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable).fillMaxWidth(),
        )
        // le menu : une ligne par choix
        ExposedDropdownMenu(expanded = ouvert, onDismissRequest = { ouvert = false }) {
            for (c in choix) {
                DropdownMenuItem(
                    text = { Text(texte(c)) },
                    onClick = {
                        onChange(c)
                        ouvert = false
                    },
                )
            }
        }
    }
}

11.11.9. widgets/Etiquette.kt

// widgets/Etiquette.kt - une étiquette colorée (le "badge" des autres clients)
package fr.stahe.rdvmedecins.widgets

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp

@Composable
fun Etiquette(texte: String, couleur: Color) {
    Text(
        texte,
        color = Color.White,
        fontSize = 12.sp,
        fontWeight = FontWeight.Bold,
        modifier = Modifier.background(couleur, RoundedCornerShape(6.dp)).padding(horizontal = 8.dp, vertical = 3.dp),
    )
}

11.11.10. widgets/FenetreReservation.kt

// =========================================================================
// widgets/FenetreReservation.kt - la fenêtre de réservation d'un créneau
// -------------------------------------------------------------------------
// Une destination de type "dialog" de la navigation (Ecrans.Reservation) :
// elle s'affiche PAR-DESSUS l'agenda, fond grisé compris, et le bouton
// Retour du téléphone la ferme. Sa route contient tout ce qu'il lui faut :
// le médecin, le jour, le créneau.
// Elle relit l'agenda dans le CACHE (aucune requête : l'agenda vient d'être
// lu) pour y trouver le créneau et vérifier qu'il est réservable.
// Après la réservation : un message flash, la fermeture, et l'invalidation
// du cache ; l'agenda, resté vivant sous la fenêtre, se relit tout seul.
// =========================================================================
package fr.stahe.rdvmedecins.widgets

import androidx.compose.foundation.layout.Arrangement
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.MaterialTheme
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.Modifier
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import fr.stahe.rdvmedecins.LocalDependances
import fr.stahe.rdvmedecins.api.CreneauAgenda
import fr.stahe.rdvmedecins.api.ErreurApi
import fr.stahe.rdvmedecins.api.Personne
import fr.stahe.rdvmedecins.api.TypePersonnes
import fr.stahe.rdvmedecins.etat.estGestionnaire
import fr.stahe.rdvmedecins.etat.estPatient
import fr.stahe.rdvmedecins.horaire
import fr.stahe.rdvmedecins.i18n.t
import fr.stahe.rdvmedecins.navigation.Ecrans
import fr.stahe.rdvmedecins.pages.cleAgenda
import kotlinx.coroutines.launch

@Composable
fun FenetreReservation(route: Ecrans.Reservation, onFermer: () -> Unit) {
    val d = LocalDependances.current
    val u = d.session.utilisateur
    var creneau by remember { mutableStateOf<CreneauAgenda?>(null) }
    // ADMIN / DOCTOR : les patients parmi lesquels choisir
    var clients by remember { mutableStateOf(listOf<Personne>()) }
    var idClient by remember { mutableStateOf<Int?>(null) }
    var envoiEnCours by remember { mutableStateOf(false) }
    val portee = rememberCoroutineScope()

    LaunchedEffect(Unit) {
        try {
            val agenda = d.cache.lire(cleAgenda(route.idMedecin, route.jour)) { d.api.agenda(route.idMedecin, route.jour) }
            // seulement si ce créneau est réservable par cet utilisateur
            creneau = agenda.creneaux.find { it.id == route.idCreneau && it.reservable }
            if (creneau == null) onFermer()
            if (u.estGestionnaire) clients = d.cache.lire("clients") { d.api.lister(TypePersonnes.CLIENTS) }
        } catch (e: ErreurApi) {
            d.messages.afficherErreurApi(e)
        }
    }

    fun confirmer() {
        val c = creneau ?: return
        portee.launch {
            envoiEnCours = true
            try {
                d.api.reserver(route.jour, c.id, if (u.estGestionnaire) idClient else null)
                d.messages.flash("AGENDA.BOOKED")
            } catch (e: ErreurApi) {
                if (e.cle == "ERRORS.CHOOSE_PATIENT") {
                    // aucun patient choisi : la fenêtre reste ouverte
                    d.messages.afficherErreur(e.cle)
                    envoiEnCours = false
                    return@launch
                }
                // créneau pris entre-temps, patient qui a déjà un rendez-vous... :
                // le message s'affichera sur l'agenda, fenêtre fermée
                d.messages.flash(e.cle, e.params, succes = false)
            }
            envoiEnCours = false
            onFermer()
            // c'est le serveur qui fait foi : l'agenda (et l'espace du patient) seront relus
            d.cache.invalider("mon-espace")
            d.cache.invalider("agenda")
        }
    }

    val c = creneau ?: return
    Card(Modifier.fillMaxWidth()) {
        Column(Modifier.padding(20.dp)) {
            Text(t("BOOKING_DIALOG.TITLE"), style = MaterialTheme.typography.titleLarge)
            Spacer(Modifier.height(12.dp))
            Text(t("BOOKING_DIALOG.SLOT", mapOf("horaire" to horaire(c))))
            Spacer(Modifier.height(12.dp))
            if (u.estPatient) {
                // un patient réserve pour lui-même : pas de liste (et le serveur
                // ignorerait de toute façon un éventuel idClient)
                Text(
                    buildAnnotatedString {
                        append("${t("BOOKING_DIALOG.PATIENT_LABEL")} : ")
                        withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(u?.nom ?: "") }
                    },
                )
            } else {
                val invite = t("BOOKING_DIALOG.CHOOSE_PATIENT")
                ListeDeroulante(
                    libelle = t("BOOKING_DIALOG.PATIENT_LABEL"),
                    valeur = idClient,
                    choix = listOf<Int?>(null) + clients.map { it.id },
                    texte = { i -> clients.find { it.id == i }?.libelle ?: invite },
                    onChange = { idClient = it },
                    modifier = Modifier.fillMaxWidth(),
                )
            }
            // une erreur (aucun patient choisi) : la zone d'erreur de l'écran est
            // cachée sous la fenêtre, on la répète ici
            d.messages.erreur?.let { m ->
                Text(t(m.cle, m.params), color = androidx.compose.ui.graphics.Color(0xFFC62828), modifier = Modifier.padding(top = 8.dp))
            }
            Spacer(Modifier.height(20.dp))
            Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp, androidx.compose.ui.Alignment.End)) {
                TextButton(onClick = onFermer) { Text(t("BOOKING_DIALOG.CANCEL")) }
                Button(onClick = { confirmer() }, enabled = !envoiEnCours) { Text(t("BOOKING_DIALOG.CONFIRM")) }
            }
        }
    }
}
  • lignes 64-70 : l'agenda, relu dans le CACHE (aucune requête : il vient d'être lu), pour y trouver le créneau ; les patients, pour un gestionnaire ;
  • lignes 76-98 : la réservation : un message flash, la fermeture, et l'invalidation du cache ; l'agenda, resté vivant sous la fenêtre, se relit tout seul.

11.12. Les écrans

11.12.1. pages/PageDemarrage.kt

// =========================================================================
// pages/PageDemarrage.kt - le premier écran
// -------------------------------------------------------------------------
// Le temps de demander au serveur, une fois, qui est connecté (GET
// /api/auth/moi : le cookie du jeton a pu être gardé d'une utilisation
// précédente). Connecté : la navigation (qui observe la session) mène à
// la page d'accueil du rôle ; personne : onPersonne, l'écran de connexion.
// =========================================================================
package fr.stahe.rdvmedecins.pages

import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import fr.stahe.rdvmedecins.LocalDependances

@Composable
fun PageDemarrage(onPersonne: () -> Unit) {
    val session = LocalDependances.current.session
    LaunchedEffect(Unit) {
        session.verifier()
        if (session.utilisateur == null) onPersonne()
    }
    Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
}

11.12.2. pages/PageErreur.kt

// =========================================================================
// pages/PageErreur.kt - l'écran d'erreur : 403 (rôle insuffisant, cf.
// navigation/Navigation.kt) ou 404
// =========================================================================
package fr.stahe.rdvmedecins.pages

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import fr.stahe.rdvmedecins.i18n.t

@Composable
fun PageErreur(code: Int) {
    Column(Modifier.padding(24.dp)) {
        Text("${t("ERROR_PAGE.TITLE")} $code", style = MaterialTheme.typography.headlineMedium, color = Color(0xFFC62828))
        Spacer(Modifier.height(8.dp))
        Text(t(if (code == 403) "ERRORS.FORBIDDEN" else "ERRORS.NOT_FOUND"))
    }
}

11.12.3. pages/PageConnexion.kt

// =========================================================================
// pages/PageConnexion.kt - l'écran de connexion
// -------------------------------------------------------------------------
// Même écran que dans les autres clients : login, mot de passe, captcha, et
// un second bouton "Créer un compte client" qui vérifie le MÊME captcha
// avant d'ouvrir la création de compte.
// login : le champ est pré-rempli (juste après la création d'un compte).
// Après une connexion réussie, cet écran ne navigue PAS lui-même : la
// session change, et c'est la navigation qui quitte l'écran de connexion
// (cf. navigation/Navigation.kt).
// =========================================================================
package fr.stahe.rdvmedecins.pages

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
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.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.ContentType
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import fr.stahe.rdvmedecins.LocalDependances
import fr.stahe.rdvmedecins.api.Captcha
import fr.stahe.rdvmedecins.i18n.t
import fr.stahe.rdvmedecins.widgets.ChampCaptcha
import fr.stahe.rdvmedecins.widgets.ChampSaisie
import fr.stahe.rdvmedecins.widgets.EnvoiFormulaire
import kotlinx.coroutines.launch

@Composable
fun PageConnexion(login: String, onInscription: () -> Unit) {
    val d = LocalDependances.current
    val envoi = remember { EnvoiFormulaire(d.messages) }
    // la saisie : une Map, champ -> valeur ; rememberSaveable : elle survit à un
    // changement de langue (l'activité est recréée)
    var saisie by rememberSaveable { mutableStateOf(mapOf("login" to login, "password" to "", "captcha" to "")) }
    var captcha by remember { mutableStateOf<Captcha?>(null) }
    val portee = rememberCoroutineScope()

    // une nouvelle image : il le faut après CHAQUE tentative, car une image ne
    // sert qu'UNE fois (le serveur l'oublie dès qu'il l'a vérifiée)
    suspend fun rechargerCaptcha() {
        captcha = try {
            d.api.captcha()
        } catch (e: Exception) {
            null // serveur injoignable : la connexion échouera avec un message clair
        }
    }
    LaunchedEffect(Unit) { rechargerCaptcha() }

    fun modifier(champ: String, valeur: String) {
        saisie = saisie + (champ to valeur)
        envoi.effacerErreur(champ)
    }

    // les deux boutons partagent ce déroulement : envoi, puis, en cas
    // d'échec, message d'erreur et NOUVELLE image
    fun tenter(action: suspend () -> Unit) {
        portee.launch {
            d.messages.effacer()
            if (!envoi.envoyer(action)) {
                saisie = saisie + ("captcha" to "")
                rechargerCaptcha()
            }
        }
    }

    // en cas de succès, rien d'autre à faire ici : la navigation change d'écran
    fun seConnecter() = tenter {
        d.session.connecter(saisie.getValue("login").trim(), saisie.getValue("password"), captcha?.id ?: "", saisie.getValue("captcha"))
    }

    fun creerCompte() = tenter {
        d.api.accesInscription(captcha?.id ?: "", saisie.getValue("captcha"))
        onInscription()
    }

    Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) {
        Text(t("LOGIN.TITLE"), style = MaterialTheme.typography.headlineSmall)
        Spacer(Modifier.height(12.dp))
        // typeContenu : le gestionnaire de mots de passe du téléphone peut remplir le formulaire
        ChampSaisie(
            libelle = "LOGIN.LOGIN_LABEL",
            valeur = saisie.getValue("login"),
            onChange = { modifier("login", it) },
            erreur = envoi.erreurs["login"],
            typeContenu = ContentType.Username,
        )
        ChampSaisie(
            libelle = "LOGIN.PASSWORD_LABEL",
            secret = true,
            valeur = saisie.getValue("password"),
            onChange = { modifier("password", it) },
            erreur = envoi.erreurs["password"],
            typeContenu = ContentType.Password,
        )
        ChampCaptcha(
            svg = captcha?.svg,
            valeur = saisie.getValue("captcha"),
            onChange = { modifier("captcha", it) },
            onRecharger = { portee.launch { rechargerCaptcha() } },
            erreur = envoi.erreurs["captcha"],
        )
        Spacer(Modifier.height(8.dp))
        Button(onClick = { seConnecter() }, enabled = !envoi.enCours, modifier = Modifier.fillMaxWidth()) { Text(t("LOGIN.SUBMIT")) }
        Spacer(Modifier.height(12.dp))
        OutlinedButton(onClick = { creerCompte() }, enabled = !envoi.enCours, modifier = Modifier.fillMaxWidth()) {
            Text(t("LOGIN.CREATE_ACCOUNT"))
        }
        Text(
            t("LOGIN.CREATE_ACCOUNT_HELP"),
            textAlign = TextAlign.Center,
            style = MaterialTheme.typography.bodySmall,
            modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
        )
        Spacer(Modifier.height(16.dp))
        ComptesDemo()
    }
}

// les comptes de démonstration
@Composable
private fun ComptesDemo() {
    val style = MaterialTheme.typography.bodySmall
    Text(t("LOGIN.DEMO_ACCOUNTS"), style = style, color = Color.DarkGray)
    for ((comptes, cle) in listOf(
        "admin" to "LOGIN.DEMO_ADMIN",
        "pelissier, tatou, dupont" to "LOGIN.DEMO_DOCTOR",
        "martin, durand, bistrou, german" to "LOGIN.DEMO_USER",
    )) {
        Text(
            buildAnnotatedString {
                append("• ")
                withStyle(SpanStyle(fontFamily = FontFamily.Monospace)) { append(comptes) }
                append(" - ${t(cle)}")
            },
            style = style,
            color = Color.DarkGray,
        )
    }
}
  • ligne 32 : la saisie, une [Map] champ → valeur, gardée par [rememberSaveable] : elle survit à un changement de langue ;
  • lignes 61-70 : une nouvelle image après CHAQUE tentative : une image ne sert qu'une fois ;
  • ligne 89 : en cas de succès, rien d'autre à faire : la session change, et la navigation quitte l'écran de connexion.

11.12.4. pages/PageAgenda.kt

// =========================================================================
// pages/PageAgenda.kt - l'écran de l'agenda
// -------------------------------------------------------------------------
// Trois blocs, comme dans les autres clients :
//   1. le choix du médecin et du jour ;
//   2. l'agenda de ce médecin ce jour-là ;
//   3. la fenêtre de réservation d'un créneau : une autre route,
//      Ecrans.Reservation, affichée par-dessus (cf. FenetreReservation.kt).
//
// TOUT l'état de l'écran est dans sa ROUTE : Ecrans.Agenda(idMedecin, jour).
// L'écran en DÉDUIT ce qu'il affiche, et les actions de l'utilisateur
// changent la route (onVoir, onReserver) ; le bouton Retour du téléphone
// ferme la fenêtre de réservation.
//
// Ce que chacun voit et peut faire est décidé par le SERVEUR (champs
// patient, moi, reservable, idRv de chaque créneau) : l'écran se contente
// de l'afficher. Un patient ne reçoit même pas le nom des autres patients.
// =========================================================================
@file:OptIn(ExperimentalMaterial3Api::class)

package fr.stahe.rdvmedecins.pages

import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DateRange
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
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.material3.TextButton
import androidx.compose.material3.rememberDatePickerState
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.saveable.rememberSaveable
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.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import fr.stahe.rdvmedecins.LocalDependances
import fr.stahe.rdvmedecins.api.Agenda
import fr.stahe.rdvmedecins.api.CreneauAgenda
import fr.stahe.rdvmedecins.api.ErreurApi
import fr.stahe.rdvmedecins.api.Personne
import fr.stahe.rdvmedecins.api.Role
import fr.stahe.rdvmedecins.api.TypePersonnes
import fr.stahe.rdvmedecins.aujourdhui
import fr.stahe.rdvmedecins.depuisMillis
import fr.stahe.rdvmedecins.estUneDateValide
import fr.stahe.rdvmedecins.horaire
import fr.stahe.rdvmedecins.i18n.LocalTraductions
import fr.stahe.rdvmedecins.i18n.t
import fr.stahe.rdvmedecins.jourLong
import fr.stahe.rdvmedecins.versMillis
import fr.stahe.rdvmedecins.widgets.Etiquette
import fr.stahe.rdvmedecins.widgets.FenetreConfirmation
import fr.stahe.rdvmedecins.widgets.ListeDeroulante
import fr.stahe.rdvmedecins.widgets.rememberConfirmation
import kotlinx.coroutines.launch

// la clé de l'agenda d'un médecin un jour donné, dans le cache
fun cleAgenda(idMedecin: Int?, jour: String) = "agenda/$idMedecin/$jour"

@Composable
fun PageAgenda(
    idMedecin: Int?,
    jour: String?, // tel quel dans la route : peut-être invalide
    onVoir: (Int?, String) -> Unit,
    onReserver: (Int?, String, Int) -> Unit,
    onMonEspace: () -> Unit,
) {
    val d = LocalDependances.current
    val utilisateur = d.session.utilisateur
    val confirmation = rememberConfirmation(d.messages)
    val portee = rememberCoroutineScope()
    // le jour : celui de la route s'il est valide, sinon aujourd'hui
    val jourInvalide = jour != null && !estUneDateValide(jour)
    val leJour = if (jour != null && !jourInvalide) jour else aujourdhui()

    var medecins by remember { mutableStateOf(listOf<Personne>()) }
    // l'agenda (null tant qu'il n'est pas arrivé, ou s'il n'y en a pas)
    var agenda by remember { mutableStateOf<Agenda?>(null) }
    var chargement by remember { mutableStateOf(false) }

    // les médecins : lus une fois (cache) ; une erreur s'affiche en bas de l'écran
    LaunchedEffect(Unit) {
        try {
            medecins = d.cache.lire("medecins") { d.api.lister(TypePersonnes.MEDECINS) }
        } catch (e: ErreurApi) {
            d.messages.afficherErreurApi(e)
        }
        if (jourInvalide) d.messages.afficherErreur("ERRORS.INVALID_DATE")
    }

    // l'agenda : relu si la route change (autre médecin, autre jour) ou si le
    // cache est invalidé (réservation, annulation : d.cache.version change).
    // Sans médecin dans la route, un DOCTOR voit SON agenda (le serveur le
    // retrouve à partir du jeton) ; les autres ne voient rien
    val avecAgenda = !jourInvalide && (idMedecin != null || utilisateur?.role == Role.DOCTOR)
    LaunchedEffect(idMedecin, leJour, d.cache.version) {
        if (!avecAgenda) {
            agenda = null
            return@LaunchedEffect
        }
        chargement = true
        try {
            agenda = d.cache.lire(cleAgenda(idMedecin, leJour)) { d.api.agenda(idMedecin, leJour) }
        } catch (e: ErreurApi) {
            agenda = null
            d.messages.afficherErreurApi(e)
        }
        chargement = false
    }

    // c'est le serveur qui fait foi : après une annulation, on relit l'agenda
    // (et l'espace du patient, qui montre son rendez-vous)
    fun annuler(c: CreneauAgenda) {
        val idRv = c.idRv ?: return
        portee.launch {
            if (!confirmation.confirmer("AGENDA.CANCEL_CONFIRM")) return@launch
            try {
                d.api.annulerRv(idRv)
                d.messages.afficherSucces("AGENDA.CANCELLED")
            } catch (e: ErreurApi) {
                d.messages.afficherErreurApi(e)
            }
            d.cache.invalider("mon-espace")
            d.cache.invalider("agenda")
        }
    }

    val a = agenda
    // le médecin affiché : celui de la route, ou celui de l'agenda reçu (DOCTOR)
    val idAffiche = idMedecin ?: a?.medecin?.id
    Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(12.dp)) {
        // 1. le choix du médecin et du jour. key : si la route change, le
        // formulaire est recréé, et repart des nouvelles valeurs
        androidx.compose.runtime.key(idAffiche, leJour) {
            ChoixMedecinJour(medecins = medecins, idMedecin = idAffiche, jour = leJour, onVoir = onVoir)
        }
        // patient qui a déjà un rendez-vous à venir : pourquoi il n'y a pas de bouton "Réserver"
        a?.info?.let { info ->
            Card(colors = CardDefaults.cardColors(containerColor = Color(0xFFE1F5FE)), modifier = Modifier.padding(top = 8.dp)) {
                ListItem(
                    colors = androidx.compose.material3.ListItemDefaults.colors(containerColor = Color.Transparent),
                    headlineContent = { Text(t(info)) },
                    trailingContent = { TextButton(onClick = onMonEspace) { Text(t("APP.MENU_MY_SPACE")) } },
                )
            }
        }
        Spacer(Modifier.height(8.dp))
        when {
            chargement && a == null -> Box(Modifier.fillMaxWidth().padding(24.dp), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
            a != null -> LeAgenda(
                agenda = a,
                // "Réserver" change la ROUTE : la fenêtre de réservation s'ouvre
                onReserver = { c -> onReserver(idAffiche, leJour, c.id) },
                onAnnuler = ::annuler,
            )
            else -> Text(t("AGENDA.EMPTY_STATE"), Modifier.padding(16.dp))
        }
    }
    FenetreConfirmation(confirmation)
}

// -------------------------------------------------------------------------
// 1. le choix du médecin et du jour : un petit formulaire, dont l'état ne
// sert qu'avant l'envoi. Il DÉMARRE avec les valeurs de la route.
// -------------------------------------------------------------------------
@Composable
private fun ChoixMedecinJour(medecins: List<Personne>, idMedecin: Int?, jour: String, onVoir: (Int?, String) -> Unit) {
    var id by rememberSaveable { mutableStateOf(idMedecin) }
    var leJour by rememberSaveable { mutableStateOf(jour) }
    var calendrier by remember { mutableStateOf(false) }
    val langue = LocalTraductions.current.langue
    // le médecin choisi doit être dans la liste (sinon : aucun)
    val idConnu = id.takeIf { i -> medecins.any { it.id == i } }
    // t() est une fonction @Composable : on traduit AVANT la lambda texte, qui n'en est pas une
    val invite = t("DOCTOR_DAY_PICKER.CHOOSE_DOCTOR")
    Card(Modifier.fillMaxWidth()) {
        Column(Modifier.padding(12.dp)) {
            ListeDeroulante(
                libelle = t("DOCTOR_DAY_PICKER.DOCTOR_LABEL"),
                valeur = idConnu,
                choix = listOf<Int?>(null) + medecins.map { it.id },
                texte = { i -> medecins.find { it.id == i }?.libelle ?: invite },
                onChange = { id = it },
                modifier = Modifier.fillMaxWidth(),
            )
            Spacer(Modifier.height(8.dp))
            // le jour : un champ en lecture seule ; le toucher ouvre le calendrier
            Box {
                OutlinedTextField(
                    value = jourLong(leJour, langue),
                    onValueChange = {},
                    readOnly = true,
                    label = { Text(t("DOCTOR_DAY_PICKER.DAY_LABEL")) },
                    trailingIcon = { Icon(Icons.Default.DateRange, contentDescription = t("DOCTOR_DAY_PICKER.CHOOSE_DAY")) },
                    modifier = Modifier.fillMaxWidth(),
                )
                // une surface transparente, par-dessus le champ, qui reçoit le toucher
                Box(Modifier.matchParentSize().clickable { calendrier = true })
            }
            Spacer(Modifier.height(12.dp))
            Button(onClick = { onVoir(idConnu, leJour) }, enabled = idConnu != null, modifier = Modifier.fillMaxWidth()) {
                Text(t("DOCTOR_DAY_PICKER.VIEW_AGENDA"))
            }
        }
    }
    if (calendrier) {
        val etat = rememberDatePickerState(initialSelectedDateMillis = versMillis(leJour))
        DatePickerDialog(
            onDismissRequest = { calendrier = false },
            // les textes OK / Annuler d'Android, dans la langue de l'application
            confirmButton = {
                TextButton(onClick = {
                    etat.selectedDateMillis?.let { leJour = depuisMillis(it) }
                    calendrier = false
                }) { Text(stringResource(android.R.string.ok)) }
            },
            dismissButton = { TextButton(onClick = { calendrier = false }) { Text(stringResource(android.R.string.cancel)) } },
        ) { DatePicker(state = etat) }
    }
}

// -------------------------------------------------------------------------
// 2. l'agenda
// -------------------------------------------------------------------------
@Composable
private fun LeAgenda(agenda: Agenda, onReserver: (CreneauAgenda) -> Unit, onAnnuler: (CreneauAgenda) -> Unit) {
    val langue = LocalTraductions.current.langue
    Card(Modifier.fillMaxWidth()) {
        Column(Modifier.padding(vertical = 8.dp)) {
            ListItem(
                headlineContent = { Text("${t("AGENDA.TITLE_PREFIX")} ${jourLong(agenda.jour, langue)}", style = MaterialTheme.typography.titleMedium) },
                supportingContent = { Text(agenda.medecin.libelle) },
            )
            if (agenda.creneaux.isEmpty()) Text(t("AGENDA.NO_SLOTS"), Modifier.padding(16.dp))
            for (c in agenda.creneaux) {
                HorizontalDivider()
                Row(
                    verticalAlignment = Alignment.CenterVertically,
                    modifier = Modifier
                        .fillMaxWidth()
                        // les créneaux réservables sont légèrement colorés
                        .background(if (c.reservable) Color(0xFFE8F5E9) else Color.Transparent)
                        .padding(horizontal = 12.dp, vertical = 6.dp),
                ) {
                    Text(horaire(c), fontWeight = FontWeight.Medium, modifier = Modifier.width(96.dp))
                    Box(Modifier.weight(1f)) {
                        when {
                            c.libre -> Etiquette(t("AGENDA.FREE"), Color(0xFF2E7D32))
                            c.patient != null -> Etiquette(c.patient.libelle, if (c.moi) Color(0xFF3F51B5) else Color(0xFF757575))
                            else -> Etiquette(t("AGENDA.BOOKED_BY_OTHER"), Color(0xFF757575))
                        }
                    }
                    when {
                        c.reservable -> OutlinedButton(onClick = { onReserver(c) }) { Text(t("AGENDA.BOOK")) }
                        c.idRv != null -> OutlinedButton(
                            onClick = { onAnnuler(c) },
                            colors = ButtonDefaults.outlinedButtonColors(contentColor = Color(0xFFC62828)),
                        ) { Text(t("AGENDA.CANCEL_APPOINTMENT")) }
                    }
                }
            }
        }
    }
}
  • ligne 91 : la clé de l'agenda dans le cache ;
  • lignes 115-117 : les médecins, lus une fois (cache) ;
  • lignes 129-136 : l'agenda, relu si la route change ou si le cache est invalidé ;
  • lignes 149-157 : l'annulation : confirmation, requête, invalidation du cache ;
  • lignes 200-230 : le choix du médecin et du jour ; le jour est un champ en lecture seule, que recouvre une surface transparente qui ouvre le calendrier ;
  • lignes 259-274 : l'agenda : ce que chacun voit et peut faire est décidé par le SERVEUR.

11.12.5. pages/PageEspacePatient.kt

// =========================================================================
// pages/PageEspacePatient.kt - "Mon espace" (rôle USER)
// -------------------------------------------------------------------------
// L'écran d'accueil du patient, juste après sa connexion :
//   - deux options : [Gérer mon compte] et [Voir un agenda] ;
//   - son rendez-vous à venir, s'il en a un, avec le moyen de l'annuler.
// =========================================================================
@file:OptIn(ExperimentalLayoutApi::class)

package fr.stahe.rdvmedecins.pages

import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
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.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.DateRange
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
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.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import fr.stahe.rdvmedecins.LocalDependances
import fr.stahe.rdvmedecins.api.ErreurApi
import fr.stahe.rdvmedecins.api.MonEspace
import fr.stahe.rdvmedecins.horaire
import fr.stahe.rdvmedecins.i18n.LocalTraductions
import fr.stahe.rdvmedecins.i18n.t
import fr.stahe.rdvmedecins.jourLong
import fr.stahe.rdvmedecins.widgets.FenetreConfirmation
import fr.stahe.rdvmedecins.widgets.rememberConfirmation
import kotlinx.coroutines.launch

@Composable
fun PageEspacePatient(onMonCompte: () -> Unit, onAgenda: (Int?, String?) -> Unit) {
    val d = LocalDependances.current
    val langue = LocalTraductions.current.langue
    val confirmation = rememberConfirmation(d.messages)
    val portee = rememberCoroutineScope()
    var espace by remember { mutableStateOf<MonEspace?>(null) }

    LaunchedEffect(d.cache.version) {
        try {
            espace = d.cache.lire("mon-espace") { d.api.monEspace() }
        } catch (e: ErreurApi) {
            d.messages.afficherErreurApi(e)
        }
    }

    fun annuler(idRv: Int) {
        portee.launch {
            if (!confirmation.confirmer("AGENDA.CANCEL_CONFIRM")) return@launch
            try {
                d.api.annulerRv(idRv)
                d.messages.afficherSucces("AGENDA.CANCELLED")
            } catch (e: ErreurApi) {
                d.messages.afficherErreurApi(e)
            }
            // l'espace ET les agendas ont changé
            d.cache.invalider("mon-espace")
            d.cache.invalider("agenda")
        }
    }

    val e = espace
    if (e == null) {
        Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
        return
    }
    Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(12.dp)) {
        Text(t("ESPACE.HELLO", mapOf("nom" to e.patient.libelle)), style = MaterialTheme.typography.headlineSmall, modifier = Modifier.padding(4.dp))
        Option(Icons.Default.AccountCircle, t("ESPACE.MANAGE_ACCOUNT"), t("ESPACE.MANAGE_ACCOUNT_HELP"), onMonCompte)
        Option(Icons.Default.DateRange, t("ESPACE.VIEW_AGENDA"), t("ESPACE.VIEW_AGENDA_HELP")) { onAgenda(null, null) }
        Spacer(Modifier.height(8.dp))
        Card(Modifier.fillMaxWidth()) {
            Column(Modifier.padding(12.dp)) {
                Text(t("ESPACE.MY_APPOINTMENT"), style = MaterialTheme.typography.titleMedium)
                // normalement un seul (règle "un rendez-vous à venir à la fois"),
                // mais l'écran sait en afficher plusieurs
                for (rv in e.rendezVous) {
                    Spacer(Modifier.height(8.dp))
                    Text("${jourLong(rv.jour, langue)}, ${horaire(rv.creneau)}", fontWeight = FontWeight.SemiBold)
                    Text(rv.medecin.libelle)
                    FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
                        // l'agenda du médecin ce jour-là : le patient y voit son nom
                        TextButton(onClick = { onAgenda(rv.medecin.id, rv.jour) }) { Text(t("ESPACE.SEE_IN_AGENDA")) }
                        OutlinedButton(
                            onClick = { annuler(rv.id) },
                            colors = ButtonDefaults.outlinedButtonColors(contentColor = Color(0xFFC62828)),
                        ) { Text(t("ESPACE.CANCEL_APPOINTMENT")) }
                    }
                }
                if (e.rendezVous.isEmpty()) Text(t("ESPACE.NO_APPOINTMENT"), color = Color.Gray, modifier = Modifier.padding(top = 8.dp))
            }
        }
    }
    FenetreConfirmation(confirmation)
}

// une "carte" d'option, qu'on touche pour ouvrir un écran
@Composable
private fun Option(icone: ImageVector, titre: String, aide: String, onClick: () -> Unit) {
    Card(Modifier.fillMaxWidth().padding(vertical = 4.dp).clickable(onClick = onClick)) {
        ListItem(
            leadingContent = { Icon(icone, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(32.dp)) },
            headlineContent = { Text(titre, style = MaterialTheme.typography.titleMedium) },
            supportingContent = { Text(aide) },
            trailingContent = { Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null) },
        )
    }
}

11.12.6. pages/PersonnesConfig.kt

// =========================================================================
// pages/PersonnesConfig.kt - médecins ou clients ?
// -------------------------------------------------------------------------
// Les écrans de gestion des médecins et des clients sont IDENTIQUES : même
// liste, même formulaire. On les écrit UNE fois (PagePersonnes.kt,
// PagePersonneFormulaire.kt) ; seules changent l'adresse de l'API et les
// clés des libellés, décrites ici. La route passe le type à chaque écran.
// =========================================================================
package fr.stahe.rdvmedecins.pages

import fr.stahe.rdvmedecins.api.Personne
import fr.stahe.rdvmedecins.api.TypePersonnes

data class ClesPersonnes(
    val titre: String, // "Médecins"
    val ajouter: String, // "Ajouter un médecin"
    val modifier: String, // "Modifier un médecin"
    val vide: String, // "Aucun médecin."
    val confirmerSuppression: String,
    val ajoute: String, // "Le médecin {{nom}} a été ajouté (login : {{login}})."
    val modifie: String,
    val supprime: String,
    val introuvable: String, // la fiche a été supprimée entre-temps
)

// les clés d'un type : une seule écriture, grâce au préfixe "MEDECINS." ou "CLIENTS."
fun cles(type: TypePersonnes): ClesPersonnes {
    val p = if (type == TypePersonnes.MEDECINS) "MEDECINS" else "CLIENTS"
    return ClesPersonnes(
        titre = "$p.TITLE",
        ajouter = "$p.ADD",
        modifier = "$p.EDIT",
        vide = "$p.EMPTY",
        confirmerSuppression = "$p.DELETE_CONFIRM",
        ajoute = "$p.ADDED",
        modifie = "$p.UPDATED",
        supprime = "$p.DELETED",
        introuvable = if (type == TypePersonnes.MEDECINS) "ERRORS.DOCTOR_NOT_FOUND" else "ERRORS.PATIENT_NOT_FOUND",
    )
}

// {{nom}} et {{login}} des messages "ajouté / modifié / supprimé"
fun parametres(p: Personne) = mapOf("nom" to "${p.prenom} ${p.nom}", "login" to (p.login ?: ""))

11.12.7. pages/PagePersonnes.kt

// =========================================================================
// pages/PagePersonnes.kt - la liste des médecins OU des clients (ADMIN)
// -------------------------------------------------------------------------
// Un seul écran pour les deux : le type (médecins ou clients) arrive par la
// route ; les libellés propres à chacun sont dans PersonnesConfig.kt.
// Sur un téléphone, un tableau à cinq colonnes ne tient pas : une ligne de
// liste par personne (nom, login), avec ses deux boutons ; le bouton rond
// "+" (FloatingActionButton) ajoute une personne.
// La liste est une LazyColumn : seules les lignes visibles sont fabriquées.
// =========================================================================
package fr.stahe.rdvmedecins.pages

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
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.font.FontFamily
import androidx.compose.ui.unit.dp
import fr.stahe.rdvmedecins.LocalDependances
import fr.stahe.rdvmedecins.api.ErreurApi
import fr.stahe.rdvmedecins.api.Personne
import fr.stahe.rdvmedecins.api.TypePersonnes
import fr.stahe.rdvmedecins.i18n.t
import fr.stahe.rdvmedecins.widgets.FenetreConfirmation
import fr.stahe.rdvmedecins.widgets.rememberConfirmation
import kotlinx.coroutines.launch

@Composable
fun PagePersonnes(type: TypePersonnes, onAjouter: () -> Unit, onModifier: (Int) -> Unit) {
    val d = LocalDependances.current
    val c = cles(type)
    val confirmation = rememberConfirmation(d.messages)
    val portee = rememberCoroutineScope()
    // null : pas encore chargée
    var liste by remember(type) { mutableStateOf<List<Personne>?>(null) }
    var echec by remember(type) { mutableStateOf(false) }

    // relue si le type change ou si le cache est invalidé
    LaunchedEffect(type, d.cache.version) {
        try {
            liste = d.cache.lire(type.chemin) { d.api.lister(type) }
        } catch (e: ErreurApi) {
            echec = true
            d.messages.afficherErreurApi(e)
        }
    }

    fun supprimer(p: Personne) {
        portee.launch {
            if (!confirmation.confirmer(c.confirmerSuppression)) return@launch
            try {
                d.api.supprimer(type, p.id)
                d.messages.afficherSucces(c.supprime, parametres(p))
            } catch (e: ErreurApi) {
                // un médecin qui a encore des rendez-vous : 409 ERRORS.DOCTOR_HAS_APPOINTMENTS
                d.messages.afficherErreurApi(e)
            }
            d.cache.invalider(type.chemin)
        }
    }

    Box(Modifier.fillMaxSize()) {
        val l = liste
        if (l == null) {
            // pas encore chargée (ou chargement en échec : le message est en bas de l'écran)
            if (!echec) CircularProgressIndicator(Modifier.align(Alignment.Center))
        } else {
            // de la place en bas, pour ne pas cacher la dernière ligne sous le bouton flottant
            LazyColumn(contentPadding = PaddingValues(start = 8.dp, top = 8.dp, end = 8.dp, bottom = 88.dp)) {
                item { Text(t(c.titre), style = MaterialTheme.typography.headlineSmall, modifier = Modifier.padding(8.dp)) }
                if (l.isEmpty()) item { ListItem(headlineContent = { Text(t(c.vide)) }) }
                // une ligne par personne ; key : l'identité de la ligne
                items(l, key = { it.id }) { p ->
                    Card(Modifier.padding(vertical = 4.dp)) {
                        ListItem(
                            leadingContent = {
                                Box(Modifier.size(40.dp).background(MaterialTheme.colorScheme.primaryContainer, CircleShape), contentAlignment = Alignment.Center) {
                                    Text("${p.prenom.first()}${p.nom.first()}")
                                }
                            },
                            headlineContent = { Text(p.libelle) },
                            supportingContent = { Text(p.login ?: "", fontFamily = FontFamily.Monospace) },
                            trailingContent = {
                                Row {
                                    IconButton(onClick = { onModifier(p.id) }) { Icon(Icons.Default.Edit, contentDescription = t("PERSONNES.EDIT")) }
                                    IconButton(onClick = { supprimer(p) }) {
                                        Icon(Icons.Outlined.Delete, contentDescription = t("PERSONNES.DELETE"), tint = Color(0xFFC62828))
                                    }
                                }
                            },
                        )
                    }
                }
            }
        }
        ExtendedFloatingActionButton(
            onClick = onAjouter,
            icon = { Icon(Icons.Default.Add, contentDescription = null) },
            text = { Text(t(c.ajouter)) },
            modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp),
        )
    }
    FenetreConfirmation(confirmation)
}
  • ligne 97 : une [LazyColumn] : seules les lignes visibles sont fabriquées (l'équivalent du [ListView.builder] de [Flutter]) ; [contentPadding] laisse de la place sous la dernière ligne pour le bouton flottant ;
  • ligne 124 : le bouton flottant ([ExtendedFloatingActionButton]) qui ajoute une personne.

11.12.8. pages/PagePersonneFormulaire.kt

// =========================================================================
// pages/PagePersonneFormulaire.kt - création OU modification d'un médecin
// ou d'un client (ADMIN)
// -------------------------------------------------------------------------
//     Ecrans.Personne(MEDECINS)          -> création (pas d'id)
//     Ecrans.Personne(MEDECINS, id = 3)  -> modification
// Deux composants :
//   - PagePersonneFormulaire lit la fiche à modifier (s'il y en a une) ;
//   - Formulaire n'est affiché qu'une fois la fiche lue : il DÉMARRE avec
//     ses valeurs (état initial), sans avoir à les recopier plus tard.
// Aucune règle n'est vérifiée ici : le serveur valide tout, et ses erreurs
// s'affichent sous les champs (cf. widgets/EnvoiFormulaire.kt).
// La VERSION lue avec la fiche repart avec la modification : si un autre
// ADMIN l'a modifiée entre-temps, le serveur répond 409 CONCURRENT_UPDATE.
// =========================================================================
@file:OptIn(ExperimentalLayoutApi::class)

package fr.stahe.rdvmedecins.pages

import androidx.compose.foundation.layout.Arrangement
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.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
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.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import fr.stahe.rdvmedecins.LocalDependances
import fr.stahe.rdvmedecins.api.Donnees
import fr.stahe.rdvmedecins.api.ErreurApi
import fr.stahe.rdvmedecins.api.TypePersonnes
import fr.stahe.rdvmedecins.i18n.t
import fr.stahe.rdvmedecins.widgets.ChampSaisie
import fr.stahe.rdvmedecins.widgets.ChampsPersonne
import fr.stahe.rdvmedecins.widgets.EnvoiFormulaire
import kotlinx.coroutines.launch

private val vide = mapOf("titre" to "Mr", "prenom" to "", "nom" to "", "login" to "", "password" to "")

@Composable
fun PagePersonneFormulaire(type: TypePersonnes, id: Int?, onListe: () -> Unit) {
    val d = LocalDependances.current
    // les valeurs de départ : vides (création), ou celles de la fiche lue
    var initial by remember { mutableStateOf<Donnees?>(if (id == null) vide else null) }

    if (id != null) {
        LaunchedEffect(id) {
            try {
                // SANS cache : la fiche doit être à jour (sinon le formulaire
                // démarrerait avec une version périmée : 409 CONCURRENT_UPDATE)
                val p = d.api.lire(type, id)
                initial = mapOf("titre" to p.titre, "prenom" to p.prenom, "nom" to p.nom, "login" to (p.login ?: ""), "password" to "", "version" to p.version)
            } catch (e: ErreurApi) {
                // fiche inexistante (supprimée entre-temps...) : retour à la liste
                d.messages.flash(e.cle, e.params, succes = false)
                onListe()
            }
        }
    }
    val depart = initial
    if (depart == null) {
        Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
    } else {
        Formulaire(type, id, depart, onListe)
    }
}

// le formulaire lui-même
@Composable
private fun Formulaire(type: TypePersonnes, id: Int?, initial: Donnees, onListe: () -> Unit) {
    val d = LocalDependances.current
    val c = cles(type)
    val creation = id == null
    val envoi = remember { EnvoiFormulaire(d.messages) }
    // une COPIE modifiable des valeurs de départ
    var saisie by rememberSaveable { mutableStateOf(initial) }
    val portee = rememberCoroutineScope()

    fun modifier(champ: String, valeur: String) {
        saisie = saisie + (champ to valeur)
        envoi.effacerErreur(champ)
    }

    fun enregistrer() {
        portee.launch {
            val reussi = envoi.envoyer {
                val p = if (id == null) d.api.ajouter(type, saisie) else d.api.modifier(type, id, saisie)
                d.messages.flash(if (creation) c.ajoute else c.modifie, parametres(p))
            }
            val cle = d.messages.erreur?.cle
            if (reussi) {
                d.cache.invalider(type.chemin) // la liste a changé
                onListe()
            } else if (cle == c.introuvable) {
                // supprimé entre-temps par un autre ADMIN : retour à la liste
                d.messages.flash(cle, succes = false)
                onListe()
            }
        }
    }

    Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) {
        Text(t(if (creation) c.ajouter else c.modifier), style = MaterialTheme.typography.headlineSmall)
        Spacer(Modifier.height(8.dp))
        ChampsPersonne(valeurs = saisie, erreurs = envoi.erreurs, onChange = ::modifier)
        Spacer(Modifier.height(16.dp))
        Text(t("PERSONNES.ACCOUNT_SECTION"), style = MaterialTheme.typography.titleSmall)
        ChampSaisie(
            libelle = "PERSONNES.LOGIN_LABEL",
            longueurMax = 30,
            valeur = saisie["login"] as String,
            onChange = { modifier("login", it) },
            erreur = envoi.erreurs["login"],
            aide = if (creation) "PERSONNES.LOGIN_HELP_CREATE" else "PERSONNES.LOGIN_HELP_EDIT",
        )
        // jamais pré-rempli : le mot de passe ne revient jamais du serveur
        ChampSaisie(
            libelle = "PERSONNES.PASSWORD_LABEL",
            secret = true,
            longueurMax = 72,
            valeur = saisie["password"] as String,
            onChange = { modifier("password", it) },
            erreur = envoi.erreurs["password"],
            params = envoi.params,
            aide = if (creation) "PERSONNES.PASSWORD_HELP_CREATE" else "PERSONNES.PASSWORD_HELP_EDIT",
        )
        Spacer(Modifier.height(16.dp))
        FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
            Button(onClick = { enregistrer() }, enabled = !envoi.enCours) { Text(t("PERSONNES.SAVE")) }
            OutlinedButton(onClick = onListe) { Text(t("PERSONNES.BACK")) }
        }
    }
}

11.12.9. pages/PageInscription.kt

// =========================================================================
// pages/PageInscription.kt - "Créer un compte client"
// -------------------------------------------------------------------------
// On n'arrive ici qu'après avoir recopié le captcha de l'écran de connexion
// (bouton "Créer un compte client") : le serveur a alors délivré un
// laissez-passer (un cookie). L'écran le vérifie dès son ouverture (GET
// /api/inscription/acces) : sans laissez-passer, retour à la connexion.
// Mêmes champs que le formulaire de l'ADMIN, mais le login et le mot de
// passe sont obligatoires, avec confirmation.
// =========================================================================
@file:OptIn(ExperimentalLayoutApi::class)

package fr.stahe.rdvmedecins.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.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
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.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.ContentType
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import fr.stahe.rdvmedecins.LocalDependances
import fr.stahe.rdvmedecins.i18n.t
import fr.stahe.rdvmedecins.widgets.ChampSaisie
import fr.stahe.rdvmedecins.widgets.ChampsPersonne
import fr.stahe.rdvmedecins.widgets.EnvoiFormulaire
import kotlinx.coroutines.launch

// longueur minimale d'un mot de passe choisi par un patient : même valeur
// que le serveur (qui renvoie aussi {min} avec l'erreur PASSWORD_TOO_SHORT)
const val MOT_DE_PASSE_MIN = "6"

@Composable
fun PageInscription(onConnexion: (String) -> Unit) {
    val d = LocalDependances.current
    val envoi = remember { EnvoiFormulaire(d.messages) }
    var saisie by rememberSaveable {
        mutableStateOf(mapOf("titre" to "Mr", "prenom" to "", "nom" to "", "login" to "", "password" to "", "confirmation" to ""))
    }
    val portee = rememberCoroutineScope()

    // le garde PROPRE à cet écran : sans laissez-passer, retour à la connexion
    LaunchedEffect(Unit) {
        val valide = runCatching { d.api.etatAccesInscription() }.getOrDefault(false)
        if (!valide) {
            d.messages.flash("ERRORS.CAPTCHA_BEFORE_ACCOUNT", succes = false)
            onConnexion("")
        }
    }

    fun modifier(champ: String, valeur: String) {
        saisie = saisie + (champ to valeur)
        envoi.effacerErreur(champ)
    }

    fun creer() {
        portee.launch {
            var login = ""
            val reussi = envoi.envoyer { login = d.api.inscrire(saisie) }
            if (reussi) {
                // retour à l'écran de connexion, login pré-rempli
                d.messages.flash("INSCRIPTION.DONE", mapOf("login" to login))
                onConnexion(login)
            } else if (d.messages.erreur?.cle == "ERRORS.CAPTCHA_BEFORE_ACCOUNT") {
                // laissez-passer expiré (15 minutes) : retour au captcha
                d.messages.flash("ERRORS.CAPTCHA_BEFORE_ACCOUNT", succes = false)
                onConnexion("")
            }
        }
    }

    // les paramètres du message d'aide ET des erreurs du mot de passe
    val paramsPassword = mapOf("min" to MOT_DE_PASSE_MIN) + envoi.params
    Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) {
        Text(t("INSCRIPTION.TITLE"), style = MaterialTheme.typography.headlineSmall)
        Text(t("INSCRIPTION.INTRO"), color = Color.Gray)
        Spacer(Modifier.height(8.dp))
        ChampsPersonne(valeurs = saisie, erreurs = envoi.erreurs, onChange = ::modifier)
        Spacer(Modifier.height(16.dp))
        Text(t("PERSONNES.ACCOUNT_SECTION"), style = MaterialTheme.typography.titleSmall)
        ChampSaisie(
            libelle = "PERSONNES.LOGIN_LABEL",
            longueurMax = 30,
            valeur = saisie.getValue("login"),
            onChange = { modifier("login", it) },
            erreur = envoi.erreurs["login"],
            aide = "INSCRIPTION.LOGIN_HELP",
            typeContenu = ContentType.NewUsername,
        )
        ChampSaisie(
            libelle = "PERSONNES.PASSWORD_LABEL",
            secret = true,
            longueurMax = 72,
            valeur = saisie.getValue("password"),
            onChange = { modifier("password", it) },
            erreur = envoi.erreurs["password"],
            params = paramsPassword,
            aide = "INSCRIPTION.PASSWORD_HELP",
            typeContenu = ContentType.NewPassword,
        )
        ChampSaisie(
            libelle = "COMPTE.CONFIRMATION_LABEL",
            secret = true,
            longueurMax = 72,
            valeur = saisie.getValue("confirmation"),
            onChange = { modifier("confirmation", it) },
            erreur = envoi.erreurs["confirmation"],
        )
        Spacer(Modifier.height(16.dp))
        FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
            Button(onClick = { creer() }, enabled = !envoi.enCours) { Text(t("INSCRIPTION.SUBMIT")) }
            OutlinedButton(onClick = { onConnexion("") }) { Text(t("INSCRIPTION.BACK_TO_LOGIN")) }
        }
    }
}

11.12.10. pages/PageMonCompte.kt

// =========================================================================
// pages/PageMonCompte.kt - "Gérer mon compte" (rôle USER)
// -------------------------------------------------------------------------
// Deux formulaires indépendants :
//   1. la modification (identité, login, nouveau mot de passe facultatif) ;
//   2. la suppression du compte - qui annule aussi ses rendez-vous.
// L'un comme l'autre exigent le mot de passe ACTUEL (vérifié par le serveur).
// Deux formulaires, donc deux EnvoiFormulaire (deux jeux d'erreurs). Comme
// pour le formulaire des personnes, la modification n'est affichée qu'une
// fois le compte lu, et démarre avec ses valeurs.
// =========================================================================
@file:OptIn(ExperimentalLayoutApi::class)

package fr.stahe.rdvmedecins.pages

import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
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.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Text
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.saveable.rememberSaveable
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 fr.stahe.rdvmedecins.LocalDependances
import fr.stahe.rdvmedecins.api.ErreurApi
import fr.stahe.rdvmedecins.api.MonCompte
import fr.stahe.rdvmedecins.i18n.t
import fr.stahe.rdvmedecins.widgets.ChampSaisie
import fr.stahe.rdvmedecins.widgets.ChampsPersonne
import fr.stahe.rdvmedecins.widgets.EnvoiFormulaire
import fr.stahe.rdvmedecins.widgets.FenetreConfirmation
import fr.stahe.rdvmedecins.widgets.rememberConfirmation
import kotlinx.coroutines.launch

@Composable
fun PageMonCompte(onMonEspace: () -> Unit) {
    val d = LocalDependances.current
    // relu à chaque visite, sans cache : sa version doit être à jour
    var compte by remember { mutableStateOf<MonCompte?>(null) }
    LaunchedEffect(Unit) {
        try {
            compte = d.api.monCompte()
        } catch (e: ErreurApi) {
            d.messages.afficherErreurApi(e)
        }
    }
    Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(12.dp)) {
        val c = compte
        if (c == null) {
            Box(Modifier.fillMaxWidth().padding(24.dp), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
        } else {
            Modification(c, onMonEspace)
        }
        Spacer(Modifier.height(12.dp))
        Suppression()
    }
}

// -------------------------------------------------------------------------
// 1. la modification
// -------------------------------------------------------------------------
@Composable
private fun Modification(compte: MonCompte, onMonEspace: () -> Unit) {
    val d = LocalDependances.current
    val envoi = remember { EnvoiFormulaire(d.messages) }
    val portee = rememberCoroutineScope()
    var saisie by rememberSaveable {
        mutableStateOf(
            mapOf<String, Any?>(
                "titre" to compte.titre,
                "prenom" to compte.prenom,
                "nom" to compte.nom,
                "login" to compte.login,
                "password" to "",
                "confirmation" to "",
                "motDePasseActuel" to "",
                "version" to compte.version,
            ),
        )
    }

    fun modifier(champ: String, valeur: String) {
        saisie = saisie + (champ to valeur)
        envoi.effacerErreur(champ)
    }

    fun enregistrer() {
        portee.launch {
            val reussi = envoi.envoyer {
                // le serveur a délivré un NOUVEAU jeton (nom, login) : la barre du haut doit suivre
                d.session.mettreAJour(d.api.modifierMonCompte(saisie))
                d.messages.flash("MON_COMPTE.UPDATED")
            }
            if (reussi) {
                d.cache.invalider("mon-espace")
                onMonEspace()
            } else {
                // jamais de mot de passe laissé dans le champ après un envoi refusé ;
                // on change la valeur SANS passer par modifier(), pour ne pas
                // effacer l'erreur que le serveur vient d'y attacher
                saisie = saisie + ("motDePasseActuel" to "")
            }
        }
    }

    Card(Modifier.fillMaxWidth()) {
        Column(Modifier.padding(16.dp)) {
            Text(t("MON_COMPTE.TITLE"), style = MaterialTheme.typography.titleLarge)
            ChampsPersonne(valeurs = saisie, erreurs = envoi.erreurs, onChange = ::modifier)
            Spacer(Modifier.height(12.dp))
            Text(t("PERSONNES.ACCOUNT_SECTION"), style = MaterialTheme.typography.titleSmall)
            ChampSaisie("PERSONNES.LOGIN_LABEL", saisie["login"] as String, { modifier("login", it) }, envoi.erreurs["login"], longueurMax = 30)
            ChampSaisie(
                libelle = "MON_COMPTE.NEW_PASSWORD_LABEL",
                secret = true,
                longueurMax = 72,
                valeur = saisie["password"] as String,
                onChange = { modifier("password", it) },
                erreur = envoi.erreurs["password"],
                params = envoi.params,
                aide = "PERSONNES.PASSWORD_HELP_EDIT",
            )
            ChampSaisie(
                libelle = "COMPTE.CONFIRMATION_LABEL",
                secret = true,
                longueurMax = 72,
                valeur = saisie["confirmation"] as String,
                onChange = { modifier("confirmation", it) },
                erreur = envoi.erreurs["confirmation"],
            )
            ChampSaisie(
                libelle = "MON_COMPTE.CURRENT_PASSWORD_LABEL",
                secret = true,
                longueurMax = 72,
                valeur = saisie["motDePasseActuel"] as String,
                onChange = { modifier("motDePasseActuel", it) },
                erreur = envoi.erreurs["motDePasseActuel"],
                aide = "MON_COMPTE.CURRENT_PASSWORD_HELP",
            )
            Spacer(Modifier.height(8.dp))
            FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
                Button(onClick = { enregistrer() }, enabled = !envoi.enCours) { Text(t("PERSONNES.SAVE")) }
                OutlinedButton(onClick = onMonEspace) { Text(t("MON_COMPTE.BACK")) }
            }
        }
    }
}

// -------------------------------------------------------------------------
// 2. la suppression : un formulaire d'un seul champ
// -------------------------------------------------------------------------
@Composable
private fun Suppression() {
    val d = LocalDependances.current
    val envoi = remember { EnvoiFormulaire(d.messages) }
    val confirmation = rememberConfirmation(d.messages)
    val portee = rememberCoroutineScope()
    var motDePasseActuel by remember { mutableStateOf("") }

    fun supprimer() {
        portee.launch {
            if (!confirmation.confirmer("MON_COMPTE.DELETE_CONFIRM")) return@launch
            val reussi = envoi.envoyer { d.api.supprimerMonCompte(motDePasseActuel) }
            if (reussi) {
                // le message d'abord : il s'affichera sur l'écran de connexion,
                // où la navigation nous emmène dès que la session est vide
                d.messages.flash("MON_COMPTE.DELETED")
                d.session.oublier(volontaire = true) // le serveur a effacé le cookie du jeton
            }
        }
    }

    OutlinedCard(Modifier.fillMaxWidth(), border = BorderStroke(1.dp, Color(0xFFEF9A9A))) {
        Column(Modifier.padding(16.dp)) {
            Text(t("MON_COMPTE.DELETE_TITLE"), style = MaterialTheme.typography.titleLarge, color = Color(0xFFC62828))
            Text(t("MON_COMPTE.DELETE_HELP"), color = Color.Gray)
            ChampSaisie(
                libelle = "MON_COMPTE.CURRENT_PASSWORD_LABEL",
                secret = true,
                longueurMax = 72,
                valeur = motDePasseActuel,
                onChange = {
                    motDePasseActuel = it
                    envoi.effacerErreur("motDePasseActuel")
                },
                erreur = envoi.erreurs["motDePasseActuel"],
            )
            Button(
                onClick = { supprimer() },
                enabled = !envoi.enCours,
                colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFC62828)),
            ) { Text(t("MON_COMPTE.DELETE_SUBMIT")) }
        }
    }
    FenetreConfirmation(confirmation)
}