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.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
Compose for Desktop: Get Your Weather!
25 mins
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:
- 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. - 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.
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:
-
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. - You need the
scope
to launch a coroutine from aComposable
.
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:
- 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. -
downloadImage()
downloads a resource from a URL and saves it as anarray
ofbytes
. Then, it uses a couple of helper functions to convert thearray
into abitmap
, 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.
So far, so good!