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 }