3. Premiers pas avec Kotlin et Jetpack Compose
3.1. Exemple 01 : la structure d'une application Android
3.1.1. Le projet
Le projet des exemples a été créé par [Android Studio] (modèle [Empty Activity], cf. chapitre précédent), puis complété. Un projet [Android] est un projet [Gradle] : l'outil qui télécharge les bibliothèques, compile le code [Kotlin] et fabrique le fichier [.apk] installé sur le téléphone. Il est décrit par quelques fichiers écrits en [Kotlin] (les fichiers [.kts], « Kotlin script »).
3.1.2. Le fichier settings.gradle.kts
| // =========================================================================
// settings.gradle.kts - la configuration GLOBALE du projet Gradle
// (généré par Android Studio ; seul rootProject.name a été modifié)
// =========================================================================
// où Gradle cherche ses PLUGINS (le plugin Android, le plugin Compose...)
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
}
// où Gradle cherche les BIBLIOTHÈQUES : google() pour AndroidX et Compose,
// mavenCentral() pour OkHttp, kotlinx.serialization, AndroidSVG
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "exemples_kotlin"
// un seul module : l'application, dans le dossier app/
include(":app")
|
- lignes 7-17 : où [Gradle] cherche ses PLUGINS (les extensions de [Gradle] : le plugin [Android], le compilateur [Compose]...) : le dépôt de Google et le dépôt central des bibliothèques Java ([Maven Central]) ;
- lignes 25-29 : où [Gradle] cherche les BIBLIOTHÈQUES de l'application ;
- lignes 3-11 : le nom du projet, et son unique module, l'application, dans le dossier [app/].
3.1.3. Le catalogue des versions : gradle/libs.versions.toml
Toutes les versions des plugins et des bibliothèques sont réunies dans un seul fichier, au format TOML :
| # =========================================================================
# gradle/libs.versions.toml - le "catalogue de versions" du projet
# -------------------------------------------------------------------------
# TOUTES les versions des plugins et des bibliothèques, en un seul endroit.
# Les fichiers build.gradle.kts y font référence par des noms
# (libs.okhttp, libs.plugins.kotlin.compose...). C'est l'équivalent de la
# section dependencies du pubspec.yaml de Flutter.
# =========================================================================
[versions]
agp = "9.4.1"
kotlin = "2.2.10"
coreKtx = "1.17.0"
lifecycle = "2.9.4"
activityCompose = "1.11.0"
composeBom = "2026.02.01"
appcompat = "1.7.1"
navigation = "2.9.5"
datastore = "1.1.7"
okhttp = "4.12.0"
kotlinxSerializationJson = "1.9.0"
androidsvg = "1.4"
junit = "4.13.2"
junitVersion = "1.3.0"
espressoCore = "3.7.0"
testRunner = "1.7.0"
[libraries]
# --- générées par Android Studio (modèle "Empty Activity") ---
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "testRunner" }
# --- ajoutées pour les exemples ---
# les icônes Material de base (Add, Delete, Search...)
androidx-compose-material-icons-core = { group = "androidx.compose.material", name = "material-icons-core" }
# le cycle de vie de l'activité vu de Compose (exemples 13, 20)
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
# viewModel() dans une fonction @Composable (exemples 20, 24, 25)
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
# la langue PROPRE à l'application (exemples 21, 25)
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
# le routeur (exemples 17, 18)
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" }
# un petit dictionnaire clé -> valeur, conservé sur l'appareil (exemple 20)
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
# le client HTTP (exemples 22 à 25)
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
# la (dé)sérialisation JSON (exemples 17, 18, 22 à 25)
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
# le dessin d'une image SVG (exemple 03, captcha des exemples 22 à 25)
androidsvg = { group = "com.caverock", name = "androidsvg-aar", version.ref = "androidsvg" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
# génère le code de (dé)sérialisation des classes @Serializable
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
- section [[versions]] : les numéros de version, nommés ;
- section [[libraries]] : les bibliothèques, désignées par leur groupe et leur nom ([Maven]), et leur version. Les bibliothèques [Compose] n'ont pas de version : c'est la « BOM » ([compose-bom], « Bill Of Materials ») qui fixe d'un coup des versions compatibles entre elles pour toutes ;
- section [[plugins]] : les plugins de [Gradle]. Les fichiers [build.gradle.kts] y font référence par des noms : [libs.okhttp], [libs.plugins.kotlin.compose] (les tirets deviennent des points).
Le rôle de chaque bibliothèque est rappelé en commentaire ; chacune sera présentée dans l'exemple qui s'en sert.
3.1.4. Le fichier app/build.gradle.kts
La configuration de l'application elle-même :
| // =========================================================================
// app/build.gradle.kts - la configuration du module "app" : l'application
// des 25 exemples. Généré par Android Studio (modèle "Empty Activity"),
// puis complété (lignes marquées "ajout").
// =========================================================================
plugins {
// le plugin Android : compile le code Kotlin (le plugin Android 9 intègre
// lui-même la prise en charge de Kotlin) et produit le fichier .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" de base du code (classe R, BuildConfig...)
namespace = "fr.stahe.exemples"
// la version de l'API Android utilisée pour COMPILER
compileSdk {
version = release(37)
}
defaultConfig {
// l'identifiant UNIQUE de l'application sur le téléphone
applicationId = "fr.stahe.exemples"
// la plus ancienne version d'Android acceptée : Android 8.0 (API 26)
minSdk = 26
// la version d'Android pour laquelle l'application a été testée
targetSdk = 37
versionCode = 1
versionName = "1.0"
// ajout : l'adresse du serveur JSON (exemples 22 à 25), modifiable au
// moment de la compilation : gradlew installDebug -Pserveur=http://192.168.1.20:8080
val serveur = providers.gradleProperty("serveur").getOrElse("http://localhost:8080")
buildConfigField("String", "URL_SERVEUR", "\"$serveur\"")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
optimization {
enable = false
}
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
buildFeatures {
compose = true
// ajout : la classe BuildConfig (BuildConfig.URL_SERVEUR)
buildConfig = true
}
}
dependencies {
// la "BOM" Compose : des versions compatibles entre elles pour toutes les
// bibliothèques Compose, 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 les exemples
implementation(libs.androidx.compose.material.icons.core)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.datastore.preferences)
implementation(libs.okhttp)
implementation(libs.kotlinx.serialization.json)
implementation(libs.androidsvg)
// les tests : ici, la fabrication des copies d'écran du cours (dossier androidTest)
testImplementation(libs.junit)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.test.runner)
debugImplementation(libs.androidx.compose.ui.test.manifest)
debugImplementation(libs.androidx.compose.ui.tooling)
}
|
- lignes 6-13 : les plugins appliqués au module : le plugin [Android] (qui compile aussi le [Kotlin]), le compilateur [Compose] (qui transforme les fonctions [@Composable]) et le plugin [kotlinx.serialization] (qui écrit le code de conversion JSON des classes [@Serializable], cf. exemple 05) ;
- lignes 18-21 : [namespace], le paquet de base du code (celui de la classe [R] des ressources) ; [compileSdk], la version de l'API [Android] utilisée pour compiler ;
- lignes 24-32 : [applicationId], l'identifiant unique de l'application sur le téléphone ; [minSdk = 26] : l'application s'installe sur [Android] 8.0 et plus ; [targetSdk], la version pour laquelle elle a été testée ;
- lignes 33-36 : un champ de la classe [BuildConfig], générée à la compilation : l'adresse du serveur JSON (exemples 22 à 25), qu'on peut changer au moment de la compilation ([-Pserveur=...]) ;
- lignes 52-55 : [compose = true] active [Compose] ; [buildConfig = true], la classe [BuildConfig] ;
- lignes 59-88 : les bibliothèques de l'application ([implementation]), des tests instrumentés ([androidTestImplementation]) et de la seule version de mise au point ([debugImplementation]).
3.1.5. Le fichier AndroidManifest.xml
Le manifeste est la « carte d'identité » de l'application pour le système [Android] : ses permissions, sa classe [Application], ses ACTIVITÉS - les écrans qu'[Android] peut lancer :
| <?xml version="1.0" encoding="utf-8"?>
<!-- =====================================================================
AndroidManifest.xml - la "carte d'identité" de l'application
............................................................................
Ses permissions, sa classe Application, ses activités (une par
exemple, plus le menu).
===================================================================== -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- l'accès au réseau (exemples 22 à 25) : accordé d'office, sans
question posée à l'utilisateur -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- android:name : la classe Application (ExemplesApplication.kt).
android:usesCleartextTraffic="true" : autorise le HTTP "en clair"
(http:// et non https://), bloqué par défaut depuis Android 9 : le
serveur JSON de ce cours écoute en HTTP simple.
android:localeConfig : les langues de l'application (exemple 21) -->
<application
android:name=".ExemplesApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:localeConfig="@xml/locales_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Exemples"
android:usesCleartextTraffic="true"
tools:targetApi="33">
<!-- le menu : l'activité lancée par l'icône de l'application
(le filtre MAIN / LAUNCHER) -->
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- les exemples. exported="false" : seule l'application peut les
ouvrir (adb, lui, le peut toujours).
windowSoftInputMode="adjustResize" : le clavier réduit l'écran
au lieu de le cacher -->
<activity android:name=".ex01.Ex01Activity" android:exported="false" android:windowSoftInputMode="adjustResize" />
<activity android:name=".ex02.Ex02Activity" android:exported="false" android:windowSoftInputMode="adjustResize" />
<activity android:name=".ex03.Ex03Activity" android:exported="false" android:windowSoftInputMode="adjustResize" />
<activity android:name=".ex04.Ex04Activity" android:exported="false" android:windowSoftInputMode="adjustResize" />
<activity android:name=".ex05.Ex05Activity" android:exported="false" android:windowSoftInputMode="adjustResize" />
<activity android:name=".ex06.Ex06Activity" android:exported="false" android:windowSoftInputMode="adjustResize" />
<activity android:name=".ex07.Ex07Activity" android:exported="false" android:windowSoftInputMode="adjustResize" />
<activity android:name=".ex08.Ex08Activity" android:exported="false" android:windowSoftInputMode="adjustResize" />
<activity android:name=".ex09.Ex09Activity" android:exported="false" android:windowSoftInputMode="adjustResize" />
<activity android:name=".ex10.Ex10Activity" android:exported="false" android:windowSoftInputMode="adjustResize" />
<activity android:name=".ex11.Ex11Activity" android:exported="false" android:windowSoftInputMode="adjustResize" />
<activity android:name=".ex12.Ex12Activity" android:exported="false" android:windowSoftInputMode="adjustResize" />
<activity android:name=".ex13.Ex13Activity" android:exported="false" android:windowSoftInputMode="adjustResize" />
<activity android:name=".ex14.Ex14Activity" android:exported="false" android:windowSoftInputMode="adjustResize" />
<activity android:name=".ex15.Ex15Activity" android:exported="false" android:windowSoftInputMode="adjustResize" />
<activity android:name=".ex16.Ex16Activity" android:exported="false" android:windowSoftInputMode="adjustResize" />
<activity android:name=".ex19.Ex19Activity" android:exported="false" android:windowSoftInputMode="adjustResize" />
<activity android:name=".ex20.Ex20Activity" android:exported="false" android:windowSoftInputMode="adjustResize" />
<activity android:name=".ex21.Ex21Activity" android:exported="false" android:windowSoftInputMode="adjustResize" />
<activity android:name=".ex22.Ex22Activity" android:exported="false" android:windowSoftInputMode="adjustResize" />
<activity android:name=".ex23.Ex23Activity" android:exported="false" android:windowSoftInputMode="adjustResize" />
<activity android:name=".ex24.Ex24Activity" android:exported="false" android:windowSoftInputMode="adjustResize" />
<activity android:name=".ex25.Ex25Activity" android:exported="false" android:windowSoftInputMode="adjustResize" />
<!-- exemples 17 et 18 : des adresses app://exemples/... et
app://exemples18/... (des "liens profonds"), qu'une autre
application (ou adb) peut ouvrir : un filtre d'intentions VIEW -->
<activity android:name=".ex17.Ex17Activity" android:exported="true" android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="app" android:host="exemples" />
</intent-filter>
</activity>
<activity android:name=".ex18.Ex18Activity" android:exported="true" android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="app" android:host="exemples18" />
</intent-filter>
</activity>
<!-- exemple 21 : avant Android 13, c'est AppCompat qui mémorise la
langue choisie par l'application (autoStoreLocales) -->
<service
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
android:enabled="false"
android:exported="false">
<meta-data
android:name="autoStoreLocales"
android:value="true" />
</service>
</application>
</manifest>
|
- ligne 13 : la permission d'accéder au réseau (exemples 22 à 25) ;
- lignes 20-32 : l'application : sa classe [Application] (exemple 23), son icône, son nom ([@string/app_name], une ressource, cf. exemple 21), son thème, le trafic HTTP « en clair » (le serveur de ce cours n'est pas en https) ;
- lignes 37-43 : le menu, l'activité de démarrage : le filtre d'intentions [MAIN] / [LAUNCHER] en fait l'activité lancée par l'icône de l'application ;
- lignes 49-71 : une activité par exemple ; [exported="false"] : seule l'application peut les ouvrir.
3.1.6. L'activité : Ex01Activity.kt
Le code de l'exemple 01 :
| // =========================================================================
// ex01/Ex01Activity.kt - une première application
// -------------------------------------------------------------------------
// Un écran d'une application Android est une ACTIVITÉ : une classe qui
// hérite de ComponentActivity, que le système crée quand on lance
// l'application. Avec Jetpack Compose, l'activité ne fait presque rien :
// elle confie son écran à des fonctions @Composable (setContent).
// Une fonction @Composable DÉCRIT un morceau de l'écran (un texte, un
// bouton, une colonne...) en appelant d'autres fonctions @Composable :
// elles forment un ARBRE. Compose exécute ces fonctions pour fabriquer
// l'écran ; il les RÉEXÉCUTE quand une donnée qu'elles lisent (un "état")
// change : c'est la RECOMPOSITION. On ne modifie jamais l'écran soi-même.
// =========================================================================
@file:OptIn(ExperimentalMaterial3Api::class)
package fr.stahe.exemples.ex01
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
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.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import fr.stahe.exemples.commun.ThemeExemples
import java.time.LocalTime
import java.time.format.DateTimeFormatter
// des données "ordinaires", hors des fonctions @Composable : elles ne changent jamais
const val TITRE = "Bienvenue dans Kotlin"
val langages = listOf("Kotlin", "Jetpack Compose", "Material Design")
// "12:05:09" : l'heure courante
fun heureCourante(): String = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss"))
// l'activité : le point d'entrée de l'écran (déclarée dans AndroidManifest.xml)
class Ex01Activity : ComponentActivity() {
// onCreate : appelée par Android quand l'activité est créée
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// l'application dessine aussi sous la barre d'état et la barre de navigation
enableEdgeToEdge()
// setContent : Compose prend possession de l'écran, et y affiche la
// fonction @Composable donnée, enveloppée par le thème des exemples
setContent {
ThemeExemples {
PageAccueil()
}
}
}
}
// une fonction @Composable : son nom commence par une majuscule, comme une
// classe (c'est un morceau d'interface), et elle ne renvoie rien (Unit)
@Composable
fun PageAccueil() {
// l'ÉTAT : des variables observées par Compose. Les modifier provoque
// la réexécution (recomposition) des fonctions qui les lisent.
// rememberSaveable : la valeur est conservée d'une exécution à l'autre
// de la fonction... et même quand Android recrée l'activité (rotation
// de l'écran, changement de langue)
var compteur by rememberSaveable { mutableIntStateOf(0) }
var heure by rememberSaveable { mutableStateOf(heureCourante()) }
// Scaffold : la structure d'un écran Material (barre du haut, contenu...)
Scaffold(
topBar = { TopAppBar(title = { Text("Exemple 01 - une première application") }) },
) { marges ->
// Column : des enfants placés les uns sous les autres ; les marges
// du Scaffold (la place de la barre du haut), puis 16 dp de plus
Column(modifier = Modifier.padding(marges).padding(16.dp)) {
Text(TITRE, style = MaterialTheme.typography.headlineSmall)
Spacer(Modifier.height(12.dp))
Text(
"Cet écran est fabriqué par Jetpack Compose, à partir de l'activité " +
"Ex01Activity et de la fonction PageAccueil.",
)
Spacer(Modifier.height(8.dp))
// ${expression} insère la valeur d'une expression dans une chaîne
Text("On y écrit du ${langages.joinToString(", ")} (${langages.size} technologies).")
Spacer(Modifier.height(16.dp))
// onClick : la fonction (ici une "lambda") appelée quand on appuie.
// On modifie l'ÉTAT ; Compose met l'écran à jour
Button(onClick = {
compteur++
heure = heureCourante()
}) {
Text("Cliquez-moi")
}
Spacer(Modifier.height(16.dp))
Text(
"Vous avez cliqué $compteur fois (dernier clic : $heure).",
fontWeight = FontWeight.Bold,
color = Color(0xFF087EA4),
)
}
}
}
|
- ligne 16 : le paquet ([package]) du fichier : chaque exemple a le sien, ce qui permet d'avoir des noms identiques (une fonction [PageAccueil]) dans deux exemples ;
- ligne 14 : [@file:OptIn(...)] : certaines fonctions de [Material 3] ([TopAppBar]) sont encore marquées « expérimentales » ; on déclare les utiliser en connaissance de cause ;
- lignes 47-48 : des données ordinaires, hors de toute fonction [@Composable] ; [const val] : une constante connue à la compilation ;
- ligne 54 : l'ACTIVITÉ, une classe qui hérite de [ComponentActivity]. [Android] la crée quand on lance l'écran, puis appelle sa fonction [onCreate] ;
- lignes 59-64 : [enableEdgeToEdge()] : l'application dessine aussi sous la barre d'état et sous la barre de navigation du téléphone (c'est la règle depuis [Android] 15) ; [setContent [ ... ]] : [Compose] prend possession de l'écran et y affiche le contenu du bloc. [ThemeExemples] (fichier [commun/Theme.kt]) fournit les couleurs de [Material 3] à tout ce qu'il contient ;
- lignes 7-73 : une fonction [@Composable] : elle DÉCRIT un morceau de l'écran en appelant d'autres fonctions [@Composable]. Par convention, son nom commence par une majuscule, comme une classe ; elle ne renvoie rien ;
- lignes 79-80 : l'ÉTAT. [mutableIntStateOf(0)] crée une valeur OBSERVÉE par [Compose] : quand elle change, [Compose] réexécute les fonctions qui la lisent (la recomposition). [by] délègue la variable [compteur] à cet état : on écrit [compteur++] comme pour une variable ordinaire. [rememberSaveable] : la valeur est conservée d'une exécution à l'autre de la fonction (sans [remember], chaque exécution recréerait l'état à 0)... et même quand [Android] RECRÉE l'activité, ce qu'il fait à chaque rotation de l'écran ;
- lignes 83-85 : [Scaffold], la structure d'un écran [Material] : une barre du haut ([topBar]) et un contenu. Le contenu est une lambda qui reçoit les MARGES à respecter (la place de la barre du haut) ;
- ligne 88 : [Column] place ses enfants les uns sous les autres. [Modifier] : la façon de [Compose] d'ajuster un composant (marges, taille, fond, réaction aux gestes...) : on enchaîne les modificateurs, dans l'ordre où ils s'appliquent ;
- lignes 89-97 : des textes et des espaces ([Spacer]) ; [MaterialTheme.typography.headlineSmall] : un des styles de texte du thème ; ["$[...]"] insère une expression dans une chaîne ;
- lignes 101-105 : un bouton. [onClick] reçoit la fonction (une lambda) à exécuter à l'appui : elle modifie l'état, et c'est tout. On ne touche jamais à l'écran soi-même : [Compose] réexécute [PageAccueil], qui affiche le nouveau compteur ; la dernière lambda, entre accolades après les parenthèses, est le CONTENU du bouton (son texte) ;
- lignes 109-111 : un texte qui lit l'état : il sera mis à jour à chaque clic.
Le thème des exemples, [commun/Theme.kt] :
| // =========================================================================
// commun/Theme.kt - le thème commun à tous les exemples
// -------------------------------------------------------------------------
// MaterialTheme : les couleurs, les textes et les formes de Material
// Design 3, que TOUS les composants Material placés à l'intérieur utilisent
// (boutons, champs, cartes...). On lui donne un jeu de couleurs (un
// ColorScheme), clair ou sombre, construit autour d'un bleu indigo.
// ThemeExemples(sombre = true) : le thème sombre (cf. exemple 20).
// =========================================================================
package fr.stahe.exemples.commun
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
// les couleurs du thème clair : les autres sont celles de Material 3 par défaut
private val couleursClaires = lightColorScheme(
primary = Color(0xFF4355B9),
onPrimary = Color.White,
primaryContainer = Color(0xFFDEE0FF),
onPrimaryContainer = Color(0xFF00105C),
secondaryContainer = Color(0xFFE0E1F9),
onSecondaryContainer = Color(0xFF181A2C),
surface = Color(0xFFFBF8FF),
surfaceContainer = Color(0xFFEFEDF4),
surfaceContainerLow = Color(0xFFF5F2FA),
)
// les couleurs du thème sombre
private val couleursSombres = darkColorScheme(
primary = Color(0xFFBAC3FF),
onPrimary = Color(0xFF08218A),
primaryContainer = Color(0xFF293CA0),
onPrimaryContainer = Color(0xFFDEE0FF),
secondaryContainer = Color(0xFF434659),
onSecondaryContainer = Color(0xFFE0E1F9),
)
// une fonction @Composable qui "enveloppe" un contenu : le paramètre
// contenu est lui-même une fonction @Composable (cf. exemple 12)
@Composable
fun ThemeExemples(sombre: Boolean = false, contenu: @Composable () -> Unit) {
MaterialTheme(
colorScheme = if (sombre) couleursSombres else couleursClaires,
content = contenu,
)
}
|
- [lightColorScheme(...)], [darkColorScheme(...)] : les couleurs de [Material 3], claires et sombres ; les couleurs non précisées gardent leur valeur par défaut ;
- [ThemeExemples(sombre, contenu)] : une fonction [@Composable] qui reçoit... une autre fonction [@Composable], son contenu. C'est le mécanisme central de la composition (cf. exemple 12) : [ThemeExemples [ PageAccueil() ]] appelle [MaterialTheme] avec ce contenu.
3.1.7. Exécution
Dans [Android Studio], le bouton [Run] lance l'application : le menu des exemples s'affiche ; la première ligne ouvre l'exemple 01. Après deux clics sur le bouton :


