diff --git a/app/src/main/java/com/capyreader/app/preferences/ArticleListVerticalSwipe.kt b/app/src/main/java/com/capyreader/app/preferences/ArticleListVerticalSwipe.kt index 1bf9214d7..c78593c65 100644 --- a/app/src/main/java/com/capyreader/app/preferences/ArticleListVerticalSwipe.kt +++ b/app/src/main/java/com/capyreader/app/preferences/ArticleListVerticalSwipe.kt @@ -4,12 +4,14 @@ import com.capyreader.app.R enum class ArticleListVerticalSwipe { DISABLED, - NEXT_FEED; + NEXT_FEED, + MARK_ALL_READ; val translationKey: Int get() = when (this) { DISABLED -> R.string.article_list_swipe_disabled NEXT_FEED -> R.string.article_list_swipe_next_feed + MARK_ALL_READ -> R.string.article_list_swipe_mark_all_read } companion object { diff --git a/app/src/main/java/com/capyreader/app/ui/articles/ArticleScreen.kt b/app/src/main/java/com/capyreader/app/ui/articles/ArticleScreen.kt index dc345fd1e..3a6f9815f 100644 --- a/app/src/main/java/com/capyreader/app/ui/articles/ArticleScreen.kt +++ b/app/src/main/java/com/capyreader/app/ui/articles/ArticleScreen.kt @@ -46,8 +46,10 @@ import androidx.paging.compose.collectAsLazyPagingItems import com.capyreader.app.R import com.capyreader.app.common.Media import com.capyreader.app.common.Saver +import com.capyreader.app.common.asState import com.capyreader.app.preferences.AfterReadAllBehavior import com.capyreader.app.preferences.AppPreferences +import com.capyreader.app.preferences.ArticleListVerticalSwipe import com.capyreader.app.refresher.RefreshInterval import com.capyreader.app.ui.LocalBadgeStyle import com.capyreader.app.ui.LocalConnectivity @@ -69,8 +71,10 @@ import com.capyreader.app.ui.articles.feeds.SavedSearchActions import com.capyreader.app.ui.articles.list.ArticleListTopBar import com.capyreader.app.ui.articles.list.EmptyOnboardingView import com.capyreader.app.ui.articles.list.LabelBottomSheet +import com.capyreader.app.ui.articles.list.LocalMarkAllRead import com.capyreader.app.ui.articles.list.MarkAllReadButton -import com.capyreader.app.ui.articles.list.PullToNextFeedBox +import com.capyreader.app.ui.articles.list.MarkAllReadDialog +import com.capyreader.app.ui.articles.list.SwipeUpActionBox import com.capyreader.app.ui.articles.list.resetScrollBehaviorListener import com.capyreader.app.ui.articles.media.ArticleMediaView import com.capyreader.app.ui.collectChangesWithCurrent @@ -120,6 +124,7 @@ fun ArticleScreen( val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle("") val searchState by viewModel.searchState.collectAsStateWithLifecycle(SearchState.INACTIVE) val nextFilter by viewModel.nextFilter.collectAsStateWithLifecycle(initialValue = null) + val swipeBottom by viewModel.listSwipeBottom.collectAsStateWithLifecycle() val afterReadAll by viewModel.afterReadAll.collectAsStateWithLifecycle() val hideReadArticles by viewModel.hideReadArticles.collectAsStateWithLifecycle() val scope = rememberCoroutineScope() @@ -127,7 +132,11 @@ fun ArticleScreen( .refreshInterval .collectChangesWithDefault(appPreferences.refreshInterval.get()) - val canSwipeToNextFeed = nextFilter != null + val canSwipeBottom = when (swipeBottom) { + ArticleListVerticalSwipe.DISABLED -> false + ArticleListVerticalSwipe.NEXT_FEED -> nextFilter != null + ArticleListVerticalSwipe.MARK_ALL_READ -> true + } val context = LocalContext.current val canSaveExternally by viewModel.canSaveArticleExternally.collectAsStateWithLifecycle() @@ -156,9 +165,6 @@ fun ArticleScreen( drawerState.open() } }, - searches = savedSearches, - folders = folders, - feeds = feeds, range = range, ) } @@ -175,6 +181,9 @@ fun ArticleScreen( val snackbarHostState = remember { SnackbarHostState() } + val confirmMarkAllReadEnabled by appPreferences.articleListOptions.confirmMarkAllRead.asState() + var isMarkAllReadDialogOpen by remember { mutableStateOf(false) } + CompositionLocalProvider( LocalFullContent provides fullContent, LocalArticleActions provides articleActions, @@ -280,17 +289,14 @@ fun ArticleScreen( scaffoldNavigator.navigateTo(ListDetailPaneScaffoldRole.List) } - fun requestNextFeed() { - coroutineScope.launchUI { - openNextStatus { - viewModel.requestNextFeed() - } + fun markAllRead(range: MarkRead) { + if (range == MarkRead.All && confirmMarkAllReadEnabled) { + isMarkAllReadDialogOpen = true + return } - } - fun markAllRead(range: MarkRead) { val animateMarkRead = openNextFeedOnReadAll && - canSwipeToNextFeed && + nextFilter != null && canOpenNextFeed(filter, range) if (animateMarkRead) { @@ -304,6 +310,24 @@ fun ArticleScreen( } } + fun onSwipeUp() { + when (swipeBottom) { + ArticleListVerticalSwipe.NEXT_FEED -> { + coroutineScope.launchUI { + openNextStatus { + viewModel.requestNextFeed() + } + } + } + + ArticleListVerticalSwipe.MARK_ALL_READ -> { + markAllRead(MarkRead.All) + } + + ArticleListVerticalSwipe.DISABLED -> {} + } + } + val refreshPagination = { coroutineScope.launch { resetScrollBehaviorOffset() @@ -510,107 +534,106 @@ fun ArticleScreen( val keyboardManager = LocalSoftwareKeyboardController.current val markReadPosition = LocalMarkAllReadButtonPosition.current - Scaffold( - modifier = Modifier - .nestedScroll(scrollBehavior.nestedScrollConnection) - .nestedScroll(object : NestedScrollConnection { - override fun onPostScroll( - consumed: Offset, - available: Offset, - source: NestedScrollSource - ): Offset { - if (search.isActive) { - keyboardManager?.hide() - } + CompositionLocalProvider( + LocalMarkAllRead provides { markAllRead(MarkRead.All) }, + ) { + Scaffold( + modifier = Modifier + .nestedScroll(scrollBehavior.nestedScrollConnection) + .nestedScroll(object : NestedScrollConnection { + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource + ): Offset { + if (search.isActive) { + keyboardManager?.hide() + } - return Offset.Zero - } - }), - topBar = { - ArticleListTopBar( - onRequestJumpToTop = { scrollToTop() }, - onNavigateToDrawer = { openDrawer() }, - scrollBehavior = scrollBehavior, - onMarkAllRead = { markAllRead(MarkRead.All) }, - search = search, - filter = filter, - feeds = allFeeds, - savedSearches = savedSearches, - folders = allFolders, - hideReadArticles = hideReadArticles, - onToggleHideReadArticles = { viewModel.toggleHideReadArticles() }, - ) - }, - snackbarHost = { - SnackbarHost(hostState = snackbarHostState) - }, - floatingActionButton = { - if (markReadPosition == MarkReadPosition.FLOATING_ACTION_BUTTON) { - MarkAllReadButton( - onMarkAllRead = { - markAllRead(MarkRead.All) - }, - position = MarkReadPosition.FLOATING_ACTION_BUTTON, - ) - } - }, - bottomBar = { - audioEnclosure?.let { audio -> - FloatingAudioPlayer( - audio = audio, - controller = audioController, - onDismiss = { - audioController.dismiss() - }, + return Offset.Zero + } + }), + topBar = { + ArticleListTopBar( + onRequestJumpToTop = { scrollToTop() }, + onNavigateToDrawer = { openDrawer() }, + scrollBehavior = scrollBehavior, + search = search, + filter = filter, + feeds = allFeeds, + savedSearches = savedSearches, + folders = allFolders, + hideReadArticles = hideReadArticles, + onToggleHideReadArticles = { viewModel.toggleHideReadArticles() }, ) - } - } - ) { innerPadding -> - ArticleListScaffold( - padding = innerPadding, - showOnboarding = showOnboarding, - onboarding = { - EmptyOnboardingView { - AddFeedButton( - onComplete = { - onFeedAdded(it) - } + }, + snackbarHost = { + SnackbarHost(hostState = snackbarHostState) + }, + floatingActionButton = { + if (markReadPosition == MarkReadPosition.FLOATING_ACTION_BUTTON) { + MarkAllReadButton( + position = MarkReadPosition.FLOATING_ACTION_BUTTON, ) } }, - ) { - PullToRefreshBox( - isRefreshing = isPullToRefreshing, - onRefresh = { - refreshFeeds() + bottomBar = { + audioEnclosure?.let { audio -> + FloatingAudioPlayer( + audio = audio, + controller = audioController, + onDismiss = { + audioController.dismiss() + }, + ) + } + } + ) { innerPadding -> + ArticleListScaffold( + padding = innerPadding, + showOnboarding = showOnboarding, + onboarding = { + EmptyOnboardingView { + AddFeedButton( + onComplete = { + onFeedAdded(it) + } + ) + } }, - modifier = Modifier.fillMaxSize() ) { - PullToNextFeedBox( - modifier = Modifier.fillMaxSize(), - enabled = canSwipeToNextFeed, - onRequestNext = { - requestNextFeed() + PullToRefreshBox( + isRefreshing = isPullToRefreshing, + onRefresh = { + refreshFeeds() }, + modifier = Modifier.fillMaxSize() ) { - if (isRefreshInitialized && articles.itemCount == 0) { - ArticleListEmptyView() - } else { - ArticleList( - articles = articles, - selectedArticleKey = article?.id, - listState = listState, - enableMarkReadOnScroll = enableMarkReadOnScroll, - refreshingAll = viewModel.refreshingAll, - dimReadArticles = filter !is ArticleFilter.Starred, - showIcons = !filter.isReadLaterFeed(readLaterFeed), - onMarkAllRead = { range -> - onMarkAllRead(range) - }, - onSelect = { articleID -> - selectArticle(articleID) - }, - ) + SwipeUpActionBox( + modifier = Modifier.fillMaxSize(), + enabled = canSwipeBottom, + onRequestNext = { + onSwipeUp() + }, + ) { + if (isRefreshInitialized && articles.itemCount == 0) { + ArticleListEmptyView() + } else { + ArticleList( + articles = articles, + selectedArticleKey = article?.id, + listState = listState, + enableMarkReadOnScroll = enableMarkReadOnScroll, + refreshingAll = viewModel.refreshingAll, + dimReadArticles = filter !is ArticleFilter.Starred, + onMarkAllRead = { range -> + onMarkAllRead(range) + }, + onSelect = { articleID -> + selectArticle(articleID) + }, + ) + } } } } @@ -687,6 +710,18 @@ fun ArticleScreen( } + if (isMarkAllReadDialogOpen) { + MarkAllReadDialog( + onConfirm = { + isMarkAllReadDialogOpen = false + onMarkAllRead(MarkRead.All) + }, + onDismissRequest = { + isMarkAllReadDialogOpen = false + }, + ) + } + if (viewModel.showUnauthorizedMessage) { UnauthorizedAlertDialog( onConfirm = openUpdatePasswordDialog, diff --git a/app/src/main/java/com/capyreader/app/ui/articles/ArticleScreenViewModel.kt b/app/src/main/java/com/capyreader/app/ui/articles/ArticleScreenViewModel.kt index 114cdf409..5c38a6016 100644 --- a/app/src/main/java/com/capyreader/app/ui/articles/ArticleScreenViewModel.kt +++ b/app/src/main/java/com/capyreader/app/ui/articles/ArticleScreenViewModel.kt @@ -27,7 +27,7 @@ import com.jocmp.capy.Folder import com.jocmp.capy.MarkRead import com.jocmp.capy.SavedSearch import com.jocmp.capy.articles.ArticleContent -import com.jocmp.capy.articles.NextFilter +import com.jocmp.capy.articles.SidebarItem import com.jocmp.capy.common.UnauthorizedError import com.jocmp.capy.common.launchIO import com.jocmp.capy.common.launchUI @@ -61,7 +61,7 @@ class ArticleScreenViewModel( val hideReadArticles = appPreferences.articleListOptions.hideReadArticles.stateIn(viewModelScope) - private val listSwipeBottom = + val listSwipeBottom = appPreferences.articleListOptions.swipeBottom.stateIn(viewModelScope) private val _searchQuery = MutableStateFlow("") @@ -146,28 +146,35 @@ class ArticleScreenViewModel( ?.let { copyFeedCounts(it, latestCounts) } } - private val nextFilterListener: Flow = + private val sidebar: Flow> = combine( + readLaterFeed, + savedSearches, + folders, + topLevelFeeds, + ) { readLater, searches, fldrs, fds -> + SidebarItem.buildList( + readLaterFeed = readLater, + savedSearches = searches, + folders = fldrs, + feeds = fds, + ) + } + + private val _nextItem = MutableStateFlow(null) + + private val nextItemListener: Flow = combine( listSwipeBottom, - savedSearches, - topLevelFeeds, - folders, - filter - ) { swipeBottom, savedSearches, feeds, folders, filter -> + sidebar, + filter, + ) { swipeBottom, sidebar, filter -> if (swipeBottom == ArticleListVerticalSwipe.DISABLED) { return@combine null } - NextFilter.findSwipeDestination( - filter, - searches = savedSearches, - folders = folders, - feeds = feeds, - ) + sidebar.find { it.isSelected(filter) }?.next } - private val _nextFilter = MutableStateFlow(null) - val statusCount: Flow = filter.flatMapLatest { latestFilter -> account.countAllByStatus(countableStatus(latestFilter)) } @@ -200,13 +207,13 @@ class ArticleScreenViewModel( val searchState: Flow get() = _searchState - val nextFilter: Flow - get() = _nextFilter + val nextFilter: Flow + get() = _nextItem init { viewModelScope.launch { - nextFilterListener.collect { - _nextFilter.value = it + nextItemListener.collect { + _nextItem.value = it } } } @@ -266,9 +273,6 @@ class ArticleScreenViewModel( fun markAllRead( onArticlesCleared: () -> Unit, range: MarkRead, - searches: List, - folders: List, - feeds: List, ) { viewModelScope.launchIO { val articleIDs = account.unreadArticleIDs( @@ -293,7 +297,7 @@ class ArticleScreenViewModel( if (afterReadAll.value == AfterReadAllBehavior.OPEN_DRAWER) { onArticlesCleared() } else if (afterReadAll.value == AfterReadAllBehavior.OPEN_NEXT_FEED) { - openNextFeedOnAllRead(onArticlesCleared, searches, folders, feeds) + openNextFeedOnAllRead(onArticlesCleared) } } } @@ -489,19 +493,12 @@ class ArticleScreenViewModel( } fun requestNextFeed() { - _nextFilter.value?.let(::selectNextFilter) + _nextItem.value?.let(::selectSidebarItem) } - private fun selectNextFilter(filter: NextFilter) { - when (filter) { - is NextFilter.FeedFilter -> selectFeed( - feedID = filter.feedID, - folderTitle = filter.folderTitle - ) - - is NextFilter.FolderFilter -> selectFolder(title = filter.folderTitle) - is NextFilter.SearchFilter -> selectSavedSearch(filter.savedSearchID) - } + private fun selectSidebarItem(item: SidebarItem) { + val filter = item.toFilter(currentStatus) + updateFilter(filter) } private fun addStar(articleID: String) { @@ -679,19 +676,11 @@ class ArticleScreenViewModel( private fun openNextFeedOnAllRead( onArticlesCleared: () -> Unit, - searches: List, - folders: List, - feeds: List, ) { - val nextFilter = NextFilter.findMarkReadDestination( - latestFilter, - searches, - folders, - feeds, - ) + val nextItem = _nextItem.value - if (nextFilter != null) { - selectNextFilter(nextFilter) + if (nextItem != null) { + selectSidebarItem(nextItem) } else { if (latestFilter.status == UNREAD) { selectArticleFilter() diff --git a/app/src/main/java/com/capyreader/app/ui/articles/FilterActionMenu.kt b/app/src/main/java/com/capyreader/app/ui/articles/FilterActionMenu.kt index 53d5693da..a62b3fea5 100644 --- a/app/src/main/java/com/capyreader/app/ui/articles/FilterActionMenu.kt +++ b/app/src/main/java/com/capyreader/app/ui/articles/FilterActionMenu.kt @@ -24,7 +24,6 @@ import com.jocmp.capy.Feed @Composable fun FilterActionMenu( filter: ArticleFilter, - onMarkAllRead: () -> Unit, onRequestSearch: () -> Unit, hideSearchIcon: Boolean, hideReadArticles: Boolean = false, @@ -64,11 +63,7 @@ fun FilterActionMenu( } if (markReadPosition == MarkReadPosition.TOOLBAR) { - MarkAllReadButton( - onMarkAllRead = { - onMarkAllRead() - }, - ) + MarkAllReadButton() } } } @@ -78,7 +73,6 @@ fun FilterActionMenu( fun FeedActionsPreview(@PreviewParameter(FeedSample::class) feed: Feed) { PreviewKoinApplication { FilterActionMenu( - onMarkAllRead = {}, onRequestSearch = {}, filter = ArticleFilter.Feeds( feedID = feed.id, @@ -96,7 +90,6 @@ fun FeedActionsPreview(@PreviewParameter(FeedSample::class) feed: Feed) { fun FeedActionsPreviewFilterOff(@PreviewParameter(FeedSample::class) feed: Feed) { PreviewKoinApplication { FilterActionMenu( - onMarkAllRead = {}, onRequestSearch = {}, filter = ArticleFilter.Feeds( feedID = feed.id, diff --git a/app/src/main/java/com/capyreader/app/ui/articles/list/ArticleListTopBar.kt b/app/src/main/java/com/capyreader/app/ui/articles/list/ArticleListTopBar.kt index 7b58ca1e1..de599a699 100644 --- a/app/src/main/java/com/capyreader/app/ui/articles/list/ArticleListTopBar.kt +++ b/app/src/main/java/com/capyreader/app/ui/articles/list/ArticleListTopBar.kt @@ -43,7 +43,6 @@ fun ArticleListTopBar( onRequestJumpToTop: () -> Unit, onNavigateToDrawer: () -> Unit, scrollBehavior: TopAppBarScrollBehavior, - onMarkAllRead: () -> Unit, search: ArticleSearch, filter: ArticleFilter, feeds: List, @@ -139,7 +138,6 @@ fun ArticleListTopBar( FilterActionMenu( filter = filter, onRequestSearch = { search.start() }, - onMarkAllRead = { onMarkAllRead() }, hideSearchIcon = enableSearch, hideReadArticles = hideReadArticles, onToggleHideReadArticles = onToggleHideReadArticles, @@ -157,7 +155,6 @@ private fun FeedListTopBarPreview() { onRequestJumpToTop = { }, onNavigateToDrawer = { }, scrollBehavior = scrollBehavior, - onMarkAllRead = {}, search = ArticleSearch(), filter = ArticleFilter.default(), feeds = listOf(), diff --git a/app/src/main/java/com/capyreader/app/ui/articles/list/LocalMarkAllRead.kt b/app/src/main/java/com/capyreader/app/ui/articles/list/LocalMarkAllRead.kt new file mode 100644 index 000000000..4a5985f15 --- /dev/null +++ b/app/src/main/java/com/capyreader/app/ui/articles/list/LocalMarkAllRead.kt @@ -0,0 +1,5 @@ +package com.capyreader.app.ui.articles.list + +import androidx.compose.runtime.compositionLocalOf + +val LocalMarkAllRead = compositionLocalOf<() -> Unit> { {} } diff --git a/app/src/main/java/com/capyreader/app/ui/articles/list/MarkAllReadButton.kt b/app/src/main/java/com/capyreader/app/ui/articles/list/MarkAllReadButton.kt index 48968d45e..0a046e3cd 100644 --- a/app/src/main/java/com/capyreader/app/ui/articles/list/MarkAllReadButton.kt +++ b/app/src/main/java/com/capyreader/app/ui/articles/list/MarkAllReadButton.kt @@ -11,9 +11,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.ui.res.stringResource import com.capyreader.app.R import com.capyreader.app.ui.LocalUnreadCount @@ -21,27 +18,10 @@ import com.capyreader.app.ui.articles.MarkReadPosition @Composable fun MarkAllReadButton( - onMarkAllRead: () -> Unit, position: MarkReadPosition = MarkReadPosition.TOOLBAR, ) { val unreadCount = LocalUnreadCount.current - val confirmationEnabled by rememberMarkAllReadState() - - val (isDialogOpen, setDialogOpen) = remember { - mutableStateOf(false) - } - - val closeDialog = { - setDialogOpen(false) - } - - val onClick = { - if (confirmationEnabled) { - setDialogOpen(true) - } else { - onMarkAllRead() - } - } + val requestMarkAllRead = LocalMarkAllRead.current if (position == MarkReadPosition.FLOATING_ACTION_BUTTON) { AnimatedVisibility( @@ -52,9 +32,7 @@ fun MarkAllReadButton( FloatingActionButton( containerColor = MaterialTheme.colorScheme.primary, shape = CircleShape, - onClick = { - onClick() - } + onClick = { requestMarkAllRead() } ) { Icon( imageVector = Icons.Filled.CheckCircle, @@ -65,9 +43,7 @@ fun MarkAllReadButton( } else { IconButton( enabled = unreadCount > 0, - onClick = { - onClick() - } + onClick = { requestMarkAllRead() } ) { Icon( imageVector = Icons.Filled.CheckCircle, @@ -75,14 +51,4 @@ fun MarkAllReadButton( ) } } - - if (isDialogOpen) { - MarkAllReadDialog( - onConfirm = { - closeDialog() - onMarkAllRead() - }, - onDismissRequest = { closeDialog() } - ) - } } diff --git a/app/src/main/java/com/capyreader/app/ui/articles/list/MarkAllReadState.kt b/app/src/main/java/com/capyreader/app/ui/articles/list/MarkAllReadState.kt deleted file mode 100644 index e46fe5349..000000000 --- a/app/src/main/java/com/capyreader/app/ui/articles/list/MarkAllReadState.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.capyreader.app.ui.articles.list - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.State -import com.capyreader.app.preferences.AppPreferences -import com.capyreader.app.common.asState -import org.koin.compose.koinInject - -@Composable -internal fun rememberMarkAllReadState( - appPreferences: AppPreferences = koinInject(), -): State { - return appPreferences.articleListOptions.confirmMarkAllRead.asState() -} diff --git a/app/src/main/java/com/capyreader/app/ui/articles/list/PullToNextFeedBox.kt b/app/src/main/java/com/capyreader/app/ui/articles/list/SwipeUpActionBox.kt similarity index 97% rename from app/src/main/java/com/capyreader/app/ui/articles/list/PullToNextFeedBox.kt rename to app/src/main/java/com/capyreader/app/ui/articles/list/SwipeUpActionBox.kt index db320dceb..ccd19e118 100644 --- a/app/src/main/java/com/capyreader/app/ui/articles/list/PullToNextFeedBox.kt +++ b/app/src/main/java/com/capyreader/app/ui/articles/list/SwipeUpActionBox.kt @@ -10,7 +10,7 @@ import androidx.compose.ui.platform.LocalHapticFeedback import com.capyreader.app.ui.components.pullrefresh.SwipeRefresh @Composable -fun PullToNextFeedBox( +fun SwipeUpActionBox( modifier: Modifier = Modifier, enabled: Boolean = true, onRequestNext: () -> Unit, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bb810e261..08fc46a4c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -256,6 +256,7 @@ Toggle Read Toggle Starred Go to next feed + Mark all as read Disabled Article Sort Newest First diff --git a/capy/src/main/java/com/jocmp/capy/articles/NextFilter.kt b/capy/src/main/java/com/jocmp/capy/articles/NextFilter.kt deleted file mode 100644 index 85fc09776..000000000 --- a/capy/src/main/java/com/jocmp/capy/articles/NextFilter.kt +++ /dev/null @@ -1,173 +0,0 @@ -package com.jocmp.capy.articles - -import com.jocmp.capy.ArticleFilter -import com.jocmp.capy.Feed -import com.jocmp.capy.Folder -import com.jocmp.capy.SavedSearch - -sealed class NextFilter { - data class FolderFilter(val folderTitle: String) : NextFilter() - - data class FeedFilter(val feedID: String, val folderTitle: String? = null) : NextFilter() - - data class SearchFilter(val savedSearchID: String) : NextFilter() - - companion object { - fun findMarkReadDestination( - filter: ArticleFilter, - searches: List, - folders: List, - feeds: List, - ): NextFilter? { - return when (filter) { - is ArticleFilter.Feeds -> findNextFeed(filter, folders, feeds) - is ArticleFilter.Folders -> { - val folderIndex = folders.indexOfFirst { it.title == filter.folderTitle } - val nextFolder = folders.getOrNull(folderIndex + 1) - val nextFeed = feeds.firstOrNull() - - if (nextFolder != null) { - FolderFilter(nextFolder.title) - } else if (nextFeed != null) { - FeedFilter(feedID = nextFeed.id, folderTitle = filter.folderTitle) - } else { - null - } - } - - is ArticleFilter.SavedSearches -> { - val index = searches.indexOfFirst { it.id == filter.savedSearchID } - val nextSearch = searches.getOrNull(index + 1) - val nextFolder = folders.firstOrNull() - val nextFeed = feeds.firstOrNull() - - if (nextSearch != null) { - SearchFilter(nextSearch.id) - } else if (nextFolder != null) { - FolderFilter(nextFolder.title) - } else if (nextFeed != null) { - FeedFilter(feedID = nextFeed.id, folderTitle = null) - } else { - null - } - } - - else -> null - } - } - - fun findSwipeDestination( - filter: ArticleFilter, - searches: List, - feeds: List, - folders: List, - ): NextFilter? { - return when (filter) { - is ArticleFilter.Articles -> { - val firstFeed = feeds.firstOrNull() - val firstFolder = folders.firstOrNull() - val firstSearch = searches.firstOrNull() - - if (firstSearch != null) { - SearchFilter(firstSearch.id) - } else if (firstFolder != null) { - FolderFilter(firstFolder.title) - } else if (firstFeed != null) { - FeedFilter(feedID = firstFeed.id, folderTitle = null) - } else { - null - } - } - - is ArticleFilter.SavedSearches -> { - val firstFeed = feeds.firstOrNull() - val firstFolder = folders.firstOrNull() - val index = searches.indexOfFirst { filter.savedSearchID == it.id } - val nextSearch = searches.getOrNull(index + 1) - - if (nextSearch != null) { - SearchFilter(nextSearch.id) - } else if (firstFolder != null) { - FolderFilter(firstFolder.title) - } else if (firstFeed != null) { - FeedFilter(feedID = firstFeed.id, folderTitle = null) - } else { - null - } - } - - is ArticleFilter.Folders -> { - val firstFolderFeed = folders - .find { it.title == filter.folderTitle } - ?.feeds - ?.firstOrNull() - - val nextFeed = feeds.firstOrNull() - val folderIndex = folders.indexOfFirst { it.title == filter.folderTitle } - val nextFolder = folders.getOrNull(folderIndex + 1) - - if (firstFolderFeed != null && firstFolderFeed.folderExpanded) { - FeedFilter(feedID = firstFolderFeed.id, folderTitle = filter.folderTitle) - } else if (nextFolder != null) { - FolderFilter(nextFolder.title) - } else if (nextFeed != null) { - FeedFilter(feedID = nextFeed.id, folderTitle = null) - } else { - null - } - } - - is ArticleFilter.Feeds -> findNextFeed(filter, folders, feeds) - is ArticleFilter.Today, is ArticleFilter.Starred -> { - val firstFeed = feeds.firstOrNull() - val firstFolder = folders.firstOrNull() - val firstSearch = searches.firstOrNull() - - if (firstSearch != null) { - SearchFilter(firstSearch.id) - } else if (firstFolder != null) { - FolderFilter(firstFolder.title) - } else if (firstFeed != null) { - FeedFilter(feedID = firstFeed.id, folderTitle = null) - } else { - null - } - } - } - } - - private fun findNextFeed( - filter: ArticleFilter.Feeds, - folders: List, - feeds: List - ): NextFilter? { - return if (filter.folderTitle == null) { - val index = feeds.indexOfFirst { it.id == filter.feedID } - - val nextFeed = feeds.getOrNull(index + 1) ?: return null - - FeedFilter(feedID = nextFeed.id, folderTitle = null) - } else { - val folderIndex = folders - .indexOfFirst { it.title == filter.folderTitle } - - val folderFeeds = folders.getOrNull(folderIndex)?.feeds.orEmpty() - - val index = folderFeeds.indexOfFirst { it.id == filter.feedID } - val nextFolderFeed = folderFeeds.getOrNull(index + 1) - val nextFolder = folders.getOrNull(folderIndex + 1) - val nextFeed = feeds.firstOrNull { it.id != filter.feedID } - - if (nextFolderFeed != null) { - FeedFilter(feedID = nextFolderFeed.id, folderTitle = filter.folderTitle) - } else if (nextFolder != null) { - FolderFilter(nextFolder.title) - } else if (nextFeed != null) { - FeedFilter(feedID = nextFeed.id, folderTitle = null) - } else { - null - } - } - } - } -} diff --git a/capy/src/main/java/com/jocmp/capy/articles/SidebarItem.kt b/capy/src/main/java/com/jocmp/capy/articles/SidebarItem.kt new file mode 100644 index 000000000..db530bde6 --- /dev/null +++ b/capy/src/main/java/com/jocmp/capy/articles/SidebarItem.kt @@ -0,0 +1,85 @@ +package com.jocmp.capy.articles + +import com.jocmp.capy.ArticleFilter +import com.jocmp.capy.ArticleStatus +import com.jocmp.capy.Feed +import com.jocmp.capy.Folder +import com.jocmp.capy.SavedSearch + +class SidebarItem( + val toFilter: (ArticleStatus) -> ArticleFilter, + val isSelected: (ArticleFilter) -> Boolean, + var next: SidebarItem? = null, +) { + companion object { + fun buildList( + readLaterFeed: Feed? = null, + savedSearches: List = emptyList(), + folders: List = emptyList(), + feeds: List = emptyList(), + ): List { + val items = mutableListOf() + + items += todayItem() + items += articlesItem() + items += starredItem() + + if (readLaterFeed != null) { + items += feedItem(feedID = readLaterFeed.id, folderTitle = null) + } + + savedSearches.forEach { items += savedSearchItem(savedSearchID = it.id) } + + folders.forEach { folder -> + items += folderItem(folderTitle = folder.title) + if (folder.expanded) { + folder.feeds.forEach { feed -> + items += feedItem(feedID = feed.id, folderTitle = folder.title) + } + } + } + + feeds.forEach { items += feedItem(feedID = it.id, folderTitle = null) } + + items.zipWithNext().forEach { (current, next) -> + current.next = next + } + + return items + } + + private fun todayItem() = SidebarItem( + toFilter = { ArticleFilter.Today(it) }, + isSelected = { it is ArticleFilter.Today }, + ) + + private fun articlesItem() = SidebarItem( + toFilter = { ArticleFilter.Articles(it) }, + isSelected = { it is ArticleFilter.Articles }, + ) + + private fun starredItem() = SidebarItem( + toFilter = { ArticleFilter.Starred(it) }, + isSelected = { it is ArticleFilter.Starred }, + ) + + private fun savedSearchItem(savedSearchID: String) = SidebarItem( + toFilter = { ArticleFilter.SavedSearches(savedSearchID = savedSearchID, savedSearchStatus = it) }, + isSelected = { it is ArticleFilter.SavedSearches && it.savedSearchID == savedSearchID }, + ) + + private fun folderItem(folderTitle: String) = SidebarItem( + toFilter = { ArticleFilter.Folders(folderTitle = folderTitle, folderStatus = it) }, + isSelected = { it is ArticleFilter.Folders && it.folderTitle == folderTitle }, + ) + + private fun feedItem(feedID: String, folderTitle: String?) = SidebarItem( + toFilter = { ArticleFilter.Feeds(feedID = feedID, folderTitle = folderTitle, feedStatus = it) }, + isSelected = { + it is ArticleFilter.Feeds && + it.feedID == feedID && + it.folderTitle.orEmpty() == folderTitle.orEmpty() + }, + ) + } +} diff --git a/capy/src/test/java/com/jocmp/capy/articles/NextFilterTest.kt b/capy/src/test/java/com/jocmp/capy/articles/NextFilterTest.kt deleted file mode 100644 index 58d9e6bfb..000000000 --- a/capy/src/test/java/com/jocmp/capy/articles/NextFilterTest.kt +++ /dev/null @@ -1,449 +0,0 @@ -package com.jocmp.capy.articles - -import com.jocmp.capy.ArticleFilter -import com.jocmp.capy.ArticleStatus -import com.jocmp.capy.Folder -import com.jocmp.capy.InMemoryDatabaseProvider -import com.jocmp.capy.SavedSearch -import com.jocmp.capy.fixtures.FeedFixture -import com.jocmp.capy.repeated -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNull -import kotlin.test.assertTrue - -class NextFilterTest { - private lateinit var feedFixture: FeedFixture - - @BeforeTest - fun setup() { - val database = InMemoryDatabaseProvider.build(accountID = "1009") - feedFixture = FeedFixture(database) - } - - @Test - fun `findSwipeDestination on article filter with a saved search`() { - val filter = ArticleFilter.Articles(articleStatus = ArticleStatus.UNREAD) - val search = SavedSearch(id = "1", name = "My Search", query = null) - - val next = NextFilter.findSwipeDestination( - filter, - feeds = emptyList(), - folders = listOf(Folder(title = "Uncategorized")), - searches = listOf(search) - )!! - - assertTrue(next is NextFilter.SearchFilter) - assertEquals(actual = next.savedSearchID, expected = search.id) - } - - @Test - fun `findSwipeDestination on article filter with the last saved search`() { - val folder = Folder(title = "This Is My Next Folder") - val folders = listOf(folder) - val search = SavedSearch(id = "2", name = "My Second Search", query = null) - val searches = listOf(search) - val filter = ArticleFilter.SavedSearches( - savedSearchID = search.id, - savedSearchStatus = ArticleStatus.UNREAD - ) - - val next = NextFilter.findSwipeDestination( - filter, - feeds = emptyList(), - folders = folders, - searches = searches, - )!! - - assertTrue(next is NextFilter.FolderFilter) - assertEquals(actual = next.folderTitle, expected = folder.title) - } - - @Test - fun `findSwipeDestination on article filter with a folder`() { - val filter = ArticleFilter.Articles(articleStatus = ArticleStatus.UNREAD) - val folder = Folder(title = "This Is My Next Folder") - val folders = listOf(folder) - - val next = NextFilter.findSwipeDestination( - filter, - feeds = emptyList(), - folders = folders, - searches = emptyList(), - )!! - - assertTrue(next is NextFilter.FolderFilter) - assertEquals(actual = next.folderTitle, expected = folder.title) - } - - @Test - fun `findSwipeDestination on article filter with a feed`() { - val filter = ArticleFilter.Articles(articleStatus = ArticleStatus.UNREAD) - val feed = feedFixture.create() - val feeds = listOf(feed) - - val next = NextFilter.findSwipeDestination( - filter, - feeds = feeds, - folders = emptyList(), - searches = emptyList(), - )!! - - assertTrue(next is NextFilter.FeedFilter) - assertEquals(actual = next.feedID, expected = feed.id) - assertNull(next.folderTitle) - } - - @Test - fun `findSwipeDestination on article filter that is empty`() { - val filter = ArticleFilter.Articles(articleStatus = ArticleStatus.UNREAD) - - val next = NextFilter.findSwipeDestination( - filter, - feeds = emptyList(), - folders = emptyList(), - searches = emptyList() - ) - - assertNull(next) - } - - @Test - fun `findSwipeDestination on a folder filter with a feed`() { - val folderTitle = "My Folder" - val folderFeeds = 2.repeated { index -> - feedFixture.create(title = "${index + 1} My Title").copy(folderExpanded = true) - } - val folder = Folder( - title = folderTitle, - feeds = folderFeeds, - ) - val anotherFolder = Folder(title = "Bad folder") - val filter = ArticleFilter.Folders( - folderTitle = folder.title, - folderStatus = ArticleStatus.UNREAD - ) - - val next = NextFilter.findSwipeDestination( - filter, - feeds = emptyList(), - folders = listOf(anotherFolder, folder), - searches = emptyList(), - )!! - - val expectedFeed = folderFeeds.first() - assertTrue(next is NextFilter.FeedFilter) - assertEquals(actual = next.feedID, expected = expectedFeed.id) - assertEquals(actual = next.folderTitle, expected = folder.title) - } - - @Test - fun `findSwipeDestination on a folder filter with a feed that's not expanded`() { - val folderTitle = "My Folder" - val folderFeeds = 2.repeated { index -> - feedFixture.create(title = "${index + 1} My Title") - } - val folder = Folder( - title = folderTitle, - feeds = folderFeeds, - ) - val anotherFolder = Folder(title = "Next folder") - val filter = ArticleFilter.Folders( - folderTitle = folder.title, - folderStatus = ArticleStatus.UNREAD - ) - - val next = NextFilter.findSwipeDestination( - filter, - feeds = emptyList(), - folders = listOf(folder, anotherFolder), - searches = emptyList(), - )!! - - assertTrue(next is NextFilter.FolderFilter) - assertEquals(actual = next.folderTitle, expected = anotherFolder.title) - } - - @Test - fun `findSwipeDestination on a folder filter that is empty`() { - val folderTitle = "My Folder" - val folder = Folder(title = folderTitle) - val anotherFolder = Folder(title = "Bad folder") - - val filter = ArticleFilter.Folders( - folderTitle = folder.title, - folderStatus = ArticleStatus.UNREAD - ) - - val next = - NextFilter.findSwipeDestination( - filter, - feeds = emptyList(), - folders = listOf(anotherFolder, folder), - searches = emptyList(), - ) - - assertNull(next) - } - - @Test - fun `findSwipeDestination on a feed filter that is a top-level feed`() { - val topLevelFeeds = 3.repeated { index -> - feedFixture.create(title = "${index + 1} My Title") - } - val someFolder = Folder(title = "Some folder") - val anotherFolder = Folder(title = "Yet another folder") - - val filter = ArticleFilter.Feeds( - feedID = topLevelFeeds.first().id, - folderTitle = null, - feedStatus = ArticleStatus.UNREAD - ) - - val next = NextFilter.findSwipeDestination( - filter, - feeds = topLevelFeeds, - folders = listOf(someFolder, anotherFolder), - searches = emptyList(), - )!! - - val expectedFeed = topLevelFeeds[1] - assertTrue(next is NextFilter.FeedFilter) - assertEquals(actual = next.feedID, expected = expectedFeed.id) - assertNull(next.folderTitle) - } - - @Test - fun `findSwipeDestination on a feed filter that is the last top-level feed`() { - val topLevelFeeds = 3.repeated { index -> - feedFixture.create(title = "${index + 1} My Title") - } - val someFolder = Folder(title = "Some folder") - val anotherFolder = Folder(title = "Yet another folder") - - val filter = ArticleFilter.Feeds( - feedID = topLevelFeeds[2].id, - folderTitle = null, - feedStatus = ArticleStatus.UNREAD - ) - - val next = NextFilter.findSwipeDestination( - filter, - feeds = topLevelFeeds, - folders = listOf(someFolder, anotherFolder), - searches = emptyList(), - ) - - assertNull(next) - } - - @Test - fun `findSwipeDestination on the last folder feed with a next feed`() { - val folderTitle = "My Folder" - val folderFeeds = 2.repeated { index -> - feedFixture.create(title = "${index + 1} My Title") - } - val folder = Folder( - title = folderTitle, - feeds = folderFeeds - ) - val anotherFolder = Folder(title = "Next Folder") - val filter = ArticleFilter.Feeds( - feedID = folderFeeds.first().id, - folderTitle = folderTitle, - feedStatus = ArticleStatus.UNREAD - ) - val next = - NextFilter.findSwipeDestination( - filter, - feeds = emptyList(), - folders = listOf(folder, anotherFolder), - searches = emptyList(), - )!! - - val expectedFeed = folderFeeds[1] - assertTrue(next is NextFilter.FeedFilter) - assertEquals(actual = next.feedID, expected = expectedFeed.id) - assertEquals(actual = next.folderTitle, expected = folderTitle) - } - - @Test - fun `findSwipeDestination on the last folder feed with a next folder`() { - val folderTitle = "My Folder" - val folderFeeds = 2.repeated { index -> - feedFixture.create(title = "${index + 1} My Title") - } - val folder = Folder( - title = folderTitle, - feeds = folderFeeds - ) - val anotherFolder = Folder(title = "Next Folder") - val filter = ArticleFilter.Feeds( - feedID = folderFeeds[1].id, - folderTitle = folderTitle, - feedStatus = ArticleStatus.UNREAD - ) - val next = - NextFilter.findSwipeDestination( - filter, - feeds = emptyList(), - folders = listOf(folder, anotherFolder), - searches = emptyList(), - )!! - - assertTrue(next is NextFilter.FolderFilter) - assertEquals(actual = next.folderTitle, expected = anotherFolder.title) - } - - @Test - fun `findSwipeDestination on the last folder feed with a next top-level feed`() { - val topLevelFeeds = 3.repeated { index -> - feedFixture.create(title = "${index + 1} My Top Level Title") - } - val folderTitle = "My Folder" - val folderFeeds = 2.repeated { index -> - feedFixture.create(title = "${index + 1} My nested title") - } - val folder = Folder( - title = folderTitle, - feeds = folderFeeds - ) - val filter = ArticleFilter.Feeds( - feedID = folderFeeds[1].id, - folderTitle = folderTitle, - feedStatus = ArticleStatus.UNREAD - ) - val next = - NextFilter.findSwipeDestination( - filter, - feeds = topLevelFeeds, - folders = listOf(folder), - searches = emptyList(), - )!! - - val expectedFeed = topLevelFeeds.first() - assertTrue(next is NextFilter.FeedFilter) - assertEquals(actual = next.feedID, expected = expectedFeed.id) - assertNull(next.folderTitle) - } - - @Test - fun `findMarkReadDestination on the last search filter and a next folder`() { - val someFolder = Folder(title = "Some folder") - val search = SavedSearch(id = "1", name = "My Search", query = null) - val filter = ArticleFilter.SavedSearches(search.id, savedSearchStatus = ArticleStatus.UNREAD) - - val next = NextFilter.findMarkReadDestination( - filter, - searches = listOf(search), - feeds = emptyList(), - folders = listOf(someFolder) - ) - - assertEquals(expected = NextFilter.FolderFilter(someFolder.title), actual = next) - } - - - @Test - fun `findMarkReadDestination on the last search filter and a next feed`() { - val topLevelFeeds = 3.repeated { index -> - feedFixture.create(title = "${index + 1} My Title") - } - val nextFeed = topLevelFeeds.first() - val search = SavedSearch(id = "1", name = "My Search", query = null) - val filter = ArticleFilter.SavedSearches(search.id, savedSearchStatus = ArticleStatus.UNREAD) - - val next = NextFilter.findMarkReadDestination( - filter, - searches = listOf(search), - feeds = topLevelFeeds, - folders = emptyList() - ) - - assertEquals(expected = NextFilter.FeedFilter(feedID = nextFeed.id), actual = next) - } - - @Test - fun `findMarkReadDestination on a feed filter that is the last top-level feed`() { - val topLevelFeeds = 3.repeated { index -> - feedFixture.create(title = "${index + 1} My Title") - } - val someFolder = Folder(title = "Some folder") - val anotherFolder = Folder(title = "Yet another folder") - - val filter = ArticleFilter.Feeds( - feedID = topLevelFeeds[2].id, - folderTitle = null, - feedStatus = ArticleStatus.UNREAD - ) - - val next = NextFilter.findMarkReadDestination( - filter, - searches = emptyList(), - feeds = topLevelFeeds, - folders = listOf(someFolder, anotherFolder) - ) - - assertNull(next) - } - - @Test - fun `findMarkReadDestination on the last folder feed with a next folder`() { - val folderTitle = "My Folder" - val folderFeeds = 2.repeated { index -> - feedFixture.create(title = "${index + 1} My Title") - } - val folder = Folder( - title = folderTitle, - feeds = folderFeeds - ) - val anotherFolder = Folder(title = "Next Folder") - val filter = ArticleFilter.Feeds( - feedID = folderFeeds[1].id, - folderTitle = folderTitle, - feedStatus = ArticleStatus.UNREAD - ) - val next = - NextFilter.findMarkReadDestination( - filter, - searches = emptyList(), - feeds = emptyList(), - folders = listOf(folder, anotherFolder) - )!! - - assertTrue(next is NextFilter.FolderFilter) - assertEquals(actual = next.folderTitle, expected = anotherFolder.title) - } - - @Test - fun `findMarkReadDestination on the last folder feed with a next top-level feed`() { - val topLevelFeeds = 3.repeated { index -> - feedFixture.create(title = "${index + 1} My Top Level Title") - } - val folderTitle = "My Folder" - val folderFeeds = 2.repeated { index -> - feedFixture.create(title = "${index + 1} My nested title") - } - val folder = Folder( - title = folderTitle, - feeds = folderFeeds - ) - val filter = ArticleFilter.Feeds( - feedID = folderFeeds[1].id, - folderTitle = folderTitle, - feedStatus = ArticleStatus.UNREAD - ) - val next = NextFilter.findMarkReadDestination( - filter, - searches = emptyList(), - feeds = topLevelFeeds, - folders = listOf(folder) - )!! - - val expectedFeed = topLevelFeeds.first() - assertTrue(next is NextFilter.FeedFilter) - assertEquals(actual = next.feedID, expected = expectedFeed.id) - assertNull(next.folderTitle) - } -} diff --git a/capy/src/test/java/com/jocmp/capy/articles/SidebarItemTest.kt b/capy/src/test/java/com/jocmp/capy/articles/SidebarItemTest.kt new file mode 100644 index 000000000..95935098f --- /dev/null +++ b/capy/src/test/java/com/jocmp/capy/articles/SidebarItemTest.kt @@ -0,0 +1,394 @@ +package com.jocmp.capy.articles + +import com.jocmp.capy.ArticleFilter +import com.jocmp.capy.ArticleStatus +import com.jocmp.capy.Folder +import com.jocmp.capy.InMemoryDatabaseProvider +import com.jocmp.capy.SavedSearch +import com.jocmp.capy.fixtures.FeedFixture +import com.jocmp.capy.repeated +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class SidebarItemTest { + private lateinit var feedFixture: FeedFixture + + @BeforeTest + fun setup() { + val database = InMemoryDatabaseProvider.build(accountID = "1009") + feedFixture = FeedFixture(database) + } + + private fun findNext( + filter: ArticleFilter, + searches: List = emptyList(), + folders: List = emptyList(), + feeds: List = emptyList(), + ): SidebarItem? { + val items = SidebarItem.buildList( + savedSearches = searches, + folders = folders, + feeds = feeds, + ) + return items.find { it.isSelected(filter) }?.next + } + + @Test + fun `next from starred with a saved search`() { + val filter = ArticleFilter.Starred() + val search = SavedSearch(id = "1", name = "My Search", query = null) + + val next = findNext( + filter, + folders = listOf(Folder(title = "Uncategorized")), + searches = listOf(search), + ) + + assertNotNull(next) + val nextFilter = next.toFilter(ArticleStatus.ALL) + assertIs(nextFilter) + assertEquals(expected = search.id, actual = nextFilter.savedSearchID) + } + + @Test + fun `next from last saved search to first folder`() { + val folder = Folder(title = "This Is My Next Folder") + val search = SavedSearch(id = "2", name = "My Second Search", query = null) + val filter = ArticleFilter.SavedSearches( + savedSearchID = search.id, + savedSearchStatus = ArticleStatus.UNREAD, + ) + + val next = findNext( + filter, + folders = listOf(folder), + searches = listOf(search), + ) + + assertNotNull(next) + val nextFilter = next.toFilter(ArticleStatus.ALL) + assertIs(nextFilter) + assertEquals(expected = folder.title, actual = nextFilter.folderTitle) + } + + @Test + fun `next from article filter with a folder`() { + val filter = ArticleFilter.Articles(articleStatus = ArticleStatus.UNREAD) + val folder = Folder(title = "This Is My Next Folder") + + val next = findNext( + filter, + folders = listOf(folder), + ) + + assertNotNull(next) + assertIs(next.toFilter(ArticleStatus.ALL)) + } + + @Test + fun `next from article filter with a feed`() { + val filter = ArticleFilter.Articles(articleStatus = ArticleStatus.UNREAD) + val feed = feedFixture.create() + + val next = findNext( + filter, + feeds = listOf(feed), + ) + + assertNotNull(next) + assertIs(next.toFilter(ArticleStatus.ALL)) + } + + @Test + fun `next from article filter that is empty`() { + val filter = ArticleFilter.Articles(articleStatus = ArticleStatus.UNREAD) + + val next = findNext(filter) + + assertNotNull(next) + assertIs(next.toFilter(ArticleStatus.ALL)) + } + + @Test + fun `next from starred when empty`() { + val filter = ArticleFilter.Starred() + + val next = findNext(filter) + + assertNull(next) + } + + @Test + fun `next from starred with a folder`() { + val filter = ArticleFilter.Starred() + val folder = Folder(title = "My Folder") + + val next = findNext( + filter, + folders = listOf(folder), + ) + + assertNotNull(next) + val nextFilter = next.toFilter(ArticleStatus.ALL) + assertIs(nextFilter) + assertEquals(expected = folder.title, actual = nextFilter.folderTitle) + } + + @Test + fun `next from expanded folder to first folder feed`() { + val folderTitle = "My Folder" + val folderFeeds = 2.repeated { index -> + feedFixture.create(title = "${index + 1} My Title").copy(folderExpanded = true) + } + val folder = Folder( + title = folderTitle, + feeds = folderFeeds, + expanded = true, + ) + val anotherFolder = Folder(title = "Bad folder") + val filter = ArticleFilter.Folders( + folderTitle = folder.title, + folderStatus = ArticleStatus.UNREAD, + ) + + val next = findNext( + filter, + folders = listOf(folder, anotherFolder), + ) + + assertNotNull(next) + val nextFilter = next.toFilter(ArticleStatus.ALL) + assertIs(nextFilter) + assertEquals(expected = folderFeeds.first().id, actual = nextFilter.feedID) + assertEquals(expected = folder.title, actual = nextFilter.folderTitle) + } + + @Test + fun `next from collapsed folder skips feeds to next folder`() { + val folderTitle = "My Folder" + val folderFeeds = 2.repeated { index -> + feedFixture.create(title = "${index + 1} My Title") + } + val folder = Folder( + title = folderTitle, + feeds = folderFeeds, + expanded = false, + ) + val anotherFolder = Folder(title = "Next folder") + val filter = ArticleFilter.Folders( + folderTitle = folder.title, + folderStatus = ArticleStatus.UNREAD, + ) + + val next = findNext( + filter, + folders = listOf(folder, anotherFolder), + ) + + assertNotNull(next) + val nextFilter = next.toFilter(ArticleStatus.ALL) + assertIs(nextFilter) + assertEquals(expected = anotherFolder.title, actual = nextFilter.folderTitle) + } + + @Test + fun `next from collapsed folder with no sibling returns null`() { + val folderTitle = "My Folder" + val folder = Folder(title = folderTitle) + + val filter = ArticleFilter.Folders( + folderTitle = folder.title, + folderStatus = ArticleStatus.UNREAD, + ) + + val next = findNext(filter, folders = listOf(folder)) + + assertNull(next) + } + + @Test + fun `next from top-level feed to next top-level feed`() { + val topLevelFeeds = 3.repeated { index -> + feedFixture.create(title = "${index + 1} My Title") + } + + val filter = ArticleFilter.Feeds( + feedID = topLevelFeeds.first().id, + folderTitle = null, + feedStatus = ArticleStatus.UNREAD, + ) + + val next = findNext( + filter, + feeds = topLevelFeeds, + ) + + assertNotNull(next) + val nextFilter = next.toFilter(ArticleStatus.ALL) + assertIs(nextFilter) + assertEquals(expected = topLevelFeeds[1].id, actual = nextFilter.feedID) + assertNull(nextFilter.folderTitle) + } + + @Test + fun `next from last top-level feed is null`() { + val topLevelFeeds = 3.repeated { index -> + feedFixture.create(title = "${index + 1} My Title") + } + + val filter = ArticleFilter.Feeds( + feedID = topLevelFeeds[2].id, + folderTitle = null, + feedStatus = ArticleStatus.UNREAD, + ) + + val next = findNext( + filter, + feeds = topLevelFeeds, + ) + + assertNull(next) + } + + @Test + fun `next from folder feed to next folder feed`() { + val folderTitle = "My Folder" + val folderFeeds = 2.repeated { index -> + feedFixture.create(title = "${index + 1} My Title") + } + val folder = Folder( + title = folderTitle, + feeds = folderFeeds, + expanded = true, + ) + val anotherFolder = Folder(title = "Next Folder") + val filter = ArticleFilter.Feeds( + feedID = folderFeeds.first().id, + folderTitle = folderTitle, + feedStatus = ArticleStatus.UNREAD, + ) + + val next = findNext( + filter, + folders = listOf(folder, anotherFolder), + ) + + assertNotNull(next) + val nextFilter = next.toFilter(ArticleStatus.ALL) + assertIs(nextFilter) + assertEquals(expected = folderFeeds[1].id, actual = nextFilter.feedID) + assertEquals(expected = folderTitle, actual = nextFilter.folderTitle) + } + + @Test + fun `next from last folder feed to next folder`() { + val folderTitle = "My Folder" + val folderFeeds = 2.repeated { index -> + feedFixture.create(title = "${index + 1} My Title") + } + val folder = Folder( + title = folderTitle, + feeds = folderFeeds, + expanded = true, + ) + val anotherFolder = Folder(title = "Next Folder") + val filter = ArticleFilter.Feeds( + feedID = folderFeeds[1].id, + folderTitle = folderTitle, + feedStatus = ArticleStatus.UNREAD, + ) + + val next = findNext( + filter, + folders = listOf(folder, anotherFolder), + ) + + assertNotNull(next) + val nextFilter = next.toFilter(ArticleStatus.ALL) + assertIs(nextFilter) + assertEquals(expected = anotherFolder.title, actual = nextFilter.folderTitle) + } + + @Test + fun `next from last folder feed to first top-level feed`() { + val topLevelFeeds = 3.repeated { index -> + feedFixture.create(title = "${index + 1} My Top Level Title") + } + val folderTitle = "My Folder" + val folderFeeds = 2.repeated { index -> + feedFixture.create(title = "${index + 1} My nested title") + } + val folder = Folder( + title = folderTitle, + feeds = folderFeeds, + expanded = true, + ) + val filter = ArticleFilter.Feeds( + feedID = folderFeeds[1].id, + folderTitle = folderTitle, + feedStatus = ArticleStatus.UNREAD, + ) + + val next = findNext( + filter, + feeds = topLevelFeeds, + folders = listOf(folder), + ) + + assertNotNull(next) + val nextFilter = next.toFilter(ArticleStatus.ALL) + assertIs(nextFilter) + assertEquals(expected = topLevelFeeds.first().id, actual = nextFilter.feedID) + assertNull(nextFilter.folderTitle) + } + + @Test + fun `buildList ordering matches sidebar`() { + val search = SavedSearch(id = "1", name = "Search", query = null) + val folderFeed = feedFixture.create(title = "Folder Feed") + val folder = Folder(title = "My Folder", feeds = listOf(folderFeed), expanded = true) + val topLevelFeed = feedFixture.create(title = "Top Level") + + val items = SidebarItem.buildList( + savedSearches = listOf(search), + folders = listOf(folder), + feeds = listOf(topLevelFeed), + ) + + val filters = items.map { it.toFilter(ArticleStatus.ALL) } + assertIs(filters[0]) + assertIs(filters[1]) + assertIs(filters[2]) + assertIs(filters[3]) + assertIs(filters[4]) + val folderFeedFilter = filters[5] + assertIs(folderFeedFilter) + assertEquals(expected = folder.title, actual = folderFeedFilter.folderTitle) + val topLevelFilter = filters[6] + assertIs(topLevelFilter) + assertNull(topLevelFilter.folderTitle) + } + + @Test + fun `linked list is wired correctly`() { + val feed = feedFixture.create() + val items = SidebarItem.buildList(feeds = listOf(feed)) + + val today = items[0] + assertTrue(today.isSelected(ArticleFilter.Today(ArticleStatus.ALL))) + assertNotNull(today.next) + assertTrue(today.next!!.isSelected(ArticleFilter.Articles(ArticleStatus.ALL))) + assertNotNull(today.next?.next) + assertTrue(today.next!!.next!!.isSelected(ArticleFilter.Starred())) + assertNotNull(today.next?.next?.next) + assertTrue(today.next!!.next!!.next!!.isSelected( + ArticleFilter.Feeds(feedID = feed.id, folderTitle = null, feedStatus = ArticleStatus.ALL) + )) + assertNull(today.next?.next?.next?.next) + } +}