commit efa4df9404e829882386ed81dc903cac6d5bd02c parent a4ff2e59ce25af69f90bdaffec7a6ee0bea078a9 Author: Bohdan Potuzhnyi <bohdan.potuzhnyi@gmail.com> Date: Thu, 5 Mar 2026 12:12:06 +0100 [merchant-terminal] small fix of stuff around Diffstat:
17 files changed, 1186 insertions(+), 256 deletions(-)
diff --git a/merchant-terminal/build.gradle b/merchant-terminal/build.gradle @@ -3,6 +3,7 @@ plugins { id 'kotlin-android' id 'kotlinx-serialization' id 'androidx.navigation.safeargs.kotlin' + id "org.jetbrains.kotlin.plugin.compose" version "$kotlin_version" } android { @@ -43,6 +44,7 @@ android { buildFeatures { buildConfig = true viewBinding = true + compose = true } testOptions { @@ -69,24 +71,29 @@ dependencies { implementation "com.google.android.material:material:$material_version" implementation "androidx.constraintlayout:constraintlayout:$constraintlayout_version" - implementation 'androidx.compose.material3:material3:1.4.0' + implementation platform('androidx.compose:compose-bom:2026.02.01') + implementation 'androidx.compose.material3:material3' + implementation 'androidx.compose.ui:ui' + implementation 'androidx.compose.ui:ui-tooling-preview' implementation "androidx.recyclerview:recyclerview:1.4.0" implementation "androidx.recyclerview:recyclerview-selection:1.2.0" // CameraX - implementation "androidx.camera:camera-camera2:1.5.1" - implementation "androidx.camera:camera-lifecycle:1.5.1" - implementation "androidx.camera:camera-view:1.5.1" + implementation "androidx.camera:camera-camera2:1.5.3" + implementation "androidx.camera:camera-lifecycle:1.5.3" + implementation "androidx.camera:camera-view:1.5.3" // ZXING core – on-device barcode/QR detector - implementation "com.google.zxing:core:3.5.3" + implementation "com.google.zxing:core:3.5.4" // Navigation implementation "androidx.navigation:navigation-fragment-ktx:$nav_version" implementation "androidx.navigation:navigation-ui-ktx:$nav_version" - implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0" + implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.2.0" testImplementation 'androidx.test.ext:junit:1.3.0' - testImplementation 'org.robolectric:robolectric:4.16' + testImplementation 'org.robolectric:robolectric:4.16.1' + + debugImplementation 'androidx.compose.ui:ui-tooling' } diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/MainActivity.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/MainActivity.kt @@ -145,9 +145,9 @@ class MainActivity : AppCompatActivity(), OnNavigationItemSelectedListener { val currentDestination = nav.currentDestination?.id if (ui.drawerLayout.isDrawerOpen(START)) { ui.drawerLayout.closeDrawer(START) - } else if (currentDestination == R.id.nav_settings + } else if ((currentDestination == R.id.nav_settings || currentDestination == R.id.nav_instanceSettings) && !model.configManager.config.isValid()) { - // we are in the configuration screen and need a config to continue + // we are in settings and need a valid config to continue val intent = Intent(ACTION_MAIN).apply { addCategory(CATEGORY_HOME) flags = FLAG_ACTIVITY_NEW_TASK diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/amount/AmountEntryFragment.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/amount/AmountEntryFragment.kt @@ -20,8 +20,51 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.ArrayAdapter import android.widget.Toast +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.Row +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.foundation.layout.Spacer +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.Icon +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.OutlinedTextField +import androidx.compose.material3.Text +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.unit.TextUnit import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import net.taler.common.Amount @@ -31,8 +74,8 @@ import net.taler.merchantpos.R import net.taler.merchantpos.amount.AmountEntryFragmentDirections.Companion.actionAmountEntryToProcessPayment import net.taler.merchantpos.amount.AmountEntryFragmentDirections.Companion.actionGlobalConfigFetcher import net.taler.merchantpos.amount.AmountEntryFragmentDirections.Companion.actionGlobalMerchantSettings +import net.taler.merchantpos.compose.PosTheme import net.taler.merchantpos.config.ConfigProduct -import net.taler.merchantpos.databinding.FragmentAmountEntryBinding import net.taler.merchantpos.order.Order private const val QUICK_AMOUNT_ORDER_ID = -1 @@ -43,18 +86,31 @@ class AmountEntryFragment : Fragment() { private val viewModel: MainViewModel by activityViewModels() private val paymentManager by lazy { viewModel.paymentManager } - private lateinit var ui: FragmentAmountEntryBinding - - private var selectedCurrency: String? = null - private var amount: Amount? = null + private var selectedCurrency by mutableStateOf<String?>(null) + private var amount by mutableStateOf<Amount?>(null) override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, ): View { - ui = FragmentAmountEntryBinding.inflate(inflater, container, false) - return ui.root + initializeAmountState() + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + AmountEntryScreen( + amountText = amount?.toString(showSymbol = false) ?: "0.00", + selectedCurrency = selectedCurrency, + currencyOptions = viewModel.configManager.currency?.let(::listOf) ?: emptyList(), + chargeEnabled = amount?.isZero() == false, + onCurrencySelected = ::setCurrency, + onDigitPressed = ::onDigitPressed, + onClearPressed = ::clearAmount, + onBackspacePressed = ::onBackspacePressed, + onChargePressed = ::onChargePressed, + ) + } + } } override fun onStart() { @@ -66,39 +122,13 @@ class AmountEntryFragment : Fragment() { } } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - val configuredCurrency = viewModel.configManager.currency - if (configuredCurrency != null) { - bindCurrency(listOf(configuredCurrency)) - setCurrency(configuredCurrency) + private fun initializeAmountState() { + val configuredCurrency = viewModel.configManager.currency ?: return + if (selectedCurrency == null) { + selectedCurrency = configuredCurrency } - - ui.key0.setOnClickListener { onDigitPressed('0') } - ui.key1.setOnClickListener { onDigitPressed('1') } - ui.key2.setOnClickListener { onDigitPressed('2') } - ui.key3.setOnClickListener { onDigitPressed('3') } - ui.key4.setOnClickListener { onDigitPressed('4') } - ui.key5.setOnClickListener { onDigitPressed('5') } - ui.key6.setOnClickListener { onDigitPressed('6') } - ui.key7.setOnClickListener { onDigitPressed('7') } - ui.key8.setOnClickListener { onDigitPressed('8') } - ui.key9.setOnClickListener { onDigitPressed('9') } - ui.keyClear.setOnClickListener { clearAmount() } - ui.keyBackspace.setOnClickListener { onBackspacePressed() } - - ui.chargeButton.setOnClickListener { onChargePressed() } - - render() - } - - private fun bindCurrency(currencies: List<String>) { - val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_list_item_1, currencies) - ui.currencyView.setAdapter(adapter) - ui.currencyView.setOnItemClickListener { _, _, position, _ -> - val newCurrency = adapter.getItem(position) ?: return@setOnItemClickListener - setCurrency(newCurrency) + if (amount == null) { + amount = Amount.zero(configuredCurrency) } } @@ -110,26 +140,21 @@ class AmountEntryFragment : Fragment() { currentAmount.currency == currency -> currentAmount else -> currentAmount.withCurrency(currency) } - ui.currencyView.setText(currency, false) - render() } private fun onDigitPressed(digit: Char) { val currentAmount = amount ?: return amount = currentAmount.addInputDigit(digit) ?: currentAmount - render() } private fun onBackspacePressed() { val currentAmount = amount ?: return amount = currentAmount.removeInputDigit() ?: currentAmount - render() } private fun clearAmount() { val currency = selectedCurrency ?: return amount = Amount.zero(currency) - render() } private fun onChargePressed() { @@ -168,11 +193,321 @@ class AmountEntryFragment : Fragment() { paymentManager.createPayment(order, includeProducts = false) navigate(actionAmountEntryToProcessPayment()) } +} - private fun render() { - val currentAmount = amount - ui.amountView.text = currentAmount?.toString(showSymbol = false) ?: "0.00" - val enabled = currentAmount != null && !currentAmount.isZero() - ui.chargeButton.isEnabled = enabled +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AmountEntryScreen( + amountText: String, + selectedCurrency: String?, + currencyOptions: List<String>, + chargeEnabled: Boolean, + onCurrencySelected: (String) -> Unit, + onDigitPressed: (Char) -> Unit, + onClearPressed: () -> Unit, + onBackspacePressed: () -> Unit, + onChargePressed: () -> Unit, +) { + PosTheme { + val isTabletLayout = LocalConfiguration.current.smallestScreenWidthDp >= 600 + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + if (!isTabletLayout) { + Row( + modifier = Modifier + .fillMaxSize() + .padding(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + AmountPane( + amountText = amountText, + selectedCurrency = selectedCurrency, + currencyOptions = currencyOptions, + isTabletLayout = false, + onCurrencySelected = onCurrencySelected, + modifier = Modifier + .weight(0.3f) + .padding(8.dp), + ) + KeypadPane( + isTabletLayout = false, + chargeEnabled = chargeEnabled, + onDigitPressed = onDigitPressed, + onClearPressed = onClearPressed, + onBackspacePressed = onBackspacePressed, + onChargePressed = onChargePressed, + modifier = Modifier + .weight(0.7f) + .padding(4.dp), + ) + } + } else { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + AmountPane( + amountText = amountText, + selectedCurrency = selectedCurrency, + currencyOptions = currencyOptions, + isTabletLayout = true, + onCurrencySelected = onCurrencySelected, + modifier = Modifier + .fillMaxWidth() + .weight(0.35f), + ) + Row( + modifier = Modifier + .fillMaxWidth() + .weight(0.65f), + horizontalArrangement = Arrangement.Center, + ) { + KeypadPane( + isTabletLayout = true, + chargeEnabled = chargeEnabled, + onDigitPressed = onDigitPressed, + onClearPressed = onClearPressed, + onBackspacePressed = onBackspacePressed, + onChargePressed = onChargePressed, + modifier = Modifier.fillMaxWidth(0.6f), + ) + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AmountPane( + amountText: String, + selectedCurrency: String?, + currencyOptions: List<String>, + isTabletLayout: Boolean, + onCurrencySelected: (String) -> Unit, + modifier: Modifier = Modifier, +) { + var expanded by remember { mutableStateOf(false) } + + val dropdownComposable: @Composable () -> Unit = { + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { if (currencyOptions.isNotEmpty()) expanded = !expanded }, + modifier = Modifier.wrapContentWidth(Alignment.CenterHorizontally), + ) { + OutlinedTextField( + modifier = Modifier + .menuAnchor( + type = ExposedDropdownMenuAnchorType.PrimaryNotEditable, + enabled = currencyOptions.isNotEmpty(), + ) + .widthIn(min = 96.dp), + value = selectedCurrency.orEmpty(), + onValueChange = {}, + readOnly = true, + singleLine = true, + label = { Text(stringResource(R.string.amount_entry_label)) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + ) + + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + currencyOptions.forEach { currency -> + DropdownMenuItem( + text = { Text(currency) }, + onClick = { + expanded = false + onCurrencySelected(currency) + }, + ) + } + } + } + } + + if (isTabletLayout) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = amountText, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.headlineLarge.copy(fontSize = 56.sp), + maxLines = 1, + ) + Spacer(modifier = Modifier.width(12.dp)) + dropdownComposable() + } + } else { + val phoneAmountFontSize = when (amountText.length) { + in 0..6 -> 56.sp + in 7..8 -> 48.sp + in 9..10 -> 40.sp + in 11..12 -> 32.sp + in 13..14 -> 26.sp + else -> 22.sp + } + + Column( + modifier = modifier, + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = amountText, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.headlineLarge.copy(fontSize = phoneAmountFontSize), + maxLines = 1, + softWrap = false, + ) + + Spacer(modifier = Modifier.height(12.dp)) + dropdownComposable() + } + } +} + +@Composable +private fun KeypadPane( + isTabletLayout: Boolean, + chargeEnabled: Boolean, + onDigitPressed: (Char) -> Unit, + onClearPressed: () -> Unit, + onBackspacePressed: () -> Unit, + onChargePressed: () -> Unit, + modifier: Modifier = Modifier, +) { + val configuration = LocalConfiguration.current + val isCompactPhone = !isTabletLayout && configuration.screenHeightDp <= 720 + val rowSpacing = if (isCompactPhone) 6.dp else 8.dp + val digitFontSize = if (isCompactPhone) 24.sp else 28.sp + + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(rowSpacing), + ) { + val keyContainerColor = colorResource(R.color.amount_entry_key_background) + val keyContentColor = colorResource(R.color.amount_entry_key_text) + val clearLabel = stringResource(R.string.amount_entry_clear) + val clearTextSize = when { + clearLabel.length >= 14 -> if (isCompactPhone) 12.sp else 14.sp + clearLabel.length >= 10 -> if (isCompactPhone) 14.sp else 16.sp + else -> if (isCompactPhone) 16.sp else 20.sp + } + + listOf( + listOf("1", "2", "3"), + listOf("4", "5", "6"), + listOf("7", "8", "9"), + ).forEach { row -> + Row( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + horizontalArrangement = Arrangement.spacedBy(rowSpacing), + ) { + row.forEach { key -> + KeyButton( + text = key, + modifier = Modifier.weight(1f), + containerColor = keyContainerColor, + contentColor = keyContentColor, + fontSize = digitFontSize, + onClick = { onDigitPressed(key.first()) }, + ) + } + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + horizontalArrangement = Arrangement.spacedBy(rowSpacing), + ) { + KeyButton( + text = clearLabel, + modifier = Modifier.weight(1f), + containerColor = keyContainerColor, + contentColor = keyContentColor, + fontSize = clearTextSize, + onClick = onClearPressed, + ) + KeyButton( + text = "0", + modifier = Modifier.weight(1f), + containerColor = keyContainerColor, + contentColor = keyContentColor, + onClick = { onDigitPressed('0') }, + ) + Button( + modifier = Modifier + .weight(1f) + .fillMaxSize(), + onClick = onBackspacePressed, + colors = ButtonDefaults.buttonColors( + containerColor = keyContainerColor, + contentColor = keyContentColor, + ), + contentPadding = PaddingValues(0.dp), + ) { + Icon( + painter = painterResource(R.drawable.ic_backspace), + contentDescription = stringResource(R.string.amount_entry_backspace), + tint = keyContentColor, + ) + } + } + + Button( + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + onClick = onChargePressed, + enabled = chargeEnabled, + colors = ButtonDefaults.buttonColors( + containerColor = colorResource(R.color.colorPrimary), + contentColor = colorResource(R.color.colorOnPrimary), + disabledContainerColor = colorResource(R.color.colorSecondary).copy(alpha = 0.12f), + ), + ) { + Text(stringResource(R.string.amount_entry_create_order_charge)) + } + } +} + +@Composable +private fun KeyButton( + text: String, + modifier: Modifier, + containerColor: Color, + contentColor: Color, + fontSize: TextUnit? = null, + onClick: () -> Unit, +) { + Button( + modifier = modifier.fillMaxSize(), + onClick = onClick, + colors = ButtonDefaults.buttonColors( + containerColor = containerColor, + contentColor = contentColor, + ), + contentPadding = PaddingValues(horizontal = 2.dp, vertical = 0.dp), + ) { + Text( + text = text, + textAlign = TextAlign.Center, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Ellipsis, + style = fontSize?.let { MaterialTheme.typography.headlineMedium.copy(fontSize = it) } + ?: MaterialTheme.typography.headlineMedium, + ) } } diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/compose/PosOutlinedCard.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/compose/PosOutlinedCard.kt @@ -0,0 +1,45 @@ +/* + * 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.compose + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun PosOutlinedCard( + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(16.dp), + content: @Composable (PaddingValues) -> Unit, +) { + Card( + modifier = modifier.fillMaxWidth(), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface, + ), + ) { + content(contentPadding) + } +} diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/compose/PosTheme.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/compose/PosTheme.kt @@ -0,0 +1,139 @@ +/* + * 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.compose + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.material3.Typography +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +private val PosLightColorScheme = lightColorScheme( + primary = Color(0xFF0042B3), + onPrimary = Color(0xFFFFFFFF), + primaryContainer = Color(0xFFD3DEFF), + onPrimaryContainer = Color(0xFF00134A), + inversePrimary = Color(0xFFB4C5FF), + secondary = Color(0xFF586A88), + onSecondary = Color(0xFFFFFFFF), + secondaryContainer = Color(0xFFD9E3F9), + onSecondaryContainer = Color(0xFF111C2B), + tertiary = Color(0xFF338AF0), + onTertiary = Color(0xFFFFFFFF), + tertiaryContainer = Color(0xFFD1E4FF), + onTertiaryContainer = Color(0xFF001C39), + error = Color(0xFFB3261E), + onError = Color(0xFFFFFFFF), + errorContainer = Color(0xFFF9DEDC), + onErrorContainer = Color(0xFF410E0B), + background = Color(0xFFFDFDFF), + onBackground = Color(0xFF1A1C1F), + surface = Color(0xFFFDFDFF), + onSurface = Color(0xFF1C1B1F), + surfaceVariant = Color(0xFFE0E3EC), + onSurfaceVariant = Color(0xFF45474F), + outline = Color(0xFF767880), + outlineVariant = Color(0xFFC4C6D0), + inverseSurface = Color(0xFF2C2F3A), + inverseOnSurface = Color(0xFFF0F2FF), + primaryFixed = Color(0xFFD3DEFF), + onPrimaryFixed = Color(0xFF00134A), + primaryFixedDim = Color(0xFFB4C5FF), + onPrimaryFixedVariant = Color(0xFF00379C), + secondaryFixed = Color(0xFFD9E3F9), + onSecondaryFixed = Color(0xFF111C2B), + secondaryFixedDim = Color(0xFFB0BDD3), + onSecondaryFixedVariant = Color(0xFF445670), + tertiaryFixed = Color(0xFFD1E4FF), + onTertiaryFixed = Color(0xFF001C39), + tertiaryFixedDim = Color(0xFFA2CDFF), + onTertiaryFixedVariant = Color(0xFF0C5EA5), + surfaceDim = Color(0xFFDADDE5), + surfaceBright = Color(0xFFFFFFFF), + surfaceContainerLowest = Color(0xFFFFFFFF), + surfaceContainerLow = Color(0xFFF5F7FC), + surfaceContainer = Color(0xFFF0F2F7), + surfaceContainerHigh = Color(0xFFEAECEF), + surfaceContainerHighest = Color(0xFFE3E6EB), + surfaceTint = Color(0xFF0042B3), + scrim = Color(0xFF000000), +) + +private val PosDarkColorScheme = darkColorScheme( + primary = Color(0xFFB4C5FF), + onPrimary = Color(0xFF002A78), + primaryContainer = Color(0xFF0042B3), + onPrimaryContainer = Color(0xFFE5EBFF), + inversePrimary = Color(0xFF2756C7), + secondary = Color(0xFFA4C9FF), + onSecondary = Color(0xFF00315D), + secondaryContainer = Color(0xFF72A3E5), + onSecondaryContainer = Color(0xFF003869), + tertiary = Color(0xFF8DD1E5), + onTertiary = Color(0xFF003641), + tertiaryContainer = Color(0xFF166577), + onTertiaryContainer = Color(0xFF9CE0F5), + error = Color(0xFFFFB4AA), + onError = Color(0xFF690003), + errorContainer = Color(0xFFB3261E), + onErrorContainer = Color(0xFFFFCBC4), + background = Color(0xFF11131A), + onBackground = Color(0xFFE2E2EB), + surface = Color(0xFF11131A), + onSurface = Color(0xFFE5E2E1), + surfaceVariant = Color(0xFF45474B), + onSurfaceVariant = Color(0xFFC6C6CB), + outline = Color(0xFF8F9095), + outlineVariant = Color(0xFF45474B), + inverseSurface = Color(0xFFE5E2E1), + inverseOnSurface = Color(0xFF313030), + primaryFixed = Color(0xFFDBE1FF), + onPrimaryFixed = Color(0xFF00174B), + primaryFixedDim = Color(0xFFB4C5FF), + onPrimaryFixedVariant = Color(0xFF003EA8), + secondaryFixed = Color(0xFFD4E3FF), + onSecondaryFixed = Color(0xFF001C39), + secondaryFixedDim = Color(0xFFA4C9FF), + onSecondaryFixedVariant = Color(0xFF004883), + tertiaryFixed = Color(0xFFAFECFF), + onTertiaryFixed = Color(0xFF001F27), + tertiaryFixedDim = Color(0xFF8DD1E5), + onTertiaryFixedVariant = Color(0xFF004E5D), + surfaceDim = Color(0xFF11131A), + surfaceBright = Color(0xFF3A3939), + surfaceContainerLowest = Color(0xFF0E0E0E), + surfaceContainerLow = Color(0xFF1C1B1B), + surfaceContainer = Color(0xFF201F1F), + surfaceContainerHigh = Color(0xFF2A2A2A), + surfaceContainerHighest = Color(0xFF353434), + surfaceTint = Color(0xFFB4C5FF), + scrim = Color(0xFF000000), +) + +@Composable +fun PosTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit, +) { + MaterialTheme( + colorScheme = if (darkTheme) PosDarkColorScheme else PosLightColorScheme, + typography = Typography(), + content = content, + ) +} diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/config/ConfigFragment.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/config/ConfigFragment.kt @@ -46,6 +46,7 @@ import androidx.core.net.toUri import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.findNavController import com.google.android.material.button.MaterialButtonToggleGroup import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.textfield.TextInputEditText @@ -56,10 +57,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext -import net.taler.common.navigate import net.taler.merchantpos.MainViewModel import net.taler.merchantpos.R -import net.taler.merchantpos.config.ConfigFragmentDirections.Companion.actionSettingsToOrder import net.taler.merchantpos.databinding.FragmentMerchantConfigBinding import androidx.core.view.isVisible import com.google.zxing.* @@ -295,7 +294,7 @@ class ConfigFragment : Fragment() { onResultReceived() updateView() Snackbar.make(requireView(), getString(R.string.config_changed, currency), LENGTH_LONG).show() - navigate(actionSettingsToOrder()) + findNavController().navigate(R.id.action_instanceSettings_to_order) } private fun onError(msg: String) { diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/config/GeneralSettingsFragment.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/config/GeneralSettingsFragment.kt @@ -0,0 +1,233 @@ +/* + * 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.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.ExposedDropdownMenuAnchorType +import androidx.compose.material3.DropdownMenu +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.core.os.LocaleListCompat +import androidx.fragment.app.Fragment +import androidx.navigation.fragment.findNavController +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import net.taler.merchantpos.compose.PosOutlinedCard +import net.taler.merchantpos.compose.PosTheme +import net.taler.merchantpos.R +import java.util.Locale + +private data class LanguageOption( + val languageTag: String, + val label: String, +) + +class GeneralSettingsFragment : Fragment() { + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + val options = createLanguageOptions() + val selectedTag = getCurrentLanguageTag() + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + GeneralSettingsScreen( + options = options, + initialSelectedTag = selectedTag, + onLanguageSelected = ::applyLanguage, + onInstanceSettingsClick = { + findNavController().navigate(R.id.action_settings_to_instanceSettings) + }, + ) + } + } + } + + 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( + options: List<LanguageOption>, + initialSelectedTag: String, + onLanguageSelected: (String) -> Unit, + onInstanceSettingsClick: () -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + var selectedTag by rememberSaveable { mutableStateOf(initialSelectedTag) } + val selectedLabel = options.firstOrNull { it.languageTag == selectedTag }?.label + ?: options.firstOrNull()?.label.orEmpty() + + PosTheme { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + SettingsCard( + title = stringResource(R.string.settings_language_label), + description = stringResource(R.string.settings_language_description), + ) { + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = !expanded }, + ) { + OutlinedTextField( + modifier = Modifier + .fillMaxWidth() + .menuAnchor( + type = ExposedDropdownMenuAnchorType.PrimaryNotEditable, + enabled = true, + ), + value = selectedLabel, + onValueChange = {}, + readOnly = true, + singleLine = true, + label = { Text(stringResource(R.string.settings_language_hint)) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) }, + ) + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + options.forEach { option -> + DropdownMenuItem( + text = { Text(option.label) }, + onClick = { + selectedTag = option.languageTag + expanded = false + onLanguageSelected(option.languageTag) + }, + ) + } + } + } + } + + SettingsCard( + title = stringResource(R.string.settings_instance_title), + description = stringResource(R.string.settings_instance_description), + ) { + OutlinedButton( + modifier = Modifier.fillMaxWidth(), + onClick = onInstanceSettingsClick, + ) { + Icon( + painter = painterResource(R.drawable.ic_menu_manage), + contentDescription = null, + ) + Spacer(Modifier.width(8.dp)) + Text( + text = stringResource(R.string.settings_instance_button), + ) + } + } + } + } +} + +@Composable +private fun SettingsCard( + title: String, + description: String, + 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.titleSmall, + ) + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + ) + content() + } + } +} diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/payment/AnimatedQrBorderView.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/payment/AnimatedQrBorderView.kt @@ -1,127 +0,0 @@ -package net.taler.merchantpos.payment - -import android.animation.ValueAnimator -import android.content.Context -import android.graphics.Canvas -import android.graphics.Matrix -import android.graphics.Paint -import android.graphics.RectF -import android.graphics.SweepGradient -import android.util.AttributeSet -import android.view.View -import android.view.animation.LinearInterpolator -import androidx.core.content.ContextCompat -import androidx.core.graphics.ColorUtils -import net.taler.merchantpos.R -import androidx.core.view.isVisible - -/** - * Draws a rounded border around the QR code with two animated gradient lines. - */ -class AnimatedQrBorderView @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0, -) : View(context, attrs, defStyleAttr) { - - private val strokeWidthPx = 6f * resources.displayMetrics.density - private val cornerRadiusPx = 20f * resources.displayMetrics.density - private val strokePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - style = Paint.Style.STROKE - strokeWidth = strokeWidthPx - } - private val borderRect = RectF() - private val gradientMatrix = Matrix() - - private var gradient: SweepGradient? = null - private var rotationAngle = 0f - - private val animator = ValueAnimator.ofFloat(0f, 360f).apply { - duration = 5250L - repeatCount = ValueAnimator.INFINITE - interpolator = LinearInterpolator() - addUpdateListener { - rotationAngle = it.animatedValue as Float - invalidate() - } - } - - override fun onAttachedToWindow() { - super.onAttachedToWindow() - if (isVisible && !animator.isStarted) animator.start() - } - - override fun onDetachedFromWindow() { - animator.cancel() - super.onDetachedFromWindow() - } - - override fun onVisibilityChanged(changedView: View, visibility: Int) { - super.onVisibilityChanged(changedView, visibility) - if (visibility == VISIBLE) { - if (!animator.isStarted) animator.start() - } else { - animator.cancel() - } - } - - override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { - super.onSizeChanged(w, h, oldw, oldh) - val halfStroke = strokeWidthPx / 2f - borderRect.set( - halfStroke, - halfStroke, - w.toFloat() - halfStroke, - h.toFloat() - halfStroke, - ) - gradient = createGradient(w / 2f, h / 2f) - strokePaint.shader = gradient - } - - override fun onDraw(canvas: Canvas) { - super.onDraw(canvas) - val shader = gradient ?: return - gradientMatrix.reset() - gradientMatrix.setRotate(rotationAngle, width / 2f, height / 2f) - shader.setLocalMatrix(gradientMatrix) - canvas.drawRoundRect(borderRect, cornerRadiusPx, cornerRadiusPx, strokePaint) - } - - private fun createGradient(cx: Float, cy: Float): SweepGradient { - val accent = ContextCompat.getColor(context, R.color.colorPrimary) - val background = ContextCompat.getColor(context, R.color.colorSurface) - val softAccent = ColorUtils.blendARGB(background, accent, 0.55f) - return SweepGradient( - cx, - cy, - intArrayOf( - background, - background, - softAccent, - accent, - softAccent, - background, - background, - softAccent, - accent, - softAccent, - background, - background, - ), - floatArrayOf( - 0.00f, - 0.05f, - 0.09f, - 0.12f, - 0.16f, - 0.21f, - 0.50f, - 0.55f, - 0.59f, - 0.62f, - 0.66f, - 1.00f, - ), - ) - } -} diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/payment/ProcessPaymentFragment.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/payment/ProcessPaymentFragment.kt @@ -17,18 +17,15 @@ package net.taler.merchantpos.payment import android.graphics.Bitmap -import android.graphics.Bitmap.Config.ARGB_8888 -import android.graphics.Canvas -import android.graphics.Paint -import android.graphics.Rect -import android.graphics.RectF -import android.graphics.drawable.BitmapDrawable -import android.graphics.drawable.Drawable import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.activity.OnBackPressedCallback +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.navigation.NavOptions @@ -43,12 +40,12 @@ import net.taler.common.fadeIn import net.taler.common.fadeOut import net.taler.common.shareText import net.taler.common.showError +import net.taler.lib.android.AnimatedQrCodeComposable import net.taler.lib.android.TalerNfcService.Companion.hasNfc import net.taler.merchantpos.MainViewModel import net.taler.merchantpos.R +import net.taler.merchantpos.compose.PosTheme import net.taler.merchantpos.databinding.FragmentProcessPaymentBinding -import androidx.core.graphics.createBitmap -import net.taler.common.QrCodeManager class ProcessPaymentFragment : Fragment() { @@ -58,6 +55,7 @@ class ProcessPaymentFragment : Fragment() { private lateinit var ui: FragmentProcessPaymentBinding private lateinit var qrPreviewBackCallback: OnBackPressedCallback private var currentPayUri: String? = null + private var currentQrBitmap: Bitmap? = null private var deviceHasNfc: Boolean = false override fun onCreateView( @@ -182,7 +180,7 @@ class ProcessPaymentFragment : Fragment() { } private fun showQrPreview() { - val qrBitmap = (ui.qrcodeView.drawable as? BitmapDrawable)?.bitmap ?: return + val qrBitmap = currentQrBitmap ?: return ui.qrPreviewImage.setImageBitmap(qrBitmap) ui.qrPreviewOverlay.visibility = View.VISIBLE qrPreviewBackCallback.isEnabled = true @@ -197,8 +195,24 @@ class ProcessPaymentFragment : Fragment() { private fun renderPaymentQrCode(text: String, onRendered: (() -> Unit)? = null) { ui.qrcodeView.post { - val qrSize = minOf(ui.qrcodeView.width, ui.qrcodeView.height).coerceAtLeast(256) - ui.qrcodeView.setImageBitmap(makePaymentQrCode(text, qrSize)) + val blockSize = minOf(ui.qrcodeView.width, ui.qrcodeView.height).coerceAtLeast(256) + val qrSize = (blockSize * 0.88f).toInt().coerceAtLeast(256) + currentQrBitmap = makePaymentQrCode(text, qrSize) + + val density = resources.displayMetrics.density + val widthDp = ui.qrcodeView.width / density + val heightDp = ui.qrcodeView.height / density + ui.qrcodeView.setContent { + PosTheme { + AnimatedQrCodeComposable( + width = widthDp.dp, + height = heightDp.dp, + link = text, + logoPainter = painterResource(R.drawable.ic_taler_logo_qr), + modifier = Modifier.fillMaxSize(), + ) + } + } onRendered?.invoke() } } @@ -207,13 +221,12 @@ class ProcessPaymentFragment : Fragment() { return makeQrCode( text = text, size = size, - margin = 1, + margin = 0, errorCorrection = ErrorCorrectionLevel.H, - centerLogo = ContextCompat.getDrawable( - requireContext(), - R.drawable.ic_taler_logo_qr, - ), + centerLogo = null, drawBackground = true, + lightColor = ContextCompat.getColor(requireContext(), R.color.colorSurfaceVariant), + trimQuietZone = true, ) } diff --git a/merchant-terminal/src/main/res/layout/fragment_process_payment.xml b/merchant-terminal/src/main/res/layout/fragment_process_payment.xml @@ -44,18 +44,12 @@ app:layout_constraintTop_toTopOf="parent" app:layout_constraintBottom_toTopOf="@id/shareButton"> - <ImageView + <androidx.compose.ui.platform.ComposeView android:id="@+id/qrcodeView" android:layout_width="match_parent" android:layout_height="match_parent" android:layout_margin="6dp" - tools:ignore="ContentDescription" - tools:src="@tools:sample/avatars" /> - - <net.taler.merchantpos.payment.AnimatedQrBorderView - android:id="@+id/qrAnimatedBorder" - android:layout_width="match_parent" - android:layout_height="match_parent" /> + tools:ignore="ContentDescription" /> </FrameLayout> diff --git a/merchant-terminal/src/main/res/navigation/nav_graph.xml b/merchant-terminal/src/main/res/navigation/nav_graph.xml @@ -91,11 +91,20 @@ <fragment android:id="@+id/nav_settings" + android:name="net.taler.merchantpos.config.GeneralSettingsFragment" + android:label="@string/menu_settings"> + <action + android:id="@+id/action_settings_to_instanceSettings" + app:destination="@+id/nav_instanceSettings" /> + </fragment> + + <fragment + android:id="@+id/nav_instanceSettings" android:name="net.taler.merchantpos.config.ConfigFragment" android:label="@string/config_label" tools:layout="@layout/fragment_merchant_config"> <action - android:id="@+id/action_settings_to_order" + android:id="@+id/action_instanceSettings_to_order" app:destination="@+id/nav_order" app:launchSingleTop="true" app:popUpTo="@+id/nav_graph" @@ -109,7 +118,7 @@ tools:layout="@layout/fragment_config_fetcher"> <action android:id="@+id/action_configFetcher_to_merchantSettings" - app:destination="@+id/nav_settings" + app:destination="@+id/nav_instanceSettings" app:launchSingleTop="true" app:popUpTo="@+id/nav_graph" app:popUpToInclusive="true" /> diff --git a/merchant-terminal/src/main/res/values/colors.xml b/merchant-terminal/src/main/res/values/colors.xml @@ -12,10 +12,10 @@ <color name="colorOnPrimary">#ffffff</color> <color name="colorPrimaryContainer">#d3deff</color> <color name="colorOnPrimaryContainer">#00134a</color> -<color name="colorInversePrimary">#afc6ff</color> +<color name="colorInversePrimary">#B4C5FF</color> <color name="colorOnSecondary">#ffffff</color> -<color name="colorSecondaryContainer">#d8e3f9</color> +<color name="colorSecondaryContainer">#D9E3F9</color> <color name="colorOnSecondaryContainer">#111c2b</color> <color name="colorOnTertiary">#ffffff</color> @@ -37,8 +37,8 @@ <color name="colorOnSurfaceVariant">#45474f</color> <color name="colorSurfaceVariant">#e0e3ec</color> -<color name="colorInverseOnSurface">#f4eff4</color> -<color name="colorInverseSurface">#313033</color> +<color name="colorInverseOnSurface">#F0F2FF</color> +<color name="colorInverseSurface">#2C2F3A</color> <color name="colorSurfaceTint">#0042b3</color> diff --git a/merchant-terminal/src/main/res/values/strings.xml b/merchant-terminal/src/main/res/values/strings.xml @@ -35,6 +35,14 @@ <string name="amount_entry_error_wrong_currency">Unsupported currency</string> <string name="config_label">Merchant settings</string> + <string name="settings_language_label">Language</string> + <string name="settings_language_hint">App language</string> + <string name="settings_language_system_default">System default</string> + <string name="settings_subtitle">Choose the app language and manage merchant instance credentials.</string> + <string name="settings_language_description">Applied immediately to the app interface.</string> + <string name="settings_instance_title">Saved instance</string> + <string name="settings_instance_description">Change merchant URL, username, token, and other instance-specific settings.</string> + <string name="settings_instance_button">Modify saved instance</string> <string name="config_old_label">JSON file (old)</string> <string name="config_setup_password">Password</string> <string name="config_old_deprecation">This configuration method is deprecated, please use the new merchant API configuration.</string> @@ -118,4 +126,34 @@ <string name="pick_time">Pick time</string> <string name="session_expired_toast">Session expired – please re-enter your credentials</string> <string name="config_fragment_camera_needed_text">Camera permission is required for QR scanning</string> + + <string-array name="settings_language_values"> + <item></item> + <item>en</item> + <item>de</item> + <item>es</item> + <item>fi</item> + <item>fr</item> + <item>it</item> + <item>ru</item> + <item>sv</item> + <item>tr</item> + <item>uk</item> + <item>he</item> + </string-array> + + <string-array name="settings_language_labels"> + <item>@string/settings_language_system_default</item> + <item>English</item> + <item>Deutsch</item> + <item>Español</item> + <item>Suomi</item> + <item>Français</item> + <item>Italiano</item> + <item>Русский</item> + <item>Svenska</item> + <item>Türkçe</item> + <item>Українська</item> + <item>עברית</item> + </string-array> </resources> diff --git a/taler-kotlin-android/build.gradle b/taler-kotlin-android/build.gradle @@ -18,6 +18,7 @@ plugins { id 'com.android.library' id 'kotlin-android' id 'kotlinx-serialization' + id "org.jetbrains.kotlin.plugin.compose" version "$kotlin_version" } android { @@ -43,6 +44,7 @@ android { buildFeatures { viewBinding = true + compose = true } compileOptions { sourceCompatibility = JavaVersion.VERSION_17 @@ -59,6 +61,11 @@ android { } dependencies { + implementation platform('androidx.compose:compose-bom:2026.02.01') + implementation 'androidx.compose.material3:material3' + implementation 'androidx.compose.ui:ui' + implementation 'androidx.compose.foundation:foundation' + implementation 'androidx.appcompat:appcompat:1.7.1' implementation 'androidx.core:core-ktx:1.17.0' implementation "androidx.constraintlayout:constraintlayout:$constraintlayout_version" diff --git a/taler-kotlin-android/src/main/java/net/taler/common/QrCodeManager.kt b/taler-kotlin-android/src/main/java/net/taler/common/QrCodeManager.kt @@ -20,6 +20,7 @@ import android.graphics.Bitmap import android.graphics.Bitmap.Config.ARGB_8888 import android.graphics.Bitmap.Config.RGB_565 import android.graphics.Canvas +import android.graphics.Color import android.graphics.Color.BLACK import android.graphics.Color.WHITE import android.graphics.Paint @@ -51,6 +52,9 @@ object QrCodeManager { centerLogo: Drawable? = null, centerLogoSize: QrLogoSize = QrLogoSize.MEDIUM, drawBackground: Boolean = false, + darkColor: Int = BLACK, + lightColor: Int = WHITE, + trimQuietZone: Boolean = false, ): Bitmap { val qrCodeWriter = QRCodeWriter() val hints = mapOf( @@ -63,22 +67,51 @@ object QrCodeManager { val bmp = createBitmap(width, height, RGB_565) for (x in 0 until width) { for (y in 0 until height) { - bmp[x, y] = if (bitMatrix.get(x, y)) BLACK else WHITE + bmp[x, y] = if (bitMatrix.get(x, y)) darkColor else lightColor } } + val qrBitmap = if (trimQuietZone) trimQrQuietZone(bmp, lightColor) else bmp + return if (centerLogo != null) { - addCenteredLogo(bmp, centerLogo, centerLogoSize, drawBackground) + addCenteredLogo(qrBitmap, centerLogo, centerLogoSize, drawBackground, lightColor) } else { - bmp + qrBitmap } } + private fun trimQrQuietZone(bitmap: Bitmap, lightColor: Int): Bitmap { + val width = bitmap.width + val height = bitmap.height + var minX = width + var minY = height + var maxX = -1 + var maxY = -1 + + for (y in 0 until height) { + for (x in 0 until width) { + if (bitmap.getPixel(x, y) != lightColor) { + if (x < minX) minX = x + if (x > maxX) maxX = x + if (y < minY) minY = y + if (y > maxY) maxY = y + } + } + } + + if (maxX < minX || maxY < minY) return bitmap + val croppedWidth = maxX - minX + 1 + val croppedHeight = maxY - minY + 1 + if (croppedWidth == width && croppedHeight == height) return bitmap + return Bitmap.createBitmap(bitmap, minX, minY, croppedWidth, croppedHeight) + } + private fun addCenteredLogo( qrBitmap: Bitmap, logoDrawable: Drawable, logoSize: QrLogoSize = QrLogoSize.MEDIUM, drawBackground: Boolean = false, + logoBackgroundColor: Int = WHITE, ): Bitmap { val result = qrBitmap.copy(ARGB_8888, true) val canvas = Canvas(result) @@ -119,7 +152,7 @@ object QrCodeManager { val backgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL - color = WHITE + color = logoBackgroundColor } val cornerRadius = halfBackgroundHeight // * 0.8f taler has circle in logo, so it can be fine diff --git a/taler-kotlin-android/src/main/java/net/taler/lib/android/AnimatedQrCodeComposable.kt b/taler-kotlin-android/src/main/java/net/taler/lib/android/AnimatedQrCodeComposable.kt @@ -0,0 +1,193 @@ +/* + * 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.lib.android + +import android.graphics.Matrix +import android.graphics.Paint +import android.graphics.RectF +import android.graphics.SweepGradient +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel +import net.taler.common.QrCodeManager.makeQrCode + +@Composable +fun AnimatedQrCodeComposable( + width: Dp, + height: Dp, + link: String, + logoPainter: Painter? = null, + qrCornerRadiusFraction: Float = 0f, + modifier: Modifier = Modifier, +) { + val blockSize = minOf(width, height) + val cornerRadius = blockSize * 0.08f + val stripeWidth = blockSize * 0.025f + val qrSize = blockSize * 0.88f + val logoWidth = qrSize * 0.30f + val qrCornerRadius = qrSize * qrCornerRadiusFraction.coerceIn(0f, 0.5f) + val qrImageModifier = if (qrCornerRadius > 0.dp) { + Modifier + .size(qrSize) + .background(Color.White) + .clip(RoundedCornerShape(qrCornerRadius)) + } else { + Modifier + .size(qrSize) + .background(Color.White) + } + + val qrSizePx = with(LocalDensity.current) { qrSize.roundToPx().coerceAtLeast(256) } + val qrBitmap = remember(link, qrSizePx) { + makeQrCode( + text = link, + size = qrSizePx, + margin = 0, + errorCorrection = ErrorCorrectionLevel.H, + centerLogo = null, + drawBackground = true, + trimQuietZone = true, + ) + } + + val infinite = rememberInfiniteTransition(label = "qrStripe") + val angle by infinite.animateFloat( + initialValue = 0f, + targetValue = 360f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 5250, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "qrStripeAngle", + ) + + val stripeColor = MaterialTheme.colorScheme.primary + val stripeBase = Color.White //MaterialTheme.colorScheme.surfaceVariant + val stripeSoft = remember(stripeBase, stripeColor) { + lerp(stripeBase, stripeColor, 0.55f) + } + val gradientStops = remember { + floatArrayOf(0.00f, 0.05f, 0.09f, 0.12f, 0.16f, 0.21f, 0.50f, 0.55f, 0.59f, 0.62f, 0.66f, 1.00f) + } + val gradientColors = remember(stripeBase, stripeSoft, stripeColor) { + intArrayOf( + stripeBase.toArgb(), + stripeBase.toArgb(), + stripeSoft.toArgb(), + stripeColor.toArgb(), + stripeSoft.toArgb(), + stripeBase.toArgb(), + stripeBase.toArgb(), + stripeSoft.toArgb(), + stripeColor.toArgb(), + stripeSoft.toArgb(), + stripeBase.toArgb(), + stripeBase.toArgb(), + ) + } + val gradientMatrix = remember { Matrix() } + val stripePaint = remember { + Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE } + } + + Box( + modifier = modifier.requiredSize(width, height), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier.size(blockSize), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(cornerRadius)) + .background(Color.White), //TODO: VLADA design MaterialTheme.colorScheme.surfaceVariant), + ) + + Canvas( + modifier = Modifier.fillMaxSize(), + ) { + val stripePx = stripeWidth.toPx() + val inset = stripePx / 2f + val cornerPx = (cornerRadius.toPx() - inset).coerceAtLeast(0f) + val cx = size.width / 2f + val cy = size.height / 2f + + val shader = SweepGradient(cx, cy, gradientColors, gradientStops) + gradientMatrix.reset() + gradientMatrix.setRotate(angle, cx, cy) + shader.setLocalMatrix(gradientMatrix) + + stripePaint.strokeWidth = stripePx + stripePaint.shader = shader + + drawContext.canvas.nativeCanvas.drawRoundRect( + RectF(inset, inset, size.width - inset, size.height - inset), + cornerPx, + cornerPx, + stripePaint, + ) + } + + Image( + bitmap = qrBitmap.asImageBitmap(), + contentDescription = null, + contentScale = ContentScale.FillBounds, + modifier = qrImageModifier, + ) + + if (logoPainter != null) { + Image( + painter = logoPainter, + contentDescription = null, + modifier = Modifier.width(logoWidth), + ) + } + } + } +} diff --git a/taler-kotlin-android/src/main/res/drawable/ic_taler_logo_qr.xml b/taler-kotlin-android/src/main/res/drawable/ic_taler_logo_qr.xml @@ -1,34 +1,46 @@ -<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ 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/> + --> + <vector xmlns:android="http://schemas.android.com/apk/res/android" - android:width="201dp" - android:height="90dp" - android:viewportWidth="201" - android:viewportHeight="90"> - <path - android:fillColor="#0042B3" - android:fillType="evenOdd" - android:pathData="M86.662,1.121C102.252,1.121,115.791,10.522,122.623,24.323L116.806,24.323C110.487,13.623,99.349,6.518,86.662,6.518C66.972,6.518,51.009,23.63,51.009,44.739C51.009,55.07,54.835,64.443,61.049,71.322C59.706,72.443,58.277,73.45,56.773,74.328C50.071,66.553,45.975,56.16,45.975,44.739C45.975,20.649,64.191,1.121,86.662,1.121ZM122.514,65.376C115.648,79.056,102.169,88.356,86.662,88.356C85.609,88.356,84.566,88.313,83.533,88.229C86.586,86.668,89.447,84.749,92.072,82.522C102.393,80.837,111.258,74.408,116.674,65.376Z" /> - <path - android:fillColor="#0042B3" - android:fillType="evenOdd" - android:pathData="M64.212,1.121C65.265,1.121,66.308,1.164,67.341,1.248C64.289,2.809,61.427,4.729,58.803,6.956C41.68,9.75,28.559,25.602,28.559,44.739C28.559,59.003,35.85,71.441,46.653,78.008C45.06,78.275,43.426,78.415,41.763,78.415C40.523,78.415,39.301,78.335,38.099,78.185C29.191,70.184,23.525,58.172,23.525,44.739C23.525,20.649,41.741,1.121,64.212,1.121ZM69.622,82.522C79.943,80.837,88.808,74.408,94.224,65.375L100.065,65.375C93.198,79.056,79.719,88.356,64.212,88.356C63.16,88.356,62.116,88.313,61.084,88.229C64.136,86.668,66.998,84.749,69.622,82.522ZM94.356,24.323C91.216,19.008,86.888,14.58,81.771,11.47C83.365,11.203,84.998,11.063,86.662,11.063C87.902,11.063,89.124,11.142,90.326,11.292C94.342,14.899,97.699,19.322,100.175,24.323Z" /> - <path - android:fillColor="#0042B3" - android:fillType="evenOdd" - android:pathData="M41.763,1.121C42.827,1.121,43.881,1.166,44.925,1.251C41.879,2.81,39.022,4.726,36.402,6.948C19.255,9.721,6.11,25.583,6.11,44.739C6.11,65.847,22.072,82.959,41.763,82.959C54.362,82.959,65.435,75.952,71.776,65.375L77.615,65.375C70.748,79.056,57.269,88.356,41.763,88.356C19.292,88.356,1.075,68.828,1.075,44.739C1.075,20.649,19.292,1.121,41.763,1.121ZM71.905,24.323C70.593,22.102,69.074,20.036,67.376,18.156C68.719,17.036,70.148,16.028,71.652,15.149C74.025,17.902,76.071,20.984,77.724,24.323Z" /> - <path - android:fillColor="#121212" - android:pathData="M76.135,34.409L85.296,34.409L85.296,29.366L61.858,29.366L61.858,34.409L71.019,34.409L71.019,60.332L76.135,60.332Z" /> - <path - android:fillColor="#121212" - android:pathData="M92.648,52.856L106.307,52.856L109.237,60.332L114.601,60.332L101.891,29.145L97.187,29.145L84.477,60.332L89.677,60.332ZM104.45,48.034L94.505,48.034L99.457,35.648Z" /> - <path - android:fillColor="#121212" - android:pathData="M123.806,29.366L119.226,29.366L119.226,60.332L139.773,60.332L139.773,55.422L123.806,55.422Z" /> - <path - android:fillColor="#121212" - android:pathData="M166.472,29.366L145.097,29.366L145.097,60.332L166.679,60.332L166.679,55.422L150.131,55.422L150.131,47.149L164.615,47.149L164.615,42.239L150.131,42.239L150.131,34.276L166.472,34.276Z" /> - <path - android:fillColor="#121212" - android:pathData="M191.19,39.475C191.19,41.074,190.654,42.35,189.574,43.293C188.501,44.245,187.05,44.716,185.227,44.716L177.779,44.716L177.779,34.276L185.186,34.276C187.091,34.276,188.57,34.711,189.615,35.589C190.668,36.459,191.19,37.756,191.19,39.475ZM197.256,60.332L189.457,48.609C190.475,48.314,191.404,47.894,192.243,47.349C193.082,46.803,193.804,46.139,194.409,45.358C195.014,44.576,195.489,43.677,195.833,42.66C196.177,41.642,196.348,40.484,196.348,39.187C196.348,37.683,196.101,36.319,195.606,35.095C195.111,33.871,194.402,32.839,193.481,31.998C192.559,31.158,191.431,30.509,190.097,30.052C188.763,29.594,187.27,29.366,185.62,29.366L172.744,29.366L172.744,60.332L177.779,60.332L177.779,49.539L184.154,49.539L191.273,60.332Z" /> + xmlns:tools="http://schemas.android.com/tools" + android:width="200dp" + android:height="95dp" + android:viewportWidth="200" + android:viewportHeight="95"> + <group> + <clip-path + android:pathData="M0,0h200v95h-200z" + tools:ignore="VectorRaster" /> + <path + android:pathData="M47.5,0L152.5,0A47.5,47.5 0,0 1,200 47.5L200,47.5A47.5,47.5 0,0 1,152.5 95L47.5,95A47.5,47.5 0,0 1,0 47.5L0,47.5A47.5,47.5 0,0 1,47.5 0z" + android:fillColor="#ffffff"/> + <path + android:pathData="M88.62,8C102.94,8 115.23,16.62 121.65,29.28H116.24C113.46,24.39 109.46,20.3 104.61,17.44C99.76,14.57 94.25,13.02 88.62,12.95C70.55,12.95 55.87,28.64 55.87,48C55.87,57.54 59.36,66.07 65.05,72.4C63.84,73.43 62.55,74.35 61.19,75.16C54.74,67.59 51.22,57.95 51.28,48C51.28,25.98 67.98,8 88.62,8ZM121.47,66.99C118.27,73.65 113.15,79.19 106.78,82.92C100.4,86.64 93.06,88.38 85.69,87.91C88.44,86.53 91.19,84.7 93.58,82.68C98.25,81.86 102.69,80.03 106.59,77.32C110.49,74.62 113.75,71.09 116.15,66.99H121.47Z" + android:fillColor="#0042B3" + android:fillType="evenOdd"/> + <path + android:pathData="M67.98,8L70.83,8.09C68.07,9.56 65.41,11.3 63.03,13.41C55.07,14.99 47.93,19.32 42.86,25.65C37.79,31.98 35.12,39.89 35.32,48C35.32,61.12 42.02,72.5 51.83,78.55C49.26,78.99 46.63,79.05 44.04,78.73C35.78,71.39 30.64,60.39 30.64,48C30.64,25.98 47.34,8 67.98,8ZM72.94,82.68C77.61,81.86 82.05,80.03 85.95,77.32C89.85,74.62 93.11,71.09 95.5,66.99H100.92C97.72,73.65 92.6,79.19 86.22,82.92C79.85,86.64 72.51,88.38 65.14,87.91C67.89,86.53 70.55,84.7 72.94,82.68ZM95.69,29.28C92.86,24.48 88.89,20.45 84.13,17.54C86.71,17.11 89.33,17.04 91.93,17.36C95.6,20.66 98.72,24.7 101.01,29.28H95.69Z" + android:fillColor="#0042B3" + android:fillType="evenOdd"/> + <path + android:pathData="M47.43,8C48.35,8 49.27,8 50.28,8.18C47.52,9.56 44.86,11.3 42.48,13.32C34.49,14.88 27.32,19.22 22.23,25.57C17.14,31.92 14.46,39.87 14.68,48C14.68,67.36 29.36,83.14 47.43,83.14C58.99,83.14 69.08,76.72 74.95,66.99H80.28C77.31,73.22 72.66,78.49 66.86,82.22C61.05,85.94 54.33,87.98 47.43,88.09C26.79,88.09 10,70.11 10,48C10,25.98 26.7,8 47.34,8H47.43ZM75.05,29.28C73.86,27.29 72.48,25.41 70.92,23.69C72.11,22.59 73.39,21.67 74.86,20.94C76.97,23.41 78.9,26.26 80.37,29.28H75.05Z" + android:fillColor="#0042B3" + android:fillType="evenOdd"/> + <path + android:pathData="M78.9,38.55H87.34V33.96H65.87V38.55H74.22V62.4H78.9V38.55ZM94.04,55.52H106.61L109.36,62.31H114.22L102.57,33.69H98.26L86.61,62.31H91.38L94.04,55.52ZM104.86,51.03H95.78L100.37,39.65L104.86,51.03ZM122.66,33.96H118.44V62.4H137.34V57.82H122.66V33.96ZM161.84,33.96H142.11V62.4H161.93V57.82H146.7V50.2H160V45.71H146.7V38.37H161.74L161.84,33.96ZM184.5,43.23C184.5,44.7 184.04,45.8 183.03,46.72C182.02,47.63 180.64,48 178.99,48H172.2V38.46H178.99C180.73,38.46 182.11,38.83 183.03,39.65C183.95,40.48 184.5,41.67 184.5,43.23ZM190,62.31L182.94,51.58C183.85,51.3 184.68,50.94 185.41,50.39C186.96,49.4 188.13,47.91 188.72,46.17C189.08,45.25 189.17,44.15 189.17,42.95C189.17,41.58 188.99,40.29 188.53,39.19C188.11,38.11 187.45,37.14 186.61,36.35C185.69,35.61 184.77,34.97 183.49,34.51C182.29,34.15 180.92,33.96 179.36,33.96H167.52V62.4H172.11V52.31H178.07L184.5,62.22L190,62.31Z" + android:fillColor="#000000"/> + </group> </vector>