Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
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
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[versions]
# Android Gradle Plugin
androidGradlePlugin = "8.13.0"
androidGradlePlugin = "8.14.0"

# Kotlin
kotlin = "2.2.0"
Expand Down
Loading