feat(ui-polish): improve RemoteLibrary & BookshelfSettings UI, add icons/strings

This commit is contained in:
Atte149 2026-06-16 11:35:04 +03:00
parent 1ed35239fd
commit 23298b0f26
7 changed files with 302 additions and 85 deletions

View file

@ -113,8 +113,8 @@ class MainActivity : AppCompatActivity() {
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
selectedIcon = R.drawable.bookshelf_screen_filled,
unselectedIcon = R.drawable.bookshelf_screen_outlined
)
)

View file

@ -25,6 +25,7 @@ data class BookshelfSettingsState(
val libraries: List<RemoteLibrary> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null,
val librariesSuccess: Boolean = false,
)
@HiltViewModel
@ -53,28 +54,58 @@ class BookshelfSettingsModel
}
}
fun updateBookshelfUrl(value: String) = _state.update { it.copy(bookshelfUrl = value) }
fun updateAbsUrl(value: String) = _state.update { it.copy(absUrl = value) }
fun updateAbsToken(value: String) = _state.update { it.copy(absToken = value) }
fun updateBookshelfUrl(value: String) = _state.update {
it.copy(
bookshelfUrl = value,
librariesSuccess = false,
error = null,
)
}
fun updateAbsUrl(value: String) = _state.update {
it.copy(
absUrl = value,
librariesSuccess = false,
error = null,
)
}
fun updateAbsToken(value: String) = _state.update {
it.copy(
absToken = value,
librariesSuccess = false,
error = null,
)
}
fun save() {
viewModelScope.launch {
serverSettings.setBookshelfUrl(_state.value.bookshelfUrl)
serverSettings.setAbsUrl(_state.value.absUrl)
serverSettings.setAbsToken(_state.value.absToken)
val current = _state.value
serverSettings.setBookshelfUrl(current.bookshelfUrl)
serverSettings.setAbsUrl(current.absUrl)
serverSettings.setAbsToken(current.absToken)
}
}
fun testLibraries() {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
_state.update {
it.copy(
isLoading = true,
error = null,
librariesSuccess = false,
)
}
save()
val result = fetchLibrariesUseCase()
val libraries = result.getOrDefault(emptyList())
_state.update {
it.copy(
isLoading = false,
libraries = result.getOrDefault(emptyList()),
libraries = libraries,
error = result.exceptionOrNull()?.message,
librariesSuccess = result.isSuccess && libraries.isNotEmpty(),
)
}
}

View file

@ -7,69 +7,77 @@
package org.dueattendant149.bookshelf.ui.bookshelf
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.Button
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CloudOff
import androidx.compose.material.icons.filled.CloudQueue
import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ListItem
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.dueattendant149.bookshelf.R
import org.dueattendant149.bookshelf.presentation.bookshelf.RemoteLibraryModel
import org.dueattendant149.bookshelf.ui.common.components.placeholder.EmptyPlaceholder
import org.dueattendant149.bookshelf.ui.common.components.placeholder.ErrorPlaceholder
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class)
@Composable
fun RemoteLibraryContent(model: RemoteLibraryModel) {
val state by model.state.collectAsStateWithLifecycle()
val refreshState = rememberPullRefreshState(
refreshing = state.isLoading,
onRefresh = model::loadLibraries
)
Scaffold(
topBar = {
TopAppBar(
title = { Text("Bookshelf") }
title = { Text(stringResource(R.string.bookshelf_screen)) }
)
}
) { padding ->
LazyColumn(
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
.pullRefresh(refreshState)
.padding(padding)
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
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,
text = stringResource(R.string.bookshelf_libraries_header),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(bottom = 4.dp)
)
}
}
items(state.libraries.size) { index ->
val library = state.libraries[index]
@ -78,15 +86,43 @@ fun RemoteLibraryContent(model: RemoteLibraryModel) {
onClick = { model.selectLibrary(library) },
modifier = Modifier.fillMaxWidth(),
) {
ListItem(
headlineContent = {
Text(
text = library.name,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(16.dp)
style = MaterialTheme.typography.titleMedium
)
},
supportingContent = {
Text(
text = "${library.mediaType}${library.itemCount} items",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(horizontal = 16.dp).padding(bottom = 16.dp)
text = stringResource(
R.string.bookshelf_library_subtitle,
library.mediaType,
library.itemCount
)
)
},
trailingContent = {
if (selected) {
Text(
text = stringResource(R.string.bookshelf_selected_label),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary
)
}
}
)
}
}
state.selectedLibrary?.let { library ->
item {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.bookshelf_books_header, library.name),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(bottom = 4.dp)
)
}
}
@ -96,18 +132,64 @@ fun RemoteLibraryContent(model: RemoteLibraryModel) {
Card(
modifier = Modifier.fillMaxWidth(),
) {
ListItem(
headlineContent = {
Text(
text = book.title,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(16.dp)
style = MaterialTheme.typography.titleMedium
)
},
supportingContent = {
Text(
text = book.author.getAsString() ?: "",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(horizontal = 16.dp).padding(bottom = 16.dp)
text = book.author.getAsString()
?: stringResource(R.string.unknown_author)
)
}
)
}
}
if (state.isLoading && state.libraries.isEmpty() && state.books.isEmpty()) {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 32.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
}
}
if (!state.isLoading && state.libraries.isEmpty() && state.error == null) {
EmptyPlaceholder(
modifier = Modifier.align(Alignment.Center),
message = stringResource(R.string.bookshelf_empty_message),
icon = rememberVectorPainter(Icons.Default.CloudQueue),
actionTitle = stringResource(R.string.bookshelf_refresh_action),
action = model::loadLibraries
)
}
state.error?.let { error ->
ErrorPlaceholder(
modifier = Modifier.align(Alignment.Center),
errorMessage = error,
icon = rememberVectorPainter(Icons.Default.CloudOff),
actionTitle = stringResource(R.string.bookshelf_retry_action),
action = model::loadLibraries
)
}
PullRefreshIndicator(
refreshing = state.isLoading,
state = refreshState,
modifier = Modifier.align(Alignment.TopCenter),
backgroundColor = MaterialTheme.colorScheme.inverseSurface,
contentColor = MaterialTheme.colorScheme.inverseOnSurface
)
}
}
}

