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
@@ -1,3 +1,5 @@
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
// NOTE: no org.jetbrains.kotlin.android plugin — AGP 9's Kotlin support is
|
||||
@@ -8,6 +10,17 @@ plugins {
|
||||
alias(libs.plugins.paparazzi)
|
||||
}
|
||||
|
||||
// Release signing is optional: `keystore.properties` (gitignored, alongside the
|
||||
// keystore it points at) only exists on machines that own the release key. Anyone
|
||||
// else still gets a working `assembleRelease` — it just comes out debug-signed.
|
||||
val keystorePropertiesFile = rootProject.file("keystore.properties")
|
||||
val keystoreProperties = Properties().apply {
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
keystorePropertiesFile.inputStream().use { load(it) }
|
||||
}
|
||||
}
|
||||
val hasReleaseKeystore = keystorePropertiesFile.exists()
|
||||
|
||||
android {
|
||||
namespace = "org.modg.bookshelf"
|
||||
compileSdk = 37
|
||||
@@ -23,10 +36,24 @@ android {
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
if (hasReleaseKeystore) {
|
||||
create("release") {
|
||||
storeFile = rootProject.file(keystoreProperties["storeFile"] as String)
|
||||
storePassword = keystoreProperties["storePassword"] as String
|
||||
keyAlias = keystoreProperties["keyAlias"] as String
|
||||
keyPassword = keystoreProperties["keyPassword"] as String
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
if (hasReleaseKeystore) {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ class SettingsStore(private val context: Context) {
|
||||
val SERVER_URL = stringPreferencesKey("server_url")
|
||||
val AUTH_TOKEN = stringPreferencesKey("auth_token")
|
||||
val USER_ID = stringPreferencesKey("user_id")
|
||||
val USER_EMAIL = stringPreferencesKey("user_email")
|
||||
val LAST_SYNC_TIME = longPreferencesKey("last_sync_time")
|
||||
fun cursor(collection: String) = stringPreferencesKey("cursor_$collection")
|
||||
}
|
||||
@@ -31,6 +32,7 @@ class SettingsStore(private val context: Context) {
|
||||
val serverUrl: Flow<String?> = context.dataStore.data.map { it[Keys.SERVER_URL] }
|
||||
val authToken: Flow<String?> = context.dataStore.data.map { it[Keys.AUTH_TOKEN] }
|
||||
val userId: Flow<String?> = context.dataStore.data.map { it[Keys.USER_ID] }
|
||||
val userEmail: Flow<String?> = context.dataStore.data.map { it[Keys.USER_EMAIL] }
|
||||
val lastSyncTime: Flow<Long?> = context.dataStore.data.map { it[Keys.LAST_SYNC_TIME] }
|
||||
|
||||
fun cursorFor(collection: String): Flow<String?> =
|
||||
@@ -48,6 +50,10 @@ class SettingsStore(private val context: Context) {
|
||||
context.dataStore.edit { it[Keys.USER_ID] = userId }
|
||||
}
|
||||
|
||||
suspend fun setUserEmail(email: String) {
|
||||
context.dataStore.edit { it[Keys.USER_EMAIL] = email }
|
||||
}
|
||||
|
||||
suspend fun setCursor(collection: String, cursor: String) {
|
||||
context.dataStore.edit { it[Keys.cursor(collection)] = cursor }
|
||||
}
|
||||
@@ -61,6 +67,7 @@ class SettingsStore(private val context: Context) {
|
||||
context.dataStore.edit {
|
||||
it.remove(Keys.AUTH_TOKEN)
|
||||
it.remove(Keys.USER_ID)
|
||||
it.remove(Keys.USER_EMAIL)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ class AuthRepository(
|
||||
val response = api.authWithPassword(AuthWithPasswordRequest(identity = email, password = password))
|
||||
settingsStore.setAuthToken(response.token)
|
||||
settingsStore.setUserId(response.record.id)
|
||||
settingsStore.setUserEmail(email)
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
|
||||
@@ -76,7 +76,7 @@ fun SettingsScreen(
|
||||
GoldDivider(modifier = Modifier.padding(vertical = 16.dp))
|
||||
|
||||
SectionHeading("Account")
|
||||
InfoRow(label = "Signed in as", value = state.userId ?: "Unknown")
|
||||
InfoRow(label = "Signed in as", value = state.userEmail ?: "Unknown")
|
||||
SecondaryButton(
|
||||
text = "Sign out",
|
||||
onClick = { confirmSignOut = true },
|
||||
|
||||
@@ -17,7 +17,7 @@ import org.modg.bookshelf.ui.components.SyncStatus
|
||||
|
||||
data class SettingsUiState(
|
||||
val serverUrl: String? = null,
|
||||
val userId: String? = null,
|
||||
val userEmail: String? = null,
|
||||
val bookCount: Int = 0,
|
||||
val coverCount: Int = 0,
|
||||
val lastSyncTime: Long? = null,
|
||||
@@ -35,7 +35,7 @@ data class SettingsUiState(
|
||||
|
||||
private data class BaseInfo(
|
||||
val serverUrl: String?,
|
||||
val userId: String?,
|
||||
val userEmail: String?,
|
||||
val bookCount: Int,
|
||||
val coverCount: Int,
|
||||
val lastSyncTime: Long?,
|
||||
@@ -54,13 +54,13 @@ class SettingsViewModel(
|
||||
|
||||
private val baseInfo = combine(
|
||||
authRepository.serverUrl,
|
||||
settingsStore.userId,
|
||||
settingsStore.userEmail,
|
||||
bookRepository.observeAll(),
|
||||
settingsStore.lastSyncTime,
|
||||
) { url, userId, books, lastSync ->
|
||||
) { url, userEmail, books, lastSync ->
|
||||
BaseInfo(
|
||||
serverUrl = url,
|
||||
userId = userId,
|
||||
userEmail = userEmail,
|
||||
bookCount = books.size,
|
||||
coverCount = books.count { !it.coverUrl.isNullOrBlank() || !it.localCoverPath.isNullOrBlank() },
|
||||
lastSyncTime = lastSync,
|
||||
@@ -70,7 +70,7 @@ class SettingsViewModel(
|
||||
val uiState: StateFlow<SettingsUiState> = combine(baseInfo, isSyncing, syncError) { base, syncing, error ->
|
||||
SettingsUiState(
|
||||
serverUrl = base.serverUrl,
|
||||
userId = base.userId,
|
||||
userEmail = base.userEmail,
|
||||
bookCount = base.bookCount,
|
||||
coverCount = base.coverCount,
|
||||
lastSyncTime = base.lastSyncTime,
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package org.modg.bookshelf.data.repo
|
||||
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.modg.bookshelf.data.prefs.SettingsStore
|
||||
import org.modg.bookshelf.data.remote.PocketBaseApi
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/**
|
||||
* Covers the settings-email gap from the wave-4 handoff: Settings used to show the
|
||||
* PocketBase user id because login never persisted the entered email.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class AuthRepositoryTest {
|
||||
|
||||
private lateinit var settingsStore: SettingsStore
|
||||
private lateinit var api: FakePocketBaseApi
|
||||
private lateinit var repository: AuthRepository
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
settingsStore = SettingsStore(ApplicationProvider.getApplicationContext())
|
||||
api = FakePocketBaseApi()
|
||||
repository = AuthRepository(apiProvider = { api as PocketBaseApi }, settingsStore = settingsStore)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `login persists the entered email so Settings can show it`() = runTest {
|
||||
val result = repository.login(email = "reader@example.com", password = "hunter2")
|
||||
|
||||
assertTrue(result.isSuccess)
|
||||
assertEquals("reader@example.com", settingsStore.userEmail.first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sign out clears the persisted email`() = runTest {
|
||||
repository.login(email = "reader@example.com", password = "hunter2")
|
||||
|
||||
repository.signOut()
|
||||
|
||||
assertNull(settingsStore.userEmail.first())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package org.modg.bookshelf.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
|
||||
import androidx.compose.material.icons.outlined.Delete
|
||||
import androidx.compose.material.icons.outlined.Edit
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.cash.paparazzi.DeviceConfig
|
||||
import app.cash.paparazzi.Paparazzi
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.modg.bookshelf.ui.components.BookshelfScaffold
|
||||
import org.modg.bookshelf.ui.components.GoldDivider
|
||||
import org.modg.bookshelf.ui.components.PaperSurface
|
||||
import org.modg.bookshelf.ui.detail.BookHeader
|
||||
import org.modg.bookshelf.ui.detail.DescriptionSection
|
||||
import org.modg.bookshelf.ui.detail.LocationSection
|
||||
import org.modg.bookshelf.ui.detail.NotesSection
|
||||
import org.modg.bookshelf.ui.theme.BookshelfTheme
|
||||
|
||||
/**
|
||||
* SPEC.md "detail" screen — big cover, metadata, collapsible description,
|
||||
* editable notes, location picker. Rebuilds [org.modg.bookshelf.ui.detail.DetailScreen]'s
|
||||
* shell around its own real internal sections ([BookHeader], [DescriptionSection],
|
||||
* [NotesSection], [LocationSection]) with [ScreenFixtures.detailBook], per the
|
||||
* pattern in [ScreenFixtures].
|
||||
*/
|
||||
class DetailScreenPaparazziTest {
|
||||
|
||||
@get:Rule
|
||||
val paparazzi = Paparazzi(deviceConfig = DeviceConfig.PIXEL_6)
|
||||
|
||||
@Test
|
||||
fun detailPopulatedLight() = snapshotBoth("detail-populated") { Populated() }
|
||||
|
||||
@Composable
|
||||
private fun Populated() = Shell()
|
||||
|
||||
@Composable
|
||||
private fun Shell() {
|
||||
val book = ScreenFixtures.detailBook
|
||||
BookshelfScaffold(
|
||||
title = book.title,
|
||||
navigationIcon = {
|
||||
IconButton(onClick = {}) { Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = "Back") }
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = {}) { Icon(Icons.Outlined.Edit, contentDescription = "Edit") }
|
||||
IconButton(onClick = {}) { Icon(Icons.Outlined.Delete, contentDescription = "Delete") }
|
||||
},
|
||||
) { innerPadding ->
|
||||
PaperSurface(modifier = Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
) {
|
||||
BookHeader(book = book)
|
||||
GoldDivider(modifier = Modifier.padding(vertical = 16.dp))
|
||||
DescriptionSection(description = book.description)
|
||||
|
||||
GoldDivider(modifier = Modifier.padding(vertical = 16.dp))
|
||||
NotesSection(book = book, onSave = {})
|
||||
|
||||
GoldDivider(modifier = Modifier.padding(vertical = 16.dp))
|
||||
LocationSection(
|
||||
book = book,
|
||||
bookcases = ScreenFixtures.bookcases,
|
||||
shelves = ScreenFixtures.shelves,
|
||||
onShelfSelected = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun snapshotBoth(name: String, content: @Composable () -> Unit) {
|
||||
paparazzi.snapshot(name = "$name-light") { BookshelfTheme(darkTheme = false) { content() } }
|
||||
paparazzi.snapshot(name = "$name-dark") { BookshelfTheme(darkTheme = true) { content() } }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package org.modg.bookshelf.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.cash.paparazzi.DeviceConfig
|
||||
import app.cash.paparazzi.Paparazzi
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.modg.bookshelf.ui.components.BookshelfScaffold
|
||||
import org.modg.bookshelf.ui.components.GoldDivider
|
||||
import org.modg.bookshelf.ui.components.PaperSurface
|
||||
import org.modg.bookshelf.ui.locations.BookcaseRow
|
||||
import org.modg.bookshelf.ui.locations.BookcaseUi
|
||||
import org.modg.bookshelf.ui.locations.ShelfUi
|
||||
import org.modg.bookshelf.ui.theme.BookshelfTheme
|
||||
|
||||
/**
|
||||
* SPEC.md "locations" screen — bookcases -> shelves tree with per-shelf book
|
||||
* counts. Rebuilds [org.modg.bookshelf.ui.locations.LocationsScreen]'s shell
|
||||
* around its own real [BookcaseRow] (which nests the real `ShelfRow`), fed
|
||||
* counts derived from [ScreenFixtures.books], per the pattern in [ScreenFixtures].
|
||||
*/
|
||||
class LocationsScreenPaparazziTest {
|
||||
|
||||
@get:Rule
|
||||
val paparazzi = Paparazzi(deviceConfig = DeviceConfig.PIXEL_6)
|
||||
|
||||
@Test
|
||||
fun locationsPopulatedLight() = snapshotBoth("locations-populated") { Populated() }
|
||||
|
||||
@Composable
|
||||
private fun Populated() = Shell()
|
||||
|
||||
@Composable
|
||||
private fun Shell() {
|
||||
val bookcaseUis = ScreenFixtures.bookcases.map { bookcase ->
|
||||
BookcaseUi(
|
||||
bookcase = bookcase,
|
||||
shelves = ScreenFixtures.shelves
|
||||
.filter { it.bookcaseId == bookcase.id }
|
||||
.map { shelf -> ShelfUi(shelf, ScreenFixtures.books.count { it.shelfId == shelf.id }) },
|
||||
)
|
||||
}
|
||||
|
||||
BookshelfScaffold(
|
||||
title = "Locations",
|
||||
navigationIcon = {
|
||||
IconButton(onClick = {}) { Icon(Icons.Filled.ArrowBack, contentDescription = "Back") }
|
||||
},
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(onClick = {}) { Icon(Icons.Filled.Add, contentDescription = "Add bookcase") }
|
||||
},
|
||||
) { innerPadding ->
|
||||
PaperSurface(modifier = Modifier.fillMaxWidth()) {
|
||||
LazyColumn(contentPadding = PaddingValues(top = innerPadding.calculateTopPadding(), bottom = 96.dp)) {
|
||||
items(bookcaseUis, key = { it.bookcase.id }) { bookcaseUi ->
|
||||
BookcaseRow(
|
||||
bookcaseUi = bookcaseUi,
|
||||
onEdit = {},
|
||||
onDelete = {},
|
||||
onMoveUp = {},
|
||||
onMoveDown = {},
|
||||
onAddShelf = {},
|
||||
onShelfClick = {},
|
||||
onEditShelf = {},
|
||||
onDeleteShelf = {},
|
||||
onMoveShelfBooks = {},
|
||||
onShelfMoveUp = {},
|
||||
onShelfMoveDown = {},
|
||||
)
|
||||
GoldDivider(modifier = Modifier.padding(vertical = 4.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun snapshotBoth(name: String, content: @Composable () -> Unit) {
|
||||
paparazzi.snapshot(name = "$name-light") { BookshelfTheme(darkTheme = false) { content() } }
|
||||
paparazzi.snapshot(name = "$name-dark") { BookshelfTheme(darkTheme = true) { content() } }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package org.modg.bookshelf.ui.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
|
||||
import androidx.compose.material.icons.outlined.FlashOff
|
||||
import androidx.compose.material.icons.outlined.Keyboard
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.cash.paparazzi.DeviceConfig
|
||||
import app.cash.paparazzi.Paparazzi
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.modg.bookshelf.data.metadata.BookMetadata
|
||||
import org.modg.bookshelf.ui.components.BookshelfScaffold
|
||||
import org.modg.bookshelf.ui.scan.DuplicateStatus
|
||||
import org.modg.bookshelf.ui.scan.FoundBookSheet
|
||||
import org.modg.bookshelf.ui.scan.ScanReticle
|
||||
import org.modg.bookshelf.ui.scan.SessionBadge
|
||||
import org.modg.bookshelf.ui.theme.BookshelfTheme
|
||||
|
||||
/**
|
||||
* SPEC.md "scan" screen — camera + reticle, on-hit bottom sheet.
|
||||
*
|
||||
* Paparazzi has no real camera: [org.modg.bookshelf.ui.scan.ScanScreen]'s
|
||||
* `CameraPreview` binds a live `ProcessCameraProvider`/`PreviewView`, which
|
||||
* cannot run headless (same class of problem [ScreenFixtures] documents for
|
||||
* Room/OkHttp). So this renders the screen's real overlay pieces — [ScanReticle],
|
||||
* [SessionBadge], and the real [FoundBookSheet] — over a plain dark backdrop
|
||||
* standing in for the live viewfinder, per the wave-4 task instructions.
|
||||
*/
|
||||
class ScanScreenPaparazziTest {
|
||||
|
||||
@get:Rule
|
||||
val paparazzi = Paparazzi(deviceConfig = DeviceConfig.PIXEL_6)
|
||||
|
||||
@Test
|
||||
fun scanReticleLight() = snapshotBoth("scan-reticle") { ReticleOverlay() }
|
||||
|
||||
@Test
|
||||
fun scanFoundSheetLight() = snapshotBoth("scan-found-sheet") { FoundSheetOverlay() }
|
||||
|
||||
@Composable
|
||||
private fun ReticleOverlay() = Shell {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
ScanReticle(modifier = Modifier.align(Alignment.Center))
|
||||
SessionBadge(count = 3, modifier = Modifier.align(Alignment.TopCenter).padding(16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FoundSheetOverlay() = Shell {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Surface(
|
||||
modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth(),
|
||||
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
) {
|
||||
FoundBookSheet(
|
||||
metadata = BookMetadata(
|
||||
isbn13 = "9780765326355",
|
||||
title = "The Way of Kings",
|
||||
authors = listOf("Brandon Sanderson"),
|
||||
publisher = "Tor",
|
||||
publishedDate = "2010",
|
||||
pageCount = 1007,
|
||||
),
|
||||
duplicate = DuplicateStatus.New,
|
||||
bookcases = ScreenFixtures.bookcases,
|
||||
shelves = ScreenFixtures.shelves,
|
||||
selectedShelfId = ScreenFixtures.deskShelf.id,
|
||||
onShelfSelected = {},
|
||||
onSave = {},
|
||||
onSkip = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Shell(overlay: @Composable () -> Unit) {
|
||||
BookshelfScaffold(
|
||||
title = "Scan",
|
||||
navigationIcon = {
|
||||
IconButton(onClick = {}) { Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = "Back") }
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = {}) { Icon(Icons.Outlined.FlashOff, contentDescription = "Toggle flashlight") }
|
||||
IconButton(onClick = {}) { Icon(Icons.Outlined.Keyboard, contentDescription = "Enter ISBN manually") }
|
||||
},
|
||||
) { innerPadding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.fillMaxSize()
|
||||
// Static stand-in for the live CameraX viewfinder — see class doc.
|
||||
.background(Color(0xFF1A1A1A)),
|
||||
) {
|
||||
overlay()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun snapshotBoth(name: String, content: @Composable () -> Unit) {
|
||||
paparazzi.snapshot(name = "$name-light") { BookshelfTheme(darkTheme = false) { content() } }
|
||||
paparazzi.snapshot(name = "$name-dark") { BookshelfTheme(darkTheme = true) { content() } }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package org.modg.bookshelf.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.cash.paparazzi.DeviceConfig
|
||||
import app.cash.paparazzi.Paparazzi
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.modg.bookshelf.ui.components.BookshelfScaffold
|
||||
import org.modg.bookshelf.ui.components.GoldDivider
|
||||
import org.modg.bookshelf.ui.components.PaperSurface
|
||||
import org.modg.bookshelf.ui.components.PrimaryButton
|
||||
import org.modg.bookshelf.ui.components.SecondaryButton
|
||||
import org.modg.bookshelf.ui.components.SyncStatus
|
||||
import org.modg.bookshelf.ui.components.SyncStatusBar
|
||||
import org.modg.bookshelf.ui.settings.InfoRow
|
||||
import org.modg.bookshelf.ui.settings.SectionHeading
|
||||
import org.modg.bookshelf.ui.settings.formatLastSync
|
||||
import org.modg.bookshelf.ui.theme.BookshelfTheme
|
||||
|
||||
/**
|
||||
* SPEC.md "settings" screen — server, account (now showing the signed-in
|
||||
* email per the wave-4 fix), sync status, book/cover counts. Rebuilds
|
||||
* [org.modg.bookshelf.ui.settings.SettingsScreen]'s shell around its own real
|
||||
* [SectionHeading]/[InfoRow]/[formatLastSync], per the pattern in [ScreenFixtures].
|
||||
*/
|
||||
class SettingsScreenPaparazziTest {
|
||||
|
||||
@get:Rule
|
||||
val paparazzi = Paparazzi(deviceConfig = DeviceConfig.PIXEL_6)
|
||||
|
||||
@Test
|
||||
fun settingsPopulatedLight() = snapshotBoth("settings-populated") { Populated() }
|
||||
|
||||
@Composable
|
||||
private fun Populated() = Shell()
|
||||
|
||||
@Composable
|
||||
private fun Shell() {
|
||||
val lastSync = System.currentTimeMillis() - 2 * 60 * 1000L
|
||||
BookshelfScaffold(
|
||||
title = "Settings",
|
||||
navigationIcon = {
|
||||
IconButton(onClick = {}) { Icon(Icons.Filled.ArrowBack, contentDescription = "Back") }
|
||||
},
|
||||
syncStatusBar = {
|
||||
SyncStatusBar(status = SyncStatus.Synced, label = "Synced • ${formatLastSync(lastSync)}")
|
||||
},
|
||||
) { innerPadding ->
|
||||
PaperSurface(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(innerPadding).padding(16.dp)) {
|
||||
SectionHeading("Server")
|
||||
InfoRow(label = "URL", value = "https://library.montanaro.home")
|
||||
|
||||
GoldDivider(modifier = Modifier.padding(vertical = 16.dp))
|
||||
|
||||
SectionHeading("Account")
|
||||
InfoRow(label = "Signed in as", value = "reader@example.com")
|
||||
SecondaryButton(text = "Sign out", onClick = {}, modifier = Modifier.padding(top = 8.dp))
|
||||
|
||||
GoldDivider(modifier = Modifier.padding(vertical = 16.dp))
|
||||
|
||||
SectionHeading("Sync")
|
||||
InfoRow(label = "Last synced", value = formatLastSync(lastSync))
|
||||
PrimaryButton(text = "Sync now", onClick = {}, modifier = Modifier.padding(top = 8.dp))
|
||||
|
||||
GoldDivider(modifier = Modifier.padding(vertical = 16.dp))
|
||||
|
||||
SectionHeading("Library")
|
||||
InfoRow(label = "Books", value = ScreenFixtures.books.size.toString())
|
||||
InfoRow(label = "Covers", value = "0")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun snapshotBoth(name: String, content: @Composable () -> Unit) {
|
||||
paparazzi.snapshot(name = "$name-light") { BookshelfTheme(darkTheme = false) { content() } }
|
||||
paparazzi.snapshot(name = "$name-dark") { BookshelfTheme(darkTheme = true) { content() } }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package org.modg.bookshelf.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.cash.paparazzi.DeviceConfig
|
||||
import app.cash.paparazzi.Paparazzi
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.modg.bookshelf.ui.components.GoldDivider
|
||||
import org.modg.bookshelf.ui.components.PaperSurface
|
||||
import org.modg.bookshelf.ui.components.PrimaryButton
|
||||
import org.modg.bookshelf.ui.theme.BookshelfTheme
|
||||
|
||||
/**
|
||||
* SPEC.md "setup" screen — first run, server URL + email + password.
|
||||
* [org.modg.bookshelf.ui.setup.SetupScreen] wires everything straight to
|
||||
* [org.modg.bookshelf.ui.setup.SetupViewModel] with no internal presentational
|
||||
* split, so per [ScreenFixtures]'s established approach this rebuilds the same
|
||||
* layout inline with plain, filled-in state instead of driving a real
|
||||
* ViewModel (whose reachability probe builds an OkHttpClient — see
|
||||
* [ScreenFixtures] for why that blows up under Paparazzi).
|
||||
*/
|
||||
class SetupScreenPaparazziTest {
|
||||
|
||||
@get:Rule
|
||||
val paparazzi = Paparazzi(deviceConfig = DeviceConfig.PIXEL_6)
|
||||
|
||||
@Test
|
||||
fun setupFilledLight() = snapshotBoth("setup-filled") { Filled() }
|
||||
|
||||
@Composable
|
||||
private fun Filled() = Shell(
|
||||
serverUrl = "https://library.montanaro.home",
|
||||
email = "reader@example.com",
|
||||
password = "hunter2",
|
||||
urlError = null,
|
||||
credentialsError = null,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun Shell(
|
||||
serverUrl: String,
|
||||
email: String,
|
||||
password: String,
|
||||
urlError: String?,
|
||||
credentialsError: String?,
|
||||
) {
|
||||
PaperSurface(modifier = Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(text = "Bookshelf", style = MaterialTheme.typography.displaySmall)
|
||||
Text(
|
||||
text = "Connect to your home library server to get started.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 8.dp, bottom = 24.dp),
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = serverUrl,
|
||||
onValueChange = {},
|
||||
label = { Text("Server URL") },
|
||||
placeholder = { Text("https://library.example.com") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||
isError = urlError != null,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
if (urlError != null) {
|
||||
Text(
|
||||
text = urlError,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
GoldDivider(modifier = Modifier.padding(vertical = 16.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = email,
|
||||
onValueChange = {},
|
||||
label = { Text("Email") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
|
||||
isError = credentialsError != null,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = {},
|
||||
label = { Text("Password") },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
isError = credentialsError != null,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
)
|
||||
if (credentialsError != null) {
|
||||
Text(
|
||||
text = credentialsError,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
PrimaryButton(
|
||||
text = "Sign in",
|
||||
onClick = {},
|
||||
modifier = Modifier.padding(top = 24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun snapshotBoth(name: String, content: @Composable () -> Unit) {
|
||||
paparazzi.snapshot(name = "$name-light") { BookshelfTheme(darkTheme = false) { content() } }
|
||||
paparazzi.snapshot(name = "$name-dark") { BookshelfTheme(darkTheme = true) { content() } }
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 27 KiB |