Skip to content

5. Chapitre 4 - Le client Kotlin de l’application RdvMedecins

5.1. Rappel de l’architecture

Image

Ce client dialogue avec le serveur [rdvmedecins-nestjs-server] du cours, strictement inchangé, par les mêmes routes HTTP (POST [/login], GET [/getAllMedecins], GET [/getAllClients], GET [/getAgendaMedecinJour/:idMedecin/:jour], POST [/ajouterRv], POST [/supprimerRv]), avec la même enveloppe JSON [{ status, data }] et la même authentification par jeton [JWT] (entête [Authorization: Bearer <jeton>]).

5.2. Arborescence du projet

rdvmedecins-kotlin-client/
├── settings.gradle.kts, build.gradle.kts, gradle/      configuration Gradle
└── app/
    ├── build.gradle.kts                    configuration Android + dépendances
    └── src/main/
        ├── AndroidManifest.xml             permissions, classe Application, activité
        ├── assets/i18n/                    dictionnaires de traduction (fr.json, en.json)
        ├── res/                            icône, nom et thème de l'application
        └── java/fr/rdvmedecins/client/
            ├── RdvMedecinsApplication.kt   construit les services (une fois)
            ├── MainActivity.kt             l'écran : fournit les services, affiche App
            ├── App.kt                      l'écran racine (chef d'orchestre + garde)
            ├── AppViewModel.kt             l'état et la logique de l'écran racine
            ├── core/
            │   ├── Locals.kt               les CompositionLocal (équivalent de provider)
            │   ├── models/                 classes de données (miroir du JSON du serveur)
            │   ├── services/               SettingsService, AuthService, RdvService
            │   ├── http/                   AuthInterceptor (jeton JWT + déconnexion sur 401)
            │   ├── i18n/                   TranslationsService (français/anglais)
            │   └── ui/                     ListeDeroulante (composant réutilisable)
            └── features/                   un composant par fonctionnalité

Cette arborescence reprend, dossier pour dossier, celle du client Flutter ([lib/core/models], [lib/core/services], [lib/core/http], [lib/core/i18n], [lib/features]), elle-même reprise du client Vue.js. Deux ajouts, propres à Android : la classe Application ([RdvMedecinsApplication.kt]) et le ViewModel de l’écran ([AppViewModel.kt]), dont le chapitre 3 a expliqué la raison d’être ; et un petit dossier [core/ui] pour une liste déroulante réutilisable. Les noms des dossiers Kotlin sont en minuscules et sans tiret ([doctordaypicker], [bookingdialog]) : ce sont des noms de packages.

5.3. Fichiers de configuration du projet

5.3.1. gradle/libs.versions.toml

Le catalogue des versions : toutes les bibliothèques et tous les plugins Gradle du projet, avec leur version, en un seul endroit. C’est l’équivalent de la section [dependencies] du [pubspec.yaml] de la version Flutter. Le fichier a été généré par Android Studio, puis complété.

# =========================================================================
# 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...) - l'équivalent des numéros de version
# du pubspec.yaml de la version Flutter, ou du package.json de Node.js.
# =========================================================================

