Project 6: Weather App with Retrofit and Coroutines

Android Development with Kotlin: 21 Real Projects · Building Real Apps · 12 min read · Updated

Loading data from the internet

  1. The user types a city and presses Search. The ViewModel sets the state to Loading.
  2. A coroutine calls the repository and Retrofit without blocking the screen.
  3. Retrofit sends an HTTPS request and the server answers with JSON.
  4. The JSON becomes Kotlin objects, the state becomes Success or Error, and the screen draws it.

Most apps talk to a server. Weather, maps, shopping, banking and chat all load data from the internet. Doing this well on a phone is harder than it sounds: the network is slow, it fails, and the app must stay smooth while it waits. In Android you solve this with three tools. Retrofit turns a web API into a Kotlin interface. kotlinx.serialization turns JSON into Kotlin objects. Coroutines let you wait for the network without freezing the screen.

Concepts in this project

  • REST API and JSON: you send an HTTP request to a URL and get back text in JSON form.
  • Retrofit: you declare @GET("forecast") suspend fun ... and it builds the HTTP call.
  • Coroutines and `suspend`: a suspend function can pause without blocking a thread. viewModelScope.launch starts one that is cancelled when the ViewModel goes away.
  • UI state: a sealed class with Loading, Success and Error. The screen shows one of the three, so every case is handled.
  • Repository: a class between the ViewModel and the network, which hides where data comes from.
  • Permission: the app needs INTERNET in the manifest.

This project uses Open-Meteo, a free weather API that needs no key, plus its free geocoding endpoint to turn a city name into coordinates. Check its current terms and URL on its website, because public APIs can change.

Create the project

  1. New Empty Activity project Weather, package com.example.weather.
  2. Add the Retrofit, serialization and lifecycle libraries and the serialization plugin, then sync.
  3. Add <uses-permission android:name="android.permission.INTERNET"/> to the manifest.
  4. Create the files below.

`Gradle additions (Retrofit and serialization are in this snippet)`

// Top of app/build.gradle.kts  -> plugins { ... }  (add the KSP plugin; match its version to your Kotlin version)
id("com.google.devtools.ksp") version "2.0.21-1.0.28"
// (In the project-level build file, or in settings, declare the same plugin with "apply false" if Android Studio asks.)

// app/build.gradle.kts  -> dependencies { ... }
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")                                   // generates the database code at build time

// Project 6 only (weather):
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-kotlinx-serialization:2.11.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
// and in the plugins block: id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21"

`app/src/main/AndroidManifest.xml`

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

    <!-- Without this line every network call fails. -->
    <uses-permission android:name="android.permission.INTERNET" />

    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="Weather"
        android:theme="@style/Theme.Weather">
        <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>
    </application>
</manifest>
Weather/app/src/main/java/com/example/weather/
  WeatherApi.kt         DTOs + Retrofit interfaces
  WeatherRepository.kt  calls the API, maps results
  WeatherViewModel.kt   UiState and search action
  WeatherScreen.kt      search box + result card
  MainActivity.kt

The code

`app/src/main/java/com/example/weather/WeatherApi.kt`

package com.example.weather

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import retrofit2.http.GET
import retrofit2.http.Query

@Serializable
data class GeoResponse(val results: List<GeoPlace>? = null)

@Serializable
data class GeoPlace(val name: String, val latitude: Double, val longitude: Double, val country: String? = null)

@Serializable
data class ForecastResponse(val current: Current, val daily: Daily)

@Serializable
data class Current(
    @SerialName("temperature_2m") val temperature: Double,
    @SerialName("wind_speed_10m") val windSpeed: Double,
    @SerialName("weather_code") val weatherCode: Int
)

@Serializable
data class Daily(
    val time: List<String>,
    @SerialName("temperature_2m_max") val max: List<Double>,
    @SerialName("temperature_2m_min") val min: List<Double>
)

interface GeoApi {
    @GET("v1/search")
    suspend fun search(@Query("name") name: String, @Query("count") count: Int = 1): GeoResponse
}

interface ForecastApi {
    @GET("v1/forecast")
    suspend fun forecast(
        @Query("latitude") lat: Double,
        @Query("longitude") lon: Double,
        @Query("current") current: String = "temperature_2m,wind_speed_10m,weather_code",
        @Query("daily") daily: String = "temperature_2m_max,temperature_2m_min",
        @Query("timezone") timezone: String = "auto"
    ): ForecastResponse
}

The @Serializable classes describe only the JSON fields you care about. ignoreUnknownKeys in the converter setup tells the parser to skip everything else, so a new field on the server does not break your app.

`app/src/main/java/com/example/weather/WeatherRepository.kt`

package com.example.weather

import retrofit2.converter.kotlinx.serialization.asConverterFactory
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import retrofit2.Retrofit

data class Weather(val place: String, val current: Current, val daily: Daily)

class CityNotFound(val city: String) : Exception("No place called \"$city\" was found")

class WeatherRepository {
    private val json = Json { ignoreUnknownKeys = true }
    private fun retrofit(base: String) = Retrofit.Builder()
        .baseUrl(base)
        .addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
        .build()

    private val geo = retrofit("https://geocoding-api.open-meteo.com/").create(GeoApi::class.java)
    private val forecast = retrofit("https://api.open-meteo.com/").create(ForecastApi::class.java)

