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.launchstarts 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
INTERNETin 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
- New Empty Activity project
Weather, packagecom.example.weather. - Add the Retrofit, serialization and lifecycle libraries and the serialization plugin, then sync.
- Add
<uses-permission android:name="android.permission.INTERNET"/>to the manifest. - 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
- Build the app and search for three cities.
- Test the error path with airplane mode and with a nonsense city.
- Show the weather description with an emoji chosen by a
whenon the code. - 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.