[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"
okhttp = "4.12.0"
kotlinxSerializationJson = "1.9.0"
junit = "4.13.2"
junitVersion = "1.1.5"
espressoCore = "3.5.1"

[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" }
# --- ajoutées pour ce client ---
# viewModel { ... } dans une fonction @Composable (cf. App.kt)
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
# les icônes Material de base (déconnexion, calendrier)
androidx-compose-material-icons-core = { group = "androidx.compose.material", name = "material-icons-core" }
# 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" }

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

Commentons ce code :

  • ligne 10 : [agp = "9.4.1"] - la version de l’Android Gradle Plugin ([AGP]), le plugin Gradle qui sait construire une application Android. Depuis sa version 9, il compile lui-même le code Kotlin : il n’y a plus de plugin Kotlin séparé à déclarer ;
  • ligne 11 : [kotlin = "2.2.10"] - la version des deux plugins de compilation Kotlin déclarés plus bas (Compose et serialization) ;
  • ligne 15 : [composeBom] - la version de la « BOM » Compose (Bill Of Materials), qui fixe d’un coup des versions compatibles entre elles pour toutes les bibliothèques Compose (cf. [app/build.gradle.kts]) ;
  • lignes 38-46 : les quatre bibliothèques ajoutées pour ce client : [lifecycle-viewmodel-compose] (la fonction [viewModel { ... }]), [material-icons-core] (les icônes de déconnexion et de calendrier), [OkHttp] (le client HTTP, l’équivalent du package [http] de Flutter) et [kotlinx-serialization-json] (la conversion JSON <-> objets Kotlin). Les deux premières n’ont pas de version : elle est fixée par la BOM Compose ;
  • ligne 52 : le plugin de compilation [kotlinx.serialization], qui génère le code de conversion JSON des classes annotées [@Serializable] (cf. [core/models]).

5.3.2. app/build.gradle.kts

La configuration du module [app], l’application elle-même : plugins appliqués, réglages Android, dépendances.

// =========================================================================
// app/build.gradle.kts - la configuration du module "app" : l'application
// Android elle-même. Généré par Android Studio (modèle "Empty Activity"),
// puis complété pour ce client (lignes marquées "ajout").
// =========================================================================
plugins {
    // le plugin Android : compile le code Kotlin (Android Gradle Plugin 9
    // intègre lui-même la prise en charge de Kotlin) et produit le .apk
    alias(libs.plugins.android.application)
    // le compilateur Compose : transforme les fonctions @Composable
    alias(libs.plugins.kotlin.compose)
    // ajout : le plugin kotlinx.serialization (classes @Serializable)
    alias(libs.plugins.kotlin.serialization)
}

android {
    // le "package" Kotlin de base du code source (classe R, BuildConfig...)
    namespace = "fr.rdvmedecins.client"
    // la version de l'API Android utilisée pour COMPILER
    compileSdk {
        version = release(37)
    }

    defaultConfig {
        // l'identifiant UNIQUE de l'application sur un téléphone (et sur le
        // Play Store)
        applicationId = "fr.rdvmedecins.client"
        // la plus ancienne version d'Android acceptée : Android 8.0 (API 26),
        // la première à proposer java.time (LocalDate, cf. DoctorDayPicker)
        minSdk = 26
        // la version d'Android pour laquelle l'application a été testée
        targetSdk = 37
        versionCode = 1
        versionName = "1.0"

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

    buildTypes {
        release {
            optimization {
                enable = false
            }
        }
    }
    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_11
        targetCompatibility = JavaVersion.VERSION_11
    }
    buildFeatures {
        compose = true
    }
}

dependencies {
    // la "BOM" Compose (Bill Of Materials) : fixe d'un coup des versions
    // compatibles entre elles pour toutes les bibliothèques Compose, qui
    // sont donc déclarées ci-dessous SANS numéro de version
    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 ce client
    implementation(libs.androidx.lifecycle.viewmodel.compose)
    implementation(libs.androidx.compose.material.icons.core)
    implementation(libs.okhttp)
    implementation(libs.kotlinx.serialization.json)
    // tests (générés par Android Studio, non utilisés dans ce document)
    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)
    debugImplementation(libs.androidx.compose.ui.test.manifest)
    debugImplementation(libs.androidx.compose.ui.tooling)
}

Commentons ce code :

  • lignes 6-13 : les trois plugins appliqués au module : le plugin Android, le compilateur Compose (qui transforme les fonctions [@Composable]) et le plugin kotlinx.serialization ;
  • ligne 18 : [namespace] - le package de base du code Kotlin ;
  • lignes 20-21 : [compileSdk] - la version de l’API Android utilisée pour compiler (37 = Android 17). [targetSdk] (ligne 32) est la version pour laquelle l’application a été testée : Android lui applique les comportements de cette version ;
  • ligne 27 : [applicationId] - l’identifiant unique de l’application sur un téléphone et sur le Play Store ;
  • ligne 30 : [minSdk = 26] - Android 8.0 au minimum, la première version qui fournit l’API [java.time] ([LocalDate]), utilisée par [DoctorDayPicker] ;
  • ligne 51 : [compose = true] - active Compose pour ce module ;
  • ligne 59 : la BOM Compose, déclarée comme une « plateforme » : les bibliothèques Compose qui suivent sont déclarées sans version ;
  • lignes 67-71 : les quatre dépendances ajoutées pour ce client (cf. [libs.versions.toml]). La syntaxe [libs.okhttp] fait référence au catalogue des versions : Gradle génère un accesseur Kotlin pour chaque entrée du catalogue, ce qui permet l’autocomplétion dans Android Studio.

5.3.3. app/src/main/AndroidManifest.xml

Le manifeste de l’application. La version Flutter n’en avait pas : le dossier [android/], qui le contient, était généré par [flutter create] et n’était pas étudié. Ici, il fait partie du code de l’application.

<?xml version="1.0" encoding="utf-8"?>
<!-- =====================================================================
     AndroidManifest.xml
     ............................................................................
     La "carte d'identité" de l'application pour le système Android : ses
     permissions, sa classe Application, ses activités (écrans).
     ===================================================================== -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <!-- accès au réseau : sans cette permission, toute requête HTTP vers
         le serveur NestJS échoue (aucune boîte de dialogue n'est montrée à
         l'utilisateur pour cette permission : elle est accordée d'office) -->
    <uses-permission android:name="android.permission.INTERNET" />

    <!-- android:name : la classe Application qui construit les services
         (cf. RdvMedecinsApplication.kt).
         android:usesCleartextTraffic="true" : autorise le HTTP "en clair"
         (http:// et non https://), bloqué par défaut depuis Android 9.
         Indispensable avec le serveur NestJS de ce cours, qui écoute en
         HTTP simple - à retirer pour un serveur de production en https
         (cf. chapitre 5). -->
    <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:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.RdvMedecins"
        android:usesCleartextTraffic="true"
        tools:targetApi="31">

        <!-- l'unique écran de l'application. exported="true" + le filtre
             MAIN/LAUNCHER : c'est l'activité lancée par l'icône de
             l'application. windowSoftInputMode="adjustResize" : quand le
             clavier apparaît, l'écran est réduit (et reste défilable) au
             lieu d'être masqué par le clavier. -->
        <activity
            android:name=".MainActivity"
            android:exported="true"
            android:windowSoftInputMode="adjustResize"
            android:theme="@style/Theme.RdvMedecins">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>

</manifest>

Commentons ce code :

  • ligne 14 : [android.permission.INTERNET] - sans cette permission, toute connexion au serveur échoue. C’est une permission « normale » : elle est accordée à l’installation, sans demande à l’utilisateur ;
  • ligne 24 : [android:name=".RdvMedecinsApplication"] - la classe Application de l’application (le point initial [.] est remplacé par le [namespace]) ;
  • ligne 33 : [android:usesCleartextTraffic="true"] - autorise le HTTP « en clair » ([http://] et non [https://]), bloqué par défaut depuis Android 9. Le serveur NestJS de ce cours écoutant en HTTP simple, ce réglage est indispensable (cf. chapitre 5) ;
  • lignes 41-50 : l’unique activité, [MainActivity]. Le filtre [MAIN]/[LAUNCHER] en fait l’activité lancée par l’icône de l’application ; [exported="true"] l’autorise à être lancée par une autre application - ici, le lanceur d’applications du téléphone ;
  • ligne 44 : [windowSoftInputMode="adjustResize"] - quand le clavier apparaît, l’écran est réduit à la place restante (et reste défilable) au lieu d’être en partie masqué par le clavier.

Le reste du dossier [res/] vient du modèle d’Android Studio : l’icône de l’application ([mipmap-*], [drawable/]), son nom ([values/strings.xml]), son thème « Android », utilisé uniquement pendant le lancement, avant que Compose n’affiche le premier écran avec son propre thème ([values/themes.xml]), et les règles de sauvegarde des données ([xml/]).

5.4. Démarrage de l’application

Le [lib/main.dart] de la version Flutter faisait deux choses : construire les services, puis afficher le premier écran. Sur Android, ces deux rôles sont répartis entre la classe Application et l’activité.

5.4.1. RdvMedecinsApplication.kt

Construit, une seule fois au lancement de l’application, les quatre services du client.

// =========================================================================
// RdvMedecinsApplication.kt
// -------------------------------------------------------------------------
// Première moitié du lib/main.dart de la version Flutter : la construction,
// UNE SEULE FOIS, des services de l'application.
//
// Pourquoi une classe Application ? Sur Android, l'écran (l'Activity, cf.
// MainActivity) est DÉTRUIT puis RECRÉÉ à chaque rotation du téléphone, à
// chaque changement de langue du système, etc. Des services construits
// dans l'Activity seraient donc reconstruits à chaque rotation. L'objet
// Application, lui, est créé au lancement du processus et vit aussi
// longtemps que lui : c'est le bon endroit pour des objets qui doivent
// durer toute la vie de l'application. Il est déclaré dans
// AndroidManifest.xml (attribut android:name de la balise <application>).
//
// L'ORDRE des constructions compte, comme dans la version Flutter :
// AuthService a besoin de SettingsService (l'URL du serveur) ; le client
// HTTP authentifié a besoin d'AuthService (le jeton) ; RdvService a
// besoin du client HTTP authentifié et de SettingsService.
// =========================================================================

package fr.rdvmedecins.client

import android.app.Application
import android.content.Context
import fr.rdvmedecins.client.core.http.AuthInterceptor
import fr.rdvmedecins.client.core.i18n.TranslationsService
import fr.rdvmedecins.client.core.services.AuthService
import fr.rdvmedecins.client.core.services.RdvService
import fr.rdvmedecins.client.core.services.SettingsService
import okhttp3.OkHttpClient
import java.util.concurrent.TimeUnit

class RdvMedecinsApplication : Application() {

    // lateinit : ces propriétés non nulles seront initialisées dans
    // onCreate(), et non dans le constructeur (cf. le cours Kotlin)
    lateinit var settings: SettingsService
        private set
    lateinit var auth: AuthService
        private set
    lateinit var rdv: RdvService
        private set
    lateinit var translations: TranslationsService
        private set

    // appelée par Android au lancement de l'application, avant tout écran
    override fun onCreate() {
        super.onCreate()

        // les préférences de l'application : un petit fichier clé/valeur
        // privé, qui survit à l'arrêt de l'application - l'équivalent du
        // localStorage d'un navigateur
        val prefs = getSharedPreferences("rdvmedecins", Context.MODE_PRIVATE)

        // 1. SettingsService en premier : les autres en ont besoin
        settings = SettingsService(prefs)

        // 2. un client HTTP OkHttp "simple", utilisé par AuthService pour
        //    POST /login. Délais courts : sur mobile, mieux vaut un message
        //    d'erreur rapide qu'une attente interminable si le serveur est
        //    injoignable (mauvaise URL, Wi-Fi coupé...).
        val httpSimple = OkHttpClient.Builder()
            .connectTimeout(10, TimeUnit.SECONDS)
            .readTimeout(15, TimeUnit.SECONDS)
            .build()
        auth = AuthService(settings, prefs, httpSimple)

        // 3. le client HTTP authentifié : une COPIE du précédent
        //    (newBuilder() réutilise sa configuration et ses connexions),
        //    complétée de l'intercepteur qui ajoute le jeton JWT
        val httpAuthentifie = httpSimple.newBuilder()
            .addInterceptor(AuthInterceptor(auth))
            .build()
        rdv = RdvService(httpAuthentifie, settings)

        // 4. les traductions : aucune dépendance envers les autres services
        translations = TranslationsService(assets, prefs)
    }
}

Commentons ce code :

  • ligne 34 : la classe étend [android.app.Application]. Android en crée une instance, une seule, au lancement du processus de l’application - avant toute activité - et la garde jusqu’à la fin du processus ;
  • lignes 38-44 : les quatre services, déclarés [lateinit var ... private set] : non nuls, initialisés plus tard (dans [onCreate]), et modifiables uniquement par cette classe ;
  • ligne 54 : [getSharedPreferences(...)] - les préférences de l’application, un petit fichier clé/valeur privé qui survit à l’arrêt de l’application : l’équivalent du [localStorage] d’un navigateur et du package [shared_preferences] de Flutter ;
  • lignes 63-66 : un client [OkHttp] « simple », avec des délais d’attente courts. Un client OkHttp est fait pour être PARTAGÉ : il gère lui-même un réservoir de connexions réutilisables ;
  • ligne 67 : [AuthService] reçoit le client simple : la route [/login] est la seule route publique du serveur, elle n’attend aucun jeton ;
  • lignes 72-75 : le client authentifié : [newBuilder()] en fait une copie (qui partage les connexions du premier) complétée de l’intercepteur [AuthInterceptor], qui ajoute le jeton JWT à chaque requête (cf. [core/http]). [RdvService] le reçoit : aucune de ses méthodes n’a à se soucier du jeton ;
  • ligne 78 : [assets] - l’accès aux fichiers du dossier [assets/] de l’application, transmis à [TranslationsService].

Différence notable avec la version Flutter : aucune initialisation asynchrone n’est nécessaire avant d’afficher le premier écran. Les [SharedPreferences] se lisent de façon synchrone (elles sont chargées en mémoire au premier accès) : [AuthService] et [TranslationsService] lisent la session et la langue mémorisées dès leur construction, là où [lib/main.dart] devait attendre [await auth.init()] et [await translations.init()].

5.4.2. MainActivity.kt

L’unique activité de l’application : elle rend les services disponibles à toutes les fonctions @Composable, puis affiche l’écran racine.

// =========================================================================
// MainActivity.kt
// -------------------------------------------------------------------------
// Seconde moitié du lib/main.dart de la version Flutter : l'affichage du
// premier écran.
//
// Une application Android est composée d'"activités" (Activity) : une
// activité est un écran, lancé par le système. Celle-ci est déclarée dans
// AndroidManifest.xml comme l'activité de démarrage (LAUNCHER) : c'est
// elle qu'Android lance quand on touche l'icône de l'application.
//
// ComponentActivity est l'activité de base prévue pour Jetpack Compose :
// setContent { ... } y remplace l'ancien système de vues Android (fichiers
// de mise en page XML) par des fonctions @Composable - le pendant du
// runApp(...) de Flutter.
// =========================================================================

package fr.rdvmedecins.client

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.CompositionLocalProvider
import fr.rdvmedecins.client.core.LocalAuth
import fr.rdvmedecins.client.core.LocalRdv
import fr.rdvmedecins.client.core.LocalSettings
import fr.rdvmedecins.client.core.LocalTranslations

class MainActivity : ComponentActivity() {

    // appelée à chaque création de l'activité : au lancement, mais aussi
    // après chaque rotation de l'écran (cf. RdvMedecinsApplication)
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // l'application dessine sous la barre d'état et la barre de
        // navigation du système ; Scaffold (cf. App.kt) se charge de
        // laisser la place nécessaire à ces barres
        enableEdgeToEdge()

        // les services, construits une fois pour toutes par
        // RdvMedecinsApplication - et non ici, où ils seraient reconstruits
        // à chaque rotation
        val app = application as RdvMedecinsApplication

        setContent {
            // CompositionLocalProvider : rend les quatre services disponibles
            // à toutes les fonctions @Composable appelées dans le bloc - le
            // MultiProvider de la version Flutter (cf. core/Locals.kt)
            CompositionLocalProvider(
                LocalSettings provides app.settings,
                LocalAuth provides app.auth,
                LocalRdv provides app.rdv,
                LocalTranslations provides app.translations,
            ) {
                RdvMedecinsApp()
            }
        }
    }
}

Commentons ce code :

  • ligne 34 : [onCreate(...)] est appelée à CHAQUE création de l’activité : au lancement, mais aussi après chaque rotation de l’écran. C’est pourquoi les services sont construits par la classe Application, et non ici ;
  • ligne 39 : [enableEdgeToEdge()] - l’application dessine sur tout l’écran, y compris sous la barre d’état (en haut) et la barre de navigation (en bas) du système. [Scaffold] (cf. [App.kt]) fournit les marges nécessaires pour que le contenu ne soit pas masqué par ces barres ;
  • ligne 44 : [application as RdvMedecinsApplication] - la propriété [application] d’une activité renvoie l’objet Application : on le convertit dans son type réel pour accéder aux services ;
  • ligne 46 : [setContent { ... }] - le pendant de [runApp(...)] : l’interface de l’activité est désormais décrite par les fonctions @Composable du bloc ;
  • lignes 50-56 : [CompositionLocalProvider(LocalSettings provides app.settings, ...)] - fournit les quatre services à tout l’arbre de fonctions @Composable appelé dans le bloc : le [MultiProvider] de la version Flutter. [provides] est une fonction « infixe » : [a provides b] s’écrit sans point ni parenthèses (cf. le cours Kotlin).

5.4.3. core/Locals.kt

Les quatre « clés » des services fournis par [MainActivity].

// =========================================================================
// Locals.kt
// -------------------------------------------------------------------------
// Les "CompositionLocal" de l'application : le mécanisme de Jetpack
// Compose pour rendre un objet disponible à TOUT un sous-arbre de
// fonctions @Composable, sans le passer en paramètre d'appel en appel.
// C'est l'équivalent du package provider de la version Flutter
// (MultiProvider / context.read<...>()), de provide/inject côté Vue.js ou
// d'un Context React.
//
//   - MainActivity FOURNIT les instances :
//         CompositionLocalProvider(LocalAuth provides auth, ...) { ... }
//   - n'importe quelle fonction @Composable descendante les LIT :
//         val auth = LocalAuth.current
//
// staticCompositionLocalOf (plutôt que compositionLocalOf) : ces services
// ne sont jamais REMPLACÉS par d'autres instances pendant la vie de
// l'application, seul leur CONTENU change - et ce contenu est déjà
// observé par Compose grâce aux mutableStateOf de chaque service.
// Le bloc { error(...) } donne la valeur par défaut : ici, une exception
// explicite si un écran est affiché sans que le service ait été fourni.
// =========================================================================

package fr.rdvmedecins.client.core

import androidx.compose.runtime.staticCompositionLocalOf
import fr.rdvmedecins.client.core.i18n.TranslationsService
import fr.rdvmedecins.client.core.services.AuthService
import fr.rdvmedecins.client.core.services.RdvService
import fr.rdvmedecins.client.core.services.SettingsService

val LocalSettings = staticCompositionLocalOf<SettingsService> { error("SettingsService non fourni") }
val LocalAuth = staticCompositionLocalOf<AuthService> { error("AuthService non fourni") }
val LocalRdv = staticCompositionLocalOf<RdvService> { error("RdvService non fourni") }
val LocalTranslations = staticCompositionLocalOf<TranslationsService> { error("TranslationsService non fourni") }

Commentons ce code :

  • ligne 33 : [staticCompositionLocalOf<AuthService> { ... }] - déclare une CompositionLocal de type [AuthService]. Le bloc donne la valeur par défaut, utilisée si aucune valeur n’a été fournie : ici, une exception explicite ([error(...)]), plus parlante qu’une erreur obscure plus loin ;
  • [static...] plutôt que [compositionLocalOf] : une CompositionLocal « statique » ne suit pas les REMPLACEMENTS de la valeur fournie (ce qui serait plus coûteux). Cela convient ici : les services ne sont jamais remplacés par d’autres instances ; seul leur contenu change, et ce contenu est déjà observé grâce à leurs propriétés [mutableStateOf].

5.4.4. App.kt

Portage de [lib/app.dart] : l’écran racine. Il assemble les composants de [features/], relaie leurs actions au ViewModel, et joue le rôle de « garde » : tant que l’utilisateur n’est pas connecté, il n’affiche que l’écran de connexion. Son état et sa logique sont dans [AppViewModel] : ce fichier ne fait qu’AFFICHER.

// =========================================================================
// App.kt
// -------------------------------------------------------------------------
// Portage de lib/app.dart (client Flutter) : l'écran RACINE de
// l'application. Comme dans toutes les variantes de ce cours, il joue le
// rôle de "chef d'orchestre" - il assemble les écrans de lib/features/
// (ici features/) et relaie leurs actions - et celui de "garde" : tant que
// l'utilisateur n'est pas connecté, il n'affiche que l'écran de connexion.
//
// Son état et sa logique ont été déplacés dans AppViewModel (cf. ce
// fichier pour la raison) : ce fichier ne fait qu'AFFICHER.
//
// Trois fonctions @Composable :
//   - RdvMedecinsApp : le thème de l'application (MaterialApp côté Flutter) ;
//   - AppRoot        : l'écran lui-même (barre de titre + contenu) ;
//   - ServerUrlField et SelecteurLangue : deux petits éléments de l'écran.
// =========================================================================

package fr.rdvmedecins.client

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.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ExitToApp
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import fr.rdvmedecins.client.core.LocalAuth
import fr.rdvmedecins.client.core.LocalRdv
import fr.rdvmedecins.client.core.LocalSettings
import fr.rdvmedecins.client.core.LocalTranslations
import fr.rdvmedecins.client.core.i18n.TranslationsService
import fr.rdvmedecins.client.features.agenda.AgendaView
import fr.rdvmedecins.client.features.bookingdialog.BookingDialog
import fr.rdvmedecins.client.features.doctordaypicker.DoctorDayPicker
import fr.rdvmedecins.client.features.login.LoginPage

// -------------------------------------------------------------------------
// le thème : un bleu "primary" pour la barre de titre et les boutons, comme
// le bandeau bleu (navbar bg-primary) des variantes web de ce cours
// -------------------------------------------------------------------------
private val couleurs = lightColorScheme(
    primary = Color(0xFF1565C0),
    onPrimary = Color.White,
)

@Composable
fun RdvMedecinsApp() {
    MaterialTheme(colorScheme = couleurs) {
        AppRoot()
    }
}

// -------------------------------------------------------------------------
// l'écran principal
// -------------------------------------------------------------------------
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppRoot() {
    // les services, fournis par MainActivity (cf. core/Locals.kt)
    val auth = LocalAuth.current
    val rdv = LocalRdv.current
    val translations = LocalTranslations.current
    // une référence vers la méthode t(...) : t("APP.TITLE") se lit alors
    // comme dans les autres variantes du cours
    val t = translations::t

    // viewModel { ... } : RETROUVE le ViewModel de cet écran s'il existe
    // déjà (après une rotation), ou le CRÉE avec le bloc fourni la première
    // fois. C'est pour cela qu'il ne faut jamais écrire
    // "val vm = AppViewModel(auth, rdv)" : un nouveau ViewModel, vide,
    // serait construit à chaque affichage.
    val vm = viewModel { AppViewModel(auth, rdv) }

    // écran étroit (téléphone en portrait) : la barre de titre est allégée
    val etroit = LocalConfiguration.current.screenWidthDp < 600

    // Scaffold : la structure standard d'un écran Material (barre de titre,
    // contenu...) - le même widget, et le même nom, qu'en Flutter
    Scaffold(
        topBar = {
            TopAppBar(
                // sur écran étroit, un titre court : la place manque pour le
                // titre complet à côté des boutons FR/EN, du rôle et de la
                // déconnexion
                title = {
                    Text(
                        text = if (etroit) "RdvMedecins" else t("APP.TITLE"),
                        maxLines = 1,
                        overflow = TextOverflow.Ellipsis,
                    )
                },
                colors = TopAppBarDefaults.topAppBarColors(
                    containerColor = MaterialTheme.colorScheme.primary,
                    titleContentColor = MaterialTheme.colorScheme.onPrimary,
                    actionIconContentColor = MaterialTheme.colorScheme.onPrimary,
                ),
                // actions : les éléments alignés à droite de la barre de titre
                actions = {
                    // sélecteur FR/EN : TOUJOURS visible, même avant connexion
                    SelecteurLangue(translations)
                    // un simple if Kotlin : le badge du rôle, le nom et le
                    // bouton de déconnexion n'existent qu'une fois connecté
                    if (auth.estConnecte) {
                        Surface(
                            shape = RoundedCornerShape(8.dp),
                            color = Color.White.copy(alpha = 0.2f),
                            contentColor = MaterialTheme.colorScheme.onPrimary,
                        ) {
                            Text(
                                text = auth.role?.name ?: "",
                                style = MaterialTheme.typography.labelMedium,
                                modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
                            )
                        }
                        // sur écran étroit, le nom est masqué faute de place
                        if (!etroit) {
                            Spacer(Modifier.width(8.dp))
                            Text(auth.nom ?: "", color = MaterialTheme.colorScheme.onPrimary)
                        }
                        IconButton(onClick = vm::deconnecter) {
                            Icon(Icons.AutoMirrored.Filled.ExitToApp, contentDescription = t("APP.LOGOUT"))
                        }
                    }
                },
            )
        },
    ) { margesSysteme ->
        // margesSysteme : la place occupée par la barre de titre et les
        // barres du système, à laisser libre (cf. enableEdgeToEdge())
        Column(
            modifier = Modifier
                .padding(margesSysteme)
                .fillMaxSize()
                // l'équivalent du SingleChildScrollView de la version Flutter
                .verticalScroll(rememberScrollState())
                .padding(16.dp),
        ) {
            // l'URL du serveur, modifiable avant comme après la connexion
            ServerUrlField(label = t("APP.SERVER_URL_LABEL"))
            Spacer(Modifier.height(16.dp))

            // le message d'erreur, s'il y en a un
            vm.erreur?.let { message ->
                Surface(
                    color = MaterialTheme.colorScheme.errorContainer,
                    contentColor = MaterialTheme.colorScheme.onErrorContainer,
                    shape = RoundedCornerShape(4.dp),
                    modifier = Modifier.fillMaxWidth(),
                ) {
                    Text(message, modifier = Modifier.padding(12.dp))
                }
                Spacer(Modifier.height(16.dp))
            }

            // la "garde" : pas connecté -> rien d'autre que l'écran de connexion
            if (!auth.estConnecte) {
                LoginPage()
            } else {
                // 1. choix du médecin et du jour
                DoctorDayPicker(medecins = vm.medecins, onRechercher = vm::rechercherAgenda)
                Spacer(Modifier.height(16.dp))
                // 2. l'agenda ; peutModifier masque "Réserver"/"Annuler"
                //    pour le rôle USER
                AgendaView(
                    agenda = vm.agenda,
                    peutModifier = auth.estAdmin,
                    onReserver = vm::demanderReservation,
                    onAnnuler = vm::annulerRv,
                )
            }
        }

        // 3. la boîte de dialogue de réservation : affichée tant qu'un
        //    créneau est en cours de réservation (cf. AppViewModel)
        vm.creneauEnReservation?.let { creneau ->
            BookingDialog(
                creneau = creneau,
                clients = vm.clients,
                onConfirmer = vm::confirmerReservation,
                onFermer = vm::fermerReservation,
            )
        }
    }
}

// -------------------------------------------------------------------------
// le champ "URL du serveur". La version Flutter avait besoin d'un
// StatefulWidget dédié pour créer, puis libérer, un TextEditingController.
// En Compose, un champ de texte n'a pas d'état caché : il AFFICHE value et
// SIGNALE chaque frappe par onValueChange - à nous de mettre à jour l'état,
// ce qui provoque le réaffichage du champ avec la nouvelle valeur.
// C'est le "v-model" de Vue.js écrit en deux moitiés explicites.
// -------------------------------------------------------------------------
@Composable
private fun ServerUrlField(label: String) {
    val settings = LocalSettings.current
    OutlinedTextField(
        value = settings.apiBaseUrl,
        onValueChange = settings::changerUrl,
        label = { Text(label) },
        singleLine = true,
        // clavier adapté à la saisie d'une URL (touches "/", ".", ":"...)
        keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
        modifier = Modifier.widthIn(max = 360.dp).fillMaxWidth(),
    )
}

// -------------------------------------------------------------------------
// les deux boutons FR / EN de la barre de titre : la langue courante est
// affichée en gras et en blanc, l'autre en blanc atténué
// -------------------------------------------------------------------------
@Composable
private fun SelecteurLangue(translations: TranslationsService) {
    listOf("fr", "en").forEach { langue ->
        val courante = langue == translations.langueCourante
        TextButton(onClick = { translations.changerLangue(langue) }) {
            Text(
                text = langue.uppercase(),
                fontWeight = if (courante) FontWeight.Bold else FontWeight.Normal,
                color = MaterialTheme.colorScheme.onPrimary.copy(alpha = if (courante) 1f else 0.6f),
            )
        }
    }
}

Commentons ce code :

  • lignes 70-72 : le schéma de couleurs : un bleu en couleur principale ([primary]), du blanc pour le texte posé dessus ([onPrimary]). Les autres couleurs sont celles, par défaut, de Material 3 ;
  • lignes 76-78 : [RdvMedecinsApp] applique le thème à tout l’écran ([MaterialTheme]), comme le [MaterialApp] de Flutter ;
  • ligne 85 : [@OptIn(ExperimentalMaterial3Api::class)] - [TopAppBar] est encore marquée « expérimentale » par Material 3 : l’annotation indique qu’on l’utilise en connaissance de cause (sans elle, le compilateur refuse le code) ;
  • lignes 89-91 : les services, lus dans les CompositionLocal - l’équivalent des [context.read<...>()] / [context.watch<...>()] ;
  • ligne 94 : [val t = translations::t] - une RÉFÉRENCE à la méthode [t] de l’objet [translations] : on écrit ensuite [t("APP.TITLE")], comme dans les autres variantes du cours ;
  • ligne 101 : [viewModel { AppViewModel(auth, rdv) }] - RETROUVE le ViewModel de cet écran s’il existe déjà (après une rotation), ou le CRÉE avec le bloc fourni la première fois. Le ViewModel survit ainsi aux recréations de l’activité ;
  • ligne 104 : la largeur de l’écran, en dp, lue dans la configuration courante : en dessous de 600 dp (téléphone en portrait), la barre de titre est allégée - titre court (ligne 116) et nom de l’utilisateur masqué (ligne 145) ;
  • ligne 108 : [Scaffold(topBar = { ... }) { margesSysteme -> ... }] - la structure standard d’un écran Material, comme en Flutter. Le dernier paramètre, un lambda, est le contenu de l’écran ; il reçoit les marges à respecter (barre de titre et barres du système) ;
  • ligne 110 : [TopAppBar] - la barre de titre, colorée avec la couleur principale du thème ;
  • lignes 127-132 : [actions = { ... }] - les éléments alignés à droite de la barre : le sélecteur de langue, TOUJOURS visible ; puis, avec un simple [if], le badge du rôle, le nom et le bouton de déconnexion, qui n’existent qu’une fois connecté. Comme [auth.estConnecte] est un état observable, la barre est recomposée automatiquement à la connexion et à la déconnexion ;
  • ligne 149 : [onClick = vm::deconnecter] - une référence de méthode passée comme gestionnaire de clic ;
  • lignes 159-165 : le contenu : une [Column] défilante ([verticalScroll], l’équivalent du [SingleChildScrollView] de Flutter) ;
  • ligne 172 : le message d’erreur du ViewModel, s’il y en a un : [vm.erreur?.let { message -> ... }] n’émet le bloc que si [erreur] n’est pas [null] ;
  • lignes 185-197 : la « garde » : pas connecté, l’écran de connexion ; connecté, le choix du médecin et du jour, puis l’agenda. [peutModifier = auth.estAdmin] masque les boutons de réservation pour le rôle USER ;
  • lignes 204-209 : la boîte de dialogue de réservation, émise tant que [vm.creneauEnReservation] n’est pas [null] (cf. [AppViewModel] et [BookingDialog]) ;
  • lignes 224-232 : [ServerUrlField] - le champ « URL du serveur ». En Compose, un champ de saisie n’a pas d’état caché : il AFFICHE [value] et SIGNALE chaque frappe par [onValueChange]. Ici, chaque frappe est transmise à [settings.changerUrl(...)], qui modifie l’état observé - et le champ est recomposé avec la nouvelle valeur. La version Flutter avait besoin d’un [TextEditingController], et donc d’un [StatefulWidget] dédié pour le créer et le libérer ;
  • lignes 242-245 : [SelecteurLangue] - deux [TextButton] « FR » et « EN », générés par une boucle sur la liste des langues ; la langue courante est en gras.

5.4.5. AppViewModel.kt

L’état et la logique de l’écran racine : la partie [_AppRootState] de [lib/app.dart].

// =========================================================================
// AppViewModel.kt
// -------------------------------------------------------------------------
// L'ÉTAT et la LOGIQUE de l'écran principal : la partie "_AppRootState"
// du lib/app.dart de la version Flutter (médecins, patients, agenda
// affiché, erreur, dernière recherche...) et les réactions aux actions de
// l'utilisateur (rechercher un agenda, réserver, annuler).
//
// Pourquoi un ViewModel, plutôt que des états locaux dans la fonction
// @Composable AppRoot (cf. App.kt) ? Parce que l'Activity est détruite et
// recréée à chaque rotation de l'écran (cf. RdvMedecinsApplication) : un
// état local serait perdu - l'agenda affiché disparaîtrait au moindre
// basculement du téléphone. Un ViewModel (bibliothèque Android Jetpack
// "lifecycle") SURVIT à ces recréations : Android le conserve, et le
// redonne à la nouvelle Activity. Il n'est détruit que lorsque l'écran
// est quitté pour de bon. Flutter n'a pas ce problème : la rotation n'y
// détruit rien, seule la taille de l'écran change.
//
// Le découpage obtenu est classique sur Android : App.kt AFFICHE, ce
// ViewModel DÉCIDE et appelle les services.
// =========================================================================

package fr.rdvmedecins.client

import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import fr.rdvmedecins.client.core.models.AgendaMedecinJour
import fr.rdvmedecins.client.core.models.Client
import fr.rdvmedecins.client.core.models.CreneauJson
import fr.rdvmedecins.client.core.models.Medecin
import fr.rdvmedecins.client.core.models.RvJson
import fr.rdvmedecins.client.core.services.AuthService
import fr.rdvmedecins.client.core.services.RdvService
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch

class AppViewModel(
    private val auth: AuthService,
    private val rdv: RdvService,
) : ViewModel() {

    // --- état affiché par App.kt (lecture publique, écriture privée) ------
    var medecins by mutableStateOf<List<Medecin>>(emptyList())
        private set
    var clients by mutableStateOf<List<Client>>(emptyList())
        private set
    var agenda by mutableStateOf<AgendaMedecinJour?>(null)
        private set
    var erreur by mutableStateOf<String?>(null)
        private set

    // le créneau en cours de réservation : non null = la boîte de dialogue
    // de réservation est ouverte (cf. App.kt et BookingDialog.kt)
    var creneauEnReservation by mutableStateOf<CreneauJson?>(null)
        private set

    // la dernière recherche (médecin + jour), pour rafraîchir l'agenda après
    // une réservation ou une annulation. Jamais affichée : de simples
    // propriétés suffisent, inutile d'en faire des états Compose.
    private var dernierIdMedecin: Int? = null
    private var dernierJour: String? = null

    init {
        // snapshotFlow { ... } transforme la lecture d'un état Compose en un
        // FLUX de valeurs : il émet la valeur courante de auth.estConnecte
        // tout de suite, puis chaque nouvelle valeur. C'est l'équivalent
        // exact du "watch(() => auth.estConnecte, ..., { immediate: true })"
        // de Vue.js - et de l'abonnement addListener(...) + appel initial
        // que la version Flutter devait écrire à la main.
        // viewModelScope : les coroutines lancées dans cette portée sont
        // automatiquement annulées quand le ViewModel est détruit (plus
        // besoin du removeListener(...) de dispose() côté Flutter).
        viewModelScope.launch {
            snapshotFlow { auth.estConnecte }.collect { connecte ->
                if (connecte) {
                    chargerListesInitiales()
                } else {
                    reinitialiser()
                }
            }
        }
    }

    // -----------------------------------------------------------------------
    // à la connexion (ou à la restauration d'une session mémorisée) : on
    // charge une bonne fois pour toutes les médecins et les patients. Deux
    // coroutines distinctes : les deux requêtes partent EN PARALLÈLE.
    // -----------------------------------------------------------------------
    private fun chargerListesInitiales() {
        lancer { medecins = rdv.getAllMedecins() }
        lancer { clients = rdv.getAllClients() }
    }

    // -----------------------------------------------------------------------
    // à la déconnexion (bouton, ou jeton refusé par le serveur) : on purge
    // l'état métier, pour qu'un nouvel utilisateur ne voie pas, même un
    // instant, l'agenda du précédent
    // -----------------------------------------------------------------------
    private fun reinitialiser() {
        medecins = emptyList()
        clients = emptyList()
        agenda = null
        erreur = null
        creneauEnReservation = null
        dernierIdMedecin = null
        dernierJour = null
    }

    // bouton "Se déconnecter" : il suffit de déconnecter AuthService -
    // snapshotFlow (cf. init) déclenche ensuite reinitialiser()
    fun deconnecter() {
        auth.seDeconnecter()
    }

    // -----------------------------------------------------------------------
    // bouton "Voir l'agenda" de DoctorDayPicker
    // -----------------------------------------------------------------------
    fun rechercherAgenda(idMedecin: Int, jour: String) {
        erreur = null
        dernierIdMedecin = idMedecin
        dernierJour = jour
        chargerAgenda()
    }

    // -----------------------------------------------------------------------
    // bouton "Réserver" d'AgendaView : on se contente d'OUVRIR la boîte de
    // dialogue, en mémorisant le créneau concerné. Avec Compose, une boîte
    // de dialogue fait partie de l'affichage comme le reste : elle est
    // affichée tant que creneauEnReservation n'est pas null (cf. App.kt) -
    // le même principe que la prop "ouvert" du BookingDialog.vue de la
    // variante Vue.js, à l'opposé du showDialog(...) de Flutter.
    // -----------------------------------------------------------------------
    fun demanderReservation(creneau: CreneauJson) {
        creneauEnReservation = creneau
    }

    // bouton "Annuler" (ou appui en dehors) de la boîte de dialogue
    fun fermerReservation() {
        creneauEnReservation = null
    }

    // bouton "Confirmer" de la boîte de dialogue
    fun confirmerReservation(idClient: Int) {
        val creneau = creneauEnReservation ?: return
        val jour = dernierJour ?: return
        creneauEnReservation = null // ferme la boîte de dialogue
        lancer {
            rdv.ajouterRv(jour, idClient, creneau.id)
            chargerAgenda() // le créneau apparaît maintenant occupé
        }
    }

    // -----------------------------------------------------------------------
    // bouton "Annuler" d'un créneau occupé, dans AgendaView
    // -----------------------------------------------------------------------
    fun annulerRv(rv: RvJson) {
        lancer {
            rdv.supprimerRv(rv.id)
            chargerAgenda() // le créneau redevient libre
        }
    }

    private fun chargerAgenda() {
        val idMedecin = dernierIdMedecin ?: return
        val jour = dernierJour ?: return
        lancer { agenda = rdv.getAgendaMedecinJour(idMedecin, jour) }
    }

    // -----------------------------------------------------------------------
    // lance [action] dans une coroutine du ViewModel, et transforme toute
    // exception en message d'erreur affiché à l'écran. Factoriser ce
    // try/catch évite de le répéter dans chaque méthode ci-dessus.
    // Une CancellationException n'est PAS une erreur : c'est le signal
    // d'annulation d'une coroutine (ViewModel détruit), qu'il faut laisser
    // se propager.
    // -----------------------------------------------------------------------
    private fun lancer(action: suspend () -> Unit) {
        viewModelScope.launch {
            try {
                action()
            } catch (e: CancellationException) {
                throw e
            } catch (e: Exception) {
                Log.w("RdvMedecins", "échec d'un appel au serveur", e) // cf. Logcat
                erreur = e.message ?: e.toString()
            }
        }
    }
}

Commentons ce code :

  • ligne 45 : la classe étend [ViewModel] ; elle reçoit les deux services dont elle a besoin par son constructeur ;
  • lignes 48-54 : l’état affiché par [App.kt] : des propriétés [by mutableStateOf(...)], lisibles par tous ([App.kt] les lit) mais modifiables uniquement par le ViewModel ([private set]) ;
  • ligne 59 : [creneauEnReservation] - le créneau en cours de réservation. Non [null], la boîte de dialogue de réservation est affichée ;
  • lignes 65-66 : la dernière recherche (médecin et jour), pour rafraîchir l’agenda après une réservation ou une annulation. De simples propriétés privées : elles ne sont jamais affichées ;
  • lignes 78-83 : [snapshotFlow { auth.estConnecte }] transforme la lecture d’un état Compose en un FLUX de valeurs : la valeur courante tout de suite, puis chaque nouvelle valeur. [collect { ... }] traite chacune : connecté, on charge médecins et patients ; déconnecté, on purge l’état. C’est le [watch(..., { immediate: true })] de Vue.js, que la version Flutter devait reproduire avec [addListener(...)] et un appel initial écrit à la main ;
  • lignes 94-96 : [chargerListesInitiales()] - deux coroutines distinctes : les deux requêtes partent EN PARALLÈLE ;
  • lignes 116-117 : [deconnecter()] se contente de déconnecter [AuthService] : c’est [snapshotFlow] qui déclenche ensuite la purge de l’état. Il en va de même quand c’est [AuthInterceptor] qui déconnecte l’utilisateur (jeton refusé par le serveur) : le ViewModel n’a pas à savoir d’où vient la déconnexion ;
  • lignes 138-139 : [demanderReservation(...)] n’OUVRE PAS de boîte de dialogue : il mémorise le créneau. L’affichage suit l’état - c’est [App.kt] qui émet [BookingDialog] tant que [creneauEnReservation] n’est pas [null]. Cette boîte de dialogue survit donc, elle aussi, à la rotation de l’écran (cf. chapitre 5) ;
  • lignes 148-154 : [confirmerReservation(...)] - ferme la boîte de dialogue, puis appelle le serveur et rafraîchit l’agenda : le créneau apparaît occupé ;
  • lignes 182-190 : [lancer(action)] - lance [action] (une fonction [suspend]) dans une coroutine du ViewModel, et transforme toute exception en message d’erreur affiché. La [CancellationException] est relancée : ce n’est pas une erreur, c’est le signal d’annulation d’une coroutine (ViewModel détruit), qu’il faut laisser se propager. [Log.w(...)] écrit l’erreur complète dans le journal d’Android, consultable dans l’onglet [Logcat] d’Android Studio.

5.5. La couche core/models

5.5.1. core/models/RdvModels.kt

Portage de [lib/core/models/rdv_models.dart] : les classes de données échangées avec le serveur.

// =========================================================================
// RdvModels.kt
// -------------------------------------------------------------------------
// Les classes de données échangées avec le serveur NestJS : le miroir, en
// Kotlin, du JSON renvoyé par ses routes. Portage de
// lib/core/models/rdv_models.dart (client Flutter de ce cours).
//
// Différence majeure avec la version Flutter : AUCUN constructeur
// fromJson(...) écrit à la main. L'annotation @Serializable demande au
// plugin de compilation kotlinx.serialization (cf. app/build.gradle.kts)
// de GÉNÉRER, à la compilation, le code qui lit et écrit chaque classe en
// JSON. Comme en Dart (et contrairement à TypeScript), les types sont
// vérifiés à l'exécution : une clé obligatoire absente, ou d'un type
// inattendu, lève une exception immédiate (SerializationException).
//
// Une "data class" Kotlin (cf. le cours Kotlin) apporte en plus, sans une
// ligne de code : equals/hashCode (comparaison par valeur), toString et
// copy(...). Toutes les propriétés sont en lecture seule (val) : ces
// objets sont IMMUABLES, ce qui convient parfaitement à Jetpack Compose,
// qui compare l'ancien et le nouvel état pour savoir quoi redessiner.
// =========================================================================

package fr.rdvmedecins.client.core.models

import kotlinx.serialization.Serializable

// un médecin, tel que renvoyé par GET /getAllMedecins (le serveur envoie
// aussi un champ "version", ignoré ici grâce à ignoreUnknownKeys, cf.
// Reponse.kt)
@Serializable
data class Medecin(
    val id: Int,
    val titre: String,
    val nom: String,
    val prenom: String,
)

// un patient (client du cabinet médical), renvoyé par GET /getAllClients
@Serializable
data class Client(
    val id: Int,
    val titre: String,
    val nom: String,
    val prenom: String,
)

// un créneau horaire de consultation : hDebut/mDebut -> hFin/mFin
@Serializable
data class CreneauJson(
    val id: Int,
    val hDebut: Int,
    val mDebut: Int,
    val hFin: Int,
    val mFin: Int,
)

// un rendez-vous : un jour, un patient, un créneau. Les valeurs par défaut
// "= null" rendent les clés facultatives dans le JSON reçu.
@Serializable
data class RvJson(
    val id: Int,
    val jour: String, // format "yyyy-MM-dd"
    val client: Client? = null,
    val creneau: CreneauJson? = null,
)

// une ligne de l'agenda : un créneau et, s'il est occupé, son rendez-vous
@Serializable
data class CreneauAgenda(
    val creneau: CreneauJson,
    val rv: RvJson? = null, // null = créneau libre
)

// l'agenda d'un médecin pour un jour, renvoyé par
// GET /getAgendaMedecinJour/{idMedecin}/{jour}
@Serializable
data class AgendaMedecinJour(
    val medecin: Medecin,
    val jour: String,
    val creneaux: List<CreneauAgenda>,
)

// -------------------------------------------------------------------------
// Authentification
// -------------------------------------------------------------------------

// les deux rôles de l'application. Les noms des constantes sont
// EXACTEMENT les chaînes envoyées par le serveur ("ADMIN", "USER") :
// kotlinx.serialization convertit donc directement la chaîne JSON en
// constante d'enum, là où la version Flutter devait écrire à la main
// Role.depuisChaine(...) et versChaine().
@Serializable
enum class Role { ADMIN, USER }

// le corps JSON envoyé à POST /login
@Serializable
data class LoginDemande(
    val login: String,
    val password: String,
)

// le contenu de "data" dans la réponse de POST /login
@Serializable
data class LoginResultat(
    val accessToken: String, // le jeton JWT à présenter à chaque requête suivante
    val login: String,
    val nom: String,
    val role: Role,
)

// les corps JSON envoyés à POST /ajouterRv et POST /supprimerRv
@Serializable
data class AjouterRvDemande(
    val jour: String,
    val idClient: Int,
    val idCreneau: Int,
)

@Serializable
data class SupprimerRvDemande(
    val idRv: Int,
)

Commentons ce code :

  • lignes 30-31 : [@Serializable data class Medecin(...)] - une « data class » (cf. le cours Kotlin) : [equals]/[hashCode] par valeur, [toString], [copy(...)], sans une ligne de code. L’annotation [@Serializable] demande au plugin kotlinx.serialization de générer, à la compilation, le code qui lit et écrit la classe en JSON - là où la version Flutter écrivait à la main un constructeur [fromJson(...)] par classe ;
  • ligne 63 : [val client: Client? = null] - un type nullable, avec une valeur par défaut : la clé [client] peut être absente du JSON reçu, ou valoir [null] ;
  • ligne 71 : [val rv: RvJson? = null] - [null] signifie que le créneau est libre ;
  • ligne 93 : [enum class Role { ADMIN, USER }] - les noms des constantes sont exactement les chaînes envoyées par le serveur : kotlinx.serialization convertit directement ["ADMIN"] en [Role.ADMIN], là où la version Flutter écrivait [Role.depuisChaine(...)] et [versChaine()] ;
  • lignes 97, 113 et 120 : les classes des corps JSON ENVOYÉS au serveur ([LoginDemande], [AjouterRvDemande], [SupprimerRvDemande]) : la même annotation [@Serializable] sert à l’encodage. La version Flutter construisait ces corps avec des [Map] littérales.

5.5.2. core/models/Reponse.kt

Le décodage de l’enveloppe [{ status, data }] commune à toutes les réponses métier du serveur.

// =========================================================================
// Reponse.kt
// -------------------------------------------------------------------------
// Toutes les réponses métier du serveur NestJS ont la même forme :
//
//     { "status": 0, "data": ... }
//
// où status vaut 0 en cas de succès (cf. StatutReponse côté serveur).
// La version Flutter décodait cette enveloppe avec une classe générique
// Reponse<T>. On procède ici en DEUX temps, pour une raison précise : en
// cas d'erreur, le serveur place dans "data" une liste de messages
// (List<String>) là où, en cas de succès, on attend par exemple un agenda.
// Décoder d'emblée {status, data} en Reponse<AgendaMedecinJour> échouerait
// donc sur une réponse d'erreur, avant même d'avoir pu lire le statut.
//   1. on lit l'enveloppe comme un arbre JSON générique (JsonObject) ;
//   2. on vérifie status ; s'il vaut 0 seulement, on décode "data" dans le
//      type attendu.
// =========================================================================

package fr.rdvmedecins.client.core.models

import kotlinx.serialization.DeserializationStrategy
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive

// la configuration JSON partagée par tout le client :
// - ignoreUnknownKeys : le serveur envoie des champs que ce client
//   n'utilise pas (ex : "version" d'un médecin) - on les ignore plutôt que
//   de lever une exception ;
// - explicitNulls = false : à l'encodage, une propriété null n'est pas
//   écrite (sans effet sur les classes de ce client, mais plus proche du
//   comportement de JSON.stringify côté JavaScript).
val JsonRdv = Json {
    ignoreUnknownKeys = true
    explicitNulls = false
}

// exception levée quand le serveur répond avec un statut métier différent
// de 0 (ressource introuvable, date invalide, erreur serveur)
class ReponseException(val status: Int) :
    Exception("Le serveur a répondu avec le statut d'erreur $status")

// décode le corps d'une réponse {status, data} et renvoie "data" dans le
// type T décrit par [deserialiseur] (ex : Medecin.serializer(), ou
// ListSerializer(Medecin.serializer()) pour une liste de médecins)
fun <T> decoderReponse(corps: String, deserialiseur: DeserializationStrategy<T>): T {
    // 1. l'enveloppe, sous forme d'arbre JSON générique
    val enveloppe = JsonRdv.parseToJsonElement(corps).jsonObject
    // 2. le statut métier
    val status = enveloppe.getValue("status").jsonPrimitive.int
    if (status != 0) {
        throw ReponseException(status)
    }
    // 3. "data", décodé seulement maintenant, dans le type attendu
    val data = enveloppe["data"] ?: JsonNull
    return JsonRdv.decodeFromJsonElement(deserialiseur, data)
}

Commentons ce code :

  • lignes 36-38 : [JsonRdv] - la configuration JSON partagée par tout le client. [ignoreUnknownKeys = true] : le serveur envoie des champs que le client n’utilise pas ([version] d’un médecin, par exemple) ; sans ce réglage, kotlinx.serialization lèverait une exception à la première clé inconnue ;
  • ligne 43 : [ReponseException] - l’exception levée quand le serveur répond avec un statut métier différent de 0 ;
  • ligne 49 : [decoderReponse(corps, deserialiseur)] - une fonction générique : le paramètre [deserialiseur] (par exemple [Medecin.serializer()], généré par le plugin) indique dans quel type [T] décoder [data] ;
  • lignes 51-59 : le décodage se fait en DEUX temps. D’abord l’enveloppe, lue comme un arbre JSON générique ([JsonObject]) ; puis, seulement si [status] vaut 0, [data] est décodé dans le type attendu. Raison : en cas d’erreur, le serveur place dans [data] une liste de messages là où, en cas de succès, on attend par exemple un agenda. Décoder d’emblée l’enveloppe complète dans le type de succès échouerait sur une réponse d’erreur, avant même d’avoir pu lire le statut.

5.6. La couche core/services

5.6.1. core/services/SettingsService.kt

Portage de [lib/core/services/settings_service.dart] : l’URL du serveur.

// =========================================================================
// SettingsService.kt
// -------------------------------------------------------------------------
// Portage de lib/core/services/settings_service.dart : détient l'URL du
// serveur NestJS, modifiable depuis l'écran (champ "URL du serveur").
//
// La version Flutter étendait ChangeNotifier et devait appeler
// notifyListeners() après chaque modification. Ici, la propriété est
// déclarée avec "by mutableStateOf(...)" : c'est un ÉTAT OBSERVABLE de
// Jetpack Compose. Toute fonction @Composable qui LIT apiBaseUrl pendant
// son affichage est automatiquement réaffichée quand la valeur change -
// sans notifyListeners(), sans context.watch<...>() : Compose enregistre
// lui-même chaque lecture (le même principe que reactive(...) / ref(...)
// côté Vue.js).
//
// Petite amélioration par rapport à la version Flutter : l'URL est
// mémorisée dans les SharedPreferences du téléphone, pour ne pas avoir à
// la retaper à chaque lancement de l'application (sur mobile, elle diffère
// presque toujours de la valeur par défaut, cf. chapitre 5).
// =========================================================================

package fr.rdvmedecins.client.core.services

import android.content.SharedPreferences
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.core.content.edit

class SettingsService(private val prefs: SharedPreferences) {

    // la valeur initiale est relue dans les préférences, de façon
    // SYNCHRONE : getString(...) répond immédiatement (les préférences sont
    // chargées en mémoire au premier accès). "private set" : seule cette
    // classe peut modifier l'URL, via changerUrl(...).
    var apiBaseUrl: String by mutableStateOf(prefs.getString(CLE_URL, null) ?: URL_PAR_DEFAUT)
        private set

    fun changerUrl(url: String) {
        if (url == apiBaseUrl) {
            return // évite un réaffichage et une écriture inutiles
        }
        apiBaseUrl = url
        // edit { ... } : fonction d'extension de androidx.core (core-ktx) ;
        // elle ouvre un éditeur, exécute le bloc, puis appelle apply(), qui
        // écrit sur le disque en arrière-plan, sans bloquer l'interface.
        prefs.edit { putString(CLE_URL, url) }
    }

    companion object {
        // 10.0.2.2 : dans l'émulateur Android, cette adresse spéciale
        // désigne la machine hôte, celle où tourne le serveur NestJS
        // (localhost désignerait l'émulateur lui-même, cf. chapitre 5)
        const val URL_PAR_DEFAUT = "http://10.0.2.2:8080"
        private const val CLE_URL = "rdvmedecins.apiBaseUrl"
    }
}

Commentons ce code :

  • lignes 36-37 : [var apiBaseUrl: String by mutableStateOf(...)] - un état observable, initialisé avec la valeur mémorisée dans les préférences, ou la valeur par défaut. [private set] : seule cette classe peut le modifier ;
  • ligne 39 : [changerUrl(...)] - la méthode ne peut pas s’appeler [setApiBaseUrl] : ce nom est déjà celui du « setter » privé que Kotlin génère pour la propriété [apiBaseUrl] (les deux auraient la même signature pour la JVM) ;
  • ligne 47 : [prefs.edit { putString(...) }] - une fonction d’extension de la bibliothèque [core-ktx] : elle ouvre un éditeur, exécute le bloc, puis appelle [apply()], qui écrit sur le disque en arrière-plan. L’URL est ainsi retrouvée au lancement suivant (une petite amélioration par rapport à la version Flutter, utile sur un téléphone où l’URL diffère presque toujours de la valeur par défaut) ;
  • ligne 54 : [URL_PAR_DEFAUT = "http://10.0.2.2:8080"] - l’adresse du serveur vue depuis l’émulateur Android (cf. chapitre 5). La version Flutter, qui visait aussi le web et le bureau, utilisait [localhost].

5.6.2. core/services/AuthService.kt

Portage de [lib/core/services/auth_service.dart] : la session de l’utilisateur connecté, la connexion et la déconnexion.

// =========================================================================
// AuthService.kt
// -------------------------------------------------------------------------
// Portage de lib/core/services/auth_service.dart : la session de
// l'utilisateur connecté (jeton JWT, login, nom, rôle), la connexion
// (POST /login) et la déconnexion.
//
// Comme SettingsService, les propriétés affichées par l'interface sont des
// états Compose (mutableStateOf) : l'écran bascule AUTOMATIQUEMENT de
// l'écran de connexion à l'agenda dès que estConnecte passe à true.
//
// La session est mémorisée dans les SharedPreferences : un utilisateur qui
// relance l'application la retrouve sans se reconnecter (tant que le jeton
// n'a pas expiré - cf. AuthInterceptor pour le cas du jeton expiré).
// Différence avec Flutter : SharedPreferences (l'API Android d'origine,
// dont le package Flutter shared_preferences a repris le nom) se lit de
// façon SYNCHRONE. La session est donc restaurée dès le constructeur (bloc
// init), sans méthode init() asynchrone à attendre avant d'afficher le
// premier écran.
// =========================================================================

package fr.rdvmedecins.client.core.services

import android.content.SharedPreferences
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.core.content.edit
import fr.rdvmedecins.client.core.models.JsonRdv
import fr.rdvmedecins.client.core.models.LoginDemande
import fr.rdvmedecins.client.core.models.LoginResultat
import fr.rdvmedecins.client.core.models.Role
import fr.rdvmedecins.client.core.models.decoderReponse
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.IOException

class AuthService(
    private val settings: SettingsService,
    private val prefs: SharedPreferences,
    // un client HTTP SANS l'intercepteur d'authentification : POST /login
    // est la seule route publique du serveur, il n'y a pas de jeton à
    // envoyer (cf. RdvMedecinsApplication)
    private val http: OkHttpClient,
) {
    // --- état de la session, observé par l'interface ---------------------
    var estConnecte by mutableStateOf(false)
        private set
    var login by mutableStateOf<String?>(null)
        private set
    var nom by mutableStateOf<String?>(null)
        private set
    var role by mutableStateOf<Role?>(null)
        private set

    // le jeton n'est jamais AFFICHÉ : inutile d'en faire un état Compose.
    // Il est en revanche lu par AuthInterceptor sur un thread réseau :
    // @Volatile garantit que ce thread voit toujours la dernière valeur
    // écrite par le thread principal.
    @Volatile
    var accessToken: String? = null
        private set

    // propriété calculée : relit role à chaque accès (et, lue pendant un
    // affichage, abonne l'affichage aux changements de role)
    val estAdmin: Boolean
        get() = role == Role.ADMIN

    init {
        restaurerSession()
    }

    // -----------------------------------------------------------------------
    // relit une éventuelle session mémorisée lors d'un précédent lancement
    // -----------------------------------------------------------------------
    private fun restaurerSession() {
        val brut = prefs.getString(CLE_SESSION, null) ?: return // pas de session mémorisée
        try {
            // la session a été mémorisée sous forme d'un LoginResultat
            // sérialisé en JSON (cf. seConnecter) : on le relit tel quel
            appliquerSession(JsonRdv.decodeFromString(LoginResultat.serializer(), brut))
        } catch (e: Exception) {
            // contenu illisible (ex : format d'une ancienne version de
            // l'application) : on l'oublie, l'utilisateur se reconnectera
            prefs.edit { remove(CLE_SESSION) }
        }
    }

    // -----------------------------------------------------------------------
    // POST /login. "suspend" : cette fonction peut se mettre en pause le
    // temps que le serveur réponde, SANS bloquer le thread qui l'appelle -
    // l'équivalent d'une fonction async renvoyant un Future<LoginResultat>
    // côté Dart. Elle lève une exception si la connexion échoue.
    // -----------------------------------------------------------------------
    suspend fun seConnecter(login: String, password: String): LoginResultat {
        // withContext(Dispatchers.IO) : le bloc s'exécute sur un thread
        // réservé aux entrées/sorties. Android INTERDIT tout accès réseau
        // depuis le thread principal (NetworkOnMainThreadException) : c'est
        // lui qui dessine l'interface, il ne doit jamais attendre.
        val resultat = withContext(Dispatchers.IO) {
            val corps = JsonRdv.encodeToString(LoginDemande.serializer(), LoginDemande(login, password))
            val requete = Request.Builder()
                .url("${settings.apiBaseUrl}/login")
                .post(corps.toRequestBody(TYPE_JSON))
                .build()
            // execute() : appel BLOQUANT - sans danger ici, on est sur un
            // thread d'entrées/sorties. use { } ferme la réponse à la fin du
            // bloc, quoi qu'il arrive (l'équivalent du try-with-resources de
            // Java, cf. le cours Kotlin).
            http.newCall(requete).execute().use { reponse ->
                // mauvais identifiants : le serveur répond 401 Unauthorized
                if (!reponse.isSuccessful) {
                    throw IOException("Échec de connexion (HTTP ${reponse.code})")
                }
                decoderReponse(reponse.body!!.string(), LoginResultat.serializer())
            }
        }
        // de retour sur le thread de l'appelant (le thread principal) :
        // c'est ici, et non sur le thread d'entrées/sorties, qu'on modifie
        // l'état observé par l'interface
        appliquerSession(resultat)
        prefs.edit { putString(CLE_SESSION, JsonRdv.encodeToString(LoginResultat.serializer(), resultat)) }
        return resultat
    }

    // -----------------------------------------------------------------------
    // déconnexion : on oublie la session, en mémoire et dans les préférences.
    // Doit être appelée sur le thread principal (cf. AuthInterceptor).
    // -----------------------------------------------------------------------
    fun seDeconnecter() {
        accessToken = null
        login = null
        nom = null
        role = null
        estConnecte = false
        prefs.edit { remove(CLE_SESSION) }
    }

    private fun appliquerSession(session: LoginResultat) {
        accessToken = session.accessToken
        login = session.login
        nom = session.nom
        role = session.role
        estConnecte = true
    }

    companion object {
        private const val CLE_SESSION = "rdvmedecins.session"
        private val TYPE_JSON = "application/json; charset=utf-8".toMediaType()
    }
}

Commentons ce code :

  • lignes 51-57 : l’état de la session, observé par l’interface : [estConnecte], [login], [nom], [role] ;
  • lignes 64-65 : [accessToken] n’est jamais affiché : inutile d’en faire un état Compose. Il est en revanche lu par [AuthInterceptor] sur un thread réseau : [@Volatile] garantit que ce thread voit toujours la dernière valeur écrite par le thread principal ;
  • lignes 70-71 : [estAdmin] - une propriété calculée ([get() = ...]), relue à chaque accès ;
  • lignes 73-85 : le bloc [init] restaure une éventuelle session mémorisée, dès la construction du service : les préférences se lisent de façon synchrone. La session est mémorisée sous la forme d’un [LoginResultat] sérialisé en JSON, relu avec [decodeFromString(LoginResultat.serializer(), brut)] ;
  • ligne 99 : [suspend fun seConnecter(...)] - une fonction suspendue : elle se met en pause le temps que le serveur réponde, sans bloquer le thread appelant ;
  • ligne 104 : [withContext(Dispatchers.IO) { ... }] - la requête est exécutée sur un thread d’entrées/sorties ; la valeur de la dernière expression du bloc (le [LoginResultat] décodé) en est le résultat ;
  • lignes 106-109 : la requête OkHttp : [Request.Builder()], l’URL, puis [.post(corps.toRequestBody(TYPE_JSON))] - le corps JSON, avec son type MIME ;
  • ligne 114 : [execute()] exécute la requête de façon BLOQUANTE - sans danger sur un thread d’entrées/sorties. [use { reponse -> ... }] ferme la réponse à la fin du bloc, quoi qu’il arrive : l’équivalent du try-with-resources de Java ;
  • lignes 116-117 : mauvais identifiants : le serveur répond 401 Unauthorized ; on lève une exception, que [LoginPage] transforme en message d’erreur ;
  • lignes 125-126 : de retour sur le thread appelant (le thread principal), on applique la session - ce qui modifie l’état observé : l’écran passe de lui-même à l’agenda - puis on la mémorise dans les préférences ;
  • lignes 134-140 : [seDeconnecter()] - oublie la session, en mémoire et dans les préférences.

5.6.3. core/services/RdvService.kt

Portage de [lib/core/services/rdv_service.dart] : une méthode par route métier utilisée par le client.

// =========================================================================
// RdvService.kt
// -------------------------------------------------------------------------
// Portage de lib/core/services/rdv_service.dart : une méthode par route
// métier du serveur NestJS utilisée par ce client. Chaque méthode est une
// fonction "suspend" (cf. AuthService.seConnecter) qui renvoie
// directement la donnée utile ("data" de l'enveloppe {status, data}), ou
// lève une exception.
//
// Le client HTTP reçu au constructeur contient AuthInterceptor (cf.
// RdvMedecinsApplication) : aucune méthode ci-dessous n'a donc à se
// préoccuper du jeton JWT.
//
// Ce service n'a AUCUN état observable (rien n'y varie une fois construit) :
// c'est une classe ordinaire, sans mutableStateOf - le pendant du
// Provider.value(value: rdv) simple (et non ChangeNotifierProvider) de la
// version Flutter.
// =========================================================================

package fr.rdvmedecins.client.core.services

import fr.rdvmedecins.client.core.models.AgendaMedecinJour
import fr.rdvmedecins.client.core.models.AjouterRvDemande
import fr.rdvmedecins.client.core.models.Client
import fr.rdvmedecins.client.core.models.JsonRdv
import fr.rdvmedecins.client.core.models.Medecin
import fr.rdvmedecins.client.core.models.RvJson
import fr.rdvmedecins.client.core.models.SupprimerRvDemande
import fr.rdvmedecins.client.core.models.decoderReponse
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.DeserializationStrategy
import kotlinx.serialization.builtins.ListSerializer
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 RdvService(
    private val http: OkHttpClient,
    private val settings: SettingsService,
) {
    // -----------------------------------------------------------------------
    // les routes GET
    // -----------------------------------------------------------------------
    suspend fun getAllMedecins(): List<Medecin> =
        appeler(requeteGet("/getAllMedecins"), ListSerializer(Medecin.serializer()))

    suspend fun getAllClients(): List<Client> =
        appeler(requeteGet("/getAllClients"), ListSerializer(Client.serializer()))

    suspend fun getAgendaMedecinJour(idMedecin: Int, jour: String): AgendaMedecinJour =
        appeler(requeteGet("/getAgendaMedecinJour/$idMedecin/$jour"), AgendaMedecinJour.serializer())

    // -----------------------------------------------------------------------
    // les routes POST (réservées au rôle ADMIN par le serveur)
    // -----------------------------------------------------------------------
    suspend fun ajouterRv(jour: String, idClient: Int, idCreneau: Int): RvJson {
        val corps = JsonRdv.encodeToString(
            AjouterRvDemande.serializer(),
            AjouterRvDemande(jour, idClient, idCreneau),
        )
        return appeler(requetePost("/ajouterRv", corps), RvJson.serializer())
    }

    suspend fun supprimerRv(idRv: Int) {
        val corps = JsonRdv.encodeToString(SupprimerRvDemande.serializer(), SupprimerRvDemande(idRv))
        // "data" vaut null en cas de succès : on le décode comme un élément
        // JSON quelconque, et on l'ignore
        appeler(requetePost("/supprimerRv", corps), JsonElement.serializer())
    }

    // -----------------------------------------------------------------------
    // construction des requêtes
    // -----------------------------------------------------------------------
    private fun requeteGet(chemin: String): Request =
        Request.Builder().url(settings.apiBaseUrl + chemin).get().build()

    private fun requetePost(chemin: String, corpsJson: String): Request =
        Request.Builder()
            .url(settings.apiBaseUrl + chemin)
            .post(corpsJson.toRequestBody(TYPE_JSON))
            .build()

    // -----------------------------------------------------------------------
    // exécute une requête sur un thread d'entrées/sorties et décode la
    // réponse {status, data}. Le paramètre générique <T> et le
    // [deserialiseur] qui l'accompagne indiquent dans quel type décoder
    // "data" - le rôle du paramètre parseData de la version Flutter.
    // -----------------------------------------------------------------------
    private suspend fun <T> appeler(requete: Request, deserialiseur: DeserializationStrategy<T>): T =
        withContext(Dispatchers.IO) {
            http.newCall(requete).execute().use { reponse ->
                // 401 (jeton refusé), 403 (rôle insuffisant), 404... : le
                // corps n'est pas une enveloppe {status, data}
                if (!reponse.isSuccessful) {
                    throw IOException("Erreur HTTP ${reponse.code}")
                }
                decoderReponse(reponse.body!!.string(), deserialiseur)
            }
        }

    companion object {
        private val TYPE_JSON = "application/json; charset=utf-8".toMediaType()
    }
}

Commentons ce code :

  • lignes 48-55 : les trois routes GET. Chaque méthode est une fonction [suspend] écrite en une expression : elle construit la requête et indique le type attendu de [data]. [ListSerializer(Medecin.serializer())] décrit une [List<Medecin>] ;
  • lignes 60-72 : les deux routes POST : le corps JSON est encodé à partir des classes [AjouterRvDemande] et [SupprimerRvDemande]. Pour [/supprimerRv], [data] vaut [null] en cas de succès : on le décode comme un élément JSON quelconque ([JsonElement.serializer()]) et on l’ignore ;
  • lignes 93-101 : [appeler(...)] - la méthode commune : exécution sur un thread d’entrées/sorties, contrôle du code HTTP (401 jeton refusé, 403 rôle insuffisant : le corps n’est alors pas une enveloppe [{status, data}]), puis décodage avec [decoderReponse(...)].

Ce service n’a aucun état observable : rien n’y change une fois construit. C’est une classe ordinaire - le pendant du [Provider.value(value: rdv)] simple (et non [ChangeNotifierProvider]) de la version Flutter.

5.7. La couche core/http

5.7.1. core/http/AuthInterceptor.kt

Portage de [lib/core/http/auth_http_client.dart] : l’ajout du jeton JWT à chaque requête, et la déconnexion sur une réponse 401.

// =========================================================================
// AuthInterceptor.kt
// -------------------------------------------------------------------------
// Portage de lib/core/http/auth_http_client.dart. Deux responsabilités,
// les mêmes que dans toutes les variantes de ce cours :
//   1. ajouter l'entête "Authorization: Bearer <jeton>" à chaque requête ;
//   2. déconnecter l'utilisateur si le serveur répond 401 Unauthorized
//      (jeton expiré ou invalide).
//
// La version Flutter enveloppait le client HTTP dans une classe à elle,
// faute de mécanisme d'interception dans le package http. OkHttp, lui,
// propose des INTERCEPTEURS : des objets par lesquels passe chaque
// requête avant son envoi, et chaque réponse avant son retour à
// l'appelant - exactement la notion d'intercepteur HTTP d'Angular
// (auth.interceptor.ts dans la variante Angular de ce cours).
// =========================================================================

package fr.rdvmedecins.client.core.http

import android.os.Handler
import android.os.Looper
import fr.rdvmedecins.client.core.services.AuthService
import okhttp3.Interceptor
import okhttp3.Response

class AuthInterceptor(private val auth: AuthService) : Interceptor {

    // un Handler associé au thread principal : il permet de lui confier du
    // travail depuis un autre thread (cf. intercept ci-dessous)
    private val threadPrincipal = Handler(Looper.getMainLooper())

    // appelée par OkHttp pour CHAQUE requête, sur un thread réseau
    override fun intercept(chain: Interceptor.Chain): Response {
        // 1. la requête d'origine, complétée du jeton s'il existe. Une
        //    requête OkHttp est immuable : newBuilder() en fabrique une
        //    copie modifiable.
        val jeton = auth.accessToken
        val requete = if (jeton == null) {
            chain.request()
        } else {
            chain.request().newBuilder()
                .header("Authorization", "Bearer $jeton")
                .build()
        }
        // 2. on laisse la requête poursuivre son chemin jusqu'au serveur
        val reponse = chain.proceed(requete)
        // 3. jeton refusé : on déconnecte l'utilisateur. Cette méthode
        //    s'exécute sur un thread réseau ; or seDeconnecter() modifie
        //    l'état affiché par l'interface : on confie donc cet appel au
        //    thread principal (post), sans attendre qu'il soit exécuté - le
        //    pendant du unawaited(auth.seDeconnecter()) de la version Flutter.
        if (reponse.code == 401) {
            threadPrincipal.post { auth.seDeconnecter() }
        }
        return reponse
    }
}

Commentons ce code :

  • ligne 26 : la classe implémente l’interface [okhttp3.Interceptor]. OkHttp fait passer chaque requête par les intercepteurs du client avant de l’envoyer, et chaque réponse avant de la rendre à l’appelant : la notion d’intercepteur HTTP d’Angular. La version Flutter, faute d’un tel mécanisme dans le package [http], enveloppait le client HTTP dans une classe à elle ;
  • ligne 30 : un [Handler] associé au thread principal ([Looper.getMainLooper()]) : il permet de lui confier du travail depuis un autre thread ;
  • lignes 41-43 : une requête OkHttp est IMMUABLE : [newBuilder()] en fait une copie modifiable, complétée de l’entête [Authorization] ;
  • ligne 46 : [chain.proceed(requete)] - la requête poursuit son chemin jusqu’au serveur ; la méthode renvoie la réponse ;
  • lignes 52-53 : jeton refusé (401) : [intercept] s’exécute sur un thread réseau, or [seDeconnecter()] modifie l’état affiché. On confie donc l’appel au thread principal ([post { ... }]), sans attendre qu’il soit exécuté - le pendant du [unawaited(auth.seDeconnecter())] de la version Flutter.

5.8. La couche core/i18n

5.8.1. core/i18n/TranslationsService.kt

Portage de [lib/core/i18n/translations_service.dart] : le passage français/anglais de l’interface, à la volée.

// =========================================================================
// TranslationsService.kt
// -------------------------------------------------------------------------
// Portage de lib/core/i18n/translations_service.dart : le passage
// français/anglais de l'interface, à la volée, sans relancer
// l'application. Les dictionnaires sont les MÊMES fichiers JSON que ceux
// des clients Flutter et Vue.js de ce cours (fr.json, en.json), placés ici
// dans app/src/main/assets/i18n/ : le dossier assets/ d'un projet Android
// est embarqué tel quel dans l'application (le fichier .apk), comme les
// assets déclarés dans le pubspec.yaml de la version Flutter.
//
// Remarque : la méthode "officielle" d'Android pour traduire une
// application repose sur des fichiers de ressources res/values/strings.xml
// (français) et res/values-en/strings.xml (anglais), le système
// choisissant le bon fichier selon la langue du TÉLÉPHONE. Ce document
// garde les dictionnaires JSON communs à toutes les variantes du cours,
// et la bascule FR/EN dans l'application elle-même, comme le faisait
// l'application d'origine (2014).
// =========================================================================

package fr.rdvmedecins.client.core.i18n

import android.content.SharedPreferences
import android.content.res.AssetManager
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.core.content.edit
import fr.rdvmedecins.client.core.models.JsonRdv
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonObject

class TranslationsService(
    private val assets: AssetManager,
    private val prefs: SharedPreferences,
) {
    // la langue affichée : un état Compose. Comme t(...) ci-dessous LIT
    // cette propriété, tout texte affiché avec t(...) est automatiquement
    // réaffiché quand la langue change - aucun context.watch<...>() à
    // écrire, contrairement à la version Flutter.
    var langueCourante by mutableStateOf(prefs.getString(CLE_LANGUE, null) ?: LANGUE_PAR_DEFAUT)
        private set

    // les dictionnaires déjà chargés, par langue ("fr" -> {...})
    private val dictionnaires = mutableMapOf<String, JsonObject>()

    fun changerLangue(langue: String) {
        if (langue == langueCourante) {
            return
        }
        dictionnaire(langue) // charge le dictionnaire AVANT de changer de langue
        langueCourante = langue
        prefs.edit { putString(CLE_LANGUE, langue) }
    }

    // -----------------------------------------------------------------------
    // t("LOGIN.TITLE") : parcourt le dictionnaire de la langue courante
    // segment par segment ("LOGIN", puis "TITLE"). Si la clé est
    // introuvable, on renvoie la clé elle-même : le texte manquant reste
    // ainsi visible à l'écran, sans faire planter l'application.
    // -----------------------------------------------------------------------
    fun t(cle: String): String {
        var valeurCourante: JsonElement = dictionnaire(langueCourante)
        for (segment in cle.split('.')) {
            valeurCourante = (valeurCourante as? JsonObject)?.get(segment) ?: return cle
        }
        val primitive = valeurCourante as? JsonPrimitive
        return if (primitive != null && primitive.isString) primitive.content else cle
    }

    // -----------------------------------------------------------------------
    // le dictionnaire d'une langue, chargé au premier besoin puis gardé en
    // mémoire. getOrPut : renvoie la valeur associée à la clé, ou exécute
    // le bloc, mémorise son résultat et le renvoie. La lecture est
    // synchrone : les deux fichiers ne font que quelques centaines
    // d'octets, leur lecture est instantanée.
    // -----------------------------------------------------------------------
    private fun dictionnaire(langue: String): JsonObject =
        dictionnaires.getOrPut(langue) {
            assets.open("i18n/$langue.json").bufferedReader().use { lecteur ->
                JsonRdv.parseToJsonElement(lecteur.readText()).jsonObject
            }
        }

    companion object {
        private const val CLE_LANGUE = "rdvmedecins.langue"
        private const val LANGUE_PAR_DEFAUT = "fr"
    }
}

Commentons ce code :

  • lignes 43-44 : [langueCourante] - un état observable. Comme [t(...)] LIT cette propriété, toute fonction @Composable qui affiche un texte avec [t(...)] est recomposée automatiquement quand la langue change : aucun [context.watch<...>()] à écrire ;
  • lignes 49-55 : [changerLangue(...)] - charge le dictionnaire AVANT de changer de langue, puis mémorise le choix dans les préférences ;
  • lignes 64-70 : [t("LOGIN.TITLE")] - parcourt le dictionnaire segment par segment. [as?] est le transtypage « sûr » de Kotlin : il donne [null] (au lieu d’une exception) si l’objet n’est pas du type demandé ; combiné à [?: return cle], il fait renvoyer la clé elle-même dès qu’un segment est introuvable - le texte manquant reste visible à l’écran ;
  • lignes 80-83 : [dictionnaire(langue)] - [getOrPut] renvoie le dictionnaire déjà chargé, ou exécute le bloc (lecture de [assets/i18n/<langue>.json]), mémorise son résultat et le renvoie. La lecture est synchrone : les deux fichiers ne font que quelques centaines d’octets.

5.8.2. assets/i18n/fr.json et assets/i18n/en.json

Les dictionnaires sont ceux des clients Flutter et Vue.js, à quatre retouches près : le titre de l’application, deux libellés du sélecteur de date ([DOCTOR_DAY_PICKER.PICK_DAY], [OK], [CANCEL]), le message d’invitation de l’agenda (« touchez » plutôt que « cliquez ») et la suppression d’une clé devenue inutile. Extrait de [fr.json] :

{
  "APP": {
    "TITLE": "RdvMedecins - Kotlin / Android",
    "SERVER_URL_LABEL": "URL du serveur",
    "LOGOUT": "Se déconnecter"
  },
  "LOGIN": {
    "TITLE": "Connexion",
    "LOGIN_LABEL": "Login",
    "PASSWORD_LABEL": "Mot de passe",
    "SUBMIT": "Se connecter",
    "SUBMITTING": "Connexion...",
    "ERROR": "Login ou mot de passe incorrect.",
    "DEMO_ACCOUNTS_PREFIX": "Comptes de démonstration :",
    "DEMO_ACCOUNTS_ADMIN": "(rôle ADMIN, accès complet)",
    "DEMO_ACCOUNTS_OR": "ou",
    "DEMO_ACCOUNTS_USER": "(rôle USER, lecture seule)"
  },
  "DOCTOR_DAY_PICKER": {
    "DOCTOR_LABEL": "Médecin",
    "CHOOSE_DOCTOR": "-- choisir un médecin --",
    "DAY_LABEL": "Jour",
    "VIEW_AGENDA": "Voir l'agenda",
    "PICK_DAY": "Choisir le jour",
    "OK": "OK",
    "CANCEL": "Annuler"
  },

Note : la méthode « officielle » d’Android pour traduire une application repose sur des fichiers de ressources [res/values/strings.xml] (langue par défaut) et [res/values-en/strings.xml] (anglais), le système choisissant le bon fichier selon la langue du TÉLÉPHONE. Ce document garde les dictionnaires JSON communs à toutes les variantes du cours, et la bascule FR/EN dans l’application elle-même, comme l’application d’origine (2014). Une conséquence visible : les composants fournis par le système, comme le calendrier du choix de la date, suivent la langue du téléphone et non celle choisie dans l’application.

5.9. La couche core/ui

5.9.1. core/ui/ListeDeroulante.kt

Une liste déroulante générique, utilisée deux fois : choix du médecin et choix du patient. Flutter disposait pour cela d’un widget prêt à l’emploi, [DropdownButtonFormField] ; Material 3 pour Compose propose un assemblage de trois éléments, regroupés ici une fois pour toutes.

// =========================================================================
// ListeDeroulante.kt
// -------------------------------------------------------------------------
// Une liste déroulante générique (le <select> du HTML), utilisée deux fois
// dans ce client : choix du médecin (DoctorDayPicker) et choix du patient
// (BookingDialog). La version Flutter disposait pour cela d'un widget prêt
// à l'emploi, DropdownButtonFormField ; Material 3 pour Compose propose un
// assemblage de trois éléments - ExposedDropdownMenuBox, un champ de texte
// en lecture seule, et le menu ExposedDropdownMenu - qu'on regroupe ici
// une fois pour toutes.
//
// La fonction est GÉNÉRIQUE (<T>) : elle affiche une liste de médecins
// comme une liste de patients. [libelle] dit comment afficher un élément,
// [idDe] comment l'identifier.
// =========================================================================

package fr.rdvmedecins.client.core.ui

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

// les API ExposedDropdownMenu* sont encore marquées "expérimentales" par
// Material 3 : @OptIn indique qu'on les utilise en connaissance de cause
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun <T> ListeDeroulante(
    label: String,
    invite: String,          // texte affiché tant que rien n'est choisi
    elements: List<T>,
    idSelectionne: Int?,     // identifiant de l'élément choisi, ou null
    idDe: (T) -> Int,
    libelle: (T) -> String,
    onSelection: (Int) -> Unit,
    modifier: Modifier = Modifier,
) {
    // menu ouvert ou fermé : un état LOCAL à cette fonction, mémorisé d'un
    // affichage à l'autre par remember (cf. chapitre 3)
    var ouvert by remember { mutableStateOf(false) }
    val selection = elements.firstOrNull { idDe(it) == idSelectionne }

    ExposedDropdownMenuBox(
        expanded = ouvert,
        onExpandedChange = { ouvert = it },
        modifier = modifier,
    ) {
        // le champ qui affiche le choix courant ; menuAnchor(...) en fait
        // le point d'ancrage du menu (un clic dessus ouvre le menu)
        OutlinedTextField(
            value = selection?.let(libelle) ?: "",
            onValueChange = {},
            readOnly = true,
            singleLine = true,
            label = { Text(label) },
            placeholder = { Text(invite) },
            trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = ouvert) },
            modifier = Modifier
                .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
                .fillMaxWidth(),
        )
        // le menu lui-même : un DropdownMenuItem par élément
        ExposedDropdownMenu(expanded = ouvert, onDismissRequest = { ouvert = false }) {
            elements.forEach { element ->
                DropdownMenuItem(
                    text = { Text(libelle(element)) },
                    onClick = {
                        onSelection(idDe(element))
                        ouvert = false
                    },
                )
            }
        }
    }
}

Commentons ce code :

  • lignes 38-46 : [fun <T> ListeDeroulante(...)] - une fonction @Composable GÉNÉRIQUE : elle affiche aussi bien des médecins que des patients. [idDe] indique comment identifier un élément, [libelle] comment l’afficher, et [onSelection] signale l’identifiant choisi ;
  • ligne 46 : [modifier: Modifier = Modifier] - la convention Compose : tout composant réutilisable accepte un modificateur, appliqué à son élément racine, pour que l’appelant puisse régler sa taille ou ses marges ;
  • ligne 50 : [var ouvert by remember { mutableStateOf(false) }] - l’état local du composant : menu ouvert ou fermé ;
  • ligne 53 : [ExposedDropdownMenuBox] - le conteneur, qui gère l’ouverture et la fermeture du menu ;
  • lignes 60-69 : le champ en lecture seule qui affiche le choix courant. [menuAnchor(...)] en fait le point d’ancrage du menu : un appui dessus ouvre le menu ;
  • lignes 73-79 : le menu lui-même : un [DropdownMenuItem] par élément, généré par une boucle [forEach].

5.10. La couche features

Comme dans toutes les variantes du cours, chaque fonctionnalité de l’écran est un composant : il reçoit ses données en paramètres (entrées) et signale les actions de l’utilisateur par des fonctions (sorties). Seul [LoginPage] appelle directement un service ([AuthService]).

5.10.1. features/login/LoginPage.kt

Portage de [lib/features/login/login_page.dart] : l’écran de connexion.

// =========================================================================
// LoginPage.kt
// -------------------------------------------------------------------------
// Portage de lib/features/login/login_page.dart : l'écran de connexion
// (login, mot de passe, bouton "Se connecter", message d'erreur).
//
// La version Flutter avait besoin de DEUX classes (_LoginForm et
// _LoginFormState) et de deux TextEditingController à libérer dans
// dispose(). Une fonction @Composable suffit ici : ses états locaux sont
// déclarés directement dans la fonction, avec remember / rememberSaveable.
// =========================================================================

package fr.rdvmedecins.client.features.login

import android.util.Log
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import fr.rdvmedecins.client.core.LocalAuth
import fr.rdvmedecins.client.core.LocalTranslations
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch

@Composable
fun LoginPage() {
    val auth = LocalAuth.current
    val t = LocalTranslations.current::t

    // --- état local de l'écran -------------------------------------------
    // rememberSaveable : la valeur est mémorisée d'un affichage à l'autre
    // ET survit à la recréation de l'Activity (rotation de l'écran) - un
    // login à moitié tapé n'est pas perdu si l'on tourne le téléphone.
    var login by rememberSaveable { mutableStateOf("") }
    var password by rememberSaveable { mutableStateOf("") }
    // la clé de traduction du message d'erreur (et non le message traduit :
    // il suit ainsi un changement de langue), ou null
    var erreur by rememberSaveable { mutableStateOf<String?>(null) }
    // remember seul : une connexion en cours n'a pas à survivre à une rotation
    var enCours by remember { mutableStateOf(false) }

    // une portée de coroutines liée à cet écran : les coroutines qu'on y
    // lance sont annulées si l'écran disparaît
    val scope = rememberCoroutineScope()

    // une fonction locale (cf. le cours Kotlin) : la validation du
    // formulaire, appelée par le bouton ET par la touche "Terminé" du clavier
    fun valider() {
        if (login.isBlank() || password.isEmpty() || enCours) {
            return
        }
        erreur = null
        enCours = true
        scope.launch {
            try {
                // en cas de succès, AuthService passe estConnecte à true :
                // App.kt remplace alors AUTOMATIQUEMENT cet écran par
                // l'agenda - rien d'autre à faire ici
                auth.seConnecter(login.trim(), password)
            } catch (e: CancellationException) {
                throw e
            } catch (e: Exception) {
                // la cause exacte (serveur injoignable, mauvais identifiants...)
                // est écrite dans le journal Android (onglet Logcat d'Android
                // Studio) ; l'utilisateur, lui, voit un message simple
                Log.w("RdvMedecins", "échec de la connexion", e)
                erreur = "LOGIN.ERROR"
            } finally {
                enCours = false
            }
        }
    }

    // Box + Alignment.TopCenter : la carte est centrée horizontalement
    Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.TopCenter) {
        Card(modifier = Modifier.widthIn(max = 420.dp).fillMaxWidth()) {
            Column(
                modifier = Modifier.padding(24.dp),
                verticalArrangement = Arrangement.spacedBy(12.dp),
            ) {
                Text(t("LOGIN.TITLE"), style = MaterialTheme.typography.titleLarge)

                erreur?.let { cle ->
                    Surface(
                        color = MaterialTheme.colorScheme.errorContainer,
                        contentColor = MaterialTheme.colorScheme.onErrorContainer,
                        shape = RoundedCornerShape(4.dp),
                        modifier = Modifier.fillMaxWidth(),
                    ) {
                        Text(t(cle), modifier = Modifier.padding(8.dp))
                    }
                }

                OutlinedTextField(
                    value = login,
                    onValueChange = { login = it },
                    label = { Text(t("LOGIN.LOGIN_LABEL")) },
                    singleLine = true,
                    // touche "Suivant" du clavier : passe au champ suivant
                    keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
                    modifier = Modifier.fillMaxWidth(),
                )

                OutlinedTextField(
                    value = password,
                    onValueChange = { password = it },
                    label = { Text(t("LOGIN.PASSWORD_LABEL")) },
                    singleLine = true,
                    // affiche des points à la place des caractères
                    visualTransformation = PasswordVisualTransformation(),
                    keyboardOptions = KeyboardOptions(
                        keyboardType = KeyboardType.Password,
                        imeAction = ImeAction.Done,
                    ),
                    // touche "Terminé" du clavier : valide le formulaire
                    keyboardActions = KeyboardActions(onDone = { valider() }),
                    modifier = Modifier.fillMaxWidth(),
                )

                Spacer(Modifier.height(4.dp))
                Button(
                    onClick = { valider() },
                    enabled = !enCours,
                    modifier = Modifier.fillMaxWidth(),
                ) {
                    Text(if (enCours) t("LOGIN.SUBMITTING") else t("LOGIN.SUBMIT"))
                }

                Text(
                    text = "${t("LOGIN.DEMO_ACCOUNTS_PREFIX")} admin / admin ${t("LOGIN.DEMO_ACCOUNTS_ADMIN")} " +
                        "${t("LOGIN.DEMO_ACCOUNTS_OR")} user / user ${t("LOGIN.DEMO_ACCOUNTS_USER")}.",
                    style = MaterialTheme.typography.bodySmall,
                )
            }
        }
    }
}

Commentons ce code :

  • lignes 60-66 : les états locaux de l’écran. [rememberSaveable] pour le login, le mot de passe et le message d’erreur : ils survivent à une rotation de l’écran. [remember] seul pour [enCours] : une connexion en cours n’a pas à y survivre ;
  • ligne 70 : [rememberCoroutineScope()] - une portée de coroutines liée à cet écran : les coroutines lancées dedans sont annulées s’il disparaît ;
  • ligne 74 : [fun valider()] - une fonction LOCALE (déclarée dans une autre fonction, cf. le cours Kotlin), appelée par le bouton et par la touche « Terminé » du clavier ;
  • lignes 80-93 : [scope.launch { ... }] lance la connexion dans une coroutine. En cas de succès, il n’y a rien d’autre à faire : [AuthService] passe [estConnecte] à [true], et [App.kt] remplace AUTOMATIQUEMENT cet écran par l’agenda. En cas d’échec, on mémorise la CLÉ du message d’erreur (et non le message traduit : il suit ainsi un changement de langue) ;
  • ligne 92 : [Log.w(...)] - la cause exacte de l’échec (serveur injoignable, mauvais identifiants...) est écrite dans le journal d’Android, l’onglet [Logcat] d’Android Studio. C’est grâce à ce journal que le problème de l’adresse [10.0.2.2], décrit au chapitre 5, a été diagnostiqué ;
  • lignes 120-122 : [OutlinedTextField(value = login, onValueChange = { login = it }, ...)] - le champ affiche l’état et le met à jour à chaque frappe : le [v-model] de Vue.js écrit en deux moitiés ;
  • ligne 126 : [ImeAction.Next] - la touche « Suivant » du clavier virtuel passe au champ suivant ;
  • lignes 136-142 : le mot de passe : [PasswordVisualTransformation()] affiche des points à la place des caractères ; [KeyboardType.Password] désactive les suggestions du clavier ; [onDone] valide le formulaire ;
  • lignes 147-152 : le bouton, désactivé ([enabled = !enCours]) pendant la connexion, avec un libellé « Connexion... ».

5.10.2. features/doctordaypicker/DoctorDayPicker.kt

Portage de [lib/features/doctor_day_picker/doctor_day_picker.dart] : le choix du médecin et du jour.

// =========================================================================
// DoctorDayPicker.kt
// -------------------------------------------------------------------------
// Portage de lib/features/doctor_day_picker/doctor_day_picker.dart : le
// choix d'un médecin (liste déroulante) et d'un jour (calendrier), puis le
// bouton "Voir l'agenda".
//
// Ce composant ne connaît ni le serveur ni RdvService : il reçoit la liste
// des médecins en PARAMÈTRE (entrée) et signale le choix de l'utilisateur
// par la fonction onRechercher (sortie) - le même contrat que dans toutes
// les variantes du cours (props/emit en Vue.js, paramètres du
// constructeur en Flutter).
// =========================================================================

package fr.rdvmedecins.client.features.doctordaypicker

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DateRange
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.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.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.rdvmedecins.client.core.LocalTranslations
import fr.rdvmedecins.client.core.models.Medecin
import fr.rdvmedecins.client.core.ui.ListeDeroulante
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneOffset

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DoctorDayPicker(
    medecins: List<Medecin>,
    onRechercher: (idMedecin: Int, jour: String) -> Unit,
) {
    val t = LocalTranslations.current::t

    // --- état local, conservé en cas de rotation de l'écran ---------------
    var idMedecin by rememberSaveable { mutableStateOf<Int?>(null) }
    // le jour choisi, au format "yyyy-MM-dd" : LocalDate.toString() produit
    // exactement ce format (ISO 8601), celui qu'attend le serveur - plus
    // besoin du package intl de la version Flutter
    var jour by rememberSaveable { mutableStateOf(LocalDate.now().toString()) }
    var calendrierOuvert by remember { mutableStateOf(false) }

    // les trois éléments du composant, préparés une fois, puis disposés
    // différemment selon la largeur disponible (cf. plus bas). Ce sont des
    // LAMBDAS @Composable : on ne les AFFICHE qu'en les appelant.
    val champMedecin = @Composable { modifier: Modifier ->
        ListeDeroulante(
            label = t("DOCTOR_DAY_PICKER.DOCTOR_LABEL"),
            invite = t("DOCTOR_DAY_PICKER.CHOOSE_DOCTOR"),
            elements = medecins,
            idSelectionne = idMedecin,
            idDe = { it.id },
            libelle = { "${it.titre} ${it.prenom} ${it.nom}" },
            onSelection = { idMedecin = it },
            modifier = modifier,
        )
    }
    val champJour = @Composable { modifier: Modifier ->
        OutlinedTextField(
            value = jour,
            onValueChange = {},
            readOnly = true,
            singleLine = true,
            label = { Text(t("DOCTOR_DAY_PICKER.DAY_LABEL")) },
            // l'icône calendrier ouvre le sélecteur de date
            trailingIcon = {
                IconButton(onClick = { calendrierOuvert = true }) {
                    Icon(Icons.Filled.DateRange, contentDescription = t("DOCTOR_DAY_PICKER.PICK_DAY"))
                }
            },
            modifier = modifier,
        )
    }
    val boutonAgenda = @Composable { modifier: Modifier ->
        Button(
            // bouton désactivé tant qu'aucun médecin n'est choisi
            enabled = idMedecin != null,
            onClick = { idMedecin?.let { onRechercher(it, jour) } },
            modifier = modifier,
        ) {
            Text(t("DOCTOR_DAY_PICKER.VIEW_AGENDA"))
        }
    }

    Card(modifier = Modifier.fillMaxWidth()) {
        // BoxWithConstraints : donne accès à la largeur RÉELLEMENT
        // disponible (maxWidth) - le LayoutBuilder de la version Flutter.
        // Écran étroit (téléphone en portrait) : les trois éléments les uns
        // sous les autres ; écran large : sur une seule ligne.
        BoxWithConstraints(modifier = Modifier.padding(16.dp)) {
            if (maxWidth < 600.dp) {
                Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
                    champMedecin(Modifier.fillMaxWidth())
                    champJour(Modifier.fillMaxWidth())
                    boutonAgenda(Modifier.fillMaxWidth())
                }
            } else {
                Row(
                    horizontalArrangement = Arrangement.spacedBy(12.dp),
                    verticalAlignment = Alignment.CenterVertically,
                ) {
                    // weight(...) : répartit la largeur entre les trois
                    // éléments (5/12, 4/12, 3/12) - les flex: 5, 4, 3 des
                    // Expanded de la version Flutter
                    champMedecin(Modifier.weight(5f))
                    champJour(Modifier.weight(4f))
                    boutonAgenda(Modifier.weight(3f))
                }
            }
        }
    }

    // le calendrier : une boîte de dialogue affichée tant que
    // calendrierOuvert vaut true (même principe que BookingDialog)
    if (calendrierOuvert) {
        // DatePicker travaille en millisecondes depuis le 1er janvier 1970,
        // à minuit UTC : on convertit dans les deux sens avec java.time
        val etat = rememberDatePickerState(
            initialSelectedDateMillis = LocalDate.parse(jour)
                .atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli(),
        )
        DatePickerDialog(
            onDismissRequest = { calendrierOuvert = false },
            confirmButton = {
                TextButton(onClick = {
                    etat.selectedDateMillis?.let { millis ->
                        jour = Instant.ofEpochMilli(millis).atZone(ZoneOffset.UTC).toLocalDate().toString()
                    }
                    calendrierOuvert = false
                }) { Text(t("DOCTOR_DAY_PICKER.OK")) }
            },
            dismissButton = {
                TextButton(onClick = { calendrierOuvert = false }) {
                    Text(t("DOCTOR_DAY_PICKER.CANCEL"))
                }
            },
        ) {
            DatePicker(state = etat)
        }
    }
}