    suspend fun load(city: String): Weather {
        val place = geo.search(city.trim()).results?.firstOrNull() ?: throw CityNotFound(city)
        val data = forecast.forecast(place.latitude, place.longitude)
        return Weather(listOfNotNull(place.name, place.country).joinToString(", "), data.current, data.daily)
    }
}

fun describe(code: Int): String = when (code) {
    0 -> "Clear sky"
    1, 2, 3 -> "Partly cloudy"
    45, 48 -> "Fog"
    in 51..57 -> "Drizzle"
    in 61..67 -> "Rain"
    in 71..77 -> "Snow"
    in 80..82 -> "Rain showers"
    in 95..99 -> "Thunderstorm"
    else -> "Unknown"
}

`app/src/main/java/com/example/weather/WeatherViewModel.kt`

package com.example.weather

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.io.IOException

// One sealed type lists every state the screen can be in. The UI cannot show a half-state by mistake.
sealed interface UiState {
    data object Idle : UiState
    data object Loading : UiState
    data class Success(val weather: Weather) : UiState
    data class Error(val message: String) : UiState
}

class WeatherViewModel(private val repo: WeatherRepository = WeatherRepository()) : ViewModel() {
    private val _state = MutableStateFlow<UiState>(UiState.Idle)
    val state: StateFlow<UiState> = _state.asStateFlow()

    fun search(city: String) {
        if (city.isBlank()) { _state.value = UiState.Error("Type a city name first"); return }
        _state.value = UiState.Loading
        viewModelScope.launch {
            _state.value = try {
                UiState.Success(repo.load(city))
            } catch (e: CityNotFound) {
                UiState.Error(e.message ?: "City not found")
            } catch (e: IOException) {
                UiState.Error("No connection. Check the internet and try again.")
            } catch (e: Exception) {
                UiState.Error("Something went wrong: ${e.message}")
            }
        }
    }
}

UiState is a sealed interface. The when in the screen must cover Loading, Success and Error, and the compiler warns you if you forget one. Errors from try/catch become an Error state with a message the screen can show.

`app/src/main/java/com/example/weather/WeatherScreen.kt`

package com.example.weather

import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel

@Composable
fun WeatherScreen(vm: WeatherViewModel = viewModel()) {
    val state by vm.state.collectAsStateWithLifecycle()
    var city by remember { mutableStateOf("") }

    Column(Modifier.fillMaxSize().systemBarsPadding().padding(20.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
        Text("Weather", style = MaterialTheme.typography.headlineMedium)
        Row(verticalAlignment = Alignment.CenterVertically) {
            OutlinedTextField(city, { city = it }, label = { Text("City") }, singleLine = true, modifier = Modifier.weight(1f))
            Spacer(Modifier.width(8.dp))
            Button(onClick = { vm.search(city) }) { Text("Search") }
        }

        when (val s = state) {
            UiState.Idle -> Text("Search for a city to see its weather.")
            UiState.Loading -> CircularProgressIndicator()
            is UiState.Error -> Text(s.message, color = MaterialTheme.colorScheme.error)
            is UiState.Success -> {
                val w = s.weather
                Card(Modifier.fillMaxWidth()) {
                    Column(Modifier.padding(16.dp)) {
                        Text(w.place, style = MaterialTheme.typography.titleLarge)
                        Text("${w.current.temperature}°C", style = MaterialTheme.typography.displayMedium)
                        Text(describe(w.current.weatherCode))
                        Text("Wind ${w.current.windSpeed} km/h")
                    }
                }
                Text("Next days", style = MaterialTheme.typography.titleMedium)
                w.daily.time.forEachIndexed { i, day ->
                    Text("$day:  ${w.daily.min[i]}° to ${w.daily.max[i]}°")
                }
            }
        }
    }
}

`app/src/main/java/com/example/weather/MainActivity.kt`

package com.example.weather

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent { MaterialTheme { Surface { WeatherScreen() } } }
    }
}

Expected output

The app opens with a search box and a Search button. Type a city such as London and press Search. A progress circle shows briefly, then a card appears with the city name, the temperature in degrees Celsius, a short text such as Partly cloudy and the wind speed. The numbers are live and will differ from any example here. Type a nonsense word and the app shows a friendly "City not found" message. Switch on airplane mode and search again: an error message appears and the app does not crash.

Your task

  1. Build the app and search for three cities.
  2. Test the error path with airplane mode and with a nonsense city.
  3. Show the weather description with an emoji chosen by a when on the code.
  4. Remember the last searched city using rememberSaveable, and load it when the app opens.

How to check it worked

  • A real city shows live data.
  • Airplane mode shows a message, not a crash.
  • You can explain what a suspend function is and why the network call must not run on the main thread.

Frequently asked questions

What is a coroutine?

A coroutine is a task that can pause and resume without blocking a thread. It lets you write network code that looks like normal step-by-step code while the screen stays responsive.

Why use a sealed class for UI state?

It lists every possible state in one place and the compiler makes sure you handle them all. That removes bugs like a spinner that never goes away.

Where should I keep an API key?

Not in the app. Anything inside an APK can be extracted. Use keyless APIs while learning, and a small server of your own for keyed services in real products.