@ -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 {
|
||||
|
||||
@ -55,5 +55,11 @@
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.Omnivore"/>
|
||||
|
||||
<provider
|
||||
android:name="androidx.startup.InitializationProvider"
|
||||
android:authorities="${applicationId}.androidx-startup"
|
||||
tools:node="remove">
|
||||
</provider>
|
||||
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<SavedItemLabel>,
|
||||
sortFilter: SavedItemSortFilter,
|
||||
updateSavedItemFilter:(SavedItemFilter) -> Unit,
|
||||
updateSavedItemFilter: (SavedItemFilter) -> Unit,
|
||||
updateSavedItemSortFilter: (SavedItemSortFilter) -> Unit,
|
||||
setBottomSheetState: (LibraryBottomSheetState) -> Unit,
|
||||
updateAppliedLabels: (List<SavedItemLabel>) -> 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
@ -26,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
|
||||
@ -35,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
|
||||
@ -50,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
|
||||
)
|
||||
@ -151,14 +153,13 @@ fun SearchView(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
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,
|
||||
@ -169,8 +170,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
|
||||
@ -190,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 { "" }}"
|
||||
}
|
||||
|
||||
@ -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 {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,178 +12,220 @@ 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
|
||||
|
||||
// Not sure why we need this class, but directly opening SaveSheetActivity
|
||||
// causes the app to crash.
|
||||
class SaveSheetActivity : SaveSheetActivityBase()
|
||||
import kotlin.time.toJavaDuration
|
||||
|
||||
@AndroidEntryPoint
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
abstract class SaveSheetActivityBase : AppCompatActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
class SaveSheetActivity : AppCompatActivity() {
|
||||
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<SaveURLWorker>()
|
||||
.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<LibrarySyncWorker>()
|
||||
.setConstraints(constraints)
|
||||
.addTag(url)
|
||||
.setInitialDelay(5.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<Boolean>,
|
||||
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<Boolean>,
|
||||
modalBottomSheetState: ModalBottomSheetState
|
||||
) {
|
||||
when {
|
||||
!isSheetOpened.value -> initializeModalLayout(isSheetOpened, modalBottomSheetState)
|
||||
else -> exit()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun initializeModalLayout(
|
||||
isSheetOpened: MutableState<Boolean>,
|
||||
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<Boolean>,
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user