Commentons ce code :

  • lignes 54-56 : les entrées/sorties du composant : la liste des médecins, et la fonction [onRechercher(idMedecin, jour)]. Les noms donnés aux paramètres du type fonction ([idMedecin: Int, jour: String]) ne servent qu’à la documentation ;
  • ligne 65 : le jour choisi, mémorisé au format [yyyy-MM-dd] : [LocalDate.now().toString()] produit exactement ce format (ISO 8601), celui qu’attend le serveur. Plus besoin du package [intl] de la version Flutter ;
  • lignes 71-99 : les trois éléments du composant, préparés sous forme de LAMBDAS @Composable ([@Composable { modifier: Modifier -> ... }]) : ils ne sont affichés que lorsqu’on les appelle, avec le modificateur voulu. Cela permet de les disposer de deux façons différentes sans écrire deux fois leur code ;
  • lignes 91-93 : le champ du jour, en lecture seule ; l’icône calendrier, à droite, ouvre le sélecteur de date ;
  • lignes 115-132 : [BoxWithConstraints] donne accès à la largeur RÉELLEMENT disponible ([maxWidth]) - le [LayoutBuilder] de la version Flutter. Moins de 600 dp : les trois éléments les uns sous les autres ([Column]) ; sinon, sur une seule ligne ([Row]), avec des poids 5/4/3 ([Modifier.weight(...)], les [Expanded(flex: ...)] de Flutter) ;
  • lignes 140-163 : le sélecteur de date : un [DatePickerDialog] affiché tant que [calendrierOuvert] vaut [true]. Le [DatePicker] de Material 3 travaille en millisecondes depuis le 1er janvier 1970 à minuit UTC : on convertit dans les deux sens avec [java.time] ([atStartOfDay(ZoneOffset.UTC)] et [Instant.ofEpochMilli(...)]).

