What You'll Build
A one-screen app with a city search field. Submitting a city calls a public weather API through Retrofit, shows a loading spinner while the request is in flight, and displays either the result or a friendly error message.
Prerequisites
- A Compose-enabled Android Studio project
- A free API key from OpenWeatherMap (or swap in any REST API — the pattern is identical)
- Read Networking with Retrofit and Coroutines & Suspend Functions first if these are new to you
Step by Step
1 Add dependencies and internet permission
// build.gradle.kts (module)
dependencies {
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-moshi:2.11.0")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
}
<!-- AndroidManifest.xml -->
<uses-permission android:name="android.permission.INTERNET" />
2 Model the API response and define the endpoint
// WeatherResponse.kt
data class WeatherResponse(
val name: String,
val main: Main,
val weather: List<WeatherDesc>
)
data class Main(val temp: Double, val feels_like: Double, val humidity: Int)
data class WeatherDesc(val description: String)
// WeatherApi.kt
interface WeatherApi {
@GET("data/2.5/weather")
suspend fun getWeather(
@Query("q") city: String,
@Query("appid") apiKey: String,
@Query("units") units: String = "metric"
): WeatherResponse
}
object WeatherApiClient {
private const val API_KEY = "YOUR_API_KEY"
val service: WeatherApi = Retrofit.Builder()
.baseUrl("https://api.openweathermap.org/")
.addConverterFactory(MoshiConverterFactory.create())
.build()
.create(WeatherApi::class.java)
}
In a real app, never hardcode an API key in source — read it from local.properties via BuildConfig so it stays out of version control.
3 Model UI state explicitly
Rather than juggling separate booleans for loading/error, a sealed interface makes every possible screen state explicit and exhaustive — the compiler forces you to handle each one.
// WeatherUiState.kt
sealed interface WeatherUiState {
object Idle : WeatherUiState
object Loading : WeatherUiState
data class Success(val data: WeatherResponse) : WeatherUiState
data class Error(val message: String) : WeatherUiState
}
4 Build the ViewModel
// WeatherViewModel.kt
class WeatherViewModel : ViewModel() {
private val _uiState = MutableStateFlow<WeatherUiState>(WeatherUiState.Idle)
val uiState: StateFlow<WeatherUiState> = _uiState.asStateFlow()
fun fetchWeather(city: String) {
if (city.isBlank()) return
_uiState.value = WeatherUiState.Loading
viewModelScope.launch {
try {
val result = WeatherApiClient.service.getWeather(city, "YOUR_API_KEY")
_uiState.value = WeatherUiState.Success(result)
} catch (e: Exception) {
_uiState.value = WeatherUiState.Error(
e.message ?: "Couldn't load weather — check the city name and your connection"
)
}
}
}
}
Wrapping the suspend call in try/catch is essential — network requests fail for many reasons (no connection, typo'd city, rate limits), and an uncaught exception inside a coroutine launched from viewModelScope will crash the app.
5 Build the Compose UI
// WeatherScreen.kt
@Composable
fun WeatherScreen(viewModel: WeatherViewModel = viewModel()) {
val uiState by viewModel.uiState.collectAsState()
var city by remember { mutableStateOf("") }
Column(Modifier.fillMaxSize().padding(20.dp)) {
Row {
OutlinedTextField(
value = city,
onValueChange = { city = it },
label = { Text("City name") },
modifier = Modifier.weight(1f)
)
Spacer(Modifier.width(8.dp))
Button(onClick = { viewModel.fetchWeather(city) }) { Text("Search") }
}
Spacer(Modifier.height(24.dp))
when (val state = uiState) {
is WeatherUiState.Idle -> Text("Enter a city to see the weather.")
is WeatherUiState.Loading -> CircularProgressIndicator()
is WeatherUiState.Error -> Text("⚠️ ${state.message}", color = MaterialTheme.colorScheme.error)
is WeatherUiState.Success -> {
val w = state.data
Text(w.name, style = MaterialTheme.typography.headlineMedium)
Text("${w.main.temp}°C — feels like ${w.main.feels_like}°C")
Text(w.weather.firstOrNull()?.description ?: "")
Text("Humidity: ${w.main.humidity}%")
}
}
}
}
The exhaustive when over the sealed interface means Compose knows exactly what to render for every state — there's no way to accidentally leave the UI stuck on a spinner if you add a new state later, since the compiler will flag the missing branch.
6 Wire it up
// MainActivity.kt
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MaterialTheme { WeatherScreen() }
}
}
}
Run the app, type a city, tap Search. You should see the loading spinner briefly, then either the weather details or an error message — try airplane mode to confirm the error path works too.
Final Working Code
The six pieces above — WeatherResponse.kt/WeatherApi.kt, WeatherUiState.kt, WeatherViewModel.kt, and WeatherScreen.kt plus MainActivity.kt — form the complete app exactly as written; nothing was trimmed for the walkthrough. Drop each block into a matching file name and build.