Second on-device feedback round: eight fixes, and a measured retry policy

The ghost bookcase was an inset bug, not a data bug. LocationsScreen's list
branch dropped the Scaffold's innerPadding while its empty-state branch applied
it, so the first bookcase row rendered under the top app bar and was invisible.
Both of the user's bookcases were always real; they just could not see the first
one, so they made a second. Every other screen was checked for the same class of
bug — Locations was the only one.

Metadata lookup is now designed against a measurement rather than a guess
(docs/METADATA-SOURCES.md § "Measured again 2026-09-09"):

  - Google Books keyless is dead for everyone, permanently. The user's
    residential-IP test returned a quota error naming a shared anonymous PROJECT,
    not an IP, so the earlier "your phone may well get answers" guess is wrong and
    is now marked CORRECTED in place. Because combine() turns any Failed-with-no-
    Found into Unavailable, that standing failure meant every Open Library hiccup
    surfaced as "one or more sources couldn't be reached". The API key is
    deliberately deferred by the user; this commit leaves the source broken.

  - Our own timeouts were manufacturing failures. Over 30 live requests, 13%
    failed — all fast TLS resets under 2.5s — while successes ran to a median of
    4.3s and a max of 22.0s. Two of 26 successes exceeded the old 12s callTimeout,
    so ~8% of lookups that were about to work were cancelled and reported as
    unreachable. Timeouts are now 25s/20s/20s.