5.10.3. features/agenda/AgendaView.kt

Portage de [lib/features/agenda/agenda_view.dart] : l’agenda d’un médecin pour un jour.

// =========================================================================
// AgendaView.kt
// -------------------------------------------------------------------------
// Portage de lib/features/agenda/agenda_view.dart : l'affichage de
// l'agenda d'un médecin pour un jour - une ligne par créneau, libre ou
// occupé, avec un bouton "Réserver" (créneau libre) ou "Annuler" (créneau
// occupé) pour le rôle ADMIN.
//
// Comme DoctorDayPicker, ce composant est "sans état" : tout ce qu'il
// affiche lui est donné en paramètre, et il signale les clics par
// onReserver / onAnnuler. C'est AppViewModel qui appelle le serveur.
// =========================================================================

package fr.rdvmedecins.client.features.agenda

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
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.text.font.FontWeight
import androidx.compose.ui.unit.dp
import fr.rdvmedecins.client.core.LocalTranslations
import fr.rdvmedecins.client.core.models.AgendaMedecinJour
import fr.rdvmedecins.client.core.models.CreneauAgenda
import fr.rdvmedecins.client.core.models.CreneauJson
import fr.rdvmedecins.client.core.models.RvJson

// largeur de la colonne des horaires ("10:40 - 11:00")
private val LARGEUR_HORAIRE = 116.dp

