diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 0000000..d358945
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,19 @@
+## ✏️ TODO
+*(List your doing in your pull request here)*
+
+- Feature one.
+- Feature two.
+
+## 📘 Libraries
+*(List your libraries that you add or modifier in your pull request)*
+
+- Jetpack Compose.
+- Jetpack DataStore.
+
+## 🔔 Notes
+*(You must provides some information such as how to testing your pull request because this is important for your reviewer to check your pull request)*
+
+- Delete the project because it's suck. :smile:
+
+## 📷 Screenshots
+*(Add some screenshots in here)*
diff --git a/.idea/deploymentTargetDropDown.xml b/.idea/deploymentTargetDropDown.xml
index 737f496..dde95e4 100644
--- a/.idea/deploymentTargetDropDown.xml
+++ b/.idea/deploymentTargetDropDown.xml
@@ -12,6 +12,6 @@
-
+
\ No newline at end of file
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
index 8632f65..20ad4d6 100644
--- a/.idea/gradle.xml
+++ b/.idea/gradle.xml
@@ -7,6 +7,7 @@
+
diff --git a/README.md b/README.md
index 542dd69..4d772b6 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +13,6 @@ I want to say thanks for those people to help me to make this app.
## :hammer: How to build app
-- Using android 13 and above to build app.
- Using the newest version of Android Studio Canary.
- You must add `BASE_URL`, `API_KEY` and `MAPS_API_KEY` inside `local.properties` to build and run Weapose app, like the code below:
@@ -51,6 +50,12 @@ Weapose is built according to the Clean Architecture model combined with the MVV
- Guild to app architecture by Google Android.
- Clean architectur by Uncle Bob.
+## :mag_right: Unit test
+
+- Using [MockK](https://mockk.io/) to write unit test.
+- Using [Kotlin Reflection](https://kotlinlang.org/docs/reflection.html) to access the private method, private property, etc.
+- Using [Kotlin Kover](https://github.com/Kotlin/kotlinx-kover) to generate the test coverage. To generate, you just run command ` ./gradlew koverHtmlReport`.
+
## :tram: Data flow
Weapose is supported by `Flow` and `suspend` for data stream flow in app.
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index be80eca..fc076ca 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -6,22 +6,48 @@ plugins {
kotlin("kapt")
id("dagger.hilt.android.plugin")
id("com.google.android.libraries.mapsplatform.secrets-gradle-plugin")
+ id("org.jetbrains.kotlinx.kover") version "0.5.0"
}
val properties = Properties().apply {
load(project.rootProject.file("local.properties").inputStream())
}
+project.afterEvaluate {
+ tasks.koverHtmlReport {
+ group = "kover"
+ description = ""
+ dependsOn("testDebugUnitTest")
+
+ isEnabled = true
+ htmlReportDir.set(layout.buildDirectory.dir("kover_report/html_result"))
+ includes = listOf("com.minhdtm.example.weapose.*")
+ excludes = listOf(
+ "*Screen*",
+ "*_Factory*",
+ "*_HiltModules*",
+ "*di*",
+ "*_Impl*",
+ "*BuildConfig*",
+ "*Activity*",
+ "*App*",
+ "*Drawer*",
+ "*Graph*",
+ "*.theme*",
+ )
+ }
+}
+
android {
namespace = "com.minhdtm.example.weapose"
- compileSdkPreview = "Tiramisu"
+ compileSdk = 33
defaultConfig {
applicationId = "com.minhdtm.example.weapose"
minSdk = 21
- targetSdkPreview = "Tiramisu"
+ targetSdk = 33
versionCode = 1
- versionName = "1.0.1"
+ versionName = "1.0.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -39,6 +65,12 @@ android {
}
}
+ testOptions {
+ unitTests {
+ isIncludeAndroidResources = true
+ }
+ }
+
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
@@ -48,6 +80,7 @@ android {
jvmTarget = "11"
freeCompilerArgs = freeCompilerArgs.toMutableList().apply {
add("-opt-in=kotlin.RequiresOptIn")
+ add("-Xuse-experimental=androidx.compose.ui.text.ExperimentalTextApi")
}
}
@@ -67,13 +100,13 @@ android {
}
dependencies {
- implementation("androidx.core:core-ktx:1.7.0")
- implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.4.1")
- implementation("androidx.activity:activity-compose:1.4.0")
- implementation("androidx.compose.ui:ui:1.1.1")
- implementation("androidx.compose.ui:ui-tooling-preview:1.1.1")
- implementation("androidx.compose.material3:material3:1.0.0-alpha12")
- implementation("androidx.compose.material:material:1.2.0-beta03")
+ implementation("androidx.core:core-ktx:1.8.0")
+ implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.5.0")
+ implementation("androidx.activity:activity-compose:1.5.0")
+ implementation("androidx.compose.ui:ui:1.3.0-alpha01")
+ implementation("androidx.compose.ui:ui-tooling-preview:1.3.0-alpha01")
+ implementation("androidx.compose.material3:material3:1.0.0-alpha14")
+ implementation("androidx.compose.material:material:1.3.0-alpha01")
// Google accompanist
implementation("com.google.accompanist:accompanist-navigation-animation:0.24.9-beta")
@@ -83,7 +116,7 @@ dependencies {
implementation("com.google.accompanist:accompanist-flowlayout:0.24.9-beta")
// Google play services
- implementation("com.google.android.gms:play-services-location:19.0.1")
+ implementation("com.google.android.gms:play-services-location:20.0.0")
implementation("com.google.android.gms:play-services-maps:18.0.2")
implementation("com.google.android.libraries.places:places:2.6.0")
implementation("com.google.maps.android:maps-compose:2.1.1")
@@ -112,12 +145,14 @@ dependencies {
kapt("com.google.dagger:hilt-android-compiler:2.40")
// Navigation
- implementation("androidx.navigation:navigation-compose:2.4.2")
+ implementation("androidx.navigation:navigation-compose:2.5.0")
// ViewModel
- implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.0-rc01")
- implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.5.0-rc01")
+ implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.0-alpha01")
+ implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.0-alpha01")
+ // LiveData
+ implementation("androidx.compose.runtime:runtime-livedata:1.3.0-alpha01")
// Gson
implementation("com.google.code.gson:gson:2.9.0")
@@ -127,13 +162,20 @@ dependencies {
// DataStore
implementation("androidx.datastore:datastore-preferences:1.0.0")
+ // Kotlin reflect
+ implementation("org.jetbrains.kotlin:kotlin-reflect:1.7.0")
+
testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.1")
+ // MockK
+ testImplementation("io.mockk:mockk:1.12.4")
+ testImplementation("io.mockk:mockk-agent-jvm:1.12.4")
+
androidTestImplementation("androidx.test.ext:junit:1.1.3")
androidTestImplementation("androidx.test.espresso:espresso-core:3.4.0")
androidTestImplementation("androidx.compose.ui:ui-test-junit4:1.1.1")
- debugImplementation("androidx.compose.ui:ui-tooling:1.1.1")
- debugImplementation("androidx.compose.ui:ui-test-manifest:1.1.1")
+ debugImplementation("androidx.compose.ui:ui-tooling:1.3.0-alpha01")
+ debugImplementation("androidx.compose.ui:ui-test-manifest:1.3.0-alpha01")
}
diff --git a/app/src/main/java/com/minhdtm/example/weapose/data/model/CurrentWeather.kt b/app/src/main/java/com/minhdtm/example/weapose/data/model/CurrentWeather.kt
index dae85fd..6d44d1b 100644
--- a/app/src/main/java/com/minhdtm/example/weapose/data/model/CurrentWeather.kt
+++ b/app/src/main/java/com/minhdtm/example/weapose/data/model/CurrentWeather.kt
@@ -5,15 +5,15 @@ import com.google.gson.annotations.SerializedName
data class CurrentWeather(
@SerializedName("id") val id: Int? = 0,
@SerializedName("name") val name: String? = "",
- @SerializedName("cod") val cod: Int,
- @SerializedName("coord") val coord: Coord,
- @SerializedName("weather") val weatherItems: List,
+ @SerializedName("cod") val cod: Int? = 0,
+ @SerializedName("coord") val coord: Coord? = null,
+ @SerializedName("weather") val weatherItems: List? = emptyList(),
@SerializedName("base") val base: String? = "",
- @SerializedName("main") val main: Main,
- @SerializedName("visibility") val visibility: Int,
- @SerializedName("wind") val wind: Wind,
- @SerializedName("clouds") val clouds: Cloud,
- @SerializedName("dt") val dt: Long,
- @SerializedName("sys") val sys: Sys,
- @SerializedName("timezone") val timezone: Int
+ @SerializedName("main") val main: Main? = null,
+ @SerializedName("visibility") val visibility: Int? = 0,
+ @SerializedName("wind") val wind: Wind? = null,
+ @SerializedName("clouds") val clouds: Cloud? = null,
+ @SerializedName("dt") val dt: Long? = 0L,
+ @SerializedName("sys") val sys: Sys? = null,
+ @SerializedName("timezone") val timezone: Int? = null
) : Model()
diff --git a/app/src/main/java/com/minhdtm/example/weapose/data/repositories/LocationRepositoryImpl.kt b/app/src/main/java/com/minhdtm/example/weapose/data/repositories/LocationRepositoryImpl.kt
index 58bb884..aa378cd 100644
--- a/app/src/main/java/com/minhdtm/example/weapose/data/repositories/LocationRepositoryImpl.kt
+++ b/app/src/main/java/com/minhdtm/example/weapose/data/repositories/LocationRepositoryImpl.kt
@@ -5,9 +5,8 @@ import android.content.Context
import android.location.Address
import android.location.Geocoder
import android.os.Build
-import androidx.compose.ui.text.intl.Locale
-import com.google.android.gms.location.LocationRequest
import com.google.android.gms.location.LocationServices
+import com.google.android.gms.location.Priority
import com.google.android.gms.maps.model.LatLng
import com.google.android.gms.tasks.CancellationTokenSource
import com.google.android.libraries.places.api.model.AutocompletePrediction
@@ -25,7 +24,6 @@ import kotlinx.coroutines.flow.flatMapConcat
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.suspendCancellableCoroutine
import timber.log.Timber
-import java.util.*
import javax.inject.Inject
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
@@ -45,7 +43,7 @@ class LocationRepositoryImpl @Inject constructor(
val cancellationTokenSource = CancellationTokenSource()
fusedLocationProviderClient.getCurrentLocation(
- LocationRequest.PRIORITY_HIGH_ACCURACY,
+ Priority.PRIORITY_HIGH_ACCURACY,
cancellationTokenSource.token,
).addOnSuccessListener { location ->
if (location != null) {
diff --git a/app/src/main/java/com/minhdtm/example/weapose/presentation/base/Event.kt b/app/src/main/java/com/minhdtm/example/weapose/presentation/base/Event.kt
deleted file mode 100644
index 3a49e96..0000000
--- a/app/src/main/java/com/minhdtm/example/weapose/presentation/base/Event.kt
+++ /dev/null
@@ -1,3 +0,0 @@
-package com.minhdtm.example.weapose.presentation.base
-
-open class Event
diff --git a/app/src/main/java/com/minhdtm/example/weapose/presentation/model/CurrentWeatherViewData.kt b/app/src/main/java/com/minhdtm/example/weapose/presentation/model/CurrentWeatherViewData.kt
index 2eed328..2ab3ff3 100644
--- a/app/src/main/java/com/minhdtm/example/weapose/presentation/model/CurrentWeatherViewData.kt
+++ b/app/src/main/java/com/minhdtm/example/weapose/presentation/model/CurrentWeatherViewData.kt
@@ -2,7 +2,6 @@ package com.minhdtm.example.weapose.presentation.model
import androidx.compose.ui.text.capitalize
import androidx.compose.ui.text.intl.Locale
-import androidx.compose.ui.text.toUpperCase
import com.minhdtm.example.weapose.data.model.CurrentWeather
import com.minhdtm.example.weapose.presentation.utils.Constants
import com.minhdtm.example.weapose.presentation.utils.toBackground
@@ -28,14 +27,14 @@ class CurrentWeatherMapper @Inject constructor() : DataModelMapper
- when (event) {
- is CurrentWeatherEvent.CheckPermission -> {
- when {
- locationPermissionState.allPermissionsGranted -> {
- viewModel.getCurrentLocation()
- }
- locationPermissionState.shouldShowRationale -> {
- viewModel.permissionIsNotGranted()
- }
- else -> {
- locationPermissionState.launchMultiplePermissionRequest()
- }
+ LaunchedEffect(state) {
+ val navigateToSearch = state.navigateSearch
+ val requestPermission = state.isRequestPermission
+
+ when {
+ requestPermission -> {
+ when {
+ locationPermissionState.allPermissionsGranted -> {
+ viewModel.getCurrentLocation()
+ }
+ locationPermissionState.shouldShowRationale -> {
+ viewModel.permissionIsNotGranted()
+ }
+ else -> {
+ locationPermissionState.launchMultiplePermissionRequest()
}
}
- is CurrentWeatherEvent.NavigateToSearchByMap -> {
- appState.navigateToSearchByText(Screen.CurrentWeather, event.latLng)
- }
}
+ navigateToSearch != null -> {
+ appState.navigateToSearchByText(Screen.CurrentWeather, navigateToSearch)
+ }
+ else -> return@LaunchedEffect
}
+ viewModel.cleanEvent()
}
CurrentWeatherScreen(
diff --git a/app/src/main/java/com/minhdtm/example/weapose/presentation/ui/home/CurrentWeatherViewModel.kt b/app/src/main/java/com/minhdtm/example/weapose/presentation/ui/home/CurrentWeatherViewModel.kt
index 373d6d1..5435a36 100644
--- a/app/src/main/java/com/minhdtm/example/weapose/presentation/ui/home/CurrentWeatherViewModel.kt
+++ b/app/src/main/java/com/minhdtm/example/weapose/presentation/ui/home/CurrentWeatherViewModel.kt
@@ -2,7 +2,6 @@ package com.minhdtm.example.weapose.presentation.ui.home
import android.annotation.SuppressLint
import android.content.Context
-import androidx.lifecycle.viewModelScope
import com.google.android.gms.maps.model.LatLng
import com.minhdtm.example.weapose.R
import com.minhdtm.example.weapose.domain.enums.ActionType
@@ -13,7 +12,6 @@ import com.minhdtm.example.weapose.domain.usecase.GetCurrentWeatherUseCase
import com.minhdtm.example.weapose.domain.usecase.GetHourWeatherUseCase
import com.minhdtm.example.weapose.domain.usecase.GetLocationFromTextUseCase
import com.minhdtm.example.weapose.presentation.base.BaseViewModel
-import com.minhdtm.example.weapose.presentation.base.Event
import com.minhdtm.example.weapose.presentation.base.ViewState
import com.minhdtm.example.weapose.presentation.model.CurrentWeatherMapper
import com.minhdtm.example.weapose.presentation.model.CurrentWeatherViewData
@@ -22,9 +20,10 @@ import com.minhdtm.example.weapose.presentation.model.HourWeatherViewData
import com.minhdtm.example.weapose.presentation.utils.Constants
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
-import kotlinx.coroutines.channels.Channel
-import kotlinx.coroutines.flow.*
-import kotlinx.coroutines.launch
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.flow.zip
import javax.inject.Inject
@SuppressLint("StaticFieldLeak")
@@ -41,14 +40,11 @@ class CurrentWeatherViewModel @Inject constructor(
private val _state = MutableStateFlow(CurrentWeatherViewState())
val state: StateFlow = _state
- private val _event = Channel(Channel.BUFFERED)
- val event = _event.receiveAsFlow()
-
private var currentLocation = Constants.Default.LAT_LNG_DEFAULT
init {
- viewModelScope.launch {
- _event.send(CurrentWeatherEvent.CheckPermission)
+ _state.update {
+ it.copy(isRequestPermission = true)
}
}
@@ -121,8 +117,8 @@ class CurrentWeatherViewModel @Inject constructor(
}
fun navigateToSearchByMap() {
- callApi {
- _event.send(CurrentWeatherEvent.NavigateToSearchByMap(latLng = currentLocation))
+ _state.update {
+ it.copy(navigateSearch = currentLocation)
}
}
@@ -137,9 +133,12 @@ class CurrentWeatherViewModel @Inject constructor(
getCurrentWeather(currentLocation)
}
- private fun showLoading() {
+ fun cleanEvent() {
_state.update {
- it.copy(isLoading = true)
+ it.copy(
+ isRequestPermission = false,
+ navigateSearch = null,
+ )
}
}
@@ -165,6 +164,12 @@ class CurrentWeatherViewModel @Inject constructor(
it.copy(isLoading = false, error = null)
}
}
+
+ private fun showLoading() {
+ _state.update {
+ it.copy(isLoading = true)
+ }
+ }
}
data class CurrentWeatherViewState(
@@ -173,10 +178,6 @@ data class CurrentWeatherViewState(
val isRefresh: Boolean = false,
val currentWeather: CurrentWeatherViewData? = null,
val listHourlyWeatherToday: List = emptyList(),
+ val navigateSearch: LatLng? = null,
+ val isRequestPermission: Boolean = false,
) : ViewState(isLoading, error)
-
-sealed class CurrentWeatherEvent : Event() {
- object CheckPermission : CurrentWeatherEvent()
-
- data class NavigateToSearchByMap(val latLng: LatLng) : CurrentWeatherEvent()
-}
diff --git a/app/src/main/java/com/minhdtm/example/weapose/presentation/ui/search/map/SearchByMapScreen.kt b/app/src/main/java/com/minhdtm/example/weapose/presentation/ui/search/map/SearchByMapScreen.kt
index 64058e4..e777762 100644
--- a/app/src/main/java/com/minhdtm/example/weapose/presentation/ui/search/map/SearchByMapScreen.kt
+++ b/app/src/main/java/com/minhdtm/example/weapose/presentation/ui/search/map/SearchByMapScreen.kt
@@ -31,7 +31,6 @@ import com.minhdtm.example.weapose.presentation.component.WeatherScaffold
import com.minhdtm.example.weapose.presentation.theme.WeaposeTheme
import com.minhdtm.example.weapose.presentation.ui.WeatherAppState
import com.minhdtm.example.weapose.presentation.utils.Constants
-import kotlinx.coroutines.flow.collectLatest
@Composable
fun SearchByMap(
@@ -56,19 +55,23 @@ fun SearchByMap(
}
}
- LaunchedEffect(true) {
- viewModel.event.collectLatest { event ->
- when (event) {
- is SearchByMapEvent.PopTo -> {
+ LaunchedEffect(state) {
+ when {
+ state.popupToRoute != null -> {
+ state.address?.let { address ->
val params = mutableMapOf()
- params[Constants.Key.LAT_LNG] = event.latLng
- appState.popBackStack(popToRoute = event.toRoute, params = params)
+ params[Constants.Key.LAT_LNG] = LatLng(address.latitude, address.longitude)
+ appState.popBackStack(popToRoute = state.popupToRoute, params = params)
}
- is SearchByMapEvent.MoveCamera -> {
- cameraPositionState.move(CameraUpdateFactory.newLatLng(event.latLng))
+ }
+ state.moveCamera != null -> {
+ state.moveCamera?.let {
+ cameraPositionState.move(CameraUpdateFactory.newLatLng(it))
}
}
+ else -> return@LaunchedEffect
}
+ viewModel.cleanEvent()
}
SearchByMapScreen(
diff --git a/app/src/main/java/com/minhdtm/example/weapose/presentation/ui/search/map/SearchByMapViewModel.kt b/app/src/main/java/com/minhdtm/example/weapose/presentation/ui/search/map/SearchByMapViewModel.kt
index d395975..ac5acd7 100644
--- a/app/src/main/java/com/minhdtm/example/weapose/presentation/ui/search/map/SearchByMapViewModel.kt
+++ b/app/src/main/java/com/minhdtm/example/weapose/presentation/ui/search/map/SearchByMapViewModel.kt
@@ -10,12 +10,13 @@ import com.minhdtm.example.weapose.domain.usecase.GetCurrentLocationUseCase
import com.minhdtm.example.weapose.domain.usecase.GetDarkModeGoogleMapUseCase
import com.minhdtm.example.weapose.domain.usecase.SetDarkModeGoogleMapUseCase
import com.minhdtm.example.weapose.presentation.base.BaseViewModel
-import com.minhdtm.example.weapose.presentation.base.Event
import com.minhdtm.example.weapose.presentation.base.ViewState
import com.minhdtm.example.weapose.presentation.utils.Constants
import dagger.hilt.android.lifecycle.HiltViewModel
-import kotlinx.coroutines.channels.Channel
-import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.flow.zip
import javax.inject.Inject
@HiltViewModel
@@ -29,9 +30,6 @@ class SearchByMapViewModel @Inject constructor(
private val _state = MutableStateFlow(SearchByMapViewState())
val state: StateFlow = _state
- private val _event = Channel(Channel.BUFFERED)
- val event = _event.receiveAsFlow()
-
init {
callApi {
getDarkModeGoogleMapUseCase().collect { isDarkMode ->
@@ -45,10 +43,9 @@ class SearchByMapViewModel @Inject constructor(
savedStateHandle.getStateFlow(Constants.Key.LAT, "").zip(
savedStateHandle.getStateFlow(Constants.Key.LNG, ""),
transform = { lat, lng ->
- Pair(lat, lng)
+ LatLng(lat.toDouble(), lng.toDouble())
},
- ).collect { latAndLng ->
- val latLng = LatLng(latAndLng.first.toDouble(), latAndLng.second.toDouble())
+ ).collect { latLng ->
if (latLng != Constants.Default.LAT_LNG_DEFAULT) {
setMarker(latLng)
}
@@ -65,7 +62,9 @@ class SearchByMapViewModel @Inject constructor(
fun setMarker(latLng: LatLng) {
callApi {
- _event.send(SearchByMapEvent.MoveCamera(latLng))
+ _state.update {
+ it.copy(moveCamera = latLng)
+ }
getAddressFromLocationUseCase(GetAddressFromLocationUseCase.Params(latLng)).collect { address ->
_state.update {
@@ -78,12 +77,6 @@ class SearchByMapViewModel @Inject constructor(
}
}
- override fun hideError() {
- _state.update {
- it.copy(error = null)
- }
- }
-
fun onClickCurrentLocation() {
callApi {
getCurrentLocationUseCase().collect { latLng ->
@@ -94,17 +87,32 @@ class SearchByMapViewModel @Inject constructor(
fun onClickDone() {
callApi {
- val lat = _state.value.address?.latitude
- val lng = _state.value.address?.longitude
-
val toRoute = savedStateHandle.get(Constants.Key.FROM_ROUTE)
- if (lat != null && lng != null && !toRoute.isNullOrBlank()) {
- val latLng = LatLng(lat, lng)
- _event.send(SearchByMapEvent.PopTo(toRoute, latLng))
+ if (!toRoute.isNullOrBlank()) {
+ _state.update {
+ it.copy(
+ popupToRoute = toRoute,
+ )
+ }
}
}
}
+
+ fun cleanEvent() {
+ _state.update {
+ it.copy(
+ popupToRoute = null,
+ moveCamera = null,
+ )
+ }
+ }
+
+ override fun hideError() {
+ _state.update {
+ it.copy(error = null)
+ }
+ }
}
data class SearchByMapViewState(
@@ -113,10 +121,6 @@ data class SearchByMapViewState(
val isDarkMode: Boolean = false,
val marker: MarkerState? = null,
val address: Address? = null,
+ val popupToRoute: String? = null,
+ val moveCamera: LatLng? = null,
) : ViewState(isLoading, error)
-
-sealed class SearchByMapEvent : Event() {
- data class PopTo(val toRoute: String, val latLng: LatLng) : SearchByMapEvent()
-
- data class MoveCamera(val latLng: LatLng) : SearchByMapEvent()
-}
diff --git a/app/src/main/java/com/minhdtm/example/weapose/presentation/ui/search/text/SearchByTextScreen.kt b/app/src/main/java/com/minhdtm/example/weapose/presentation/ui/search/text/SearchByTextScreen.kt
index f323526..6ead56a 100644
--- a/app/src/main/java/com/minhdtm/example/weapose/presentation/ui/search/text/SearchByTextScreen.kt
+++ b/app/src/main/java/com/minhdtm/example/weapose/presentation/ui/search/text/SearchByTextScreen.kt
@@ -31,7 +31,6 @@ import com.minhdtm.example.weapose.presentation.model.HistorySearchAddressViewDa
import com.minhdtm.example.weapose.presentation.ui.WeatherAppState
import com.minhdtm.example.weapose.presentation.utils.Constants
import com.minhdtm.example.weapose.presentation.utils.clearFocusOnKeyboardDismiss
-import kotlinx.coroutines.flow.collectLatest
@OptIn(ExperimentalComposeUiApi::class)
@Composable
@@ -46,14 +45,17 @@ fun SearchByText(
}
// Get event
- LaunchedEffect(true) {
- viewModel.event.collectLatest { event ->
- when (event) {
- is SearchByTextEvent.NavigateToSearchByMap -> {
- appState.navigateToSearchByMap(event.fromRoute, event.latLng)
- }
+ LaunchedEffect(state) {
+ val navigateToSearchByMap = state.navigateToSearchByMap
+
+ when {
+ navigateToSearchByMap != null -> {
+ appState.navigateToSearchByMap(navigateToSearchByMap.fromRoute, navigateToSearchByMap.latLng)
}
+ else -> return@LaunchedEffect
}
+
+ viewModel.cleanEvent()
}
// Hide keyboard when SearchByText is disposed
diff --git a/app/src/main/java/com/minhdtm/example/weapose/presentation/ui/search/text/SearchByTextViewModel.kt b/app/src/main/java/com/minhdtm/example/weapose/presentation/ui/search/text/SearchByTextViewModel.kt
index 355b213..16ae134 100644
--- a/app/src/main/java/com/minhdtm/example/weapose/presentation/ui/search/text/SearchByTextViewModel.kt
+++ b/app/src/main/java/com/minhdtm/example/weapose/presentation/ui/search/text/SearchByTextViewModel.kt
@@ -9,7 +9,6 @@ import com.minhdtm.example.weapose.R
import com.minhdtm.example.weapose.domain.exception.WeatherException
import com.minhdtm.example.weapose.domain.usecase.*
import com.minhdtm.example.weapose.presentation.base.BaseViewModel
-import com.minhdtm.example.weapose.presentation.base.Event
import com.minhdtm.example.weapose.presentation.base.ViewState
import com.minhdtm.example.weapose.presentation.model.HistorySearchAddressViewData
import com.minhdtm.example.weapose.presentation.model.HistorySearchAddressViewDataMapper
@@ -17,8 +16,10 @@ import com.minhdtm.example.weapose.presentation.utils.Constants
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.FlowPreview
-import kotlinx.coroutines.channels.Channel
-import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.flatMapConcat
+import kotlinx.coroutines.flow.update
import javax.inject.Inject
@OptIn(FlowPreview::class)
@@ -41,9 +42,6 @@ class SearchByTextViewModel @Inject constructor(
private val _state = MutableStateFlow(SearchByTextViewState(addressPlaceHolder = placeHolder))
val state: StateFlow = _state
- private val _event = Channel(Channel.BUFFERED)
- val event = _event.receiveAsFlow()
-
init {
callApi {
getSearchAddressUseCase().collect { listSearch ->
@@ -125,18 +123,26 @@ class SearchByTextViewModel @Inject constructor(
}
fun onNavigateToSearchByMap() {
- callApi {
- var latLng = Constants.Default.LAT_LNG_DEFAULT
+ var latLng = Constants.Default.LAT_LNG_DEFAULT
- val fromRoute = savedStateHandle.get(Constants.Key.FROM_ROUTE) ?: ""
- val lat = savedStateHandle.get(Constants.Key.LAT) ?: ""
- val lng = savedStateHandle.get(Constants.Key.LNG) ?: ""
+ val fromRoute = savedStateHandle.get(Constants.Key.FROM_ROUTE) ?: ""
+ val lat = savedStateHandle.get(Constants.Key.LAT) ?: ""
+ val lng = savedStateHandle.get(Constants.Key.LNG) ?: ""
- if (lat.isNotBlank() && lng.isNotBlank()) {
- latLng = LatLng(lat.toDouble(), lng.toDouble())
- }
+ if (lat.isNotBlank() && lng.isNotBlank()) {
+ latLng = LatLng(lat.toDouble(), lng.toDouble())
+ }
+
+ _state.update {
+ it.copy(navigateToSearchByMap = NavigateToSearchByMapEvent(latLng, fromRoute))
+ }
+ }
- _event.send(SearchByTextEvent.NavigateToSearchByMap(fromRoute, latLng))
+ fun cleanEvent() {
+ _state.update {
+ it.copy(
+ navigateToSearchByMap = null,
+ )
}
}
}
@@ -148,8 +154,10 @@ data class SearchByTextViewState(
val listSearch: List = emptyList(),
val addressPlaceHolder: List = emptyList(),
val listResult: List = emptyList(),
+ val navigateToSearchByMap: NavigateToSearchByMapEvent? = null,
) : ViewState(isLoading, error)
-sealed class SearchByTextEvent : Event() {
- data class NavigateToSearchByMap(val fromRoute: String, val latLng: LatLng) : SearchByTextEvent()
-}
+data class NavigateToSearchByMapEvent(
+ val latLng: LatLng,
+ val fromRoute: String,
+)
diff --git a/app/src/main/java/com/minhdtm/example/weapose/presentation/ui/sevendaysweather/SevenDaysWeatherScreen.kt b/app/src/main/java/com/minhdtm/example/weapose/presentation/ui/sevendaysweather/SevenDaysWeatherScreen.kt
index 9f6a109..cddc519 100644
--- a/app/src/main/java/com/minhdtm/example/weapose/presentation/ui/sevendaysweather/SevenDaysWeatherScreen.kt
+++ b/app/src/main/java/com/minhdtm/example/weapose/presentation/ui/sevendaysweather/SevenDaysWeatherScreen.kt
@@ -1,11 +1,14 @@
package com.minhdtm.example.weapose.presentation.ui.sevendaysweather
+import android.content.res.Configuration.UI_MODE_NIGHT_YES
import androidx.compose.animation.*
+import androidx.compose.animation.core.updateTransition
import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
@@ -16,6 +19,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.google.accompanist.swiperefresh.SwipeRefresh
@@ -24,6 +28,8 @@ import com.google.android.gms.maps.model.LatLng
import com.minhdtm.example.weapose.R
import com.minhdtm.example.weapose.presentation.component.WeatherScaffold
import com.minhdtm.example.weapose.presentation.model.DayWeatherViewData
+import com.minhdtm.example.weapose.presentation.model.factory.previewDayWeatherViewData
+import com.minhdtm.example.weapose.presentation.theme.WeaposeTheme
import com.minhdtm.example.weapose.presentation.ui.Screen
import com.minhdtm.example.weapose.presentation.ui.WeatherAppState
import com.minhdtm.example.weapose.presentation.ui.home.CurrentWeatherAppBar
@@ -49,9 +55,9 @@ fun SevenDaysWeather(
}
LaunchedEffect(true) {
- appState.getDataFromNextScreen(Constants.Key.LAT_LNG, Constants.Default.LAT_LNG_DEFAULT)?.collect {
- if (it != LatLng(0.0, 0.0)) {
- viewModel.getWeatherByLocation(it)
+ appState.getDataFromNextScreen(Constants.Key.LAT_LNG, Constants.Default.LAT_LNG_DEFAULT)?.collect { latLng ->
+ if (latLng != Constants.Default.LAT_LNG_DEFAULT) {
+ viewModel.getWeatherByLocation(latLng)
appState.removeDataFromNextScreen(Constants.Key.LAT_LNG)
}
}
@@ -65,14 +71,17 @@ fun SevenDaysWeather(
}
// Get event
- LaunchedEffect(true) {
- viewModel.event.collectLatest { event ->
- when (event) {
- is SevenDaysEvent.NavigateToSearchByText -> {
- appState.navigateToSearchByText(Screen.SevenDaysWeather, event.latLng)
- }
+ LaunchedEffect(state) {
+ val navigateToSearchByText = state.navigateToSearchByText
+
+ when {
+ navigateToSearchByText != null -> {
+ appState.navigateToSearchByText(Screen.SevenDaysWeather, navigateToSearchByText)
}
+ else -> return@LaunchedEffect
}
+
+ viewModel.cleanEvent()
}
SevenDaysWeatherScreen(
@@ -146,7 +155,12 @@ fun ListWeatherDay(
modifier = modifier,
contentPadding = PaddingValues(bottom = paddingBottom + 10.dp),
) {
- itemsIndexed(items = list) { _, item ->
+ items(
+ items = list,
+ key = { item ->
+ item.dateTime
+ },
+ ) { item ->
WeatherDayItem(
modifier = Modifier.fillMaxWidth(),
item = item,
@@ -165,7 +179,9 @@ fun WeatherDayItem(
mutableStateOf(false)
}
- Column(modifier = modifier) {
+ val transition = updateTransition(targetState = isExpanded, label = "")
+
+ Column(modifier = modifier.background(MaterialTheme.colorScheme.background)) {
Row(
modifier = Modifier
.height(70.dp)
@@ -206,14 +222,13 @@ fun WeatherDayItem(
style = MaterialTheme.typography.titleSmall.copy(color = MaterialTheme.colorScheme.secondary),
)
-
Text(
text = stringResource(id = R.string.degrees_c, item.minTemp.toString()),
style = MaterialTheme.typography.titleSmall.copy(color = MaterialTheme.colorScheme.inversePrimary),
)
}
- AnimatedContent(targetState = isExpanded, transitionSpec = {
+ transition.AnimatedContent(transitionSpec = {
if (!targetState) {
slideInVertically { height -> height } + fadeIn() with slideOutVertically { height -> -height } + fadeOut()
} else {
@@ -231,7 +246,11 @@ fun WeatherDayItem(
}
}
- AnimatedVisibility(visible = isExpanded) {
+ transition.AnimatedVisibility(
+ visible = { it },
+ enter = expandVertically(),
+ exit = shrinkVertically(),
+ ) {
Column(
modifier = Modifier
.fillMaxWidth()
@@ -265,7 +284,7 @@ fun WeatherDayItem(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 5.dp),
- title = stringResource(id = R.string.sunrise_sunset),
+ title = stringResource(id = R.string.sunset_sunrise),
description = stringResource(id = R.string.sunrise_sunset, item.sunrise, item.sunset),
)
}
@@ -295,3 +314,12 @@ fun WeatherInformation(
)
}
}
+
+@Preview(name = "Dark", uiMode = UI_MODE_NIGHT_YES, showBackground = true)
+@Preview(name = "Light", showBackground = true)
+@Composable
+fun WeatherDayItemPreview() {
+ WeaposeTheme {
+ WeatherDayItem(item = previewDayWeatherViewData())
+ }
+}
diff --git a/app/src/main/java/com/minhdtm/example/weapose/presentation/ui/sevendaysweather/SevenDaysWeatherViewModel.kt b/app/src/main/java/com/minhdtm/example/weapose/presentation/ui/sevendaysweather/SevenDaysWeatherViewModel.kt
index f0946b0..a8d2443 100644
--- a/app/src/main/java/com/minhdtm/example/weapose/presentation/ui/sevendaysweather/SevenDaysWeatherViewModel.kt
+++ b/app/src/main/java/com/minhdtm/example/weapose/presentation/ui/sevendaysweather/SevenDaysWeatherViewModel.kt
@@ -7,16 +7,13 @@ import com.minhdtm.example.weapose.domain.usecase.GetCurrentAddressUseCase
import com.minhdtm.example.weapose.domain.usecase.GetLocationFromTextUseCase
import com.minhdtm.example.weapose.domain.usecase.GetSevenDaysWeatherUseCase
import com.minhdtm.example.weapose.presentation.base.BaseViewModel
-import com.minhdtm.example.weapose.presentation.base.Event
import com.minhdtm.example.weapose.presentation.base.ViewState
import com.minhdtm.example.weapose.presentation.model.DayWeatherViewData
import com.minhdtm.example.weapose.presentation.model.SevenWeatherViewDataMapper
import com.minhdtm.example.weapose.presentation.utils.Constants
import dagger.hilt.android.lifecycle.HiltViewModel
-import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import javax.inject.Inject
@@ -31,9 +28,6 @@ class SevenDaysWeatherViewModel @Inject constructor(
private val _state = MutableStateFlow(SevenDaysViewState(isLoading = true))
val state: StateFlow = _state
- private val _event = Channel(Channel.BUFFERED)
- val event = _event.receiveAsFlow()
-
private var currentLocation = Constants.Default.LAT_LNG_DEFAULT
init {
@@ -106,8 +100,8 @@ class SevenDaysWeatherViewModel @Inject constructor(
}
fun onNavigateToSearch() {
- callApi {
- _event.send(SevenDaysEvent.NavigateToSearchByText(currentLocation))
+ _state.update {
+ it.copy(navigateToSearchByText = currentLocation)
}
}
@@ -116,7 +110,7 @@ class SevenDaysWeatherViewModel @Inject constructor(
_state.update {
it.copy(
isRefresh = isShowRefresh,
- isLoading = true,
+ isLoading = false,
)
}
@@ -152,6 +146,14 @@ class SevenDaysWeatherViewModel @Inject constructor(
it.copy(isLoading = false, error = null)
}
}
+
+ fun cleanEvent() {
+ _state.update {
+ it.copy(
+ navigateToSearchByText = null,
+ )
+ }
+ }
}
data class SevenDaysViewState(
@@ -159,9 +161,6 @@ data class SevenDaysViewState(
override val error: WeatherException? = null,
val isRefresh: Boolean = false,
val address: String = "",
- val listSevenDays: List = emptyList()
+ val listSevenDays: List = emptyList(),
+ val navigateToSearchByText: LatLng? = null,
) : ViewState(isLoading, error)
-
-sealed class SevenDaysEvent : Event() {
- data class NavigateToSearchByText(val latLng: LatLng) : SevenDaysEvent()
-}
diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml
index 85ef54b..a7082f1 100644
--- a/app/src/main/res/values-vi/strings.xml
+++ b/app/src/main/res/values-vi/strings.xml
@@ -13,7 +13,7 @@
Tìm kiếm bất kì đâu bạn muốn …
7 ngày ở
Chỉ số UV
- Mặt trời mọc/lặn
+ Mặt trời mọc - Mặt trời lặn
Thử lại
Invalid
Địa điểm
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 534ced8..269b969 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -8,7 +8,7 @@
Humidity
Address
UV index
- Sunset/Sunrise
+ Sunset - Sunrise
Retry
Invalid
diff --git a/app/src/test/java/com/minhdtm/example/weapose/ExampleUnitTest.kt b/app/src/test/java/com/minhdtm/example/weapose/ExampleUnitTest.kt
deleted file mode 100644
index c7901cb..0000000
--- a/app/src/test/java/com/minhdtm/example/weapose/ExampleUnitTest.kt
+++ /dev/null
@@ -1,17 +0,0 @@
-package com.minhdtm.example.weapose
-
-import org.junit.Test
-
-import org.junit.Assert.*
-
-/**
- * Example local unit test, which will execute on the development machine (host).
- *
- * See [testing documentation](http://d.android.com/tools/testing).
- */
-class ExampleUnitTest {
- @Test
- fun addition_isCorrect() {
- assertEquals(4, 2 + 2)
- }
-}
\ No newline at end of file
diff --git a/app/src/test/java/com/minhdtm/example/weapose/base/BaseTest.kt b/app/src/test/java/com/minhdtm/example/weapose/base/BaseTest.kt
new file mode 100644
index 0000000..21b4032
--- /dev/null
+++ b/app/src/test/java/com/minhdtm/example/weapose/base/BaseTest.kt
@@ -0,0 +1,8 @@
+package com.minhdtm.example.weapose.base
+
+import org.junit.Rule
+
+open class BaseTest {
+ @get:Rule
+ val mainDispatcherRule = MainDispatcherRule()
+}
diff --git a/app/src/test/java/com/minhdtm/example/weapose/base/MainDispatcherRule.kt b/app/src/test/java/com/minhdtm/example/weapose/base/MainDispatcherRule.kt
new file mode 100644
index 0000000..8387431
--- /dev/null
+++ b/app/src/test/java/com/minhdtm/example/weapose/base/MainDispatcherRule.kt
@@ -0,0 +1,23 @@
+package com.minhdtm.example.weapose.base
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.TestDispatcher
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.setMain
+import org.junit.rules.TestWatcher
+import org.junit.runner.Description
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class MainDispatcherRule constructor(
+ val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
+) : TestWatcher() {
+ override fun starting(description: Description) {
+ Dispatchers.setMain(testDispatcher)
+ }
+
+ override fun finished(description: Description) {
+ Dispatchers.resetMain()
+ }
+}
diff --git a/app/src/test/java/com/minhdtm/example/weapose/presentation/ui/CurrentWeatherViewModelTest.kt b/app/src/test/java/com/minhdtm/example/weapose/presentation/ui/CurrentWeatherViewModelTest.kt
new file mode 100644
index 0000000..22449cc
--- /dev/null
+++ b/app/src/test/java/com/minhdtm/example/weapose/presentation/ui/CurrentWeatherViewModelTest.kt
@@ -0,0 +1,146 @@
+package com.minhdtm.example.weapose.presentation.ui
+
+import android.content.Context
+import android.location.Address
+import com.google.android.gms.maps.model.LatLng
+import com.minhdtm.example.weapose.base.BaseTest
+import com.minhdtm.example.weapose.utils.callPrivateFunction
+import com.minhdtm.example.weapose.utils.getPrivateProperty
+import com.minhdtm.example.weapose.domain.usecase.GetCurrentLocationUseCase
+import com.minhdtm.example.weapose.domain.usecase.GetCurrentWeatherUseCase
+import com.minhdtm.example.weapose.domain.usecase.GetHourWeatherUseCase
+import com.minhdtm.example.weapose.domain.usecase.GetLocationFromTextUseCase
+import com.minhdtm.example.weapose.presentation.model.CurrentWeatherMapper
+import com.minhdtm.example.weapose.presentation.model.HourWeatherMapper
+import com.minhdtm.example.weapose.presentation.model.HourWeatherViewData
+import com.minhdtm.example.weapose.presentation.ui.home.CurrentWeatherViewModel
+import com.minhdtm.example.weapose.utils.factory.ModelDefault
+import com.minhdtm.example.weapose.utils.factory.ViewDataDefault
+import com.minhdtm.example.weapose.utils.toFlow
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.junit4.MockKRule
+import io.mockk.spyk
+import io.mockk.verify
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotEquals
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import java.util.*
+
+class CurrentWeatherViewModelTest : BaseTest() {
+ @get:Rule
+ val mockkRule = MockKRule(this)
+
+ @MockK
+ lateinit var context: Context
+
+ @MockK
+ lateinit var getCurrentWeatherUseCase: GetCurrentWeatherUseCase
+
+ @MockK
+ lateinit var currentWeatherMapper: CurrentWeatherMapper
+
+ @MockK
+ lateinit var getCurrentLocationUseCase: GetCurrentLocationUseCase
+
+ @MockK
+ lateinit var getHourWeatherUseCase: GetHourWeatherUseCase
+
+ @MockK
+ lateinit var hourWeatherMapper: HourWeatherMapper
+
+ @MockK
+ lateinit var getLocationFromTextUseCase: GetLocationFromTextUseCase
+
+ private lateinit var viewModel: CurrentWeatherViewModel
+
+ @Before
+ fun setup() {
+ viewModel = spyk(
+ CurrentWeatherViewModel(
+ context,
+ getCurrentWeatherUseCase,
+ currentWeatherMapper,
+ getCurrentLocationUseCase,
+ getHourWeatherUseCase,
+ hourWeatherMapper,
+ getLocationFromTextUseCase,
+ ),
+ recordPrivateCalls = true,
+ )
+ }
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ @Test
+ fun `getCurrentLocation success`() = runTest(mainDispatcherRule.testDispatcher) {
+ every { getCurrentLocationUseCase() } returns flow { emit(ModelDefault.latLng()) }
+
+ viewModel.getCurrentLocation()
+
+ verify(exactly = 1) {
+ viewModel["showLoading"]()
+ viewModel["getCurrentWeather"](ModelDefault.latLng())
+ }
+ }
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ @Test
+ fun `getWeatherByLocation success`() = runTest(mainDispatcherRule.testDispatcher) {
+ viewModel.getWeatherByLocation(ModelDefault.latLng())
+
+ verify(exactly = 1) {
+ viewModel["showLoading"]()
+ viewModel["getCurrentWeather"](ModelDefault.latLng())
+ }
+ }
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ @Test
+ fun `getWeatherByAddressName success`() = runTest(mainDispatcherRule.testDispatcher) {
+ val address = spyk(Address(Locale.ENGLISH))
+ every { address.longitude } returns 0.0
+ every { address.latitude } returns 0.0
+ every { getLocationFromTextUseCase(any()) } returns address.toFlow()
+
+ viewModel.getWeatherByAddressName("")
+
+ verify(exactly = 1) {
+ viewModel["showLoading"]()
+ viewModel["getCurrentWeather"](LatLng(0.0, 0.0))
+ }
+ }
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ @Test
+ fun `getCurrentWeather success`() = runTest(mainDispatcherRule.testDispatcher) {
+ every { getCurrentWeatherUseCase(any()) } returns ModelDefault.currentWeather().toFlow()
+ every { getHourWeatherUseCase(any()) } returns ModelDefault.hourWeather().toFlow()
+ every { currentWeatherMapper.mapToViewData(any()) } returns ViewDataDefault.currentWeather()
+ every { hourWeatherMapper.mapToViewData(any()) } returns ViewDataDefault.hourWeather()
+
+ viewModel.callPrivateFunction("getCurrentWeather", ModelDefault.latLng())
+
+ assertEquals(viewModel.state.value.currentWeather, ViewDataDefault.currentWeather())
+ assertNotEquals(viewModel.state.value.listHourlyWeatherToday, emptyList())
+ }
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ @Test
+ fun `getCurrentWeather success with current location is in Ha Noi`() = runTest(mainDispatcherRule.testDispatcher) {
+ every { getCurrentWeatherUseCase(any()) } returns ModelDefault.currentWeather().toFlow()
+ every { getHourWeatherUseCase(any()) } returns ModelDefault.hourWeather().toFlow()
+ every { currentWeatherMapper.mapToViewData(any()) } returns ViewDataDefault.currentWeather()
+ every { hourWeatherMapper.mapToViewData(any()) } returns ViewDataDefault.hourWeather()
+
+ viewModel.callPrivateFunction("getCurrentWeather", ModelDefault.latLngHaNoi())
+
+ assertEquals(viewModel.state.value.currentWeather, ViewDataDefault.currentWeather())
+ assertNotEquals(viewModel.state.value.listHourlyWeatherToday, emptyList())
+ assertEquals(viewModel.getPrivateProperty("currentLocation"), ModelDefault.latLngHaNoi())
+ }
+}
diff --git a/app/src/test/java/com/minhdtm/example/weapose/utils/AccessPrivateExt.kt b/app/src/test/java/com/minhdtm/example/weapose/utils/AccessPrivateExt.kt
new file mode 100644
index 0000000..32a947a
--- /dev/null
+++ b/app/src/test/java/com/minhdtm/example/weapose/utils/AccessPrivateExt.kt
@@ -0,0 +1,35 @@
+package com.minhdtm.example.weapose.utils
+
+import kotlin.reflect.KProperty1
+import kotlin.reflect.full.functions
+import kotlin.reflect.full.memberProperties
+import kotlin.reflect.jvm.isAccessible
+
+fun Any.callPrivateFunction(methodName: String, vararg args: Any?): R {
+ val privateMethod = this::class.functions.firstOrNull { function ->
+ function.name == methodName
+ }
+
+ val argumentList = args.toMutableList()
+ argumentList.add(0, this)
+
+ if (privateMethod != null) {
+ privateMethod.isAccessible = true
+ return privateMethod.call(*argumentList.toTypedArray()) as R
+ } else {
+ throw NoSuchMethodException("Method $methodName does not exist in ${this::class.qualifiedName}")
+ }
+}
+
+fun Any.getPrivateProperty(propertyName: String): R {
+ val privateProperty = this::class.memberProperties.firstOrNull { property ->
+ property.name == propertyName
+ } as? KProperty1
+
+ if (privateProperty != null) {
+ privateProperty.isAccessible = true
+ return privateProperty.get(this) as R
+ } else {
+ throw NoSuchFieldException("Field $propertyName does not exist in ${this::class.qualifiedName}")
+ }
+}
diff --git a/app/src/test/java/com/minhdtm/example/weapose/utils/FlowExt.kt b/app/src/test/java/com/minhdtm/example/weapose/utils/FlowExt.kt
new file mode 100644
index 0000000..3675bbb
--- /dev/null
+++ b/app/src/test/java/com/minhdtm/example/weapose/utils/FlowExt.kt
@@ -0,0 +1,8 @@
+package com.minhdtm.example.weapose.utils
+
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flow
+
+fun T.toFlow(): Flow = flow {
+ emit(this@toFlow)
+}
diff --git a/app/src/test/java/com/minhdtm/example/weapose/utils/factory/ModelDefault.kt b/app/src/test/java/com/minhdtm/example/weapose/utils/factory/ModelDefault.kt
new file mode 100644
index 0000000..65f9e7d
--- /dev/null
+++ b/app/src/test/java/com/minhdtm/example/weapose/utils/factory/ModelDefault.kt
@@ -0,0 +1,50 @@
+package com.minhdtm.example.weapose.utils.factory
+
+import com.google.android.gms.maps.model.LatLng
+import com.minhdtm.example.weapose.data.model.CurrentWeather
+import com.minhdtm.example.weapose.data.model.Hourly
+import com.minhdtm.example.weapose.data.model.Weather
+import com.minhdtm.example.weapose.domain.usecase.GetHourWeatherUseCase
+
+object ModelDefault {
+ fun latLng() = LatLng(0.0, 0.0)
+
+ fun latLngHaNoi() = LatLng(21.028844836079177, 105.85215012120968)
+
+ fun currentWeather() = CurrentWeather()
+
+ fun hourWeather() = GetHourWeatherUseCase.Response(
+ today = (0..23).toMutableList().map {
+ val dt = (hourly().dt ?: 0L) + it * 60 * 60 * 1000
+ hourly().copy(dt = dt)
+ },
+ tomorrow = (23..47).toMutableList().map {
+ val dt = (hourly().dt ?: 0L) + it * 60 * 60 * 1000
+ hourly().copy(dt = dt)
+ },
+ )
+
+ fun weather() = Weather(
+ id = 1,
+ main = "main",
+ description = "description",
+ icon = "icon",
+ )
+
+ fun hourly() = Hourly(
+ dt = 1655882985,
+ temp = 38.0,
+ feelsLike = 40.0,
+ pressure = 70.0,
+ humidity = 10,
+ dewPoint = 10.0,
+ uvi = 10.0,
+ clouds = 10.0,
+ visibility = 7,
+ windSpeed = 20.0,
+ windDeg = 10,
+ windGust = 10.0,
+ weather = listOf(weather()),
+ pop = 10.0,
+ )
+}
diff --git a/app/src/test/java/com/minhdtm/example/weapose/utils/factory/ViewDataDefault.kt b/app/src/test/java/com/minhdtm/example/weapose/utils/factory/ViewDataDefault.kt
new file mode 100644
index 0000000..80b1886
--- /dev/null
+++ b/app/src/test/java/com/minhdtm/example/weapose/utils/factory/ViewDataDefault.kt
@@ -0,0 +1,25 @@
+package com.minhdtm.example.weapose.utils.factory
+
+import com.minhdtm.example.weapose.presentation.model.CurrentWeatherViewData
+import com.minhdtm.example.weapose.presentation.model.HourWeatherViewData
+
+object ViewDataDefault {
+ fun currentWeather() = CurrentWeatherViewData(
+ city = "Ha Noi",
+ maxTemp = "40",
+ minTemp = "30",
+ temp = "35",
+ weather = "Rain",
+ sunRise = "06:00",
+ wind = "20",
+ humidity = "80",
+ background = 0,
+ )
+
+ fun hourWeather() = HourWeatherViewData(
+ timeStamp = 1655966766,
+ time = "10:00",
+ weatherIcon = 0,
+ )
+
+}
diff --git a/build.gradle.kts b/build.gradle.kts
index 584b094..1b4b7ab 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -1,6 +1,6 @@
plugins {
- id("com.android.application") version "7.4.0-alpha02" apply false
- id("com.android.library") version "7.4.0-alpha02" apply false
+ id("com.android.application") version "7.4.0-alpha08" apply false
+ id("com.android.library") version "7.4.0-alpha08" apply false
id("org.jetbrains.kotlin.android") version "1.6.10" apply false
id("org.jetbrains.kotlin.jvm") version "1.6.10" apply false
id("com.google.android.libraries.mapsplatform.secrets-gradle-plugin") version "2.0.1" apply false
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 03a4e76..24e8054 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
-#Fri May 06 10:41:52 ICT 2022
+#Sat Jul 09 23:12:33 ICT 2022
distributionBase=GRADLE_USER_HOME
-distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-rc-2-bin.zip
distributionPath=wrapper/dists
zipStorePath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
diff --git a/image/data_flow.png b/image/data_flow.png
index 7ceac82..a5ea245 100644
Binary files a/image/data_flow.png and b/image/data_flow.png differ
diff --git a/image/seven_days_dark.png b/image/seven_days_dark.png
index 0e6d49c..c4e6637 100644
Binary files a/image/seven_days_dark.png and b/image/seven_days_dark.png differ
diff --git a/image/seven_days_light.png b/image/seven_days_light.png
index 500ad1f..6706d64 100644
Binary files a/image/seven_days_light.png and b/image/seven_days_light.png differ