mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
Update LibraryView to use ModalBottomSheet
using the modalBottomSheet will support back action, tapping off the bottomsheet, and swiping down
This commit is contained in:
parent
ec316cd9f9
commit
b7fd8a6326
6 changed files with 731 additions and 666 deletions
|
|
@ -24,12 +24,15 @@ class EventTracker @Inject constructor(val app: Context) {
|
|||
PostHog.setSingletonInstance(posthog)
|
||||
}
|
||||
|
||||
fun registerUser(userID: String, intercomHash: String?) {
|
||||
fun registerUser(userID: String, intercomHash: String?, isDebug: Boolean) {
|
||||
posthog.identify(userID)
|
||||
Intercom.client().loginIdentifiedUser(Registration.create().withUserId(userID))
|
||||
intercomHash?.let { intercomHash ->
|
||||
Intercom.client().setUserHash(intercomHash)
|
||||
if (!isDebug) {
|
||||
Intercom.client().loginIdentifiedUser(Registration.create().withUserId(userID))
|
||||
intercomHash?.let { intercomHash ->
|
||||
Intercom.client().setUserHash(intercomHash)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fun track(eventName: String, properties: Properties = Properties()) {
|
||||
|
|
|
|||
|
|
@ -130,7 +130,7 @@ class LoginViewModel @Inject constructor(
|
|||
viewModelScope.launch {
|
||||
val viewer = networker.viewer()
|
||||
viewer?.let {
|
||||
eventTracker.registerUser(viewer.userID, viewer.intercomHash)
|
||||
eventTracker.registerUser(viewer.userID, viewer.intercomHash, BuildConfig.DEBUG)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
package app.omnivore.omnivore.ui.library
|
||||
|
||||
enum class LibraryBottomSheetState {
|
||||
HIDDEN,
|
||||
ADD_LINK,
|
||||
LABEL,
|
||||
EDIT
|
||||
}
|
||||
|
|
@ -63,7 +63,7 @@ fun LibraryFilterBar(viewModel: LibraryViewModel) {
|
|||
modifier = Modifier.padding(end = 6.dp)
|
||||
)
|
||||
AssistChip(
|
||||
onClick = { viewModel.showLabelsSelectionSheetLiveData.value = true },
|
||||
onClick = { viewModel.bottomSheetState.value = LibraryBottomSheetState.LABEL },
|
||||
label = { Text(stringResource(R.string.library_filter_bar_label_labels)) },
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
|
|
|
|||
|
|
@ -3,37 +3,50 @@ package app.omnivore.omnivore.ui.library
|
|||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.AnimationSpec
|
||||
import androidx.compose.animation.core.FloatTweenSpec
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.DismissDirection
|
||||
import androidx.compose.material.DismissState
|
||||
import androidx.compose.material.FractionalThreshold
|
||||
import androidx.compose.material.DismissValue
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.material.FractionalThreshold
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.Scaffold
|
||||
import androidx.compose.material.ScaffoldState
|
||||
import androidx.compose.material.SwipeToDismiss
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Archive
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Unarchive
|
||||
import androidx.compose.material.pullrefresh.PullRefreshIndicator
|
||||
import androidx.compose.material.pullrefresh.pullRefresh
|
||||
import androidx.compose.material.pullrefresh.rememberPullRefreshState
|
||||
import androidx.compose.material.SwipeToDismiss
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Archive
|
||||
import androidx.compose.material.icons.filled.Unarchive
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.material.rememberDismissState
|
||||
import androidx.compose.material.rememberScaffoldState
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
|
@ -47,370 +60,386 @@ import app.omnivore.omnivore.ui.components.LabelsSelectionSheetContent
|
|||
import app.omnivore.omnivore.ui.components.LabelsViewModel
|
||||
import app.omnivore.omnivore.ui.editinfo.EditInfoSheetContent
|
||||
import app.omnivore.omnivore.ui.editinfo.EditInfoViewModel
|
||||
import app.omnivore.omnivore.ui.savedItemViews.SavedItemCard
|
||||
import app.omnivore.omnivore.ui.reader.PDFReaderActivity
|
||||
import app.omnivore.omnivore.ui.reader.WebReaderLoadingContainerActivity
|
||||
import app.omnivore.omnivore.ui.save.SaveState
|
||||
import app.omnivore.omnivore.ui.save.SaveViewModel
|
||||
import app.omnivore.omnivore.ui.savedItemViews.SavedItemCard
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
fun LibraryView(
|
||||
libraryViewModel: LibraryViewModel,
|
||||
labelsViewModel: LabelsViewModel,
|
||||
saveViewModel: SaveViewModel,
|
||||
editInfoViewModel: EditInfoViewModel,
|
||||
navController: NavHostController
|
||||
libraryViewModel: LibraryViewModel,
|
||||
labelsViewModel: LabelsViewModel,
|
||||
saveViewModel: SaveViewModel,
|
||||
editInfoViewModel: EditInfoViewModel,
|
||||
navController: NavHostController
|
||||
) {
|
||||
val scaffoldState: ScaffoldState = rememberScaffoldState()
|
||||
val showLabelsSelectionSheet: Boolean by libraryViewModel.showLabelsSelectionSheetLiveData.observeAsState(false)
|
||||
val showAddLinkSheet: Boolean by libraryViewModel.showAddLinkSheetLiveData.observeAsState(false)
|
||||
val showEditInfoSheet: Boolean by libraryViewModel.showEditInfoSheetLiveData.observeAsState(false)
|
||||
val scaffoldState: ScaffoldState = rememberScaffoldState()
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val modalBottomSheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden,
|
||||
confirmValueChange = { it != ModalBottomSheetValue.Hidden }
|
||||
)
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
if (showLabelsSelectionSheet || showAddLinkSheet || showEditInfoSheet) {
|
||||
coroutineScope.launch {
|
||||
modalBottomSheetState.show()
|
||||
}
|
||||
} else {
|
||||
coroutineScope.launch {
|
||||
modalBottomSheetState.hide()
|
||||
}
|
||||
}
|
||||
|
||||
libraryViewModel.snackbarMessage?.let {
|
||||
coroutineScope.launch {
|
||||
scaffoldState.snackbarHostState.showSnackbar(it)
|
||||
libraryViewModel.clearSnackbarMessage()
|
||||
}
|
||||
}
|
||||
|
||||
ModalBottomSheetLayout(
|
||||
sheetBackgroundColor = Color.Transparent,
|
||||
sheetState = modalBottomSheetState,
|
||||
sheetContent = {
|
||||
BottomSheetContent(libraryViewModel, labelsViewModel, saveViewModel,editInfoViewModel)
|
||||
Spacer(modifier = Modifier.weight(1.0F))
|
||||
}
|
||||
) {
|
||||
Scaffold(
|
||||
scaffoldState = scaffoldState,
|
||||
topBar = {
|
||||
LibraryNavigationBar(
|
||||
savedItemViewModel = libraryViewModel,
|
||||
onSearchClicked = { navController.navigate(Routes.Search.route) },
|
||||
onAddLinkClicked = { libraryViewModel.showAddLinkSheetLiveData.value = true },
|
||||
onSettingsIconClick = { navController.navigate(Routes.Settings.route) }
|
||||
)
|
||||
},
|
||||
) { paddingValues ->
|
||||
LibraryViewContent(
|
||||
libraryViewModel,
|
||||
modifier = Modifier
|
||||
.padding(top = paddingValues.calculateTopPadding())
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BottomSheetContent(libraryViewModel: LibraryViewModel,
|
||||
labelsViewModel: LabelsViewModel,
|
||||
saveViewModel: SaveViewModel,
|
||||
editInfoViewModel: EditInfoViewModel
|
||||
) {
|
||||
val showLabelsSelectionSheet: Boolean by libraryViewModel.showLabelsSelectionSheetLiveData.observeAsState(false)
|
||||
val showAddLinkSheet: Boolean by libraryViewModel.showAddLinkSheetLiveData.observeAsState(false)
|
||||
val showEditInfoSheet: Boolean by libraryViewModel.showEditInfoSheetLiveData.observeAsState(false)
|
||||
val currentSavedItemData = libraryViewModel.currentSavedItemUnderEdit()
|
||||
val labels: List<SavedItemLabel> by libraryViewModel.savedItemLabelsLiveData.observeAsState(listOf())
|
||||
|
||||
if (showLabelsSelectionSheet) {
|
||||
BottomSheetUI {
|
||||
if (currentSavedItemData != null) {
|
||||
LabelsSelectionSheetContent(
|
||||
labels = labels,
|
||||
labelsViewModel = labelsViewModel,
|
||||
initialSelectedLabels = currentSavedItemData.labels,
|
||||
onCancel = {
|
||||
libraryViewModel.showLabelsSelectionSheetLiveData.value = false
|
||||
libraryViewModel.currentItemLiveData.value = null
|
||||
},
|
||||
isLibraryMode = false,
|
||||
onSave = {
|
||||
if (it != labels) {
|
||||
libraryViewModel.updateSavedItemLabels(
|
||||
savedItemID = currentSavedItemData.savedItem.savedItemId,
|
||||
labels = it
|
||||
)
|
||||
}
|
||||
libraryViewModel.currentItemLiveData.value = null
|
||||
libraryViewModel.showLabelsSelectionSheetLiveData.value = false
|
||||
},
|
||||
onCreateLabel = { newLabelName, labelHexValue ->
|
||||
libraryViewModel.createNewSavedItemLabel(newLabelName, labelHexValue)
|
||||
}
|
||||
)
|
||||
} else { // Is used in library mode
|
||||
LabelsSelectionSheetContent(
|
||||
labels = labels,
|
||||
labelsViewModel = labelsViewModel,
|
||||
initialSelectedLabels = libraryViewModel.activeLabelsLiveData.value ?: listOf(),
|
||||
onCancel = { libraryViewModel.showLabelsSelectionSheetLiveData.value = false },
|
||||
isLibraryMode = true,
|
||||
onSave = {
|
||||
libraryViewModel.updateAppliedLabels(it)
|
||||
libraryViewModel.currentItemLiveData.value = null
|
||||
libraryViewModel.showLabelsSelectionSheetLiveData.value = false
|
||||
},
|
||||
onCreateLabel = { newLabelName, labelHexValue ->
|
||||
libraryViewModel.createNewSavedItemLabel(newLabelName, labelHexValue)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (showAddLinkSheet) {
|
||||
BottomSheetUI {
|
||||
AddLinkSheetContent(
|
||||
viewModel = saveViewModel,
|
||||
onCancel = {
|
||||
libraryViewModel.showAddLinkSheetLiveData.value = false
|
||||
saveViewModel.state.value = SaveState.DEFAULT
|
||||
},
|
||||
onLinkAdded = {
|
||||
libraryViewModel.showAddLinkSheetLiveData.value = false
|
||||
saveViewModel.state.value = SaveState.DEFAULT
|
||||
}
|
||||
)
|
||||
}
|
||||
} else if (showEditInfoSheet) {
|
||||
BottomSheetUI {
|
||||
EditInfoSheetContent(
|
||||
savedItemId = currentSavedItemData?.savedItem?.savedItemId,
|
||||
title = currentSavedItemData?.savedItem?.title,
|
||||
author = currentSavedItemData?.savedItem?.author,
|
||||
description = currentSavedItemData?.savedItem?.descriptionText,
|
||||
viewModel = editInfoViewModel,
|
||||
onCancel = {
|
||||
libraryViewModel.showEditInfoSheetLiveData.value = false
|
||||
libraryViewModel.currentItemLiveData.value = null
|
||||
},
|
||||
onUpdated = {
|
||||
libraryViewModel.showEditInfoSheetLiveData.value = false
|
||||
libraryViewModel.currentItemLiveData.value = null
|
||||
libraryViewModel.refresh()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LibraryViewContent(libraryViewModel: LibraryViewModel, modifier: Modifier) {
|
||||
val context = LocalContext.current
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
val pullRefreshState = rememberPullRefreshState(
|
||||
refreshing = libraryViewModel.isRefreshing,
|
||||
onRefresh = { libraryViewModel.refresh() }
|
||||
)
|
||||
|
||||
val selectedItem: SavedItemWithLabelsAndHighlights? by libraryViewModel.actionsMenuItemLiveData.observeAsState()
|
||||
val cardsData: List<SavedItemWithLabelsAndHighlights> by libraryViewModel.itemsLiveData.observeAsState(
|
||||
listOf()
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.pullRefresh(pullRefreshState)
|
||||
) {
|
||||
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
verticalArrangement = Arrangement.Top,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = modifier
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 6.dp)
|
||||
) {
|
||||
item {
|
||||
LibraryFilterBar(libraryViewModel)
|
||||
}
|
||||
items(
|
||||
items = cardsData,
|
||||
key = { item -> item.savedItem.savedItemId }
|
||||
) { cardDataWithLabels ->
|
||||
val swipeThreshold = 0.40f
|
||||
|
||||
val currentThresholdFraction = remember { mutableStateOf(0f) }
|
||||
val currentItem by rememberUpdatedState(cardDataWithLabels.savedItem)
|
||||
val swipeState = rememberDismissState(
|
||||
confirmStateChange = {
|
||||
if (it == DismissValue.DismissedToEnd ||
|
||||
currentThresholdFraction.value < swipeThreshold ||
|
||||
currentThresholdFraction.value > 1.0f) {
|
||||
false
|
||||
}
|
||||
|
||||
if (it == DismissValue.DismissedToEnd) { // Archiving/UnArchiving.
|
||||
if (currentItem.isArchived) {
|
||||
libraryViewModel.unarchiveSavedItem(currentItem.savedItemId)
|
||||
} else {
|
||||
libraryViewModel.archiveSavedItem(currentItem.savedItemId)
|
||||
}
|
||||
} else if (it == DismissValue.DismissedToStart) { // Deleting.
|
||||
libraryViewModel.deleteSavedItem(currentItem.savedItemId)
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
)
|
||||
SwipeToDismiss(
|
||||
state = swipeState,
|
||||
modifier = Modifier.padding(vertical = 4.dp),
|
||||
directions = setOf(DismissDirection.StartToEnd, DismissDirection.EndToStart),
|
||||
dismissThresholds = { FractionalThreshold(swipeThreshold) },
|
||||
background = {
|
||||
val direction = swipeState.dismissDirection ?: return@SwipeToDismiss
|
||||
val color by animateColorAsState(
|
||||
when (swipeState.targetValue) {
|
||||
DismissValue.Default -> Color.LightGray
|
||||
DismissValue.DismissedToEnd -> Color.Green
|
||||
DismissValue.DismissedToStart -> Color.Red
|
||||
}, label = "backgroundColor"
|
||||
)
|
||||
val alignment = when (direction) {
|
||||
DismissDirection.StartToEnd -> Alignment.CenterStart
|
||||
DismissDirection.EndToStart -> Alignment.CenterEnd
|
||||
}
|
||||
val icon = when (direction) {
|
||||
DismissDirection.StartToEnd -> if (currentItem.isArchived) Icons.Default.Unarchive else Icons.Default.Archive
|
||||
DismissDirection.EndToStart -> Icons.Default.Delete
|
||||
}
|
||||
val scale by animateFloatAsState(
|
||||
if (swipeState.targetValue == DismissValue.Default) 0.75f else 1f,
|
||||
label = "scaleAnimation"
|
||||
)
|
||||
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(color).padding(horizontal = 20.dp),
|
||||
contentAlignment = alignment
|
||||
) {
|
||||
currentThresholdFraction.value = swipeState.progress.fraction
|
||||
Icon(
|
||||
icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.scale(scale)
|
||||
)
|
||||
}
|
||||
},
|
||||
dismissContent = {
|
||||
val selected = currentItem.savedItemId == selectedItem?.savedItem?.savedItemId
|
||||
SavedItemCard(
|
||||
selected = selected,
|
||||
savedItemViewModel = libraryViewModel,
|
||||
savedItem = cardDataWithLabels,
|
||||
onClickHandler = {
|
||||
libraryViewModel.actionsMenuItemLiveData.postValue(null)
|
||||
val activityClass =
|
||||
if (currentItem.contentReader == "PDF") PDFReaderActivity::class.java else WebReaderLoadingContainerActivity::class.java
|
||||
val intent = Intent(context, activityClass)
|
||||
intent.putExtra("SAVED_ITEM_SLUG", currentItem.slug)
|
||||
context.startActivity(intent)
|
||||
},
|
||||
actionHandler = {
|
||||
libraryViewModel.handleSavedItemAction(
|
||||
currentItem.savedItemId,
|
||||
it
|
||||
)
|
||||
}
|
||||
)
|
||||
},
|
||||
)
|
||||
when {
|
||||
swipeState.isDismissed(DismissDirection.EndToStart) -> Reset(state = swipeState)
|
||||
swipeState.isDismissed(DismissDirection.StartToEnd) -> Reset(state = swipeState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
InfiniteListHandler(listState = listState) {
|
||||
if (cardsData.isEmpty()) {
|
||||
Log.d("sync", "loading with load func")
|
||||
libraryViewModel.initialLoad()
|
||||
} else {
|
||||
Log.d("sync", "loading with search api")
|
||||
libraryViewModel.loadUsingSearchAPI()
|
||||
}
|
||||
}
|
||||
|
||||
PullRefreshIndicator(
|
||||
refreshing = libraryViewModel.isRefreshing,
|
||||
state = pullRefreshState,
|
||||
modifier = Modifier.align(Alignment.TopCenter)
|
||||
val showBottomSheet: LibraryBottomSheetState by libraryViewModel.bottomSheetState.observeAsState(
|
||||
LibraryBottomSheetState.HIDDEN
|
||||
)
|
||||
|
||||
// LabelsSelectionSheet(viewModel = libraryViewModel)
|
||||
}
|
||||
libraryViewModel.snackbarMessage?.let {
|
||||
coroutineScope.launch {
|
||||
scaffoldState.snackbarHostState.showSnackbar(it)
|
||||
libraryViewModel.clearSnackbarMessage()
|
||||
}
|
||||
}
|
||||
|
||||
when (showBottomSheet) {
|
||||
LibraryBottomSheetState.ADD_LINK -> {
|
||||
AddLinkBottomSheet(saveViewModel) {
|
||||
libraryViewModel.bottomSheetState.value = LibraryBottomSheetState.HIDDEN
|
||||
}
|
||||
}
|
||||
|
||||
LibraryBottomSheetState.LABEL -> {
|
||||
LabelBottomSheet(
|
||||
libraryViewModel,
|
||||
labelsViewModel
|
||||
) {
|
||||
libraryViewModel.bottomSheetState.value = LibraryBottomSheetState.HIDDEN
|
||||
}
|
||||
}
|
||||
|
||||
LibraryBottomSheetState.EDIT -> {
|
||||
EditBottomSheet(
|
||||
editInfoViewModel,
|
||||
libraryViewModel
|
||||
) {
|
||||
libraryViewModel.bottomSheetState.value = LibraryBottomSheetState.HIDDEN
|
||||
}
|
||||
}
|
||||
|
||||
LibraryBottomSheetState.HIDDEN -> {
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
scaffoldState = scaffoldState,
|
||||
topBar = {
|
||||
LibraryNavigationBar(
|
||||
savedItemViewModel = libraryViewModel,
|
||||
onSearchClicked = { navController.navigate(Routes.Search.route) },
|
||||
onAddLinkClicked = { showAddLinkBottomSheet(libraryViewModel) },
|
||||
onSettingsIconClick = { navController.navigate(Routes.Settings.route) }
|
||||
)
|
||||
},
|
||||
) { paddingValues ->
|
||||
LibraryViewContent(
|
||||
libraryViewModel,
|
||||
modifier = Modifier
|
||||
.padding(top = paddingValues.calculateTopPadding())
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun showAddLinkBottomSheet(libraryViewModel: LibraryViewModel) {
|
||||
libraryViewModel.bottomSheetState.value = LibraryBottomSheetState.ADD_LINK
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LabelBottomSheet(
|
||||
libraryViewModel: LibraryViewModel,
|
||||
labelsViewModel: LabelsViewModel,
|
||||
onDismiss: () -> Unit = {}
|
||||
) {
|
||||
ModalBottomSheet(onDismissRequest = { onDismiss() }) {
|
||||
val currentSavedItemData = libraryViewModel.currentSavedItemUnderEdit()
|
||||
val labels: List<SavedItemLabel> by libraryViewModel.savedItemLabelsLiveData.observeAsState(
|
||||
listOf()
|
||||
)
|
||||
if (currentSavedItemData != null) {
|
||||
LabelsSelectionSheetContent(
|
||||
labels = labels,
|
||||
labelsViewModel = labelsViewModel,
|
||||
initialSelectedLabels = currentSavedItemData.labels,
|
||||
onCancel = {
|
||||
libraryViewModel.currentItemLiveData.value = null
|
||||
onDismiss()
|
||||
},
|
||||
isLibraryMode = false,
|
||||
onSave = {
|
||||
if (it != labels) {
|
||||
libraryViewModel.updateSavedItemLabels(
|
||||
savedItemID = currentSavedItemData.savedItem.savedItemId,
|
||||
labels = it
|
||||
)
|
||||
}
|
||||
libraryViewModel.currentItemLiveData.value = null
|
||||
onDismiss()
|
||||
},
|
||||
onCreateLabel = { newLabelName, labelHexValue ->
|
||||
libraryViewModel.createNewSavedItemLabel(newLabelName, labelHexValue)
|
||||
}
|
||||
)
|
||||
} else { // Is used in library mode
|
||||
LabelsSelectionSheetContent(
|
||||
labels = labels,
|
||||
labelsViewModel = labelsViewModel,
|
||||
initialSelectedLabels = libraryViewModel.activeLabelsLiveData.value ?: listOf(),
|
||||
onCancel = { onDismiss() },
|
||||
isLibraryMode = true,
|
||||
onSave = {
|
||||
libraryViewModel.updateAppliedLabels(it)
|
||||
libraryViewModel.currentItemLiveData.value = null
|
||||
onDismiss()
|
||||
},
|
||||
onCreateLabel = { newLabelName, labelHexValue ->
|
||||
libraryViewModel.createNewSavedItemLabel(newLabelName, labelHexValue)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AddLinkBottomSheet(
|
||||
saveViewModel: SaveViewModel,
|
||||
onDismiss: () -> Unit = {}
|
||||
) {
|
||||
ModalBottomSheet(onDismissRequest = { onDismiss() }) {
|
||||
AddLinkSheetContent(
|
||||
viewModel = saveViewModel,
|
||||
onCancel = {
|
||||
saveViewModel.state.value = SaveState.DEFAULT
|
||||
onDismiss()
|
||||
},
|
||||
onLinkAdded = {
|
||||
saveViewModel.state.value = SaveState.DEFAULT
|
||||
onDismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun EditBottomSheet(
|
||||
editInfoViewModel: EditInfoViewModel,
|
||||
libraryViewModel: LibraryViewModel,
|
||||
onDismiss: () -> Unit = {}
|
||||
) {
|
||||
ModalBottomSheet(onDismissRequest = { onDismiss() }) {
|
||||
val currentSavedItemData = libraryViewModel.currentSavedItemUnderEdit()
|
||||
EditInfoSheetContent(
|
||||
savedItemId = currentSavedItemData?.savedItem?.savedItemId,
|
||||
title = currentSavedItemData?.savedItem?.title,
|
||||
author = currentSavedItemData?.savedItem?.author,
|
||||
description = currentSavedItemData?.savedItem?.descriptionText,
|
||||
viewModel = editInfoViewModel,
|
||||
onCancel = {
|
||||
libraryViewModel.currentItemLiveData.value = null
|
||||
onDismiss()
|
||||
},
|
||||
onUpdated = {
|
||||
libraryViewModel.currentItemLiveData.value = null
|
||||
libraryViewModel.refresh()
|
||||
onDismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
fun LibraryViewContent(libraryViewModel: LibraryViewModel, modifier: Modifier) {
|
||||
val context = LocalContext.current
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
val pullRefreshState = rememberPullRefreshState(
|
||||
refreshing = libraryViewModel.isRefreshing,
|
||||
onRefresh = { libraryViewModel.refresh() }
|
||||
)
|
||||
|
||||
val selectedItem: SavedItemWithLabelsAndHighlights? by libraryViewModel.actionsMenuItemLiveData.observeAsState()
|
||||
val cardsData: List<SavedItemWithLabelsAndHighlights> by libraryViewModel.itemsLiveData.observeAsState(
|
||||
listOf()
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.pullRefresh(pullRefreshState)
|
||||
) {
|
||||
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
verticalArrangement = Arrangement.Top,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = modifier
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 6.dp)
|
||||
) {
|
||||
item {
|
||||
LibraryFilterBar(libraryViewModel)
|
||||
}
|
||||
items(
|
||||
items = cardsData,
|
||||
key = { item -> item.savedItem.savedItemId }
|
||||
) { cardDataWithLabels ->
|
||||
val swipeThreshold = 0.40f
|
||||
|
||||
val currentThresholdFraction = remember { mutableStateOf(0f) }
|
||||
val currentItem by rememberUpdatedState(cardDataWithLabels.savedItem)
|
||||
val swipeState = rememberDismissState(
|
||||
confirmStateChange = {
|
||||
if (it == DismissValue.DismissedToEnd ||
|
||||
currentThresholdFraction.value < swipeThreshold ||
|
||||
currentThresholdFraction.value > 1.0f
|
||||
) {
|
||||
false
|
||||
}
|
||||
|
||||
if (it == DismissValue.DismissedToEnd) { // Archiving/UnArchiving.
|
||||
if (currentItem.isArchived) {
|
||||
libraryViewModel.unarchiveSavedItem(currentItem.savedItemId)
|
||||
} else {
|
||||
libraryViewModel.archiveSavedItem(currentItem.savedItemId)
|
||||
}
|
||||
} else if (it == DismissValue.DismissedToStart) { // Deleting.
|
||||
libraryViewModel.deleteSavedItem(currentItem.savedItemId)
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
)
|
||||
SwipeToDismiss(
|
||||
state = swipeState,
|
||||
modifier = Modifier.padding(vertical = 4.dp),
|
||||
directions = setOf(DismissDirection.StartToEnd, DismissDirection.EndToStart),
|
||||
dismissThresholds = { FractionalThreshold(swipeThreshold) },
|
||||
background = {
|
||||
val direction = swipeState.dismissDirection ?: return@SwipeToDismiss
|
||||
val color by animateColorAsState(
|
||||
when (swipeState.targetValue) {
|
||||
DismissValue.Default -> Color.LightGray
|
||||
DismissValue.DismissedToEnd -> Color.Green
|
||||
DismissValue.DismissedToStart -> Color.Red
|
||||
}, label = "backgroundColor"
|
||||
)
|
||||
val alignment = when (direction) {
|
||||
DismissDirection.StartToEnd -> Alignment.CenterStart
|
||||
DismissDirection.EndToStart -> Alignment.CenterEnd
|
||||
}
|
||||
val icon = when (direction) {
|
||||
DismissDirection.StartToEnd -> if (currentItem.isArchived) Icons.Default.Unarchive else Icons.Default.Archive
|
||||
DismissDirection.EndToStart -> Icons.Default.Delete
|
||||
}
|
||||
val scale by animateFloatAsState(
|
||||
if (swipeState.targetValue == DismissValue.Default) 0.75f else 1f,
|
||||
label = "scaleAnimation"
|
||||
)
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(color)
|
||||
.padding(horizontal = 20.dp),
|
||||
contentAlignment = alignment
|
||||
) {
|
||||
currentThresholdFraction.value = swipeState.progress.fraction
|
||||
Icon(
|
||||
icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.scale(scale)
|
||||
)
|
||||
}
|
||||
},
|
||||
dismissContent = {
|
||||
val selected =
|
||||
currentItem.savedItemId == selectedItem?.savedItem?.savedItemId
|
||||
SavedItemCard(
|
||||
selected = selected,
|
||||
savedItemViewModel = libraryViewModel,
|
||||
savedItem = cardDataWithLabels,
|
||||
onClickHandler = {
|
||||
libraryViewModel.actionsMenuItemLiveData.postValue(null)
|
||||
val activityClass =
|
||||
if (currentItem.contentReader == "PDF") PDFReaderActivity::class.java else WebReaderLoadingContainerActivity::class.java
|
||||
val intent = Intent(context, activityClass)
|
||||
intent.putExtra("SAVED_ITEM_SLUG", currentItem.slug)
|
||||
context.startActivity(intent)
|
||||
},
|
||||
actionHandler = {
|
||||
libraryViewModel.handleSavedItemAction(
|
||||
currentItem.savedItemId,
|
||||
it
|
||||
)
|
||||
}
|
||||
)
|
||||
},
|
||||
)
|
||||
when {
|
||||
swipeState.isDismissed(DismissDirection.EndToStart) -> Reset(state = swipeState)
|
||||
swipeState.isDismissed(DismissDirection.StartToEnd) -> Reset(state = swipeState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
InfiniteListHandler(listState = listState) {
|
||||
if (cardsData.isEmpty()) {
|
||||
Log.d("sync", "loading with load func")
|
||||
libraryViewModel.initialLoad()
|
||||
} else {
|
||||
Log.d("sync", "loading with search api")
|
||||
libraryViewModel.loadUsingSearchAPI()
|
||||
}
|
||||
}
|
||||
|
||||
PullRefreshIndicator(
|
||||
refreshing = libraryViewModel.isRefreshing,
|
||||
state = pullRefreshState,
|
||||
modifier = Modifier.align(Alignment.TopCenter)
|
||||
)
|
||||
|
||||
// LabelsSelectionSheet(viewModel = libraryViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
private fun Reset(state: DismissState) {
|
||||
val scope = rememberCoroutineScope()
|
||||
LaunchedEffect(key1 = state.dismissDirection) {
|
||||
scope.launch {
|
||||
state.reset()
|
||||
state.animateTo(DismissValue.Default, FloatTweenSpec(duration= 0, delay = 0))
|
||||
val scope = rememberCoroutineScope()
|
||||
LaunchedEffect(key1 = state.dismissDirection) {
|
||||
scope.launch {
|
||||
state.reset()
|
||||
state.animateTo(DismissValue.Default, FloatTweenSpec(duration = 0, delay = 0))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BottomSheetUI(content: @Composable () -> Unit) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.wrapContentHeight()
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(topEnd = 20.dp, topStart = 20.dp))
|
||||
.background(Color.White)
|
||||
.statusBarsPadding()
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
fun InfiniteListHandler(
|
||||
listState: LazyListState,
|
||||
buffer: Int = 2,
|
||||
onLoadMore: () -> Unit
|
||||
listState: LazyListState,
|
||||
buffer: Int = 2,
|
||||
onLoadMore: () -> Unit
|
||||
) {
|
||||
val loadMore = remember {
|
||||
derivedStateOf {
|
||||
val layoutInfo = listState.layoutInfo
|
||||
val totalItemsNumber = layoutInfo.totalItemsCount
|
||||
val lastVisibleItemIndex = (layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1
|
||||
val loadMore = remember {
|
||||
derivedStateOf {
|
||||
val layoutInfo = listState.layoutInfo
|
||||
val totalItemsNumber = layoutInfo.totalItemsCount
|
||||
val lastVisibleItemIndex = (layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1
|
||||
|
||||
lastVisibleItemIndex > (totalItemsNumber - buffer)
|
||||
lastVisibleItemIndex > (totalItemsNumber - buffer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(loadMore) {
|
||||
snapshotFlow { loadMore.value }
|
||||
.distinctUntilChanged()
|
||||
.collect {
|
||||
onLoadMore()
|
||||
}
|
||||
}
|
||||
LaunchedEffect(loadMore) {
|
||||
snapshotFlow { loadMore.value }
|
||||
.distinctUntilChanged()
|
||||
.collect {
|
||||
onLoadMore()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,155 +20,160 @@ import javax.inject.Inject
|
|||
|
||||
@HiltViewModel
|
||||
class LibraryViewModel @Inject constructor(
|
||||
private val networker: Networker,
|
||||
private val dataService: DataService,
|
||||
private val datastoreRepo: DatastoreRepository,
|
||||
private val resourceProvider: ResourceProvider
|
||||
): ViewModel(), SavedItemViewModel {
|
||||
private val contentRequestChannel = Channel<String>(capacity = Channel.UNLIMITED)
|
||||
private val networker: Networker,
|
||||
private val dataService: DataService,
|
||||
private val datastoreRepo: DatastoreRepository,
|
||||
private val resourceProvider: ResourceProvider
|
||||
) : ViewModel(), SavedItemViewModel {
|
||||
private val contentRequestChannel = Channel<String>(capacity = Channel.UNLIMITED)
|
||||
|
||||
private var cursor: String? = null
|
||||
private var librarySearchCursor: String? = null
|
||||
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
|
||||
// These are used to make sure we handle search result
|
||||
// responses in the right order
|
||||
private var searchIdx = 0
|
||||
private var receivedIdx = 0
|
||||
|
||||
var snackbarMessage by mutableStateOf<String?>(null)
|
||||
private set
|
||||
var snackbarMessage by mutableStateOf<String?>(null)
|
||||
private set
|
||||
|
||||
// Live Data
|
||||
private var itemsLiveDataInternal = dataService.db.savedItemDao().filteredLibraryData(
|
||||
allowedArchiveStates = listOf(0),
|
||||
sortKey = "newest",
|
||||
requiredLabels = listOf(),
|
||||
excludedLabels = listOf(),
|
||||
allowedContentReaders = listOf("WEB", "PDF", "EPUB")
|
||||
)
|
||||
val itemsLiveData = MediatorLiveData<List<SavedItemWithLabelsAndHighlights>>()
|
||||
val appliedFilterLiveData = MutableLiveData(SavedItemFilter.INBOX)
|
||||
val appliedSortFilterLiveData = MutableLiveData(SavedItemSortFilter.NEWEST)
|
||||
val showLabelsSelectionSheetLiveData = MutableLiveData(false)
|
||||
val showEditInfoSheetLiveData = MutableLiveData(false)
|
||||
val showAddLinkSheetLiveData = MutableLiveData(false)
|
||||
val currentItemLiveData = MutableLiveData<String?>(null)
|
||||
val savedItemLabelsLiveData = dataService.db.savedItemLabelDao().getSavedItemLabelsLiveData()
|
||||
val activeLabelsLiveData = MutableLiveData<List<SavedItemLabel>>(listOf())
|
||||
// Live Data
|
||||
private var itemsLiveDataInternal = dataService.db.savedItemDao().filteredLibraryData(
|
||||
allowedArchiveStates = listOf(0),
|
||||
sortKey = "newest",
|
||||
requiredLabels = listOf(),
|
||||
excludedLabels = listOf(),
|
||||
allowedContentReaders = listOf("WEB", "PDF", "EPUB")
|
||||
)
|
||||
val itemsLiveData = MediatorLiveData<List<SavedItemWithLabelsAndHighlights>>()
|
||||
val appliedFilterLiveData = MutableLiveData(SavedItemFilter.INBOX)
|
||||
val appliedSortFilterLiveData = MutableLiveData(SavedItemSortFilter.NEWEST)
|
||||
val bottomSheetState = MutableLiveData(LibraryBottomSheetState.HIDDEN)
|
||||
val currentItemLiveData = MutableLiveData<String?>(null)
|
||||
val savedItemLabelsLiveData = dataService.db.savedItemLabelDao().getSavedItemLabelsLiveData()
|
||||
val activeLabelsLiveData = MutableLiveData<List<SavedItemLabel>>(listOf())
|
||||
|
||||
override val actionsMenuItemLiveData = MutableLiveData<SavedItemWithLabelsAndHighlights?>(null)
|
||||
override val actionsMenuItemLiveData = MutableLiveData<SavedItemWithLabelsAndHighlights?>(null)
|
||||
|
||||
var isRefreshing by mutableStateOf(false)
|
||||
private var hasLoadedInitialFilters = false
|
||||
var isRefreshing by mutableStateOf(false)
|
||||
private var hasLoadedInitialFilters = false
|
||||
|
||||
private fun loadInitialFilterValues() {
|
||||
if (hasLoadedInitialFilters) { return }
|
||||
hasLoadedInitialFilters = false
|
||||
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
dataService.syncLabels()
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
handleFilterChanges()
|
||||
for (slug in contentRequestChannel) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
dataService.fetchSavedItemContent(slug)
|
||||
private fun loadInitialFilterValues() {
|
||||
if (hasLoadedInitialFilters) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
hasLoadedInitialFilters = false
|
||||
|
||||
fun clearSnackbarMessage() {
|
||||
snackbarMessage = null
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
cursor = null
|
||||
librarySearchCursor = null
|
||||
isRefreshing = true
|
||||
load()
|
||||
}
|
||||
|
||||
private fun getLastSyncTime(): Instant? = runBlocking {
|
||||
datastoreRepo.getString(DatastoreKeys.libraryLastSyncTimestamp)?.let {
|
||||
try {
|
||||
return@let Instant.parse(it)
|
||||
} catch (e: Exception) {
|
||||
return@let null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun initialLoad() {
|
||||
if (getLastSyncTime() == null) {
|
||||
hasLoadedInitialFilters = false
|
||||
cursor = null
|
||||
librarySearchCursor = null
|
||||
searchIdx = 0
|
||||
receivedIdx = 0
|
||||
}
|
||||
|
||||
if (hasLoadedInitialFilters) { return }
|
||||
load()
|
||||
}
|
||||
|
||||
fun load() {
|
||||
loadInitialFilterValues()
|
||||
|
||||
viewModelScope.launch {
|
||||
syncItems()
|
||||
loadUsingSearchAPI()
|
||||
}
|
||||
}
|
||||
|
||||
fun loadUsingSearchAPI() {
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
val result = dataService.librarySearch(cursor = librarySearchCursor, query = searchQueryString())
|
||||
result.cursor?.let {
|
||||
librarySearchCursor = it
|
||||
}
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
isRefreshing = false
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
dataService.syncLabels()
|
||||
}
|
||||
}
|
||||
|
||||
result.savedItems.map {
|
||||
val isSavedInDB = dataService.isSavedItemContentStoredInDB(it.savedItem.slug)
|
||||
|
||||
if (!isSavedInDB) {
|
||||
delay(2000)
|
||||
contentRequestChannel.send(it.savedItem.slug)
|
||||
}
|
||||
viewModelScope.launch {
|
||||
handleFilterChanges()
|
||||
for (slug in contentRequestChannel) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
dataService.fetchSavedItemContent(slug)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateSavedItemFilter(filter: SavedItemFilter) {
|
||||
viewModelScope.launch {
|
||||
datastoreRepo.putString(DatastoreKeys.lastUsedSavedItemFilter, filter.rawValue)
|
||||
appliedFilterLiveData.value = filter
|
||||
handleFilterChanges()
|
||||
fun clearSnackbarMessage() {
|
||||
snackbarMessage = null
|
||||
}
|
||||
}
|
||||
|
||||
fun updateSavedItemSortFilter(filter: SavedItemSortFilter) {
|
||||
viewModelScope.launch {
|
||||
datastoreRepo.putString(DatastoreKeys.lastUsedSavedItemSortFilter, filter.rawValue)
|
||||
appliedSortFilterLiveData.value = filter
|
||||
handleFilterChanges()
|
||||
fun refresh() {
|
||||
cursor = null
|
||||
librarySearchCursor = null
|
||||
isRefreshing = true
|
||||
load()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateAppliedLabels(labels: List<SavedItemLabel>) {
|
||||
viewModelScope.launch {
|
||||
activeLabelsLiveData.value = labels
|
||||
handleFilterChanges()
|
||||
private fun getLastSyncTime(): Instant? = runBlocking {
|
||||
datastoreRepo.getString(DatastoreKeys.libraryLastSyncTimestamp)?.let {
|
||||
try {
|
||||
return@let Instant.parse(it)
|
||||
} catch (e: Exception) {
|
||||
return@let null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun initialLoad() {
|
||||
if (getLastSyncTime() == null) {
|
||||
hasLoadedInitialFilters = false
|
||||
cursor = null
|
||||
librarySearchCursor = null
|
||||
searchIdx = 0
|
||||
receivedIdx = 0
|
||||
}
|
||||
|
||||
if (hasLoadedInitialFilters) {
|
||||
return
|
||||
}
|
||||
load()
|
||||
}
|
||||
|
||||
fun load() {
|
||||
loadInitialFilterValues()
|
||||
|
||||
viewModelScope.launch {
|
||||
syncItems()
|
||||
loadUsingSearchAPI()
|
||||
}
|
||||
}
|
||||
|
||||
fun loadUsingSearchAPI() {
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
val result = dataService.librarySearch(
|
||||
cursor = librarySearchCursor,
|
||||
query = searchQueryString()
|
||||
)
|
||||
result.cursor?.let {
|
||||
librarySearchCursor = it
|
||||
}
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
isRefreshing = false
|
||||
}
|
||||
|
||||
result.savedItems.map {
|
||||
val isSavedInDB = dataService.isSavedItemContentStoredInDB(it.savedItem.slug)
|
||||
|
||||
if (!isSavedInDB) {
|
||||
delay(2000)
|
||||
contentRequestChannel.send(it.savedItem.slug)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateSavedItemFilter(filter: SavedItemFilter) {
|
||||
viewModelScope.launch {
|
||||
datastoreRepo.putString(DatastoreKeys.lastUsedSavedItemFilter, filter.rawValue)
|
||||
appliedFilterLiveData.value = filter
|
||||
handleFilterChanges()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateSavedItemSortFilter(filter: SavedItemSortFilter) {
|
||||
viewModelScope.launch {
|
||||
datastoreRepo.putString(DatastoreKeys.lastUsedSavedItemSortFilter, filter.rawValue)
|
||||
appliedSortFilterLiveData.value = filter
|
||||
handleFilterChanges()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateAppliedLabels(labels: List<SavedItemLabel>) {
|
||||
viewModelScope.launch {
|
||||
activeLabelsLiveData.value = labels
|
||||
handleFilterChanges()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fun sortKey(appliedSortKey: String) {
|
||||
// when(appliedSortKey) {
|
||||
|
|
@ -176,208 +181,228 @@ class LibraryViewModel @Inject constructor(
|
|||
// }
|
||||
// }
|
||||
|
||||
private fun handleFilterChanges() {
|
||||
librarySearchCursor = null
|
||||
private fun handleFilterChanges() {
|
||||
librarySearchCursor = null
|
||||
|
||||
if (appliedSortFilterLiveData.value != null && appliedFilterLiveData.value != null) {
|
||||
val sortKey = when (appliedSortFilterLiveData.value) {
|
||||
SavedItemSortFilter.NEWEST -> "newest"
|
||||
SavedItemSortFilter.OLDEST -> "oldest"
|
||||
SavedItemSortFilter.RECENTLY_READ -> "recentlyRead"
|
||||
SavedItemSortFilter.RECENTLY_PUBLISHED -> "recentlyPublished"
|
||||
else -> "newest"
|
||||
}
|
||||
if (appliedSortFilterLiveData.value != null && appliedFilterLiveData.value != null) {
|
||||
val sortKey = when (appliedSortFilterLiveData.value) {
|
||||
SavedItemSortFilter.NEWEST -> "newest"
|
||||
SavedItemSortFilter.OLDEST -> "oldest"
|
||||
SavedItemSortFilter.RECENTLY_READ -> "recentlyRead"
|
||||
SavedItemSortFilter.RECENTLY_PUBLISHED -> "recentlyPublished"
|
||||
else -> "newest"
|
||||
}
|
||||
|
||||
val allowedArchiveStates = when (appliedFilterLiveData.value) {
|
||||
SavedItemFilter.ALL -> listOf(0, 1)
|
||||
SavedItemFilter.ARCHIVED -> listOf(1)
|
||||
else -> listOf(0)
|
||||
}
|
||||
val allowedArchiveStates = when (appliedFilterLiveData.value) {
|
||||
SavedItemFilter.ALL -> listOf(0, 1)
|
||||
SavedItemFilter.ARCHIVED -> listOf(1)
|
||||
else -> listOf(0)
|
||||
}
|
||||
|
||||
val allowedContentReaders = when(appliedFilterLiveData.value) {
|
||||
SavedItemFilter.FILES -> listOf("PDF", "EPUB")
|
||||
else -> listOf("WEB", "PDF", "EPUB")
|
||||
}
|
||||
val allowedContentReaders = when (appliedFilterLiveData.value) {
|
||||
SavedItemFilter.FILES -> listOf("PDF", "EPUB")
|
||||
else -> listOf("WEB", "PDF", "EPUB")
|
||||
}
|
||||
|
||||
var requiredLabels = when(appliedFilterLiveData.value) {
|
||||
SavedItemFilter.NEWSLETTERS -> listOf("Newsletter")
|
||||
SavedItemFilter.FEEDS -> listOf("RSS")
|
||||
else -> (activeLabelsLiveData.value ?: listOf()).map { it.name }
|
||||
}
|
||||
var requiredLabels = when (appliedFilterLiveData.value) {
|
||||
SavedItemFilter.NEWSLETTERS -> listOf("Newsletter")
|
||||
SavedItemFilter.FEEDS -> listOf("RSS")
|
||||
else -> (activeLabelsLiveData.value ?: listOf()).map { it.name }
|
||||
}
|
||||
|
||||
activeLabelsLiveData.value?.let { it ->
|
||||
requiredLabels = requiredLabels + it.map { it.name }
|
||||
}
|
||||
activeLabelsLiveData.value?.let { it ->
|
||||
requiredLabels = requiredLabels + it.map { it.name }
|
||||
}
|
||||
|
||||
|
||||
val excludeLabels = when(appliedFilterLiveData.value) {
|
||||
SavedItemFilter.READ_LATER -> listOf("Newsletter", "RSS")
|
||||
else -> listOf()
|
||||
}
|
||||
val excludeLabels = when (appliedFilterLiveData.value) {
|
||||
SavedItemFilter.READ_LATER -> listOf("Newsletter", "RSS")
|
||||
else -> listOf()
|
||||
}
|
||||
|
||||
val newData = dataService.db.savedItemDao().filteredLibraryData(
|
||||
allowedArchiveStates = allowedArchiveStates,
|
||||
sortKey = sortKey,
|
||||
requiredLabels = requiredLabels,
|
||||
excludedLabels = excludeLabels,
|
||||
allowedContentReaders = allowedContentReaders
|
||||
)
|
||||
val newData = dataService.db.savedItemDao().filteredLibraryData(
|
||||
allowedArchiveStates = allowedArchiveStates,
|
||||
sortKey = sortKey,
|
||||
requiredLabels = requiredLabels,
|
||||
excludedLabels = excludeLabels,
|
||||
allowedContentReaders = allowedContentReaders
|
||||
)
|
||||
|
||||
itemsLiveData.removeSource(itemsLiveDataInternal)
|
||||
itemsLiveDataInternal = newData
|
||||
itemsLiveData.addSource(itemsLiveDataInternal, itemsLiveData::setValue)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun syncItems() {
|
||||
val syncStart = Instant.now()
|
||||
val lastSyncDate = getLastSyncTime() ?: Instant.MIN
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
performItemSync(cursor = null, since = lastSyncDate.toString(), count = 0, startTime = syncStart.toString())
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun performItemSync(cursor: String?, since: String, count: Int, startTime: String, isInitialBatch: Boolean = true) {
|
||||
dataService.syncOfflineItemsWithServerIfNeeded()
|
||||
val result = dataService.sync(since = since, cursor = cursor, limit = 20)
|
||||
|
||||
// Fetch content for the initial batch only
|
||||
if (isInitialBatch) {
|
||||
for (slug in result.savedItemSlugs) {
|
||||
delay(250)
|
||||
contentRequestChannel.send(slug)
|
||||
}
|
||||
itemsLiveData.removeSource(itemsLiveDataInternal)
|
||||
itemsLiveDataInternal = newData
|
||||
itemsLiveData.addSource(itemsLiveDataInternal, itemsLiveData::setValue)
|
||||
}
|
||||
}
|
||||
|
||||
val totalCount = count + result.count
|
||||
private suspend fun syncItems() {
|
||||
val syncStart = Instant.now()
|
||||
val lastSyncDate = getLastSyncTime() ?: Instant.MIN
|
||||
|
||||
if (!result.hasError && result.hasMoreItems && result.cursor != null) {
|
||||
performItemSync(
|
||||
cursor = result.cursor,
|
||||
since = since,
|
||||
count = totalCount,
|
||||
startTime = startTime,
|
||||
isInitialBatch = false
|
||||
)
|
||||
} else {
|
||||
datastoreRepo.putString(DatastoreKeys.libraryLastSyncTimestamp, startTime)
|
||||
withContext(Dispatchers.IO) {
|
||||
performItemSync(
|
||||
cursor = null,
|
||||
since = lastSyncDate.toString(),
|
||||
count = 0,
|
||||
startTime = syncStart.toString()
|
||||
)
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun handleSavedItemAction(itemID: String, action: SavedItemAction) {
|
||||
when (action) {
|
||||
SavedItemAction.Delete -> {
|
||||
deleteSavedItem(itemID)
|
||||
}
|
||||
SavedItemAction.Archive -> {
|
||||
archiveSavedItem(itemID)
|
||||
}
|
||||
SavedItemAction.Unarchive -> {
|
||||
unarchiveSavedItem(itemID)
|
||||
}
|
||||
SavedItemAction.EditLabels -> {
|
||||
currentItemLiveData.value = itemID
|
||||
showLabelsSelectionSheetLiveData.value = true
|
||||
}
|
||||
SavedItemAction.EditInfo -> {
|
||||
currentItemLiveData.value = itemID
|
||||
showEditInfoSheetLiveData.value = true
|
||||
}
|
||||
}
|
||||
actionsMenuItemLiveData.postValue(null)
|
||||
}
|
||||
private suspend fun performItemSync(
|
||||
cursor: String?,
|
||||
since: String,
|
||||
count: Int,
|
||||
startTime: String,
|
||||
isInitialBatch: Boolean = true
|
||||
) {
|
||||
dataService.syncOfflineItemsWithServerIfNeeded()
|
||||
val result = dataService.sync(since = since, cursor = cursor, limit = 20)
|
||||
|
||||
fun deleteSavedItem(itemID: String) {
|
||||
viewModelScope.launch {
|
||||
dataService.deleteSavedItem(itemID)
|
||||
}
|
||||
}
|
||||
// Fetch content for the initial batch only
|
||||
if (isInitialBatch) {
|
||||
for (slug in result.savedItemSlugs) {
|
||||
delay(250)
|
||||
contentRequestChannel.send(slug)
|
||||
}
|
||||
}
|
||||
|
||||
fun archiveSavedItem(itemID: String) {
|
||||
viewModelScope.launch {
|
||||
dataService.archiveSavedItem(itemID)
|
||||
}
|
||||
}
|
||||
val totalCount = count + result.count
|
||||
|
||||
fun unarchiveSavedItem(itemID: String) {
|
||||
viewModelScope.launch {
|
||||
dataService.unarchiveSavedItem(itemID)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateSavedItemLabels(savedItemID: String, labels: List<SavedItemLabel>) {
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
val result = setSavedItemLabels(
|
||||
networker = networker,
|
||||
dataService = dataService,
|
||||
savedItemID = savedItemID,
|
||||
labels = labels
|
||||
)
|
||||
|
||||
snackbarMessage = if (result) {
|
||||
resourceProvider.getString(R.string.library_view_model_snackbar_success)
|
||||
if (!result.hasError && result.hasMoreItems && result.cursor != null) {
|
||||
performItemSync(
|
||||
cursor = result.cursor,
|
||||
since = since,
|
||||
count = totalCount,
|
||||
startTime = startTime,
|
||||
isInitialBatch = false
|
||||
)
|
||||
} else {
|
||||
resourceProvider.getString(R.string.library_view_model_snackbar_error)
|
||||
datastoreRepo.putString(DatastoreKeys.libraryLastSyncTimestamp, startTime)
|
||||
}
|
||||
}
|
||||
|
||||
override fun handleSavedItemAction(itemID: String, action: SavedItemAction) {
|
||||
when (action) {
|
||||
SavedItemAction.Delete -> {
|
||||
deleteSavedItem(itemID)
|
||||
}
|
||||
|
||||
SavedItemAction.Archive -> {
|
||||
archiveSavedItem(itemID)
|
||||
}
|
||||
|
||||
SavedItemAction.Unarchive -> {
|
||||
unarchiveSavedItem(itemID)
|
||||
}
|
||||
|
||||
SavedItemAction.EditLabels -> {
|
||||
currentItemLiveData.value = itemID
|
||||
bottomSheetState.value = LibraryBottomSheetState.EDIT
|
||||
}
|
||||
|
||||
SavedItemAction.EditInfo -> {
|
||||
currentItemLiveData.value = itemID
|
||||
bottomSheetState.value = LibraryBottomSheetState.EDIT
|
||||
}
|
||||
}
|
||||
actionsMenuItemLiveData.postValue(null)
|
||||
}
|
||||
|
||||
fun deleteSavedItem(itemID: String) {
|
||||
viewModelScope.launch {
|
||||
dataService.deleteSavedItem(itemID)
|
||||
}
|
||||
}
|
||||
|
||||
fun archiveSavedItem(itemID: String) {
|
||||
viewModelScope.launch {
|
||||
dataService.archiveSavedItem(itemID)
|
||||
}
|
||||
}
|
||||
|
||||
fun unarchiveSavedItem(itemID: String) {
|
||||
viewModelScope.launch {
|
||||
dataService.unarchiveSavedItem(itemID)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateSavedItemLabels(savedItemID: String, labels: List<SavedItemLabel>) {
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
val result = setSavedItemLabels(
|
||||
networker = networker,
|
||||
dataService = dataService,
|
||||
savedItemID = savedItemID,
|
||||
labels = labels
|
||||
)
|
||||
|
||||
snackbarMessage = if (result) {
|
||||
resourceProvider.getString(R.string.library_view_model_snackbar_success)
|
||||
} else {
|
||||
resourceProvider.getString(R.string.library_view_model_snackbar_error)
|
||||
}
|
||||
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
handleFilterChanges()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createNewSavedItemLabel(labelName: String, hexColorValue: String) {
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
val newLabel = networker.createNewLabel(
|
||||
CreateLabelInput(
|
||||
color = Optional.presentIfNotNull(hexColorValue), name = labelName
|
||||
)
|
||||
)
|
||||
|
||||
newLabel?.let {
|
||||
val savedItemLabel = SavedItemLabel(
|
||||
savedItemLabelId = it.id,
|
||||
name = it.name,
|
||||
color = it.color,
|
||||
createdAt = it.createdAt as String?,
|
||||
labelDescription = it.description
|
||||
)
|
||||
|
||||
dataService.db.savedItemLabelDao().insertAll(listOf(savedItemLabel))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun currentSavedItemUnderEdit(): SavedItemWithLabelsAndHighlights? {
|
||||
currentItemLiveData.value?.let { itemID ->
|
||||
return itemsLiveData.value?.first { it.savedItem.savedItemId == itemID }
|
||||
}
|
||||
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
handleFilterChanges()
|
||||
return null
|
||||
}
|
||||
|
||||
private fun searchQueryString(): String {
|
||||
var query =
|
||||
"${appliedFilterLiveData.value?.queryString} ${appliedSortFilterLiveData.value?.queryString}"
|
||||
|
||||
activeLabelsLiveData.value?.let {
|
||||
if (it.isNotEmpty()) {
|
||||
query += " label:"
|
||||
query += it.joinToString { label -> label.name }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return query
|
||||
}
|
||||
}
|
||||
|
||||
fun createNewSavedItemLabel(labelName: String, hexColorValue: String) {
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
val newLabel = networker.createNewLabel(CreateLabelInput(color = Optional.presentIfNotNull(hexColorValue), name = labelName))
|
||||
|
||||
newLabel?.let {
|
||||
val savedItemLabel = SavedItemLabel(
|
||||
savedItemLabelId = it.id,
|
||||
name = it.name,
|
||||
color = it.color,
|
||||
createdAt = it.createdAt as String?,
|
||||
labelDescription = it.description
|
||||
)
|
||||
|
||||
dataService.db.savedItemLabelDao().insertAll(listOf(savedItemLabel))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun currentSavedItemUnderEdit(): SavedItemWithLabelsAndHighlights? {
|
||||
currentItemLiveData.value?.let { itemID ->
|
||||
return itemsLiveData.value?.first { it.savedItem.savedItemId == itemID }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun searchQueryString(): String {
|
||||
var query = "${appliedFilterLiveData.value?.queryString} ${appliedSortFilterLiveData.value?.queryString}"
|
||||
|
||||
activeLabelsLiveData.value?.let {
|
||||
if (it.isNotEmpty()) {
|
||||
query += " label:"
|
||||
query += it.joinToString { label -> label.name }
|
||||
}
|
||||
}
|
||||
|
||||
return query
|
||||
}
|
||||
}
|
||||
|
||||
enum class SavedItemAction {
|
||||
Delete,
|
||||
Archive,
|
||||
Unarchive,
|
||||
EditLabels,
|
||||
EditInfo,
|
||||
Delete,
|
||||
Archive,
|
||||
Unarchive,
|
||||
EditLabels,
|
||||
EditInfo,
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue