Reinvention on Weather App Project integrating MVI, Koin, OkHttp using Kotlin
This project is a modern Android application designed as a comprehensive demonstration of Weather API integration using the latest…
Reinvention on Weather App Project integrating MVI, Koin, OkHttp using Kotlin

This project is a modern Android application designed as a comprehensive demonstration of Weather API integration using the latest industry-standard libraries and architectural patterns. It serves as a “reinvention” of a weather forecasting tool, moving away from legacy Android development towards a reactive, declarative, and clean architecture approach. At its core, the app allows users to search for any global location and instantly receive a detailed weather report.
What You’ll Learn
The project focus on Unidirectional Data Flow (MVI) and high-performance dependency injection, ensuring the app remains responsive and maintainable even as it scales.
Refer Weather App Project integrating Retrofit, Coil and Compose UI using Kotlin
Adding Dependencies
[versions]
coilCompose = "2.7.0"
lifecycleRuntimeKtx = "2.10.0"
materialIconsCore = "1.7.8"
retrofitVersion = "3.0.0"
loggingInterceptor = "5.3.2"
koin = "4.1.1"
[libraries]
coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coilCompose" }
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-compose-material-icons-core = { module = "androidx.compose.material:material-icons-core", version.ref = "materialIconsCore" }
androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "materialIconsCore" }
retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofitVersion" }
converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofitVersion" }
logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "loggingInterceptor" }
koin-android = { group = "io.insert-koin", name = "koin-android", version.ref = "koin" }
koin-core = { group = "io.insert-koin", name = "koin-core", version.ref = "koin" }
koin-androidx-compose = { group = "io.insert-koin", name = "koin-androidx-compose", version.ref = "koin" }
[bundles]
retrofit = [
"retrofit",
"converter-gson",
"logging-interceptor"
]
koin = [
"koin-core",
"koin-android",
"koin-androidx-compose"
]
In build.gradle.kts(:app)
dependencies {
implementation(libs.coil.compose)
implementation(libs.androidx.compose.material.icons.core)
implementation(libs.androidx.compose.material.icons.extended)
implementation(libs.bundles.retrofit)
implementation(libs.bundles.koin)
implementation(libs.kotzilla.sdk.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
}
Note: Use the latest version of the dependencies and sync it.
Store API keys
In local.properties
WEATHER_API_KEY = PASTE_YOUR_API_KEY
In build.gradle.kts(:app)
import java.util.Properties
android {
defaultConfig {
// ...
val properties = Properties() // Properties from java.util package
val localPropertiesFile = project.rootProject.file("local.properties")
if (localPropertiesFile.exists()) {
properties.load(localPropertiesFile.inputStream())
}
val apiKey = properties.getProperty("WEATHER_API_KEY") ?: ""
buildConfigField("String", "WEATHER_API_KEY", "\"$apiKey\"")
}
buildFeatures {
compose = true
buildConfig = true // build config to true
}
}
Note: Add the local.properties file to .gitignore and set buildConfig to true.
Creating packages for Clean Architecture
In initial stage of architecture create packages as app, core and feature(weather).
com.example.project
|
|__app
|
|__core
| |__presentation
| |__designsystem
| |__util
|
|__weather
|__data
|__domain
|__presentation
Move the MainActivity.kt into app package. Move the theme package into core.presentation.designsystem package.
In Domain package
Create a model package in domain package and create a data class.
package com.example.project.weather.domain.model
data class WeatherResponse(
val current: Current,
val location: Location
)
data class Current(
val cloud: String,
val condition: Condition,
val dewpoint_c: String,
val dewpoint_f: String,
val diff_rad: String,
val dni: String,
val feelslike_c: String,
val feelslike_f: String,
val gti: String,
val gust_kph: String,
val gust_mph: String,
val heatindex_c: String,
val heatindex_f: String,
val humidity: String,
val is_day: String,
val last_updated: String,
val last_updated_epoch: String,
val precip_in: String,
val precip_mm: String,
val pressure_in: String,
val pressure_mb: String,
val short_rad: String,
val temp_c: String,
val temp_f: String,
val uv: String,
val vis_km: String,
val vis_miles: String,
val wind_degree: String,
val wind_dir: String,
val wind_kph: String,
val wind_mph: String,
val windchill_c: String,
val windchill_f: String
)
data class Location(
val country: String,
val lat: String,
val localtime: String,
val localtime_epoch: String,
val lon: String,
val name: String,
val region: String,
val tz_id: String
)
data class Condition(
val code: String,
val icon: String,
val text: String
)
Create Error interface in domain package to handle all types of error cases during the network request.
package com.example.project.weather.domain
sealed interface Error
package com.example.project.weather.domain
typealias RootError = Error
sealed interface Result<out D, out E: RootError> {
data class Success<out D, out E: RootError>(val data: D): Result<D, E>
data class Error<out D, out E: RootError>(val error: E): Result<D, E>
data object Loading: Result<Nothing, Nothing>
}
package com.example.project.weather.domain
sealed interface DataError : Error {
enum class Network : DataError {
REQUEST_TIMEOUT,
TOO_MANY_REQUESTS,
NO_INTERNET,
PAYLOAD_TOO_LARGE,
SERVER_ERROR,
SERIALIZATION,
UNAUTHORISED,
UNKNOWN
}
enum class Local : DataError {
DISK_FULL
}
}
Weather repository interface in domain and handle those implementation in those data package.
package com.example.project.weather.domain
interface WeatherRepository {
suspend fun getWeatherData(city: String): Result<WeatherResponse, DataError.Network>
}
In Data package
Weather api service interface to send request to the server.
package com.example.project.weather.data
interface WeatherApiService {
@GET("current.json")
suspend fun getWeather(
@Query("key") apikey: String,
@Query("q") city: String
): WeatherResponse
}
Implementation of the weather repository in WeatherRepositoryImpl.kt, handling all types of network business logics.
package com.example.project.weather.data
class WeatherRepositoryImpl(
private val weatherApiService: WeatherApiService
): WeatherRepository {
override suspend fun getWeatherData(city: String): Result<WeatherResponse, DataError.Network> {
return try {
val response = weatherApiService.getWeather(
apikey = BuildConfig.WEATHER_API_KEY,
city = city
)
Result.Success(response)
} catch (e: Exception) {
val networkError = when (e) {
is HttpException -> when (e.hashCode()) {
401 -> DataError.Network.UNAUTHORISED
408 -> DataError.Network.REQUEST_TIMEOUT
413 -> DataError.Network.PAYLOAD_TOO_LARGE
429 -> DataError.Network.TOO_MANY_REQUESTS
in 500..599 -> DataError.Network.SERVER_ERROR
else -> DataError.Network.UNKNOWN
}
is IOException -> DataError.Network.NO_INTERNET
is JsonSyntaxException -> DataError.Network.SERIALIZATION
else -> DataError.Network.UNKNOWN
}
Result.Error(networkError)
}
}
}
Create a di (dependency injection) package in data on how to communicate the server, OkHttpClient(Engine) and Retrofit(The Translator).
package com.example.project.weather.data.di
val networkModule = module {
single {
OkHttpClient.Builder()
.addInterceptor(
HttpLoggingInterceptor().setLevel(
HttpLoggingInterceptor.Level.BODY
)
)
.connectTimeout(120, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
.build()
}
single {
Retrofit.Builder()
.baseUrl("https://api.weatherapi.com/v1/")
.client(get())
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(WeatherApiService::class.java)
}
}
.addInterceptor() → works as an middleman for our network calls.
HttpLoggingInterceptor() → monitors all data going out and coming in.
.setLevel(Level.BODY) → it prints the full JSON response from the Weather API into your Logcat and it allows us to see exactly the server response.
.connectTimeout() → if the server doesn’t send response within 120 seconds, the app will stop trying and throw SocketTimeoutException.
.readTimeout() → if the server is slow at calculating the weather data and takes more than 120 seconds, the app will time out.
In Presentation package
Implementation of the UI of weather app using the MVI pattern.
In WeatherState.kt
package com.example.project.weather.presentation
data class WeatherState(
val searchQuery: String = "",
val weatherResult: Result<WeatherResponse, DataError.Network>? = null
)
In WeatherAction.kt
package com.example.project.weather.presentation
sealed interface WeatherAction {
data class OnSearchQueryChange(val query: String) : WeatherAction
data object OnSearchClick : WeatherAction
data class LoadWeather(val city: String) : WeatherAction
}
In WeatherViewModel.kt
package com.example.project.weather.presentation
class WeatherViewModel(
private val weatherRepository: WeatherRepository
) : ViewModel() {
private var hasLoadedInitialData = false
private val _state = MutableStateFlow(WeatherState())
val state = _state
.onStart {
if (!hasLoadedInitialData) {
/** Load initial data here **/
// onAction(WeatherAction.LoadWeather("Tokyo"))
hasLoadedInitialData = true
}
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = WeatherState()
)
fun onAction(action: WeatherAction) {
when (action) {
is WeatherAction.LoadWeather -> {
fetchWeather(action.city)
}
WeatherAction.OnSearchClick -> {
onAction(WeatherAction.LoadWeather(_state.value.searchQuery))
}
is WeatherAction.OnSearchQueryChange -> {
_state.update {
it.copy(
searchQuery = action.query
)
}
}
}
}
private fun fetchWeather(city: String) {
if (city.isBlank()) return
viewModelScope.launch {
_state.update {
it.copy(
weatherResult = Result.Loading
)
}
val result = weatherRepository.getWeatherData(city)
_state.update {
it.copy(
weatherResult = result
)
}
}
}
}
In WeatherScreen.kt
package com.example.project.weather.presentation
@Composable
fun WeatherRoot(
viewModel: WeatherViewModel = koinViewModel()
) {
val state by viewModel.state.collectAsStateWithLifecycle()
WeatherScreen(
state = state,
onAction = viewModel::onAction
)
}
@Composable
fun WeatherScreen(
state: WeatherState,
onAction: (WeatherAction) -> Unit,
) {
val keyboardController = LocalSoftwareKeyboardController.current
Scaffold(
modifier = Modifier
.padding(),
topBar = {},
contentWindowInsets = WindowInsets.safeGestures
) { paddingValues ->
Box(
modifier = Modifier
.fillMaxSize()
.background(
brush = Brush.verticalGradient(
colors = listOf(
Purple,
DarkPurple,
Black
)
)
)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(top = 16.dp)
.padding(32.dp),
verticalArrangement = Arrangement.Top,
horizontalAlignment = Alignment.CenterHorizontally
) {
Row(
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
modifier = Modifier
.weight(1f),
value = state.searchQuery,
onValueChange = {
onAction(WeatherAction.OnSearchQueryChange(it))
},
label = { Text("Search any Location") },
singleLine = true,
textStyle = TextStyle(
fontSize = 20.sp,
fontWeight = FontWeight.Medium
),
placeholder = { Text("Search") },
colors = TextFieldDefaults.colors(
unfocusedContainerColor = Color.Transparent,
focusedContainerColor = Color.DarkGray.copy(0.4f),
focusedTextColor = Color.White,
unfocusedLabelColor = Color.White,
focusedLabelColor = Color.White,
focusedIndicatorColor = DarkGray,
unfocusedIndicatorColor = Color.White,
cursorColor = Color.Black,
unfocusedTrailingIconColor = Color.White,
focusedTrailingIconColor = Color.White
),
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Search
),
keyboardActions = KeyboardActions(
onSearch = {
onAction(WeatherAction.LoadWeather(state.searchQuery))
keyboardController?.hide()
}
),
trailingIcon = {
IconButton(
onClick = {
onAction(WeatherAction.LoadWeather(state.searchQuery))
keyboardController?.hide()
}
) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = null,
Modifier.size(24.dp)
)
}
}
)
}
Spacer(modifier = Modifier.height(32.dp))
when (val weatherResult = state.weatherResult) {
Result.Loading -> {
CircularProgressIndicator(
color = Color.White
)
}
is Result.Error -> {
val errorMessage = weatherResult.error.asUiText().asString()
Text("Error: $errorMessage")
}
is Result.Success -> {
WeatherDetails(weatherResult.data)
}
null -> {
Text(
text = "Search a City name to see Weather data.",
fontSize = 16.sp,
lineHeight = 8.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
color = Color.White
)
}
}
}
}
}
}
@Composable
fun WeatherDetails(data: WeatherResponse) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Row(
modifier = Modifier
.fillMaxWidth(),
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.Bottom
) {
Icon(
imageVector = Icons.Default.LocationOn,
contentDescription = null,
modifier = Modifier.size(40.dp)
)
Text(text = "${data.location.name}, ", fontSize = 30.sp)
Text(text = data.location.country, fontSize = 20.sp)
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "${data.current.temp_c} °C",
fontSize = 56.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
)
AsyncImage(
model = "https:${data.current.condition.icon}"
.replace("64x64", "128x128"),
//this replace makes the icon more clear from blur
//as the 64x64 was in the api image dimensions
//"icon": "//cdn.weatherapi.com/weather/64x64/night/122.png"
contentDescription = null,
modifier = Modifier.size(160.dp)
)
Text(
text = data.current.condition.text,
fontSize = 30.sp,
fontWeight = FontWeight.Medium,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(16.dp))
Card(
border = BorderStroke(
width = 1.dp,
color = Color.Black
),
elevation = CardDefaults.cardElevation(
defaultElevation = 50.dp
),
colors = CardDefaults.cardColors(
containerColor = DarkGray
)
) {
Column(
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceAround
) {
WeatherKeyValues(
key = "Humidity",
value = "${data.current.humidity}%"
)
WeatherKeyValues(
key = "Wind Speed",
value = "${data.current.wind_kph} km/h"
)
}
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceAround
) {
WeatherKeyValues(
key = "UV Index",
value = data.current.uv
)
WeatherKeyValues(
key = "Wind Direction",
value = data.current.wind_dir
)
}
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceAround
) {
WeatherKeyValues(
key = "Local Time",
value = data.location.localtime.split(" ")[1]
)
WeatherKeyValues(
key = "Local Date",
value = data.location.localtime.split(" ")[0]
)
}
}
}
}
}
@Composable
fun WeatherKeyValues(key: String, value: String) {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = value, fontSize = 24.sp, fontWeight = FontWeight.Bold)
Text(text = key, fontSize = 16.sp, fontWeight = FontWeight.Medium)
}
}
@Preview
@Composable
private fun Preview() {
RetrofitProjectTheme {
WeatherScreen(
state = WeatherState(),
onAction = {}
)
}
}
Utilities for Presentation in util package
In asUiText.kt
package com.example.project.weather.presentation.util
fun Result.Error<*, DataError>.asUiText(): UiText {
return error.asUiText()
}
fun DataError.asUiText(): UiText {
return when (this) {
DataError.Network.REQUEST_TIMEOUT -> {
UiText.StringResource(R.string.error_network_request_timeout)
}
DataError.Network.TOO_MANY_REQUESTS -> {
UiText.StringResource(R.string.error_network_too_many_requests)
}
DataError.Network.NO_INTERNET -> {
UiText.StringResource(R.string.error_network_no_internet)
}
DataError.Network.PAYLOAD_TOO_LARGE -> {
UiText.StringResource(R.string.error_network_payload_too_large)
}
DataError.Network.SERVER_ERROR -> {
UiText.StringResource(R.string.error_network_server_error)
}
DataError.Network.SERIALIZATION -> {
UiText.StringResource(R.string.error_network_serialization)
}
DataError.Network.UNAUTHORISED -> {
UiText.StringResource(R.string.unauthorised)
}
DataError.Network.UNKNOWN -> {
UiText.StringResource(R.string.error_network_unknown)
}
DataError.Local.DISK_FULL -> {
UiText.StringResource(R.string.error_local_disk_full)
}
}
}
In UiText.kt
package com.example.project.weather.presentation.util
sealed class UiText {
data class DynamicString(val value: String) : UiText()
class StringResource(
@StringRes val id: Int,
val args: Array<Any> = arrayOf()
) : UiText()
@Composable
fun asString(): String {
return when(this){
is DynamicString -> value
is StringResource -> LocalContext.current.getString(id, *args)
}
}
fun asString(context: Context): String {
return when(this){
is DynamicString -> value
is StringResource -> context.getString(id, *args)
}
}
}
Utilizing UiText and Result types ensures that technical failures (like a 401 Unauthorized or 408 Timeout) are translated into meaningful guidance for the end user.
In string.xml
<resources>
<string name="error_network_request_timeout">The request timed out</string>
<string name="error_network_too_many_requests">Too many requests</string>
<string name="error_network_no_internet">No internet connection</string>
<string name="error_network_payload_too_large">Payload too large</string>
<string name="error_network_server_error">Server error</string>
<string name="error_network_serialization">Serialization error</string>
<string name="error_network_unknown">Unknown error</string>
<string name="error_local_disk_full">Disk full</string>
<string name="unauthorised">UnAuthorised error</string>
</resources>
DI for presentation
package com.example.project.weather.di
val weatherModule = module {
singleOf(::WeatherRepositoryImpl) { bind<WeatherRepository>() }
viewModelOf(::WeatherViewModel)
}
WeatherApp application class
In app package
package com.example.project.app
class WeatherApp: Application() {
override fun onCreate() {
super.onCreate()
startKoin {
androidContext(this@WeatherApp)
modules(
networkModule,
weatherModule
)
}
}
}
In AndroidManifest.xml
<manifest>
<!--internet permission-->
<uses-permission android:name="android.permission.INTERNET" />
<!--registering the application class-->
<application
android:name=".app.WeatherApp">
</application>
</manifest>
Conclusion
Building this Weather App Reinvention has demonstrated that modern Android development is no longer just about “making things work” — it’s about predictability, scalability, and performance. By moving away from legacy patterns and embracing MVI (Model-View-Intent), we’ve created an app where the state is a single “Source of Truth,” making UI debugging a breeze. The integration of Koin using the new Constructor DSL ensures our dependency graph is resolved at lightning speed, while Retrofit and Coroutines handle our asynchronous data flow with minimal boilerplate.
If you found this guide helpful, feel free to check out the full source code on my GitHub.
Refer this for MVI boilerplate code. Refer this for Error handling class for better understanding.
메타데이터
- post_id
- 3adfc58cf33c
- slug
- reinvention-on-weather-app-project-integrating-mvi-koin-okhttp-using-kotlin-3adfc58cf33c
- url
- https://medium.com/@kabi20/reinvention-on-weather-app-project-integrating-mvi-koin-okhttp-using-kotlin-3adfc58cf33c
- canonical_url
- https://medium.com/@kabi20/reinvention-on-weather-app-project-integrating-mvi-koin-okhttp-using-kotlin-3adfc58cf33c
- author_url
- https://medium.com/@kabi20
- status
- ok
- fetched_at
- 2026-06-17 14:59:50