From 0132aaf4285637cfc076ee5a2f7778e456c0dc3a Mon Sep 17 00:00:00 2001 From: Mario Miklos Date: Fri, 4 Jul 2025 16:34:08 +0200 Subject: [PATCH 1/2] [Summit prep] Add a search feature --- .../feature/countrylist/CountryListAdapter.kt | 6 ++- .../feature/countrylist/CountryListPage.kt | 25 ++++++++++-- .../CountrySelectingButton.kt | 2 +- .../CountrySelectingList.kt | 2 +- .../viewmodels/CountryListViewModel.kt | 20 ++++++++++ .../viewmodels/CountryListViewModelTests.kt | 40 +++++++++++++++++++ 6 files changed, 88 insertions(+), 7 deletions(-) rename feature/src/main/java/com/example/feature/countrylist/{componenets => components}/CountrySelectingButton.kt (93%) rename feature/src/main/java/com/example/feature/countrylist/{componenets => components}/CountrySelectingList.kt (97%) diff --git a/feature/src/main/java/com/example/feature/countrylist/CountryListAdapter.kt b/feature/src/main/java/com/example/feature/countrylist/CountryListAdapter.kt index 2eef933..7dc99af 100644 --- a/feature/src/main/java/com/example/feature/countrylist/CountryListAdapter.kt +++ b/feature/src/main/java/com/example/feature/countrylist/CountryListAdapter.kt @@ -29,7 +29,11 @@ fun CountryListAdapter( viewModel.onCountrySelect(country) }, onRefreshTap = { viewModel.onRefreshTap() }, - onFailOtherTap = { viewModel.onFailOtherTap() } + onFailOtherTap = { viewModel.onFailOtherTap() }, + onSearchQueryChange = { query -> + viewModel.updateSearchQuery(query) + }, + filteredContinents = viewModel.filteredContinents ) FloatingAlertNotifier( diff --git a/feature/src/main/java/com/example/feature/countrylist/CountryListPage.kt b/feature/src/main/java/com/example/feature/countrylist/CountryListPage.kt index 2fc5ce8..c380ab2 100644 --- a/feature/src/main/java/com/example/feature/countrylist/CountryListPage.kt +++ b/feature/src/main/java/com/example/feature/countrylist/CountryListPage.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Button import androidx.compose.material.Text +import androidx.compose.material.TextField import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -15,8 +16,8 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.example.domainmodels.Continent import com.example.domainmodels.Country -import com.example.feature.countrylist.componenets.CountryListButton -import com.example.feature.countrylist.componenets.CountryListList +import com.example.feature.countrylist.components.CountryListButton +import com.example.feature.countrylist.components.CountryListList import com.example.features.R import com.example.uicomponents.library.ProgressIndicator import com.example.viewmodels.CountryListViewModel @@ -26,14 +27,30 @@ fun CountryListPage( listUiState: CountryListViewModel.UiState, onCountrySelect: ((Country) -> Unit)? = null, onRefreshTap: (() -> Unit)? = null, - onFailOtherTap: (() -> Unit)? = null + onFailOtherTap: (() -> Unit)? = null, + onSearchQueryChange: ((String) -> Unit)? = null, + filteredContinents: List = emptyList() ) { Box { ProgressIndicator(isLoading = listUiState.isLoading) Column(modifier = Modifier.fillMaxHeight()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + TextField( + value = listUiState.searchQuery, + onValueChange = { onSearchQueryChange?.invoke(it) }, + label = { Text("Search countries") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + } Column(modifier = Modifier.weight(1f)) { CountryListList( - list = listUiState.continents, + list = filteredContinents, onClick = onCountrySelect ) } diff --git a/feature/src/main/java/com/example/feature/countrylist/componenets/CountrySelectingButton.kt b/feature/src/main/java/com/example/feature/countrylist/components/CountrySelectingButton.kt similarity index 93% rename from feature/src/main/java/com/example/feature/countrylist/componenets/CountrySelectingButton.kt rename to feature/src/main/java/com/example/feature/countrylist/components/CountrySelectingButton.kt index 0ba1211..82a73f3 100644 --- a/feature/src/main/java/com/example/feature/countrylist/componenets/CountrySelectingButton.kt +++ b/feature/src/main/java/com/example/feature/countrylist/components/CountrySelectingButton.kt @@ -1,4 +1,4 @@ -package com.example.feature.countrylist.componenets +package com.example.feature.countrylist.components import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding diff --git a/feature/src/main/java/com/example/feature/countrylist/componenets/CountrySelectingList.kt b/feature/src/main/java/com/example/feature/countrylist/components/CountrySelectingList.kt similarity index 97% rename from feature/src/main/java/com/example/feature/countrylist/componenets/CountrySelectingList.kt rename to feature/src/main/java/com/example/feature/countrylist/components/CountrySelectingList.kt index dbeb901..5aea115 100644 --- a/feature/src/main/java/com/example/feature/countrylist/componenets/CountrySelectingList.kt +++ b/feature/src/main/java/com/example/feature/countrylist/components/CountrySelectingList.kt @@ -1,4 +1,4 @@ -package com.example.feature.countrylist.componenets +package com.example.feature.countrylist.components import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background diff --git a/viewmodels/src/main/java/com/example/viewmodels/CountryListViewModel.kt b/viewmodels/src/main/java/com/example/viewmodels/CountryListViewModel.kt index 95d9c52..daa5850 100644 --- a/viewmodels/src/main/java/com/example/viewmodels/CountryListViewModel.kt +++ b/viewmodels/src/main/java/com/example/viewmodels/CountryListViewModel.kt @@ -20,8 +20,28 @@ class CountryListViewModel( val error: CountryListError? = null, val serverStatus: ServerStatus? = null, val navigationTarget: Country? = null, + var searchQuery: String = "", ) + val filteredContinents: List + get() { + val query = _state.value?.searchQuery.orEmpty() + return _state.value?.continents + ?.map { continent -> + val filteredCountries = continent.countries.filter { + it.countryName.contains(query, ignoreCase = true) || + it.regionCode.contains(query, ignoreCase = true) + } + continent.copy(countries = filteredCountries) + } + ?.filter { it.countries.isNotEmpty() } + ?: emptyList() + } + + fun updateSearchQuery(query: String) { + _state.onNext(_state.value!!.copy(searchQuery = query)) + } + private val _state: BehaviorSubject = BehaviorSubject.createDefault(UiState()) val state: Observable = _state diff --git a/viewmodels/src/test/kotlin/com/example/viewmodels/CountryListViewModelTests.kt b/viewmodels/src/test/kotlin/com/example/viewmodels/CountryListViewModelTests.kt index 7174d5b..fdb7f43 100644 --- a/viewmodels/src/test/kotlin/com/example/viewmodels/CountryListViewModelTests.kt +++ b/viewmodels/src/test/kotlin/com/example/viewmodels/CountryListViewModelTests.kt @@ -80,4 +80,44 @@ class CountryListViewModelTests: KoinTest { } } } + + @Nested + @DisplayName("filteredContinents") + inner class FilteredContinents { + @Test + fun `returns only continents with matching countries`() { + // Arrange: set up continents with countries + val continents = listOf( + com.example.domainmodels.Continent( + name = "Europe", + countries = listOf( + com.example.domainmodels.Country(regionCode = "FR"), + com.example.domainmodels.Country(regionCode = "DE") + ) + ), + com.example.domainmodels.Continent( + name = "Asia", + countries = listOf( + com.example.domainmodels.Country(regionCode = "JP"), + com.example.domainmodels.Country(regionCode = "CN") + ) + ) + ) + // Set continents in state + val stateField = CountryListViewModel::class.java.getDeclaredField("_state") + stateField.isAccessible = true + val subject = stateField.get(viewModel) as io.reactivex.rxjava3.subjects.BehaviorSubject + subject.onNext(CountryListViewModel.UiState(continents = continents)) + + // Act: update search query + viewModel.updateSearchQuery("fr") + + // Assert: only Europe with France should match + val filtered = viewModel.filteredContinents + filtered.size.shouldBeEqualTo(1) + filtered[0].name.shouldBeEqualTo("Europe") + filtered[0].countries.size.shouldBeEqualTo(1) + filtered[0].countries[0].regionCode.shouldBeEqualTo("FR") + } + } } From baa7af834ed392f9e9e46274db6d840fdf4f2c8d Mon Sep 17 00:00:00 2001 From: Mario Miklos Date: Fri, 4 Jul 2025 16:46:45 +0200 Subject: [PATCH 2/2] [Summit prep] Code review --- .../feature/countrylist/CountryListPage.kt | 2 +- feature/src/main/res/values/strings.xml | 1 + .../viewmodels/CountryListViewModel.kt | 12 +++++-- .../viewmodels/CountryListViewModelTests.kt | 31 ++++--------------- 4 files changed, 18 insertions(+), 28 deletions(-) diff --git a/feature/src/main/java/com/example/feature/countrylist/CountryListPage.kt b/feature/src/main/java/com/example/feature/countrylist/CountryListPage.kt index c380ab2..b293ce6 100644 --- a/feature/src/main/java/com/example/feature/countrylist/CountryListPage.kt +++ b/feature/src/main/java/com/example/feature/countrylist/CountryListPage.kt @@ -43,7 +43,7 @@ fun CountryListPage( TextField( value = listUiState.searchQuery, onValueChange = { onSearchQueryChange?.invoke(it) }, - label = { Text("Search countries") }, + label = { Text(text = stringResource(R.string.country_list_search_hint)) }, modifier = Modifier.fillMaxWidth(), singleLine = true ) diff --git a/feature/src/main/res/values/strings.xml b/feature/src/main/res/values/strings.xml index 3895d2d..1b1fe68 100644 --- a/feature/src/main/res/values/strings.xml +++ b/feature/src/main/res/values/strings.xml @@ -20,6 +20,7 @@ Save as favorite (will always fail) + Search for a country This country is blocked Would you like to go to a random country instead? Go to random diff --git a/viewmodels/src/main/java/com/example/viewmodels/CountryListViewModel.kt b/viewmodels/src/main/java/com/example/viewmodels/CountryListViewModel.kt index daa5850..8e380e4 100644 --- a/viewmodels/src/main/java/com/example/viewmodels/CountryListViewModel.kt +++ b/viewmodels/src/main/java/com/example/viewmodels/CountryListViewModel.kt @@ -1,5 +1,6 @@ package com.example.viewmodels +import androidx.annotation.VisibleForTesting import com.example.domainmodels.Continent import com.example.domainmodels.Country import com.example.domainmodels.ServerStatus @@ -20,7 +21,7 @@ class CountryListViewModel( val error: CountryListError? = null, val serverStatus: ServerStatus? = null, val navigationTarget: Country? = null, - var searchQuery: String = "", + val searchQuery: String = "", ) val filteredContinents: List @@ -39,7 +40,14 @@ class CountryListViewModel( } fun updateSearchQuery(query: String) { - _state.onNext(_state.value!!.copy(searchQuery = query)) + _state.value?.let { currentState -> + _state.onNext(currentState.copy(searchQuery = query)) + } + } + + @VisibleForTesting + fun setStateForTest(state: UiState) { + _state.onNext(state) } private val _state: BehaviorSubject = BehaviorSubject.createDefault(UiState()) diff --git a/viewmodels/src/test/kotlin/com/example/viewmodels/CountryListViewModelTests.kt b/viewmodels/src/test/kotlin/com/example/viewmodels/CountryListViewModelTests.kt index fdb7f43..e9aa660 100644 --- a/viewmodels/src/test/kotlin/com/example/viewmodels/CountryListViewModelTests.kt +++ b/viewmodels/src/test/kotlin/com/example/viewmodels/CountryListViewModelTests.kt @@ -1,6 +1,8 @@ package com.example.viewmodels import com.example.AutoCloseKoinAfterEachExtension +import com.example.domainmodels.Continent +import com.example.domainmodels.Country import com.example.domainmodels.ServerStatus import com.example.interfaces.networkLogicApiMocks import com.example.logic.logicModule @@ -86,33 +88,12 @@ class CountryListViewModelTests: KoinTest { inner class FilteredContinents { @Test fun `returns only continents with matching countries`() { - // Arrange: set up continents with countries val continents = listOf( - com.example.domainmodels.Continent( - name = "Europe", - countries = listOf( - com.example.domainmodels.Country(regionCode = "FR"), - com.example.domainmodels.Country(regionCode = "DE") - ) - ), - com.example.domainmodels.Continent( - name = "Asia", - countries = listOf( - com.example.domainmodels.Country(regionCode = "JP"), - com.example.domainmodels.Country(regionCode = "CN") - ) - ) + Continent("Europe", listOf(Country("FR"), Country("DE"))), + Continent("Asia", listOf(Country("JP"), Country("CN"))) ) - // Set continents in state - val stateField = CountryListViewModel::class.java.getDeclaredField("_state") - stateField.isAccessible = true - val subject = stateField.get(viewModel) as io.reactivex.rxjava3.subjects.BehaviorSubject - subject.onNext(CountryListViewModel.UiState(continents = continents)) - - // Act: update search query - viewModel.updateSearchQuery("fr") - - // Assert: only Europe with France should match + viewModel.setStateForTest(CountryListViewModel.UiState(continents = continents)) + viewModel.updateSearchQuery("france") val filtered = viewModel.filteredContinents filtered.size.shouldBeEqualTo(1) filtered[0].name.shouldBeEqualTo("Europe")