diff --git a/README.md b/README.md
index 0b137b9..faefb38 100644
--- a/README.md
+++ b/README.md
@@ -29,6 +29,7 @@ WeatherApp is a Kotlin Multiplatform (KMP) project that provides a **5-day weath
- Kotlinx Serialization : Facilitates data serialization and deserialization in a format-agnostic way.
- KtLint: creates convenient tasks in your Gradle project that run ktlint checks or do code auto format.
- Mokkery : For mocking dependencies in tests.
+- Turbine : Specialized library for testing kotlinx.coroutines Flow.
- Build Konfig : BuildConfig for Kotlin Multiplatform Project.
- Compass : Kotlin Multiplatform library location toolkit for geocoding and geolocation
- Assertk : assertions for kotlin tests
@@ -41,6 +42,6 @@ Other dependencies are listed in the build.gradle files.
## iOS Weather App Screenshots
> 
-## APK File
+## Artifacts
The app artifacts(Android & iOS) can be found from the latest successful action on the [GitHub Actions](https://github.com/KennethMathari/WeatherApp_KMP/actions) tab
diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts
index 4a02044..f30be1f 100644
--- a/composeApp/build.gradle.kts
+++ b/composeApp/build.gradle.kts
@@ -112,6 +112,8 @@ kotlin {
implementation(libs.assertk)
// Kotlin Coroutine Test
implementation(libs.kotlinx.coroutines.test)
+ // Turbine
+ implementation(libs.turbine)
}
iosMain.dependencies {
diff --git a/composeApp/src/commonMain/kotlin/co/ke/weather/multiplatform/data/repository/WeatherRepositoryImpl.kt b/composeApp/src/commonMain/kotlin/co/ke/weather/multiplatform/data/repository/WeatherRepositoryImpl.kt
index eb4e7a2..ba3882b 100644
--- a/composeApp/src/commonMain/kotlin/co/ke/weather/multiplatform/data/repository/WeatherRepositoryImpl.kt
+++ b/composeApp/src/commonMain/kotlin/co/ke/weather/multiplatform/data/repository/WeatherRepositoryImpl.kt
@@ -3,6 +3,7 @@ package co.ke.weather.multiplatform.data.repository
import co.ke.weather.multiplatform.data.model.weather.WeatherForecastDTO
import co.ke.weather.multiplatform.domain.repository.WeatherRepository
import co.ke.weather.multiplatform.utils.Constants.WEATHER_FORECAST_BASE_URL
+import co.ke.weather.multiplatform.utils.DispatcherProvider
import co.ke.weather.multiplatform.utils.NetworkResult
import io.ktor.client.HttpClient
import io.ktor.client.call.body
@@ -12,14 +13,13 @@ import io.ktor.client.plugins.ServerResponseException
import io.ktor.client.request.get
import io.ktor.http.isSuccess
import io.ktor.utils.io.errors.IOException
-import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
class WeatherRepositoryImpl(
private val httpClient: HttpClient,
- private val ioDispatcher: CoroutineDispatcher
+ private val dispatcherProvider: DispatcherProvider
) : WeatherRepository {
override fun getWeatherForecast(
@@ -57,6 +57,6 @@ class WeatherRepositoryImpl(
} catch (e: Exception) {
emit(NetworkResult.NetworkError(e))
}
- }.flowOn(ioDispatcher)
+ }.flowOn(dispatcherProvider.io)
}
}
diff --git a/composeApp/src/commonMain/kotlin/co/ke/weather/multiplatform/di/SharedModule.kt b/composeApp/src/commonMain/kotlin/co/ke/weather/multiplatform/di/SharedModule.kt
index 0301c3d..9a475c2 100644
--- a/composeApp/src/commonMain/kotlin/co/ke/weather/multiplatform/di/SharedModule.kt
+++ b/composeApp/src/commonMain/kotlin/co/ke/weather/multiplatform/di/SharedModule.kt
@@ -3,6 +3,10 @@ package co.ke.weather.multiplatform.di
import co.ke.weather.multiplatform.data.repository.WeatherRepositoryImpl
import co.ke.weather.multiplatform.domain.repository.WeatherRepository
import co.ke.weather.multiplatform.ui.viewmodel.WeatherViewModel
+import co.ke.weather.multiplatform.utils.DefaultDispatcherProvider
+import co.ke.weather.multiplatform.utils.DispatcherProvider
+import dev.jordond.compass.geolocation.Geolocator
+import dev.jordond.compass.geolocation.mobile
import io.ktor.client.HttpClient
import io.ktor.client.plugins.HttpRequestRetry
import io.ktor.client.plugins.HttpTimeout
@@ -13,35 +17,35 @@ import io.ktor.client.plugins.logging.Logger
import io.ktor.client.plugins.logging.Logging
import io.ktor.http.HttpHeaders
import io.ktor.serialization.kotlinx.json.json
-import kotlinx.coroutines.CoroutineDispatcher
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.IO
import kotlinx.serialization.json.Json
import org.koin.compose.viewmodel.dsl.viewModelOf
import org.koin.dsl.module
val sharedModule = module {
- single {
- Dispatchers.IO
+ single {
+ DefaultDispatcherProvider()
}
single {
WeatherRepositoryImpl(
- httpClient = get(),
- ioDispatcher = get()
+ httpClient = get(), dispatcherProvider = get()
)
}
+ single {
+ Geolocator.mobile()
+ }
+
single {
HttpClient {
install(ContentNegotiation) {
json(
Json {
- ignoreUnknownKeys = true
- prettyPrint = true
- isLenient = true
- }
+ ignoreUnknownKeys = true
+ prettyPrint = true
+ isLenient = true
+ }
)
}
diff --git a/composeApp/src/commonMain/kotlin/co/ke/weather/multiplatform/ui/viewmodel/WeatherViewModel.kt b/composeApp/src/commonMain/kotlin/co/ke/weather/multiplatform/ui/viewmodel/WeatherViewModel.kt
index 60697b7..d9acad0 100644
--- a/composeApp/src/commonMain/kotlin/co/ke/weather/multiplatform/ui/viewmodel/WeatherViewModel.kt
+++ b/composeApp/src/commonMain/kotlin/co/ke/weather/multiplatform/ui/viewmodel/WeatherViewModel.kt
@@ -6,18 +6,20 @@ import co.ke.weather.multiplatform.BuildKonfig
import co.ke.weather.multiplatform.data.model.weather.WeatherForecastDTO
import co.ke.weather.multiplatform.domain.repository.WeatherRepository
import co.ke.weather.multiplatform.ui.state.WeatherState
+import co.ke.weather.multiplatform.utils.DispatcherProvider
import co.ke.weather.multiplatform.utils.NetworkResult
import dev.jordond.compass.Priority
import dev.jordond.compass.geolocation.Geolocator
import dev.jordond.compass.geolocation.GeolocatorResult
-import dev.jordond.compass.geolocation.mobile
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
class WeatherViewModel(
- private val weatherRepository: WeatherRepository
+ private val weatherRepository: WeatherRepository,
+ private val dispatcherProvider: DispatcherProvider,
+ private val geolocator: Geolocator
) : ViewModel() {
private val _weatherState = MutableStateFlow(WeatherState())
@@ -31,21 +33,19 @@ class WeatherViewModel(
fun checkApiKey(openWeatherApiKey: String) {
if (openWeatherApiKey.isEmpty() || openWeatherApiKey == "DEFAULT_API_KEY") {
- _weatherState.value = WeatherState(
- isLoading = false, weatherForecast = null, errorMessage = "API Key is not set!"
- )
+ updateErrorMessage(errorMessage = "API Key is not set!")
} else {
fetchUserLocation()
}
}
- private fun fetchUserLocation() {
- viewModelScope.launch {
+ fun fetchUserLocation() {
+ viewModelScope.launch(dispatcherProvider.main) {
_weatherState.value = WeatherState(
isLoading = true, weatherForecast = null, errorMessage = null
)
- when (val locationResult = Geolocator.mobile().current(Priority.HighAccuracy)) {
+ when (val locationResult = geolocator.current(Priority.HighAccuracy)) {
is GeolocatorResult.Success -> {
val latitude = locationResult.data.coordinates.latitude
val longitude = locationResult.data.coordinates.longitude
@@ -64,16 +64,14 @@ class WeatherViewModel(
else -> "An unknown error occurred while fetching location."
}
- _weatherState.value = WeatherState(
- isLoading = false, weatherForecast = null, errorMessage = errorMessage
- )
+ updateErrorMessage(errorMessage)
}
}
}
}
- private fun getWeatherForecast(latitude: Double, longitude: Double) {
- viewModelScope.launch {
+ fun getWeatherForecast(latitude: Double, longitude: Double) {
+ viewModelScope.launch(dispatcherProvider.main) {
weatherRepository.getWeatherForecast(
latitude = latitude.toString(),
longitude = longitude.toString(),
@@ -105,15 +103,15 @@ class WeatherViewModel(
}
}
- private fun updateErrorMessage(errorMessage: String) {
- viewModelScope.launch {
+ fun updateErrorMessage(errorMessage: String) {
+ viewModelScope.launch(dispatcherProvider.main) {
_weatherState.value = WeatherState(
isLoading = false, weatherForecast = null, errorMessage = errorMessage
)
}
}
- private fun filterDailyWeather(forecast: WeatherForecastDTO): WeatherForecastDTO {
+ fun filterDailyWeather(forecast: WeatherForecastDTO): WeatherForecastDTO {
val filteredList = forecast.list.filter { weatherItem ->
weatherItem.dtTxt.contains("12:00:00")
}
diff --git a/composeApp/src/commonMain/kotlin/co/ke/weather/multiplatform/utils/DispatcherProvider.kt b/composeApp/src/commonMain/kotlin/co/ke/weather/multiplatform/utils/DispatcherProvider.kt
new file mode 100644
index 0000000..fa1bba7
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/co/ke/weather/multiplatform/utils/DispatcherProvider.kt
@@ -0,0 +1,26 @@
+package co.ke.weather.multiplatform.utils
+
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.IO
+
+interface DispatcherProvider {
+
+ val main: CoroutineDispatcher
+
+ val io: CoroutineDispatcher
+
+ val default: CoroutineDispatcher
+}
+
+class DefaultDispatcherProvider : DispatcherProvider {
+
+ override val main: CoroutineDispatcher
+ get() = Dispatchers.Main
+
+ override val io: CoroutineDispatcher
+ get() = Dispatchers.IO
+
+ override val default: CoroutineDispatcher
+ get() = Dispatchers.Default
+}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/co/ke/weather/multiplatform/utils/GeolocatorProvider.kt b/composeApp/src/commonMain/kotlin/co/ke/weather/multiplatform/utils/GeolocatorProvider.kt
new file mode 100644
index 0000000..ce4afbd
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/co/ke/weather/multiplatform/utils/GeolocatorProvider.kt
@@ -0,0 +1,13 @@
+package co.ke.weather.multiplatform.utils
+
+import dev.jordond.compass.geolocation.Geolocator
+import dev.jordond.compass.geolocation.mobile
+
+interface GeolocatorProvider {
+ val mobile: Geolocator
+}
+
+class DefaultGeolocatorProvider : GeolocatorProvider {
+
+ override val mobile: Geolocator = Geolocator.mobile()
+}
\ No newline at end of file
diff --git a/composeApp/src/commonTest/kotlin/co/ke/weather/multiplatform/data/repository/WeatherRepositoryTest.kt b/composeApp/src/commonTest/kotlin/co/ke/weather/multiplatform/data/repository/WeatherRepositoryTest.kt
index c224c72..6c3042a 100644
--- a/composeApp/src/commonTest/kotlin/co/ke/weather/multiplatform/data/repository/WeatherRepositoryTest.kt
+++ b/composeApp/src/commonTest/kotlin/co/ke/weather/multiplatform/data/repository/WeatherRepositoryTest.kt
@@ -1,44 +1,25 @@
+import app.cash.turbine.test
import assertk.assertThat
import assertk.assertions.isEqualTo
-import co.ke.weather.multiplatform.data.model.weather.City
-import co.ke.weather.multiplatform.data.model.weather.Coord
-import co.ke.weather.multiplatform.data.model.weather.WeatherForecastDTO
import co.ke.weather.multiplatform.domain.repository.WeatherRepository
import co.ke.weather.multiplatform.utils.NetworkResult
+import co.ke.weather.multiplatform.utils.TestData.apiKey
+import co.ke.weather.multiplatform.utils.TestData.latitude
+import co.ke.weather.multiplatform.utils.TestData.longitude
+import co.ke.weather.multiplatform.utils.TestData.weatherForecastDTO
+import co.ke.weather.multiplatform.utils.TestData.error
import dev.mokkery.answering.returns
import dev.mokkery.everySuspend
import dev.mokkery.mock
-import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
+import kotlin.test.assertEquals
class WeatherRepositoryTest {
private val weatherRepository = mock()
- private val weatherForecastDTO = WeatherForecastDTO(
- city = City(
- coord = Coord(
- lat = -1.365646, lon = 36.45675
- ),
- country = "Kenya",
- id = 1,
- name = "Kiambu",
- population = 3455566,
- sunrise = 54633636,
- sunset = 456456,
- timezone = 554365354
- ),
- cnt = 3555, cod = "null", list = emptyList(), message = 200
- )
-
- private val error = Exception("Error")
-
- private val latitude = "-1.365646"
- private val longitude = "36.45675"
- private val apiKey = "ksnckwnckwnc"
-
@Test
fun getWeatherForecastReturnsSuccess() = runTest {
everySuspend {
@@ -47,9 +28,10 @@ class WeatherRepositoryTest {
)
} returns flowOf(NetworkResult.Success(weatherForecastDTO))
- val result = weatherRepository.getWeatherForecast(latitude, longitude, apiKey).first()
-
- assertThat(result).isEqualTo(NetworkResult.Success(weatherForecastDTO))
+ weatherRepository.getWeatherForecast(latitude, longitude, apiKey).test {
+ assertEquals(NetworkResult.Success(weatherForecastDTO), awaitItem())
+ cancelAndIgnoreRemainingEvents()
+ }
}
@Test
@@ -60,9 +42,10 @@ class WeatherRepositoryTest {
)
} returns flowOf(NetworkResult.ClientError(error))
- val result = weatherRepository.getWeatherForecast(latitude, longitude, apiKey).first()
-
- assertThat(result).isEqualTo(NetworkResult.ClientError(error))
+ weatherRepository.getWeatherForecast(latitude, longitude, apiKey).test {
+ assertThat(awaitItem()).isEqualTo(NetworkResult.ClientError(error))
+ cancelAndIgnoreRemainingEvents()
+ }
}
@Test
@@ -73,9 +56,10 @@ class WeatherRepositoryTest {
)
} returns flowOf(NetworkResult.NetworkError(error))
- val result = weatherRepository.getWeatherForecast(latitude, longitude, apiKey).first()
-
- assertThat(result).isEqualTo(NetworkResult.NetworkError(error))
+ weatherRepository.getWeatherForecast(latitude, longitude, apiKey).test {
+ assertThat(awaitItem()).isEqualTo(NetworkResult.NetworkError(error))
+ cancelAndIgnoreRemainingEvents()
+ }
}
@Test
@@ -86,8 +70,9 @@ class WeatherRepositoryTest {
)
} returns flowOf(NetworkResult.ServerError(error))
- val result = weatherRepository.getWeatherForecast(latitude, longitude, apiKey).first()
-
- assertThat(result).isEqualTo(NetworkResult.ServerError(error))
+ weatherRepository.getWeatherForecast(latitude, longitude, apiKey).test {
+ assertThat(awaitItem()).isEqualTo(NetworkResult.ServerError(error))
+ cancelAndIgnoreRemainingEvents()
+ }
}
}
\ No newline at end of file
diff --git a/composeApp/src/commonTest/kotlin/co/ke/weather/multiplatform/ui/WeatherViewModelTest.kt b/composeApp/src/commonTest/kotlin/co/ke/weather/multiplatform/ui/WeatherViewModelTest.kt
new file mode 100644
index 0000000..72f06be
--- /dev/null
+++ b/composeApp/src/commonTest/kotlin/co/ke/weather/multiplatform/ui/WeatherViewModelTest.kt
@@ -0,0 +1,99 @@
+package co.ke.weather.multiplatform.ui
+
+import app.cash.turbine.test
+import co.ke.weather.multiplatform.domain.repository.WeatherRepository
+import co.ke.weather.multiplatform.ui.viewmodel.WeatherViewModel
+import co.ke.weather.multiplatform.utils.DispatcherProvider
+import co.ke.weather.multiplatform.utils.TestData.weatherForecastDTO
+import co.ke.weather.multiplatform.utils.TestDispatcherProvider
+import dev.jordond.compass.Priority
+import dev.jordond.compass.geolocation.Geolocator
+import dev.jordond.compass.geolocation.GeolocatorResult
+import dev.jordond.compass.geolocation.LocationRequest
+import dev.jordond.compass.geolocation.TrackingStatus
+import dev.mokkery.mock
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.emptyFlow
+import kotlinx.coroutines.test.runTest
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+@ExperimentalCoroutinesApi
+class WeatherViewModelTest {
+ private val weatherRepository = mock()
+
+ private val testDispatcherProvider: DispatcherProvider = TestDispatcherProvider()
+ private lateinit var weatherViewModel: WeatherViewModel
+
+ private val fakeGeolocator = object : Geolocator {
+
+ override val trackingStatus: Flow
+ get() = emptyFlow()
+
+ override suspend fun current(priority: Priority): GeolocatorResult {
+ return GeolocatorResult.NotFound // Simulate not found error
+ }
+
+ override suspend fun isAvailable(): Boolean {
+ return true
+ }
+
+ override fun stopTracking() {
+ }
+
+ override fun track(request: LocationRequest): Flow {
+ return emptyFlow()
+ }
+ }
+
+ @BeforeTest
+ fun setUp() {
+ weatherViewModel =
+ WeatherViewModel(weatherRepository, testDispatcherProvider, fakeGeolocator)
+ }
+
+ @Test
+ fun `checkApiKey should set error message when API key is not set`() = runTest {
+ val invalidApiKey = "DEFAULT_API_KEY"
+
+ weatherViewModel.checkApiKey(invalidApiKey)
+
+ weatherViewModel.weatherState.test {
+ val state = awaitItem()
+ assertEquals("API Key is not set!", state.errorMessage)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `fetchUserLocation should set error message on geolocation error`() = runTest {
+ weatherViewModel.fetchUserLocation()
+
+ weatherViewModel.weatherState.test {
+ val state = awaitItem()
+ assertTrue(state.errorMessage!!.contains("Location not found."))
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `updateErrorMessage should update state after invocation`() = runTest {
+ weatherViewModel.updateErrorMessage("error")
+
+ weatherViewModel.weatherState.test {
+ val state = awaitItem()
+ assertTrue(state.errorMessage!!.contains("error"))
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `filterDailyWeather returns WeatherForecastDTO`() = run {
+ val result = weatherViewModel.filterDailyWeather(weatherForecastDTO)
+
+ assertEquals(weatherForecastDTO, result)
+ }
+}
\ No newline at end of file
diff --git a/composeApp/src/commonTest/kotlin/co/ke/weather/multiplatform/utils/TestData.kt b/composeApp/src/commonTest/kotlin/co/ke/weather/multiplatform/utils/TestData.kt
new file mode 100644
index 0000000..a8dad54
--- /dev/null
+++ b/composeApp/src/commonTest/kotlin/co/ke/weather/multiplatform/utils/TestData.kt
@@ -0,0 +1,30 @@
+package co.ke.weather.multiplatform.utils
+
+import co.ke.weather.multiplatform.data.model.weather.City
+import co.ke.weather.multiplatform.data.model.weather.Coord
+import co.ke.weather.multiplatform.data.model.weather.WeatherForecastDTO
+
+object TestData {
+
+ val weatherForecastDTO = WeatherForecastDTO(
+ city = City(
+ coord = Coord(
+ lat = -1.365646, lon = 36.45675
+ ),
+ country = "Kenya",
+ id = 1,
+ name = "Kiambu",
+ population = 3455566,
+ sunrise = 54633636,
+ sunset = 456456,
+ timezone = 554365354
+ ),
+ cnt = 3555, cod = "null", list = emptyList(), message = 200
+ )
+
+ val error = Exception("Error")
+
+ val latitude = "-1.365646"
+ val longitude = "36.45675"
+ val apiKey = "ksnckwnckwnc"
+}
\ No newline at end of file
diff --git a/composeApp/src/commonTest/kotlin/co/ke/weather/multiplatform/utils/TestDispatcherProvider.kt b/composeApp/src/commonTest/kotlin/co/ke/weather/multiplatform/utils/TestDispatcherProvider.kt
new file mode 100644
index 0000000..c36d4c7
--- /dev/null
+++ b/composeApp/src/commonTest/kotlin/co/ke/weather/multiplatform/utils/TestDispatcherProvider.kt
@@ -0,0 +1,20 @@
+package co.ke.weather.multiplatform.utils
+
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+
+@ExperimentalCoroutinesApi
+class TestDispatcherProvider : DispatcherProvider {
+
+ private val testDispatcher = UnconfinedTestDispatcher()
+
+ override val main: CoroutineDispatcher
+ get() = testDispatcher
+
+ override val io: CoroutineDispatcher
+ get() = testDispatcher
+
+ override val default: CoroutineDispatcher
+ get() = testDispatcher
+}
\ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 670c209..67d34ea 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -28,6 +28,7 @@ compass = "1.2.2"
ktor = "2.3.12"
assertk = "0.28.1"
kotlinx-coroutines-test = "1.8.1"
+turbine = "1.2.0"
@@ -67,8 +68,10 @@ ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "kto
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor"}
+ktor-client-mock = {module="io.ktor:ktor-client-mock", version.ref = "ktor"}
assertk = { module = "com.willowtreeapps.assertk:assertk", version.ref = "assertk" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines-test" }
+turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" }