// "8:00", "8:20"... : les minutes toujours sur deux chiffres
private fun formaterHeure(h: Int, m: Int): String = "$h:${m.toString().padStart(2, '0')}"

@Composable
fun AgendaView(
    agenda: AgendaMedecinJour?,
    peutModifier: Boolean,
    onReserver: (CreneauJson) -> Unit,
    onAnnuler: (RvJson) -> Unit,
) {
    val t = LocalTranslations.current::t

    // pas encore d'agenda : un simple message d'invitation
    if (agenda == null) {
        Text(
            text = t("AGENDA.EMPTY_STATE"),
            color = MaterialTheme.colorScheme.outline,
            modifier = Modifier.padding(vertical = 24.dp),
        )
        return // une fonction @Composable peut s'arrêter là : rien d'autre n'est affiché
    }

    Card(modifier = Modifier.fillMaxWidth()) {
        Column(modifier = Modifier.padding(16.dp)) {
            val medecin = agenda.medecin
            Text(
                text = "${t("AGENDA.TITLE_PREFIX")} ${agenda.jour} - ${medecin.titre} ${medecin.prenom} ${medecin.nom}",
                style = MaterialTheme.typography.titleMedium,
            )
            // en-tête des colonnes : même largeur de première colonne que
            // dans chaque ligne (LARGEUR_HORAIRE), pour que les colonnes
            // soient alignées
            Row(modifier = Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 4.dp)) {
                Text(t("AGENDA.COLUMN_SLOT"), fontWeight = FontWeight.Bold, modifier = Modifier.width(LARGEUR_HORAIRE))
                Spacer(Modifier.width(8.dp)) // l'espacement entre colonnes des lignes
                Text(t("AGENDA.COLUMN_STATUS"), fontWeight = FontWeight.Bold)
            }
            HorizontalDivider()
            // une ligne par créneau. Une simple boucle forEach, dans une
            // Column : pas de LazyColumn ici (la liste "paresseuse" de
            // Compose, qui ne construit que les lignes visibles), car cet
            // agenda est déjà placé dans une Column défilante (cf. App.kt),
            // et une douzaine de lignes ne justifie pas cette optimisation.
            agenda.creneaux.forEach { creneauAgenda ->
                LigneCreneau(creneauAgenda, peutModifier, onReserver, onAnnuler)
                HorizontalDivider()
            }
        }
    }
}