View file

@ -8,15 +8,21 @@ package org.dueattendant149.bookshelf.ui.settings.bookshelf
import androidx.compose.foundation.layout.Arrangement
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.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
@ -24,9 +30,12 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.dueattendant149.bookshelf.R
import org.dueattendant149.bookshelf.presentation.settings.BookshelfSettingsModel
import org.dueattendant149.bookshelf.ui.navigator.NavigatorBackIconButton
@ -43,7 +52,7 @@ fun BookshelfSettingsContent(
Scaffold(
topBar = {
TopAppBar(
title = { Text("Bookshelf server") },
title = { Text(stringResource(R.string.bookshelf_settings_screen_title)) },
navigationIcon = {
NavigatorBackIconButton(navigateBack = navigateBack)
},
@ -63,8 +72,9 @@ fun BookshelfSettingsContent(
OutlinedTextField(
value = state.bookshelfUrl,
onValueChange = model::updateBookshelfUrl,
label = { Text("Bookshelf API URL") },
label = { Text(stringResource(R.string.bookshelf_url_option)) },
placeholder = { Text("http://bookshelf-api:8073") },
supportingText = { Text(stringResource(R.string.bookshelf_url_option_desc)) },
modifier = Modifier.fillMaxWidth()
)
}
@ -72,8 +82,9 @@ fun BookshelfSettingsContent(
OutlinedTextField(
value = state.absUrl,
onValueChange = model::updateAbsUrl,
label = { Text("Audiobookshelf URL") },
label = { Text(stringResource(R.string.abs_url_option)) },
placeholder = { Text("http://192.168.1.119:13378") },
supportingText = { Text(stringResource(R.string.abs_url_option_desc)) },
modifier = Modifier.fillMaxWidth()
)
}
@ -81,20 +92,54 @@ fun BookshelfSettingsContent(
OutlinedTextField(
value = state.absToken,
onValueChange = model::updateAbsToken,
label = { Text("ABS token") },
label = { Text(stringResource(R.string.abs_token_option)) },
supportingText = { Text(stringResource(R.string.abs_token_option_desc)) },
modifier = Modifier.fillMaxWidth()
)
}
item {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Button(
onClick = model::save,
enabled = !state.isLoading,
modifier = Modifier.weight(1f)
) {
Text(stringResource(R.string.save_button))
}
OutlinedButton(
onClick = model::testLibraries,
enabled = !state.isLoading,
modifier = Modifier.fillMaxWidth()
modifier = Modifier.weight(1f)
) {
if (state.isLoading) {
CircularProgressIndicator()
} else {
Text("Fetch libraries")
Text(stringResource(R.string.fetch_libraries_button))
}
}
}
}
state.librariesSuccess.takeIf { it }?.let {
item {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
imageVector = Icons.Default.CheckCircle,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Text(
text = stringResource(
R.string.libraries_fetched_successful,
state.libraries.size
),
color = MaterialTheme.colorScheme.primary,
)
}
}
}
@ -102,7 +147,7 @@ fun BookshelfSettingsContent(
item {
Text(
text = error,
color = androidx.compose.material3.MaterialTheme.colorScheme.error,
color = MaterialTheme.colorScheme.error,
)
}
}
@ -110,7 +155,15 @@ fun BookshelfSettingsContent(
val library = state.libraries[index]
ListItem(
headlineContent = { Text(library.name) },
supportingContent = { Text("${library.mediaType}${library.itemCount} items") }
supportingContent = {
Text(
stringResource(
R.string.bookshelf_library_subtitle,
library.mediaType,
library.itemCount
)
)
}
)
}
}

View file

@ -0,0 +1,16 @@
<!--
~ Book's Story — free and open-source Material You eBook reader.
~ Copyright (C) 2024-2026 Acclorite
~ SPDX-License-Identifier: GPL-3.0-only
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M18.95,11.45c-0.3,-2.1 -2.15,-3.7 -4.35,-3.55c-0.65,-2.35 -2.85,-4.05 -5.45,-4.05c-2.95,0 -5.4,2.05 -6,4.85C1.55,9.35 0,11.45 0,13.9c0,2.95 2.4,5.35 5.35,5.35h13.3c2.4,0 4.35,-1.95 4.35,-4.35C23,12.85 21.25,11.55 18.95,11.45zM14,13h-1v4.5l-1.25,-0.75L10.5,17.5V13h-1c-0.55,0 -1,-0.45 -1,-1s0.45,-1 1,-1h4.5c0.55,0 1,0.45 1,1S14.55,13 14,13z" />
</vector>

View file

@ -0,0 +1,16 @@
<!--
~ Book's Story — free and open-source Material You eBook reader.
~ Copyright (C) 2024-2026 Acclorite
~ SPDX-License-Identifier: GPL-3.0-only
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M18.95,11.45c-0.3,-2.1 -2.15,-3.7 -4.35,-3.55c-0.65,-2.35 -2.85,-4.05 -5.45,-4.05c-2.95,0 -5.4,2.05 -6,4.85C1.55,9.35 0,11.45 0,13.9c0,2.95 2.4,5.35 5.35,5.35h13.3c2.4,0 4.35,-1.95 4.35,-4.35C23,12.85 21.25,11.55 18.95,11.45zM18.65,17.65H5.35C3.2,17.65 1.5,15.95 1.5,13.9c0,-1.75 1.15,-3.25 2.85,-3.7l0.55,-0.15l0.15,-0.55c0.4,-2.1 2.25,-3.65 4.4,-3.65c1.95,0 3.7,1.2 4.35,3l0.25,0.7l0.7,-0.05c1.65,-0.1 3.05,1.15 3.05,2.8c0,1.6 -1.3,2.9 -2.9,2.9zM9.5,13v4.5l1.25,-0.75l1.25,0.75V13H14c0.55,0 1,-0.45 1,-1s-0.45,-1 -1,-1h-4.5c-0.55,0 -1,0.45 -1,1S8.95,13 9.5,13z" />
</vector>

View file

@ -137,6 +137,25 @@
<string name="bookshelf_settings">Bookshelf</string>
<string name="bookshelf_settings_desc">Server, ABS, TTS</string>
<!-- Bookshelf server settings -->
<string name="bookshelf_settings_screen_title">Bookshelf server</string>
<string name="bookshelf_url_option">Bookshelf API URL</string>
<string name="bookshelf_url_option_desc">Base URL of the bookshelf-api service</string>
<string name="abs_url_option">Audiobookshelf URL</string>
<string name="abs_url_option_desc">Direct URL of the Audiobookshelf server</string>
<string name="abs_token_option">ABS token</string>
<string name="abs_token_option_desc">API token from Audiobookshelf settings</string>
<string name="save_button">Save</string>
<string name="fetch_libraries_button">Fetch libraries</string>
<string name="libraries_fetched_successful">Connected to %1$d libraries</string>
<string name="bookshelf_libraries_header">Libraries</string>
<string name="bookshelf_library_subtitle">%1$s • %2$d items</string>
<string name="bookshelf_selected_label">Selected</string>
<string name="bookshelf_books_header">Books in %1$s</string>
<string name="bookshelf_empty_message">No remote libraries found</string>
<string name="bookshelf_refresh_action">Refresh</string>
<string name="bookshelf_retry_action">Retry</string>
<!-- Settings subcategories -->
<string name="font_reader_settings">Font</string>
<string name="text_reader_settings">Text</string>