From d999e9df775520afe8aa080ab65e7837c6cfaed1 Mon Sep 17 00:00:00 2001 From: Atte149 Date: Wed, 24 Jun 2026 15:45:57 +0300 Subject: [PATCH] feat(bookshelf): add BookshelfViewModel, LibraryScreen and nav entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- .../bookreader/reader/AppNavigation.kt | 14 ++ .../bookreader/reader/LibraryScreen.kt | 6 + .../bookshelf/BookshelfLibraryScreen.kt | 171 ++++++++++++++++++ .../bookshelf/BookshelfViewModel.kt | 113 ++++++++++++ 4 files changed, 304 insertions(+) create mode 100644 app/src/main/java/org/dueattendant149/bookreader/bookshelf/BookshelfLibraryScreen.kt create mode 100644 app/src/main/java/org/dueattendant149/bookreader/bookshelf/BookshelfViewModel.kt diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/AppNavigation.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/AppNavigation.kt index dd9963e..f2a8484 100644 --- a/app/src/main/java/com/dueattendant149/bookreader/reader/AppNavigation.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/AppNavigation.kt @@ -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( diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/LibraryScreen.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/LibraryScreen.kt index fa79d21..97c8e4f 100644 --- a/app/src/main/java/com/dueattendant149/bookreader/reader/LibraryScreen.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/LibraryScreen.kt @@ -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)) } diff --git a/app/src/main/java/org/dueattendant149/bookreader/bookshelf/BookshelfLibraryScreen.kt b/app/src/main/java/org/dueattendant149/bookreader/bookshelf/BookshelfLibraryScreen.kt new file mode 100644 index 0000000..79bfcfd --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookreader/bookshelf/BookshelfLibraryScreen.kt @@ -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 + ) + } + } +} diff --git a/app/src/main/java/org/dueattendant149/bookreader/bookshelf/BookshelfViewModel.kt b/app/src/main/java/org/dueattendant149/bookreader/bookshelf/BookshelfViewModel.kt new file mode 100644 index 0000000..46c10f3 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookreader/bookshelf/BookshelfViewModel.kt @@ -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 = _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 = emptyList(), + val selectedLibrary: LibraryResponse? = null, + val items: List = emptyList(), + val isLoading: Boolean = false, + val error: String? = null, +)