diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/following/FollowingScreen.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/following/FollowingScreen.kt new file mode 100644 index 000000000..343702d68 --- /dev/null +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/following/FollowingScreen.kt @@ -0,0 +1,153 @@ +package app.omnivore.omnivore.feature.following + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.StrokeCap +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavHostController +import app.omnivore.omnivore.core.database.entities.SavedItemWithLabelsAndHighlights +import app.omnivore.omnivore.feature.components.LabelsViewModel +import app.omnivore.omnivore.feature.editinfo.EditInfoViewModel +import app.omnivore.omnivore.feature.library.AddLinkBottomSheet +import app.omnivore.omnivore.feature.library.EditBottomSheet +import app.omnivore.omnivore.feature.library.LabelBottomSheet +import app.omnivore.omnivore.feature.library.LibraryBottomSheetState +import app.omnivore.omnivore.feature.library.LibraryNavigationBar +import app.omnivore.omnivore.feature.library.LibraryViewContent +import app.omnivore.omnivore.feature.save.SaveViewModel +import app.omnivore.omnivore.navigation.Routes +import app.omnivore.omnivore.navigation.TopLevelDestination +import kotlinx.coroutines.launch + +@Composable +internal fun FollowingScreen( + labelsViewModel: LabelsViewModel, + saveViewModel: SaveViewModel, + editInfoViewModel: EditInfoViewModel, + navController: NavHostController, + viewModel: FollowingViewModel = hiltViewModel() +) { + val snackbarHostState = remember { SnackbarHostState() } + + val coroutineScope = rememberCoroutineScope() + + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + val showBottomSheet: LibraryBottomSheetState by viewModel.bottomSheetState.observeAsState( + LibraryBottomSheetState.HIDDEN + ) + + viewModel.snackbarMessage?.let { + coroutineScope.launch { + snackbarHostState.showSnackbar(it) + viewModel.clearSnackbarMessage() + } + } + + val labels by viewModel.labelsState.collectAsStateWithLifecycle() + val activeLabels by viewModel.activeLabels.collectAsStateWithLifecycle() + + when (showBottomSheet) { + LibraryBottomSheetState.ADD_LINK -> { + AddLinkBottomSheet(saveViewModel) { + viewModel.bottomSheetState.value = LibraryBottomSheetState.HIDDEN + } + } + + LibraryBottomSheetState.LABEL -> { + LabelBottomSheet( + deleteCurrentItem = { viewModel.currentItem.value = null }, + labels = labels, + currentSavedItemData = viewModel.currentSavedItemUnderEdit(), + labelsViewModel, + { viewModel.bottomSheetState.value = LibraryBottomSheetState.HIDDEN }, + { labelName, hexColorValue -> + viewModel.createNewSavedItemLabel(labelName, hexColorValue) + }, + { savedItemId, labels -> + viewModel.updateSavedItemLabels(savedItemId, labels) + }, + activeLabels, + { viewModel.updateAppliedLabels(it) } + ) + } + + LibraryBottomSheetState.EDIT -> { + EditBottomSheet( + editInfoViewModel, + deleteCurrentItem = { viewModel.currentItem.value = null }, + { viewModel.refresh() }, + viewModel.currentSavedItemUnderEdit() + ) { + viewModel.bottomSheetState.value = LibraryBottomSheetState.HIDDEN + } + } + + LibraryBottomSheetState.HIDDEN -> { + } + } + + val currentTopLevelDestination = TopLevelDestination.entries.find { it.route == navController.currentDestination?.route } + val selectedItem: SavedItemWithLabelsAndHighlights? by viewModel.actionsMenuItemLiveData.observeAsState() + + Scaffold( + topBar = { + LibraryNavigationBar( + currentDestination = currentTopLevelDestination, + savedItemViewModel = viewModel, + onSearchClicked = { navController.navigate(Routes.Search.route) }, + onAddLinkClicked = { + viewModel.bottomSheetState.value = LibraryBottomSheetState.ADD_LINK + } + ) + }, + ) { paddingValues -> + when (uiState) { + is FollowingUiState.Success -> { + LibraryViewContent( + isFollowingScreen = currentTopLevelDestination == TopLevelDestination.FOLLOWING, + { viewModel.actionsMenuItemLiveData.postValue(null) }, + savedItemViewModel = viewModel, + refresh = { viewModel.refresh() }, + onUnarchive = { viewModel.unarchiveSavedItem(it) }, + onArchive = { viewModel.archiveSavedItem(it) }, + onDelete = { viewModel.deleteSavedItem(it) }, + paddingValues = paddingValues, + items = (uiState as FollowingUiState.Success).items, + selectedItem = selectedItem, + onSavedItemAction = { id, action -> + viewModel.handleSavedItemAction(id, action) + }, + { viewModel.loadUsingSearchAPI() }, + { viewModel.initialLoad() } + ) + } + is FollowingUiState.Loading -> { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(strokeCap = StrokeCap.Round) + } + } + else -> { + // TODO + } + } + } +} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/following/FollowingViewModel.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/following/FollowingViewModel.kt new file mode 100644 index 000000000..f5012f550 --- /dev/null +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/following/FollowingViewModel.kt @@ -0,0 +1,390 @@ +package app.omnivore.omnivore.feature.following + +import android.content.Context +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.omnivore.omnivore.R +import app.omnivore.omnivore.core.data.model.LibraryQuery +import app.omnivore.omnivore.core.data.repository.LibraryRepository +import app.omnivore.omnivore.core.database.entities.SavedItemLabel +import app.omnivore.omnivore.core.database.entities.SavedItemWithLabelsAndHighlights +import app.omnivore.omnivore.core.datastore.DatastoreRepository +import app.omnivore.omnivore.feature.library.LibraryBottomSheetState +import app.omnivore.omnivore.feature.library.SavedItemAction +import app.omnivore.omnivore.feature.library.SavedItemFilter +import app.omnivore.omnivore.feature.library.SavedItemSortFilter +import app.omnivore.omnivore.feature.library.SavedItemViewModel +import app.omnivore.omnivore.utils.DatastoreKeys +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import java.time.Instant +import javax.inject.Inject + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltViewModel +class FollowingViewModel @Inject constructor( + private val datastoreRepo: DatastoreRepository, + private val libraryRepository: LibraryRepository, + @ApplicationContext private val applicationContext: Context +) : ViewModel(), SavedItemViewModel { + + private val contentRequestChannel = Channel(capacity = Channel.UNLIMITED) + private var librarySearchCursor: String? = null + + var snackbarMessage by mutableStateOf(null) + private set + + private val _libraryQuery = MutableStateFlow( + LibraryQuery( + allowedArchiveStates = listOf(0), + sortKey = "newest", + requiredLabels = listOf(), + excludedLabels = listOf(), + allowedContentReaders = listOf("WEB", "PDF", "EPUB") + ) + ) + + val uiState: StateFlow = _libraryQuery.flatMapLatest { query -> + libraryRepository.getSavedItems(query) + }.map(FollowingUiState::Success).stateIn( + scope = viewModelScope, + started = SharingStarted.Lazily, + initialValue = FollowingUiState.Loading + ) + + val appliedFilterLiveData = MutableLiveData( + SavedItemFilter.FOLLOWING + ) + val appliedSortFilterLiveData = MutableLiveData(SavedItemSortFilter.NEWEST) + val bottomSheetState = MutableLiveData(LibraryBottomSheetState.HIDDEN) + val currentItem = mutableStateOf(null) + + val labelsState = libraryRepository.getSavedItemsLabels().stateIn( + scope = viewModelScope, started = SharingStarted.Lazily, initialValue = listOf() + ) + + val activeLabels = MutableStateFlow>(listOf()) + + override val actionsMenuItemLiveData = MutableLiveData(null) + + + private fun loadInitialFilterValues() { + syncLabels() + + viewModelScope.launch { + handleFilterChanges() + for (slug in contentRequestChannel) { + libraryRepository.fetchSavedItemContent(slug) + } + } + + updateSavedItemFilter(appliedFilterLiveData.value ?: SavedItemFilter.INBOX) + } + + private fun syncLabels() { + viewModelScope.launch { + val labels = libraryRepository.getLabels() + libraryRepository.insertAllLabels(labels) + } + } + + fun clearSnackbarMessage() { + snackbarMessage = null + } + + fun refresh() { + librarySearchCursor = null + 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) { + librarySearchCursor = null + } + load() + } + + fun load() { + loadInitialFilterValues() + + viewModelScope.launch { + syncItems() + loadUsingSearchAPI() + } + } + + fun loadUsingSearchAPI() { + viewModelScope.launch { + val result = libraryRepository.librarySearch( + cursor = librarySearchCursor, query = searchQueryString() + ) + result.cursor?.let { + librarySearchCursor = it + } + result.savedItems.map { + val isSavedInDB = libraryRepository.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) { + viewModelScope.launch { + activeLabels.value = labels + handleFilterChanges() + } + } + + 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" + } + + 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") + } + + var requiredLabels = when (appliedFilterLiveData.value) { + SavedItemFilter.FOLLOWING -> listOf("Newsletter", "RSS") + SavedItemFilter.NEWSLETTERS -> listOf("Newsletter") + SavedItemFilter.FEEDS -> listOf("RSS") + else -> listOf("Newsletter", "RSS")//activeLabels.value.map { it.name } + } + + activeLabels.value.let { it -> + requiredLabels = requiredLabels + it.map { it.name } + } + + + val excludeLabels = when (appliedFilterLiveData.value) { + SavedItemFilter.READ_LATER -> listOf("Newsletter", "RSS") + else -> listOf() + } + + _libraryQuery.value = LibraryQuery( + allowedArchiveStates = allowedArchiveStates, + sortKey = sortKey, + requiredLabels = requiredLabels, + excludedLabels = excludeLabels, + allowedContentReaders = allowedContentReaders + ) + } + } + + 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() + ) + } + } + + private suspend fun performItemSync( + cursor: String?, + since: String, + count: Int, + startTime: String, + isInitialBatch: Boolean = true + ) { + libraryRepository.syncOfflineItemsWithServerIfNeeded() + val result = libraryRepository.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) + } + } + + val totalCount = count + result.count + + 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) + } + } + + override fun handleSavedItemAction(itemId: String, action: SavedItemAction) { + when (action) { + SavedItemAction.Delete -> { + deleteSavedItem(itemId) + } + + SavedItemAction.Archive -> { + archiveSavedItem(itemId) + } + + SavedItemAction.Unarchive -> { + unarchiveSavedItem(itemId) + } + + SavedItemAction.EditLabels -> { + currentItem.value = itemId + bottomSheetState.value = LibraryBottomSheetState.LABEL + } + + SavedItemAction.EditInfo -> { + currentItem.value = itemId + bottomSheetState.value = LibraryBottomSheetState.EDIT + } + + SavedItemAction.MarkRead -> { + viewModelScope.launch { + libraryRepository.updateReadingProgress(itemId, 100.0, 0) + } + } + + SavedItemAction.MarkUnread -> { + viewModelScope.launch { + libraryRepository.updateReadingProgress(itemId, 0.0, 0) + } + } + } + actionsMenuItemLiveData.postValue(null) + } + + fun deleteSavedItem(itemID: String) { + viewModelScope.launch { + libraryRepository.deleteSavedItem(itemID) + } + } + + fun archiveSavedItem(itemID: String) { + viewModelScope.launch { + libraryRepository.archiveSavedItem(itemID) + } + } + + fun unarchiveSavedItem(itemID: String) { + viewModelScope.launch { + libraryRepository.unarchiveSavedItem(itemID) + } + } + + fun updateSavedItemLabels(savedItemID: String, labels: List) { + viewModelScope.launch { + val result = libraryRepository.setSavedItemLabels( + itemId = savedItemID, labels = labels + ) + snackbarMessage = if (result) { + applicationContext.getString(R.string.library_view_model_snackbar_success) + } else { + applicationContext.getString(R.string.library_view_model_snackbar_error) + } + handleFilterChanges() + } + } + + fun createNewSavedItemLabel(labelName: String, hexColorValue: String) { + viewModelScope.launch { + libraryRepository.createNewSavedItemLabel(labelName, hexColorValue) + } + } + + fun currentSavedItemUnderEdit(): SavedItemWithLabelsAndHighlights? { + currentItem.value?.let { itemID -> + return (uiState.value as FollowingUiState.Success).items.first { it.savedItem.savedItemId == itemID } + } + + return null + } + + private fun searchQueryString(): String { + var query = + "${appliedFilterLiveData.value?.queryString} ${appliedSortFilterLiveData.value?.queryString}" + + activeLabels.value.let { + if (it.isNotEmpty()) { + query += " label:" + query += it.joinToString { label -> label.name } + } + } + + return query + } +} + +sealed interface FollowingUiState { + data object Loading : FollowingUiState + + data class Success( + val items: List, + ) : FollowingUiState + + data object Error : FollowingUiState +} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/LibraryFilterBar.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/LibraryFilterBar.kt index 99e49aa07..129df6fc8 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/LibraryFilterBar.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/LibraryFilterBar.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.text.intl.Locale import androidx.compose.ui.text.toLowerCase import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import app.omnivore.omnivore.R import app.omnivore.omnivore.core.database.entities.SavedItemLabel import app.omnivore.omnivore.feature.components.LabelChipColors @@ -41,7 +42,7 @@ fun LibraryFilterBar( val activeSavedItemFilter: SavedItemFilter by viewModel.appliedFilterLiveData.observeAsState( if (isFollowingScreen) SavedItemFilter.FOLLOWING else SavedItemFilter.INBOX ) - val activeLabels: List by viewModel.activeLabelsLiveData.observeAsState(listOf()) + val activeLabels: List by viewModel.activeLabels.collectAsStateWithLifecycle() var isSavedItemSortFilterMenuExpanded by remember { mutableStateOf(false) } val activeSavedItemSortFilter: SavedItemSortFilter by viewModel.appliedSortFilterLiveData.observeAsState( @@ -97,7 +98,7 @@ fun LibraryFilterBar( val chipColors = LabelChipColors.fromHex(label.color) AssistChip(onClick = { - viewModel.updateAppliedLabels((viewModel.activeLabelsLiveData.value + viewModel.updateAppliedLabels((viewModel.activeLabels.value ?: listOf()).filter { it.savedItemLabelId != label.savedItemLabelId }) }, label = { Text(label.name) }, diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/LibraryView.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/LibraryView.kt index a5f440625..89c34f535 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/LibraryView.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/LibraryView.kt @@ -21,14 +21,12 @@ import androidx.compose.material.DismissState import androidx.compose.material.DismissValue import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.FractionalThreshold -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.rememberDismissState -import androidx.compose.material.rememberScaffoldState import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider @@ -36,6 +34,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.pulltorefresh.PullToRefreshContainer import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.material3.rememberModalBottomSheetState @@ -60,6 +59,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController +import app.omnivore.omnivore.core.database.entities.SavedItemLabel import app.omnivore.omnivore.core.database.entities.SavedItemWithLabelsAndHighlights import app.omnivore.omnivore.feature.components.AddLinkSheetContent import app.omnivore.omnivore.feature.components.LabelsSelectionSheetContent @@ -85,8 +85,7 @@ internal fun LibraryView( navController: NavHostController, viewModel: LibraryViewModel = hiltViewModel() ) { - val scaffoldState: ScaffoldState = rememberScaffoldState() - + val snackbarHostState = remember { SnackbarHostState() } val coroutineScope = rememberCoroutineScope() val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -97,11 +96,14 @@ internal fun LibraryView( viewModel.snackbarMessage?.let { coroutineScope.launch { - scaffoldState.snackbarHostState.showSnackbar(it) + snackbarHostState.showSnackbar(it) viewModel.clearSnackbarMessage() } } + val labels by viewModel.labelsState.collectAsStateWithLifecycle() + val activeLabels by viewModel.activeLabels.collectAsStateWithLifecycle() + when (showBottomSheet) { LibraryBottomSheetState.ADD_LINK -> { AddLinkBottomSheet(saveViewModel) { @@ -111,17 +113,28 @@ internal fun LibraryView( LibraryBottomSheetState.LABEL -> { LabelBottomSheet( - viewModel, - labelsViewModel - ) { - viewModel.bottomSheetState.value = LibraryBottomSheetState.HIDDEN - } + deleteCurrentItem = { viewModel.currentItem.value = null }, + labels = labels, + currentSavedItemData = viewModel.currentSavedItemUnderEdit(), + labelsViewModel, + { viewModel.bottomSheetState.value = LibraryBottomSheetState.HIDDEN }, + { labelName, hexColorValue -> + viewModel.createNewSavedItemLabel(labelName, hexColorValue) + }, + { savedItemId, labels -> + viewModel.updateSavedItemLabels(savedItemId, labels) + }, + activeLabels, + { viewModel.updateAppliedLabels(it) } + ) } LibraryBottomSheetState.EDIT -> { EditBottomSheet( editInfoViewModel, - viewModel + deleteCurrentItem = { viewModel.currentItem.value = null }, + { viewModel.refresh() }, + viewModel.currentSavedItemUnderEdit() ) { viewModel.bottomSheetState.value = LibraryBottomSheetState.HIDDEN } @@ -132,6 +145,7 @@ internal fun LibraryView( } val currentTopLevelDestination = TopLevelDestination.entries.find { it.route == navController.currentDestination?.route } + val selectedItem: SavedItemWithLabelsAndHighlights? by viewModel.actionsMenuItemLiveData.observeAsState() Scaffold( topBar = { @@ -139,7 +153,9 @@ internal fun LibraryView( currentDestination = currentTopLevelDestination, savedItemViewModel = viewModel, onSearchClicked = { navController.navigate(Routes.Search.route) }, - onAddLinkClicked = { showAddLinkBottomSheet(viewModel) } + onAddLinkClicked = { + viewModel.bottomSheetState.value = LibraryBottomSheetState.ADD_LINK + } ) }, ) { paddingValues -> @@ -147,9 +163,20 @@ internal fun LibraryView( is LibraryUiState.Success -> { LibraryViewContent( isFollowingScreen = currentTopLevelDestination == TopLevelDestination.FOLLOWING, - viewModel, + { viewModel.actionsMenuItemLiveData.postValue(null) }, + savedItemViewModel = viewModel, + refresh = { viewModel.refresh() }, + onUnarchive = { viewModel.unarchiveSavedItem(it) }, + onArchive = { viewModel.archiveSavedItem(it) }, + onDelete = { viewModel.deleteSavedItem(it) }, paddingValues = paddingValues, - uiState = uiState + items = (uiState as LibraryUiState.Success).items, + selectedItem = selectedItem, + onSavedItemAction = { id, action -> + viewModel.handleSavedItemAction(id, action) + }, + { viewModel.loadUsingSearchAPI() }, + { viewModel.initialLoad() } ) } is LibraryUiState.Loading -> { @@ -169,16 +196,18 @@ internal fun LibraryView( } } -fun showAddLinkBottomSheet(libraryViewModel: LibraryViewModel) { - libraryViewModel.bottomSheetState.value = LibraryBottomSheetState.ADD_LINK -} - @OptIn(ExperimentalMaterial3Api::class) @Composable fun LabelBottomSheet( - libraryViewModel: LibraryViewModel, + deleteCurrentItem: () -> Unit, + labels: List, + currentSavedItemData: SavedItemWithLabelsAndHighlights?, labelsViewModel: LabelsViewModel, - onDismiss: () -> Unit = {} + onDismiss: () -> Unit = {}, + createNewSavedItemLabel: (String, String) -> Unit, + updateSavedItemLabels: (String, List) -> Unit, + activeLabels: List, + updateAppliedLabels: (List) -> Unit ) { ModalBottomSheet( onDismissRequest = { onDismiss() }, @@ -188,48 +217,41 @@ fun LabelBottomSheet( ), ) { - val currentSavedItemData = libraryViewModel.currentSavedItemUnderEdit() - - val labels by libraryViewModel.labelsState.collectAsStateWithLifecycle() - if (currentSavedItemData != null) { LabelsSelectionSheetContent( labels = labels, labelsViewModel = labelsViewModel, initialSelectedLabels = currentSavedItemData.labels, onCancel = { - libraryViewModel.currentItem.value = null + deleteCurrentItem() onDismiss() }, isLibraryMode = false, onSave = { if (it != labels) { - libraryViewModel.updateSavedItemLabels( - savedItemID = currentSavedItemData.savedItem.savedItemId, - labels = it - ) + updateSavedItemLabels(currentSavedItemData.savedItem.savedItemId, it) } - libraryViewModel.currentItem.value = null + deleteCurrentItem() onDismiss() }, onCreateLabel = { newLabelName, labelHexValue -> - libraryViewModel.createNewSavedItemLabel(newLabelName, labelHexValue) + createNewSavedItemLabel(newLabelName, labelHexValue) } ) } else { // Is used in library mode LabelsSelectionSheetContent( labels = labels, labelsViewModel = labelsViewModel, - initialSelectedLabels = libraryViewModel.activeLabelsLiveData.value ?: listOf(), + initialSelectedLabels = activeLabels, onCancel = { onDismiss() }, isLibraryMode = true, onSave = { - libraryViewModel.updateAppliedLabels(it) - libraryViewModel.currentItem.value = null + updateAppliedLabels(it) + deleteCurrentItem() onDismiss() }, onCreateLabel = { newLabelName, labelHexValue -> - libraryViewModel.createNewSavedItemLabel(newLabelName, labelHexValue) + createNewSavedItemLabel(newLabelName, labelHexValue) } ) } @@ -268,7 +290,9 @@ fun AddLinkBottomSheet( @Composable fun EditBottomSheet( editInfoViewModel: EditInfoViewModel, - libraryViewModel: LibraryViewModel, + deleteCurrentItem: () -> Unit, + refresh: () -> Unit, + currentSavedItemUnderEdit: SavedItemWithLabelsAndHighlights?, onDismiss: () -> Unit = {} ) { ModalBottomSheet( @@ -278,20 +302,19 @@ fun EditBottomSheet( skipPartiallyExpanded = true ), ) { - val currentSavedItemData = libraryViewModel.currentSavedItemUnderEdit() EditInfoSheetContent( - savedItemId = currentSavedItemData?.savedItem?.savedItemId, - title = currentSavedItemData?.savedItem?.title, - author = currentSavedItemData?.savedItem?.author, - description = currentSavedItemData?.savedItem?.descriptionText, + savedItemId = currentSavedItemUnderEdit?.savedItem?.savedItemId, + title = currentSavedItemUnderEdit?.savedItem?.title, + author = currentSavedItemUnderEdit?.savedItem?.author, + description = currentSavedItemUnderEdit?.savedItem?.descriptionText, viewModel = editInfoViewModel, onCancel = { - libraryViewModel.currentItem.value = null + deleteCurrentItem() onDismiss() }, onUpdated = { - libraryViewModel.currentItem.value = null - libraryViewModel.refresh() + deleteCurrentItem() + refresh() onDismiss() } ) @@ -303,9 +326,18 @@ fun EditBottomSheet( @Composable fun LibraryViewContent( isFollowingScreen: Boolean, - libraryViewModel: LibraryViewModel, + selectItem: () -> Unit, + savedItemViewModel: SavedItemViewModel, + refresh: () -> Unit, + onUnarchive: (String) -> Unit, + onArchive: (String) -> Unit, + onDelete: (String) -> Unit, paddingValues: PaddingValues, - uiState: LibraryUiState + items: List, + selectedItem: SavedItemWithLabelsAndHighlights?, + onSavedItemAction: (String, SavedItemAction) -> Unit, + loadUsingSearchAPI: () -> Unit, + initialLoad: () -> Unit ) { val context = LocalContext.current val listState = rememberLazyListState() @@ -315,13 +347,11 @@ fun LibraryViewContent( LaunchedEffect(true) { // fetch something delay(1500) - libraryViewModel.refresh() + refresh() pullToRefreshState.endRefresh() } } - val selectedItem: SavedItemWithLabelsAndHighlights? by libraryViewModel.actionsMenuItemLiveData.observeAsState() - Box( modifier = Modifier .padding(top = paddingValues.calculateTopPadding()) @@ -337,11 +367,10 @@ fun LibraryViewContent( horizontalAlignment = Alignment.CenterHorizontally ) { items( - items = (uiState as LibraryUiState.Success).items, + items = items, key = { item -> item.savedItem.savedItemId } ) { cardDataWithLabels -> val swipeThreshold = 0.45f - val currentThresholdFraction = remember { mutableStateOf(0f) } val currentItem by rememberUpdatedState(cardDataWithLabels.savedItem) val swipeState = rememberDismissState( @@ -364,12 +393,12 @@ fun LibraryViewContent( if (it == DismissValue.DismissedToEnd) { // Archiving/UnArchiving. if (currentItem.isArchived) { - libraryViewModel.unarchiveSavedItem(currentItem.savedItemId) + onUnarchive(currentItem.savedItemId) } else { - libraryViewModel.archiveSavedItem(currentItem.savedItemId) + onArchive(currentItem.savedItemId) } } else if (it == DismissValue.DismissedToStart) { // Deleting. - libraryViewModel.deleteSavedItem(currentItem.savedItemId) + onDelete(currentItem.savedItemId) } true @@ -426,10 +455,10 @@ fun LibraryViewContent( ) SavedItemCard( selected = selected, - savedItemViewModel = libraryViewModel, + savedItemViewModel = savedItemViewModel, savedItem = savedItem, onClickHandler = { - libraryViewModel.actionsMenuItemLiveData.postValue(null) + selectItem() val activityClass = if (currentItem.contentReader == "PDF") PDFReaderActivity::class.java else WebReaderLoadingContainerActivity::class.java val intent = Intent(context, activityClass) @@ -437,7 +466,7 @@ fun LibraryViewContent( context.startActivity(intent) }, actionHandler = { - libraryViewModel.handleSavedItemAction( + onSavedItemAction( currentItem.savedItemId, it ) @@ -454,12 +483,12 @@ fun LibraryViewContent( } InfiniteListHandler(listState = listState) { - if ((uiState as LibraryUiState.Success).items.isEmpty()) { + if (items.isEmpty()) { Log.d("sync", "loading with load func") - libraryViewModel.initialLoad() + initialLoad() } else { Log.d("sync", "loading with search api") - libraryViewModel.loadUsingSearchAPI() + loadUsingSearchAPI() } } @@ -467,8 +496,6 @@ fun LibraryViewContent( modifier = Modifier.align(Alignment.TopCenter), state = pullToRefreshState, ) - - // LabelsSelectionSheet(viewModel = libraryViewModel) } } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/LibraryViewModel.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/LibraryViewModel.kt index 8a06a7cc4..df331d547 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/LibraryViewModel.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/LibraryViewModel.kt @@ -45,7 +45,7 @@ class LibraryViewModel @Inject constructor( var snackbarMessage by mutableStateOf(null) private set - + private val _libraryQuery = MutableStateFlow( LibraryQuery( allowedArchiveStates = listOf(0), @@ -59,10 +59,10 @@ class LibraryViewModel @Inject constructor( val uiState: StateFlow = _libraryQuery.flatMapLatest { query -> libraryRepository.getSavedItems(query) }.map(LibraryUiState::Success).stateIn( - scope = viewModelScope, - started = SharingStarted.Lazily, - initialValue = LibraryUiState.Loading - ) + scope = viewModelScope, + started = SharingStarted.Lazily, + initialValue = LibraryUiState.Loading + ) val appliedFilterLiveData = MutableLiveData() val appliedSortFilterLiveData = MutableLiveData(SavedItemSortFilter.NEWEST) @@ -73,7 +73,7 @@ class LibraryViewModel @Inject constructor( scope = viewModelScope, started = SharingStarted.Lazily, initialValue = listOf() ) - val activeLabelsLiveData = MutableLiveData>(listOf()) + val activeLabels = MutableStateFlow>(listOf()) override val actionsMenuItemLiveData = MutableLiveData(null) @@ -169,7 +169,7 @@ class LibraryViewModel @Inject constructor( fun updateAppliedLabels(labels: List) { viewModelScope.launch { - activeLabelsLiveData.value = labels + activeLabels.value = labels handleFilterChanges() } } @@ -201,10 +201,10 @@ class LibraryViewModel @Inject constructor( SavedItemFilter.FOLLOWING -> listOf("Newsletter", "RSS") SavedItemFilter.NEWSLETTERS -> listOf("Newsletter") SavedItemFilter.FEEDS -> listOf("RSS") - else -> (activeLabelsLiveData.value ?: listOf()).map { it.name } + else -> activeLabels.value.map { it.name } } - activeLabelsLiveData.value?.let { it -> + activeLabels.value.let { it -> requiredLabels = requiredLabels + it.map { it.name } } @@ -271,39 +271,39 @@ class LibraryViewModel @Inject constructor( } } - override fun handleSavedItemAction(itemID: String, action: SavedItemAction) { + override fun handleSavedItemAction(itemId: String, action: SavedItemAction) { when (action) { SavedItemAction.Delete -> { - deleteSavedItem(itemID) + deleteSavedItem(itemId) } SavedItemAction.Archive -> { - archiveSavedItem(itemID) + archiveSavedItem(itemId) } SavedItemAction.Unarchive -> { - unarchiveSavedItem(itemID) + unarchiveSavedItem(itemId) } SavedItemAction.EditLabels -> { - currentItem.value = itemID + currentItem.value = itemId bottomSheetState.value = LibraryBottomSheetState.LABEL } SavedItemAction.EditInfo -> { - currentItem.value = itemID + currentItem.value = itemId bottomSheetState.value = LibraryBottomSheetState.EDIT } SavedItemAction.MarkRead -> { viewModelScope.launch { - libraryRepository.updateReadingProgress(itemID, 100.0, 0) + libraryRepository.updateReadingProgress(itemId, 100.0, 0) } } SavedItemAction.MarkUnread -> { viewModelScope.launch { - libraryRepository.updateReadingProgress(itemID, 0.0, 0) + libraryRepository.updateReadingProgress(itemId, 0.0, 0) } } } @@ -328,10 +328,10 @@ class LibraryViewModel @Inject constructor( } } - fun updateSavedItemLabels(savedItemID: String, labels: List) { + fun updateSavedItemLabels(savedItemId: String, labels: List) { viewModelScope.launch { val result = libraryRepository.setSavedItemLabels( - itemId = savedItemID, labels = labels + itemId = savedItemId, labels = labels ) snackbarMessage = if (result) { applicationContext.getString(R.string.library_view_model_snackbar_success) @@ -352,7 +352,6 @@ class LibraryViewModel @Inject constructor( currentItem.value?.let { itemID -> return (uiState.value as LibraryUiState.Success).items.first { it.savedItem.savedItemId == itemID } } - return null } @@ -360,7 +359,7 @@ class LibraryViewModel @Inject constructor( var query = "${appliedFilterLiveData.value?.queryString} ${appliedSortFilterLiveData.value?.queryString}" - activeLabelsLiveData.value?.let { + activeLabels.value.let { if (it.isNotEmpty()) { query += " label:" query += it.joinToString { label -> label.name } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/SavedItemViewModel.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/SavedItemViewModel.kt index 559caa299..92f5dc060 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/SavedItemViewModel.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/SavedItemViewModel.kt @@ -8,5 +8,5 @@ interface SavedItemViewModel { val actionsMenuItemLiveData: MutableLiveData get() = MutableLiveData(null) - fun handleSavedItemAction(itemID: String, action: SavedItemAction) + fun handleSavedItemAction(itemId: String, action: SavedItemAction) } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/SearchViewModel.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/SearchViewModel.kt index 1ad2360cb..6a0240a27 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/SearchViewModel.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/SearchViewModel.kt @@ -153,23 +153,23 @@ class SearchViewModel @Inject constructor( isRefreshing.postValue(false) } - override fun handleSavedItemAction(itemID: String, action: SavedItemAction) { + override fun handleSavedItemAction(itemId: String, action: SavedItemAction) { when (action) { SavedItemAction.Delete -> { viewModelScope.launch { - dataService.deleteSavedItem(itemID) + dataService.deleteSavedItem(itemId) } } SavedItemAction.Archive -> { viewModelScope.launch { - dataService.archiveSavedItem(itemID) + dataService.archiveSavedItem(itemId) } } SavedItemAction.Unarchive -> { viewModelScope.launch { - dataService.unarchiveSavedItem(itemID) + dataService.unarchiveSavedItem(itemId) } } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/root/RootView.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/root/RootView.kt index 2538a748b..db43e0144 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/root/RootView.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/root/RootView.kt @@ -39,6 +39,7 @@ import app.omnivore.omnivore.feature.auth.LoginViewModel import app.omnivore.omnivore.feature.auth.WelcomeScreen import app.omnivore.omnivore.feature.components.LabelsViewModel import app.omnivore.omnivore.feature.editinfo.EditInfoViewModel +import app.omnivore.omnivore.feature.following.FollowingScreen import app.omnivore.omnivore.feature.library.LibraryView import app.omnivore.omnivore.feature.library.SearchView import app.omnivore.omnivore.feature.library.SearchViewModel @@ -134,7 +135,7 @@ fun PrimaryNavigator( } composable(Routes.Following.route) { - LibraryView( + FollowingScreen( navController = navController, labelsViewModel = labelsViewModel, saveViewModel = saveViewModel, diff --git a/android/Omnivore/gradle/wrapper/gradle-wrapper.properties b/android/Omnivore/gradle/wrapper/gradle-wrapper.properties index 490da3e99..9cbc7cf15 100644 --- a/android/Omnivore/gradle/wrapper/gradle-wrapper.properties +++ b/android/Omnivore/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Wed Feb 14 01:21:10 GMT 2024 +#Wed Apr 17 23:59:51 CEST 2024 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists