Aller au contenu

Formulaires de données (Ajout, Modification, Suppression)

Via le DAO, on peut effectuer des opérations CRUD (Create, Read, Update, Delete) sur les données de la base de données.

Les requêtes SQL pour Create, Update et Delete sont automatiquement générées par Room à partir des annotations @Insert, @Update et @Delete placées sur les méthodes du DAO.

Par contre, pour Read, il faut écrire une requête SQL avec l'annotation @Query sur la méthode du DAO. On peut avoir à obtenir une liste, ou un seul élément à partir d'un identifiant.

Le @Delete généré par Room supprime l'enregistrement correspondant à l'objet passé en paramètre. Si vous ne possédez que l'identifiant, vous devrez soit:

  • écrire manuellement la requête de suppression, soit
  • d'abord retrouver l'objet complet dans la base de données à l'aide d'une requête SQL avec l'annotation @Query sur la méthode du DAO.

Les requêtes Create et Update sont généralement effectuées à partir d'un formulaire de saisie de données. Le formulaire peut être utilisé pour ajouter de nouvelles données ou pour modifier des données existantes.

Une validation des données saisies est souvent nécessaire avant de les enregistrer dans la base de données.

Validation

C'est dans un ViewModel que la logique de validation sera codée.

Les bonnes pratiques de programmation demandent d'utiliser un ViewModel propre à la page du formulaire.

