Compose for Desktop: Get Your Weather!

Build a desktop weather app with Compose for Desktop! You’ll get user input, fetch network data and display it all with the Compose UI toolkit. By Roberto Orgiu.

5 (2) · 1 Review

Download materials
Save for later
Share
You are currently viewing page 2 of 3 of this article. Click here to view the first page.

Transforming the Network Data

Before you can dive into the UI, you need to get some data. You’re already familiar with the Repository that fetches weather updates from the backend, but these models aren’t suitable for your UI just yet. You need to transform them into something that more closely matches what your UI will represent.

As a first step, as you already did before, create a WeatherUIModels.kt file and add the following code in it:

data class WeatherCard(
  val condition: String,
  val iconUrl: String,
  val temperature: Double,
  val feelsLike: Double,
  val chanceOfRain: Double? = null,
)

data class WeatherResults(
  val currentWeather: WeatherCard,
  val forecast: List<WeatherCard>,
)

WeatherCard represents a single forecast: You have the expected weather condition with its icon for a visual representation, the temperature and what the weather actually feels like to people, and finally, the chance of rain.

WeatherResults contains all the various weather reports for your UI: You’ll have a large card with the current weather, and a carousel of smaller cards that represent the forecast for the upcoming days.

Next, you’ll transform the models you get from the network into these new models that are easier to display on your UI. Create a new Kotlin class and name it WeatherTransformer.

Then, write code to extract the current weather condition from the response. Add this function inside WeatherTransformer:

private fun extractCurrentWeatherFrom(response: WeatherResponse): WeatherCard {
  return WeatherCard(
    condition = response.current.condition.text,
    iconUrl = "https:" + response.current.condition.icon.replace("64x64", "128x128"),
    temperature = response.current.tempC,
    feelsLike = response.current.feelslikeC,
  )
}

With these lines, you’re mapping the fields in different objects of the response to a simple object that will have the data exactly how your UI expects it. Instead of reading nested values, you’ll have simple properties!

Unfortunately, the icon URL returned by the weather API isn’t an actual URL. One of these values looks something like this:

//cdn.weatherapi.com/weather/64x64/day/116.png

To fix this, you prepend the HTTPS protocol and increase the size of the icon, from 64×64 to 128×128. After all, you’ll display the current weather on a larger card!

Now, you need to extract the forecast data from the response, which will take a bit more work. Below extractCurrentWeatherFrom(), add the following functions:

// 1
private fun extractForecastWeatherFrom(response: WeatherResponse): List<WeatherCard> {
  return response.forecast.forecastday.map { forecastDay ->
    WeatherCard(
      condition = forecastDay.day.condition.text,
      iconUrl = "https:" + forecastDay.day.condition.icon,
      temperature = forecastDay.day.avgtempC,
      feelsLike = avgFeelsLike(forecastDay),
      chanceOfRain = avgChanceOfRain(forecastDay),
    )
  }
}

// 2
private fun avgFeelsLike(forecastDay: Forecastday): Double =
  forecastDay.hour.map(Hour::feelslikeC).average()
private fun avgChanceOfRain(forecastDay: Forecastday): Double =
  forecastDay.hour.map(Hour::chanceOfRain).average()

Here’s a step-by-step breakdown of this code:

  1. The first thing you need to do is loop through each of the nested forecast objects, so that you can map them each to a WeatherCard, similar to what you did for the current weather model. This time, the response represents both the feeling of the weather and the chance of rain as arrays, containing the hourly forecasts for these values.
  2. For each hour, take the data you need (either the felt temperature or the chance of rain) and calculate the average across the whole day. This gives you an approximation you can show on the UI.

With these functions prepared, you can now create a function that returns the proper model expected by your UI. At the end of WeatherTransformer, add this function:

fun transform(response: WeatherResponse): WeatherResults {
  val current = extractCurrentWeatherFrom(response)
  val forecast = extractForecastWeatherFrom(response)

  return WeatherResults(
    currentWeather = current,
    forecast = forecast,
  )
}

Your data transformation code is ready! Time to put it into action.

Updating the Repository

Open Repository.kt and change the visibility of getWeatherForCity() to private:

private suspend fun getWeatherForCity(city: String) : WeatherResponse = ...

Instead of calling this method directly, you’re going to wrap it in a new one so that it returns your new models.

Inside Repository, create a property that contains a WeatherTransformer:

