Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ WeatherApp is a Kotlin Multiplatform (KMP) project that provides a **5-day weath
- <b>Kotlinx Serialization </b>: Facilitates data serialization and deserialization in a format-agnostic way.
- <b>KtLint</b>: creates convenient tasks in your Gradle project that run ktlint checks or do code auto format.
- <b>Mokkery </b>: For mocking dependencies in tests.
- <b>Turbine </b>: Specialized library for testing kotlinx.coroutines Flow.
- <b>Build Konfig </b> : BuildConfig for Kotlin Multiplatform Project.
- <b>Compass </b> : Kotlin Multiplatform library location toolkit for geocoding and geolocation
- <b>Assertk </b> : assertions for kotlin tests
Expand All @@ -41,6 +42,6 @@ Other dependencies are listed in the build.gradle files.
## iOS Weather App Screenshots
> ![iOS](https://github.com/user-attachments/assets/e2019086-147d-4849-a627-313b6d0144bb)

## 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

2 changes: 2 additions & 0 deletions composeApp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ kotlin {
implementation(libs.assertk)
// Kotlin Coroutine Test
implementation(libs.kotlinx.coroutines.test)
// Turbine
implementation(libs.turbine)
}

iosMain.dependencies {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -57,6 +57,6 @@ class WeatherRepositoryImpl(
} catch (e: Exception) {
emit(NetworkResult.NetworkError(e))
}
}.flowOn(ioDispatcher)
}.flowOn(dispatcherProvider.io)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<CoroutineDispatcher> {
Dispatchers.IO
single<DispatcherProvider> {
DefaultDispatcherProvider()
}

single<WeatherRepository> {
WeatherRepositoryImpl(
httpClient = get(),
ioDispatcher = get()
httpClient = get(), dispatcherProvider = get()
)
}

single<Geolocator> {
Geolocator.mobile()
}

single<HttpClient> {
HttpClient {
install(ContentNegotiation) {
json(
Json {
ignoreUnknownKeys = true
prettyPrint = true
isLenient = true
}
ignoreUnknownKeys = true
prettyPrint = true
isLenient = true
}
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -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
Expand All @@ -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(),
Expand Down Expand Up @@ -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")
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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()
}
Original file line number Diff line number Diff line change
@@ -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<WeatherRepository>()

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 {
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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()
}
}
}
Loading
Loading