diff --git a/app/app/src/main/java/org/modg/bookshelf/AppContainer.kt b/app/app/src/main/java/org/modg/bookshelf/AppContainer.kt index 0933b18..2df9d24 100644 --- a/app/app/src/main/java/org/modg/bookshelf/AppContainer.kt +++ b/app/app/src/main/java/org/modg/bookshelf/AppContainer.kt @@ -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, diff --git a/app/app/src/main/java/org/modg/bookshelf/MainActivity.kt b/app/app/src/main/java/org/modg/bookshelf/MainActivity.kt index 3f3c0dd..1f03c6e 100644 --- a/app/app/src/main/java/org/modg/bookshelf/MainActivity.kt +++ b/app/app/src/main/java/org/modg/bookshelf/MainActivity.kt @@ -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() - } - } - } -} - -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") - } - }, - 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 = {}) }, - ) - } 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 = {}) - } - } + BookshelfApp(container) } } } } @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), - ) +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(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 + } + + 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 { + BookshelfNavHost(container = container, startDestination = destination) + } } diff --git a/app/app/src/main/java/org/modg/bookshelf/data/local/BookDao.kt b/app/app/src/main/java/org/modg/bookshelf/data/local/BookDao.kt index 464ceee..f1228de 100644 --- a/app/app/src/main/java/org/modg/bookshelf/data/local/BookDao.kt +++ b/app/app/src/main/java/org/modg/bookshelf/data/local/BookDao.kt @@ -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 + /** 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? diff --git a/app/app/src/main/java/org/modg/bookshelf/data/repo/BookRepository.kt b/app/app/src/main/java/org/modg/bookshelf/data/repo/BookRepository.kt index 3da7b84..6843731 100644 --- a/app/app/src/main/java/org/modg/bookshelf/data/repo/BookRepository.kt +++ b/app/app/src/main/java/org/modg/bookshelf/data/repo/BookRepository.kt @@ -39,6 +39,7 @@ class BookRepository( fun search(query: String): Flow> = bookDao.search(query) suspend fun getById(id: String): BookEntity? = bookDao.getById(id) + fun observeById(id: String): Flow = bookDao.observeById(id) suspend fun findByIsbn13(isbn13: String): BookEntity? = bookDao.findByIsbn13(isbn13) suspend fun countByShelf(shelfId: String): Int = bookDao.countByShelf(shelfId) diff --git a/app/app/src/main/java/org/modg/bookshelf/data/repo/LocationRepository.kt b/app/app/src/main/java/org/modg/bookshelf/data/repo/LocationRepository.kt index c8a2cc9..3f245c4 100644 --- a/app/app/src/main/java/org/modg/bookshelf/data/repo/LocationRepository.kt +++ b/app/app/src/main/java/org/modg/bookshelf/data/repo/LocationRepository.kt @@ -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)) + } + } } diff --git a/app/app/src/main/java/org/modg/bookshelf/ui/detail/DetailScreen.kt b/app/app/src/main/java/org/modg/bookshelf/ui/detail/DetailScreen.kt new file mode 100644 index 0000000..449287d --- /dev/null +++ b/app/app/src/main/java/org/modg/bookshelf/ui/detail/DetailScreen.kt @@ -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(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, + shelves: List, + 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), + ) +} diff --git a/app/app/src/main/java/org/modg/bookshelf/ui/detail/DetailViewModel.kt b/app/app/src/main/java/org/modg/bookshelf/ui/detail/DetailViewModel.kt new file mode 100644 index 0000000..850a012 --- /dev/null +++ b/app/app/src/main/java/org/modg/bookshelf/ui/detail/DetailViewModel.kt @@ -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 = bookRepository.observeById(bookId) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + + val bookcases: StateFlow> = locationRepository.observeBookcases() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + val shelves: StateFlow> = 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) } + } +} diff --git a/app/app/src/main/java/org/modg/bookshelf/ui/library/LibraryModels.kt b/app/app/src/main/java/org/modg/bookshelf/ui/library/LibraryModels.kt new file mode 100644 index 0000000..fc379ae --- /dev/null +++ b/app/app/src/main/java/org/modg/bookshelf/ui/library/LibraryModels.kt @@ -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, option: LibrarySortOption): List = 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, + filter: LibraryFilter, + shelfIdsByBookcase: Map>, + ): List = 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 } + } + } +} diff --git a/app/app/src/main/java/org/modg/bookshelf/ui/library/LibraryScreen.kt b/app/app/src/main/java/org/modg/bookshelf/ui/library/LibraryScreen.kt new file mode 100644 index 0000000..25148a5 --- /dev/null +++ b/app/app/src/main/java/org/modg/bookshelf/ui/library/LibraryScreen.kt @@ -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, + shelves: List, +) { + 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, + ) + } + } +} diff --git a/app/app/src/main/java/org/modg/bookshelf/ui/library/LibrarySyncPresenter.kt b/app/app/src/main/java/org/modg/bookshelf/ui/library/LibrarySyncPresenter.kt new file mode 100644 index 0000000..532f06d --- /dev/null +++ b/app/app/src/main/java/org/modg/bookshelf/ui/library/LibrarySyncPresenter.kt @@ -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" + } + } +} diff --git a/app/app/src/main/java/org/modg/bookshelf/ui/library/LibraryViewModel.kt b/app/app/src/main/java/org/modg/bookshelf/ui/library/LibraryViewModel.kt new file mode 100644 index 0000000..bc43bd6 --- /dev/null +++ b/app/app/src/main/java/org/modg/bookshelf/ui/library/LibraryViewModel.kt @@ -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 = emptyList(), + val hasAnyBooks: Boolean = true, // true until proven otherwise, so the empty state never flashes on first frame + val bookcases: List = emptyList(), + val shelves: List = 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 = _query.asStateFlow() + + private val _sortOption = MutableStateFlow(LibrarySortOption.TITLE) + val sortOption: StateFlow = _sortOption.asStateFlow() + + private val _filter = MutableStateFlow( + initialShelfId?.let { LibraryFilter.Shelf(it) } ?: LibraryFilter.All, + ) + val filter: StateFlow = _filter.asStateFlow() + + private val _isSyncing = MutableStateFlow(false) + private val _lastSyncOutcome = MutableStateFlow(SyncOutcome.NONE) + + val bookcases: StateFlow> = locationRepository.observeBookcases() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + val shelves: StateFlow> = 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>> = 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 = bookRepository.observeAll() + .map { it.isNotEmpty() } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), true) + + private val filteredSortedBooks: StateFlow> = 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 = 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 = 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 + } + } +} diff --git a/app/app/src/main/java/org/modg/bookshelf/ui/locations/LocationsScreen.kt b/app/app/src/main/java/org/modg/bookshelf/ui/locations/LocationsScreen.kt new file mode 100644 index 0000000..8a6acd3 --- /dev/null +++ b/app/app/src/main/java/org/modg/bookshelf/ui/locations/LocationsScreen.kt @@ -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, + 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") } }, + ) +} diff --git a/app/app/src/main/java/org/modg/bookshelf/ui/locations/LocationsViewModel.kt b/app/app/src/main/java/org/modg/bookshelf/ui/locations/LocationsViewModel.kt new file mode 100644 index 0000000..b42340b --- /dev/null +++ b/app/app/src/main/java/org/modg/bookshelf/ui/locations/LocationsViewModel.kt @@ -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) + +/** 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 = 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>(emptyMap()) + private val dialogState = MutableStateFlow(null) + private val errorState = MutableStateFlow(null) + + val uiState: StateFlow = 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) { + 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)) + } + } +} diff --git a/app/app/src/main/java/org/modg/bookshelf/ui/nav/BookshelfNavHost.kt b/app/app/src/main/java/org/modg/bookshelf/ui/nav/BookshelfNavHost.kt new file mode 100644 index 0000000..4bd5985 --- /dev/null +++ b/app/app/src/main/java/org/modg/bookshelf/ui/nav/BookshelfNavHost.kt @@ -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, + ) + } + } +} diff --git a/app/app/src/main/java/org/modg/bookshelf/ui/nav/Routes.kt b/app/app/src/main/java/org/modg/bookshelf/ui/nav/Routes.kt new file mode 100644 index 0000000..5bb9f8f --- /dev/null +++ b/app/app/src/main/java/org/modg/bookshelf/ui/nav/Routes.kt @@ -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" +} diff --git a/app/app/src/main/java/org/modg/bookshelf/ui/scan/ScanModels.kt b/app/app/src/main/java/org/modg/bookshelf/ui/scan/ScanModels.kt new file mode 100644 index 0000000..229ba3d --- /dev/null +++ b/app/app/src/main/java/org/modg/bookshelf/ui/scan/ScanModels.kt @@ -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) +} diff --git a/app/app/src/main/java/org/modg/bookshelf/ui/scan/ScanScreen.kt b/app/app/src/main/java/org/modg/bookshelf/ui/scan/ScanScreen.kt new file mode 100644 index 0000000..891aad9 --- /dev/null +++ b/app/app/src/main/java/org/modg/bookshelf/ui/scan/ScanScreen.kt @@ -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(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, + shelves: List, + 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, + shelves: List, + selectedShelfId: String?, + onShelfSelected: (String?) -> Unit, + onSave: (title: String, authors: List) -> 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, + shelves: List, + 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) + }, + ) +} diff --git a/app/app/src/main/java/org/modg/bookshelf/ui/scan/ScanViewModel.kt b/app/app/src/main/java/org/modg/bookshelf/ui/scan/ScanViewModel.kt new file mode 100644 index 0000000..a5d0ee8 --- /dev/null +++ b/app/app/src/main/java/org/modg/bookshelf/ui/scan/ScanViewModel.kt @@ -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.Hidden) + val sheetState: StateFlow = _sheetState.asStateFlow() + + private val _sessionState = MutableStateFlow(ScanSessionState()) + val sessionState: StateFlow = _sessionState.asStateFlow() + + val bookcases: StateFlow> = locationRepository.observeBookcases() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + val shelves: StateFlow> = 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(null) + val selectedShelfId: StateFlow = _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) { + 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() + } +} diff --git a/app/app/src/main/java/org/modg/bookshelf/ui/settings/SettingsScreen.kt b/app/app/src/main/java/org/modg/bookshelf/ui/settings/SettingsScreen.kt new file mode 100644 index 0000000..22b2b86 --- /dev/null +++ b/app/app/src/main/java/org/modg/bookshelf/ui/settings/SettingsScreen.kt @@ -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)) + } +} diff --git a/app/app/src/main/java/org/modg/bookshelf/ui/settings/SettingsViewModel.kt b/app/app/src/main/java/org/modg/bookshelf/ui/settings/SettingsViewModel.kt new file mode 100644 index 0000000..c8144ec --- /dev/null +++ b/app/app/src/main/java/org/modg/bookshelf/ui/settings/SettingsViewModel.kt @@ -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(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 = 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() + } + } +} diff --git a/app/app/src/main/java/org/modg/bookshelf/ui/setup/SetupScreen.kt b/app/app/src/main/java/org/modg/bookshelf/ui/setup/SetupScreen.kt new file mode 100644 index 0000000..acb7270 --- /dev/null +++ b/app/app/src/main/java/org/modg/bookshelf/ui/setup/SetupScreen.kt @@ -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), + ) + } + } +} diff --git a/app/app/src/main/java/org/modg/bookshelf/ui/setup/SetupViewModel.kt b/app/app/src/main/java/org/modg/bookshelf/ui/setup/SetupViewModel.kt new file mode 100644 index 0000000..eed0e5b --- /dev/null +++ b/app/app/src/main/java/org/modg/bookshelf/ui/setup/SetupViewModel.kt @@ -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 = _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 + } + } +} diff --git a/app/app/src/test/java/org/modg/bookshelf/data/local/BookDaoTest.kt b/app/app/src/test/java/org/modg/bookshelf/data/local/BookDaoTest.kt index 53f0ae1..92c3cd6 100644 --- a/app/app/src/test/java/org/modg/bookshelf/data/local/BookDaoTest.kt +++ b/app/app/src/test/java/org/modg/bookshelf/data/local/BookDaoTest.kt @@ -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")) diff --git a/app/app/src/test/java/org/modg/bookshelf/data/repo/LocationRepositoryTest.kt b/app/app/src/test/java/org/modg/bookshelf/data/repo/LocationRepositoryTest.kt new file mode 100644 index 0000000..e1576f3 --- /dev/null +++ b/app/app/src/test/java/org/modg/bookshelf/data/repo/LocationRepositoryTest.kt @@ -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) + } +} diff --git a/app/app/src/test/java/org/modg/bookshelf/ui/library/LibraryFilterLogicTest.kt b/app/app/src/test/java/org/modg/bookshelf/ui/library/LibraryFilterLogicTest.kt new file mode 100644 index 0000000..511b4ac --- /dev/null +++ b/app/app/src/test/java/org/modg/bookshelf/ui/library/LibraryFilterLogicTest.kt @@ -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(), result.map { it.id }) + } +} diff --git a/app/app/src/test/java/org/modg/bookshelf/ui/library/LibrarySortTest.kt b/app/app/src/test/java/org/modg/bookshelf/ui/library/LibrarySortTest.kt new file mode 100644 index 0000000..2dbda25 --- /dev/null +++ b/app/app/src/test/java/org/modg/bookshelf/ui/library/LibrarySortTest.kt @@ -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 = 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 }) + } +} diff --git a/app/app/src/test/java/org/modg/bookshelf/ui/library/LibrarySyncPresenterTest.kt b/app/app/src/test/java/org/modg/bookshelf/ui/library/LibrarySyncPresenterTest.kt new file mode 100644 index 0000000..8c1729b --- /dev/null +++ b/app/app/src/test/java/org/modg/bookshelf/ui/library/LibrarySyncPresenterTest.kt @@ -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) + } +} diff --git a/app/app/src/test/java/org/modg/bookshelf/ui/scan/ScanModelsTest.kt b/app/app/src/test/java/org/modg/bookshelf/ui/scan/ScanModelsTest.kt new file mode 100644 index 0000000..5477883 --- /dev/null +++ b/app/app/src/test/java/org/modg/bookshelf/ui/scan/ScanModelsTest.kt @@ -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) + } +} diff --git a/logs/E1-shell.sid b/logs/E1-shell.sid new file mode 100644 index 0000000..a431f58 --- /dev/null +++ b/logs/E1-shell.sid @@ -0,0 +1 @@ +fdc7809c-518c-4711-b7a9-781a33e330d5 diff --git a/logs/E1-shell.summary b/logs/E1-shell.summary new file mode 100644 index 0000000..cdda3c4 --- /dev/null +++ b/logs/E1-shell.summary @@ -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. diff --git a/logs/E2-books.sid b/logs/E2-books.sid new file mode 100644 index 0000000..b3ad3b9 --- /dev/null +++ b/logs/E2-books.sid @@ -0,0 +1 @@ +c4155cba-b34d-4321-b0a7-e7c225b23e32 diff --git a/logs/E2-books.summary b/logs/E2-books.summary new file mode 100644 index 0000000..03712f5 --- /dev/null +++ b/logs/E2-books.summary @@ -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.