private val transformer = WeatherTransformer()

Now, add this new function below the property:

suspend fun weatherForCity(city: String): Lce<WeatherResults> {
  return try {
    val result = getWeatherForCity(city)
    val content = transformer.transform(result)
    Lce.Content(content)
  } catch (e: Exception) {
    e.printStackTrace()
    Lce.Error(e)
  }
}

In this method, you get the weather, and you use the transformer to convert it into a WeatherResult and wrap it inside Lce.Content. In case something goes terribly wrong during the network call, you wrap the exception into Lce.Error.

If you want an overview of how you could test a repository like this one, written with Ktor, look at RepositoryTest.kt in the final project. It uses Ktor’s MockEngine to drive an offline test.

Showing the Loading State

Now you know everything about the LCE pattern, and you’re ready to apply these concepts in a real-world application, aren’t you? Good!

Open WeatherScreen.kt, and below WeatherScreen(), add this function:

@Composable
fun LoadingUI() {
  Box(modifier = Modifier.fillMaxSize()) {
    CircularProgressIndicator(
      modifier = Modifier
        .align(alignment = Alignment.Center)
        .defaultMinSize(minWidth = 96.dp, minHeight = 96.dp)
    )
  }
}

What happens here is the representation of the loading UI — nothing more, nothing less.

Now, you want to display this loading UI below the input components. In WeatherScreen(), wrap the existing Row into a vertical Column and call LoadingUI() below it in the following way:

Column(horizontalAlignment = Alignment.CenterHorizontally) {
  Row(...) { ... } // Your existing input code
  LoadingUI()
}

Build and run, and you’ll see a spinner.

The app displaying the loading state

You’ve got the loading UI up and running, but you also need to show the results, which you’ll do next.

Displaying the Results

The first thing you need to do is declare a UI for the content as a function inside WeatherScreen:

@Composable
fun ContentUI(data: WeatherResults) {
}

You’ll handle the real UI later, but for the moment, you need a placeholder. :]

Next, at the top of WeatherScreen(), you need to declare a couple of values below the existing queriedCity:

// 1
var weatherState by remember { mutableStateOf<Lce<WeatherResults>?>(null) } 
// 2
val scope = rememberCoroutineScope() 

In the code above:

  1. weatherState will hold the current state to display. Every time the LCE changes, Compose will recompose your UI so that you can react to this change.
  2. You need the scope to launch a coroutine from a Composable.

Now, you need to implement the button’s onClick() (the one marked with the /* We'll deal with this later */ comment), like so:

onClick = {
    weatherState = Lce.Loading
    scope.launch {
      weatherState = repository.weatherForCity(queriedCity)
    }
  }

Every time you click, weatherState changes to Loading, causing a recomposition. At the same time, you’ll launch a request to get the updated weather. When the result arrives, this will change weatherState again, causing another recomposition.

Then, add the necessary import:

import kotlinx.coroutines.launch

At this point, you need to handle the recomposition, and you need to draw something different for each state. Go to where you invoked LoadingUI at the end of WeatherScreen(), and replace that invocation with the following code:

when (val state = weatherState) {
 is Lce.Loading -> LoadingUI()
 is Lce.Error -> Unit
 is Lce.Content -> ContentUI(state.data)
}

With this code, every time a recomposition occurs, you’ll be able to draw a different UI based on the state.

Your next step is downloading the image for the weather conditions. Unfortunately, there isn’t an API in Compose for Desktop for doing that just yet. However, you can implement your own solution! Create a new file and name it ImageDownloader.kt. Inside, add this code:

import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.request.*
import org.jetbrains.skija.Image

object ImageDownloader {
  private val imageClient = HttpClient(CIO) // 1

  suspend fun downloadImage(url: String): ImageBitmap { // 2
    val image = imageClient.get<ByteArray>(url)
    return Image.makeFromEncoded(image).asImageBitmap()
  }
}

Here’s an overview of what this class does:

  1. The first thing you might notice is that you’re creating a new HttpClient: This is because you don’t need all the JSON-related configuration from the repository, and you really only need one client for all the images.
  2. downloadImage() downloads a resource from a URL and saves it as an array of bytes. Then, it uses a couple of helper functions to convert the array into a bitmap, which is ready to use in your Compose UI.

Now, go back to WeatherScreen.kt, find ContentUI() and add this code to it:

