diff --git a/README.md b/README.md new file mode 100644 index 0000000..5e61ba0 --- /dev/null +++ b/README.md @@ -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 +./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. diff --git a/app/.gitignore b/app/.gitignore index 698018d..ab6baee 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -10,3 +10,6 @@ local.properties **/build/ .kotlin/ +keystore.properties +*.jks +*.keystore diff --git a/app/app/build.gradle.kts b/app/app/build.gradle.kts index 38998ca..0554d24 100644 --- a/app/app/build.gradle.kts +++ b/app/app/build.gradle.kts @@ -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") + } } } diff --git a/app/app/src/main/java/org/modg/bookshelf/data/prefs/SettingsStore.kt b/app/app/src/main/java/org/modg/bookshelf/data/prefs/SettingsStore.kt index 4f802c4..b7a2817 100644 --- a/app/app/src/main/java/org/modg/bookshelf/data/prefs/SettingsStore.kt +++ b/app/app/src/main/java/org/modg/bookshelf/data/prefs/SettingsStore.kt @@ -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 = context.dataStore.data.map { it[Keys.SERVER_URL] } val authToken: Flow = context.dataStore.data.map { it[Keys.AUTH_TOKEN] } val userId: Flow = context.dataStore.data.map { it[Keys.USER_ID] } + val userEmail: Flow = context.dataStore.data.map { it[Keys.USER_EMAIL] } val lastSyncTime: Flow = context.dataStore.data.map { it[Keys.LAST_SYNC_TIME] } fun cursorFor(collection: String): Flow = @@ -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) } } diff --git a/app/app/src/main/java/org/modg/bookshelf/data/repo/AuthRepository.kt b/app/app/src/main/java/org/modg/bookshelf/data/repo/AuthRepository.kt index 4c2b74d..366c33c 100644 --- a/app/app/src/main/java/org/modg/bookshelf/data/repo/AuthRepository.kt +++ b/app/app/src/main/java/org/modg/bookshelf/data/repo/AuthRepository.kt @@ -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) diff --git a/app/app/src/main/java/org/modg/bookshelf/ui/settings/SettingsScreen.kt b/app/app/src/main/java/org/modg/bookshelf/ui/settings/SettingsScreen.kt index ac068ac..17607d3 100644 --- a/app/app/src/main/java/org/modg/bookshelf/ui/settings/SettingsScreen.kt +++ b/app/app/src/main/java/org/modg/bookshelf/ui/settings/SettingsScreen.kt @@ -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 }, diff --git a/app/app/src/main/java/org/modg/bookshelf/ui/settings/SettingsViewModel.kt b/app/app/src/main/java/org/modg/bookshelf/ui/settings/SettingsViewModel.kt index c8144ec..6d6d2e1 100644 --- a/app/app/src/main/java/org/modg/bookshelf/ui/settings/SettingsViewModel.kt +++ b/app/app/src/main/java/org/modg/bookshelf/ui/settings/SettingsViewModel.kt @@ -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 = 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, diff --git a/app/app/src/test/java/org/modg/bookshelf/data/repo/AuthRepositoryTest.kt b/app/app/src/test/java/org/modg/bookshelf/data/repo/AuthRepositoryTest.kt new file mode 100644 index 0000000..b16eebd --- /dev/null +++ b/app/app/src/test/java/org/modg/bookshelf/data/repo/AuthRepositoryTest.kt @@ -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()) + } +} diff --git a/app/app/src/test/java/org/modg/bookshelf/ui/screens/DetailScreenPaparazziTest.kt b/app/app/src/test/java/org/modg/bookshelf/ui/screens/DetailScreenPaparazziTest.kt new file mode 100644 index 0000000..500d674 --- /dev/null +++ b/app/app/src/test/java/org/modg/bookshelf/ui/screens/DetailScreenPaparazziTest.kt @@ -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() } } + } +} diff --git a/app/app/src/test/java/org/modg/bookshelf/ui/screens/LocationsScreenPaparazziTest.kt b/app/app/src/test/java/org/modg/bookshelf/ui/screens/LocationsScreenPaparazziTest.kt new file mode 100644 index 0000000..b5ec1b0 --- /dev/null +++ b/app/app/src/test/java/org/modg/bookshelf/ui/screens/LocationsScreenPaparazziTest.kt @@ -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() } } + } +} diff --git a/app/app/src/test/java/org/modg/bookshelf/ui/screens/ScanScreenPaparazziTest.kt b/app/app/src/test/java/org/modg/bookshelf/ui/screens/ScanScreenPaparazziTest.kt new file mode 100644 index 0000000..76270b3 --- /dev/null +++ b/app/app/src/test/java/org/modg/bookshelf/ui/screens/ScanScreenPaparazziTest.kt @@ -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() } } + } +} diff --git a/app/app/src/test/java/org/modg/bookshelf/ui/screens/SettingsScreenPaparazziTest.kt b/app/app/src/test/java/org/modg/bookshelf/ui/screens/SettingsScreenPaparazziTest.kt new file mode 100644 index 0000000..354d178 --- /dev/null +++ b/app/app/src/test/java/org/modg/bookshelf/ui/screens/SettingsScreenPaparazziTest.kt @@ -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() } } + } +} diff --git a/app/app/src/test/java/org/modg/bookshelf/ui/screens/SetupScreenPaparazziTest.kt b/app/app/src/test/java/org/modg/bookshelf/ui/screens/SetupScreenPaparazziTest.kt new file mode 100644 index 0000000..bb1f0a3 --- /dev/null +++ b/app/app/src/test/java/org/modg/bookshelf/ui/screens/SetupScreenPaparazziTest.kt @@ -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() } } + } +} diff --git a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_DetailScreenPaparazziTest_detailPopulatedLight_detail-populated-dark.png b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_DetailScreenPaparazziTest_detailPopulatedLight_detail-populated-dark.png new file mode 100644 index 0000000..87f40d5 Binary files /dev/null and b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_DetailScreenPaparazziTest_detailPopulatedLight_detail-populated-dark.png differ diff --git a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_DetailScreenPaparazziTest_detailPopulatedLight_detail-populated-light.png b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_DetailScreenPaparazziTest_detailPopulatedLight_detail-populated-light.png new file mode 100644 index 0000000..88b8b96 Binary files /dev/null and b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_DetailScreenPaparazziTest_detailPopulatedLight_detail-populated-light.png differ diff --git a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_LocationsScreenPaparazziTest_locationsPopulatedLight_locations-populated-dark.png b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_LocationsScreenPaparazziTest_locationsPopulatedLight_locations-populated-dark.png new file mode 100644 index 0000000..808d1c6 Binary files /dev/null and b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_LocationsScreenPaparazziTest_locationsPopulatedLight_locations-populated-dark.png differ diff --git a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_LocationsScreenPaparazziTest_locationsPopulatedLight_locations-populated-light.png b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_LocationsScreenPaparazziTest_locationsPopulatedLight_locations-populated-light.png new file mode 100644 index 0000000..1b2f4a6 Binary files /dev/null and b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_LocationsScreenPaparazziTest_locationsPopulatedLight_locations-populated-light.png differ diff --git a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_ScanScreenPaparazziTest_scanFoundSheetLight_scan-found-sheet-dark.png b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_ScanScreenPaparazziTest_scanFoundSheetLight_scan-found-sheet-dark.png new file mode 100644 index 0000000..6826fd1 Binary files /dev/null and b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_ScanScreenPaparazziTest_scanFoundSheetLight_scan-found-sheet-dark.png differ diff --git a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_ScanScreenPaparazziTest_scanFoundSheetLight_scan-found-sheet-light.png b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_ScanScreenPaparazziTest_scanFoundSheetLight_scan-found-sheet-light.png new file mode 100644 index 0000000..54f0771 Binary files /dev/null and b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_ScanScreenPaparazziTest_scanFoundSheetLight_scan-found-sheet-light.png differ diff --git a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_ScanScreenPaparazziTest_scanReticleLight_scan-reticle-dark.png b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_ScanScreenPaparazziTest_scanReticleLight_scan-reticle-dark.png new file mode 100644 index 0000000..b850c48 Binary files /dev/null and b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_ScanScreenPaparazziTest_scanReticleLight_scan-reticle-dark.png differ diff --git a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_ScanScreenPaparazziTest_scanReticleLight_scan-reticle-light.png b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_ScanScreenPaparazziTest_scanReticleLight_scan-reticle-light.png new file mode 100644 index 0000000..88dddc0 Binary files /dev/null and b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_ScanScreenPaparazziTest_scanReticleLight_scan-reticle-light.png differ diff --git a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_SettingsScreenPaparazziTest_settingsPopulatedLight_settings-populated-dark.png b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_SettingsScreenPaparazziTest_settingsPopulatedLight_settings-populated-dark.png new file mode 100644 index 0000000..b9f9839 Binary files /dev/null and b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_SettingsScreenPaparazziTest_settingsPopulatedLight_settings-populated-dark.png differ diff --git a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_SettingsScreenPaparazziTest_settingsPopulatedLight_settings-populated-light.png b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_SettingsScreenPaparazziTest_settingsPopulatedLight_settings-populated-light.png new file mode 100644 index 0000000..1354963 Binary files /dev/null and b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_SettingsScreenPaparazziTest_settingsPopulatedLight_settings-populated-light.png differ diff --git a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_SetupScreenPaparazziTest_setupFilledLight_setup-filled-dark.png b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_SetupScreenPaparazziTest_setupFilledLight_setup-filled-dark.png new file mode 100644 index 0000000..d369627 Binary files /dev/null and b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_SetupScreenPaparazziTest_setupFilledLight_setup-filled-dark.png differ diff --git a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_SetupScreenPaparazziTest_setupFilledLight_setup-filled-light.png b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_SetupScreenPaparazziTest_setupFilledLight_setup-filled-light.png new file mode 100644 index 0000000..5460fa7 Binary files /dev/null and b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_SetupScreenPaparazziTest_setupFilledLight_setup-filled-light.png differ