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