var imageState by remember { mutableStateOf<ImageBitmap?>(null) }

LaunchedEffect(data.currentWeather.iconUrl) {
  imageState = ImageDownloader.downloadImage(data.currentWeather.iconUrl)
}

These lines will save the image you downloaded into a state so that it survives recompositions. LaunchedEffect() will run the download of the image only when the first recomposition occurs. If you didn’t use this, every time something else changes, your image download would run again, downloading unneeded data and causing glitches in the UI.

Then, add the necessary import:

import androidx.compose.ui.graphics.ImageBitmap

At the end of ContentUI(), add a title for the current weather:

Text(
  text = "Current weather",
  modifier = Modifier.padding(all = 16.dp),
  style = MaterialTheme.typography.h6,
)

Next, you’ll create a Card that will host the data about the current weather. Add this below the previously added Text:

Card(
  modifier = Modifier
    .fillMaxWidth()
    .padding(horizontal = 72.dp)
) {
  Column(
    modifier = Modifier.fillMaxWidth().padding(16.dp),
    horizontalAlignment = Alignment.CenterHorizontally,
  ) {
    Text(
      text = data.currentWeather.condition,
      style = MaterialTheme.typography.h6,
    )

    imageState?.let { bitmap ->
      Image(
        bitmap = bitmap,
        contentDescription = null,
        modifier = Modifier
          .defaultMinSize(minWidth = 128.dp, minHeight = 128.dp)
          .padding(top = 8.dp)
      )
    }

    Text(
      text = "Temperature in °C: ${data.currentWeather.temperature}",
      modifier = Modifier.padding(all = 8.dp),
    )
    Text(
      text = "Feels like: ${data.currentWeather.feelsLike}",
      style = MaterialTheme.typography.caption,
    )
  }
}

Here, you use a couple of Text components to show the different values, and an Image to show the icon, if that’s already available.
To use the code above, you need to import androidx.compose.foundation.Image.

Next, add this code below Card:

Divider(
  color = MaterialTheme.colors.primary,
  modifier = Modifier.padding(all = 16.dp),
)

This adds a simple divider between the current weather and the forecast you’ll implement in the next step.

The last piece of content you want to display is the forecast weather. Here, you’ll use yet another title and a LazyRow to display the carousel of items, as you don’t know how many of them will come back from the network request, and you want it to be scrollable.

Add this code below the Divider:

Text(
  text = "Forecast",
  modifier = Modifier.padding(all = 16.dp),
  style = MaterialTheme.typography.h6,
)
LazyRow {
  items(data.forecast) { weatherCard ->
    ForecastUI(weatherCard)
  }
}

Add the missing imports as well:

import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items

At this point, you’ll notice the IDE complaining, but that’s expected, as you didn’t create ForecastUI() yet. Go ahead add this below ContentUI():

@Composable
fun ForecastUI(weatherCard: WeatherCard) {
}

Here, you declare the missing function. Inside, you can use the same image loading pattern you used for the current weather’s icon:

var imageState by remember { mutableStateOf<ImageBitmap?>(null) }

LaunchedEffect(weatherCard.iconUrl) {
  imageState = ImageDownloader.downloadImage(weatherCard.iconUrl)
}

Once again, you’re downloading an image, and it’s now time to show the UI for the rest of the data inside your models. At the bottom of ForecaseUI(), add the following:

Card(modifier = Modifier.padding(all = 4.dp)) {
  Column(
    modifier = Modifier.padding(8.dp),
    horizontalAlignment = Alignment.CenterHorizontally,
  ) {
    Text(
      text = weatherCard.condition,
      style = MaterialTheme.typography.h6
    )

    imageState?.let { bitmap ->
      Image(
        bitmap = bitmap,
        contentDescription = null,
        modifier = Modifier
          .defaultMinSize(minWidth = 64.dp, minHeight = 64.dp)
          .padding(top = 8.dp)
      )
    }

    val chanceOfRainText = String.format(
      "Chance of rain: %.2f%%", weatherCard.chanceOfRain
    )

    Text(
      text = chanceOfRainText,
      style = MaterialTheme.typography.caption,
    )
  }
}

This is again similar to displaying the current weather, but this time, you’ll also display the chance of rain.

Build and run. If you search for a valid city name, you’ll receive a result like in the following image.

The app displaying the Content state

So far, so good!