From 1666ae0fe200b52611c979679d03f7b7b4613746 Mon Sep 17 00:00:00 2001 From: Atte149 Date: Wed, 24 Jun 2026 16:01:03 +0300 Subject: [PATCH] feat(bookshelf): add ServerSettingsScreen for bookshelf-api/ABS config - Create ServerSettingsViewModel (Hilt) backed by SharedPreferences ServerSettings. - Create ServerSettingsScreen Compose UI with URL/token fields and Save button. - Add SERVER_SETTINGS_ROUTE to AppDestinations and wire in AppNavigation. - Add settings icon in BookshelfLibraryScreen top bar to open settings. - ':app:assembleOssDebug' passes. --- .../bookreader/reader/AppNavigation.kt | 12 ++ .../bookreader/ServerSettingsScreen.kt | 114 ++++++++++++++++++ .../bookreader/ServerSettingsViewModel.kt | 58 +++++++++ .../bookshelf/BookshelfLibraryScreen.kt | 10 ++ 4 files changed, 194 insertions(+) create mode 100644 app/src/main/java/org/dueattendant149/bookreader/ServerSettingsScreen.kt create mode 100644 app/src/main/java/org/dueattendant149/bookreader/ServerSettingsViewModel.kt diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/AppNavigation.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/AppNavigation.kt index f2a8484..9fd3842 100644 --- a/app/src/main/java/com/dueattendant149/bookreader/reader/AppNavigation.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/AppNavigation.kt @@ -83,6 +83,7 @@ object AppDestinations { const val AI_SETTINGS_SCREEN_ROUTE = "ai_settings_screen_route" const val SETTINGS_SCREEN_ROUTE = "settings_screen_route" const val BOOKSHELF_LIBRARY_ROUTE = "bookshelf_library" + const val SERVER_SETTINGS_ROUTE = "server_settings" } fun shouldInterceptAppNavBack( @@ -438,9 +439,20 @@ fun AppNavigation( viewModel = bookshelfViewModel, onItemClick = { item -> Timber.d("Bookshelf item selected: ${item.id}") + }, + onOpenSettings = { + navController.navigateIfReady(AppDestinations.SERVER_SETTINGS_ROUTE) } ) } + + composable(route = AppDestinations.SERVER_SETTINGS_ROUTE) { + val settingsViewModel: org.dueattendant149.bookreader.bookshelf.ServerSettingsViewModel = hiltViewModel() + org.dueattendant149.bookreader.bookshelf.ServerSettingsScreen( + viewModel = settingsViewModel, + onBackClick = { navController.popBackStackIfReady() } + ) + } } AnimatedVisibility( diff --git a/app/src/main/java/org/dueattendant149/bookreader/ServerSettingsScreen.kt b/app/src/main/java/org/dueattendant149/bookreader/ServerSettingsScreen.kt new file mode 100644 index 0000000..1f7120c --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookreader/ServerSettingsScreen.kt @@ -0,0 +1,114 @@ +package org.dueattendant149.bookreader.bookshelf + +import androidx.compose.foundation.layout.Column +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.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +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.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ServerSettingsScreen( + viewModel: ServerSettingsViewModel, + onBackClick: () -> Unit, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + var bookshelfUrl by remember(uiState.bookshelfUrl) { androidx.compose.runtime.mutableStateOf(uiState.bookshelfUrl) } + var absUrl by remember(uiState.absUrl) { androidx.compose.runtime.mutableStateOf(uiState.absUrl) } + var absToken by remember(uiState.absToken) { androidx.compose.runtime.mutableStateOf(uiState.absToken) } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Server settings") }, + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + } + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(16.dp) + ) { + Text("Bookshelf API") + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = bookshelfUrl, + onValueChange = { + bookshelfUrl = it + viewModel.updateBookshelfUrl(it) + }, + label = { Text("https://books.dueattendant149.org/") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Text("Audiobookshelf") + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = absUrl, + onValueChange = { + absUrl = it + viewModel.updateAbsUrl(it) + }, + label = { Text("https://abs.dueattendant149.org/") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = absToken, + onValueChange = { + absToken = it + viewModel.updateAbsToken(it) + }, + label = { Text("Bearer token") }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Button( + onClick = viewModel::save, + modifier = Modifier.fillMaxWidth() + ) { + Text("Save") + } + + if (uiState.saved) { + Spacer(modifier = Modifier.height(8.dp)) + Text("Saved") + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/dueattendant149/bookreader/ServerSettingsViewModel.kt b/app/src/main/java/org/dueattendant149/bookreader/ServerSettingsViewModel.kt new file mode 100644 index 0000000..eaaf09a --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookreader/ServerSettingsViewModel.kt @@ -0,0 +1,58 @@ +package org.dueattendant149.bookreader.bookshelf + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.dueattendant149.bookreader.data.settings.ServerSettings +import javax.inject.Inject + +data class ServerSettingsUiState( + val bookshelfUrl: String = "", + val absUrl: String = "", + val absToken: String = "", + val saved: Boolean = false, +) + +@HiltViewModel +class ServerSettingsViewModel + @Inject + constructor( + private val serverSettings: ServerSettings, + ) : ViewModel() { + + private val _uiState = MutableStateFlow(ServerSettingsUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + _uiState.value = ServerSettingsUiState( + bookshelfUrl = serverSettings.getBookshelfUrl() ?: "", + absUrl = serverSettings.getAbsUrl() ?: "", + absToken = serverSettings.getAbsToken() ?: "", + ) + } + + fun updateBookshelfUrl(url: String) { + _uiState.value = _uiState.value.copy(bookshelfUrl = url, saved = false) + } + + fun updateAbsUrl(url: String) { + _uiState.value = _uiState.value.copy(absUrl = url, saved = false) + } + + fun updateAbsToken(token: String) { + _uiState.value = _uiState.value.copy(absToken = token, saved = false) + } + + fun save() { + viewModelScope.launch { + serverSettings.setBookshelfUrl(_uiState.value.bookshelfUrl.trim()) + serverSettings.setAbsUrl(_uiState.value.absUrl.trim()) + serverSettings.setAbsToken(_uiState.value.absToken.trim()) + _uiState.value = _uiState.value.copy(saved = true) + } + } + } \ No newline at end of file diff --git a/app/src/main/java/org/dueattendant149/bookreader/bookshelf/BookshelfLibraryScreen.kt b/app/src/main/java/org/dueattendant149/bookreader/bookshelf/BookshelfLibraryScreen.kt index 79bfcfd..d0b64d1 100644 --- a/app/src/main/java/org/dueattendant149/bookreader/bookshelf/BookshelfLibraryScreen.kt +++ b/app/src/main/java/org/dueattendant149/bookreader/bookshelf/BookshelfLibraryScreen.kt @@ -8,9 +8,13 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -33,6 +37,7 @@ import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.UnifiedItem fun BookshelfLibraryScreen( viewModel: BookshelfViewModel, onItemClick: (UnifiedItemResponse) -> Unit, + onOpenSettings: () -> Unit = {}, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -46,6 +51,11 @@ fun BookshelfLibraryScreen( topBar = { TopAppBar( title = { Text("Server Library") }, + actions = { + IconButton(onClick = onOpenSettings) { + Icon(Icons.Default.Settings, contentDescription = "Server settings") + } + } ) } ) { padding ->