Phase 5b: RemoteLibrary tab — fetch libraries/books, simple list UI

This commit is contained in:
Atte149 2026-06-16 09:58:26 +03:00
parent 0c2dfbd592
commit cff4db13ab
5 changed files with 232 additions and 3 deletions

View file

@ -0,0 +1,82 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2026 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package org.dueattendant149.bookshelf.presentation.bookshelf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.dueattendant149.bookshelf.data.settings.ServerSettings
import org.dueattendant149.bookshelf.domain.model.library.Book
import org.dueattendant149.bookshelf.domain.model.remote.RemoteLibrary
import org.dueattendant149.bookshelf.domain.use_case.remote.FetchBooksUseCase
import org.dueattendant149.bookshelf.domain.use_case.remote.FetchLibrariesUseCase
import javax.inject.Inject
data class RemoteLibraryState(
val libraries: List<RemoteLibrary> = emptyList(),
val books: List<Book> = emptyList(),
val selectedLibrary: RemoteLibrary? = null,
val isLoading: Boolean = false,
val error: String? = null,
)
@HiltViewModel
class RemoteLibraryModel
@Inject
constructor(
private val fetchLibrariesUseCase: FetchLibrariesUseCase,
private val fetchBooksUseCase: FetchBooksUseCase,
private val serverSettings: ServerSettings,
) : ViewModel() {
private val _state = MutableStateFlow(RemoteLibraryState())
val state = _state.asStateFlow()
init {
loadLibraries()
}
fun loadLibraries() {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
if (!serverSettings.hasCredentials()) {
_state.update { it.copy(isLoading = false, error = "Server not configured") }
return@launch
}
val result = fetchLibrariesUseCase()
_state.update {
it.copy(
isLoading = false,
libraries = result.getOrDefault(emptyList()),
error = result.exceptionOrNull()?.message,
)
}
}
}
fun selectLibrary(library: RemoteLibrary) {
_state.update { it.copy(selectedLibrary = library, books = emptyList(), error = null) }
loadBooks(library.id)
}
fun loadBooks(libraryId: String) {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
val result = fetchBooksUseCase(libraryId)
_state.update {
it.copy(
isLoading = false,
books = result.getOrDefault(emptyList()),
error = result.exceptionOrNull()?.message,
)
}
}
}
}

View file

@ -0,0 +1,24 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2026 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package org.dueattendant149.bookshelf.presentation.bookshelf
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.hilt.navigation.compose.hiltViewModel
import kotlinx.parcelize.Parcelize
import org.dueattendant149.bookshelf.presentation.navigator.Screen
import org.dueattendant149.bookshelf.ui.bookshelf.RemoteLibraryContent
@Parcelize
object RemoteLibraryScreen : Screen, Parcelable {
@Composable
override fun Content() {
val model = hiltViewModel<RemoteLibraryModel>()
RemoteLibraryContent(model = model)
}
}

View file

