-
Notifications
You must be signed in to change notification settings - Fork 996
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
Changes from all commits
4e0a6ce
99d3d0d
c024098
47be917
1127874
bd7ed4a
1c58cf9
8f3adff
d30ef53
9fa490a
1f18bf1
2df7419
0cbb916
0e8896f
a91d222
e66fb30
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
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( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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> | ||
|
@@ -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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nice 👌 |
||
} catch (apolloException: ApolloException) { | ||
val exception = apolloException.toClientException() | ||
FirebaseCrashlytics.getInstance().recordException(exception) | ||
|
There was a problem hiding this comment.
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