// -------------------------------------------------------------------------
// une ligne de l'agenda : horaire | statut (libre ou patient) | bouton
// -------------------------------------------------------------------------
@Composable
private fun LigneCreneau(
    creneauAgenda: CreneauAgenda,
    peutModifier: Boolean,
    onReserver: (CreneauJson) -> Unit,
    onAnnuler: (RvJson) -> Unit,
) {
    val t = LocalTranslations.current::t
    val creneau = creneauAgenda.creneau
    val rv = creneauAgenda.rv

    Row(
        modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
        verticalAlignment = Alignment.CenterVertically,
        horizontalArrangement = Arrangement.spacedBy(8.dp),
    ) {
        // l'horaire : une largeur fixe, la même que celle de l'en-tête
        Text(
            text = "${formaterHeure(creneau.hDebut, creneau.mDebut)} - ${formaterHeure(creneau.hFin, creneau.mFin)}",
            modifier = Modifier.width(LARGEUR_HORAIRE),
        )
        // le statut : une pastille verte "Libre", ou le nom du patient
        Surface(
            shape = RoundedCornerShape(8.dp),
            color = if (rv == null) Color(0xFFC8E6C9) else MaterialTheme.colorScheme.secondaryContainer,
            // weight(1f) : le statut occupe toute la largeur laissée libre par
            // l'horaire et le bouton
            modifier = Modifier.weight(1f),
        ) {
            val client = rv?.client
            Text(
                text = if (rv == null) t("AGENDA.FREE") else "${client?.titre ?: ""} ${client?.prenom ?: ""} ${client?.nom ?: ""}",
                style = MaterialTheme.typography.bodyMedium,
                modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
            )
        }
        // le bouton n'existe que pour le rôle ADMIN ; il réserve un créneau
        // libre, ou annule le rendez-vous d'un créneau occupé
        if (peutModifier) {
            // PAS de weight(...) ici : le bouton prend la largeur de son texte.
            // Une première version lui imposait un quart de la ligne
            // (weight(0.25f)) : sur un téléphone en portrait, le libellé
            // "Réserver" était alors tronqué en "Rése" (cf. chapitre 5).
            OutlinedButton(
                onClick = { if (rv == null) onReserver(creneau) else onAnnuler(rv) },
                // marges intérieures réduites (24 dp par défaut de chaque côté)
                contentPadding = PaddingValues(horizontal = 12.dp),
                // une largeur minimale commune : les boutons "Réserver" et
                // "Annuler" ont ainsi la même largeur, et les colonnes restent
                // alignées d'une ligne à l'autre
                modifier = Modifier.widthIn(min = 96.dp),
            ) {
                Text(if (rv == null) t("AGENDA.BOOK") else t("AGENDA.CANCEL_APPOINTMENT"), maxLines = 1)
            }
        }
    }
}

