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, screen = RemoteLibraryScreen,
title = R.string.bookshelf_screen, title = R.string.bookshelf_screen,
tooltip = R.string.bookshelf_content_desc, tooltip = R.string.bookshelf_content_desc,
selectedIcon = R.drawable.library_screen_filled, selectedIcon = R.drawable.bookshelf_screen_filled,
unselectedIcon = R.drawable.library_screen_outlined unselectedIcon = R.drawable.bookshelf_screen_outlined
) )
) )

View file

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

View file

@ -7,69 +7,77 @@
package org.dueattendant149.bookshelf.ui.bookshelf package org.dueattendant149.bookshelf.ui.bookshelf
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn 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.Card
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.dueattendant149.bookshelf.R
import org.dueattendant149.bookshelf.presentation.bookshelf.RemoteLibraryModel 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 @Composable
fun RemoteLibraryContent(model: RemoteLibraryModel) { fun RemoteLibraryContent(model: RemoteLibraryModel) {
val state by model.state.collectAsStateWithLifecycle() val state by model.state.collectAsStateWithLifecycle()
val refreshState = rememberPullRefreshState(
refreshing = state.isLoading,
onRefresh = model::loadLibraries
)
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text("Bookshelf") } title = { Text(stringResource(R.string.bookshelf_screen)) }
) )
} }
) { padding -> ) { padding ->
LazyColumn( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(padding), .pullRefresh(refreshState)
.padding(padding)
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.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 { item {
Text( Text(
text = error, text = stringResource(R.string.bookshelf_libraries_header),
color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(bottom = 4.dp)
) )
} }
}
items(state.libraries.size) { index -> items(state.libraries.size) { index ->
val library = state.libraries[index] val library = state.libraries[index]
@ -78,15 +86,43 @@ fun RemoteLibraryContent(model: RemoteLibraryModel) {
onClick = { model.selectLibrary(library) }, onClick = { model.selectLibrary(library) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
ListItem(
headlineContent = {
Text( Text(
text = library.name, text = library.name,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium
modifier = Modifier.padding(16.dp)
) )
},
supportingContent = {
Text( Text(
text = "${library.mediaType}${library.itemCount} items", text = stringResource(
style = MaterialTheme.typography.bodyMedium, R.string.bookshelf_library_subtitle,
modifier = Modifier.padding(horizontal = 16.dp).padding(bottom = 16.dp) 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( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
ListItem(
headlineContent = {
Text( Text(
text = book.title, text = book.title,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium
modifier = Modifier.padding(16.dp)
) )
},
supportingContent = {
Text( Text(
text = book.author.getAsString() ?: "", text = book.author.getAsString()
style = MaterialTheme.typography.bodyMedium, ?: stringResource(R.string.unknown_author)
modifier = Modifier.padding(horizontal = 16.dp).padding(bottom = 16.dp) )
}
)
}
}
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.Arrangement
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState 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.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
@ -24,9 +30,12 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.dueattendant149.bookshelf.R
import org.dueattendant149.bookshelf.presentation.settings.BookshelfSettingsModel import org.dueattendant149.bookshelf.presentation.settings.BookshelfSettingsModel
import org.dueattendant149.bookshelf.ui.navigator.NavigatorBackIconButton import org.dueattendant149.bookshelf.ui.navigator.NavigatorBackIconButton
@ -43,7 +52,7 @@ fun BookshelfSettingsContent(
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text("Bookshelf server") }, title = { Text(stringResource(R.string.bookshelf_settings_screen_title)) },
navigationIcon = { navigationIcon = {
NavigatorBackIconButton(navigateBack = navigateBack) NavigatorBackIconButton(navigateBack = navigateBack)
}, },
@ -63,8 +72,9 @@ fun BookshelfSettingsContent(
OutlinedTextField( OutlinedTextField(
value = state.bookshelfUrl, value = state.bookshelfUrl,
onValueChange = model::updateBookshelfUrl, onValueChange = model::updateBookshelfUrl,
label = { Text("Bookshelf API URL") }, label = { Text(stringResource(R.string.bookshelf_url_option)) },
placeholder = { Text("http://bookshelf-api:8073") }, placeholder = { Text("http://bookshelf-api:8073") },
supportingText = { Text(stringResource(R.string.bookshelf_url_option_desc)) },
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
} }
@ -72,8 +82,9 @@ fun BookshelfSettingsContent(
OutlinedTextField( OutlinedTextField(
value = state.absUrl, value = state.absUrl,
onValueChange = model::updateAbsUrl, onValueChange = model::updateAbsUrl,
label = { Text("Audiobookshelf URL") }, label = { Text(stringResource(R.string.abs_url_option)) },
placeholder = { Text("http://192.168.1.119:13378") }, placeholder = { Text("http://192.168.1.119:13378") },
supportingText = { Text(stringResource(R.string.abs_url_option_desc)) },
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
} }
@ -81,20 +92,54 @@ fun BookshelfSettingsContent(
OutlinedTextField( OutlinedTextField(
value = state.absToken, value = state.absToken,
onValueChange = model::updateAbsToken, 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() modifier = Modifier.fillMaxWidth()
) )
} }
item { item {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Button( Button(
onClick = model::save,
enabled = !state.isLoading,
modifier = Modifier.weight(1f)
) {
Text(stringResource(R.string.save_button))
}
OutlinedButton(
onClick = model::testLibraries, onClick = model::testLibraries,
enabled = !state.isLoading, enabled = !state.isLoading,
modifier = Modifier.fillMaxWidth() modifier = Modifier.weight(1f)
) { ) {
if (state.isLoading) { if (state.isLoading) {
CircularProgressIndicator() CircularProgressIndicator()
} else { } 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 { item {
Text( Text(
text = error, text = error,
color = androidx.compose.material3.MaterialTheme.colorScheme.error, color = MaterialTheme.colorScheme.error,
) )
} }
} }
@ -110,7 +155,15 @@ fun BookshelfSettingsContent(
val library = state.libraries[index] val library = state.libraries[index]
ListItem( ListItem(
headlineContent = { Text(library.name) }, 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">Bookshelf</string>
<string name="bookshelf_settings_desc">Server, ABS, TTS</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 --> <!-- Settings subcategories -->
<string name="font_reader_settings">Font</string> <string name="font_reader_settings">Font</string>
<string name="text_reader_settings">Text</string> <string name="text_reader_settings">Text</string>