diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..f4703fca7 --- /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, 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 + +| 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 ` 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..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 @@ -14,7 +14,9 @@ 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.compose.ui.platform.LocalConfiguration import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator import androidx.navigation3.runtime.NavBackStack @@ -24,11 +26,13 @@ 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 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 +73,14 @@ fun AppEntry(backStack: NavBackStack) { if (backStack.size == 1) backStack[0] = Route.Feeds else backStack.removeLastOrNull() } - val scaffoldDirective = calculatePaneScaffoldDirective(currentWindowAdaptiveInfo()) + val forceSingleColumn = LocalFlowSingleColumn.current + val isLandscape = LocalConfiguration.current.screenWidthDp > LocalConfiguration.current.screenHeightDp + val computedDirective = calculatePaneScaffoldDirective(currentWindowAdaptiveInfo()) + val scaffoldDirective = if (forceSingleColumn.value && !isLandscape) { + computedDirective.copy(maxHorizontalPartitions = 1) + } else { + computedDirective + } val navigator = rememberListDetailPaneScaffoldNavigator( @@ -77,6 +88,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), 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 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"