Build a Weather App with Kotlin

Build an Android weather app using Jetpack Compose, Retrofit, and MVVM.

Introduction

30 Second Summary

Every time you check the weather on your phone, that app is pulling live data from a server somewhere on the internet. Building that connection between a mobile app and a remote data source is one of the most fundamental skills in Android development.

In this project, you will build a weather app that fetches real-time data from the OpenWeatherMap API and displays it in a modern Android UI. You will learn how professional Android teams structure their code, handle network requests, and manage different screen states.

What You'll Build

A single-screen Android weather app where users type a city name and see live temperature, weather conditions, humidity, and wind speed, built entirely with Kotlin and Jetpack Compose.

By the end of this project, you'll have:

  • A working Android app that calls the OpenWeatherMap API using Retrofit and displays live weather data for any city.
  • Hands-on experience with the MVVM architecture pattern using ViewModel, StateFlow, and Kotlin Coroutines for non-blocking network calls.
  • A clean Material 3 UI that handles loading, success, and error states using sealed classes.
  • Secret Mission: Add a 5-day forecast section that shows upcoming weather below the current conditions.

Are there any prerequisites?

This project assumes you have Java programming experience and Android Studio installed (version Quail 1 or newer recommended). Your Java knowledge translates directly into Kotlin, and the project introduces Kotlin-specific syntax as you go.

Before We Start

Before We Start...

Set Up Your Project and Dependencies

To build a weather app that fetches live data, you need the right foundation. That means a Jetpack Compose-ready project structure and the networking libraries that handle API communication.

In this step, you'll create a fresh Android Studio project and add the dependencies your app needs for HTTP networking, asynchronous operations, and state management.

In this step, get ready to:
  • Create a new Compose project in Android Studio.
  • Add Retrofit, coroutines, and ViewModel dependencies.
  • Enable internet access in the app manifest.
Create a new Android Studio project

The Empty Activity template generates a Compose-ready project with Kotlin, Material Design 3, and the basic Compose dependencies already wired up.

  • On the Android Studio Welcome screen, click New Project.
  • Make sure the Phone and Tablet tab is selected.
  • Select the Empty Activity template, then click Next.
  • In the Name field, enter WeatherApp.
  • Leave the Package name as com.example.weatherapp.
  • Set the Minimum SDK dropdown to API 24 ("Android 7.0 Nougat").
  • Click Finish.

Android Studio creates the project and begins a Gradle sync automatically. Wait for the sync to complete before moving on.

What is the Empty Activity template?

This template sets up a single-screen app with Jetpack Compose already configured. It includes MainActivity.kt with a sample composable, the Compose BOM for dependency alignment, and Material 3 theming.

Gradle sync stuck or failing?

Make sure you have a stable internet connection. Gradle downloads dependencies the first time it syncs.

If you see a JDK error, go to File then Project Structure then SDK Location and confirm the JDK path is set.

Help me troubleshoot my Gradle sync

Add dependencies to your build file

Your app needs libraries for HTTP networking, JSON parsing, logging, and coroutine-based state management. You'll add these to the app-level Gradle file.

  • In the Project pane on the left, expand Gradle Scripts.
  • Open the file labeled build.gradle.kts (Module :app).
  • Find the dependencies { } block and replace its entire contents with the following code:
    implementation("androidx.core:core-ktx:1.15.0")
    implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
    implementation("androidx.activity:activity-compose:1.9.3")
    implementation(platform("androidx.compose:compose-bom:2024.12.01"))
    implementation("androidx.compose.ui:ui")
    implementation("androidx.compose.ui:ui-graphics")
    implementation("androidx.compose.ui:ui-tooling-preview")
    implementation("androidx.compose.material3:material3")

    // Retrofit for HTTP networking
    implementation("com.squareup.retrofit2:retrofit:3.0.0")
    implementation("com.squareup.retrofit2:converter-gson:3.0.0")

    // OkHttp Logging for debugging network requests
    implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")

    // ViewModel Compose for state management
    implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")

    // Coroutines for async operations
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")

    testImplementation("junit:junit:4.13.2")
    androidTestImplementation("androidx.test.ext:junit:1.2.1")
    androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
    androidTestImplementation(platform("androidx.compose:compose-bom:2024.12.01"))
    androidTestImplementation("androidx.compose.ui:ui-test-junit4")
    debugImplementation("androidx.compose.ui:ui-tooling")
    debugImplementation("androidx.compose.ui:ui-test-manifest")

What are these dependencies?

  • Retrofit (3.0.0) is the HTTP client that turns your API interface into working network calls.
  • converter-gson automatically converts JSON responses into Kotlin data classes.
  • logging-interceptor prints network requests and responses to Logcat so you can debug API issues.
  • lifecycle-viewmodel-compose provides the ViewModel integration for Compose.
  • kotlinx-coroutines-android enables non-blocking asynchronous code with Kotlin Coroutines.
  • After pasting, click the Sync Now banner that appears at the top of the editor.
  • Wait for the sync to complete. You should see "BUILD SUCCESSFUL" in the Build output pane at the bottom.

Sync failed with dependency errors?

