From 74b3581a7467475cc6a005114ac60c318981f714 Mon Sep 17 00:00:00 2001 From: Mohamed Date: Thu, 30 May 2024 09:53:03 +0300 Subject: [PATCH] Add offline typeaheadSearch and replaced LiveData with Flow --- .../core/data/repository/LibraryRepository.kt | 3 + .../repository/impl/LibraryRepositoryImpl.kt | 9 ++ .../core/database/dao/SavedItemDao.kt | 4 + .../omnivore/feature/library/SearchView.kt | 17 ++- .../feature/library/SearchViewModel.kt | 103 +++++++----------- 5 files changed, 62 insertions(+), 74 deletions(-) diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/data/repository/LibraryRepository.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/data/repository/LibraryRepository.kt index 5b335a358..632f7c913 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/data/repository/LibraryRepository.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/data/repository/LibraryRepository.kt @@ -7,6 +7,7 @@ import app.omnivore.omnivore.core.data.model.LibraryQuery import app.omnivore.omnivore.core.database.entities.HighlightChange import app.omnivore.omnivore.core.database.entities.SavedItemLabel import app.omnivore.omnivore.core.database.entities.SavedItemWithLabelsAndHighlights +import app.omnivore.omnivore.core.database.entities.TypeaheadCardData import kotlinx.coroutines.flow.Flow interface LibraryRepository { @@ -46,4 +47,6 @@ interface LibraryRepository { suspend fun syncHighlightChange(highlightChange: HighlightChange): Boolean suspend fun sync(context: Context, since: String, cursor: String?, limit: Int = 20): SavedItemSyncResult + + suspend fun getTypeaheadData(query: String): List } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/data/repository/impl/LibraryRepositoryImpl.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/data/repository/impl/LibraryRepositoryImpl.kt index 5e4fbc1be..9830dcd69 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/data/repository/impl/LibraryRepositoryImpl.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/data/repository/impl/LibraryRepositoryImpl.kt @@ -20,6 +20,7 @@ import app.omnivore.omnivore.core.database.entities.SavedItem import app.omnivore.omnivore.core.database.entities.SavedItemAndSavedItemLabelCrossRef import app.omnivore.omnivore.core.database.entities.SavedItemLabel import app.omnivore.omnivore.core.database.entities.SavedItemWithLabelsAndHighlights +import app.omnivore.omnivore.core.database.entities.TypeaheadCardData import app.omnivore.omnivore.core.database.entities.highlightChangeToHighlight import app.omnivore.omnivore.core.network.Networker import app.omnivore.omnivore.core.network.ReadingProgressParams @@ -47,8 +48,10 @@ import app.omnivore.omnivore.graphql.generated.type.SetLabelsInput import app.omnivore.omnivore.graphql.generated.type.UpdateHighlightInput import com.apollographql.apollo3.api.Optional import com.google.gson.Gson +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withContext import javax.inject.Inject class LibraryRepositoryImpl @Inject constructor( @@ -94,6 +97,12 @@ class LibraryRepositoryImpl @Inject constructor( } } + override suspend fun getTypeaheadData(query: String): List { + return withContext(Dispatchers.IO) { + savedItemDao.getTypeaheadData(query) + } + } + override suspend fun updateReadingProgress( itemId: String, readingProgressPercentage: Double, diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/database/dao/SavedItemDao.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/database/dao/SavedItemDao.kt index 650cedde4..ca400bdfe 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/database/dao/SavedItemDao.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/database/dao/SavedItemDao.kt @@ -8,6 +8,7 @@ import androidx.room.Update import app.omnivore.omnivore.core.database.entities.SavedItem import app.omnivore.omnivore.core.database.entities.SavedItemQueryConstants import app.omnivore.omnivore.core.database.entities.SavedItemWithLabelsAndHighlights +import app.omnivore.omnivore.core.database.entities.TypeaheadCardData import kotlinx.coroutines.flow.Flow @Dao @@ -19,6 +20,9 @@ interface SavedItemDao { @Query("SELECT * FROM savedItem WHERE savedItemId = :itemID") suspend fun findById(itemID: String): SavedItem? + @Query("SELECT savedItemId, slug, title, isArchived FROM saveditem WHERE title LIKE '%' || :query || '%'") + suspend fun getTypeaheadData(query: String): List + @Query("SELECT * FROM savedItem WHERE serverSyncStatus != 0") fun getUnSynced(): List diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/SearchView.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/SearchView.kt index 452589ee8..5b18aae4f 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/SearchView.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/SearchView.kt @@ -27,6 +27,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Alignment @@ -36,10 +37,10 @@ import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController import app.omnivore.omnivore.R import app.omnivore.omnivore.core.database.entities.SavedItemWithLabelsAndHighlights -import app.omnivore.omnivore.core.database.entities.TypeaheadCardData import app.omnivore.omnivore.feature.reader.PDFReaderActivity import app.omnivore.omnivore.feature.reader.WebReaderLoadingContainerActivity import app.omnivore.omnivore.feature.savedItemViews.SavedItemCard @@ -51,9 +52,9 @@ fun SearchView( navController: NavHostController, viewModel: SearchViewModel = hiltViewModel() ) { - val isRefreshing: Boolean by viewModel.isRefreshing.observeAsState(false) - val typeaheadMode: Boolean by viewModel.typeaheadMode.observeAsState(true) - val searchText: String by viewModel.searchTextLiveData.observeAsState("") + val isRefreshing: Boolean by viewModel.isRefreshing.collectAsStateWithLifecycle() + val typeaheadMode: Boolean by viewModel.typeaheadMode.collectAsStateWithLifecycle() + val searchText: String by viewModel.searchTextFlow.collectAsState("") val actionsMenuItem: SavedItemWithLabelsAndHighlights? by viewModel.actionsMenuItemLiveData.observeAsState( null ) @@ -158,9 +159,7 @@ fun TypeaheadSearchViewContent(viewModel: SearchViewModel, modifier: Modifier) { val context = LocalContext.current val listState = rememberLazyListState() - val searchedCardsData: List by viewModel.searchItemsLiveData.observeAsState( - listOf() - ) + val searchedCardsData by viewModel.searchItemsFlow.collectAsStateWithLifecycle() LazyColumn( state = listState, @@ -193,9 +192,7 @@ fun SearchViewContent(viewModel: SearchViewModel, modifier: Modifier) { val context = LocalContext.current val listState = rememberLazyListState() - val cardsData: List by viewModel.itemsLiveData.observeAsState( - listOf() - ) + val cardsData: List by viewModel.itemsState.collectAsStateWithLifecycle() LazyColumn( state = listState, diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/SearchViewModel.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/SearchViewModel.kt index fda56889d..fee80bc09 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/SearchViewModel.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/SearchViewModel.kt @@ -1,7 +1,6 @@ package app.omnivore.omnivore.feature.library import android.content.Context -import androidx.lifecycle.MediatorLiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -10,68 +9,70 @@ import app.omnivore.omnivore.core.data.archiveSavedItem import app.omnivore.omnivore.core.data.deleteSavedItem import app.omnivore.omnivore.core.data.isSavedItemContentStoredInDB import app.omnivore.omnivore.core.data.librarySearch +import app.omnivore.omnivore.core.data.repository.LibraryRepository import app.omnivore.omnivore.core.data.unarchiveSavedItem import app.omnivore.omnivore.core.database.entities.SavedItemLabel import app.omnivore.omnivore.core.database.entities.SavedItemWithLabelsAndHighlights import app.omnivore.omnivore.core.database.entities.TypeaheadCardData import app.omnivore.omnivore.core.datastore.DatastoreRepository -import app.omnivore.omnivore.core.network.Networker -import app.omnivore.omnivore.core.network.typeaheadSearch import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import javax.inject.Inject +@OptIn(FlowPreview::class) @HiltViewModel class SearchViewModel @Inject constructor( - private val networker: Networker, private val dataService: DataService, private val datastoreRepo: DatastoreRepository, + private val repository: LibraryRepository, @ApplicationContext private val applicationContext: Context ) : ViewModel(), SavedItemViewModel { private val contentRequestChannel = Channel(capacity = Channel.UNLIMITED) - private var cursor: String? = null private var librarySearchCursor: String? = null - // These are used to make sure we handle search result - // responses in the right order - private var searchIdx = 0 - private var receivedIdx = 0 - - // Live Data - var isRefreshing = MutableLiveData(false) - val typeaheadMode = MutableLiveData(true) - val searchTextLiveData = MutableLiveData("") - val searchItemsLiveData = MutableLiveData>(listOf()) - val itemsLiveData = MediatorLiveData>() + var isRefreshing = MutableStateFlow(false) + val typeaheadMode = MutableStateFlow(true) + val searchTextFlow = MutableStateFlow("") + val searchItemsFlow = MutableStateFlow>(emptyList()) + val itemsState = MutableStateFlow>(emptyList()) override val actionsMenuItemLiveData = MutableLiveData(null) + init { + searchTextFlow + .debounce(300) + .onEach { + if (it.isBlank()){ + searchItemsFlow.update { emptyList() } + } else { + performTypeaheadSearch() + } + }.launchIn(viewModelScope) + } fun updateSearchText(text: String) { - typeaheadMode.postValue(true) - searchTextLiveData.value = text - - if (text == "") { - searchItemsLiveData.value = listOf() - } else { - viewModelScope.launch { - performTypeaheadSearch(true) - } - } + typeaheadMode.update { true } + searchTextFlow.update { text } } fun performSearch() { // To perform search we just clear the current state, so the LibraryView infinite scroll // load will update items. viewModelScope.launch { - isRefreshing.postValue(true) - itemsLiveData.postValue(listOf()) - typeaheadMode.postValue(false) + isRefreshing.update { true } + itemsState.update { (listOf()) } + typeaheadMode.update { false } loadUsingSearchAPI() } @@ -79,7 +80,6 @@ class SearchViewModel @Inject constructor( private fun loadUsingSearchAPI() { viewModelScope.launch { - val context = applicationContext withContext(Dispatchers.IO) { val result = dataService.librarySearch( context = applicationContext, @@ -122,40 +122,24 @@ class SearchViewModel @Inject constructor( } */ - itemsLiveData.value?.let { - itemsLiveData.postValue(newItems + it) - } ?: run { - itemsLiveData.postValue(newItems) + itemsState.update { + it + newItems } - isRefreshing.postValue(false) + isRefreshing.update { false } } } } - private suspend fun performTypeaheadSearch(clearPreviousSearch: Boolean) { - if (clearPreviousSearch) { - cursor = null - } - - val thisSearchIdx = searchIdx - searchIdx += 1 + private suspend fun performTypeaheadSearch() { + isRefreshing.update { true } // Execute the search - val searchResult = networker.typeaheadSearch(searchTextLiveData.value ?: "") + val searchResult = repository.getTypeaheadData(searchTextFlow.value) - // Search results aren't guaranteed to return in order so this - // will discard old results that are returned while a user is typing. - // For example if a user types 'Canucks', often the search results - // for 'C' are returned after 'Canucks' because it takes the backend - // much longer to compute. - if (thisSearchIdx in 1..receivedIdx) { - return - } + searchItemsFlow.update { searchResult } - searchItemsLiveData.postValue(searchResult.cardsData) - - isRefreshing.postValue(false) + isRefreshing.update { false } } override fun handleSavedItemAction(itemId: String, action: SavedItemAction) { @@ -219,14 +203,5 @@ class SearchViewModel @Inject constructor( // } } - private fun searchQueryString(): String { - var query = "" - val searchText = searchTextLiveData.value ?: "" - - if (searchText.isNotEmpty()) { - query += " $searchText" - } - - return query - } + private fun searchQueryString() = " ${searchTextFlow.value.ifBlank { "" }}" }