Add offline typeaheadSearch and replaced LiveData with Flow
This commit is contained in:
@ -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<TypeaheadCardData>
|
||||
}
|
||||
|
||||
@ -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<TypeaheadCardData> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
savedItemDao.getTypeaheadData(query)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun updateReadingProgress(
|
||||
itemId: String,
|
||||
readingProgressPercentage: Double,
|
||||
|
||||
@ -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<TypeaheadCardData>
|
||||
|
||||
@Query("SELECT * FROM savedItem WHERE serverSyncStatus != 0")
|
||||
fun getUnSynced(): List<SavedItem>
|
||||
|
||||
|
||||
@ -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<TypeaheadCardData> 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<SavedItemWithLabelsAndHighlights> by viewModel.itemsLiveData.observeAsState(
|
||||
listOf()
|
||||
)
|
||||
val cardsData: List<SavedItemWithLabelsAndHighlights> by viewModel.itemsState.collectAsStateWithLifecycle()
|
||||
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
|
||||
@ -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<String>(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<List<TypeaheadCardData>>(listOf())
|
||||
val itemsLiveData = MediatorLiveData<List<SavedItemWithLabelsAndHighlights>>()
|
||||
var isRefreshing = MutableStateFlow(false)
|
||||
val typeaheadMode = MutableStateFlow(true)
|
||||
val searchTextFlow = MutableStateFlow("")
|
||||
val searchItemsFlow = MutableStateFlow<List<TypeaheadCardData>>(emptyList())
|
||||
val itemsState = MutableStateFlow<List<SavedItemWithLabelsAndHighlights>>(emptyList())
|
||||
|
||||
override val actionsMenuItemLiveData = MutableLiveData<SavedItemWithLabelsAndHighlights?>(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 { "" }}"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user