diff --git a/README.md b/README.md index fa0331d..3a394ef 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,148 @@ -This is a Kotlin Multiplatform project targeting Android, iOS. +# Weather -* `/composeApp` is for code that will be shared across your Compose Multiplatform applications. - It contains several subfolders: - - `commonMain` is for code that’s common for all targets. - - Other folders are for Kotlin code that will be compiled for only the platform indicated in the folder name. - For example, if you want to use Apple’s CoreCrypto for the iOS part of your Kotlin app, - `iosMain` would be the right folder for such calls. +Weather is a simple Kotlin Multiplatform application built with Compose Multiplatform that gets +weather information data for a specific city. -* `/iosApp` contains iOS applications. Even if you’re sharing your UI with Compose Multiplatform, - you need this entry point for your iOS app. This is also where you should add SwiftUI code for your project. +## Features +- [X] user can get today's weather data +- [X] user can get weather data for the next 7 days +- [X] user can get the last 14 days weather data -Learn more about [Kotlin Multiplatform](https://www.jetbrains.com/help/kotlin-multiplatform-dev/get-started.html)… +## Design + +### android + +| Loading | Today | More | +|:-----------------------------------------------------------------------------:|:---------------------------------------------------------------------------:|:--------------------------------------------------------------------------:| +| loading | calorie | calorie | + +### iOS + +| Loading | Today | More | +|:-------------------------------------------------------------------------:|:-----------------------------------------------------------------------:|:----------------------------------------------------------------------:| +| loading | calorie | calorie | + +## Architecture + +This project uses the MVI(Model - View - Intent) architecture based on UDF(Unidirectional Data Flow) +and Reactive programming. + +Why? + +- more clear and intentional separation of concerns +- single source of truth for our UI state which can only be mutated by intent/actions +- simpler and more direct UI testability, since we can define how the UI should look like with our + state objects + +### Packaging Structure + +- `sources` + - `remotesource` + - handles getting data from any server/remote source + - `localsource` + - handles getting cached device data +- `data` + - handles getting and mutating data from needed sources +- `domain` + - handles encasing business logic for reuse +- `ui` + - handles displaying data on device + +### Testing + +The app includes both unit and instrumented tests. +#### Sources +--- + +- Remote + +| WeatherRemoteSource | +|:----------------------------------------------------------------------------:| +| loading | + +- Local + +| WeatherLocalSource | +|:-------------------------------------------:| +|

In Progress

| + +#### Data +--- + +- Repositories + +| WeatherRepository | +|:--------------------------------------------------------------------------:| +| loading | + +- Extensions + +| DateTime | +|:-----------------------------------------------------------------------------------------:| +| loading | + +| String | +|:---------------------------------------------------------------------------------------:| +| loading | + +| Int | +|:------------------------------------------------------------------------------------:| +| loading | + +#### Domain +--- + +| GetCurrentWeatherDataUseCase | +|:------------------------------------------------------------------------------------:| +| loading | + +| GetHistoryWeatherDataUseCase | +|:------------------------------------------------------------------------------------:| +| loading | + +#### ui +--- + +- screen-model + +| WeatherDetailScreenModel | +|:---------------------------------------------------------------------------------------:| +| loading | + +| WeatherListScreenModel | +|:------------------------------------------------------------------------------------:| +| loading | + +- screens + +| WeatherDetailScreen | +|:---------------------------------------------------------------------------------------:| +| loading | + +| WeatherListScreen | +|:-------------------------------------------:| +|

In Progress

| + +## Stack + +### Language & Framework + +| Title | Description | + |:-----------------------------------------------------------------------------------|:-----------------------------------| +| [Kotlin](https://kotlinlang.org/) | `fun` programming language | +| [KMP - Kotlin Multiplatform](https://www.jetbrains.com/kotlin-multiplatform/) | cross platform framework | +| [CMP - Compose Multiplatform](https://www.jetbrains.com/lp/compose-multiplatform/) | declarative UI rendering framework | +| [Ktor](https://github.com/ktorio/ktor) | networking client framework | + +### Libraries + +| Title | Description | +|:-------------------------------------------------------------------------|:------------------| +| [Kotlinx-DateTime](https://github.com/Kotlin/kotlinx-datetime) | date/time library | +| [Kotlinx-Coroutines](https://github.com/Kotlin/kotlinx.coroutines) | async programming | +| [Kotlinx-Serialization](https://github.com/Kotlin/kotlinx.serialization) | serialization | +| [Kamel](https://github.com/Kamel-Media/Kamel) | image loading | +| [Voyager](https://github.com/adrielcafe/voyager) | navigation | + + \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ui/composables/WeatherHourItem.kt b/composeApp/src/commonMain/kotlin/ui/composables/WeatherHourItem.kt index 5a689f3..0b14633 100644 --- a/composeApp/src/commonMain/kotlin/ui/composables/WeatherHourItem.kt +++ b/composeApp/src/commonMain/kotlin/ui/composables/WeatherHourItem.kt @@ -42,7 +42,7 @@ fun WeatherHourItem( ) { Text( text = buildAnnotatedString { - append("${item.temperatureInCelsius}") + append("${item.temperatureInCelsius.toInt()}") withStyle( style = SpanStyle( fontSize = 12.sp, diff --git a/composeApp/src/commonMain/kotlin/ui/screens/detail/WeatherDetailScreenModel.kt b/composeApp/src/commonMain/kotlin/ui/screens/detail/WeatherDetailScreenModel.kt index 14cb708..b68bfba 100644 --- a/composeApp/src/commonMain/kotlin/ui/screens/detail/WeatherDetailScreenModel.kt +++ b/composeApp/src/commonMain/kotlin/ui/screens/detail/WeatherDetailScreenModel.kt @@ -25,6 +25,7 @@ class WeatherDetailScreenModel( ) : StatefulScreenModel(initial = WeatherDetailScreenState()) { fun getCurrentWeatherForecast() { + if (state.value.weatherData !is LoadState.Success) screenModelScope.launch { updateState { copy(weatherData = LoadState.Loading) } when (val result = getCurrentWeatherDataUseCase()) { diff --git a/composeApp/src/commonTest/kotlin/data/repositories/WeatherRepositoryTest.kt b/composeApp/src/commonTest/kotlin/data/repositories/WeatherRepositoryTest.kt index b6fea65..90b6957 100644 --- a/composeApp/src/commonTest/kotlin/data/repositories/WeatherRepositoryTest.kt +++ b/composeApp/src/commonTest/kotlin/data/repositories/WeatherRepositoryTest.kt @@ -29,35 +29,34 @@ import kotlin.test.assertTrue class FakeWeatherRepository : WeatherRepository { private val data: MutableStateFlow?> = MutableStateFlow(emptyList()) + private val fake = WeatherForecastData( + date = LocalDate(2024, 1, 1), + day = WeatherDayData( + maxTemperatureInCelsius = 35.0, + minTemperatureInCelsius = 24.0, + averageTemperatureInCelsius = 29.5, + averageHumidity = 24.0, + maxWindInKilometersPerHour = 40.0, + willRain = false, + chancesOfRainInPercentage = 25.0, + condition = WeatherConditionData("Sunny", "") + ), + hours = listOf( + WeatherHourData( + time = LocalDateTime(2024, 2, 3, 6, 30, 0, 0), + temperatureInCelsius = 25.0, + isDaylight = true, + condition = WeatherConditionData("Sunny", "") + ) + ) + ) fun simulateError() { data.value = null } fun simulateSuccess() { - data.value = listOf( - WeatherForecastData( - date = LocalDate(2024, 1, 1), - day = WeatherDayData( - maxTemperatureInCelsius = 35.0, - minTemperatureInCelsius = 24.0, - averageTemperatureInCelsius = 29.5, - averageHumidity = 24.0, - maxWindInKilometersPerHour = 40.0, - willRain = false, - chancesOfRainInPercentage = 25.0, - condition = WeatherConditionData("Sunny", "") - ), - hours = listOf( - WeatherHourData( - time = LocalDateTime(2024, 2, 3, 6, 30, 0, 0), - temperatureInCelsius = 25.0, - isDaylight = true, - condition = WeatherConditionData("Sunny", "") - ) - ) - ) - ) + data.value = listOf(fake, fake) } override suspend fun getCurrentWeatherData(): DataResult> { @@ -92,7 +91,7 @@ class WeatherRepositoryTest { } @Test - fun `given WeatherRepository - when fetching weather data - should return DataResult Error`() = + fun `given WeatherRepository - when fetching weather data - on failure should return DataResult Error`() = runTest { repository.simulateError() val data = repository.getCurrentWeatherData() diff --git a/composeApp/src/commonTest/kotlin/ui/screens/WeatherListScreenModelTest.kt b/composeApp/src/commonTest/kotlin/ui/screens/WeatherListScreenModelTest.kt index 944e8be..e6918ef 100644 --- a/composeApp/src/commonTest/kotlin/ui/screens/WeatherListScreenModelTest.kt +++ b/composeApp/src/commonTest/kotlin/ui/screens/WeatherListScreenModelTest.kt @@ -18,6 +18,7 @@ import ui.screens.list.WeatherListScreenModel import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertTrue /** @@ -79,7 +80,7 @@ class WeatherListScreenModelTest { @Test fun `given WeatherListScreenModel - when updating futureState - should be ListState Success`() = runTest { - weatherListScreenModel.updateFutureList(listOf()) + weatherListScreenModel.updateFutureList(listOf(data)) val data = weatherListScreenModel.state.value.futureState assertTrue { data is LoadState.Success } } @@ -87,10 +88,11 @@ class WeatherListScreenModelTest { @Test fun `given WeatherListScreenModel - when updating futureState with data - state should contain data`() = runTest { - weatherListScreenModel.updateFutureList(listOf(data)) - val data = weatherListScreenModel.state.value.futureState - val value = (data as LoadState.Success).data - assertTrue { value.isNotEmpty() } + val list = listOf(data, data) + weatherListScreenModel.updateFutureList(list) + val state = weatherListScreenModel.state.value.futureState + val value = (state as LoadState.Success).data + assertEquals(listOf(data), value) } @Test diff --git a/images/android/loading.png b/images/android/loading.png new file mode 100644 index 0000000..b9ed94c Binary files /dev/null and b/images/android/loading.png differ diff --git a/images/android/more.png b/images/android/more.png new file mode 100644 index 0000000..e3b27a6 Binary files /dev/null and b/images/android/more.png differ diff --git a/images/android/today.png b/images/android/today.png new file mode 100644 index 0000000..cc4643c Binary files /dev/null and b/images/android/today.png differ diff --git a/images/ios/loading.png b/images/ios/loading.png new file mode 100644 index 0000000..ce1a487 Binary files /dev/null and b/images/ios/loading.png differ diff --git a/images/ios/more.png b/images/ios/more.png new file mode 100644 index 0000000..4983f19 Binary files /dev/null and b/images/ios/more.png differ diff --git a/images/ios/today.png b/images/ios/today.png new file mode 100644 index 0000000..c1fccdf Binary files /dev/null and b/images/ios/today.png differ diff --git a/images/testing/domain/current.png b/images/testing/domain/current.png new file mode 100644 index 0000000..f41ec94 Binary files /dev/null and b/images/testing/domain/current.png differ diff --git a/images/testing/domain/history.png b/images/testing/domain/history.png new file mode 100644 index 0000000..6b4d999 Binary files /dev/null and b/images/testing/domain/history.png differ diff --git a/images/testing/extensions/datetime.png b/images/testing/extensions/datetime.png new file mode 100644 index 0000000..ded4542 Binary files /dev/null and b/images/testing/extensions/datetime.png differ diff --git a/images/testing/extensions/int.png b/images/testing/extensions/int.png new file mode 100644 index 0000000..fdce53f Binary files /dev/null and b/images/testing/extensions/int.png differ diff --git a/images/testing/extensions/string.png b/images/testing/extensions/string.png new file mode 100644 index 0000000..b72b445 Binary files /dev/null and b/images/testing/extensions/string.png differ diff --git a/images/testing/remote.png b/images/testing/remote.png new file mode 100644 index 0000000..54223a9 Binary files /dev/null and b/images/testing/remote.png differ diff --git a/images/testing/repo.png b/images/testing/repo.png new file mode 100644 index 0000000..90901dc Binary files /dev/null and b/images/testing/repo.png differ diff --git a/images/testing/ui/models/details.png b/images/testing/ui/models/details.png new file mode 100644 index 0000000..25b50d6 Binary files /dev/null and b/images/testing/ui/models/details.png differ diff --git a/images/testing/ui/models/list.png b/images/testing/ui/models/list.png new file mode 100644 index 0000000..8af6184 Binary files /dev/null and b/images/testing/ui/models/list.png differ diff --git a/images/testing/ui/screens/detail.png b/images/testing/ui/screens/detail.png new file mode 100644 index 0000000..9a1fd89 Binary files /dev/null and b/images/testing/ui/screens/detail.png differ diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index 66c59c0..5667b42 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -39,6 +39,30 @@ name = Frameworks; sourceTree = ""; }; + 62DBF4183AB37B668A72A8E5 /* xcschemes */ = { + isa = PBXGroup; + children = ( + ); + path = xcschemes; + sourceTree = ""; + }; + 62DBF5F15D2B2366E4BAFF9A /* mambo.xcuserdatad */ = { + isa = PBXGroup; + children = ( + 62DBF4183AB37B668A72A8E5 /* xcschemes */, + ); + path = mambo.xcuserdatad; + sourceTree = ""; + }; + 62DBF7D64596272A11ECB760 /* xcuserdata */ = { + isa = PBXGroup; + children = ( + 62DBF5F15D2B2366E4BAFF9A /* mambo.xcuserdatad */, + ); + name = xcuserdata; + path = iosApp.xcodeproj/xcuserdata; + sourceTree = ""; + }; 7555FF72242A565900829871 = { isa = PBXGroup; children = ( @@ -46,6 +70,7 @@ 7555FF7D242A565900829871 /* iosApp */, 7555FF7C242A565900829871 /* Products */, 42799AB246E5F90AF97AA0EF /* Frameworks */, + 62DBF7D64596272A11ECB760 /* xcuserdata */, ); sourceTree = ""; };