Double check that the dependencies { } braces are still intact. You should have replaced only the lines inside the braces, not the braces themselves.

If you see "Failed to resolve" errors, confirm your internet connection is active. Gradle needs to download these libraries from Maven Central.

Help me debug my dependency errors

✔️ Awesome, I've got everything!

Great. Make sure you've saved build.gradle.kts and the sync completed without errors.

ⓧ I'd like to double check the full code

Here is the complete dependencies block for your app/build.gradle.kts:

dependencies {
    implementation("androidx.core:core-ktx:1.15.0")
    implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
    implementation("androidx.activity:activity-compose:1.9.3")
    implementation(platform("androidx.compose:compose-bom:2024.12.01"))
    implementation("androidx.compose.ui:ui")
    implementation("androidx.compose.ui:ui-graphics")
    implementation("androidx.compose.ui:ui-tooling-preview")
    implementation("androidx.compose.material3:material3")

    // Retrofit
    implementation("com.squareup.retrofit2:retrofit:3.0.0")
    implementation("com.squareup.retrofit2:converter-gson:3.0.0")

    // OkHttp Logging
    implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")

    // ViewModel Compose
    implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")

    // Coroutines
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")

    testImplementation("junit:junit:4.13.2")
    androidTestImplementation("androidx.test.ext:junit:1.2.1")
    androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
    androidTestImplementation(platform("androidx.compose:compose-bom:2024.12.01"))
    androidTestImplementation("androidx.compose.ui:ui-test-junit4")
    debugImplementation("androidx.compose.ui:ui-tooling")
    debugImplementation("androidx.compose.ui:ui-test-manifest")
}
Add the internet permission

Android apps cannot access the network by default. You need to declare the INTERNET permission so your app can make API calls.

  • In the Project pane, expand app then manifests.
  • Open AndroidManifest.xml.
  • Find the opening <manifest> tag. Add the following line directly above the <application tag:
    <uses-permission android:name="android.permission.INTERNET" />

Why does Android require this permission?

Android uses a permission model where apps must explicitly declare capabilities they need. The INTERNET permission is a "normal" permission that's granted automatically at install time. Without it, any network call from Retrofit would silently fail.

  • Save AndroidManifest.xml.
  • Run the app by clicking the green Run button (triangle icon) in the toolbar.
  • Select your emulator or connected device from the device picker.
  • You should see a blank screen with "Hello Android!" text. This confirms the project compiles and runs correctly.

App not launching on the emulator?

If no device appears in the device picker, you need to create a virtual device. Go to Tools then Device Manager then click Create Virtual Device. Pick any phone (e.g., Pixel 6) and download a system image with API 34 or higher.

If you see a "permission denied" error related to INTERNET, make sure the <uses-permission> line is placed above <application>, not inside it.

Help me get my app running on the emulator

✔️ Awesome, I've got everything!

Great. Double check that you've saved AndroidManifest.xml and the app ran on your device.

ⓧ I'd like to double check the full code

Here is the complete AndroidManifest.xml file:

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

    <uses-permission android:name="android.permission.INTERNET" />

    <application
        android:allowBackup="true"
        android:dataExtractionRules="@xml/data_extraction_rules"
        android:fullBackupContent="@xml/backup_rules"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.WeatherApp"
        tools:targetApi="31">
        <activity
            android:name=".MainActivity"
            android:exported="true"
            android:label="@string/app_name"
            android:theme="@style/Theme.WeatherApp">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>

</manifest>

Your project structure is ready with all the networking and architecture libraries in place. Next, you'll get your API key and define the data models that map to the weather API response.

Get Your API Key and Define the Data Model

Your project is set up and running a blank Compose screen. Now it's time to connect your app to the real world.

Weather data comes from the OpenWeatherMap API, a free service that provides current conditions for any city. In this step, you'll get your API key, then define the Kotlin data structures that tell your app what shape the weather data takes.

In this step, get ready to:
  • Get a free OpenWeatherMap API key.
  • Create Kotlin data classes that map to the weather JSON response.
  • Define a Retrofit API interface for making network requests.
Sign up for OpenWeatherMap and get your API key

Your app needs an API key to authenticate with OpenWeatherMap's servers. The free tier gives you 60 calls per minute, which is more than enough for development and testing.

  • Open your browser and navigate to openweathermap.org/register.
  • Fill in your username, email, and password.
  • Check the boxes to agree to the Privacy Policy and Terms.
  • Click Create Account.
  • Verify your email address by clicking the confirmation link in the email OpenWeatherMap sends you.
  • Once logged in, click your username in the top-right corner.
  • Select the API keys tab.
  • Copy the default API key that was generated for your account. Save it somewhere safe (like a text file or note) for later use.

Don't see weather data yet? Your key may still be activating

OpenWeatherMap API keys can take up to 2 hours to activate after signup. If you get a 401 error when testing later, wait and try again. The key will start working without any action on your part.

Why do I need an API key?

An API key identifies your app to the OpenWeatherMap servers. It lets them track usage, enforce rate limits, and keep the service free for everyone. You'll pass this key with every request your app makes.

