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
|
local.properties
|
||||||
**/build/
|
**/build/
|
||||||
.kotlin/
|
.kotlin/
|
||||||
|
keystore.properties
|
||||||
|
*.jks
|
||||||
|
*.keystore
|
||||||
|
|||||||
@@ -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() } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 |