From a70b0bc22fe9ed6a22ddda8d1049c6aad9625813 Mon Sep 17 00:00:00 2001 From: Jonas Schneider Date: Wed, 15 Apr 2026 12:59:59 +0200 Subject: [PATCH 1/3] feat: add timeout setting under server advanced view --- .../composeResources/values-de/strings.xml | 12 ++ .../composeResources/values-fr/strings.xml | 11 +- .../composeResources/values/strings.xml | 10 + .../kotlin/de/kitshn/KitshnViewModel.kt | 10 + .../commonMain/kotlin/de/kitshn/Settings.kt | 10 + .../de/kitshn/api/tandoor/TandoorClient.kt | 43 +++- .../ui/dialog/TimeoutSelectionBottomSheet.kt | 121 +++++++++++ .../kitshn/ui/view/settings/SettingsServer.kt | 201 ++++++++++++------ .../view/settings/SettingsServerAdvanced.kt | 160 ++++++++++++++ .../kitshn/api/tandoor/TandoorClientTest.kt | 28 +++ 10 files changed, 529 insertions(+), 77 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/de/kitshn/ui/dialog/TimeoutSelectionBottomSheet.kt create mode 100644 composeApp/src/commonMain/kotlin/de/kitshn/ui/view/settings/SettingsServerAdvanced.kt create mode 100644 composeApp/src/commonTest/kotlin/de/kitshn/api/tandoor/TandoorClientTest.kt diff --git a/composeApp/src/commonMain/composeResources/values-de/strings.xml b/composeApp/src/commonMain/composeResources/values-de/strings.xml index ac7a6698..c55805fb 100644 --- a/composeApp/src/commonMain/composeResources/values-de/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-de/strings.xml @@ -192,6 +192,8 @@ Webseite Willkommen Gestern + Standard + Fehler Es konnte keine Verbindung zu deinem Tandoor-Server hergestellt werden. Die Instanz, mit der du dich verbinden möchtest, ist nicht in dieser App eingerichtet. @@ -357,6 +359,16 @@ Daten löschen und verwalten Informationen über deinen Server Server + Erweitert + Timeouts und weitere Netzwerkeinstellungen + Kurzes Timeout + Wird für reguläre API-Anfragen verwendet (%1$d Sekunden) + Langes Timeout + Für zeitintensive Vorgänge wie KI-Importe (%1$d Sekunden) + Auf Standard zurücksetzen + Alle Netzwerkeinstellungen auf die Standardwerte zurücksetzen + + Timeout auswählen Hey! Schau dir dieses Rezept in Tandoor an: Teile dieses Rezept von Tandoor Wenn %1$s vor der geteilten URL steht, können andere kitshn-Nutzer den Link direkt in der App öffnen. diff --git a/composeApp/src/commonMain/composeResources/values-fr/strings.xml b/composeApp/src/commonMain/composeResources/values-fr/strings.xml index 6b382d90..9035b7aa 100644 --- a/composeApp/src/commonMain/composeResources/values-fr/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-fr/strings.xml @@ -163,6 +163,7 @@ Nom d'utilisateur Site web Bienvenue + Défaut Aucune connexion n'a pu être établie avec votre serveur Tandoor. Demande erronée Ingrédients non attribués @@ -420,4 +421,12 @@ Empêcher l'écran de se mettre en veille pendant l’affichage de la recette, tout comme dans le Mode cuisine Gardez l’affichage de la recette actif Liste de courses - + Avancé + Délais d'attente et autres paramètres réseau + Délai d'attente court + Utilisé pour les requêtes API classiques (%1$d secondes) + Délai d'attente prolongé + Utilisé pour les opérations longues comme l'importation IA (%1$d secondes) + Réinitialiser par défaut + Rétablir toutes les valeurs par défaut des paramètres réseau + Sélectionner le délai diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 59dec480..19e64273 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -214,6 +214,7 @@ Website Welcome Yesterday + Default Error No connection to your Tandoor server could be established. @@ -420,6 +421,15 @@ Delete and manage data Information about your instance Server + Advanced + Timeout and other network settings + Short timeout + Used for regular API requests (%1$d seconds) + Long timeout + Used for long-running operations like AI imports (%1$d seconds) + Restore default settings + Set all network settings back to their default values + Select timeout Hey! Check out this recipe I've found on Tandoor: Share this recipe on Tandoor diff --git a/composeApp/src/commonMain/kotlin/de/kitshn/KitshnViewModel.kt b/composeApp/src/commonMain/kotlin/de/kitshn/KitshnViewModel.kt index 3900684e..61425020 100644 --- a/composeApp/src/commonMain/kotlin/de/kitshn/KitshnViewModel.kt +++ b/composeApp/src/commonMain/kotlin/de/kitshn/KitshnViewModel.kt @@ -6,6 +6,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.navigation.NavHostController @@ -23,6 +24,7 @@ import io.ktor.client.plugins.HttpTimeout import io.ktor.http.HttpMethod import io.ktor.http.HttpStatusCode import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.serialization.SerializationException @@ -107,6 +109,14 @@ class KitshnViewModel( if(tandoorClient == null) tandoorClient = TandoorClient(credentials) favorites.init(tandoorClient!!) + viewModelScope.launch { + snapshotFlow { tandoorClient } + .combine(settings.getTandoorTimeoutSettings) { client, timeout -> client to timeout } + .collect { (client, timeout) -> + client?.configureTimeouts(timeout) + } + } + connectivityCheck() try { diff --git a/composeApp/src/commonMain/kotlin/de/kitshn/Settings.kt b/composeApp/src/commonMain/kotlin/de/kitshn/Settings.kt index 73911fc2..71ba9483 100644 --- a/composeApp/src/commonMain/kotlin/de/kitshn/Settings.kt +++ b/composeApp/src/commonMain/kotlin/de/kitshn/Settings.kt @@ -13,6 +13,7 @@ import com.russhwolf.settings.coroutines.getStringFlow import com.russhwolf.settings.coroutines.getStringOrNullFlow import com.russhwolf.settings.observable.makeObservable import de.kitshn.api.tandoor.TandoorCredentials +import de.kitshn.api.tandoor.TandoorTimeoutSettings import de.kitshn.api.tandoor.model.shopping.TandoorShoppingList import de.kitshn.api.tandoor.model.shopping.TandoorSupermarket import kotlinx.coroutines.flow.Flow @@ -57,6 +58,7 @@ const val KEY_SETTINGS_SHOPPING_LISTS = "shopping_lists" const val KEY_SETTINGS_ONBOARDING_COMPLETED = "onboarding_completed" const val KEY_SETTINGS_TANDOOR_CREDENTIALS = "tandoor_credentials" +const val KEY_SETTINGS_TANDOOR_TIMEOUT_SETTINGS = "tandoor_timeout_settings" const val KEY_SETTINGS_IOS_TIMER_SHORTCUT_INSTALLED = "ios_timer_shortcut_installed" @@ -108,6 +110,14 @@ class SettingsViewModel : ViewModel() { fun saveTandoorCredentials(credentials: TandoorCredentials?) = obs.putString(KEY_SETTINGS_TANDOOR_CREDENTIALS, json.encodeToString(credentials)) + // timeouts + val getTandoorTimeoutSettings: Flow = obs.getStringFlow( + KEY_SETTINGS_TANDOOR_TIMEOUT_SETTINGS, "{}" + ).map { json.maybeDecodeFromString(it) ?: TandoorTimeoutSettings() } + + fun setTandoorTimeoutSettings(settings: TandoorTimeoutSettings) = + obs.putString(KEY_SETTINGS_TANDOOR_TIMEOUT_SETTINGS, json.encodeToString(settings)) + // iOS timer shortcut installed val getIosTimerShortcutInstalled: Flow = obs.getBooleanFlow(KEY_SETTINGS_IOS_TIMER_SHORTCUT_INSTALLED, false) diff --git a/composeApp/src/commonMain/kotlin/de/kitshn/api/tandoor/TandoorClient.kt b/composeApp/src/commonMain/kotlin/de/kitshn/api/tandoor/TandoorClient.kt index 933927a8..5a41a4a9 100644 --- a/composeApp/src/commonMain/kotlin/de/kitshn/api/tandoor/TandoorClient.kt +++ b/composeApp/src/commonMain/kotlin/de/kitshn/api/tandoor/TandoorClient.kt @@ -28,6 +28,12 @@ import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.put +@Serializable +data class TandoorTimeoutSettings( + val shortTimeout: Long = 10000L, + val longTimeout: Long = 60000L +) + @Serializable data class TandoorCredentialsCustomHeader( var field: String, @@ -52,23 +58,45 @@ data class TandoorCredentials( ) class TandoorClient( - val credentials: TandoorCredentials + val credentials: TandoorCredentials, + var timeoutSettings: TandoorTimeoutSettings = TandoorTimeoutSettings() ) { - val httpClient = HttpClient { + var httpClient = createHttpClient(timeoutSettings.shortTimeout) + + var longHttpClient = createLongHttpClient(timeoutSettings.longTimeout) + + private fun createHttpClient(timeout: Long) = HttpClient { followRedirects = true + + install(HttpTimeout) { + connectTimeoutMillis = timeout + requestTimeoutMillis = timeout + socketTimeoutMillis = timeout + } } - val longHttpClient = HttpClient { + private fun createLongHttpClient(timeout: Long) = HttpClient { followRedirects = true install(HttpTimeout) { - connectTimeoutMillis = 60000 - requestTimeoutMillis = 60000 - socketTimeoutMillis = 60000 + connectTimeoutMillis = timeout + requestTimeoutMillis = timeout + socketTimeoutMillis = timeout } } + fun configureTimeouts(settings: TandoorTimeoutSettings) { + if(settings == timeoutSettings) return + timeoutSettings = settings + + httpClient.close() + longHttpClient.close() + + httpClient = createHttpClient(settings.shortTimeout) + longHttpClient = createLongHttpClient(settings.longTimeout) + } + val container = TandoorContainer(this) val media = TandoorMedia(this) @@ -122,5 +150,4 @@ class TandoorClient( return false } } - -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/de/kitshn/ui/dialog/TimeoutSelectionBottomSheet.kt b/composeApp/src/commonMain/kotlin/de/kitshn/ui/dialog/TimeoutSelectionBottomSheet.kt new file mode 100644 index 00000000..53b03c18 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/de/kitshn/ui/dialog/TimeoutSelectionBottomSheet.kt @@ -0,0 +1,121 @@ +package de.kitshn.ui.dialog + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.Timer +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import de.kitshn.ui.component.settings.SettingsListItem +import de.kitshn.ui.component.settings.SettingsListItemPosition +import kitshn.composeapp.generated.resources.Res +import kitshn.composeapp.generated.resources.common_default +import kitshn.composeapp.generated.resources.settings_section_server_advanced_timeout_selection_title +import org.jetbrains.compose.resources.stringResource + +@Composable +fun rememberTimeoutSelectionBottomSheetState(): TimeoutSelectionBottomSheetState { + return remember { + TimeoutSelectionBottomSheetState() + } +} + +class TimeoutSelectionBottomSheetState( + val shown: MutableState = mutableStateOf(false) +) { + var options by mutableStateOf>(listOf()) + var selectedValue by mutableStateOf(0L) + var defaultValue by mutableStateOf(0L) + var onSelect: (Long) -> Unit = {} + + fun open( + options: List, + selectedValue: Long, + defaultValue: Long, + onSelect: (Long) -> Unit + ) { + this.options = options + this.selectedValue = selectedValue + this.defaultValue = defaultValue + this.onSelect = onSelect + this.shown.value = true + } + + fun dismiss() { + this.shown.value = false + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TimeoutSelectionBottomSheet( + state: TimeoutSelectionBottomSheetState +) { + if(!state.shown.value) return + + ModalBottomSheet( + onDismissRequest = { + state.dismiss() + } + ) { + Text( + modifier = Modifier.padding(16.dp), + text = stringResource(Res.string.settings_section_server_advanced_timeout_selection_title), + style = MaterialTheme.typography.titleLarge + ) + + LazyColumn( + modifier = Modifier.padding(bottom = 32.dp) + ) { + items(state.options.size) { index -> + val option = state.options[index] + val position = when { + state.options.size == 1 -> SettingsListItemPosition.SINGULAR + index == 0 -> SettingsListItemPosition.TOP + index == state.options.size - 1 -> SettingsListItemPosition.BOTTOM + else -> SettingsListItemPosition.BETWEEN + } + + SettingsListItem( + position = position, + label = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("${option / 1000} seconds") + if(option == state.defaultValue) { + Text( + modifier = Modifier.padding(start = 8.dp), + text = "(${stringResource(Res.string.common_default)})", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary + ) + } + } + }, + icon = Icons.Rounded.Timer, + trailingContent = { + if(option == state.selectedValue) { + Icon(Icons.Rounded.Check, null) + } + }, + contentDescription = "${option / 1000} seconds" + ) { + state.onSelect(option) + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/de/kitshn/ui/view/settings/SettingsServer.kt b/composeApp/src/commonMain/kotlin/de/kitshn/ui/view/settings/SettingsServer.kt index bb187fbe..70c0abdc 100644 --- a/composeApp/src/commonMain/kotlin/de/kitshn/ui/view/settings/SettingsServer.kt +++ b/composeApp/src/commonMain/kotlin/de/kitshn/ui/view/settings/SettingsServer.kt @@ -1,12 +1,20 @@ package de.kitshn.ui.view.settings +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight import androidx.compose.material.icons.automirrored.rounded.Logout import androidx.compose.material.icons.rounded.AccountCircle import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Numbers +import androidx.compose.material.icons.rounded.Tune import androidx.compose.material.icons.rounded.Web import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button @@ -34,6 +42,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.withLink import androidx.compose.ui.text.withStyle +import de.kitshn.BackHandler import de.kitshn.api.tandoor.TandoorRequestState import de.kitshn.closeAppHandler import de.kitshn.launchWebsiteHandler @@ -50,6 +59,8 @@ import kitshn.composeapp.generated.resources.common_instance_url import kitshn.composeapp.generated.resources.common_manage_space import kitshn.composeapp.generated.resources.common_unknown import kitshn.composeapp.generated.resources.common_version +import kitshn.composeapp.generated.resources.settings_section_server_advanced_description +import kitshn.composeapp.generated.resources.settings_section_server_advanced_label import kitshn.composeapp.generated.resources.settings_section_server_delete_and_manage_data_description import kitshn.composeapp.generated.resources.settings_section_server_delete_and_manage_data_dialog_line_1 import kitshn.composeapp.generated.resources.settings_section_server_delete_and_manage_data_dialog_line_2 @@ -73,6 +84,13 @@ fun ViewSettingsServer( var showVersionCompatibilityBottomSheet by remember { mutableStateOf(false) } var showDataManagementDialog by remember { mutableStateOf(false) } + var showAdvancedSettings by remember { mutableStateOf(false) } + + val navigateBack: (() -> Unit)? = if(showAdvancedSettings) { + { showAdvancedSettings = false } + } else { + p.back + } LaunchedEffect(Unit) { TandoorRequestState().wrapRequest { @@ -81,87 +99,134 @@ fun ViewSettingsServer( } } + BackHandler(showAdvancedSettings) { + showAdvancedSettings = false + } + Scaffold( topBar = { CenterAlignedTopAppBar( - navigationIcon = { BackButton(p.back) }, - title = { Text(stringResource(Res.string.settings_section_server_label)) }, + navigationIcon = { BackButton(navigateBack) }, + title = { + Text( + stringResource( + if(showAdvancedSettings) { + Res.string.settings_section_server_advanced_label + } else { + Res.string.settings_section_server_label + } + ) + ) + }, scrollBehavior = scrollBehavior ) } ) { - LazyColumn( + AnimatedContent( + targetState = showAdvancedSettings, modifier = Modifier .padding(it) - .nestedScroll(scrollBehavior.nestedScrollConnection) + .nestedScroll(scrollBehavior.nestedScrollConnection), + transitionSpec = { + if(targetState) { + slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Left) + fadeIn() togetherWith + slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Left) + fadeOut() + } else { + slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Right) + fadeIn() togetherWith + slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Right) + fadeOut() + }.using(SizeTransform(clip = false)) + }, + label = "server-settings-content" ) { - item { - SettingsListItem( - position = SettingsListItemPosition.TOP, - label = { Text(stringResource(Res.string.common_account)) }, - description = { - Text( - p.vm.uiState.userDisplayName.ifBlank { - stringResource(Res.string.common_unknown) - } + if(it) { + SettingsServerAdvancedContent(p) + } else { + LazyColumn { + item { + SettingsListItem( + position = SettingsListItemPosition.TOP, + label = { Text(stringResource(Res.string.common_account)) }, + description = { + Text( + p.vm.uiState.userDisplayName.ifBlank { + stringResource(Res.string.common_unknown) + } + ) + }, + icon = Icons.Rounded.AccountCircle, + enabled = p.vm.uiState.userDisplayName.isNotBlank(), + contentDescription = stringResource(Res.string.common_account) ) - }, - icon = Icons.Rounded.AccountCircle, - enabled = p.vm.uiState.userDisplayName.isNotBlank(), - contentDescription = stringResource(Res.string.common_account) - ) - - SettingsListItem( - position = SettingsListItemPosition.BETWEEN, - label = { Text(stringResource(Res.string.common_instance_url)) }, - description = { - Text( - p.vm.tandoorClient?.credentials?.instanceUrl - ?: stringResource(Res.string.common_unknown) - ) - }, - icon = Icons.Rounded.Web, - contentDescription = stringResource(Res.string.common_instance_url) - ) - - SettingsListItem( - position = SettingsListItemPosition.BOTTOM, - label = { Text(stringResource(Res.string.common_version)) }, - description = { - Text( - p.vm.tandoorClient?.container?.serverSettings?.version - ?: stringResource(Res.string.common_unknown) + + SettingsListItem( + position = SettingsListItemPosition.BETWEEN, + label = { Text(stringResource(Res.string.common_instance_url)) }, + description = { + Text( + p.vm.tandoorClient?.credentials?.instanceUrl + ?: stringResource(Res.string.common_unknown) + ) + }, + icon = Icons.Rounded.Web, + contentDescription = stringResource(Res.string.common_instance_url) ) - }, - icon = Icons.Rounded.Numbers, - enabled = p.vm.tandoorClient?.container?.serverSettings?.version != null, - contentDescription = stringResource(Res.string.common_version) - ) { - coroutineScope.launch { - showVersionCompatibilityBottomSheet = true - } - } - SettingsListItem( - position = SettingsListItemPosition.SINGULAR, - label = { Text(stringResource(Res.string.action_sign_out)) }, - description = { Text(stringResource(Res.string.action_sign_out_description)) }, - icon = Icons.AutoMirrored.Rounded.Logout, - contentDescription = stringResource(Res.string.action_sign_out) - ) { - p.vm.signOut() - closeAppHandler() - } + SettingsListItem( + position = SettingsListItemPosition.BOTTOM, + label = { Text(stringResource(Res.string.common_version)) }, + description = { + Text( + p.vm.tandoorClient?.container?.serverSettings?.version + ?: stringResource(Res.string.common_unknown) + ) + }, + icon = Icons.Rounded.Numbers, + enabled = p.vm.tandoorClient?.container?.serverSettings?.version != null, + contentDescription = stringResource(Res.string.common_version) + ) { + coroutineScope.launch { + showVersionCompatibilityBottomSheet = true + } + } + + SettingsListItem( + position = SettingsListItemPosition.SINGULAR, + label = { Text(stringResource(Res.string.settings_section_server_advanced_label)) }, + description = { Text(stringResource(Res.string.settings_section_server_advanced_description)) }, + icon = Icons.Rounded.Tune, + trailingContent = { + Icon( + Icons.AutoMirrored.Rounded.KeyboardArrowRight, + null + ) + }, + contentDescription = stringResource(Res.string.settings_section_server_advanced_label) + ) { + showAdvancedSettings = true + } - // needed for iOS because app gets denied (reason: https://developer.apple.com/app-store/review/guidelines/#data-collection-and-storage) - SettingsListItem( - position = SettingsListItemPosition.SINGULAR, - label = { Text(stringResource(Res.string.settings_section_server_delete_and_manage_data_label)) }, - description = { Text(stringResource(Res.string.settings_section_server_delete_and_manage_data_description)) }, - icon = Icons.Rounded.Delete, - contentDescription = stringResource(Res.string.settings_section_server_delete_and_manage_data_description) - ) { - showDataManagementDialog = true + SettingsListItem( + position = SettingsListItemPosition.SINGULAR, + label = { Text(stringResource(Res.string.action_sign_out)) }, + description = { Text(stringResource(Res.string.action_sign_out_description)) }, + icon = Icons.AutoMirrored.Rounded.Logout, + contentDescription = stringResource(Res.string.action_sign_out) + ) { + p.vm.signOut() + closeAppHandler() + } + + // needed for iOS because app gets denied (reason: https://developer.apple.com/app-store/review/guidelines/#data-collection-and-storage) + SettingsListItem( + position = SettingsListItemPosition.SINGULAR, + label = { Text(stringResource(Res.string.settings_section_server_delete_and_manage_data_label)) }, + description = { Text(stringResource(Res.string.settings_section_server_delete_and_manage_data_description)) }, + icon = Icons.Rounded.Delete, + contentDescription = stringResource(Res.string.settings_section_server_delete_and_manage_data_description) + ) { + showDataManagementDialog = true + } + } } } } @@ -226,4 +291,4 @@ fun ViewSettingsServer( } } ) -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/de/kitshn/ui/view/settings/SettingsServerAdvanced.kt b/composeApp/src/commonMain/kotlin/de/kitshn/ui/view/settings/SettingsServerAdvanced.kt new file mode 100644 index 00000000..e5cba333 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/de/kitshn/ui/view/settings/SettingsServerAdvanced.kt @@ -0,0 +1,160 @@ +package de.kitshn.ui.view.settings + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material.icons.rounded.Timer +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.unit.dp +import de.kitshn.api.tandoor.TandoorTimeoutSettings +import de.kitshn.ui.component.buttons.BackButton +import de.kitshn.ui.component.settings.SettingsListItem +import de.kitshn.ui.component.settings.SettingsListItemPosition +import de.kitshn.ui.dialog.TimeoutSelectionBottomSheet +import de.kitshn.ui.dialog.rememberTimeoutSelectionBottomSheetState +import de.kitshn.ui.view.ViewParameters +import kitshn.composeapp.generated.resources.Res +import kitshn.composeapp.generated.resources.settings_section_server_advanced_label +import kitshn.composeapp.generated.resources.settings_section_server_advanced_long_timeout_description +import kitshn.composeapp.generated.resources.settings_section_server_advanced_long_timeout_label +import kitshn.composeapp.generated.resources.settings_section_server_advanced_reset_description +import kitshn.composeapp.generated.resources.settings_section_server_advanced_reset_label +import kitshn.composeapp.generated.resources.settings_section_server_advanced_short_timeout_description +import kitshn.composeapp.generated.resources.settings_section_server_advanced_short_timeout_label +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource + +@Composable +internal fun SettingsServerAdvancedContent( + p: ViewParameters, + modifier: Modifier = Modifier +) { + val coroutineScope = rememberCoroutineScope() + + val timeoutSettingsFlow = p.vm.settings.getTandoorTimeoutSettings.collectAsState(initial = TandoorTimeoutSettings()) + val timeoutSettings = timeoutSettingsFlow.value + + val shortOptions = listOf(2000L, 5000L, 10000L, 20000L, 30000L, 60000L) + val longOptions = listOf(30000L, 60000L, 120000L, 300000L, 600000L) + + val timeoutSelectionBottomSheetState = rememberTimeoutSelectionBottomSheetState() + + LazyColumn( + modifier = modifier + ) { + item { + SettingsListItem( + position = SettingsListItemPosition.TOP, + label = { Text(stringResource(Res.string.settings_section_server_advanced_short_timeout_label)) }, + description = { + Text( + stringResource( + Res.string.settings_section_server_advanced_short_timeout_description, + (timeoutSettings.shortTimeout / 1000).toInt() + ) + ) + }, + icon = Icons.Rounded.Timer, + contentDescription = stringResource(Res.string.settings_section_server_advanced_short_timeout_label) + ) { + timeoutSelectionBottomSheetState.open( + options = shortOptions, + selectedValue = timeoutSettings.shortTimeout, + defaultValue = TandoorTimeoutSettings().shortTimeout, + onSelect = { newValue -> + coroutineScope.launch { + p.vm.settings.setTandoorTimeoutSettings( + timeoutSettings.copy(shortTimeout = newValue) + ) + timeoutSelectionBottomSheetState.dismiss() + } + } + ) + } + + SettingsListItem( + position = SettingsListItemPosition.BOTTOM, + label = { Text(stringResource(Res.string.settings_section_server_advanced_long_timeout_label)) }, + description = { + Text( + stringResource( + Res.string.settings_section_server_advanced_long_timeout_description, + (timeoutSettings.longTimeout / 1000).toInt() + ) + ) + }, + icon = Icons.Rounded.Timer, + contentDescription = stringResource(Res.string.settings_section_server_advanced_long_timeout_label) + ) { + timeoutSelectionBottomSheetState.open( + options = longOptions, + selectedValue = timeoutSettings.longTimeout, + defaultValue = TandoorTimeoutSettings().longTimeout, + onSelect = { newValue -> + coroutineScope.launch { + p.vm.settings.setTandoorTimeoutSettings( + timeoutSettings.copy(longTimeout = newValue) + ) + timeoutSelectionBottomSheetState.dismiss() + } + } + ) + } + } + + item { + SettingsListItem( + modifier = Modifier.padding(top = 16.dp), + position = SettingsListItemPosition.SINGULAR, + label = { Text(stringResource(Res.string.settings_section_server_advanced_reset_label)) }, + description = { Text(stringResource(Res.string.settings_section_server_advanced_reset_description)) }, + icon = Icons.Rounded.Refresh, + contentDescription = stringResource(Res.string.settings_section_server_advanced_reset_label) + ) { + coroutineScope.launch { + p.vm.settings.setTandoorTimeoutSettings(TandoorTimeoutSettings()) + } + } + } + } + + TimeoutSelectionBottomSheet( + state = timeoutSelectionBottomSheetState + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ViewSettingsServerAdvanced( + p: ViewParameters +) { + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(state = rememberTopAppBarState()) + + Scaffold( + topBar = { + CenterAlignedTopAppBar( + navigationIcon = { BackButton(p.back) }, + title = { Text(stringResource(Res.string.settings_section_server_advanced_label)) }, + scrollBehavior = scrollBehavior + ) + } + ) { + SettingsServerAdvancedContent( + p = p, + modifier = Modifier + .padding(it) + .nestedScroll(scrollBehavior.nestedScrollConnection) + ) + } +} diff --git a/composeApp/src/commonTest/kotlin/de/kitshn/api/tandoor/TandoorClientTest.kt b/composeApp/src/commonTest/kotlin/de/kitshn/api/tandoor/TandoorClientTest.kt new file mode 100644 index 00000000..f9d2e500 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/de/kitshn/api/tandoor/TandoorClientTest.kt @@ -0,0 +1,28 @@ +package de.kitshn.api.tandoor + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotSame + +class TandoorClientTest { + + @Test + fun testConfigureTimeouts() { + val credentials = TandoorCredentials("https://example.com") + val client = TandoorClient(credentials) + + val initialShortClient = client.httpClient + val initialLongClient = client.longHttpClient + assertEquals(10000L, client.timeoutSettings.shortTimeout) + assertEquals(60000L, client.timeoutSettings.longTimeout) + + val newSettings = TandoorTimeoutSettings(shortTimeout = 5000L, longTimeout = 120000L) + client.configureTimeouts(newSettings) + + assertEquals(newSettings, client.timeoutSettings) + assertNotSame(initialShortClient, client.httpClient) + assertNotSame(initialLongClient, client.longHttpClient) + assertEquals(5000L, client.timeoutSettings.shortTimeout) + assertEquals(120000L, client.timeoutSettings.longTimeout) + } +} From bbc5ef444ff914582d8fea533857857950ddcd77 Mon Sep 17 00:00:00 2001 From: aimok04 Date: Sat, 18 Apr 2026 17:30:02 +0200 Subject: [PATCH 2/3] fix(ui/TimeoutSelectionBottomSheet): add i18n for SettingsListItem label and contentDescription --- .../composeResources/values-de/strings.xml | 5 +++++ .../commonMain/composeResources/values/strings.xml | 5 +++++ .../kitshn/ui/dialog/TimeoutSelectionBottomSheet.kt | 13 ++++++++++--- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/composeApp/src/commonMain/composeResources/values-de/strings.xml b/composeApp/src/commonMain/composeResources/values-de/strings.xml index c55805fb..1f2500eb 100644 --- a/composeApp/src/commonMain/composeResources/values-de/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-de/strings.xml @@ -144,6 +144,11 @@ eine Minute %1$d Minuten + + no seconds + one second + %1$d seconds + keine Portionen eine Portion diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 19e64273..40af679d 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -164,6 +164,11 @@ one minute %1$d minutes + + no seconds + one second + %1$d seconds + no servings one serving diff --git a/composeApp/src/commonMain/kotlin/de/kitshn/ui/dialog/TimeoutSelectionBottomSheet.kt b/composeApp/src/commonMain/kotlin/de/kitshn/ui/dialog/TimeoutSelectionBottomSheet.kt index 53b03c18..c816937a 100644 --- a/composeApp/src/commonMain/kotlin/de/kitshn/ui/dialog/TimeoutSelectionBottomSheet.kt +++ b/composeApp/src/commonMain/kotlin/de/kitshn/ui/dialog/TimeoutSelectionBottomSheet.kt @@ -24,7 +24,8 @@ import de.kitshn.ui.component.settings.SettingsListItem import de.kitshn.ui.component.settings.SettingsListItemPosition import kitshn.composeapp.generated.resources.Res import kitshn.composeapp.generated.resources.common_default -import kitshn.composeapp.generated.resources.settings_section_server_advanced_timeout_selection_title +import kitshn.composeapp.generated.resources.common_plural_seconds +import org.jetbrains.compose.resources.pluralStringResource import org.jetbrains.compose.resources.stringResource @Composable @@ -90,11 +91,17 @@ fun TimeoutSelectionBottomSheet( else -> SettingsListItemPosition.BETWEEN } + val label = pluralStringResource( + Res.plurals.common_plural_seconds, + (option / 1000).toInt(), + (option / 1000).toInt() + ) + SettingsListItem( position = position, label = { Row(verticalAlignment = Alignment.CenterVertically) { - Text("${option / 1000} seconds") + Text(label) if(option == state.defaultValue) { Text( modifier = Modifier.padding(start = 8.dp), @@ -111,7 +118,7 @@ fun TimeoutSelectionBottomSheet( Icon(Icons.Rounded.Check, null) } }, - contentDescription = "${option / 1000} seconds" + contentDescription = label ) { state.onSelect(option) } From 24ef34719152b653565ddd1f5d18372e430b2724 Mon Sep 17 00:00:00 2001 From: aimok04 Date: Sat, 18 Apr 2026 17:30:40 +0200 Subject: [PATCH 3/3] feat(ui/TimeoutSelectionBottomSheet): remove title and bottom padding --- .../ui/dialog/TimeoutSelectionBottomSheet.kt | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/de/kitshn/ui/dialog/TimeoutSelectionBottomSheet.kt b/composeApp/src/commonMain/kotlin/de/kitshn/ui/dialog/TimeoutSelectionBottomSheet.kt index c816937a..9c1c2958 100644 --- a/composeApp/src/commonMain/kotlin/de/kitshn/ui/dialog/TimeoutSelectionBottomSheet.kt +++ b/composeApp/src/commonMain/kotlin/de/kitshn/ui/dialog/TimeoutSelectionBottomSheet.kt @@ -39,8 +39,8 @@ class TimeoutSelectionBottomSheetState( val shown: MutableState = mutableStateOf(false) ) { var options by mutableStateOf>(listOf()) - var selectedValue by mutableStateOf(0L) - var defaultValue by mutableStateOf(0L) + var selectedValue by mutableStateOf(0L) + var defaultValue by mutableStateOf(0L) var onSelect: (Long) -> Unit = {} fun open( @@ -73,15 +73,7 @@ fun TimeoutSelectionBottomSheet( state.dismiss() } ) { - Text( - modifier = Modifier.padding(16.dp), - text = stringResource(Res.string.settings_section_server_advanced_timeout_selection_title), - style = MaterialTheme.typography.titleLarge - ) - - LazyColumn( - modifier = Modifier.padding(bottom = 32.dp) - ) { + LazyColumn { items(state.options.size) { index -> val option = state.options[index] val position = when {