Bryn Flow

🤖 Build a Weather App with Retrofit and Coroutines

Fetch live data from a REST API, handle loading/error/success states properly, and render it with Compose. The networking pattern here applies to almost any Android app that talks to a backend.

Intermediate ⏱️ ~40 minutes 🧰 Kotlin, Retrofit, Coroutines, Compose
← Android Track

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

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.

What to Try Next

Want more Android walkthroughs?

Head back to the Android track for the full topic list, roadmap, and practice quiz.

Back to Android Track