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
@@ -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.
|
||||
@@ -10,3 +10,6 @@
|
||||
local.properties
|
||||
**/build/
|
||||
.kotlin/
|
||||
keystore.properties
|
||||
*.jks
|
||||
*.keystore
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
// NOTE: no org.jetbrains.kotlin.android plugin — AGP 9's Kotlin support is
|
||||
@@ -8,6 +10,17 @@ plugins {
|
||||
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 {
|
||||
namespace = "org.modg.bookshelf"
|
||||
compileSdk = 37
|
||||
@@ -23,10 +36,24 @@ android {
|
||||
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 {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
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 AUTH_TOKEN = stringPreferencesKey("auth_token")
|
||||
val USER_ID = stringPreferencesKey("user_id")
|
||||
val USER_EMAIL = stringPreferencesKey("user_email")
|
||||
val LAST_SYNC_TIME = longPreferencesKey("last_sync_time")
|
||||
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 authToken: Flow<String?> = context.dataStore.data.map { it[Keys.AUTH_TOKEN] }
|
||||
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] }
|
||||
|
||||
fun cursorFor(collection: String): Flow<String?> =
|
||||
@@ -48,6 +50,10 @@ class SettingsStore(private val context: Context) {
|
||||
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) {
|
||||
context.dataStore.edit { it[Keys.cursor(collection)] = cursor }
|
||||
}
|
||||
@@ -61,6 +67,7 @@ class SettingsStore(private val context: Context) {
|
||||
context.dataStore.edit {
|
||||
it.remove(Keys.AUTH_TOKEN)
|
||||
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))
|
||||
settingsStore.setAuthToken(response.token)
|
||||
settingsStore.setUserId(response.record.id)
|
||||
settingsStore.setUserEmail(email)
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
|
||||
@@ -76,7 +76,7 @@ fun SettingsScreen(
|
||||
GoldDivider(modifier = Modifier.padding(vertical = 16.dp))
|
||||
|
||||
SectionHeading("Account")
|
||||
InfoRow(label = "Signed in as", value = state.userId ?: "Unknown")
|
||||
InfoRow(label = "Signed in as", value = state.userEmail ?: "Unknown")
|
||||
SecondaryButton(
|
||||
text = "Sign out",
|
||||
onClick = { confirmSignOut = true },
|
||||
|
||||
@@ -17,7 +17,7 @@ import org.modg.bookshelf.ui.components.SyncStatus
|
||||
|
||||
data class SettingsUiState(
|
||||
val serverUrl: String? = null,
|
||||
val userId: String? = null,
|
||||
val userEmail: String? = null,
|
||||
val bookCount: Int = 0,
|
||||
val coverCount: Int = 0,
|
||||
val lastSyncTime: Long? = null,
|
||||
@@ -35,7 +35,7 @@ data class SettingsUiState(
|
||||
|
||||
private data class BaseInfo(
|
||||
val serverUrl: String?,
|
||||
val userId: String?,
|
||||
val userEmail: String?,
|
||||
val bookCount: Int,
|
||||
val coverCount: Int,
|
||||
val lastSyncTime: Long?,
|
||||
@@ -54,13 +54,13 @@ class SettingsViewModel(
|
||||
|
||||
private val baseInfo = combine(
|
||||
authRepository.serverUrl,
|
||||
settingsStore.userId,
|
||||
settingsStore.userEmail,
|
||||
bookRepository.observeAll(),
|
||||
settingsStore.lastSyncTime,
|
||||
) { url, userId, books, lastSync ->
|
||||
) { url, userEmail, books, lastSync ->
|
||||
BaseInfo(
|
||||
serverUrl = url,
|
||||
userId = userId,
|
||||
userEmail = userEmail,
|
||||
bookCount = books.size,
|
||||
coverCount = books.count { !it.coverUrl.isNullOrBlank() || !it.localCoverPath.isNullOrBlank() },
|
||||
lastSyncTime = lastSync,
|
||||
@@ -70,7 +70,7 @@ class SettingsViewModel(
|
||||
val uiState: StateFlow<SettingsUiState> = combine(baseInfo, isSyncing, syncError) { base, syncing, error ->
|
||||
SettingsUiState(
|
||||
serverUrl = base.serverUrl,
|
||||
userId = base.userId,
|
||||
userEmail = base.userEmail,
|
||||
bookCount = base.bookCount,
|
||||
coverCount = base.coverCount,
|
||||
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() } }
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 27 KiB |