That asymmetry (cheap failures, expensive successes) is what RetryPolicy encodes.
It retries TRANSPORT and SERVER_ERROR with a 250ms/750ms jittered backoff, and
deliberately does not retry TIMEOUT (the budget is already spent) or RATE_LIMITED
(hammering a quota is how an intermittent block becomes a permanent one — this
project's IP has already been refused outright once during research).

SourceResult.Failed now carries a FailureKind alongside its human reason, and the
reason names the specific failure ("tls connection reset, 3 attempts") instead of
a generic "network error". That string was already threaded to the UI and dropped
on the floor; LookupFailedSheet now renders it. It is the only diagnostic channel
we have from a real phone, so nothing may parse it.

Also from the same feedback round:
  - Grouped ModalBottomSheet shelf picker, replacing two near-duplicate flat
    dropdowns that listed every bookcase x shelf pair. Sections per bookcase,
    empty bookcases say so, and the most recently used shelf is pinned on top.
  - The recent shelf persists across sessions (SettingsStore.LAST_SHELF_ID) and is
    cleared on sign-out. It is offered, never pre-selected: the user weighed that
    and chose one tap over the risk of silently mis-shelving a book.
  - Locations dialogs and the manual-ISBN dialog auto-focus their first field.
  - The library filter menu offers "Add a bookcase to enable filtering" instead of
    a lone "All books" that is already the active state and cannot be changed.
  - The Locations button is Material Symbols' "shelves" (a bookcase) instead of
    Warehouse (a barn). material-icons-extended 1.7.8 has no bookcase glyph.
  - The scan sheet drops "you can lower the book" — the ISBN echo already says it.

assembleDebug exit 0; testDebugUnitTest 172 tests, 1 skipped, 0 failures (was
138); verifyPaparazziDebug exit 0; assembleRelease exit 0, signed with the real
release key; zero "always 'false'" warnings on a --rerun-tasks rebuild.

Three soft spots are recorded in docs/HANDOFF.md and are NOT verified: the
ghost-bookcase Paparazzi snapshot renders a lookalike of the screen rather than
the screen, the auto-focus calls swallow their own failure and no emulator exists
here, and the picker opens as a sheet stacked on the save sheet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LSnVqWdiQNEcPFRq1hGZAi
This commit is contained in:
Sprite
2026-09-09 17:45:09 +00:00
co-authored by claude
parent dd3a61fc33
commit d6d02f788c
44 changed files with 1740 additions and 127 deletions
@@ -93,11 +93,24 @@ class AppContainer(private val context: Context) {
// server. A call timeout is load-bearing here: with none, a stalled
// connection hangs on OkHttp's default (unbounded) socket timeouts, and the
// user is standing at a bookshelf waiting on it.
//
// These were 12s/10s/10s and were MANUFACTURING failures. Measuring the exact
// Open Library call 30 times on 2026-09-09 (docs/METADATA-SOURCES.md) found
// successful requests with a median of 4.3s but a long tail — p90 9.2s, max
// 22.0s, and one connect phase alone of 19.6s. Two of 26 successes exceeded the
// old 12s call timeout, so ~8% of lookups that were about to work were being
// cancelled and reported to the user as "couldn't be reached".
//
// Raising these does NOT slow the failure path: every observed failure was a
// TLS-stage reset returning in under 2.5s, and a socket that is going to break
// breaks long before any of these limits. The timeouts only ever bound the
// slow-success tail, which is precisely what we want to stop truncating.
// 25s > the 22.0s worst observed success, with room to spare.
private val metadataHttpClient: OkHttpClient by lazy {
OkHttpClient.Builder()
.callTimeout(12, TimeUnit.SECONDS)
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.callTimeout(25, TimeUnit.SECONDS)
.connectTimeout(20, TimeUnit.SECONDS)
.readTimeout(20, TimeUnit.SECONDS)
.build()
}
@@ -1,7 +1,6 @@
package org.modg.bookshelf.data.metadata
import java.io.IOException
import java.net.SocketTimeoutException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerializationException
@@ -20,7 +19,18 @@ class GoogleBooksClient(
) {
private val json = Json(from = json) { ignoreUnknownKeys = true }
suspend fun lookup(isbn13: String): SourceResult = withContext(Dispatchers.IO) { fetch(isbn13) }
/**
* Retries transient failures per [RetryPolicy]. Note this source's standing
* failure — keyless requests share one exhausted global quota and answer 429,
* which [RetryPolicy] deliberately does NOT retry, so today this costs nothing
* and changes nothing here. See docs/METADATA-SOURCES.md.
*/
suspend fun lookup(isbn13: String): SourceResult =
withContext(Dispatchers.IO) { withRetry { fetch(isbn13) } }
/** Single un-retried attempt, for tests that need to count calls. */
internal suspend fun lookupOnce(isbn13: String): SourceResult =
withContext(Dispatchers.IO) { fetch(isbn13) }
private fun fetch(isbn13: String): SourceResult = try {
val request = Request.Builder()
@@ -29,10 +39,8 @@ class GoogleBooksClient(
httpClient.newCall(request).execute().use { response ->
classify(response.code, response.body.string())
}
} catch (e: SocketTimeoutException) {
SourceResult.Failed("timeout")
} catch (e: IOException) {
SourceResult.Failed("network error")
SourceResult.fromException(e)
}
/**
@@ -42,16 +50,16 @@ class GoogleBooksClient(
* can never disagree about what a body means.
*/
internal fun classify(httpCode: Int, body: String?): SourceResult {
if (httpCode !in 200..299) return SourceResult.Failed("http $httpCode")
if (body.isNullOrBlank()) return SourceResult.Failed("empty body")
if (httpCode !in 200..299) return SourceResult.fromHttpCode(httpCode)
if (body.isNullOrBlank()) return SourceResult.Failed("empty body", FailureKind.MALFORMED)
return try {
val dto = json.decodeFromString(GoogleBooksResponseDto.serializer(), body)
val metadata = dto.items.firstOrNull()?.volumeInfo?.toBookMetadata()
if (metadata != null) SourceResult.Found(metadata) else SourceResult.NotFound
} catch (e: SerializationException) {
SourceResult.Failed("malformed json")
SourceResult.Failed("malformed json", FailureKind.MALFORMED)
} catch (e: IllegalArgumentException) {
SourceResult.Failed("malformed json")
SourceResult.Failed("malformed json", FailureKind.MALFORMED)
}
}
@@ -1,7 +1,6 @@
package org.modg.bookshelf.data.metadata
import java.io.IOException
import java.net.SocketTimeoutException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerializationException
@@ -23,7 +22,17 @@ class OpenLibraryClient(
// Real responses carry fields this DTO doesn't model; never let an unknown key throw.
private val json = Json(from = json) { ignoreUnknownKeys = true }
suspend fun lookup(isbn13: String): SourceResult = withContext(Dispatchers.IO) { fetch(isbn13) }
/**
* Retries transient failures per [RetryPolicy]. Measured against the live API,
* 13% of requests fail at the TLS stage in well under a second while successful
* ones take seconds — so a retry is nearly free and removes most of that 13%.
*/
suspend fun lookup(isbn13: String): SourceResult =
withContext(Dispatchers.IO) { withRetry { fetch(isbn13) } }
/** Single un-retried attempt, for tests that need to count calls. */
internal suspend fun lookupOnce(isbn13: String): SourceResult =
withContext(Dispatchers.IO) { fetch(isbn13) }
private fun fetch(isbn13: String): SourceResult = try {
val request = Request.Builder()
@@ -32,10 +41,8 @@ class OpenLibraryClient(
httpClient.newCall(request).execute().use { response ->
classify(response.code, response.body.string(), isbn13)
}
} catch (e: SocketTimeoutException) {
SourceResult.Failed("timeout")
} catch (e: IOException) {
SourceResult.Failed("network error")
SourceResult.fromException(e)
}
/**
@@ -45,16 +52,16 @@ class OpenLibraryClient(
* can never disagree about what a body means.
*/
internal fun classify(httpCode: Int, body: String?, isbn13: String): SourceResult {
if (httpCode !in 200..299) return SourceResult.Failed("http $httpCode")
if (body.isNullOrBlank()) return SourceResult.Failed("empty body")
if (httpCode !in 200..299) return SourceResult.fromHttpCode(httpCode)
if (body.isNullOrBlank()) return SourceResult.Failed("empty body", FailureKind.MALFORMED)
return try {
val root = json.parseToJsonElement(body).jsonObject
val entry = root["ISBN:$isbn13"]?.jsonObject ?: return SourceResult.NotFound
SourceResult.Found(json.decodeFromJsonElement<OpenLibraryBookDto>(entry).toBookMetadata(isbn13))
} catch (e: SerializationException) {
SourceResult.Failed("malformed json")
SourceResult.Failed("malformed json", FailureKind.MALFORMED)
} catch (e: IllegalArgumentException) {
SourceResult.Failed("malformed json")
SourceResult.Failed("malformed json", FailureKind.MALFORMED)
}
}
@@ -0,0 +1,110 @@
package org.modg.bookshelf.data.metadata
import kotlin.random.Random
import kotlinx.coroutines.delay
/**
* When a failed lookup attempt is worth repeating, and how long to wait first.
*
* Grounded in a measurement of the live Open Library API from 2026-09-09 — 30
* requests, the exact call [OpenLibraryClient] makes (see docs/METADATA-SOURCES.md):
*
* - 13% of requests failed, every one of them a TLS-stage connection reset.
* - Failures were FAST: 0.23s, 0.31s, 0.59s, 2.46s.
* - Successes were SLOW and long-tailed: median 4.3s, p90 9.2s, max 22.0s.
*
* That asymmetry drives every constant here. A retry costs roughly a fifth of a
* second of the user's time and turns a 13% failure rate into ~1.7% at two
* attempts and ~0.2% at three, which is why the backoff is short rather than the
* conventional exponential-with-seconds. It is also why [isRetryable] refuses to
* repeat a [FailureKind.TIMEOUT]: a timeout means we already spent the full budget
* on that attempt, so repeating it risks tripling the wait for a user standing at
* a bookshelf, and the measurement says slow requests usually eventually succeed
* rather than fail — the fix for those is a generous timeout, not another attempt.
*/
object RetryPolicy {
/** One original attempt plus two retries. Beyond this the marginal gain is noise. */
const val MAX_ATTEMPTS = 3
/**
* Stop starting NEW attempts once this much time has gone into a single source.
* A backstop against pathological cases (every attempt hitting the slow tail),
* not a normal-path limit. It is deliberately checked only BETWEEN attempts —
* an in-flight request is never cancelled, because the 22-second request in the
* sample was a successful one and killing it would manufacture exactly the
* failure this whole change exists to remove.
*/
const val TOTAL_BUDGET_MILLIS = 30_000L
/**
* [FailureKind.TRANSPORT] and [FailureKind.SERVER_ERROR] are transient and
* cheap to re-ask. The rest are not:
* - TIMEOUT — the budget is already spent; see the class KDoc.
* - RATE_LIMITED — the source is explicitly asking us to stop. Hammering a
* quota is how an intermittent block becomes a permanent one,
* and METADATA-SOURCES.md records that happening to this
* project's IP during research. When the Google Books API key
* lands, revisit this: a keyed 429 is a per-second rate limit
* and IS worth one Retry-After-respecting retry, unlike
* today's keyless daily-quota 429, which never clears.
* - CLIENT_ERROR — an identical request gets an identical answer.
* - MALFORMED — same bytes, same parse failure.
*/
fun isRetryable(kind: FailureKind): Boolean =
kind == FailureKind.TRANSPORT || kind == FailureKind.SERVER_ERROR
/**
* Backoff before attempt number [nextAttempt] (2-based: the wait before the
* first retry is `backoffMillis(2)`). 250ms then 750ms, plus up to 40% jitter
* so that two sources — or two phones in the same house — cannot fall into
* lockstep and hammer a recovering server in unison.
*/
fun backoffMillis(nextAttempt: Int, random: Random = Random.Default): Long {
val base = when (nextAttempt) {
2 -> 250L
else -> 750L
}
return base + random.nextLong(0, (base * 0.4).toLong().coerceAtLeast(1))
}
}
/**
* Runs [attempt] until it succeeds, fails un-retryably, or runs out of attempts or
* budget. Returns the LAST result, so the caller always sees a real outcome rather
* than a synthesised one.
*
* A [SourceResult.Failed] that survives retrying has its attempt count appended to
* [SourceResult.Failed.reason] ("tls connection reset, 3 attempts"). That string is
* shown to the user on the scan sheet and is the only diagnostic we get back from a
* real phone — "failed once" and "failed three times in a row" are very different
* stories about the network, and without this they are indistinguishable.
*
* [sleep] and [nowMillis] are injectable purely so tests can run the real policy
* with no wall-clock delay; production callers use the defaults.
*/
suspend fun withRetry(
maxAttempts: Int = RetryPolicy.MAX_ATTEMPTS,
budgetMillis: Long = RetryPolicy.TOTAL_BUDGET_MILLIS,
random: Random = Random.Default,
nowMillis: () -> Long = { System.currentTimeMillis() },
sleep: suspend (Long) -> Unit = { delay(it) },
attempt: suspend () -> SourceResult,
): SourceResult {
val started = nowMillis()
var last: SourceResult = attempt()
var attemptsMade = 1
while (attemptsMade < maxAttempts) {
val failure = last as? SourceResult.Failed ?: return last
if (!RetryPolicy.isRetryable(failure.kind)) break
if (nowMillis() - started >= budgetMillis) break
sleep(RetryPolicy.backoffMillis(attemptsMade + 1, random))
last = attempt()
attemptsMade++
}
val failure = last as? SourceResult.Failed ?: return last
return if (attemptsMade > 1) failure.copy(reason = "${failure.reason}, $attemptsMade attempts") else failure
}
@@ -1,5 +1,39 @@
package org.modg.bookshelf.data.metadata
import java.io.IOException
import java.io.InterruptedIOException
import java.net.ConnectException
import java.net.SocketException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import javax.net.ssl.SSLException
import javax.net.ssl.SSLHandshakeException
/**
* Why a source failed, in the only terms that matter to a caller: is it worth
* asking again? [RetryPolicy] is the single place that decides, so the answer
* cannot drift between the two clients.
*/
enum class FailureKind {
/** Never got an answer: DNS, refused connection, reset socket, failed TLS handshake. */
TRANSPORT,
/** We gave up waiting. Distinct from [TRANSPORT] because the budget is already spent. */
TIMEOUT,
/** HTTP 429. The source is telling us to stop asking; asking harder is the wrong move. */
RATE_LIMITED,
/** HTTP 5xx — the source's problem, and usually a passing one. */
SERVER_ERROR,
/** HTTP 4xx other than 429. Repeating an identical request cannot change the answer. */
CLIENT_ERROR,
/** 2xx whose body we could not parse. Deterministic: the same bytes will fail again. */
MALFORMED,
}
/**
* Per-source lookup outcome (SPEC.md "Book metadata lookup": "Lookup outcome is
* THREE-WAY, never a bare null"). [OpenLibraryClient] and [GoogleBooksClient] each
@@ -14,10 +48,54 @@ sealed interface SourceResult {
data object NotFound : SourceResult
/**
* The source could not be asked, or its answer couldn't be trusted: non-2xx,
* timeout, transport error, or a body that didn't parse. [reason] is a short
* diagnostic ("http 429", "timeout", "malformed json") for logs — never shown
* to the user verbatim.
* The source could not be asked, or its answer couldn't be trusted. [reason] is
* a short diagnostic ("http 429", "tls reset", "malformed json") that is shown
* to the user as supplementary detail on the scan sheet and is our ONLY
* diagnostic channel from a real phone — so it names the specific failure, not
* a generic one. [kind] is the same fact in a form [RetryPolicy] can act on;
* nothing should ever parse [reason] to recover it.
*/
data class Failed(val reason: String) : SourceResult
data class Failed(val reason: String, val kind: FailureKind = FailureKind.TRANSPORT) : SourceResult
companion object {
/**
* Maps a thrown [IOException] to a specific failure. Both clients call this
* so they can never disagree, and so a new exception type gets classified
* once rather than twice.
*
* Measured against the live Open Library API on 2026-09-09 (30 requests):
* every observed failure was a TLS-stage `Connection reset by peer`, and all
* four came back in under 2.5s while successful requests took a median of
* 4.3s. Failures are cheap and fast; that asymmetry is the whole reason
* retrying is worth doing, and why the timeouts are set as generously as
* they are in `AppContainer.metadataHttpClient`.
*
* Order matters: [SocketTimeoutException] is an [InterruptedIOException] and
* [SSLHandshakeException] is an [SSLException], so the specific arms come
* first. OkHttp reports a blown `callTimeout` as a bare
* [InterruptedIOException], which is why that arm exists at all.
*/
fun fromException(e: IOException): Failed = when (e) {
is SocketTimeoutException -> Failed("timeout", FailureKind.TIMEOUT)
is InterruptedIOException -> Failed("timeout", FailureKind.TIMEOUT)
is UnknownHostException -> Failed("dns lookup failed", FailureKind.TRANSPORT)
is SSLHandshakeException -> Failed("tls handshake failed", FailureKind.TRANSPORT)
is SSLException -> Failed("tls connection reset", FailureKind.TRANSPORT)
is ConnectException -> Failed("connection refused", FailureKind.TRANSPORT)
is SocketException -> Failed("connection reset", FailureKind.TRANSPORT)
else -> Failed("network error (${e.javaClass.simpleName})", FailureKind.TRANSPORT)
}
/**
* Maps a non-2xx HTTP status to a specific failure. 429 is called out
* separately from the rest of 4xx because it is the one client error that is
* about us rather than about the request, and because it is currently
* Google Books' permanent state — see docs/METADATA-SOURCES.md.
*/
fun fromHttpCode(code: Int): Failed = when {
code == 429 -> Failed("http 429 (rate limited)", FailureKind.RATE_LIMITED)
code in 500..599 -> Failed("http $code (server error)", FailureKind.SERVER_ERROR)
else -> Failed("http $code", FailureKind.CLIENT_ERROR)
}
}
}
@@ -26,6 +26,7 @@ class SettingsStore(private val context: Context) {
val USER_ID = stringPreferencesKey("user_id")
val USER_EMAIL = stringPreferencesKey("user_email")
val LAST_SYNC_TIME = longPreferencesKey("last_sync_time")
val LAST_SHELF_ID = stringPreferencesKey("last_shelf_id")
fun cursor(collection: String) = stringPreferencesKey("cursor_$collection")
}
@@ -35,6 +36,13 @@ class SettingsStore(private val context: Context) {
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] }
/**
* The shelf most recently assigned to a book — SPEC "remember the most recently
* used shelf" (shelving a box of books usually means one shelf, over and over).
* Never written for "Not shelved"; see [SettingsStore] callers.
*/
val lastShelfId: Flow<String?> = context.dataStore.data.map { it[Keys.LAST_SHELF_ID] }
fun cursorFor(collection: String): Flow<String?> =
context.dataStore.data.map { it[Keys.cursor(collection)] }
@@ -62,12 +70,25 @@ class SettingsStore(private val context: Context) {
context.dataStore.edit { it[Keys.LAST_SYNC_TIME] = epochMillis }
}
/** Sign out: drop the token/user identity but keep the server URL — no need to re-enter it. */
suspend fun setLastShelfId(shelfId: String) {
context.dataStore.edit { it[Keys.LAST_SHELF_ID] = shelfId }
}
suspend fun clearLastShelfId() {
context.dataStore.edit { it.remove(Keys.LAST_SHELF_ID) }
}
/**
* Sign out: drop the token/user identity but keep the server URL — no need to
* re-enter it. Also drops the remembered shelf: a shared library's other
* account shouldn't have its shelving habit leak into this one's session.
*/
suspend fun clearAuth() {
context.dataStore.edit {
it.remove(Keys.AUTH_TOKEN)
it.remove(Keys.USER_ID)
it.remove(Keys.USER_EMAIL)
it.remove(Keys.LAST_SHELF_ID)
}
}
@@ -0,0 +1,180 @@
package org.modg.bookshelf.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
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.outlined.Check
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import org.modg.bookshelf.data.local.BookcaseEntity
import org.modg.bookshelf.data.local.ShelfEntity
/**
* The grouped shelf picker shared by the scan screen's save sheet and the
* detail screen's location picker. Replaces the old flat "Bookcase • Shelf"
* [androidx.compose.material3.DropdownMenu] — which stopped being usable once
* a library had more than a couple of bookcases — with a scrolling
* [ModalBottomSheet] grouped one section per bookcase. A bookcase is not
* itself a place a book can sit, so its header is a non-selectable label;
* only the shelves listed under it are choices.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ShelfPickerSheet(
bookcases: List<BookcaseEntity>,
shelves: List<ShelfEntity>,
selectedShelfId: String?,
recentShelfId: String?,
onShelfSelected: (String?) -> Unit,
onDismissRequest: () -> Unit,
) {
ModalBottomSheet(onDismissRequest = onDismissRequest, sheetState = rememberModalBottomSheetState()) {
ShelfPickerContent(
bookcases = bookcases,
shelves = shelves,
selectedShelfId = selectedShelfId,
recentShelfId = recentShelfId,
onShelfSelected = { shelfId ->
onShelfSelected(shelfId)
onDismissRequest()
},
)
}
}
/**
* The picker's contents, split out from [ShelfPickerSheet] so it can be rendered
* directly (Paparazzi has no real Window/scrim behind a headless [ModalBottomSheet],
* same problem [org.modg.bookshelf.ui.screens.ScanScreenPaparazziTest]'s class doc
* describes for the camera preview). Internal rather than private so that test can
* reach it.
*/
@Composable
internal fun ShelfPickerContent(
bookcases: List<BookcaseEntity>,
shelves: List<ShelfEntity>,
selectedShelfId: String?,
recentShelfId: String?,
onShelfSelected: (String?) -> Unit,
) {
val recent = resolveRecentShelf(recentShelfId, shelves, bookcases)
val recentShelf = recent?.first
val recentBookcase = recent?.second
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
// Omitted entirely when there is no remembered shelf, or it no longer exists
// (deleted since it was last used) — a dangling "Recent" entry would be worse
// than no shortcut at all.
if (recentShelf != null && recentBookcase != null) {
SectionHeader(text = "Recent")
ShelfRow(
label = "${recentBookcase.name}${recentShelf.label}",
selected = selectedShelfId == recentShelf.id,
onClick = { onShelfSelected(recentShelf.id) },
)
GoldDivider(modifier = Modifier.padding(vertical = 12.dp))
}
ShelfRow(
label = "Not shelved",
selected = selectedShelfId == null,
onClick = { onShelfSelected(null) },
)
bookcases.sortedBy { it.position }.forEach { bookcase ->
GoldDivider(modifier = Modifier.padding(vertical = 12.dp))
SectionHeader(text = bookcase.name)
val bookcaseShelves = shelves.filter { it.bookcaseId == bookcase.id }.sortedBy { it.position }
if (bookcaseShelves.isEmpty()) {
// So an empty bookcase reads as "no shelves yet", not a rendering bug.
Text(
text = "No shelves yet",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontStyle = FontStyle.Italic,
modifier = Modifier.padding(vertical = 8.dp),
)
} else {
bookcaseShelves.forEach { shelf ->
ShelfRow(
label = shelf.label,
selected = selectedShelfId == shelf.id,
onClick = { onShelfSelected(shelf.id) },
)
}
}
}
}
}
@Composable
private fun SectionHeader(text: String) {
Text(
text = text,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp, bottom = 4.dp),
)
}
@Composable
private fun ShelfRow(label: String, selected: Boolean, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = label,
style = MaterialTheme.typography.bodyLarge,
fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal,
color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f),
)
if (selected) {
Icon(
imageVector = Icons.Outlined.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
}
}
}
/**
* Resolves [recentShelfId] to the shelf + bookcase it names, or null when there is
* nothing remembered, or the remembered shelf was deleted since — the two cases the
* "Recent" section must be omitted for. Pure so it's directly unit-testable without
* standing up Compose.
*/
internal fun resolveRecentShelf(
recentShelfId: String?,
shelves: List<ShelfEntity>,
bookcases: List<BookcaseEntity>,
): Pair<ShelfEntity, BookcaseEntity>? {
val shelf = recentShelfId?.let { id -> shelves.find { it.id == id } } ?: return null
val bookcase = bookcases.find { it.id == shelf.bookcaseId } ?: return null
return shelf to bookcase
}
@@ -19,8 +19,6 @@ import androidx.compose.material.icons.outlined.ExpandLess
import androidx.compose.material.icons.outlined.ExpandMore
import androidx.compose.material.icons.outlined.LocationOn
import androidx.compose.material.icons.outlined.Save
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -56,6 +54,7 @@ import org.modg.bookshelf.ui.components.BookCover
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.ShelfPickerSheet
/**
* SPEC.md "detail" screen. [book] filters `deleted = 0`, so once the user
@@ -76,6 +75,7 @@ fun DetailScreen(
DetailViewModel(
bookRepository = container.bookRepository,
locationRepository = container.locationRepository,
settingsStore = container.settingsStore,
bookId = bookId,
)
}
@@ -85,6 +85,7 @@ fun DetailScreen(
val book by viewModel.book.collectAsState()
val bookcases by viewModel.bookcases.collectAsState()
val shelves by viewModel.shelves.collectAsState()
val recentShelfId by viewModel.recentShelfId.collectAsState()
var retainedBook by remember { mutableStateOf<BookEntity?>(null) }
LaunchedEffect(book) { book?.let { retainedBook = it } }
@@ -163,6 +164,7 @@ fun DetailScreen(
book = display,
bookcases = bookcases,
shelves = shelves,
recentShelfId = recentShelfId,
onShelfSelected = viewModel::saveLocation,
)
}
@@ -271,9 +273,10 @@ internal fun LocationSection(
book: BookEntity,
bookcases: List<BookcaseEntity>,
shelves: List<ShelfEntity>,
recentShelfId: String?,
onShelfSelected: (String?) -> Unit,
) {
var menuExpanded by remember { mutableStateOf(false) }
var pickerOpen by remember { mutableStateOf(false) }
val currentShelf = shelves.find { it.id == book.shelfId }
val currentBookcase = currentShelf?.let { shelf -> bookcases.find { it.id == shelf.bookcaseId } }
val label = if (currentShelf != null && currentBookcase != null) {
@@ -291,25 +294,20 @@ internal fun LocationSection(
) {
Icon(Icons.Outlined.LocationOn, contentDescription = null, tint = MaterialTheme.colorScheme.secondary)
Text(text = label, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
Box {
TextButton(onClick = { menuExpanded = true }) { Text("Change") }
DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) {
DropdownMenuItem(
text = { Text("Not shelved") },
onClick = { onShelfSelected(null); menuExpanded = false },
)
bookcases.forEach { bookcase ->
shelves.filter { it.bookcaseId == bookcase.id }.forEach { shelf ->
DropdownMenuItem(
text = { Text("${bookcase.name}${shelf.label}") },
onClick = { onShelfSelected(shelf.id); menuExpanded = false },
)
}
}
}
}
TextButton(onClick = { pickerOpen = true }) { Text("Change") }
}
}
if (pickerOpen) {
ShelfPickerSheet(
bookcases = bookcases,
shelves = shelves,
selectedShelfId = book.shelfId,
recentShelfId = recentShelfId,
onShelfSelected = onShelfSelected,
onDismissRequest = { pickerOpen = false },
)
}
}
@Composable
@@ -9,6 +9,7 @@ import kotlinx.coroutines.launch
import org.modg.bookshelf.data.local.BookEntity
import org.modg.bookshelf.data.local.BookcaseEntity
import org.modg.bookshelf.data.local.ShelfEntity
import org.modg.bookshelf.data.prefs.SettingsStore
import org.modg.bookshelf.data.repo.BookRepository
import org.modg.bookshelf.data.repo.LocationRepository
import org.modg.bookshelf.data.repo.decodeAuthors
@@ -44,6 +45,7 @@ data class BookEditForm(
class DetailViewModel(
private val bookRepository: BookRepository,
private val locationRepository: LocationRepository,
private val settingsStore: SettingsStore,
private val bookId: String,
) : ViewModel() {
@@ -61,6 +63,10 @@ class DetailViewModel(
val shelves: StateFlow<List<ShelfEntity>> = locationRepository.observeShelves()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
/** The shelf most recently assigned to any book, across sessions — surfaced as the picker's "Recent" shortcut. */
val recentShelfId: StateFlow<String?> = settingsStore.lastShelfId
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
fun saveNotes(notes: String) {
val current = book.value ?: return
viewModelScope.launch { bookRepository.save(current.copy(notes = notes.ifBlank { null })) }
@@ -68,7 +74,17 @@ class DetailViewModel(
fun saveLocation(shelfId: String?) {
val current = book.value ?: return
viewModelScope.launch { bookRepository.save(current.copy(shelfId = shelfId)) }
viewModelScope.launch { performSaveLocation(current, shelfId) }
}
/**
* The actual save-location logic, split out from [saveLocation] so tests can await it
* directly (as a plain suspend call) instead of racing [viewModelScope]'s launch.
*/
internal suspend fun performSaveLocation(current: BookEntity, shelfId: String?) {
bookRepository.save(current.copy(shelfId = shelfId))
// "Not shelved" (null) must never overwrite the memory — it isn't a shelf.
if (shelfId != null) settingsStore.setLastShelfId(shelfId)
}
fun saveEdit(form: BookEditForm) {
@@ -18,7 +18,6 @@ import androidx.compose.material.icons.outlined.QrCodeScanner
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.Sort
import androidx.compose.material.icons.outlined.Warehouse
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -37,6 +36,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -44,6 +44,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import org.modg.bookshelf.AppContainer
import org.modg.bookshelf.R
import org.modg.bookshelf.data.local.BookEntity
import org.modg.bookshelf.data.local.BookcaseEntity
import org.modg.bookshelf.data.local.ShelfEntity
@@ -97,7 +98,7 @@ fun LibraryScreen(
title = "Bookshelf",
actions = {
IconButton(onClick = onLocationsClick) {
Icon(Icons.Outlined.Warehouse, contentDescription = "Bookcases & shelves")
Icon(painterResource(R.drawable.ic_shelves), contentDescription = "Bookcases & shelves")
}
IconButton(onClick = onSettingsClick) {
Icon(Icons.Outlined.Settings, contentDescription = "Settings")
@@ -202,20 +203,28 @@ internal fun LibraryToolbar(
Icon(Icons.Outlined.FilterList, contentDescription = "Filter by bookcase or shelf")
}
DropdownMenu(expanded = filterMenuExpanded, onDismissRequest = { filterMenuExpanded = false }) {
DropdownMenuItem(
text = { Text("All books") },
onClick = { onFilterChange(LibraryFilter.All); filterMenuExpanded = false },
)
bookcases.forEach { bookcase ->
if (bookcases.isEmpty() && shelves.isEmpty()) {
DropdownMenuItem(
text = { Text(bookcase.name, style = MaterialTheme.typography.titleSmall) },
onClick = { onFilterChange(LibraryFilter.Bookcase(bookcase.id)); filterMenuExpanded = false },
text = { Text("Add a bookcase to enable filtering") },
enabled = false,
onClick = {},
)
shelves.filter { it.bookcaseId == bookcase.id }.forEach { shelf ->
} else {
DropdownMenuItem(
text = { Text("All books") },
onClick = { onFilterChange(LibraryFilter.All); filterMenuExpanded = false },
)
bookcases.forEach { bookcase ->
DropdownMenuItem(
text = { Text(" ${shelf.label}") },
onClick = { onFilterChange(LibraryFilter.Shelf(shelf.id)); filterMenuExpanded = false },
text = { Text(bookcase.name, style = MaterialTheme.typography.titleSmall) },
onClick = { onFilterChange(LibraryFilter.Bookcase(bookcase.id)); filterMenuExpanded = false },
)
shelves.filter { it.bookcaseId == bookcase.id }.forEach { shelf ->
DropdownMenuItem(
text = { Text(" ${shelf.label}") },
onClick = { onFilterChange(LibraryFilter.Shelf(shelf.id)); filterMenuExpanded = false },
)
}
}
}
}
@@ -32,12 +32,15 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer
@@ -94,7 +97,12 @@ fun LocationsScreen(
action = { PrimaryButton(text = "Add a bookcase", onClick = viewModel::openAddBookcase) },
)
} else {
LazyColumn(contentPadding = PaddingValues(bottom = 96.dp)) {
LazyColumn(
contentPadding = PaddingValues(
top = innerPadding.calculateTopPadding(),
bottom = innerPadding.calculateBottomPadding() + 96.dp,
),
) {
items(state.bookcases, key = { it.bookcase.id }) { bookcaseUi ->
BookcaseRow(
bookcaseUi = bookcaseUi,
@@ -268,12 +276,22 @@ private fun BookcaseEditDialog(
) {
var name by remember { mutableStateOf(editing?.name.orEmpty()) }
var note by remember { mutableStateOf(editing?.note.orEmpty()) }
val nameFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
runCatching { nameFocusRequester.requestFocus() }
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(text = title, style = MaterialTheme.typography.titleLarge) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(value = name, onValueChange = { name = it }, label = { Text("Name") }, singleLine = true)
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Name") },
singleLine = true,
modifier = Modifier.focusRequester(nameFocusRequester),
)
OutlinedTextField(value = note, onValueChange = { note = it }, label = { Text("Note (optional)") }, singleLine = true)
}
},
@@ -292,11 +310,21 @@ private fun ShelfEditDialog(
onSubmit: (label: String) -> Unit,
) {
var label by remember { mutableStateOf(editing?.label.orEmpty()) }
val labelFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
runCatching { labelFocusRequester.requestFocus() }
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(text = title, style = MaterialTheme.typography.titleLarge) },
text = {
OutlinedTextField(value = label, onValueChange = { label = it }, label = { Text("Label") }, singleLine = true)
OutlinedTextField(
value = label,
onValueChange = { label = it },
label = { Text("Label") },
singleLine = true,
modifier = Modifier.focusRequester(labelFocusRequester),
)
},
confirmButton = {
TextButton(onClick = { onSubmit(label) }, enabled = label.isNotBlank()) { Text("Save") }
@@ -44,7 +44,10 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.unit.dp
@@ -66,6 +69,7 @@ import org.modg.bookshelf.ui.components.BookshelfScaffold
import org.modg.bookshelf.ui.components.EmptyState
import org.modg.bookshelf.ui.components.PrimaryButton
import org.modg.bookshelf.ui.components.SecondaryButton
import org.modg.bookshelf.ui.components.ShelfPickerSheet
/**
* SPEC.md "scan" screen: camera + reticle, on-hit bottom sheet, continuous
@@ -85,6 +89,7 @@ fun ScanScreen(
bookRepository = container.bookRepository,
locationRepository = container.locationRepository,
metadataRepository = container.metadataRepository,
settingsStore = container.settingsStore,
)
}
},
@@ -97,6 +102,7 @@ fun ScanScreen(
val bookcases by viewModel.bookcases.collectAsState()
val shelves by viewModel.shelves.collectAsState()
val selectedShelfId by viewModel.selectedShelfId.collectAsState()
val recentShelfId by viewModel.recentShelfId.collectAsState()
val permissionState = rememberPermissionState(Manifest.permission.CAMERA)
LaunchedEffect(Unit) {
@@ -159,6 +165,7 @@ fun ScanScreen(
bookcases = bookcases,
shelves = shelves,
selectedShelfId = selectedShelfId,
recentShelfId = recentShelfId,
onShelfSelected = viewModel::selectShelf,
onSave = { viewModel.save(state.isbn13, state.metadata) },
onSkip = { viewModel.skip() },
@@ -174,6 +181,7 @@ fun ScanScreen(
bookcases = bookcases,
shelves = shelves,
selectedShelfId = selectedShelfId,
recentShelfId = recentShelfId,
onShelfSelected = viewModel::selectShelf,
onSave = { title, authors -> viewModel.saveManualEntry(state.isbn13, title, authors) },
onSkip = { viewModel.skip() },
@@ -185,6 +193,7 @@ fun ScanScreen(
) {
LookupFailedSheet(
isbn13 = state.isbn13,
reason = state.reason,
onRetry = { viewModel.retryLookup(state.isbn13) },
onEnterByHand = { viewModel.enterByHand(state.isbn13) },
onSkip = { viewModel.skip() },
@@ -260,9 +269,8 @@ internal fun ScanReticle(modifier: Modifier = Modifier) {
/**
* Shown the moment a barcode is decoded, while the metadata lookup runs. It names
* the ISBN it read and says so in words, because a lone spinner reads as "still
* working on it" — the user goes on holding the book up to the camera when the
* camera is already done with it.
* the ISBN it read, because a lone spinner reads as "still working on it" — echoing
* the number back is what tells the user the barcode was actually recognised.
*/
@Composable
internal fun SearchingSheet(isbn13: String) {
@@ -283,12 +291,6 @@ internal fun SearchingSheet(isbn13: String) {
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
Text(
text = "Barcode read — you can lower the book.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 12.dp),
)
}
}
@@ -321,6 +323,7 @@ internal fun FoundBookSheet(
bookcases: List<BookcaseEntity>,
shelves: List<ShelfEntity>,
selectedShelfId: String?,
recentShelfId: String?,
onShelfSelected: (String?) -> Unit,
onSave: () -> Unit,
onSkip: () -> Unit,
@@ -353,6 +356,7 @@ internal fun FoundBookSheet(
bookcases = bookcases,
shelves = shelves,
selectedShelfId = selectedShelfId,
recentShelfId = recentShelfId,
onShelfSelected = onShelfSelected,
)
Row(modifier = Modifier.fillMaxWidth().padding(top = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
@@ -375,6 +379,7 @@ internal fun ManualEntrySheet(
bookcases: List<BookcaseEntity>,
shelves: List<ShelfEntity>,
selectedShelfId: String?,
recentShelfId: String?,
onShelfSelected: (String?) -> Unit,
onSave: (title: String, authors: List<String>) -> Unit,
onSkip: () -> Unit,
@@ -409,6 +414,7 @@ internal fun ManualEntrySheet(
bookcases = bookcases,
shelves = shelves,
selectedShelfId = selectedShelfId,
recentShelfId = recentShelfId,
onShelfSelected = onShelfSelected,
)
Row(modifier = Modifier.fillMaxWidth().padding(top = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
@@ -429,11 +435,15 @@ internal fun ManualEntrySheet(
* Shown when a lookup couldn't be completed — SPEC: "a retry affordance, with
* manual entry as the escape hatch; it must NOT claim the book is unknown." No
* shelf picker here: saving isn't offered from this sheet, only a path onward to
* one that does (Retry, or the manual-entry form via "Enter by hand").
* one that does (Retry, or the manual-entry form via "Enter by hand"). [reason]
* is [MetadataRepository]'s diagnostic string (e.g. "open library: network error;
* google books: http 429") — the only channel we have back from a lookup failure
* on a real phone, so it must actually reach the screen instead of being dropped.
*/
@Composable
internal fun LookupFailedSheet(
isbn13: String,
reason: String,
onRetry: () -> Unit,
onEnterByHand: () -> Unit,
onSkip: () -> Unit,
@@ -445,7 +455,13 @@ internal fun LookupFailedSheet(
"This doesn't mean the book is unknown.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp, bottom = 16.dp),
modifier = Modifier.padding(top = 8.dp),
)
Text(
text = reason,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp, bottom = 16.dp),
)
PrimaryButton(text = "Retry", onClick = onRetry, modifier = Modifier.fillMaxWidth())
Row(modifier = Modifier.fillMaxWidth().padding(top = 12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
@@ -478,9 +494,10 @@ internal fun ShelfPicker(
bookcases: List<BookcaseEntity>,
shelves: List<ShelfEntity>,
selectedShelfId: String?,
recentShelfId: String?,
onShelfSelected: (String?) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
var pickerOpen by remember { mutableStateOf(false) }
val currentShelf = shelves.find { it.id == selectedShelfId }
val currentBookcase = currentShelf?.let { shelf -> bookcases.find { it.id == shelf.bookcaseId } }
val label = if (currentShelf != null && currentBookcase != null) {
@@ -490,21 +507,18 @@ internal fun ShelfPicker(
}
Box(modifier = Modifier.padding(top = 12.dp)) {
SecondaryButton(text = label, onClick = { expanded = true })
androidx.compose.material3.DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
androidx.compose.material3.DropdownMenuItem(
text = { Text("Not shelved") },
onClick = { onShelfSelected(null); expanded = false },
)
bookcases.forEach { bookcase ->
shelves.filter { it.bookcaseId == bookcase.id }.forEach { shelf ->
androidx.compose.material3.DropdownMenuItem(
text = { Text("${bookcase.name}${shelf.label}") },
onClick = { onShelfSelected(shelf.id); expanded = false },
)
}
}
}
SecondaryButton(text = label, onClick = { pickerOpen = true })
}
if (pickerOpen) {
ShelfPickerSheet(
bookcases = bookcases,
shelves = shelves,
selectedShelfId = selectedShelfId,
recentShelfId = recentShelfId,
onShelfSelected = onShelfSelected,
onDismissRequest = { pickerOpen = false },
)
}
}
@@ -517,6 +531,14 @@ private fun ManualIsbnDialog(onDismiss: () -> Unit, onSubmit: (String) -> Unit)
// screen was just rebuilt to eliminate.
val isbn13 = remember(text) { IsbnUtils.toIsbn13(text) }
val malformed = text.isNotBlank() && isbn13 == null
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
// The dialog opens with the field unfocused otherwise — this is the only entry
// point into the field, so make it ready to type into immediately.
LaunchedEffect(Unit) {
runCatching { focusRequester.requestFocus() }
keyboardController?.show()
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Enter ISBN") },
@@ -529,6 +551,7 @@ private fun ManualIsbnDialog(onDismiss: () -> Unit, onSubmit: (String) -> Unit)
singleLine = true,
isError = malformed,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.focusRequester(focusRequester),
)
if (malformed) {
Text(
@@ -15,6 +15,7 @@ import org.modg.bookshelf.data.local.ShelfEntity
import org.modg.bookshelf.data.metadata.BookMetadata
import org.modg.bookshelf.data.metadata.IsbnUtils
import org.modg.bookshelf.data.metadata.MetadataRepository
import org.modg.bookshelf.data.prefs.SettingsStore
import org.modg.bookshelf.data.repo.BookRepository
import org.modg.bookshelf.data.repo.LocationRepository
@@ -28,6 +29,7 @@ class ScanViewModel(
private val bookRepository: BookRepository,
locationRepository: LocationRepository,
private val metadataRepository: MetadataRepository,
private val settingsStore: SettingsStore,
val scannerController: ScannerController = ScannerController(),
) : ViewModel() {
@@ -51,6 +53,10 @@ class ScanViewModel(
private val _selectedShelfId = MutableStateFlow<String?>(null)
val selectedShelfId: StateFlow<String?> = _selectedShelfId.asStateFlow()
/** The shelf most recently assigned to any book, across sessions — surfaced as the picker's "Recent" shortcut. */
val recentShelfId: StateFlow<String?> = settingsStore.lastShelfId
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
init {
viewModelScope.launch {
scannerController.scanResults.collect { isbn13 -> onScanned(isbn13) }
@@ -104,35 +110,53 @@ class ScanViewModel(
/** Save from a successful metadata lookup. */
fun save(isbn13: String, metadata: BookMetadata) {
viewModelScope.launch {
bookRepository.createBook(
title = metadata.title ?: "Untitled",
subtitle = metadata.subtitle,
authors = metadata.authors,
isbn13 = metadata.isbn13 ?: isbn13,
isbn10 = metadata.isbn10,
publisher = metadata.publisher,
publishedDate = metadata.publishedDate,
pageCount = metadata.pageCount,
description = metadata.description,
coverSourceUrl = metadata.coverUrl,
shelfId = _selectedShelfId.value,
)
recordSave()
}
viewModelScope.launch { performSave(isbn13, metadata) }
}
/**
* The actual save-from-metadata logic, split out from [save] so tests can await it
* directly (as a plain suspend call) instead of racing [viewModelScope]'s launch.
*/
internal suspend fun performSave(isbn13: String, metadata: BookMetadata) {
val shelfId = _selectedShelfId.value
bookRepository.createBook(
title = metadata.title ?: "Untitled",
subtitle = metadata.subtitle,
authors = metadata.authors,
isbn13 = metadata.isbn13 ?: isbn13,
isbn10 = metadata.isbn10,
publisher = metadata.publisher,
publishedDate = metadata.publishedDate,
pageCount = metadata.pageCount,
description = metadata.description,
coverSourceUrl = metadata.coverUrl,
shelfId = shelfId,
)
rememberShelf(shelfId)
recordSave()
}
/** Save from the manual-entry form shown when metadata lookup misses (SPEC: pre-filled with the scanned ISBN). */
fun saveManualEntry(isbn13: String, title: String, authors: List<String>) {
viewModelScope.launch {
bookRepository.createBook(
title = title.ifBlank { "Untitled" },
authors = authors,
isbn13 = isbn13,
shelfId = _selectedShelfId.value,
)
recordSave()
}
viewModelScope.launch { performSaveManualEntry(isbn13, title, authors) }
}
/** Same split as [performSave], for [saveManualEntry]. */
internal suspend fun performSaveManualEntry(isbn13: String, title: String, authors: List<String>) {
val shelfId = _selectedShelfId.value
bookRepository.createBook(
title = title.ifBlank { "Untitled" },
authors = authors,
isbn13 = isbn13,
shelfId = shelfId,
)
rememberShelf(shelfId)
recordSave()
}
/** "Not shelved" (null) must never overwrite the memory — it isn't a shelf. */
private suspend fun rememberShelf(shelfId: String?) {
if (shelfId != null) settingsStore.setLastShelfId(shelfId)
}
private fun recordSave() {
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Material Symbols "shelves" (Apache 2.0). Source viewBox is
"0 -960 960 960"; Android has no viewport origin, so the group
translate is load-bearing. Do not flatten it. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<group android:translateY="960">
<path
android:fillColor="#FF000000"
android:pathData="M120-40v-880h80v80h560v-80h80v880h-80v-80H200v80h-80Zm80-480h80v-160h240v160h240v-240H200v240Zm0 320h240v-160h240v160h80v-240H200v240Zm160-320h80v-80h-80v80Zm160 320h80v-80h-80v80Z" />
</group>
</vector>
@@ -66,25 +66,25 @@ class GoogleBooksClientTest {
@Test
fun `classify reports Failed for a 404`() {
val result = client.classify(404, null)
assertEquals(SourceResult.Failed("http 404"), result)
assertEquals(SourceResult.Failed("http 404", FailureKind.CLIENT_ERROR), result)
}
@Test
fun `classify reports Failed for a 429 keyless-quota response, distinctly from NotFound`() {
val result = client.classify(429, null)
assertEquals(SourceResult.Failed("http 429"), result)
assertEquals(SourceResult.Failed("http 429 (rate limited)", FailureKind.RATE_LIMITED), result)
}
@Test
fun `classify reports Failed for a 500`() {
val result = client.classify(500, "Internal Server Error")
assertEquals(SourceResult.Failed("http 500"), result)
assertEquals(SourceResult.Failed("http 500 (server error)", FailureKind.SERVER_ERROR), result)
}
@Test
fun `classify reports Failed for a malformed body even on a 2xx status`() {
val result = client.classify(200, fixture("malformed.json"))
assertEquals(SourceResult.Failed("malformed json"), result)
assertEquals(SourceResult.Failed("malformed json", FailureKind.MALFORMED), result)
}
private fun fixture(name: String): String =
@@ -125,25 +125,25 @@ class OpenLibraryClientTest {
@Test
fun `classify reports Failed for a 404`() {
val result = client.classify(404, null, "9780201558029")
assertEquals(SourceResult.Failed("http 404"), result)
assertEquals(SourceResult.Failed("http 404", FailureKind.CLIENT_ERROR), result)
}
@Test
fun `classify reports Failed for a 429, distinctly from NotFound`() {
val result = client.classify(429, null, "9780201558029")
assertEquals(SourceResult.Failed("http 429"), result)
assertEquals(SourceResult.Failed("http 429 (rate limited)", FailureKind.RATE_LIMITED), result)
}
@Test
fun `classify reports Failed for a 500`() {
val result = client.classify(500, "Internal Server Error", "9780201558029")
assertEquals(SourceResult.Failed("http 500"), result)
assertEquals(SourceResult.Failed("http 500 (server error)", FailureKind.SERVER_ERROR), result)
}
@Test
fun `classify reports Failed for a malformed body even on a 2xx status`() {
val result = client.classify(200, fixture("malformed.json"), "9780201558029")
assertEquals(SourceResult.Failed("malformed json"), result)
assertEquals(SourceResult.Failed("malformed json", FailureKind.MALFORMED), result)
}
private fun fixture(name: String): String =
@@ -0,0 +1,191 @@
package org.modg.bookshelf.data.metadata
import java.io.IOException
import java.io.InterruptedIOException
import java.net.ConnectException
import java.net.SocketException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import javax.net.ssl.SSLException
import javax.net.ssl.SSLHandshakeException
import kotlin.random.Random
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Covers the retry decision and the loop that acts on it. Every case is driven by
* the 2026-09-09 measurement of the live Open Library API recorded in
* docs/METADATA-SOURCES.md: fast transient TLS resets (worth repeating) against a
* slow but usually-successful long tail (not worth repeating).
*
* The loop is exercised with an injected clock and sleep, so these assert the real
* policy constants with no wall-clock time and no flakiness.
*/
class RetryPolicyTest {
private val transport = SourceResult.Failed("tls connection reset", FailureKind.TRANSPORT)
private val found = SourceResult.Found(BookMetadata(title = "Dune"))
// --- which failures are worth repeating ---
@Test
fun `transport and server errors are retryable`() {
assertTrue(RetryPolicy.isRetryable(FailureKind.TRANSPORT))
assertTrue(RetryPolicy.isRetryable(FailureKind.SERVER_ERROR))
}
@Test
fun `a timeout is not retried -- its budget is already spent`() {
assertFalse(RetryPolicy.isRetryable(FailureKind.TIMEOUT))
}
@Test
fun `a rate limit is not retried -- hammering a quota is how a block becomes permanent`() {
assertFalse(RetryPolicy.isRetryable(FailureKind.RATE_LIMITED))
}
@Test
fun `deterministic failures are not retried`() {
assertFalse(RetryPolicy.isRetryable(FailureKind.CLIENT_ERROR))
assertFalse(RetryPolicy.isRetryable(FailureKind.MALFORMED))
}
// --- the loop ---
@Test
fun `a first-attempt success is returned without retrying`() = runTest {
var calls = 0
val result = withRetry(sleep = {}) { calls++; found }
assertEquals(found, result)
assertEquals(1, calls)
}
@Test
fun `a transient failure followed by success returns the success`() = runTest {
var calls = 0
val result = withRetry(sleep = {}) {
calls++
if (calls == 1) transport else found
}
assertEquals(found, result)
assertEquals(2, calls)
}
@Test
fun `retrying stops at MAX_ATTEMPTS and reports how many were made`() = runTest {
var calls = 0
val result = withRetry(sleep = {}) { calls++; transport }
assertEquals(RetryPolicy.MAX_ATTEMPTS, calls)
// The attempt count is the whole diagnostic value of the string on a real
// phone: one reset and three in a row are different network stories.
assertEquals(
SourceResult.Failed("tls connection reset, 3 attempts", FailureKind.TRANSPORT),
result,
)
}
@Test
fun `a single failure is reported without an attempt count`() = runTest {
val timeout = SourceResult.Failed("timeout", FailureKind.TIMEOUT)
var calls = 0
val result = withRetry(sleep = {}) { calls++; timeout }
assertEquals(1, calls)
assertEquals(timeout, result)
}
@Test
fun `NotFound is authoritative and is never retried`() = runTest {
var calls = 0
val result = withRetry(sleep = {}) { calls++; SourceResult.NotFound }
assertEquals(SourceResult.NotFound, result)
assertEquals(1, calls)
}
@Test
fun `an un-retryable failure short-circuits after one attempt`() = runTest {
var calls = 0
val result = withRetry(sleep = {}) { calls++; SourceResult.fromHttpCode(429) }
assertEquals(1, calls)
assertEquals(FailureKind.RATE_LIMITED, (result as SourceResult.Failed).kind)
}
@Test
fun `the budget stops a new attempt but never cancels one in flight`() = runTest {
// Clock jumps past the budget during the first attempt. The result of that
// attempt must still be honoured, and no second attempt may start.
var now = 0L
var calls = 0
val result = withRetry(
budgetMillis = 1_000L,
nowMillis = { now },
sleep = {},
) {
calls++
now += 5_000L
transport
}
assertEquals(1, calls)
assertEquals(transport, result)
}
@Test
fun `backoff is short and jittered, never zero and never seconds long`() {
val r = Random(1234)
repeat(200) {
val first = RetryPolicy.backoffMillis(2, r)
val second = RetryPolicy.backoffMillis(3, r)
assertTrue("first retry backoff was $first", first in 250L..349L)
assertTrue("second retry backoff was $second", second in 750L..1049L)
}
}
// --- exception and status classification ---
@Test
fun `socket timeout and a blown call timeout both classify as TIMEOUT`() {
assertEquals(FailureKind.TIMEOUT, SourceResult.fromException(SocketTimeoutException()).kind)
// OkHttp reports an exceeded callTimeout as a bare InterruptedIOException.
assertEquals(FailureKind.TIMEOUT, SourceResult.fromException(InterruptedIOException()).kind)
}
@Test
fun `the observed live failure -- a TLS-stage reset -- classifies as retryable transport`() {
val failed = SourceResult.fromException(SSLException("Connection reset by peer"))
assertEquals(FailureKind.TRANSPORT, failed.kind)
assertTrue(RetryPolicy.isRetryable(failed.kind))
}
@Test
fun `each transport exception gets its own reason, not a generic one`() {
// The reason string is the only diagnostic we get back from a real phone,
// so these must stay distinguishable from each other.
val reasons = listOf(
SourceResult.fromException(UnknownHostException()).reason,
SourceResult.fromException(SSLHandshakeException("h")).reason,
SourceResult.fromException(SSLException("r")).reason,
SourceResult.fromException(ConnectException()).reason,
SourceResult.fromException(SocketException()).reason,
)
assertEquals(reasons.size, reasons.toSet().size)
assertTrue(reasons.none { it.isBlank() })
}
@Test
fun `an unrecognised IOException names its own type rather than saying network error`() {
val failed = SourceResult.fromException(IOException("boom"))
assertEquals(FailureKind.TRANSPORT, failed.kind)
assertTrue(failed.reason, failed.reason.contains("IOException"))
}
@Test
fun `http statuses map to the kinds that drive retrying`() {
assertEquals(FailureKind.RATE_LIMITED, SourceResult.fromHttpCode(429).kind)
assertEquals(FailureKind.SERVER_ERROR, SourceResult.fromHttpCode(500).kind)
assertEquals(FailureKind.SERVER_ERROR, SourceResult.fromHttpCode(503).kind)
assertEquals(FailureKind.CLIENT_ERROR, SourceResult.fromHttpCode(404).kind)
assertEquals(FailureKind.CLIENT_ERROR, SourceResult.fromHttpCode(400).kind)
}
}
@@ -0,0 +1,73 @@
package org.modg.bookshelf.data.prefs
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.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/**
* Covers the "remember the most recently used shelf" feature's persistence layer —
* see [org.modg.bookshelf.ui.scan.ScanViewModel] and
* [org.modg.bookshelf.ui.detail.DetailViewModel] for the callers that decide *when*
* to write, and [org.modg.bookshelf.ui.components.resolveRecentShelf] for how the
* picker decides whether the remembered shelf is still offerable.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class SettingsStoreTest {
private lateinit var settingsStore: SettingsStore
@Before
fun setUp() = runTest {
settingsStore = SettingsStore(ApplicationProvider.getApplicationContext())
// DataStore's backing file lives in the app's real files dir, which Robolectric
// does not reset between test methods in this class — start every test from a
// known-clean slate instead of depending on method execution order.
settingsStore.clearLastShelfId()
}
@Test
fun `last shelf id is null before anything is remembered`() = runTest {
assertNull(settingsStore.lastShelfId.first())
}
@Test
fun `last shelf id round-trips through the store`() = runTest {
settingsStore.setLastShelfId("sh-top")
assertEquals("sh-top", settingsStore.lastShelfId.first())
}
@Test
fun `setting a new last shelf id overwrites the previous one`() = runTest {
settingsStore.setLastShelfId("sh-top")
settingsStore.setLastShelfId("sh-desk")
assertEquals("sh-desk", settingsStore.lastShelfId.first())
}
@Test
fun `clearLastShelfId drops the remembered shelf`() = runTest {
settingsStore.setLastShelfId("sh-top")
settingsStore.clearLastShelfId()
assertNull(settingsStore.lastShelfId.first())
}
@Test
fun `sign out clears the remembered shelf so it can't leak to the other account`() = runTest {
settingsStore.setLastShelfId("sh-top")
settingsStore.clearAuth()
assertNull(settingsStore.lastShelfId.first())
}
}
@@ -0,0 +1,78 @@
package org.modg.bookshelf.ui.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import app.cash.paparazzi.DeviceConfig
import app.cash.paparazzi.Paparazzi
import org.junit.Rule
import org.junit.Test
import org.modg.bookshelf.data.local.BookcaseEntity
import org.modg.bookshelf.data.local.ShelfEntity
import org.modg.bookshelf.data.local.SyncState
import org.modg.bookshelf.ui.theme.BookshelfTheme
/**
* [ShelfPickerSheet]'s grouped picker, replacing the old flat dropdown (SPEC
* task: "the state the whole change exists for"). Renders [ShelfPickerContent]
* directly rather than the real [androidx.compose.material3.ModalBottomSheet] —
* Paparazzi has no real Window/scrim behind a headless sheet, same problem
* [org.modg.bookshelf.ui.screens.ScanScreenPaparazziTest]'s class doc describes.
* Two bookcases, one of them empty, plus a remembered "Recent" shelf — the
* state this whole component exists for.
*/
class ShelfPickerSheetPaparazziTest {
@get:Rule
val paparazzi = Paparazzi(deviceConfig = DeviceConfig.PIXEL_6)
private val livingRoom = BookcaseEntity(
id = "bc-living-room", name = "Living Room", note = null, position = 0,
createdAt = 1, updatedAt = 1, syncState = SyncState.SYNCED,
)
private val study = BookcaseEntity(
id = "bc-study", name = "Study", note = null, position = 1,
createdAt = 1, updatedAt = 1, syncState = SyncState.SYNCED,
)
private val topShelf = ShelfEntity(
id = "sh-top", bookcaseId = livingRoom.id, label = "Top shelf — fiction", position = 0,
createdAt = 1, updatedAt = 1, syncState = SyncState.SYNCED,
)
private val bottomShelf = ShelfEntity(
id = "sh-bottom", bookcaseId = livingRoom.id, label = "Bottom shelf — reference", position = 1,
createdAt = 1, updatedAt = 1, syncState = SyncState.SYNCED,
)
@Test
fun shelfPickerGroupedLight() = snapshotBoth("shelf-picker-grouped") {
Content()
}
@Composable
private fun Content() = Shell {
ShelfPickerContent(
bookcases = listOf(livingRoom, study), // "study" has no shelves yet — empty-bookcase state
shelves = listOf(topShelf, bottomShelf),
selectedShelfId = bottomShelf.id,
recentShelfId = topShelf.id, // recent shelf, distinct from the current selection
onShelfSelected = {},
)
}
@Composable
private fun Shell(content: @Composable () -> Unit) {
Box(modifier = Modifier.fillMaxWidth()) {
Surface(color = MaterialTheme.colorScheme.surface) {
content()
}
}
}
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,53 @@
package org.modg.bookshelf.ui.components
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import org.modg.bookshelf.data.local.BookcaseEntity
import org.modg.bookshelf.data.local.ShelfEntity
import org.modg.bookshelf.data.local.SyncState
/**
* [resolveRecentShelf] backs [ShelfPickerSheet]'s "Recent" section: it must be
* omitted, not shown dangling, when there is nothing remembered or the
* remembered shelf no longer exists (deleted since it was last used).
*/
class ShelfPickerSheetTest {
private val livingRoom = BookcaseEntity(
id = "bc-living-room", name = "Living Room", note = null, position = 0,
createdAt = 1, updatedAt = 1, syncState = SyncState.SYNCED,
)
private val topShelf = ShelfEntity(
id = "sh-top", bookcaseId = livingRoom.id, label = "Top shelf", position = 0,
createdAt = 1, updatedAt = 1, syncState = SyncState.SYNCED,
)
@Test
fun `no remembered shelf resolves to null`() {
val result = resolveRecentShelf(recentShelfId = null, shelves = listOf(topShelf), bookcases = listOf(livingRoom))
assertNull(result)
}
@Test
fun `a remembered shelf that no longer exists resolves to null`() {
val result = resolveRecentShelf(recentShelfId = "sh-deleted", shelves = listOf(topShelf), bookcases = listOf(livingRoom))
assertNull(result)
}
@Test
fun `a remembered shelf whose bookcase was deleted resolves to null`() {
val result = resolveRecentShelf(recentShelfId = topShelf.id, shelves = listOf(topShelf), bookcases = emptyList())
assertNull(result)
}
@Test
fun `a remembered shelf that still exists resolves to its shelf and bookcase`() {
val result = resolveRecentShelf(recentShelfId = topShelf.id, shelves = listOf(topShelf), bookcases = listOf(livingRoom))
assertEquals(topShelf to livingRoom, result)
}
}
@@ -0,0 +1,77 @@
package org.modg.bookshelf.ui.detail
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.modg.bookshelf.data.local.BookEntity
import org.modg.bookshelf.data.local.BookshelfDatabase
import org.modg.bookshelf.data.prefs.SettingsStore
import org.modg.bookshelf.data.repo.BookRepository
import org.modg.bookshelf.data.repo.LocationRepository
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/**
* SPEC's "remember the most recently used shelf" from the detail screen's side:
* [DetailViewModel.saveLocation] must write a non-null shelf to [SettingsStore],
* but "Not shelved" (null) must never overwrite what's already remembered.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class DetailViewModelTest {
private lateinit var db: BookshelfDatabase
private lateinit var settingsStore: SettingsStore
private lateinit var viewModel: DetailViewModel
private lateinit var book: BookEntity
private lateinit var shelfId: String
@Before
fun setUp() = runTest {
val context = ApplicationProvider.getApplicationContext<android.content.Context>()
db = Room.inMemoryDatabaseBuilder(context, BookshelfDatabase::class.java)
.allowMainThreadQueries()
.build()
settingsStore = SettingsStore(context)
val bookRepository = BookRepository(db.bookDao(), context)
val locationRepository = LocationRepository(db.bookcaseDao(), db.shelfDao(), db.bookDao())
val bookcaseId = locationRepository.createBookcase(name = "Living Room")
shelfId = locationRepository.createShelf(bookcaseId, label = "Top shelf")
val bookId = bookRepository.createBook(title = "Piranesi")
book = checkNotNull(bookRepository.getById(bookId))
viewModel = DetailViewModel(
bookRepository = bookRepository,
locationRepository = locationRepository,
settingsStore = settingsStore,
bookId = bookId,
)
}
@After
fun tearDown() {
db.close()
}
@Test
fun `moving a book to a shelf remembers that shelf`() = runTest {
viewModel.performSaveLocation(book, shelfId)
assertEquals(shelfId, settingsStore.lastShelfId.first())
}
@Test
fun `marking a book Not shelved does not overwrite the remembered shelf`() = runTest {
settingsStore.setLastShelfId(shelfId)
viewModel.performSaveLocation(book, null)
assertEquals(shelfId, settingsStore.lastShelfId.first())
}
}
@@ -0,0 +1,100 @@
package org.modg.bookshelf.ui.scan
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.modg.bookshelf.data.local.BookshelfDatabase
import org.modg.bookshelf.data.metadata.BookMetadata
import org.modg.bookshelf.data.metadata.MetadataRepository
import org.modg.bookshelf.data.prefs.SettingsStore
import org.modg.bookshelf.data.repo.BookRepository
import org.modg.bookshelf.data.repo.LocationRepository
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/**
* SPEC's "remember the most recently used shelf": [ScanViewModel.save] and
* [ScanViewModel.saveManualEntry] must write the chosen shelf to [SettingsStore]
* so it survives to the next scanning session — except "Not shelved" (null),
* which must never overwrite what's already remembered.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class ScanViewModelTest {
private lateinit var db: BookshelfDatabase
private lateinit var settingsStore: SettingsStore
private lateinit var viewModel: ScanViewModel
private lateinit var locationRepository: LocationRepository
private lateinit var shelfId: String
@Before
fun setUp() = runTest {
val context = ApplicationProvider.getApplicationContext<android.content.Context>()
db = Room.inMemoryDatabaseBuilder(context, BookshelfDatabase::class.java)
.allowMainThreadQueries()
.build()
settingsStore = SettingsStore(context)
locationRepository = LocationRepository(db.bookcaseDao(), db.shelfDao(), db.bookDao())
val bookcaseId = locationRepository.createBookcase(name = "Living Room")
shelfId = locationRepository.createShelf(bookcaseId, label = "Top shelf")
viewModel = ScanViewModel(
bookRepository = BookRepository(db.bookDao(), context),
locationRepository = locationRepository,
metadataRepository = MetadataRepository(OkHttpClient(), Json { ignoreUnknownKeys = true }),
settingsStore = settingsStore,
)
}
@After
fun tearDown() {
db.close()
}
@Test
fun `saving a metadata hit to a shelf remembers that shelf`() = runTest {
viewModel.selectShelf(shelfId)
viewModel.performSave("9780765326355", BookMetadata(title = "The Way of Kings"))
assertEquals(shelfId, settingsStore.lastShelfId.first())
}
@Test
fun `saving a manual entry to a shelf remembers that shelf`() = runTest {
viewModel.selectShelf(shelfId)
viewModel.performSaveManualEntry("9780765326355", "The Way of Kings", listOf("Brandon Sanderson"))
assertEquals(shelfId, settingsStore.lastShelfId.first())
}
@Test
fun `saving as Not shelved does not overwrite the remembered shelf`() = runTest {
settingsStore.setLastShelfId(shelfId)
viewModel.selectShelf(null)
viewModel.performSave("9780765326355", BookMetadata(title = "The Way of Kings"))
assertEquals(shelfId, settingsStore.lastShelfId.first())
}
@Test
fun `saving a manual entry as Not shelved does not overwrite the remembered shelf`() = runTest {
settingsStore.setLastShelfId(shelfId)
viewModel.selectShelf(null)
viewModel.performSaveManualEntry("9780765326355", "The Way of Kings", emptyList())
assertEquals(shelfId, settingsStore.lastShelfId.first())
}
}
@@ -78,6 +78,7 @@ class DetailScreenPaparazziTest {
book = book,
bookcases = ScreenFixtures.bookcases,
shelves = ScreenFixtures.shelves,
recentShelfId = null,
onShelfSelected = {},
)
}
@@ -18,6 +18,7 @@ import app.cash.paparazzi.DeviceConfig
import app.cash.paparazzi.Paparazzi
import org.junit.Rule
import org.junit.Test
import org.modg.bookshelf.data.local.BookcaseEntity
import org.modg.bookshelf.ui.components.BookshelfScaffold
import org.modg.bookshelf.ui.components.GoldDivider
import org.modg.bookshelf.ui.components.PaperSurface
@@ -40,12 +41,25 @@ class LocationsScreenPaparazziTest {
@Test
fun locationsPopulatedLight() = snapshotBoth("locations-populated") { Populated() }
@Composable
private fun Populated() = Shell()
/**
* Regression coverage for the ghost-bookcase bug: with the list branch not
* folding in the Scaffold's top app bar inset, a single bookcase's row
* rendered underneath the app bar and was invisible. This renders exactly
* that one-bookcase state; check the recorded PNG shows the bookcase row
* fully below the app bar, not clipped/hidden behind it.
*/
@Test
fun locationsSingleBookcaseLight() = snapshotBoth("locations-single-bookcase") { SingleBookcase() }
@Composable
private fun Shell() {
val bookcaseUis = ScreenFixtures.bookcases.map { bookcase ->
private fun Populated() = Shell(ScreenFixtures.bookcases)
@Composable
private fun SingleBookcase() = Shell(listOf(ScreenFixtures.livingRoom))
@Composable
private fun Shell(bookcases: List<BookcaseEntity>) {
val bookcaseUis = bookcases.map { bookcase ->
BookcaseUi(
bookcase = bookcase,
shelves = ScreenFixtures.shelves
@@ -96,6 +96,7 @@ class ScanScreenPaparazziTest {
bookcases = ScreenFixtures.bookcases,
shelves = ScreenFixtures.shelves,
selectedShelfId = ScreenFixtures.deskShelf.id,
recentShelfId = null,
onShelfSelected = {},
onSave = {},
onSkip = {},
@@ -127,6 +128,7 @@ class ScanScreenPaparazziTest {
) {
LookupFailedSheet(
isbn13 = "9780765326355",
reason = "open library: network error; google books: http 429",
onRetry = {},
onEnterByHand = {},
onSkip = {},
Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 13 KiB