taler-android

Android apps for GNU Taler (wallet, PoS, cashier)
Log | Files | Refs | README | LICENSE

GeneralSettingsFragment.kt (16318B)


      1 /*
      2  * This file is part of GNU Taler
      3  * (C) 2026 Taler Systems S.A.
      4  *
      5  * GNU Taler is free software; you can redistribute it and/or modify it under the
      6  * terms of the GNU General Public License as published by the Free Software
      7  * Foundation; either version 3, or (at your option) any later version.
      8  *
      9  * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
     10  * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
     11  * A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
     12  *
     13  * You should have received a copy of the GNU General Public License along with
     14  * GNU Taler; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
     15  */
     16 
     17 package net.taler.merchantpos.config
     18 
     19 import android.os.Bundle
     20 import android.view.LayoutInflater
     21 import android.view.View
     22 import android.view.ViewGroup
     23 import androidx.appcompat.app.AppCompatDelegate
     24 import androidx.compose.foundation.layout.Arrangement
     25 import androidx.compose.foundation.layout.Column
     26 import androidx.compose.foundation.layout.PaddingValues
     27 import androidx.compose.foundation.layout.Spacer
     28 import androidx.compose.foundation.layout.fillMaxSize
     29 import androidx.compose.foundation.layout.fillMaxWidth
     30 import androidx.compose.foundation.layout.padding
     31 import androidx.compose.foundation.layout.width
     32 import androidx.compose.foundation.lazy.LazyColumn
     33 import androidx.compose.material3.ExperimentalMaterial3Api
     34 import androidx.compose.material3.ExposedDropdownMenuBox
     35 import androidx.compose.material3.ExposedDropdownMenuDefaults
     36 import androidx.compose.material3.DropdownMenuItem
     37 import androidx.compose.material3.Button
     38 import androidx.compose.material3.ButtonDefaults
     39 import androidx.compose.material3.Icon
     40 import androidx.compose.material3.MaterialTheme
     41 import androidx.compose.material3.OutlinedTextField
     42 import androidx.compose.material3.OutlinedTextFieldDefaults
     43 import androidx.compose.material3.Text
     44 import androidx.compose.material3.ExposedDropdownMenuAnchorType
     45 import androidx.compose.material3.DropdownMenu
     46 import androidx.compose.runtime.Composable
     47 import androidx.compose.runtime.getValue
     48 import androidx.compose.runtime.mutableStateOf
     49 import androidx.compose.runtime.remember
     50 import androidx.compose.runtime.saveable.rememberSaveable
     51 import androidx.compose.runtime.setValue
     52 import androidx.core.os.LocaleListCompat
     53 import androidx.fragment.app.Fragment
     54 import androidx.fragment.app.activityViewModels
     55 import androidx.compose.ui.Modifier
     56 import androidx.compose.ui.graphics.Shape
     57 import androidx.compose.ui.text.SpanStyle
     58 import androidx.compose.ui.text.buildAnnotatedString
     59 import androidx.compose.ui.text.font.FontWeight
     60 import androidx.compose.ui.text.withStyle
     61 import androidx.compose.ui.platform.ComposeView
     62 import androidx.compose.ui.platform.ViewCompositionStrategy
     63 import androidx.compose.ui.res.painterResource
     64 import androidx.compose.ui.res.stringResource
     65 import androidx.compose.foundation.shape.RoundedCornerShape
     66 import androidx.compose.ui.unit.dp
     67 import androidx.core.net.toUri
     68 import net.taler.merchantpos.BuildConfig
     69 import net.taler.merchantpos.compose.PosOutlinedCard
     70 import net.taler.merchantpos.compose.PosTheme
     71 import net.taler.merchantpos.R
     72 import net.taler.merchantpos.PosDestination
     73 import java.util.Locale
     74 
     75 private data class LanguageOption(
     76     val languageTag: String,
     77     val label: String,
     78 )
     79 
     80 private data class InitialOrderOption(
     81     val screen: InitialOrderScreen,
     82     val label: String,
     83 )
     84 
     85 private val SettingsControlShape: Shape = RoundedCornerShape(14.dp)
     86 
     87 class GeneralSettingsFragment : Fragment() {
     88 
     89     private val viewModel: net.taler.merchantpos.MainViewModel by activityViewModels()
     90     private val configManager by lazy { viewModel.configManager }
     91 
     92     override fun onCreateView(
     93         inflater: LayoutInflater,
     94         container: ViewGroup?,
     95         savedInstanceState: Bundle?,
     96     ): View {
     97         val languageOptions = createLanguageOptions()
     98         val initialOrderOptions = createInitialOrderOptions()
     99         val selectedTag = getCurrentLanguageTag()
    100         return ComposeView(requireContext()).apply {
    101             setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
    102             val config = configManager.config
    103             val username = if (config is Config.New && config.merchantUrl.isNotBlank()) {
    104                 config.merchantUrl.toUri().pathSegments
    105                     ?.lastOrNull { it.isNotBlank() }
    106             } else null
    107             setContent {
    108                 GeneralSettingsScreen(
    109                     languageOptions = languageOptions,
    110                     initialSelectedTag = selectedTag,
    111                     onLanguageSelected = ::applyLanguage,
    112                     initialOrderOptions = initialOrderOptions,
    113                     initialSelectedOrderScreen = configManager.initialOrderScreen,
    114                     onInitialOrderSelected = { configManager.initialOrderScreen = it },
    115                     onInstanceSettingsClick = {
    116                         (requireActivity() as net.taler.merchantpos.MainActivity).navigateTo(PosDestination.Config)
    117                     },
    118                     onLogoutClick = {
    119                         configManager.logout()
    120                         (requireActivity() as net.taler.merchantpos.MainActivity).navigateTo(
    121                             PosDestination.Config,
    122                             clearBackStack = true,
    123                         )
    124                     },
    125                     backendVersion = configManager.backendVersion,
    126                     username = username,
    127                     merchantUrl = (config as? Config.New)?.merchantUrl?.takeIf { it.isNotBlank() },
    128                 )
    129             }
    130         }
    131     }
    132 
    133     private fun createInitialOrderOptions(): List<InitialOrderOption> {
    134         return listOf(
    135             InitialOrderOption(
    136                 screen = InitialOrderScreen.AmountEntry,
    137                 label = getString(R.string.menu_amount_entry),
    138             ),
    139             InitialOrderOption(
    140                 screen = InitialOrderScreen.Inventory,
    141                 label = getString(R.string.menu_order),
    142             ),
    143         )
    144     }
    145 
    146     private fun createLanguageOptions(): List<LanguageOption> {
    147         val values = resources.getStringArray(R.array.settings_language_values)
    148         val labels = resources.getStringArray(R.array.settings_language_labels)
    149         return values.indices.map { index ->
    150             val tag = values[index]
    151             val label = if (index < labels.size && labels[index].isNotBlank()) {
    152                 labels[index]
    153             } else if (tag.isBlank()) {
    154                 getString(R.string.settings_language_system_default)
    155             } else {
    156                 val locale = Locale.forLanguageTag(tag)
    157                 locale.getDisplayName(Locale.getDefault()).replaceFirstChar { it.titlecase(locale) }
    158             }
    159             LanguageOption(tag, label)
    160         }
    161     }
    162 
    163     private fun getCurrentLanguageTag(): String {
    164         val locales = AppCompatDelegate.getApplicationLocales()
    165         return if (locales.isEmpty) "" else locales[0]?.toLanguageTag().orEmpty()
    166     }
    167 
    168     private fun applyLanguage(languageTag: String) {
    169         val locales = if (languageTag.isBlank()) {
    170             LocaleListCompat.getEmptyLocaleList()
    171         } else {
    172             LocaleListCompat.forLanguageTags(languageTag)
    173         }
    174         AppCompatDelegate.setApplicationLocales(locales)
    175     }
    176 }
    177 
    178 @OptIn(ExperimentalMaterial3Api::class)
    179 @Composable
    180 private fun GeneralSettingsScreen(
    181     languageOptions: List<LanguageOption>,
    182     initialSelectedTag: String,
    183     onLanguageSelected: (String) -> Unit,
    184     initialOrderOptions: List<InitialOrderOption>,
    185     initialSelectedOrderScreen: InitialOrderScreen,
    186     onInitialOrderSelected: (InitialOrderScreen) -> Unit,
    187     onInstanceSettingsClick: () -> Unit,
    188     onLogoutClick: () -> Unit,
    189     backendVersion: String? = null,
    190     username: String? = null,
    191     merchantUrl: String? = null,
    192 ) {
    193     var languageExpanded by remember { mutableStateOf(false) }
    194     var selectedTag by rememberSaveable { mutableStateOf(initialSelectedTag) }
    195     val selectedLabel = languageOptions.firstOrNull { it.languageTag == selectedTag }?.label
    196         ?: languageOptions.firstOrNull()?.label.orEmpty()
    197     var initialOrderExpanded by remember { mutableStateOf(false) }
    198     var selectedInitialOrderScreen by rememberSaveable {
    199         mutableStateOf(initialSelectedOrderScreen)
    200     }
    201     val selectedInitialOrderLabel =
    202         initialOrderOptions.firstOrNull { it.screen == selectedInitialOrderScreen }?.label
    203             ?: initialOrderOptions.firstOrNull()?.label.orEmpty()
    204 
    205     PosTheme {
    206         LazyColumn(
    207             modifier = Modifier
    208                 .fillMaxSize()
    209                 .padding(20.dp),
    210             verticalArrangement = Arrangement.spacedBy(14.dp),
    211         ) {
    212             item {
    213                 SettingsCard(
    214                     title = stringResource(R.string.settings_app_title),
    215                 ) {
    216                     SettingsDropdown(
    217                         expanded = languageExpanded,
    218                         onExpandedChange = { languageExpanded = it },
    219                         value = selectedLabel,
    220                         label = stringResource(R.string.settings_language_hint),
    221                         options = languageOptions,
    222                         optionLabel = { it.label },
    223                         onOptionSelected = { option ->
    224                             selectedTag = option.languageTag
    225                             languageExpanded = false
    226                             onLanguageSelected(option.languageTag)
    227                         },
    228                     )
    229                     SettingsDropdown(
    230                         expanded = initialOrderExpanded,
    231                         onExpandedChange = { initialOrderExpanded = it },
    232                         value = selectedInitialOrderLabel,
    233                         label = stringResource(R.string.settings_initial_order_hint),
    234                         options = initialOrderOptions,
    235                         optionLabel = { it.label },
    236                         onOptionSelected = { option ->
    237                             selectedInitialOrderScreen = option.screen
    238                             initialOrderExpanded = false
    239                             onInitialOrderSelected(option.screen)
    240                         },
    241                     )
    242                 }
    243             }
    244 
    245             item {
    246                 SettingsCard(
    247                     title = stringResource(R.string.settings_instance_title),
    248                     description = stringResource(R.string.settings_instance_description),
    249                 ) {
    250                     Button(
    251                         modifier = Modifier.fillMaxWidth(),
    252                         onClick = onInstanceSettingsClick,
    253                         shape = SettingsControlShape,
    254                         colors = ButtonDefaults.buttonColors(
    255                             containerColor = MaterialTheme.colorScheme.primaryContainer,
    256                             contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
    257                         ),
    258                     ) {
    259                         Icon(
    260                             painter = painterResource(R.drawable.ic_menu_manage),
    261                             contentDescription = null,
    262                         )
    263                         Spacer(Modifier.width(8.dp))
    264                         Text(
    265                             text = stringResource(R.string.settings_instance_button),
    266                         )
    267                     }
    268                     Button(
    269                         modifier = Modifier.fillMaxWidth(),
    270                         onClick = onLogoutClick,
    271                         shape = SettingsControlShape,
    272                         colors = ButtonDefaults.buttonColors(
    273                             containerColor = MaterialTheme.colorScheme.errorContainer,
    274                             contentColor = MaterialTheme.colorScheme.onErrorContainer,
    275                         ),
    276                     ) {
    277                         Text(
    278                             text = stringResource(R.string.settings_logout_button),
    279                         )
    280                     }
    281                 }
    282             }
    283 
    284             item {
    285                 SettingsCard(
    286                     title = stringResource(R.string.settings_about_title),
    287                 ) {
    288                     AboutRow(stringResource(R.string.app_name), BuildConfig.VERSION_NAME)
    289                     if (username != null) {
    290                         AboutRow(stringResource(R.string.config_username), username)
    291                     }
    292                     if (merchantUrl != null) {
    293                         val suffix = if (backendVersion != null) " ($backendVersion)" else ""
    294                         AboutRow(stringResource(R.string.config_merchant_url), "$merchantUrl$suffix")
    295                     }
    296                 }
    297             }
    298         }
    299     }
    300 }
    301 
    302 @Composable
    303 private fun SettingsCard(
    304     title: String,
    305     description: String? = null,
    306     content: @Composable () -> Unit,
    307 ) {
    308     PosOutlinedCard(
    309         contentPadding = PaddingValues(16.dp),
    310     ) { contentPadding ->
    311         Column(
    312             modifier = Modifier.padding(contentPadding),
    313             verticalArrangement = Arrangement.spacedBy(12.dp),
    314         ) {
    315             Text(
    316                 text = title,
    317                 style = MaterialTheme.typography.titleSmall,
    318             )
    319             if (description != null) {
    320                 Text(
    321                     text = description,
    322                     style = MaterialTheme.typography.bodyMedium,
    323                 )
    324             }
    325             content()
    326         }
    327     }
    328 }
    329 
    330 @Composable
    331 private fun AboutRow(label: String, value: String) {
    332     Text(
    333         text = buildAnnotatedString {
    334             withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
    335                 append("$label: ")
    336             }
    337             append(value)
    338         },
    339         style = MaterialTheme.typography.bodyMedium,
    340         color = MaterialTheme.colorScheme.onSurfaceVariant,
    341     )
    342 }
    343 
    344 @OptIn(ExperimentalMaterial3Api::class)
    345 @Composable
    346 private fun <T> SettingsDropdown(
    347     expanded: Boolean,
    348     onExpandedChange: (Boolean) -> Unit,
    349     value: String,
    350     label: String,
    351     options: List<T>,
    352     optionLabel: (T) -> String,
    353     onOptionSelected: (T) -> Unit,
    354 ) {
    355     ExposedDropdownMenuBox(
    356         expanded = expanded,
    357         onExpandedChange = { onExpandedChange(!expanded) },
    358     ) {
    359         OutlinedTextField(
    360             modifier = Modifier
    361                 .fillMaxWidth()
    362                 .menuAnchor(
    363                     type = ExposedDropdownMenuAnchorType.PrimaryNotEditable,
    364                     enabled = true,
    365                 ),
    366             value = value,
    367             onValueChange = {},
    368             readOnly = true,
    369             singleLine = true,
    370             label = { Text(label) },
    371             trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) },
    372             shape = SettingsControlShape,
    373             colors = OutlinedTextFieldDefaults.colors(
    374                 focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow,
    375                 unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow,
    376                 disabledContainerColor = MaterialTheme.colorScheme.surfaceContainerLow,
    377                 focusedBorderColor = MaterialTheme.colorScheme.primary,
    378                 unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant,
    379                 focusedLabelColor = MaterialTheme.colorScheme.primary,
    380                 unfocusedLabelColor = MaterialTheme.colorScheme.onSurfaceVariant,
    381                 focusedTextColor = MaterialTheme.colorScheme.onSurface,
    382                 unfocusedTextColor = MaterialTheme.colorScheme.onSurface,
    383                 focusedTrailingIconColor = MaterialTheme.colorScheme.primary,
    384                 unfocusedTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
    385             ),
    386         )
    387         DropdownMenu(
    388             expanded = expanded,
    389             onDismissRequest = { onExpandedChange(false) },
    390         ) {
    391             options.forEach { option ->
    392                 DropdownMenuItem(
    393                     text = { Text(optionLabel(option)) },
    394                     onClick = { onOptionSelected(option) },
    395                 )
    396             }
    397         }
    398     }
    399 }