feat(bookshelf): add BookshelfViewModel, LibraryScreen and nav entry

- Create BookshelfViewModel (Hilt) for browsing remote libraries via BookshelfApiRepository.
- Create BookshelfLibraryScreen Compose UI: libraries list → items list.
- Add BOOKSHELF_LIBRARY_ROUTE to AppDestinations and wire composable in AppNavigation using hiltViewModel().
- Add Cloud icon button in LibraryScreen top bar to open server library.
- ':app:assembleOssDebug' passes.
This commit is contained in:
Atte149 2026-06-24 15:45:57 +03:00
parent a8ad2bf900
commit d999e9df77
4 changed files with 304 additions and 0 deletions

View file

@ -52,6 +52,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.platform.LocalContext
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.media3.common.util.UnstableApi
@ -59,6 +61,7 @@ import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import org.dueattendant149.bookreader.bookshelf.BookshelfLibraryScreen
import org.dueattendant149.bookreader.epubreader.EpubReaderScreen
import org.dueattendant149.bookreader.feedback.FeedbackScreen
import org.dueattendant149.bookreader.feedback.SupportProjectScreen
@ -79,6 +82,7 @@ object AppDestinations {
const val FONTS_SCREEN_ROUTE = "fonts_screen_route"
const val AI_SETTINGS_SCREEN_ROUTE = "ai_settings_screen_route"
const val SETTINGS_SCREEN_ROUTE = "settings_screen_route"
const val BOOKSHELF_LIBRARY_ROUTE = "bookshelf_library"
}
fun shouldInterceptAppNavBack(
@ -427,6 +431,16 @@ fun AppNavigation(
onBackClick = { navController.popBackStackIfReady() }
)
}
composable(route = AppDestinations.BOOKSHELF_LIBRARY_ROUTE) {
val bookshelfViewModel: org.dueattendant149.bookreader.bookshelf.BookshelfViewModel = hiltViewModel()
BookshelfLibraryScreen(
viewModel = bookshelfViewModel,
onItemClick = { item ->
Timber.d("Bookshelf item selected: ${item.id}")
}
)
}
}
AnimatedVisibility(

View file

@ -80,6 +80,7 @@ import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AssistChip
@ -389,6 +390,7 @@ fun LibraryScreen(
},
onDeleteCatalogStreams = viewModel::deleteStreamedBooksForCatalog,
onSettingsClick = { navController.navigate(AppDestinations.SETTINGS_SCREEN_ROUTE) },
onBookshelfClick = { navController.navigate(AppDestinations.BOOKSHELF_LIBRARY_ROUTE) },
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName
)
@ -663,6 +665,7 @@ fun LibraryScreenContent(
onStreamOpdsBook: (OpdsEntry, OpdsCatalog?) -> Unit,
onDeleteCatalogStreams: (String) -> Unit,
onSettingsClick: () -> Unit,
onBookshelfClick: () -> Unit,
usePdfFileNameAsDisplayName: Boolean,
) {
val isBookContextualModeActive = selectedItems.isNotEmpty()
@ -804,6 +807,9 @@ fun LibraryScreenContent(
Icon(Icons.Default.Search, contentDescription = stringResource(R.string.action_search))
}
}
IconButton(onClick = onBookshelfClick) {
Icon(Icons.Default.Cloud, contentDescription = "Server library")
}
IconButton(onClick = onSettingsClick) {
Icon(Icons.Default.Settings, contentDescription = stringResource(R.string.settings))
}

View file

@ -0,0 +1,171 @@
package org.dueattendant149.bookreader.bookshelf
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
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.compose.collectAsStateWithLifecycle
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.BookItemResponse
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.LibraryResponse
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.UnifiedItemResponse
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BookshelfLibraryScreen(
viewModel: BookshelfViewModel,
onItemClick: (UnifiedItemResponse) -> Unit,
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
if (uiState.libraries.isEmpty()) {
viewModel.loadLibraries()
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Server Library") },
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
if (uiState.isLoading && uiState.libraries.isEmpty()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
return@Scaffold
}
uiState.error?.let { error ->
Text(
text = error,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(16.dp)
)
}
val selected = uiState.selectedLibrary
if (selected == null) {
Text(
text = "Select a library",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(16.dp)
)
LazyColumn(
contentPadding = PaddingValues(16.dp),
modifier = Modifier.fillMaxWidth()
) {
items(uiState.libraries) { library ->
LibraryCard(
library = library,
onClick = { viewModel.selectLibrary(library) }
)
}
}
} else {
Text(
text = selected.name,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(16.dp)
)
if (uiState.isLoading) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
LazyColumn(
contentPadding = PaddingValues(16.dp),
modifier = Modifier.fillMaxSize()
) {
items(uiState.items) { item ->
ItemCard(
item = item,
onClick = { onItemClick(item) }
)
}
}
}
}
}
}
@Composable
private fun LibraryCard(
library: LibraryResponse,
onClick: () -> Unit,
) {
Card(
onClick = onClick,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = library.name,
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = "${library.itemCount} items · ${library.mediaType}",
style = MaterialTheme.typography.bodySmall
)
}
}
}
@Composable
private fun ItemCard(
item: UnifiedItemResponse,
onClick: () -> Unit,
) {
Card(
onClick = onClick,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = item.title,
style = MaterialTheme.typography.titleMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
if (item.author.isNotBlank()) {
Text(
text = item.author,
style = MaterialTheme.typography.bodyMedium
)
}
Text(
text = item.type,
style = MaterialTheme.typography.bodySmall
)
}
}
}

View file

@ -0,0 +1,113 @@
package org.dueattendant149.bookreader.bookshelf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.dueattendant149.bookreader.data.remote.bookshelfapi.BookshelfApiRepository
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.LibraryResponse
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.UnifiedItemResponse
import javax.inject.Inject
@HiltViewModel
class BookshelfViewModel
@Inject
constructor(
private val repository: BookshelfApiRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow(BookshelfUiState())
val uiState: StateFlow<BookshelfUiState> = _uiState.asStateFlow()
fun loadLibraries() {
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
viewModelScope.launch {
repository.getLibraries()
.onSuccess { libraries ->
_uiState.value = _uiState.value.copy(
libraries = libraries,
isLoading = false,
error = null
)
}
.onFailure { error ->
_uiState.value = _uiState.value.copy(
isLoading = false,
error = error.message ?: "Failed to load libraries"
)
}
}
}
fun selectLibrary(library: LibraryResponse) {
_uiState.value = _uiState.value.copy(selectedLibrary = library, items = emptyList())
loadItems(library.id)
}
fun loadItems(libraryId: String) {
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
viewModelScope.launch {
repository.getLibraryItems(libraryId)
.onSuccess { response ->
_uiState.value = _uiState.value.copy(
items = response.items,
isLoading = false,
error = null
)
}
.onFailure { error ->
_uiState.value = _uiState.value.copy(
isLoading = false,
error = error.message ?: "Failed to load items"
)
}
}
}
fun search(libraryId: String, query: String) {
if (query.isBlank()) {
loadItems(libraryId)
return
}
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
viewModelScope.launch {
repository.searchLibrary(libraryId, query)
.onSuccess { searchItems ->
val mapped = searchItems.map { bookItem ->
UnifiedItemResponse(
id = bookItem.id,
title = bookItem.title,
author = bookItem.author,
type = bookItem.mediaType,
mediaType = bookItem.mediaType,
libraryId = bookItem.libraryId,
coverUrl = bookItem.coverUrl
)
}
_uiState.value = _uiState.value.copy(
items = mapped,
isLoading = false,
error = null
)
}
.onFailure { error ->
_uiState.value = _uiState.value.copy(
isLoading = false,
error = error.message ?: "Search failed"
)
}
}
}
}
data class BookshelfUiState(
val libraries: List<LibraryResponse> = emptyList(),
val selectedLibrary: LibraryResponse? = null,
val items: List<UnifiedItemResponse> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null,
)