Create Kotlin data classes for the weather response

When your app calls the OpenWeatherMap API, it gets back a JSON object with nested fields for temperature, humidity, wind, and conditions. You need Kotlin data classes that mirror this structure so Gson can automatically convert the JSON into objects your code can use.

  • In the Project panel on the left side of Android Studio, expand app > src > main > java > com.example.weatherapp.
  • Right-click on com.example.weatherapp, then select New > Package.
  • Type data as the package name and press Enter.
  • Right-click on the new data package, then select New > Kotlin Class/File.
  • Type WeatherResponse and select File from the dropdown.
  • Replace everything in the new file with the following code:
package com.example.weatherapp.data

import com.google.gson.annotations.SerializedName

// Top-level response object from the /weather endpoint
data class WeatherResponse(
    val name: String,
    val main: Main,
    val weather: List<Weather>,
    val wind: Wind
)

// Temperature, humidity, and pressure data
data class Main(
    val temp: Double,
    @SerializedName("feels_like")
    val feelsLike: Double,
    val humidity: Int,
    val pressure: Int
)

// Weather condition (description and icon code)
data class Weather(
    val id: Int,
    val main: String,
    val description: String,
    val icon: String
)

// Wind speed data
data class Wind(
    val speed: Double
)

What does this code do?

`WeatherResponse` is the top-level object that maps to the entire JSON response. It contains the city name and nested objects for the other data.

`Main` holds temperature (temp), what it feels like (feelsLike), humidity, and pressure. The @SerializedName("feels_like") annotation tells Gson to map the JSON field feels_like (snake_case) to the Kotlin property feelsLike (camelCase).

`Weather` describes the current condition (like "clear sky" or "light rain"). The API returns a list of these, but typically only one item. `Wind` holds the wind speed in meters per second.

  • Save WeatherResponse.kt by pressing Cmd+S (macOS) or Ctrl+S (Windows).
  • Check the bottom bar of Android Studio. You should see no red error indicators for this file.

Seeing a red underline on SerializedName?

Make sure the import com.google.gson.annotations.SerializedName line is at the top of your file. If the import shows as red, your Gson dependency may not have synced properly. Go to File > Sync Project with Gradle Files and wait for the sync to complete.

Help me fix the SerializedName import error in my WeatherResponse.kt file.

Create the Retrofit API interface

Retrofit uses an interface to define your API endpoints. Each function in the interface describes one HTTP request, including the URL path and query parameters. Retrofit generates the networking code for you at runtime.

  • Right-click on com.example.weatherapp in the Project panel, then select New > Package.
  • Type network as the package name and press Enter.
  • Right-click on the new network package, then select New > Kotlin Class/File.
  • Type WeatherApiService and select Interface from the dropdown.
  • Replace everything in the new file with the following code:
package com.example.weatherapp.network

import com.example.weatherapp.data.WeatherResponse
import retrofit2.http.GET
import retrofit2.http.Query

// Defines the API endpoints for OpenWeatherMap
interface WeatherApiService {

    // Fetches current weather for a given city
    @GET("data/2.5/weather")
    suspend fun getCurrentWeather(
        @Query("q") city: String,
        @Query("appid") apiKey: String,
        @Query("units") units: String = "metric"
    ): WeatherResponse
}

What does this code do?

`@GET("data/2.5/weather")` tells Retrofit this is a GET request to the /data/2.5/weather endpoint. Retrofit will combine this with the base URL later.

Each @Query annotation adds a URL parameter. The q parameter is the city name, appid is your API key, and units defaults to "metric" so temperatures come back in Celsius.

The suspend keyword makes this a coroutine-friendly function. It means Retrofit will perform the network call on a background thread without blocking the UI.

  • Save WeatherApiService.kt by pressing Cmd+S (macOS) or Ctrl+S (Windows).
  • Check that no red error indicators appear in the bottom bar or in the file itself.

Seeing unresolved reference errors?

Make sure the package declaration at the top says package com.example.weatherapp.network and that you created the file inside the network package. If the WeatherResponse import shows red, confirm that WeatherResponse.kt exists in the data package and compiles cleanly.

Help me fix unresolved reference errors in my WeatherApiService.kt file.

✔️ Awesome, I've got everything!

Great. Double check you've saved both files.

ⓧ I'd like to double check the full code

Here is the full WeatherResponse.kt file:

package com.example.weatherapp.data

import com.google.gson.annotations.SerializedName

data class WeatherResponse(
    val name: String,
    val main: Main,
    val weather: List<Weather>,
    val wind: Wind
)

data class Main(
    val temp: Double,
    @SerializedName("feels_like")
    val feelsLike: Double,
    val humidity: Int,
    val pressure: Int
)

data class Weather(
    val id: Int,
    val main: String,
    val description: String,
    val icon: String
)

data class Wind(
    val speed: Double
)

And here is the full WeatherApiService.kt file:

package com.example.weatherapp.network

import com.example.weatherapp.data.WeatherResponse
import retrofit2.http.GET
import retrofit2.http.Query

interface WeatherApiService {