@ -28,6 +28,7 @@ import org.dueattendant149.bookshelf.presentation.browse.BrowseModel
import org.dueattendant149.bookshelf.presentation.browse.BrowseScreen import org.dueattendant149.bookshelf.presentation.browse.BrowseScreen
import org.dueattendant149.bookshelf.presentation.history.HistoryModel import org.dueattendant149.bookshelf.presentation.history.HistoryModel
import org.dueattendant149.bookshelf.presentation.history.HistoryScreen import org.dueattendant149.bookshelf.presentation.history.HistoryScreen
import org.dueattendant149.bookshelf.presentation.bookshelf.RemoteLibraryScreen
import org.dueattendant149.bookshelf.presentation.library.LibraryModel import org.dueattendant149.bookshelf.presentation.library.LibraryModel
import org.dueattendant149.bookshelf.presentation.library.LibraryScreen import org.dueattendant149.bookshelf.presentation.library.LibraryScreen
import org.dueattendant149.bookshelf.presentation.navigator.NavigatorItem import org.dueattendant149.bookshelf.presentation.navigator.NavigatorItem
@ -107,6 +108,13 @@ class MainActivity : AppCompatActivity() {
tooltip = R.string.browse_content_desc, tooltip = R.string.browse_content_desc,
selectedIcon = R.drawable.browse_screen_filled, selectedIcon = R.drawable.browse_screen_filled,
unselectedIcon = R.drawable.browse_screen_outlined unselectedIcon = R.drawable.browse_screen_outlined
),
NavigatorItem(
screen = RemoteLibraryScreen,
title = R.string.bookshelf_screen,
tooltip = R.string.bookshelf_content_desc,
selectedIcon = R.drawable.library_screen_filled,
unselectedIcon = R.drawable.library_screen_outlined
) )
) )
@ -137,14 +145,14 @@ class MainActivity : AppCompatActivity() {
}, },
contentKey = { contentKey = {
when (it) { when (it) {
LibraryScreen, HistoryScreen, BrowseScreen -> "tabs" LibraryScreen, HistoryScreen, BrowseScreen, RemoteLibraryScreen -> "tabs"
else -> it else -> it
} }
}, },
backHandlerEnabled = { it != StartScreen } backHandlerEnabled = { it != StartScreen }
) { screen -> ) { screen ->
when (screen) { when (screen) {
LibraryScreen, HistoryScreen, BrowseScreen -> { LibraryScreen, HistoryScreen, BrowseScreen, RemoteLibraryScreen -> {
NavigatorTabs( NavigatorTabs(
currentTab = screen, currentTab = screen,
transitionSpec = { transitionSpec = {

View file

@ -0,0 +1,113 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2026 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package org.dueattendant149.bookshelf.ui.bookshelf
import androidx.compose.foundation.layout.Arrangement
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.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
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.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.dueattendant149.bookshelf.presentation.bookshelf.RemoteLibraryModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RemoteLibraryContent(model: RemoteLibraryModel) {
val state by model.state.collectAsStateWithLifecycle()
Scaffold(
topBar = {
TopAppBar(
title = { Text("Bookshelf") }
)
}
) { padding ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
item {
Button(
onClick = model::loadLibraries,
enabled = !state.isLoading,
modifier = Modifier.fillMaxWidth()
) {
Text("Refresh libraries")
}
}
if (state.isLoading) {
item {
CircularProgressIndicator()
}
}
state.error?.let { error ->
item {
Text(
text = error,
color = MaterialTheme.colorScheme.error,
)
}
}
items(state.libraries.size) { index ->
val library = state.libraries[index]
val selected = state.selectedLibrary?.id == library.id
Card(
onClick = { model.selectLibrary(library) },
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = library.name,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(16.dp)
)
Text(
text = "${library.mediaType}${library.itemCount} items",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(horizontal = 16.dp).padding(bottom = 16.dp)
)
}
}
items(state.books.size) { index ->
val book = state.books[index]
Card(
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = book.title,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(16.dp)
)
Text(
text = book.author.getAsString() ?: "",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(horizontal = 16.dp).padding(bottom = 16.dp)
)
}
}
}
}
}

View file

@ -489,6 +489,8 @@
<string name="library_content_desc">Your library</string> <string name="library_content_desc">Your library</string>
<string name="history_content_desc">Reading history</string> <string name="history_content_desc">Reading history</string>
<string name="browse_content_desc">Add books</string> <string name="browse_content_desc">Add books</string>
<string name="bookshelf_screen">Bookshelf</string>
<string name="bookshelf_content_desc">Remote bookshelf</string>
<string name="arrow_content_desc">Arrow</string> <string name="arrow_content_desc">Arrow</string>
<string name="reset_start_content_desc">Reset start guide</string> <string name="reset_start_content_desc">Reset start guide</string>
<string name="open_in_web_content_desc">Open in web</string> <string name="open_in_web_content_desc">Open in web</string>