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

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

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

The signed APK and the keystore are intentionally not committed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TyzeWmdTqi7U85iYNGy7P
This commit is contained in:
Sprite
2026-09-08 21:28:11 +00:00
co-authored by claude
parent 36b46b4644
commit 5455df2d61
25 changed files with 811 additions and 7 deletions
+3
View File
@@ -10,3 +10,6 @@
local.properties
**/build/
.kotlin/
keystore.properties
*.jks
*.keystore
+27
View File
@@ -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() } }
}
}