Ceci permet de mieux séparer les responsabilités (Single Responsibility Principle) :

  • Un ViewModel gère la liste et les opérations CRUD avec la base de données.

  • L'autre gère l'état du formulaire et sa validation (il n'accédera pas à la base de données)

Je vous suggère d'ajouter la validation à la méthode qui met à jour la valeur saisie. Ainsi, le texte et son éventuel message d'erreur sont calculés ensemble et publiés dans le même UiState, sans afficher temporairement le nouveau texte avec l'ancien message d'erreur.

Voici un exemple:

ViewModel
data class FormulaireCategorieUiState(
    val titre: String = "",
    val erreurTitre: String? = null
)

class FormulaireCategorieViewModel : ViewModel() {
    private val _uiState = mutableStateOf(FormulaireCategorieUiState())
    val uiState: FormulaireCategorieUiState
        get() = _uiState.value

    fun ajusterEtValiderTitre(titre: String) {
        val erreurTitre = if (titre.length > 100) {
            "Le titre doit comporter 100 caractères ou moins."
        } else {
            null
        }
        _uiState.value = _uiState.value.copy(
            titre = titre,
            erreurTitre = erreurTitre
        )
    }
}
Jetpack Compose (Kotin)
OutlinedTextField(
    value = formulaireCategorieUiState.titre,
    onValueChange = {
        formulaireCategorieViewModel.ajusterEtValiderTitre(it)
    },
    label = { Text("Titre") },
    ...,   // autres configurations, par exemple choix du clavier
    isError = formulaireCategorieUiState.erreurTitre != null,
)

Pour afficher le message, vous pouvez utiliser l'attribut supportingText du composant OutlinedTextField. Il est possible d'y mettre un composable conditionnel qui affichera le message d'erreur seulement si celui-ci n'est pas vide.

Jetpack Compose (Kotin)
OutlinedTextField(
    value = formulaireCategorieUiState.titre,
    onValueChange = {
         formulaireCategorieViewModel.ajusterEtValiderTitre(it)
    },
    label = { Text("Titre") },
    ...,   // autres configurations, par exemple choix du clavier
    isError = formulaireCategorieUiState.erreurTitre != null,
    supportingText = {
        formulaireCategorieUiState.erreurTitre?.let { Text(it) }
    }
)

Attention : ceci fait en sorte que la validation n'aura lieu que lorsque le texte est modifié.

À vous de vous assurer que la validation ait lieu même si rien n'est entré dans la case de saisie alors que cette information est obligatoire.

Internationaliser les messages d'erreur

Je vous propose une technique pour internationaliser les message d'erreur de validation dans le ViewModel.

On sait que pour retrouver une chaîne internationalisée, il est possible d'utiliser stringResource.

Composable (Kotlin)
val message = stringResource(R.string.le_titre_est_requis)

Ceci ne fonctionne cependant que dans un composable, ce qui n'est pas le cas des méthodes du ViewModel.

On peut alors utiliser un contexte avec getString().

Dans le cas où le ViewModel hérite de AndroidViewModel et reçoit une référence à l'application en paramètre, on pourra faire ceci :

ViewModel (Kotlin)
val message = application.applicationContext.getString(R.string.le_titre_est_requis)

Une autre approche, qui évite de fournir un contexte au ViewModel, consiste à y conserver l'identifiant de la ressource plutôt que le texte traduit. Le ViewModel peut retourner cet identifiant ou, comme dans l'exemple ci-dessous, le placer dans le UiState. Le composable utilise ensuite stringResource pour afficher le message.

L'annotation @StringRes indique que cette propriété contient un identifiant de ressource de chaîne.

UiState et ViewModel (Kotlin)
data class FormulaireCategorieUiState(
    val titre: String = "",
    @StringRes val erreurTitreId: Int? = null
)

class FormulaireCategorieViewModel : ViewModel() {
    private val _uiState = mutableStateOf(FormulaireCategorieUiState())
    val uiState: FormulaireCategorieUiState
        get() = _uiState.value

    fun ajusterEtValiderTitre(titre: String) {
        val erreurTitreId = if (titre.isBlank()) {
            R.string.le_titre_est_requis
        } else {
            null
        }
        _uiState.value = _uiState.value.copy(
            titre = titre,
            erreurTitreId = erreurTitreId
        )
    }
}

Dans le composable, on affiche le message seulement lorsqu'un identifiant est présent (remarquer stringResource):

Composable (Kotlin)
OutlinedTextField(
    value = uiState.titre,
    onValueChange = formulaireViewModel::ajusterEtValiderTitre,
    label = { Text("Titre") },
    isError = uiState.erreurTitreId != null,
    supportingText = {
        val erreurId = uiState.erreurTitreId
        if (erreurId != null) {
            Text(stringResource(erreurId))
        }
    }
)

Enregistrement

Dans un formulaire pour ajouter ou éditer des données, un bouton permettra de traiter les données saisies.

Dans cet exemple, les données sont insérées dans la base de données à l'aide d'une méthode du ViewModel.

Jetpack Compose (Kotlin)
@Composable
fun AjouterCategorieScreen(
    categorieViewModel: CategorieViewModel,
    navController: NavController,
    formulaireViewModel: FormulaireCategorieViewModel = viewModel()
) {
    val uiState = formulaireViewModel.uiState
    val coroutineScope = rememberCoroutineScope()

    Column {
        OutlinedTextField(
            value = uiState.titre,
            onValueChange = formulaireViewModel::ajusterEtValiderTitre,
            label = { Text("Titre") },
            isError = uiState.erreurTitre != null,
            supportingText = {
                uiState.erreurTitre?.let { Text(it) }
            }
        )

        Button(
            enabled = uiState.titre.isNotBlank() && uiState.erreurTitre == null,
            onClick = {
                coroutineScope.launch {
                    categorieViewModel.insererCategorie(
                        Categorie(titre = uiState.titre)
                    )
                    navController.navigate("listeCategories")
                }
            }
        ) {
            Text("Enregistrer")
        }
    }
}

Comme insererCategorie() est suspendue, la navigation a lieu après la fin de l'insertion. Remplacez "listeCategories" par la route de la page qui affiche la liste dans votre application.

Retrouver les données d'un enregistrement à modifier

Les données seront retrouvées à partir de l'identifiant dans la base de données à l'aide du ViewModel.

Vous devrez :

  • Dans le DAO : ajouter une annotation @Query pour spécifier la requête à effectuer pour retrouver les données à partir d'un identifiant. La requête travaillera avec un paramètre id (identifié par :id dans le SQL). L'annotation @Query sera suivie par une fonction qui retourne un Flow.
Fichier data/CategorieDao.kt
@Query("SELECT * FROM categories WHERE id = :id")
fun retrouverCategorie(id: Int): Flow<Categorie?>

Remarquez que cette fonction n'a pas besoin d'être suspendue car le mécanisme de flux gère l'exécution asynchrone.

  • Dans le ViewModel : obtenir le DAO de la base de données et définir une fonction qui appelle le DAO pour retrouver les données.

Remarquez que cette fonction utilise firstOrNull() pour retrouver le premier élément émis par le flux puis arrêter le flux. Si elle utilisait collect(), la fonction demeurerait suspendue tant que le flux n'aurait pas terminé d'émettre des éléments.

À moins d'être initialisées dans un LaunchedEffect, les informations recherchées pourraient donc ne pas encore être disponibles au moment où elles doivent être utilisées.

Cette fois, la fonction doit être suspendue car elle utilise la fonction suspendue firstOrNull().

Fichier ui/CategorieViewModel.kt
suspend fun retrouverCategorie(id: Int) : Categorie? {
    return categorieDao.retrouverCategorie(id).firstOrNull()
}

La fonction composable qui affiche le formulaire recevra l'identifiant en paramètre et retrouvera les données dans la base de données comme suit :

Fichier ui/FormulaireCategorie.kt
@Composable
fun FormulaireCategorie( categorieId: Int , ...) {
    // initialiser les variables d'état du ViewModel dès que categorieId change
    LaunchedEffect(categorieId) {
        val categorie = categorieViewModel.retrouverCategorie(categorieId)
        ...
    } 
    ...
}