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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
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 = {})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
BookshelfNavHost(container = container, startDestination = destination)
|
||||
}
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
fdc7809c-518c-4711-b7a9-781a33e330d5
|
||||
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
c4155cba-b34d-4321-b0a7-e7c225b23e32
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user