From 4c70c8621ffd25a08dc3c865874acd68fab4ceb4 Mon Sep 17 00:00:00 2001 From: Mohamed Date: Wed, 29 May 2024 16:22:04 +0300 Subject: [PATCH 1/6] Added sync library after saving from share menu --- android/Omnivore/app/build.gradle.kts | 3 + .../Omnivore/app/src/main/AndroidManifest.xml | 6 + .../omnivore/omnivore/OmnivoreApplication.kt | 14 +- .../feature/library/LibrarySyncWorker.kt | 100 ++++++ .../feature/save/SaveSheetActivity.kt | 320 ++++++++++-------- .../omnivore/feature/save/SaveURLWorker.kt | 81 +++++ android/Omnivore/gradle/libs.versions.toml | 5 + 7 files changed, 391 insertions(+), 138 deletions(-) create mode 100644 android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/LibrarySyncWorker.kt create mode 100644 android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/save/SaveURLWorker.kt diff --git a/android/Omnivore/app/build.gradle.kts b/android/Omnivore/app/build.gradle.kts index f32c807ae..8ef784415 100644 --- a/android/Omnivore/app/build.gradle.kts +++ b/android/Omnivore/app/build.gradle.kts @@ -168,6 +168,9 @@ dependencies { implementation(libs.androidx.core.splashscreen) + implementation(libs.work.runtime.ktx) + implementation(libs.hilt.work) + ksp(libs.hilt.work.compiler) } apollo { diff --git a/android/Omnivore/app/src/main/AndroidManifest.xml b/android/Omnivore/app/src/main/AndroidManifest.xml index cb7c7584f..5d3936eb1 100644 --- a/android/Omnivore/app/src/main/AndroidManifest.xml +++ b/android/Omnivore/app/src/main/AndroidManifest.xml @@ -55,5 +55,11 @@ android:exported="true" android:theme="@style/Theme.Omnivore"/> + + + diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/OmnivoreApplication.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/OmnivoreApplication.kt index 3add9d375..3f97b3a4f 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/OmnivoreApplication.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/OmnivoreApplication.kt @@ -1,11 +1,23 @@ package app.omnivore.omnivore import android.app.Application +import androidx.hilt.work.HiltWorkerFactory +import androidx.work.Configuration import dagger.hilt.android.HiltAndroidApp import io.intercom.android.sdk.Intercom +import javax.inject.Inject @HiltAndroidApp -class OmnivoreApplication: Application() { +class OmnivoreApplication: Application(), Configuration.Provider { + + @Inject + lateinit var workerFactory: HiltWorkerFactory + + override val workManagerConfiguration + get() = Configuration.Builder() + .setWorkerFactory(workerFactory) + .build() + override fun onCreate() { super.onCreate() diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/LibrarySyncWorker.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/LibrarySyncWorker.kt new file mode 100644 index 000000000..85ba6e69b --- /dev/null +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/LibrarySyncWorker.kt @@ -0,0 +1,100 @@ +package app.omnivore.omnivore.feature.library + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import app.omnivore.omnivore.core.data.repository.LibraryRepository +import app.omnivore.omnivore.core.datastore.DatastoreRepository +import app.omnivore.omnivore.core.datastore.libraryLastSyncTimestamp +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import java.time.Instant + +@HiltWorker +class LibrarySyncWorker @AssistedInject constructor( + @Assisted appContext: Context, + @Assisted workerParams: WorkerParameters, + private val libraryRepository: LibraryRepository, + private val datastoreRepository: DatastoreRepository, +) : CoroutineWorker(appContext, workerParams) { + + override suspend fun doWork(): Result { + return withContext(Dispatchers.IO) { + try { + performItemSync() + loadUsingSearchAPI() + Result.success() + } catch (e: Exception) { + e.printStackTrace() + Result.failure() + } + } + } + + private suspend fun performItemSync( + cursor: String? = null, + since: String = getLastSyncTime()?.toString() ?: Instant.MIN.toString(), + count: Int = 0, + syncStart: String = Instant.now().toString(), + ) { + libraryRepository.syncOfflineItemsWithServerIfNeeded() + + val result = libraryRepository.sync( + context = applicationContext, + since = since, + cursor = cursor, + limit = 10 + ) + val totalCount = count + result.count + + if (result.hasError) { + result.errorString?.let { errorString -> + println("SYNC ERROR: $errorString") + } + } + + if (!result.hasError && result.hasMoreItems && result.cursor != null) { + performItemSync( + cursor = result.cursor, + since = since, + count = totalCount, + syncStart = syncStart + ) + } else { + datastoreRepository.putString(libraryLastSyncTimestamp, syncStart) + } + } + + private suspend fun loadUsingSearchAPI() { + val result = libraryRepository.librarySearch( + context = applicationContext, + cursor = null, + query = "${SavedItemFilter.INBOX.queryString} ${SavedItemSortFilter.NEWEST.queryString}" + ) + result.savedItems.map { + val isSavedInDB = + libraryRepository.isSavedItemContentStoredInDB( + applicationContext, + it.savedItem.slug + ) + + if (!isSavedInDB) { + libraryRepository.fetchSavedItemContent(applicationContext, it.savedItem.slug) + } + } + } + + private fun getLastSyncTime(): Instant? = runBlocking { + datastoreRepository.getString(libraryLastSyncTimestamp)?.let { + try { + return@let Instant.parse(it) + } catch (e: Exception) { + return@let null + } + } + } +} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/save/SaveSheetActivity.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/save/SaveSheetActivity.kt index f1e6dbf1e..aaccf8e19 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/save/SaveSheetActivity.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/save/SaveSheetActivity.kt @@ -5,7 +5,6 @@ import android.content.Intent import android.os.Bundle import android.util.Log import androidx.activity.compose.setContent -import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -13,17 +12,27 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.runtime.* -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Alignment.Companion.TopCenter import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp +import androidx.work.Constraints +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.OutOfQuotaPolicy +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.workDataOf +import app.omnivore.omnivore.feature.library.LibrarySyncWorker import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlin.time.Duration.Companion.seconds +import kotlin.time.toJavaDuration // Not sure why we need this class, but directly opening SaveSheetActivity // causes the app to crash. @@ -32,159 +41,196 @@ class SaveSheetActivity : SaveSheetActivityBase() @AndroidEntryPoint @OptIn(ExperimentalMaterialApi::class) abstract class SaveSheetActivityBase : AppCompatActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) - val viewModel: SaveViewModel by viewModels() - var extractedText: String? + var extractedText: String? = null + var saveState: SaveState by mutableStateOf(SaveState.DEFAULT) + val workManager = WorkManager.getInstance(applicationContext) - when (intent?.action) { - Intent.ACTION_SEND -> { - if (intent.type?.startsWith("text/plain") == true) { - intent.getStringExtra(Intent.EXTRA_TEXT)?.let { - extractedText = it - viewModel.saveURL(it) - Log.d(ContentValues.TAG, "Extracted text: $extractedText") - } + when (intent?.action) { + Intent.ACTION_SEND -> { + if (intent.type?.startsWith("text/plain") == true) { + intent.getStringExtra(Intent.EXTRA_TEXT)?.let { + extractedText = it + workManager.enqueueSaveWorker(it) + Log.d(ContentValues.TAG, "Extracted text: $extractedText") + } + } + if (intent.type?.startsWith("text/html") == true) { + intent.getStringExtra(Intent.EXTRA_HTML_TEXT)?.let { + extractedText = it + } + } + } + + else -> { + // Handle other intents, such as being started from the home screen + } } - if (intent.type?.startsWith("text/html") == true) { - intent.getStringExtra(Intent.EXTRA_HTML_TEXT)?.let { - extractedText = it - } - } - } - else -> { - // Handle other intents, such as being started from the home screen - } + setContent { + LaunchedEffect(extractedText) { + extractedText?.let { url -> + workManager.getWorkInfosByTagFlow(url).map { + saveState = when (it.firstOrNull()?.state) { + WorkInfo.State.RUNNING -> SaveState.SAVING + WorkInfo.State.SUCCEEDED -> SaveState.SAVED + WorkInfo.State.FAILED -> SaveState.ERROR + else -> SaveState.SAVING + } + }.collect() + } + } + + val scaffoldState: ScaffoldState = rememberScaffoldState() + + + val message = when (saveState) { + SaveState.DEFAULT -> "" + SaveState.SAVING -> "Saved to Omnivore" + SaveState.ERROR -> "Error Saving Article" + SaveState.SAVED -> "Saved to Omnivore" + } + + Scaffold( + modifier = Modifier.clickable { + Log.d("debug", "DISMISS SCAFFOLD") + exit() + }, + scaffoldState = scaffoldState, + backgroundColor = Color.Transparent, + + // TODO: In future versions we can present Label, Note, Highlight options here + bottomBar = { + + androidx.compose.material3.BottomAppBar( + + modifier = Modifier + .height(55.dp) + .fillMaxWidth() + .clip(RoundedCornerShape(topEnd = 5.dp, topStart = 5.dp)), + containerColor = MaterialTheme.colors.background, + actions = { + Spacer(modifier = Modifier.width(25.dp)) + Text( + message, + style = androidx.compose.material3.MaterialTheme.typography.titleMedium + ) + }, + ) + }, + ) { + + } + + LaunchedEffect(saveState) { + if (saveState == SaveState.SAVED) { + delay(1.5.seconds) + exit() + } + } + } } - setContent { - val saveState: SaveState by viewModel.state.observeAsState(SaveState.DEFAULT) - val scaffoldState: ScaffoldState = rememberScaffoldState() + private fun WorkManager.enqueueSaveWorker(url: String) { + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + val saveWorkerRequest = OneTimeWorkRequestBuilder() + .setConstraints(constraints) + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .setInputData(workDataOf("url" to url)) + .addTag(url) + // Can add other configs like setBackoffCriteria to retry sync if failed + .build() + val syncWorkerRequest = OneTimeWorkRequestBuilder() + .setConstraints(constraints) + .addTag(url) + // delay to make sure the url title and content are fetched + .setInitialDelay(10.seconds.toJavaDuration()) + .build() - val message = when (saveState) { - SaveState.DEFAULT -> "" - SaveState.SAVING -> "Saved to Omnivore" - SaveState.ERROR -> "Error Saving Article" - SaveState.SAVED -> "Saved to Omnivore" - } - - Scaffold( - modifier = Modifier.clickable { - Log.d("debug", "DISMISS SCAFFOLD") - exit() - }, - scaffoldState = scaffoldState, - backgroundColor = Color.Transparent, - - // TODO: In future versions we can present Label, Note, Highlight options here - bottomBar = { - - androidx.compose.material3.BottomAppBar( + beginWith(saveWorkerRequest) + .then(syncWorkerRequest) + .enqueue() + } + @Composable + private fun BottomSheetUI(content: @Composable () -> Unit) { + Box( modifier = Modifier - .height(55.dp) - .fillMaxWidth() - .clip(RoundedCornerShape(topEnd = 5.dp, topStart = 5.dp)), - containerColor = MaterialTheme.colors.background, - actions = { - Spacer(modifier = Modifier.width(25.dp)) - Text( - message, - style = androidx.compose.material3.MaterialTheme.typography.titleMedium - ) - }, - ) - }, - ) { + .wrapContentHeight() + .fillMaxWidth() + .clip(RoundedCornerShape(topEnd = 20.dp, topStart = 20.dp)) + .background(Color.White) + .statusBarsPadding() + ) { + content() - } - - LaunchedEffect(saveState) { - if (saveState == SaveState.SAVED) { - delay(1.5.seconds) - exit() + Divider( + color = Color.Gray, + thickness = 5.dp, + modifier = Modifier + .padding(top = 15.dp) + .align(TopCenter) + .width(80.dp) + .clip(RoundedCornerShape(50.dp)) + ) } - } } - } - @Composable - private fun BottomSheetUI(content: @Composable () -> Unit) { - Box( - modifier = Modifier - .wrapContentHeight() - .fillMaxWidth() - .clip(RoundedCornerShape(topEnd = 20.dp, topStart = 20.dp)) - .background(Color.White) - .statusBarsPadding() + // Helper methods + private suspend fun handleBottomSheetAtHiddenState( + isSheetOpened: MutableState, + modalBottomSheetState: ModalBottomSheetState ) { - content() - - Divider( - color = Color.Gray, - thickness = 5.dp, - modifier = Modifier - .padding(top = 15.dp) - .align(TopCenter) - .width(80.dp) - .clip(RoundedCornerShape(50.dp)) - ) + when { + !isSheetOpened.value -> initializeModalLayout(isSheetOpened, modalBottomSheetState) + else -> exit() + } } - } - // Helper methods - private suspend fun handleBottomSheetAtHiddenState( - isSheetOpened: MutableState, - modalBottomSheetState: ModalBottomSheetState - ) { - when { - !isSheetOpened.value -> initializeModalLayout(isSheetOpened, modalBottomSheetState) - else -> exit() - } - } - - private suspend fun initializeModalLayout( - isSheetOpened: MutableState, - modalBottomSheetState: ModalBottomSheetState - ) { - isSheetOpened.value = true - modalBottomSheetState.show() - } - - open fun exit() = finish() - - private fun onFinish( - coroutineScope: CoroutineScope, - modalBottomSheetState: ModalBottomSheetState, - withResults: Boolean = false, - result: Intent? = null - ) { - coroutineScope.launch { - if (withResults) setResult(RESULT_OK) - result?.let { intent = it } - modalBottomSheetState.hide() // will trigger the LaunchedEffect - } - } - - @Composable - fun ScreenContent( - viewModel: SaveViewModel, - modalBottomSheetState: ModalBottomSheetState - ) { - Box( - modifier = Modifier - .height(300.dp) - .background(Color.White) + private suspend fun initializeModalLayout( + isSheetOpened: MutableState, + modalBottomSheetState: ModalBottomSheetState ) { - SaveContent(viewModel, modalBottomSheetState, modifier = Modifier.fillMaxSize()) + isSheetOpened.value = true + modalBottomSheetState.show() } - } - override fun onPause() { - super.onPause() - overridePendingTransition(0, 0) - } + open fun exit() = finish() + + private fun onFinish( + coroutineScope: CoroutineScope, + modalBottomSheetState: ModalBottomSheetState, + withResults: Boolean = false, + result: Intent? = null + ) { + coroutineScope.launch { + if (withResults) setResult(RESULT_OK) + result?.let { intent = it } + modalBottomSheetState.hide() // will trigger the LaunchedEffect + } + } + + @Composable + fun ScreenContent( + viewModel: SaveViewModel, + modalBottomSheetState: ModalBottomSheetState + ) { + Box( + modifier = Modifier + .height(300.dp) + .background(Color.White) + ) { + SaveContent(viewModel, modalBottomSheetState, modifier = Modifier.fillMaxSize()) + } + } + + override fun onPause() { + super.onPause() + overridePendingTransition(0, 0) + } } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/save/SaveURLWorker.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/save/SaveURLWorker.kt new file mode 100644 index 000000000..b305dd8a9 --- /dev/null +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/save/SaveURLWorker.kt @@ -0,0 +1,81 @@ +package app.omnivore.omnivore.feature.save + +import android.content.Context +import androidx.compose.ui.text.intl.Locale +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import app.omnivore.omnivore.core.datastore.DatastoreRepository +import app.omnivore.omnivore.core.datastore.omnivoreAuthToken +import app.omnivore.omnivore.graphql.generated.SaveUrlMutation +import app.omnivore.omnivore.graphql.generated.type.SaveUrlInput +import app.omnivore.omnivore.utils.Constants +import com.apollographql.apollo3.ApolloClient +import com.apollographql.apollo3.api.Optional +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.util.TimeZone +import java.util.UUID +import java.util.regex.Pattern + +@HiltWorker +class SaveURLWorker @AssistedInject constructor( + @Assisted appContext: Context, + @Assisted workerParams: WorkerParameters, + private val datastoreRepository: DatastoreRepository, +) : CoroutineWorker(appContext, workerParams) { + + override suspend fun doWork(): Result { + return withContext(Dispatchers.IO){ + val url = inputData.getString("url") ?: return@withContext Result.failure() + if (saveURL(url)) Result.success() else Result.failure() + } + } + + private suspend fun saveURL(url: String): Boolean { + + val authToken = datastoreRepository.getString(omnivoreAuthToken) ?: return false + + val apolloClient = ApolloClient.Builder() + .serverUrl("${Constants.apiURL}/api/graphql") + .addHttpHeader("Authorization", value = authToken) + .build() + + val cleanedUrl = cleanUrl(url) ?: url + + try { + val timezone = TimeZone.getDefault().id + val locale = Locale.current.toLanguageTag() + + val response = apolloClient.mutation( + SaveUrlMutation( + SaveUrlInput( + clientRequestId = UUID.randomUUID().toString(), + source = "android", + url = cleanedUrl, + timezone = Optional.present(timezone), + locale = Optional.present(locale) + ) + ) + ).execute() + + return (response.data?.saveUrl?.onSaveSuccess?.url != null) + } catch (e: Exception) { + return false + } + } + + private fun cleanUrl(text: String): String? { + val pattern = Pattern.compile("\\b(?:https?|ftp)://\\S+") + val matcher = pattern.matcher(text) + + if (matcher.find()) { + return matcher.group() + } + return null + } + + +} diff --git a/android/Omnivore/gradle/libs.versions.toml b/android/Omnivore/gradle/libs.versions.toml index 3ab32681d..f875bf6c9 100644 --- a/android/Omnivore/gradle/libs.versions.toml +++ b/android/Omnivore/gradle/libs.versions.toml @@ -31,6 +31,8 @@ posthog = "2.0.3" pspdfkit = "8.9.1" retrofit = "2.11.0" room = "2.6.1" +workManager = "2.9.0" +hiltWork = "1.2.0" [libraries] accompanist-flowlayout = { group = "com.google.accompanist", name = "accompanist-flowlayout", version.ref = "accompanistFlowLayout" } @@ -81,6 +83,9 @@ room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = " android-hilt = { group = "com.google.dagger", name = "hilt-android-gradle-plugin", version.ref = "hilt" } android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "androidGradlePlugin" } kotlin-gradlePlugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" } +work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workManager"} +hilt-work = { group = "androidx.hilt", name = "hilt-work", version.ref = "hiltWork"} +hilt-work-compiler = { group = "androidx.hilt", name = "hilt-compiler", version.ref = "hiltWork"} [plugins] android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } From b20f1ba522490acfaa095938907b341b2002fc17 Mon Sep 17 00:00:00 2001 From: Mohamed Date: Wed, 29 May 2024 16:35:52 +0300 Subject: [PATCH 2/6] Remove unnecessary abstract class in SaveSheetActivity --- .../app/omnivore/omnivore/feature/save/SaveSheetActivity.kt | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/save/SaveSheetActivity.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/save/SaveSheetActivity.kt index aaccf8e19..9fe114c34 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/save/SaveSheetActivity.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/save/SaveSheetActivity.kt @@ -34,13 +34,9 @@ import kotlinx.coroutines.launch import kotlin.time.Duration.Companion.seconds import kotlin.time.toJavaDuration -// Not sure why we need this class, but directly opening SaveSheetActivity -// causes the app to crash. -class SaveSheetActivity : SaveSheetActivityBase() - @AndroidEntryPoint @OptIn(ExperimentalMaterialApi::class) -abstract class SaveSheetActivityBase : AppCompatActivity() { +class SaveSheetActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) From fcf7f40af3efec034884b8d4cd892e33c7f3e18c Mon Sep 17 00:00:00 2001 From: Mohamed Date: Wed, 29 May 2024 16:55:49 +0300 Subject: [PATCH 3/6] Add animateItemPlacement to LazyColumn items --- .../omnivore/feature/library/LibraryView.kt | 25 +++++++++---------- .../omnivore/feature/library/SearchView.kt | 5 +++- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/LibraryView.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/LibraryView.kt index 4357371bd..f619d0dbf 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/LibraryView.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/LibraryView.kt @@ -1,12 +1,11 @@ package app.omnivore.omnivore.feature.library import android.content.Intent -import android.content.pm.PackageManager import android.util.Log -import android.widget.Toast import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.FloatTweenSpec import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -58,8 +57,6 @@ import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp -import androidx.core.app.ActivityCompat -import androidx.core.content.ContextCompat import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController @@ -80,7 +77,6 @@ import app.omnivore.omnivore.navigation.TopLevelDestination import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch -import java.util.jar.Manifest @Composable internal fun LibraryView( @@ -91,12 +87,11 @@ internal fun LibraryView( viewModel: LibraryViewModel = hiltViewModel() ) { val snackbarHostState = remember { SnackbarHostState() } - val coroutineScope = rememberCoroutineScope() val uiState by viewModel.uiState.collectAsStateWithLifecycle() - viewModel.snackbarMessage?.let { - coroutineScope.launch { + LaunchedEffect(viewModel.snackbarMessage) { + viewModel.snackbarMessage?.let { snackbarHostState.showSnackbar(it) viewModel.clearSnackbarMessage() } @@ -135,7 +130,8 @@ internal fun LibraryView( } LibraryBottomSheetState.EDIT -> { - EditBottomSheet(editInfoViewModel, + EditBottomSheet( + editInfoViewModel, deleteCurrentItem = { viewModel.currentItem.value = null }, { viewModel.refresh() }, viewModel.currentSavedItemUnderEdit() @@ -318,13 +314,15 @@ fun EditBottomSheet( } -@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class, + ExperimentalFoundationApi::class +) @Composable fun LibraryViewContent( itemsFilter: SavedItemFilter, activeLabels: List, sortFilter: SavedItemSortFilter, - updateSavedItemFilter:(SavedItemFilter) -> Unit, + updateSavedItemFilter: (SavedItemFilter) -> Unit, updateSavedItemSortFilter: (SavedItemSortFilter) -> Unit, setBottomSheetState: (LibraryBottomSheetState) -> Unit, updateAppliedLabels: (List) -> Unit, @@ -415,6 +413,7 @@ fun LibraryViewContent( true }) SwipeToDismiss( + modifier = Modifier.animateItemPlacement(), state = swipeState, directions = setOf( DismissDirection.StartToEnd, DismissDirection.EndToStart @@ -535,7 +534,7 @@ fun InfiniteListHandler( LaunchedEffect(loadMore) { snapshotFlow { loadMore.value }.distinctUntilChanged().collect { - onLoadMore() - } + onLoadMore() + } } } 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 9a77a9cca..452589ee8 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 @@ -1,6 +1,7 @@ package app.omnivore.omnivore.feature.library import android.content.Intent +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row @@ -151,6 +152,7 @@ fun SearchView( } } +@OptIn(ExperimentalFoundationApi::class) @Composable fun TypeaheadSearchViewContent(viewModel: SearchViewModel, modifier: Modifier) { val context = LocalContext.current @@ -169,8 +171,9 @@ fun TypeaheadSearchViewContent(viewModel: SearchViewModel, modifier: Modifier) { .fillMaxSize() ) { - items(searchedCardsData) { cardData -> + items(searchedCardsData, key = { it.savedItemId } ) { cardData -> TypeaheadSearchCard( + modifier = Modifier.animateItemPlacement(), cardData = cardData, onClickHandler = { // val activityClass = if (cardData.isPDF()) PDFReaderActivity::class.java else WebReaderLoadingContainerActivity::class.java From 8aadc9c5afc756b43600c2a213c284430edf27fe Mon Sep 17 00:00:00 2001 From: Mohamed Date: Wed, 29 May 2024 16:56:51 +0300 Subject: [PATCH 4/6] Handle some Compose side effects --- .../omnivore/feature/auth/WelcomeScreen.kt | 29 +++++++++---------- .../feature/following/FollowingScreen.kt | 8 ++--- .../omnivore/feature/root/RootView.kt | 5 ++-- .../savedItemViews/TypeaheadSearchCard.kt | 4 +-- 4 files changed, 20 insertions(+), 26 deletions(-) diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/WelcomeScreen.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/WelcomeScreen.kt index c43ecd15b..bc13591b6 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/WelcomeScreen.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/WelcomeScreen.kt @@ -22,7 +22,6 @@ import androidx.compose.ui.unit.dp import app.omnivore.omnivore.R import app.omnivore.omnivore.feature.theme.OmnivoreTheme import com.google.android.gms.common.GoogleApiAvailability -import kotlinx.coroutines.launch @Composable fun WelcomeScreen(viewModel: LoginViewModel) { @@ -40,11 +39,10 @@ fun WelcomeScreen(viewModel: LoginViewModel) { @Composable fun WelcomeScreenContent(viewModel: LoginViewModel) { val registrationState: RegistrationState by viewModel.registrationStateLiveData.observeAsState( - RegistrationState.SocialLogin - ) + RegistrationState.SocialLogin + ) val snackBarHostState = remember { SnackbarHostState() } - val coroutineScope = rememberCoroutineScope() Column( verticalArrangement = Arrangement.SpaceAround, @@ -101,19 +99,18 @@ fun WelcomeScreenContent(viewModel: LoginViewModel) { Spacer(modifier = Modifier.weight(1.0F)) } - if (viewModel.errorMessage != null) { - coroutineScope.launch { - val result = snackBarHostState.showSnackbar( - viewModel.errorMessage!!, - actionLabel = "Dismiss", - duration = SnackbarDuration.Indefinite - ) - when (result) { - SnackbarResult.ActionPerformed -> viewModel.resetErrorMessage() - else -> {} - } + LaunchedEffect(viewModel.errorMessage) { + val result = snackBarHostState.showSnackbar( + viewModel.errorMessage!!, + actionLabel = "Dismiss", + duration = SnackbarDuration.Indefinite + ) + when (result) { + SnackbarResult.ActionPerformed -> viewModel.resetErrorMessage() + else -> {} } - + } + if (viewModel.errorMessage != null) { SnackbarHost(hostState = snackBarHostState) } } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/following/FollowingScreen.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/following/FollowingScreen.kt index 0ad2fe432..5cb8ca50e 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/following/FollowingScreen.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/following/FollowingScreen.kt @@ -8,10 +8,10 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.StrokeCap @@ -43,12 +43,10 @@ internal fun FollowingScreen( ) { val snackbarHostState = remember { SnackbarHostState() } - val coroutineScope = rememberCoroutineScope() - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - viewModel.snackbarMessage?.let { - coroutineScope.launch { + LaunchedEffect(viewModel.snackbarMessage) { + viewModel.snackbarMessage?.let { snackbarHostState.showSnackbar(it) viewModel.clearSnackbarMessage() } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/root/RootView.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/root/RootView.kt index 5a7c39493..cd81bb79e 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/root/RootView.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/root/RootView.kt @@ -20,7 +20,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @@ -98,11 +98,10 @@ fun RootView( startDestination = startDestination, loginViewModel = loginViewModel ) - DisposableEffect(hasAuthToken) { + LaunchedEffect(hasAuthToken) { if (hasAuthToken) { loginViewModel.registerUser() } - onDispose {} } } } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/savedItemViews/TypeaheadSearchCard.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/savedItemViews/TypeaheadSearchCard.kt index 2eb86e04a..156e49eac 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/savedItemViews/TypeaheadSearchCard.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/savedItemViews/TypeaheadSearchCard.kt @@ -15,9 +15,9 @@ import app.omnivore.omnivore.feature.library.SavedItemAction @OptIn(ExperimentalFoundationApi::class) @Composable -fun TypeaheadSearchCard(cardData: TypeaheadCardData, onClickHandler: () -> Unit, actionHandler: (SavedItemAction) -> Unit) { +fun TypeaheadSearchCard(modifier: Modifier = Modifier, cardData: TypeaheadCardData, onClickHandler: () -> Unit, actionHandler: (SavedItemAction) -> Unit) { Column( - modifier = Modifier + modifier = modifier .combinedClickable( onClick = onClickHandler, ) From 98c724c4c6cc671df603f2a9c273f19aa82bf214 Mon Sep 17 00:00:00 2001 From: Mohamed Date: Thu, 30 May 2024 09:32:15 +0300 Subject: [PATCH 5/6] Decrease syncWorker initial delay --- .../app/omnivore/omnivore/feature/save/SaveSheetActivity.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/save/SaveSheetActivity.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/save/SaveSheetActivity.kt index 9fe114c34..9a2c99ae1 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/save/SaveSheetActivity.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/save/SaveSheetActivity.kt @@ -144,8 +144,7 @@ class SaveSheetActivity : AppCompatActivity() { val syncWorkerRequest = OneTimeWorkRequestBuilder() .setConstraints(constraints) .addTag(url) - // delay to make sure the url title and content are fetched - .setInitialDelay(10.seconds.toJavaDuration()) + .setInitialDelay(5.seconds.toJavaDuration()) .build() beginWith(saveWorkerRequest) From 74b3581a7467475cc6a005114ac60c318981f714 Mon Sep 17 00:00:00 2001 From: Mohamed Date: Thu, 30 May 2024 09:53:03 +0300 Subject: [PATCH 6/6] 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 { "" }}" }