diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b93fd047b..b8ead4543 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,9 +1,7 @@ -import com.android.build.gradle.internal.tasks.factory.dependsOn import java.util.Properties plugins { id("com.android.application") - id("org.jetbrains.kotlin.android") id("kotlin-parcelize") kotlin("plugin.serialization") version libs.versions.kotlin alias(libs.plugins.compose.compiler) @@ -23,7 +21,7 @@ if (rootProject.file("secrets.properties").exists()) { android { namespace = "com.capyreader.app" - compileSdk = 36 + compileSdk = 37 defaultConfig { applicationId = "com.capyreader.app" @@ -126,10 +124,12 @@ dependencies { implementation(libs.androidx.adaptive) implementation(libs.androidx.adaptive.layout) implementation(libs.androidx.adaptive.navigation) + implementation(libs.androidx.adaptive.navigation3) implementation(libs.androidx.browser) implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.lifecycle.viewmodel.navigation3) implementation(libs.androidx.material) implementation(libs.androidx.material.icons.extended) implementation(libs.androidx.material3) @@ -138,6 +138,8 @@ dependencies { implementation(libs.androidx.media3.exoplayer) implementation(libs.androidx.media3.session) implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.navigation3.runtime) + implementation(libs.androidx.navigation3.ui) implementation(libs.androidx.palette) implementation(libs.androidx.paging.compose) implementation(libs.androidx.paging.runtime.ktx) @@ -204,4 +206,4 @@ tasks.register("useGMSDebugFile") { } } -project.tasks.preBuild.dependsOn("useGMSDebugFile") +tasks.named("preBuild") { dependsOn("useGMSDebugFile") } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 05244401d..94305b984 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -44,6 +44,14 @@ + + + + + + + + () - private var pendingArticleID by mutableStateOf(null) + private var deepLink by mutableStateOf?>(null) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - pendingArticleID = NotificationHelper.openFromIntent(intent, appPreferences = appPreferences) + val startBackStack = resolveBackStack(intent) + applyListFilter(startBackStack) setContent { App( - startDestination = startDestination(), + startBackStack = startBackStack, appPreferences = appPreferences, - pendingArticleID = pendingArticleID, - onPendingArticleSelected = { pendingArticleID = null }, + deepLink = deepLink, + onDeepLinkConsumed = { deepLink = null }, ) } } override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) - pendingArticleID = NotificationHelper.openFromIntent(intent, appPreferences = appPreferences) + // A deep link must never bypass the add-account flow when no account is configured. + if (!hasAccount) return + DeepLink.parse(intent.data, currentStatus)?.let { parsed -> + applyListFilter(parsed) + deepLink = parsed + } } - private fun startDestination(): Route { - val appPreferences = get() - - val accountID = appPreferences.accountID.get() + /** + * Keep the persisted filter in sync with the deep link's list, so the list and the reader's + * neighbor query (both read [AppPreferences.filter]) agree on which articles are siblings. + */ + private fun applyListFilter(backStack: List) { + (backStack.firstOrNull() as? Route.ArticleList)?.let { + appPreferences.filter.set(it.filter) + } + } - return if (accountID.isBlank()) { - Route.AddAccount - } else { - Route.Articles + /** + * Resolve the launch back stack. The account gate comes first: a deep link only applies once an + * account exists, otherwise we always land on the add-account flow. + */ + private fun resolveBackStack(intent: Intent): List { + if (!hasAccount) { + return listOf(Route.AddAccount) } + + return DeepLink.parse(intent.data, currentStatus) + ?: listOf(Route.ArticleList(appPreferences.filter.get())) } + + private val hasAccount: Boolean + get() = appPreferences.accountID.get().isNotBlank() + + private val currentStatus: ArticleStatus + get() = appPreferences.filter.get().status } diff --git a/app/src/main/java/com/capyreader/app/notifications/NotificationHelper.kt b/app/src/main/java/com/capyreader/app/notifications/NotificationHelper.kt index f617ae03f..af1ad8129 100644 --- a/app/src/main/java/com/capyreader/app/notifications/NotificationHelper.kt +++ b/app/src/main/java/com/capyreader/app/notifications/NotificationHelper.kt @@ -6,21 +6,16 @@ import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent -import android.os.Bundle import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import com.capyreader.app.ArticleStatusBroadcastReceiver import com.capyreader.app.MainActivity +import com.capyreader.app.OpenArticleInBrowserActivity import com.capyreader.app.R -import com.capyreader.app.notifications.NotificationHelper.Companion.ARTICLE_ID_KEY -import com.capyreader.app.notifications.NotificationHelper.Companion.FEED_ID_KEY -import com.capyreader.app.preferences.AppPreferences +import com.capyreader.app.ui.DeepLink import com.jocmp.capy.Account -import com.jocmp.capy.ArticleFilter -import com.jocmp.capy.ArticleStatus import com.jocmp.capy.ArticleNotification import com.jocmp.capy.logging.CapyLog -import com.jocmp.capy.preferences.getAndSet import java.time.ZonedDateTime class NotificationHelper( @@ -136,9 +131,6 @@ class NotificationHelper( companion object { const val ARTICLE_ID_KEY = "article_id" - const val FEED_ID_KEY = "feed_id" - const val UNREAD_ONLY_KEY = "unread_only" - const val SHOW_ALL_KEY = "show_all" private const val ARTICLE_REFRESH_GROUP = "article_refresh" @@ -155,45 +147,6 @@ class NotificationHelper( putExtra(ArticleStatusBroadcastReceiver.ARTICLE_ID, articleID) } } - - fun openFromIntent(intent: Intent, appPreferences: AppPreferences): String? { - val openFromShowMore = intent.getBooleanExtra(UNREAD_ONLY_KEY, false) - val openShowAll = intent.getBooleanExtra(SHOW_ALL_KEY, false) - val articleID = intent.getStringExtra(ARTICLE_ID_KEY) - val feedID = intent.getStringExtra(FEED_ID_KEY) - - if (openFromShowMore) { - intent.replaceExtras(Bundle()) - - appPreferences.filter.set( - ArticleFilter.Articles(articleStatus = ArticleStatus.UNREAD) - ) - - return null - } else if (openShowAll) { - intent.replaceExtras(Bundle()) - - appPreferences.filter.set( - ArticleFilter.Articles(articleStatus = ArticleStatus.ALL) - ) - - return null - } else if (articleID != null && feedID != null) { - intent.replaceExtras(Bundle()) - - appPreferences.filter.getAndSet { currentFilter -> - ArticleFilter.Feeds( - feedID, - feedStatus = currentFilter.status, - folderTitle = null - ) - } - - return articleID - } - - return null - } } } @@ -206,10 +159,23 @@ private fun NotificationManagerCompat.tryNotify(id: Int, notification: Notificat } private fun ArticleNotification.contentIntent(context: Context): PendingIntent { - val notifyIntent = Intent(context, MainActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK - putExtra(ARTICLE_ID_KEY, articleID) - putExtra(FEED_ID_KEY, feedID) + val notifyIntent = if (openInBrowser && !url.isNullOrBlank()) { + // Feeds flagged "open in browser" route through the activity that opens the link and marks + // the article read, matching the in-app list tap and the widgets. + Intent(context, OpenArticleInBrowserActivity::class.java).apply { + putExtra(NotificationHelper.ARTICLE_ID_KEY, articleID) + putExtra(OpenArticleInBrowserActivity.ARTICLE_URL_KEY, url) + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + } else { + Intent( + Intent.ACTION_VIEW, + DeepLink.articleUri(articleID = articleID, feedID = feedID), + context, + MainActivity::class.java, + ).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } } return PendingIntent.getActivity( diff --git a/app/src/main/java/com/capyreader/app/ui/App.kt b/app/src/main/java/com/capyreader/app/ui/App.kt index 79416d361..aea43e931 100644 --- a/app/src/main/java/com/capyreader/app/ui/App.kt +++ b/app/src/main/java/com/capyreader/app/ui/App.kt @@ -1,67 +1,258 @@ package com.capyreader.app.ui +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.DrawerState +import androidx.compose.material3.DrawerValue +import androidx.compose.material3.LocalMinimumInteractiveComponentSize import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalDrawerSheet +import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.Surface +import androidx.compose.material3.VerticalDragHandle +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2 +import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirective +import androidx.compose.material3.adaptive.navigation3.ListDetailSceneStrategy +import androidx.compose.material3.adaptive.navigation3.rememberListDetailSceneStrategy import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.rememberNavController +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.navigation3.ui.NavDisplay import com.capyreader.app.preferences.AppPreferences -import com.capyreader.app.ui.accounts.accountsGraph -import com.capyreader.app.ui.articles.articleGraph +import com.capyreader.app.ui.accounts.AddAccountScreen +import com.capyreader.app.ui.accounts.LoginScreen +import com.capyreader.app.ui.articles.ArticleDetailScreen +import com.capyreader.app.ui.articles.ArticleScreen +import com.capyreader.app.ui.articles.LocalArticlePaneExpansion +import com.capyreader.app.ui.articles.detail.CapyPlaceholder +import com.capyreader.app.ui.articles.media.MediaSceneStrategy +import com.capyreader.app.ui.articles.media.MediaScreen +import com.capyreader.app.ui.articles.rememberArticlePaneExpansion +import com.capyreader.app.ui.shared.materialSharedAxisXIn +import com.capyreader.app.ui.shared.materialSharedAxisXOut +import com.capyreader.app.ui.settings.SettingsScreen import com.capyreader.app.ui.theme.CapyTheme import com.capyreader.app.unloadAccountModules +import org.koin.androidx.compose.koinViewModel +import org.koin.core.parameter.parametersOf +@OptIn(ExperimentalMaterial3AdaptiveApi::class) @Composable fun App( - startDestination: Route, + startBackStack: List, appPreferences: AppPreferences, - pendingArticleID: String? = null, - onPendingArticleSelected: () -> Unit = {}, + deepLink: List? = null, + onDeepLinkConsumed: () -> Unit = {}, ) { - val navController = rememberNavController() + val backStack = rememberNavBackStack(*startBackStack.toTypedArray()) + + // Warm-start deep links (onNewIntent): replace the back stack with the link's synthetic stack. + LaunchedEffect(deepLink) { + val target = deepLink ?: return@LaunchedEffect + onDeepLinkConsumed() + backStack.clear() + backStack.addAll(target) + } + + val windowAdaptiveInfo = currentWindowAdaptiveInfoV2() + val directive = remember(windowAdaptiveInfo) { + calculatePaneScaffoldDirective(windowAdaptiveInfo) + .copy(horizontalPartitionSpacerSize = 0.dp) + } + val mediaSceneStrategy = remember { MediaSceneStrategy() } + val paneExpansion = rememberArticlePaneExpansion() + val listDetailStrategy = rememberListDetailSceneStrategy( + directive = directive, + paneExpansionState = paneExpansion.state, + paneExpansionDragHandle = { state -> + val interactionSource = remember { MutableInteractionSource() } + VerticalDragHandle( + modifier = Modifier + .padding(horizontal = HandleEdgeInset) + .paneExpansionDraggable( + state, + LocalMinimumInteractiveComponentSize.current, + interactionSource, + ), + interactionSource = interactionSource, + ) + }, + ) + + // Window-level drawer: hosting it above NavDisplay lets its scrim cover both panes (the list + // entry publishes the content + drives open/close through LocalAppDrawer). + val drawerState = remember { DrawerState(DrawerValue.Closed) } + var drawerContent by remember { mutableStateOf<(@Composable () -> Unit)?>(null) } + val drawerController = remember(drawerState) { + AppDrawerController(state = drawerState, setContent = { drawerContent = it }) + } CapyTheme(appPreferences) { Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background ) { - NavHost( - navController = navController, - startDestination = startDestination - ) { - accountsGraph( - onAddSuccess = { - navController.navigate(Route.Articles) { - launchSingleTop = true - - popUpTo(Route.AddAccount) { - inclusive = true - } - } - }, - onNavigateBack = { - navController.navigateUp() - }, - onNavigateToLogin = { source -> - navController.navigate(Route.Login(source)) - }, - onRemoveAccount = { - navController.navigate(Route.AddAccount) { - popUpTo(Route.Articles) { - inclusive = true + ModalNavigationDrawer( + drawerState = drawerState, + gesturesEnabled = drawerState.isOpen, + drawerContent = { + ModalDrawerSheet { drawerContent?.invoke() } + }, + ) { + CompositionLocalProvider( + LocalArticlePaneExpansion provides paneExpansion, + LocalAppDrawer provides drawerController, + ) { + NavDisplay( + backStack = backStack, + onBack = { backStack.removeLastOrNull() }, + // Scope a ViewModelStore per NavEntry so each route instance gets its own + // ViewModel (Login per source, ArticleDetail per article id). + entryDecorators = listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator(), + ), + sceneStrategies = listOf(mediaSceneStrategy, listDetailStrategy), + entryProvider = entryProvider { + entry { + AddAccountScreen( + onAddSuccess = { backStack.resetToArticles(appPreferences) }, + onNavigateToLogin = { source -> backStack.add(Route.Login(source)) } + ) + } + entry { key -> + LoginScreen( + viewModel = koinViewModel { parametersOf(key.source) }, + onNavigateBack = { backStack.removeLastOrNull() }, + onSuccess = { backStack.resetToArticles(appPreferences) }, + ) + } + entry { + SettingsScreen( + onRemoveAccount = { + backStack.resetTo(Route.AddAccount) + unloadAccountModules() + }, + onNavigateBack = { backStack.removeLastOrNull() } + ) + } + entry( + metadata = ListDetailSceneStrategy.listPane( + detailPlaceholder = { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CapyPlaceholder() + } } - } - unloadAccountModules() + ) + ) { + ArticleScreen( + onSelectArticle = { id -> backStack.openArticle(id) }, + onNavigateToSettings = { backStack.add(Route.Settings) }, + selectedArticleID = (backStack.lastOrNull() as? Route.ArticleDetail)?.articleID, + ) + } + entry( + // Stable contentKey: next/previous swaps the article id without remounting + // the reader, so the chrome persists and content transitions animate. + clazzContentKey = { ARTICLE_DETAIL_CONTENT_KEY }, + // On phone (single pane) the list <-> detail navigation is a scene change; + // animate it with the shared-axis-X motion the pane scaffold used originally + // instead of the default cross-fade. On tablet both panes share one scene, + // so this never fires there. + metadata = ListDetailSceneStrategy.detailPane() + + NavDisplay.transitionSpec { + sharedAxisXEnter(forward = true) togetherWith + sharedAxisXExit(forward = true) + } + + NavDisplay.popTransitionSpec { + sharedAxisXEnter(forward = false) togetherWith + sharedAxisXExit(forward = false) + } + + NavDisplay.predictivePopTransitionSpec { + sharedAxisXEnter(forward = false) togetherWith + sharedAxisXExit(forward = false) + }, + ) { key -> + ArticleDetailScreen( + articleID = key.articleID, + onBackPressed = { backStack.removeLastOrNull() }, + onSelectArticle = { id -> backStack.openArticle(id) }, + onSelectMedia = { media -> backStack.add(Route.MediaViewer(media)) }, + ) + } + entry( + metadata = MediaSceneStrategy.overlay(), + ) { key -> + MediaScreen( + media = key.media, + onDismiss = { backStack.removeLastOrNull() }, + ) } - ) - articleGraph( - navController = navController, - pendingArticleID = pendingArticleID, - onPendingArticleSelected = onPendingArticleSelected, - ) - } + } + ) + } + } } } } + +private const val ARTICLE_DETAIL_CONTENT_KEY = "article_detail" + +/** Matches the pane scaffold's original shared-axis offset (10% of the pane width). */ +private const val PANE_OFFSET_FACTOR = 0.10f + +private fun sharedAxisXEnter(forward: Boolean) = + materialSharedAxisXIn(initialOffsetX = { width -> + val offset = (width * PANE_OFFSET_FACTOR).toInt() + if (forward) offset else -offset + }) + +private fun sharedAxisXExit(forward: Boolean) = + materialSharedAxisXOut(targetOffsetX = { width -> + val offset = (width * PANE_OFFSET_FACTOR).toInt() + if (forward) -offset else offset + }) + +private val HandleEdgeInset = 16.dp + +/** + * Opens an article in the detail pane. If a detail is already on top (reader next/previous), the + * top entry is replaced so the back stack stays [list, detail] rather than growing per article. + */ +private fun NavBackStack.openArticle(articleID: String) { + if (lastOrNull() is Route.ArticleDetail) { + this[lastIndex] = Route.ArticleDetail(articleID) + } else { + add(Route.ArticleDetail(articleID)) + } +} + +/** Replaces the entire back stack with [key] (account add/remove transitions). */ +private fun NavBackStack.resetTo(key: NavKey) { + clear() + add(key) +} + +private fun NavBackStack.resetToArticles(appPreferences: AppPreferences) { + resetTo(Route.ArticleList(appPreferences.filter.get())) +} diff --git a/app/src/main/java/com/capyreader/app/ui/AppDrawer.kt b/app/src/main/java/com/capyreader/app/ui/AppDrawer.kt new file mode 100644 index 000000000..41b49411d --- /dev/null +++ b/app/src/main/java/com/capyreader/app/ui/AppDrawer.kt @@ -0,0 +1,22 @@ +package com.capyreader.app.ui + +import androidx.compose.material3.DrawerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.compositionLocalOf + +/** + * Bridges the navigation drawer between the window-level host (in [App], which owns the + * [ModalNavigationDrawer] so its scrim covers the whole window — both list and detail panes) and + * the article list entry, which owns the drawer's content and the ViewModel that drives it. + * + * The list entry calls [setContent] to publish its drawer pane upward, and reads [state] to open + * and close the drawer. Null when no host is present (e.g. previews). + */ +@Stable +class AppDrawerController( + val state: DrawerState, + val setContent: (content: (@Composable () -> Unit)?) -> Unit, +) + +val LocalAppDrawer = compositionLocalOf { null } diff --git a/app/src/main/java/com/capyreader/app/ui/DeepLink.kt b/app/src/main/java/com/capyreader/app/ui/DeepLink.kt new file mode 100644 index 000000000..f773503f4 --- /dev/null +++ b/app/src/main/java/com/capyreader/app/ui/DeepLink.kt @@ -0,0 +1,80 @@ +package com.capyreader.app.ui + +import android.net.Uri +import androidx.navigation3.runtime.NavKey +import com.jocmp.capy.ArticleFilter +import com.jocmp.capy.ArticleStatus + +/** + * Parses a `capy://` deep link into a synthetic Nav3 back stack. + * + * The article id is the resource, so it's a path segment (percent-encoded by the caller, since + * article ids are URLs; [Uri] decodes it back). The feed is optional context as a query param. + * + * - `capy://article/` (optionally `?feedID=`) → list + the article reader. + * With `feedID` the list is that feed; without it, the All list. + * - `capy://articles` → the All list. + * - `capy://articles/unread` → the Unread list. + * + * Returns `null` for anything we don't recognize, so the caller can fall back to its default. + */ +object DeepLink { + const val SCHEME = "capy" + + /** + * `capy://article/` (optionally `?feedID=`). The article id is a path segment, so + * [Uri.Builder.appendPath] percent-encodes it (article ids are URLs). + */ + fun articleUri(articleID: String, feedID: String? = null): Uri = + Uri.Builder() + .scheme(SCHEME) + .authority("article") + .appendPath(articleID) + .apply { if (!feedID.isNullOrBlank()) appendQueryParameter("feedID", feedID) } + .build() + + /** `capy://articles` (All) or `capy://articles/unread` (Unread). */ + fun articlesUri(status: ArticleStatus): Uri = + Uri.Builder() + .scheme(SCHEME) + .authority("articles") + .apply { if (status == ArticleStatus.UNREAD) appendPath("unread") } + .build() + + /** + * [currentStatus] is the user's persisted list status; a feed deep link keeps it (rather than + * forcing unread) so the link doesn't silently narrow the list the user was browsing. + */ + fun parse(uri: Uri?, currentStatus: ArticleStatus = ArticleStatus.UNREAD): List? { + if (uri?.scheme != SCHEME) return null + + return when (uri.host) { + "article" -> { + val articleID = uri.pathSegments.firstOrNull() ?: return null + val feedID = uri.getQueryParameter("feedID") + val list = if (!feedID.isNullOrBlank()) { + Route.ArticleList( + ArticleFilter.Feeds( + feedID = feedID, + folderTitle = null, + feedStatus = currentStatus, + ) + ) + } else { + Route.ArticleList(ArticleFilter.default()) + } + listOf(list, Route.ArticleDetail(articleID)) + } + + "articles" -> { + val status = when (uri.pathSegments.firstOrNull()) { + "unread" -> ArticleStatus.UNREAD + else -> ArticleStatus.ALL + } + listOf(Route.ArticleList(ArticleFilter.Articles(status))) + } + + else -> null + } + } +} diff --git a/app/src/main/java/com/capyreader/app/ui/Route.kt b/app/src/main/java/com/capyreader/app/ui/Route.kt index c2e18233d..ac75ef4bf 100644 --- a/app/src/main/java/com/capyreader/app/ui/Route.kt +++ b/app/src/main/java/com/capyreader/app/ui/Route.kt @@ -1,9 +1,12 @@ package com.capyreader.app.ui +import androidx.navigation3.runtime.NavKey +import com.capyreader.app.common.Media +import com.jocmp.capy.ArticleFilter import com.jocmp.capy.accounts.Source import kotlinx.serialization.Serializable -sealed class Route { +sealed class Route : NavKey { @Serializable data object AddAccount : Route() @@ -15,4 +18,23 @@ sealed class Route { @Serializable data object Articles : Route() + + /** + * The article list, parameterized by its [filter]. Filter is navigation state; + * [com.capyreader.app.preferences.AppPreferences.filter] is demoted to a cold-boot seed. + */ + @Serializable + data class ArticleList(val filter: ArticleFilter) : Route() + + /** A single article opened in the reader. Resolved from [articleID] by the detail ViewModel. */ + @Serializable + data class ArticleDetail(val articleID: String) : Route() + + /** + * Full-screen image viewer, rendered as an overlay above the list/detail panes (see + * [com.capyreader.app.ui.articles.media.MediaSceneStrategy]). [Media] is already + * `@Serializable`, so it persists with the back stack across process death. + */ + @Serializable + data class MediaViewer(val media: Media) : Route() } diff --git a/app/src/main/java/com/capyreader/app/ui/accounts/AccountNavigation.kt b/app/src/main/java/com/capyreader/app/ui/accounts/AccountNavigation.kt deleted file mode 100644 index 72111205b..000000000 --- a/app/src/main/java/com/capyreader/app/ui/accounts/AccountNavigation.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.capyreader.app.ui.accounts - -import androidx.navigation.NavGraphBuilder -import androidx.navigation.compose.composable -import com.capyreader.app.ui.Route -import com.capyreader.app.ui.settings.SettingsScreen -import com.jocmp.capy.accounts.Source - -fun NavGraphBuilder.accountsGraph( - onAddSuccess: () -> Unit, - onNavigateBack: () -> Unit, - onNavigateToLogin: (source: Source) -> Unit, - onRemoveAccount: () -> Unit, -) { - composable { - AddAccountScreen( - onAddSuccess = onAddSuccess, - onNavigateToLogin = onNavigateToLogin - ) - } - composable { - LoginScreen( - onNavigateBack = onNavigateBack, - onSuccess = onAddSuccess, - ) - } - composable { - SettingsScreen( - onRemoveAccount = onRemoveAccount, - onNavigateBack = onNavigateBack - ) - } -} diff --git a/app/src/main/java/com/capyreader/app/ui/accounts/LoginModule.kt b/app/src/main/java/com/capyreader/app/ui/accounts/LoginModule.kt index db2551a7b..92b5126f5 100644 --- a/app/src/main/java/com/capyreader/app/ui/accounts/LoginModule.kt +++ b/app/src/main/java/com/capyreader/app/ui/accounts/LoginModule.kt @@ -1,5 +1,6 @@ package com.capyreader.app.ui.accounts +import com.jocmp.capy.accounts.Source import org.koin.core.module.dsl.viewModel import org.koin.dsl.module @@ -11,9 +12,9 @@ val loginModule = module { refreshScheduler = get(), ) } - viewModel { + viewModel { (source: Source) -> LoginViewModel( - handle = get(), + routeSource = source, accountManager = get(), appPreferences = get(), clientCertManager = get(), diff --git a/app/src/main/java/com/capyreader/app/ui/accounts/LoginViewModel.kt b/app/src/main/java/com/capyreader/app/ui/accounts/LoginViewModel.kt index 47ef8802c..1a7419b2d 100644 --- a/app/src/main/java/com/capyreader/app/ui/accounts/LoginViewModel.kt +++ b/app/src/main/java/com/capyreader/app/ui/accounts/LoginViewModel.kt @@ -5,14 +5,11 @@ import android.security.KeyChain import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import androidx.navigation.toRoute import com.capyreader.app.loadAccountModules import com.capyreader.app.preferences.AppPreferences import com.capyreader.app.refresher.RefreshScheduler -import com.capyreader.app.ui.Route import com.jocmp.capy.AccountManager import com.jocmp.capy.ClientCertManager import com.jocmp.capy.accounts.Credentials @@ -27,7 +24,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext class LoginViewModel( - handle: SavedStateHandle, + private val routeSource: Source, private val accountManager: AccountManager, private val appPreferences: AppPreferences, private val clientCertManager: ClientCertManager, @@ -39,7 +36,6 @@ class LoginViewModel( private var _clientCertAlias by mutableStateOf("") private var _result by mutableStateOf>(Async.Uninitialized) private var _useApiToken by mutableStateOf(false) - private val routeSource = handle.toRoute().source val source: Source get() = if (routeSource == Source.MINIFLUX && _useApiToken) { diff --git a/app/src/main/java/com/capyreader/app/ui/articles/ArticleDetailScreen.kt b/app/src/main/java/com/capyreader/app/ui/articles/ArticleDetailScreen.kt new file mode 100644 index 000000000..fdfecf381 --- /dev/null +++ b/app/src/main/java/com/capyreader/app/ui/articles/ArticleDetailScreen.kt @@ -0,0 +1,141 @@ +package com.capyreader.app.ui.articles + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.capyreader.app.common.Media +import com.capyreader.app.common.Saver +import com.capyreader.app.ui.LocalConnectivity +import com.capyreader.app.ui.LocalLinkOpener +import com.capyreader.app.ui.articles.audio.AudioPlayerController +import com.capyreader.app.ui.articles.detail.ArticleView +import com.capyreader.app.ui.articles.detail.CapyPlaceholder +import com.capyreader.app.ui.articles.list.LabelBottomSheet +import com.capyreader.app.ui.provideLinkOpener +import com.capyreader.app.ui.rememberLocalConnectivity +import org.koin.androidx.compose.koinViewModel +import org.koin.compose.koinInject + +/** + * Content of the [com.capyreader.app.ui.Route.ArticleDetail] entry. Renders the reader for a single + * article, backed by its own [ArticleViewModel]. Independent of the list entry; neighbor ids for + * the next/previous reader navigation are supplied by the caller. + */ +@Composable +fun ArticleDetailScreen( + articleID: String, + onBackPressed: () -> Unit, + onSelectArticle: (id: String) -> Unit, + onSelectMedia: (media: Media) -> Unit, + viewModel: ArticleViewModel = koinViewModel(), +) { + LaunchedEffect(articleID) { + viewModel.load(articleID) + } + + val context = LocalContext.current + val article = viewModel.article + val canSaveExternally by viewModel.canSaveArticleExternally.collectAsStateWithLifecycle() + val savedSearches by viewModel.savedSearches.collectAsStateWithLifecycle(initialValue = emptyList()) + val connectivity = rememberLocalConnectivity() + + val fullContent = remember(viewModel) { + FullContentFetcher( + fetch = viewModel::fetchFullContentAsync, + reset = viewModel::resetFullContent, + ) + } + + val articleActions = remember(viewModel) { + ArticleActions(saveExternally = viewModel::saveArticleExternallyAsync) + } + + var labelSheetArticleID by remember { mutableStateOf(null) } + val articleLabels by viewModel.getArticleLabels(labelSheetArticleID) + .collectAsState(initial = emptyList()) + + val labelsActions = remember(savedSearches, labelSheetArticleID, articleLabels) { + LabelsActions( + source = viewModel.source, + showLabels = viewModel.source.supportsLabels, + savedSearches = savedSearches, + selectedArticleID = labelSheetArticleID, + articleLabels = articleLabels, + openSheet = { labelSheetArticleID = it }, + closeSheet = { labelSheetArticleID = null }, + addLabel = viewModel::addLabelAsync, + removeLabel = viewModel::removeLabelAsync, + createLabel = viewModel::createLabel, + ) + } + + val audioController: AudioPlayerController = koinInject() + val isAudioPlaying by audioController.isPlaying.collectAsState() + val currentAudio by audioController.currentAudio.collectAsState() + + val paneExpansion = LocalArticlePaneExpansion.current + + CompositionLocalProvider( + LocalFullContent provides fullContent, + LocalArticleActions provides articleActions, + LocalLabelsActions provides labelsActions, + LocalConnectivity provides connectivity, + LocalLinkOpener provides provideLinkOpener(context), + ) { + val current = article + + if (current == null) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CapyPlaceholder() + } + } else { + ArticleView( + article = current, + previousArticleID = viewModel.previousArticleID, + nextArticleID = viewModel.nextArticleID, + onBackPressed = onBackPressed, + onToggleRead = viewModel::toggleArticleRead, + onToggleStar = viewModel::toggleArticleStar, + canSaveExternally = canSaveExternally, + onDeletePage = { + onBackPressed() + viewModel.deletePage(current.id) + }, + onSelectMedia = onSelectMedia, + onSelectAudio = { audio -> audioController.play(audio) }, + onPauseAudio = { audioController.pause() }, + onSelectArticle = onSelectArticle, + currentAudioUrl = currentAudio?.url, + isAudioPlaying = isAudioPlaying, + isFullscreen = paneExpansion?.isFullscreen ?: false, + onToggleFullscreen = { paneExpansion?.toggleFullscreen() }, + ) + + labelSheetArticleID?.let { id -> + LabelBottomSheet( + articleID = id, + savedSearches = labelsActions.savedSearches, + articleLabels = labelsActions.articleLabels, + onAddLabel = { savedSearchID -> labelsActions.addLabel(id, savedSearchID) }, + onRemoveLabel = { savedSearchID -> labelsActions.removeLabel(id, savedSearchID) }, + onCreateLabel = labelsActions.createLabel, + onDismissRequest = labelsActions.closeSheet, + ) + } + } + } +} diff --git a/app/src/main/java/com/capyreader/app/ui/articles/ArticleList.kt b/app/src/main/java/com/capyreader/app/ui/articles/ArticleList.kt index 292b92ba8..9f15681e6 100644 --- a/app/src/main/java/com/capyreader/app/ui/articles/ArticleList.kt +++ b/app/src/main/java/com/capyreader/app/ui/articles/ArticleList.kt @@ -39,7 +39,7 @@ import java.time.LocalDateTime @Composable fun ArticleList( articles: LazyPagingItems
, - onSelect: (articleID: String) -> Unit, + onSelect: (article: Article) -> Unit, selectedArticleKey: String?, listState: LazyListState, onMarkAllRead: (range: MarkRead) -> Unit = {}, @@ -77,7 +77,7 @@ fun ArticleList( index = index, selected = selectedArticleKey == item.id, onSelect = { - onSelect(it) + onSelect(item) }, onMarkAllRead = onMarkAllRead, currentTime = currentTime, diff --git a/app/src/main/java/com/capyreader/app/ui/articles/ArticleNavigation.kt b/app/src/main/java/com/capyreader/app/ui/articles/ArticleNavigation.kt deleted file mode 100644 index 992b38dbe..000000000 --- a/app/src/main/java/com/capyreader/app/ui/articles/ArticleNavigation.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.capyreader.app.ui.articles - -import androidx.navigation.NavController -import androidx.navigation.NavGraphBuilder -import androidx.navigation.compose.composable -import com.capyreader.app.ui.Route - -fun NavGraphBuilder.articleGraph( - navController: NavController, - pendingArticleID: String? = null, - onPendingArticleSelected: () -> Unit = {}, -) { - composable { - ArticleScreen( - pendingArticleID = pendingArticleID, - onPendingArticleSelected = onPendingArticleSelected, - onNavigateToSettings = { - navController.navigate(Route.Settings) { - launchSingleTop = true - } - } - ) - } -} diff --git a/app/src/main/java/com/capyreader/app/ui/articles/ArticlePaneExpansion.kt b/app/src/main/java/com/capyreader/app/ui/articles/ArticlePaneExpansion.kt index 26d10d29e..f43758e2b 100644 --- a/app/src/main/java/com/capyreader/app/ui/articles/ArticlePaneExpansion.kt +++ b/app/src/main/java/com/capyreader/app/ui/articles/ArticlePaneExpansion.kt @@ -7,6 +7,7 @@ import androidx.compose.material3.adaptive.layout.rememberPaneExpansionState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable +import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember @@ -19,6 +20,13 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import org.koin.compose.koinInject +/** + * Bridges the list-detail [PaneExpansionState] (owned by the Scene at the [App] level) down into the + * reader entry, so the reader's top-bar toggle can expand/collapse the detail pane. `null` when no + * expandable pane is present (compact / single-pane). + */ +val LocalArticlePaneExpansion = compositionLocalOf { null } + private val DetailFullscreenAnchor = PaneExpansionAnchor.Proportion(0f) private val ListFullscreenAnchor = PaneExpansionAnchor.Proportion(1f) diff --git a/app/src/main/java/com/capyreader/app/ui/articles/ArticleScaffold.kt b/app/src/main/java/com/capyreader/app/ui/articles/ArticleScaffold.kt deleted file mode 100644 index 488ac73a2..000000000 --- a/app/src/main/java/com/capyreader/app/ui/articles/ArticleScaffold.kt +++ /dev/null @@ -1,98 +0,0 @@ -package com.capyreader.app.ui.articles - -import androidx.compose.foundation.background -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.DrawerState -import androidx.compose.material3.DrawerValue -import androidx.compose.material3.LocalMinimumInteractiveComponentSize -import androidx.compose.material3.ModalDrawerSheet -import androidx.compose.material3.ModalNavigationDrawer -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.VerticalDragHandle -import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi -import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold -import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldNavigator -import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator -import androidx.compose.material3.rememberDrawerState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.tooling.preview.Preview -import com.capyreader.app.ui.components.CapyAnimatedPane -import com.capyreader.app.ui.theme.CapyTheme - -@OptIn(ExperimentalMaterial3AdaptiveApi::class) -@Composable -fun ArticleScaffold( - drawerState: DrawerState = rememberDrawerState(DrawerValue.Closed), - scaffoldNavigator: ThreePaneScaffoldNavigator = rememberListDetailPaneScaffoldNavigator(), - paneExpansion: ArticlePaneExpansion = rememberArticlePaneExpansion(), - drawerPane: @Composable () -> Unit, - listPane: @Composable () -> Unit, - detailPane: @Composable () -> Unit, -) { - ModalNavigationDrawer( - drawerState = drawerState, - gesturesEnabled = drawerState.isOpen, - drawerContent = { - ModalDrawerSheet { - drawerPane() - } - }, - ) { - ListDetailPaneScaffold( - directive = scaffoldNavigator.scaffoldDirective, - scaffoldState = scaffoldNavigator.scaffoldState, - paneExpansionDragHandle = { state -> - val interactionSource = remember { MutableInteractionSource() } - VerticalDragHandle( - modifier = Modifier.paneExpansionDraggable( - state, - LocalMinimumInteractiveComponentSize.current, - interactionSource, - ), - interactionSource = interactionSource, - ) - }, - paneExpansionState = paneExpansion.state, - listPane = { - CapyAnimatedPane { - listPane() - } - }, - detailPane = { - CapyAnimatedPane { - detailPane() - } - } - ) - } -} - -@OptIn(ExperimentalMaterial3AdaptiveApi::class) -@Preview(device = "spec:width=1280dp,height=800dp,dpi=240") -@Composable -fun ArticlesLayoutPreview() { - CapyTheme { - ArticleScaffold( - drawerPane = { - Text("List here!") - }, - listPane = { - Surface( - Modifier - .background(Color.Cyan) - .fillMaxSize() - ) { - Text("Index list here...") - } - }, - detailPane = { - Text("Detail!") - } - ) - } -} 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 796fb4174..9ddfd38ab 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 @@ -16,12 +16,11 @@ import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.TopAppBarDefaults.pinnedScrollBehavior import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi -import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole -import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -62,8 +61,6 @@ import com.capyreader.app.ui.LocalTimeFormats import com.capyreader.app.ui.LocalUnreadCount import com.capyreader.app.ui.articles.audio.AudioPlayerController import com.capyreader.app.ui.articles.audio.FloatingAudioPlayer -import com.capyreader.app.ui.articles.detail.ArticleView -import com.capyreader.app.ui.articles.detail.CapyPlaceholder import com.capyreader.app.ui.articles.feeds.AngleRefreshState import com.capyreader.app.ui.articles.feeds.FeedActions import com.capyreader.app.ui.articles.feeds.FeedList @@ -83,6 +80,9 @@ import com.capyreader.app.ui.articles.list.resetScrollBehaviorListener import com.capyreader.app.ui.articles.media.ArticleMediaView import com.capyreader.app.ui.collectChangesWithCurrent import com.capyreader.app.ui.collectChangesWithDefault +import com.capyreader.app.ui.LocalAppDrawer +import com.capyreader.app.ui.articles.list.SearchView +import com.capyreader.app.ui.isCompact import com.capyreader.app.ui.components.ArticleSearch import com.capyreader.app.ui.components.LocalSnackbarHost import com.capyreader.app.ui.components.SearchState @@ -111,11 +111,11 @@ import org.koin.compose.koinInject @OptIn(ExperimentalMaterial3AdaptiveApi::class, ExperimentalMaterial3Api::class, FlowPreview::class) @Composable fun ArticleScreen( + onSelectArticle: (articleID: String) -> Unit, + onNavigateToSettings: () -> Unit, viewModel: ArticleScreenViewModel = koinViewModel(), appPreferences: AppPreferences = koinInject(), - pendingArticleID: String? = null, - onPendingArticleSelected: () -> Unit = {}, - onNavigateToSettings: () -> Unit, + selectedArticleID: String? = null, ) { val currentFeed by viewModel.currentFeed.collectAsStateWithLifecycle(initialValue = null) val feeds by viewModel.topLevelFeeds.collectAsStateWithLifecycle(initialValue = emptyList()) @@ -143,16 +143,17 @@ fun ArticleScreen( } val context = LocalContext.current - val canSaveExternally by viewModel.canSaveArticleExternally.collectAsStateWithLifecycle() - - val fullContent = rememberFullContent(viewModel) val articleActions = rememberArticleActions(viewModel) val folderActions = rememberFolderActions(viewModel) val feedActions = rememberFeedActions(viewModel) val savedSearchActions = rememberSavedSearchActions(viewModel) val labelsActions = rememberLabelsActions(viewModel, allSavedSearches) val connectivity = rememberLocalConnectivity() - val drawerState = rememberDrawerState(DrawerValue.Closed) + // The drawer is hosted at the window level (see App / LocalAppDrawer) so its scrim covers both + // panes; this entry only publishes its content and drives open/close. Fall back to a local + // state when there's no host (previews). + val appDrawer = LocalAppDrawer.current + val drawerState = appDrawer?.state ?: rememberDrawerState(DrawerValue.Closed) val showOnboarding by viewModel.showOnboarding.collectAsState(false) val markAllReadButtonPosition by appPreferences .articleListOptions @@ -161,6 +162,7 @@ fun ArticleScreen( val badgeStyle by appPreferences.badgeStyle.collectChangesWithDefault() val articles = viewModel.articles.collectAsLazyPagingItems() + val searchResults = viewModel.searchResults.collectAsLazyPagingItems() val onMarkAllRead = { range: MarkRead -> viewModel.markAllRead( @@ -173,8 +175,6 @@ fun ArticleScreen( ) } - val article = viewModel.article - val search = ArticleSearch( query = searchQuery, start = { viewModel.startSearch() }, @@ -189,7 +189,6 @@ fun ArticleScreen( var isMarkAllReadDialogOpen by remember { mutableStateOf(false) } CompositionLocalProvider( - LocalFullContent provides fullContent, LocalArticleActions provides articleActions, LocalFolderActions provides folderActions, LocalFeedActions provides feedActions, @@ -214,13 +213,9 @@ fun ArticleScreen( mutableStateOf(false) } val coroutineScope = rememberCoroutineScope() - val scaffoldNavigator = rememberListDetailPaneScaffoldNavigator() - val showMultipleColumns = scaffoldNavigator.scaffoldDirective.maxHorizontalPartitions > 1 - val paneExpansion = rememberArticlePaneExpansion() val isPullToRefreshing = viewModel.isPullToRefreshing val addFeedSuccessMessage = stringResource(R.string.add_feed_success) val scrollBehavior = pinnedScrollBehavior() - var media by rememberSaveable(saver = Media.Saver) { mutableStateOf(null) } val audioController: AudioPlayerController = koinInject() val audioEnclosure by audioController.currentAudio.collectAsState() val focusManager = LocalFocusManager.current @@ -228,28 +223,8 @@ fun ArticleScreen( viewModel.dismissUnauthorizedMessage() setUpdatePasswordDialogOpen(true) } - suspend fun navigateToDetail() { - scaffoldNavigator.navigateTo(ListDetailPaneScaffoldRole.Detail) - if (showMultipleColumns) { - paneExpansion.restore() - } - } - val listState = articles.rememberLazyListState() - fun scrollToArticle(index: Int) { - coroutineScope.launch { - if (index > -1) { - val visibleItemsInfo = listState.layoutInfo.visibleItemsInfo - val isItemVisible = visibleItemsInfo.any { it.index == index } - - if (!isItemVisible) { - listState.animateScrollToItem(index) - } - } - } - } - val resetScrollBehaviorOffset = resetScrollBehaviorListener( listState = listState, scrollBehavior = scrollBehavior @@ -262,6 +237,18 @@ fun ArticleScreen( } } + // In a two-pane layout the list stays beside the reader, so keep the selected article in + // view (e.g. when stepping next/previous) by scrolling to it when it isn't already visible. + val isCompact = isCompact() + LaunchedEffect(selectedArticleID, isCompact, articles.itemCount) { + if (isCompact) return@LaunchedEffect + val id = selectedArticleID ?: return@LaunchedEffect + val index = articles.itemSnapshotList.indexOfFirst { it?.id == id } + if (index > -1 && listState.layoutInfo.visibleItemsInfo.none { it.index == index }) { + listState.animateScrollToItem(index) + } + } + val (scrolledFilter, setScrolledFilter) = rememberSaveable( saver = ArticleFilter.Saver ) { mutableStateOf(null) } @@ -299,7 +286,6 @@ fun ArticleScreen( suspend fun openNextStatus(action: suspend () -> Unit) { scope.launchIO { action() } - scaffoldNavigator.navigateTo(ListDetailPaneScaffoldRole.List) } fun markAllRead(range: MarkRead) { @@ -357,13 +343,6 @@ fun ArticleScreen( } } - fun clearArticle() { - coroutineScope.launchUI { - scaffoldNavigator.navigateTo(ListDetailPaneScaffoldRole.List) - } - viewModel.clearArticle() - } - val toggleDrawer = { coroutineScope.launch { if (drawerState.isOpen) { @@ -404,27 +383,20 @@ fun ArticleScreen( } } - fun setArticle(articleID: String, onComplete: (article: Article) -> Unit = {}) { - viewModel.selectArticle(articleID, onComplete) - } - val linkOpener = LocalLinkOpener.current - fun selectArticle(articleID: String) { - setArticle(articleID) { nextArticle -> - if (search.isActive) { - focusManager.clearFocus() - } + fun selectArticle(article: Article) { + if (search.isActive) { + focusManager.clearFocus() + search.clear() + } - val url = nextArticle.url - if (nextArticle.openInBrowser && url != null) { - clearArticle() - linkOpener.open(url.toString().toUri()) - } else { - coroutineScope.launch { - navigateToDetail() - } - } + // Feeds flagged "open in browser" skip the in-app reader, matching the widget behavior. + val url = article.url + if (article.openInBrowser && url != null) { + linkOpener.open(url.toString().toUri()) + } else { + onSelectArticle(article.id) } } @@ -472,17 +444,14 @@ fun ArticleScreen( } } - LaunchedEffect(pendingArticleID) { - val id = pendingArticleID ?: return@LaunchedEffect - onPendingArticleSelected() - selectArticle(id) - } - - ArticleScaffold( - drawerState = drawerState, - scaffoldNavigator = scaffoldNavigator, - paneExpansion = paneExpansion, - drawerPane = { + // Publish the drawer pane up to the window-level host. Keyed on the data so the lambda is + // recreated (and the drawer recomposes) only when its contents change; the callbacks it + // captures are behaviorally stable. + val drawerContent: @Composable () -> Unit = remember( + folders, feeds, readLaterFeed, savedSearches, filter, + statusCount, todayCount, refreshAllState, + ) { + { FeedList( source = viewModel.source, folders = folders, @@ -512,8 +481,17 @@ fun ArticleScreen( statusCount = statusCount, todayCount = todayCount, ) - }, - listPane = { + } + } + + LaunchedEffect(appDrawer, drawerContent) { + appDrawer?.setContent(drawerContent) + } + DisposableEffect(appDrawer) { + onDispose { appDrawer?.setContent(null) } + } + + Box(modifier = Modifier.fillMaxSize()) { val keyboardManager = LocalSoftwareKeyboardController.current val markReadPosition = LocalMarkAllReadButtonPosition.current @@ -613,7 +591,7 @@ fun ArticleScreen( } else { ArticleList( articles = articles, - selectedArticleKey = article?.id, + selectedArticleKey = selectedArticleID, listState = listState, enableMarkReadOnScroll = viewModel.markReadOnScrollEnabled, dimReadArticles = filter.status != ArticleStatus.STARRED, @@ -621,8 +599,8 @@ fun ArticleScreen( onMarkAllRead = { range -> onMarkAllRead(range) }, - onSelect = { articleID -> - selectArticle(articleID) + onSelect = { article -> + selectArticle(article) }, ) } @@ -631,77 +609,21 @@ fun ArticleScreen( } } } - }, - detailPane = { - if (article == null && showMultipleColumns) { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .fillMaxSize() - ) { - CapyPlaceholder() - } - } else if (article != null) { - val isAudioPlaying by audioController.isPlaying.collectAsState() - val currentAudio by audioController.currentAudio.collectAsState() - - ArticleView( - article = article, - articles = articles, - onBackPressed = { - clearArticle() - }, - onToggleRead = viewModel::toggleArticleRead, - onToggleStar = viewModel::toggleArticleStar, - canSaveExternally = canSaveExternally, - onDeletePage = { - clearArticle() - viewModel.deletePage(article.id) - }, - onSelectMedia = { media = it }, - onSelectAudio = { audio -> - audioController.play(audio) - }, - onPauseAudio = { - audioController.pause() - }, - onSelectArticle = { articleID -> - setArticle(articleID) - }, - onScrollToArticle = { index -> - scrollToArticle(index) - }, - currentAudioUrl = currentAudio?.url, - isAudioPlaying = isAudioPlaying, - isFullscreen = paneExpansion.isFullscreen, - onToggleFullscreen = { paneExpansion.toggleFullscreen() }, - ) - } - } - ) - LaunchedEffect(scaffoldNavigator.currentDestination) { - val isOnList = - scaffoldNavigator.currentDestination?.pane != ListDetailPaneScaffoldRole.Detail - if (isOnList && article != null) { - viewModel.clearArticle() + AnimatedVisibility( + visible = search.isActive, + enter = fadeIn(), + exit = fadeOut(), + ) { + SearchView( + search = search, + results = searchResults, + selectedArticleID = selectedArticleID, + onSelect = { article -> selectArticle(article) }, + ) } } - AnimatedVisibility( - enter = fadeIn(), - exit = fadeOut(), - visible = media != null - ) { - ArticleMediaView( - onDismissRequest = { - media = null - }, - media = media - ) - } - - if (isMarkAllReadDialogOpen) { MarkAllReadDialog( onConfirm = { @@ -749,24 +671,16 @@ fun ArticleScreen( ) } - BackHandler(media != null) { - media = null - } - - BackHandler(media == null && article != null) { - paneExpansion.reset() - clearArticle() - } - - BackHandler(media == null && search.isActive && article == null) { + BackHandler(search.isActive) { search.clear() } + // When a detail is open the list yields back to NavDisplay so it can pop the detail entry. ArticleListBackHandler( filter, onRequestFilter = selectFilter, onRequestFolder = selectFolder, - enabled = isFeedActive(media, article, search), + enabled = selectedArticleID == null && !search.isActive, isDrawerOpen = drawerState.isOpen, toggleDrawer = { toggleDrawer() @@ -775,12 +689,6 @@ fun ArticleScreen( closeDrawer() } ) - - LayoutNavigationHandler( - enabled = article == null - ) { - scaffoldNavigator.navigateTo(ListDetailPaneScaffoldRole.List) - } } } @@ -869,16 +777,6 @@ fun rememberSavedSearchActions(viewModel: ArticleScreenViewModel): SavedSearchAc } } -@Composable -fun rememberFullContent(viewModel: ArticleScreenViewModel): FullContentFetcher { - return remember { - FullContentFetcher( - fetch = viewModel::fetchFullContentAsync, - reset = viewModel::resetFullContent, - ) - } -} - fun canOpenNextFeed( filter: ArticleFilter, range: MarkRead, @@ -886,16 +784,6 @@ fun canOpenNextFeed( return range is MarkRead.All && filter !is ArticleFilter.Articles } -fun isFeedActive( - media: Media?, - article: Article?, - search: ArticleSearch -): Boolean { - return media == null && - article == null && - !search.isActive -} - @OptIn(FlowPreview::class) @Composable private fun MarkReadOnScroll( 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 4171ffd8e..550fe94aa 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 @@ -9,6 +9,7 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import androidx.paging.PagingData +import kotlinx.coroutines.flow.flowOf import com.capyreader.app.R import com.capyreader.app.common.isOnWifi import com.capyreader.app.common.toast @@ -23,6 +24,7 @@ import com.capyreader.app.ui.widget.WidgetUpdater import com.jocmp.capy.Account import com.jocmp.capy.Article import com.jocmp.capy.ArticleFilter +import com.jocmp.capy.articles.SortOrder import com.jocmp.capy.ArticleStatus import com.jocmp.capy.ArticleStatus.UNREAD import com.jocmp.capy.Feed @@ -61,13 +63,12 @@ class ArticleScreenViewModel( private val appPreferences: AppPreferences, private val application: Application, private val notificationHelper: NotificationHelper, + private val articleCutoff: ArticleSessionCutoff, private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, private val syncFlushInterval: Duration? = SYNC_FLUSH_INTERVAL, ) : AndroidViewModel(application) { private var refreshJob: Job? = null - private var fullContentJob: Job? = null - var refreshSkipReason by mutableStateOf(null) private set @@ -84,8 +85,6 @@ class ArticleScreenViewModel( private val _searchState = MutableStateFlow(SearchState.INACTIVE) - private var _article by mutableStateOf(null) - private val _refreshAllState = MutableStateFlow(AngleRefreshState.STOPPED) val refreshAllState: Flow @@ -125,20 +124,28 @@ class ArticleScreenViewModel( account.countAllBySavedSearch(latestFilter.status) } + // The list pager is filter-only: it must stay in lockstep with the reader's neighbor query + // (which keys off the persisted filter), so search no longer participates here. val articles: Flow> = - combine( - filter, - _searchQuery, - articlesSince, - sortOrder - ) { filter, query, since, sort -> - account.buildArticlePager( - filter = filter, - query = query, - sortOrder = sort, - since = since - ).flow - }.flatMapLatest { it } + combine(filter, articlesSince, sortOrder) { filter, since, sort -> + ArticlePagerKey(filter = filter, query = null, since = since, sort = sort) + }.flatMapLatest(::pagerFlow) + + // Search has its own pager so it can filter freely without disturbing the list above. + val searchResults: Flow> = + combine(_searchQuery, filter, articlesSince, sortOrder) { query, filter, since, sort -> + ArticlePagerKey(filter = filter, query = query, since = since, sort = sort) + }.flatMapLatest { key -> + if (key.query.isNullOrBlank()) flowOf(PagingData.empty()) else pagerFlow(key) + } + + private fun pagerFlow(key: ArticlePagerKey): Flow> = + account.buildArticlePager( + filter = key.filter, + query = key.query, + sortOrder = key.sort, + since = key.since, + ).flow val folders: Flow> = combine( account.folders, @@ -273,9 +280,6 @@ class ArticleScreenViewModel( val showUnauthorizedMessage: Boolean get() = _showUnauthorizedMessage == UnauthorizedMessageState.SHOW - val article: Article? - get() = _article - val searchQuery: Flow get() = _searchQuery @@ -557,103 +561,38 @@ class ArticleScreenViewModel( } } - fun selectArticle(articleID: String, onComplete: (article: Article) -> Unit = {}) { - if (_article?.id == articleID) { - return - } - - viewModelScope.launchIO { - val article = buildArticle(articleID) ?: return@launchIO - _article = article - - launchIO { - markRead(articleID) - } - - launchUI { - onComplete(article) - } - - if (article.fullContent == Article.FullContentState.LOADING) { - fullContentJob?.cancel() - - fullContentJob = viewModelScope.launchIO { fetchFullContent(article) } - } - } - } - - fun toggleArticleRead() { - _article?.let { article -> - viewModelScope.launch { - if (article.read) { - markUnread(article.id) - } else { - markRead(article.id) - } - } - - _article = article.copy(read = !article.read) - } - } - - fun toggleArticleStar() { - _article?.let { article -> - viewModelScope.launch { - if (article.starred) { - removeStar(article.id) - } else { - addStar(article.id) - } - - _article = article.copy(starred = !article.starred) - } - } - } - fun dismissUnauthorizedMessage() { _showUnauthorizedMessage = UnauthorizedMessageState.LATER } - fun clearArticle() { - _article = null - } - fun startSearch() { _searchState.value = SearchState.ACTIVE } fun clearSearch() { - if (_searchQuery.value.isNotBlank()) { - clearArticle() - } _searchQuery.value = "" _searchState.value = SearchState.INACTIVE resetScrollHighWaterMark() } fun updateSearch(query: String) { - clearArticle() _searchQuery.value = query resetScrollHighWaterMark() } fun addStarAsync(articleID: String) { - toggleCurrentStarred(articleID) addStar(articleID) } fun removeStarAsync(articleID: String) = viewModelScope.launchIO { - toggleCurrentStarred(articleID) removeStar(articleID) } fun markReadAsync(articleID: String) = viewModelScope.launchIO { - toggleCurrentRead(articleID) markRead(articleID) } fun markUnreadAsync(articleID: String) = viewModelScope.launchIO { - toggleCurrentRead(articleID) markUnread(articleID) } @@ -689,26 +628,9 @@ class ArticleScreenViewModel( updateFilter(ArticleFilter.default()) } - private fun toggleCurrentStarred(articleID: String) { - _article?.let { article -> - if (articleID == article.id) { - _article = article.copy(starred = !article.starred) - } - } - } - - private fun toggleCurrentRead(articleID: String) { - _article?.let { article -> - if (articleID == article.id) { - _article = article.copy(read = !article.read) - } - } - } - private fun updateFilter(filter: ArticleFilter) { appPreferences.filter.set(filter) - clearArticle() resetScrollHighWaterMark() updateArticlesSince() @@ -716,6 +638,8 @@ class ArticleScreenViewModel( private fun updateArticlesSince() { articlesSince.value = OffsetDateTime.now().plusSeconds(1) + // Share the list's session cutoff so the reader's neighbor pinning matches the list exactly. + articleCutoff.set(articlesSince.value) } private fun copyFolderCounts( @@ -742,91 +666,6 @@ class ArticleScreenViewModel( return savedSearch.copy(count = counts.getOrDefault(savedSearch.id, 0)) } - private suspend fun buildArticle(articleID: String): Article? { - val article = account.findArticle(articleID = articleID) ?: return null - - val fullContent = if (enableStickyFullContent && article.enableStickyFullContent) { - Article.FullContentState.LOADING - } else { - Article.FullContentState.NONE - } - - val content = when (fullContent) { - Article.FullContentState.LOADING -> "" - else -> article.defaultContent - } - - return article.copy( - read = true, - content = content, - fullContent = fullContent - ) - } - - fun fetchFullContentAsync(article: Article? = _article) { - article ?: return - - viewModelScope.launchIO { - if (enableStickyFullContent && !account.isFullContentEnabled(feedID = article.feedID)) { - account.enableStickyContent(article.feedID) - } - - _article = article.copy(fullContent = Article.FullContentState.LOADING) - - _article?.let { fetchFullContent(it) } - } - } - - fun resetFullContent() { - val article = _article ?: return - - _article = article.copy( - content = article.defaultContent, - fullContent = Article.FullContentState.NONE - ) - - if (enableStickyFullContent) { - viewModelScope.launch { - account.disableStickyContent(article.feedID) - } - } - } - - private suspend fun fetchFullContent(article: Article) { - account.fetchFullContent(article) - .fold( - onSuccess = { value -> - if (_article?.id == article.id) { - _article = article.copy( - content = value, - fullContent = Article.FullContentState.LOADED - ) - } - }, - onFailure = { - if (_article?.id != article.id) { - return - } - _article = article.copy( - content = article.defaultContent, - fullContent = Article.FullContentState.ERROR - ) - - CapyLog.warn( - "full_content", - mapOf( - "error_type" to it::class.simpleName, - "error_message" to it.message - ) - ) - - viewModelScope.launchUI { - context.showFullContentErrorToast(it) - } - } - ) - } - private suspend fun openNextFeedOnAllRead( onArticlesCleared: () -> Unit, ) { @@ -883,8 +722,6 @@ class ArticleScreenViewModel( private val currentStatus: ArticleStatus get() = latestFilter.status - private val enableStickyFullContent: Boolean - get() = appPreferences.enableStickyFullContent.get() private val context: Context get() = application.applicationContext @@ -949,3 +786,11 @@ fun countableStatus(filter: ArticleFilter): ArticleStatus { else -> UNREAD } } + +/** Reactive inputs that, when any changes, rebuild an article [PagingData] flow. */ +private data class ArticlePagerKey( + val filter: ArticleFilter, + val query: String?, + val since: OffsetDateTime, + val sort: SortOrder, +) diff --git a/app/src/main/java/com/capyreader/app/ui/articles/ArticleSessionCutoff.kt b/app/src/main/java/com/capyreader/app/ui/articles/ArticleSessionCutoff.kt new file mode 100644 index 000000000..2176b3890 --- /dev/null +++ b/app/src/main/java/com/capyreader/app/ui/articles/ArticleSessionCutoff.kt @@ -0,0 +1,43 @@ +package com.capyreader.app.ui.articles + +import java.time.OffsetDateTime + +/** + * The "since" cutoff for the reader's next/previous neighbor query, owned by the reading session + * rather than the list's refresh. + * + * Set when a reading session begins (the reader opens its first article, before it marks anything + * read) and [reset] when the session ends (the reader leaves the back stack). This keeps articles + * read/unstarred during the session pinned in the neighbor set, so swiping back to where you + * started works — independent of whether a list was ever loaded, which is what makes it correct for + * cold deep links. + */ +class ArticleSessionCutoff { + var value: OffsetDateTime? = null + private set + + /** Written by the list when its session snapshot starts, so the reader's neighbors match it exactly. */ + fun set(value: OffsetDateTime) { + this.value = value + } + + /** + * Begins a reading session. Sets the cutoff when none is active, and also pulls a *future* + * cutoff back to now: the list stamps its snapshot slightly ahead ([ArticleScreenViewModel] + * uses now + 1s), and the reader marks the opened article read at now — without this, that + * article would fall before the cutoff and drop out of its own neighbor set (you couldn't swipe + * back to it). Otherwise idempotent, so repeated reader opens (next/previous) keep the same + * session start. + */ + fun start() { + val now = OffsetDateTime.now() + if (value == null || value?.isAfter(now) == true) { + value = now + } + } + + /** Ends the session so the next one starts fresh rather than reusing a stale cutoff. */ + fun reset() { + value = null + } +} diff --git a/app/src/main/java/com/capyreader/app/ui/articles/ArticleViewModel.kt b/app/src/main/java/com/capyreader/app/ui/articles/ArticleViewModel.kt new file mode 100644 index 000000000..9ad25b453 --- /dev/null +++ b/app/src/main/java/com/capyreader/app/ui/articles/ArticleViewModel.kt @@ -0,0 +1,257 @@ +package com.capyreader.app.ui.articles + +import android.app.Application +import android.content.Context +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.capyreader.app.notifications.NotificationHelper +import com.capyreader.app.preferences.AppPreferences +import com.jocmp.capy.Account +import com.jocmp.capy.Article +import com.jocmp.capy.SavedSearch +import com.jocmp.capy.common.launchIO +import com.jocmp.capy.common.launchUI +import com.jocmp.capy.common.withUIContext +import com.jocmp.capy.logging.CapyLog +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.launch + +/** + * Backs a single [com.capyreader.app.ui.Route.ArticleDetail] entry. The [articleID] arrives as a + * persistent reader surface. [load] swaps the displayed article (the entry uses a stable + * contentKey so this ViewModel survives next/previous navigation), and it owns its own + * full-content fetch/parse lifecycle. + */ +class ArticleViewModel( + private val account: Account, + private val appPreferences: AppPreferences, + private val application: Application, + private val notificationHelper: NotificationHelper, + private val articleCutoff: ArticleSessionCutoff, +) : AndroidViewModel(application) { + + private var fullContentJob: Job? = null + + private var currentArticleID: String? = null + + var article by mutableStateOf(null) + private set + + var previousArticleID by mutableStateOf(null) + private set + + var nextArticleID by mutableStateOf(null) + private set + + val canSaveArticleExternally = account.canSaveArticleExternally.stateIn(viewModelScope) + + val source = account.source + + val savedSearches: Flow> = account.savedSearches + + /** + * Loads [articleID] into the persistent reader surface. The previously loaded [article] is kept + * until the new one resolves so the reader chrome stays present while the content swaps. + */ + fun load(articleID: String) { + if (currentArticleID == articleID) { + return + } + currentArticleID = articleID + // Stamp the session cutoff before marking anything read, so this-session reads stay + // pinned in the neighbor set (and the article we're opening can be navigated back to). + articleCutoff.start() + + viewModelScope.launchIO { + val loaded = buildArticle(articleID) ?: return@launchIO + article = loaded + + launchIO { markRead(articleID) } + + if (loaded.fullContent == Article.FullContentState.LOADING) { + fullContentJob?.cancel() + fullContentJob = viewModelScope.launchIO { fetchFullContent(loaded) } + } + } + + viewModelScope.launchIO { + val (previous, next) = account.neighbors( + filter = appPreferences.filter.get(), + sortOrder = appPreferences.articleListOptions.sortOrder.get(), + since = articleCutoff.value, + articleID = articleID, + ) + previousArticleID = previous + nextArticleID = next + } + } + + fun toggleArticleRead() { + val current = article ?: return + + viewModelScope.launch { + if (current.read) markUnread(current.id) else markRead(current.id) + } + + article = current.copy(read = !current.read) + } + + fun toggleArticleStar() { + val current = article ?: return + + viewModelScope.launch { + if (current.starred) removeStar(current.id) else addStar(current.id) + } + + article = current.copy(starred = !current.starred) + } + + fun fetchFullContentAsync(target: Article? = article) { + target ?: return + + viewModelScope.launchIO { + if (enableStickyFullContent && !account.isFullContentEnabled(feedID = target.feedID)) { + account.enableStickyContent(target.feedID) + } + + article = target.copy(fullContent = Article.FullContentState.LOADING) + article?.let { fetchFullContent(it) } + } + } + + fun resetFullContent() { + val current = article ?: return + + article = current.copy( + content = current.defaultContent, + fullContent = Article.FullContentState.NONE + ) + + if (enableStickyFullContent) { + viewModelScope.launch { account.disableStickyContent(current.feedID) } + } + } + + fun deletePage(articleID: String) { + viewModelScope.launchIO { account.deletePage(articleID) } + } + + fun saveArticleExternallyAsync(articleID: String, onComplete: (Result) -> Unit) { + viewModelScope.launchIO { + val result = account.saveArticleExternally(articleID) + withUIContext { onComplete(result) } + } + } + + fun getArticleLabels(articleID: String?): Flow> { + articleID ?: return emptyFlow() + return account.getArticleSavedSearches(articleID) + } + + fun addLabelAsync(articleID: String, savedSearchID: String) { + viewModelScope.launchIO { account.addSavedSearch(articleID, savedSearchID) } + } + + fun removeLabelAsync(articleID: String, savedSearchID: String) { + viewModelScope.launchIO { account.removeSavedSearch(articleID, savedSearchID) } + } + + suspend fun createLabel(articleID: String, name: String): Result { + return account.createSavedSearch(name).fold( + onSuccess = { labelID -> + account.addSavedSearch(articleID, labelID).fold( + onSuccess = { Result.success(labelID) }, + onFailure = { Result.failure(it) } + ) + }, + onFailure = { Result.failure(it) } + ) + } + + private suspend fun buildArticle(articleID: String): Article? { + val found = account.findArticle(articleID = articleID) ?: return null + + val fullContent = if (enableStickyFullContent && found.enableStickyFullContent) { + Article.FullContentState.LOADING + } else { + Article.FullContentState.NONE + } + + val content = when (fullContent) { + Article.FullContentState.LOADING -> "" + else -> found.defaultContent + } + + return found.copy( + read = true, + content = content, + fullContent = fullContent + ) + } + + private suspend fun fetchFullContent(article: Article) { + account.fetchFullContent(article).fold( + onSuccess = { value -> + if (this.article?.id == article.id) { + this.article = article.copy( + content = value, + fullContent = Article.FullContentState.LOADED + ) + } + }, + onFailure = { + if (this.article?.id != article.id) return + this.article = article.copy( + content = article.defaultContent, + fullContent = Article.FullContentState.ERROR + ) + + CapyLog.warn( + "full_content", + mapOf( + "error_type" to it::class.simpleName, + "error_message" to it.message + ) + ) + + viewModelScope.launchUI { context.showFullContentErrorToast(it) } + } + ) + } + + private suspend fun markRead(articleID: String) { + account.markRead(articleID) + notificationHelper.dismissNotifications(listOf(articleID)) + } + + private suspend fun markUnread(articleID: String) { + account.markUnread(articleID) + } + + private fun addStar(articleID: String) { + viewModelScope.launchIO { account.addStar(articleID) } + } + + private suspend fun removeStar(articleID: String) { + account.removeStar(articleID) + } + + override fun onCleared() { + super.onCleared() + // The reader left the back stack: end the session so the next open starts a fresh cutoff + // rather than reusing this session's (next/previous keep the same instance, so they don't + // trigger this). + articleCutoff.reset() + } + + private val enableStickyFullContent: Boolean + get() = appPreferences.enableStickyFullContent.get() + + private val context: Context + get() = application.applicationContext +} diff --git a/app/src/main/java/com/capyreader/app/ui/articles/ArticlesModule.kt b/app/src/main/java/com/capyreader/app/ui/articles/ArticlesModule.kt index d28a909b8..a3cfc00f4 100644 --- a/app/src/main/java/com/capyreader/app/ui/articles/ArticlesModule.kt +++ b/app/src/main/java/com/capyreader/app/ui/articles/ArticlesModule.kt @@ -12,6 +12,7 @@ import org.koin.core.module.dsl.viewModel import org.koin.dsl.module internal val articlesModule = module { + single { ArticleSessionCutoff() } factory { AddFeedViewModel( account = get(), @@ -55,6 +56,16 @@ internal val articlesModule = module { appPreferences = appPreferences, notificationHelper = get(), application = get(), + articleCutoff = get(), + ) + } + viewModel { + ArticleViewModel( + account = get(), + appPreferences = get(), + application = get(), + notificationHelper = get(), + articleCutoff = get(), ) } viewModel { diff --git a/app/src/main/java/com/capyreader/app/ui/articles/detail/ArticleView.kt b/app/src/main/java/com/capyreader/app/ui/articles/detail/ArticleView.kt index 6f31e5bb3..57d37cf35 100644 --- a/app/src/main/java/com/capyreader/app/ui/articles/detail/ArticleView.kt +++ b/app/src/main/java/com/capyreader/app/ui/articles/detail/ArticleView.kt @@ -29,7 +29,6 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.unit.dp import androidx.core.net.toUri -import androidx.paging.compose.LazyPagingItems import com.capyreader.app.common.AudioEnclosure import com.capyreader.app.common.Media import com.capyreader.app.preferences.AppPreferences @@ -50,13 +49,13 @@ import org.koin.compose.koinInject @Composable fun ArticleView( article: Article, - articles: LazyPagingItems
, + previousArticleID: String? = null, + nextArticleID: String? = null, onBackPressed: () -> Unit, onToggleRead: () -> Unit, onToggleStar: () -> Unit, canSaveExternally: Boolean = false, onDeletePage: () -> Unit = {}, - onScrollToArticle: (index: Int) -> Unit, onSelectArticle: (id: String) -> Unit, onSelectMedia: (media: Media) -> Unit, onSelectAudio: (audio: AudioEnclosure) -> Unit = {}, @@ -79,36 +78,15 @@ fun ArticleView( } } - val index = remember( - article.id, - articles.itemCount, - ) { - articles.itemSnapshotList.indexOfFirst { it?.id == article.id } - } - - val previousIndex = index - 1 - val nextIndex = index + 1 - - val hasPrevious = previousIndex > -1 && articles[index - 1] != null - val hasNext = nextIndex < articles.itemCount && articles[index + 1] != null - - val previousArticleId = if (hasPrevious) articles[previousIndex]?.id else null - val nextArticleId = if (hasNext) articles[nextIndex]?.id else null + val hasPrevious = previousArticleID != null + val hasNext = nextArticleID != null fun selectPrevious() { - if (previousIndex < 0) return - - articles[previousIndex]?.let { - onSelectArticle(it.id) - } + previousArticleID?.let(onSelectArticle) } fun selectNext() { - if (nextIndex >= articles.itemCount) return - - articles[nextIndex]?.let { - onSelectArticle(it.id) - } + nextArticleID?.let(onSelectArticle) } val onSwipe = { swipe: ArticleVerticalSwipe -> @@ -161,8 +139,8 @@ fun ArticleView( ArticleTransition( article = article, enableHorizontalPager = enableHorizontalPager, - previousArticleId = previousArticleId, - nextArticleId = nextArticleId, + previousArticleId = previousArticleID, + nextArticleId = nextArticleID, ) { targetArticle -> ArticleReader( article = targetArticle, @@ -209,13 +187,6 @@ fun ArticleView( } } } - - LaunchedEffect(index) { - if (index > -1) { - onScrollToArticle(index) - } - } - } @Composable 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 c4fe7f61a..3f6417bcf 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 @@ -1,37 +1,18 @@ package com.capyreader.app.ui.articles.list -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.ArrowBack -import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.Menu import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.Text -import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults.pinnedScrollBehavior import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.capyreader.app.R import com.capyreader.app.ui.articles.FilterActionMenu import com.capyreader.app.ui.articles.FilterAppBarTitle import com.capyreader.app.ui.components.ArticleSearch -import com.capyreader.app.ui.components.SearchTextField import com.jocmp.capy.ArticleFilter import com.jocmp.capy.Feed import com.jocmp.capy.Folder @@ -53,87 +34,27 @@ fun ArticleListTopBar( folders: List, source: Source, ) { - val enableSearch = search.isActive - - val closeSearch = { - search.clear() - } - + // Search runs in its own full-surface overlay (see SearchView), so the list top bar only needs + // to launch it; the active-search field/back-arrow used to live here. TopAppBar( scrollBehavior = scrollBehavior, title = { - if (enableSearch) { - val focusRequester = remember { FocusRequester() } - - Box( - modifier = Modifier - .fillMaxWidth(), - contentAlignment = Alignment.Center - ) { - SearchTextField( - placeholder = { Text(stringResource(R.string.search_bar_placeholder)) }, - value = search.query.orEmpty(), - onValueChange = { - search.update(it) - }, - colors = TextFieldDefaults.colors( - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - ), - trailingIcon = { - IconButton(onClick = closeSearch) { - Icon( - imageVector = Icons.Rounded.Close, - contentDescription = null - ) - } - }, - singleLine = true, - maxLines = 1, - shape = RoundedCornerShape(8.dp), - modifier = Modifier - .fillMaxWidth() - .height(48.dp) - .focusRequester(focusRequester), - ) - } - - LaunchedEffect(search.isActive) { - if (search.isActive) { - focusRequester.requestFocus() - } - } - } else { - FilterAppBarTitle( - filter = filter, - allFeeds = feeds, - allFolders = folders, - allSavedSearches = savedSearches, - onRequestJumpToTop = onRequestJumpToTop - ) - } + FilterAppBarTitle( + filter = filter, + allFeeds = feeds, + allFolders = folders, + allSavedSearches = savedSearches, + onRequestJumpToTop = onRequestJumpToTop + ) }, navigationIcon = { - if (enableSearch) { - IconButton( - onClick = { - search.clear() - } - ) { - Icon( - imageVector = Icons.AutoMirrored.Rounded.ArrowBack, - contentDescription = stringResource(R.string.feed_list_top_bar_close_search) - ) - } - } else { - IconButton( - onClick = onNavigateToDrawer - ) { - Icon( - imageVector = Icons.Rounded.Menu, - contentDescription = null - ) - } + IconButton( + onClick = onNavigateToDrawer + ) { + Icon( + imageVector = Icons.Rounded.Menu, + contentDescription = null + ) } }, actions = { @@ -142,7 +63,7 @@ fun ArticleListTopBar( currentFeed = currentFeed, onRemoveFolder = onRemoveFolder, onRequestSearch = { search.start() }, - hideSearchIcon = enableSearch, + hideSearchIcon = false, source = source, ) } diff --git a/app/src/main/java/com/capyreader/app/ui/articles/list/SearchView.kt b/app/src/main/java/com/capyreader/app/ui/articles/list/SearchView.kt new file mode 100644 index 000000000..8bfd9a84b --- /dev/null +++ b/app/src/main/java/com/capyreader/app/ui/articles/list/SearchView.kt @@ -0,0 +1,132 @@ +package com.capyreader.app.ui.articles.list + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.paging.compose.LazyPagingItems +import com.capyreader.app.R +import com.capyreader.app.ui.articles.ArticleList +import com.capyreader.app.ui.components.ArticleSearch +import com.capyreader.app.ui.components.SearchTextField +import com.jocmp.capy.Article + +/** + * Full-surface search overlay. It owns its own results pager so the list (and therefore the + * reader's neighbor query) underneath keeps the base filter untouched. Shown while + * [ArticleSearch.isActive]; selecting a result closes it via the caller's [onSelect]. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SearchView( + search: ArticleSearch, + results: LazyPagingItems
, + selectedArticleID: String?, + onSelect: (article: Article) -> Unit, +) { + val focusRequester = remember { FocusRequester() } + val query = search.query.orEmpty() + + Surface( + modifier = Modifier + .fillMaxSize() + // Eat taps that land on gaps so they can't fall through to the list beneath the overlay. + .pointerInput(Unit) { detectTapGestures { } } + ) { + Scaffold( + topBar = { + TopAppBar( + navigationIcon = { + IconButton(onClick = search.clear) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(R.string.feed_list_top_bar_close_search) + ) + } + }, + title = { + SearchTextField( + placeholder = { Text(stringResource(R.string.search_bar_placeholder)) }, + value = query, + onValueChange = search.update, + colors = TextFieldDefaults.colors( + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + ), + trailingIcon = { + if (query.isNotEmpty()) { + IconButton(onClick = { search.update("") }) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = null + ) + } + } + }, + singleLine = true, + maxLines = 1, + shape = RoundedCornerShape(8.dp), + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + .focusRequester(focusRequester), + ) + }, + ) + } + ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) { + if (query.isNotBlank() && results.itemCount == 0) { + Text( + text = stringResource(R.string.search_no_results), + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = 32.dp) + ) + } else { + ArticleList( + articles = results, + selectedArticleKey = selectedArticleID, + listState = rememberLazyListState(), + dimReadArticles = false, + onSelect = onSelect, + ) + } + } + } + } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } +} diff --git a/app/src/main/java/com/capyreader/app/ui/articles/media/MediaSceneStrategy.kt b/app/src/main/java/com/capyreader/app/ui/articles/media/MediaSceneStrategy.kt new file mode 100644 index 000000000..d0f6a1cc4 --- /dev/null +++ b/app/src/main/java/com/capyreader/app/ui/articles/media/MediaSceneStrategy.kt @@ -0,0 +1,75 @@ +package com.capyreader.app.ui.articles.media + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.scene.OverlayScene +import androidx.navigation3.scene.Scene +import androidx.navigation3.scene.SceneStrategy +import androidx.navigation3.scene.SceneStrategyScope + +/** + * Renders an entry marked with [overlay] metadata (the [com.capyreader.app.ui.Route.MediaViewer] + * entry) as an [OverlayScene] drawn on top of the live list/detail panes (its [overlaidEntries]), + * rather than replacing them. Unlike [androidx.navigation3.scene.DialogScene] the content stays in + * the same window — no platform Dialog — so it fades in/out over both panes. Being a real + * back-stack entry, the system back gesture pops it for free. + */ +class MediaSceneStrategy : SceneStrategy { + override fun SceneStrategyScope.calculateScene( + entries: List> + ): Scene? { + val top = entries.lastOrNull() ?: return null + if (!top.metadata.containsKey(OVERLAY_KEY)) return null + // Everything below the viewer keeps rendering (and is laid out by the other strategies). + val below = entries.dropLast(1) + if (below.isEmpty()) return null + + return MediaOverlayScene( + key = top.contentKey, + entry = top, + overlaidEntries = below, + onBack = onBack, + ) + } + + companion object { + private const val OVERLAY_KEY = "media_overlay" + + /** Marks an entry to be rendered by [MediaSceneStrategy] as a full-window overlay. */ + fun overlay(): Map = mapOf(OVERLAY_KEY to true) + } +} + +private class MediaOverlayScene( + override val key: Any, + private val entry: NavEntry, + override val overlaidEntries: List>, + private val onBack: () -> Unit, +) : OverlayScene { + override val entries: List> = listOf(entry) + + override val previousEntries: List> = overlaidEntries + + private val alpha = Animatable(0f) + + override val content: @Composable () -> Unit = { + // The overlay shares the window (no Dialog), so it must claim system back itself. + BackHandler { onBack() } + LaunchedEffect(Unit) { alpha.animateTo(1f, tween(durationMillis = 220)) } + Box(Modifier.graphicsLayer { this.alpha = this@MediaOverlayScene.alpha.value }) { + entry.Content() + } + } + + override suspend fun onRemove() { + alpha.animateTo(0f, tween(durationMillis = 220)) + } +} diff --git a/app/src/main/java/com/capyreader/app/ui/articles/media/MediaScreen.kt b/app/src/main/java/com/capyreader/app/ui/articles/media/MediaScreen.kt new file mode 100644 index 000000000..646b2fe2f --- /dev/null +++ b/app/src/main/java/com/capyreader/app/ui/articles/media/MediaScreen.kt @@ -0,0 +1,20 @@ +package com.capyreader.app.ui.articles.media + +import androidx.compose.runtime.Composable +import com.capyreader.app.common.Media + +/** + * Content of the [com.capyreader.app.ui.Route.MediaViewer] overlay entry. [ArticleMediaView] + * supplies the black surface, swipe-to-dismiss, and snackbar host; the fade in/out lives in + * [MediaSceneStrategy]'s scene. + */ +@Composable +fun MediaScreen( + media: Media, + onDismiss: () -> Unit, +) { + ArticleMediaView( + onDismissRequest = onDismiss, + media = media, + ) +} diff --git a/app/src/main/java/com/capyreader/app/ui/widget/ArticleHeadline.kt b/app/src/main/java/com/capyreader/app/ui/widget/ArticleHeadline.kt index 661a4c643..43ba33bea 100644 --- a/app/src/main/java/com/capyreader/app/ui/widget/ArticleHeadline.kt +++ b/app/src/main/java/com/capyreader/app/ui/widget/ArticleHeadline.kt @@ -2,12 +2,10 @@ package com.capyreader.app.ui.widget import android.content.Context import android.content.Intent -import android.net.Uri import androidx.compose.material3.darkColorScheme import androidx.compose.runtime.Composable import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.core.net.toUri import androidx.glance.GlanceModifier import androidx.glance.GlanceTheme import androidx.glance.LocalContext @@ -31,7 +29,7 @@ import com.capyreader.app.MainActivity import com.capyreader.app.OpenArticleInBrowserActivity import com.capyreader.app.OpenArticleInBrowserActivity.Companion.ARTICLE_URL_KEY import com.capyreader.app.notifications.NotificationHelper.Companion.ARTICLE_ID_KEY -import com.capyreader.app.notifications.NotificationHelper.Companion.FEED_ID_KEY +import com.capyreader.app.ui.DeepLink import com.jocmp.capy.Article import com.jocmp.capy.articles.relativeTime import com.jocmp.capy.common.DisplayTimeFormats @@ -114,27 +112,17 @@ private fun Context.openArticle(article: Article): Action { ) } else { actionStartActivity( - Intent(this, MainActivity::class.java).apply { - putExtra(ARTICLE_ID_KEY, article.id) - putExtra(FEED_ID_KEY, article.feedID) - data = uniqueUri(article) + Intent( + Intent.ACTION_VIEW, + DeepLink.articleUri(articleID = article.id, feedID = article.feedID), + this, + MainActivity::class.java, + ).apply { setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) }) } } - -private fun uniqueUri(article: Article): Uri { - val fallbackUri = "https://capyreader.com/${article.id}".toUri() - val url = article.url - - return try { - url.toString().toUri() - } catch (e: Throwable) { - fallbackUri - } -} - @OptIn(ExperimentalGlancePreviewApi::class) @Preview @Composable diff --git a/app/src/main/java/com/capyreader/app/ui/widget/HeadlinesLayout.kt b/app/src/main/java/com/capyreader/app/ui/widget/HeadlinesLayout.kt index e6b4ef6c3..d1473896e 100644 --- a/app/src/main/java/com/capyreader/app/ui/widget/HeadlinesLayout.kt +++ b/app/src/main/java/com/capyreader/app/ui/widget/HeadlinesLayout.kt @@ -37,7 +37,8 @@ import androidx.glance.text.Text import androidx.glance.text.TextStyle import com.capyreader.app.MainActivity import com.capyreader.app.R -import com.capyreader.app.notifications.NotificationHelper.Companion.UNREAD_ONLY_KEY +import com.capyreader.app.ui.DeepLink +import com.jocmp.capy.ArticleStatus import com.jocmp.capy.Article import java.time.LocalDateTime @@ -100,8 +101,12 @@ fun HeadlinesLayout(articles: List
) { private fun Context.openUnread() = actionStartActivity( - Intent(this, MainActivity::class.java).apply { - putExtra(UNREAD_ONLY_KEY, true) + Intent( + Intent.ACTION_VIEW, + DeepLink.articlesUri(ArticleStatus.UNREAD), + this, + MainActivity::class.java, + ).apply { setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) }) diff --git a/app/src/main/java/com/capyreader/app/ui/widget/SpotlightLayout.kt b/app/src/main/java/com/capyreader/app/ui/widget/SpotlightLayout.kt index 822b6a013..6743cc5aa 100644 --- a/app/src/main/java/com/capyreader/app/ui/widget/SpotlightLayout.kt +++ b/app/src/main/java/com/capyreader/app/ui/widget/SpotlightLayout.kt @@ -9,7 +9,6 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.core.net.toUri import androidx.glance.Button import androidx.glance.ColorFilter import androidx.glance.GlanceModifier @@ -45,8 +44,8 @@ import com.capyreader.app.OpenArticleInBrowserActivity import com.capyreader.app.OpenArticleInBrowserActivity.Companion.ARTICLE_URL_KEY import com.capyreader.app.R import com.capyreader.app.notifications.NotificationHelper.Companion.ARTICLE_ID_KEY -import com.capyreader.app.notifications.NotificationHelper.Companion.FEED_ID_KEY -import com.capyreader.app.notifications.NotificationHelper.Companion.SHOW_ALL_KEY +import com.capyreader.app.ui.DeepLink +import com.jocmp.capy.ArticleStatus @Composable fun SpotlightLayout( @@ -198,8 +197,12 @@ private fun EmptyState(context: Context) { private fun Context.openAll() = actionStartActivity( - Intent(this, MainActivity::class.java).apply { - putExtra(SHOW_ALL_KEY, true) + Intent( + Intent.ACTION_VIEW, + DeepLink.articlesUri(ArticleStatus.ALL), + this, + MainActivity::class.java, + ).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK } ) @@ -214,10 +217,12 @@ private fun Context.openArticle(entry: SpotlightEntry): Action { ) } else { actionStartActivity( - Intent(this, MainActivity::class.java).apply { - putExtra(ARTICLE_ID_KEY, entry.id) - putExtra(FEED_ID_KEY, entry.feedID) - data = entry.articleURL?.toUri() + Intent( + Intent.ACTION_VIEW, + DeepLink.articleUri(articleID = entry.id, feedID = entry.feedID), + this, + MainActivity::class.java, + ).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK } ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 327db8c16..5e6d8dc34 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -300,6 +300,7 @@ Fetch blocked by site Feeds Search + No results found Save Image saved Failed to save image diff --git a/app/src/test/java/com/capyreader/app/ui/articles/ArticleScreenViewModelTest.kt b/app/src/test/java/com/capyreader/app/ui/articles/ArticleScreenViewModelTest.kt index 9a0823372..9e5d5c972 100644 --- a/app/src/test/java/com/capyreader/app/ui/articles/ArticleScreenViewModelTest.kt +++ b/app/src/test/java/com/capyreader/app/ui/articles/ArticleScreenViewModelTest.kt @@ -226,6 +226,7 @@ class ArticleScreenViewModelTest { notificationHelper = notificationHelper, ioDispatcher = testDispatcher, syncFlushInterval = syncFlushInterval, + articleCutoff = ArticleSessionCutoff(), ) } } diff --git a/build.gradle.kts b/build.gradle.kts index aa3ab61de..ef6adf715 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,8 +1,7 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { - id("com.android.application") version "8.11.1" apply false - id("com.android.library") version "8.11.1" apply false - id("org.jetbrains.kotlin.android") version libs.versions.kotlin apply false + id("com.android.application") version "9.1.1" apply false + id("com.android.library") version "9.1.1" apply false id("org.jetbrains.kotlin.jvm") version libs.versions.kotlin apply false id("org.jetbrains.kotlin.plugin.serialization") version libs.versions.kotlin id("com.google.gms.google-services") version "4.4.3" apply false diff --git a/capy/src/main/java/com/jocmp/capy/Account.kt b/capy/src/main/java/com/jocmp/capy/Account.kt index d2f719012..68d14914f 100644 --- a/capy/src/main/java/com/jocmp/capy/Account.kt +++ b/capy/src/main/java/com/jocmp/capy/Account.kt @@ -353,6 +353,24 @@ data class Account( ) } + /** + * Previous/next article id relative to [articleID] for the reader's swipe navigation. + * Suspends onto IO so the (synchronous) query never runs on the main thread. + */ + suspend fun neighbors( + filter: ArticleFilter, + sortOrder: SortOrder, + since: java.time.OffsetDateTime?, + articleID: String, + ): Pair = withIOContext { + articleRecords.neighbors( + filter = filter, + sortOrder = sortOrder, + since = since, + articleID = articleID, + ) + } + fun countUnread( filter: ArticleFilter, query: String?, diff --git a/capy/src/main/java/com/jocmp/capy/ArticleNotification.kt b/capy/src/main/java/com/jocmp/capy/ArticleNotification.kt index c187d9fde..44e6f4ca4 100644 --- a/capy/src/main/java/com/jocmp/capy/ArticleNotification.kt +++ b/capy/src/main/java/com/jocmp/capy/ArticleNotification.kt @@ -4,7 +4,9 @@ data class ArticleNotification( val id: Int, val articleID: String, val title: String, + val url: String?, val feedID: String, val feedTitle: String, val feedFaviconURL: String?, + val openInBrowser: Boolean, ) diff --git a/capy/src/main/java/com/jocmp/capy/persistence/ArticleNotificationMapper.kt b/capy/src/main/java/com/jocmp/capy/persistence/ArticleNotificationMapper.kt index c13857cc3..c36e01a6f 100644 --- a/capy/src/main/java/com/jocmp/capy/persistence/ArticleNotificationMapper.kt +++ b/capy/src/main/java/com/jocmp/capy/persistence/ArticleNotificationMapper.kt @@ -6,14 +6,18 @@ internal fun articleNotificationMapper( articleID: String, title: String?, summary: String?, + url: String?, feedID: String?, feedTitle: String?, feedFavicon: String?, + openInBrowser: Boolean, ) = ArticleNotification( id = articleID.hashCode(), articleID = articleID, title = title.orEmpty().ifBlank { summary.orEmpty() }, + url = url, feedID = feedID!!, feedTitle = feedTitle.orEmpty(), - feedFaviconURL = feedFavicon + feedFaviconURL = feedFavicon, + openInBrowser = openInBrowser, ) diff --git a/capy/src/main/java/com/jocmp/capy/persistence/ArticleRecords.kt b/capy/src/main/java/com/jocmp/capy/persistence/ArticleRecords.kt index 6056b201b..13e3936d0 100644 --- a/capy/src/main/java/com/jocmp/capy/persistence/ArticleRecords.kt +++ b/capy/src/main/java/com/jocmp/capy/persistence/ArticleRecords.kt @@ -320,6 +320,59 @@ class ArticleRecords( return database.articlesQueries.filterUnreadStatuses(ids).executeAsList() } + /** + * The previous/next article id relative to [articleID] in the order/membership [filter] shows. + * [since] is the session cutoff (keeps this-session reads/unstars pinned), matching the list. + */ + fun neighbors( + filter: ArticleFilter, + sortOrder: SortOrder, + since: java.time.OffsetDateTime?, + articleID: String, + ): Pair { + return when (filter) { + is ArticleFilter.Articles -> byStatus.neighbors( + filter.articleStatus, + sortOrder = sortOrder, + since = since, + articleID = articleID, + ) + + is ArticleFilter.Feeds -> byFeed.neighbors( + feedIDs = listOf(filter.feedID), + status = filter.feedStatus, + sortOrder = sortOrder, + since = since, + priority = FeedPriority.FEED, + articleID = articleID, + ) + + is ArticleFilter.Folders -> byFeed.neighbors( + feedIDs = folderFeedIDs(filter), + status = filter.status, + sortOrder = sortOrder, + since = since, + priority = FeedPriority.CATEGORY, + articleID = articleID, + ) + + is ArticleFilter.SavedSearches -> bySavedSearch.neighbors( + savedSearchID = filter.savedSearchID, + status = filter.status, + sortOrder = sortOrder, + since = since, + articleID = articleID, + ) + + is ArticleFilter.Today -> byToday.neighbors( + status = filter.status, + sortOrder = sortOrder, + since = since, + articleID = articleID, + ) + } + } + fun unreadArticleIDs( filter: ArticleFilter, range: MarkRead, diff --git a/capy/src/main/java/com/jocmp/capy/persistence/articles/ByArticleStatus.kt b/capy/src/main/java/com/jocmp/capy/persistence/articles/ByArticleStatus.kt index 55f382574..20b7a0b75 100644 --- a/capy/src/main/java/com/jocmp/capy/persistence/articles/ByArticleStatus.kt +++ b/capy/src/main/java/com/jocmp/capy/persistence/articles/ByArticleStatus.kt @@ -68,6 +68,43 @@ class ByArticleStatus(private val database: Database) { ) } + fun neighbors( + status: ArticleStatus, + sortOrder: SortOrder, + since: OffsetDateTime?, + articleID: String, + publishedSince: Long? = null, + ): Pair { + val (read, starred) = status.toStatusPair + val newestFirst = isNewestFirst(sortOrder) + val queries = database.articlesByStatusQueries + + val findBefore = + if (newestFirst) queries::articleBeforeNewestFirst else queries::articleBeforeOldestFirst + val findAfter = + if (newestFirst) queries::articleAfterNewestFirst else queries::articleAfterOldestFirst + + val previous = findBefore( + articleID, + read, + mapLastRead(read, since), + starred, + mapLastUnstarred(starred, since), + publishedSince, + ).executeAsOneOrNull() + + val next = findAfter( + articleID, + read, + mapLastRead(read, since), + starred, + mapLastUnstarred(starred, since), + publishedSince, + ).executeAsOneOrNull() + + return previous to next + } + fun maxArrivedAt(): Long? { return database.articlesQueries.lastUpdatedAt().executeAsOne().MAX } diff --git a/capy/src/main/java/com/jocmp/capy/persistence/articles/ByFeed.kt b/capy/src/main/java/com/jocmp/capy/persistence/articles/ByFeed.kt index 3b62f9f07..55add1f70 100644 --- a/capy/src/main/java/com/jocmp/capy/persistence/articles/ByFeed.kt +++ b/capy/src/main/java/com/jocmp/capy/persistence/articles/ByFeed.kt @@ -80,6 +80,48 @@ class ByFeed(private val database: Database) { ) } + fun neighbors( + feedIDs: List, + status: ArticleStatus, + sortOrder: SortOrder, + since: OffsetDateTime?, + priority: FeedPriority, + articleID: String, + ): Pair { + val (read, starred) = status.toStatusPair + val newestFirst = isNewestFirst(sortOrder) + val queries = database.articlesByFeedQueries + + val findBefore = + if (newestFirst) queries::articleBeforeNewestFirst else queries::articleBeforeOldestFirst + val findAfter = + if (newestFirst) queries::articleAfterNewestFirst else queries::articleAfterOldestFirst + + val previous = findBefore( + articleID, + feedIDs, + read, + mapLastRead(read, since), + starred, + mapLastUnstarred(starred, since), + null, + priority.inclusivePriorities, + ).executeAsOneOrNull() + + val next = findAfter( + articleID, + feedIDs, + read, + mapLastRead(read, since), + starred, + mapLastUnstarred(starred, since), + null, + priority.inclusivePriorities, + ).executeAsOneOrNull() + + return previous to next + } + fun count( feedIDs: List, status: ArticleStatus, diff --git a/capy/src/main/java/com/jocmp/capy/persistence/articles/BySavedSearch.kt b/capy/src/main/java/com/jocmp/capy/persistence/articles/BySavedSearch.kt index daea1233a..05564c403 100644 --- a/capy/src/main/java/com/jocmp/capy/persistence/articles/BySavedSearch.kt +++ b/capy/src/main/java/com/jocmp/capy/persistence/articles/BySavedSearch.kt @@ -75,6 +75,45 @@ class BySavedSearch(private val database: Database) { ) } + fun neighbors( + savedSearchID: String, + status: ArticleStatus, + sortOrder: SortOrder, + since: OffsetDateTime?, + articleID: String, + ): Pair { + val (read, starred) = status.toStatusPair + val newestFirst = isNewestFirst(sortOrder) + val queries = database.articlesBySavedSearchQueries + + val findBefore = + if (newestFirst) queries::articleBeforeNewestFirst else queries::articleBeforeOldestFirst + val findAfter = + if (newestFirst) queries::articleAfterNewestFirst else queries::articleAfterOldestFirst + + val previous = findBefore( + articleID, + savedSearchID, + read, + mapLastRead(read, since), + starred, + mapLastUnstarred(starred, since), + null, + ).executeAsOneOrNull() + + val next = findAfter( + articleID, + savedSearchID, + read, + mapLastRead(read, since), + starred, + mapLastUnstarred(starred, since), + null, + ).executeAsOneOrNull() + + return previous to next + } + fun count( savedSearchID: String, status: ArticleStatus, diff --git a/capy/src/main/java/com/jocmp/capy/persistence/articles/ByToday.kt b/capy/src/main/java/com/jocmp/capy/persistence/articles/ByToday.kt index 03a23b9e2..3aba701cf 100644 --- a/capy/src/main/java/com/jocmp/capy/persistence/articles/ByToday.kt +++ b/capy/src/main/java/com/jocmp/capy/persistence/articles/ByToday.kt @@ -30,7 +30,7 @@ class ByToday(private val database: Database) { offset = offset, lastReadAt = mapLastRead(read, since), lastUnstarredAt = mapLastUnstarred(starred, since), - publishedSince = mapTodayStartDate(), + publishedSince = mapTodayStartDate(since), query = query, mapper = ::listMapper ) @@ -42,7 +42,7 @@ class ByToday(private val database: Database) { offset = offset, lastReadAt = mapLastRead(read, since), lastUnstarredAt = mapLastUnstarred(starred, since), - publishedSince = mapTodayStartDate(), + publishedSince = mapTodayStartDate(since), query = query, mapper = ::listMapper ) @@ -62,7 +62,7 @@ class ByToday(private val database: Database) { starred = starred, afterArticleID = afterArticleID, beforeArticleID = beforeArticleID, - publishedSince = mapTodayStartDate(), + publishedSince = mapTodayStartDate(since = null), newestFirst = isNewestFirst(sortOrder), query = query, ) @@ -81,11 +81,53 @@ class ByToday(private val database: Database) { query = query, lastReadAt = mapLastRead(read, since), lastUnstarredAt = mapLastUnstarred(starred, since), - publishedSince = mapTodayStartDate() + publishedSince = mapTodayStartDate(since) ) } - private fun mapTodayStartDate(): Long { - return OffsetDateTime.now().minusHours(24).toEpochSecond() + fun neighbors( + status: ArticleStatus, + sortOrder: SortOrder, + since: OffsetDateTime?, + articleID: String, + ): Pair { + val (read, starred) = status.toStatusPair + val newestFirst = isNewestFirst(sortOrder) + val queries = database.articlesByStatusQueries + val publishedSince = mapTodayStartDate(since) + + val findBefore = + if (newestFirst) queries::articleBeforeNewestFirst else queries::articleBeforeOldestFirst + val findAfter = + if (newestFirst) queries::articleAfterNewestFirst else queries::articleAfterOldestFirst + + val previous = findBefore( + articleID, + read, + mapLastRead(read, since), + starred, + mapLastUnstarred(starred, since), + publishedSince, + ).executeAsOneOrNull() + + val next = findAfter( + articleID, + read, + mapLastRead(read, since), + starred, + mapLastUnstarred(starred, since), + publishedSince, + ).executeAsOneOrNull() + + return previous to next + } + + /** + * Anchor the 24h window on the session [since] (the list snapshot / reader cutoff) rather than + * the current instant, so the list pager and the reader's neighbor query — which run at + * different times — agree on the Today window instead of drifting apart near the boundary. + */ + private fun mapTodayStartDate(since: OffsetDateTime?): Long { + return (since ?: OffsetDateTime.now()).minusHours(24).toEpochSecond() } } diff --git a/capy/src/main/sqldelight/com/jocmp/capy/db/article_notifications.sq b/capy/src/main/sqldelight/com/jocmp/capy/db/article_notifications.sq index d3a023b78..ccbafb3ff 100644 --- a/capy/src/main/sqldelight/com/jocmp/capy/db/article_notifications.sq +++ b/capy/src/main/sqldelight/com/jocmp/capy/db/article_notifications.sq @@ -3,9 +3,11 @@ SELECT article_id, articles.title, articles.summary, + articles.url, feeds.id AS feed_id, feeds.title AS feed_title, - feeds.favicon_url AS feed_favicon_url + feeds.favicon_url AS feed_favicon_url, + feeds.open_articles_in_browser FROM article_notifications JOIN articles ON article_notifications.article_id = articles.id JOIN feeds ON articles.feed_id = feeds.id diff --git a/capy/src/main/sqldelight/com/jocmp/capy/db/articlesByFeed.sq b/capy/src/main/sqldelight/com/jocmp/capy/db/articlesByFeed.sq index d3280fb68..50787c1f4 100644 --- a/capy/src/main/sqldelight/com/jocmp/capy/db/articlesByFeed.sq +++ b/capy/src/main/sqldelight/com/jocmp/capy/db/articlesByFeed.sq @@ -99,3 +99,66 @@ AND ( OR (articles.published_at = before_articles.published_at AND articles.id >= :beforeArticleID) END ); + +-- Neighbor lookups are split by sort order so the ORDER BY references plain columns and can use an +-- index, instead of wrapping them in CASE (which forces a full sort). "After"/"before" are relative +-- to the displayed order: newest-first's next article is older, oldest-first's next is newer. +articleAfterNewestFirst: +SELECT articles.id +FROM articles +JOIN feeds ON articles.feed_id = feeds.id +JOIN article_statuses ON articles.id = article_statuses.article_id +JOIN articles AS anchor ON anchor.id = :articleID +WHERE articles.feed_id IN :feedIDs +AND ((article_statuses.read = :read AND article_statuses.last_read_at IS NULL OR article_statuses.last_read_at >= :lastReadAt) OR :read IS NULL) +AND ((article_statuses.starred = :starred AND article_statuses.last_unstarred_at IS NULL OR article_statuses.last_unstarred_at >= :lastUnstarredAt) OR :starred IS NULL) +AND (articles.published_at >= :publishedSince OR :publishedSince IS NULL) +AND (feeds.priority IN :priorities OR feeds.priority IS NULL) +AND (articles.published_at < anchor.published_at OR (articles.published_at = anchor.published_at AND articles.id < anchor.id)) +ORDER BY articles.published_at DESC, articles.id DESC +LIMIT 1; + +articleAfterOldestFirst: +SELECT articles.id +FROM articles +JOIN feeds ON articles.feed_id = feeds.id +JOIN article_statuses ON articles.id = article_statuses.article_id +JOIN articles AS anchor ON anchor.id = :articleID +WHERE articles.feed_id IN :feedIDs +AND ((article_statuses.read = :read AND article_statuses.last_read_at IS NULL OR article_statuses.last_read_at >= :lastReadAt) OR :read IS NULL) +AND ((article_statuses.starred = :starred AND article_statuses.last_unstarred_at IS NULL OR article_statuses.last_unstarred_at >= :lastUnstarredAt) OR :starred IS NULL) +AND (articles.published_at >= :publishedSince OR :publishedSince IS NULL) +AND (feeds.priority IN :priorities OR feeds.priority IS NULL) +AND (articles.published_at > anchor.published_at OR (articles.published_at = anchor.published_at AND articles.id > anchor.id)) +ORDER BY articles.published_at ASC, articles.id ASC +LIMIT 1; + +articleBeforeNewestFirst: +SELECT articles.id +FROM articles +JOIN feeds ON articles.feed_id = feeds.id +JOIN article_statuses ON articles.id = article_statuses.article_id +JOIN articles AS anchor ON anchor.id = :articleID +WHERE articles.feed_id IN :feedIDs +AND ((article_statuses.read = :read AND article_statuses.last_read_at IS NULL OR article_statuses.last_read_at >= :lastReadAt) OR :read IS NULL) +AND ((article_statuses.starred = :starred AND article_statuses.last_unstarred_at IS NULL OR article_statuses.last_unstarred_at >= :lastUnstarredAt) OR :starred IS NULL) +AND (articles.published_at >= :publishedSince OR :publishedSince IS NULL) +AND (feeds.priority IN :priorities OR feeds.priority IS NULL) +AND (articles.published_at > anchor.published_at OR (articles.published_at = anchor.published_at AND articles.id > anchor.id)) +ORDER BY articles.published_at ASC, articles.id ASC +LIMIT 1; + +articleBeforeOldestFirst: +SELECT articles.id +FROM articles +JOIN feeds ON articles.feed_id = feeds.id +JOIN article_statuses ON articles.id = article_statuses.article_id +JOIN articles AS anchor ON anchor.id = :articleID +WHERE articles.feed_id IN :feedIDs +AND ((article_statuses.read = :read AND article_statuses.last_read_at IS NULL OR article_statuses.last_read_at >= :lastReadAt) OR :read IS NULL) +AND ((article_statuses.starred = :starred AND article_statuses.last_unstarred_at IS NULL OR article_statuses.last_unstarred_at >= :lastUnstarredAt) OR :starred IS NULL) +AND (articles.published_at >= :publishedSince OR :publishedSince IS NULL) +AND (feeds.priority IN :priorities OR feeds.priority IS NULL) +AND (articles.published_at < anchor.published_at OR (articles.published_at = anchor.published_at AND articles.id < anchor.id)) +ORDER BY articles.published_at DESC, articles.id DESC +LIMIT 1; diff --git a/capy/src/main/sqldelight/com/jocmp/capy/db/articlesBySavedSearch.sq b/capy/src/main/sqldelight/com/jocmp/capy/db/articlesBySavedSearch.sq index a90ddd183..4bb87b0d2 100644 --- a/capy/src/main/sqldelight/com/jocmp/capy/db/articlesBySavedSearch.sq +++ b/capy/src/main/sqldelight/com/jocmp/capy/db/articlesBySavedSearch.sq @@ -98,3 +98,64 @@ AND ( OR (articles.published_at = before_articles.published_at AND articles.id >= :beforeArticleID) END ); + +-- Split by sort order so the ORDER BY hits an index instead of sorting via CASE; see articlesByFeed.sq. +articleAfterNewestFirst: +SELECT articles.id +FROM articles +JOIN feeds ON articles.feed_id = feeds.id +JOIN article_statuses ON articles.id = article_statuses.article_id +JOIN saved_search_articles ON articles.id = saved_search_articles.article_id +JOIN articles AS anchor ON anchor.id = :articleID +WHERE saved_search_id = :savedSearchID +AND ((article_statuses.read = :read AND article_statuses.last_read_at IS NULL OR article_statuses.last_read_at >= :lastReadAt) OR :read IS NULL) +AND ((article_statuses.starred = :starred AND article_statuses.last_unstarred_at IS NULL OR article_statuses.last_unstarred_at >= :lastUnstarredAt) OR :starred IS NULL) +AND (articles.published_at >= :publishedSince OR :publishedSince IS NULL) +AND (articles.published_at < anchor.published_at OR (articles.published_at = anchor.published_at AND articles.id < anchor.id)) +ORDER BY articles.published_at DESC, articles.id DESC +LIMIT 1; + +articleAfterOldestFirst: +SELECT articles.id +FROM articles +JOIN feeds ON articles.feed_id = feeds.id +JOIN article_statuses ON articles.id = article_statuses.article_id +JOIN saved_search_articles ON articles.id = saved_search_articles.article_id +JOIN articles AS anchor ON anchor.id = :articleID +WHERE saved_search_id = :savedSearchID +AND ((article_statuses.read = :read AND article_statuses.last_read_at IS NULL OR article_statuses.last_read_at >= :lastReadAt) OR :read IS NULL) +AND ((article_statuses.starred = :starred AND article_statuses.last_unstarred_at IS NULL OR article_statuses.last_unstarred_at >= :lastUnstarredAt) OR :starred IS NULL) +AND (articles.published_at >= :publishedSince OR :publishedSince IS NULL) +AND (articles.published_at > anchor.published_at OR (articles.published_at = anchor.published_at AND articles.id > anchor.id)) +ORDER BY articles.published_at ASC, articles.id ASC +LIMIT 1; + +articleBeforeNewestFirst: +SELECT articles.id +FROM articles +JOIN feeds ON articles.feed_id = feeds.id +JOIN article_statuses ON articles.id = article_statuses.article_id +JOIN saved_search_articles ON articles.id = saved_search_articles.article_id +JOIN articles AS anchor ON anchor.id = :articleID +WHERE saved_search_id = :savedSearchID +AND ((article_statuses.read = :read AND article_statuses.last_read_at IS NULL OR article_statuses.last_read_at >= :lastReadAt) OR :read IS NULL) +AND ((article_statuses.starred = :starred AND article_statuses.last_unstarred_at IS NULL OR article_statuses.last_unstarred_at >= :lastUnstarredAt) OR :starred IS NULL) +AND (articles.published_at >= :publishedSince OR :publishedSince IS NULL) +AND (articles.published_at > anchor.published_at OR (articles.published_at = anchor.published_at AND articles.id > anchor.id)) +ORDER BY articles.published_at ASC, articles.id ASC +LIMIT 1; + +articleBeforeOldestFirst: +SELECT articles.id +FROM articles +JOIN feeds ON articles.feed_id = feeds.id +JOIN article_statuses ON articles.id = article_statuses.article_id +JOIN saved_search_articles ON articles.id = saved_search_articles.article_id +JOIN articles AS anchor ON anchor.id = :articleID +WHERE saved_search_id = :savedSearchID +AND ((article_statuses.read = :read AND article_statuses.last_read_at IS NULL OR article_statuses.last_read_at >= :lastReadAt) OR :read IS NULL) +AND ((article_statuses.starred = :starred AND article_statuses.last_unstarred_at IS NULL OR article_statuses.last_unstarred_at >= :lastUnstarredAt) OR :starred IS NULL) +AND (articles.published_at >= :publishedSince OR :publishedSince IS NULL) +AND (articles.published_at < anchor.published_at OR (articles.published_at = anchor.published_at AND articles.id < anchor.id)) +ORDER BY articles.published_at DESC, articles.id DESC +LIMIT 1; diff --git a/capy/src/main/sqldelight/com/jocmp/capy/db/articlesByStatus.sq b/capy/src/main/sqldelight/com/jocmp/capy/db/articlesByStatus.sq index fcb79ff62..ed3c7a3d0 100644 --- a/capy/src/main/sqldelight/com/jocmp/capy/db/articlesByStatus.sq +++ b/capy/src/main/sqldelight/com/jocmp/capy/db/articlesByStatus.sq @@ -95,3 +95,60 @@ AND ( OR (articles.published_at = before_articles.published_at AND articles.id >= :beforeArticleID) END ); + +-- Split by sort order so the ORDER BY hits an index instead of sorting via CASE; see articlesByFeed.sq. +articleAfterNewestFirst: +SELECT articles.id +FROM articles +JOIN feeds ON articles.feed_id = feeds.id +JOIN article_statuses ON articles.id = article_statuses.article_id +JOIN articles AS anchor ON anchor.id = :articleID +WHERE ((article_statuses.read = :read AND article_statuses.last_read_at IS NULL OR article_statuses.last_read_at >= :lastReadAt) OR :read IS NULL) +AND (feeds.priority IS NULL OR feeds.priority IN ('main', 'important')) +AND ((article_statuses.starred = :starred AND article_statuses.last_unstarred_at IS NULL OR article_statuses.last_unstarred_at >= :lastUnstarredAt) OR :starred IS NULL) +AND (articles.published_at >= :publishedSince OR :publishedSince IS NULL) +AND (articles.published_at < anchor.published_at OR (articles.published_at = anchor.published_at AND articles.id < anchor.id)) +ORDER BY articles.published_at DESC, articles.id DESC +LIMIT 1; + +articleAfterOldestFirst: +SELECT articles.id +FROM articles +JOIN feeds ON articles.feed_id = feeds.id +JOIN article_statuses ON articles.id = article_statuses.article_id +JOIN articles AS anchor ON anchor.id = :articleID +WHERE ((article_statuses.read = :read AND article_statuses.last_read_at IS NULL OR article_statuses.last_read_at >= :lastReadAt) OR :read IS NULL) +AND (feeds.priority IS NULL OR feeds.priority IN ('main', 'important')) +AND ((article_statuses.starred = :starred AND article_statuses.last_unstarred_at IS NULL OR article_statuses.last_unstarred_at >= :lastUnstarredAt) OR :starred IS NULL) +AND (articles.published_at >= :publishedSince OR :publishedSince IS NULL) +AND (articles.published_at > anchor.published_at OR (articles.published_at = anchor.published_at AND articles.id > anchor.id)) +ORDER BY articles.published_at ASC, articles.id ASC +LIMIT 1; + +articleBeforeNewestFirst: +SELECT articles.id +FROM articles +JOIN feeds ON articles.feed_id = feeds.id +JOIN article_statuses ON articles.id = article_statuses.article_id +JOIN articles AS anchor ON anchor.id = :articleID +WHERE ((article_statuses.read = :read AND article_statuses.last_read_at IS NULL OR article_statuses.last_read_at >= :lastReadAt) OR :read IS NULL) +AND (feeds.priority IS NULL OR feeds.priority IN ('main', 'important')) +AND ((article_statuses.starred = :starred AND article_statuses.last_unstarred_at IS NULL OR article_statuses.last_unstarred_at >= :lastUnstarredAt) OR :starred IS NULL) +AND (articles.published_at >= :publishedSince OR :publishedSince IS NULL) +AND (articles.published_at > anchor.published_at OR (articles.published_at = anchor.published_at AND articles.id > anchor.id)) +ORDER BY articles.published_at ASC, articles.id ASC +LIMIT 1; + +articleBeforeOldestFirst: +SELECT articles.id +FROM articles +JOIN feeds ON articles.feed_id = feeds.id +JOIN article_statuses ON articles.id = article_statuses.article_id +JOIN articles AS anchor ON anchor.id = :articleID +WHERE ((article_statuses.read = :read AND article_statuses.last_read_at IS NULL OR article_statuses.last_read_at >= :lastReadAt) OR :read IS NULL) +AND (feeds.priority IS NULL OR feeds.priority IN ('main', 'important')) +AND ((article_statuses.starred = :starred AND article_statuses.last_unstarred_at IS NULL OR article_statuses.last_unstarred_at >= :lastUnstarredAt) OR :starred IS NULL) +AND (articles.published_at >= :publishedSince OR :publishedSince IS NULL) +AND (articles.published_at < anchor.published_at OR (articles.published_at = anchor.published_at AND articles.id < anchor.id)) +ORDER BY articles.published_at DESC, articles.id DESC +LIMIT 1; diff --git a/capy/src/test/java/com/jocmp/capy/persistence/ArticleRecordsTest.kt b/capy/src/test/java/com/jocmp/capy/persistence/ArticleRecordsTest.kt index 97e78680a..e2edab2f5 100644 --- a/capy/src/test/java/com/jocmp/capy/persistence/ArticleRecordsTest.kt +++ b/capy/src/test/java/com/jocmp/capy/persistence/ArticleRecordsTest.kt @@ -3,6 +3,7 @@ package com.jocmp.capy.persistence import com.jocmp.capy.Article import com.jocmp.capy.ArticleFilter import com.jocmp.capy.ArticleStatus +import com.jocmp.capy.Feed import com.jocmp.capy.FeedPriority import com.jocmp.capy.InMemoryDatabaseProvider import com.jocmp.capy.RandomUUID @@ -638,6 +639,127 @@ class ArticleRecordsTest { assertEquals(expected = 3, actual = counts[firstSearch.id]) assertEquals(expected = 2, actual = counts[secondSearch.id]) } + + @Test + fun neighbors_newestFirst_returnsAdjacentArticlesAndBoundaries() { + val feed = FeedFixture(database).create() + val (newest, middle, oldest) = threeArticles(feed) + val filter = ArticleFilter.Feeds( + feedID = feed.id, + folderTitle = null, + feedStatus = ArticleStatus.ALL, + ) + + // List order is [newest, middle, oldest]; previous is up the list, next is down. + assertEquals( + expected = newest.id to oldest.id, + actual = neighbors(filter, SortOrder.NEWEST_FIRST, middle.id), + ) + assertEquals( + expected = null to middle.id, + actual = neighbors(filter, SortOrder.NEWEST_FIRST, newest.id), + ) + assertEquals( + expected = middle.id to null, + actual = neighbors(filter, SortOrder.NEWEST_FIRST, oldest.id), + ) + } + + @Test + fun neighbors_oldestFirst_flipsTheOrder() { + val feed = FeedFixture(database).create() + val (newest, middle, oldest) = threeArticles(feed) + val filter = ArticleFilter.Feeds( + feedID = feed.id, + folderTitle = null, + feedStatus = ArticleStatus.ALL, + ) + + // List order is [oldest, middle, newest]. + assertEquals( + expected = oldest.id to newest.id, + actual = neighbors(filter, SortOrder.OLDEST_FIRST, middle.id), + ) + assertEquals( + expected = null to middle.id, + actual = neighbors(filter, SortOrder.OLDEST_FIRST, oldest.id), + ) + assertEquals( + expected = middle.id to null, + actual = neighbors(filter, SortOrder.OLDEST_FIRST, newest.id), + ) + } + + @Test + fun neighbors_byArticleStatus() { + val feed = FeedFixture(database).create() + val (newest, middle, oldest) = threeArticles(feed) + val filter = ArticleFilter.Articles(ArticleStatus.ALL) + + assertEquals( + expected = newest.id to oldest.id, + actual = neighbors(filter, SortOrder.NEWEST_FIRST, middle.id), + ) + } + + @Test + fun neighbors_bySavedSearch() { + val savedSearch = SavedSearchFixture(database).create() + val savedSearchRecords = SavedSearchRecords(database) + val feed = FeedFixture(database).create() + val (newest, middle, oldest) = threeArticles(feed) + + listOf(newest, middle, oldest).forEach { + savedSearchRecords.upsertArticle(articleID = it.id, savedSearchID = savedSearch.id) + } + + val filter = ArticleFilter.SavedSearches( + savedSearchID = savedSearch.id, + savedSearchStatus = ArticleStatus.ALL, + ) + + assertEquals( + expected = newest.id to oldest.id, + actual = neighbors(filter, SortOrder.NEWEST_FIRST, middle.id), + ) + assertEquals( + expected = oldest.id to newest.id, + actual = neighbors(filter, SortOrder.OLDEST_FIRST, middle.id), + ) + } + + private fun neighbors( + filter: ArticleFilter, + sortOrder: SortOrder, + articleID: String, + ): Pair = + articleRecords.neighbors( + filter = filter, + sortOrder = sortOrder, + since = null, + articleID = articleID, + ) + + /** Three articles in [feed], newest to oldest, one day apart. */ + private fun threeArticles(feed: Feed): Triple { + val start = nowUTC() + val newest = articleFixture.create( + feed = feed, + title = "newest", + publishedAt = start.toEpochSecond(), + ) + val middle = articleFixture.create( + feed = feed, + title = "middle", + publishedAt = start.minusDays(1).toEpochSecond(), + ) + val oldest = articleFixture.create( + feed = feed, + title = "oldest", + publishedAt = start.minusDays(2).toEpochSecond(), + ) + return Triple(newest, middle, oldest) + } } fun sortedMessage(expected: List
, actual: List
): String { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1e9dbb28f..13a6f764b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,11 +5,12 @@ coil = "3.4.0" coroutines = "1.10.2" glance-material3 = "1.2.0-rc01" kotlin = "2.3.20" -ksp = "2.2.20-2.0.4" +ksp = "2.3.9" androidx-lifecycle-runtime = "2.10.0" mockk = "1.14.9" mockwebserver3 = "5.3.2" navigation-compose = "2.9.7" +navigation3 = "1.1.2" okhttp = "5.3.2" retrofit = "3.0.0" paging-compose = "3.4.2" @@ -21,6 +22,7 @@ androidx-activity-compose = { module = "androidx.activity:activity-compose", ver androidx-adaptive = { module = "androidx.compose.material3.adaptive:adaptive" } androidx-adaptive-layout = { module = "androidx.compose.material3.adaptive:adaptive-layout" } androidx-adaptive-navigation = { module = "androidx.compose.material3.adaptive:adaptive-navigation" } +androidx-adaptive-navigation3 = { module = "androidx.compose.material3.adaptive:adaptive-navigation3", version = "1.3.0-beta02" } androidx-browser = { module = "androidx.browser:browser", version = "1.10.0" } androidx-compose-bom = { module = "androidx.compose:compose-bom", version = "2026.03.01" } androidx-core-ktx = { module = "androidx.core:core-ktx", version = "1.18.0" } @@ -30,6 +32,7 @@ androidx-glance-material3 = { module = "androidx.glance:glance-material3", versi androidx-glance-preview = { module = "androidx.glance:glance-preview", version.ref = "glance-material3" } androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle-runtime" } androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "androidx-lifecycle-runtime" } +androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "androidx-lifecycle-runtime" } androidx-material = { module = "com.google.android.material:material", version = "1.13.0" } androidx-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" } androidx-material3 = { module = "androidx.compose.material3:material3", version = "1.5.0-alpha17" } @@ -38,6 +41,8 @@ androidx-media3-datasource-okhttp = { module = "androidx.media3:media3-datasourc androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" } androidx-media3-session = { module = "androidx.media3:media3-session", version.ref = "media3" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation-compose" } +androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "navigation3" } +androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "navigation3" } androidx-palette = { module = "androidx.palette:palette-ktx", version = "1.0.0" } androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "paging-compose" } androidx-paging-runtime-ktx = { module = "androidx.paging:paging-runtime-ktx", version.ref = "androidx-paging" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 43f938008..3a5161c82 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,7 @@ #Fri Nov 01 17:50:12 CDT 2024 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionSha256Sum=bd71102213493060956ec229d946beee57158dbd89d0e62b91bca0fa2c5f3531 -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +distributionSha256Sum=b266d5ff6b90eada6dc3b20cb090e3731302e553a27c5d3e4df1f0d76beaff06 +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists