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