    @GET("data/2.5/weather")
    suspend fun getCurrentWeather(
        @Query("q") city: String,
        @Query("appid") apiKey: String,
        @Query("units") units: String = "metric"
    ): WeatherResponse
}

Your data model and API interface are ready. Next up, you'll wire Retrofit to actually make network calls and build the ViewModel that manages your app's state.

Build the Network Layer with Retrofit

Your data classes and API interface define what your app sends and receives. Now you need to build the layer that actually makes the network call and manages the result.

This step introduces the MVVM architecture pattern. You will create a Retrofit client singleton, define the different states your UI can be in, and build a ViewModel that coordinates everything.

In this step, get ready to:
  • Configure a Retrofit singleton with logging for debugging.
  • Define a sealed interface that represents every possible UI state.
  • Build a ViewModel that fetches weather data and exposes state to the UI.
Create the Retrofit singleton

A singleton ensures your app only creates one instance of the Retrofit client, no matter how many times it makes a network call. This object also attaches a logging interceptor so you can see raw HTTP requests and responses in Logcat while debugging.

  • In Android Studio, right-click the network package in the **Project** panel (where WeatherApiService.kt lives).
  • Select New, then Kotlin Class/File.
  • Choose Object from the list and name it RetrofitInstance.
  • Replace the entire file contents with the following code:
package com.example.weatherapp.network

import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory

object RetrofitInstance {

    private const val BASE_URL = "https://api.openweathermap.org/"

    // Logs every HTTP request and response body to Logcat
    private val loggingInterceptor = HttpLoggingInterceptor().apply {
        level = HttpLoggingInterceptor.Level.BODY
    }

    // Build an OkHttp client with the logging interceptor attached
    private val client = OkHttpClient.Builder()
        .addInterceptor(loggingInterceptor)
        .build()

    // Create the Retrofit service lazily (only when first accessed)
    val api: WeatherApiService by lazy {
        Retrofit.Builder()
            .baseUrl(BASE_URL)
            .client(client)
            .addConverterFactory(GsonConverterFactory.create())
            .build()
            .create(WeatherApiService::class.java)
    }
}

What does this code do?

The object keyword in Kotlin creates a singleton. There is only ever one RetrofitInstance in your entire app.

The HttpLoggingInterceptor at Level.BODY prints the full request URL, headers, and response JSON to Logcat. This is invaluable for debugging API issues.

The by lazy block means the Retrofit instance is only created the first time RetrofitInstance.api is accessed. GsonConverterFactory automatically converts the JSON response into your Kotlin data classes.

  • Save RetrofitInstance.kt.
  • Check the Build panel at the bottom of Android Studio. You should see no red error highlights in the file.

Seeing unresolved reference errors?

  • Make sure the file is in the network package, not the data or ui package.
  • Confirm your build.gradle.kts includes the Retrofit and OkHttp dependencies from Step 1, and that Gradle sync completed successfully.
  • If imports show red, try clicking File then Sync Project with Gradle Files.

Help me fix unresolved reference errors in RetrofitInstance.kt

Why a logging interceptor for debugging?

When an API call fails, the error message alone rarely tells you what went wrong. The logging interceptor shows you the exact URL, query parameters, and server response in Logcat. This makes it easy to spot typos in the URL, missing parameters, or unexpected server errors.

Define the UI state with a sealed interface

Your app can be in one of several states at any given moment: waiting for input, loading data, showing results, or displaying an error. A sealed interface lets you define these states as a closed set, so the compiler can verify you handle every possibility.

  • Right-click the com.example.weatherapp package in the Project panel.
  • Select New, then Package, and name it ui.
  • Right-click the new ui package, select New, then Kotlin Class/File.
  • Choose Interface and name it WeatherUiState.
  • Replace the entire file contents with the following code:
package com.example.weatherapp.ui

import com.example.weatherapp.data.WeatherResponse

// A sealed interface defines every possible screen state
sealed interface WeatherUiState {
    // Initial state before the user searches
    data object Idle : WeatherUiState
    // Network request is in progress
    data object Loading : WeatherUiState
    // Successfully received weather data
    data class Success(val data: WeatherResponse) : WeatherUiState
    // Something went wrong (network error, invalid city, etc.)
    data class Error(val message: String) : WeatherUiState
}

What does this code do?

The sealed keyword means no other class outside this file can implement WeatherUiState. The compiler knows every possible state at compile time.

When you use a when block on this state in the UI layer, Kotlin will warn you if you forget to handle one of the cases. This prevents bugs where your UI silently ignores an error or loading state.

  • Save WeatherUiState.kt.
  • Confirm the file has no red underlines. The import for WeatherResponse should resolve to the data class you created in the previous step.

WeatherResponse showing as unresolved?

  • Check that your WeatherResponse.kt file has the package declaration package com.example.weatherapp.data at the top.
  • Make sure WeatherUiState.kt is in the ui package, not the network package.

Help me fix an unresolved WeatherResponse import in my sealed interface file

Create the WeatherViewModel

The ViewModel sits between your network layer and your UI. It calls the Retrofit API, catches errors, and exposes the result as a StateFlow that the Compose UI can observe. Kotlin Coroutines handle the asynchronous network call without blocking the main thread.

  • Right-click the ui package, select New, then Kotlin Class/File.
  • Choose Class and name it WeatherViewModel.
  • Replace the entire file contents with the following code:
package com.example.weatherapp.ui

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.weatherapp.network.RetrofitInstance
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch

class WeatherViewModel : ViewModel() {

    // Private mutable state that only the ViewModel can update
    private val _uiState = MutableStateFlow<WeatherUiState>(WeatherUiState.Idle)
    // Public read-only state that the UI observes
    val uiState: StateFlow<WeatherUiState> = _uiState.asStateFlow()

    fun fetchWeather(city: String) {
        // Guard against empty input
        if (city.isBlank()) {
            _uiState.value = WeatherUiState.Error("Please enter a city name")
            return
        }

        // Launch a coroutine on the ViewModel's lifecycle scope
        viewModelScope.launch {
            _uiState.value = WeatherUiState.Loading
            try {
                val response = RetrofitInstance.api.getCurrentWeather(
                    city = city,
                    apiKey = "your-api-key-here"
                )
                _uiState.value = WeatherUiState.Success(response)
            } catch (e: Exception) {
                _uiState.value = WeatherUiState.Error(
                    e.localizedMessage ?: "An unknown error occurred"
                )
            }
        }
    }
}

What does this code do?

  • _uiState is a private MutableStateFlow that starts at Idle. Only the ViewModel can change it.
  • uiState is the public read-only version. The UI subscribes to this and re-renders whenever the value changes.
  • viewModelScope.launch starts a coroutine tied to the ViewModel's lifecycle. If the user navigates away, the coroutine is automatically cancelled.
  • The try/catch wraps the network call so that connection failures, timeouts, or invalid cities surface as a user-friendly error message.

Where does my API key go?

Replace the your-api-key-here placeholder with the OpenWeatherMap API key you copied in Step 2. Paste it directly between the quotes. In a production app you would store this in a secure config file, but for a learning project this approach keeps things simple.

  • Replace your-api-key-here with your actual OpenWeatherMap API key.
  • Save WeatherViewModel.kt.
  • Click Build in the top menu bar, then Rebuild Project. The Build panel should show "BUILD SUCCESSFUL" with no errors.

Build fails with unresolved references?

  • If RetrofitInstance cannot be found, verify that RetrofitInstance.kt is in the network package and its package declaration says package com.example.weatherapp.network.
  • If viewModelScope is unresolved, check that lifecycle-viewmodel-compose is in your build.gradle.kts dependencies and Gradle sync is complete.
  • If MutableStateFlow is unresolved, confirm the coroutines dependency kotlinx-coroutines-android is present in your Gradle file.

Help me fix my WeatherViewModel build errors

✔️ Awesome, I've got everything!

Great. Double check you have saved all three new files: RetrofitInstance.kt, WeatherUiState.kt, and WeatherViewModel.kt.

ⓧ I'd like to double check the full code

Here is the full code for each of the three files you created in this step.

package com.example.weatherapp.network

import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory

object RetrofitInstance {

    private const val BASE_URL = "https://api.openweathermap.org/"

    private val loggingInterceptor = HttpLoggingInterceptor().apply {
        level = HttpLoggingInterceptor.Level.BODY
    }

    private val client = OkHttpClient.Builder()
        .addInterceptor(loggingInterceptor)
        .build()

    val api: WeatherApiService by lazy {
        Retrofit.Builder()
            .baseUrl(BASE_URL)
            .client(client)
            .addConverterFactory(GsonConverterFactory.create())
            .build()
            .create(WeatherApiService::class.java)
    }
}
package com.example.weatherapp.ui

import com.example.weatherapp.data.WeatherResponse

sealed interface WeatherUiState {
    data object Idle : WeatherUiState
    data object Loading : WeatherUiState
    data class Success(val data: WeatherResponse) : WeatherUiState
    data class Error(val message: String) : WeatherUiState
}
package com.example.weatherapp.ui

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.weatherapp.network.RetrofitInstance
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch

class WeatherViewModel : ViewModel() {

    private val _uiState = MutableStateFlow<WeatherUiState>(WeatherUiState.Idle)
    val uiState: StateFlow<WeatherUiState> = _uiState.asStateFlow()

    fun fetchWeather(city: String) {
        if (city.isBlank()) {
            _uiState.value = WeatherUiState.Error("Please enter a city name")
            return
        }

        viewModelScope.launch {
            _uiState.value = WeatherUiState.Loading
            try {
                val response = RetrofitInstance.api.getCurrentWeather(
                    city = city,
                    apiKey = "your-api-key-here"
                )
                _uiState.value = WeatherUiState.Success(response)
            } catch (e: Exception) {
                _uiState.value = WeatherUiState.Error(
                    e.localizedMessage ?: "An unknown error occurred"
                )
            }
        }
    }
}

Your network layer and state management are ready. In the next step, you will build the Compose UI that connects to this ViewModel and displays live weather data on screen.

Build the Compose UI

Your network layer fetches data and your ViewModel manages state. The missing piece is the screen the user actually sees and touches.

