serras/WeatherApp
Weather App with Arrow + Compose Desktop
Weather App with Arrow + Compose Desktop
Based on the How to Build an MVI Clean Code Weather App tutorial
by Philipp Lackner. The Weather domain model is heavily based on
his original implementation.
This repository contains an implementation of a small weather forecast application using functional style, as
described in Arrow's design section and the book Functional Ideas for
the Curious Kotliner.
The application uses Open-Meteo to gather forecast data, following the original
tutorial. GeoIP2 is
used to map IPs to locations, since we don't use location services.
Compose Desktop
The application is implemented in Compose Multiplatform Desktop
instead of Android. The main reason is being able to use experimental Kotlin features, which are only available
in the JVM back-end. Furthermore, it makes it possible for everybody to check the application, even if they don't
own an Android phone nor want to download a simulator.
State as sealed interface
The original tutorial uses a class with nullable fields to represent
the different states of the application (loading, error, success).
data class WeatherState(
val isLoading: Boolean,
val weatherInfo: WeatherInfo?,
val error: String?
)Our implementation
uses sealed interfaces instead.
Each state gets its own type, making it impossible to represent invalid states,
sealed interface WeatherState {
data object Loading : WeatherState
data class Error(val error: String) : WeatherState
data class Ok(val place: String?, val weatherInfo: WeatherInfo) : WeatherState
}Context parameters
Our implementation doesn't use dependency injection framework, as opposed to most Android applications, which use
Hilt. Instead, the dependencies are injected using
context parameters,
// actually implemented as 'invoke' on the companion object
context(weather: WeatherRepository, location: LocationTracker)
fun WeatherViewModel() { /* implementation */ }The actual injection of dependencies is performed manually in the entry point,
suspend fun <A> injectDependencies(
block: context(WeatherRepository, LocationTracker) () -> A
): A = resourceScope {
val weather: WeatherRepository = autoCloseable { WeatherRepositoryImpl() }
val location: LocationTracker = autoCloseable { LocationTrackerImpl() }
block(weather, location)
}Another advantage of this approach, apart from the speed gains at both compile and run time, is that resources
are managed correctly using Arrow's resourceScope.
This is often a convoluted task when using dependency injection frameworks -- when are instances actually created
and disposed -- whereas here everything is explicit.
Lifecycle as CoroutineScope context
Jetpack Compose encourages to keep the activity state in a
ViewModel. One of the main benefits of
this approach is that ViewModels are lifecycle-aware. For example, if you launch a concurrent coroutine and the
activity is then closed, the coroutine is automatically cancelled.
This ability comes in a great deal from the
structured concurrency
guarantees from Kotlin's coroutines.
We use the fact that Compose already brings one such CoroutineScope in
our ViewModel,
class WeatherViewModel {
/* ... */
fun loadWeatherInfo() {
// 'launch' comes from the 'viewModelScope' CoroutineScope
viewModelScope.launch(Dispatchers.IO) {
/* ... */
}
}
}Arrow DSLs
We've already mentioned that resourceScope is used
to correctly manage resource acquisition and disposal. This is one of Arrow's DSLs, each of them providing
additional features within a certain scope. The other one used heavily within this application are
typed errors.
The implementation of LocationTracker
showcases how the DSLs can be used and combined.
Tests with Turbine
One of the advantages of having a Flow as source of truth for our application is the availability of specialized
testing libraries. In particular, Turbine allows us to specify how
the flow should evolve over time.
For example, one of our tests simulates that our location tracking is failing by providing a LocationTracker
instance that always returns null. In that case, we know that the expected turn of events is loading,
and then error.
"errors when location is down" {
// set up WeatherViewModel with a LocationTracker that always fails
model.state.test {
awaitItem().shouldBeInstanceOf<WeatherState.Loading>()
model.loadWeatherInfo()
awaitItem().shouldBeInstanceOf<WeatherState.Error>()
}
}Another tool in our tests is property-based testing, brought by Kotest. Shortly, property-
based testing executes the same tests several times with arbitrary data, ensuring that more complex conditions and
corner cases are covered. By using their reflective generators,
starting with a random location and weather data is quite simple.
checkAll(
Arb.bind<Location>(),
Arb.list(Arb.bind<WeatherData>(), 24..48)
) { location, weatherData -> /* test */ }