GeneralSettingsFragment.kt
/*
* This file is part of GNU Taler
* (C) 2026 Taler Systems S.A.
*
* GNU Taler is free software; you can redistribute it and/or modify it under the
* terms of the GNU General Public License as published by the Free Software
* Foundation; either version 3, or (at your option) any later version.
*
* GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with
* GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
package net.taler.merchantpos.config
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.core.os.LocaleListCompat
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import net.taler.merchantpos.BuildConfig
import net.taler.merchantpos.MainActivity
import net.taler.merchantpos.MainViewModel
import net.taler.merchantpos.PosDestination
import net.taler.merchantpos.R
import net.taler.merchantpos.compose.PosOutlinedCard
import net.taler.merchantpos.compose.PosTheme
import net.taler.merchantpos.paymentoptions.PaymentOptionsEntry
import net.taler.merchantpos.paymentoptions.PaymentOptionsSummary
import java.util.Locale
private data class LanguageOption(
val languageTag: String,
val label: String,
)
private data class InitialOrderOption(
val screen: InitialOrderScreen,
val label: String,
)
private val SettingsControlShape: Shape = RoundedCornerShape(14.dp)
private val SettingsActionShape: Shape = RoundedCornerShape(28.dp)
class GeneralSettingsFragment : Fragment()
{
private val viewModel: MainViewModel by activityViewModels()
private val configDraft: ConfigDraftViewModel by activityViewModels()
private val configManager by lazy { viewModel.configManager }
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View
{
val languageOptions = createLanguageOptions()
val initialOrderOptions = createInitialOrderOptions()
val selectedTag = getCurrentLanguageTag()
return ComposeView(requireContext()).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
val config = configManager.config
val username =
if (config is Config.New &&
config.merchantUrl.isNotBlank())
{
config.merchantUrl
.toUri()
.pathSegments
?.lastOrNull { it.isNotBlank() }
}
else
{
null
}
setContent {
val optionsSummary by viewModel.paymentOptions.summary.observeAsState(PaymentOptionsSummary())
GeneralSettingsScreen(
paymentOptionsEntry = {
PaymentOptionsEntry(
summary = optionsSummary,
onOpen = {
(requireActivity() as MainActivity)
.navigateTo(PosDestination.PaymentOptions)
},
)
},
languageOptions = languageOptions,
initialSelectedTag = selectedTag,
onLanguageSelected = ::applyLanguage,
initialOrderOptions = initialOrderOptions,
initialSelectedOrderScreen = configManager.initialOrderScreen,
onInitialOrderSelected = { configManager.initialOrderScreen = it },
onInstanceSettingsClick = {
(requireActivity() as MainActivity).navigateTo(PosDestination.Config)
},
onLogoutClick = {
configDraft.clear()
viewModel.paymentOptions.logout()
configManager.logout()
(requireActivity() as MainActivity).navigateTo(
PosDestination.Config,
clearBackStack = true,
)
},
backendVersion = configManager.backendVersion,
username = username,
merchantUrl = (config as? Config.New)?.merchantUrl?.takeIf { it.isNotBlank() },
)
}
}
}
private fun createInitialOrderOptions(): List<InitialOrderOption> =
listOf(
InitialOrderOption(
screen = InitialOrderScreen.AmountEntry,
label = getString(R.string.menu_amount_entry),
),
InitialOrderOption(
screen = InitialOrderScreen.Inventory,
label = getString(R.string.menu_order),
),
)
private fun createLanguageOptions(): List<LanguageOption>
{
val values = resources.getStringArray(R.array.settings_language_values)
val labels = resources.getStringArray(R.array.settings_language_labels)
return values.indices.map { index ->
val tag = values[index]
val label =
if (index < labels.size &&
labels[index].isNotBlank())
{
labels[index]
}
else if (tag.isBlank())
{
getString(R.string.settings_language_system_default)
}
else
{
val locale = Locale.forLanguageTag(tag)
locale.getDisplayName(Locale.getDefault()).replaceFirstChar { it.titlecase(locale) }
}
LanguageOption(tag, label)
}
}
private fun getCurrentLanguageTag(): String
{
val locales = AppCompatDelegate.getApplicationLocales()
return if (locales.isEmpty) "" else locales[0]?.toLanguageTag().orEmpty()
}
private fun applyLanguage(languageTag: String)
{
val locales =
if (languageTag.isBlank())
{
LocaleListCompat.getEmptyLocaleList()
}
else
{
LocaleListCompat.forLanguageTags(languageTag)
}
AppCompatDelegate.setApplicationLocales(locales)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun GeneralSettingsScreen(
paymentOptionsEntry: @Composable () -> Unit,
languageOptions: List<LanguageOption>,
initialSelectedTag: String,
onLanguageSelected: (String) -> Unit,
initialOrderOptions: List<InitialOrderOption>,
initialSelectedOrderScreen: InitialOrderScreen,
onInitialOrderSelected: (InitialOrderScreen) -> Unit,
onInstanceSettingsClick: () -> Unit,
onLogoutClick: () -> Unit,
backendVersion: String? = null,
username: String? = null,
merchantUrl: String? = null,
initialLogoutConfirmationStep: Int = 0,
)
{
var languageExpanded by remember { mutableStateOf(false) }
var selectedTag by rememberSaveable { mutableStateOf(initialSelectedTag) }
val selectedLabel =
languageOptions.firstOrNull { it.languageTag == selectedTag }?.label
?: languageOptions.firstOrNull()?.label.orEmpty()
var initialOrderExpanded by remember { mutableStateOf(false) }
var selectedInitialOrderScreen by rememberSaveable {
mutableStateOf(initialSelectedOrderScreen)
}
var logoutConfirmationStep by rememberSaveable { mutableIntStateOf(initialLogoutConfirmationStep) }
val selectedInitialOrderLabel =
initialOrderOptions.firstOrNull { it.screen == selectedInitialOrderScreen }?.label
?: initialOrderOptions.firstOrNull()?.label.orEmpty()
PosTheme {
when (logoutConfirmationStep)
{
1 ->
{
AlertDialog(
onDismissRequest = { logoutConfirmationStep = 0 },
title = { Text(stringResource(R.string.settings_logout_button)) },
text = { Text(stringResource(R.string.settings_logout_warning)) },
confirmButton = {
Button(onClick = { logoutConfirmationStep = 2 }) {
Text(stringResource(R.string.payment_back_button))
}
},
dismissButton = {
TextButton(onClick = { logoutConfirmationStep = 0 }) {
Text(stringResource(android.R.string.cancel))
}
},
)
}
2 ->
{
AlertDialog(
onDismissRequest = { logoutConfirmationStep = 0 },
title = { Text(stringResource(R.string.settings_logout_button)) },
text = { Text(stringResource(R.string.settings_logout_confirmation)) },
confirmButton = {
Button(
onClick = {
logoutConfirmationStep = 0
onLogoutClick()
},
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
),
) {
Text(stringResource(R.string.settings_logout_button))
}
},
dismissButton = {
TextButton(onClick = { logoutConfirmationStep = 0 }) {
Text(stringResource(android.R.string.cancel))
}
},
)
}
}
BoxWithConstraints(Modifier.fillMaxSize()) {
val horizontalInset = maxOf(20.dp, (maxWidth - 920.dp) / 2)
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = horizontalInset, vertical = 20.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
item {
SettingsCard(
title = stringResource(R.string.settings_app_title),
) {
SettingsDropdown(
expanded = languageExpanded,
onExpandedChange = { languageExpanded = it },
value = selectedLabel,
label = stringResource(R.string.settings_language_hint),
options = languageOptions,
optionLabel = { it.label },
onOptionSelected = { option ->
selectedTag = option.languageTag
languageExpanded = false
onLanguageSelected(option.languageTag)
},
)
SettingsDropdown(
expanded = initialOrderExpanded,
onExpandedChange = { initialOrderExpanded = it },
value = selectedInitialOrderLabel,
label = stringResource(R.string.settings_initial_order_hint),
options = initialOrderOptions,
optionLabel = { it.label },
onOptionSelected = { option ->
selectedInitialOrderScreen = option.screen
initialOrderExpanded = false
onInitialOrderSelected(option.screen)
},
)
}
}
item {
SettingsCard(
title = stringResource(R.string.payment_options_title),
description = stringResource(R.string.payment_options_description),
) {
paymentOptionsEntry()
}
}
item {
SettingsCard(
title = stringResource(R.string.settings_instance_title),
description = stringResource(R.string.settings_instance_description),
) {
OutlinedButton(
modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp),
onClick = onInstanceSettingsClick,
shape = SettingsActionShape,
) {
Text(stringResource(R.string.settings_manage_connection_action))
}
OutlinedButton(
modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp),
onClick = { logoutConfirmationStep = 1 },
shape = SettingsActionShape,
) {
Text(stringResource(R.string.settings_logout_action))
}
}
}
item {
SettingsCard(
title = stringResource(R.string.settings_about_title),
) {
AboutRow(stringResource(R.string.app_name), BuildConfig.VERSION_NAME)
if (username != null)
{
AboutRow(stringResource(R.string.config_username), username)
}
if (merchantUrl != null)
{
val suffix = if (backendVersion != null) " ($backendVersion)" else ""
AboutRow(stringResource(R.string.config_merchant_url), "$merchantUrl$suffix")
}
}
}
}
}
}
}
/** Production settings UI with fixed, offline fixture values for screenshot rendering. */
@Composable
internal fun GeneralSettingsScreenshotContent(
paymentOptionsEntry: @Composable () -> Unit,
logoutConfirmationStep: Int = 0,
)
{
val displayLocale = LocalConfiguration.current.locales[0] ?: Locale.ENGLISH
val languageOptions =
listOf(
LanguageOption("en", Locale.forLanguageTag("en").getDisplayLanguage(displayLocale)),
LanguageOption("de", Locale.forLanguageTag("de").getDisplayLanguage(displayLocale)),
)
val initialOrderOptions =
listOf(
InitialOrderOption(InitialOrderScreen.AmountEntry, stringResource(R.string.menu_amount_entry)),
InitialOrderOption(InitialOrderScreen.Inventory, stringResource(R.string.menu_order)),
)
GeneralSettingsScreen(
paymentOptionsEntry = paymentOptionsEntry,
languageOptions = languageOptions,
initialSelectedTag = "en",
onLanguageSelected = {},
initialOrderOptions = initialOrderOptions,
initialSelectedOrderScreen = InitialOrderScreen.Inventory,
onInitialOrderSelected = {},
onInstanceSettingsClick = {},
onLogoutClick = {},
username = "sandbox",
merchantUrl = "https://backend.demo.taler.net/instances/sandbox",
initialLogoutConfirmationStep = logoutConfirmationStep,
)
}
@Composable
private fun SettingsCard(
title: String,
description: String? = null,
content: @Composable () -> Unit,
)
{
PosOutlinedCard(
contentPadding = PaddingValues(16.dp),
) { contentPadding ->
Column(
modifier = Modifier.padding(contentPadding),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(text = title, style = MaterialTheme.typography.titleMedium)
if (description != null)
{
Text(
text = description,
style = MaterialTheme.typography.bodyMedium,
)
}
content()
}
}
}
@Composable
private fun AboutRow(
label: String,
value: String,
)
{
Text(
text =
buildAnnotatedString {
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
append("$label: ")
}
append(value)
},
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun <T> SettingsDropdown(
expanded: Boolean,
onExpandedChange: (Boolean) -> Unit,
value: String,
label: String,
options: List<T>,
optionLabel: (T) -> String,
onOptionSelected: (T) -> Unit,
)
{
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { onExpandedChange(!expanded) },
) {
OutlinedTextField(
modifier =
Modifier
.fillMaxWidth()
.menuAnchor(
type = ExposedDropdownMenuAnchorType.PrimaryNotEditable,
enabled = true,
),
value = value,
onValueChange = {},
readOnly = true,
singleLine = true,
label = { Text(label) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) },
shape = SettingsControlShape,
colors =
OutlinedTextFieldDefaults.colors(
focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow,
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow,
disabledContainerColor = MaterialTheme.colorScheme.surfaceContainerLow,
focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant,
focusedLabelColor = MaterialTheme.colorScheme.primary,
unfocusedLabelColor = MaterialTheme.colorScheme.onSurfaceVariant,
focusedTextColor = MaterialTheme.colorScheme.onSurface,
unfocusedTextColor = MaterialTheme.colorScheme.onSurface,
focusedTrailingIconColor = MaterialTheme.colorScheme.primary,
unfocusedTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
),
)
DropdownMenu(
expanded = expanded,
onDismissRequest = { onExpandedChange(false) },
) {
options.forEach { option ->
DropdownMenuItem(
text = { Text(optionLabel(option)) },
onClick = { onOptionSelected(option) },
)
}
}
}
}