In this step, you'll replace the default MainActivity.kt with a Jetpack Compose screen that collects user input, triggers the ViewModel, and renders different UI based on the current state.

In this step, get ready to:
  • Set up the WeatherScreen composable with a text field and search button.
  • Handle loading, success, and error states with a when block.
  • Create a styled WeatherCard to display temperature, humidity, and wind data.
Set up the WeatherScreen composable with input controls

The WeatherScreen composable is the main screen of your app. It needs to create an instance of your ViewModel, observe its state, and give the user a text field to type a city name plus a button to search.

  • In Android Studio, open MainActivity.kt from the app/src/main/java/com/example/weatherapp/ folder in the Project panel on the left.
  • Select all the existing code and replace it with the following:
package com.example.weatherapp

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.weatherapp.data.WeatherResponse
import com.example.weatherapp.ui.WeatherUiState
import com.example.weatherapp.ui.WeatherViewModel
import com.example.weatherapp.ui.theme.WeatherAppTheme

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            WeatherAppTheme {
                Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
                    WeatherScreen(modifier = Modifier.padding(innerPadding))
                }
            }
        }
    }
}

@Composable
fun WeatherScreen(
    modifier: Modifier = Modifier,
    viewModel: WeatherViewModel = viewModel()
) {
    // Collect the ViewModel's StateFlow as Compose state
    val uiState by viewModel.uiState.collectAsState()
    // Local state for what the user types in the text field
    var cityInput by remember { mutableStateOf("") }

    Column(
        modifier = modifier
            .fillMaxSize()
            .padding(16.dp),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        // App title at the top of the screen
        Text(
            text = "Weather App",
            style = MaterialTheme.typography.headlineMedium,
            fontWeight = FontWeight.Bold
        )

        Spacer(modifier = Modifier.height(16.dp))

        // Text field for entering a city name
        OutlinedTextField(
            value = cityInput,
            onValueChange = { cityInput = it },
            label = { Text("Enter city name") },
            modifier = Modifier.fillMaxWidth(),
            singleLine = true
        )

        Spacer(modifier = Modifier.height(8.dp))

        // Button that triggers the API call via the ViewModel
        Button(
            onClick = { viewModel.fetchWeather(cityInput) },
            modifier = Modifier.fillMaxWidth()
        ) {
            Text("Get Weather")
        }

        Spacer(modifier = Modifier.height(24.dp))
    }
}

What does this code do?

  • viewModel() creates (or retrieves) a WeatherViewModel instance that survives configuration changes like screen rotations.
  • collectAsState() converts the ViewModel's StateFlow into Compose State. Whenever the flow emits a new value, Compose automatically recomposes the UI.
  • mutableStateOf("") holds the text the user types. Compose watches this value and updates the text field in real time.
  • The Button onClick calls fetchWeather(cityInput), which kicks off the network request you built in the previous step.
  • Save MainActivity.kt.
  • Confirm the file compiles without errors. You should see no red underlines in the editor.

Seeing unresolved reference errors?

  • If viewModel() is unresolved, check that the lifecycle-viewmodel-compose dependency is in your build.gradle.kts and that you synced the project.
  • If WeatherViewModel or WeatherUiState are unresolved, verify the import statements at the top match the package names in your ViewModel and state files.
  • help me fix unresolved reference errors in my Compose WeatherScreen
Handle each UI state with a when block

Right now your screen has input controls but no way to display results. The when block maps each state from your sealed interface to a different piece of UI.

  • In MainActivity.kt, find the last line inside the Column block in WeatherScreen:
        Spacer(modifier = Modifier.height(24.dp))
    }
}
  • Add the following when block between the last Spacer and the closing braces of the Column:
        // Render different UI based on the current state
        when (val state = uiState) {
            is WeatherUiState.Idle -> {}
            is WeatherUiState.Loading -> {
                CircularProgressIndicator()
            }
            is WeatherUiState.Success -> {
                WeatherCard(data = state.data)
            }
            is WeatherUiState.Error -> {
                Text(
                    text = state.message,
                    color = MaterialTheme.colorScheme.error,
                    style = MaterialTheme.typography.bodyLarge
                )
                Spacer(modifier = Modifier.height(8.dp))
                Button(onClick = { viewModel.fetchWeather(cityInput) }) {
                    Text("Retry")
                }
            }
        }

What does the when block do?

