Progression Partie 10 sur 12

Charger les pays avec CountryViewModel

Nous avons maintenant tous les éléments nécessaires pour récupérer les pays.

Il nous reste à connecter ces données à l’interface. Pour cela, nous allons créer un ViewModel chargé de récupérer les données et d’exposer l’état de l’écran.

Déclarer le ViewModel

Crée le fichier ui/screen/CountryViewModel.kt ensuite déclare la classe CountryViewModel :

ui/screen/CountryViewModel.kt
...
import androidx.lifecycle.ViewModel
import com.composechef.countryexplorer.data.CountryRepository
...

class CountryViewModel(
    private val repository: CountryRepository
) : ViewModel() {
}

Le ViewModel reçoit le repository dans son constructeur.

Conserver l’état avec MutableStateFlow

Nous allons utiliser un MutableStateFlow pour conserver l’état courant de l’écran.

À l’intérieur de CountryViewModel, ajoute :

ui/screen/CountryViewModel.kt
...
import kotlinx.coroutines.flow.MutableStateFlow
...

private val _uiState = MutableStateFlow<CountryUiState>(CountryUiState.Loading)
...

Un StateFlow est un flux qui conserve toujours une valeur actuelle.

Dans notre cas, cette valeur est un CountryUiState. L’état initial est CountryUiState.Loading.

Lorsque l’écran commence à observer le ViewModel, il sait donc immédiatement qu’il doit afficher un indicateur de chargement.

Le préfixe _ dans _uiState indique qu’il s’agit de la version interne et modifiable de l’état.

Exposer un état en lecture seule

L’interface ne doit pas pouvoir modifier directement _uiState. Nous allons donc l’exposer sous la forme d’un StateFlow en lecture seule.

Juste sous _uiState, ajoute :

ui/screen/CountryViewModel.kt
...
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
...

val uiState: StateFlow<CountryUiState> = _uiState.asStateFlow()
...

Le ViewModel peut modifier _uiState, mais l’interface peut uniquement observer uiState.

Les changements d’état restent ainsi centralisés dans le ViewModel.

Charger les pays au démarrage

Sous la propriété uiState, ajoute le bloc init suivant :

ui/screen/CountryViewModel.kt
...
init {
    getCountries()
}
...

Le bloc init est exécuté lors de la création du ViewModel. Nous lançons donc immédiatement le chargement des pays.

La fonction getCountries() n’existe pas encore. Nous allons la créer dans l’étape suivante.

Exécuter la requête dans une coroutine

CountryRepository.getCountries() est une fonction suspendue. Nous devons donc l’appeler depuis une coroutine.

Sous le bloc init, ajoute :

ui/screen/CountryViewModel.kt
...
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch
...

private fun getCountries() {
    viewModelScope.launch {
        repository.getCountries()
    }
}
...

viewModelScope est un scope de coroutine lié au cycle de vie du ViewModel.

Lorsque le ViewModel est supprimé, les coroutines lancées dans ce scope sont automatiquement annulées.

Traiter le succès

Dans getCountries(), complète l’appel au repository en ajoutant onSuccess :

ui/screen/CountryViewModel.kt
repository.getCountries()
    .onSuccess { countries ->
        _uiState.value = CountryUiState.Success(countries)
    }

Lorsque le chargement réussit, l’état contient la liste de pays que l’interface pourra afficher.

Convertir les exceptions en messages lisibles

Les exceptions retournées par Ktor sont principalement destinées aux développeurs. Leur message brut n’est pas toujours adapté à l’utilisateur.

Nous allons créer une fonction d’extension chargée de les convertir en messages plus simples.

Crée le fichier data/Util.kt. Ajoute la fonction suivante :

data/Util.kt
...
import io.ktor.client.plugins.ClientRequestException
import io.ktor.client.plugins.RedirectResponseException
import io.ktor.client.plugins.ServerResponseException
import kotlinx.serialization.SerializationException
import java.io.IOException
...

fun Throwable.toUserMessage(): String {
    return when (this) {
        is RedirectResponseException,
        is ClientRequestException,
        is ServerResponseException -> "Le serveur a répondu avec une erreur."

        is SerializationException -> "Les données reçues ne correspondent pas au format attendu."
        is IOException -> "Impossible de se connecter. Vérifiez votre connexion internet."
        else -> "Une erreur inattendue est survenue."
    }
}

Cette fonction d’extension reçoit une exception Throwable et retourne un message adapté à son type.

Une erreur HTTP affiche un message lié au serveur, une erreur de sérialisation indique que les données ne correspondent pas au format attendu et une IOException indique généralement un problème de connexion.

Traiter l’échec

Retourne dans CountryViewModel.kt.

Nous allons utiliser toUserMessage() dans ce fichier. Complète maintenant getCountries() avec onFailure :

La fonction complète devient :

ui/screen/CountryViewModel.kt
...
import com.composechef.countryexplorer.data.toUserMessage
...

private fun getCountries() {
    viewModelScope.launch {
        repository.getCountries()
            .onSuccess { countries ->
                _uiState.value = CountryUiState.Success(countries)
            }
            .onFailure { error ->
                _uiState.value = CountryUiState.Error(error.toUserMessage())
            }
    }
}

Si la requête échoue, le ViewModel expose maintenant un état CountryUiState.Error contenant un message lisible par l’utilisateur.

Permettre de relancer le chargement

Nous allons ajouter une fonction qui replace l’écran dans l’état de chargement, puis relance la requête.

Sous la fonction getCountries(), mais toujours à l’intérieur de CountryViewModel, ajoute :

ui/screen/CountryViewModel.kt
...
import kotlinx.coroutines.flow.update
...

fun reloadCountries() {
    _uiState.update { CountryUiState.Loading }
    getCountries()
}
...

Nous utiliserons cette fonction lorsque l’utilisateur appuiera sur le bouton Réessayer.

Ajouter la Factory

CountryViewModel possède un paramètre dans son constructeur :

ui/screen/CountryViewModel.kt
class CountryViewModel(
    private val repository: CountryRepository
) : ViewModel()

Android sait créer automatiquement un ViewModel sans dépendance, mais il ne sait pas comment construire notre CountryRepository.

Pour déclarer la Factory, À la fin de la classe CountryViewModel, après reloadCountries() et avant la dernière accolade, ajoute :

ui/screen/CountryViewModel.kt
...
import androidx.lifecycle.ViewModelProvider
...


class Factory(
    private val repository: CountryRepository
) : ViewModelProvider.Factory {
    @Suppress("UNCHECKED_CAST")
    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        if (modelClass.isAssignableFrom(CountryViewModel::class.java)) {
            return CountryViewModel(repository) as T
        }
        throw IllegalArgumentException("Unknown ViewModel class")
    }
}
...

La Factory reçoit le repository, puis l’utilise pour créer CountryViewModel.

La version finale de CountryViewModel

À ce stade, CountryViewModel.kt doit contenir :

ui/screen/CountryViewModel.kt
package com.composechef.countryexplorer.ui.screen

import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.composechef.countryexplorer.data.CountryRepository
import com.composechef.countryexplorer.data.toUserMessage
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch

class CountryViewModel(
    private val repository: CountryRepository
) : ViewModel() {

    private val _uiState = MutableStateFlow<CountryUiState>(CountryUiState.Loading)
    val uiState: StateFlow<CountryUiState> = _uiState.asStateFlow()

    init {
        getCountries()
    }

    private fun getCountries() {
        viewModelScope.launch {
            repository.getCountries()
                .onSuccess { countries ->
                    _uiState.value = CountryUiState.Success(countries)
                }.onFailure { error ->
                    _uiState.value = CountryUiState.Error(error.toUserMessage())
                }
        }
    }

    fun reloadCountries() {
        _uiState.update { CountryUiState.Loading }
        getCountries()
    }

    class Factory(
        private val repository: CountryRepository
    ) : ViewModelProvider.Factory {
        @Suppress("UNCHECKED_CAST")
        override fun <T : ViewModel> create(modelClass: Class<T>): T {
            if (modelClass.isAssignableFrom(CountryViewModel::class.java)) {
                return CountryViewModel(repository) as T
            }
            throw IllegalArgumentException("Unknown ViewModel class")
        }
    }
}

Notre ViewModel est maintenant capable de charger les pays et d’exposer un état observable.

Dans la prochaine partie, nous allons connecter le ViewModel à CountryScreen, observer le StateFlow et afficher le contenu correspondant à chaque état.