Skip to content

MBL-2135: SearchAndFilter viewmodel #2247

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 16 commits into from
Mar 10, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,16 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.SnackbarDuration
import androidx.compose.material.SnackbarHostState
import androidx.compose.material.rememberScaffoldState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import com.kickstarter.R
import com.kickstarter.features.search.viewmodel.SearchAndFilterViewModel
import com.kickstarter.libs.RefTag
Expand All @@ -28,8 +31,10 @@ import com.kickstarter.libs.utils.extensions.isTrue
import com.kickstarter.models.Project
import com.kickstarter.ui.IntentKey
import com.kickstarter.ui.activities.compose.search.SearchScreen
import com.kickstarter.ui.compose.designsystem.KSSnackbarTypes
import com.kickstarter.ui.compose.designsystem.KickstarterApp
import com.kickstarter.ui.extensions.setUpConnectivityStatusCheck
import kotlinx.coroutines.launch

class SearchAndFilterActivity : ComponentActivity() {

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

viewModel.getPopularProjects()
setContent {
val searchUIState by viewModel.searchUIState.collectAsStateWithLifecycle()

var currentSearchTerm by rememberSaveable { mutableStateOf("") }

val popularProjects = searchUIState.popularProjectsList

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

val isLoading = searchUIState.isLoading

val isTyping by remember { mutableStateOf(false) }

val lazyListState = rememberLazyListState()

val snackbarHostState = remember { SnackbarHostState() }

viewModel.provideErrorAction { message ->
lifecycleScope.launch {
snackbarHostState.showSnackbar(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might enqueue multiple snackbars while searching

message = message ?: getString(R.string.Something_went_wrong_please_try_again),
actionLabel = KSSnackbarTypes.KS_ERROR.name,
duration = SnackbarDuration.Long
)
}
}

val darModeEnabled = this.isDarkModeEnabled(env = env)
KickstarterApp(useDarkTheme = darModeEnabled) {
SearchScreen(
environment = env,
onBackClicked = { onBackPressedDispatcher.onBackPressed() },
scaffoldState = rememberScaffoldState(),
errorSnackBarHostState = snackbarHostState,
isLoading = isLoading,
isPopularList = currentSearchTerm.isTrimmedEmpty(),
itemsList = if (currentSearchTerm.isTrimmedEmpty()) {
Expand All @@ -78,15 +95,15 @@ class SearchAndFilterActivity : ComponentActivity() {
!currentSearchTerm.isTrimmedEmpty() &&
searchedProjects.isEmpty(),
onSearchTermChanged = { searchTerm ->
if (searchTerm.isEmpty()) // TODO will be handled on VM
currentSearchTerm = searchTerm
currentSearchTerm = searchTerm
viewModel.updateSearchTerm(searchTerm)
},
onItemClicked = { project ->
// TODO extend on MBL-2135 with proper reftags & analytics for project card clicked
val projAndRef = viewModel.getProjectAndRefTag(project)
if (project.displayPrelaunch().isTrue()) {
startPreLaunchProjectActivity(project, RefTag.projectShare())
startPreLaunchProjectActivity(project, projAndRef.second)
} else {
startProjectActivity(Pair(project, RefTag.projectShare()))
startProjectActivity(projAndRef)
}
}
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,90 +1,175 @@
package com.kickstarter.features.search.viewmodel

import android.util.Pair
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.kickstarter.libs.Environment
import com.kickstarter.libs.RefTag
import com.kickstarter.libs.utils.extensions.isNull
import com.kickstarter.libs.utils.extensions.isTrue
import com.kickstarter.models.Project
import com.kickstarter.services.DiscoveryParams
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import kotlin.coroutines.EmptyCoroutineContext
import kotlin.text.isNotBlank

data class SearchUIState(
val isLoading: Boolean = false,
val isErrored: Boolean = false,
val popularProjectsList: List<Project> = emptyList(), // TODO MBL-2135 popular & search lists could be potentially unified
val popularProjectsList: List<Project> = emptyList(),
val searchList: List<Project> = emptyList()
)

@OptIn(FlowPreview::class)
class SearchAndFilterViewModel(
private val environment: Environment,
private val testDispatcher: CoroutineDispatcher? = null
) : ViewModel() {

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

private val _searchUIState = MutableStateFlow(SearchUIState())
val searchUIState: StateFlow<SearchUIState>
get() = _searchUIState
.asStateFlow()
.stateIn(
scope = viewModelScope,
scope = scope,
started = SharingStarted.WhileSubscribed(),
initialValue = SearchUIState()
)

// Popular projects sorting selection
// - Popular projects sorting selection
private val popularDiscoveryParam = DiscoveryParams.builder().sort(DiscoveryParams.Sort.POPULAR).build()
// TODO Will be updated with the params used to call search with, private for now
private val listOfSearchParams = listOf(popularDiscoveryParam)

private val _params = MutableStateFlow(popularDiscoveryParam)
val params: StateFlow<DiscoveryParams> = _params

private val debouncePeriod = 300L
private val _searchTerm = MutableStateFlow("")
private val searchTerm: StateFlow<String> = _searchTerm

private var errorAction: (message: String?) -> Unit = {}

private var projectsList = emptyList<Project>()
private var popularProjectsList = emptyList<Project>()

init {
scope.launch {
searchTerm
.debounce(debouncePeriod)
.onEach { debouncedTerm ->
// - Reset to initial state in case of empty search term
if (debouncedTerm.isEmpty() || debouncedTerm.isBlank()) {
_params.emit(popularDiscoveryParam)
} else
_params.emit(
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In future additions, with category selection or project sort, there will be inputs to this VM (similar to updateSearchTerm) that will add new emissions to _params with each user selection.
In the future will likely not collect searchTerm chain but rather params

DiscoveryParams.builder()
.term(debouncedTerm)
.sort(DiscoveryParams.Sort.POPULAR) // TODO: update once sort option is ready MBL-2131, by default popular in every search
.build()
)
}.collectLatest {
updateSearchResultsState()
}
}
}

fun provideErrorAction(errorAction: (message: String?) -> Unit) {
this.errorAction = errorAction
}

/**
* Search screen will present the list of popular projects
* as default when presenting SearchAndFilterActivity.
* Update UIState with after executing Search query with latest params
*/
fun getPopularProjects() {
scope.launch {
// TODO trigger loading state UI will handle on MBL-2135
_searchUIState.emit(
SearchUIState(
isLoading = true,
)
)
private suspend fun updateSearchResultsState() {
analyticEvents.trackSearchCTAButtonClicked(params.value)

emitCurrentState(isLoading = true)

val searchEnvelopeResult = search(listOfSearchParams.last())
// - Result from API
val searchEnvelopeResult = apolloClient.getSearchProjects(params.value)

if (searchEnvelopeResult.isFailure) {
// - errorAction.invoke(searchEnvelopeResult.exceptionOrNull()?.message) to return API level message
errorAction.invoke(null)
}

if (searchEnvelopeResult.isFailure) {
// TODO trigger error state UI will handle on MBL-2135
_searchUIState.emit(
SearchUIState(
isErrored = true,
)
if (searchEnvelopeResult.isSuccess) {
searchEnvelopeResult.getOrNull()?.projectList?.let {
if (params.value.term().isNull()) popularProjectsList = it
if (params.value.term()?.isNotBlank().isTrue()) projectsList = it

emitCurrentState(isLoading = false)

analyticEvents.trackSearchResultPageViewed(
params.value,
1, // TODO: this will contain the page when pagination ready MBL-2139
params.value.sort() ?: DiscoveryParams.Sort.POPULAR
)
}
}
}

if (searchEnvelopeResult.isSuccess) {
searchEnvelopeResult.getOrNull()?.projectList?.let {
_searchUIState.emit(
SearchUIState(
isErrored = false,
isLoading = false,
popularProjectsList = it
)
)
}
}
private suspend fun emitCurrentState(isLoading: Boolean = false) {
_searchUIState.emit(
SearchUIState(
isLoading = isLoading,
popularProjectsList = popularProjectsList,
searchList = projectsList
)
)
}

/**
* Returns a project and its appropriate ref tag given its location in a list of popular projects or search results.
*
* @param searchTerm The search term entered to determine list of search results.
* @param projects The list of popular or search result projects.
* @param selectedProject The project selected by the user.
* @return The project and its appropriate ref tag.
*/
private fun projectAndRefTag(
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method has been ported directly from the old search experience.

searchTerm: String,
projects: List<Project>,
selectedProject: Project
): Pair<Project, RefTag> {
val isFirstResult = if (projects.isEmpty()) false else selectedProject === projects[0]
return if (searchTerm.isEmpty()) {
if (isFirstResult) Pair.create(
selectedProject,
RefTag.searchPopularFeatured()
) else Pair.create(selectedProject, RefTag.searchPopular())
} else {
if (isFirstResult) Pair.create(
selectedProject,
RefTag.searchFeatured()
) else Pair.create(selectedProject, RefTag.search())
}
}

fun updateSearchTerm(searchTerm: String) {
scope.launch {
_searchTerm.emit(searchTerm)
}
}

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

class Factory(
private val environment: Environment,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ import io.reactivex.schedulers.Schedulers
import io.reactivex.subjects.PublishSubject
import java.net.SocketTimeoutException
import java.nio.charset.Charset
import kotlin.coroutines.cancellation.CancellationException

interface ApolloClientTypeV2 {
fun getProject(project: Project): Observable<Project>
Expand Down Expand Up @@ -1911,6 +1912,9 @@ class KSApolloClientV2(val service: ApolloClient, val gson: Gson) : ApolloClient
private suspend fun <T> executeForResult(block: suspend () -> T): Result<T> =
try {
Result.success(block())
} catch (cancellationException: CancellationException) {
// - When using try catch blocks with suspending functions always rethrow CancellationExceptions and not threat them as an error
throw cancellationException
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice 👌

} catch (apolloException: ApolloException) {
val exception = apolloException.toClientException()
FirebaseCrashlytics.getInstance().recordException(exception)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import androidx.compose.material.ModalBottomSheetLayout
import androidx.compose.material.ModalBottomSheetValue.Hidden
import androidx.compose.material.Scaffold
import androidx.compose.material.ScaffoldState
import androidx.compose.material.SnackbarHost
import androidx.compose.material.SnackbarHostState
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.rememberModalBottomSheetState
Expand All @@ -47,6 +49,9 @@ import com.kickstarter.libs.utils.extensions.deadlineCountdownValue
import com.kickstarter.models.Project
import com.kickstarter.ui.compose.designsystem.KSCircularProgressIndicator
import com.kickstarter.ui.compose.designsystem.KSDividerLineGrey
import com.kickstarter.ui.compose.designsystem.KSErrorSnackbar
import com.kickstarter.ui.compose.designsystem.KSHeadsupSnackbar
import com.kickstarter.ui.compose.designsystem.KSSnackbarTypes
import com.kickstarter.ui.compose.designsystem.KSTheme
import com.kickstarter.ui.compose.designsystem.KSTheme.colors
import com.kickstarter.ui.compose.designsystem.KSTheme.dimensions
Expand All @@ -65,6 +70,7 @@ fun SearchScreenPreviewNonEmpty() {
SearchScreen(
onBackClicked = { },
scaffoldState = rememberScaffoldState(),
errorSnackBarHostState = SnackbarHostState(),
isLoading = false,
isPopularList = true,
itemsList = List(100) {
Expand All @@ -91,6 +97,7 @@ fun SearchScreenPreviewEmpty() {
SearchScreen(
onBackClicked = { },
scaffoldState = rememberScaffoldState(),
errorSnackBarHostState = SnackbarHostState(),
isLoading = true,
itemsList = listOf(),
lazyColumnListState = rememberLazyListState(),
Expand Down Expand Up @@ -119,6 +126,7 @@ fun SearchScreen(
environment: Environment? = null,
onBackClicked: () -> Unit,
scaffoldState: ScaffoldState,
errorSnackBarHostState: SnackbarHostState = SnackbarHostState(),
isPopularList: Boolean = true,
isLoading: Boolean,
itemsList: List<Project> = listOf(),
Expand Down Expand Up @@ -151,6 +159,19 @@ fun SearchScreen(
Scaffold(
modifier = Modifier.systemBarsPadding(),
scaffoldState = scaffoldState,
snackbarHost = {
SnackbarHost(
modifier = Modifier.padding(dimensions.paddingSmall),
hostState = errorSnackBarHostState,
snackbar = { data ->
if (data.actionLabel == KSSnackbarTypes.KS_ERROR.name) {
KSErrorSnackbar(text = data.message)
} else {
KSHeadsupSnackbar(text = data.message)
}
}
)
},
topBar = {
Surface(elevation = 3.dp) {
SearchTopBar(
Expand Down
Loading