Kotlin's when on a sealed interface is exhaustive. The compiler guarantees you handle every possible state. Here is what each branch renders:

  • Idle shows nothing. The screen is waiting for the user to search.
  • Loading shows a spinning progress indicator while the network call runs.
  • Success passes the weather data to a WeatherCard composable (which you'll create next).
  • Error displays the error message in red and a Retry button that re-triggers the same search.
  • Save MainActivity.kt.
  • You will see a red underline on WeatherCard because it does not exist yet. You will create it in the next substep.
Build the WeatherCard composable

The WeatherCard is a reusable composable that takes a WeatherResponse and displays the city name, temperature, description, humidity, wind speed, and feels-like temperature inside a Material 3 Card.

  • Add the following composable function below the closing brace of WeatherScreen in MainActivity.kt:
@Composable
fun WeatherCard(data: WeatherResponse) {
    Card(
        modifier = Modifier.fillMaxWidth(),
        elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
    ) {
        Column(
            modifier = Modifier.padding(16.dp),
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            // City name heading
            Text(
                text = data.name,
                style = MaterialTheme.typography.headlineSmall,
                fontWeight = FontWeight.Bold
            )

            Spacer(modifier = Modifier.height(8.dp))

            // Temperature as a large integer with degree symbol
            Text(
                text = "${data.main.temp.toInt()}\u00B0C",
                fontSize = 48.sp,
                fontWeight = FontWeight.Bold
            )

            // Weather condition description
            Text(
                text = data.weather.firstOrNull()?.description?.replaceFirstChar {
                    it.uppercase()
                } ?: "",
                style = MaterialTheme.typography.bodyLarge
            )

            Spacer(modifier = Modifier.height(16.dp))

            // Row of detail stats: humidity, wind, feels like
            Row(
                modifier = Modifier.fillMaxWidth(),
                horizontalArrangement = Arrangement.SpaceEvenly
            ) {
                Column(horizontalAlignment = Alignment.CenterHorizontally) {
                    Text(text = "Humidity", style = MaterialTheme.typography.bodySmall)
                    Text(
                        text = "${data.main.humidity}%",
                        fontWeight = FontWeight.Bold
                    )
                }
                Column(horizontalAlignment = Alignment.CenterHorizontally) {
                    Text(text = "Wind", style = MaterialTheme.typography.bodySmall)
                    Text(
                        text = "${data.wind.speed} m/s",
                        fontWeight = FontWeight.Bold
                    )
                }
                Column(horizontalAlignment = Alignment.CenterHorizontally) {
                    Text(text = "Feels Like", style = MaterialTheme.typography.bodySmall)
                    Text(
                        text = "${data.main.feelsLike.toInt()}\u00B0C",
                        fontWeight = FontWeight.Bold
                    )
                }
            }
        }
    }
}

What does this code do?

  • Card wraps the weather details in a Material 3 elevated card with a subtle shadow (4.dp elevation).
  • data.main.temp.toInt() converts the decimal temperature to a clean whole number. The \u00B0 is the Unicode degree symbol.
  • replaceFirstChar { it.uppercase() } capitalizes the weather description since the API returns it in lowercase (e.g. "clear sky" becomes "Clear sky").
  • The Row with Arrangement.SpaceEvenly distributes the three stat columns (humidity, wind, feels like) evenly across the card width.
  • Save MainActivity.kt. The red underline on WeatherCard in your when block should disappear now.
  • Run the app on your emulator or device by clicking the green play button in the toolbar.
  • Type London in the text field and tap Get Weather. You should see a card with the city name, temperature, weather description, humidity, wind speed, and feels-like temperature.
  • Type an invalid city name (e.g. xyznotacity) and tap Get Weather. You should see a red error message and a Retry button.

Not seeing weather data?

  • If you see an error immediately, your API key may not be activated yet. OpenWeatherMap keys can take up to 2 hours after signup. Wait and try again.
  • If the app crashes, open the Logcat panel at the bottom of Android Studio and look for a stack trace. The OkHttp logging interceptor also prints the full request and response there.
  • If you see "Unable to resolve host", make sure the INTERNET permission is in your AndroidManifest.xml and that your emulator has network access.
  • help me debug why my weather app isn't showing data when I search for a city

✔️ Awesome, I've got everything!

Great. Double check you have saved MainActivity.kt and the app compiles cleanly.

ⓧ I'd like to double check the full code

Here is the complete MainActivity.kt file. Compare your code against this:

package com.example.weatherapp

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.weatherapp.data.WeatherResponse
import com.example.weatherapp.ui.WeatherUiState
import com.example.weatherapp.ui.WeatherViewModel
import com.example.weatherapp.ui.theme.WeatherAppTheme

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            WeatherAppTheme {
                Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
                    WeatherScreen(modifier = Modifier.padding(innerPadding))
                }
            }
        }
    }
}

@Composable
fun WeatherScreen(
    modifier: Modifier = Modifier,
    viewModel: WeatherViewModel = viewModel()
) {
    val uiState by viewModel.uiState.collectAsState()
    var cityInput by remember { mutableStateOf("") }

    Column(
        modifier = modifier
            .fillMaxSize()
            .padding(16.dp),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text(
            text = "Weather App",
            style = MaterialTheme.typography.headlineMedium,
            fontWeight = FontWeight.Bold
        )

        Spacer(modifier = Modifier.height(16.dp))

        OutlinedTextField(
            value = cityInput,
            onValueChange = { cityInput = it },
            label = { Text("Enter city name") },
            modifier = Modifier.fillMaxWidth(),
            singleLine = true
        )

        Spacer(modifier = Modifier.height(8.dp))

        Button(
            onClick = { viewModel.fetchWeather(cityInput) },
            modifier = Modifier.fillMaxWidth()
        ) {
            Text("Get Weather")
        }

        Spacer(modifier = Modifier.height(24.dp))

        when (val state = uiState) {
            is WeatherUiState.Idle -> {}
            is WeatherUiState.Loading -> {
                CircularProgressIndicator()
            }
            is WeatherUiState.Success -> {
                WeatherCard(data = state.data)
            }
            is WeatherUiState.Error -> {
                Text(
                    text = state.message,
                    color = MaterialTheme.colorScheme.error,
                    style = MaterialTheme.typography.bodyLarge
                )
                Spacer(modifier = Modifier.height(8.dp))
                Button(onClick = { viewModel.fetchWeather(cityInput) }) {
                    Text("Retry")
                }
            }
        }
    }
}

