Update LibraryView to use ModalBottomSheet

using the modalBottomSheet will support back action, tapping off the bottomsheet, and swiping down
This commit is contained in:
Gannon 2024-01-04 22:11:43 -05:00
parent ec316cd9f9
commit b7fd8a6326
6 changed files with 731 additions and 666 deletions

View file

@ -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()) {

View file

@ -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)
}
}
}

View file

@ -0,0 +1,8 @@
package app.omnivore.omnivore.ui.library
enum class LibraryBottomSheetState {
HIDDEN,
ADD_LINK,
LABEL,
EDIT
}

View file

@ -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(

View file

@ -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()
}
}
}

View file

@ -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,
}