From 0fc0797fe6c1e5c98a8bc27a3e13cf83a9814927 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Mar 2026 02:09:37 +0000 Subject: [PATCH 1/5] feat: add single-column layout toggle under Style > Feeds page Adds a user preference to force single-column mode on tablets/large screens. When enabled, the 2-pane adaptive layout collapses to a single column where tapping an article takes over the full screen and back returns to the article list. Default is off (adaptive behavior unchanged). - FlowSingleColumnPreference: new boolean preference (ON/OFF) - DataStoreExt: register key "flowSingleColumn" in both PreferencesKey and legacy DataStoreKey - Settings/Preference/SettingsProvider: wire preference through the settings system - AppEntry: override scaffold directive with maxHorizontalPartitions=1 when enabled - FeedsPageStylePage: add "Layout" section with toggle switch - strings.xml: add single_column_layout and layout string resources https://claude.ai/code/session_01DJ46HYUPdGBeDXuYgrqGNB --- .../preference/FlowSingleColumnPreference.kt | 47 +++++++++++++++++++ .../infrastructure/preference/Preference.kt | 1 + .../infrastructure/preference/Settings.kt | 1 + .../preference/SettingsProvider.kt | 1 + .../java/me/ash/reader/ui/ext/DataStoreExt.kt | 5 ++ .../me/ash/reader/ui/page/nav3/AppEntry.kt | 9 +++- .../color/feeds/FeedsPageStylePage.kt | 20 ++++++++ app/src/main/res/values/strings.xml | 2 + 8 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/me/ash/reader/infrastructure/preference/FlowSingleColumnPreference.kt diff --git a/app/src/main/java/me/ash/reader/infrastructure/preference/FlowSingleColumnPreference.kt b/app/src/main/java/me/ash/reader/infrastructure/preference/FlowSingleColumnPreference.kt new file mode 100644 index 000000000..136b8b008 --- /dev/null +++ b/app/src/main/java/me/ash/reader/infrastructure/preference/FlowSingleColumnPreference.kt @@ -0,0 +1,47 @@ +package me.ash.reader.infrastructure.preference + +import android.content.Context +import androidx.compose.runtime.compositionLocalOf +import androidx.datastore.preferences.core.Preferences +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import me.ash.reader.ui.ext.DataStoreKey +import me.ash.reader.ui.ext.DataStoreKey.Companion.flowSingleColumn +import me.ash.reader.ui.ext.dataStore +import me.ash.reader.ui.ext.put + +val LocalFlowSingleColumn = + compositionLocalOf { FlowSingleColumnPreference.default } + +sealed class FlowSingleColumnPreference(val value: Boolean) : Preference() { + object ON : FlowSingleColumnPreference(true) + object OFF : FlowSingleColumnPreference(false) + + override fun put(context: Context, scope: CoroutineScope) { + scope.launch { + context.dataStore.put( + DataStoreKey.flowSingleColumn, + value + ) + } + } + + companion object { + + val default = OFF + val values = listOf(ON, OFF) + + fun fromPreferences(preferences: Preferences) = + when (preferences[DataStoreKey.keys[flowSingleColumn]?.key as Preferences.Key]) { + true -> ON + false -> OFF + else -> default + } + } +} + +operator fun FlowSingleColumnPreference.not(): FlowSingleColumnPreference = + when (value) { + true -> FlowSingleColumnPreference.OFF + false -> FlowSingleColumnPreference.ON + } diff --git a/app/src/main/java/me/ash/reader/infrastructure/preference/Preference.kt b/app/src/main/java/me/ash/reader/infrastructure/preference/Preference.kt index bdc2c057d..7db83a7dc 100644 --- a/app/src/main/java/me/ash/reader/infrastructure/preference/Preference.kt +++ b/app/src/main/java/me/ash/reader/infrastructure/preference/Preference.kt @@ -33,6 +33,7 @@ fun Preferences.toSettings(): Settings { feedsTopBarTonalElevation = FeedsTopBarTonalElevationPreference.fromPreferences(this), feedsGroupListExpand = FeedsGroupListExpandPreference.fromPreferences(this), feedsGroupListTonalElevation = FeedsGroupListTonalElevationPreference.fromPreferences(this), + flowSingleColumn = FlowSingleColumnPreference.fromPreferences(this), // Flow page flowFilterBarStyle = FlowFilterBarStylePreference.fromPreferences(this), diff --git a/app/src/main/java/me/ash/reader/infrastructure/preference/Settings.kt b/app/src/main/java/me/ash/reader/infrastructure/preference/Settings.kt index af4f01012..ad35b7bc6 100644 --- a/app/src/main/java/me/ash/reader/infrastructure/preference/Settings.kt +++ b/app/src/main/java/me/ash/reader/infrastructure/preference/Settings.kt @@ -28,6 +28,7 @@ data class Settings( val feedsTopBarTonalElevation: FeedsTopBarTonalElevationPreference = FeedsTopBarTonalElevationPreference.default, val feedsGroupListExpand: FeedsGroupListExpandPreference = FeedsGroupListExpandPreference.default, val feedsGroupListTonalElevation: FeedsGroupListTonalElevationPreference = FeedsGroupListTonalElevationPreference.default, + val flowSingleColumn: FlowSingleColumnPreference = FlowSingleColumnPreference.default, // Flow page val flowFilterBarStyle: FlowFilterBarStylePreference = FlowFilterBarStylePreference.default, diff --git a/app/src/main/java/me/ash/reader/infrastructure/preference/SettingsProvider.kt b/app/src/main/java/me/ash/reader/infrastructure/preference/SettingsProvider.kt index dd14b72d3..d9cd139d1 100644 --- a/app/src/main/java/me/ash/reader/infrastructure/preference/SettingsProvider.kt +++ b/app/src/main/java/me/ash/reader/infrastructure/preference/SettingsProvider.kt @@ -87,6 +87,7 @@ class SettingsProvider @Inject constructor( LocalFeedsFilterBarStyle provides settings.feedsFilterBarStyle, LocalFeedsFilterBarPadding provides settings.feedsFilterBarPadding, LocalFeedsFilterBarTonalElevation provides settings.feedsFilterBarTonalElevation, + LocalFlowSingleColumn provides settings.flowSingleColumn, // Flow page LocalFlowTopBarTonalElevation provides settings.flowTopBarTonalElevation, diff --git a/app/src/main/java/me/ash/reader/ui/ext/DataStoreExt.kt b/app/src/main/java/me/ash/reader/ui/ext/DataStoreExt.kt index 7552b3d02..4cc54f54c 100644 --- a/app/src/main/java/me/ash/reader/ui/ext/DataStoreExt.kt +++ b/app/src/main/java/me/ash/reader/ui/ext/DataStoreExt.kt @@ -146,6 +146,7 @@ sealed interface PreferencesKey { const val feedsTopBarTonalElevation = "feedsTopBarTonalElevation" const val feedsGroupListExpand = "feedsGroupListExpand" const val feedsGroupListTonalElevation = "feedsGroupListTonalElevation" + const val flowSingleColumn = "flowSingleColumn" // Flow page const val flowFilterBarStyle = "flowFilterBarStyle" @@ -225,6 +226,7 @@ sealed interface PreferencesKey { IntKey(feedsTopBarTonalElevation), BooleanKey(feedsGroupListExpand), IntKey(feedsGroupListTonalElevation), + BooleanKey(flowSingleColumn), // Flow page IntKey(flowFilterBarStyle), IntKey(flowFilterBarPadding), @@ -308,6 +310,7 @@ data class DataStoreKey(val key: Preferences.Key, val type: Class) { const val feedsTopBarTonalElevation = "feedsTopBarTonalElevation" const val feedsGroupListExpand = "feedsGroupListExpand" const val feedsGroupListTonalElevation = "feedsGroupListTonalElevation" + const val flowSingleColumn = "flowSingleColumn" // Flow page const val flowFilterBarStyle = "flowFilterBarStyle" @@ -404,6 +407,8 @@ data class DataStoreKey(val key: Preferences.Key, val type: Class) { DataStoreKey(booleanPreferencesKey(feedsGroupListExpand), Boolean::class.java), feedsGroupListTonalElevation to DataStoreKey(intPreferencesKey(feedsGroupListTonalElevation), Int::class.java), + flowSingleColumn to + DataStoreKey(booleanPreferencesKey(flowSingleColumn), Boolean::class.java), // Flow page flowFilterBarStyle to DataStoreKey(intPreferencesKey(flowFilterBarStyle), Int::class.java), diff --git a/app/src/main/java/me/ash/reader/ui/page/nav3/AppEntry.kt b/app/src/main/java/me/ash/reader/ui/page/nav3/AppEntry.kt index 0ab9f8349..e5b029a0e 100644 --- a/app/src/main/java/me/ash/reader/ui/page/nav3/AppEntry.kt +++ b/app/src/main/java/me/ash/reader/ui/page/nav3/AppEntry.kt @@ -29,6 +29,7 @@ import me.ash.reader.ui.motion.materialSharedAxisXOut import me.ash.reader.ui.page.adaptive.ArticleData import me.ash.reader.ui.page.adaptive.ArticleListReaderPage import me.ash.reader.ui.page.adaptive.ArticleListReaderViewModel +import me.ash.reader.infrastructure.preference.LocalFlowSingleColumn import me.ash.reader.ui.page.home.feeds.FeedsPage import me.ash.reader.ui.page.home.feeds.subscribe.SubscribeViewModel import me.ash.reader.ui.page.nav3.key.Route @@ -69,7 +70,13 @@ fun AppEntry(backStack: NavBackStack) { if (backStack.size == 1) backStack[0] = Route.Feeds else backStack.removeLastOrNull() } - val scaffoldDirective = calculatePaneScaffoldDirective(currentWindowAdaptiveInfo()) + val forceSingleColumn = LocalFlowSingleColumn.current + val computedDirective = calculatePaneScaffoldDirective(currentWindowAdaptiveInfo()) + val scaffoldDirective = if (forceSingleColumn.value) { + computedDirective.copy(maxHorizontalPartitions = 1) + } else { + computedDirective + } val navigator = rememberListDetailPaneScaffoldNavigator( diff --git a/app/src/main/java/me/ash/reader/ui/page/settings/color/feeds/FeedsPageStylePage.kt b/app/src/main/java/me/ash/reader/ui/page/settings/color/feeds/FeedsPageStylePage.kt index 68d71a615..052f8cc7c 100644 --- a/app/src/main/java/me/ash/reader/ui/page/settings/color/feeds/FeedsPageStylePage.kt +++ b/app/src/main/java/me/ash/reader/ui/page/settings/color/feeds/FeedsPageStylePage.kt @@ -32,6 +32,7 @@ fun FeedsPageStylePage( val topBarTonalElevation = LocalFeedsTopBarTonalElevation.current val groupListExpand = LocalFeedsGroupListExpand.current val groupListTonalElevation = LocalFeedsGroupListTonalElevation.current + val singleColumn = LocalFlowSingleColumn.current val scope = rememberCoroutineScope() @@ -86,6 +87,25 @@ fun FeedsPageStylePage( Spacer(modifier = Modifier.height(24.dp)) } + // Layout + item { + Subtitle( + modifier = Modifier.padding(horizontal = 24.dp), + text = stringResource(R.string.layout) + ) + SettingItem( + title = stringResource(R.string.single_column_layout), + onClick = { + (!singleColumn).put(context, scope) + }, + ) { + RYSwitch(activated = singleColumn.value) { + (!singleColumn).put(context, scope) + } + } + Spacer(modifier = Modifier.height(24.dp)) + } + // Top Bar item { Subtitle( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f7f249f63..7cbe51021 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -187,6 +187,8 @@ Article list Group list Always expand + Single column layout + Layout Top \"Mark as read\" button position Top bar From 3b55fa06abb91b88408c8ff3929be435b2367888 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Mar 2026 05:14:01 +0000 Subject: [PATCH 2/5] Immediately refresh layout when single-column toggle changes The navigator's scaffoldValue only recomputes on navigation events, not when scaffoldDirective alone changes. Without a navigation event, isTwoPane stays stale after toggling, requiring a rotation or app restart to take effect. Add a LaunchedEffect that watches forceSingleColumn via snapshotFlow and navigates to the List pane on every change (after initial composition), triggering the navigator to recompute scaffoldValue with the updated directive. https://claude.ai/code/session_01DJ46HYUPdGBeDXuYgrqGNB --- app/src/main/java/me/ash/reader/ui/page/nav3/AppEntry.kt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/src/main/java/me/ash/reader/ui/page/nav3/AppEntry.kt b/app/src/main/java/me/ash/reader/ui/page/nav3/AppEntry.kt index e5b029a0e..307476a2c 100644 --- a/app/src/main/java/me/ash/reader/ui/page/nav3/AppEntry.kt +++ b/app/src/main/java/me/ash/reader/ui/page/nav3/AppEntry.kt @@ -14,6 +14,7 @@ import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaf import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator @@ -24,6 +25,7 @@ import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.ui.LocalNavAnimatedContentScope import androidx.navigation3.ui.NavDisplay import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.drop import me.ash.reader.ui.motion.materialSharedAxisXIn import me.ash.reader.ui.motion.materialSharedAxisXOut import me.ash.reader.ui.page.adaptive.ArticleData @@ -84,6 +86,12 @@ fun AppEntry(backStack: NavBackStack) { isDestinationHistoryAware = false, ) + LaunchedEffect(navigator) { + snapshotFlow { forceSingleColumn.value } + .drop(1) + .collect { navigator.navigateTo(ListDetailPaneScaffoldRole.List) } + } + SharedTransitionLayout { NavDisplay( modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface), From 56fd9087d16c4648bc801d61bf002049a1ef58fd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Mar 2026 20:25:47 +0000 Subject: [PATCH 3/5] Add CLAUDE.md with project context for Claude Code Covers build setup, architecture overview, preferences system pattern, adaptive layout notes (including the single-column toggle behavior), key libraries, and branch naming convention. https://claude.ai/code/session_01DJ46HYUPdGBeDXuYgrqGNB --- CLAUDE.md | 96 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..868c8a0db --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,96 @@ +# ReadYou — Claude Code Context + +## Project Overview +ReadYou is an Android RSS reader built with Jetpack Compose and Material You (Material 3). It supports multiple RSS/Atom backend sources and presents content in a clean, adaptive layout. + +- **Package:** `me.ash.reader` +- **Min SDK:** 26 | **Target SDK:** 34 | **Compile SDK:** 36 +- **Version:** 0.16.1 (code 46) +- **Language:** Kotlin +- **UI:** Jetpack Compose + Material 3 Adaptive + +## Build & Run + +```bash +# Debug build +./gradlew assembleDebug + +# Run all unit tests +./gradlew test + +# Run instrumented tests +./gradlew connectedAndroidTest + +# Install on connected device +./gradlew installDebug +``` + +Requires JDK 17. If `JAVA_HOME` isn't set, point it at the bundled JBR inside Android Studio: +```bash +export JAVA_HOME=/path/to/Android\ Studio.app/Contents/jbr/Contents/Home +``` + +## Architecture + +**Pattern:** MVVM + Hilt DI + Room + DataStore + +``` +app/src/main/java/me/ash/reader/ +├── infrastructure/ +│ ├── preference/ # All user preferences (DataStore-backed sealed classes) +│ ├── rss/ # RSS sync service, parsers, repositories +│ └── db/ # Room database, DAOs, entities +├── ui/ +│ ├── page/ +│ │ ├── nav3/ # AppEntry.kt — top-level nav scaffold (Nav3 + M3 Adaptive) +│ │ ├── adaptive/ # ArticleListReadingPage — two/one pane scaffold +│ │ ├── home/ +│ │ │ ├── feeds/ # FeedsPage — subscription list +│ │ │ ├── flow/ # FlowPage — article list +│ │ │ └── reading/ # ReadingPage — article reader +│ │ └── settings/ # All settings pages +│ ├── component/ # Shared UI components +│ └── ext/ # DataStore keys, Compose extensions +``` + +## Preferences System + +All preferences follow a consistent pattern: +1. **Sealed class** in `infrastructure/preference/` — extends `Preference`, has `ON`/`OFF` or enum variants, `put()`, `fromPreferences()`, `default`, `LocalXxx` CompositionLocal +2. **DataStore key** registered in `ui/ext/DataStoreExt.kt` — in both `keyList` and the deprecated `DataStoreKey.keys` map +3. **`Settings.kt`** — add field with default +4. **`Preference.kt` `toSettings()`** — map DataStore → Settings +5. **`SettingsProvider.kt` `ProvidesSettings()`** — expose via CompositionLocal + +Use `FlowSingleColumnPreference.kt` as a reference implementation for boolean prefs. + +## Adaptive Layout + +The app uses **Material 3 Adaptive** (`NavigableListDetailPaneScaffold`) for tablet two-pane support. + +Key files: +- `AppEntry.kt` — creates the `ListDetailPaneScaffoldNavigator`, computes `scaffoldDirective` +- `ArticleListReadingPage.kt` — derives `isTwoPane` from `navigator.scaffoldValue` + +**Single-column toggle** (`FlowSingleColumnPreference`): forces `maxHorizontalPartitions = 1` on the directive. A `LaunchedEffect` in `AppEntry.kt` watches the preference via `snapshotFlow` and calls `navigator.navigateTo(List)` on change — this is required because the navigator only recomputes `scaffoldValue` on navigation events, not on directive-only changes. + +## Key Libraries + +| Library | Purpose | +|---------|---------| +| Hilt | Dependency injection | +| Room | Local database | +| DataStore | Preference persistence | +| Nav3 (`androidx.navigation3`) | Navigation back stack | +| Material3 Adaptive | Two-pane / adaptive layout | +| Coil | Image loading | +| OkHttp + Rome | RSS fetching & parsing | +| Timber | Logging | + +## Development Branch + +Active feature work: `claude/rss-reader-layout-research-irv2Q` + +Branch naming convention: `claude/-` + +Push with: `git push -u origin ` From 16e658801af5aa3d0e6b635cc97b956131090f87 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Mar 2026 03:10:54 +0000 Subject: [PATCH 4/5] Allow two-pane layout in landscape when single-column toggle is on When the single-column preference is enabled, only force maxHorizontalPartitions=1 in portrait. In landscape (screenWidthDp > screenHeightDp) the adaptive directive is used as-is, so a tablet rotated sideways naturally shows the two-pane view. https://claude.ai/code/session_01DJ46HYUPdGBeDXuYgrqGNB --- CLAUDE.md | 2 +- app/src/main/java/me/ash/reader/ui/page/nav3/AppEntry.kt | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 868c8a0db..f4703fca7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,7 +72,7 @@ Key files: - `AppEntry.kt` — creates the `ListDetailPaneScaffoldNavigator`, computes `scaffoldDirective` - `ArticleListReadingPage.kt` — derives `isTwoPane` from `navigator.scaffoldValue` -**Single-column toggle** (`FlowSingleColumnPreference`): forces `maxHorizontalPartitions = 1` on the directive. A `LaunchedEffect` in `AppEntry.kt` watches the preference via `snapshotFlow` and calls `navigator.navigateTo(List)` on change — this is required because the navigator only recomputes `scaffoldValue` on navigation events, not on directive-only changes. +**Single-column toggle** (`FlowSingleColumnPreference`): forces `maxHorizontalPartitions = 1` on the directive, but **only in portrait** (`screenWidthDp <= screenHeightDp`). In landscape the adaptive layout runs normally, allowing two panes on a tablet held sideways. A `LaunchedEffect` in `AppEntry.kt` watches the preference via `snapshotFlow` and calls `navigator.navigateTo(List)` on change — this is required because the navigator only recomputes `scaffoldValue` on navigation events, not on directive-only changes. Orientation changes recreate the Activity (no `configChanges` override in the manifest), so no extra effect is needed for rotation. ## Key Libraries diff --git a/app/src/main/java/me/ash/reader/ui/page/nav3/AppEntry.kt b/app/src/main/java/me/ash/reader/ui/page/nav3/AppEntry.kt index 307476a2c..86d94b3eb 100644 --- a/app/src/main/java/me/ash/reader/ui/page/nav3/AppEntry.kt +++ b/app/src/main/java/me/ash/reader/ui/page/nav3/AppEntry.kt @@ -16,6 +16,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator import androidx.navigation3.runtime.NavBackStack @@ -73,8 +74,9 @@ fun AppEntry(backStack: NavBackStack) { } val forceSingleColumn = LocalFlowSingleColumn.current + val isLandscape = LocalConfiguration.current.screenWidthDp > LocalConfiguration.current.screenHeightDp val computedDirective = calculatePaneScaffoldDirective(currentWindowAdaptiveInfo()) - val scaffoldDirective = if (forceSingleColumn.value) { + val scaffoldDirective = if (forceSingleColumn.value && !isLandscape) { computedDirective.copy(maxHorizontalPartitions = 1) } else { computedDirective From 8c2dd71269f6bc7ee48da11a91ccd82a6270fcd7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 12:33:55 +0000 Subject: [PATCH 5/5] Update AGP to 8.14.0 for Gradle 9.6+ compatibility AGP 8.13.0 uses a Gradle internal API (InternalProblems) that was removed in Gradle 9.6.0. Upgrading to AGP 8.14.0 removes this dependency and allows builds with Gradle 9.6+. --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d78154f4a..f4ba4d6d1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] # Android Gradle Plugin -androidGradlePlugin = "8.13.0" +androidGradlePlugin = "8.14.0" # Kotlin kotlin = "2.2.0"