Skip to content

Commit 22e685d

Browse files
authored
MBL-2135: SearchAndFilter viewmodel (#2247)
1 parent 2e7adb0 commit 22e685d

File tree

6 files changed

+340
-86
lines changed

6 files changed

+340
-86
lines changed

app/src/main/java/com/kickstarter/features/search/ui/SearchAndFilterActivity.kt

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,16 @@ import androidx.activity.ComponentActivity
77
import androidx.activity.compose.setContent
88
import androidx.activity.viewModels
99
import androidx.compose.foundation.lazy.rememberLazyListState
10+
import androidx.compose.material.SnackbarDuration
11+
import androidx.compose.material.SnackbarHostState
1012
import androidx.compose.material.rememberScaffoldState
1113
import androidx.compose.runtime.getValue
1214
import androidx.compose.runtime.mutableStateOf
1315
import androidx.compose.runtime.remember
1416
import androidx.compose.runtime.saveable.rememberSaveable
1517
import androidx.compose.runtime.setValue
1618
import androidx.lifecycle.compose.collectAsStateWithLifecycle
19+
import androidx.lifecycle.lifecycleScope
1720
import com.kickstarter.R
1821
import com.kickstarter.features.search.viewmodel.SearchAndFilterViewModel
1922
import com.kickstarter.libs.RefTag
@@ -28,8 +31,10 @@ import com.kickstarter.libs.utils.extensions.isTrue
2831
import com.kickstarter.models.Project
2932
import com.kickstarter.ui.IntentKey
3033
import com.kickstarter.ui.activities.compose.search.SearchScreen
34+
import com.kickstarter.ui.compose.designsystem.KSSnackbarTypes
3135
import com.kickstarter.ui.compose.designsystem.KickstarterApp
3236
import com.kickstarter.ui.extensions.setUpConnectivityStatusCheck
37+
import kotlinx.coroutines.launch
3338

3439
class SearchAndFilterActivity : ComponentActivity() {
3540

@@ -43,28 +48,40 @@ class SearchAndFilterActivity : ComponentActivity() {
4348
this.getEnvironment()?.let { env ->
4449
viewModelFactory = SearchAndFilterViewModel.Factory(env)
4550

46-
viewModel.getPopularProjects()
4751
setContent {
4852
val searchUIState by viewModel.searchUIState.collectAsStateWithLifecycle()
4953

5054
var currentSearchTerm by rememberSaveable { mutableStateOf("") }
5155

5256
val popularProjects = searchUIState.popularProjectsList
5357

54-
var searchedProjects = emptyList<Project>() // TODO will come from VM MBL-2135
58+
val searchedProjects = searchUIState.searchList
5559

5660
val isLoading = searchUIState.isLoading
5761

5862
val isTyping by remember { mutableStateOf(false) }
5963

6064
val lazyListState = rememberLazyListState()
6165

66+
val snackbarHostState = remember { SnackbarHostState() }
67+
68+
viewModel.provideErrorAction { message ->
69+
lifecycleScope.launch {
70+
snackbarHostState.showSnackbar(
71+
message = message ?: getString(R.string.Something_went_wrong_please_try_again),
72+
actionLabel = KSSnackbarTypes.KS_ERROR.name,
73+
duration = SnackbarDuration.Long
74+
)
75+
}
76+
}
77+
6278
val darModeEnabled = this.isDarkModeEnabled(env = env)
6379
KickstarterApp(useDarkTheme = darModeEnabled) {
6480
SearchScreen(
6581
environment = env,
6682
onBackClicked = { onBackPressedDispatcher.onBackPressed() },
6783
scaffoldState = rememberScaffoldState(),
84+
errorSnackBarHostState = snackbarHostState,
6885
isLoading = isLoading,
6986
isPopularList = currentSearchTerm.isTrimmedEmpty(),
7087
itemsList = if (currentSearchTerm.isTrimmedEmpty()) {
@@ -78,15 +95,15 @@ class SearchAndFilterActivity : ComponentActivity() {
7895
!currentSearchTerm.isTrimmedEmpty() &&
7996
searchedProjects.isEmpty(),
8097
onSearchTermChanged = { searchTerm ->
81-
if (searchTerm.isEmpty()) // TODO will be handled on VM
82-
currentSearchTerm = searchTerm
98+
currentSearchTerm = searchTerm
99+
viewModel.updateSearchTerm(searchTerm)
83100
},
84101
onItemClicked = { project ->
85-
// TODO extend on MBL-2135 with proper reftags & analytics for project card clicked
102+
val projAndRef = viewModel.getProjectAndRefTag(project)
86103
if (project.displayPrelaunch().isTrue()) {
87-
startPreLaunchProjectActivity(project, RefTag.projectShare())
104+
startPreLaunchProjectActivity(project, projAndRef.second)
88105
} else {
89-
startProjectActivity(Pair(project, RefTag.projectShare()))
106+
startProjectActivity(projAndRef)
90107
}
91108
}
92109
)

app/src/main/java/com/kickstarter/features/search/viewmodel/SearchAndFilterViewModel.kt

Lines changed: 120 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,90 +1,175 @@
11
package com.kickstarter.features.search.viewmodel
22

3+
import android.util.Pair
34
import androidx.lifecycle.ViewModel
45
import androidx.lifecycle.ViewModelProvider
56
import androidx.lifecycle.viewModelScope
67
import com.kickstarter.libs.Environment
8+
import com.kickstarter.libs.RefTag
9+
import com.kickstarter.libs.utils.extensions.isNull
10+
import com.kickstarter.libs.utils.extensions.isTrue
711
import com.kickstarter.models.Project
812
import com.kickstarter.services.DiscoveryParams
913
import kotlinx.coroutines.CoroutineDispatcher
14+
import kotlinx.coroutines.FlowPreview
1015
import kotlinx.coroutines.flow.MutableStateFlow
1116
import kotlinx.coroutines.flow.SharingStarted
1217
import kotlinx.coroutines.flow.StateFlow
1318
import kotlinx.coroutines.flow.asStateFlow
19+
import kotlinx.coroutines.flow.collectLatest
20+
import kotlinx.coroutines.flow.debounce
21+
import kotlinx.coroutines.flow.onEach
1422
import kotlinx.coroutines.flow.stateIn
1523
import kotlinx.coroutines.launch
1624
import kotlinx.coroutines.plus
1725
import kotlin.coroutines.EmptyCoroutineContext
26+
import kotlin.text.isNotBlank
1827

1928
data class SearchUIState(
2029
val isLoading: Boolean = false,
21-
val isErrored: Boolean = false,
22-
val popularProjectsList: List<Project> = emptyList(), // TODO MBL-2135 popular & search lists could be potentially unified
30+
val popularProjectsList: List<Project> = emptyList(),
2331
val searchList: List<Project> = emptyList()
2432
)
2533

34+
@OptIn(FlowPreview::class)
2635
class SearchAndFilterViewModel(
2736
private val environment: Environment,
2837
private val testDispatcher: CoroutineDispatcher? = null
2938
) : ViewModel() {
3039

3140
private val scope = viewModelScope + (testDispatcher ?: EmptyCoroutineContext)
3241
private val apolloClient = requireNotNull(environment.apolloClientV2())
42+
private val analyticEvents = requireNotNull(environment.analytics())
3343

3444
private val _searchUIState = MutableStateFlow(SearchUIState())
3545
val searchUIState: StateFlow<SearchUIState>
3646
get() = _searchUIState
3747
.asStateFlow()
3848
.stateIn(
39-
scope = viewModelScope,
49+
scope = scope,
4050
started = SharingStarted.WhileSubscribed(),
4151
initialValue = SearchUIState()
4252
)
4353

44-
// Popular projects sorting selection
54+
// - Popular projects sorting selection
4555
private val popularDiscoveryParam = DiscoveryParams.builder().sort(DiscoveryParams.Sort.POPULAR).build()
46-
// TODO Will be updated with the params used to call search with, private for now
47-
private val listOfSearchParams = listOf(popularDiscoveryParam)
56+
57+
private val _params = MutableStateFlow(popularDiscoveryParam)
58+
val params: StateFlow<DiscoveryParams> = _params
59+
60+
private val debouncePeriod = 300L
61+
private val _searchTerm = MutableStateFlow("")
62+
private val searchTerm: StateFlow<String> = _searchTerm
63+
64+
private var errorAction: (message: String?) -> Unit = {}
65+
66+
private var projectsList = emptyList<Project>()
67+
private var popularProjectsList = emptyList<Project>()
68+
69+
init {
70+
scope.launch {
71+
searchTerm
72+
.debounce(debouncePeriod)
73+
.onEach { debouncedTerm ->
74+
// - Reset to initial state in case of empty search term
75+
if (debouncedTerm.isEmpty() || debouncedTerm.isBlank()) {
76+
_params.emit(popularDiscoveryParam)
77+
} else
78+
_params.emit(
79+
DiscoveryParams.builder()
80+
.term(debouncedTerm)
81+
.sort(DiscoveryParams.Sort.POPULAR) // TODO: update once sort option is ready MBL-2131, by default popular in every search
82+
.build()
83+
)
84+
}.collectLatest {
85+
updateSearchResultsState()
86+
}
87+
}
88+
}
89+
90+
fun provideErrorAction(errorAction: (message: String?) -> Unit) {
91+
this.errorAction = errorAction
92+
}
4893

4994
/**
50-
* Search screen will present the list of popular projects
51-
* as default when presenting SearchAndFilterActivity.
95+
* Update UIState with after executing Search query with latest params
5296
*/
53-
fun getPopularProjects() {
54-
scope.launch {
55-
// TODO trigger loading state UI will handle on MBL-2135
56-
_searchUIState.emit(
57-
SearchUIState(
58-
isLoading = true,
59-
)
60-
)
97+
private suspend fun updateSearchResultsState() {
98+
analyticEvents.trackSearchCTAButtonClicked(params.value)
99+
100+
emitCurrentState(isLoading = true)
61101

62-
val searchEnvelopeResult = search(listOfSearchParams.last())
102+
// - Result from API
103+
val searchEnvelopeResult = apolloClient.getSearchProjects(params.value)
104+
105+
if (searchEnvelopeResult.isFailure) {
106+
// - errorAction.invoke(searchEnvelopeResult.exceptionOrNull()?.message) to return API level message
107+
errorAction.invoke(null)
108+
}
63109

64-
if (searchEnvelopeResult.isFailure) {
65-
// TODO trigger error state UI will handle on MBL-2135
66-
_searchUIState.emit(
67-
SearchUIState(
68-
isErrored = true,
69-
)
110+
if (searchEnvelopeResult.isSuccess) {
111+
searchEnvelopeResult.getOrNull()?.projectList?.let {
112+
if (params.value.term().isNull()) popularProjectsList = it
113+
if (params.value.term()?.isNotBlank().isTrue()) projectsList = it
114+
115+
emitCurrentState(isLoading = false)
116+
117+
analyticEvents.trackSearchResultPageViewed(
118+
params.value,
119+
1, // TODO: this will contain the page when pagination ready MBL-2139
120+
params.value.sort() ?: DiscoveryParams.Sort.POPULAR
70121
)
71122
}
123+
}
124+
}
72125

73-
if (searchEnvelopeResult.isSuccess) {
74-
searchEnvelopeResult.getOrNull()?.projectList?.let {
75-
_searchUIState.emit(
76-
SearchUIState(
77-
isErrored = false,
78-
isLoading = false,
79-
popularProjectsList = it
80-
)
81-
)
82-
}
83-
}
126+
private suspend fun emitCurrentState(isLoading: Boolean = false) {
127+
_searchUIState.emit(
128+
SearchUIState(
129+
isLoading = isLoading,
130+
popularProjectsList = popularProjectsList,
131+
searchList = projectsList
132+
)
133+
)
134+
}
135+
136+
/**
137+
* Returns a project and its appropriate ref tag given its location in a list of popular projects or search results.
138+
*
139+
* @param searchTerm The search term entered to determine list of search results.
140+
* @param projects The list of popular or search result projects.
141+
* @param selectedProject The project selected by the user.
142+
* @return The project and its appropriate ref tag.
143+
*/
144+
private fun projectAndRefTag(
145+
searchTerm: String,
146+
projects: List<Project>,
147+
selectedProject: Project
148+
): Pair<Project, RefTag> {
149+
val isFirstResult = if (projects.isEmpty()) false else selectedProject === projects[0]
150+
return if (searchTerm.isEmpty()) {
151+
if (isFirstResult) Pair.create(
152+
selectedProject,
153+
RefTag.searchPopularFeatured()
154+
) else Pair.create(selectedProject, RefTag.searchPopular())
155+
} else {
156+
if (isFirstResult) Pair.create(
157+
selectedProject,
158+
RefTag.searchFeatured()
159+
) else Pair.create(selectedProject, RefTag.search())
160+
}
161+
}
162+
163+
fun updateSearchTerm(searchTerm: String) {
164+
scope.launch {
165+
_searchTerm.emit(searchTerm)
84166
}
85167
}
86168

87-
private suspend fun search(params: DiscoveryParams) = apolloClient.getSearchProjects(params)
169+
fun getProjectAndRefTag(project: Project): Pair<Project, RefTag> {
170+
val allProjectsList = popularProjectsList.union(projectsList).toList()
171+
return projectAndRefTag(searchTerm.value, allProjectsList, project)
172+
}
88173

89174
class Factory(
90175
private val environment: Environment,

app/src/main/java/com/kickstarter/services/KSApolloClientV2.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ import io.reactivex.schedulers.Schedulers
126126
import io.reactivex.subjects.PublishSubject
127127
import java.net.SocketTimeoutException
128128
import java.nio.charset.Charset
129+
import kotlin.coroutines.cancellation.CancellationException
129130

130131
interface ApolloClientTypeV2 {
131132
fun getProject(project: Project): Observable<Project>
@@ -1911,6 +1912,9 @@ class KSApolloClientV2(val service: ApolloClient, val gson: Gson) : ApolloClient
19111912
private suspend fun <T> executeForResult(block: suspend () -> T): Result<T> =
19121913
try {
19131914
Result.success(block())
1915+
} catch (cancellationException: CancellationException) {
1916+
// - When using try catch blocks with suspending functions always rethrow CancellationExceptions and not threat them as an error
1917+
throw cancellationException
19141918
} catch (apolloException: ApolloException) {
19151919
val exception = apolloException.toClientException()
19161920
FirebaseCrashlytics.getInstance().recordException(exception)

app/src/main/java/com/kickstarter/ui/activities/compose/search/SearchScreen.kt

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import androidx.compose.material.ModalBottomSheetLayout
2121
import androidx.compose.material.ModalBottomSheetValue.Hidden
2222
import androidx.compose.material.Scaffold
2323
import androidx.compose.material.ScaffoldState
24+
import androidx.compose.material.SnackbarHost
25+
import androidx.compose.material.SnackbarHostState
2426
import androidx.compose.material.Surface
2527
import androidx.compose.material.Text
2628
import androidx.compose.material.rememberModalBottomSheetState
@@ -47,6 +49,9 @@ import com.kickstarter.libs.utils.extensions.deadlineCountdownValue
4749
import com.kickstarter.models.Project
4850
import com.kickstarter.ui.compose.designsystem.KSCircularProgressIndicator
4951
import com.kickstarter.ui.compose.designsystem.KSDividerLineGrey
52+
import com.kickstarter.ui.compose.designsystem.KSErrorSnackbar
53+
import com.kickstarter.ui.compose.designsystem.KSHeadsupSnackbar
54+
import com.kickstarter.ui.compose.designsystem.KSSnackbarTypes
5055
import com.kickstarter.ui.compose.designsystem.KSTheme
5156
import com.kickstarter.ui.compose.designsystem.KSTheme.colors
5257
import com.kickstarter.ui.compose.designsystem.KSTheme.dimensions
@@ -65,6 +70,7 @@ fun SearchScreenPreviewNonEmpty() {
6570
SearchScreen(
6671
onBackClicked = { },
6772
scaffoldState = rememberScaffoldState(),
73+
errorSnackBarHostState = SnackbarHostState(),
6874
isLoading = false,
6975
isPopularList = true,
7076
itemsList = List(100) {
@@ -91,6 +97,7 @@ fun SearchScreenPreviewEmpty() {
9197
SearchScreen(
9298
onBackClicked = { },
9399
scaffoldState = rememberScaffoldState(),
100+
errorSnackBarHostState = SnackbarHostState(),
94101
isLoading = true,
95102
itemsList = listOf(),
96103
lazyColumnListState = rememberLazyListState(),
@@ -119,6 +126,7 @@ fun SearchScreen(
119126
environment: Environment? = null,
120127
onBackClicked: () -> Unit,
121128
scaffoldState: ScaffoldState,
129+
errorSnackBarHostState: SnackbarHostState = SnackbarHostState(),
122130
isPopularList: Boolean = true,
123131
isLoading: Boolean,
124132
itemsList: List<Project> = listOf(),
@@ -151,6 +159,19 @@ fun SearchScreen(
151159
Scaffold(
152160
modifier = Modifier.systemBarsPadding(),
153161
scaffoldState = scaffoldState,
162+
snackbarHost = {
163+
SnackbarHost(
164+
modifier = Modifier.padding(dimensions.paddingSmall),
165+
hostState = errorSnackBarHostState,
166+
snackbar = { data ->
167+
if (data.actionLabel == KSSnackbarTypes.KS_ERROR.name) {
168+
KSErrorSnackbar(text = data.message)
169+
} else {
170+
KSHeadsupSnackbar(text = data.message)
171+
}
172+
}
173+
)
174+
},
154175
topBar = {
155176
Surface(elevation = 3.dp) {
156177
SearchTopBar(

0 commit comments

Comments
 (0)