Wave 4 (F3): settings email, five screens' screenshots, release signing, README

Completes wave 4. Verified by the orchestrator, not self-reported:
assembleDebug / testDebugUnitTest / assembleRelease all exit 0;
102 tests, 1 skipped, 0 failures, 0 errors.

- Settings showed the PocketBase user id instead of the signed-in email,
  because login never persisted the email. AuthRepository now writes it to
  SettingsStore on success and sign-out clears it; SettingsUiState carries
  userEmail in place of userId. AuthRepositoryTest asserts both directions.
- Paparazzi coverage for the five screens library was missing: setup, detail,
  scan, locations, settings, each light + dark, populated rather than empty.
  Scan cannot show a live camera under Paparazzi, so its tests render the
  reticle overlay and the result bottom sheet over a static backdrop.
- Release signing via an optional gitignored app/keystore.properties. Without
  it assembleRelease still works and comes out debug-signed, so the build is
  not owner-only. R8 deliberately left off; nothing has proven Room, Retrofit,
  kotlinx-serialization and ML Kit survive it.
- Top-level README: shared-library model, offline-first architecture, the
  push-then-pull last-write-wins conflict rule SPEC requires be documented
  here, build/deploy/install steps, and honest current limitations.

The signed APK and the keystore are intentionally not committed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TyzeWmdTqi7U85iYNGy7P
This commit is contained in:
Sprite
2026-09-08 21:28:11 +00:00
co-authored by claude
parent 36b46b4644
commit 5455df2d61
25 changed files with 811 additions and 7 deletions
+179
View File
@@ -0,0 +1,179 @@
# Bookshelf
A private, self-hosted home library app for two people. You scan the
barcodes on your books; it looks up the metadata, stores it on your own
server, and keeps both people's phones in sync. No cloud service, no public
registration, no ads, no accounts you don't control.
## The shared-library model
Bookshelf is built for exactly one household: two people who both want to
know what's on the shelves and where. There's no concept of "my books" vs.
"your books" — every book belongs to the one shared library, and either
person can scan, edit, move, or delete anything in it.
- **Self-hosted.** You run a small [PocketBase](https://pocketbase.io/)
server on your own hardware (a NUC, an old laptop, a Raspberry Pi — see
[`server/deploy/`](server/deploy/)). Nobody else's data touches it, and it
touches nobody else's.
- **Private by construction.** The server has no public sign-up
(`createRule = null` on the `users` collection in PocketBase — only a
superuser can create an account, via `server/create-user.sh`), and every
read/write to books, shelves, and bookcases requires a logged-in user.
- **Two named accounts.** Create one account per person with
`server/create-user.sh`. That's the whole user model — there is no admin
UI, no roles, no invitations.
## Architecture
**Android app** (Kotlin, Jetpack Compose, Material 3) talking to a
**PocketBase** backend over HTTPS.
```
app/ Android Gradle project (org.modg.bookshelf)
server/ PocketBase schema, provisioning scripts, deploy docs
docs/ SPEC.md — the authoritative product/technical spec this was built from
```
### Offline-first, Room-backed
Every screen reads from a local [Room](https://developer.android.com/training/data-storage/room)
database, never directly from the network — the app has to be fully usable
(browse, search, edit notes, move books between shelves) with the home
server unreachable, which residential NAT/dynamic IP setups make a routine
occurrence, not an edge case. Every write lands in Room first and is
synced to the server later; nothing blocks on network I/O, and a sync
failure surfaces as a quiet status line, never a crash or a blocking dialog.
Deletion is always a soft tombstone (`deleted = true`), never a hard delete,
on both the client and the server — so a delete on one phone propagates to
the other on next sync instead of just disappearing from one copy.
### Sync: push-then-pull, last-write-wins
Each sync cycle (on app start, pull-to-refresh, and a ~6-hourly WorkManager
job) does two passes, in this order:
1. **Push** every locally-changed record (tracked via a `syncState` column:
`PENDING_CREATE` / `PENDING_UPDATE` / `PENDING_DELETE`) to PocketBase.
New records use a client-generated 15-character id, sent as-is on create —
PocketBase accepts client-supplied ids, so an id never has to be remapped
after the fact. A `PENDING_DELETE` is pushed as a `PATCH {deleted: true}`,
never an actual record delete.
2. **Pull** everything changed on the server since the last-seen cursor
(`updated > cursor`, paginated to exhaustion), so the two devices'
changes reconcile in one direction after the local push.
**Conflict rule: last-write-wins on the server's `updated` timestamp.**
If both phones edit the same book while offline, whichever write reaches
the server later simply overwrites the earlier one — there is no merge, no
per-field reconciliation, and no conflict UI. This is a deliberate
simplification for a two-person household doing infrequent concurrent edits,
not a limitation either of you should expect to fight with day-to-day, but
it does mean a same-book edit race can silently lose one side's change.
Book covers get the same treatment as everything else: the app downloads
the cover from the metadata source and re-uploads it to PocketBase's own
`cover` file field, so the household's library doesn't rot when an external
cover URL eventually 404s. If offline, the cover is queued locally and
uploaded on the next sync.
### Metadata lookup
Scanning a barcode looks the ISBN up against
[Open Library](https://openlibrary.org/dev/docs/api/books), falling back to
[Google Books](https://developers.google.com/books) if Open Library has
nothing. The two results are merged (prefer whichever has a title; fill in
blanks from the other); if both come up empty, the app offers manual entry
pre-filled with the scanned ISBN instead of a dead end.
## Building
Requirements: JDK 21, Android SDK (compileSdk/targetSdk 37, build-tools
37.0.0). No emulator is required or used in this project's own verification —
see "Current limitations" below.
```sh
cd app
./gradlew assembleDebug # debug APK
./gradlew testDebugUnitTest # JVM unit tests (Robolectric + Paparazzi)
./gradlew recordPaparazziDebug # re-record screenshot goldens under src/test/snapshots
./gradlew assembleRelease # release APK — see "Signing" below
```
### Signing a release build
`app/app/build.gradle.kts` reads signing credentials from
`app/keystore.properties` (gitignored, alongside the `.jks` keystore it
points at) if that file exists:
```properties
storeFile=release-keystore.jks
storePassword=...
keyAlias=bookshelf
keyPassword=...
```
Without that file, `assembleRelease` still succeeds — the release build type
simply comes out unsigned (debug-signed by AGP's defaults), so anyone who
clones this repo can build and run it without needing the household's actual
release key. Only the machine(s) that own `keystore.properties` produce an
APK you'd actually want to install permanently (Android treats a
signing-key change as a different app for update purposes, so hang on to
that keystore).
## Deploying the server
See [`server/README.md`](server/README.md) for the schema and provisioning
scripts, and [`server/deploy/`](server/deploy/) for running PocketBase
long-term (systemd or Docker), reaching it from outside your home network
(Tailscale is the recommended option — no ports opened on your router, no
TLS cert management), and backups. In short:
```sh
cd server
./setup-schema.sh http://127.0.0.1:8090 <superuser-email> <superuser-password>
./create-user.sh you@example.com "a strong password" "Your Name"
./create-user.sh partner@example.com "a different strong password" "Partner Name"
```
Enter the server's URL (must be `https://`, unless it's Tailscale-only —
see the deploy README) on the app's first-run setup screen; it's never
hardcoded into the build.
## Installing the APK
Build (or ask whoever holds the release keystore to build)
`app/app/build/outputs/apk/release/app-release.apk`, copy it to the phone,
and open it. Android will prompt to allow installs from that source the
first time. There's no Play Store listing — this app is not, and was never
meant to be, publicly distributed.
## Current limitations
Read this before assuming more polish than exists:
- **The app has never run on a physical device or emulator.** This
environment has no KVM, so there is no Android emulator available.
Everything here was verified via `./gradlew assembleDebug`,
`testDebugUnitTest` (JVM/Robolectric unit tests), and Paparazzi screenshot
rendering (`src/test/snapshots/images/`) — real logic paths (ISBN
checksums, metadata merging, sync conflict resolution, DAO queries) are
unit-tested, and every screen has been rendered to a static PNG in both
light and dark theme, but nothing has been tap-tested on an actual screen.
Camera/barcode scanning in particular has only been exercised through unit
tests of the pure logic (`IsbnBarcodeAnalyzer`/`ScanCodeFilter`), never a
live camera.
- **Sync has been round-tripped against a real PocketBase exactly once**
(a live-server test covering auth, push with client-generated ids, pull,
last-write-wins, tombstones, and a byte-for-byte cover round-trip — see
`server/live-sync-test.sh`). It has not been exercised over an actual flaky
residential connection, nor with two devices genuinely racing each other.
- **Conflict resolution is last-write-wins with no merge and no UI** for it,
as described above — acceptable for this app's scale, but worth knowing
before relying on it under real concurrent edits.
- **No Room foreign keys** between books/shelves/bookcases (a deliberate
simplification) — an orphaned `shelfId` on a book is handled in queries,
not prevented by the schema.
- **No automated instrumented/UI tests** — only JVM unit tests and Paparazzi
screenshots. There is no CI pipeline in this repo.
+3
View File
@@ -10,3 +10,6 @@
local.properties local.properties
**/build/ **/build/
.kotlin/ .kotlin/
keystore.properties
*.jks
*.keystore
+27
View File
@@ -1,3 +1,5 @@
import java.util.Properties
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
// NOTE: no org.jetbrains.kotlin.android plugin — AGP 9's Kotlin support is // NOTE: no org.jetbrains.kotlin.android plugin — AGP 9's Kotlin support is
@@ -8,6 +10,17 @@ plugins {
alias(libs.plugins.paparazzi) alias(libs.plugins.paparazzi)
} }
// Release signing is optional: `keystore.properties` (gitignored, alongside the
// keystore it points at) only exists on machines that own the release key. Anyone
// else still gets a working `assembleRelease` — it just comes out debug-signed.
val keystorePropertiesFile = rootProject.file("keystore.properties")
val keystoreProperties = Properties().apply {
if (keystorePropertiesFile.exists()) {
keystorePropertiesFile.inputStream().use { load(it) }
}
}
val hasReleaseKeystore = keystorePropertiesFile.exists()
android { android {
namespace = "org.modg.bookshelf" namespace = "org.modg.bookshelf"
compileSdk = 37 compileSdk = 37
@@ -23,10 +36,24 @@ android {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
signingConfigs {
if (hasReleaseKeystore) {
create("release") {
storeFile = rootProject.file(keystoreProperties["storeFile"] as String)
storePassword = keystoreProperties["storePassword"] as String
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["keyPassword"] as String
}
}
}
buildTypes { buildTypes {
release { release {
isMinifyEnabled = false isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
if (hasReleaseKeystore) {
signingConfig = signingConfigs.getByName("release")
}
} }
} }
@@ -24,6 +24,7 @@ class SettingsStore(private val context: Context) {
val SERVER_URL = stringPreferencesKey("server_url") val SERVER_URL = stringPreferencesKey("server_url")
val AUTH_TOKEN = stringPreferencesKey("auth_token") val AUTH_TOKEN = stringPreferencesKey("auth_token")
val USER_ID = stringPreferencesKey("user_id") val USER_ID = stringPreferencesKey("user_id")
val USER_EMAIL = stringPreferencesKey("user_email")
val LAST_SYNC_TIME = longPreferencesKey("last_sync_time") val LAST_SYNC_TIME = longPreferencesKey("last_sync_time")
fun cursor(collection: String) = stringPreferencesKey("cursor_$collection") fun cursor(collection: String) = stringPreferencesKey("cursor_$collection")
} }
@@ -31,6 +32,7 @@ class SettingsStore(private val context: Context) {
val serverUrl: Flow<String?> = context.dataStore.data.map { it[Keys.SERVER_URL] } val serverUrl: Flow<String?> = context.dataStore.data.map { it[Keys.SERVER_URL] }
val authToken: Flow<String?> = context.dataStore.data.map { it[Keys.AUTH_TOKEN] } val authToken: Flow<String?> = context.dataStore.data.map { it[Keys.AUTH_TOKEN] }
val userId: Flow<String?> = context.dataStore.data.map { it[Keys.USER_ID] } val userId: Flow<String?> = context.dataStore.data.map { it[Keys.USER_ID] }
val userEmail: Flow<String?> = context.dataStore.data.map { it[Keys.USER_EMAIL] }
val lastSyncTime: Flow<Long?> = context.dataStore.data.map { it[Keys.LAST_SYNC_TIME] } val lastSyncTime: Flow<Long?> = context.dataStore.data.map { it[Keys.LAST_SYNC_TIME] }
fun cursorFor(collection: String): Flow<String?> = fun cursorFor(collection: String): Flow<String?> =
@@ -48,6 +50,10 @@ class SettingsStore(private val context: Context) {
context.dataStore.edit { it[Keys.USER_ID] = userId } context.dataStore.edit { it[Keys.USER_ID] = userId }
} }
suspend fun setUserEmail(email: String) {
context.dataStore.edit { it[Keys.USER_EMAIL] = email }
}
suspend fun setCursor(collection: String, cursor: String) { suspend fun setCursor(collection: String, cursor: String) {
context.dataStore.edit { it[Keys.cursor(collection)] = cursor } context.dataStore.edit { it[Keys.cursor(collection)] = cursor }
} }
@@ -61,6 +67,7 @@ class SettingsStore(private val context: Context) {
context.dataStore.edit { context.dataStore.edit {
it.remove(Keys.AUTH_TOKEN) it.remove(Keys.AUTH_TOKEN)
it.remove(Keys.USER_ID) it.remove(Keys.USER_ID)
it.remove(Keys.USER_EMAIL)
} }
} }
@@ -35,6 +35,7 @@ class AuthRepository(
val response = api.authWithPassword(AuthWithPasswordRequest(identity = email, password = password)) val response = api.authWithPassword(AuthWithPasswordRequest(identity = email, password = password))
settingsStore.setAuthToken(response.token) settingsStore.setAuthToken(response.token)
settingsStore.setUserId(response.record.id) settingsStore.setUserId(response.record.id)
settingsStore.setUserEmail(email)
Result.success(Unit) Result.success(Unit)
} catch (e: Exception) { } catch (e: Exception) {
Result.failure(e) Result.failure(e)
@@ -76,7 +76,7 @@ fun SettingsScreen(
GoldDivider(modifier = Modifier.padding(vertical = 16.dp)) GoldDivider(modifier = Modifier.padding(vertical = 16.dp))
SectionHeading("Account") SectionHeading("Account")
InfoRow(label = "Signed in as", value = state.userId ?: "Unknown") InfoRow(label = "Signed in as", value = state.userEmail ?: "Unknown")
SecondaryButton( SecondaryButton(
text = "Sign out", text = "Sign out",
onClick = { confirmSignOut = true }, onClick = { confirmSignOut = true },
@@ -17,7 +17,7 @@ import org.modg.bookshelf.ui.components.SyncStatus
data class SettingsUiState( data class SettingsUiState(
val serverUrl: String? = null, val serverUrl: String? = null,
val userId: String? = null, val userEmail: String? = null,
val bookCount: Int = 0, val bookCount: Int = 0,
val coverCount: Int = 0, val coverCount: Int = 0,
val lastSyncTime: Long? = null, val lastSyncTime: Long? = null,
@@ -35,7 +35,7 @@ data class SettingsUiState(
private data class BaseInfo( private data class BaseInfo(
val serverUrl: String?, val serverUrl: String?,
val userId: String?, val userEmail: String?,
val bookCount: Int, val bookCount: Int,
val coverCount: Int, val coverCount: Int,
val lastSyncTime: Long?, val lastSyncTime: Long?,
@@ -54,13 +54,13 @@ class SettingsViewModel(
private val baseInfo = combine( private val baseInfo = combine(
authRepository.serverUrl, authRepository.serverUrl,
settingsStore.userId, settingsStore.userEmail,
bookRepository.observeAll(), bookRepository.observeAll(),
settingsStore.lastSyncTime, settingsStore.lastSyncTime,
) { url, userId, books, lastSync -> ) { url, userEmail, books, lastSync ->
BaseInfo( BaseInfo(
serverUrl = url, serverUrl = url,
userId = userId, userEmail = userEmail,
bookCount = books.size, bookCount = books.size,
coverCount = books.count { !it.coverUrl.isNullOrBlank() || !it.localCoverPath.isNullOrBlank() }, coverCount = books.count { !it.coverUrl.isNullOrBlank() || !it.localCoverPath.isNullOrBlank() },
lastSyncTime = lastSync, lastSyncTime = lastSync,
@@ -70,7 +70,7 @@ class SettingsViewModel(
val uiState: StateFlow<SettingsUiState> = combine(baseInfo, isSyncing, syncError) { base, syncing, error -> val uiState: StateFlow<SettingsUiState> = combine(baseInfo, isSyncing, syncError) { base, syncing, error ->
SettingsUiState( SettingsUiState(
serverUrl = base.serverUrl, serverUrl = base.serverUrl,
userId = base.userId, userEmail = base.userEmail,
bookCount = base.bookCount, bookCount = base.bookCount,
coverCount = base.coverCount, coverCount = base.coverCount,
lastSyncTime = base.lastSyncTime, lastSyncTime = base.lastSyncTime,
@@ -0,0 +1,52 @@
package org.modg.bookshelf.data.repo
import androidx.test.core.app.ApplicationProvider
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.modg.bookshelf.data.prefs.SettingsStore
import org.modg.bookshelf.data.remote.PocketBaseApi
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/**
* Covers the settings-email gap from the wave-4 handoff: Settings used to show the
* PocketBase user id because login never persisted the entered email.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class AuthRepositoryTest {
private lateinit var settingsStore: SettingsStore
private lateinit var api: FakePocketBaseApi
private lateinit var repository: AuthRepository
@Before
fun setUp() {
settingsStore = SettingsStore(ApplicationProvider.getApplicationContext())
api = FakePocketBaseApi()
repository = AuthRepository(apiProvider = { api as PocketBaseApi }, settingsStore = settingsStore)
}
@Test
fun `login persists the entered email so Settings can show it`() = runTest {
val result = repository.login(email = "reader@example.com", password = "hunter2")
assertTrue(result.isSuccess)
assertEquals("reader@example.com", settingsStore.userEmail.first())
}
@Test
fun `sign out clears the persisted email`() = runTest {
repository.login(email = "reader@example.com", password = "hunter2")
repository.signOut()
assertNull(settingsStore.userEmail.first())
}
}
@@ -0,0 +1,92 @@
package org.modg.bookshelf.ui.screens
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import app.cash.paparazzi.DeviceConfig
import app.cash.paparazzi.Paparazzi
import org.junit.Rule
import org.junit.Test
import org.modg.bookshelf.ui.components.BookshelfScaffold
import org.modg.bookshelf.ui.components.GoldDivider
import org.modg.bookshelf.ui.components.PaperSurface
import org.modg.bookshelf.ui.detail.BookHeader
import org.modg.bookshelf.ui.detail.DescriptionSection
import org.modg.bookshelf.ui.detail.LocationSection
import org.modg.bookshelf.ui.detail.NotesSection
import org.modg.bookshelf.ui.theme.BookshelfTheme
/**
* SPEC.md "detail" screen — big cover, metadata, collapsible description,
* editable notes, location picker. Rebuilds [org.modg.bookshelf.ui.detail.DetailScreen]'s
* shell around its own real internal sections ([BookHeader], [DescriptionSection],
* [NotesSection], [LocationSection]) with [ScreenFixtures.detailBook], per the
* pattern in [ScreenFixtures].
*/
class DetailScreenPaparazziTest {
@get:Rule
val paparazzi = Paparazzi(deviceConfig = DeviceConfig.PIXEL_6)
@Test
fun detailPopulatedLight() = snapshotBoth("detail-populated") { Populated() }
@Composable
private fun Populated() = Shell()
@Composable
private fun Shell() {
val book = ScreenFixtures.detailBook
BookshelfScaffold(
title = book.title,
navigationIcon = {
IconButton(onClick = {}) { Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = "Back") }
},
actions = {
IconButton(onClick = {}) { Icon(Icons.Outlined.Edit, contentDescription = "Edit") }
IconButton(onClick = {}) { Icon(Icons.Outlined.Delete, contentDescription = "Delete") }
},
) { innerPadding ->
PaperSurface(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
) {
BookHeader(book = book)
GoldDivider(modifier = Modifier.padding(vertical = 16.dp))
DescriptionSection(description = book.description)
GoldDivider(modifier = Modifier.padding(vertical = 16.dp))
NotesSection(book = book, onSave = {})
GoldDivider(modifier = Modifier.padding(vertical = 16.dp))
LocationSection(
book = book,
bookcases = ScreenFixtures.bookcases,
shelves = ScreenFixtures.shelves,
onShelfSelected = {},
)
}
}
}
}
private fun snapshotBoth(name: String, content: @Composable () -> Unit) {
paparazzi.snapshot(name = "$name-light") { BookshelfTheme(darkTheme = false) { content() } }
paparazzi.snapshot(name = "$name-dark") { BookshelfTheme(darkTheme = true) { content() } }
}
}
@@ -0,0 +1,94 @@
package org.modg.bookshelf.ui.screens
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import app.cash.paparazzi.DeviceConfig
import app.cash.paparazzi.Paparazzi
import org.junit.Rule
import org.junit.Test
import org.modg.bookshelf.ui.components.BookshelfScaffold
import org.modg.bookshelf.ui.components.GoldDivider
import org.modg.bookshelf.ui.components.PaperSurface
import org.modg.bookshelf.ui.locations.BookcaseRow
import org.modg.bookshelf.ui.locations.BookcaseUi
import org.modg.bookshelf.ui.locations.ShelfUi
import org.modg.bookshelf.ui.theme.BookshelfTheme
/**
* SPEC.md "locations" screen — bookcases -> shelves tree with per-shelf book
* counts. Rebuilds [org.modg.bookshelf.ui.locations.LocationsScreen]'s shell
* around its own real [BookcaseRow] (which nests the real `ShelfRow`), fed
* counts derived from [ScreenFixtures.books], per the pattern in [ScreenFixtures].
*/
class LocationsScreenPaparazziTest {
@get:Rule
val paparazzi = Paparazzi(deviceConfig = DeviceConfig.PIXEL_6)
@Test
fun locationsPopulatedLight() = snapshotBoth("locations-populated") { Populated() }
@Composable
private fun Populated() = Shell()
@Composable
private fun Shell() {
val bookcaseUis = ScreenFixtures.bookcases.map { bookcase ->
BookcaseUi(
bookcase = bookcase,
shelves = ScreenFixtures.shelves
.filter { it.bookcaseId == bookcase.id }
.map { shelf -> ShelfUi(shelf, ScreenFixtures.books.count { it.shelfId == shelf.id }) },
)
}
BookshelfScaffold(
title = "Locations",
navigationIcon = {
IconButton(onClick = {}) { Icon(Icons.Filled.ArrowBack, contentDescription = "Back") }
},
floatingActionButton = {
FloatingActionButton(onClick = {}) { Icon(Icons.Filled.Add, contentDescription = "Add bookcase") }
},
) { innerPadding ->
PaperSurface(modifier = Modifier.fillMaxWidth()) {
LazyColumn(contentPadding = PaddingValues(top = innerPadding.calculateTopPadding(), bottom = 96.dp)) {
items(bookcaseUis, key = { it.bookcase.id }) { bookcaseUi ->
BookcaseRow(
bookcaseUi = bookcaseUi,
onEdit = {},
onDelete = {},
onMoveUp = {},
onMoveDown = {},
onAddShelf = {},
onShelfClick = {},
onEditShelf = {},
onDeleteShelf = {},
onMoveShelfBooks = {},
onShelfMoveUp = {},
onShelfMoveDown = {},
)
GoldDivider(modifier = Modifier.padding(vertical = 4.dp))
}
}
}
}
}
private fun snapshotBoth(name: String, content: @Composable () -> Unit) {
paparazzi.snapshot(name = "$name-light") { BookshelfTheme(darkTheme = false) { content() } }
paparazzi.snapshot(name = "$name-dark") { BookshelfTheme(darkTheme = true) { content() } }
}
}
@@ -0,0 +1,120 @@
package org.modg.bookshelf.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material.icons.outlined.FlashOff
import androidx.compose.material.icons.outlined.Keyboard
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import app.cash.paparazzi.DeviceConfig
import app.cash.paparazzi.Paparazzi
import org.junit.Rule
import org.junit.Test
import org.modg.bookshelf.data.metadata.BookMetadata
import org.modg.bookshelf.ui.components.BookshelfScaffold
import org.modg.bookshelf.ui.scan.DuplicateStatus
import org.modg.bookshelf.ui.scan.FoundBookSheet
import org.modg.bookshelf.ui.scan.ScanReticle
import org.modg.bookshelf.ui.scan.SessionBadge
import org.modg.bookshelf.ui.theme.BookshelfTheme
/**
* SPEC.md "scan" screen — camera + reticle, on-hit bottom sheet.
*
* Paparazzi has no real camera: [org.modg.bookshelf.ui.scan.ScanScreen]'s
* `CameraPreview` binds a live `ProcessCameraProvider`/`PreviewView`, which
* cannot run headless (same class of problem [ScreenFixtures] documents for
* Room/OkHttp). So this renders the screen's real overlay pieces — [ScanReticle],
* [SessionBadge], and the real [FoundBookSheet] — over a plain dark backdrop
* standing in for the live viewfinder, per the wave-4 task instructions.
*/
class ScanScreenPaparazziTest {
@get:Rule
val paparazzi = Paparazzi(deviceConfig = DeviceConfig.PIXEL_6)
@Test
fun scanReticleLight() = snapshotBoth("scan-reticle") { ReticleOverlay() }
@Test
fun scanFoundSheetLight() = snapshotBoth("scan-found-sheet") { FoundSheetOverlay() }
@Composable
private fun ReticleOverlay() = Shell {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
ScanReticle(modifier = Modifier.align(Alignment.Center))
SessionBadge(count = 3, modifier = Modifier.align(Alignment.TopCenter).padding(16.dp))
}
}
@Composable
private fun FoundSheetOverlay() = Shell {
Box(modifier = Modifier.fillMaxSize()) {
Surface(
modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth(),
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
color = MaterialTheme.colorScheme.surface,
) {
FoundBookSheet(
metadata = BookMetadata(
isbn13 = "9780765326355",
title = "The Way of Kings",
authors = listOf("Brandon Sanderson"),
publisher = "Tor",
publishedDate = "2010",
pageCount = 1007,
),
duplicate = DuplicateStatus.New,
bookcases = ScreenFixtures.bookcases,
shelves = ScreenFixtures.shelves,
selectedShelfId = ScreenFixtures.deskShelf.id,
onShelfSelected = {},
onSave = {},
onSkip = {},
)
}
}
}
@Composable
private fun Shell(overlay: @Composable () -> Unit) {
BookshelfScaffold(
title = "Scan",
navigationIcon = {
IconButton(onClick = {}) { Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = "Back") }
},
actions = {
IconButton(onClick = {}) { Icon(Icons.Outlined.FlashOff, contentDescription = "Toggle flashlight") }
IconButton(onClick = {}) { Icon(Icons.Outlined.Keyboard, contentDescription = "Enter ISBN manually") }
},
) { innerPadding ->
Box(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
// Static stand-in for the live CameraX viewfinder — see class doc.
.background(Color(0xFF1A1A1A)),
) {
overlay()
}
}
}
private fun snapshotBoth(name: String, content: @Composable () -> Unit) {
paparazzi.snapshot(name = "$name-light") { BookshelfTheme(darkTheme = false) { content() } }
paparazzi.snapshot(name = "$name-dark") { BookshelfTheme(darkTheme = true) { content() } }
}
}
@@ -0,0 +1,89 @@
package org.modg.bookshelf.ui.screens
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import app.cash.paparazzi.DeviceConfig
import app.cash.paparazzi.Paparazzi
import org.junit.Rule
import org.junit.Test
import org.modg.bookshelf.ui.components.BookshelfScaffold
import org.modg.bookshelf.ui.components.GoldDivider
import org.modg.bookshelf.ui.components.PaperSurface
import org.modg.bookshelf.ui.components.PrimaryButton
import org.modg.bookshelf.ui.components.SecondaryButton
import org.modg.bookshelf.ui.components.SyncStatus
import org.modg.bookshelf.ui.components.SyncStatusBar
import org.modg.bookshelf.ui.settings.InfoRow
import org.modg.bookshelf.ui.settings.SectionHeading
import org.modg.bookshelf.ui.settings.formatLastSync
import org.modg.bookshelf.ui.theme.BookshelfTheme
/**
* SPEC.md "settings" screen — server, account (now showing the signed-in
* email per the wave-4 fix), sync status, book/cover counts. Rebuilds
* [org.modg.bookshelf.ui.settings.SettingsScreen]'s shell around its own real
* [SectionHeading]/[InfoRow]/[formatLastSync], per the pattern in [ScreenFixtures].
*/
class SettingsScreenPaparazziTest {
@get:Rule
val paparazzi = Paparazzi(deviceConfig = DeviceConfig.PIXEL_6)
@Test
fun settingsPopulatedLight() = snapshotBoth("settings-populated") { Populated() }
@Composable
private fun Populated() = Shell()
@Composable
private fun Shell() {
val lastSync = System.currentTimeMillis() - 2 * 60 * 1000L
BookshelfScaffold(
title = "Settings",
navigationIcon = {
IconButton(onClick = {}) { Icon(Icons.Filled.ArrowBack, contentDescription = "Back") }
},
syncStatusBar = {
SyncStatusBar(status = SyncStatus.Synced, label = "Synced • ${formatLastSync(lastSync)}")
},
) { innerPadding ->
PaperSurface(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(innerPadding).padding(16.dp)) {
SectionHeading("Server")
InfoRow(label = "URL", value = "https://library.montanaro.home")
GoldDivider(modifier = Modifier.padding(vertical = 16.dp))
SectionHeading("Account")
InfoRow(label = "Signed in as", value = "reader@example.com")
SecondaryButton(text = "Sign out", onClick = {}, modifier = Modifier.padding(top = 8.dp))
GoldDivider(modifier = Modifier.padding(vertical = 16.dp))
SectionHeading("Sync")
InfoRow(label = "Last synced", value = formatLastSync(lastSync))
PrimaryButton(text = "Sync now", onClick = {}, modifier = Modifier.padding(top = 8.dp))
GoldDivider(modifier = Modifier.padding(vertical = 16.dp))
SectionHeading("Library")
InfoRow(label = "Books", value = ScreenFixtures.books.size.toString())
InfoRow(label = "Covers", value = "0")
}
}
}
}
private fun snapshotBoth(name: String, content: @Composable () -> Unit) {
paparazzi.snapshot(name = "$name-light") { BookshelfTheme(darkTheme = false) { content() } }
paparazzi.snapshot(name = "$name-dark") { BookshelfTheme(darkTheme = true) { content() } }
}
}
@@ -0,0 +1,140 @@
package org.modg.bookshelf.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import app.cash.paparazzi.DeviceConfig
import app.cash.paparazzi.Paparazzi
import org.junit.Rule
import org.junit.Test
import org.modg.bookshelf.ui.components.GoldDivider
import org.modg.bookshelf.ui.components.PaperSurface
import org.modg.bookshelf.ui.components.PrimaryButton
import org.modg.bookshelf.ui.theme.BookshelfTheme
/**
* SPEC.md "setup" screen — first run, server URL + email + password.
* [org.modg.bookshelf.ui.setup.SetupScreen] wires everything straight to
* [org.modg.bookshelf.ui.setup.SetupViewModel] with no internal presentational
* split, so per [ScreenFixtures]'s established approach this rebuilds the same
* layout inline with plain, filled-in state instead of driving a real
* ViewModel (whose reachability probe builds an OkHttpClient — see
* [ScreenFixtures] for why that blows up under Paparazzi).
*/
class SetupScreenPaparazziTest {
@get:Rule
val paparazzi = Paparazzi(deviceConfig = DeviceConfig.PIXEL_6)
@Test
fun setupFilledLight() = snapshotBoth("setup-filled") { Filled() }
@Composable
private fun Filled() = Shell(
serverUrl = "https://library.montanaro.home",
email = "reader@example.com",
password = "hunter2",
urlError = null,
credentialsError = null,
)
@Composable
private fun Shell(
serverUrl: String,
email: String,
password: String,
urlError: String?,
credentialsError: String?,
) {
PaperSurface(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(24.dp),
verticalArrangement = Arrangement.Center,
) {
Text(text = "Bookshelf", style = MaterialTheme.typography.displaySmall)
Text(
text = "Connect to your home library server to get started.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp, bottom = 24.dp),
)
OutlinedTextField(
value = serverUrl,
onValueChange = {},
label = { Text("Server URL") },
placeholder = { Text("https://library.example.com") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
isError = urlError != null,
modifier = Modifier.fillMaxWidth(),
)
if (urlError != null) {
Text(
text = urlError,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 4.dp),
)
}
GoldDivider(modifier = Modifier.padding(vertical = 16.dp))
OutlinedTextField(
value = email,
onValueChange = {},
label = { Text("Email") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
isError = credentialsError != null,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
)
OutlinedTextField(
value = password,
onValueChange = {},
label = { Text("Password") },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
isError = credentialsError != null,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
)
if (credentialsError != null) {
Text(
text = credentialsError,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 4.dp),
)
}
PrimaryButton(
text = "Sign in",
onClick = {},
modifier = Modifier.padding(top = 24.dp),
)
}
}
}
private fun snapshotBoth(name: String, content: @Composable () -> Unit) {
paparazzi.snapshot(name = "$name-light") { BookshelfTheme(darkTheme = false) { content() } }
paparazzi.snapshot(name = "$name-dark") { BookshelfTheme(darkTheme = true) { content() } }
}
}