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.
This commit is contained in:
Atte149 2026-06-24 16:01:03 +03:00
parent d999e9df77
commit 1666ae0fe2
4 changed files with 194 additions and 0 deletions

View file

@ -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(

View file

@ -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")
}
}
}
}

View file

@ -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<ServerSettingsUiState> = _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)
}
}
}

View file

@ -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 ->