Skip to content
Open
96 changes: 96 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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/<description>-<sessionId>`

Push with: `git push -u origin <branch-name>`
Original file line number Diff line number Diff line change
@@ -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> { 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<Boolean>]) {
true -> ON
false -> OFF
else -> default
}
}
}

operator fun FlowSingleColumnPreference.not(): FlowSingleColumnPreference =
when (value) {
true -> FlowSingleColumnPreference.OFF
false -> FlowSingleColumnPreference.ON
}
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions app/src/main/java/me/ash/reader/ui/ext/DataStoreExt.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -225,6 +226,7 @@ sealed interface PreferencesKey {
IntKey(feedsTopBarTonalElevation),
BooleanKey(feedsGroupListExpand),
IntKey(feedsGroupListTonalElevation),
BooleanKey(flowSingleColumn),
// Flow page
IntKey(flowFilterBarStyle),
IntKey(flowFilterBarPadding),
Expand Down Expand Up @@ -308,6 +310,7 @@ data class DataStoreKey<T>(val key: Preferences.Key<T>, val type: Class<T>) {
const val feedsTopBarTonalElevation = "feedsTopBarTonalElevation"
const val feedsGroupListExpand = "feedsGroupListExpand"
const val feedsGroupListTonalElevation = "feedsGroupListTonalElevation"
const val flowSingleColumn = "flowSingleColumn"

// Flow page
const val flowFilterBarStyle = "flowFilterBarStyle"
Expand Down Expand Up @@ -404,6 +407,8 @@ data class DataStoreKey<T>(val key: Preferences.Key<T>, val type: Class<T>) {
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),
Expand Down
25 changes: 23 additions & 2 deletions app/src/main/java/me/ash/reader/ui/page/home/flow/FlowPage.kt
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,9 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
Expand Down Expand Up @@ -149,6 +151,9 @@ fun FlowPage(

val listState = rememberSaveable(pagerData, saver = LazyListState.Saver) { LazyListState(0, 0) }

val density = LocalDensity.current
var viewportHeightPx by remember { mutableStateOf(0) }

val isTopBarElevated = topBarTonalElevation.value > 0
val scrolledTopBarContainerColor =
with(MaterialTheme.colorScheme) { if (isTopBarElevated) surfaceContainer else surface }
Expand Down Expand Up @@ -530,7 +535,11 @@ fun FlowPage(
.firstOrNull { it.contentType == CONTENT_TYPE_ARTICLE }
?.key
val items = mutableListOf<ArticleWithFeed>()
var found = false
// No item with contentType == CONTENT_TYPE_ARTICLE remains
// visible once every article has scrolled past the read line
// (e.g. the trailing spacer is the only thing left on screen),
// so treat that as every remaining article being read.
var found = firstItemKey == null
val itemCount = pagingItems.itemCount
for (index in 0 until itemCount) {
pagingItems.peek(index).let {
Expand Down Expand Up @@ -637,7 +646,12 @@ fun FlowPage(
)
.also { currentPullToLoadState = it }

Box(modifier = Modifier.fillMaxSize()) {
Box(
modifier =
Modifier.fillMaxSize().onSizeChanged {
viewportHeightPx = it.height
}
) {
LazyColumn(
modifier =
Modifier.pullToLoad(
Expand Down Expand Up @@ -701,6 +715,13 @@ fun FlowPage(
WindowInsets.navigationBars
)
)
// Ensures the last item can scroll all the way to the top of the
// viewport, so "mark as read on scroll" can reach it like any
// other item.
Spacer(
modifier =
Modifier.height(with(density) { viewportHeightPx.toDp() })
)
}
}
}
Expand Down
19 changes: 18 additions & 1 deletion app/src/main/java/me/ash/reader/ui/page/nav3/AppEntry.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -69,14 +73,27 @@ fun AppEntry(backStack: NavBackStack<NavKey>) {
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<ArticleData>(
scaffoldDirective = scaffoldDirective,
isDestinationHistoryAware = false,
)

LaunchedEffect(navigator) {
snapshotFlow { forceSingleColumn.value }
.drop(1)
.collect { navigator.navigateTo(ListDetailPaneScaffoldRole.List) }
}

SharedTransitionLayout {
NavDisplay(
modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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(
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,8 @@
<string name="article_list">Article list</string>
<string name="group_list">Group list</string>
<string name="always_expand">Always expand</string>
<string name="single_column_layout">Single column layout</string>
<string name="layout">Layout</string>
<string name="top">Top</string>
<string name="mark_as_read_button_position">\"Mark as read\" button position</string>
<string name="top_bar">Top bar</string>
Expand Down
Loading