From 0f2f829a042470de637ee7cb993dd9fded234bc4 Mon Sep 17 00:00:00 2001 From: Josiah Campbell <9521010+jocmp@users.noreply.github.com> Date: Sun, 14 Jun 2026 14:00:02 -0500 Subject: [PATCH 01/24] Add Nav3 dependencies and route keys Foundation for the Navigation 3 migration: navigation3-runtime/ui 1.1.2 and lifecycle-viewmodel-navigation3 (tracking lifecycle 2.10.0). Route now implements NavKey; adds Route.ArticleList(filter) and Route.ArticleDetail(articleID). Nothing is wired up yet - the existing NavHost still drives the app. --- app/build.gradle.kts | 3 +++ app/src/main/java/com/capyreader/app/ui/Route.kt | 15 ++++++++++++++- gradle/libs.versions.toml | 4 ++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b93fd047b..524512a7d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -130,6 +130,7 @@ dependencies { 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 +139,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) 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..aea8d6321 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,11 @@ package com.capyreader.app.ui +import androidx.navigation3.runtime.NavKey +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 +17,15 @@ 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() } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1e9dbb28f..97f5b2d64 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,6 +10,7 @@ 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" @@ -30,6 +31,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 +40,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" } From 9952aa455f2278b1034fe9284717f7f5f92ff230 Mon Sep 17 00:00:00 2001 From: Josiah Campbell <9521010+jocmp@users.noreply.github.com> Date: Sun, 14 Jun 2026 14:05:40 -0500 Subject: [PATCH 02/24] Replace app NavHost with Nav3 NavDisplay Unifies the whole app under a single NavBackStack/NavDisplay. Account, login, settings, and articles become entry<> destinations; the old popUpTo/launchSingleTop transitions become explicit backstack resets. Adds rememberViewModelStoreNavEntryDecorator so each entry gets its own ViewModelStore (required for per-entry VMs). Login source now flows via Koin parametersOf instead of SavedStateHandle.toRoute. ArticleScreen is unchanged and still drives the list/detail panes internally - that split comes later. Compile-verified only; nav flows still need a runtime check. --- .../main/java/com/capyreader/app/ui/App.kt | 104 +++++++++++------- .../app/ui/accounts/AccountNavigation.kt | 33 ------ .../capyreader/app/ui/accounts/LoginModule.kt | 5 +- .../app/ui/accounts/LoginViewModel.kt | 6 +- .../app/ui/articles/ArticleNavigation.kt | 24 ---- 5 files changed, 69 insertions(+), 103 deletions(-) delete mode 100644 app/src/main/java/com/capyreader/app/ui/accounts/AccountNavigation.kt delete mode 100644 app/src/main/java/com/capyreader/app/ui/articles/ArticleNavigation.kt 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..3545d16a9 100644 --- a/app/src/main/java/com/capyreader/app/ui/App.kt +++ b/app/src/main/java/com/capyreader/app/ui/App.kt @@ -5,13 +5,22 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.rememberNavController +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.ArticleScreen +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 @Composable fun App( @@ -20,48 +29,65 @@ fun App( pendingArticleID: String? = null, onPendingArticleSelected: () -> Unit = {}, ) { - val navController = rememberNavController() + val backStack = rememberNavBackStack(startDestination) 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 - } - } - unloadAccountModules() + NavDisplay( + backStack = backStack, + onBack = { backStack.removeLastOrNull() }, + // Scope a ViewModelStore per NavEntry so each route instance gets its own + // ViewModel (e.g. Login per source, and the per-entry article VMs to come). + // Overriding entryDecorators replaces the defaults, so re-add the saveable one. + entryDecorators = listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator(), + ), + entryProvider = entryProvider { + entry { + AddAccountScreen( + onAddSuccess = { backStack.resetTo(Route.Articles) }, + onNavigateToLogin = { source -> backStack.add(Route.Login(source)) } + ) + } + entry { key -> + LoginScreen( + viewModel = koinViewModel { parametersOf(key.source) }, + onNavigateBack = { backStack.removeLastOrNull() }, + onSuccess = { backStack.resetTo(Route.Articles) }, + ) + } + entry { + SettingsScreen( + onRemoveAccount = { + backStack.resetTo(Route.AddAccount) + unloadAccountModules() + }, + onNavigateBack = { backStack.removeLastOrNull() } + ) } - ) - articleGraph( - navController = navController, - pendingArticleID = pendingArticleID, - onPendingArticleSelected = onPendingArticleSelected, - ) - } + entry { + ArticleScreen( + pendingArticleID = pendingArticleID, + onPendingArticleSelected = onPendingArticleSelected, + onNavigateToSettings = { backStack.add(Route.Settings) } + ) + } + } + ) } } } + +/** + * Replaces the entire back stack with [key]. Mirrors the previous + * `popUpTo(..) { inclusive = true }` + `launchSingleTop` navigation used for + * account add/remove transitions. + */ +private fun NavBackStack.resetTo(key: NavKey) { + clear() + add(key) +} 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/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 - } - } - ) - } -} From d257b288521b7e5bd102ab72a31b8f67a6718d7c Mon Sep 17 00:00:00 2001 From: Josiah Campbell <9521010+jocmp@users.noreply.github.com> Date: Sun, 14 Jun 2026 15:33:13 -0500 Subject: [PATCH 03/24] Upgrade to AGP 9.1 and compileSdk 37 Adopts the AGP 9 toolchain so we can use the official Material adaptive-navigation3 list-detail Scene (requires AGP 9.1+ / compileSdk 37) instead of hand-rolling one. - AGP 8.11.1 -> 9.1.1, Gradle 8.14.3 -> 9.3.1 (AGP 9.1 minimum) - compileSdk 36 -> 37 (targetSdk stays 36) - Built-in Kotlin: drop the org.jetbrains.kotlin.android plugin from :app and root (AGP 9 provides Kotlin compilation). JVM modules keep kotlin.jvm. - KSP 2.2.20-2.0.4 -> 2.3.9 (new decoupled KSP versioning required by AGP 9) - Replace internal AGP dependsOn extension with tasks.named API - Add androidx.compose.material3.adaptive:adaptive-navigation3 Verified: gradlew help, build --dry-run, assembleFreeDebug, and launches on emulator. --- app/build.gradle.kts | 7 +++---- build.gradle.kts | 5 ++--- gradle/libs.versions.toml | 3 ++- gradle/wrapper/gradle-wrapper.properties | 4 ++-- 4 files changed, 9 insertions(+), 10 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 524512a7d..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,6 +124,7 @@ 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) @@ -207,4 +206,4 @@ tasks.register("useGMSDebugFile") { } } -project.tasks.preBuild.dependsOn("useGMSDebugFile") +tasks.named("preBuild") { dependsOn("useGMSDebugFile") } 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/gradle/libs.versions.toml b/gradle/libs.versions.toml index 97f5b2d64..13a6f764b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,7 +5,7 @@ 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" @@ -22,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" } 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 From 2078d2b3d175823e3f6b9fefe368cd7d7e906fbf Mon Sep 17 00:00:00 2001 From: Josiah Campbell <9521010+jocmp@users.noreply.github.com> Date: Sun, 14 Jun 2026 15:49:00 -0500 Subject: [PATCH 04/24] Decouple ArticleView from the paging list ArticleView now takes previousArticleID/nextArticleID instead of the whole LazyPagingItems - the only thing it ever used the list for was resolving neighbors. Neighbor computation moves to the call site (ArticleScreen still works unchanged). This lets the reader live in its own Nav3 detail entry without sharing the list's pager. Also adds ArticleViewModel (detail VM): takes articleID via Koin parametersOf, resolves the article + full content on init. Wired in ArticlesModule; rendered by the upcoming Route.ArticleDetail entry. --- .../app/ui/articles/ArticleScreen.kt | 20 +- .../app/ui/articles/ArticleViewModel.kt | 218 ++++++++++++++++++ .../app/ui/articles/ArticlesModule.kt | 9 + .../app/ui/articles/detail/ArticleView.kt | 45 +--- 4 files changed, 251 insertions(+), 41 deletions(-) create mode 100644 app/src/main/java/com/capyreader/app/ui/articles/ArticleViewModel.kt 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..86ebdbd97 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 @@ -645,9 +645,24 @@ fun ArticleScreen( val isAudioPlaying by audioController.isPlaying.collectAsState() val currentAudio by audioController.currentAudio.collectAsState() + val index = remember(article.id, articles.itemCount) { + articles.itemSnapshotList.indexOfFirst { it?.id == article.id } + } + val previousArticleID = + if (index > 0) articles.itemSnapshotList.getOrNull(index - 1)?.id else null + val nextArticleID = + if (index > -1) articles.itemSnapshotList.getOrNull(index + 1)?.id else null + + LaunchedEffect(index) { + if (index > -1) { + scrollToArticle(index) + } + } + ArticleView( article = article, - articles = articles, + previousArticleID = previousArticleID, + nextArticleID = nextArticleID, onBackPressed = { clearArticle() }, @@ -668,9 +683,6 @@ fun ArticleScreen( onSelectArticle = { articleID -> setArticle(articleID) }, - onScrollToArticle = { index -> - scrollToArticle(index) - }, currentAudioUrl = currentAudio?.url, isAudioPlaying = isAudioPlaying, isFullscreen = paneExpansion.isFullscreen, 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..8290f214c --- /dev/null +++ b/app/src/main/java/com/capyreader/app/ui/articles/ArticleViewModel.kt @@ -0,0 +1,218 @@ +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.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 + * navigation argument (via Koin `parametersOf`), the article is resolved from the database on + * [init], and this ViewModel owns its own full-content fetch/parse lifecycle. + */ +class ArticleViewModel( + private val articleID: String, + private val account: Account, + private val appPreferences: AppPreferences, + private val application: Application, + private val notificationHelper: NotificationHelper, +) : AndroidViewModel(application) { + + private var fullContentJob: Job? = null + + var article by mutableStateOf(null) + private set + + val canSaveArticleExternally = account.canSaveArticleExternally.stateIn(viewModelScope) + + val source = account.source + + init { + loadArticle() + } + + private fun loadArticle() { + 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) } + } + } + } + + 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) + } + + 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..e23cc6d82 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 @@ -57,6 +57,15 @@ internal val articlesModule = module { application = get(), ) } + viewModel { (articleID: String) -> + ArticleViewModel( + articleID = articleID, + account = get(), + appPreferences = get(), + application = get(), + notificationHelper = get(), + ) + } viewModel { EditFeedViewModel( account = get(), 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 From 9334ca680440ae605d71bb052cd1f33037126b79 Mon Sep 17 00:00:00 2001 From: Josiah Campbell <9521010+jocmp@users.noreply.github.com> Date: Sun, 14 Jun 2026 17:06:09 -0500 Subject: [PATCH 05/24] Split list and detail into Nav3 entries Article selection is now navigation: tapping pushes Route.ArticleDetail(id) to the back stack, opening the reader in its own entry backed by ArticleViewModel. The list (ArticleScreen) and reader (ArticleDetailScreen) are independent screens. Two-pane on wide screens is handled by the Material adaptive ListDetailSceneStrategy in App.kt (listPane/detailPane metadata) rather than ArticleScreen's old internal ListDetailPaneScaffold; ArticleScaffold is now just the drawer. The list yields back to NavDisplay when a detail is open so back pops the detail rather than exiting. Removes the detail pane, scaffold navigator, pane expansion, and dead selection/media/back-handler code from ArticleScreen. Verified on emulator (tablet two-pane): list, tap-to-open with full content, and back all work. Known follow-ups: next/prev reader navigation passes null neighbors for now (Stage 4); custom pane drag-to-resize (ArticlePaneExpansion) and open-in-browser-on-tap are temporarily dropped. --- .../java/com/capyreader/app/MainActivity.kt | 2 +- .../main/java/com/capyreader/app/ui/App.kt | 62 +++++- .../app/ui/articles/ArticleDetailScreen.kt | 150 +++++++++++++++ .../app/ui/articles/ArticleScaffold.kt | 65 +------ .../app/ui/articles/ArticleScreen.kt | 178 ++---------------- .../app/ui/articles/ArticleViewModel.kt | 3 + 6 files changed, 226 insertions(+), 234 deletions(-) create mode 100644 app/src/main/java/com/capyreader/app/ui/articles/ArticleDetailScreen.kt diff --git a/app/src/main/java/com/capyreader/app/MainActivity.kt b/app/src/main/java/com/capyreader/app/MainActivity.kt index c9550f5d2..61bae566d 100644 --- a/app/src/main/java/com/capyreader/app/MainActivity.kt +++ b/app/src/main/java/com/capyreader/app/MainActivity.kt @@ -45,7 +45,7 @@ class MainActivity : BaseActivity() { return if (accountID.isBlank()) { Route.AddAccount } else { - Route.Articles + Route.ArticleList(appPreferences.filter.get()) } } } 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 3545d16a9..c2519a10b 100644 --- a/app/src/main/java/com/capyreader/app/ui/App.kt +++ b/app/src/main/java/com/capyreader/app/ui/App.kt @@ -3,8 +3,15 @@ package com.capyreader.app.ui import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface +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.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey @@ -15,13 +22,16 @@ import androidx.navigation3.ui.NavDisplay import com.capyreader.app.preferences.AppPreferences 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.detail.CapyPlaceholder 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, @@ -31,6 +41,13 @@ fun App( ) { val backStack = rememberNavBackStack(startDestination) + val windowAdaptiveInfo = currentWindowAdaptiveInfoV2() + val directive = remember(windowAdaptiveInfo) { + calculatePaneScaffoldDirective(windowAdaptiveInfo) + .copy(horizontalPartitionSpacerSize = 0.dp) + } + val listDetailStrategy = rememberListDetailSceneStrategy(directive = directive) + CapyTheme(appPreferences) { Surface( modifier = Modifier.fillMaxSize(), @@ -40,16 +57,16 @@ fun App( backStack = backStack, onBack = { backStack.removeLastOrNull() }, // Scope a ViewModelStore per NavEntry so each route instance gets its own - // ViewModel (e.g. Login per source, and the per-entry article VMs to come). - // Overriding entryDecorators replaces the defaults, so re-add the saveable one. + // ViewModel (Login per source, ArticleDetail per article id). entryDecorators = listOf( rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator(), ), + sceneStrategies = listOf(listDetailStrategy), entryProvider = entryProvider { entry { AddAccountScreen( - onAddSuccess = { backStack.resetTo(Route.Articles) }, + onAddSuccess = { backStack.resetToArticles(appPreferences) }, onNavigateToLogin = { source -> backStack.add(Route.Login(source)) } ) } @@ -57,7 +74,7 @@ fun App( LoginScreen( viewModel = koinViewModel { parametersOf(key.source) }, onNavigateBack = { backStack.removeLastOrNull() }, - onSuccess = { backStack.resetTo(Route.Articles) }, + onSuccess = { backStack.resetToArticles(appPreferences) }, ) } entry { @@ -69,11 +86,26 @@ fun App( onNavigateBack = { backStack.removeLastOrNull() } ) } - entry { + entry( + metadata = ListDetailSceneStrategy.listPane( + detailPlaceholder = { CapyPlaceholder() } + ) + ) { ArticleScreen( + onSelectArticle = { id -> backStack.openArticle(id) }, + onNavigateToSettings = { backStack.add(Route.Settings) }, + selectedArticleID = (backStack.lastOrNull() as? Route.ArticleDetail)?.articleID, pendingArticleID = pendingArticleID, onPendingArticleSelected = onPendingArticleSelected, - onNavigateToSettings = { backStack.add(Route.Settings) } + ) + } + entry( + metadata = ListDetailSceneStrategy.detailPane() + ) { key -> + ArticleDetailScreen( + articleID = key.articleID, + onBackPressed = { backStack.removeLastOrNull() }, + onSelectArticle = { id -> backStack.openArticle(id) }, ) } } @@ -83,11 +115,23 @@ fun App( } /** - * Replaces the entire back stack with [key]. Mirrors the previous - * `popUpTo(..) { inclusive = true }` + `launchSingleTop` navigation used for - * account add/remove transitions. + * 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/articles/ArticleDetailScreen.kt b/app/src/main/java/com/capyreader/app/ui/articles/ArticleDetailScreen.kt new file mode 100644 index 000000000..d8e53cd24 --- /dev/null +++ b/app/src/main/java/com/capyreader/app/ui/articles/ArticleDetailScreen.kt @@ -0,0 +1,150 @@ +package com.capyreader.app.ui.articles + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +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.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +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 +import org.koin.core.parameter.parametersOf + +/** + * 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, + previousArticleID: String? = null, + nextArticleID: String? = null, + viewModel: ArticleViewModel = koinViewModel { parametersOf(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() + var media by rememberSaveable(saver = Media.Saver) { mutableStateOf(null) } + + 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 = previousArticleID, + nextArticleID = nextArticleID, + onBackPressed = onBackPressed, + onToggleRead = viewModel::toggleArticleRead, + onToggleStar = viewModel::toggleArticleStar, + canSaveExternally = canSaveExternally, + onDeletePage = { + onBackPressed() + viewModel.deletePage(current.id) + }, + onSelectMedia = { media = it }, + onSelectAudio = { audio -> audioController.play(audio) }, + onPauseAudio = { audioController.pause() }, + onSelectArticle = onSelectArticle, + currentAudioUrl = currentAudio?.url, + isAudioPlaying = isAudioPlaying, + ) + + AnimatedVisibility( + enter = fadeIn(), + exit = fadeOut(), + visible = media != null, + ) { + com.capyreader.app.ui.articles.media.ArticleMediaView( + onDismissRequest = { media = null }, + media = media, + ) + } + + 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/ArticleScaffold.kt b/app/src/main/java/com/capyreader/app/ui/articles/ArticleScaffold.kt index 488ac73a2..df6754257 100644 --- a/app/src/main/java/com/capyreader/app/ui/articles/ArticleScaffold.kt +++ b/app/src/main/java/com/capyreader/app/ui/articles/ArticleScaffold.kt @@ -1,38 +1,24 @@ 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) +/** + * The article list pane wrapped in the navigation drawer. The list-detail two-pane layout is now + * handled at the navigation layer by the Nav3 list-detail Scene, so this only owns the drawer. + */ @Composable fun ArticleScaffold( drawerState: DrawerState = rememberDrawerState(DrawerValue.Closed), - scaffoldNavigator: ThreePaneScaffoldNavigator = rememberListDetailPaneScaffoldNavigator(), - paneExpansion: ArticlePaneExpansion = rememberArticlePaneExpansion(), drawerPane: @Composable () -> Unit, - listPane: @Composable () -> Unit, - detailPane: @Composable () -> Unit, + content: @Composable () -> Unit, ) { ModalNavigationDrawer( drawerState = drawerState, @@ -43,36 +29,10 @@ fun ArticleScaffold( } }, ) { - 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() - } - } - ) + content() } } -@OptIn(ExperimentalMaterial3AdaptiveApi::class) @Preview(device = "spec:width=1280dp,height=800dp,dpi=240") @Composable fun ArticlesLayoutPreview() { @@ -81,18 +41,9 @@ fun ArticlesLayoutPreview() { drawerPane = { Text("List here!") }, - listPane = { - Surface( - Modifier - .background(Color.Cyan) - .fillMaxSize() - ) { - Text("Index list here...") - } + content = { + 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 86ebdbd97..9aa373866 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 @@ -111,11 +111,13 @@ 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(), + selectedArticleID: String? = null, pendingArticleID: String? = null, onPendingArticleSelected: () -> Unit = {}, - onNavigateToSettings: () -> Unit, ) { val currentFeed by viewModel.currentFeed.collectAsStateWithLifecycle(initialValue = null) val feeds by viewModel.topLevelFeeds.collectAsStateWithLifecycle(initialValue = emptyList()) @@ -143,8 +145,6 @@ fun ArticleScreen( } val context = LocalContext.current - val canSaveExternally by viewModel.canSaveArticleExternally.collectAsStateWithLifecycle() - val fullContent = rememberFullContent(viewModel) val articleActions = rememberArticleActions(viewModel) val folderActions = rememberFolderActions(viewModel) @@ -173,8 +173,6 @@ fun ArticleScreen( ) } - val article = viewModel.article - val search = ArticleSearch( query = searchQuery, start = { viewModel.startSearch() }, @@ -214,13 +212,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 +222,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 @@ -299,7 +273,6 @@ fun ArticleScreen( suspend fun openNextStatus(action: suspend () -> Unit) { scope.launchIO { action() } - scaffoldNavigator.navigateTo(ListDetailPaneScaffoldRole.List) } fun markAllRead(range: MarkRead) { @@ -357,13 +330,6 @@ fun ArticleScreen( } } - fun clearArticle() { - coroutineScope.launchUI { - scaffoldNavigator.navigateTo(ListDetailPaneScaffoldRole.List) - } - viewModel.clearArticle() - } - val toggleDrawer = { coroutineScope.launch { if (drawerState.isOpen) { @@ -404,28 +370,11 @@ 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() - } - - val url = nextArticle.url - if (nextArticle.openInBrowser && url != null) { - clearArticle() - linkOpener.open(url.toString().toUri()) - } else { - coroutineScope.launch { - navigateToDetail() - } - } + if (search.isActive) { + focusManager.clearFocus() } + onSelectArticle(articleID) } val selectFilter = { @@ -480,8 +429,6 @@ fun ArticleScreen( ArticleScaffold( drawerState = drawerState, - scaffoldNavigator = scaffoldNavigator, - paneExpansion = paneExpansion, drawerPane = { FeedList( source = viewModel.source, @@ -513,7 +460,7 @@ fun ArticleScreen( todayCount = todayCount, ) }, - listPane = { + content = { val keyboardManager = LocalSoftwareKeyboardController.current val markReadPosition = LocalMarkAllReadButtonPosition.current @@ -613,7 +560,7 @@ fun ArticleScreen( } else { ArticleList( articles = articles, - selectedArticleKey = article?.id, + selectedArticleKey = selectedArticleID, listState = listState, enableMarkReadOnScroll = viewModel.markReadOnScrollEnabled, dimReadArticles = filter.status != ArticleStatus.STARRED, @@ -632,87 +579,8 @@ 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() - - val index = remember(article.id, articles.itemCount) { - articles.itemSnapshotList.indexOfFirst { it?.id == article.id } - } - val previousArticleID = - if (index > 0) articles.itemSnapshotList.getOrNull(index - 1)?.id else null - val nextArticleID = - if (index > -1) articles.itemSnapshotList.getOrNull(index + 1)?.id else null - - LaunchedEffect(index) { - if (index > -1) { - scrollToArticle(index) - } - } - - ArticleView( - article = article, - previousArticleID = previousArticleID, - nextArticleID = nextArticleID, - 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) - }, - 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( - enter = fadeIn(), - exit = fadeOut(), - visible = media != null - ) { - ArticleMediaView( - onDismissRequest = { - media = null - }, - media = media - ) - } - if (isMarkAllReadDialogOpen) { MarkAllReadDialog( @@ -761,24 +629,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() @@ -787,12 +647,6 @@ fun ArticleScreen( closeDrawer() } ) - - LayoutNavigationHandler( - enabled = article == null - ) { - scaffoldNavigator.navigateTo(ListDetailPaneScaffoldRole.List) - } } } @@ -898,16 +752,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/ArticleViewModel.kt b/app/src/main/java/com/capyreader/app/ui/articles/ArticleViewModel.kt index 8290f214c..87130389c 100644 --- a/app/src/main/java/com/capyreader/app/ui/articles/ArticleViewModel.kt +++ b/app/src/main/java/com/capyreader/app/ui/articles/ArticleViewModel.kt @@ -11,6 +11,7 @@ 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 @@ -42,6 +43,8 @@ class ArticleViewModel( val source = account.source + val savedSearches: Flow> = account.savedSearches + init { loadArticle() } From 8a2325836cc486aaf485abf9faef3453dca46f9c Mon Sep 17 00:00:00 2001 From: Josiah Campbell <9521010+jocmp@users.noreply.github.com> Date: Sun, 14 Jun 2026 20:05:41 -0500 Subject: [PATCH 06/24] Make the reader a persistent surface Give the Route.ArticleDetail entry a stable contentKey so next/previous (and tapping a different list article) swaps the article id without remounting the entry. ArticleViewModel now loads reactively via load(id) and keeps the previously shown article until the new one resolves, so the reader chrome (top/bottom bars) stays present and only the content area swaps. Verified on device: switching articles keeps the back stack at [list, detail] and does not recreate the ViewModel (vm_init=0). --- .../main/java/com/capyreader/app/ui/App.kt | 7 ++++++- .../app/ui/articles/ArticleDetailScreen.kt | 8 +++++-- .../app/ui/articles/ArticleViewModel.kt | 21 ++++++++++++------- .../app/ui/articles/ArticlesModule.kt | 3 +-- 4 files changed, 27 insertions(+), 12 deletions(-) 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 c2519a10b..a9fd6381a 100644 --- a/app/src/main/java/com/capyreader/app/ui/App.kt +++ b/app/src/main/java/com/capyreader/app/ui/App.kt @@ -100,7 +100,10 @@ fun App( ) } entry( - metadata = ListDetailSceneStrategy.detailPane() + // 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 }, + metadata = ListDetailSceneStrategy.detailPane(), ) { key -> ArticleDetailScreen( articleID = key.articleID, @@ -114,6 +117,8 @@ fun App( } } +private const val ARTICLE_DETAIL_CONTENT_KEY = "article_detail" + /** * 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. 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 index d8e53cd24..d378abb54 100644 --- a/app/src/main/java/com/capyreader/app/ui/articles/ArticleDetailScreen.kt +++ b/app/src/main/java/com/capyreader/app/ui/articles/ArticleDetailScreen.kt @@ -7,6 +7,7 @@ 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 @@ -29,7 +30,6 @@ import com.capyreader.app.ui.provideLinkOpener import com.capyreader.app.ui.rememberLocalConnectivity import org.koin.androidx.compose.koinViewModel import org.koin.compose.koinInject -import org.koin.core.parameter.parametersOf /** * Content of the [com.capyreader.app.ui.Route.ArticleDetail] entry. Renders the reader for a single @@ -43,8 +43,12 @@ fun ArticleDetailScreen( onSelectArticle: (id: String) -> Unit, previousArticleID: String? = null, nextArticleID: String? = null, - viewModel: ArticleViewModel = koinViewModel { parametersOf(articleID) }, + viewModel: ArticleViewModel = koinViewModel(), ) { + LaunchedEffect(articleID) { + viewModel.load(articleID) + } + val context = LocalContext.current val article = viewModel.article val canSaveExternally by viewModel.canSaveArticleExternally.collectAsStateWithLifecycle() 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 index 87130389c..f07d427eb 100644 --- a/app/src/main/java/com/capyreader/app/ui/articles/ArticleViewModel.kt +++ b/app/src/main/java/com/capyreader/app/ui/articles/ArticleViewModel.kt @@ -23,11 +23,11 @@ import kotlinx.coroutines.launch /** * Backs a single [com.capyreader.app.ui.Route.ArticleDetail] entry. The [articleID] arrives as a - * navigation argument (via Koin `parametersOf`), the article is resolved from the database on - * [init], and this ViewModel owns its own full-content fetch/parse lifecycle. + * 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 articleID: String, private val account: Account, private val appPreferences: AppPreferences, private val application: Application, @@ -36,6 +36,8 @@ class ArticleViewModel( private var fullContentJob: Job? = null + private var currentArticleID: String? = null + var article by mutableStateOf(null) private set @@ -45,11 +47,16 @@ class ArticleViewModel( val savedSearches: Flow> = account.savedSearches - init { - loadArticle() - } + /** + * 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 - private fun loadArticle() { viewModelScope.launchIO { val loaded = buildArticle(articleID) ?: return@launchIO article = loaded 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 e23cc6d82..efb239df2 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 @@ -57,9 +57,8 @@ internal val articlesModule = module { application = get(), ) } - viewModel { (articleID: String) -> + viewModel { ArticleViewModel( - articleID = articleID, account = get(), appPreferences = get(), application = get(), From ed87693953f5a8687d47d6ebff58cbd9bfa3e1a6 Mon Sep 17 00:00:00 2001 From: Josiah Campbell <9521010+jocmp@users.noreply.github.com> Date: Sun, 14 Jun 2026 21:33:36 -0500 Subject: [PATCH 07/24] Add reader next/prev via cursor neighbor query Reader prev/next navigation (replace-top) for the All/Unread filter, via a cursor DB query instead of holding a list: articleAfter/articleBefore in articlesByStatus.sq return the adjacent article id by (published_at, id) position, matching the list's order and membership. Anchor-based, so it's O(1) and works from a cold deep link (no list needed) - the property a shared pager can't give. ArticleSessionCutoff (Koin single) carries the read/unstar session cutoff, owned by the reading session (stamped when the reader opens its first article, cleared on close) so this-session reads stay pinned in the neighbor set and back-navigation works regardless of whether a list was loaded. Account.neighbors is suspend + withIOContext so the query never runs on the main thread. Real prev/next ids now flow into ArticleTransition, restoring the directional transition. Verified on device (All view): open a middle article -> correct prev/next; tap next -> advances and re-anchors (prev points back to where you came from); back stack stays [list, detail]. Follow-ups: neighbor queries for Feeds/Folders/SavedSearches/Today (currently return null -> next/prev disabled there); list writing the cutoff for exact parity when coming from a freshly-loaded list (currently reader-owned, sub-second drift). --- .../app/ui/articles/ArticleDetailScreen.kt | 6 +-- .../app/ui/articles/ArticleSessionCutoff.kt | 27 +++++++++++++ .../app/ui/articles/ArticleViewModel.kt | 26 ++++++++++++ .../app/ui/articles/ArticlesModule.kt | 2 + capy/src/main/java/com/jocmp/capy/Account.kt | 18 +++++++++ .../jocmp/capy/persistence/ArticleRecords.kt | 23 +++++++++++ .../persistence/articles/ByArticleStatus.kt | 31 ++++++++++++++ .../com/jocmp/capy/db/articlesByStatus.sq | 40 +++++++++++++++++++ 8 files changed, 169 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/com/capyreader/app/ui/articles/ArticleSessionCutoff.kt 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 index d378abb54..158c3b456 100644 --- a/app/src/main/java/com/capyreader/app/ui/articles/ArticleDetailScreen.kt +++ b/app/src/main/java/com/capyreader/app/ui/articles/ArticleDetailScreen.kt @@ -41,8 +41,6 @@ fun ArticleDetailScreen( articleID: String, onBackPressed: () -> Unit, onSelectArticle: (id: String) -> Unit, - previousArticleID: String? = null, - nextArticleID: String? = null, viewModel: ArticleViewModel = koinViewModel(), ) { LaunchedEffect(articleID) { @@ -109,8 +107,8 @@ fun ArticleDetailScreen( } else { ArticleView( article = current, - previousArticleID = previousArticleID, - nextArticleID = nextArticleID, + previousArticleID = viewModel.previousArticleID, + nextArticleID = viewModel.nextArticleID, onBackPressed = onBackPressed, onToggleRead = viewModel::toggleArticleRead, onToggleStar = viewModel::toggleArticleStar, 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..6c36bee3f --- /dev/null +++ b/app/src/main/java/com/capyreader/app/ui/articles/ArticleSessionCutoff.kt @@ -0,0 +1,27 @@ +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 cleared when the session ends. 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 + + fun startIfNeeded() { + if (value == null) { + value = OffsetDateTime.now() + } + } + + fun clear() { + 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 index f07d427eb..f876dfd05 100644 --- a/app/src/main/java/com/capyreader/app/ui/articles/ArticleViewModel.kt +++ b/app/src/main/java/com/capyreader/app/ui/articles/ArticleViewModel.kt @@ -32,6 +32,7 @@ class ArticleViewModel( private val appPreferences: AppPreferences, private val application: Application, private val notificationHelper: NotificationHelper, + private val articleCutoff: ArticleSessionCutoff, ) : AndroidViewModel(application) { private var fullContentJob: Job? = null @@ -41,6 +42,17 @@ class ArticleViewModel( var article by mutableStateOf(null) private set + var previousArticleID by mutableStateOf(null) + private set + + var nextArticleID by mutableStateOf(null) + private set + + override fun onCleared() { + articleCutoff.clear() + super.onCleared() + } + val canSaveArticleExternally = account.canSaveArticleExternally.stateIn(viewModelScope) val source = account.source @@ -56,6 +68,9 @@ class ArticleViewModel( 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.startIfNeeded() viewModelScope.launchIO { val loaded = buildArticle(articleID) ?: return@launchIO @@ -68,6 +83,17 @@ class ArticleViewModel( 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() { 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 efb239df2..55eb19b3c 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(), @@ -63,6 +64,7 @@ internal val articlesModule = module { appPreferences = get(), application = get(), notificationHelper = get(), + articleCutoff = get(), ) } viewModel { 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/persistence/ArticleRecords.kt b/capy/src/main/java/com/jocmp/capy/persistence/ArticleRecords.kt index 6056b201b..c8769b10c 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,29 @@ 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, + ) + + // TODO: byFeed / bySavedSearch / byToday neighbor queries + else -> null to null + } + } + 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..f0cc2ee37 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,37 @@ class ByArticleStatus(private val database: Database) { ) } + 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 previous = queries.articleBefore( + articleID = articleID, + read = read, + lastReadAt = mapLastRead(read, since), + starred = starred, + lastUnstarredAt = mapLastUnstarred(starred, since), + newestFirst = newestFirst, + ).executeAsOneOrNull() + + val next = queries.articleAfter( + articleID = articleID, + read = read, + lastReadAt = mapLastRead(read, since), + starred = starred, + lastUnstarredAt = mapLastUnstarred(starred, since), + newestFirst = newestFirst, + ).executeAsOneOrNull() + + return previous to next + } + fun maxArrivedAt(): Long? { return database.articlesQueries.lastUpdatedAt().executeAsOne().MAX } 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..356612fac 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,43 @@ AND ( OR (articles.published_at = before_articles.published_at AND articles.id >= :beforeArticleID) END ); + +articleAfter: +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 CASE WHEN :newestFirst + THEN articles.published_at < anchor.published_at OR (articles.published_at = anchor.published_at AND articles.id < anchor.id) + ELSE articles.published_at > anchor.published_at OR (articles.published_at = anchor.published_at AND articles.id > anchor.id) +END +ORDER BY + CASE WHEN :newestFirst THEN articles.published_at END DESC, + CASE WHEN :newestFirst THEN articles.id END DESC, + CASE WHEN :newestFirst THEN NULL ELSE articles.published_at END ASC, + CASE WHEN :newestFirst THEN NULL ELSE articles.id END ASC +LIMIT 1; + +articleBefore: +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 CASE WHEN :newestFirst + THEN articles.published_at > anchor.published_at OR (articles.published_at = anchor.published_at AND articles.id > anchor.id) + ELSE articles.published_at < anchor.published_at OR (articles.published_at = anchor.published_at AND articles.id < anchor.id) +END +ORDER BY + CASE WHEN :newestFirst THEN articles.published_at END ASC, + CASE WHEN :newestFirst THEN articles.id END ASC, + CASE WHEN :newestFirst THEN NULL ELSE articles.published_at END DESC, + CASE WHEN :newestFirst THEN NULL ELSE articles.id END DESC +LIMIT 1; From 95aac6d20824ef989972161f4ddde8f0772ad5c5 Mon Sep 17 00:00:00 2001 From: Josiah Campbell <9521010+jocmp@users.noreply.github.com> Date: Sun, 14 Jun 2026 21:44:34 -0500 Subject: [PATCH 08/24] Extend reader neighbors to feed/folder/saved-search/today Adds articleAfter/articleBefore cursor queries for byFeed and bySavedSearch, and a byToday neighbors path (reuses the byStatus queries with the today cutoff). ArticleRecords.neighbors now dispatches all five filter types, so next/prev works in Feeds, Folders, SavedSearches, and Today views - not just All/Unread. publishedSince was added back to the byStatus neighbor queries so Today can reuse them. All queries execute within Account.neighbors' withIOContext boundary (off the main thread). byStatus path runtime-verified earlier; the others use the identical proven cursor pattern with their own scoping (feedIDs+priorities / saved-search join / today cutoff) and are schema-validated by SQLDelight. Smoke-tested: no regression, no SQLite errors. --- .../jocmp/capy/persistence/ArticleRecords.kt | 34 +++++++++++++- .../persistence/articles/ByArticleStatus.kt | 3 ++ .../jocmp/capy/persistence/articles/ByFeed.kt | 39 ++++++++++++++++ .../persistence/articles/BySavedSearch.kt | 36 +++++++++++++++ .../capy/persistence/articles/ByToday.kt | 34 ++++++++++++++ .../com/jocmp/capy/db/articlesByFeed.sq | 44 +++++++++++++++++++ .../jocmp/capy/db/articlesBySavedSearch.sq | 44 +++++++++++++++++++ .../com/jocmp/capy/db/articlesByStatus.sq | 2 + 8 files changed, 234 insertions(+), 2 deletions(-) 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 c8769b10c..13e3936d0 100644 --- a/capy/src/main/java/com/jocmp/capy/persistence/ArticleRecords.kt +++ b/capy/src/main/java/com/jocmp/capy/persistence/ArticleRecords.kt @@ -338,8 +338,38 @@ class ArticleRecords( articleID = articleID, ) - // TODO: byFeed / bySavedSearch / byToday neighbor queries - else -> null to null + 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, + ) } } 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 f0cc2ee37..190ab5728 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 @@ -73,6 +73,7 @@ class ByArticleStatus(private val database: Database) { sortOrder: SortOrder, since: OffsetDateTime?, articleID: String, + publishedSince: Long? = null, ): Pair { val (read, starred) = status.toStatusPair val newestFirst = isNewestFirst(sortOrder) @@ -84,6 +85,7 @@ class ByArticleStatus(private val database: Database) { lastReadAt = mapLastRead(read, since), starred = starred, lastUnstarredAt = mapLastUnstarred(starred, since), + publishedSince = publishedSince, newestFirst = newestFirst, ).executeAsOneOrNull() @@ -93,6 +95,7 @@ class ByArticleStatus(private val database: Database) { lastReadAt = mapLastRead(read, since), starred = starred, lastUnstarredAt = mapLastUnstarred(starred, since), + publishedSince = publishedSince, newestFirst = newestFirst, ).executeAsOneOrNull() 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..5d3b2e964 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,45 @@ 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 previous = queries.articleBefore( + articleID = articleID, + feedIDs = feedIDs, + read = read, + lastReadAt = mapLastRead(read, since), + starred = starred, + lastUnstarredAt = mapLastUnstarred(starred, since), + publishedSince = null, + priorities = priority.inclusivePriorities, + newestFirst = newestFirst, + ).executeAsOneOrNull() + + val next = queries.articleAfter( + articleID = articleID, + feedIDs = feedIDs, + read = read, + lastReadAt = mapLastRead(read, since), + starred = starred, + lastUnstarredAt = mapLastUnstarred(starred, since), + publishedSince = null, + priorities = priority.inclusivePriorities, + newestFirst = newestFirst, + ).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..f06ea9560 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,42 @@ 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 previous = queries.articleBefore( + articleID = articleID, + savedSearchID = savedSearchID, + read = read, + lastReadAt = mapLastRead(read, since), + starred = starred, + lastUnstarredAt = mapLastUnstarred(starred, since), + publishedSince = null, + newestFirst = newestFirst, + ).executeAsOneOrNull() + + val next = queries.articleAfter( + articleID = articleID, + savedSearchID = savedSearchID, + read = read, + lastReadAt = mapLastRead(read, since), + starred = starred, + lastUnstarredAt = mapLastUnstarred(starred, since), + publishedSince = null, + newestFirst = newestFirst, + ).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..3acd1355f 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 @@ -85,6 +85,40 @@ class ByToday(private val database: Database) { ) } + 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() + + val previous = queries.articleBefore( + articleID = articleID, + read = read, + lastReadAt = mapLastRead(read, since), + starred = starred, + lastUnstarredAt = mapLastUnstarred(starred, since), + publishedSince = publishedSince, + newestFirst = newestFirst, + ).executeAsOneOrNull() + + val next = queries.articleAfter( + articleID = articleID, + read = read, + lastReadAt = mapLastRead(read, since), + starred = starred, + lastUnstarredAt = mapLastUnstarred(starred, since), + publishedSince = publishedSince, + newestFirst = newestFirst, + ).executeAsOneOrNull() + + return previous to next + } + private fun mapTodayStartDate(): Long { return OffsetDateTime.now().minusHours(24).toEpochSecond() } 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..7ce6fafea 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,47 @@ AND ( OR (articles.published_at = before_articles.published_at AND articles.id >= :beforeArticleID) END ); + +articleAfter: +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 CASE WHEN :newestFirst + THEN articles.published_at < anchor.published_at OR (articles.published_at = anchor.published_at AND articles.id < anchor.id) + ELSE articles.published_at > anchor.published_at OR (articles.published_at = anchor.published_at AND articles.id > anchor.id) +END +ORDER BY + CASE WHEN :newestFirst THEN articles.published_at END DESC, + CASE WHEN :newestFirst THEN articles.id END DESC, + CASE WHEN :newestFirst THEN NULL ELSE articles.published_at END ASC, + CASE WHEN :newestFirst THEN NULL ELSE articles.id END ASC +LIMIT 1; + +articleBefore: +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 CASE WHEN :newestFirst + THEN articles.published_at > anchor.published_at OR (articles.published_at = anchor.published_at AND articles.id > anchor.id) + ELSE articles.published_at < anchor.published_at OR (articles.published_at = anchor.published_at AND articles.id < anchor.id) +END +ORDER BY + CASE WHEN :newestFirst THEN articles.published_at END ASC, + CASE WHEN :newestFirst THEN articles.id END ASC, + CASE WHEN :newestFirst THEN NULL ELSE articles.published_at END DESC, + CASE WHEN :newestFirst THEN NULL ELSE articles.id END 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..ef01af56d 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,47 @@ AND ( OR (articles.published_at = before_articles.published_at AND articles.id >= :beforeArticleID) END ); + +articleAfter: +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 CASE WHEN :newestFirst + THEN articles.published_at < anchor.published_at OR (articles.published_at = anchor.published_at AND articles.id < anchor.id) + ELSE articles.published_at > anchor.published_at OR (articles.published_at = anchor.published_at AND articles.id > anchor.id) +END +ORDER BY + CASE WHEN :newestFirst THEN articles.published_at END DESC, + CASE WHEN :newestFirst THEN articles.id END DESC, + CASE WHEN :newestFirst THEN NULL ELSE articles.published_at END ASC, + CASE WHEN :newestFirst THEN NULL ELSE articles.id END ASC +LIMIT 1; + +articleBefore: +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 CASE WHEN :newestFirst + THEN articles.published_at > anchor.published_at OR (articles.published_at = anchor.published_at AND articles.id > anchor.id) + ELSE articles.published_at < anchor.published_at OR (articles.published_at = anchor.published_at AND articles.id < anchor.id) +END +ORDER BY + CASE WHEN :newestFirst THEN articles.published_at END ASC, + CASE WHEN :newestFirst THEN articles.id END ASC, + CASE WHEN :newestFirst THEN NULL ELSE articles.published_at END DESC, + CASE WHEN :newestFirst THEN NULL ELSE articles.id END 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 356612fac..80b5dcbd2 100644 --- a/capy/src/main/sqldelight/com/jocmp/capy/db/articlesByStatus.sq +++ b/capy/src/main/sqldelight/com/jocmp/capy/db/articlesByStatus.sq @@ -105,6 +105,7 @@ 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 CASE WHEN :newestFirst THEN articles.published_at < anchor.published_at OR (articles.published_at = anchor.published_at AND articles.id < anchor.id) ELSE articles.published_at > anchor.published_at OR (articles.published_at = anchor.published_at AND articles.id > anchor.id) @@ -125,6 +126,7 @@ 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 CASE WHEN :newestFirst THEN articles.published_at > anchor.published_at OR (articles.published_at = anchor.published_at AND articles.id > anchor.id) ELSE articles.published_at < anchor.published_at OR (articles.published_at = anchor.published_at AND articles.id < anchor.id) From fb40d4f3590f2717f93d626c76081f82365726d2 Mon Sep 17 00:00:00 2001 From: Josiah Campbell <9521010+jocmp@users.noreply.github.com> Date: Sun, 14 Jun 2026 21:47:39 -0500 Subject: [PATCH 09/24] Share the list's session cutoff with the reader The list now writes the shared ArticleSessionCutoff (the same value as its own articlesSince) whenever its session snapshot starts, and the reader reads it. So coming from a loaded list, the reader's neighbor pinning matches the list exactly - articles read this session stay navigable via next/prev, even after going back to the list and opening a different article. The reader only stamps the cutoff itself (start(), idempotent) as a fallback for cold deep links with no list session, and no longer clears it (the list owns its lifecycle and re-stamps on refresh/filter change). --- .../app/ui/articles/ArticleScreenViewModel.kt | 3 +++ .../app/ui/articles/ArticleSessionCutoff.kt | 16 +++++++++++----- .../app/ui/articles/ArticleViewModel.kt | 7 +------ .../capyreader/app/ui/articles/ArticlesModule.kt | 1 + 4 files changed, 16 insertions(+), 11 deletions(-) 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..a8e3f18a1 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 @@ -61,6 +61,7 @@ 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) { @@ -716,6 +717,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( 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 index 6c36bee3f..5cb7cc9b7 100644 --- a/app/src/main/java/com/capyreader/app/ui/articles/ArticleSessionCutoff.kt +++ b/app/src/main/java/com/capyreader/app/ui/articles/ArticleSessionCutoff.kt @@ -15,13 +15,19 @@ class ArticleSessionCutoff { var value: OffsetDateTime? = null private set - fun startIfNeeded() { + /** Written by the list when its session snapshot starts, so the reader's neighbors match it exactly. */ + fun set(value: OffsetDateTime) { + this.value = value + } + + /** + * Starts a session cutoff if one isn't already set — the fallback for cold deep links (no list + * session). Idempotent: it won't overwrite the list's cutoff, and repeated reader opens + * (next/previous) keep the same session start. + */ + fun start() { if (value == null) { value = OffsetDateTime.now() } } - - fun clear() { - 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 index f876dfd05..54b7e24b5 100644 --- a/app/src/main/java/com/capyreader/app/ui/articles/ArticleViewModel.kt +++ b/app/src/main/java/com/capyreader/app/ui/articles/ArticleViewModel.kt @@ -48,11 +48,6 @@ class ArticleViewModel( var nextArticleID by mutableStateOf(null) private set - override fun onCleared() { - articleCutoff.clear() - super.onCleared() - } - val canSaveArticleExternally = account.canSaveArticleExternally.stateIn(viewModelScope) val source = account.source @@ -70,7 +65,7 @@ class ArticleViewModel( 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.startIfNeeded() + articleCutoff.start() viewModelScope.launchIO { val loaded = buildArticle(articleID) ?: return@launchIO 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 55eb19b3c..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 @@ -56,6 +56,7 @@ internal val articlesModule = module { appPreferences = appPreferences, notificationHelper = get(), application = get(), + articleCutoff = get(), ) } viewModel { From 9473fcbc495b7fb6beb273f199d1ee405c480559 Mon Sep 17 00:00:00 2001 From: Josiah Campbell <9521010+jocmp@users.noreply.github.com> Date: Sun, 14 Jun 2026 21:56:04 -0500 Subject: [PATCH 10/24] Add capy:// deep link support MainActivity parses capy:// VIEW intents into a synthetic Nav3 back stack and seeds it (cold start) or replaces it via onNewIntent (warm start), following URL semantics: - capy://article/ -> [ArticleList(All), ArticleDetail(id)] - capy://article/?feedID= -> [ArticleList(feed), ArticleDetail(id)] - capy://articles -> [ArticleList(All)] - capy://articles/unread -> [ArticleList(Unread)] The article id is a path segment (percent-encoded by the caller, since ids are URLs; Uri decodes it). The deep link also syncs AppPreferences.filter to its list filter, so the list and the reader's neighbor query agree on siblings. Because rendering and neighbors are list-independent, a cold deep link opens the article AND supports next/prev immediately. Manifest gets a capy:// VIEW intent-filter on MainActivity (singleTask). The old widget/notification extras path still works in parallel; Stage 6 switches those to capy:// and removes the pendingArticleID trampoline. Verified on device: cold capy://article/, cold capy://articles/unread, and warm article link all open correctly, no crashes. --- app/src/main/AndroidManifest.xml | 8 +++ .../java/com/capyreader/app/MainActivity.kt | 24 +++++++- .../main/java/com/capyreader/app/ui/App.kt | 26 ++++++++- .../java/com/capyreader/app/ui/DeepLink.kt | 56 +++++++++++++++++++ 4 files changed, 110 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/com/capyreader/app/ui/DeepLink.kt 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 @@ + + + + + + + + (null) + private var deepLink by mutableStateOf?>(null) + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) pendingArticleID = NotificationHelper.openFromIntent(intent, appPreferences = appPreferences) + val startBackStack = DeepLink.parse(intent.data) ?: listOf(startDestination()) + applyListFilter(startBackStack) setContent { App( - startDestination = startDestination(), + startBackStack = startBackStack, appPreferences = appPreferences, + deepLink = deepLink, + onDeepLinkConsumed = { deepLink = null }, pendingArticleID = pendingArticleID, onPendingArticleSelected = { pendingArticleID = null }, ) @@ -34,9 +42,23 @@ class MainActivity : BaseActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) + DeepLink.parse(intent.data)?.let { parsed -> + applyListFilter(parsed) + deepLink = parsed + } pendingArticleID = NotificationHelper.openFromIntent(intent, appPreferences = appPreferences) } + /** + * 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) + } + } + private fun startDestination(): Route { val appPreferences = get() 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 a9fd6381a..9fbcb5ae6 100644 --- a/app/src/main/java/com/capyreader/app/ui/App.kt +++ b/app/src/main/java/com/capyreader/app/ui/App.kt @@ -1,5 +1,6 @@ package com.capyreader.app.ui +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -9,7 +10,9 @@ 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.LaunchedEffect import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator @@ -34,12 +37,22 @@ import org.koin.core.parameter.parametersOf @OptIn(ExperimentalMaterial3AdaptiveApi::class) @Composable fun App( - startDestination: Route, + startBackStack: List, appPreferences: AppPreferences, + deepLink: List? = null, + onDeepLinkConsumed: () -> Unit = {}, pendingArticleID: String? = null, onPendingArticleSelected: () -> Unit = {}, ) { - val backStack = rememberNavBackStack(startDestination) + 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) { @@ -88,7 +101,14 @@ fun App( } entry( metadata = ListDetailSceneStrategy.listPane( - detailPlaceholder = { CapyPlaceholder() } + detailPlaceholder = { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CapyPlaceholder() + } + } ) ) { ArticleScreen( 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..b2b25b388 --- /dev/null +++ b/app/src/main/java/com/capyreader/app/ui/DeepLink.kt @@ -0,0 +1,56 @@ +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" + + fun parse(uri: Uri?): 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 != null) { + Route.ArticleList( + ArticleFilter.Feeds( + feedID = feedID, + folderTitle = null, + feedStatus = ArticleStatus.UNREAD, + ) + ) + } 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 + } + } +} From c73f20eac5a7b9580339611b97816ad19ecd3a39 Mon Sep 17 00:00:00 2001 From: Josiah Campbell <9521010+jocmp@users.noreply.github.com> Date: Mon, 15 Jun 2026 09:03:04 -0500 Subject: [PATCH 11/24] Restore pane drag handle, close icon, center placeholder The list-detail Scene supports pane resizing natively via rememberListDetailSceneStrategy's paneExpansionDragHandle, so wire a VerticalDragHandle there (resolved through the public ThreePaneScaffoldScope.paneExpansionDraggable extension). With resize back on the handle, the reader's redundant fullscreen-toggle arrow becomes the close 'X' always - ArticleNavigationIcon is now close-only, and the dead isFullscreen/onToggleFullscreen plumbing is removed from ArticleView/ArticleTopBar (it was a no-op stub after the split dropped ArticlePaneExpansion). Also centers the detail-pane empty-state placeholder (was top-aligned). Verified on tablet: 'Pane expansion drag handle' renders between panes, the reader shows the X close icon, and the placeholder is centered. --- .../main/java/com/capyreader/app/ui/App.kt | 18 ++++++++- .../articles/detail/ArticleNavigationIcon.kt | 37 +++++-------------- .../app/ui/articles/detail/ArticleTopBar.kt | 8 +--- .../app/ui/articles/detail/ArticleView.kt | 4 -- 4 files changed, 28 insertions(+), 39 deletions(-) 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 9fbcb5ae6..7f0231fb4 100644 --- a/app/src/main/java/com/capyreader/app/ui/App.kt +++ b/app/src/main/java/com/capyreader/app/ui/App.kt @@ -1,9 +1,12 @@ package com.capyreader.app.ui +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.LocalMinimumInteractiveComponentSize import androidx.compose.material3.MaterialTheme 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 @@ -59,7 +62,20 @@ fun App( calculatePaneScaffoldDirective(windowAdaptiveInfo) .copy(horizontalPartitionSpacerSize = 0.dp) } - val listDetailStrategy = rememberListDetailSceneStrategy(directive = directive) + val listDetailStrategy = rememberListDetailSceneStrategy( + directive = directive, + paneExpansionDragHandle = { state -> + val interactionSource = remember { MutableInteractionSource() } + VerticalDragHandle( + modifier = Modifier.paneExpansionDraggable( + state, + LocalMinimumInteractiveComponentSize.current, + interactionSource, + ), + interactionSource = interactionSource, + ) + }, + ) CapyTheme(appPreferences) { Surface( diff --git a/app/src/main/java/com/capyreader/app/ui/articles/detail/ArticleNavigationIcon.kt b/app/src/main/java/com/capyreader/app/ui/articles/detail/ArticleNavigationIcon.kt index 7a58aff7d..1a4137c4c 100644 --- a/app/src/main/java/com/capyreader/app/ui/articles/detail/ArticleNavigationIcon.kt +++ b/app/src/main/java/com/capyreader/app/ui/articles/detail/ArticleNavigationIcon.kt @@ -2,42 +2,25 @@ package com.capyreader.app.ui.articles.detail import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Close -import androidx.compose.material.icons.rounded.CloseFullscreen -import androidx.compose.material.icons.rounded.OpenInFull import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.Preview -import com.capyreader.app.ui.isCompact import com.capyreader.app.ui.theme.CapyTheme +/** + * The reader's leading action is always close ("X"): pane resizing is handled natively by the + * list-detail Scene's drag handle, so the old fullscreen-toggle arrow is gone. + */ @Composable fun ArticleNavigationIcon( - isFullscreen: Boolean = false, - onToggleFullscreen: () -> Unit = {}, onClose: () -> Unit, ) { - if (isCompact()) { - IconButton(onClick = onClose) { - Icon( - imageVector = Icons.Rounded.Close, - contentDescription = null - ) - } - } else if (isFullscreen) { - IconButton(onClick = onToggleFullscreen) { - Icon( - imageVector = Icons.Rounded.CloseFullscreen, - contentDescription = null - ) - } - } else { - IconButton(onClick = onToggleFullscreen) { - Icon( - imageVector = Icons.Rounded.OpenInFull, - contentDescription = null - ) - } + IconButton(onClick = onClose) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = null + ) } } @@ -45,6 +28,6 @@ fun ArticleNavigationIcon( @Composable private fun ArticleNavigationIconPreview() { CapyTheme { - ArticleNavigationIcon(isFullscreen = true) { } + ArticleNavigationIcon { } } } diff --git a/app/src/main/java/com/capyreader/app/ui/articles/detail/ArticleTopBar.kt b/app/src/main/java/com/capyreader/app/ui/articles/detail/ArticleTopBar.kt index 2c837e155..25cbef320 100644 --- a/app/src/main/java/com/capyreader/app/ui/articles/detail/ArticleTopBar.kt +++ b/app/src/main/java/com/capyreader/app/ui/articles/detail/ArticleTopBar.kt @@ -61,8 +61,6 @@ fun ArticleTopBar( canDeletePage: Boolean = false, canSaveExternally: Boolean = false, onDeletePage: () -> Unit = {}, - isFullscreen: Boolean = false, - onToggleFullscreen: () -> Unit = {}, onClose: () -> Unit, ) { val containerColor = MaterialTheme.colorScheme.surface @@ -95,11 +93,7 @@ fun ArticleTopBar( ) { TopAppBar( navigationIcon = { - ArticleNavigationIcon( - isFullscreen = isFullscreen, - onToggleFullscreen = onToggleFullscreen, - onClose = onClose, - ) + ArticleNavigationIcon(onClose = onClose) }, title = {}, actions = { 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 57d37cf35..e753cf613 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 @@ -62,8 +62,6 @@ fun ArticleView( onPauseAudio: () -> Unit = {}, currentAudioUrl: String? = null, isAudioPlaying: Boolean = false, - isFullscreen: Boolean = false, - onToggleFullscreen: () -> Unit = {}, appPreferences: AppPreferences = koinInject() ) { val enableHorizontalPager by appPreferences.readerOptions.enableHorizontaPagination.collectChangesWithDefault() @@ -163,8 +161,6 @@ fun ArticleView( canDeletePage = article.isReadLater, canSaveExternally = canSaveExternally, onDeletePage = onDeletePage, - isFullscreen = isFullscreen, - onToggleFullscreen = onToggleFullscreen, onClose = onBackPressed, ) From 8b06235cb23276f59fa02c0059e4d8e3ff5f7b78 Mon Sep 17 00:00:00 2001 From: Josiah Campbell <9521010+jocmp@users.noreply.github.com> Date: Mon, 15 Jun 2026 09:24:29 -0500 Subject: [PATCH 12/24] Drive pane fullscreen toggle from reader top bar Restores the expand/collapse pane toggle the old hand-rolled scaffold had, now backed by the list-detail Scene's PaneExpansionState. App creates one ArticlePaneExpansion, shares its state with both the Scene's drag handle and (via LocalArticlePaneExpansion) the reader's top bar, so the VerticalDragHandle and the toolbar toggle manipulate the same anchors. ArticleNavigationIcon keeps its original three-branch behavior: compact -> close 'X'; medium+ split -> OpenInFull (expand); medium+ fullscreen -> CloseFullscreen (collapse). This reverts the always-'X' change while keeping the drag handle and centered placeholder. Verified on a foldable (opened, 2076px): split shows the expand arrows, tapping collapses the list to a fullscreen reader and flips the icon, and tapping again restores the saved split. --- .../main/java/com/capyreader/app/ui/App.kt | 7 ++++ .../app/ui/articles/ArticleDetailScreen.kt | 4 ++ .../app/ui/articles/ArticlePaneExpansion.kt | 8 ++++ .../articles/detail/ArticleNavigationIcon.kt | 37 ++++++++++++++----- .../app/ui/articles/detail/ArticleTopBar.kt | 8 +++- .../app/ui/articles/detail/ArticleView.kt | 4 ++ 6 files changed, 57 insertions(+), 11 deletions(-) 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 7f0231fb4..da66659c5 100644 --- a/app/src/main/java/com/capyreader/app/ui/App.kt +++ b/app/src/main/java/com/capyreader/app/ui/App.kt @@ -13,6 +13,7 @@ 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.remember import androidx.compose.ui.Alignment @@ -30,7 +31,9 @@ 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.rememberArticlePaneExpansion import com.capyreader.app.ui.settings.SettingsScreen import com.capyreader.app.ui.theme.CapyTheme import com.capyreader.app.unloadAccountModules @@ -62,8 +65,10 @@ fun App( calculatePaneScaffoldDirective(windowAdaptiveInfo) .copy(horizontalPartitionSpacerSize = 0.dp) } + val paneExpansion = rememberArticlePaneExpansion() val listDetailStrategy = rememberListDetailSceneStrategy( directive = directive, + paneExpansionState = paneExpansion.state, paneExpansionDragHandle = { state -> val interactionSource = remember { MutableInteractionSource() } VerticalDragHandle( @@ -82,6 +87,7 @@ fun App( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background ) { + CompositionLocalProvider(LocalArticlePaneExpansion provides paneExpansion) { NavDisplay( backStack = backStack, onBack = { backStack.removeLastOrNull() }, @@ -149,6 +155,7 @@ fun App( } } ) + } } } } 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 index 158c3b456..ece483f6b 100644 --- a/app/src/main/java/com/capyreader/app/ui/articles/ArticleDetailScreen.kt +++ b/app/src/main/java/com/capyreader/app/ui/articles/ArticleDetailScreen.kt @@ -88,6 +88,8 @@ fun ArticleDetailScreen( val currentAudio by audioController.currentAudio.collectAsState() var media by rememberSaveable(saver = Media.Saver) { mutableStateOf(null) } + val paneExpansion = LocalArticlePaneExpansion.current + CompositionLocalProvider( LocalFullContent provides fullContent, LocalArticleActions provides articleActions, @@ -123,6 +125,8 @@ fun ArticleDetailScreen( onSelectArticle = onSelectArticle, currentAudioUrl = currentAudio?.url, isAudioPlaying = isAudioPlaying, + isFullscreen = paneExpansion?.isFullscreen ?: false, + onToggleFullscreen = { paneExpansion?.toggleFullscreen() }, ) AnimatedVisibility( 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/detail/ArticleNavigationIcon.kt b/app/src/main/java/com/capyreader/app/ui/articles/detail/ArticleNavigationIcon.kt index 1a4137c4c..7a58aff7d 100644 --- a/app/src/main/java/com/capyreader/app/ui/articles/detail/ArticleNavigationIcon.kt +++ b/app/src/main/java/com/capyreader/app/ui/articles/detail/ArticleNavigationIcon.kt @@ -2,25 +2,42 @@ package com.capyreader.app.ui.articles.detail import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.CloseFullscreen +import androidx.compose.material.icons.rounded.OpenInFull import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.Preview +import com.capyreader.app.ui.isCompact import com.capyreader.app.ui.theme.CapyTheme -/** - * The reader's leading action is always close ("X"): pane resizing is handled natively by the - * list-detail Scene's drag handle, so the old fullscreen-toggle arrow is gone. - */ @Composable fun ArticleNavigationIcon( + isFullscreen: Boolean = false, + onToggleFullscreen: () -> Unit = {}, onClose: () -> Unit, ) { - IconButton(onClick = onClose) { - Icon( - imageVector = Icons.Rounded.Close, - contentDescription = null - ) + if (isCompact()) { + IconButton(onClick = onClose) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = null + ) + } + } else if (isFullscreen) { + IconButton(onClick = onToggleFullscreen) { + Icon( + imageVector = Icons.Rounded.CloseFullscreen, + contentDescription = null + ) + } + } else { + IconButton(onClick = onToggleFullscreen) { + Icon( + imageVector = Icons.Rounded.OpenInFull, + contentDescription = null + ) + } } } @@ -28,6 +45,6 @@ fun ArticleNavigationIcon( @Composable private fun ArticleNavigationIconPreview() { CapyTheme { - ArticleNavigationIcon { } + ArticleNavigationIcon(isFullscreen = true) { } } } diff --git a/app/src/main/java/com/capyreader/app/ui/articles/detail/ArticleTopBar.kt b/app/src/main/java/com/capyreader/app/ui/articles/detail/ArticleTopBar.kt index 25cbef320..2c837e155 100644 --- a/app/src/main/java/com/capyreader/app/ui/articles/detail/ArticleTopBar.kt +++ b/app/src/main/java/com/capyreader/app/ui/articles/detail/ArticleTopBar.kt @@ -61,6 +61,8 @@ fun ArticleTopBar( canDeletePage: Boolean = false, canSaveExternally: Boolean = false, onDeletePage: () -> Unit = {}, + isFullscreen: Boolean = false, + onToggleFullscreen: () -> Unit = {}, onClose: () -> Unit, ) { val containerColor = MaterialTheme.colorScheme.surface @@ -93,7 +95,11 @@ fun ArticleTopBar( ) { TopAppBar( navigationIcon = { - ArticleNavigationIcon(onClose = onClose) + ArticleNavigationIcon( + isFullscreen = isFullscreen, + onToggleFullscreen = onToggleFullscreen, + onClose = onClose, + ) }, title = {}, actions = { 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 e753cf613..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 @@ -62,6 +62,8 @@ fun ArticleView( onPauseAudio: () -> Unit = {}, currentAudioUrl: String? = null, isAudioPlaying: Boolean = false, + isFullscreen: Boolean = false, + onToggleFullscreen: () -> Unit = {}, appPreferences: AppPreferences = koinInject() ) { val enableHorizontalPager by appPreferences.readerOptions.enableHorizontaPagination.collectChangesWithDefault() @@ -161,6 +163,8 @@ fun ArticleView( canDeletePage = article.isReadLater, canSaveExternally = canSaveExternally, onDeletePage = onDeletePage, + isFullscreen = isFullscreen, + onToggleFullscreen = onToggleFullscreen, onClose = onBackPressed, ) From ba01260000290b8c81fb6ce4c1730c3d8a920d75 Mon Sep 17 00:00:00 2001 From: Josiah Campbell <9521010+jocmp@users.noreply.github.com> Date: Mon, 15 Jun 2026 10:14:49 -0500 Subject: [PATCH 13/24] Inset drag handle from edge when pane collapsed At 0% the handle sat on the screen edge with half its touch target off-screen and was hard to grab. Animate a 16dp inset into the detail pane while the detail is fullscreen so the full grab area stays reachable to drag the list back out. --- .../main/java/com/capyreader/app/ui/App.kt | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) 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 da66659c5..42fd7fac6 100644 --- a/app/src/main/java/com/capyreader/app/ui/App.kt +++ b/app/src/main/java/com/capyreader/app/ui/App.kt @@ -1,8 +1,10 @@ package com.capyreader.app.ui +import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset import androidx.compose.material3.LocalMinimumInteractiveComponentSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -15,6 +17,7 @@ import androidx.compose.material3.adaptive.navigation3.rememberListDetailSceneSt import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -71,12 +74,20 @@ fun App( paneExpansionState = paneExpansion.state, paneExpansionDragHandle = { state -> val interactionSource = remember { MutableInteractionSource() } + // When collapsed to 0% the handle sits on the screen edge with half its touch target + // off-screen; inset it into the detail pane so it stays graspable to drag the list back. + val edgeInset by animateDpAsState( + targetValue = if (paneExpansion.isFullscreen) HandleEdgeInset else 0.dp, + label = "dragHandleInset", + ) VerticalDragHandle( - modifier = Modifier.paneExpansionDraggable( - state, - LocalMinimumInteractiveComponentSize.current, - interactionSource, - ), + modifier = Modifier + .offset(x = edgeInset) + .paneExpansionDraggable( + state, + LocalMinimumInteractiveComponentSize.current, + interactionSource, + ), interactionSource = interactionSource, ) }, @@ -162,6 +173,9 @@ fun App( private const val ARTICLE_DETAIL_CONTENT_KEY = "article_detail" +/** How far to inset the drag handle from the screen edge when the detail pane is fullscreen. */ +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. From c52bc9d625e34a60c21bfcc3319074a5df62ed22 Mon Sep 17 00:00:00 2001 From: Josiah Campbell <9521010+jocmp@users.noreply.github.com> Date: Mon, 15 Jun 2026 10:23:38 -0500 Subject: [PATCH 14/24] Replace pendingArticleID trampoline with deep links Widgets and notifications now launch MainActivity with a capy:// ACTION_VIEW intent instead of stashing article/filter extras that App replayed through a pendingArticleID LaunchedEffect. DeepLink gains articleUri()/articlesUri() builders so the same object both builds and parses the links. Removes: NotificationHelper.openFromIntent and its UNREAD_ONLY/SHOW_ALL/FEED_ID extras; pendingArticleID + onPendingArticleSelected across MainActivity, App, and ArticleScreen; and the trampoline LaunchedEffect. The list filter is now set from the parsed Route.ArticleList in MainActivity.applyListFilter. Verified on device: capy://articles, capy://articles/unread, and capy://article/?feedID= each route to the right list/reader. --- .../java/com/capyreader/app/MainActivity.kt | 7 --- .../app/notifications/NotificationHelper.kt | 59 +++---------------- .../main/java/com/capyreader/app/ui/App.kt | 4 -- .../java/com/capyreader/app/ui/DeepLink.kt | 20 +++++++ .../app/ui/articles/ArticleScreen.kt | 8 --- .../app/ui/widget/ArticleHeadline.kt | 26 +++----- .../app/ui/widget/HeadlinesLayout.kt | 11 +++- .../app/ui/widget/SpotlightLayout.kt | 23 +++++--- 8 files changed, 56 insertions(+), 102 deletions(-) diff --git a/app/src/main/java/com/capyreader/app/MainActivity.kt b/app/src/main/java/com/capyreader/app/MainActivity.kt index 6349957ea..d0f7ac541 100644 --- a/app/src/main/java/com/capyreader/app/MainActivity.kt +++ b/app/src/main/java/com/capyreader/app/MainActivity.kt @@ -7,7 +7,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.navigation3.runtime.NavKey -import com.capyreader.app.notifications.NotificationHelper import com.capyreader.app.preferences.AppPreferences import com.capyreader.app.ui.App import com.capyreader.app.ui.DeepLink @@ -18,13 +17,10 @@ import org.koin.android.ext.android.inject class MainActivity : BaseActivity() { val appPreferences by inject() - 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 = DeepLink.parse(intent.data) ?: listOf(startDestination()) applyListFilter(startBackStack) @@ -34,8 +30,6 @@ class MainActivity : BaseActivity() { appPreferences = appPreferences, deepLink = deepLink, onDeepLinkConsumed = { deepLink = null }, - pendingArticleID = pendingArticleID, - onPendingArticleSelected = { pendingArticleID = null }, ) } } @@ -46,7 +40,6 @@ class MainActivity : BaseActivity() { applyListFilter(parsed) deepLink = parsed } - pendingArticleID = NotificationHelper.openFromIntent(intent, appPreferences = appPreferences) } /** 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..2b4bf2445 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,15 @@ 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.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 +130,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 +146,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 +158,13 @@ private fun NotificationManagerCompat.tryNotify(id: Int, notification: Notificat } private fun ArticleNotification.contentIntent(context: Context): PendingIntent { - val notifyIntent = Intent(context, MainActivity::class.java).apply { + val notifyIntent = 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 - putExtra(ARTICLE_ID_KEY, articleID) - putExtra(FEED_ID_KEY, feedID) } 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 42fd7fac6..844daeab7 100644 --- a/app/src/main/java/com/capyreader/app/ui/App.kt +++ b/app/src/main/java/com/capyreader/app/ui/App.kt @@ -50,8 +50,6 @@ fun App( appPreferences: AppPreferences, deepLink: List? = null, onDeepLinkConsumed: () -> Unit = {}, - pendingArticleID: String? = null, - onPendingArticleSelected: () -> Unit = {}, ) { val backStack = rememberNavBackStack(*startBackStack.toTypedArray()) @@ -148,8 +146,6 @@ fun App( onSelectArticle = { id -> backStack.openArticle(id) }, onNavigateToSettings = { backStack.add(Route.Settings) }, selectedArticleID = (backStack.lastOrNull() as? Route.ArticleDetail)?.articleID, - pendingArticleID = pendingArticleID, - onPendingArticleSelected = onPendingArticleSelected, ) } entry( diff --git a/app/src/main/java/com/capyreader/app/ui/DeepLink.kt b/app/src/main/java/com/capyreader/app/ui/DeepLink.kt index b2b25b388..6c6e691b8 100644 --- a/app/src/main/java/com/capyreader/app/ui/DeepLink.kt +++ b/app/src/main/java/com/capyreader/app/ui/DeepLink.kt @@ -21,6 +21,26 @@ import com.jocmp.capy.ArticleStatus 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() + fun parse(uri: Uri?): List? { if (uri?.scheme != SCHEME) return null 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 9aa373866..90f83811c 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 @@ -116,8 +116,6 @@ fun ArticleScreen( viewModel: ArticleScreenViewModel = koinViewModel(), appPreferences: AppPreferences = koinInject(), selectedArticleID: String? = null, - pendingArticleID: String? = null, - onPendingArticleSelected: () -> Unit = {}, ) { val currentFeed by viewModel.currentFeed.collectAsStateWithLifecycle(initialValue = null) val feeds by viewModel.topLevelFeeds.collectAsStateWithLifecycle(initialValue = emptyList()) @@ -421,12 +419,6 @@ fun ArticleScreen( } } - LaunchedEffect(pendingArticleID) { - val id = pendingArticleID ?: return@LaunchedEffect - onPendingArticleSelected() - selectArticle(id) - } - ArticleScaffold( drawerState = drawerState, drawerPane = { 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 } ) From 1a8398ec03cb6606b8cb5440808c72ce03e6d726 Mon Sep 17 00:00:00 2001 From: Josiah Campbell <9521010+jocmp@users.noreply.github.com> Date: Mon, 15 Jun 2026 10:33:06 -0500 Subject: [PATCH 15/24] Use shared-axis-X for list/detail nav on phone On a single pane the list <-> detail navigation is a scene change, which NavDisplay was cross-fading. Restore the pane scaffold's original shared-axis-X motion (10% offset slide + fade) by attaching transitionSpec/popTransitionSpec/predictivePopTransitionSpec to the ArticleDetail entry. rememberListDetailSceneStrategy defaults shouldHandleSinglePaneLayout=false, so on compact it declines and the single-pane scene forwards this entry metadata; on tablet both panes live in one scene, so the spec never fires there and the cross-fade default still applies to login/settings as before. --- .../main/java/com/capyreader/app/ui/App.kt | 36 ++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) 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 844daeab7..4d534a6b4 100644 --- a/app/src/main/java/com/capyreader/app/ui/App.kt +++ b/app/src/main/java/com/capyreader/app/ui/App.kt @@ -1,6 +1,7 @@ package com.capyreader.app.ui import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.togetherWith import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize @@ -37,6 +38,8 @@ 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.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 @@ -152,7 +155,23 @@ fun App( // 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 }, - metadata = ListDetailSceneStrategy.detailPane(), + // 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, @@ -169,6 +188,21 @@ fun App( 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 + }) + /** How far to inset the drag handle from the screen edge when the detail pane is fullscreen. */ private val HandleEdgeInset = 16.dp From fd5f0700357eda769e1f3c25d9650de8ba435cdb Mon Sep 17 00:00:00 2001 From: Josiah Campbell <9521010+jocmp@users.noreply.github.com> Date: Mon, 15 Jun 2026 11:39:06 -0500 Subject: [PATCH 16/24] Extract search into its own overlay Search was folded into the list's article pager, so an active search mutated the very list the reader derives next/previous neighbors from. Split it out: - The list pager (articles) is now filter-only; a separate searchResults pager carries the query. combine() emits a plain ArticlePagerKey and one flatMapLatest builds the pager, dropping the Flow> flatten. - Search runs in a full-surface SearchView overlay (its own field + results) shown while active; the list/reader underneath keep the base filter, so neighbors stay correct. Selecting a result closes the overlay and opens the reader. - ArticleListTopBar drops the inline search field (now just launches the overlay). Verified on tablet: search filters in the overlay, the list underneath stays on the base filter, and selecting a result opens that article. --- .../app/ui/articles/ArticleScreen.kt | 17 +++ .../app/ui/articles/ArticleScreenViewModel.kt | 44 ++++-- .../app/ui/articles/list/ArticleListTopBar.kt | 113 +++------------ .../app/ui/articles/list/SearchView.kt | 132 ++++++++++++++++++ app/src/main/res/values/strings.xml | 1 + 5 files changed, 198 insertions(+), 109 deletions(-) create mode 100644 app/src/main/java/com/capyreader/app/ui/articles/list/SearchView.kt 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 90f83811c..e36060699 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 @@ -83,6 +83,7 @@ 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.articles.list.SearchView import com.capyreader.app.ui.components.ArticleSearch import com.capyreader.app.ui.components.LocalSnackbarHost import com.capyreader.app.ui.components.SearchState @@ -159,6 +160,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( @@ -371,6 +373,7 @@ fun ArticleScreen( fun selectArticle(articleID: String) { if (search.isActive) { focusManager.clearFocus() + search.clear() } onSelectArticle(articleID) } @@ -419,6 +422,7 @@ fun ArticleScreen( } } + Box(modifier = Modifier.fillMaxSize()) { ArticleScaffold( drawerState = drawerState, drawerPane = { @@ -573,6 +577,19 @@ fun ArticleScreen( }, ) + AnimatedVisibility( + visible = search.isActive, + enter = fadeIn(), + exit = fadeOut(), + ) { + SearchView( + search = search, + results = searchResults, + selectedArticleID = selectedArticleID, + onSelect = { selectArticle(it) }, + ) + } + } if (isMarkAllReadDialogOpen) { MarkAllReadDialog( 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 a8e3f18a1..afcc41415 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 @@ -126,20 +128,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, @@ -952,3 +962,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/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..91aea5670 --- /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: (articleID: String) -> 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/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 From ed0ec32e156428112e29a97f519b5ccd70d8aa58 Mon Sep 17 00:00:00 2001 From: Josiah Campbell <9521010+jocmp@users.noreply.github.com> Date: Mon, 15 Jun 2026 11:42:29 -0500 Subject: [PATCH 17/24] Scroll list to the selected article in two-pane Splitting the reader out dropped the old onScrollToArticle bridge, so in two-pane the list no longer followed the reader's selection (e.g. when stepping next/previous). Restore it on the list side, which already receives selectedArticleID: when it changes (and we're not compact) scroll the article into view if it isn't already visible. Single-pane is skipped since selecting there navigates away from the list. --- .../com/capyreader/app/ui/articles/ArticleScreen.kt | 13 +++++++++++++ 1 file changed, 13 insertions(+) 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 e36060699..11c9b218f 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 @@ -84,6 +84,7 @@ 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.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 @@ -236,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) } From 96f10934f5d165847b517c320c34683c523a4b64 Mon Sep 17 00:00:00 2001 From: Josiah Campbell <9521010+jocmp@users.noreply.github.com> Date: Mon, 15 Jun 2026 13:46:06 -0500 Subject: [PATCH 18/24] Always inset the pane drag handle from its edge Replace the collapse-only animated offset with a constant 16dp start padding so the handle keeps a graspable gap at every pane position, not just when fullscreen. --- app/src/main/java/com/capyreader/app/ui/App.kt | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) 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 4d534a6b4..5601dbfae 100644 --- a/app/src/main/java/com/capyreader/app/ui/App.kt +++ b/app/src/main/java/com/capyreader/app/ui/App.kt @@ -1,11 +1,10 @@ package com.capyreader.app.ui -import androidx.compose.animation.core.animateDpAsState 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.offset +import androidx.compose.foundation.layout.padding import androidx.compose.material3.LocalMinimumInteractiveComponentSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -18,7 +17,6 @@ import androidx.compose.material3.adaptive.navigation3.rememberListDetailSceneSt import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -75,15 +73,11 @@ fun App( paneExpansionState = paneExpansion.state, paneExpansionDragHandle = { state -> val interactionSource = remember { MutableInteractionSource() } - // When collapsed to 0% the handle sits on the screen edge with half its touch target - // off-screen; inset it into the detail pane so it stays graspable to drag the list back. - val edgeInset by animateDpAsState( - targetValue = if (paneExpansion.isFullscreen) HandleEdgeInset else 0.dp, - label = "dragHandleInset", - ) VerticalDragHandle( + // Always keep a fixed gap on the handle's start edge so it's never flush against the + // screen edge (notably when the pane is collapsed to 0%) and stays graspable. modifier = Modifier - .offset(x = edgeInset) + .padding(start = HandleEdgeInset) .paneExpansionDraggable( state, LocalMinimumInteractiveComponentSize.current, From 2e9c5348c7e4ddc7d08e209a18ddf296cd73fa10 Mon Sep 17 00:00:00 2001 From: Josiah Campbell <9521010+jocmp@users.noreply.github.com> Date: Mon, 15 Jun 2026 14:11:05 -0500 Subject: [PATCH 19/24] Host the nav drawer at the window level After the Nav3 split the ModalNavigationDrawer lived inside the list pane, so its scrim only dimmed the list, not the detail. Move it above NavDisplay in App so the scrim covers the whole window again (matching the pre-split behavior and the usual drawer pattern). The drawer state and a content slot live in App and are exposed via LocalAppDrawer; the article list entry publishes its FeedList up (keyed on the data so the drawer recomposes only when it changes, with behaviorally-stable callbacks) and drives open/close through the shared state. ArticleScaffold is gone. The drawer content still runs against the list entry's ViewModel since the published lambda captures it. Verified on tablet: opening the drawer dims both panes, content renders, and selecting a feed filters the list and closes the drawer. --- .../main/java/com/capyreader/app/ui/App.kt | 30 +++++++++++- .../java/com/capyreader/app/ui/AppDrawer.kt | 22 +++++++++ .../app/ui/articles/ArticleScaffold.kt | 49 ------------------- .../app/ui/articles/ArticleScreen.kt | 35 +++++++++---- 4 files changed, 77 insertions(+), 59 deletions(-) create mode 100644 app/src/main/java/com/capyreader/app/ui/AppDrawer.kt delete mode 100644 app/src/main/java/com/capyreader/app/ui/articles/ArticleScaffold.kt 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 5601dbfae..37f236e0a 100644 --- a/app/src/main/java/com/capyreader/app/ui/App.kt +++ b/app/src/main/java/com/capyreader/app/ui/App.kt @@ -5,10 +5,14 @@ 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.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.rememberDrawerState import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2 import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirective @@ -17,7 +21,10 @@ import androidx.compose.material3.adaptive.navigation3.rememberListDetailSceneSt 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.compose.ui.unit.dp @@ -88,12 +95,32 @@ fun App( }, ) + // 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 = rememberDrawerState(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 ) { - CompositionLocalProvider(LocalArticlePaneExpansion provides paneExpansion) { + ModalNavigationDrawer( + drawerState = drawerState, + gesturesEnabled = drawerState.isOpen, + drawerContent = { + drawerContent?.let { content -> + ModalDrawerSheet { content() } + } + }, + ) { + CompositionLocalProvider( + LocalArticlePaneExpansion provides paneExpansion, + LocalAppDrawer provides drawerController, + ) { NavDisplay( backStack = backStack, onBack = { backStack.removeLastOrNull() }, @@ -176,6 +203,7 @@ fun App( } ) } + } } } } 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/articles/ArticleScaffold.kt b/app/src/main/java/com/capyreader/app/ui/articles/ArticleScaffold.kt deleted file mode 100644 index df6754257..000000000 --- a/app/src/main/java/com/capyreader/app/ui/articles/ArticleScaffold.kt +++ /dev/null @@ -1,49 +0,0 @@ -package com.capyreader.app.ui.articles - -import androidx.compose.material3.DrawerState -import androidx.compose.material3.DrawerValue -import androidx.compose.material3.ModalDrawerSheet -import androidx.compose.material3.ModalNavigationDrawer -import androidx.compose.material3.Text -import androidx.compose.material3.rememberDrawerState -import androidx.compose.runtime.Composable -import androidx.compose.ui.tooling.preview.Preview -import com.capyreader.app.ui.theme.CapyTheme - -/** - * The article list pane wrapped in the navigation drawer. The list-detail two-pane layout is now - * handled at the navigation layer by the Nav3 list-detail Scene, so this only owns the drawer. - */ -@Composable -fun ArticleScaffold( - drawerState: DrawerState = rememberDrawerState(DrawerValue.Closed), - drawerPane: @Composable () -> Unit, - content: @Composable () -> Unit, -) { - ModalNavigationDrawer( - drawerState = drawerState, - gesturesEnabled = drawerState.isOpen, - drawerContent = { - ModalDrawerSheet { - drawerPane() - } - }, - ) { - content() - } -} - -@Preview(device = "spec:width=1280dp,height=800dp,dpi=240") -@Composable -fun ArticlesLayoutPreview() { - CapyTheme { - ArticleScaffold( - drawerPane = { - Text("List here!") - }, - content = { - Text("Index list here...") - }, - ) - } -} 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 11c9b218f..781055cdb 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 @@ -22,6 +22,7 @@ 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 @@ -83,6 +84,7 @@ 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 @@ -152,7 +154,11 @@ fun ArticleScreen( 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 @@ -435,10 +441,14 @@ fun ArticleScreen( } } - Box(modifier = Modifier.fillMaxSize()) { - ArticleScaffold( - drawerState = drawerState, - 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, @@ -468,8 +478,17 @@ fun ArticleScreen( statusCount = statusCount, todayCount = todayCount, ) - }, - content = { + } + } + + LaunchedEffect(appDrawer, drawerContent) { + appDrawer?.setContent(drawerContent) + } + DisposableEffect(appDrawer) { + onDispose { appDrawer?.setContent(null) } + } + + Box(modifier = Modifier.fillMaxSize()) { val keyboardManager = LocalSoftwareKeyboardController.current val markReadPosition = LocalMarkAllReadButtonPosition.current @@ -587,8 +606,6 @@ fun ArticleScreen( } } } - }, - ) AnimatedVisibility( visible = search.isActive, From a426e9a92b277a8e5043592c38186a8d04ff3214 Mon Sep 17 00:00:00 2001 From: Josiah Campbell <9521010+jocmp@users.noreply.github.com> Date: Mon, 15 Jun 2026 15:00:00 -0500 Subject: [PATCH 20/24] Render media viewer as a full-window OverlayScene MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tapping an image now navigates to Route.MediaViewer, a real back-stack entry rendered by MediaSceneStrategy as an OverlayScene over the live list/detail panes (same window, no Dialog). This restores the full-window viewer the pre-split single scaffold had — previously it rendered inside the detail pane only. Media rides in the route (already @Serializable, so it survives process death); MediaScaffold already owns its black surface, swipe-dismiss, and snackbar host. Also pad the pane drag handle on both edges. Verified on tablet: tapping an article image opens the viewer full-screen over both panes. --- .../main/java/com/capyreader/app/ui/App.kt | 19 +++++-- .../main/java/com/capyreader/app/ui/Route.kt | 9 ++++ .../app/ui/articles/ArticleDetailScreen.kt | 19 +------ .../ui/articles/media/MediaSceneStrategy.kt | 53 +++++++++++++++++++ .../app/ui/articles/media/MediaScreen.kt | 20 +++++++ 5 files changed, 98 insertions(+), 22 deletions(-) create mode 100644 app/src/main/java/com/capyreader/app/ui/articles/media/MediaSceneStrategy.kt create mode 100644 app/src/main/java/com/capyreader/app/ui/articles/media/MediaScreen.kt 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 37f236e0a..98d0bdeaf 100644 --- a/app/src/main/java/com/capyreader/app/ui/App.kt +++ b/app/src/main/java/com/capyreader/app/ui/App.kt @@ -42,6 +42,8 @@ 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 @@ -74,6 +76,7 @@ fun App( calculatePaneScaffoldDirective(windowAdaptiveInfo) .copy(horizontalPartitionSpacerSize = 0.dp) } + val mediaSceneStrategy = remember { MediaSceneStrategy() } val paneExpansion = rememberArticlePaneExpansion() val listDetailStrategy = rememberListDetailSceneStrategy( directive = directive, @@ -81,10 +84,8 @@ fun App( paneExpansionDragHandle = { state -> val interactionSource = remember { MutableInteractionSource() } VerticalDragHandle( - // Always keep a fixed gap on the handle's start edge so it's never flush against the - // screen edge (notably when the pane is collapsed to 0%) and stays graspable. modifier = Modifier - .padding(start = HandleEdgeInset) + .padding(horizontal = HandleEdgeInset) .paneExpansionDraggable( state, LocalMinimumInteractiveComponentSize.current, @@ -130,7 +131,7 @@ fun App( rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator(), ), - sceneStrategies = listOf(listDetailStrategy), + sceneStrategies = listOf(mediaSceneStrategy, listDetailStrategy), entryProvider = entryProvider { entry { AddAccountScreen( @@ -198,6 +199,15 @@ fun App( 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() }, ) } } @@ -225,7 +235,6 @@ private fun sharedAxisXExit(forward: Boolean) = if (forward) -offset else offset }) -/** How far to inset the drag handle from the screen edge when the detail pane is fullscreen. */ private val HandleEdgeInset = 16.dp /** 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 aea8d6321..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,6 +1,7 @@ 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 @@ -28,4 +29,12 @@ sealed class Route : NavKey { /** 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/articles/ArticleDetailScreen.kt b/app/src/main/java/com/capyreader/app/ui/articles/ArticleDetailScreen.kt index ece483f6b..fdfecf381 100644 --- a/app/src/main/java/com/capyreader/app/ui/articles/ArticleDetailScreen.kt +++ b/app/src/main/java/com/capyreader/app/ui/articles/ArticleDetailScreen.kt @@ -1,8 +1,5 @@ package com.capyreader.app.ui.articles -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable @@ -12,7 +9,6 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -41,6 +37,7 @@ fun ArticleDetailScreen( articleID: String, onBackPressed: () -> Unit, onSelectArticle: (id: String) -> Unit, + onSelectMedia: (media: Media) -> Unit, viewModel: ArticleViewModel = koinViewModel(), ) { LaunchedEffect(articleID) { @@ -86,7 +83,6 @@ fun ArticleDetailScreen( val audioController: AudioPlayerController = koinInject() val isAudioPlaying by audioController.isPlaying.collectAsState() val currentAudio by audioController.currentAudio.collectAsState() - var media by rememberSaveable(saver = Media.Saver) { mutableStateOf(null) } val paneExpansion = LocalArticlePaneExpansion.current @@ -119,7 +115,7 @@ fun ArticleDetailScreen( onBackPressed() viewModel.deletePage(current.id) }, - onSelectMedia = { media = it }, + onSelectMedia = onSelectMedia, onSelectAudio = { audio -> audioController.play(audio) }, onPauseAudio = { audioController.pause() }, onSelectArticle = onSelectArticle, @@ -129,17 +125,6 @@ fun ArticleDetailScreen( onToggleFullscreen = { paneExpansion?.toggleFullscreen() }, ) - AnimatedVisibility( - enter = fadeIn(), - exit = fadeOut(), - visible = media != null, - ) { - com.capyreader.app.ui.articles.media.ArticleMediaView( - onDismissRequest = { media = null }, - media = media, - ) - } - labelSheetArticleID?.let { id -> LabelBottomSheet( articleID = id, 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..8c33afb22 --- /dev/null +++ b/app/src/main/java/com/capyreader/app/ui/articles/media/MediaSceneStrategy.kt @@ -0,0 +1,53 @@ +package com.capyreader.app.ui.articles.media + +import androidx.compose.runtime.Composable +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 can scale/animate freely 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, + ) + } + + 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>, +) : OverlayScene { + override val entries: List> = listOf(entry) + + override val previousEntries: List> = overlaidEntries + + override val content: @Composable () -> Unit = { entry.Content() } +} 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..6f828d14b --- /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] / + * [MediaScaffold] already supply the black full-screen surface, swipe-to-dismiss, and their own + * snackbar host, so this is just the entry seam (and the home for the open/close transition). + */ +@Composable +fun MediaScreen( + media: Media, + onDismiss: () -> Unit, +) { + ArticleMediaView( + onDismissRequest = onDismiss, + media = media, + ) +} From d292f71f2e21582b20a9cc83bcd357e66f1281be Mon Sep 17 00:00:00 2001 From: Josiah Campbell <9521010+jocmp@users.noreply.github.com> Date: Mon, 15 Jun 2026 17:28:17 -0500 Subject: [PATCH 21/24] Keep the nav drawer closed on launch The drawerContent only rendered ModalDrawerSheet when content was non-null, so on first composition (before the list entry publishes its content) the drawer had no sheet and initialized open. Always render the sheet, and use a plain remember (not rememberSaveable) so it never restores open across launches. --- app/src/main/java/com/capyreader/app/ui/App.kt | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) 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 98d0bdeaf..aea43e931 100644 --- a/app/src/main/java/com/capyreader/app/ui/App.kt +++ b/app/src/main/java/com/capyreader/app/ui/App.kt @@ -5,6 +5,7 @@ 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 @@ -12,7 +13,6 @@ import androidx.compose.material3.ModalDrawerSheet import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.Surface import androidx.compose.material3.VerticalDragHandle -import androidx.compose.material3.rememberDrawerState import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2 import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirective @@ -98,7 +98,7 @@ fun App( // 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 = rememberDrawerState(DrawerValue.Closed) + val drawerState = remember { DrawerState(DrawerValue.Closed) } var drawerContent by remember { mutableStateOf<(@Composable () -> Unit)?>(null) } val drawerController = remember(drawerState) { AppDrawerController(state = drawerState, setContent = { drawerContent = it }) @@ -113,9 +113,7 @@ fun App( drawerState = drawerState, gesturesEnabled = drawerState.isOpen, drawerContent = { - drawerContent?.let { content -> - ModalDrawerSheet { content() } - } + ModalDrawerSheet { drawerContent?.invoke() } }, ) { CompositionLocalProvider( From 40f3d06dd3bde91f98f39da9aedfd6e51217678a Mon Sep 17 00:00:00 2001 From: Josiah Campbell <9521010+jocmp@users.noreply.github.com> Date: Mon, 15 Jun 2026 17:28:17 -0500 Subject: [PATCH 22/24] Fade the media overlay and handle its back The OverlayScene shares the window (no Dialog), so it fades in on open and out via onRemove, and claims system back with a BackHandler (otherwise back fell through to the panes beneath and exited the app). Dropped the earlier zoom-from-rect machinery (WebView rect bridge, image hide + cross-entry restore seam, MediaScaffold scale refactor) in favor of this simple fade. --- .../ui/articles/media/MediaSceneStrategy.kt | 28 +++++++++++++++++-- .../app/ui/articles/media/MediaScreen.kt | 6 ++-- 2 files changed, 28 insertions(+), 6 deletions(-) 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 index 8c33afb22..d0f6a1cc4 100644 --- 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 @@ -1,6 +1,13 @@ 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 @@ -12,8 +19,8 @@ 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 can scale/animate freely over both panes. Being a - * real back-stack entry, the system back gesture pops it for free. + * 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( @@ -29,6 +36,7 @@ class MediaSceneStrategy : SceneStrategy { key = top.contentKey, entry = top, overlaidEntries = below, + onBack = onBack, ) } @@ -44,10 +52,24 @@ 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 - override val content: @Composable () -> Unit = { entry.Content() } + 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 index 6f828d14b..646b2fe2f 100644 --- 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 @@ -4,9 +4,9 @@ import androidx.compose.runtime.Composable import com.capyreader.app.common.Media /** - * Content of the [com.capyreader.app.ui.Route.MediaViewer] overlay entry. [ArticleMediaView] / - * [MediaScaffold] already supply the black full-screen surface, swipe-to-dismiss, and their own - * snackbar host, so this is just the entry seam (and the home for the open/close transition). + * 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( From e4fb8ffcaf308262d442e71db0dafe0029fc9f9e Mon Sep 17 00:00:00 2001 From: Josiah Campbell <9521010+jocmp@users.noreply.github.com> Date: Tue, 16 Jun 2026 09:19:08 -0500 Subject: [PATCH 23/24] Fix ArticleScreenViewModel test for articleCutoff param --- .../com/capyreader/app/ui/articles/ArticleScreenViewModelTest.kt | 1 + 1 file changed, 1 insertion(+) 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(), ) } } From ee7f76829619819eeaa3189de2d3f265392517cb Mon Sep 17 00:00:00 2001 From: Josiah Campbell <9521010+jocmp@users.noreply.github.com> Date: Tue, 16 Jun 2026 17:57:35 -0500 Subject: [PATCH 24/24] Followups --- .../java/com/capyreader/app/MainActivity.kt | 33 ++-- .../app/notifications/NotificationHelper.kt | 25 ++- .../java/com/capyreader/app/ui/DeepLink.kt | 10 +- .../capyreader/app/ui/articles/ArticleList.kt | 4 +- .../app/ui/articles/ArticleScreen.kt | 35 ++-- .../app/ui/articles/ArticleScreenViewModel.kt | 176 ------------------ .../app/ui/articles/ArticleSessionCutoff.kt | 26 ++- .../app/ui/articles/ArticleViewModel.kt | 8 + .../app/ui/articles/list/SearchView.kt | 2 +- .../com/jocmp/capy/ArticleNotification.kt | 2 + .../persistence/ArticleNotificationMapper.kt | 6 +- .../persistence/articles/ByArticleStatus.kt | 35 ++-- .../jocmp/capy/persistence/articles/ByFeed.kt | 43 +++-- .../persistence/articles/BySavedSearch.kt | 39 ++-- .../capy/persistence/articles/ByToday.kt | 54 +++--- .../jocmp/capy/db/article_notifications.sq | 4 +- .../com/jocmp/capy/db/articlesByFeed.sq | 59 ++++-- .../jocmp/capy/db/articlesBySavedSearch.sq | 57 ++++-- .../com/jocmp/capy/db/articlesByStatus.sq | 55 ++++-- .../capy/persistence/ArticleRecordsTest.kt | 122 ++++++++++++ 20 files changed, 426 insertions(+), 369 deletions(-) diff --git a/app/src/main/java/com/capyreader/app/MainActivity.kt b/app/src/main/java/com/capyreader/app/MainActivity.kt index d0f7ac541..ea8c6a0ef 100644 --- a/app/src/main/java/com/capyreader/app/MainActivity.kt +++ b/app/src/main/java/com/capyreader/app/MainActivity.kt @@ -11,7 +11,7 @@ import com.capyreader.app.preferences.AppPreferences import com.capyreader.app.ui.App import com.capyreader.app.ui.DeepLink import com.capyreader.app.ui.Route -import org.koin.android.ext.android.get +import com.jocmp.capy.ArticleStatus import org.koin.android.ext.android.inject class MainActivity : BaseActivity() { @@ -21,7 +21,7 @@ class MainActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val startBackStack = DeepLink.parse(intent.data) ?: listOf(startDestination()) + val startBackStack = resolveBackStack(intent) applyListFilter(startBackStack) setContent { @@ -36,7 +36,9 @@ class MainActivity : BaseActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) - DeepLink.parse(intent.data)?.let { parsed -> + // 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 } @@ -52,15 +54,22 @@ class MainActivity : BaseActivity() { } } - private fun startDestination(): Route { - val appPreferences = get() - - val accountID = appPreferences.accountID.get() - - return if (accountID.isBlank()) { - Route.AddAccount - } else { - Route.ArticleList(appPreferences.filter.get()) + /** + * 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 2b4bf2445..af1ad8129 100644 --- a/app/src/main/java/com/capyreader/app/notifications/NotificationHelper.kt +++ b/app/src/main/java/com/capyreader/app/notifications/NotificationHelper.kt @@ -10,6 +10,7 @@ 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.ui.DeepLink import com.jocmp.capy.Account @@ -158,13 +159,23 @@ private fun NotificationManagerCompat.tryNotify(id: Int, notification: Notificat } private fun ArticleNotification.contentIntent(context: Context): PendingIntent { - val notifyIntent = 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 + 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/DeepLink.kt b/app/src/main/java/com/capyreader/app/ui/DeepLink.kt index 6c6e691b8..f773503f4 100644 --- a/app/src/main/java/com/capyreader/app/ui/DeepLink.kt +++ b/app/src/main/java/com/capyreader/app/ui/DeepLink.kt @@ -41,19 +41,23 @@ object DeepLink { .apply { if (status == ArticleStatus.UNREAD) appendPath("unread") } .build() - fun parse(uri: Uri?): List? { + /** + * [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 != null) { + val list = if (!feedID.isNullOrBlank()) { Route.ArticleList( ArticleFilter.Feeds( feedID = feedID, folderTitle = null, - feedStatus = ArticleStatus.UNREAD, + feedStatus = currentStatus, ) ) } else { 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/ArticleScreen.kt b/app/src/main/java/com/capyreader/app/ui/articles/ArticleScreen.kt index 781055cdb..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,8 +16,6 @@ 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 @@ -63,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 @@ -147,7 +143,6 @@ fun ArticleScreen( } val context = LocalContext.current - val fullContent = rememberFullContent(viewModel) val articleActions = rememberArticleActions(viewModel) val folderActions = rememberFolderActions(viewModel) val feedActions = rememberFeedActions(viewModel) @@ -194,7 +189,6 @@ fun ArticleScreen( var isMarkAllReadDialogOpen by remember { mutableStateOf(false) } CompositionLocalProvider( - LocalFullContent provides fullContent, LocalArticleActions provides articleActions, LocalFolderActions provides folderActions, LocalFeedActions provides feedActions, @@ -389,12 +383,21 @@ fun ArticleScreen( } } - fun selectArticle(articleID: String) { + val linkOpener = LocalLinkOpener.current + + fun selectArticle(article: Article) { if (search.isActive) { focusManager.clearFocus() search.clear() } - onSelectArticle(articleID) + + // 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) + } } val selectFilter = { @@ -596,8 +599,8 @@ fun ArticleScreen( onMarkAllRead = { range -> onMarkAllRead(range) }, - onSelect = { articleID -> - selectArticle(articleID) + onSelect = { article -> + selectArticle(article) }, ) } @@ -616,7 +619,7 @@ fun ArticleScreen( search = search, results = searchResults, selectedArticleID = selectedArticleID, - onSelect = { selectArticle(it) }, + onSelect = { article -> selectArticle(article) }, ) } } @@ -774,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, 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 afcc41415..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 @@ -69,8 +69,6 @@ class ArticleScreenViewModel( ) : AndroidViewModel(application) { private var refreshJob: Job? = null - private var fullContentJob: Job? = null - var refreshSkipReason by mutableStateOf(null) private set @@ -87,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 @@ -284,9 +280,6 @@ class ArticleScreenViewModel( val showUnauthorizedMessage: Boolean get() = _showUnauthorizedMessage == UnauthorizedMessageState.SHOW - val article: Article? - get() = _article - val searchQuery: Flow get() = _searchQuery @@ -568,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) } @@ -700,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() @@ -755,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, ) { @@ -896,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 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 index 5cb7cc9b7..2176b3890 100644 --- a/app/src/main/java/com/capyreader/app/ui/articles/ArticleSessionCutoff.kt +++ b/app/src/main/java/com/capyreader/app/ui/articles/ArticleSessionCutoff.kt @@ -7,9 +7,10 @@ import java.time.OffsetDateTime * rather than the list's refresh. * * Set when a reading session begins (the reader opens its first article, before it marks anything - * read) and cleared when the session ends. 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. + * 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 @@ -21,13 +22,22 @@ class ArticleSessionCutoff { } /** - * Starts a session cutoff if one isn't already set — the fallback for cold deep links (no list - * session). Idempotent: it won't overwrite the list's cutoff, and repeated reader opens - * (next/previous) keep the same session start. + * 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() { - if (value == null) { - value = OffsetDateTime.now() + 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 index 54b7e24b5..9ad25b453 100644 --- a/app/src/main/java/com/capyreader/app/ui/articles/ArticleViewModel.kt +++ b/app/src/main/java/com/capyreader/app/ui/articles/ArticleViewModel.kt @@ -241,6 +241,14 @@ class ArticleViewModel( 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() 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 index 91aea5670..8bfd9a84b 100644 --- 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 @@ -48,7 +48,7 @@ fun SearchView( search: ArticleSearch, results: LazyPagingItems
, selectedArticleID: String?, - onSelect: (articleID: String) -> Unit, + onSelect: (article: Article) -> Unit, ) { val focusRequester = remember { FocusRequester() } val query = search.query.orEmpty() 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/articles/ByArticleStatus.kt b/capy/src/main/java/com/jocmp/capy/persistence/articles/ByArticleStatus.kt index 190ab5728..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 @@ -79,24 +79,27 @@ class ByArticleStatus(private val database: Database) { val newestFirst = isNewestFirst(sortOrder) val queries = database.articlesByStatusQueries - val previous = queries.articleBefore( - articleID = articleID, - read = read, - lastReadAt = mapLastRead(read, since), - starred = starred, - lastUnstarredAt = mapLastUnstarred(starred, since), - publishedSince = publishedSince, - newestFirst = newestFirst, + 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 = queries.articleAfter( - articleID = articleID, - read = read, - lastReadAt = mapLastRead(read, since), - starred = starred, - lastUnstarredAt = mapLastUnstarred(starred, since), - publishedSince = publishedSince, - newestFirst = newestFirst, + val next = findAfter( + articleID, + read, + mapLastRead(read, since), + starred, + mapLastUnstarred(starred, since), + publishedSince, ).executeAsOneOrNull() return previous to next 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 5d3b2e964..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 @@ -92,28 +92,31 @@ class ByFeed(private val database: Database) { val newestFirst = isNewestFirst(sortOrder) val queries = database.articlesByFeedQueries - val previous = queries.articleBefore( - articleID = articleID, - feedIDs = feedIDs, - read = read, - lastReadAt = mapLastRead(read, since), - starred = starred, - lastUnstarredAt = mapLastUnstarred(starred, since), - publishedSince = null, - priorities = priority.inclusivePriorities, - newestFirst = newestFirst, + 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 = queries.articleAfter( - articleID = articleID, - feedIDs = feedIDs, - read = read, - lastReadAt = mapLastRead(read, since), - starred = starred, - lastUnstarredAt = mapLastUnstarred(starred, since), - publishedSince = null, - priorities = priority.inclusivePriorities, - newestFirst = newestFirst, + val next = findAfter( + articleID, + feedIDs, + read, + mapLastRead(read, since), + starred, + mapLastUnstarred(starred, since), + null, + priority.inclusivePriorities, ).executeAsOneOrNull() return previous to next 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 f06ea9560..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 @@ -86,26 +86,29 @@ class BySavedSearch(private val database: Database) { val newestFirst = isNewestFirst(sortOrder) val queries = database.articlesBySavedSearchQueries - val previous = queries.articleBefore( - articleID = articleID, - savedSearchID = savedSearchID, - read = read, - lastReadAt = mapLastRead(read, since), - starred = starred, - lastUnstarredAt = mapLastUnstarred(starred, since), - publishedSince = null, - newestFirst = newestFirst, + 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 = queries.articleAfter( - articleID = articleID, - savedSearchID = savedSearchID, - read = read, - lastReadAt = mapLastRead(read, since), - starred = starred, - lastUnstarredAt = mapLastUnstarred(starred, since), - publishedSince = null, - newestFirst = newestFirst, + val next = findAfter( + articleID, + savedSearchID, + read, + mapLastRead(read, since), + starred, + mapLastUnstarred(starred, since), + null, ).executeAsOneOrNull() return previous to next 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 3acd1355f..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,7 +81,7 @@ class ByToday(private val database: Database) { query = query, lastReadAt = mapLastRead(read, since), lastUnstarredAt = mapLastUnstarred(starred, since), - publishedSince = mapTodayStartDate() + publishedSince = mapTodayStartDate(since) ) } @@ -94,32 +94,40 @@ class ByToday(private val database: Database) { val (read, starred) = status.toStatusPair val newestFirst = isNewestFirst(sortOrder) val queries = database.articlesByStatusQueries - val publishedSince = mapTodayStartDate() + val publishedSince = mapTodayStartDate(since) - val previous = queries.articleBefore( - articleID = articleID, - read = read, - lastReadAt = mapLastRead(read, since), - starred = starred, - lastUnstarredAt = mapLastUnstarred(starred, since), - publishedSince = publishedSince, - newestFirst = newestFirst, + 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 = queries.articleAfter( - articleID = articleID, - read = read, - lastReadAt = mapLastRead(read, since), - starred = starred, - lastUnstarredAt = mapLastUnstarred(starred, since), - publishedSince = publishedSince, - newestFirst = newestFirst, + val next = findAfter( + articleID, + read, + mapLastRead(read, since), + starred, + mapLastUnstarred(starred, since), + publishedSince, ).executeAsOneOrNull() return previous to next } - private fun mapTodayStartDate(): Long { - return OffsetDateTime.now().minusHours(24).toEpochSecond() + /** + * 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 7ce6fafea..50787c1f4 100644 --- a/capy/src/main/sqldelight/com/jocmp/capy/db/articlesByFeed.sq +++ b/capy/src/main/sqldelight/com/jocmp/capy/db/articlesByFeed.sq @@ -100,7 +100,10 @@ AND ( END ); -articleAfter: +-- 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 @@ -111,18 +114,26 @@ AND ((article_statuses.read = :read AND article_statuses.last_read_at IS NULL OR 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 CASE WHEN :newestFirst - THEN articles.published_at < anchor.published_at OR (articles.published_at = anchor.published_at AND articles.id < anchor.id) - ELSE articles.published_at > anchor.published_at OR (articles.published_at = anchor.published_at AND articles.id > anchor.id) -END -ORDER BY - CASE WHEN :newestFirst THEN articles.published_at END DESC, - CASE WHEN :newestFirst THEN articles.id END DESC, - CASE WHEN :newestFirst THEN NULL ELSE articles.published_at END ASC, - CASE WHEN :newestFirst THEN NULL ELSE articles.id END ASC +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; -articleBefore: +articleBeforeNewestFirst: SELECT articles.id FROM articles JOIN feeds ON articles.feed_id = feeds.id @@ -133,13 +144,21 @@ AND ((article_statuses.read = :read AND article_statuses.last_read_at IS NULL OR 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 CASE WHEN :newestFirst - THEN articles.published_at > anchor.published_at OR (articles.published_at = anchor.published_at AND articles.id > anchor.id) - ELSE articles.published_at < anchor.published_at OR (articles.published_at = anchor.published_at AND articles.id < anchor.id) -END -ORDER BY - CASE WHEN :newestFirst THEN articles.published_at END ASC, - CASE WHEN :newestFirst THEN articles.id END ASC, - CASE WHEN :newestFirst THEN NULL ELSE articles.published_at END DESC, - CASE WHEN :newestFirst THEN NULL ELSE articles.id END DESC +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 ef01af56d..4bb87b0d2 100644 --- a/capy/src/main/sqldelight/com/jocmp/capy/db/articlesBySavedSearch.sq +++ b/capy/src/main/sqldelight/com/jocmp/capy/db/articlesBySavedSearch.sq @@ -99,7 +99,8 @@ AND ( END ); -articleAfter: +-- 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 @@ -110,18 +111,26 @@ 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 CASE WHEN :newestFirst - THEN articles.published_at < anchor.published_at OR (articles.published_at = anchor.published_at AND articles.id < anchor.id) - ELSE articles.published_at > anchor.published_at OR (articles.published_at = anchor.published_at AND articles.id > anchor.id) -END -ORDER BY - CASE WHEN :newestFirst THEN articles.published_at END DESC, - CASE WHEN :newestFirst THEN articles.id END DESC, - CASE WHEN :newestFirst THEN NULL ELSE articles.published_at END ASC, - CASE WHEN :newestFirst THEN NULL ELSE articles.id END ASC +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; -articleBefore: +articleBeforeNewestFirst: SELECT articles.id FROM articles JOIN feeds ON articles.feed_id = feeds.id @@ -132,13 +141,21 @@ 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 CASE WHEN :newestFirst - THEN articles.published_at > anchor.published_at OR (articles.published_at = anchor.published_at AND articles.id > anchor.id) - ELSE articles.published_at < anchor.published_at OR (articles.published_at = anchor.published_at AND articles.id < anchor.id) -END -ORDER BY - CASE WHEN :newestFirst THEN articles.published_at END ASC, - CASE WHEN :newestFirst THEN articles.id END ASC, - CASE WHEN :newestFirst THEN NULL ELSE articles.published_at END DESC, - CASE WHEN :newestFirst THEN NULL ELSE articles.id END DESC +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 80b5dcbd2..ed3c7a3d0 100644 --- a/capy/src/main/sqldelight/com/jocmp/capy/db/articlesByStatus.sq +++ b/capy/src/main/sqldelight/com/jocmp/capy/db/articlesByStatus.sq @@ -96,7 +96,8 @@ AND ( END ); -articleAfter: +-- 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 @@ -106,18 +107,25 @@ WHERE ((article_statuses.read = :read AND article_statuses.last_read_at 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 CASE WHEN :newestFirst - THEN articles.published_at < anchor.published_at OR (articles.published_at = anchor.published_at AND articles.id < anchor.id) - ELSE articles.published_at > anchor.published_at OR (articles.published_at = anchor.published_at AND articles.id > anchor.id) -END -ORDER BY - CASE WHEN :newestFirst THEN articles.published_at END DESC, - CASE WHEN :newestFirst THEN articles.id END DESC, - CASE WHEN :newestFirst THEN NULL ELSE articles.published_at END ASC, - CASE WHEN :newestFirst THEN NULL ELSE articles.id END ASC +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; -articleBefore: +articleBeforeNewestFirst: SELECT articles.id FROM articles JOIN feeds ON articles.feed_id = feeds.id @@ -127,13 +135,20 @@ WHERE ((article_statuses.read = :read AND article_statuses.last_read_at 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 CASE WHEN :newestFirst - THEN articles.published_at > anchor.published_at OR (articles.published_at = anchor.published_at AND articles.id > anchor.id) - ELSE articles.published_at < anchor.published_at OR (articles.published_at = anchor.published_at AND articles.id < anchor.id) -END -ORDER BY - CASE WHEN :newestFirst THEN articles.published_at END ASC, - CASE WHEN :newestFirst THEN articles.id END ASC, - CASE WHEN :newestFirst THEN NULL ELSE articles.published_at END DESC, - CASE WHEN :newestFirst THEN NULL ELSE articles.id END DESC +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 {