Tourner le téléphone (ou l'émulateur, bouton de rotation de sa barre d'outils) : l'activité est détruite puis recréée par [Android], et pourtant le compteur reste le même, grâce à [rememberSaveable]. Remplacer [rememberSaveable] par [remember] : le compteur revient à 0 à chaque rotation. C'est une différence importante avec [Flutter], où l'écran survit à la rotation : sur [Android], la configuration de l'appareil (orientation, langue, taille du texte...) change, et l'activité est recréée.
3.2. Exemple 02 : l'état et les valeurs calculées
Un petit calculateur de prix TTC, qui distingue l'état (ce que l'utilisateur a saisi) et ce qui s'en déduit. À partir de cet exemple, la structure de l'écran (barre du haut, contenu qui défile) est écrite une fois pour toutes dans [commun/PageExemple.kt] :
| // =========================================================================
// commun/PageExemple.kt - la structure commune des écrans des exemples
// -------------------------------------------------------------------------
// Tous les exemples (à partir du 02) ont la même structure d'écran : une
// barre du haut avec un titre, puis un contenu en colonne, qui DÉFILE s'il
// dépasse la hauteur de l'écran. Plutôt que de la réécrire 25 fois, on
// l'écrit une fois, dans une fonction @Composable qui reçoit le contenu en
// paramètre (cf. exemple 12) :
//
// PageExemple("Exemple 02 - l'état") {
// Text("...") // le contenu : des composants, les uns sous les autres
// Button(...) { ... }
// }
// =========================================================================
@file:OptIn(ExperimentalMaterial3Api::class)
package fr.stahe.exemples.commun
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun PageExemple(
titre: String,
// une icône à gauche du titre (aucune par défaut)
icone: @Composable () -> Unit = {},
// des boutons à droite de la barre du haut (aucun par défaut)
actions: @Composable RowScope.() -> Unit = {},
// la zone des messages éphémères (Snackbar, cf. exemple 16), facultative
messages: SnackbarHostState? = null,
// le contenu : une fonction @Composable, appelée DANS une Column
contenu: @Composable ColumnScope.() -> Unit,
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(titre, style = MaterialTheme.typography.titleMedium, maxLines = 2) },
navigationIcon = icone,
actions = actions,
)
},
snackbarHost = { if (messages != null) SnackbarHost(messages) },
) { marges ->
Column(
modifier = Modifier
// les marges du Scaffold : sous la barre du haut, au-dessus de
// la barre de navigation du téléphone
.padding(marges)
.consumeWindowInsets(marges)
// le clavier du téléphone ne cache pas le bas de l'écran
.imePadding()
.fillMaxSize()
// la colonne défile si elle est plus haute que l'écran
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
content = contenu,
)
}
}
|
- [contenu: @Composable ColumnScope.() -> Unit] : le contenu est une fonction [@Composable] appelée DANS une [Column] (elle peut donc utiliser les modificateurs propres à une colonne, comme [weight]) ;
- [verticalScroll(rememberScrollState())] : la colonne défile si elle dépasse la hauteur de l'écran ; [imePadding()] : le clavier du téléphone ne cache pas le bas de l'écran ;
- on l'appelle avec une lambda finale : [PageExemple("titre") [ Text(...) ; Button(...) ]].
Le code de l'exemple :
| // =========================================================================
// ex02/Ex02Activity.kt - l'état et les valeurs calculées
// -------------------------------------------------------------------------
// Un petit calculateur de prix TTC :
// - l'ÉTAT : des variables mutableStateOf ; les changer fait réexécuter
// (recomposer) la fonction qui les lit, qui fabrique l'écran avec les
// nouvelles valeurs ;
// - une valeur CALCULÉE à partir de l'état n'est PAS un état : c'est une
// simple variable locale, recalculée à chaque exécution de la fonction.
// Elle est toujours à jour, sans rien à synchroniser ;
// - une donnée qui dépend des valeurs SUCCESSIVES de l'état (l'historique
// des totaux) se met à jour là où l'état change ;
// - le titre de l'application dans la liste des applications récentes
// suit l'état, comme le reste de l'écran (TitreTache).
// =========================================================================
@file:OptIn(ExperimentalMaterial3Api::class)
package fr.stahe.exemples.ex02
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableDoubleStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
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.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import fr.stahe.exemples.commun.PageExemple
import fr.stahe.exemples.commun.ThemeExemples
import fr.stahe.exemples.commun.TitreTache
import java.text.NumberFormat
import java.util.Locale
// un formateur de montants en euros, à la française (1 234,50 €), cf. exemple 09
val euros: NumberFormat = NumberFormat.getCurrencyInstance(Locale.FRANCE)
// les taux de TVA proposés
val taux = listOf(5.5, 10.0, 20.0)
// "2,5" ou "2.5" -> 2.5 ; un texte qui n'est pas un nombre -> 0
fun prix(texte: String): Double = texte.replace(',', '.').toDoubleOrNull() ?: 0.0
// le total TTC : une fonction PURE (son résultat ne dépend que de ses paramètres)
fun totalTTC(prixHT: Double, quantite: Int, tauxTVA: Double): Double = quantite * prixHT * (1 + tauxTVA / 100)
class Ex02Activity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent { ThemeExemples { PageCalculateur() } }
}
}
@Composable
fun PageCalculateur() {
// l'état : ce que l'utilisateur a saisi ou choisi
var libelle by rememberSaveable { mutableStateOf("Stylo") }
var prixTexte by rememberSaveable { mutableStateOf("2.5") } // le texte du champ, tel que tapé
var quantite by rememberSaveable { mutableIntStateOf(1) }
var tauxTVA by rememberSaveable { mutableDoubleStateOf(20.0) }
// l'historique des 5 derniers changements du total TTC : une liste NON
// modifiable, remplacée par une nouvelle liste à chaque changement
var historique by rememberSaveable { mutableStateOf(listOf<String>()) }
// des valeurs CALCULÉES : de simples variables locales, recalculées à
// chaque exécution de la fonction
val prixHT = prix(prixTexte)
val totalHT = quantite * prixHT
val montantTVA = totalHT * tauxTVA / 100
val ttc = totalHT + montantTVA
// TOUS les changements de l'état passent par cette fonction locale : elle
// note le total TTC avant le changement, applique le changement, et
// complète l'historique si le total a changé
fun changer(changement: () -> Unit) {
val ancien = ttc
changement()
// les variables d'état ont DÉJÀ leur nouvelle valeur
val nouveau = totalTTC(prix(prixTexte), quantite, tauxTVA)
if (nouveau != ancien) {
historique = (listOf("${euros.format(ancien)} -> ${euros.format(nouveau)}") + historique).take(5)
}
}
// le titre de l'application SUIT l'état, comme tout le reste
TitreTache("TTC : ${euros.format(ttc)}")
val styleTotal = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold)
PageExemple("Exemple 02 - l'état") {
Card {
Column(Modifier.padding(16.dp)) {
// un champ CONTRÔLÉ : il affiche value, et appelle onValueChange
// à chaque frappe, avec le nouveau texte
OutlinedTextField(
value = libelle,
// changer d'article remet la quantité à 1 : on le fait là où
// l'on sait POURQUOI l'état change
onValueChange = { nouveau ->
changer {
libelle = nouveau
quantite = 1
}
},
label = { Text("Article") },
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = prixTexte,
onValueChange = { texte -> changer { prixTexte = texte } },
label = { Text("Prix unitaire HT") },
// le clavier numérique du téléphone
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(12.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
// weight(1f) : le texte prend toute la largeur restante
Text("Quantité (revient à 1 si l'article change)", Modifier.weight(1f))
// enabled = false : le bouton est désactivé
IconButton(onClick = { changer { quantite-- } }, enabled = quantite > 0) {
Icon(Icons.Default.KeyboardArrowDown, contentDescription = "moins")
}
Text("$quantite", style = styleTotal)
IconButton(onClick = { changer { quantite++ } }) {
Icon(Icons.Default.Add, contentDescription = "plus")
}
}
Spacer(Modifier.height(8.dp))
// des boutons dont UN seul est sélectionné : le taux de TVA
SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) {
taux.forEachIndexed { i, t ->
SegmentedButton(
selected = t == tauxTVA,
onClick = { changer { tauxTVA = t } },
shape = SegmentedButtonDefaults.itemShape(index = i, count = taux.size),
) {
Text("${t.toString().removeSuffix(".0").replace('.', ',')} %")
}
}
}
Spacer(Modifier.height(12.dp))
OutlinedButton(onClick = {
changer {
libelle = "Stylo"
prixTexte = "2.5"
quantite = 1
tauxTVA = 20.0
}
}) {
Text("Réinitialiser")
}
}
}
Card {
Column(Modifier.padding(16.dp)) {
Text("$quantite x $libelle", style = MaterialTheme.typography.titleLarge)
HorizontalDivider(Modifier.padding(vertical = 8.dp))
Ligne("Total HT", euros.format(totalHT))
Ligne("TVA (${tauxTVA.toString().replace('.', ',')} %)", euros.format(montantTVA))
Ligne("Total TTC", euros.format(ttc), styleTotal)
}
}
Card {
Column(Modifier.padding(16.dp).fillMaxWidth()) {
Text("Historique du total TTC", style = MaterialTheme.typography.titleSmall)
// une ligne de texte par élément de l'historique (cf. exemple 03)
for (ligne in historique) {
Text("• $ligne")
}
}
}
}
}
// une ligne "libellé ....... montant" : une fonction @Composable qui en
// fabrique d'autres. style : un paramètre facultatif, avec sa valeur par défaut
@Composable
fun Ligne(libelle: String, montant: String, style: TextStyle = MaterialTheme.typography.bodyLarge) {
Row(Modifier.padding(vertical = 4.dp)) {
Text(libelle, Modifier.weight(1f), style = style)
Text(montant, style = style)
}
}
|
- lignes 72-75 : des fonctions PURES, hors de l'écran : le prix tapé (["2,5"] ou ["2.5"]) converti en nombre, le total TTC ;
- lignes 88-94 : l'état. [historique] est une liste NON modifiable ([listOf]) : on la REMPLACE par une nouvelle liste à chaque changement, ce que [Compose] détecte (l'affectation de l'état) ;
- lignes 98-101 : les valeurs CALCULÉES : de simples variables locales. La fonction est réexécutée à chaque changement de l'état : elles sont toujours à jour, il n'y a rien à synchroniser ;
- lignes 106-112 : une fonction LOCALE, par laquelle passent tous les changements : elle note le total avant le changement, l'applique ([changement()] : la fonction reçue en paramètre), calcule le nouveau total, et complète l'historique. Les variables d'état ont déjà leur nouvelle valeur juste après l'affectation ; [take(5)] garde les cinq premières lignes ;
- ligne 117 : le titre de l'application dans la liste des applications récentes suit l'état (fonction [TitreTache] de [commun/TitreTache.kt], qui appelle [setTaskDescription] de l'activité) ;
- lignes 125-136 : un champ CONTRÔLÉ : il affiche [value], et appelle [onValueChange] à chaque frappe, avec le nouveau texte. Avec [Compose], le texte d'un champ est un état comme un autre : pas de contrôleur à créer ni à libérer, et [Réinitialiser] n'a qu'à changer l'état ;
- ligne 143 : [KeyboardOptions(keyboardType = KeyboardType.Decimal)] : le clavier numérique du téléphone ;
- lignes 147-156 : la quantité : [enabled = quantite > 0] désactive le bouton « moins » à 0 ; [Modifier.weight(1f)] : le texte prend toute la largeur restante de la ligne ;
- lignes 44-168 : des boutons « segmentés », dont UN seul est sélectionné : le taux de TVA. [forEachIndexed] parcourt la liste avec le rang de chaque élément ; [itemShape] arrondit les coins du premier et du dernier ;
- lignes 209-212 : [Ligne], une fonction [@Composable] à paramètres, dont un facultatif ([style], avec sa valeur par défaut).


À gauche, après deux appuis sur [+], le choix de 10 % et la saisie de 3,2 ; à droite, l'article a changé : la quantité est revenue à 1, et l'historique a enregistré chaque changement du total.
3.3. Exemple 03 : écrire un arbre de composants
[Compose] n'a pas de langage de gabarits : pour les conditions et les boucles, on utilise les instructions de [Kotlin], directement dans la fonction [@Composable]. Une liste de médecins, filtrée par une saisie :
| // =========================================================================
// ex03/Ex03Activity.kt - écrire un arbre de composants
// -------------------------------------------------------------------------
// Compose n'a pas de langage de gabarits (pas de HTML, pas de XML) : les
// écrans sont écrits en Kotlin, avec des appels de fonctions @Composable
// imbriqués. Et pour les conditions et les boucles, on utilise... les
// instructions ordinaires de Kotlin, DIRECTEMENT dans la fonction :
// - if (condition) { Composant() } : un composant qui n'EXISTE que
// si la condition est vraie ;
// - if (c) { A() } else { B() } : l'un OU l'autre ;
// - for (x in liste) { Composant(x) } : un composant par élément ;
// - when (valeur) { cas -> ... } : une valeur parmi plusieurs.
// Une fonction @Composable est une fonction comme une autre : tout le
// langage est disponible pour décrire l'écran.
// =========================================================================
@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
package fr.stahe.exemples.ex03
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AddCircle
import androidx.compose.material.icons.filled.Face
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SuggestionChip
import androidx.compose.material3.SuggestionChipDefaults
import androidx.compose.material3.Switch
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.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import fr.stahe.exemples.R
import fr.stahe.exemples.commun.PageExemple
import fr.stahe.exemples.commun.ThemeExemples
// une "énumération" : les seules valeurs possibles d'une spécialité
enum class Specialite { GENERALISTE, PEDIATRE, CARDIOLOGUE }
// une classe de DONNÉES : ses propriétés sont val, un objet Medecin ne
// change jamais. Kotlin lui ajoute equals, hashCode, toString et copy
data class Medecin(
val id: Int,
val titre: String,
val prenom: String,
val nom: String,
val specialite: Specialite,
val disponible: Boolean,
)
val medecins = listOf(
Medecin(1, "Mme", "Marie", "PELISSIER", Specialite.GENERALISTE, true),
Medecin(2, "Mr", "Paul", "TATOU", Specialite.PEDIATRE, false),
Medecin(3, "Melle", "Sophie", "DUPONT", Specialite.CARDIOLOGUE, true),
)
// "une valeur parmi plusieurs, selon une valeur" : une EXPRESSION when.
// Le compilateur vérifie que tous les cas de l'énumération sont traités
fun libelle(s: Specialite): String = when (s) {
Specialite.GENERALISTE -> "généraliste"
Specialite.PEDIATRE -> "pédiatre"
Specialite.CARDIOLOGUE -> "cardiologue"
}
// une icône et sa couleur : une "paire" de valeurs (Pair), écrite avec "to"
fun icone(s: Specialite): Pair<ImageVector, Color> = when (s) {
Specialite.PEDIATRE -> Icons.Default.Face to Color(0xFFFF9800)
Specialite.CARDIOLOGUE -> Icons.Default.Favorite to Color.Red
else -> Icons.Default.AddCircle to Color(0xFF009688) // else : tous les autres cas
}
class Ex03Activity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent { ThemeExemples { PageMedecins() } }
}
}
@Composable
fun PageMedecins() {
var filtre by rememberSaveable { mutableStateOf("") } // le texte recherché
var seulementDisponibles by rememberSaveable { mutableStateOf(false) }
var afficherAide by rememberSaveable { mutableStateOf(true) }
// la liste affichée : une variable ordinaire, recalculée à chaque exécution
val texte = filtre.trim().lowercase()
val affiches = medecins.filter { m ->
(!seulementDisponibles || m.disponible) &&
"${m.prenom} ${m.nom} ${libelle(m.specialite)}".lowercase().contains(texte)
}
PageExemple(
titre = "Exemple 03 - les composants",
// une image vectorielle livrée avec l'application (res/drawable/medecin.xml)
icone = {
Image(
painter = painterResource(R.drawable.medecin),
contentDescription = null,
modifier = Modifier.padding(12.dp).size(28.dp),
)
},
) {
OutlinedTextField(
value = filtre,
onValueChange = { filtre = it }, // it : le paramètre unique d'une lambda
placeholder = { Text("rechercher...") },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
modifier = Modifier.fillMaxWidth(),
)
// un interrupteur, avec son libellé
ListItem(
headlineContent = { Text("disponibles seulement") },
trailingContent = { Switch(checked = seulementDisponibles, onCheckedChange = { seulementDisponibles = it }) },
)
TextButton(onClick = { afficherAide = !afficherAide }) {
Text(if (afficherAide) "Cacher l'aide" else "Montrer l'aide")
}
// un if : ce composant n'EXISTE que si afficherAide est vrai
if (afficherAide) {
Card(colors = CardDefaults.cardColors(containerColor = Color(0xFFE1F5FE))) {
Text(
"Tapez un nom ou une spécialité. Les médecins indisponibles sont grisés.",
Modifier.padding(12.dp),
)
}
}
// "l'un des trois blocs" : un when sans argument, comme une suite de if
when {
medecins.isEmpty() -> Text("Aucun médecin.")
affiches.isEmpty() -> Text("Aucun médecin ne correspond à « $filtre ».", color = Color(0xFFFF5722))
else -> Column {
// un composant par médecin, avec son rang : withIndex()
for ((i, m) in affiches.withIndex()) {
LigneMedecin(i + 1, m)
}
}
}
Spacer(Modifier.height(12.dp))
// un style CALCULÉ : la taille du texte dépend du nombre de médecins affichés
Text(
"${affiches.size} médecin(s) affiché(s) - la taille de ce texte en dépend.",
fontSize = (12 + affiches.size * 2).sp,
)
}
}
// une ligne de la liste : une fonction @Composable avec des paramètres
@Composable
fun LigneMedecin(rang: Int, m: Medecin) {
val (image, couleur) = icone(m.specialite) // une "déstructuration" de la paire
ListItem(
// alpha : les médecins indisponibles sont grisés
modifier = Modifier.alpha(if (m.disponible) 1f else 0.5f),
leadingContent = { Text("$rang", style = MaterialTheme.typography.titleMedium) },
headlineContent = { Text("${m.titre} ${m.prenom} ${m.nom}") },
// FlowRow : comme une Row, mais qui passe à la ligne si la place manque
supportingContent = {
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Icon(image, contentDescription = null, tint = couleur, modifier = Modifier.size(18.dp))
Text(libelle(m.specialite))
}
},
trailingContent = {
SuggestionChip(
onClick = {},
label = { Text(if (m.disponible) "disponible" else "indisponible") },
// une couleur choisie par une expression conditionnelle
colors = SuggestionChipDefaults.suggestionChipColors(
containerColor = if (m.disponible) Color(0xFFC8E6C9) else Color(0xFFE0E0E0),
),
)
},
)
}
|
- lignes 68-78 : une énumération et une classe de données ([data class]) : [Kotlin] leur écrit [equals], [hashCode], [toString] et [copy] ;
- lignes 89-99 : une EXPRESSION [when] : une valeur parmi plusieurs ; le compilateur vérifie que tous les cas de l'énumération sont traités ([else] : tous les autres). [to] fabrique une paire ([Pair]) ;
- lignes 117-120 : la liste affichée, calculée à chaque exécution à partir de l'état ([filter]) ;
- lignes 126-130 : [icone] : un emplacement de [PageExemple] pour l'icône de la barre du haut. [painterResource(R.drawable.medecin)] : une image VECTORIELLE de l'application (fichier [res/drawable/medecin.xml], le format vectoriel d'[Android], converti depuis le fichier SVG de l'exemple Flutter par [New / Vector Asset] d'[Android Studio]). [R] : la classe générée qui donne un numéro à chaque ressource ;
- ligne 136 : [it] : le nom du paramètre unique d'une lambda ;
- ligne 150 : un [if] : le composant n'EXISTE que si la condition est vraie ;
- lignes 159-165 : un [when] sans argument (une suite de [if]) : l'un des trois blocs ; une boucle [for], avec le rang de chaque élément ([withIndex()] et une déstructuration [(i, m)]) ;
- ligne 173 : un style calculé : la taille du texte dépend du nombre de médecins ([sp] : l'unité des textes, qui suit le réglage de taille du texte du téléphone ; [dp] : celle des distances) ;
- lignes 180-199 : une ligne de liste ([ListItem], avec ses emplacements [leadingContent], [headlineContent]...), grisée par [Modifier.alpha] si le médecin n'est pas disponible ; [FlowRow] : une ligne qui passe à la ligne si la place manque ; une puce ([SuggestionChip]) dont la couleur dépend du médecin.



3.4. Exemple 04 : les événements
Une liste de tâches : on en ajoute, on les coche, on les supprime (par un bouton, ou en faisant glisser la ligne, le geste typique d'un mobile), et une zone sensible aux appuis. Un composant interactif reçoit en paramètres les FONCTIONS à appeler quand il se passe quelque chose : ce sont les « événements » de [Compose].
| // =========================================================================
// ex04/Ex04Activity.kt - réagir aux actions de l'utilisateur
// -------------------------------------------------------------------------
// Un composant interactif reçoit en paramètres les FONCTIONS à appeler
// quand il se passe quelque chose (des "callbacks", des lambdas Kotlin) :
// - onClick = { ... } : appelée à l'appui (boutons) ;
// - onClick = { supprimer(t) } : la lambda passe ses propres arguments ;
// - onValueChange, onCheckedChange : la fonction reçoit une donnée (le
// texte, la case cochée...) ;
// - Modifier.pointerInput : ajoute des gestes (appui, double
// appui, appui long...) à N'IMPORTE
// QUEL composant ;
// - SwipeToDismissBox : faire glisser une ligne pour la
// retirer, le geste typique d'un mobile.
// Pour donner le focus à un champ depuis le code, on lui associe un
// FocusRequester.
// =========================================================================
@file:OptIn(ExperimentalMaterial3Api::class)
package fr.stahe.exemples.ex04
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SwipeToDismissBox
import androidx.compose.material3.Text
import androidx.compose.material3.rememberSwipeToDismissBoxState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import fr.stahe.exemples.commun.PageExemple
import fr.stahe.exemples.commun.ThemeExemples
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import kotlin.math.roundToInt
// une tâche : une classe de données NON modifiable ; pour la "cocher", on
// en fabrique une copie : tache.copy(faite = true)
data class Tache(val id: Int, val libelle: String, val faite: Boolean = false)
class Ex04Activity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent { ThemeExemples { PageTaches() } }
}
}
@Composable
fun PageTaches() {
// des listes OBSERVÉES par Compose : ajouter, retirer ou remplacer un
// élément provoque la recomposition. (remember, et non rememberSaveable :
// elles sont perdues si l'activité est recréée, cf. exemple 20)
val taches = remember {
mutableStateListOf(Tache(1, "Installer Android Studio", faite = true), Tache(2, "Lire le chapitre sur les événements"))
}
val journal = remember { mutableStateListOf<String>() }
var saisie by remember { mutableStateOf("") }
var prochainId by remember { mutableIntStateOf(3) }
// le FocusRequester : de quoi donner le focus au champ (le curseur et le
// clavier du téléphone) depuis le code
val focus = remember { FocusRequester() }
fun tracer(message: String) {
val heure = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm"))
journal.add(0, "$heure $message")
}
// appelée par le bouton ET par la touche "OK" du clavier (keyboardActions)
fun ajouter() {
val libelle = saisie.trim()
if (libelle.isEmpty()) {
return
}
taches.add(Tache(prochainId++, libelle))
tracer("ajout de « $libelle »")
saisie = ""
// le focus revient au champ : on peut taper la tâche suivante
focus.requestFocus()
}
fun basculer(tache: Tache, faite: Boolean) {
// on REMPLACE la tâche par une copie modifiée
taches[taches.indexOf(tache)] = tache.copy(faite = faite)
tracer("« ${tache.libelle} » : ${if (faite) "faite" else "à faire"}")
}
fun supprimer(tache: Tache, comment: String) {
taches.remove(tache)
tracer("suppression de « ${tache.libelle} » ($comment)")
}
// la densité de l'écran : pour convertir des pixels en dp
val densite = LocalDensity.current.density
PageExemple("Exemple 04 - les événements") {
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = saisie,
onValueChange = { saisie = it },
placeholder = { Text("nouvelle tâche (Échap pour vider)") },
singleLine = true,
// la touche "OK" du clavier du téléphone ajoute la tâche
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { ajouter() }),
modifier = Modifier
.weight(1f)
.focusRequester(focus)
// un clavier physique (émulateur, tablette) : Échap vide le champ
.onPreviewKeyEvent { evenement ->
if (evenement.key == Key.Escape) {
saisie = ""
true // l'événement est traité
} else {
false // l'événement poursuit son chemin
}
},
)
Spacer(Modifier.width(8.dp))
Button(onClick = { ajouter() }) { Text("Ajouter") }
}
Spacer(Modifier.height(12.dp))
for (t in taches) {
// key(t.id) : l'IDENTITÉ de la ligne. L'état de chaque ligne (son
// glissement) suit SA tâche, même si la liste change (cf. le cours)
key(t.id) {
LigneTache(
tache = t,
onBasculer = { faite -> basculer(t, faite) },
onSupprimer = { comment -> supprimer(t, comment) },
)
}
}
Spacer(Modifier.height(12.dp))
// pointerInput : un composant quelconque devient sensible aux gestes
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxWidth()
.height(80.dp)
.background(Color(0xFFE7F5FF), RoundedCornerShape(8.dp))
.pointerInput(Unit) {
detectTapGestures(
// la position de l'appui, relative au composant (en pixels)
onPress = { p -> tracer("appui en (${(p.x / densite).roundToInt()}, ${(p.y / densite).roundToInt()})") },
onDoubleTap = { tracer("double appui") },
onLongPress = { tracer("appui long") },
)
},
) {
Text("Touchez cette zone (appui, double appui, appui long)")
}
Spacer(Modifier.height(16.dp))
Text("Journal", style = MaterialTheme.typography.titleSmall)
for (ligne in journal) {
Text(ligne, fontSize = 12.sp)
}
}
}
// une ligne de la liste : la tâche, et DEUX fonctions pour prévenir l'appelant
@Composable
fun LigneTache(tache: Tache, onBasculer: (Boolean) -> Unit, onSupprimer: (String) -> Unit) {
// l'état du glissement de la ligne (où en est-elle ?)
val glissement = rememberSwipeToDismissBoxState()
// SwipeToDismissBox : faire glisser la ligne vers la gauche la retire
SwipeToDismissBox(
state = glissement,
enableDismissFromStartToEnd = false,
// onDismiss : appelée quand la ligne a glissé jusqu'au bout
onDismiss = { onSupprimer("glissement") },
// ce qui apparaît SOUS la ligne quand elle glisse
backgroundContent = {
Box(
contentAlignment = Alignment.CenterEnd,
modifier = Modifier.fillMaxSize().background(Color(0xFFFFCDD2)).padding(end = 16.dp),
) {
Icon(Icons.Default.Delete, contentDescription = null)
}
},
) {
ListItem(
// la case est À GAUCHE, comme sur une liste de tâches ; elle
// transmet sa nouvelle valeur (un Boolean)
leadingContent = { Checkbox(checked = tache.faite, onCheckedChange = onBasculer) },
headlineContent = {
Text(
tache.libelle,
style = if (tache.faite) {
TextStyle(textDecoration = TextDecoration.LineThrough, color = Color.Gray)
} else {
TextStyle.Default
},
)
},
trailingContent = {
IconButton(onClick = { onSupprimer("bouton") }) {
Icon(Icons.Outlined.Delete, contentDescription = "supprimer", tint = Color.Red)
}
},
)
}
}
|
- ligne 84 : une tâche, objet NON modifiable ; la cocher, c'est la REMPLACER par une copie ([copy(faite = true)]) ;
- lignes 99-102 : [mutableStateListOf] : une liste OBSERVÉE par [Compose] ; ajouter, retirer ou remplacer un élément provoque la recomposition. On garde ici [remember] (et non [rememberSaveable]) : ces listes sont perdues si l'activité est recréée ;
- ligne 107 : un [FocusRequester], par lequel le code donne le focus (le curseur et le clavier) au champ, après chaque ajout ([focus.requestFocus()]) ;
- lignes 115-135 : trois fonctions locales, qui modifient l'état et tiennent le journal ;
- lignes 149-150 : [imeAction = ImeAction.Done] : la touche de validation du clavier du téléphone devient « OK », et [keyboardActions] dit quoi faire quand on l'appuie : ajouter la tâche ;
- lignes 68-160 : [onPreviewKeyEvent] reçoit les touches d'un clavier PHYSIQUE (celui du PC pour l'émulateur, celui d'une tablette) : [Échap] vide le champ. Renvoyer [true] arrête l'événement ;
- lignes 171-175 : [key(t.id)] donne une IDENTITÉ à chaque ligne. Sans [key], [Compose] associe l'état d'une ligne (ici, son glissement) à sa POSITION dans la liste : après une suppression, la ligne suivante hériterait de l'état de la ligne supprimée. Les deux lambdas [onBasculer], [onSupprimer] « capturent » la tâche [t] et transmettent ce que leur passe la ligne ;
- lignes 187-192 : [Modifier.pointerInput] rend N'IMPORTE QUEL composant sensible aux gestes : [detectTapGestures] distingue l'appui, le double appui et l'appui long. La position est donnée en pixels : on la convertit en [dp] avec la densité de l'écran ([LocalDensity]) ;
- ligne 208 : [LigneTache] reçoit la tâche et DEUX fonctions pour prévenir son appelant : elle ne modifie pas la liste elle-même (cf. exemple 11) ;
- lignes 212-218 : [SwipeToDismissBox] : la ligne glisse vers la gauche ([enableDismissFromStartToEnd = false] interdit l'autre sens) en découvrant [backgroundContent] ; arrivée au bout, [onDismiss] est appelée ;
- ligne 230 : la case à cocher transmet sa nouvelle valeur ([Boolean]) : on lui passe directement [onBasculer], qui a le bon type [(Boolean) -> Unit].



À gauche, une tâche ajoutée, une autre cochée, un appui dans la zone ; au milieu, la première ligne glisse vers la gauche ; à droite, elle a été supprimée, et le journal le dit.
3.5. Exemple 05 : tous les types de champs
Avec [Compose], TOUS les champs sont CONTRÔLÉS : le champ affiche une valeur de l'état et prévient de chaque changement par une fonction, qui met l'état à jour. Ce que transmet cette fonction dépend du champ :
- [OutlinedTextField] : le nouveau texte ([String]) ;
- [Checkbox], [Switch] : un [Boolean] ;
- [RadioButton], [FilterChip] : rien : l'appui EST le choix ;
- [Slider] : un [Float] ;
- [DatePickerDialog] : une date, à la confirmation.
Pour la liste déroulante, [Material 3] ne fournit pas de composant d'un seul tenant : on l'assemble une fois pour toutes dans [commun/ListeDeroulante.kt] :
| // =========================================================================
// commun/ListeDeroulante.kt - une liste déroulante, prête à l'emploi
// -------------------------------------------------------------------------
// Material 3 pour Compose n'a pas de composant "liste déroulante" d'un
// seul tenant : on l'assemble avec trois éléments - ExposedDropdownMenuBox,
// un champ de texte en lecture seule, et le menu ExposedDropdownMenu. On
// le fait ici une fois pour toutes (exemples 05 à 08, 24, 25) :
//
// ListeDeroulante("Pays", valeur = pays, choix = listOf("fr", "be", "ch"),
// texte = { nomsPays[it] }, onChange = { pays = it })
//
// La fonction est GÉNÉRIQUE (<T>) : les choix peuvent être des chaînes,
// des nombres, des médecins... texte dit comment AFFICHER un choix. Un choix
// peut être null ("-- choisir --") si T est un type "nullable" (Int?).
// =========================================================================
@file:OptIn(ExperimentalMaterial3Api::class)
package fr.stahe.exemples.commun
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
@Composable
fun <T> ListeDeroulante(
libelle: String,
valeur: T,
choix: List<T>,
onChange: (T) -> Unit,
modifier: Modifier = Modifier,
texte: (T) -> String = { it.toString() },
// un message d'erreur sous le champ (cf. exemples 07 et 25)
erreur: String? = null,
) {
// le menu est-il ouvert ? un état PROPRE à chaque liste déroulante
var ouvert by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(expanded = ouvert, onExpandedChange = { ouvert = it }, modifier = modifier) {
// le champ qui affiche le choix courant ; menuAnchor en fait le point
// d'ancrage du menu (le toucher ouvre le menu)
OutlinedTextField(
value = texte(valeur),
onValueChange = {},
readOnly = true,
singleLine = true,
label = { Text(libelle) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = ouvert) },
isError = erreur != null,
supportingText = if (erreur == null) null else { { Text(erreur) } },
modifier = Modifier.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable).fillMaxWidth(),
)
// le menu : une ligne par choix
ExposedDropdownMenu(expanded = ouvert, onDismissRequest = { ouvert = false }) {
for (c in choix) {
DropdownMenuItem(
text = { Text(texte(c)) },
onClick = {
onChange(c)
ouvert = false
},
)
}
}
}
}
|
- lignes 36-44 : [fun <T> ListeDeroulante] : une fonction GÉNÉRIQUE : les choix peuvent être des chaînes, des nombres, des médecins... [texte] dit comment afficher un choix (par défaut, son [toString()]) ;
- ligne 47 : l'état propre à chaque liste : le menu est-il ouvert ?
- lignes 48-49 : [ExposedDropdownMenuBox] associe un champ EN LECTURE SEULE ([readOnly = true]), qui affiche le choix courant, et un menu ; [menuAnchor] fait du champ le point d'ancrage du menu ;
- lignes 63-68 : une ligne de menu par choix ; l'appui prévient l'appelant ([onChange(c)]) et ferme le menu.
Le code de l'exemple :
| // =========================================================================
// ex05/Ex05Activity.kt - tous les types de champs
// -------------------------------------------------------------------------
// Avec Compose, TOUS les champs sont CONTRÔLÉS : un champ affiche une
// valeur de l'état (value, checked, selected...) et prévient de chaque
// changement par une fonction (onValueChange, onCheckedChange, onClick...),
// qui met l'état à jour. L'état est la seule source de vérité : modifier
// l'état (par le code) modifie le champ. Ce que transmet la fonction
// dépend du champ :
// - OutlinedTextField -> le nouveau texte (String) ;
// - Checkbox, Switch -> Boolean ;
// - RadioButton -> rien : onClick, c'est le choix ;
// - ListeDeroulante (commun/) -> la valeur de l'élément choisi ;
// - FilterChip -> rien : onClick bascule la puce ;
// - Slider -> Float ;
// - DatePickerDialog (une fenêtre) -> une date, à la confirmation.
// Tout se passe sur le téléphone, sans serveur.
// =========================================================================
@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
package fr.stahe.exemples.ex05
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DateRange
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Slider
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.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
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.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import fr.stahe.exemples.commun.ListeDeroulante
import fr.stahe.exemples.commun.PageExemple
import fr.stahe.exemples.commun.ThemeExemples
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneOffset
val titres = listOf("Mr", "Mme", "Melle")
val loisirs = listOf("sport", "musique", "lecture", "cinéma")
val pays = mapOf("fr" to "France", "be" to "Belgique", "ch" to "Suisse")
val langues = mapOf("fr" to "français", "en" to "anglais", "es" to "espagnol")
// toutes les valeurs, réunies en un objet (ce qu'on enverrait à un serveur).
// @Serializable : le plugin kotlinx.serialization sait le convertir en JSON
@Serializable
data class Modele(
val titre: String,
val nom: String,
val age: Int?,
val presentation: String,
val newsletter: Boolean,
val loisirs: List<String>,
val pays: String,
val langues: List<String>,
val naissance: String?,
val satisfaction: Int,
)
// le convertisseur JSON : prettyPrint, du JSON indenté
val jsonIndente = Json { prettyPrint = true }
class Ex05Activity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent { ThemeExemples { PageFormulaire() } }
}
}
@Composable
fun PageFormulaire() {
// un état par champ du formulaire
var titre by rememberSaveable { mutableStateOf("Mme") }
var nom by rememberSaveable { mutableStateOf("") }
var age by rememberSaveable { mutableStateOf("30") }
var presentation by rememberSaveable { mutableStateOf("") } // la valeur VALIDÉE
var brouillon by rememberSaveable { mutableStateOf("") } // le texte en cours de frappe
var newsletter by rememberSaveable { mutableStateOf(false) }
var mesLoisirs by rememberSaveable { mutableStateOf(setOf("lecture")) } // un ENSEMBLE : pas de doublon
var monPays by rememberSaveable { mutableStateOf("fr") }
var mesLangues by rememberSaveable { mutableStateOf(setOf<String>()) }
var naissance by rememberSaveable { mutableStateOf<LocalDate?>(null) } // null : pas encore choisie
var satisfaction by rememberSaveable { mutableFloatStateOf(3f) }
var envoye by rememberSaveable { mutableStateOf<String?>(null) }
var calendrierOuvert by rememberSaveable { mutableStateOf(false) }
// le modèle : une valeur CALCULÉE à partir de l'état
val modele = Modele(
titre = titre,
nom = nom.trim(),
age = age.toIntOrNull(), // null si le champ est vide
presentation = presentation,
newsletter = newsletter,
loisirs = mesLoisirs.toList(),
pays = monPays,
langues = mesLangues.toList(),
naissance = naissance?.toString(), // "2026-10-05" : le format des dates de l'API RdvMedecins
satisfaction = satisfaction.toInt(),
)
val styleTitre = MaterialTheme.typography.titleSmall
PageExemple("Exemple 05 - les champs contrôlés") {
Text("Titre", style = styleTitre)
// selectableGroup : un groupe de boutons radio (pour l'accessibilité)
Row(Modifier.selectableGroup(), verticalAlignment = Alignment.CenterVertically) {
for (t in titres) {
// selectable : TOUTE la ligne (bouton + texte) réagit à l'appui
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.selectable(selected = titre == t, onClick = { titre = t }, role = Role.RadioButton),
) {
RadioButton(selected = titre == t, onClick = null)
Text(t)
Spacer(Modifier.width(12.dp))
}
}
}
Row {
OutlinedTextField(value = nom, onValueChange = { nom = it }, label = { Text("Nom") }, modifier = Modifier.weight(2f))
Spacer(Modifier.width(12.dp))
OutlinedTextField(
value = age,
onValueChange = { age = it },
label = { Text("Âge") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.weight(1f),
)
}
// onFocusChanged : prévient quand le champ GAGNE ou PERD le focus. La
// présentation n'est copiée dans l'état qu'à la sortie du champ
OutlinedTextField(
value = brouillon,
onValueChange = { brouillon = it },
label = { Text("Présentation (mise à jour à la sortie du champ)") },
minLines = 2,
modifier = Modifier.fillMaxWidth().onFocusChanged { etat ->
if (!etat.isFocused) presentation = brouillon
},
)
// une case seule : true / false. toggleable : toute la ligne réagit
ListItem(
modifier = Modifier.toggleable(value = newsletter, onValueChange = { newsletter = it }, role = Role.Checkbox),
leadingContent = { Checkbox(checked = newsletter, onCheckedChange = null) },
headlineContent = { Text("Recevoir la lettre d'information") },
)
Text("Loisirs", style = styleTitre)
// plusieurs puces pour UN ensemble : on ajoute ou on retire la valeur
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
for (l in loisirs) {
FilterChip(
selected = l in mesLoisirs,
onClick = { mesLoisirs = if (l in mesLoisirs) mesLoisirs - l else mesLoisirs + l },
label = { Text(l) },
)
}
}
// une liste déroulante : valeur dit quel élément est choisi
ListeDeroulante(
libelle = "Pays",
valeur = monPays,
choix = pays.keys.toList(),
texte = { code -> pays.getValue(code) },
onChange = { monPays = it },
modifier = Modifier.width(220.dp),
)
Text("Langues parlées", style = styleTitre)
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
for ((code, libelle) in langues) {
FilterChip(
selected = code in mesLangues,
onClick = { mesLangues = if (code in mesLangues) mesLangues - code else mesLangues + code },
label = { Text(libelle) },
)
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Satisfaction : ${satisfaction.toInt()} / 5", style = styleTitre)
// un curseur de 1 à 5 : 3 crans intermédiaires (2, 3, 4)
Slider(
value = satisfaction,
onValueChange = { satisfaction = it },
valueRange = 1f..5f,
steps = 3,
modifier = Modifier.weight(1f).padding(start = 8.dp),
)
}
ListItem(
leadingContent = { Icon(Icons.Default.DateRange, contentDescription = null) },
headlineContent = { Text("Date de naissance") },
supportingContent = { Text(naissance?.toString() ?: "non choisie") },
trailingContent = { OutlinedButton(onClick = { calendrierOuvert = true }) { Text("Choisir") } },
)
// FlowRow : les boutons passent à la ligne sur un écran étroit
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { envoye = Json.encodeToString(Modele.serializer(), modele) }) { Text("Envoyer") }
// l'état modifié PAR LE CODE : les champs suivent
OutlinedButton(onClick = {
titre = "Mr"
nom = "MARTIN"
age = "42"
mesLoisirs = setOf("sport", "cinéma")
monPays = "be"
}) { Text("Remplir par le code") }
}
Spacer(Modifier.height(8.dp))
Text("Le modèle, en direct", style = styleTitre)
Text(
jsonIndente.encodeToString(Modele.serializer(), modele),
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
modifier = Modifier.fillMaxWidth().background(Color(0xFFF5F5F5)).padding(8.dp),
)
// un bloc qui n'existe qu'après un envoi ; ?.let : seulement si envoye n'est pas null
envoye?.let { json ->
Text("Dernier envoi", style = styleTitre)
Text(
json,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
modifier = Modifier.fillMaxWidth().background(Color(0xFFE8F5E9)).padding(8.dp),
)
}
}
// la fenêtre du calendrier : elle n'existe que si calendrierOuvert est vrai
if (calendrierOuvert) {
// l'état du calendrier : la date sélectionnée, en millisecondes (UTC)
val etatCalendrier = rememberDatePickerState(
initialSelectedDateMillis = (naissance ?: LocalDate.of(2000, 1, 1)).atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli(),
)
DatePickerDialog(
onDismissRequest = { calendrierOuvert = false },
confirmButton = {
TextButton(onClick = {
etatCalendrier.selectedDateMillis?.let { ms ->
naissance = Instant.ofEpochMilli(ms).atZone(ZoneOffset.UTC).toLocalDate()
}
calendrierOuvert = false
}) { Text("OK") }
},
dismissButton = { TextButton(onClick = { calendrierOuvert = false }) { Text("Annuler") } },
) {
DatePicker(state = etatCalendrier)
}
}
}
|
- lignes 89-101 : toutes les valeurs du formulaire, réunies en une classe [@Serializable] : le plugin [kotlinx.serialization] écrit à la compilation le code qui la convertit en JSON ([Json.encodeToString(Modele.serializer(), modele)]) ;
- lignes 118-130 : un état par champ. [mesLoisirs] est un ENSEMBLE ([Set]) non modifiable : [mesLoisirs + l] et [mesLoisirs - l] en fabriquent de nouveaux ; [naissance] est une date « nullable » ([LocalDate?]) : [null] tant qu'elle n'est pas choisie ;
- lignes 133-143 : le modèle, une valeur CALCULÉE à chaque exécution ; [toIntOrNull()] : le nombre, ou [null] si le texte n'en est pas un ;
- lignes 150-157 : des boutons radio. [Modifier.selectable] rend TOUTE la ligne (bouton et texte) sensible à l'appui ; le bouton lui-même n'a donc pas d'action ([onClick = null]) ; [selectableGroup] regroupe les boutons pour les outils d'accessibilité ;
- lignes 177-182 : la présentation n'est copiée dans l'état qu'à la SORTIE du champ : [onFocusChanged] prévient quand le champ gagne ou perd le focus ;
- lignes 187-189 : une case à cocher, avec le même principe que les boutons radio ([Modifier.toggleable] sur toute la ligne) ;
- lignes 193-198 : des puces à cocher ([FilterChip]) pour choisir plusieurs valeurs ; [FlowRow] les fait passer à la ligne si la place manque ;
- lignes 203-208 : la liste déroulante de [commun/] ;
- lignes 224-228 : un curseur de 1 à 5 : [steps = 3] crans intermédiaires ;
- lignes 236-248 : « Remplir par le code » : on modifie l'état, les champs suivent ;
- lignes 251-259 : le modèle affiché en JSON indenté ([Json [ prettyPrint = true ]]) ; [envoye?.let [ ... ]] : le bloc n'existe que si [envoye] n'est pas [null] ;
- lignes 271-288 : la fenêtre du calendrier n'existe que si [calendrierOuvert] est vrai. [DatePicker] travaille en millisecondes depuis le 1er janvier 1970 (heure UTC) : on convertit dans les deux sens avec [java.time] ([LocalDate], [Instant], [ZoneOffset.UTC]).



Le calendrier ([DatePickerDialog]) est en français : [Material 3] suit la langue de l'application (cf. exemple 21).
L'exemple 05 avait une variable d'état par champ. Pour un vrai formulaire, on préfère UN objet qui contient toutes les valeurs, et l'on décrit ses changements dans une seule fonction PURE, le RÉDUCTEUR : [reducteur(etatActuel, action) -> nouvelEtat]. L'écran ne modifie plus l'état : il DÉCRIT ce qui s'est passé (« le champ NOM vaut maintenant Dupont », « le champ NOM a été quitté »), et le réducteur en déduit le nouvel état.
| // =========================================================================
// ex06/Ex06Activity.kt - un formulaire, un objet, un réducteur
// -------------------------------------------------------------------------
// L'exemple 05 avait une variable d'état par champ du formulaire. Pour un
// vrai formulaire, on préfère UN objet qui contient toutes les valeurs, le
// MODÈLE (ici un objet Profil, non modifiable), et l'on décrit ses
// changements dans une seule fonction, le RÉDUCTEUR :
//
// reducteur(etatActuel, action) -> nouvelEtat
//
// L'écran ne modifie plus l'état : il DÉCRIT ce qui s'est passé, par un
// objet ActionProfil (Modifier(Champ.NOM, "Dupont"), Quitter(Champ.NOM)...),
// et le réducteur en déduit le nouvel état. Toute la logique est en un
// endroit, dans une fonction PURE (même entrée, même sortie, aucun effet
// de bord), facile à tester.
// Kotlin s'y prête bien : une interface "sealed" énumère toutes les actions
// possibles, et un when sur une action est vérifié par le compilateur (un
// cas oublié est une erreur).
//
// L'état garde aussi, pour chaque champ, s'il a été "touché" (quitté au
// moins une fois) ; on en déduit s'il est "modifié" (sa valeur diffère de
// la valeur de départ) et s'il est désactivé.
// =========================================================================
@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
package fr.stahe.exemples.ex06
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.selection.selectable
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Slider
import androidx.compose.material3.Switch
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import fr.stahe.exemples.commun.ListeDeroulante
import fr.stahe.exemples.commun.PageExemple
import fr.stahe.exemples.commun.ThemeExemples
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.JsonObject
// le nom de chaque champ du formulaire
enum class Champ { TITRE, PRENOM, NOM, AGE, NEWSLETTER, EMAIL, PAYS, PRESENTATION }
// les données du formulaire : un objet NON MODIFIABLE (tout est val). Pour
// le "modifier", on en fabrique une copie : profil.copy(nom = "DUPONT"). La
// fonction copy est écrite par Kotlin pour toute classe "data"
data class Profil(
val titre: String,
val prenom: String,
val nom: String,
val age: Int,
val newsletter: Boolean,
val email: String,
val pays: String,
val presentation: String,
) {
// la valeur d'un champ, désigné par son nom
fun valeur(c: Champ): Any = when (c) {
Champ.TITRE -> titre
Champ.PRENOM -> prenom
Champ.NOM -> nom
Champ.AGE -> age
Champ.NEWSLETTER -> newsletter
Champ.EMAIL -> email
Champ.PAYS -> pays
Champ.PRESENTATION -> presentation
}
// une copie dont UN champ, désigné par son nom, a une nouvelle valeur
fun avec(c: Champ, v: Any): Profil = when (c) {
Champ.TITRE -> copy(titre = v as String)
Champ.PRENOM -> copy(prenom = v as String)
Champ.NOM -> copy(nom = v as String)
Champ.AGE -> copy(age = v as Int)
Champ.NEWSLETTER -> copy(newsletter = v as Boolean)
Champ.EMAIL -> copy(email = v as String)
Champ.PAYS -> copy(pays = v as String)
Champ.PRESENTATION -> copy(presentation = v as String)
}
// l'objet JSON du profil : un champ JSON par champ du formulaire
fun versJson(): JsonObject = JsonObject(
Champ.entries.associate { c ->
val v = valeur(c)
c.name.lowercase() to when (v) {
is Int -> JsonPrimitive(v) // "is" : un test de type ; v est ensuite un Int
is Boolean -> JsonPrimitive(v)
else -> JsonPrimitive(v.toString())
}
},
)
}
val depart = Profil(
titre = "Mme",
prenom = "Marie",
nom = "PELISSIER",
age = 30,
newsletter = false,
email = "",
pays = "fr",
presentation = "",
)
// l'état complet : les valeurs, et les champs déjà quittés
data class Etat(val valeurs: Profil, val touches: Set<Champ>)
// les actions possibles. "sealed" : il n'y en a pas d'autres que celles
// déclarées ici, le compilateur le sait
sealed interface ActionProfil {
data class Modifier(val champ: Champ, val valeur: Any) : ActionProfil
data class Quitter(val champ: Champ) : ActionProfil
data object Majuscules : ActionProfil // un objet unique : l'action n'a pas de données
data object Reinitialiser : ActionProfil
}
// le réducteur : il ne MODIFIE jamais l'état reçu, il en renvoie un nouveau.
// Dans chaque branche du when, action a le type de la branche ("smart cast") :
// action.champ est accessible pour un Modifier
fun reducteur(etat: Etat, action: ActionProfil): Etat = when (action) {
is ActionProfil.Modifier -> etat.copy(valeurs = etat.valeurs.avec(action.champ, action.valeur))
is ActionProfil.Quitter -> etat.copy(touches = etat.touches + action.champ)
ActionProfil.Majuscules -> etat.copy(
valeurs = etat.valeurs.copy(prenom = etat.valeurs.prenom.uppercase(), nom = etat.valeurs.nom.uppercase()),
)
ActionProfil.Reinitialiser -> Etat(depart, emptySet())
}
val titres = listOf("Mr", "Mme", "Melle")
val champsSuivis = listOf(Champ.PRENOM, Champ.NOM, Champ.AGE, Champ.EMAIL)
val nomsPays = mapOf("fr" to "France", "be" to "Belgique", "ch" to "Suisse")
val jsonIndente = Json { prettyPrint = true }
class Ex06Activity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent { ThemeExemples { PageProfil() } }
}
}
@Composable
fun PageProfil() {
var etat by remember { mutableStateOf(Etat(depart, emptySet())) }
// LA seule façon de changer l'état : décrire ce qui s'est passé
fun dispatch(action: ActionProfil) {
etat = reducteur(etat, action)
}
val v = etat.valeurs
// des valeurs DÉDUITES de l'état
fun modifie(c: Champ) = v.valeur(c) != depart.valeur(c)
fun desactive(c: Champ) = c == Champ.EMAIL && !v.newsletter // l'e-mail exige la lettre
val formulaireModifie = Champ.entries.any { modifie(it) }
val styleTitre = MaterialTheme.typography.titleSmall
// un champ de texte relié au modèle : une fonction @Composable LOCALE
@Composable
fun ChampTexte(champ: Champ, libelle: String, modifier: Modifier = Modifier, lignes: Int = 1) {
// le champ a-t-il déjà eu le focus ? (un état propre à CHAQUE champ)
var aEuLeFocus by remember { mutableStateOf(false) }
OutlinedTextField(
value = v.valeur(champ) as String,
onValueChange = { texte -> dispatch(ActionProfil.Modifier(champ, texte)) },
label = { Text(libelle) },
enabled = !desactive(champ),
minLines = lignes,
// le champ est QUITTÉ quand il perd le focus... après l'avoir eu
modifier = modifier.onFocusChanged { f ->
if (f.isFocused) {
aEuLeFocus = true
} else if (aEuLeFocus) {
dispatch(ActionProfil.Quitter(champ))
}
},
)
}
PageExemple("Exemple 06 - un réducteur") {
Row(verticalAlignment = Alignment.CenterVertically) {
for (t in titres) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.selectable(selected = v.titre == t, onClick = { dispatch(ActionProfil.Modifier(Champ.TITRE, t)) }),
) {
RadioButton(selected = v.titre == t, onClick = null)
Text(t)
Spacer(Modifier.width(12.dp))
}
}
}
Row {
ChampTexte(Champ.PRENOM, "Prénom", Modifier.weight(1f))
Spacer(Modifier.width(12.dp))
ChampTexte(Champ.NOM, "Nom", Modifier.weight(1f))
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Âge : ${v.age}", style = styleTitre)
Slider(
value = v.age.toFloat(),
onValueChange = { a -> dispatch(ActionProfil.Modifier(Champ.AGE, a.toInt())) },
valueRange = 0f..120f,
steps = 119,
// le curseur est relâché : le champ a été "quitté"
onValueChangeFinished = { dispatch(ActionProfil.Quitter(Champ.AGE)) },
modifier = Modifier.weight(1f).padding(start = 8.dp),
)
}
ListItem(
headlineContent = { Text("Recevoir la lettre d'information") },
trailingContent = {
Switch(checked = v.newsletter, onCheckedChange = { oui -> dispatch(ActionProfil.Modifier(Champ.NEWSLETTER, oui)) })
},
)
ChampTexte(Champ.EMAIL, "E-mail (si lettre d'information)", Modifier.fillMaxWidth())
ListeDeroulante(
libelle = "Pays",
valeur = v.pays,
choix = nomsPays.keys.toList(),
texte = { nomsPays.getValue(it) },
onChange = { p -> dispatch(ActionProfil.Modifier(Champ.PAYS, p)) },
modifier = Modifier.width(220.dp),
)
ChampTexte(Champ.PRESENTATION, "Présentation", Modifier.fillMaxWidth(), lignes = 2)
Spacer(Modifier.height(8.dp))
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = { dispatch(ActionProfil.Majuscules) }) { Text("Nom en majuscules") }
OutlinedButton(onClick = { dispatch(ActionProfil.Reinitialiser) }) { Text("Réinitialiser") }
}
Spacer(Modifier.height(8.dp))
Text("Le modèle, en direct", style = styleTitre)
Text(
jsonIndente.encodeToString(JsonObject.serializer(), v.versJson()),
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
modifier = Modifier.fillMaxWidth().background(Color(0xFFF5F5F5)).padding(8.dp),
)
Spacer(Modifier.height(8.dp))
Text("L'état des champs", style = styleTitre)
// un petit tableau : une Row par ligne, des colonnes de même largeur (weight)
val entetes = listOf("champ", "touché", "modifié", "désactivé")
Row { for (e in entetes) Text(e, Modifier.weight(1f), fontWeight = FontWeight.Bold) }
HorizontalDivider()
for (c in champsSuivis) {
Row {
Text(c.name.lowercase(), Modifier.weight(1f))
Text("${c in etat.touches}", Modifier.weight(1f))
Text("${modifie(c)}", Modifier.weight(1f))
Text("${desactive(c)}", Modifier.weight(1f))
}
}
Spacer(Modifier.height(8.dp))
// un texte à plusieurs styles : buildAnnotatedString
Text(
buildAnnotatedString {
append("formulaire modifié : ")
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append("$formulaireModifie") }
},
)
}
}
|
- ligne 77 : le nom de chaque champ, une énumération ;
- lignes 82-113 : le profil, non modifiable, avec deux fonctions pour lire ou remplacer UN champ désigné par son nom ([v as String] : une conversion de type, vérifiée à l'exécution) ;
- lignes 117-123 : le profil en JSON : [Champ.entries], la liste des valeurs de l'énumération ; [associate] fabrique une [Map] ; [is Int] : un test de type, après lequel [v] est connu comme un [Int] (le « smart cast ») ;
- ligne 141 : l'état complet : les valeurs, et l'ensemble des champs déjà quittés (« touchés ») ;
- lignes 145-149 : les actions possibles. [sealed] : il n'y en a pas d'autres que celles déclarées ici, et le compilateur le sait. [data object] : une action sans données, un objet unique ;
- lignes 155-161 : le réducteur : un [when] sur l'action, sans [else] : le compilateur vérifie que TOUTES les actions sont traitées. Dans chaque branche, [action] a le type de la branche : [action.champ] est accessible pour un [Modifier] ;
- lignes 179-183 : l'état, et LA seule façon de le changer, [dispatch(action)] ;
- lignes 188-190 : des valeurs DÉDUITES de l'état : un champ est « modifié » si sa valeur diffère de la valeur de départ ; l'e-mail est désactivé sans la lettre d'information ;
- lignes 195-209 : une fonction [@Composable] LOCALE (définie dans une autre) : un champ de texte relié au modèle. Chaque champ a son propre état [aEuLeFocus] : il est « quitté » quand il perd le focus après l'avoir eu ;
- lignes 236-241 : le curseur de l'âge : [onValueChangeFinished] est appelée quand on le relâche ;
- lignes 277-285 : un tableau simple, une [Row] par ligne, des colonnes de même largeur ([weight(1f)]) ;
- lignes 291-293 : un texte à plusieurs styles : [buildAnnotatedString].


Le réducteur est une fonction ordinaire : on peut la tester sans écran, par un simple test unitaire ([assertEquals(attendu, reducteur(etat, action))]).
3.7. Exemple 07 : valider un formulaire, à la main
Le formulaire « Créer un compte » de l'application RdvMedecins, avec les mêmes règles que le serveur. Une fonction pure, [valider(compte)], renvoie les erreurs ; on ne les montre que pour les champs déjà quittés, ou après une tentative d'envoi.
| // =========================================================================
// ex07/Ex07Activity.kt - valider un formulaire, à la main
// -------------------------------------------------------------------------
// Une fonction PURE, valider(compte), renvoie les erreurs des champs ; on
// l'appelle à chaque exécution de la fonction @Composable : les erreurs
// sont donc toujours à jour.
// On ne les montre pas d'emblée (un formulaire vide couvert de rouge est
// désagréable) : seulement pour un champ déjà quitté ("touché"). L'envoi
// marque tous les champs comme touchés, puis n'agit que si le formulaire
// est valide. Un OutlinedTextField affiche une erreur sous le champ quand
// on lui donne isError = true et un supportingText.
//
// Mêmes règles que le serveur RdvMedecins (formulaire "Créer un compte").
// ATTENTION : cette validation n'est qu'un CONFORT pour l'utilisateur (une
// réponse immédiate, sans aller-retour avec le serveur). Elle ne protège
// rien : n'importe qui peut envoyer au serveur une requête fabriquée à la
// main (curl...). Le serveur revalide donc TOUJOURS tout (cf. exemple 25).
// =========================================================================
@file:OptIn(ExperimentalMaterial3Api::class)
package fr.stahe.exemples.ex07
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.background
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.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import fr.stahe.exemples.commun.ListeDeroulante
import fr.stahe.exemples.commun.PageExemple
import fr.stahe.exemples.commun.ThemeExemples
enum class Champ { TITRE, PRENOM, NOM, LOGIN, PASSWORD, CONFIRMATION }
// le compte, tel que saisi
data class Compte(
val titre: String,
val prenom: String,
val nom: String,
val login: String,
val password: String,
val confirmation: String,
)
val titres = listOf("Mr", "Mme", "Melle")
val formatLogin = Regex("^[a-z0-9._-]{1,30}$")
const val MOT_DE_PASSE_MIN = 6
// les règles : une fonction pure, qui ne dépend que de son argument. Elle
// renvoie, pour chaque champ en erreur, son message. buildMap : on remplit
// une Map modifiable, la fonction renvoie une Map NON modifiable
fun valider(c: Compte): Map<Champ, String> = buildMap {
if (c.titre !in titres) put(Champ.TITRE, "Choisissez un titre dans la liste.")
when {
c.prenom.isBlank() -> put(Champ.PRENOM, "Le prénom est obligatoire.")
c.prenom.length > 20 -> put(Champ.PRENOM, "Le prénom ne doit pas dépasser 20 caractères.")
}
when {
c.nom.isBlank() -> put(Champ.NOM, "Le nom est obligatoire.")
c.nom.length > 20 -> put(Champ.NOM, "Le nom ne doit pas dépasser 20 caractères.")
}
when {
c.login.isEmpty() -> put(Champ.LOGIN, "Le login est obligatoire.")
!formatLogin.matches(c.login) -> put(Champ.LOGIN, "Lettres minuscules non accentuées, chiffres, point, tiret, souligné.")
}
when {
c.password.isEmpty() -> put(Champ.PASSWORD, "Le mot de passe est obligatoire.")
c.password.length < MOT_DE_PASSE_MIN -> put(Champ.PASSWORD, "Au moins $MOT_DE_PASSE_MIN caractères.")
}
// une règle qui compare DEUX champs
if (c.confirmation != c.password) put(Champ.CONFIRMATION, "La confirmation ne correspond pas au mot de passe.")
}
class Ex07Activity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent { ThemeExemples { PageCompte() } }
}
}
@Composable
fun PageCompte() {
var compte by remember { mutableStateOf(Compte("Mr", "", "", "", "", "")) }
var touches by remember { mutableStateOf(setOf<Champ>()) }
var envoiTente by remember { mutableStateOf(false) }
var succes by remember { mutableStateOf(false) }
// les erreurs : recalculées à chaque exécution, jamais rangées dans l'état
val erreurs = valider(compte)
// l'erreur à AFFICHER pour un champ : seulement s'il a été touché
fun erreur(c: Champ): String? = if (c in touches) erreurs[c] else null
fun envoyer() {
envoiTente = true
touches = Champ.entries.toSet() // toutes les erreurs apparaissent
// ici, on enverrait le compte au serveur (cf. exemple 25)
succes = erreurs.isEmpty()
}
// un champ de texte : libellé, erreur, et une coche verte s'il est
// touché et valide. valeur : la valeur du champ ; avec : la fonction qui
// fabrique le compte modifié
@Composable
fun ChampCompte(c: Champ, libelle: String, valeur: String, avec: (String) -> Compte, secret: Boolean = false) {
var aEuLeFocus by remember { mutableStateOf(false) }
val message = erreur(c)
OutlinedTextField(
value = valeur,
onValueChange = { texte ->
compte = avec(texte)
succes = false
},
label = { Text(libelle) },
singleLine = true,
isError = message != null,
supportingText = message?.let { m -> { Text(m) } },
// les caractères d'un mot de passe sont masqués
visualTransformation = if (secret) PasswordVisualTransformation() else VisualTransformation.None,
trailingIcon = if (c in touches && message == null) {
{ Icon(Icons.Default.CheckCircle, contentDescription = null, tint = Color(0xFF2E7D32)) }
} else {
null
},
// le champ quitté (après avoir eu le focus) devient "touché"
modifier = Modifier.fillMaxWidth().onFocusChanged { f ->
if (f.isFocused) aEuLeFocus = true else if (aEuLeFocus) touches = touches + c
},
)
}
PageExemple("Exemple 07 - la validation") {
ListeDeroulante(
libelle = "Titre",
valeur = compte.titre,
choix = titres,
onChange = { t -> compte = compte.copy(titre = t) },
modifier = Modifier.width(200.dp),
)
ChampCompte(Champ.PRENOM, "Prénom", compte.prenom, { compte.copy(prenom = it) })
ChampCompte(Champ.NOM, "Nom", compte.nom, { compte.copy(nom = it) })
ChampCompte(Champ.LOGIN, "Login", compte.login, { compte.copy(login = it) })
ChampCompte(Champ.PASSWORD, "Mot de passe", compte.password, { compte.copy(password = it) }, secret = true)
ChampCompte(Champ.CONFIRMATION, "Confirmation", compte.confirmation, { compte.copy(confirmation = it) }, secret = true)
Spacer(Modifier.height(16.dp))
Button(onClick = { envoyer() }) { Text("Créer le compte") }
Spacer(Modifier.height(12.dp))
if (envoiTente && erreurs.isNotEmpty()) {
Message("Le formulaire contient ${erreurs.size} erreur(s).", Color(0xFFFFEBEE))
}
if (succes) {
Message("Formulaire valide : il pourrait être envoyé au serveur.", Color(0xFFE8F5E9))
}
}
}
// un petit composant privé à ce fichier : un message sur fond coloré
@Composable
private fun Message(texte: String, couleur: Color) {
Text(texte, Modifier.fillMaxWidth().background(couleur, RoundedCornerShape(8.dp)).padding(12.dp))
}
|
- lignes 69-70 : une expression régulière ([Regex]) et une constante ;
- lignes 75-94 : les règles. [buildMap [ ... ]] : on remplit une [Map] modifiable (par [put]), la fonction renvoie une [Map] NON modifiable. La dernière règle compare DEUX champs ;
- ligne 113 : les erreurs sont RECALCULÉES à chaque exécution, jamais rangées dans l'état : elles sont toujours à jour ;
- ligne 116 : l'erreur à AFFICHER pour un champ : seulement s'il a été touché ;
- lignes 118-122 : l'envoi marque tous les champs comme touchés (toutes les erreurs apparaissent), et ne réussit que s'il n'y a aucune erreur ;
- lignes 129-151 : un champ du compte. [avec] est une fonction qui fabrique le compte modifié ([[ compte.copy(prenom = it) ]]) ; [isError] et [supportingText] affichent l'erreur sous le champ ; [PasswordVisualTransformation] masque les caractères ; une coche verte ([trailingIcon]) pour un champ touché et valide ;
- ligne 183 : [private] : un composant visible dans ce seul fichier.


ATTENTION : cette validation n'est qu'un CONFORT pour l'utilisateur (une réponse immédiate, sans aller-retour avec le serveur). Elle ne protège rien : n'importe qui peut envoyer au serveur une requête fabriquée à la main (avec [curl], cf. le chapitre sur le serveur). Le serveur revalide donc TOUJOURS tout (cf. exemple 25).
3.8. Exemple 08 : des champs qui savent se valider
[Flutter] a ses formulaires ([Form], [FormField]) ; [Compose] n'en a pas. On écrit donc l'outil soi-même, en quelques lignes, avec des classes ordinaires qui DÉTIENNENT un état observé par [Compose] (des « state holders ») : c'est la façon de faire habituelle avec [Compose].
| // =========================================================================
// ex08/Formulaire.kt - des champs qui savent se valider
// -------------------------------------------------------------------------
// Compose ne fournit pas d'outil de formulaire (pas d'équivalent du Form
// de Flutter ou des formulaires d'Angular) : on l'écrit soi-même, en
// quelques lignes, avec des classes ordinaires qui DÉTIENNENT un état
// observé par Compose (des "state holders") :
// - une RÈGLE est une fonction : (valeur) -> null si la valeur est bonne,
// sinon le message d'erreur ;
// - un ChampFormulaire garde sa valeur et son erreur, et se valide :
// d'abord à la demande (valider()), puis à chaque frappe, dès que
// l'utilisateur l'a modifié ;
// - un Formulaire regroupe des champs : valider() les valide TOUS,
// affiche leurs erreurs, et renvoie true si tout est bon.
// =========================================================================
package fr.stahe.exemples.ex08
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
// typealias : un nom plus parlant pour un type de fonction
typealias Regle = (String) -> String?
// des FABRIQUES de règles : chacune renvoie une fonction de validation
fun obligatoire(message: String): Regle = { v -> if (v.isBlank()) message else null }
// une règle qui en COMPLÈTE une autre : "?:" (l'opérateur Elvis) donne le
// résultat de autre, s'il n'est pas null, sinon celui de la suite
fun maximum(max: Int, autre: Regle): Regle = { v -> autre(v) ?: if (v.length > max) "$max caractères au plus." else null }
class ChampFormulaire(valeurInitiale: String = "", private val regle: Regle = { null }) {
// l'état du champ : observé par Compose, modifiable seulement ICI (private set)
var valeur by mutableStateOf(valeurInitiale)
private set
var erreur by mutableStateOf<String?>(null)
private set
// l'utilisateur a-t-il déjà modifié le champ ?
private var modifie = false
// une frappe : le champ se valide dès que l'utilisateur l'a modifié
fun changer(nouvelle: String) {
valeur = nouvelle
modifie = true
erreur = regle(valeur)
}
fun valider(): Boolean {
erreur = regle(valeur)
return erreur == null
}
// revalider, seulement si l'utilisateur a déjà modifié le champ
fun revalider() {
if (modifie) valider()
}
}
// vararg : un nombre quelconque de champs, Formulaire(prenom, nom, login...)
class Formulaire(private vararg val champs: ChampFormulaire) {
// map d'abord (TOUS les champs sont validés, et affichent leur erreur),
// all ensuite (tous sont-ils bons ?)
fun valider(): Boolean = champs.map { it.valider() }.all { it }
}
|
- ligne 23 : [typealias] : un nom pour un type de fonction : une RÈGLE reçoit la valeur et renvoie [null] si elle est bonne, sinon le message d'erreur ;
- lignes 26-30 : des FABRIQUES de règles (des fonctions qui renvoient une fonction). [?:], l'opérateur « Elvis » : la valeur de gauche si elle n'est pas [null], sinon celle de droite ;
- lignes 32-56 : un champ : sa valeur et son erreur sont des états [Compose] ([mutableStateOf]) qu'on ne peut modifier que depuis la classe ([private set]). Un champ se valide à la demande ([valider()]), puis à chaque frappe dès que l'utilisateur l'a modifié ;
- lignes 61-64 : un formulaire regroupe des champs ([vararg] : un nombre quelconque de paramètres). [map] d'abord : TOUS les champs sont validés et affichent leur erreur ; [all] ensuite : tous sont-ils bons ?
L'exemple 07, réécrit avec ces classes :
| // =========================================================================
// ex08/Ex08Activity.kt - un formulaire réutilisable
// -------------------------------------------------------------------------
// Le formulaire de l'exemple 07, écrit avec les classes de Formulaire.kt :
// les règles sont attachées aux champs, l'écran n'a plus qu'à afficher
// chaque champ et à demander formulaire.valider() à l'envoi.
// Les champs sont rangés dans une classe, CompteFormulaire, créée UNE
// fois (remember) : elle survit aux recompositions.
// =========================================================================
@file:OptIn(ExperimentalMaterial3Api::class)
package fr.stahe.exemples.ex08
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.background
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.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import fr.stahe.exemples.commun.ListeDeroulante
import fr.stahe.exemples.commun.PageExemple
import fr.stahe.exemples.commun.ThemeExemples
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
val titres = listOf("Mr", "Mme", "Melle")
// les champs du formulaire, et leurs règles
class CompteFormulaire {
var titre by mutableStateOf("Mr")
val prenom = ChampFormulaire(regle = maximum(20, obligatoire("Le prénom est obligatoire.")))
val nom = ChampFormulaire(regle = maximum(20, obligatoire("Le nom est obligatoire.")))
// une règle écrite directement : une lambda
val login = ChampFormulaire(
regle = { v ->
when {
v.isEmpty() -> "Le login est obligatoire."
!Regex("^[a-z0-9._-]{1,30}$").matches(v) -> "Lettres minuscules non accentuées, chiffres, point, tiret, souligné."
else -> null
}
},
)
val password = ChampFormulaire(
regle = { v ->
when {
v.isEmpty() -> "Le mot de passe est obligatoire."
v.length < 6 -> "Au moins 6 caractères."
else -> null
}
},
)
// une règle qui lit un AUTRE champ
val confirmation = ChampFormulaire(
regle = { v -> if (v == password.valeur) null else "La confirmation ne correspond pas au mot de passe." },
)
val formulaire = Formulaire(prenom, nom, login, password, confirmation)
// les valeurs, en JSON (ce qu'on enverrait au serveur)
fun versJson(): String = JsonObject(
mapOf(
"titre" to JsonPrimitive(titre),
"prenom" to JsonPrimitive(prenom.valeur.trim()),
"nom" to JsonPrimitive(nom.valeur.trim()),
"login" to JsonPrimitive(login.valeur),
"password" to JsonPrimitive(password.valeur),
),
).toString()
}
class Ex08Activity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent { ThemeExemples { PageCompte() } }
}
}
@Composable
fun PageCompte() {
val compte = remember { CompteFormulaire() }
// null : pas encore d'envoi ; "" : envoi refusé ; sinon, le JSON envoyé
var envoi by remember { mutableStateOf<String?>(null) }
PageExemple("Exemple 08 - un formulaire réutilisable") {
ListeDeroulante(
libelle = "Titre",
valeur = compte.titre,
choix = titres,
onChange = { compte.titre = it },
modifier = Modifier.width(200.dp),
)
ChampTexte(compte.prenom, "Prénom")
ChampTexte(compte.nom, "Nom")
ChampTexte(compte.login, "Login")
// le mot de passe change : la confirmation est revalidée (si elle a déjà été saisie)
ChampTexte(compte.password, "Mot de passe", secret = true, apres = { compte.confirmation.revalider() })
ChampTexte(compte.confirmation, "Confirmation", secret = true)
Spacer(Modifier.height(16.dp))
Button(onClick = { envoi = if (compte.formulaire.valider()) compte.versJson() else "" }) {
Text("Créer le compte")
}
Spacer(Modifier.height(12.dp))
// ?.let : ce bloc n'existe que si envoi n'est pas null
envoi?.let { e ->
Text(
if (e.isEmpty()) "Formulaire invalide." else "Formulaire valide : $e",
Modifier
.fillMaxWidth()
.background(if (e.isEmpty()) Color(0xFFFFEBEE) else Color(0xFFE8F5E9), RoundedCornerShape(8.dp))
.padding(12.dp),
)
}
}
}
// l'affichage d'un ChampFormulaire : sa valeur, son erreur. apres : ce qu'il
// faut faire après chaque frappe (rien, par défaut)
@Composable
fun ChampTexte(champ: ChampFormulaire, libelle: String, secret: Boolean = false, apres: () -> Unit = {}) {
OutlinedTextField(
value = champ.valeur,
onValueChange = {
champ.changer(it)
apres()
},
label = { Text(libelle) },
singleLine = true,
isError = champ.erreur != null,
supportingText = champ.erreur?.let { m -> { Text(m) } },
visualTransformation = if (secret) PasswordVisualTransformation() else VisualTransformation.None,
modifier = Modifier.fillMaxWidth(),
)
}
|
- lignes 48-77 : les champs du formulaire et leurs règles, réunis dans une classe ; la confirmation a une règle qui lit un AUTRE champ ([password.valeur]) ;
- ligne 101 : [remember [ CompteFormulaire() ]] : l'objet est créé UNE fois, et survit aux recompositions ;
- ligne 120 : l'envoi : [formulaire.valider()] valide tout et dit si l'on peut envoyer ;
- lignes 140-152 : l'affichage d'un champ : sa valeur, son erreur, et [apres], ce qu'il faut faire après chaque frappe : quand le mot de passe change, la confirmation est revalidée.


Dans une fonction [@Composable], on met en forme les valeurs avec des fonctions ordinaires. Celles de l'application sont réunies dans un fichier :
| // =========================================================================
// ex09/Formatage.kt - des fonctions de mise en forme "maison"
// -------------------------------------------------------------------------
// Compose n'a pas de mécanisme propre à la mise en forme (Angular a ses
// "pipes") : dans une fonction @Composable, on appelle des FONCTIONS
// ordinaires, rangées dans un fichier et testables à part :
// horaire(creneau) -> "8:00 - 8:20"
// horaire(creneau, Partie.DEBUT) -> "8:00"
// Kotlin permet aussi d'AJOUTER des fonctions et des propriétés à un type
// existant, par des EXTENSIONS : "marie pelissier".majusculeInitiale ->
// "Marie Pelissier".
// Pour les dates, les nombres, les montants, les classes de Java
// (java.time, java.text) connaissent les conventions de chaque langue
// (cf. Ex09Activity.kt).
// =========================================================================
package fr.stahe.exemples.ex09
// un créneau horaire, tel que le décrit l'API RdvMedecins
data class Creneau(val hdebut: Int, val mdebut: Int, val hfin: Int, val mfin: Int)
enum class Partie { TOUT, DEBUT, FIN }
// "8:05" plutôt que "8:5" ; private : invisible hors de ce fichier
private fun heure(h: Int, m: Int) = "$h:${m.toString().padStart(2, '0')}"
// un paramètre FACULTATIF, avec sa valeur par défaut : partie = Partie.TOUT
fun horaire(c: Creneau, partie: Partie = Partie.TOUT): String = when (partie) {
Partie.DEBUT -> heure(c.hdebut, c.mdebut)
Partie.FIN -> heure(c.hfin, c.mfin)
Partie.TOUT -> "${heure(c.hdebut, c.mdebut)} - ${heure(c.hfin, c.mfin)}"
}
// des EXTENSIONS du type String : de nouvelles propriétés pour TOUTES les
// chaînes. Dans le corps, this désigne la chaîne
// "marie pelissier" -> "Marie Pelissier" ; \p{L} : une lettre, accentuée ou non
val String.majusculeInitiale: String
get() = Regex("\\p{L}+").replace(this) { m ->
m.value.lowercase().replaceFirstChar { it.uppercase() }
}
// "Marie PELISSIER" -> "MP"
val String.initiales: String
get() = split(Regex("\\s+")).filter { it.isNotEmpty() }.joinToString("") { it.first().uppercase() }
|
- lignes 24-30 : un paramètre FACULTATIF, avec sa valeur par défaut ([partie = Partie.TOUT]) ; [padStart(2, '0')] : « 8:05 » plutôt que « 8:5 » ;
- lignes 36-43 : des EXTENSIONS : [Kotlin] permet d'AJOUTER des propriétés (et des fonctions) à un type existant, ici à toutes les chaînes. Dans le corps, [this] désigne la chaîne : ["marie pelissier".majusculeInitiale] vaut « Marie Pelissier ».
Pour les dates, les nombres et les montants, les classes de [Java] ([java.time], [java.text]) connaissent les conventions de chaque langue :
| // =========================================================================
// ex09/Ex09Activity.kt - mettre en forme des valeurs
// -------------------------------------------------------------------------
// Chaque ligne du tableau montre un morceau de code et ce qu'il affiche.
// Les formateurs (DateTimeFormatter, NumberFormat) sont créés une fois pour
// toutes, hors des fonctions @Composable : les fabriquer coûte plus cher
// que de s'en servir.
// Java connaît d'emblée les noms des jours et des mois de chaque langue :
// rien à charger avant de s'en servir.
// =========================================================================
@file:OptIn(ExperimentalMaterial3Api::class)
package fr.stahe.exemples.ex09
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import fr.stahe.exemples.commun.PageExemple
import fr.stahe.exemples.commun.ThemeExemples
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import java.text.NumberFormat
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.Locale
// dates : des formats "tout prêts" (SHORT, MEDIUM, LONG, FULL), ou un motif
val dateCourte: DateTimeFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(Locale.FRANCE)
val dateComplete: DateTimeFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).withLocale(Locale.FRANCE)
val heureSeule: DateTimeFormatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.MEDIUM).withLocale(Locale.FRANCE)
val jourAnglais: DateTimeFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.LONG).withLocale(Locale.US)
val motif: DateTimeFormatter = DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm", Locale.FRANCE)
// nombres et montants
val euros: NumberFormat = NumberFormat.getCurrencyInstance(Locale.FRANCE)
val entier: NumberFormat = NumberFormat.getNumberInstance(Locale.FRANCE)
// apply : configure l'objet qu'on vient de créer, et le renvoie
val pourcent: NumberFormat = NumberFormat.getPercentInstance(Locale.FRANCE).apply { minimumFractionDigits = 1 }
const val JOUR = "2026-10-05" // une date au format de l'API RdvMedecins
const val PRIX = 1234.5
const val TAUX = 0.055
const val NOM = "marie pelissier"
val medecin = mapOf("titre" to "Mme", "prenom" to "Marie", "nom" to "PELISSIER")
val langages = listOf("Kotlin", "Compose", "Dart", "Swift", "SQL")
val creneaux = listOf(Creneau(8, 0, 8, 20), Creneau(8, 20, 8, 40), Creneau(14, 5, 14, 25))
class Ex09Activity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent { ThemeExemples { PageMiseEnForme() } }
}
}
@Composable
fun PageMiseEnForme() {
var maintenant by remember { mutableStateOf(LocalDateTime.now()) }
// (code affiché, valeur) : une ligne du tableau, sous forme de paire
val lignes = listOf(
"dateCourte.format(maintenant)" to dateCourte.format(maintenant),
"dateComplete.format(maintenant)" to dateComplete.format(maintenant),
"heureSeule.format(maintenant)" to heureSeule.format(maintenant),
"motif.format(maintenant)" to motif.format(maintenant),
"dateComplete.format(LocalDate.parse(JOUR))" to dateComplete.format(LocalDate.parse(JOUR)),
"jourAnglais.format(LocalDate.parse(JOUR))" to jourAnglais.format(LocalDate.parse(JOUR)),
"maintenant.toString()" to maintenant.toString(),
"euros.format(PRIX)" to euros.format(PRIX),
"entier.format(PRIX)" to entier.format(PRIX),
"\"%.1f\".format(Locale.ROOT, PRIX)" to "%.1f".format(Locale.ROOT, PRIX),
"pourcent.format(TAUX)" to pourcent.format(TAUX),
"NOM.uppercase()" to NOM.uppercase(),
"NOM.majusculeInitiale" to NOM.majusculeInitiale,
"\"RDV\".lowercase()" to "RDV".lowercase(),
"langages.take(3).joinToString(\", \")" to langages.take(3).joinToString(", "),
"JsonObject(...).toString()" to JsonObject(medecin.mapValues { JsonPrimitive(it.value) }).toString(),
// deux transformations à la suite : le résultat de la première passe à la seconde
"NOM.majusculeInitiale.initiales" to NOM.majusculeInitiale.initiales,
)
PageExemple("Exemple 09 - la mise en forme") {
// un tableau : la première colonne prend 55 % de la largeur (weight)
Row(Modifier.padding(4.dp)) {
Text("Dans le code", Modifier.weight(0.55f), fontWeight = FontWeight.Bold)
Text("Affiché", Modifier.weight(0.45f), fontWeight = FontWeight.Bold)
}
// une paire se "déstructure" : (code, valeur)
for ((code, valeur) in lignes) {
HorizontalDivider()
Row(Modifier.padding(4.dp)) {
Text(code, Modifier.weight(0.55f), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = Color(0xFFC7254E))
Text(valeur, Modifier.weight(0.45f))
}
}
Spacer(Modifier.height(16.dp))
Text("Parcourir les entrées d'une Map", style = MaterialTheme.typography.titleSmall)
for ((cle, valeur) in medecin) {
Text("$cle = $valeur")
}
Spacer(Modifier.height(16.dp))
Text("Une fonction maison : horaire", style = MaterialTheme.typography.titleSmall)
for (c in creneaux) {
Text("${horaire(c)} (début : ${horaire(c, Partie.DEBUT)})")
}
Spacer(Modifier.height(16.dp))
OutlinedButton(onClick = { maintenant = LocalDateTime.now() }) { Text("Actualiser l'heure") }
}
}
|
- lignes 51-62 : les formateurs, créés une fois pour toutes hors des fonctions [@Composable] (les fabriquer coûte plus cher que de s'en servir). [FormatStyle.MEDIUM], [FULL]... : des formats « tout prêts » ; [ofPattern] : un motif ; [apply [ ... ]] configure l'objet qu'on vient de créer, et le renvoie ;
- lignes 85-103 : chaque ligne du tableau est une paire (code affiché, valeur), fabriquée par [to] ;
- ligne 113 : une paire, comme une entrée de [Map], se « déstructure » : [for ((code, valeur) in lignes)].


Notez ["%.1f".format(Locale.ROOT, PRIX)] : sans la langue « neutre » [Locale.ROOT], [format] utiliserait celle du téléphone, et écrirait « 1234,5 » en français. C'est ce qu'il faut pour un affichage, pas pour une donnée envoyée à un serveur.