@Composable
fun WeatherCard(data: WeatherResponse) {
    Card(
        modifier = Modifier.fillMaxWidth(),
        elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
    ) {
        Column(
            modifier = Modifier.padding(16.dp),
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            Text(
                text = data.name,
                style = MaterialTheme.typography.headlineSmall,
                fontWeight = FontWeight.Bold
            )

            Spacer(modifier = Modifier.height(8.dp))

            Text(
                text = "${data.main.temp.toInt()}\u00B0C",
                fontSize = 48.sp,
                fontWeight = FontWeight.Bold
            )

            Text(
                text = data.weather.firstOrNull()?.description?.replaceFirstChar {
                    it.uppercase()
                } ?: "",
                style = MaterialTheme.typography.bodyLarge
            )

            Spacer(modifier = Modifier.height(16.dp))

            Row(
                modifier = Modifier.fillMaxWidth(),
                horizontalArrangement = Arrangement.SpaceEvenly
            ) {
                Column(horizontalAlignment = Alignment.CenterHorizontally) {
                    Text(text = "Humidity", style = MaterialTheme.typography.bodySmall)
                    Text(
                        text = "${data.main.humidity}%",
                        fontWeight = FontWeight.Bold
                    )
                }
                Column(horizontalAlignment = Alignment.CenterHorizontally) {
                    Text(text = "Wind", style = MaterialTheme.typography.bodySmall)
                    Text(
                        text = "${data.wind.speed} m/s",
                        fontWeight = FontWeight.Bold
                    )
                }
                Column(horizontalAlignment = Alignment.CenterHorizontally) {
                    Text(text = "Feels Like", style = MaterialTheme.typography.bodySmall)
                    Text(
                        text = "${data.main.feelsLike.toInt()}\u00B0C",
                        fontWeight = FontWeight.Bold
                    )
                }
            }
        }
    }
}

Your weather app is fully functional. You can type any city, see live data, and gracefully handle errors. Next up, you'll extend it with a 5-day forecast in the Secret Mission.

Secret mission

Add a 5-Day Forecast

Your weather app shows the current conditions, but what about tomorrow? In this secret mission, you'll add a 5-day forecast section that fetches data from a second API endpoint, runs both network calls in parallel with Kotlin coroutines, and displays upcoming weather in a scrollable list.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely on your local machine and uses the OpenWeatherMap free tier. There are no ongoing cloud costs to worry about.

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • WeatherApp project files in Android Studio.
  • OpenWeatherMap API key (free tier, no cost).
  • Android Emulator virtual device (local disk space).

Keep everything running

No action needed. Choose this if you plan to keep building on your WeatherApp or want to reference the code later.

  • Your project stays in Android Studio ready to open anytime.
  • Your OpenWeatherMap API key remains active on the free tier at no cost.
  • Your emulator virtual device stays available in the Device Manager.

Pause - I'll come back to this later

Shut down running processes to free up memory while keeping your project files intact.

  • Stop the emulator by clicking the X button on the emulator window or clicking the square stop icon in the Device Manager.
  • Close the WeatherApp project in Android Studio by clicking File, then Close Project.
  • Your project files and API key remain saved for when you return.

Delete - I don't want to use this again

Remove all project resources and start fresh.

  • Close the project in Android Studio by clicking File, then Close Project.
  • Delete the project folder from your file system. Open a terminal and run the appropriate command for your operating system:

macOS

rm -rf ~/AndroidStudioProjects/WeatherApp

Windows

rmdir /s /q %USERPROFILE%\AndroidStudioProjects\WeatherApp
  • Delete the emulator virtual device by opening Android Studio, clicking Device Manager in the toolbar, clicking the overflow menu (three dots) next to your device, and selecting Delete.
  • Delete your OpenWeatherMap API key by signing in to openweathermap.org, navigating to API keys in your account page, and clicking the trash icon next to the key you created.

That's a Wrap!

That's a Wrap!

Nice work! You just built a fully functional weather app that fetches live data from a REST API and displays it in a modern Android UI.

You've learned how to:

  • Built a Kotlin Android app with Jetpack Compose that fetches and displays live weather data from the OpenWeatherMap API.
  • Integrated a REST API using Retrofit with Gson serialization and OkHttp logging for network debugging.
  • Implemented the MVVM architecture pattern with ViewModel, StateFlow, and sealed classes to manage loading, success, and error states cleanly.
  • Secret Mission: Extended the app with a 5-day forecast using parallel async coroutines and a LazyColumn for scrollable forecast data.

Ready to quiz yourself?