Commentons ce code :

  • lignes 60-66 : pas encore d’agenda : un message d’invitation, puis [return]. Une fonction @Composable peut s’arrêter là : rien d’autre n’est émis ;
  • lignes 79-82 : l’en-tête des colonnes : la première colonne a la même largeur fixe ([LARGEUR_HORAIRE]) que l’horaire de chaque ligne, pour que les colonnes soient alignées. Compose n’a pas d’équivalent au [DataTable] de Flutter : le tableau est construit avec des [Row] ;
  • lignes 90-91 : une ligne par créneau, avec une simple boucle [forEach] dans une [Column]. Pas de [LazyColumn] (la liste « paresseuse » de Compose, qui ne construit que les lignes visibles) : l’agenda est déjà placé dans une colonne défilante (cf. [App.kt]) - une [LazyColumn] dans une colonne défilante provoque d’ailleurs une erreur à l’exécution - et une douzaine de lignes ne justifie pas cette optimisation ;
  • lignes 122-128 : le statut : une pastille verte « Libre », ou le nom du patient. [Modifier.weight(1f)] : le statut occupe toute la largeur laissée libre par l’horaire et le bouton ;
  • lignes 139-151 : le bouton, uniquement pour le rôle ADMIN ([if (peutModifier)]) : « Réserver » sur un créneau libre, « Annuler » sur un créneau occupé. Il n’a PAS de poids : il prend la largeur de son texte, avec une largeur minimale commune. Une première version lui imposait un quart de la ligne : sur un téléphone en portrait, le libellé était alors tronqué (cf. chapitre 5).

