Skip to content

2. Moshi

Objectif

À partir d'OkHttp, on parse la réponse JSON dans le repository grâce à Moshi. Le repository retourne directement un objet Kotlin (et non une String).

Moshi sert à convertir le JSON en objets Kotlin (et inversement si besoin).


1) Dépendances

Dans gradle/libs.versions.toml :

[versions]
moshi = "1.15.2"

[libraries]
moshi = { module = "com.squareup.moshi:moshi", version.ref = "moshi" }
moshi-kotlin = { module = "com.squareup.moshi:moshi-kotlin", version.ref = "moshi" }
````

Dans `app/build.gradle.kts` :

```kotlin
dependencies {
    implementation(libs.moshi)
    implementation(libs.moshi.kotlin) // KotlinJsonAdapterFactory (reflection)
}

On utilise la réflexion pour générer les adaptateurs JSON automatiquement.

Qu'est-ce que la réflexion ? La réflexion permet à un programme d'examiner et de modifier sa propre structure et son comportement à l'exécution. En Kotlin, cela signifie que le programme peut inspecter les classes, les fonctions, les propriétés, etc., pendant qu'il s'exécute.


2) Modèle Kotlin (mapping OpenWeather)

// ForecastResponse.kt

data class ForecastResponse(
    val name: String,
    val main: Main,
    val weather: List<Weather>
) {
    data class Main(val temp: Double)
    data class Weather(val description: String)
}

Cette structure correspond au JSON retourné par /data/2.5/weather.


3) Fournir Moshi (singleton simple)

// MoshiProvider.kt

import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory

object MoshiProvider {
    val moshi: Moshi = Moshi.Builder()
        .add(KotlinJsonAdapterFactory())
        .build()
}

4) Repository : OkHttp + Moshi

// WeatherRepository.kt
package your.package.data

import com.squareup.moshi.Moshi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import your.package.model.ForecastResponse
import your.package.network.MoshiProvider

const val OPEN_WEATHER_API_KEY = "ba42b5ddc8ac6f1d786c1af6e3be8b9a"

class WeatherRepository(
    private val client: OkHttpClient,
    private val moshi: Moshi = MoshiProvider.moshi
) {
    // Fetch and parse weather for a given city. Returns a typed model.
    suspend fun fetchWeather(city: String): ForecastResponse = withContext(Dispatchers.IO) {
        val request = Request.Builder()
            .url(
                "https://api.openweathermap.org/data/2.5/weather" +
                    "?q=$city&appid=$OPEN_WEATHER_API_KEY&units=metric&lang=fr"
            )
            .build()

        client.newCall(request).execute().use { response ->
            if (!response.isSuccessful) error("HTTP error: ${response.code}")
            val body = response.body?.string() ?: error("Empty response body")

            val adapter = moshi.adapter(ForecastResponse::class.java)
            adapter.fromJson(body) ?: error("Invalid JSON")
        }
    }
}

5) ViewModel : deux flux simples (objet + erreur)

// ForecastViewModel.kt

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import your.package.data.WeatherRepository
import your.package.model.ForecastResponse

class ForecastViewModel(
    private val repository: WeatherRepository
) : ViewModel() {

    // Expose parsed model or null when loading/not loaded
    private val _forecast = MutableStateFlow<ForecastResponse?>(null)
    val forecast: StateFlow<ForecastResponse?> = _forecast

    // Expose latest error message (null when no error)
    private val _error = MutableStateFlow<String?>(null)
    val error: StateFlow<String?> = _error

    // Load weather for the given city
    fun load(city: String) {
        viewModelScope.launch {
            // Reset previous error; keep last forecast until new value arrives
            _error.value = null

            runCatching { repository.fetchWeather(city) }
                .onSuccess { response ->
                    _forecast.value = response
                }
                .onFailure { e ->
                    _error.value = e.message ?: "Unable to fetch weather"
                }
        }
    }
}

Avantages :

  • Très lisible côté UI : on affiche l’erreur si elle existe, sinon on affiche les données si dispo, sinon on affiche un “chargement”.
  • Pas de sealed class d’état.

6) Compose (exemple)

@Composable
fun WeatherScreen(viewModel: ForecastViewModel, city: String) {
    val forecast by viewModel.forecast.collectAsStateWithLifecycle()
    val error by viewModel.error.collectAsStateWithLifecycle()

    // Trigger a load when the city changes
    LaunchedEffect(city) { viewModel.load(city) }

    when {
        error != null -> {
            Text(text = "Erreur : $error")
        }
        forecast == null -> {
            Text(text = "Chargement…")
        }
        else -> {
            val data = forecast!!
            val description = data.weather.firstOrNull()?.description
                ?.replaceFirstChar { it.uppercaseChar() }
                .orEmpty()

            Column {
                Text(text = data.name, style = MaterialTheme.typography.headlineMedium)
                Text(text = "${data.main.temp} °C")
                Text(text = description)
            }
        }
    }
}

Si tu veux afficher un spinner pendant le chargement, remplace simplement le Text("Chargement…") par un CircularProgressIndicator().


Si tu es arrivé jusqu'ici tu mérite une surprise ! 🎉

Voici le code complet de ce petit projet avec OkHttp + Moshi + Compose + ViewModel + StateFlow. Amuse-toi bien ! 🚀