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 unCircularProgressIndicator().
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 ! 🚀