5.10.4. features/bookingdialog/BookingDialog.kt

Portage de [lib/features/booking_dialog/booking_dialog.dart] : la boîte de dialogue de réservation.

// =========================================================================
// BookingDialog.kt
// -------------------------------------------------------------------------
// Portage de lib/features/booking_dialog/booking_dialog.dart : la boîte
// de dialogue de réservation d'un créneau (choix du patient, puis
// "Confirmer" ou "Annuler").
//
// Différence structurelle avec la version Flutter : Flutter OUVRAIT la
// boîte avec showDialog(...) et ATTENDAIT son résultat (un Future<int?>).
// En Compose, une boîte de dialogue est un élément d'affichage comme un
// autre : AlertDialog est affichée tant que la fonction qui l'appelle
// l'appelle (cf. App.kt : tant que vm.creneauEnReservation n'est pas
// null). Elle ne "renvoie" rien : elle signale le choix par deux
// fonctions, onConfirmer et onFermer - exactement les deux événements
// "confirmer" / "fermer" du BookingDialog.vue de la variante Vue.js.
// =========================================================================

package fr.rdvmedecins.client.features.bookingdialog

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
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.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import fr.rdvmedecins.client.core.LocalTranslations
import fr.rdvmedecins.client.core.models.Client
import fr.rdvmedecins.client.core.models.CreneauJson
import fr.rdvmedecins.client.core.ui.ListeDeroulante

@Composable
fun BookingDialog(
    creneau: CreneauJson,
    clients: List<Client>,
    onConfirmer: (idClient: Int) -> Unit,
    onFermer: () -> Unit,
) {
    val t = LocalTranslations.current::t
    // le patient choisi (son id), ou null tant qu'aucun n'est choisi
    var idClient by rememberSaveable { mutableStateOf<Int?>(null) }

    AlertDialog(
        // appui en dehors de la boîte, ou touche "retour" du téléphone
        onDismissRequest = onFermer,
        title = { Text(t("BOOKING_DIALOG.TITLE")) },
        text = {
            Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
                Text(
                    "${t("BOOKING_DIALOG.SLOT_FROM")} ${creneau.hDebut}:${creneau.mDebut.toString().padStart(2, '0')} " +
                        "${t("BOOKING_DIALOG.SLOT_TO")} ${creneau.hFin}:${creneau.mFin.toString().padStart(2, '0')}",
                )
                ListeDeroulante(
                    label = t("BOOKING_DIALOG.PATIENT_LABEL"),
                    invite = t("BOOKING_DIALOG.CHOOSE_PATIENT"),
                    elements = clients,
                    idSelectionne = idClient,
                    idDe = { it.id },
                    libelle = { "${it.titre} ${it.prenom} ${it.nom}" },
                    onSelection = { idClient = it },
                    modifier = Modifier.fillMaxWidth(),
                )
            }
        },
        // "Confirmer" : désactivé tant qu'aucun patient n'est choisi
        confirmButton = {
            Button(
                enabled = idClient != null,
                onClick = { idClient?.let(onConfirmer) },
            ) { Text(t("BOOKING_DIALOG.CONFIRM")) }
        },
        dismissButton = {
            TextButton(onClick = onFermer) { Text(t("BOOKING_DIALOG.CANCEL")) }
        },
    )
}

Commentons ce code :

  • lignes 8-15 : le commentaire d’en-tête résume la différence structurelle avec la version Flutter. Flutter OUVRAIT la boîte de dialogue avec [showDialog(...)] et ATTENDAIT son résultat (un [Future<int?>]). En Compose, une boîte de dialogue est un élément d’affichage comme un autre : elle est affichée tant que la fonction qui l’appelle l’appelle. Elle ne « renvoie » rien : elle signale le choix par deux fonctions, [onConfirmer] et [onFermer] - exactement les deux événements [confirmer] / [fermer] du composant [BookingDialog.vue] de la variante Vue.js ;
  • ligne 48 : le patient choisi : [rememberSaveable], il survit à une rotation de l’écran pendant que la boîte de dialogue est ouverte ;
  • lignes 50-52 : [AlertDialog(...)] - la boîte de dialogue Material 3, avec un titre, un contenu ([text]) et deux boutons. [onDismissRequest] est appelée quand l’utilisateur touche en dehors de la boîte ou appuie sur la touche « retour » du téléphone ;
  • lignes 73-76 : le bouton « Confirmer », désactivé tant qu’aucun patient n’est choisi. [idClient?.let(onConfirmer)] : si [idClient] n’est pas [null], on appelle [onConfirmer] avec sa valeur.

5.11. Utilisation pas à pas de l’application

Les captures qui suivent ont été prises dans l’émulateur Pixel 8 (Android 17) du chapitre 1, l’application étant reliée au serveur par [adb reverse] (cf. chapitre 5), d’où l’URL [http://localhost:8080].

5.11.1. 1. Écran de connexion

Au premier lancement, aucune session n’est mémorisée : [AuthService.estConnecte] vaut [false], et [App.kt] affiche [LoginPage]. Une tentative avec un couple login/mot de passe invalide - ou un serveur injoignable - affiche le message d’erreur (clé [LOGIN.ERROR]) :

Image

Image

5.11.2. 2. Connexion en tant qu’administrateur (rôle ADMIN, accès complet)

Une fois connecté avec [admin]/[admin], l’écran de connexion laisse la place au choix du médecin et du jour ; la barre de titre affiche le badge du rôle et le bouton de déconnexion. En arrière-plan, [AppViewModel] a chargé les médecins et les patients :

Image

Image

  • à gauche, l’écran juste après la connexion : le bouton « Voir l’agenda » est désactivé tant qu’aucun médecin n’est choisi ;
  • à droite, la liste déroulante des médecins, ouverte.

L’icône calendrier ouvre le sélecteur de date ; « Voir l’agenda » affiche l’agenda du médecin pour le jour choisi :

Image

Image

« Réserver » ouvre la boîte de dialogue de choix du patient ; « Confirmer » envoie la réservation au serveur, et l’agenda est rechargé : le créneau apparaît occupé, avec un bouton « Annuler » :

Image

Image

Image

« Annuler », sur un créneau occupé, supprime le rendez-vous et le créneau redevient libre. Le bouton « EN » traduit tout l’écran, y compris les libellés de l’agenda ; le bouton de déconnexion ramène à l’écran de connexion :

Image

Image

5.11.3. 3. Connexion en tant qu’utilisateur (rôle USER, lecture seule)

Une connexion avec [user]/[user] affiche le badge « USER ». L’agenda reste consultable, mais aucun bouton « Réserver »/« Annuler » n’apparaît ([peutModifier] vaut [false]) - le serveur refuserait de toute façon ces deux actions à ce rôle (403 Forbidden, cf. chapitre 2).

Image

5.12. Ce qui reste hors périmètre ou reporté

  • Comme les autres clients de ce cours, aucun mode de démonstration/débogage particulier.
  • Aucun délai réseau artificiel introduit côté client à des fins pédagogiques.
  • Aucune navigation entre écrans : l’application tient sur un seul écran (hors boîtes de dialogue). Une application Android plus riche utiliserait la bibliothèque [Navigation Compose].
  • Aucun test automatisé : les dossiers [src/test] et [src/androidTest] générés par Android Studio ont été retirés du projet.