Phase 5b: RemoteLibrary tab — fetch libraries/books, simple list UI
This commit is contained in:
parent
0c2dfbd592
commit
cff4db13ab
5 changed files with 232 additions and 3 deletions
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue