diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/main/MainActivity.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/main/MainActivity.kt index 13cb3628..0ca1a721 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/main/MainActivity.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/presentation/main/MainActivity.kt @@ -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 ) ) diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/settings/BookshelfSettingsModel.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/settings/BookshelfSettingsModel.kt index 19bef6b7..508329f9 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/settings/BookshelfSettingsModel.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/presentation/settings/BookshelfSettingsModel.kt @@ -25,6 +25,7 @@ data class BookshelfSettingsState( val libraries: List = 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(), ) } } diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/bookshelf/RemoteLibraryContent.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/bookshelf/RemoteLibraryContent.kt index a2aef5e7..3b934004 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/ui/bookshelf/RemoteLibraryContent.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/ui/bookshelf/RemoteLibraryContent.kt @@ -7,107 +7,189 @@ 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), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) + .pullRefresh(refreshState) + .padding(padding) ) { - item { - Button( - onClick = model::loadLibraries, - enabled = !state.isLoading, - modifier = Modifier.fillMaxWidth() - ) { - Text("Refresh libraries") + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + item { + Text( + 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] + val selected = state.selectedLibrary?.id == library.id + Card( + onClick = { model.selectLibrary(library) }, + modifier = Modifier.fillMaxWidth(), + ) { + ListItem( + headlineContent = { + Text( + text = library.name, + style = MaterialTheme.typography.titleMedium + ) + }, + supportingContent = { + Text( + 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) + ) + } + } + + items(state.books.size) { index -> + val book = state.books[index] + Card( + modifier = Modifier.fillMaxWidth(), + ) { + ListItem( + headlineContent = { + Text( + text = book.title, + style = MaterialTheme.typography.titleMedium + ) + }, + supportingContent = { + Text( + 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) { - item { - 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 -> - item { - Text( - text = error, - color = MaterialTheme.colorScheme.error, - ) - } + ErrorPlaceholder( + modifier = Modifier.align(Alignment.Center), + errorMessage = error, + icon = rememberVectorPainter(Icons.Default.CloudOff), + actionTitle = stringResource(R.string.bookshelf_retry_action), + action = model::loadLibraries + ) } - 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) - ) - } - } + PullRefreshIndicator( + refreshing = state.isLoading, + state = refreshState, + modifier = Modifier.align(Alignment.TopCenter), + backgroundColor = MaterialTheme.colorScheme.inverseSurface, + contentColor = MaterialTheme.colorScheme.inverseOnSurface + ) } } } diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/bookshelf/BookshelfSettingsContent.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/bookshelf/BookshelfSettingsContent.kt index 185befb5..6931d91f 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/bookshelf/BookshelfSettingsContent.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/bookshelf/BookshelfSettingsContent.kt @@ -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 { - Button( - onClick = model::testLibraries, - enabled = !state.isLoading, - modifier = Modifier.fillMaxWidth() + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - if (state.isLoading) { - CircularProgressIndicator() - } else { - Text("Fetch libraries") + 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.weight(1f) + ) { + if (state.isLoading) { + CircularProgressIndicator() + } else { + 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 + ) + ) + } ) } } diff --git a/app/src/main/res/drawable/bookshelf_screen_filled.xml b/app/src/main/res/drawable/bookshelf_screen_filled.xml new file mode 100644 index 00000000..65fdf2b4 --- /dev/null +++ b/app/src/main/res/drawable/bookshelf_screen_filled.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/bookshelf_screen_outlined.xml b/app/src/main/res/drawable/bookshelf_screen_outlined.xml new file mode 100644 index 00000000..95c2578e --- /dev/null +++ b/app/src/main/res/drawable/bookshelf_screen_outlined.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a2997b4f..e41bb59f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -137,6 +137,25 @@ Bookshelf Server, ABS, TTS + + Bookshelf server + Bookshelf API URL + Base URL of the bookshelf-api service + Audiobookshelf URL + Direct URL of the Audiobookshelf server + ABS token + API token from Audiobookshelf settings + Save + Fetch libraries + Connected to %1$d libraries + Libraries + %1$s • %2$d items + Selected + Books in %1$s + No remote libraries found + Refresh + Retry + Font Text