Wave 3: all six screens (E1 shell/setup/locations/settings, E2 library/detail/scan)

Nav graph, setup with distinct URL-vs-credential errors, locations tree with reorder
and bulk move, settings; library grid with search/sort/filter, detail with soft-delete
undo, continuous scan with duplicate-ISBN warning.

Verified by orchestrator: assembleDebug exit 0; testDebugUnitTest exit 0,
91 tests, 0 failures, 0 errors (68 -> 91).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-06 10:48:54 +00:00
parent c18649c726
commit a9d4ad8096
32 changed files with 3083 additions and 101 deletions
@@ -10,6 +10,7 @@ import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import org.modg.bookshelf.data.local.BookshelfDatabase
import org.modg.bookshelf.data.metadata.MetadataRepository
import org.modg.bookshelf.data.prefs.SettingsStore
import org.modg.bookshelf.data.remote.ApiProvider
import org.modg.bookshelf.data.remote.PbAuthInterceptor
@@ -85,6 +86,14 @@ class AppContainer(private val context: Context) {
val authRepository by lazy { AuthRepository(apiProvider, settingsStore) }
// A bare client — deliberately NOT [okHttpClient] above, which carries our
// PocketBase bearer token via PbAuthInterceptor. Open Library/Google Books
// are third-party services; that token must never leave this device's
// requests to our own server.
private val metadataHttpClient: OkHttpClient by lazy { OkHttpClient() }
val metadataRepository by lazy { MetadataRepository(metadataHttpClient, json) }
val syncEngine by lazy {
SyncEngine(
apiProvider = apiProvider,
@@ -4,125 +4,54 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.produceState
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import org.modg.bookshelf.ui.components.BookCover
import org.modg.bookshelf.ui.components.BookshelfScaffold
import org.modg.bookshelf.ui.components.EmptyState
import org.modg.bookshelf.ui.components.GoldDivider
import kotlinx.coroutines.flow.first
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.nav.BookshelfNavHost
import org.modg.bookshelf.ui.nav.Routes
import org.modg.bookshelf.ui.theme.BookshelfTheme
/**
* Wave 2's placeholder home screen. There is no navigation graph yet (that's
* ui.nav.BookshelfNavHost, a later wave) — this Activity exists purely as a
* living style reference for the component set above, so wave 3 can see
* exactly how BookshelfScaffold/BookCover/etc. are meant to be used.
* Hosts the real navigation graph (ui.nav.BookshelfNavHost). The only thing
* this Activity itself decides is the start destination: SPEC requires the
* app to land on setup with no server configured, and skip straight to the
* library once a server URL + auth token already exist from a prior run.
*/
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val container = (application as BookshelfApplication).appContainer
setContent {
BookshelfTheme {
StyleReferenceScreen()
BookshelfApp(container)
}
}
}
}
private data class SampleBook(val title: String, val coverUrl: String?)
private val sampleBooks = listOf(
SampleBook("The Left Hand of Darkness", "https://covers.openlibrary.org/b/isbn/9780441478125-L.jpg"),
SampleBook("A broken cover URL", "https://example.invalid/nope.jpg"),
SampleBook("No cover on file", null),
SampleBook("Gödel, Escher, Bach", null),
)
@Composable
private fun StyleReferenceScreen() {
var showEmptyState by remember { mutableStateOf(false) }
BookshelfScaffold(
title = "Bookshelf",
actions = {
IconButton(onClick = { showEmptyState = !showEmptyState }) {
Icon(Icons.Outlined.Settings, contentDescription = "Toggle empty state")
private fun BookshelfApp(container: AppContainer) {
// Read the one-shot startup condition (server URL + auth token already
// present) rather than staying subscribed — once the app is up, in-app
// navigation (setup -> library, sign out -> setup) owns all further
// transitions, per the shared nav contract.
val startDestination by produceState<String?>(initialValue = null, container) {
val hasServerUrl = !container.authRepository.serverUrl.first().isNullOrBlank()
val isLoggedIn = container.authRepository.isLoggedIn.first()
value = if (hasServerUrl && isLoggedIn) Routes.LIBRARY else Routes.SETUP
}
},
syncStatusBar = {
SyncStatusBar(status = SyncStatus.Synced, label = "Synced • just now")
},
) { innerPadding ->
PaperSurface(modifier = Modifier.fillMaxWidth()) {
if (showEmptyState) {
EmptyState(
modifier = Modifier.padding(innerPadding),
title = "Your shelves are empty",
message = "Scan a barcode to add your first book.",
action = { PrimaryButton(text = "Scan a book", onClick = {}) },
)
val destination = startDestination
if (destination == null) {
// Momentary: DataStore's first read. No spinner per the app's quiet,
// paper-toned aesthetic — just the bare surface for a frame or two.
PaperSurface(modifier = Modifier.fillMaxSize()) {}
} else {
Column(modifier = Modifier.padding(innerPadding)) {
SectionLabel("Covers")
LazyVerticalGrid(
columns = GridCells.Fixed(3),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth(),
) {
items(sampleBooks) { book ->
BookCover(coverUrl = book.coverUrl, contentDescription = book.title)
BookshelfNavHost(container = container, startDestination = destination)
}
}
GoldDivider(modifier = Modifier.padding(vertical = 8.dp))
SectionLabel("Buttons")
Row(
modifier = Modifier.padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
PrimaryButton(text = "Save", onClick = {})
SecondaryButton(text = "Skip", onClick = {})
}
}
}
}
}
}
@Composable
private fun SectionLabel(text: String) {
Text(
text = text,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 4.dp),
)
}
@@ -33,6 +33,10 @@ interface BookDao {
@Query("SELECT * FROM books WHERE id = :id AND deleted = 0")
suspend fun getById(id: String): BookEntity?
/** Live version of [getById] for the detail screen — reflects edits/undo/incoming sync without a manual reload. */
@Query("SELECT * FROM books WHERE id = :id AND deleted = 0")
fun observeById(id: String): Flow<BookEntity?>
/** Used by sync (needs tombstones too) and by soft-delete/undo flows. */
@Query("SELECT * FROM books WHERE id = :id")
suspend fun getByIdIncludingDeleted(id: String): BookEntity?
@@ -39,6 +39,7 @@ class BookRepository(
fun search(query: String): Flow<List<BookEntity>> = bookDao.search(query)
suspend fun getById(id: String): BookEntity? = bookDao.getById(id)
fun observeById(id: String): Flow<BookEntity?> = bookDao.observeById(id)
suspend fun findByIsbn13(isbn13: String): BookEntity? = bookDao.findByIsbn13(isbn13)
suspend fun countByShelf(shelfId: String): Int = bookDao.countByShelf(shelfId)
@@ -1,6 +1,7 @@
package org.modg.bookshelf.data.repo
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import org.modg.bookshelf.data.local.BookDao
import org.modg.bookshelf.data.local.BookcaseDao
import org.modg.bookshelf.data.local.BookcaseEntity
@@ -68,4 +69,19 @@ class LocationRepository(
shelfDao.upsert(existing.copy(deleted = true, updatedAt = System.currentTimeMillis(), syncState = SyncState.PENDING_DELETE))
}
}
/**
* Bulk-reassigns every book currently on [fromShelfId] to [toShelfId]
* (null unassigns them) — backs the locations screen's "Move books"
* action and the cleanup when a shelf is deleted. Built only from
* [BookDao]'s existing read/upsert surface (observe + upsert), since
* BookDao itself belongs to the book-side worker.
*/
suspend fun moveBooks(fromShelfId: String, toShelfId: String?) {
val now = System.currentTimeMillis()
for (book in bookDao.observeByShelf(fromShelfId).first()) {
val nextState = if (book.syncState == SyncState.PENDING_CREATE) SyncState.PENDING_CREATE else SyncState.PENDING_UPDATE
bookDao.upsert(book.copy(shelfId = toShelfId, updatedAt = now, syncState = nextState))
}
}
}
@@ -0,0 +1,339 @@
package org.modg.bookshelf.ui.detail
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
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.Close
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Edit
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
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
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.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import kotlinx.coroutines.launch
import org.modg.bookshelf.AppContainer
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.repo.decodeAuthors
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
/**
* SPEC.md "detail" screen. [book] filters `deleted = 0`, so once the user
* deletes, the flow goes null while the undo snackbar is showing — [retainedBook]
* keeps rendering the last known content underneath it instead of collapsing
* to a blank screen.
*/
@Composable
fun DetailScreen(
bookId: String,
onBack: () -> Unit,
container: AppContainer,
) {
val viewModel: DetailViewModel = viewModel(
key = "detail-$bookId",
factory = viewModelFactory {
initializer {
DetailViewModel(
bookRepository = container.bookRepository,
locationRepository = container.locationRepository,
bookId = bookId,
)
}
},
)
val book by viewModel.book.collectAsState()
val bookcases by viewModel.bookcases.collectAsState()
val shelves by viewModel.shelves.collectAsState()
var retainedBook by remember { mutableStateOf<BookEntity?>(null) }
LaunchedEffect(book) { book?.let { retainedBook = it } }
val display = book ?: retainedBook
var editMode by remember { mutableStateOf(false) }
var editForm by remember { mutableStateOf(BookEditForm()) }
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
Box(modifier = Modifier.fillMaxSize()) {
BookshelfScaffold(
title = display?.title ?: "",
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = "Back")
}
},
actions = {
if (display != null && book != null) { // no edit/delete on a book that's mid-undo
if (editMode) {
IconButton(onClick = { editMode = false }) {
Icon(Icons.Outlined.Close, contentDescription = "Cancel edit")
}
IconButton(onClick = { viewModel.saveEdit(editForm); editMode = false }) {
Icon(Icons.Outlined.Save, contentDescription = "Save edit")
}
} else {
IconButton(onClick = { editForm = BookEditForm.from(display); editMode = true }) {
Icon(Icons.Outlined.Edit, contentDescription = "Edit")
}
IconButton(onClick = {
viewModel.delete()
scope.launch {
val result = snackbarHostState.showSnackbar(
message = "Book deleted",
actionLabel = "Undo",
duration = SnackbarDuration.Long,
)
if (result == SnackbarResult.ActionPerformed) viewModel.undoDelete() else onBack()
}
}) {
Icon(Icons.Outlined.Delete, contentDescription = "Delete")
}
}
}
},
) { innerPadding ->
PaperSurface(modifier = Modifier.fillMaxSize()) {
if (display == null) {
// Not loaded yet (or gone) — an empty paper surface beats a spinner for a
// screen that's supposed to feel instant, offline-first, per SPEC.
Box(modifier = Modifier.padding(innerPadding))
} else {
Column(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
) {
if (editMode) {
EditFields(form = editForm, onFormChange = { editForm = it })
} else {
BookHeader(book = display)
GoldDivider(modifier = Modifier.padding(vertical = 16.dp))
DescriptionSection(description = display.description)
}
GoldDivider(modifier = Modifier.padding(vertical = 16.dp))
NotesSection(book = display, onSave = viewModel::saveNotes)
GoldDivider(modifier = Modifier.padding(vertical = 16.dp))
LocationSection(
book = display,
bookcases = bookcases,
shelves = shelves,
onShelfSelected = viewModel::saveLocation,
)
}
}
}
}
SnackbarHost(hostState = snackbarHostState, modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp))
}
}
@Composable
private fun BookHeader(book: BookEntity) {
Row(modifier = Modifier.fillMaxWidth()) {
BookCover(
coverUrl = book.coverUrl ?: book.coverSourceUrl,
contentDescription = book.title,
modifier = Modifier.width(140.dp),
)
Column(modifier = Modifier.weight(1f).padding(start = 16.dp)) {
Text(text = book.title, style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onSurface)
if (!book.subtitle.isNullOrBlank()) {
Text(
text = book.subtitle,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp),
)
}
val authors = decodeAuthors(book.authorsJson)
if (authors.isNotEmpty()) {
Text(
text = authors.joinToString(", "),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp),
)
}
InfoLine(label = "Publisher", value = book.publisher)
InfoLine(label = "Published", value = book.publishedDate)
InfoLine(label = "Pages", value = book.pageCount?.toString())
InfoLine(label = "ISBN-13", value = book.isbn13)
InfoLine(label = "ISBN-10", value = book.isbn10)
}
}
}
@Composable
private fun InfoLine(label: String, value: String?) {
if (value.isNullOrBlank()) return
Text(
text = "$label: $value",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
}
@Composable
private fun DescriptionSection(description: String?) {
if (description.isNullOrBlank()) return
var expanded by remember { mutableStateOf(false) }
Column {
Text(
text = description,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
maxLines = if (expanded) Int.MAX_VALUE else 4,
overflow = TextOverflow.Ellipsis,
)
TextButton(onClick = { expanded = !expanded }) {
Text(if (expanded) "Show less" else "Read more")
Icon(
imageVector = if (expanded) Icons.Outlined.ExpandLess else Icons.Outlined.ExpandMore,
contentDescription = null,
modifier = Modifier.padding(start = 4.dp),
)
}
}
}
@Composable
private fun NotesSection(book: BookEntity, onSave: (String) -> Unit) {
var notes by remember(book.id) { mutableStateOf(book.notes.orEmpty()) }
Column {
Text(text = "Notes", style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Row(modifier = Modifier.fillMaxWidth().padding(top = 8.dp), verticalAlignment = Alignment.Top) {
OutlinedTextField(
value = notes,
onValueChange = { notes = it },
modifier = Modifier.weight(1f),
placeholder = { Text("Add a note about this copy…") },
minLines = 2,
)
if (notes != book.notes.orEmpty()) {
IconButton(onClick = { onSave(notes) }) {
Icon(Icons.Outlined.Save, contentDescription = "Save note")
}
}
}
}
}
@Composable
private fun LocationSection(
book: BookEntity,
bookcases: List<BookcaseEntity>,
shelves: List<ShelfEntity>,
onShelfSelected: (String?) -> Unit,
) {
var menuExpanded 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) {
"${currentBookcase.name}${currentShelf.label}"
} else {
"Not shelved"
}
Column {
Text(text = "Location", style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Row(
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
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 },
)
}
}
}
}
}
}
}
@Composable
private fun EditFields(form: BookEditForm, onFormChange: (BookEditForm) -> Unit) {
Column {
LabeledField("Title", form.title) { onFormChange(form.copy(title = it)) }
LabeledField("Subtitle", form.subtitle) { onFormChange(form.copy(subtitle = it)) }
LabeledField("Authors (comma-separated)", form.authors) { onFormChange(form.copy(authors = it)) }
LabeledField("Publisher", form.publisher) { onFormChange(form.copy(publisher = it)) }
LabeledField("Published date", form.publishedDate) { onFormChange(form.copy(publishedDate = it)) }
LabeledField("Pages", form.pageCount) { onFormChange(form.copy(pageCount = it)) }
LabeledField("ISBN-13", form.isbn13) { onFormChange(form.copy(isbn13 = it)) }
LabeledField("ISBN-10", form.isbn10) { onFormChange(form.copy(isbn10 = it)) }
LabeledField("Description", form.description, minLines = 3) { onFormChange(form.copy(description = it)) }
}
}
@Composable
private fun LabeledField(label: String, value: String, minLines: Int = 1, onValueChange: (String) -> Unit) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
label = { Text(label) },
minLines = minLines,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
)
}
@@ -0,0 +1,101 @@
package org.modg.bookshelf.ui.detail
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
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.repo.BookRepository
import org.modg.bookshelf.data.repo.LocationRepository
import org.modg.bookshelf.data.repo.decodeAuthors
import org.modg.bookshelf.data.repo.encodeAuthors
/** The editable fields of a book, per SPEC.md detail screen. */
data class BookEditForm(
val title: String = "",
val subtitle: String = "",
val authors: String = "", // comma-separated in the UI, split/joined at the edges
val publisher: String = "",
val publishedDate: String = "",
val pageCount: String = "",
val isbn13: String = "",
val isbn10: String = "",
val description: String = "",
) {
companion object {
fun from(book: BookEntity) = BookEditForm(
title = book.title,
subtitle = book.subtitle.orEmpty(),
authors = decodeAuthors(book.authorsJson).joinToString(", "),
publisher = book.publisher.orEmpty(),
publishedDate = book.publishedDate.orEmpty(),
pageCount = book.pageCount?.toString().orEmpty(),
isbn13 = book.isbn13.orEmpty(),
isbn10 = book.isbn10.orEmpty(),
description = book.description.orEmpty(),
)
}
}
class DetailViewModel(
private val bookRepository: BookRepository,
private val locationRepository: LocationRepository,
private val bookId: String,
) : ViewModel() {
/**
* Filters `deleted = 0`, so this goes null right when the user deletes —
* [org.modg.bookshelf.ui.detail.DetailScreen] retains the last non-null
* value locally to keep showing the book behind the undo snackbar.
*/
val book: StateFlow<BookEntity?> = bookRepository.observeById(bookId)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
val bookcases: StateFlow<List<BookcaseEntity>> = locationRepository.observeBookcases()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
val shelves: StateFlow<List<ShelfEntity>> = locationRepository.observeShelves()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
fun saveNotes(notes: String) {
val current = book.value ?: return
viewModelScope.launch { bookRepository.save(current.copy(notes = notes.ifBlank { null })) }
}
fun saveLocation(shelfId: String?) {
val current = book.value ?: return
viewModelScope.launch { bookRepository.save(current.copy(shelfId = shelfId)) }
}
fun saveEdit(form: BookEditForm) {
val current = book.value ?: return
viewModelScope.launch {
bookRepository.save(
current.copy(
title = form.title.trim().ifBlank { current.title },
subtitle = form.subtitle.trim().ifBlank { null },
authorsJson = encodeAuthors(form.authors.split(",").map { it.trim() }.filter { it.isNotEmpty() }),
publisher = form.publisher.trim().ifBlank { null },
publishedDate = form.publishedDate.trim().ifBlank { null },
pageCount = form.pageCount.trim().toIntOrNull(),
isbn13 = form.isbn13.trim().ifBlank { null },
isbn10 = form.isbn10.trim().ifBlank { null },
description = form.description.trim().ifBlank { null },
),
)
}
}
/** Soft-deletes now; the caller (screen) drives the undo snackbar and navigates back if it isn't undone. */
fun delete() {
viewModelScope.launch { bookRepository.softDelete(bookId) }
}
fun undoDelete() {
viewModelScope.launch { bookRepository.undoDelete(bookId) }
}
}
@@ -0,0 +1,54 @@
package org.modg.bookshelf.ui.library
import org.modg.bookshelf.data.local.BookEntity
import org.modg.bookshelf.data.repo.decodeAuthors
/** SPEC.md library screen: "sort title/author/added". */
enum class LibrarySortOption {
TITLE,
AUTHOR,
ADDED,
}
/** SPEC.md library screen: "filter by bookcase/shelf". */
sealed interface LibraryFilter {
data object All : LibraryFilter
data class Bookcase(val bookcaseId: String) : LibraryFilter
data class Shelf(val shelfId: String) : LibraryFilter
}
/**
* Pure sort logic, split out from [LibraryViewModel] so it's unit-testable
* without Android/Robolectric. Sorting always happens in-memory, after the
* DB has already filtered by search text — SQL ORDER BY can't reach into
* [BookEntity.authorsJson] to sort by first author.
*/
object LibrarySort {
fun sort(books: List<BookEntity>, option: LibrarySortOption): List<BookEntity> = when (option) {
LibrarySortOption.TITLE -> books.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.title })
LibrarySortOption.AUTHOR -> books.sortedWith(
compareBy(String.CASE_INSENSITIVE_ORDER) { decodeAuthors(it.authorsJson).firstOrNull().orEmpty() },
)
LibrarySortOption.ADDED -> books.sortedByDescending { it.createdAt }
}
}
/**
* Pure filter logic. [shelfIdsByBookcase] maps a bookcase id to the ids of
* every shelf in it, so a bookcase-level filter can be expressed purely in
* terms of [BookEntity.shelfId] without a dedicated DAO query.
*/
object LibraryFilterLogic {
fun apply(
books: List<BookEntity>,
filter: LibraryFilter,
shelfIdsByBookcase: Map<String, Set<String>>,
): List<BookEntity> = when (filter) {
is LibraryFilter.All -> books
is LibraryFilter.Shelf -> books.filter { it.shelfId == filter.shelfId }
is LibraryFilter.Bookcase -> {
val shelfIds = shelfIdsByBookcase[filter.bookcaseId].orEmpty()
books.filter { it.shelfId != null && it.shelfId in shelfIds }
}
}
}
@@ -0,0 +1,286 @@
package org.modg.bookshelf.ui.library
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Clear
import androidx.compose.material.icons.outlined.FilterList
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
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
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.data.local.BookEntity
import org.modg.bookshelf.data.local.BookcaseEntity
import org.modg.bookshelf.data.local.ShelfEntity
import org.modg.bookshelf.data.repo.decodeAuthors
import org.modg.bookshelf.ui.components.BookCover
import org.modg.bookshelf.ui.components.EmptyState
import org.modg.bookshelf.ui.components.BookshelfScaffold
import org.modg.bookshelf.ui.components.PaperSurface
import org.modg.bookshelf.ui.components.PrimaryButton
import org.modg.bookshelf.ui.components.SyncStatus
import org.modg.bookshelf.ui.components.SyncStatusBar
/**
* SPEC.md "library" screen: adaptive cover grid, search, filter, sort, empty
* state, FAB to scan, sync status line. Covers carry the color; card chrome
* stays minimal (cover + two lines of text) on purpose.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LibraryScreen(
shelfIdFilter: String?,
onBookClick: (String) -> Unit,
onScanClick: () -> Unit,
onLocationsClick: () -> Unit,
onSettingsClick: () -> Unit,
container: AppContainer,
) {
val viewModel: LibraryViewModel = viewModel(
// A fresh VM per distinct shelf filter, so tapping a different shelf
// in Locations doesn't inherit the previous shelf's filter/search state.
key = "library-${shelfIdFilter ?: "all"}",
factory = viewModelFactory {
initializer {
LibraryViewModel(
bookRepository = container.bookRepository,
locationRepository = container.locationRepository,
syncEngine = container.syncEngine,
settingsStore = container.settingsStore,
initialShelfId = shelfIdFilter,
)
}
},
)
val uiState by viewModel.uiState.collectAsState()
val query by viewModel.query.collectAsState()
val sortOption by viewModel.sortOption.collectAsState()
val filter by viewModel.filter.collectAsState()
BookshelfScaffold(
title = "Bookshelf",
actions = {
IconButton(onClick = onLocationsClick) {
Icon(Icons.Outlined.Warehouse, contentDescription = "Bookcases & shelves")
}
IconButton(onClick = onSettingsClick) {
Icon(Icons.Outlined.Settings, contentDescription = "Settings")
}
},
floatingActionButton = {
FloatingActionButton(onClick = onScanClick) {
Icon(Icons.Outlined.QrCodeScanner, contentDescription = "Scan a book")
}
},
syncStatusBar = {
SyncStatusBar(status = uiState.syncBar.status, label = uiState.syncBar.label)
},
) { innerPadding ->
PullToRefreshBox(
isRefreshing = uiState.syncBar.status == SyncStatus.Syncing,
onRefresh = viewModel::refreshSync,
modifier = Modifier
.padding(innerPadding)
.fillMaxSize(),
) {
PaperSurface(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxSize()) {
LibraryToolbar(
query = query,
onQueryChange = viewModel::onQueryChange,
sortOption = sortOption,
onSortOptionChange = viewModel::onSortOptionChange,
filter = filter,
onFilterChange = viewModel::onFilterChange,
bookcases = uiState.bookcases,
shelves = uiState.shelves,
)
when {
!uiState.hasAnyBooks -> EmptyState(
title = "Your shelves are empty",
message = "Scan a barcode to add your first book.",
action = { PrimaryButton(text = "Scan a book", onClick = onScanClick) },
)
uiState.books.isEmpty() -> EmptyState(
title = "No books match",
message = "Try a different search term or filter.",
)
else -> LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 110.dp),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxSize(),
) {
items(uiState.books, key = { it.id }) { book ->
LibraryBookCard(book = book, onClick = { onBookClick(book.id) })
}
}
}
}
}
}
}
}
@Composable
private fun LibraryToolbar(
query: String,
onQueryChange: (String) -> Unit,
sortOption: LibrarySortOption,
onSortOptionChange: (LibrarySortOption) -> Unit,
filter: LibraryFilter,
onFilterChange: (LibraryFilter) -> Unit,
bookcases: List<BookcaseEntity>,
shelves: List<ShelfEntity>,
) {
var filterMenuExpanded by remember { mutableStateOf(false) }
var sortMenuExpanded by remember { mutableStateOf(false) }
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedTextField(
value = query,
onValueChange = onQueryChange,
modifier = Modifier.weight(1f),
placeholder = { Text("Search title, author, ISBN", style = MaterialTheme.typography.bodyMedium) },
singleLine = true,
leadingIcon = { Icon(Icons.Outlined.Search, contentDescription = null) },
trailingIcon = {
if (query.isNotEmpty()) {
IconButton(onClick = { onQueryChange("") }) {
Icon(Icons.Outlined.Clear, contentDescription = "Clear search")
}
}
},
)
Column {
IconButton(onClick = { filterMenuExpanded = true }) {
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 ->
DropdownMenuItem(
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 },
)
}
}
}
}
Column {
IconButton(onClick = { sortMenuExpanded = true }) {
Icon(Icons.Outlined.Sort, contentDescription = "Sort")
}
DropdownMenu(expanded = sortMenuExpanded, onDismissRequest = { sortMenuExpanded = false }) {
DropdownMenuItem(
text = { Text("Title") },
onClick = { onSortOptionChange(LibrarySortOption.TITLE); sortMenuExpanded = false },
)
DropdownMenuItem(
text = { Text("Author") },
onClick = { onSortOptionChange(LibrarySortOption.AUTHOR); sortMenuExpanded = false },
)
DropdownMenuItem(
text = { Text("Recently added") },
onClick = { onSortOptionChange(LibrarySortOption.ADDED); sortMenuExpanded = false },
)
}
}
}
val filterLabel = when (val f = filter) {
is LibraryFilter.All -> null
is LibraryFilter.Bookcase -> bookcases.find { it.id == f.bookcaseId }?.name
is LibraryFilter.Shelf -> shelves.find { it.id == f.shelfId }?.label
}
if (filterLabel != null) {
Text(
text = "Filtered to $filterLabel",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
}
}
@Composable
private fun LibraryBookCard(book: BookEntity, onClick: () -> Unit) {
Column(modifier = Modifier.fillMaxWidth().clickable(onClick = onClick)) {
BookCover(
coverUrl = book.coverUrl ?: book.coverSourceUrl,
contentDescription = book.title,
modifier = Modifier.fillMaxWidth(),
)
Text(
text = book.title,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 6.dp),
)
val authors = decodeAuthors(book.authorsJson)
if (authors.isNotEmpty()) {
Text(
text = authors.joinToString(", "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@@ -0,0 +1,50 @@
package org.modg.bookshelf.ui.library
import org.modg.bookshelf.ui.components.SyncStatus
/** What the last completed [org.modg.bookshelf.data.repo.SyncEngine.sync] call did, presentation-wise. */
enum class SyncOutcome {
NONE,
SUCCESS,
FAILURE,
NO_SERVER,
}
data class SyncBarState(val status: SyncStatus, val label: String)
/**
* Turns raw sync state into the library screen's quiet status line (SPEC:
* "sync failure is a quiet status line, never a dialog"). Pure so it's
* unit-testable without touching [org.modg.bookshelf.data.repo.SyncEngine]
* or Android at all.
*/
object LibrarySyncPresenter {
fun present(
nowMillis: Long,
lastSyncMillis: Long?,
isSyncing: Boolean,
lastOutcome: SyncOutcome,
): SyncBarState {
if (isSyncing) return SyncBarState(SyncStatus.Syncing, "Syncing…")
return when (lastOutcome) {
SyncOutcome.FAILURE -> SyncBarState(SyncStatus.Error, "Sync failed — showing local library")
SyncOutcome.NO_SERVER -> SyncBarState(SyncStatus.Offline, "No server configured")
SyncOutcome.SUCCESS, SyncOutcome.NONE -> if (lastSyncMillis != null) {
SyncBarState(SyncStatus.Synced, "Synced ${elapsedPhrase(nowMillis - lastSyncMillis)}")
} else {
SyncBarState(SyncStatus.Offline, "Not synced yet")
}
}
}
/** e.g. "just now", "5m ago", "2h ago", "3d ago". */
private fun elapsedPhrase(deltaMillis: Long): String {
val seconds = (deltaMillis / 1000).coerceAtLeast(0)
return when {
seconds < 60 -> "just now"
seconds < 3600 -> "${seconds / 60}m ago"
seconds < 86400 -> "${seconds / 3600}h ago"
else -> "${seconds / 86400}d ago"
}
}
}
@@ -0,0 +1,132 @@
package org.modg.bookshelf.ui.library
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
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.SyncEngine
import org.modg.bookshelf.data.repo.SyncResult
import org.modg.bookshelf.ui.components.SyncStatus
/** Everything [LibraryScreen] needs to render the grid + chrome. */
data class LibraryUiState(
val books: List<BookEntity> = emptyList(),
val hasAnyBooks: Boolean = true, // true until proven otherwise, so the empty state never flashes on first frame
val bookcases: List<BookcaseEntity> = emptyList(),
val shelves: List<ShelfEntity> = emptyList(),
val syncBar: SyncBarState = SyncBarState(SyncStatus.Offline, ""),
)
@OptIn(ExperimentalCoroutinesApi::class)
class LibraryViewModel(
private val bookRepository: BookRepository,
private val locationRepository: LocationRepository,
private val syncEngine: SyncEngine,
private val settingsStore: SettingsStore,
initialShelfId: String?,
) : ViewModel() {
private val _query = MutableStateFlow("")
val query: StateFlow<String> = _query.asStateFlow()
private val _sortOption = MutableStateFlow(LibrarySortOption.TITLE)
val sortOption: StateFlow<LibrarySortOption> = _sortOption.asStateFlow()
private val _filter = MutableStateFlow<LibraryFilter>(
initialShelfId?.let { LibraryFilter.Shelf(it) } ?: LibraryFilter.All,
)
val filter: StateFlow<LibraryFilter> = _filter.asStateFlow()
private val _isSyncing = MutableStateFlow(false)
private val _lastSyncOutcome = MutableStateFlow(SyncOutcome.NONE)
val bookcases: StateFlow<List<BookcaseEntity>> = locationRepository.observeBookcases()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
val shelves: StateFlow<List<ShelfEntity>> = locationRepository.observeShelves()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
/** Bookcase id -> ids of every shelf in it, so a bookcase-level filter needs no dedicated DAO query. */
private val shelfIdsByBookcase: StateFlow<Map<String, Set<String>>> = locationRepository.observeShelves()
.map { shelves -> shelves.groupBy({ it.bookcaseId }, { it.id }).mapValues { it.value.toSet() } }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap())
private val hasAnyBooks: StateFlow<Boolean> = bookRepository.observeAll()
.map { it.isNotEmpty() }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), true)
private val filteredSortedBooks: StateFlow<List<BookEntity>> = combine(
_query.flatMapLatest { bookRepository.search(it) },
_filter,
_sortOption,
shelfIdsByBookcase,
) { books, filterValue, sort, shelfMap ->
LibrarySort.sort(LibraryFilterLogic.apply(books, filterValue, shelfMap), sort)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
private val syncBar: StateFlow<SyncBarState> = combine(
settingsStore.lastSyncTime,
_isSyncing,
_lastSyncOutcome,
) { lastSync, syncing, outcome ->
LibrarySyncPresenter.present(System.currentTimeMillis(), lastSync, syncing, outcome)
}.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5_000),
LibrarySyncPresenter.present(System.currentTimeMillis(), null, false, SyncOutcome.NONE),
)
val uiState: StateFlow<LibraryUiState> = combine(
filteredSortedBooks,
hasAnyBooks,
bookcases,
shelves,
syncBar,
) { books, hasAny, cases, shelfList, bar ->
LibraryUiState(books = books, hasAnyBooks = hasAny, bookcases = cases, shelves = shelfList, syncBar = bar)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), LibraryUiState())
init {
refreshSync()
}
fun onQueryChange(value: String) {
_query.value = value
}
fun onSortOptionChange(option: LibrarySortOption) {
_sortOption.value = option
}
fun onFilterChange(filter: LibraryFilter) {
_filter.value = filter
}
/** Manual trigger — app start (see init) and pull-to-refresh, per SPEC "Sync design". */
fun refreshSync() {
if (_isSyncing.value) return
viewModelScope.launch {
_isSyncing.value = true
when (syncEngine.sync()) {
is SyncResult.Success -> _lastSyncOutcome.value = SyncOutcome.SUCCESS
is SyncResult.Failure -> _lastSyncOutcome.value = SyncOutcome.FAILURE
is SyncResult.Skipped -> _lastSyncOutcome.value = SyncOutcome.NO_SERVER
}
_isSyncing.value = false
}
}
}
@@ -0,0 +1,366 @@
package org.modg.bookshelf.ui.locations
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.ArrowDropUp
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.MoveDown
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
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.unit.dp
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.data.local.BookcaseEntity
import org.modg.bookshelf.data.local.ShelfEntity
import org.modg.bookshelf.ui.components.BookshelfScaffold
import org.modg.bookshelf.ui.components.EmptyState
import org.modg.bookshelf.ui.components.GoldDivider
import org.modg.bookshelf.ui.components.PaperSurface
import org.modg.bookshelf.ui.components.PrimaryButton
/** SPEC "locations": bookcases -> shelves tree, CRUD + reorder, counts, bulk move. */
@Composable
fun LocationsScreen(
onBack: () -> Unit,
onShelfClick: (String) -> Unit,
container: AppContainer,
) {
val viewModel: LocationsViewModel = viewModel(
factory = viewModelFactory {
initializer { LocationsViewModel(container.locationRepository) }
},
)
val state by viewModel.uiState.collectAsState()
// Book counts can go stale while this screen isn't visible (a book's shelf
// can change from Library/Detail); pick up the latest each time we re-enter.
DisposableEffect(Unit) {
viewModel.refresh()
onDispose { }
}
BookshelfScaffold(
title = "Locations",
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Filled.ArrowBack, contentDescription = "Back")
}
},
floatingActionButton = {
FloatingActionButton(onClick = viewModel::openAddBookcase) {
Icon(Icons.Filled.Add, contentDescription = "Add bookcase")
}
},
) { innerPadding ->
PaperSurface(modifier = Modifier.fillMaxWidth()) {
if (state.bookcases.isEmpty()) {
EmptyState(
modifier = Modifier.padding(innerPadding),
title = "No bookcases yet",
message = "Add a bookcase to start organizing your shelves.",
action = { PrimaryButton(text = "Add a bookcase", onClick = viewModel::openAddBookcase) },
)
} else {
LazyColumn(contentPadding = PaddingValues(bottom = 96.dp)) {
items(state.bookcases, key = { it.bookcase.id }) { bookcaseUi ->
BookcaseRow(
bookcaseUi = bookcaseUi,
onEdit = { viewModel.openEditBookcase(bookcaseUi.bookcase) },
onDelete = { viewModel.openConfirmDeleteBookcase(bookcaseUi.bookcase) },
onMoveUp = { viewModel.reorderBookcase(bookcaseUi.bookcase, -1) },
onMoveDown = { viewModel.reorderBookcase(bookcaseUi.bookcase, 1) },
onAddShelf = { viewModel.openAddShelf(bookcaseUi.bookcase.id) },
onShelfClick = onShelfClick,
onEditShelf = viewModel::openEditShelf,
onDeleteShelf = viewModel::openConfirmDeleteShelf,
onMoveShelfBooks = viewModel::openMoveBooks,
onShelfMoveUp = { shelf -> viewModel.reorderShelf(bookcaseUi.bookcase.id, shelf, -1) },
onShelfMoveDown = { shelf -> viewModel.reorderShelf(bookcaseUi.bookcase.id, shelf, 1) },
)
GoldDivider(modifier = Modifier.padding(vertical = 4.dp))
}
}
}
}
}
when (val dialog = state.dialog) {
null -> Unit
is LocationsDialog.AddBookcase -> BookcaseEditDialog(
title = "Add bookcase",
editing = null,
onDismiss = viewModel::dismissDialog,
onSubmit = { name, note -> viewModel.submitBookcase(name, note, null) },
)
is LocationsDialog.EditBookcase -> BookcaseEditDialog(
title = "Edit bookcase",
editing = dialog.bookcase,
onDismiss = viewModel::dismissDialog,
onSubmit = { name, note -> viewModel.submitBookcase(name, note, dialog.bookcase) },
)
is LocationsDialog.ConfirmDeleteBookcase -> ConfirmDialog(
title = "Delete \"${dialog.bookcase.name}\"?",
message = "Its shelves will be removed too. Books on them become unassigned, not deleted.",
onDismiss = viewModel::dismissDialog,
onConfirm = { viewModel.confirmDeleteBookcase(dialog.bookcase) },
)
is LocationsDialog.AddShelf -> ShelfEditDialog(
title = "Add shelf",
editing = null,
onDismiss = viewModel::dismissDialog,
onSubmit = { label -> viewModel.submitShelf(label, dialog.bookcaseId, null) },
)
is LocationsDialog.EditShelf -> ShelfEditDialog(
title = "Edit shelf",
editing = dialog.shelf,
onDismiss = viewModel::dismissDialog,
onSubmit = { label -> viewModel.submitShelf(label, dialog.shelf.bookcaseId, dialog.shelf) },
)
is LocationsDialog.ConfirmDeleteShelf -> ConfirmDialog(
title = "Delete \"${dialog.shelf.label}\"?",
message = "Books on this shelf become unassigned, not deleted.",
onDismiss = viewModel::dismissDialog,
onConfirm = { viewModel.confirmDeleteShelf(dialog.shelf) },
)
is LocationsDialog.MoveBooks -> MoveBooksDialog(
fromShelf = dialog.fromShelf,
allShelves = state.bookcases.flatMap { it.shelves.map { s -> s.shelf } }
.filter { it.id != dialog.fromShelf.id },
onDismiss = viewModel::dismissDialog,
onConfirm = { toShelfId -> viewModel.moveBooks(dialog.fromShelf, toShelfId) },
)
}
}
@Composable
private fun BookcaseRow(
bookcaseUi: BookcaseUi,
onEdit: () -> Unit,
onDelete: () -> Unit,
onMoveUp: () -> Unit,
onMoveDown: () -> Unit,
onAddShelf: () -> Unit,
onShelfClick: (String) -> Unit,
onEditShelf: (ShelfEntity) -> Unit,
onDeleteShelf: (ShelfEntity) -> Unit,
onMoveShelfBooks: (ShelfEntity) -> Unit,
onShelfMoveUp: (ShelfEntity) -> Unit,
onShelfMoveDown: (ShelfEntity) -> Unit,
) {
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(text = bookcaseUi.bookcase.name, style = MaterialTheme.typography.titleMedium)
if (!bookcaseUi.bookcase.note.isNullOrBlank()) {
Text(
text = bookcaseUi.bookcase.note,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
ReorderButtons(onMoveUp = onMoveUp, onMoveDown = onMoveDown)
IconButton(onClick = onEdit) { Icon(Icons.Filled.Edit, contentDescription = "Edit bookcase") }
IconButton(onClick = onDelete) { Icon(Icons.Filled.Delete, contentDescription = "Delete bookcase") }
}
Column(modifier = Modifier.padding(start = 16.dp, top = 4.dp)) {
bookcaseUi.shelves.forEach { shelfUi ->
ShelfRow(
shelfUi = shelfUi,
onClick = { onShelfClick(shelfUi.shelf.id) },
onEdit = { onEditShelf(shelfUi.shelf) },
onDelete = { onDeleteShelf(shelfUi.shelf) },
onMoveBooks = { onMoveShelfBooks(shelfUi.shelf) },
onMoveUp = { onShelfMoveUp(shelfUi.shelf) },
onMoveDown = { onShelfMoveDown(shelfUi.shelf) },
)
}
TextButton(onClick = onAddShelf) {
Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Text(text = "Add shelf", modifier = Modifier.padding(start = 4.dp))
}
}
}
}
@Composable
private fun ShelfRow(
shelfUi: ShelfUi,
onClick: () -> Unit,
onEdit: () -> Unit,
onDelete: () -> Unit,
onMoveBooks: () -> Unit,
onMoveUp: () -> Unit,
onMoveDown: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(vertical = 6.dp),
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(text = shelfUi.shelf.label, style = MaterialTheme.typography.bodyLarge)
Text(
text = if (shelfUi.bookCount == 1) "1 book" else "${shelfUi.bookCount} books",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
ReorderButtons(onMoveUp = onMoveUp, onMoveDown = onMoveDown)
IconButton(onClick = onMoveBooks) { Icon(Icons.Filled.MoveDown, contentDescription = "Move books off this shelf") }
IconButton(onClick = onEdit) { Icon(Icons.Filled.Edit, contentDescription = "Edit shelf") }
IconButton(onClick = onDelete) { Icon(Icons.Filled.Delete, contentDescription = "Delete shelf") }
}
}
@Composable
private fun ReorderButtons(onMoveUp: () -> Unit, onMoveDown: () -> Unit) {
Row {
IconButton(onClick = onMoveUp) { Icon(Icons.Filled.ArrowDropUp, contentDescription = "Move up") }
IconButton(onClick = onMoveDown) { Icon(Icons.Filled.ArrowDropDown, contentDescription = "Move down") }
}
}
@Composable
private fun BookcaseEditDialog(
title: String,
editing: BookcaseEntity?,
onDismiss: () -> Unit,
onSubmit: (name: String, note: String?) -> Unit,
) {
var name by remember { mutableStateOf(editing?.name.orEmpty()) }
var note by remember { mutableStateOf(editing?.note.orEmpty()) }
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 = note, onValueChange = { note = it }, label = { Text("Note (optional)") }, singleLine = true)
}
},
confirmButton = {
TextButton(onClick = { onSubmit(name, note.ifBlank { null }) }, enabled = name.isNotBlank()) { Text("Save") }
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
}
@Composable
private fun ShelfEditDialog(
title: String,
editing: ShelfEntity?,
onDismiss: () -> Unit,
onSubmit: (label: String) -> Unit,
) {
var label by remember { mutableStateOf(editing?.label.orEmpty()) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(text = title, style = MaterialTheme.typography.titleLarge) },
text = {
OutlinedTextField(value = label, onValueChange = { label = it }, label = { Text("Label") }, singleLine = true)
},
confirmButton = {
TextButton(onClick = { onSubmit(label) }, enabled = label.isNotBlank()) { Text("Save") }
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
}
@Composable
private fun ConfirmDialog(
title: String,
message: String,
onDismiss: () -> Unit,
onConfirm: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(text = title, style = MaterialTheme.typography.titleLarge) },
text = { Text(text = message, style = MaterialTheme.typography.bodyMedium) },
confirmButton = { TextButton(onClick = onConfirm) { Text("Delete") } },
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
}
@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
@Composable
private fun MoveBooksDialog(
fromShelf: ShelfEntity,
allShelves: List<ShelfEntity>,
onDismiss: () -> Unit,
onConfirm: (toShelfId: String) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
var selected by remember { mutableStateOf(allShelves.firstOrNull()) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(text = "Move books off \"${fromShelf.label}\"", style = MaterialTheme.typography.titleLarge) },
text = {
if (allShelves.isEmpty()) {
Text(text = "There's no other shelf to move books to yet.", style = MaterialTheme.typography.bodyMedium)
} else {
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
OutlinedTextField(
value = selected?.label.orEmpty(),
onValueChange = {},
readOnly = true,
label = { Text("Destination shelf") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier.fillMaxWidth().menuAnchor(),
)
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
allShelves.forEach { shelf ->
DropdownMenuItem(
text = { Text(shelf.label) },
onClick = { selected = shelf; expanded = false },
)
}
}
}
}
},
confirmButton = {
TextButton(onClick = { selected?.let { onConfirm(it.id) } }, enabled = selected != null) { Text("Move") }
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
}
@@ -0,0 +1,178 @@
package org.modg.bookshelf.ui.locations
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.modg.bookshelf.data.local.BookcaseEntity
import org.modg.bookshelf.data.local.ShelfEntity
import org.modg.bookshelf.data.repo.LocationRepository
data class ShelfUi(val shelf: ShelfEntity, val bookCount: Int)
data class BookcaseUi(val bookcase: BookcaseEntity, val shelves: List<ShelfUi>)
/** Which modal, if any, is currently up. One at a time — never stacked. */
sealed class LocationsDialog {
object AddBookcase : LocationsDialog()
data class EditBookcase(val bookcase: BookcaseEntity) : LocationsDialog()
data class ConfirmDeleteBookcase(val bookcase: BookcaseEntity) : LocationsDialog()
data class AddShelf(val bookcaseId: String) : LocationsDialog()
data class EditShelf(val shelf: ShelfEntity) : LocationsDialog()
data class ConfirmDeleteShelf(val shelf: ShelfEntity) : LocationsDialog()
data class MoveBooks(val fromShelf: ShelfEntity) : LocationsDialog()
}
data class LocationsUiState(
val bookcases: List<BookcaseUi> = emptyList(),
val dialog: LocationsDialog? = null,
val errorMessage: String? = null,
)
/** Manual DI per SPEC: constructed by [LocationsScreen] from AppContainer, no Hilt. */
class LocationsViewModel(private val locationRepository: LocationRepository) : ViewModel() {
private val shelfCounts = MutableStateFlow<Map<String, Int>>(emptyMap())
private val dialogState = MutableStateFlow<LocationsDialog?>(null)
private val errorState = MutableStateFlow<String?>(null)
val uiState: StateFlow<LocationsUiState> = combine(
locationRepository.observeBookcases(),
locationRepository.observeShelves(),
shelfCounts,
dialogState,
errorState,
) { bookcases, shelves, counts, dialog, error ->
LocationsUiState(
bookcases = bookcases.sortedBy { it.position }.map { bc ->
BookcaseUi(
bookcase = bc,
shelves = shelves.filter { it.bookcaseId == bc.id }
.sortedBy { it.position }
.map { ShelfUi(it, counts[it.id] ?: 0) },
)
},
dialog = dialog,
errorMessage = error,
)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), LocationsUiState())
init {
// Book counts are a suspend query (BookDao has no reactive count-by-shelf
// flow, and BookDao is the other worker's file to change), so recompute
// them whenever the shelf list itself changes. [refresh] covers the case
// where only a book's shelf assignment changed elsewhere in the app.
viewModelScope.launch {
locationRepository.observeShelves().collectLatest { shelves -> recomputeCounts(shelves) }
}
}
private suspend fun recomputeCounts(shelves: List<ShelfEntity>) {
shelfCounts.value = shelves.associate { it.id to locationRepository.bookCountForShelf(it.id) }
}
/** Call when the screen (re)enters composition to pick up counts changed elsewhere. */
fun refresh() {
viewModelScope.launch { recomputeCounts(locationRepository.observeShelves().first()) }
}
fun openAddBookcase() { dialogState.value = LocationsDialog.AddBookcase }
fun openEditBookcase(bookcase: BookcaseEntity) { dialogState.value = LocationsDialog.EditBookcase(bookcase) }
fun openConfirmDeleteBookcase(bookcase: BookcaseEntity) { dialogState.value = LocationsDialog.ConfirmDeleteBookcase(bookcase) }
fun openAddShelf(bookcaseId: String) { dialogState.value = LocationsDialog.AddShelf(bookcaseId) }
fun openEditShelf(shelf: ShelfEntity) { dialogState.value = LocationsDialog.EditShelf(shelf) }
fun openConfirmDeleteShelf(shelf: ShelfEntity) { dialogState.value = LocationsDialog.ConfirmDeleteShelf(shelf) }
fun openMoveBooks(fromShelf: ShelfEntity) { dialogState.value = LocationsDialog.MoveBooks(fromShelf) }
fun dismissDialog() { dialogState.value = null }
fun dismissError() { errorState.value = null }
fun submitBookcase(name: String, note: String?, editing: BookcaseEntity?) {
val trimmedName = name.trim()
if (trimmedName.isEmpty()) return
viewModelScope.launch {
if (editing != null) {
locationRepository.saveBookcase(editing.copy(name = trimmedName, note = note?.trim()?.ifBlank { null }))
} else {
val nextPosition = (uiState.value.bookcases.maxOfOrNull { it.bookcase.position } ?: -1) + 1
locationRepository.createBookcase(trimmedName, note?.trim()?.ifBlank { null }, nextPosition)
}
dialogState.value = null
}
}
fun submitShelf(label: String, bookcaseId: String, editing: ShelfEntity?) {
val trimmedLabel = label.trim()
if (trimmedLabel.isEmpty()) return
viewModelScope.launch {
if (editing != null) {
locationRepository.saveShelf(editing.copy(label = trimmedLabel))
} else {
val nextPosition = (
uiState.value.bookcases.find { it.bookcase.id == bookcaseId }
?.shelves?.maxOfOrNull { it.shelf.position } ?: -1
) + 1
locationRepository.createShelf(bookcaseId, trimmedLabel, nextPosition)
}
dialogState.value = null
}
}
fun confirmDeleteBookcase(bookcase: BookcaseEntity) {
viewModelScope.launch {
val shelvesUnder = uiState.value.bookcases.find { it.bookcase.id == bookcase.id }?.shelves.orEmpty()
for (shelfUi in shelvesUnder) {
locationRepository.moveBooks(shelfUi.shelf.id, toShelfId = null)
locationRepository.softDeleteShelf(shelfUi.shelf.id)
}
locationRepository.softDeleteBookcase(bookcase.id)
dialogState.value = null
}
}
fun confirmDeleteShelf(shelf: ShelfEntity) {
viewModelScope.launch {
locationRepository.moveBooks(shelf.id, toShelfId = null)
locationRepository.softDeleteShelf(shelf.id)
dialogState.value = null
}
}
fun moveBooks(fromShelf: ShelfEntity, toShelfId: String) {
viewModelScope.launch {
locationRepository.moveBooks(fromShelf.id, toShelfId)
recomputeCounts(locationRepository.observeShelves().first())
dialogState.value = null
}
}
fun reorderBookcase(bookcase: BookcaseEntity, direction: Int) {
val ordered = uiState.value.bookcases.map { it.bookcase }
val index = ordered.indexOfFirst { it.id == bookcase.id }
val swapIndex = index + direction
if (index < 0 || swapIndex !in ordered.indices) return
val a = ordered[index]
val b = ordered[swapIndex]
viewModelScope.launch {
locationRepository.saveBookcase(a.copy(position = b.position))
locationRepository.saveBookcase(b.copy(position = a.position))
}
}
fun reorderShelf(bookcaseId: String, shelf: ShelfEntity, direction: Int) {
val ordered = uiState.value.bookcases.find { it.bookcase.id == bookcaseId }?.shelves?.map { it.shelf } ?: return
val index = ordered.indexOfFirst { it.id == shelf.id }
val swapIndex = index + direction
if (index < 0 || swapIndex !in ordered.indices) return
val a = ordered[index]
val b = ordered[swapIndex]
viewModelScope.launch {
locationRepository.saveShelf(a.copy(position = b.position))
locationRepository.saveShelf(b.copy(position = a.position))
}
}
}
@@ -0,0 +1,95 @@
package org.modg.bookshelf.ui.nav
import androidx.compose.runtime.Composable
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import org.modg.bookshelf.AppContainer
import org.modg.bookshelf.ui.detail.DetailScreen
import org.modg.bookshelf.ui.library.LibraryScreen
import org.modg.bookshelf.ui.locations.LocationsScreen
import org.modg.bookshelf.ui.scan.ScanScreen
import org.modg.bookshelf.ui.settings.SettingsScreen
import org.modg.bookshelf.ui.setup.SetupScreen
/**
* The whole navigation graph. [Routes.LIBRARY_WITH_SHELF] doubles as the bare
* "library" route: Navigation Compose matches a query-parameterized route
* even when the query is omitted, as long as the argument is nullable — so
* one composable serves both entries in the shared screen contract.
*/
@Composable
fun BookshelfNavHost(
container: AppContainer,
startDestination: String,
navController: NavHostController = rememberNavController(),
) {
NavHost(navController = navController, startDestination = startDestination) {
composable(Routes.SETUP) {
SetupScreen(
onSetupComplete = {
navController.navigate(Routes.LIBRARY) {
popUpTo(Routes.SETUP) { inclusive = true }
}
},
container = container,
)
}
composable(
route = Routes.LIBRARY_WITH_SHELF,
arguments = listOf(
navArgument(Routes.LIBRARY_SHELF_ARG) {
type = NavType.StringType
nullable = true
defaultValue = null
},
),
) { backStackEntry ->
LibraryScreen(
shelfIdFilter = backStackEntry.arguments?.getString(Routes.LIBRARY_SHELF_ARG),
onBookClick = { bookId -> navController.navigate(Routes.detail(bookId)) },
onScanClick = { navController.navigate(Routes.SCAN) },
onLocationsClick = { navController.navigate(Routes.LOCATIONS) },
onSettingsClick = { navController.navigate(Routes.SETTINGS) },
container = container,
)
}
composable(
route = Routes.DETAIL,
arguments = listOf(navArgument(Routes.DETAIL_ARG) { type = NavType.StringType }),
) { backStackEntry ->
DetailScreen(
bookId = backStackEntry.arguments?.getString(Routes.DETAIL_ARG).orEmpty(),
onBack = { navController.popBackStack() },
container = container,
)
}
composable(Routes.SCAN) {
ScanScreen(
onBack = { navController.popBackStack() },
container = container,
)
}
composable(Routes.LOCATIONS) {
LocationsScreen(
onBack = { navController.popBackStack() },
onShelfClick = { shelfId -> navController.navigate(Routes.libraryFilteredByShelf(shelfId)) },
container = container,
)
}
composable(Routes.SETTINGS) {
SettingsScreen(
onBack = { navController.popBackStack() },
onSignedOut = {
navController.navigate(Routes.SETUP) {
popUpTo(navController.graph.id) { inclusive = true }
}
},
container = container,
)
}
}
}
@@ -0,0 +1,21 @@
package org.modg.bookshelf.ui.nav
/**
* Route string constants for [BookshelfNavHost]. Fixed by the wave-3 screen
* contract shared with Worker E2 — do not rename or restructure these; E2's
* screens are written to navigate against exactly these values.
*/
object Routes {
const val SETUP = "setup"
const val LIBRARY = "library"
const val LIBRARY_SHELF_ARG = "shelfId"
const val LIBRARY_WITH_SHELF = "library?shelfId={shelfId}"
const val DETAIL_ARG = "bookId"
const val DETAIL = "detail/{bookId}"
const val SCAN = "scan"
const val LOCATIONS = "locations"
const val SETTINGS = "settings"
fun libraryFilteredByShelf(shelfId: String) = "library?shelfId=$shelfId"
fun detail(bookId: String) = "detail/$bookId"
}
@@ -0,0 +1,37 @@
package org.modg.bookshelf.ui.scan
import org.modg.bookshelf.data.local.BookEntity
import org.modg.bookshelf.data.metadata.BookMetadata
/** SPEC.md scan screen: "Duplicate-ISBN warning if already owned." */
sealed interface DuplicateStatus {
data object New : DuplicateStatus
data class AlreadyOwned(val bookId: String, val title: String) : DuplicateStatus
}
/** Pure — no repository/DB access, so it's directly unit-testable. */
object DuplicateCheck {
fun check(existing: BookEntity?): DuplicateStatus =
if (existing == null) DuplicateStatus.New else DuplicateStatus.AlreadyOwned(existing.id, existing.title)
}
/** What the scan bottom sheet is currently showing. */
sealed interface ScanSheetState {
data object Hidden : ScanSheetState
data object Loading : ScanSheetState
data class Found(val isbn13: String, val metadata: BookMetadata, val duplicate: DuplicateStatus) : ScanSheetState
data class NotFound(val isbn13: String) : ScanSheetState
}
/** Combines a metadata lookup result with duplicate status into the sheet state to show. */
object ScanMetadataOutcome {
fun from(isbn13: String, metadata: BookMetadata?, duplicate: DuplicateStatus): ScanSheetState = when (metadata) {
null -> ScanSheetState.NotFound(isbn13)
else -> ScanSheetState.Found(isbn13, metadata, duplicate)
}
}
/** SPEC.md "Continuous mode: ... a running 'added this session' count." */
data class ScanSessionState(val savedCount: Int = 0) {
fun withSave(): ScanSessionState = copy(savedCount = savedCount + 1)
}
@@ -0,0 +1,420 @@
package org.modg.bookshelf.ui.scan
import android.Manifest
import androidx.camera.core.Camera
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
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.FlashOn
import androidx.compose.material.icons.outlined.Keyboard
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import org.modg.bookshelf.AppContainer
import org.modg.bookshelf.data.local.BookcaseEntity
import org.modg.bookshelf.data.local.ShelfEntity
import org.modg.bookshelf.data.metadata.BookMetadata
import org.modg.bookshelf.ui.components.BookCover
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
/**
* SPEC.md "scan" screen: camera + reticle, on-hit bottom sheet, continuous
* mode. Built entirely on wave 2's [ScannerController]/[IsbnBarcodeAnalyzer] —
* this file is the only thing wave 3 adds under ui.scan.
*/
@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class)
@Composable
fun ScanScreen(
onBack: () -> Unit,
container: AppContainer,
) {
val viewModel: ScanViewModel = viewModel(
factory = viewModelFactory {
initializer {
ScanViewModel(
bookRepository = container.bookRepository,
locationRepository = container.locationRepository,
metadataRepository = container.metadataRepository,
)
}
},
)
val sheetState by viewModel.sheetState.collectAsState()
val sessionState by viewModel.sessionState.collectAsState()
val torchEnabled by viewModel.scannerController.torchEnabled.collectAsState()
val bookcases by viewModel.bookcases.collectAsState()
val shelves by viewModel.shelves.collectAsState()
val selectedShelfId by viewModel.selectedShelfId.collectAsState()
val permissionState = rememberPermissionState(Manifest.permission.CAMERA)
LaunchedEffect(Unit) {
if (!permissionState.status.isGranted) permissionState.launchPermissionRequest()
}
LaunchedEffect(permissionState.status.isGranted) {
viewModel.scannerController.setPermissionDenied(!permissionState.status.isGranted)
}
var showManualEntry by remember { mutableStateOf(false) }
BookshelfScaffold(
title = "Scan",
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = "Back")
}
},
actions = {
IconButton(onClick = { viewModel.scannerController.toggleTorch() }) {
Icon(
imageVector = if (torchEnabled) Icons.Outlined.FlashOn else Icons.Outlined.FlashOff,
contentDescription = "Toggle flashlight",
)
}
IconButton(onClick = { showManualEntry = true }) {
Icon(Icons.Outlined.Keyboard, contentDescription = "Enter ISBN manually")
}
},
) { innerPadding ->
Box(modifier = Modifier.padding(innerPadding).fillMaxSize()) {
if (permissionState.status.isGranted) {
CameraPreview(controller = viewModel.scannerController, torchEnabled = torchEnabled)
ScanReticle(modifier = Modifier.align(Alignment.Center))
SessionBadge(count = sessionState.savedCount, modifier = Modifier.align(Alignment.TopCenter).padding(16.dp))
} else {
PermissionDeniedContent(onRequestAgain = { permissionState.launchPermissionRequest() })
}
}
}
when (val state = sheetState) {
is ScanSheetState.Hidden -> Unit
is ScanSheetState.Loading -> ModalBottomSheet(onDismissRequest = { }, sheetState = rememberModalBottomSheetState()) {
Box(modifier = Modifier.fillMaxWidth().padding(32.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
is ScanSheetState.Found -> ModalBottomSheet(
onDismissRequest = { viewModel.dismissSheet() },
sheetState = rememberModalBottomSheetState(),
) {
FoundBookSheet(
metadata = state.metadata,
duplicate = state.duplicate,
bookcases = bookcases,
shelves = shelves,
selectedShelfId = selectedShelfId,
onShelfSelected = viewModel::selectShelf,
onSave = { viewModel.save(state.isbn13, state.metadata) },
onSkip = { viewModel.skip() },
)
}
is ScanSheetState.NotFound -> ModalBottomSheet(
onDismissRequest = { viewModel.dismissSheet() },
sheetState = rememberModalBottomSheetState(),
) {
ManualEntrySheet(
isbn13 = state.isbn13,
bookcases = bookcases,
shelves = shelves,
selectedShelfId = selectedShelfId,
onShelfSelected = viewModel::selectShelf,
onSave = { title, authors -> viewModel.saveManualEntry(state.isbn13, title, authors) },
onSkip = { viewModel.skip() },
)
}
}
if (showManualEntry) {
ManualIsbnDialog(
onDismiss = { showManualEntry = false },
onSubmit = { isbn -> showManualEntry = false; viewModel.manualIsbnEntered(isbn) },
)
}
}
@Composable
private fun CameraPreview(controller: ScannerController, torchEnabled: Boolean) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val previewView = remember { PreviewView(context) }
val analyzer = remember { IsbnBarcodeAnalyzer(controller) }
var camera by remember { mutableStateOf<Camera?>(null) }
DisposableEffect(lifecycleOwner) {
val providerFuture = ProcessCameraProvider.getInstance(context)
providerFuture.addListener(
{
val cameraProvider = providerFuture.get()
val preview = Preview.Builder().build().also { it.setSurfaceProvider(previewView.surfaceProvider) }
val imageAnalysis = ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
.also { it.setAnalyzer(ContextCompat.getMainExecutor(context), analyzer) }
cameraProvider.unbindAll()
camera = cameraProvider.bindToLifecycle(
lifecycleOwner,
CameraSelector.DEFAULT_BACK_CAMERA,
preview,
imageAnalysis,
)
},
ContextCompat.getMainExecutor(context),
)
onDispose {
providerFuture.get().unbindAll()
analyzer.close()
}
}
LaunchedEffect(torchEnabled, camera) {
camera?.cameraControl?.enableTorch(torchEnabled)
}
AndroidView(factory = { previewView }, modifier = Modifier.fillMaxSize())
}
@Composable
private fun ScanReticle(modifier: Modifier = Modifier) {
Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier = Modifier
.size(width = 260.dp, height = 140.dp)
.border(width = 2.dp, color = MaterialTheme.colorScheme.secondary, shape = RoundedCornerShape(16.dp)),
)
Text(
text = "Point the camera at a book's barcode",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.secondary,
modifier = Modifier.padding(top = 12.dp),
)
}
}
@Composable
private fun SessionBadge(count: Int, modifier: Modifier = Modifier) {
if (count == 0) return
Text(
text = "Added this session: $count",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onPrimary,
modifier = modifier
.background(MaterialTheme.colorScheme.primary, RoundedCornerShape(20.dp))
.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
@Composable
private fun PermissionDeniedContent(onRequestAgain: () -> Unit) {
EmptyState(
title = "Camera access needed",
message = "Bookshelf needs the camera to scan barcodes. Grant permission to continue.",
action = { PrimaryButton(text = "Grant camera permission", onClick = onRequestAgain) },
)
}
@Composable
private fun FoundBookSheet(
metadata: BookMetadata,
duplicate: DuplicateStatus,
bookcases: List<BookcaseEntity>,
shelves: List<ShelfEntity>,
selectedShelfId: String?,
onShelfSelected: (String?) -> Unit,
onSave: () -> Unit,
onSkip: () -> Unit,
) {
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
Box(
modifier = Modifier
.align(Alignment.CenterHorizontally)
.size(width = 100.dp, height = 150.dp),
) {
BookCover(coverUrl = metadata.coverUrl, contentDescription = metadata.title, modifier = Modifier.fillMaxSize())
}
Text(
text = metadata.title ?: "Untitled",
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(top = 12.dp),
)
if (metadata.authors.isNotEmpty()) {
Text(text = metadata.authors.joinToString(", "), style = MaterialTheme.typography.bodyLarge)
}
if (duplicate is DuplicateStatus.AlreadyOwned) {
Text(
text = "You already own this book (\"${duplicate.title}\").",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 8.dp),
)
}
ShelfPicker(
bookcases = bookcases,
shelves = shelves,
selectedShelfId = selectedShelfId,
onShelfSelected = onShelfSelected,
)
Row(modifier = Modifier.fillMaxWidth().padding(top = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
SecondaryButton(text = "Skip", onClick = onSkip, modifier = Modifier.weight(1f))
PrimaryButton(text = "Save", onClick = onSave, modifier = Modifier.weight(1f))
}
}
}
@Composable
private fun ManualEntrySheet(
isbn13: String,
bookcases: List<BookcaseEntity>,
shelves: List<ShelfEntity>,
selectedShelfId: String?,
onShelfSelected: (String?) -> Unit,
onSave: (title: String, authors: List<String>) -> Unit,
onSkip: () -> Unit,
) {
var title by remember { mutableStateOf("") }
var authors by remember { mutableStateOf("") }
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
Text(text = "No match found", style = MaterialTheme.typography.titleLarge)
Text(
text = "ISBN $isbn13 — enter the details by hand.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 12.dp),
)
OutlinedTextField(
value = title,
onValueChange = { title = it },
label = { Text("Title") },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
)
OutlinedTextField(
value = authors,
onValueChange = { authors = it },
label = { Text("Authors (comma-separated)") },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
)
ShelfPicker(
bookcases = bookcases,
shelves = shelves,
selectedShelfId = selectedShelfId,
onShelfSelected = onShelfSelected,
)
Row(modifier = Modifier.fillMaxWidth().padding(top = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
SecondaryButton(text = "Skip", onClick = onSkip, modifier = Modifier.weight(1f))
PrimaryButton(
text = "Save",
enabled = title.isNotBlank(),
onClick = {
onSave(title, authors.split(",").map { it.trim() }.filter { it.isNotEmpty() })
},
modifier = Modifier.weight(1f),
)
}
}
}
@Composable
private fun ShelfPicker(
bookcases: List<BookcaseEntity>,
shelves: List<ShelfEntity>,
selectedShelfId: String?,
onShelfSelected: (String?) -> Unit,
) {
var expanded 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) {
"${currentBookcase.name}${currentShelf.label}"
} else {
"Choose a shelf"
}
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 },
)
}
}
}
}
}
@Composable
private fun ManualIsbnDialog(onDismiss: () -> Unit, onSubmit: (String) -> Unit) {
var text by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Enter ISBN") },
text = {
OutlinedTextField(
value = text,
onValueChange = { text = it },
label = { Text("ISBN-10 or ISBN-13") },
singleLine = true,
)
},
confirmButton = {
PrimaryButton(text = "Look up", onClick = { onSubmit(text) }, enabled = text.isNotBlank())
},
dismissButton = {
SecondaryButton(text = "Cancel", onClick = onDismiss)
},
)
}
@@ -0,0 +1,117 @@
package org.modg.bookshelf.ui.scan
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.modg.bookshelf.data.local.BookcaseEntity
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.repo.BookRepository
import org.modg.bookshelf.data.repo.LocationRepository
/**
* Owns the scan bottom-sheet + session-count state on top of the existing
* [ScannerController]/[IsbnBarcodeAnalyzer] plumbing (wave 2) — this class adds
* no CameraX/ML Kit code of its own. [ScanScreen] is the only thing wave 3
* adds under ui.scan.
*/
class ScanViewModel(
private val bookRepository: BookRepository,
locationRepository: LocationRepository,
private val metadataRepository: MetadataRepository,
val scannerController: ScannerController = ScannerController(),
) : ViewModel() {
private val _sheetState = MutableStateFlow<ScanSheetState>(ScanSheetState.Hidden)
val sheetState: StateFlow<ScanSheetState> = _sheetState.asStateFlow()
private val _sessionState = MutableStateFlow(ScanSessionState())
val sessionState: StateFlow<ScanSessionState> = _sessionState.asStateFlow()
val bookcases: StateFlow<List<BookcaseEntity>> = locationRepository.observeBookcases()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
val shelves: StateFlow<List<ShelfEntity>> = locationRepository.observeShelves()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
/** Sticks across saves in continuous mode — shelving a box of books usually means one shelf. */
private val _selectedShelfId = MutableStateFlow<String?>(null)
val selectedShelfId: StateFlow<String?> = _selectedShelfId.asStateFlow()
init {
viewModelScope.launch {
scannerController.scanResults.collect { isbn13 -> onScanned(isbn13) }
}
}
private suspend fun onScanned(isbn13: String) {
if (_sheetState.value !is ScanSheetState.Hidden) return // a sheet is already up for a previous hit
_sheetState.value = ScanSheetState.Loading
val duplicate = DuplicateCheck.check(bookRepository.findByIsbn13(isbn13))
val metadata = metadataRepository.lookup(isbn13)
_sheetState.value = ScanMetadataOutcome.from(isbn13, metadata, duplicate)
}
/** The manual-ISBN-entry escape hatch (SPEC: for when a barcode won't scan). */
fun manualIsbnEntered(raw: String) {
val isbn13 = IsbnUtils.toIsbn13(raw) ?: return
viewModelScope.launch { onScanned(isbn13) }
}
fun selectShelf(shelfId: String?) {
_selectedShelfId.value = shelfId
}
/** 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()
}
}
/** 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()
}
}
private fun recordSave() {
_sessionState.value = _sessionState.value.withSave()
dismissSheet() // continuous mode: stay on camera for the next book, per SPEC
}
fun skip() {
dismissSheet()
}
fun dismissSheet() {
_sheetState.value = ScanSheetState.Hidden
scannerController.resetDebounce()
}
}
@@ -0,0 +1,158 @@
package org.modg.bookshelf.ui.settings
import androidx.compose.foundation.layout.Arrangement
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.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
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.unit.dp
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.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.SyncStatusBar
import java.text.DateFormat
import java.util.Date
import java.util.concurrent.TimeUnit
/** SPEC "settings": server, account, sign out, manual sync + last-sync time, book/cover counts. */
@Composable
fun SettingsScreen(
onBack: () -> Unit,
onSignedOut: () -> Unit,
container: AppContainer,
) {
val viewModel: SettingsViewModel = viewModel(
factory = viewModelFactory {
initializer {
SettingsViewModel(
authRepository = container.authRepository,
settingsStore = container.settingsStore,
bookRepository = container.bookRepository,
syncEngine = container.syncEngine,
)
}
},
)
val state by viewModel.uiState.collectAsState()
var confirmSignOut by remember { mutableStateOf(false) }
BookshelfScaffold(
title = "Settings",
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Filled.ArrowBack, contentDescription = "Back")
}
},
syncStatusBar = {
SyncStatusBar(status = state.syncStatus, label = syncLabel(state))
},
) { innerPadding ->
PaperSurface(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(innerPadding).padding(16.dp)) {
SectionHeading("Server")
InfoRow(label = "URL", value = state.serverUrl ?: "Not configured")
GoldDivider(modifier = Modifier.padding(vertical = 16.dp))
SectionHeading("Account")
InfoRow(label = "Signed in as", value = state.userId ?: "Unknown")
SecondaryButton(
text = "Sign out",
onClick = { confirmSignOut = true },
modifier = Modifier.padding(top = 8.dp),
)
GoldDivider(modifier = Modifier.padding(vertical = 16.dp))
SectionHeading("Sync")
InfoRow(label = "Last synced", value = formatLastSync(state.lastSyncTime))
PrimaryButton(
text = if (state.isSyncing) "Syncing…" else "Sync now",
onClick = viewModel::syncNow,
enabled = !state.isSyncing,
modifier = Modifier.padding(top = 8.dp),
)
GoldDivider(modifier = Modifier.padding(vertical = 16.dp))
SectionHeading("Library")
InfoRow(label = "Books", value = state.bookCount.toString())
InfoRow(label = "Covers", value = state.coverCount.toString())
}
}
}
if (confirmSignOut) {
AlertDialog(
onDismissRequest = { confirmSignOut = false },
title = { Text(text = "Sign out?", style = MaterialTheme.typography.titleLarge) },
text = { Text(text = "You'll need your email and password to sign back in. Your books stay on this server.") },
confirmButton = {
TextButton(onClick = {
confirmSignOut = false
viewModel.signOut(onSignedOut)
}) { Text("Sign out") }
},
dismissButton = { TextButton(onClick = { confirmSignOut = false }) { Text("Cancel") } },
)
}
}
@Composable
private fun SectionHeading(text: String) {
Text(
text = text,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
}
@Composable
private fun InfoRow(label: String, value: String) {
Row(
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(text = label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(text = value, style = MaterialTheme.typography.bodyMedium)
}
}
private fun syncLabel(state: SettingsUiState): String = when {
state.isSyncing -> "Syncing…"
state.syncError != null -> "Sync failed: ${state.syncError}"
state.lastSyncTime != null -> "Synced • ${formatLastSync(state.lastSyncTime)}"
else -> "Not synced yet"
}
private fun formatLastSync(epochMillis: Long?): String {
if (epochMillis == null) return "Never"
val elapsed = System.currentTimeMillis() - epochMillis
return when {
elapsed < TimeUnit.MINUTES.toMillis(1) -> "just now"
elapsed < TimeUnit.HOURS.toMillis(1) -> "${TimeUnit.MILLISECONDS.toMinutes(elapsed)} min ago"
elapsed < TimeUnit.DAYS.toMillis(1) -> "${TimeUnit.MILLISECONDS.toHours(elapsed)} hr ago"
else -> DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(Date(epochMillis))
}
}
@@ -0,0 +1,105 @@
package org.modg.bookshelf.ui.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.modg.bookshelf.data.prefs.SettingsStore
import org.modg.bookshelf.data.repo.AuthRepository
import org.modg.bookshelf.data.repo.BookRepository
import org.modg.bookshelf.data.repo.SyncEngine
import org.modg.bookshelf.data.repo.SyncResult
import org.modg.bookshelf.ui.components.SyncStatus
data class SettingsUiState(
val serverUrl: String? = null,
val userId: String? = null,
val bookCount: Int = 0,
val coverCount: Int = 0,
val lastSyncTime: Long? = null,
val isSyncing: Boolean = false,
val syncError: String? = null,
) {
val syncStatus: SyncStatus
get() = when {
isSyncing -> SyncStatus.Syncing
syncError != null -> SyncStatus.Error
lastSyncTime != null -> SyncStatus.Synced
else -> SyncStatus.Offline
}
}
private data class BaseInfo(
val serverUrl: String?,
val userId: String?,
val bookCount: Int,
val coverCount: Int,
val lastSyncTime: Long?,
)
/** Manual DI per SPEC: constructed by [SettingsScreen] from AppContainer, no Hilt. */
class SettingsViewModel(
private val authRepository: AuthRepository,
private val settingsStore: SettingsStore,
private val bookRepository: BookRepository,
private val syncEngine: SyncEngine,
) : ViewModel() {
private val isSyncing = MutableStateFlow(false)
private val syncError = MutableStateFlow<String?>(null)
private val baseInfo = combine(
authRepository.serverUrl,
settingsStore.userId,
bookRepository.observeAll(),
settingsStore.lastSyncTime,
) { url, userId, books, lastSync ->
BaseInfo(
serverUrl = url,
userId = userId,
bookCount = books.size,
coverCount = books.count { !it.coverUrl.isNullOrBlank() || !it.localCoverPath.isNullOrBlank() },
lastSyncTime = lastSync,
)
}
val uiState: StateFlow<SettingsUiState> = combine(baseInfo, isSyncing, syncError) { base, syncing, error ->
SettingsUiState(
serverUrl = base.serverUrl,
userId = base.userId,
bookCount = base.bookCount,
coverCount = base.coverCount,
lastSyncTime = base.lastSyncTime,
isSyncing = syncing,
syncError = error,
)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsUiState())
fun syncNow() {
if (isSyncing.value) return
viewModelScope.launch {
isSyncing.value = true
syncError.value = null
// SyncEngine never throws (SPEC: sync failure is a quiet status
// line, never a crash or a blocking dialog) — surface its result
// as that quiet line, nothing more.
when (val result = syncEngine.sync()) {
is SyncResult.Success -> Unit
is SyncResult.Skipped -> syncError.value = "No server configured yet."
is SyncResult.Failure -> syncError.value = result.error.message ?: "Sync failed."
}
isSyncing.value = false
}
}
fun signOut(onSignedOut: () -> Unit) {
viewModelScope.launch {
authRepository.signOut()
onSignedOut()
}
}
}
@@ -0,0 +1,118 @@
package org.modg.bookshelf.ui.setup
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.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
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 androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import org.modg.bookshelf.AppContainer
import org.modg.bookshelf.ui.components.GoldDivider
import org.modg.bookshelf.ui.components.PaperSurface
import org.modg.bookshelf.ui.components.PrimaryButton
/**
* First-run screen (SPEC "setup"): server URL, email, password. No
* self-registration — the server's `users` collection is superuser-create-only.
*/
@Composable
fun SetupScreen(onSetupComplete: () -> Unit, container: AppContainer) {
val viewModel: SetupViewModel = viewModel(
factory = viewModelFactory {
initializer { SetupViewModel(container.authRepository) }
},
)
val state by viewModel.uiState.collectAsState()
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 = state.serverUrl,
onValueChange = viewModel::onServerUrlChanged,
label = { Text("Server URL") },
placeholder = { Text("https://library.example.com") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
isError = state.urlError != null,
modifier = Modifier.fillMaxWidth(),
)
if (state.urlError != null) {
Text(
text = state.urlError!!,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 4.dp),
)
}
GoldDivider(modifier = Modifier.padding(vertical = 16.dp))
OutlinedTextField(
value = state.email,
onValueChange = viewModel::onEmailChanged,
label = { Text("Email") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
isError = state.credentialsError != null,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
)
OutlinedTextField(
value = state.password,
onValueChange = viewModel::onPasswordChanged,
label = { Text("Password") },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
isError = state.credentialsError != null,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
)
if (state.credentialsError != null) {
Text(
text = state.credentialsError!!,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 4.dp),
)
}
PrimaryButton(
text = if (state.isSubmitting) "Signing in…" else "Sign in",
onClick = { viewModel.submit(onSetupComplete) },
enabled = !state.isSubmitting &&
state.serverUrl.isNotBlank() &&
state.email.isNotBlank() &&
state.password.isNotBlank(),
modifier = Modifier.padding(top = 24.dp),
)
}
}
}
@@ -0,0 +1,114 @@
package org.modg.bookshelf.ui.setup
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import org.modg.bookshelf.data.repo.AuthRepository
import retrofit2.HttpException
import java.io.IOException
import java.util.concurrent.TimeUnit
data class SetupUiState(
val serverUrl: String = "",
val email: String = "",
val password: String = "",
val isSubmitting: Boolean = false,
// Set only for problems with the URL/server reachability, so the UI can
// tell that apart from a bad-credentials error per SPEC.
val urlError: String? = null,
val credentialsError: String? = null,
)
/**
* Manual DI per SPEC (no Hilt): constructed directly by [SetupScreen] from
* [org.modg.bookshelf.AppContainer], not via a Hilt-generated factory.
*
* The reachability probe is a plain short-timeout GET against `/api/health`
* (PocketBase's built-in health endpoint) using a throwaway OkHttpClient —
* deliberately not routed through [org.modg.bookshelf.data.remote.PocketBaseApi],
* since a probe has to succeed or fail *before* we know the URL is even a
* valid PocketBase server worth building a Retrofit client against.
*/
class SetupViewModel(private val authRepository: AuthRepository) : ViewModel() {
private val _uiState = MutableStateFlow(SetupUiState())
val uiState: StateFlow<SetupUiState> = _uiState
private val probeClient = OkHttpClient.Builder()
.connectTimeout(6, TimeUnit.SECONDS)
.readTimeout(6, TimeUnit.SECONDS)
.build()
fun onServerUrlChanged(value: String) {
_uiState.update { it.copy(serverUrl = value, urlError = null) }
}
fun onEmailChanged(value: String) {
_uiState.update { it.copy(email = value, credentialsError = null) }
}
fun onPasswordChanged(value: String) {
_uiState.update { it.copy(password = value, credentialsError = null) }
}
fun submit(onSuccess: () -> Unit) {
val state = _uiState.value
if (state.isSubmitting) return
_uiState.update { it.copy(isSubmitting = true, urlError = null, credentialsError = null) }
viewModelScope.launch {
val urlResult = authRepository.setServerUrl(state.serverUrl)
if (urlResult.isFailure) {
_uiState.update {
it.copy(
isSubmitting = false,
urlError = urlResult.exceptionOrNull()?.message ?: "Enter a valid server URL.",
)
}
return@launch
}
val trimmedUrl = state.serverUrl.trim().trimEnd('/')
if (!probeReachable(trimmedUrl)) {
_uiState.update {
it.copy(
isSubmitting = false,
urlError = "Couldn't reach a server at that address. Check the URL and your network connection.",
)
}
return@launch
}
val loginResult = authRepository.login(state.email, state.password)
if (loginResult.isSuccess) {
_uiState.update { it.copy(isSubmitting = false) }
onSuccess()
} else {
val error = loginResult.exceptionOrNull()
val message = when {
error is HttpException && (error.code() == 400 || error.code() == 401 || error.code() == 403) ->
"Incorrect email or password."
error is IOException ->
"Couldn't reach the server to sign in. Check your network connection."
else -> "Sign-in failed: ${error?.message ?: "unknown error"}"
}
_uiState.update { it.copy(isSubmitting = false, credentialsError = message) }
}
}
}
private suspend fun probeReachable(baseUrl: String): Boolean = withContext(Dispatchers.IO) {
try {
val request = Request.Builder().url("$baseUrl/api/health").get().build()
probeClient.newCall(request).execute().use { it.isSuccessful }
} catch (e: IOException) {
false
}
}
}
@@ -116,6 +116,19 @@ class BookDaoTest {
assertEquals(setOf("b", "c"), pending)
}
@Test
fun `observeById reflects updates live and goes null on soft delete`() = runTest {
dao.upsert(book("a", "Alpha"))
assertEquals("Alpha", dao.observeById("a").first()?.title)
dao.upsert(book("a", "Alpha Revised"))
assertEquals("Alpha Revised", dao.observeById("a").first()?.title)
dao.upsert(book("a", "Alpha Revised", deleted = true))
assertNull(dao.observeById("a").first())
}
@Test
fun `hardDelete actually removes the row, unlike soft delete`() = runTest {
dao.upsert(book("a", "Alpha"))
@@ -0,0 +1,77 @@
package org.modg.bookshelf.data.repo
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.Assert.assertNull
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.local.SyncState
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/** Covers the [LocationRepository.moveBooks] bulk-reassignment helper added for the locations screen. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class LocationRepositoryTest {
private lateinit var db: BookshelfDatabase
private lateinit var repository: LocationRepository
@Before
fun setUp() {
db = Room.inMemoryDatabaseBuilder(ApplicationProvider.getApplicationContext(), BookshelfDatabase::class.java)
.allowMainThreadQueries()
.build()
repository = LocationRepository(db.bookcaseDao(), db.shelfDao(), db.bookDao())
}
@After
fun tearDown() = db.close()
private fun book(id: String, shelfId: String?, syncState: SyncState = SyncState.SYNCED) = BookEntity(
id = id, title = "Book $id", shelfId = shelfId, createdAt = 0L, updatedAt = 0L, syncState = syncState,
)
@Test
fun `moveBooks reassigns every book on the source shelf and marks them pending`() = runTest {
db.bookDao().upsertAll(
listOf(
book("b1", shelfId = "shelf-a"),
book("b2", shelfId = "shelf-a"),
book("b3", shelfId = "shelf-b"),
),
)
repository.moveBooks(fromShelfId = "shelf-a", toShelfId = "shelf-b")
val moved = db.bookDao().observeByShelf("shelf-b").first()
assertEquals(setOf("b1", "b2", "b3"), moved.map { it.id }.toSet())
assertEquals(SyncState.PENDING_UPDATE, db.bookDao().getById("b1")!!.syncState)
assertEquals(0, db.bookDao().observeByShelf("shelf-a").first().size)
}
@Test
fun `moveBooks to null unassigns the books`() = runTest {
db.bookDao().upsertAll(listOf(book("b1", shelfId = "shelf-a")))
repository.moveBooks(fromShelfId = "shelf-a", toShelfId = null)
assertNull(db.bookDao().getById("b1")!!.shelfId)
}
@Test
fun `moveBooks leaves a still-uncreated book pending create, not pending update`() = runTest {
db.bookDao().upsertAll(listOf(book("b1", shelfId = "shelf-a", syncState = SyncState.PENDING_CREATE)))
repository.moveBooks(fromShelfId = "shelf-a", toShelfId = "shelf-b")
assertEquals(SyncState.PENDING_CREATE, db.bookDao().getById("b1")!!.syncState)
}
}
@@ -0,0 +1,52 @@
package org.modg.bookshelf.ui.library
import org.junit.Assert.assertEquals
import org.junit.Test
import org.modg.bookshelf.data.local.BookEntity
class LibraryFilterLogicTest {
private fun book(id: String, shelfId: String? = null) = BookEntity(
id = id, title = id, shelfId = shelfId, createdAt = 0L, updatedAt = 0L,
)
private val books = listOf(
book("a", shelfId = "shelf1"), // bookcase1
book("b", shelfId = "shelf2"), // bookcase1
book("c", shelfId = "shelf3"), // bookcase2
book("d", shelfId = null), // unshelved
)
private val shelfIdsByBookcase = mapOf(
"bookcase1" to setOf("shelf1", "shelf2"),
"bookcase2" to setOf("shelf3"),
)
@Test
fun `All returns every book unchanged`() {
assertEquals(books.map { it.id }, LibraryFilterLogic.apply(books, LibraryFilter.All, shelfIdsByBookcase).map { it.id })
}
@Test
fun `Shelf filter matches only that shelf`() {
val result = LibraryFilterLogic.apply(books, LibraryFilter.Shelf("shelf2"), shelfIdsByBookcase)
assertEquals(listOf("b"), result.map { it.id })
}
@Test
fun `Bookcase filter matches every shelf under that bookcase`() {
val result = LibraryFilterLogic.apply(books, LibraryFilter.Bookcase("bookcase1"), shelfIdsByBookcase)
assertEquals(setOf("a", "b"), result.map { it.id }.toSet())
}
@Test
fun `Bookcase filter excludes unshelved books`() {
val result = LibraryFilterLogic.apply(books, LibraryFilter.Bookcase("bookcase2"), shelfIdsByBookcase)
assertEquals(listOf("c"), result.map { it.id })
}
@Test
fun `unknown bookcase id matches nothing rather than throwing`() {
val result = LibraryFilterLogic.apply(books, LibraryFilter.Bookcase("does-not-exist"), shelfIdsByBookcase)
assertEquals(emptyList<String>(), result.map { it.id })
}
}
@@ -0,0 +1,48 @@
package org.modg.bookshelf.ui.library
import org.junit.Assert.assertEquals
import org.junit.Test
import org.modg.bookshelf.data.local.BookEntity
import org.modg.bookshelf.data.repo.encodeAuthors
class LibrarySortTest {
private fun book(id: String, title: String, authors: List<String> = emptyList(), createdAt: Long = 0L) = BookEntity(
id = id,
title = title,
authorsJson = encodeAuthors(authors),
createdAt = createdAt,
updatedAt = createdAt,
)
@Test
fun `sorts by title case-insensitively`() {
val books = listOf(book("a", "banana"), book("b", "Apple"), book("c", "cherry"))
val sorted = LibrarySort.sort(books, LibrarySortOption.TITLE)
assertEquals(listOf("Apple", "banana", "cherry"), sorted.map { it.title })
}
@Test
fun `sorts by first author, books with no author sort first`() {
val books = listOf(
book("a", "No author book"),
book("b", "Second", authors = listOf("Zed Author")),
book("c", "First", authors = listOf("Anne Author")),
)
val sorted = LibrarySort.sort(books, LibrarySortOption.AUTHOR)
assertEquals(listOf("a", "c", "b"), sorted.map { it.id })
}
@Test
fun `sorts by added, most recent first`() {
val books = listOf(book("a", "Old", createdAt = 100L), book("b", "New", createdAt = 300L), book("c", "Mid", createdAt = 200L))
val sorted = LibrarySort.sort(books, LibrarySortOption.ADDED)
assertEquals(listOf("b", "c", "a"), sorted.map { it.id })
}
}
@@ -0,0 +1,54 @@
package org.modg.bookshelf.ui.library
import org.junit.Assert.assertEquals
import org.junit.Test
import org.modg.bookshelf.ui.components.SyncStatus
class LibrarySyncPresenterTest {
@Test
fun `syncing takes priority over any outcome`() {
val state = LibrarySyncPresenter.present(nowMillis = 1_000L, lastSyncMillis = 0L, isSyncing = true, lastOutcome = SyncOutcome.FAILURE)
assertEquals(SyncStatus.Syncing, state.status)
assertEquals("Syncing…", state.label)
}
@Test
fun `failure shows a quiet error status, never a dialog-shaped state`() {
val state = LibrarySyncPresenter.present(nowMillis = 1_000L, lastSyncMillis = null, isSyncing = false, lastOutcome = SyncOutcome.FAILURE)
assertEquals(SyncStatus.Error, state.status)
assertEquals("Sync failed — showing local library", state.label)
}
@Test
fun `no server configured is offline, not an error`() {
val state = LibrarySyncPresenter.present(nowMillis = 1_000L, lastSyncMillis = null, isSyncing = false, lastOutcome = SyncOutcome.NO_SERVER)
assertEquals(SyncStatus.Offline, state.status)
assertEquals("No server configured", state.label)
}
@Test
fun `never synced before shows offline with a distinct label`() {
val state = LibrarySyncPresenter.present(nowMillis = 1_000L, lastSyncMillis = null, isSyncing = false, lastOutcome = SyncOutcome.NONE)
assertEquals(SyncStatus.Offline, state.status)
assertEquals("Not synced yet", state.label)
}
@Test
fun `recent success reads as just now`() {
val now = 1_000_000L
val state = LibrarySyncPresenter.present(nowMillis = now, lastSyncMillis = now - 5_000L, isSyncing = false, lastOutcome = SyncOutcome.SUCCESS)
assertEquals(SyncStatus.Synced, state.status)
assertEquals("Synced just now", state.label)
}
@Test
fun `older success shows minutes and hours elapsed`() {
val now = 10_000_000L
val minutesAgo = LibrarySyncPresenter.present(now, now - 5 * 60_000L, false, SyncOutcome.SUCCESS)
assertEquals("Synced 5m ago", minutesAgo.label)
val hoursAgo = LibrarySyncPresenter.present(now, now - 3 * 3_600_000L, false, SyncOutcome.SUCCESS)
assertEquals("Synced 3h ago", hoursAgo.label)
}
}
@@ -0,0 +1,53 @@
package org.modg.bookshelf.ui.scan
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.modg.bookshelf.data.local.BookEntity
import org.modg.bookshelf.data.metadata.BookMetadata
class ScanModelsTest {
@Test
fun `duplicate check finds nothing for a new isbn`() {
assertEquals(DuplicateStatus.New, DuplicateCheck.check(null))
}
@Test
fun `duplicate check reports the already-owned book`() {
val existing = BookEntity(id = "abc123", title = "Dune", createdAt = 0L, updatedAt = 0L)
val status = DuplicateCheck.check(existing)
assertTrue(status is DuplicateStatus.AlreadyOwned)
assertEquals("abc123", (status as DuplicateStatus.AlreadyOwned).bookId)
assertEquals("Dune", status.title)
}
@Test
fun `metadata miss maps to NotFound regardless of duplicate status`() {
val outcome = ScanMetadataOutcome.from("9780201558029", null, DuplicateStatus.New)
assertEquals(ScanSheetState.NotFound("9780201558029"), outcome)
}
@Test
fun `metadata hit maps to Found carrying the duplicate status through`() {
val metadata = BookMetadata(title = "Dune", isbn13 = "9780201558029")
val duplicate = DuplicateStatus.AlreadyOwned("abc123", "Dune")
val outcome = ScanMetadataOutcome.from("9780201558029", metadata, duplicate)
assertEquals(ScanSheetState.Found("9780201558029", metadata, duplicate), outcome)
}
@Test
fun `session count starts at zero and increments per save, not per skip`() {
var session = ScanSessionState()
assertEquals(0, session.savedCount)
session = session.withSave()
session = session.withSave()
assertEquals(2, session.savedCount)
}
}
+1
View File
@@ -0,0 +1 @@
fdc7809c-518c-4711-b7a9-781a33e330d5
+14
View File
@@ -0,0 +1,14 @@
=== E1-shell attempts=1 quota_waits=0 hard_fails=0 ===
cost=$2.8037944 turns=6 err=?
--- result (tail) ---
ull nav graph; `library?shelfId={shelfId}` doubles as bare `library` via a nullable arg
- `ui/setup/SetupViewModel.kt`, `ui/setup/SetupScreen.kt` — new: URL/email/password form; reachability probe is a short-timeout GET to `/api/health` done *before* attempting login, so URL/network errors and 400/401/403 credential errors get distinct messages
- `ui/locations/LocationsViewModel.kt`, `ui/locations/LocationsScreen.kt` — new: bookcase→shelf tree, add/edit/delete dialogs, up/down reorder, per-shelf book counts, tap-shelf navigation, "Move books" bulk action
- `ui/settings/SettingsViewModel.kt`, `ui/settings/SettingsScreen.kt` — new: server URL, account (user id — see gap below), sign out, manual sync + last-sync time, book/cover counts
- `data/repo/LocationRepository.kt` — extended with `moveBooks(fromShelfId, toShelfId?)`, built only from BookDao's existing observe/upsert surface (no BookDao edit)
- `app/src/test/.../LocationRepositoryTest.kt` — new: 3 tests covering `moveBooks` (reassign, unassign, PENDING_CREATE preserved)
- `MainActivity.kt` — rewritten to host `BookshelfNavHost`; picks `setup` vs `library` start route from `authRepository.serverUrl`/`isLoggedIn`
**Boundaries respected:** did not touch `ui.library`/`ui.detail`/`ui.scan`, `ui.theme`/`ui.components`, `BookDao`/`BookRepository`, or any build files. Waited out E2's in-progress work (including one quota-wait cycle) rather than working around missing `LibraryScreen`/`DetailScreen`/`ScanScreen`.
**Gap vs SPEC:** the Settings "account" section shows the PocketBase user id, not the email — `AuthRepository`/`SettingsStore` (wave-2, out of my edit scope) never persists the login email, only the token and user id. Not blocking, but worth a follow-up if a human-readable account label is wanted.
+1
View File
@@ -0,0 +1 @@
c4155cba-b34d-4321-b0a7-e7c225b23e32
+19
View File
@@ -0,0 +1,19 @@
=== E2-books attempts=2 quota_waits=1 hard_fails=0 ===
cost=$0.25929520000000006 turns=5 err=?
--- result (tail) ---
s/gw assembleDebug` → **exit 0** (UP-TO-DATE, `app-debug.apk` already built)
- `~/bookshelf/tasks/gw testDebugUnitTest` → **exit 0**, **91 tests, 0 failures, 0 errors, 0 skipped**
**My scope (ui.library, ui.detail, ui.scan) — all present and unchanged:**
- `ui/library/LibraryModels.kt`, `LibrarySyncPresenter.kt`, `LibraryViewModel.kt`, `LibraryScreen.kt` — adaptive cover grid, search, filter by bookcase/shelf, sort, empty state, FAB→scan, pull-to-refresh sync status line
- `ui/detail/DetailViewModel.kt`, `DetailScreen.kt` — cover/metadata, collapsible description, editable notes, location picker, edit mode, soft-delete with undo snackbar
- `ui/scan/ScanModels.kt`, `ScanViewModel.kt`, `ScanScreen.kt` — CameraX preview + reticle on the existing `ScannerController`/`IsbnBarcodeAnalyzer`, found/not-found/duplicate-warning bottom sheet, continuous mode with session counter, torch toggle, permission handling, manual-ISBN dialog
- Tests: `LibrarySortTest`, `LibraryFilterLogicTest`, `LibrarySyncPresenterTest`, `ScanModelsTest`, plus an added `observeById` case in `BookDaoTest`
**Minimal justified extensions (still in place):** `AppContainer.metadataRepository` wired with a bare `OkHttpClient` (no PocketBase auth token leaked to third-party APIs); `BookDao`/`BookRepository.observeById` for live detail-screen updates.
**Not touched:** ui.nav, ui.setup, ui.locations, ui.settings, MainActivity, ui.theme, ui.components, build.gradle.kts, libs.versions.toml, LocationRepository/ShelfDao/BookcaseDao.
**Unverifiable in this environment:** actual camera/torch/reticle behavior on-device — no emulator/KVM available, so verification is compile + unit tests only, per the project's stated quality bar.
Nothing further to do — the wave-3 E2 deliverable is done and green.