commit 0c93bcaecd77facf65cbbf60f4eaf84573d86614 parent c148a79b5ee0151d3ce90881939d2d61cc3a8163 Author: Bohdan Potuzhnyi <bohdan.potuzhnyi@gmail.com> Date: Sat, 16 May 2026 22:59:29 +0200 [merchant-terminal] couple of small ux updates Diffstat:
62 files changed, 1915 insertions(+), 491 deletions(-)
diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/InitialOrderNavigation.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/InitialOrderNavigation.kt @@ -0,0 +1,30 @@ +/* + * 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 + +import androidx.navigation.NavController +import net.taler.merchantpos.config.ConfigManager +import net.taler.merchantpos.config.InitialOrderScreen + +fun NavController.navigateToInitialOrderScreen(configManager: ConfigManager) { + navigate( + when (configManager.initialOrderScreen) { + InitialOrderScreen.AmountEntry -> R.id.action_global_amountEntry + InitialOrderScreen.Inventory -> R.id.action_global_order + } + ) +} diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/MainActivity.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/MainActivity.kt @@ -23,6 +23,7 @@ import android.content.Intent.FLAG_ACTIVITY_NEW_TASK import android.net.Uri import android.os.Bundle import android.os.Handler +import android.util.Log import android.view.MenuItem import android.widget.Toast import android.widget.Toast.LENGTH_SHORT @@ -38,7 +39,6 @@ import net.taler.lib.android.TalerNfcService import net.taler.merchantpos.config.Config import net.taler.merchantpos.config.ConfigUpdateResult import net.taler.merchantpos.databinding.ActivityMainBinding -import android.util.Log class MainActivity : AppCompatActivity(), OnNavigationItemSelectedListener { @@ -95,6 +95,14 @@ class MainActivity : AppCompatActivity(), OnNavigationItemSelectedListener { ) ui.main.toolbar.setupWithNavController(nav, appBarConfiguration) + if (savedInstanceState == null && + intent.action != Intent.ACTION_VIEW && + model.configManager.config.isValid() && + model.configManager.merchantConfig != null + ) { + nav.navigateToInitialOrderScreen(model.configManager) + } + handleSetupIntent(intent) } diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/MainViewModel.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/MainViewModel.kt @@ -35,7 +35,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) { private val api = MerchantApi(httpClient) val orderManager = OrderManager(app) - val configManager = ConfigManager(app, viewModelScope, httpClient, api).apply { + val configManager = ConfigManager(app, viewModelScope, httpClient).apply { addConfigurationReceiver(orderManager) } val paymentManager = PaymentManager(app, configManager, viewModelScope, api) 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 @@ -129,33 +129,34 @@ class AmountEntryFragment : Fragment() { selectedCurrency = configuredCurrency } if (amount == null) { - amount = Amount.zero(configuredCurrency) + amount = Amount.zero(configuredCurrency).withSpec(viewModel.configManager.currencySpec) } } private fun setCurrency(currency: String) { selectedCurrency = currency val currentAmount = amount + val spec = viewModel.configManager.currencySpec amount = when { currentAmount == null -> Amount.zero(currency) currentAmount.currency == currency -> currentAmount else -> currentAmount.withCurrency(currency) - } + }.withSpec(spec) } private fun onDigitPressed(digit: Char) { val currentAmount = amount ?: return - amount = currentAmount.addInputDigit(digit) ?: currentAmount + amount = currentAmount.addInputDigit(digit)?.withSpec(currentAmount.spec) ?: currentAmount } private fun onBackspacePressed() { val currentAmount = amount ?: return - amount = currentAmount.removeInputDigit() ?: currentAmount + amount = currentAmount.removeInputDigit()?.withSpec(currentAmount.spec) ?: currentAmount } private fun clearAmount() { val currency = selectedCurrency ?: return - amount = Amount.zero(currency) + amount = Amount.zero(currency).withSpec(viewModel.configManager.currencySpec) } private fun onChargePressed() { @@ -164,7 +165,8 @@ class AmountEntryFragment : Fragment() { return } val enteredCurrency = selectedCurrency ?: configuredCurrency - val enteredAmount = amount ?: Amount.zero(enteredCurrency) + val enteredAmount = amount + ?: Amount.zero(enteredCurrency).withSpec(viewModel.configManager.currencySpec) if (enteredAmount.isZero()) { Toast.makeText(requireContext(), R.string.amount_entry_error_zero, Toast.LENGTH_LONG) @@ -180,12 +182,13 @@ class AmountEntryFragment : Fragment() { val order = Order( id = QUICK_AMOUNT_ORDER_ID, currency = configuredCurrency, + currencySpec = viewModel.configManager.currencySpec, availableCategories = emptyMap(), ) val product = ConfigProduct( description = getString(R.string.amount_entry_product_description), productId = QUICK_AMOUNT_PRODUCT_ID, - price = enteredAmount, + price = enteredAmount.withSpec(viewModel.configManager.currencySpec), categories = listOf(Int.MIN_VALUE), ) order + product diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/config/ConfigFetcherFragment.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/config/ConfigFetcherFragment.kt @@ -30,6 +30,7 @@ import net.taler.merchantpos.MainViewModel import net.taler.merchantpos.config.ConfigFetcherFragmentDirections.Companion.actionConfigFetcherToMerchantSettings import net.taler.merchantpos.databinding.FragmentConfigFetcherBinding import net.taler.merchantpos.R +import net.taler.merchantpos.navigateToInitialOrderScreen class ConfigFetcherFragment : Fragment() { @@ -63,7 +64,7 @@ class ConfigFetcherFragment : Fragment() { is ConfigUpdateResult.Success -> { if (!navigating) { navigating = true - findNavController().navigate(R.id.action_global_amountEntry) + findNavController().navigateToInitialOrderScreen(configManager) } } } 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 @@ -17,8 +17,6 @@ package net.taler.merchantpos.config import android.Manifest -import android.app.TimePickerDialog -import android.app.DatePickerDialog import android.content.Intent import android.content.pm.PackageManager import android.media.Image @@ -30,10 +28,8 @@ import android.view.View.GONE import android.view.View.INVISIBLE import android.view.View.VISIBLE import android.view.ViewGroup -import android.widget.Button -import android.widget.RadioButton -import android.widget.TextView import android.widget.Toast +import androidx.core.content.res.use import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.OptIn import androidx.camera.core.CameraSelector @@ -47,7 +43,7 @@ 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.snackbar.BaseTransientBottomBar.LENGTH_LONG import com.google.android.material.snackbar.Snackbar import kotlinx.coroutines.Dispatchers @@ -56,16 +52,15 @@ import kotlinx.coroutines.withContext import net.taler.merchantpos.MainViewModel import net.taler.merchantpos.R import net.taler.merchantpos.databinding.FragmentMerchantConfigBinding +import net.taler.merchantpos.navigateToInitialOrderScreen import androidx.core.view.isVisible +import com.google.android.material.button.MaterialButtonToggleGroup import com.google.zxing.* import net.taler.merchantpos.MainActivity -import android.text.format.DateFormat import com.google.zxing.common.HybridBinarizer import net.taler.common.TokenDuration import net.taler.lib.android.ChallengeCancelledException import net.taler.lib.android.handleChallengeResponse -import java.util.Calendar -import java.util.Locale /** * Fragment that displays merchant settings, either by scanning a QR code @@ -77,6 +72,7 @@ class ConfigFragment : Fragment() { private val configManager by lazy { model.configManager } private lateinit var ui: FragmentMerchantConfigBinding + private var awaitingConfigUpdate = false private val cameraExecutor by lazy { ContextCompat.getMainExecutor(requireContext()) @@ -99,17 +95,11 @@ class ConfigFragment : Fragment() { } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - // 1) Views - val neverOption = ui.root.findViewById<RadioButton>(R.id.neverExpiresOption) - val dateOption = ui.root.findViewById<RadioButton>(R.id.dateExpiresOption) - val deadlineLayout = ui.root.findViewById<View>(R.id.deadlinePickerLayout) - val selectDateButton = ui.root.findViewById<Button>(R.id.selectDateButton) - val selectTimeButton = ui.root.findViewById<Button>(R.id.selectTimeButton) - val selectedDeadline = ui.root.findViewById<TextView>(R.id.selectedDeadline) - - // 2) Shared Calendar instance for storing the deadline - val deadlineCal = Calendar.getInstance() + configManager.configUpdateResult.observe(viewLifecycleOwner) { result -> + onConfigUpdate(result) + } + // 1) Views // set initial toggle ui.configToggle.check(R.id.newConfigButton) @@ -127,15 +117,10 @@ class ConfigFragment : Fragment() { // Only parse URL when user finishes editing (focus lost) ui.merchantUrlView.editText!!.setOnFocusChangeListener { v, hasFocus -> if (!hasFocus) { - parseMerchantUrlAndUpdateFields() + sanitizeMerchantUrlAndUpdateFields() } } - ui.forgetTokenButton.setOnClickListener { - configManager.forgetPassword() - updateView() - } - // manual configuration OK button ui.okNewButton.setOnClickListener { // launch coroutine to fetch limited token before config update @@ -145,20 +130,14 @@ class ConfigFragment : Fragment() { ui.okNewButton.visibility = INVISIBLE // normalize URL - val inputUrl = ui.merchantUrlView.editText!!.text.toString() - val url = if (inputUrl.startsWith("http")) inputUrl else "https://$inputUrl" + val url = sanitizeMerchantUrlAndUpdateFields() // retrieve username (may have been set by listener) val username = ui.usernameView.editText!!.text.toString().trim() // initial secret/token from user val initialSecret = ui.tokenView.editText!!.text.toString().trim() - val duration: TokenDuration = if (neverOption.isChecked) { - TokenDuration.Forever - } else { - val microsToDeadline = (deadlineCal.timeInMillis - System.currentTimeMillis()) * 1_000L - TokenDuration.Micros(microsToDeadline) - } + val duration = TokenDuration.Forever // fetch limited write token (with optional 2FA) val limitedToken = try { @@ -183,42 +162,11 @@ class ConfigFragment : Fragment() { accessToken = limitedToken, savePassword = ui.saveTokenCheckBox.isChecked ) - configManager.configUpdateResult.observe(viewLifecycleOwner, ::onConfigUpdate) + awaitingConfigUpdate = true configManager.fetchConfig(config, true) } } - - fun updateDeadlineText() { - val fmt = java.text.SimpleDateFormat("EEE, d MMM yyyy HH:mm", Locale.getDefault()) - selectedDeadline.text = fmt.format(deadlineCal.time) - } - - - ui.expiryOptionGroup.setOnCheckedChangeListener { _, checkedId -> - deadlineLayout.visibility = if (checkedId == R.id.dateExpiresOption) VISIBLE else GONE - } - - selectDateButton.setOnClickListener { - val year = deadlineCal.get(Calendar.YEAR) - val month = deadlineCal.get(Calendar.MONTH) - val day = deadlineCal.get(Calendar.DAY_OF_MONTH) - DatePickerDialog(requireContext(), { _, y, m, d -> - deadlineCal.set(y, m, d) - updateDeadlineText() - }, year, month, day).show() - } - - selectTimeButton.setOnClickListener { - val hour = deadlineCal.get(Calendar.HOUR_OF_DAY) - val minute = deadlineCal.get(Calendar.MINUTE) - TimePickerDialog(requireContext(), { _, h, min -> - deadlineCal.set(Calendar.HOUR_OF_DAY, h) - deadlineCal.set(Calendar.MINUTE, min) - updateDeadlineText() - }, hour, minute, DateFormat.is24HourFormat(requireContext())).show() - } - updateView(savedInstanceState == null) } @@ -262,14 +210,13 @@ class ConfigFragment : Fragment() { if (cfg is Config.New) { if (cfg.merchantUrl.isNotBlank()) { ui.merchantUrlView.editText!!.setText(cfg.merchantUrl) - parseMerchantUrlAndUpdateFields() + sanitizeMerchantUrlAndUpdateFields() } ui.saveTokenCheckBox.isChecked = cfg.savePassword - ui.tokenView.editText!!.setText(cfg.accessToken) } } - ui.forgetTokenButton.visibility = if (cfg.isValid()) VISIBLE else GONE + ui.forgetTokenButton.visibility = GONE when (cfg) { is Config.New -> { @@ -279,22 +226,26 @@ class ConfigFragment : Fragment() { } } - private fun onConfigUpdate(result: ConfigUpdateResult?) = when (result) { + private fun onConfigUpdate(result: ConfigUpdateResult?) { + if (!awaitingConfigUpdate) return + when (result) { null -> Unit is ConfigUpdateResult.Error -> { + awaitingConfigUpdate = false onError(result.msg) } is ConfigUpdateResult.Success -> { + awaitingConfigUpdate = false onConfigReceived(result.currency) } + } } private fun onConfigReceived(currency: String) { onResultReceived() updateView() Snackbar.make(requireView(), getString(R.string.config_changed, currency), LENGTH_LONG).show() - findNavController().navigate(R.id.action_instanceSettings_to_amountEntry) - configManager.configUpdateResult.removeObservers(viewLifecycleOwner) + findNavController().navigateToInitialOrderScreen(configManager) } private fun onError(msg: String) { @@ -308,22 +259,28 @@ class ConfigFragment : Fragment() { ui.okNewButton.visibility = VISIBLE } - private fun parseMerchantUrlAndUpdateFields() { - val input = ui.merchantUrlView.editText!!.text.toString().trim() - val uri = input.toUri() - // Build base URL: scheme://host[:port] - val scheme = uri.scheme ?: "" - val host = uri.host ?: "" + private fun sanitizeMerchantUrlAndUpdateFields(): String { + val rawInput = ui.merchantUrlView.editText!!.text.toString().trim() + if (rawInput.isEmpty()) return "" + + val normalizedInput = if (rawInput.startsWith("http://") || rawInput.startsWith("https://")) { + rawInput + } else { + "https://$rawInput" + } + + val uri = normalizedInput.toUri() + val host = uri.host.orEmpty() val port = if (uri.port != -1) ":${uri.port}" else "" - val baseUrl = "$scheme://$host$port" - // Check for /instances/username + val baseHost = "$host$port" + val segments = uri.pathSegments if (segments.size >= 2 && segments[0].equals("instances", true)) { - //Ensure that the username has been transferred to the username field ui.usernameView.editText!!.setText(segments[1]) } - // Ensure merchant URL has only the base - ui.merchantUrlView.editText!!.setText(baseUrl) + + ui.merchantUrlView.editText!!.setText(baseHost) + return if (baseHost.isBlank()) "" else "https://$baseHost" } private suspend fun fetchLimitedAccessTokenWithMfa( @@ -367,7 +324,6 @@ class ConfigFragment : Fragment() { } } - // ─── CameraX integration ─────────────────────────────────────────── // 1) permission launcher diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/config/ConfigManager.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/config/ConfigManager.kt @@ -39,20 +39,35 @@ import io.ktor.http.HttpStatusCode.Companion.Unauthorized import io.ktor.http.contentType import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerializationException +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.descriptors.element +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.long +import kotlinx.serialization.json.longOrNull import net.taler.common.ChallengeConfirmRequest import net.taler.common.ChallengesResponse +import net.taler.common.CurrencySpecification import net.taler.common.TokenDuration import net.taler.common.TokenRequest import net.taler.common.Version -import net.taler.lib.android.getIncompatibleStringOrNull -import net.taler.merchantlib.ConfigResponse -import net.taler.merchantlib.MerchantApi import net.taler.merchantlib.MerchantConfig import net.taler.merchantpos.BuildConfig import net.taler.merchantpos.R +import net.taler.lib.android.getIncompatibleStringOrNull private const val SETTINGS_NAME = "taler-merchant-terminal" @@ -76,6 +91,7 @@ internal const val OLD_CONFIG_PASSWORD_DEMO = "" private const val SETTINGS_MERCHANT_URL = "merchantUrl" private const val SETTINGS_ACCESS_TOKEN = "accessToken" +private const val SETTINGS_INITIAL_ORDER_SCREEN = "initialOrderScreen" internal const val NEW_CONFIG_URL_DEMO = "https://my.taler-ops.ch" @@ -83,6 +99,24 @@ private val VERSION = Version.parse(BuildConfig.BACKEND_API_VERSION)!! private val TAG = ConfigManager::class.java.simpleName +enum class InitialOrderScreen(val prefValue: String) { + AmountEntry("amountEntry"), + Inventory("inventory"); + + companion object { + fun fromPrefValue(value: String?): InitialOrderScreen { + return entries.firstOrNull { it.prefValue == value } ?: AmountEntry + } + } +} + +@kotlinx.serialization.Serializable +private data class MerchantBackendConfigResponse( + val version: String, + val currency: String, + val currencies: Map<String, CurrencySpecification> = emptyMap(), +) + /* -- Limited access token -- */ @kotlinx.serialization.Serializable private data class LimitedTokenResponse( @@ -103,14 +137,23 @@ interface ConfigurationReceiver { /** * Returns null if the configuration was valid, or a error string for user display otherwise. */ - suspend fun onConfigurationReceived(posConfig: PosConfig, currency: String): String? + suspend fun onConfigurationReceived( + posConfig: PosConfig, + currency: String, + currencySpec: CurrencySpecification?, + ): String? + + suspend fun onInventoryUpdated( + posConfig: PosConfig, + currency: String, + currencySpec: CurrencySpecification?, + ): String? = onConfigurationReceived(posConfig, currency, currencySpec) } class ConfigManager( private val context: Context, private val scope: CoroutineScope, private val httpClient: HttpClient, - private val api: MerchantApi, ) { private val _sessionExpired = MutableLiveData<Unit>() @@ -118,6 +161,7 @@ class ConfigManager( private val prefs = context.getSharedPreferences(SETTINGS_NAME, MODE_PRIVATE) private val configurationReceivers = ArrayList<ConfigurationReceiver>() + private var inventoryRefreshJob: Job? = null init { migrateLegacyPrefsIfNeeded(); @@ -139,6 +183,20 @@ class ConfigManager( var currency: String? = null private set + @Volatile + var currencySpec: CurrencySpecification? = null + private set + + var initialOrderScreen: InitialOrderScreen + get() = InitialOrderScreen.fromPrefValue( + prefs.getString(SETTINGS_INITIAL_ORDER_SCREEN, InitialOrderScreen.AmountEntry.prefValue), + ) + set(value) { + prefs.edit() + .putString(SETTINGS_INITIAL_ORDER_SCREEN, value.prefValue) + .apply() + } + private val mConfigUpdateResult = MutableLiveData<ConfigUpdateResult?>() val configUpdateResult: LiveData<ConfigUpdateResult?> = mConfigUpdateResult @@ -155,12 +213,31 @@ class ConfigManager( @UiThread fun reloadConfig() { - fetchConfig(config, true) + fetchConfig(config, save = true, inventoryOnly = false, silent = false) + } + + @UiThread + fun refreshInventory() { + inventoryRefreshJob?.cancel() + inventoryRefreshJob = scope.launch { + delay(350) + fetchConfig(config, save = false, inventoryOnly = true, silent = true) + } } @UiThread fun fetchConfig(config: Config, save: Boolean) { - mConfigUpdateResult.value = null + fetchConfig(config, save, inventoryOnly = false, silent = false) + } + + @UiThread + private fun fetchConfig( + config: Config, + save: Boolean, + inventoryOnly: Boolean, + silent: Boolean, + ) { + if (!silent) mConfigUpdateResult.value = null val configToSave = if (save) { if (config.savePassword()) config else when (val c = config) { //is Config.Old -> c.copy(password = "") @@ -195,10 +272,16 @@ class ConfigManager( is Config.New -> MerchantConfig(c.merchantUrl, "secret-token:${c.accessToken}") } - // get config from merchant backend API - api.getConfig(merchantConfig.baseUrl).handleSuspend(::onNetworkError) { - onMerchantConfigReceived(configToSave, posConfig, merchantConfig, it) - } + val backendConfig: MerchantBackendConfigResponse = + httpClient.get(merchantConfig.urlFor("config")).body() + onMerchantConfigReceived( + configToSave, + posConfig, + merchantConfig, + backendConfig, + inventoryOnly, + silent, + ) } catch (e: Exception) { Log.e(TAG, "Error retrieving merchant config", e) val msg = if (e is ClientRequestException) { @@ -209,7 +292,7 @@ class ConfigManager( } else { context.getString(R.string.config_error_malformed) } - onNetworkError(msg) + if (!silent) onNetworkError(msg) } } } @@ -219,24 +302,31 @@ class ConfigManager( newConfig: Config?, posConfig: PosConfig, merchantConfig: MerchantConfig, - configResponse: ConfigResponse, + configResponse: MerchantBackendConfigResponse, + inventoryOnly: Boolean, + silent: Boolean, ) { val versionIncompatible = VERSION.getIncompatibleStringOrNull(context, configResponse.version) if (versionIncompatible != null) { Log.e(TAG, "Versions incompatible $configResponse") - mConfigUpdateResult.postValue(ConfigUpdateResult.Error(versionIncompatible)) + if (!silent) mConfigUpdateResult.postValue(ConfigUpdateResult.Error(versionIncompatible)) return } + val currencySpec = configResponse.currencies[configResponse.currency] for (receiver in configurationReceivers) { val result = try { - receiver.onConfigurationReceived(posConfig, configResponse.currency) + if (inventoryOnly) { + receiver.onInventoryUpdated(posConfig, configResponse.currency, currencySpec) + } else { + receiver.onConfigurationReceived(posConfig, configResponse.currency, currencySpec) + } } catch (e: Exception) { Log.e(TAG, "Error handling configuration by ${receiver::class.java.simpleName}", e) context.getString(R.string.config_error_unknown) } if (result != null) { // error - mConfigUpdateResult.postValue(ConfigUpdateResult.Error(result)) + if (!silent) mConfigUpdateResult.postValue(ConfigUpdateResult.Error(result)) return } } @@ -247,7 +337,10 @@ class ConfigManager( } this@ConfigManager.merchantConfig = merchantConfig this@ConfigManager.currency = configResponse.currency - mConfigUpdateResult.value = ConfigUpdateResult.Success(configResponse.currency) + this@ConfigManager.currencySpec = currencySpec + if (!silent) { + mConfigUpdateResult.value = ConfigUpdateResult.Success(configResponse.currency) + } } } @@ -342,6 +435,7 @@ class ConfigManager( } saveConfig(config) merchantConfig = null + currencySpec = null } @UiThread 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 @@ -35,10 +35,12 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Text import androidx.compose.material3.ExposedDropdownMenuAnchorType import androidx.compose.material3.DropdownMenu @@ -50,12 +52,15 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.core.os.LocaleListCompat import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels import androidx.navigation.fragment.findNavController import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Shape 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.foundation.shape.RoundedCornerShape import androidx.compose.ui.unit.dp import net.taler.merchantpos.compose.PosOutlinedCard import net.taler.merchantpos.compose.PosTheme @@ -67,30 +72,57 @@ private data class LanguageOption( val label: String, ) +private data class InitialOrderOption( + val screen: InitialOrderScreen, + val label: String, +) + +private val SettingsControlShape: Shape = RoundedCornerShape(14.dp) + class GeneralSettingsFragment : Fragment() { + private val viewModel: net.taler.merchantpos.MainViewModel by activityViewModels() + private val configManager by lazy { viewModel.configManager } + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, ): View { - val options = createLanguageOptions() + val languageOptions = createLanguageOptions() + val initialOrderOptions = createInitialOrderOptions() val selectedTag = getCurrentLanguageTag() return ComposeView(requireContext()).apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { GeneralSettingsScreen( - options = options, + languageOptions = languageOptions, initialSelectedTag = selectedTag, onLanguageSelected = ::applyLanguage, + initialOrderOptions = initialOrderOptions, + initialSelectedOrderScreen = configManager.initialOrderScreen, + onInitialOrderSelected = { configManager.initialOrderScreen = it }, onInstanceSettingsClick = { - findNavController().navigate(R.id.action_settings_to_instanceSettings) + findNavController().navigate(R.id.nav_instanceSettings) }, ) } } } + private fun createInitialOrderOptions(): List<InitialOrderOption> { + return listOf( + InitialOrderOption( + screen = InitialOrderScreen.AmountEntry, + label = getString(R.string.menu_amount_entry), + ), + InitialOrderOption( + screen = InitialOrderScreen.Inventory, + label = getString(R.string.menu_order), + ), + ) + } + private fun createLanguageOptions(): List<LanguageOption> { val values = resources.getStringArray(R.array.settings_language_values) val labels = resources.getStringArray(R.array.settings_language_labels) @@ -126,15 +158,25 @@ class GeneralSettingsFragment : Fragment() { @OptIn(ExperimentalMaterial3Api::class) @Composable private fun GeneralSettingsScreen( - options: List<LanguageOption>, + languageOptions: List<LanguageOption>, initialSelectedTag: String, onLanguageSelected: (String) -> Unit, + initialOrderOptions: List<InitialOrderOption>, + initialSelectedOrderScreen: InitialOrderScreen, + onInitialOrderSelected: (InitialOrderScreen) -> Unit, onInstanceSettingsClick: () -> Unit, ) { - var expanded by remember { mutableStateOf(false) } + var languageExpanded by remember { mutableStateOf(false) } var selectedTag by rememberSaveable { mutableStateOf(initialSelectedTag) } - val selectedLabel = options.firstOrNull { it.languageTag == selectedTag }?.label - ?: options.firstOrNull()?.label.orEmpty() + val selectedLabel = languageOptions.firstOrNull { it.languageTag == selectedTag }?.label + ?: languageOptions.firstOrNull()?.label.orEmpty() + var initialOrderExpanded by remember { mutableStateOf(false) } + var selectedInitialOrderScreen by rememberSaveable { + mutableStateOf(initialSelectedOrderScreen) + } + val selectedInitialOrderLabel = + initialOrderOptions.firstOrNull { it.screen == selectedInitialOrderScreen }?.label + ?: initialOrderOptions.firstOrNull()?.label.orEmpty() PosTheme { Column( @@ -145,52 +187,48 @@ private fun GeneralSettingsScreen( verticalArrangement = Arrangement.spacedBy(14.dp), ) { SettingsCard( - title = stringResource(R.string.settings_language_label), - description = stringResource(R.string.settings_language_description), + title = stringResource(R.string.settings_app_title), ) { - 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) - }, - ) - } - } - } + SettingsDropdown( + expanded = languageExpanded, + onExpandedChange = { languageExpanded = it }, + value = selectedLabel, + label = stringResource(R.string.settings_language_hint), + options = languageOptions, + optionLabel = { it.label }, + onOptionSelected = { option -> + selectedTag = option.languageTag + languageExpanded = false + onLanguageSelected(option.languageTag) + }, + ) + SettingsDropdown( + expanded = initialOrderExpanded, + onExpandedChange = { initialOrderExpanded = it }, + value = selectedInitialOrderLabel, + label = stringResource(R.string.settings_initial_order_hint), + options = initialOrderOptions, + optionLabel = { it.label }, + onOptionSelected = { option -> + selectedInitialOrderScreen = option.screen + initialOrderExpanded = false + onInitialOrderSelected(option.screen) + }, + ) } SettingsCard( title = stringResource(R.string.settings_instance_title), description = stringResource(R.string.settings_instance_description), ) { - OutlinedButton( + Button( modifier = Modifier.fillMaxWidth(), onClick = onInstanceSettingsClick, + shape = SettingsControlShape, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), ) { Icon( painter = painterResource(R.drawable.ic_menu_manage), @@ -209,7 +247,7 @@ private fun GeneralSettingsScreen( @Composable private fun SettingsCard( title: String, - description: String, + description: String? = null, content: @Composable () -> Unit, ) { PosOutlinedCard( @@ -223,11 +261,70 @@ private fun SettingsCard( text = title, style = MaterialTheme.typography.titleSmall, ) - Text( - text = description, - style = MaterialTheme.typography.bodyMedium, - ) + if (description != null) { + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + ) + } content() } } } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun <T> SettingsDropdown( + expanded: Boolean, + onExpandedChange: (Boolean) -> Unit, + value: String, + label: String, + options: List<T>, + optionLabel: (T) -> String, + onOptionSelected: (T) -> Unit, +) { + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { onExpandedChange(!expanded) }, + ) { + OutlinedTextField( + modifier = Modifier + .fillMaxWidth() + .menuAnchor( + type = ExposedDropdownMenuAnchorType.PrimaryNotEditable, + enabled = true, + ), + value = value, + onValueChange = {}, + readOnly = true, + singleLine = true, + label = { Text(label) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) }, + shape = SettingsControlShape, + colors = OutlinedTextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, + disabledContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant, + focusedLabelColor = MaterialTheme.colorScheme.primary, + unfocusedLabelColor = MaterialTheme.colorScheme.onSurfaceVariant, + focusedTextColor = MaterialTheme.colorScheme.onSurface, + unfocusedTextColor = MaterialTheme.colorScheme.onSurface, + focusedTrailingIconColor = MaterialTheme.colorScheme.primary, + unfocusedTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) + DropdownMenu( + expanded = expanded, + onDismissRequest = { onExpandedChange(false) }, + ) { + options.forEach { option -> + DropdownMenuItem( + text = { Text(optionLabel(option)) }, + onClick = { onOptionSelected(option) }, + ) + } + } + } +} diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/config/PosConfig.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/config/PosConfig.kt @@ -25,6 +25,7 @@ import net.taler.common.OrderProduct import net.taler.common.TalerUtils import net.taler.common.Tax import net.taler.merchantlib.MerchantConfig +import java.math.RoundingMode import java.util.UUID sealed class Config { @@ -102,9 +103,16 @@ data class ConfigProduct( override val image: String? = null, override val taxes: Set<Tax>? = null, val categories: List<Int>, - val quantity: Int = 0 + val quantity: Int = 0, + @SerialName("total_stock") + val totalStock: Int? = null, + @SerialName("unit_total_stock") + val unitTotalStock: String? = null, + val availableToSell: Boolean = true, + val remainingStock: Int? = null, ) : OrderProduct() { - val totalPrice by lazy { price * quantity } + val totalPrice: Amount + get() = (price * quantity).withSpec(price.spec) private val normalizedProductName: String? get() = productName?.trim()?.takeIf { it.isNotEmpty() } private val normalizedDescription: String @@ -116,7 +124,26 @@ data class ConfigProduct( ?.takeIf { it != normalizedDescription } ?.let { normalizedDescription } val displayPrice: String - get() = "${price.toString(showSymbol = false)} ${price.currency}" + get() = price.toString() + val stableKey: String + get() = listOf( + productId?.trim().orEmpty(), + productName?.trim().orEmpty(), + description.trim(), + price.currency, + price.value.toString(), + price.fraction.toString(), + categories.joinToString(","), + ).joinToString("|") + val stockLimit: Int? + get() { + if (totalStock == -1 || unitTotalStock == "-1") return null + totalStock?.let { return it } + val decimalStock = unitTotalStock ?: return null + return decimalStock.toBigDecimalOrNull() + ?.setScale(0, RoundingMode.DOWN) + ?.toInt() + } fun toContractProduct() = ContractProduct( productId = productId, diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/history/HistoryFragment.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/history/HistoryFragment.kt @@ -36,14 +36,15 @@ import net.taler.merchantpos.databinding.FragmentMerchantHistoryBinding import net.taler.merchantpos.history.HistoryFragmentDirections.Companion.actionGlobalMerchantSettings import net.taler.merchantpos.history.HistoryFragmentDirections.Companion.actionNavHistoryToRefundFragment -internal interface RefundClickListener { +internal interface HistoryActionListener { fun onRefundClicked(item: OrderHistoryEntry) + fun onDeleteClicked(item: OrderHistoryEntry) } /** * Fragment to display the merchant's payment history, received from the backend. */ -class HistoryFragment : Fragment(), RefundClickListener { +class HistoryFragment : Fragment(), HistoryActionListener { companion object { const val TAG = "taler-merchant" @@ -81,7 +82,7 @@ class HistoryFragment : Fragment(), RefundClickListener { }) historyManager.items.observe(viewLifecycleOwner, { result -> when (result) { - is HistoryResult.Error -> requireActivity().showError(R.string.error_history, result.msg) + is HistoryResult.Error -> requireActivity().showError(result.mainResId, result.msg) is HistoryResult.Success -> historyListAdapter.setData(result.items) }.exhaustive }) @@ -101,4 +102,8 @@ class HistoryFragment : Fragment(), RefundClickListener { navigate(actionNavHistoryToRefundFragment()) } + override fun onDeleteClicked(item: OrderHistoryEntry) { + historyManager.deleteOrder(item.orderId) + } + } diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/history/HistoryItemAdapter.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/history/HistoryItemAdapter.kt @@ -24,13 +24,14 @@ import android.widget.TextView import androidx.core.content.ContextCompat.getColor import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.Adapter -import net.taler.lib.android.toRelativeTime +import com.google.android.material.button.MaterialButton import net.taler.merchantlib.OrderHistoryEntry import net.taler.merchantpos.R import net.taler.merchantpos.history.HistoryItemAdapter.HistoryItemViewHolder +import net.taler.lib.android.toRelativeTime -internal class HistoryItemAdapter(private val listener: RefundClickListener) : +internal class HistoryItemAdapter(private val listener: HistoryActionListener) : Adapter<HistoryItemViewHolder>() { private val items = ArrayList<OrderHistoryEntry>() @@ -60,6 +61,7 @@ internal class HistoryItemAdapter(private val listener: RefundClickListener) : private val orderTimeView: TextView = v.findViewById(R.id.orderTimeView) private val orderIdView: TextView = v.findViewById(R.id.orderIdView) private val refundButton: ImageButton = v.findViewById(R.id.refundButton) + private val deleteButton: MaterialButton = v.findViewById(R.id.deleteButton) private val orderIdColor = orderIdView.currentTextColor @@ -77,9 +79,19 @@ internal class HistoryItemAdapter(private val listener: RefundClickListener) : } if (item.refundable) { refundButton.visibility = View.VISIBLE + deleteButton.visibility = View.GONE refundButton.setOnClickListener { listener.onRefundClicked(item) } + deleteButton.setOnClickListener(null) + } else if (!item.paid) { + refundButton.visibility = View.GONE + deleteButton.visibility = View.VISIBLE + deleteButton.setOnClickListener { listener.onDeleteClicked(item) } + refundButton.setOnClickListener(null) } else { refundButton.visibility = View.GONE + deleteButton.visibility = View.GONE + refundButton.setOnClickListener(null) + deleteButton.setOnClickListener(null) } } diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/history/HistoryManager.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/history/HistoryManager.kt @@ -16,6 +16,7 @@ package net.taler.merchantpos.history +import androidx.annotation.StringRes import androidx.annotation.UiThread import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData @@ -24,10 +25,14 @@ import kotlinx.coroutines.launch import net.taler.lib.android.assertUiThread import net.taler.merchantlib.MerchantApi import net.taler.merchantlib.OrderHistoryEntry +import net.taler.merchantpos.R import net.taler.merchantpos.config.ConfigManager sealed class HistoryResult { - class Error(val msg: String) : HistoryResult() + class Error( + @StringRes val mainResId: Int, + val msg: String, + ) : HistoryResult() class Success(val items: List<OrderHistoryEntry>) : HistoryResult() } @@ -47,16 +52,27 @@ class HistoryManager( internal fun fetchHistory() = scope.launch { mIsLoading.value = true val merchantConfig = configManager.merchantConfig!! - api.getOrderHistory(merchantConfig).handle(::onHistoryError) { + api.getOrderHistory(merchantConfig).handle({ onError(R.string.error_history, it) }) { assertUiThread() mIsLoading.value = false mItems.value = HistoryResult.Success(it.orders) } } - private fun onHistoryError(msg: String) { + @UiThread + internal fun deleteOrder(orderId: String) = scope.launch { + mIsLoading.value = true + val merchantConfig = configManager.merchantConfig!! + api.deleteOrder(merchantConfig, orderId).handle({ onError(R.string.error_delete_order, it) }) { + assertUiThread() + configManager.refreshInventory() + fetchHistory() + } + } + + private fun onError(@StringRes mainResId: Int, msg: String) { assertUiThread() mIsLoading.value = false - mItems.value = HistoryResult.Error(msg) + mItems.value = HistoryResult.Error(mainResId, msg) } } diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/order/CustomDialogFragment.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/order/CustomDialogFragment.kt @@ -24,8 +24,8 @@ import android.widget.Toast import android.widget.Toast.LENGTH_LONG import androidx.fragment.app.DialogFragment import androidx.fragment.app.activityViewModels -import net.taler.common.Amount import net.taler.common.AmountParserException +import net.taler.common.Amount import net.taler.merchantpos.MainViewModel import net.taler.merchantpos.R import net.taler.merchantpos.config.ConfigProduct @@ -52,12 +52,14 @@ class CustomDialogFragment : DialogFragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { val currency = viewModel.configManager.currency ?: error("No currency") + val currencySpec = viewModel.configManager.currencySpec ui.currencyView.text = currency ui.addButton.setOnClickListener { val currentOrderId = viewModel.orderManager.currentOrderId.value ?: return@setOnClickListener val amount = try { Amount.fromString(currency, ui.amountLayout.editText!!.text.toString()) + .withSpec(currencySpec) } catch (e: AmountParserException) { Toast.makeText(requireContext(), R.string.refund_error_invalid_amount, LENGTH_LONG) .show() diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/order/LiveOrder.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/order/LiveOrder.kt @@ -22,6 +22,7 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.map import net.taler.common.Amount import net.taler.common.CombinedLiveData +import net.taler.common.CurrencySpecification import net.taler.merchantpos.config.Category import net.taler.merchantpos.config.ConfigProduct import net.taler.merchantpos.order.RestartState.DISABLED @@ -35,6 +36,7 @@ internal interface LiveOrder { val orderTotal: LiveData<Amount> val restartState: LiveData<RestartState> val modifyOrderAllowed: LiveData<Boolean> + val increaseOrderAllowed: LiveData<Boolean> val lastAddedProduct: ConfigProduct? val selectedProductKey: String? fun restartOrUndo() @@ -46,13 +48,17 @@ internal interface LiveOrder { internal class MutableLiveOrder( val id: Int, private val currency: String, - private val productsByCategory: HashMap<Category, ArrayList<ConfigProduct>> + private val currencySpec: CurrencySpecification?, + private val productsByCategory: HashMap<Category, ArrayList<ConfigProduct>>, + private val canAddProduct: (ConfigProduct) -> Boolean, + private val onChanged: () -> Unit, ) : LiveOrder { private val availableCategories: Map<Int, Category> get() = productsByCategory.keys.map { it.id to it }.toMap() override val order: MutableLiveData<Order?> = - MutableLiveData(Order(id, currency, availableCategories)) - override val orderTotal: LiveData<Amount> = order.map { it?.total ?: Amount.zero(currency) } + MutableLiveData(Order(id, currency, currencySpec, availableCategories)) + override val orderTotal: LiveData<Amount> = + order.map { it?.total ?: Amount.zero(currency).withSpec(currencySpec) } override val restartState = MutableLiveData(DISABLED) private val selectedOrderLine = MutableLiveData<ConfigProduct?>() override val selectedProductKey: String? @@ -61,14 +67,20 @@ internal class MutableLiveOrder( CombinedLiveData(restartState, selectedOrderLine) { restartState, selectedOrderLine -> restartState != DISABLED && selectedOrderLine != null } + override val increaseOrderAllowed = + CombinedLiveData(order, selectedOrderLine) { order, selectedOrderLine -> + order != null && selectedOrderLine != null && canAddProduct(selectedOrderLine) + } override var lastAddedProduct: ConfigProduct? = null private var undoOrder: Order? = null @UiThread internal fun addProduct(product: ConfigProduct) { + if (!canAddProduct(product)) return lastAddedProduct = product order.value = order.value!! + product restartState.value = ENABLED + onChanged() } @UiThread @@ -76,6 +88,7 @@ internal class MutableLiveOrder( val modifiedOrder = order.value!! - product order.value = modifiedOrder restartState.value = if (modifiedOrder.products.isEmpty()) DISABLED else ENABLED + onChanged() } @UiThread @@ -89,9 +102,10 @@ internal class MutableLiveOrder( undoOrder = null } else { undoOrder = order.value - order.value = Order(id, currency, availableCategories) + order.value = Order(id, currency, currencySpec, availableCategories) restartState.value = UNDO } + onChanged() } @UiThread diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/order/Order.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/order/Order.kt @@ -17,10 +17,16 @@ package net.taler.merchantpos.order import net.taler.common.Amount +import net.taler.common.CurrencySpecification import net.taler.merchantpos.config.Category import net.taler.merchantpos.config.ConfigProduct -data class Order(val id: Int, val currency: String, val availableCategories: Map<Int, Category>) { +data class Order( + val id: Int, + val currency: String, + val currencySpec: CurrencySpecification?, + val availableCategories: Map<Int, Category>, +) { val products = ArrayList<ConfigProduct>() val title: String = id.toString() val summary: String @@ -32,11 +38,11 @@ data class Order(val id: Int, val currency: String, val availableCategories: Map } val total: Amount get() { - var total = Amount.zero(currency) + var total = Amount.zero(currency).withSpec(currencySpec) products.forEach { product -> total += product.price * product.quantity } - return total + return total.withSpec(currencySpec) } operator fun plus(product: ConfigProduct): Order { diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/order/OrderAdapter.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/order/OrderAdapter.kt @@ -97,7 +97,7 @@ internal class OrderAdapter : Adapter<OrderViewHolder>() { description.visibility = VISIBLE description.text = productDescription } - price.text = product.totalPrice.toString(showSymbol = false) + " " + product.totalPrice.currency + price.text = product.totalPrice.toString() // base64 encoded image val bitmap = product.image?.base64Bitmap diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/order/OrderFragment.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/order/OrderFragment.kt @@ -46,6 +46,13 @@ class OrderFragment : Fragment() { private val paymentManager by lazy { viewModel.paymentManager } private lateinit var ui: FragmentOrderBinding + private var billLabel: String = "" + private var currentOrderId: Int? = null + private var currentLiveOrder: LiveOrder? = null + private var restartOrderItem: MenuItem? = null + private var deleteOrderItem: MenuItem? = null + private var previousOrderItem: MenuItem? = null + private var nextOrderItem: MenuItem? = null override fun onCreateView( inflater: LayoutInflater, @@ -58,14 +65,40 @@ class OrderFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + billLabel = getString(R.string.order_complete) + ui.completeButton.text = billLabel requireActivity().addMenuProvider(object: MenuProvider { override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.order, menu) + restartOrderItem = menu.findItem(R.id.orderRestart) + deleteOrderItem = menu.findItem(R.id.orderDelete) + previousOrderItem = menu.findItem(R.id.orderPrevious) + nextOrderItem = menu.findItem(R.id.orderNext) + updateOrderNavigationActions(currentOrderId) } override fun onMenuItemSelected(menuItem: MenuItem): Boolean { return when(menuItem.itemId) { + R.id.orderRestart -> { + currentLiveOrder?.restartOrUndo() + true + } + R.id.orderDelete -> { + orderManager.deleteCurrentOrder() + true + } + + R.id.orderPrevious -> { + orderManager.previousOrder() + true + } + + R.id.orderNext -> { + orderManager.nextOrder() + true + } + R.id.reload -> { viewModel.configManager.reloadConfig() Toast.makeText( @@ -90,9 +123,6 @@ class OrderFragment : Fragment() { .replace(R.id.fragment1, OrderStateFragment()) .commit() } - ui.customButton.setOnClickListener { - CustomDialogFragment().show(childFragmentManager, CustomDialogFragment.TAG) - } } override fun onStart() { @@ -105,39 +135,54 @@ class OrderFragment : Fragment() { } private fun onOrderSwitched(orderId: Int, liveOrder: LiveOrder) { + currentOrderId = orderId + currentLiveOrder = liveOrder + updateOrderNavigationActions(orderId) // order title liveOrder.order.observe(viewLifecycleOwner) { order -> if (order == null) return@observe activity?.title = getString(R.string.order_label_title, order.title) } - // restart button - ui.restartButton.setOnClickListener { liveOrder.restartOrUndo() } + // restart action liveOrder.restartState.observe(viewLifecycleOwner) { state -> beginDelayedTransition(view as ViewGroup) if (state == UNDO) { - ui.restartButton.setText(R.string.order_undo) - ui.restartButton.isEnabled = true + restartOrderItem?.setTitle(R.string.order_undo) + restartOrderItem?.isEnabled = true ui.completeButton.isEnabled = false } else { - ui.restartButton.setText(R.string.order_restart) - ui.restartButton.isEnabled = state == ENABLED + restartOrderItem?.setTitle(R.string.order_restart) + restartOrderItem?.isEnabled = state == ENABLED ui.completeButton.isEnabled = state == ENABLED } + deleteOrderItem?.isEnabled = + state != RestartState.DISABLED || + orderManager.hasPreviousOrder(orderId) || + (orderManager.hasNextOrder(orderId).value == true) + } + liveOrder.orderTotal.observe(viewLifecycleOwner) { orderTotal -> + ui.completeButton.text = if (orderTotal.isZero()) { + billLabel + } else { + getString(R.string.order_complete_with_amount, orderTotal) + } } // -1 and +1 buttons liveOrder.modifyOrderAllowed.observe(viewLifecycleOwner) { allowed -> ui.minusButton.isEnabled = allowed + } + liveOrder.increaseOrderAllowed.observe(viewLifecycleOwner) { allowed -> ui.plusButton.isEnabled = allowed } ui.minusButton.setOnClickListener { liveOrder.decreaseSelectedOrderLine() } ui.plusButton.setOnClickListener { liveOrder.increaseSelectedOrderLine() } - // previous and next button - ui.prevButton.isEnabled = orderManager.hasPreviousOrder(orderId) + ui.tipButton.setOnClickListener { + CustomDialogFragment().show(childFragmentManager, CustomDialogFragment.TAG) + } + // previous and next order actions orderManager.hasNextOrder(orderId).observe(viewLifecycleOwner) { hasNextOrder -> - ui.nextButton.isEnabled = hasNextOrder + if (currentOrderId == orderId) nextOrderItem?.isEnabled = hasNextOrder } - ui.prevButton.setOnClickListener { orderManager.previousOrder() } - ui.nextButton.setOnClickListener { orderManager.nextOrder() } // complete button ui.completeButton.setOnClickListener { val order = liveOrder.order.value ?: return@setOnClickListener @@ -146,4 +191,11 @@ class OrderFragment : Fragment() { } } + private fun updateOrderNavigationActions(orderId: Int?) { + previousOrderItem?.isEnabled = orderId?.let { orderManager.hasPreviousOrder(it) } ?: false + nextOrderItem?.isEnabled = orderId?.let { + orderManager.hasNextOrder(it).value ?: false + } ?: false + } + } diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/order/OrderManager.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/order/OrderManager.kt @@ -22,6 +22,7 @@ import androidx.annotation.UiThread import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.map +import net.taler.common.CurrencySpecification import net.taler.merchantpos.R import net.taler.merchantpos.config.Category import net.taler.merchantpos.config.ConfigProduct @@ -39,12 +40,13 @@ class OrderManager(private val context: Context) : ConfigurationReceiver { } private lateinit var currency: String + private var currencySpec: CurrencySpecification? = null private var orderCounter: Int = 0 private val mCurrentOrderId = MutableLiveData<Int>() internal val currentOrderId: LiveData<Int> = mCurrentOrderId private val productsByCategory = HashMap<Category, ArrayList<ConfigProduct>>() - + private val productsById = HashMap<String, ConfigProduct>() private val orders = LinkedHashMap<Int, MutableLiveOrder>() private val mProducts = MutableLiveData<List<ConfigProduct>>() @@ -52,19 +54,37 @@ class OrderManager(private val context: Context) : ConfigurationReceiver { private val mCategories = MutableLiveData<List<Category>>() internal val categories: LiveData<List<Category>> = mCategories + private var currentCategory: Category? = null + + override suspend fun onConfigurationReceived( + posConfig: PosConfig, + currency: String, + currencySpec: CurrencySpecification?, + ): String? = applyConfiguration(posConfig, currency, currencySpec, resetOrders = true) - override suspend fun onConfigurationReceived(posConfig: PosConfig, currency: String): String? { - // parse categories + override suspend fun onInventoryUpdated( + posConfig: PosConfig, + currency: String, + currencySpec: CurrencySpecification?, + ): String? = applyConfiguration(posConfig, currency, currencySpec, resetOrders = false) + + private fun applyConfiguration( + posConfig: PosConfig, + currency: String, + currencySpec: CurrencySpecification?, + resetOrders: Boolean, + ): String? { + val existingProductsByStableKey = productsById.values.associateBy { it.stableKey } if (posConfig.categories.isEmpty()) { Log.e(TAG, "No valid category found.") return context.getString(R.string.config_error_category) } + + val selectedCategoryId = if (resetOrders) ALL_PRODUCTS_CATEGORY_ID else currentCategory?.id val allProductsCategory = Category( ALL_PRODUCTS_CATEGORY_ID, context.getString(R.string.product_category_all_objects) - ).apply { - selected = true - } + ) val uncategorizedCategory = Category( UNCATEGORIZED_CATEGORY_ID, context.getString(R.string.product_category_uncategorized) @@ -75,14 +95,15 @@ class OrderManager(private val context: Context) : ConfigurationReceiver { .map { it.id } .toSet() - // group products by categories productsByCategory.clear() + productsById.clear() productsByCategory[allProductsCategory] = ArrayList() visibleCategories.forEach { category -> category.selected = false productsByCategory[category] = ArrayList() } productsByCategory[uncategorizedCategory] = ArrayList() + posConfig.products.forEach { product -> val productCurrency = product.price.currency if (productCurrency != currency) { @@ -91,25 +112,35 @@ class OrderManager(private val context: Context) : ConfigurationReceiver { R.string.config_error_currency, product.description, productCurrency, currency ) } - productsByCategory.getValue(allProductsCategory).add(product) + val remainingStock = product.stockLimit + val productWithSpec = product.copy( + id = existingProductsByStableKey[product.stableKey]?.id ?: product.id, + price = product.price.withSpec(currencySpec), + availableToSell = remainingStock == null || remainingStock > 0, + remainingStock = remainingStock, + ) + productsById[productWithSpec.id] = productWithSpec + productsByCategory.getValue(allProductsCategory).add(productWithSpec) if (product.categories.isEmpty()) { - productsByCategory.getValue(uncategorizedCategory).add(product) + productsByCategory.getValue(uncategorizedCategory).add(productWithSpec) } product.categories.forEach { categoryId -> if (categoryId in legacyDefaultCategoryIds) { - productsByCategory.getValue(uncategorizedCategory).add(product) + productsByCategory.getValue(uncategorizedCategory).add(productWithSpec) return@forEach } val category = visibleCategories.find { it.id == categoryId } if (category == null) { Log.e(TAG, "Product $product has unknown category $categoryId") - productsByCategory.getValue(uncategorizedCategory).add(product) + productsByCategory.getValue(uncategorizedCategory).add(productWithSpec) } else { - productsByCategory.getValue(category).add(product) + productsByCategory.getValue(category).add(productWithSpec) } } } + this.currency = currency + this.currencySpec = currencySpec val categoryList = buildList { add(allProductsCategory) addAll(visibleCategories) @@ -119,13 +150,20 @@ class OrderManager(private val context: Context) : ConfigurationReceiver { productsByCategory.remove(uncategorizedCategory) } } + val selectedCategory = + categoryList.firstOrNull { it.id == selectedCategoryId } ?: allProductsCategory + categoryList.forEach { it.selected = it.id == selectedCategory.id } + currentCategory = selectedCategory mCategories.postValue(categoryList) - mProducts.postValue(productsByCategory[allProductsCategory] ?: emptyList()) - orders.clear() - orderCounter = 0 - orders[0] = MutableLiveOrder(0, currency, productsByCategory) - mCurrentOrderId.postValue(0) - return null // success, no error string + mProducts.postValue(getVisibleProducts()) + + if (resetOrders) { + orders.clear() + orderCounter = 0 + orders[0] = createOrder(0) + mCurrentOrderId.postValue(0) + } + return null } @UiThread @@ -147,12 +185,13 @@ class OrderManager(private val context: Context) : ConfigurationReceiver { } if (nextId == null) { nextId = ++orderCounter - orders[nextId] = MutableLiveOrder(nextId, currency, productsByCategory) + orders[nextId] = createOrder(nextId) } val currentOrder = order(currentId) if (currentOrder.isEmpty()) orders.remove(currentId) - else currentOrder.lastAddedProduct = null // not needed anymore and it would get selected + else currentOrder.lastAddedProduct = null mCurrentOrderId.value = requireNotNull(nextId) + updateVisibleProducts() } @UiThread @@ -171,11 +210,10 @@ class OrderManager(private val context: Context) : ConfigurationReceiver { throw AssertionError("Could not find previous order for $currentId") } val currentOrder = order(currentId) - // remove current order if empty, or lastAddedProduct as it is not needed anymore - // and would get selected when navigating back instead of last selection if (currentOrder.isEmpty()) orders.remove(currentId) else currentOrder.lastAddedProduct = null mCurrentOrderId.value = requireNotNull(previousId) + updateVisibleProducts() } fun hasPreviousOrder(currentOrderId: Int): Boolean { @@ -187,12 +225,13 @@ class OrderManager(private val context: Context) : ConfigurationReceiver { } internal fun setCurrentCategory(category: Category) { + currentCategory = category val newCategories = categories.value?.apply { forEach { if (it.selected) it.selected = false } category.selected = true } mCategories.postValue(newCategories ?: emptyList()) - mProducts.postValue(productsByCategory[category]) + updateVisibleProducts() } @UiThread @@ -207,6 +246,27 @@ class OrderManager(private val context: Context) : ConfigurationReceiver { else nextOrder() } orders.remove(orderId) + updateVisibleProducts() + } + + @UiThread + internal fun deleteCurrentOrder() { + val currentId = currentOrderId.value ?: return + val orderIds = orders.keys.toList() + val currentIndex = orderIds.indexOf(currentId) + if (currentIndex == -1) return + + orders.remove(currentId) + val replacementId = when { + orders.isEmpty() -> { + orders[currentId] = createOrder(currentId) + currentId + } + currentIndex < orderIds.lastIndex -> orderIds[currentIndex + 1] + else -> orderIds[currentIndex - 1] + } + mCurrentOrderId.value = replacementId + updateVisibleProducts() } private fun order(orderId: Int): MutableLiveOrder { @@ -217,4 +277,47 @@ class OrderManager(private val context: Context) : ConfigurationReceiver { return category.name.equals(LEGACY_DEFAULT_CATEGORY_NAME, ignoreCase = true) } + private fun createOrder(orderId: Int): MutableLiveOrder { + return MutableLiveOrder( + orderId, + currency, + currencySpec, + productsByCategory, + ::canAddProduct, + ::updateVisibleProducts, + ) + } + + private fun getVisibleProducts(): List<ConfigProduct> { + val category = currentCategory ?: return emptyList() + return productsByCategory[category].orEmpty().map(::decorateProduct) + } + + private fun updateVisibleProducts() { + mProducts.postValue(getVisibleProducts()) + } + + private fun decorateProduct(product: ConfigProduct): ConfigProduct { + val remainingStock = remainingStock(product) + return product.copy( + availableToSell = remainingStock == null || remainingStock > 0, + remainingStock = remainingStock, + ) + } + + private fun canAddProduct(product: ConfigProduct): Boolean { + return remainingStock(product)?.let { it > 0 } ?: true + } + + private fun remainingStock(product: ConfigProduct): Int? { + val stockLimit = productsById[product.id]?.stockLimit ?: product.stockLimit ?: return null + val reserved = orders.values.sumOf { liveOrder -> + liveOrder.order.value + ?.products + ?.find { it.id == product.id } + ?.quantity + ?: 0 + } + return (stockLimit - reserved).coerceAtLeast(0) + } } diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/order/OrderStateFragment.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/order/OrderStateFragment.kt @@ -26,11 +26,7 @@ import androidx.recyclerview.selection.SelectionPredicates import androidx.recyclerview.selection.SelectionTracker import androidx.recyclerview.selection.StorageStrategy import androidx.recyclerview.widget.LinearLayoutManager -import net.taler.common.Amount -import net.taler.lib.android.fadeIn -import net.taler.lib.android.fadeOut import net.taler.merchantpos.MainViewModel -import net.taler.merchantpos.R import net.taler.merchantpos.databinding.FragmentOrderStateBinding import net.taler.merchantpos.order.OrderAdapter.OrderLineLookup @@ -87,15 +83,6 @@ class OrderStateFragment : Fragment() { if (order == null) return@observe onOrderChanged(order, tracker) } - liveOrder.orderTotal.observe(viewLifecycleOwner) { orderTotal: Amount -> - if (orderTotal.isZero()) { - ui.totalView.fadeOut() - ui.totalView.text = null - } else { - ui.totalView.text = getString(R.string.order_total, orderTotal) - ui.totalView.fadeIn() - } - } } override fun onSaveInstanceState(outState: Bundle) { diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/order/ProductsFragment.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/order/ProductsFragment.kt @@ -25,8 +25,11 @@ import android.view.View.VISIBLE import android.view.ViewGroup import android.widget.ImageView import android.widget.TextView +import com.google.android.material.card.MaterialCardView import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels +import androidx.recyclerview.widget.AsyncListDiffer +import androidx.recyclerview.widget.DiffUtil.ItemCallback import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView.Adapter import androidx.recyclerview.widget.RecyclerView.ViewHolder @@ -76,6 +79,7 @@ class ProductsFragment : Fragment(), ProductSelectionListener { override fun onProductSelected(product: ConfigProduct) { orderManager.addProduct(orderManager.currentOrderId.value!!, product) + viewModel.configManager.refreshInventory() } } @@ -83,10 +87,31 @@ class ProductsFragment : Fragment(), ProductSelectionListener { private class ProductAdapter( private val listener: ProductSelectionListener ) : Adapter<ProductViewHolder>() { + init { + setHasStableIds(true) + } + + private val itemCallback = object : ItemCallback<ConfigProduct>() { + override fun areItemsTheSame(oldItem: ConfigProduct, newItem: ConfigProduct): Boolean { + return oldItem.stableKey == newItem.stableKey + } - private val products = ArrayList<ConfigProduct>() + override fun areContentsTheSame(oldItem: ConfigProduct, newItem: ConfigProduct): Boolean { + return oldItem.displayName == newItem.displayName && + oldItem.displayDescription == newItem.displayDescription && + oldItem.displayPrice == newItem.displayPrice && + oldItem.image == newItem.image && + oldItem.availableToSell == newItem.availableToSell && + oldItem.remainingStock == newItem.remainingStock + } + } + private val differ = AsyncListDiffer(this, itemCallback) - override fun getItemCount() = products.size + override fun getItemCount() = differ.currentList.size + + override fun getItemId(position: Int): Long { + return differ.currentList[position].stableKey.hashCode().toLong() + } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ProductViewHolder { val view = @@ -95,13 +120,11 @@ private class ProductAdapter( } override fun onBindViewHolder(holder: ProductViewHolder, position: Int) { - holder.bind(products[position]) + holder.bind(differ.currentList[position]) } fun setItems(items: List<ConfigProduct>) { - products.clear() - products.addAll(items) - notifyDataSetChanged() + differ.submitList(items.toList()) } inner class ProductViewHolder(private val v: View) : ViewHolder(v) { @@ -109,6 +132,8 @@ private class ProductAdapter( private val description: TextView = v.findViewById(R.id.description) private val price: TextView = v.findViewById(R.id.price) private val image: ImageView = v.findViewById(R.id.image) + private val unavailable: TextView = v.findViewById(R.id.unavailableLabel) + private val card: MaterialCardView = v as MaterialCardView fun bind(product: ConfigProduct) { name.text = product.displayName @@ -130,7 +155,18 @@ private class ProductAdapter( image.setImageBitmap(bitmap) } - v.setOnClickListener { listener.onProductSelected(product) } + unavailable.visibility = if (product.availableToSell) GONE else VISIBLE + unavailable.text = when { + product.availableToSell -> "" + product.remainingStock == 0 -> v.context.getString(R.string.product_out_of_stock) + else -> v.context.getString(R.string.product_unavailable) + } + card.isEnabled = product.availableToSell + v.isEnabled = product.availableToSell + v.alpha = if (product.availableToSell) 1f else 0.5f + v.setOnClickListener { + if (product.availableToSell) listener.onProductSelected(product) + } } } diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/payment/PaymentManager.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/payment/PaymentManager.kt @@ -95,7 +95,12 @@ class PaymentManager( ignoreUnknownKeys = true }.encodeToString(request) Log.d(TAG, "PostOrderRequest: $requestJson") - api.postOrder(merchantConfig, request).handle(::onNetworkError) { orderResponse -> + api.postOrder(merchantConfig, request).handle({ error -> + if (looksLikeInventoryError(error)) { + configManager.refreshInventory() + } + onNetworkError(error) + }) { orderResponse -> assertUiThread() mPayment.value = mPayment.value!!.copy(orderId = orderResponse.orderId) checkTimer.start() @@ -132,12 +137,20 @@ class PaymentManager( cancelPayment(error) } + private fun looksLikeInventoryError(error: String): Boolean { + val normalized = error.lowercase() + return "inventory" in normalized || + "stock" in normalized || + "insufficient" in normalized || + "sold out" in normalized || + "out of stock" in normalized + } + @UiThread fun cancelPayment(error: String? = null) { - // delete unpaid order val merchantConfig = configManager.merchantConfig!! mPayment.value?.let { payment -> - if (!payment.paid && payment.error != null) payment.orderId?.let { orderId -> + if (!payment.paid) payment.orderId?.let { orderId -> Log.d(TAG, "Deleting cancelled and unpaid order $orderId") scope.launch { api.deleteOrder(merchantConfig, orderId) 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 @@ -111,7 +111,8 @@ class ProcessPaymentFragment : Fragment() { hideQrPreview() } if (payment.error != null) { - requireActivity().showError(R.string.error_payment, payment.error) + val (mainText, detailText) = getPaymentErrorDisplay(payment) + requireActivity().showError(mainText, detailText) findNavController().navigateUp() return } @@ -229,6 +230,24 @@ class ProcessPaymentFragment : Fragment() { ) } + private fun getPaymentErrorDisplay(payment: Payment): Pair<String, String> { + val error = payment.error.orEmpty() + if (payment.orderId != null) { + return getString(R.string.error_payment) to error + } + val normalized = error.lowercase() + return when { + "inventory" in normalized || + "stock" in normalized || + "insufficient" in normalized || + "sold out" in normalized || + "out of stock" in normalized -> + getString(R.string.error_inventory_unavailable) to error + else -> + getString(R.string.error_order_creation) to error + } + } + } diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/refund/RefundFragment.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/refund/RefundFragment.kt @@ -57,7 +57,8 @@ class RefundFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { val item = refundManager.toBeRefunded ?: throw IllegalStateException() - ui.amountInputView.setText(item.amount.amountStr) + val amount = item.amount.withSpec(model.configManager.currencySpec) + ui.amountInputView.setText(amount.toString(showSymbol = false)) ui.currencyView.text = item.amount.currency ui.abortButton.setOnClickListener { findNavController().navigateUp() } ui.refundButton.setOnClickListener { onRefundButtonClicked(item) } @@ -68,14 +69,19 @@ class RefundFragment : Fragment() { } private fun onRefundButtonClicked(item: OrderHistoryEntry) { + val maxAmount = item.amount.withSpec(model.configManager.currencySpec) val inputAmount = try { Amount.fromString(item.amount.currency, ui.amountInputView.text.toString()) + .withSpec(model.configManager.currencySpec) } catch (e: AmountParserException) { ui.amountView.error = getString(R.string.refund_error_invalid_amount) return } - if (inputAmount > item.amount) { - ui.amountView.error = getString(R.string.refund_error_max_amount, item.amount.amountStr) + if (inputAmount > maxAmount) { + ui.amountView.error = getString( + R.string.refund_error_max_amount, + maxAmount.toString(showSymbol = false), + ) return } if (inputAmount.isZero()) { diff --git a/merchant-terminal/src/main/res/color/order_control_button_background.xml b/merchant-terminal/src/main/res/color/order_control_button_background.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<selector xmlns:android="http://schemas.android.com/apk/res/android"> + <item android:state_enabled="false" android:alpha="0.12" android:color="?attr/colorSecondary" /> + <item android:color="?attr/colorSecondaryContainer" /> +</selector> diff --git a/merchant-terminal/src/main/res/color/order_control_button_text.xml b/merchant-terminal/src/main/res/color/order_control_button_text.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<selector xmlns:android="http://schemas.android.com/apk/res/android"> + <item android:state_enabled="false" android:alpha="0.38" android:color="?attr/colorOnSurface" /> + <item android:color="?attr/colorOnSecondaryContainer" /> +</selector> diff --git a/merchant-terminal/src/main/res/drawable/mfa_code_digit_background.xml b/merchant-terminal/src/main/res/drawable/mfa_code_digit_background.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="utf-8"?> +<selector xmlns:android="http://schemas.android.com/apk/res/android"> + <item android:state_focused="true"> + <shape android:shape="rectangle"> + <solid android:color="?attr/colorSurface" /> + <stroke + android:width="2dp" + android:color="?attr/colorPrimary" /> + <corners android:radius="6dp" /> + </shape> + </item> + <item> + <shape android:shape="rectangle"> + <solid android:color="?attr/colorSurface" /> + <stroke + android:width="1dp" + android:color="?attr/colorOutline" /> + <corners android:radius="6dp" /> + </shape> + </item> +</selector> diff --git a/merchant-terminal/src/main/res/layout/fragment_merchant_config.xml b/merchant-terminal/src/main/res/layout/fragment_merchant_config.xml @@ -116,6 +116,7 @@ android:hint="@string/config_merchant_url" app:boxBackgroundMode="outline" app:boxBackgroundColor="@android:color/transparent" + app:prefixText="https://" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toTopOf="parent"> @@ -177,91 +178,6 @@ app:layout_constraintBottom_toBottomOf="@id/tokenView" app:layout_constraintEnd_toEndOf="parent" /> - <!-- ─── Expiry Section: radio buttons + conditional date/time pickers ─── --> - <LinearLayout - android:id="@+id/expirySection" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_margin="16dp" - android:orientation="vertical" - app:layout_constraintTop_toBottomOf="@id/tokenView" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintEnd_toEndOf="parent"> - - <!-- Expiry options --> - <RadioGroup - android:id="@+id/expiryOptionGroup" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:orientation="horizontal"> - - <RadioButton - android:id="@+id/neverExpiresOption" - android:layout_width="0dp" - android:layout_weight="1" - android:layout_height="wrap_content" - android:checked="true" - android:text="@string/never_expires"/> - - <RadioButton - android:id="@+id/dateExpiresOption" - android:layout_width="0dp" - android:layout_weight="1" - android:layout_height="wrap_content" - android:text="@string/expires_on"/> - </RadioGroup> - - <!-- Deadline picker (initially hidden) --> - <LinearLayout - android:id="@+id/deadlinePickerLayout" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:orientation="vertical" - android:visibility="gone" - android:layout_marginTop="8dp"> - - <TextView - android:id="@+id/deadlineLabel" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:text="@string/token_validity_deadline" - style="@style/TextAppearance.Material3.BodyMedium" /> - - <LinearLayout - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:orientation="horizontal" - android:layout_marginTop="8dp"> - - <com.google.android.material.button.MaterialButton - style="?attr/materialButtonOutlinedStyle" - android:id="@+id/selectDateButton" - android:layout_width="0dp" - android:layout_weight="1" - android:layout_height="wrap_content" - android:text="@string/pick_date" /> - - <com.google.android.material.button.MaterialButton - style="?attr/materialButtonOutlinedStyle" - android:id="@+id/selectTimeButton" - android:layout_width="0dp" - android:layout_weight="1" - android:layout_height="wrap_content" - android:text="@string/pick_time" /> - </LinearLayout> - - <TextView - android:id="@+id/selectedDeadline" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginTop="8dp" - android:text="@string/no_deadline_set" - style="@style/TextAppearance.Material3.BodyMedium"/> - </LinearLayout> - </LinearLayout> - - - <!-- save-password checkbox --> <CheckBox android:id="@+id/saveTokenCheckBox" @@ -274,7 +190,7 @@ android:text="@string/config_save_password" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toStartOf="@+id/okNewButton" - app:layout_constraintTop_toBottomOf="@id/expirySection" + app:layout_constraintTop_toBottomOf="@id/tokenView" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintHorizontal_chainStyle="spread_inside" /> @@ -286,7 +202,7 @@ android:layout_margin="16dp" android:text="@string/config_ok" app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintTop_toBottomOf="@id/expirySection" + app:layout_constraintTop_toBottomOf="@id/tokenView" app:layout_constraintBottom_toBottomOf="parent" /> <!-- progress spinner --> diff --git a/merchant-terminal/src/main/res/layout/fragment_order.xml b/merchant-terminal/src/main/res/layout/fragment_order.xml @@ -25,9 +25,9 @@ android:layout_width="0dp" android:layout_height="0dp" android:layout_marginBottom="8dp" - app:layout_constraintBottom_toTopOf="@+id/buttonBar" - app:layout_constraintEnd_toStartOf="@+id/guideline1" - app:layout_constraintStart_toStartOf="parent" + app:layout_constraintBottom_toTopOf="@+id/orderControlsBar" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="@+id/guideline2" app:layout_constraintTop_toTopOf="parent" tools:layout="@layout/fragment_order_state" /> @@ -44,7 +44,7 @@ android:layout_width="0dp" android:layout_height="0dp" android:layout_marginBottom="8dp" - app:layout_constraintBottom_toTopOf="@+id/buttonBar" + app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toStartOf="@+id/guideline2" app:layout_constraintStart_toStartOf="@+id/guideline1" app:layout_constraintTop_toTopOf="parent" @@ -57,27 +57,38 @@ android:orientation="vertical" app:layout_constraintGuide_percent="0.75" /> + <View + android:id="@+id/orderDivider" + android:layout_width="1dp" + android:layout_height="0dp" + android:background="?attr/colorOutlineVariant" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="@id/guideline2" + app:layout_constraintStart_toStartOf="@id/guideline2" + app:layout_constraintTop_toTopOf="parent" /> + <androidx.fragment.app.FragmentContainerView android:id="@+id/fragment3" android:name="net.taler.merchantpos.order.CategoriesFragment" android:layout_width="0dp" android:layout_height="0dp" android:layout_marginBottom="8dp" - app:layout_constraintBottom_toTopOf="@+id/buttonBar" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="@+id/guideline2" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toStartOf="@+id/guideline1" + app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" tools:layout="@layout/fragment_categories" /> <HorizontalScrollView - android:id="@+id/buttonBar" + android:id="@+id/orderControlsBar" android:layout_width="0dp" android:layout_height="wrap_content" - android:scrollbars="horizontal" + android:layout_marginBottom="12dp" android:fadeScrollbars="false" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toStartOf="@id/completeButton"> + android:scrollbars="horizontal" + app:layout_constraintBottom_toTopOf="@id/completeButton" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="@id/guideline2"> <LinearLayout android:layout_width="wrap_content" @@ -85,79 +96,59 @@ android:orientation="horizontal"> <Button - android:id="@+id/restartButton" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginStart="8dp" - android:backgroundTint="@color/button_bottom" - android:text="@string/order_restart" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintStart_toStartOf="parent" /> - - <Button android:id="@+id/plusButton" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginStart="16dp" + android:layout_marginStart="12dp" + android:backgroundTint="@color/order_control_button_background" android:minWidth="48dp" android:text="+1" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintStart_toEndOf="@+id/minusButton" + android:textColor="@color/order_control_button_text" tools:ignore="HardcodedText" /> <Button android:id="@+id/minusButton" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginStart="8dp" + android:layout_marginStart="12dp" + android:backgroundTint="@color/order_control_button_background" android:minWidth="48dp" android:text="-1" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintStart_toEndOf="@+id/restartButton" + android:textColor="@color/order_control_button_text" tools:ignore="HardcodedText" /> - <ImageButton - android:id="@+id/customButton" - android:layout_width="48dp" - android:layout_height="48dp" - android:layout_marginStart="16dp" - android:layout_marginEnd="8dp" - android:backgroundTint="?colorPrimary" - app:srcCompat="@drawable/ic_dialpad" - android:contentDescription="@string/order_custom" /> - <Button - android:id="@+id/prevButton" + android:id="@+id/tipButton" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginStart="16dp" - android:backgroundTint="@color/button_bottom" - android:text="@string/order_previous" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintStart_toEndOf="@+id/plusButton" /> - - <Button - android:id="@+id/nextButton" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginStart="8dp" - android:backgroundTint="@color/button_bottom" - android:text="@string/order_next" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintStart_toEndOf="@+id/prevButton" /> + android:layout_marginStart="12dp" + android:layout_marginEnd="12dp" + android:backgroundTint="@color/order_control_button_background" + android:minWidth="48dp" + android:text="@string/order_custom_product_default" + android:textColor="@color/order_control_button_text" /> </LinearLayout> </HorizontalScrollView> - <Button + <com.google.android.material.button.MaterialButton android:id="@+id/completeButton" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginEnd="8dp" + android:layout_width="0dp" + android:layout_height="96dp" android:backgroundTint="@color/complete_button_bottom" + android:insetLeft="0dp" + android:insetTop="0dp" + android:insetRight="0dp" + android:insetBottom="0dp" + android:maxLines="1" + android:minWidth="0dp" + android:minHeight="0dp" android:text="@string/order_complete" + android:textSize="18sp" + android:textStyle="bold" + app:cornerRadius="0dp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintHorizontal_bias="1.0" /> + app:layout_constraintStart_toStartOf="@+id/guideline2" /> </androidx.constraintlayout.widget.ConstraintLayout> diff --git a/merchant-terminal/src/main/res/layout/fragment_order_state.xml b/merchant-terminal/src/main/res/layout/fragment_order_state.xml @@ -24,28 +24,10 @@ android:id="@+id/orderList" android:layout_width="0dp" android:layout_height="0dp" - app:layout_constraintBottom_toTopOf="@+id/totalView" + app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" tools:listitem="@layout/list_item_order" /> - <TextView - android:id="@+id/totalView" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:background="@color/highlightedBackground" - android:elevation="2dp" - android:gravity="center_vertical|end" - android:padding="8dp" - android:textColor="?android:textColorPrimary" - android:textSize="16sp" - android:visibility="invisible" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@+id/orderList" - tools:text="Total: 23.75 TESTKUDOS" - tools:visibility="visible" /> - </androidx.constraintlayout.widget.ConstraintLayout> diff --git a/merchant-terminal/src/main/res/layout/list_item_category.xml b/merchant-terminal/src/main/res/layout/list_item_category.xml @@ -24,6 +24,8 @@ android:id="@+id/button" android:layout_width="0dp" android:layout_height="wrap_content" + android:backgroundTint="?attr/colorSecondaryContainer" + android:textColor="?attr/colorOnSecondaryContainer" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" diff --git a/merchant-terminal/src/main/res/layout/list_item_history.xml b/merchant-terminal/src/main/res/layout/list_item_history.xml @@ -45,7 +45,7 @@ android:textSize="20sp" android:textStyle="bold" app:layout_constraintBottom_toBottomOf="@+id/orderSummaryView" - app:layout_constraintEnd_toStartOf="@+id/refundButton" + app:layout_constraintEnd_toStartOf="@+id/actionContainer" app:layout_constraintStart_toEndOf="@+id/orderSummaryView" app:layout_constraintTop_toTopOf="parent" app:layout_constraintVertical_bias="0.0" @@ -75,22 +75,38 @@ android:layout_marginEnd="16dp" android:textSize="20sp" app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toStartOf="@+id/refundButton" + app:layout_constraintEnd_toStartOf="@+id/actionContainer" app:layout_constraintStart_toEndOf="@+id/orderIdView" app:layout_constraintTop_toBottomOf="@+id/orderAmountView" app:layout_constraintVertical_bias="1.0" tools:text="3 hrs. ago" /> - <ImageButton - android:id="@+id/refundButton" - android:layout_width="48dp" - android:layout_height="48dp" - android:backgroundTint="?colorPrimary" - android:contentDescription="@string/history_refund" + <FrameLayout + android:id="@+id/actionContainer" + android:layout_width="wrap_content" + android:layout_height="wrap_content" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintTop_toTopOf="parent" - app:srcCompat="@drawable/ic_cash_refund" - app:tint="?attr/colorOnPrimary" /> + app:layout_constraintTop_toTopOf="parent"> + + <ImageButton + android:id="@+id/refundButton" + android:layout_width="48dp" + android:layout_height="48dp" + android:backgroundTint="?colorPrimary" + android:contentDescription="@string/history_refund" + app:srcCompat="@drawable/ic_cash_refund" + app:tint="?attr/colorOnPrimary" /> + + <com.google.android.material.button.MaterialButton + android:id="@+id/deleteButton" + style="@style/Widget.MaterialComponents.Button" + android:layout_width="wrap_content" + android:layout_height="48dp" + android:minWidth="0dp" + android:text="@string/order_delete" + android:textAllCaps="false" + android:visibility="gone" /> + </FrameLayout> </androidx.constraintlayout.widget.ConstraintLayout> diff --git a/merchant-terminal/src/main/res/layout/list_item_product.xml b/merchant-terminal/src/main/res/layout/list_item_product.xml @@ -72,10 +72,25 @@ android:layout_height="wrap_content" android:layout_marginTop="8dp" android:textColor="?android:textColorSecondary" + app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@+id/description" tools:text="7.95" /> + <TextView + android:id="@+id/unavailableLabel" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_marginTop="4dp" + android:text="@string/product_unavailable" + android:textColor="?attr/colorError" + android:textStyle="bold" + android:visibility="gone" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/price" + tools:visibility="visible" /> + </androidx.constraintlayout.widget.ConstraintLayout> </com.google.android.material.card.MaterialCardView> diff --git a/merchant-terminal/src/main/res/menu/activity_main_drawer.xml b/merchant-terminal/src/main/res/menu/activity_main_drawer.xml @@ -21,6 +21,7 @@ <group android:checkableBehavior="single"> <item android:id="@+id/nav_amountEntry" + android:checked="true" android:icon="@drawable/ic_dialpad" android:title="@string/menu_amount_entry" /> <item diff --git a/merchant-terminal/src/main/res/menu/order.xml b/merchant-terminal/src/main/res/menu/order.xml @@ -17,8 +17,23 @@ <menu xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto"> <item + android:id="@+id/orderRestart" + android:title="@string/order_restart" + app:showAsAction="always" /> + <item + android:id="@+id/orderDelete" + android:title="@string/order_delete" + app:showAsAction="never" /> + <item + android:id="@+id/orderPrevious" + android:title="@string/order_previous" + app:showAsAction="always" /> + <item + android:id="@+id/orderNext" + android:title="@string/order_next" + app:showAsAction="always" /> + <item android:id="@+id/reload" - android:icon="@drawable/ic_menu_reload" android:title="@string/menu_reload" - app:showAsAction="ifRoom" /> -</menu> -\ No newline at end of file + app:showAsAction="always|withText" /> +</menu> diff --git a/merchant-terminal/src/main/res/values-de/strings.xml b/merchant-terminal/src/main/res/values-de/strings.xml @@ -7,13 +7,15 @@ <string name="menu_settings">Einstellungen</string> <string name="order_label_title">Bestellung #%s</string> <string name="order_total">Summe: %s</string> - <string name="order_restart">Neustart</string> + <string name="order_restart">Leeren</string> <string name="order_undo">Rückgängig machen</string> <string name="order_previous">Vorherige Bestellung</string> <string name="order_next">Nächste Bestellung</string> - <string name="order_complete">Vollständige Bestellung</string> + <string name="order_complete">Abrechnung</string> + <string name="order_complete_with_amount">Abrechnung %s</string> + <string name="settings_app_title">App-Einstellungen</string> <string name="config_url">Konfigurations-URL</string> - <string name="config_ok">Konfiguration abrufen</string> + <string name="config_ok">Verbinden</string> <string name="config_auth_error">Fehler: Benutzername oder Passwort ungültig</string> <string name="config_error_network">Fehler: Es konnte keine Verbindung zum Konfigurationsserver hergestellt werden</string> <string name="config_error_category">Fehler: Keine gültige Produktkategorie gefunden</string> @@ -69,10 +71,10 @@ <string name="order_custom_add_button">Hinzufügen</string> <string name="config_old_label">JSON-Datei (alt)</string> <string name="config_setup_password">Händler (neu)</string> - <string name="config_merchant_url">Händler-URL</string> + <string name="config_merchant_url">Händlerportal-URL</string> <string name="config_token">Passwort</string> <string name="order_custom">Benutzerdefinierten Artikel hinzufügen</string> - <string name="menu_reload">Erneut laden</string> + <string name="menu_reload">Inventar neu laden</string> <string name="order_custom_product">Bezeichnung des Einzelartikels</string> <string name="host_apdu_service_desc">NFC-Zahlungen mit Taler Merchant</string> <string name="toast_reloading">Inventar wird neu geladen</string> @@ -97,4 +99,54 @@ <string name="amount_entry_product_description">Normale Bestellung</string> <string name="amount_entry_error_zero">Bitte geben Sie einen Betrag ein</string> <string name="amount_entry_error_wrong_currency">Dies ist keine unterstützte Währung</string> + <string name="menu_amount_entry">Nach Betrag bestellen</string> + <string name="product_category_all_objects">Alles</string> + <string name="settings_language_label">Sprache</string> + <string name="settings_language_hint">App-Sprache</string> + <string name="settings_language_system_default">Systemvorgabe</string> + <string name="settings_subtitle">Wählen Sie die App-Sprache und verwalten Sie die Zugangsdaten der Händlerinstanz.</string> + <string name="settings_language_description">Wird sofort auf die App-Oberfläche angewendet.</string> + <string name="settings_initial_order_label">Startbildschirm für Bestellungen</string> + <string name="settings_initial_order_hint">Standard-Bestellmodus</string> + <string name="settings_initial_order_description">Wird beim Start der App oder nach dem Laden der Konfiguration angezeigt.</string> + <string name="settings_instance_title">Gespeicherte Instanz</string> + <string name="settings_instance_description">Händlerportal-URL, Benutzername, Token und weitere instanzspezifische Einstellungen ändern.</string> + <string name="settings_instance_button">Gespeicherte Instanz ändern</string> + <string name="amount_entry_charge">Berechnen</string> + <string name="amount_entry_create_order_charge">Bestellung erstellen (Betrag berechnen)</string> + <string name="amount_entry_backspace">Rücktaste</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> + <string name="product_unavailable">Nicht verfugbar</string> + <string name="product_out_of_stock">Nicht auf Lager</string> + <string name="order_delete">Bestellung loschen</string> + <string name="error_order_creation">Fehler: Bestellung konnte nicht erstellt werden</string> + <string name="error_inventory_unavailable">Fehler: Nicht genug Inventar verfugbar</string> + <string name="error_delete_order">Fehler: Bestellung konnte nicht geloscht werden</string> </resources> diff --git a/merchant-terminal/src/main/res/values-es/strings.xml b/merchant-terminal/src/main/res/values-es/strings.xml @@ -6,7 +6,7 @@ <string name="menu_settings">Ajustes</string> <string name="order_label_title">Pedido #%s</string> <string name="order_total">Total: %s</string> - <string name="order_restart">Reiniciar</string> + <string name="order_restart">Borrar</string> <string name="order_undo">Deshacer</string> <string name="order_previous">Anterior</string> <string name="order_next">Siguiente</string> @@ -75,4 +75,52 @@ <string name="config_setup_password">Vendedor (nuevo)</string> <string name="config_token">Contraseña</string> <string name="host_apdu_service_desc">Pagos NFC en Taler Merchant</string> + <string name="menu_amount_entry">Pedido por importe</string> + <string name="product_category_all_objects">Todo</string> + <string name="order_complete_with_amount">Cuenta %s</string> + <string name="settings_language_label">Idioma</string> + <string name="settings_language_hint">Idioma de la app</string> + <string name="settings_language_system_default">Predeterminado del sistema</string> + <string name="settings_subtitle">Elige el idioma de la app y gestiona las credenciales de la instancia del comercio.</string> + <string name="settings_app_title">Ajustes de la app</string> + <string name="settings_language_description">Se aplica inmediatamente a la interfaz de la app.</string> + <string name="settings_initial_order_label">Pantalla inicial de pedido</string> + <string name="settings_initial_order_hint">Modo de pedido predeterminado</string> + <string name="settings_initial_order_description">Se muestra al iniciar la app o después de cargar la configuración.</string> + <string name="settings_instance_title">Instancia guardada</string> + <string name="settings_instance_description">Cambia la URL del portal del comercio, el nombre de usuario, el token y otros ajustes de la instancia.</string> + <string name="settings_instance_button">Modificar instancia guardada</string> + <string name="amount_entry_label">Importe</string> + <string name="amount_entry_charge">Cobrar</string> + <string name="amount_entry_create_order_charge">Crear pedido (cobrar importe)</string> + <string name="amount_entry_clear">Borrar</string> + <string name="amount_entry_backspace">Retroceso</string> + <string name="amount_entry_product_description">Pedido normal</string> + <string name="amount_entry_error_zero">Introduce un importe</string> + <string name="amount_entry_error_wrong_currency">Moneda no admitida</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> + <string name="menu_reload">Recargar inventario</string> + <string name="product_image">Imagen del producto</string> + <string name="product_category_uncategorized">Sin categoría</string> + <string name="toast_reloading">Recargando inventario</string> + <string name="config_qr_label">Configuración QR</string> + <string name="scan_qr_hint">Escanea el código QR del backoffice del comercio</string> + <string name="forever">Para siempre</string> + <string name="custom_duration">Duración personalizada</string> + <string name="duration">Duración</string> + <string name="token_validity_deadline">Fecha límite de validez del token:</string> + <string name="expires_on">Caduca el…</string> + <string name="never_expires">Nunca caduca</string> + <string name="no_deadline_set">Sin fecha límite</string> + <string name="pick_date">Elegir fecha</string> + <string name="pick_time">Elegir hora</string> + <string name="session_expired_toast">La sesión ha caducado – introduce de nuevo tus credenciales</string> + <string name="config_fragment_camera_needed_text">Se requiere permiso de cámara para escanear QR</string> + <string name="product_unavailable">No disponible</string> + <string name="product_out_of_stock">Agotado</string> + <string name="order_delete">Eliminar pedido</string> + <string name="error_order_creation">Error: no se pudo crear el pedido</string> + <string name="error_inventory_unavailable">Error: no hay inventario suficiente disponible</string> + <string name="error_delete_order">Error: no se pudo eliminar el pedido</string> </resources> diff --git a/merchant-terminal/src/main/res/values-fi/strings.xml b/merchant-terminal/src/main/res/values-fi/strings.xml @@ -62,11 +62,65 @@ <string name="error_history">Virhe noudettaessa tilaushistoriaa</string> <string name="toast_back_to_exit">Napsauta «takaisin» uudelleen poistuaksesi</string> <string name="menu_settings">Asetukset</string> - <string name="order_restart">Uudelleenkäynnistys</string> + <string name="order_restart">Tyhjennä</string> <string name="order_next">Seuraava</string> <string name="order_custom_add_button">Lisää</string> <string name="config_auth_error">Virhe: Virheellinen käyttäjätunnus tai salasana</string> <string name="config_fetching_label">Haetaan määritystä</string> <string name="config_docs">Katso määritysmuodon <a href="https://docs.taler.net/taler-merchant-pos-terminal.html#apis-and-data-formats">dokumentaatiosta</a>.</string> <string name="refund_error_deadline">Palautusaika on umpeutunut</string> + <string name="menu_amount_entry">Tilaus summan mukaan</string> + <string name="product_category_all_objects">Kaikki</string> + <string name="order_complete_with_amount">Lasku %s</string> + <string name="settings_language_label">Kieli</string> + <string name="settings_language_hint">Sovelluksen kieli</string> + <string name="settings_language_system_default">Järjestelmän oletus</string> + <string name="settings_subtitle">Valitse sovelluksen kieli ja hallitse kauppiasinstanssin tunnistetietoja.</string> + <string name="settings_app_title">Sovelluksen asetukset</string> + <string name="settings_language_description">Käytetään heti sovelluksen käyttöliittymässä.</string> + <string name="settings_initial_order_label">Aloitusnäkymä tilauksille</string> + <string name="settings_initial_order_hint">Oletustilaustapa</string> + <string name="settings_initial_order_description">Näytetään, kun sovellus käynnistyy tai kun määritykset on ladattu.</string> + <string name="settings_instance_title">Tallennettu instanssi</string> + <string name="settings_instance_description">Muuta kauppiasportaalin URL-osoitetta, käyttäjätunnusta, tokenia ja muita instanssin asetuksia.</string> + <string name="settings_instance_button">Muokkaa tallennettua instanssia</string> + <string name="config_merchant_url">Kauppiasportaalin URL</string> + <string name="amount_entry_label">Summa</string> + <string name="amount_entry_charge">Veloita</string> + <string name="amount_entry_create_order_charge">Luo tilaus (veloita summa)</string> + <string name="amount_entry_clear">Tyhjennä</string> + <string name="amount_entry_backspace">Askelpalautin</string> + <string name="amount_entry_product_description">Tavallinen tilaus</string> + <string name="amount_entry_error_zero">Syötä summa</string> + <string name="amount_entry_error_wrong_currency">Valuuttaa ei tueta</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> + <string name="menu_reload">Lataa varasto uudelleen</string> + <string name="product_image">Tuotekuva</string> + <string name="product_category_uncategorized">Luokittelematon</string> + <string name="config_old_label">JSON-tiedosto (vanha)</string> + <string name="config_setup_password">Salasana</string> + <string name="config_old_deprecation">Tämä määritystapa on vanhentunut, käytä uutta kauppias-API-määritystä.</string> + <string name="config_token">Käyttöoikeustunnus</string> + <string name="config_qr_label">QR-määritys</string> + <string name="scan_qr_hint">Skannaa QR-koodi kauppiaan backofficesta</string> + <string name="forever">Ikuisesti</string> + <string name="custom_duration">Mukautettu kesto</string> + <string name="duration">Kesto</string> + <string name="token_validity_deadline">Tunnuksen voimassaolon määräaika:</string> + <string name="expires_on">Vanhenee…</string> + <string name="never_expires">Ei vanhene koskaan</string> + <string name="no_deadline_set">Määräaikaa ei ole asetettu</string> + <string name="pick_date">Valitse päivämäärä</string> + <string name="pick_time">Valitse aika</string> + <string name="session_expired_toast">Istunto vanheni – syötä tunnistetiedot uudelleen</string> + <string name="config_fragment_camera_needed_text">QR-skannaus vaatii kameran käyttöoikeuden</string> + <string name="host_apdu_service_desc">Taler Merchant NFC -maksut</string> + <string name="toast_reloading">Ladataan varastoa uudelleen</string> + <string name="product_unavailable">Ei saatavilla</string> + <string name="product_out_of_stock">Loppu varastosta</string> + <string name="order_delete">Poista tilaus</string> + <string name="error_order_creation">Virhe: tilausta ei voitu luoda</string> + <string name="error_inventory_unavailable">Virhe: varastoa ei ole riittävästi saatavilla</string> + <string name="error_delete_order">Virhe: tilausta ei voitu poistaa</string> </resources> diff --git a/merchant-terminal/src/main/res/values-fr/strings.xml b/merchant-terminal/src/main/res/values-fr/strings.xml @@ -15,7 +15,7 @@ <string name="app_name_short">Terminal marchand</string> <string name="menu_order">Commandes</string> <string name="order_label_title">Commande numéro %s</string> - <string name="order_restart">Redémarrer</string> + <string name="order_restart">Effacer</string> <string name="order_undo">Annuler</string> <string name="order_previous">Précédent</string> <string name="order_next">Suivant</string> @@ -75,7 +75,7 @@ <string name="order_custom">Ajouter un produit personnalisé</string> <string name="order_custom_product">Nom de produit personnalisé</string> <string name="order_custom_product_default">Conseil</string> - <string name="menu_reload">Actualiser</string> + <string name="menu_reload">Recharger l’inventaire</string> <string name="config_old_deprecation">Cette méthode de configuration est obsolète, veuillez utiliser la nouvelle configuration de l\'API marchande.</string> <string name="toast_reloading">Rechargement de l\'inventaire</string> <string name="host_apdu_service_desc">Paiements NFC Taler Merchant</string> @@ -88,4 +88,39 @@ <string name="pick_date">Choisir la date</string> <string name="pick_time">Choisir l\'heure</string> <string name="session_expired_toast">La session a expiré – veuillez saisir à nouveau vos informations d\'identification</string> + <string name="menu_amount_entry">Commande par montant</string> + <string name="product_category_all_objects">Tout</string> + <string name="order_complete_with_amount">Note %s</string> + <string name="settings_language_label">Langue</string> + <string name="settings_language_hint">Langue de l’application</string> + <string name="settings_language_system_default">Paramètre système</string> + <string name="settings_subtitle">Choisissez la langue de l’application et gérez les identifiants de l’instance commerçant.</string> + <string name="settings_app_title">Paramètres de l’application</string> + <string name="settings_language_description">S’applique immédiatement à l’interface de l’application.</string> + <string name="settings_initial_order_label">Écran de commande initial</string> + <string name="settings_initial_order_hint">Mode de commande par défaut</string> + <string name="settings_initial_order_description">Affiché au démarrage de l’application ou après le chargement de la configuration.</string> + <string name="settings_instance_title">Instance enregistrée</string> + <string name="settings_instance_description">Modifier l’URL du portail commerçant, le nom d’utilisateur, le jeton et les autres paramètres de l’instance.</string> + <string name="settings_instance_button">Modifier l’instance enregistrée</string> + <string name="amount_entry_label">Montant</string> + <string name="amount_entry_charge">Encaisser</string> + <string name="amount_entry_create_order_charge">Créer une commande (encaisser le montant)</string> + <string name="amount_entry_clear">Effacer</string> + <string name="amount_entry_backspace">Retour arrière</string> + <string name="amount_entry_product_description">Commande normale</string> + <string name="amount_entry_error_zero">Saisissez un montant</string> + <string name="amount_entry_error_wrong_currency">Devise non prise en charge</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> + <string name="config_qr_label">Configuration QR</string> + <string name="scan_qr_hint">Scannez le code QR depuis le backoffice commerçant</string> + <string name="never_expires">N’expire jamais</string> + <string name="config_fragment_camera_needed_text">L’autorisation de la caméra est requise pour scanner le QR</string> + <string name="product_unavailable">Indisponible</string> + <string name="product_out_of_stock">Rupture de stock</string> + <string name="order_delete">Supprimer la commande</string> + <string name="error_order_creation">Erreur : impossible de créer la commande</string> + <string name="error_inventory_unavailable">Erreur : stock disponible insuffisant</string> + <string name="error_delete_order">Erreur : impossible de supprimer la commande</string> </resources> diff --git a/merchant-terminal/src/main/res/values-it/strings.xml b/merchant-terminal/src/main/res/values-it/strings.xml @@ -22,4 +22,103 @@ <string name="refund_confirm">Approva rimborso</string> <string name="refund_error_already_refunded">Già rimborsato</string> <string name="error_payment">Errore: Nessun pagamento ricevuto</string> + <string name="app_name">GNU Taler Punto vendita</string> + <string name="app_name_short">Terminale commerciante</string> + <string name="menu_amount_entry">Ordine per importo</string> + <string name="menu_history">Cronologia</string> + <string name="menu_reload">Ricarica inventario</string> + <string name="product_image">Immagine prodotto</string> + <string name="product_category_all_objects">Tutto</string> + <string name="product_category_uncategorized">Senza categoria</string> + <string name="order_restart">Cancella</string> + <string name="order_previous">Prec</string> + <string name="order_next">Succ</string> + <string name="order_custom">Aggiungi prodotto personalizzato</string> + <string name="order_complete">Conto</string> + <string name="order_complete_with_amount">Conto %s</string> + <string name="order_custom_product">Nome prodotto personalizzato</string> + <string name="order_custom_product_default">Mancia</string> + <string name="order_custom_add_button">Aggiungi</string> + <string name="amount_entry_label">Importo</string> + <string name="amount_entry_charge">Addebita</string> + <string name="amount_entry_create_order_charge">Crea ordine (addebita importo)</string> + <string name="amount_entry_clear">Cancella</string> + <string name="amount_entry_backspace">Backspace</string> + <string name="amount_entry_product_description">Ordine normale</string> + <string name="amount_entry_error_zero">Inserisci un importo</string> + <string name="amount_entry_error_wrong_currency">Valuta non supportata</string> + <string name="settings_language_label">Lingua</string> + <string name="settings_language_hint">Lingua dell’app</string> + <string name="settings_language_system_default">Predefinita di sistema</string> + <string name="settings_subtitle">Scegli la lingua dell’app e gestisci le credenziali dell’istanza commerciante.</string> + <string name="settings_app_title">Impostazioni app</string> + <string name="settings_language_description">Applicata immediatamente all’interfaccia dell’app.</string> + <string name="settings_initial_order_label">Schermata iniziale ordine</string> + <string name="settings_initial_order_hint">Modalità ordine predefinita</string> + <string name="settings_initial_order_description">Mostrata all’avvio dell’app o dopo il caricamento della configurazione.</string> + <string name="settings_instance_title">Istanza salvata</string> + <string name="settings_instance_description">Modifica URL del portale commerciante, nome utente, token e altre impostazioni dell’istanza.</string> + <string name="settings_instance_button">Modifica istanza salvata</string> + <string name="config_label">Impostazioni commerciante</string> + <string name="config_old_label">File JSON (vecchio)</string> + <string name="config_setup_password">Password</string> + <string name="config_old_deprecation">Questo metodo di configurazione è deprecato, usa la nuova configurazione API commerciante.</string> + <string name="config_url">URL configurazione</string> + <string name="config_merchant_url">URL portale commerciante</string> + <string name="config_username">Nome utente</string> + <string name="config_token">Token di accesso</string> + <string name="config_ok">Connetti</string> + <string name="config_auth_error">Errore: nome utente o password non validi</string> + <string name="config_error_network">Errore: impossibile connettersi al server di configurazione</string> + <string name="config_error_category">Errore: nessuna categoria prodotto valida trovata</string> + <string name="config_error_currency">Errore: il prodotto %1$s ha valuta %2$s, ma era attesa %3$s</string> + <string name="config_error_malformed">Errore: il JSON di configurazione non è valido</string> + <string name="config_error_product_category_id">Errore: il prodotto %1$s fa riferimento alla categoria sconosciuta ID %2$d</string> + <string name="config_fetching">Recupero configurazione…</string> + <string name="config_changed">Passato al nuovo commerciante con %s</string> + <string name="config_fetching_label">Recupero configurazione</string> + <string name="config_docs">Consulta <a href="https://docs.taler.net/taler-merchant-pos-terminal.html#apis-and-data-formats">la documentazione</a> per il formato della configurazione.</string> + <string name="config_qr_label">Configurazione QR</string> + <string name="scan_qr_hint">Scansiona il codice QR dal backoffice commerciante</string> + <string name="forever">Per sempre</string> + <string name="custom_duration">Durata personalizzata</string> + <string name="duration">Durata</string> + <string name="token_validity_deadline">Scadenza validità token:</string> + <string name="expires_on">Scade il…</string> + <string name="never_expires">Non scade mai</string> + <string name="no_deadline_set">Nessuna scadenza impostata</string> + <string name="pick_date">Scegli data</string> + <string name="pick_time">Scegli ora</string> + <string name="session_expired_toast">Sessione scaduta – inserisci di nuovo le credenziali</string> + <string name="config_fragment_camera_needed_text">È richiesta l’autorizzazione della fotocamera per la scansione QR</string> + <string name="payment_intro_nfc">Chiedi al cliente di scansionare il codice QR o usare NFC per pagare.</string> + <string name="payment_intro">Chiedi al cliente di scansionare il codice QR per pagare.</string> + <string name="payment_claimed">In attesa che il cliente confermi il pagamento…</string> + <string name="payment_order_id">Ricevuta #%s</string> + <string name="payment_process_label">Pagamento richiesto</string> + <string name="history_unpaid">Non pagato</string> + <string name="history_refund">Rimborso</string> + <string name="refund_abort">Interrompi</string> + <string name="refund_complete">Ricevuto</string> + <string name="refund_error_max_amount">Maggiore dell’importo dell’ordine di %s</string> + <string name="refund_error_zero">Deve essere un importo positivo</string> + <string name="refund_error_backend">Errore durante l’elaborazione del rimborso</string> + <string name="refund_error_deadline">La scadenza del rimborso è passata</string> + <string name="refund_intro_nfc">Chiedi al cliente di scansionare il codice QR o usare NFC per ricevere il rimborso</string> + <string name="refund_intro">Chiedi al cliente di scansionare il codice QR per ricevere il rimborso</string> + <string name="refund_order_ref">Riferimento acquisto: %1$s\n\n%2$s</string> + <string name="error_timeout">Nessun pagamento effettuato entro il periodo previsto, riprova.</string> + <string name="error_cancelled">Pagamento annullato</string> + <string name="error_history">Errore durante il recupero della cronologia ordini</string> + <string name="toast_reloading">Ricaricamento inventario</string> + <string name="toast_back_to_exit">Premi di nuovo «indietro» per uscire</string> + <string name="host_apdu_service_desc">Pagamenti NFC Taler Merchant</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> + <string name="product_unavailable">Non disponibile</string> + <string name="product_out_of_stock">Esaurito</string> + <string name="order_delete">Elimina ordine</string> + <string name="error_order_creation">Errore: impossibile creare l’ordine</string> + <string name="error_inventory_unavailable">Errore: inventario disponibile insufficiente</string> + <string name="error_delete_order">Errore: impossibile eliminare l’ordine</string> </resources> diff --git a/merchant-terminal/src/main/res/values-iw/strings.xml b/merchant-terminal/src/main/res/values-iw/strings.xml @@ -2,4 +2,123 @@ <resources> <string name="menu_settings">הגדרות</string> <string name="config_save_password">שמירת הסיסמה</string> + <string name="app_name">נקודת מכירה GNU Taler</string> + <string name="app_name_short">מסוף סוחר</string> + <string name="project_name">GNU Taler</string> + <string name="menu_order">הזמנה לפי מלאי</string> + <string name="menu_amount_entry">הזמנה לפי סכום</string> + <string name="menu_history">היסטוריה</string> + <string name="menu_reload">טעינת מלאי מחדש</string> + <string name="product_image">תמונת מוצר</string> + <string name="product_category_all_objects">הכול</string> + <string name="product_category_uncategorized">ללא קטגוריה</string> + <string name="order_label_title">הזמנה #%s</string> + <string name="order_total">סה״כ: %s</string> + <string name="order_restart">נקה</string> + <string name="order_undo">בטל</string> + <string name="order_previous">הקודם</string> + <string name="order_next">הבא</string> + <string name="order_custom">הוסף מוצר מותאם</string> + <string name="order_complete">חשבון</string> + <string name="order_complete_with_amount">חשבון %s</string> + <string name="order_custom_product">שם מוצר מותאם</string> + <string name="order_custom_product_default">טיפ</string> + <string name="order_custom_add_button">הוסף</string> + <string name="amount_entry_label">סכום</string> + <string name="amount_entry_charge">חיוב</string> + <string name="amount_entry_create_order_charge">יצירת הזמנה (חיוב סכום)</string> + <string name="amount_entry_clear">נקה</string> + <string name="amount_entry_backspace">מחיקה לאחור</string> + <string name="amount_entry_product_description">הזמנה רגילה</string> + <string name="amount_entry_error_zero">יש להזין סכום</string> + <string name="amount_entry_error_wrong_currency">מטבע לא נתמך</string> + <string name="config_label">הגדרות סוחר</string> + <string name="settings_language_label">שפה</string> + <string name="settings_language_hint">שפת האפליקציה</string> + <string name="settings_language_system_default">ברירת מחדל של המערכת</string> + <string name="settings_subtitle">בחרו את שפת האפליקציה ונהלו את פרטי הגישה של מופע הסוחר.</string> + <string name="settings_app_title">הגדרות אפליקציה</string> + <string name="settings_language_description">מוחל מיד על ממשק האפליקציה.</string> + <string name="settings_initial_order_label">מסך הזמנה התחלתי</string> + <string name="settings_initial_order_hint">מצב הזמנה ברירת מחדל</string> + <string name="settings_initial_order_description">מוצג כשהאפליקציה מופעלת או אחרי טעינת התצורה.</string> + <string name="settings_instance_title">מופע שמור</string> + <string name="settings_instance_description">שינוי כתובת פורטל הסוחר, שם משתמש, אסימון והגדרות נוספות של המופע.</string> + <string name="settings_instance_button">שינוי מופע שמור</string> + <string name="config_old_label">קובץ JSON (ישן)</string> + <string name="config_setup_password">סיסמה</string> + <string name="config_old_deprecation">שיטת תצורה זו מיושנת, השתמשו בתצורת API הסוחר החדשה.</string> + <string name="config_url">כתובת תצורה</string> + <string name="config_merchant_url">כתובת פורטל הסוחר</string> + <string name="config_username">שם משתמש</string> + <string name="config_password">סיסמה</string> + <string name="config_token">אסימון גישה</string> + <string name="config_ok">התחבר</string> + <string name="config_auth_error">שגיאה: שם משתמש או סיסמה לא תקינים</string> + <string name="config_error_network">שגיאה: לא ניתן להתחבר לשרת התצורה</string> + <string name="config_error_category">שגיאה: לא נמצאה קטגוריית מוצרים תקינה</string> + <string name="config_error_malformed">שגיאה: קובץ ה-JSON של התצורה פגום</string> + <string name="config_error_currency">שגיאה: למוצר %1$s יש מטבע %2$s, אך נדרש %3$s</string> + <string name="config_error_product_category_id">שגיאה: המוצר %1$s מפנה למזהה קטגוריה לא ידוע %2$d</string> + <string name="config_error_product_zero">שגיאה: לא נמצאו מוצרים תקינים</string> + <string name="config_error_unknown">שגיאה: תצורה לא תקינה</string> + <string name="config_fetching">טוען תצורה…</string> + <string name="config_forget_password">שכח</string> + <string name="config_changed">הוחלף לסוחר חדש עם %s</string> + <string name="config_fetching_label">טוען תצורה</string> + <string name="config_docs">עיינו ב<a href="https://docs.taler.net/taler-merchant-pos-terminal.html#apis-and-data-formats">תיעוד</a> עבור מבנה התצורה.</string> + <string name="config_qr_label">תצורת QR</string> + <string name="scan_qr_hint">סרקו את קוד ה-QR ממשרד האחורי של הסוחר</string> + <string name="forever">לתמיד</string> + <string name="custom_duration">משך מותאם</string> + <string name="duration">משך</string> + <string name="token_validity_deadline">מועד תוקף האסימון:</string> + <string name="expires_on">פג בתאריך…</string> + <string name="never_expires">לעולם לא פג</string> + <string name="no_deadline_set">לא נקבע מועד תפוגה</string> + <string name="pick_date">בחירת תאריך</string> + <string name="pick_time">בחירת שעה</string> + <string name="session_expired_toast">ההפעלה פגה – הזינו שוב את פרטי הגישה</string> + <string name="config_fragment_camera_needed_text">נדרשת הרשאת מצלמה לסריקת QR</string> + <string name="payment_intro_nfc">בקשו מהלקוח לסרוק קוד QR או להשתמש ב-NFC כדי לשלם.</string> + <string name="payment_intro">בקשו מהלקוח לסרוק קוד QR כדי לשלם.</string> + <string name="payment_claimed">ממתין לאישור התשלום על ידי הלקוח…</string> + <string name="payment_cancel">ביטול תשלום</string> + <string name="payment_received">התשלום התקבל</string> + <string name="payment_back_button">המשך</string> + <string name="payment_order_id">קבלה #%s</string> + <string name="payment_process_label">נדרש תשלום</string> + <string name="payment_canceled">התשלום בוטל</string> + <string name="history_label">היסטוריית תשלומים</string> + <string name="history_unpaid">לא שולם</string> + <string name="history_refund">החזר</string> + <string name="refund_amount">סכום</string> + <string name="refund_reason">סיבת ההחזר</string> + <string name="refund_abort">ביטול</string> + <string name="refund_complete">התקבל</string> + <string name="refund_confirm">אישור החזר</string> + <string name="refund_error_max_amount">גדול מסכום ההזמנה %s</string> + <string name="refund_error_invalid_amount">סכום לא תקין</string> + <string name="refund_error_zero">חייב להיות סכום חיובי</string> + <string name="refund_error_backend">שגיאה בעיבוד ההחזר</string> + <string name="refund_error_deadline">מועד ההחזר חלף</string> + <string name="refund_error_already_refunded">כבר הוחזר</string> + <string name="refund_intro_nfc">בקשו מהלקוח לסרוק קוד QR או להשתמש ב-NFC כדי לקבל החזר</string> + <string name="refund_intro">בקשו מהלקוח לסרוק קוד QR כדי לקבל החזר</string> + <string name="refund_order_ref">אסמכתת רכישה: %1$s\n\n%2$s</string> + <string name="error_payment">שגיאה: לא התקבל תשלום</string> + <string name="error_timeout">לא בוצע תשלום בזמן שהוקצב, נסו שוב!</string> + <string name="error_cancelled">התשלום בוטל</string> + <string name="error_history">שגיאה בטעינת היסטוריית ההזמנות</string> + <string name="toast_reloading">טוען מלאי מחדש</string> + <string name="toast_back_to_exit">לחצו שוב על «חזרה» כדי לצאת</string> + <string name="host_apdu_service_desc">תשלומי NFC של Taler Merchant</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> + <string name="product_unavailable">לא זמין</string> + <string name="product_out_of_stock">אזל מהמלאי</string> + <string name="order_delete">מחק הזמנה</string> + <string name="error_order_creation">שגיאה: לא ניתן ליצור הזמנה</string> + <string name="error_inventory_unavailable">שגיאה: אין מספיק מלאי זמין</string> + <string name="error_delete_order">שגיאה: לא ניתן למחוק את ההזמנה</string> </resources> diff --git a/merchant-terminal/src/main/res/values-night/styles.xml b/merchant-terminal/src/main/res/values-night/styles.xml @@ -5,7 +5,7 @@ <item name="windowActionModeOverlay">true</item> </style> - <style name="AppTheme.NoActionBar"> + <style name="AppTheme.NoActionBar" parent="AppTheme"> <item name="windowActionBar">false</item> <item name="windowNoTitle">true</item> @@ -28,4 +28,3 @@ </style> </resources> - diff --git a/merchant-terminal/src/main/res/values-ru/strings.xml b/merchant-terminal/src/main/res/values-ru/strings.xml @@ -25,7 +25,7 @@ <string name="menu_settings">Настройки</string> <string name="order_label_title">Заказ №%s</string> <string name="order_total">Итого: %s</string> - <string name="order_restart">Перезапустить</string> + <string name="order_restart">Очистить</string> <string name="order_undo">Отменить</string> <string name="order_previous">Предыдущая</string> <string name="order_next">Следующая</string> @@ -69,4 +69,58 @@ <string name="error_cancelled">Платеж отменен</string> <string name="error_history">Ошибка получения истории платежей</string> <string name="toast_back_to_exit">Нажмите «назад» ещё раз чтобы выйти</string> + <string name="menu_amount_entry">Заказ по сумме</string> + <string name="product_category_all_objects">Всё</string> + <string name="order_complete_with_amount">Счёт %s</string> + <string name="settings_language_label">Язык</string> + <string name="settings_language_hint">Язык приложения</string> + <string name="settings_language_system_default">Системный</string> + <string name="settings_subtitle">Выберите язык приложения и управляйте учетными данными экземпляра продавца.</string> + <string name="settings_app_title">Настройки приложения</string> + <string name="settings_language_description">Применяется к интерфейсу приложения сразу.</string> + <string name="settings_initial_order_label">Начальный экран заказа</string> + <string name="settings_initial_order_hint">Режим заказа по умолчанию</string> + <string name="settings_initial_order_description">Показывается при запуске приложения или после загрузки конфигурации.</string> + <string name="settings_instance_title">Сохраненный экземпляр</string> + <string name="settings_instance_description">Изменить URL портала продавца, имя пользователя, токен и другие настройки экземпляра.</string> + <string name="settings_instance_button">Изменить сохраненный экземпляр</string> + <string name="config_merchant_url">URL портала продавца</string> + <string name="amount_entry_label">Сумма</string> + <string name="amount_entry_charge">Списать</string> + <string name="amount_entry_create_order_charge">Создать заказ (списать сумму)</string> + <string name="amount_entry_clear">Очистить</string> + <string name="amount_entry_backspace">Backspace</string> + <string name="amount_entry_product_description">Обычный заказ</string> + <string name="amount_entry_error_zero">Введите сумму</string> + <string name="amount_entry_error_wrong_currency">Неподдерживаемая валюта</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> + <string name="menu_reload">Перезагрузить инвентарь</string> + <string name="product_image">Изображение продукта</string> + <string name="product_category_uncategorized">Без категории</string> + <string name="config_old_label">Файл JSON (старый)</string> + <string name="config_setup_password">Пароль</string> + <string name="config_old_deprecation">Этот способ настройки устарел, используйте новую настройку через API продавца.</string> + <string name="config_token">Токен доступа</string> + <string name="config_qr_label">QR-конфигурация</string> + <string name="scan_qr_hint">Отсканируйте QR-код из бэк-офиса продавца</string> + <string name="forever">Навсегда</string> + <string name="custom_duration">Пользовательская длительность</string> + <string name="duration">Длительность</string> + <string name="token_validity_deadline">Срок действия токена:</string> + <string name="expires_on">Истекает…</string> + <string name="never_expires">Никогда не истекает</string> + <string name="no_deadline_set">Срок не задан</string> + <string name="pick_date">Выбрать дату</string> + <string name="pick_time">Выбрать время</string> + <string name="session_expired_toast">Сеанс истек – введите учетные данные снова</string> + <string name="config_fragment_camera_needed_text">Для сканирования QR требуется разрешение на камеру</string> + <string name="host_apdu_service_desc">NFC-платежи Taler Merchant</string> + <string name="toast_reloading">Перезагрузка инвентаря</string> + <string name="product_unavailable">Недоступно</string> + <string name="product_out_of_stock">Нет в наличии</string> + <string name="order_delete">Удалить заказ</string> + <string name="error_order_creation">Ошибка: не удалось создать заказ</string> + <string name="error_inventory_unavailable">Ошибка: недостаточно доступного инвентаря</string> + <string name="error_delete_order">Ошибка: не удалось удалить заказ</string> </resources> diff --git a/merchant-terminal/src/main/res/values-sv/strings.xml b/merchant-terminal/src/main/res/values-sv/strings.xml @@ -6,7 +6,7 @@ <string name="menu_settings">Inställningar</string> <string name="order_label_title">Beställning #%s</string> <string name="order_total">Totalt: %s</string> - <string name="order_restart">Omstart</string> + <string name="order_restart">Rensa</string> <string name="order_undo">Ångra</string> <string name="order_previous">Föregående</string> <string name="order_next">Nästa</string> @@ -65,4 +65,62 @@ \n \n%2$s</string> <string name="payment_claimed">Väntar på att kunden ska bekräfta betalningen…</string> + <string name="menu_amount_entry">Beställ efter belopp</string> + <string name="product_category_all_objects">Allt</string> + <string name="order_complete_with_amount">Nota %s</string> + <string name="settings_language_label">Språk</string> + <string name="settings_language_hint">Appspråk</string> + <string name="settings_language_system_default">Systemstandard</string> + <string name="settings_subtitle">Välj appspråk och hantera inloggningsuppgifter för handlarinstansen.</string> + <string name="settings_app_title">Appinställningar</string> + <string name="settings_language_description">Tillämpas direkt på appens gränssnitt.</string> + <string name="settings_initial_order_label">Startskärm för beställning</string> + <string name="settings_initial_order_hint">Standardläge för beställning</string> + <string name="settings_initial_order_description">Visas när appen startar eller efter att konfigurationen har laddats.</string> + <string name="settings_instance_title">Sparad instans</string> + <string name="settings_instance_description">Ändra handlarportalens URL, användarnamn, token och andra instansspecifika inställningar.</string> + <string name="settings_instance_button">Ändra sparad instans</string> + <string name="config_merchant_url">Handlarportalens URL</string> + <string name="amount_entry_label">Belopp</string> + <string name="amount_entry_charge">Debitera</string> + <string name="amount_entry_create_order_charge">Skapa beställning (debitera belopp)</string> + <string name="amount_entry_clear">Rensa</string> + <string name="amount_entry_backspace">Backsteg</string> + <string name="amount_entry_product_description">Vanlig beställning</string> + <string name="amount_entry_error_zero">Ange ett belopp</string> + <string name="amount_entry_error_wrong_currency">Valutan stöds inte</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> + <string name="product_image">Produktbild</string> + <string name="product_category_uncategorized">Okategoriserad</string> + <string name="order_custom">Lägg till anpassad produkt</string> + <string name="order_custom_product">Namn på anpassad produkt</string> + <string name="order_custom_product_default">Dricks</string> + <string name="order_custom_add_button">Lägg till</string> + <string name="config_old_label">JSON-fil (gammal)</string> + <string name="config_setup_password">Lösenord</string> + <string name="config_old_deprecation">Den här konfigurationsmetoden är föråldrad, använd den nya konfigurationen via handlar-API:t.</string> + <string name="config_token">Åtkomsttoken</string> + <string name="config_qr_label">QR-konfiguration</string> + <string name="scan_qr_hint">Skanna QR-koden från handlarens backoffice</string> + <string name="forever">För alltid</string> + <string name="custom_duration">Anpassad varaktighet</string> + <string name="duration">Varaktighet</string> + <string name="token_validity_deadline">Tokenens giltighetstid:</string> + <string name="expires_on">Går ut…</string> + <string name="never_expires">Går aldrig ut</string> + <string name="no_deadline_set">Ingen tidsgräns angiven</string> + <string name="pick_date">Välj datum</string> + <string name="pick_time">Välj tid</string> + <string name="session_expired_toast">Sessionen har gått ut – ange dina inloggningsuppgifter igen</string> + <string name="config_fragment_camera_needed_text">Kamerabehörighet krävs för QR-skanning</string> + <string name="host_apdu_service_desc">Taler Merchant NFC-betalningar</string> + <string name="toast_reloading">Laddar om lager</string> + <string name="menu_reload">Ladda om lager</string> + <string name="product_unavailable">Inte tillganglig</string> + <string name="product_out_of_stock">Slut i lager</string> + <string name="order_delete">Ta bort bestallning</string> + <string name="error_order_creation">Fel: kunde inte skapa bestallning</string> + <string name="error_inventory_unavailable">Fel: inte tillrackligt lager tillgangligt</string> + <string name="error_delete_order">Fel: kunde inte ta bort bestallning</string> </resources> diff --git a/merchant-terminal/src/main/res/values-tr/strings.xml b/merchant-terminal/src/main/res/values-tr/strings.xml @@ -49,7 +49,7 @@ <string name="menu_settings">Ayarlar</string> <string name="order_label_title">Sipariş #%s</string> <string name="order_total">Toplam: %s</string> - <string name="order_restart">Tekrar başlat</string> + <string name="order_restart">Temizle</string> <string name="order_undo">Geri al</string> <string name="order_previous">Önceki</string> <string name="order_next">Sonraki</string> @@ -69,11 +69,58 @@ <string name="order_custom_product">Özel ürünün ismi</string> <string name="order_custom_product_default">Bahşiş</string> <string name="order_custom_add_button">Ekle</string> - <string name="menu_reload">Yeniden yükle</string> + <string name="menu_reload">Envanteri yeniden yükle</string> <string name="toast_reloading">Envanter yeniden yükleniyor</string> <string name="config_old_label">JSON dosyası (eski)</string> <string name="config_setup_password">Satıcı (yeni)</string> <string name="config_merchant_url">Satıcı URL\'si</string> <string name="config_token">Şifre</string> <string name="config_old_deprecation">Bu yapılandırma yöntemi kullanımdan kaldırıldı. Lütfen yeni satıcı API yapılandırmasını kullanın.</string> + <string name="menu_amount_entry">Tutara göre sipariş</string> + <string name="product_category_all_objects">Her şey</string> + <string name="order_complete_with_amount">Hesap %s</string> + <string name="settings_language_label">Dil</string> + <string name="settings_language_hint">Uygulama dili</string> + <string name="settings_language_system_default">Sistem varsayılanı</string> + <string name="settings_subtitle">Uygulama dilini seçin ve satıcı örneği kimlik bilgilerini yönetin.</string> + <string name="settings_app_title">Uygulama ayarları</string> + <string name="settings_language_description">Uygulama arayüzüne hemen uygulanır.</string> + <string name="settings_initial_order_label">Başlangıç sipariş ekranı</string> + <string name="settings_initial_order_hint">Varsayılan sipariş modu</string> + <string name="settings_initial_order_description">Uygulama başlatıldığında veya yapılandırma yüklendikten sonra gösterilir.</string> + <string name="settings_instance_title">Kayıtlı örnek</string> + <string name="settings_instance_description">Satıcı portalı URL’sini, kullanıcı adını, tokeni ve diğer örneğe özel ayarları değiştirin.</string> + <string name="settings_instance_button">Kayıtlı örneği değiştir</string> + <string name="amount_entry_label">Tutar</string> + <string name="amount_entry_charge">Ücret al</string> + <string name="amount_entry_create_order_charge">Sipariş oluştur (tutarı ücretlendir)</string> + <string name="amount_entry_clear">Temizle</string> + <string name="amount_entry_backspace">Geri sil</string> + <string name="amount_entry_product_description">Normal sipariş</string> + <string name="amount_entry_error_zero">Bir tutar girin</string> + <string name="amount_entry_error_wrong_currency">Desteklenmeyen para birimi</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> + <string name="product_image">Ürün resmi</string> + <string name="product_category_uncategorized">Kategorisiz</string> + <string name="config_qr_label">QR yapılandırması</string> + <string name="scan_qr_hint">Satıcı backoffice’inden QR kodunu tarayın</string> + <string name="forever">Süresiz</string> + <string name="custom_duration">Özel süre</string> + <string name="duration">Süre</string> + <string name="token_validity_deadline">Token geçerlilik son tarihi:</string> + <string name="expires_on">Bitiş tarihi…</string> + <string name="never_expires">Asla sona ermez</string> + <string name="no_deadline_set">Son tarih ayarlanmadı</string> + <string name="pick_date">Tarih seç</string> + <string name="pick_time">Saat seç</string> + <string name="session_expired_toast">Oturum süresi doldu – lütfen kimlik bilgilerinizi yeniden girin</string> + <string name="config_fragment_camera_needed_text">QR tarama için kamera izni gereklidir</string> + <string name="host_apdu_service_desc">Taler Merchant NFC ödemeleri</string> + <string name="product_unavailable">Mevcut değil</string> + <string name="product_out_of_stock">Stokta yok</string> + <string name="order_delete">Siparişi sil</string> + <string name="error_order_creation">Hata: sipariş oluşturulamadı</string> + <string name="error_inventory_unavailable">Hata: yeterli envanter mevcut değil</string> + <string name="error_delete_order">Hata: sipariş silinemedi</string> </resources> diff --git a/merchant-terminal/src/main/res/values-uk/strings.xml b/merchant-terminal/src/main/res/values-uk/strings.xml @@ -8,7 +8,7 @@ <string name="menu_settings">Налаштування</string> <string name="order_label_title">Замовлення #%s</string> <string name="order_total">Загальна сума: %s</string> - <string name="order_restart">Перезапустити</string> + <string name="order_restart">Очистити</string> <string name="order_undo">Відмінити</string> <string name="order_previous">Попереднє</string> <string name="order_next">Наступне</string> @@ -75,4 +75,52 @@ <string name="config_changed">Змінено на нового продавця, використовуючи %s</string> <string name="config_docs">Будь ласка, зверніться до <a href="https://docs.taler.net/taler-merchant-pos-terminal.html#apis-and-data-formats">документації</a> для формату конфігурації.</string> <string name="refund_reason">Причина повернення</string> + <string name="menu_amount_entry">Замовлення за сумою</string> + <string name="product_category_all_objects">Усе</string> + <string name="order_complete_with_amount">Рахунок %s</string> + <string name="settings_language_label">Мова</string> + <string name="settings_language_hint">Мова застосунку</string> + <string name="settings_language_system_default">Системна</string> + <string name="settings_subtitle">Виберіть мову застосунку та керуйте обліковими даними екземпляра продавця.</string> + <string name="settings_app_title">Налаштування застосунку</string> + <string name="settings_language_description">Застосовується до інтерфейсу застосунку негайно.</string> + <string name="settings_initial_order_label">Початковий екран замовлення</string> + <string name="settings_initial_order_hint">Тип замовлення за замовчуванням</string> + <string name="settings_initial_order_description">Показується під час запуску застосунку або після завантаження конфігурації.</string> + <string name="settings_instance_title">Збережений екземпляр</string> + <string name="settings_instance_description">Змінити URL порталу продавця, ім’я користувача, токен та інші налаштування екземпляра.</string> + <string name="settings_instance_button">Змінити збережений екземпляр</string> + <string name="amount_entry_label">Сума</string> + <string name="amount_entry_charge">Стягнути</string> + <string name="amount_entry_create_order_charge">Створити замовлення (стягнути суму)</string> + <string name="amount_entry_clear">Очистити</string> + <string name="amount_entry_backspace">Backspace</string> + <string name="amount_entry_product_description">Звичайне замовлення</string> + <string name="amount_entry_error_zero">Введіть суму</string> + <string name="amount_entry_error_wrong_currency">Непідтримувана валюта</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> + <string name="menu_reload">Перезавантажити інвентар</string> + <string name="product_image">Зображення продукту</string> + <string name="product_category_uncategorized">Без категорії</string> + <string name="toast_reloading">Перезавантаження інвентарю</string> + <string name="config_qr_label">QR-конфігурація</string> + <string name="scan_qr_hint">Скануйте QR-код із бекофісу продавця</string> + <string name="forever">Назавжди</string> + <string name="custom_duration">Власна тривалість</string> + <string name="duration">Тривалість</string> + <string name="token_validity_deadline">Кінцевий термін дії токена:</string> + <string name="expires_on">Закінчується…</string> + <string name="never_expires">Ніколи не закінчується</string> + <string name="no_deadline_set">Термін не встановлено</string> + <string name="pick_date">Вибрати дату</string> + <string name="pick_time">Вибрати час</string> + <string name="session_expired_toast">Сеанс завершився – введіть облікові дані знову</string> + <string name="config_fragment_camera_needed_text">Для сканування QR потрібен дозвіл на камеру</string> + <string name="product_unavailable">Недоступно</string> + <string name="product_out_of_stock">Немає в наявності</string> + <string name="order_delete">Видалити замовлення</string> + <string name="error_order_creation">Помилка: не вдалося створити замовлення</string> + <string name="error_inventory_unavailable">Помилка: недостатньо доступного інвентарю</string> + <string name="error_delete_order">Помилка: не вдалося видалити замовлення</string> </resources> diff --git a/merchant-terminal/src/main/res/values/strings.xml b/merchant-terminal/src/main/res/values/strings.xml @@ -3,25 +3,29 @@ <string name="app_name_short">Merchant Terminal</string> <string name="project_name">GNU Taler</string> - <string name="menu_order">Regular order</string> + <string name="menu_order">Order by inventory</string> <string name="menu_amount_entry">Order by amount</string> <string name="menu_history">History</string> <string name="menu_settings">Settings</string> - <string name="menu_reload">Reload</string> + <string name="menu_reload">Reload inventory</string> <string name="product_image">Product image</string> - <string name="product_category_all_objects">All objects</string> + <string name="product_category_all_objects">Everything</string> <string name="product_category_uncategorized">Uncategorized</string> + <string name="product_unavailable">Unavailable</string> + <string name="product_out_of_stock">Out of stock</string> <string name="order_label_title">Order #%s</string> <!-- The placeholder is the total order amount with currency --> <string name="order_total">Total: %s</string> - <string name="order_restart">Restart</string> + <string name="order_restart">Clear</string> + <string name="order_delete">Delete order</string> <string name="order_undo">Undo</string> <string name="order_previous">Prev</string> <string name="order_next">Next</string> <string name="order_custom">Add custom product</string> - <string name="order_complete">Complete order</string> + <string name="order_complete">Bill</string> + <string name="order_complete_with_amount">Bill %s</string> <string name="order_custom_product">Custom product name</string> <string name="order_custom_product_default">Tip</string> <string name="order_custom_add_button">Add</string> @@ -40,19 +44,23 @@ <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_app_title">App settings</string> <string name="settings_language_description">Applied immediately to the app interface.</string> + <string name="settings_initial_order_label">Initial order screen</string> + <string name="settings_initial_order_hint">Default order mode</string> + <string name="settings_initial_order_description">Shown when the app starts or after configuration is loaded.</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_description">Change merchant portal 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> <string name="config_url">Configuration URL</string> - <string name="config_merchant_url">Merchant URL</string> + <string name="config_merchant_url">Merchant portal URL</string> <string name="config_username">Username</string> <string name="config_password">Password</string> <string name="config_token">Access token</string> - <string name="config_ok">Fetch configuration</string> + <string name="config_ok">Connect</string> <string name="config_auth_error">Error: Invalid username or password</string> <string name="config_error_network">Error: Could not connect to configuration server</string> <string name="config_error_category">Error: No valid product category found</string> @@ -98,6 +106,9 @@ <string name="refund_order_ref">Purchase reference: %1$s\n\n%2$s</string> <string name="error_payment">Error: No payment received</string> + <string name="error_order_creation">Error: Could not create order</string> + <string name="error_inventory_unavailable">Error: Not enough inventory available</string> + <string name="error_delete_order">Error: Could not delete order</string> <string name="error_timeout">No payment made within payment period, please try again!</string> <string name="error_cancelled">Payment cancelled</string> <string name="error_history">Error fetching the order history</string> diff --git a/merchant-terminal/src/main/res/values/styles.xml b/merchant-terminal/src/main/res/values/styles.xml @@ -1,5 +1,23 @@ <resources xmlns:tools="http://schemas.android.com/tools"> + <style name="Widget.Taler.MfaCodeDigit" parent="@android:style/Widget.EditText"> + <item name="android:layout_width">30dp</item> + <item name="android:layout_height">52dp</item> + <item name="android:layout_marginHorizontal">1dp</item> + <item name="android:background">@drawable/mfa_code_digit_background</item> + <item name="android:gravity">center</item> + <item name="android:imeOptions">actionNext</item> + <item name="android:inputType">number</item> + <item name="android:maxLength">1</item> + <item name="android:padding">0dp</item> + <item name="android:selectAllOnFocus">true</item> + <item name="android:singleLine">true</item> + <item name="android:textColor">?attr/colorOnSurface</item> + <item name="android:textColorHint">?attr/colorOnSurfaceVariant</item> + <item name="android:textSize">20sp</item> + <item name="android:textStyle">bold</item> + </style> + <style name="AppTheme.Light" parent="Theme.Material3.Light"> <item name="colorPrimary">@color/colorPrimary</item> <item name="colorOnPrimary">@color/colorOnPrimary</item> @@ -89,7 +107,7 @@ <item name="windowActionModeOverlay">true</item> </style> - <style name="AppTheme.NoActionBar"> + <style name="AppTheme.NoActionBar" parent="AppTheme"> <item name="windowActionBar">false</item> <item name="windowNoTitle">true</item> <item name="android:statusBarColor">@color/colorPrimary</item> diff --git a/merchant-terminal/src/test/java/net/taler/merchantpos/config/ConfigProductTest.kt b/merchant-terminal/src/test/java/net/taler/merchantpos/config/ConfigProductTest.kt @@ -1,6 +1,7 @@ package net.taler.merchantpos.config import net.taler.common.Amount +import net.taler.common.CurrencySpecification import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Test @@ -21,7 +22,7 @@ class ConfigProductTest { } @Test - fun `display price appends currency code`() { + fun `display price formats amount`() { val product = ConfigProduct( description = "Coffee", price = Amount("KUDOS", 2, 50000000), @@ -30,4 +31,23 @@ class ConfigProductTest { assertEquals("2.50 KUDOS", product.displayPrice) } + + @Test + fun `display price uses currency spec symbol`() { + val product = ConfigProduct( + description = "Coffee", + price = Amount("CHF", 2, 50000000).withSpec( + CurrencySpecification( + name = "Swiss Francs", + numFractionalInputDigits = 2, + numFractionalNormalDigits = 2, + numFractionalTrailingZeroDigits = 2, + altUnitNames = mapOf(0 to "Fr."), + ) + ), + categories = listOf(1) + ) + + assertEquals("Fr.2.50", product.displayPrice) + } } diff --git a/merchant-terminal/src/test/java/net/taler/merchantpos/order/OrderManagerTest.kt b/merchant-terminal/src/test/java/net/taler/merchantpos/order/OrderManagerTest.kt @@ -72,7 +72,7 @@ class OrderManagerTest { @Test fun `config test missing categories`() = runBlocking { val config = posConfig.copy(categories = emptyList()) - val result = orderManager.onConfigurationReceived(config, "KUDOS") + val result = orderManager.onConfigurationReceived(config, "KUDOS", null) assertEquals(app.getString(R.string.config_error_category), result) } @@ -80,7 +80,7 @@ class OrderManagerTest { fun `config test currency mismatch`() = runBlocking { val products = listOf(posConfig.products[0].copy(price = Amount("WRONGCUR", 1, 0))) val config = posConfig.copy(products = products) - val result = orderManager.onConfigurationReceived(config, "KUDOS") + val result = orderManager.onConfigurationReceived(config, "KUDOS", null) val expectedStr = app.getString( R.string.config_error_currency, "foo", "WRONGCUR", "KUDOS" ) @@ -93,7 +93,7 @@ class OrderManagerTest { // fun `config test unknown category ID`() = runBlocking { // val products = listOf(posConfig.products[0].copy(categories = listOf(42))) // val config = posConfig.copy(products = products) -// val result = orderManager.onConfigurationReceived(config, "KUDOS") +// val result = orderManager.onConfigurationReceived(config, "KUDOS", null) // val expectedStr = app.getString( // R.string.config_error_product_category_id, "foo", 42 // ) @@ -102,13 +102,13 @@ class OrderManagerTest { @Test fun `config test valid config gets accepted`() = runBlocking { - val result = orderManager.onConfigurationReceived(posConfig, "KUDOS") + val result = orderManager.onConfigurationReceived(posConfig, "KUDOS", null) assertNull(result) } @Test fun `all objects is selected by default and shown first`() = runBlocking { - orderManager.onConfigurationReceived(posConfig, "KUDOS") + orderManager.onConfigurationReceived(posConfig, "KUDOS", null) shadowMainLooper().idle() val categories = orderManager.categories.awaitValue() @@ -131,7 +131,7 @@ class OrderManagerTest { ) val config = posConfig.copy(products = posConfig.products + uncategorizedProduct) - orderManager.onConfigurationReceived(config, "KUDOS") + orderManager.onConfigurationReceived(config, "KUDOS", null) shadowMainLooper().idle() val categories = orderManager.categories.awaitValue() @@ -156,7 +156,7 @@ class OrderManagerTest { products = posConfig.products + defaultProduct ) - orderManager.onConfigurationReceived(config, "KUDOS") + orderManager.onConfigurationReceived(config, "KUDOS", null) shadowMainLooper().idle() val categories = orderManager.categories.awaitValue() diff --git a/taler-kotlin-android/src/main/java/net/taler/lib/android/MfaUtils.kt b/taler-kotlin-android/src/main/java/net/taler/lib/android/MfaUtils.kt @@ -16,19 +16,31 @@ package net.taler.lib.android +import android.content.res.ColorStateList +import android.content.DialogInterface +import android.text.Editable +import android.text.TextWatcher +import android.view.KeyEvent import android.view.LayoutInflater +import android.view.View.GONE +import android.view.View.VISIBLE +import android.view.inputmethod.EditorInfo +import android.widget.Button +import android.widget.EditText +import android.widget.LinearLayout import android.widget.TextView import android.widget.Toast import androidx.fragment.app.Fragment +import androidx.core.content.res.use import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.textfield.TextInputEditText -import com.google.android.material.textfield.TextInputLayout +import com.google.android.material.button.MaterialButton import io.ktor.client.plugins.ClientRequestException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import net.taler.common.Challenge import net.taler.common.R +import android.graphics.drawable.GradientDrawable import kotlin.coroutines.resume enum class ChallengeRetryDecision { @@ -81,16 +93,45 @@ suspend fun Fragment.handleChallengeResponse( suspend fun Fragment.selectChallenge(challenges: List<Challenge>): Challenge? = withContext(Dispatchers.Main) { suspendCancellableCoroutine { cont -> - val labels = challenges.map { c -> - "${c.tanChannel}: ${c.tanInfo}" - }.toTypedArray() - MaterialAlertDialogBuilder(requireContext()) + val buttonContainer = LinearLayout(requireContext()).apply { + orientation = LinearLayout.VERTICAL + val density = resources.displayMetrics.density + val horizontalPadding = (24 * density).toInt() + val verticalPadding = (8 * density).toInt() + setPadding(horizontalPadding, verticalPadding, horizontalPadding, 0) + } + var dialog: androidx.appcompat.app.AlertDialog? = null + challenges.forEach { challenge -> + val button = MaterialButton(requireContext()).apply { + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT, + ).apply { + bottomMargin = dp(8) + } + text = "${challenge.tanChannel}: ${challenge.tanInfo}" + backgroundTintList = colorStateListFromAttr(androidx.appcompat.R.attr.colorPrimary) + setTextColor(colorFromAttr(com.google.android.material.R.attr.colorOnPrimary)) + isAllCaps = false + cornerRadius = dp(10) + setOnClickListener { + if (cont.isActive) cont.resume(challenge) + dialog?.dismiss() + } + } + buttonContainer.addView(button) + } + dialog = MaterialAlertDialogBuilder(requireContext()) .setTitle(R.string.mfa_choose_title) - .setItems(labels) { _, which -> - cont.resume(challenges[which]) + .setView(buttonContainer) + .setNegativeButton(android.R.string.cancel) { _, _ -> + if (cont.isActive) cont.resume(null) + } + .setOnCancelListener { + if (cont.isActive) cont.resume(null) } - .setOnCancelListener { cont.resume(null) } .show() + styleMfaDialogActions(dialog) } } @@ -108,21 +149,46 @@ suspend fun Fragment.promptForTan(challenge: Challenge): String? = false ) val messageView = dialogView.findViewById<TextView>(R.id.mfaMessageView) - val inputLayout = dialogView.findViewById<TextInputLayout>(R.id.mfaCodeInputLayout) - val input = dialogView.findViewById<TextInputEditText>(R.id.mfaCodeInput) + val errorView = dialogView.findViewById<TextView>(R.id.mfaCodeErrorView) + val inputs = listOf( + dialogView.findViewById<EditText>(R.id.mfaCodeDigit1), + dialogView.findViewById<EditText>(R.id.mfaCodeDigit2), + dialogView.findViewById<EditText>(R.id.mfaCodeDigit3), + dialogView.findViewById<EditText>(R.id.mfaCodeDigit4), + dialogView.findViewById<EditText>(R.id.mfaCodeDigit5), + dialogView.findViewById<EditText>(R.id.mfaCodeDigit6), + dialogView.findViewById<EditText>(R.id.mfaCodeDigit7), + dialogView.findViewById<EditText>(R.id.mfaCodeDigit8), + ) messageView.text = message - inputLayout.isErrorEnabled = false - MaterialAlertDialogBuilder(requireContext()) + errorView.visibility = GONE + val dialog = MaterialAlertDialogBuilder(requireContext()) .setTitle(R.string.mfa_challenge_title) .setView(dialogView) - .setPositiveButton(android.R.string.ok) { _, _ -> - cont.resume(input?.text?.toString()?.trim().orEmpty()) - } + .setPositiveButton(android.R.string.ok, null) .setNegativeButton(android.R.string.cancel) { _, _ -> cont.resume(null) } .setOnCancelListener { cont.resume(null) } .show() + fun submitCode(): Boolean { + val code = collectMfaCode(inputs) + if (code == null) { + errorView.text = getString(R.string.mfa_challenge_code_incomplete) + errorView.visibility = VISIBLE + return false + } + errorView.visibility = GONE + cont.resume(code) + dialog.dismiss() + return true + } + setupMfaCodeInputs(inputs, errorView, ::submitCode) + dialog.getButton(DialogInterface.BUTTON_POSITIVE).setOnClickListener { + submitCode() + } + styleMfaDialogActions(dialog) + inputs.firstOrNull()?.requestFocus() } } @@ -155,3 +221,108 @@ suspend fun Fragment.handleChallengeConfirmError(e: Exception): ChallengeRetryDe ).show() ChallengeRetryDecision.Abort } + +private fun setupMfaCodeInputs( + inputs: List<EditText>, + errorView: TextView, + onSubmit: () -> Boolean, +) { + inputs.forEachIndexed { index, input -> + input.imeOptions = if (index == inputs.lastIndex) { + EditorInfo.IME_ACTION_DONE + } else { + EditorInfo.IME_ACTION_NEXT + } + input.addTextChangedListener(object : TextWatcher { + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit + + override fun afterTextChanged(s: Editable?) { + errorView.visibility = GONE + if (s?.length == 1 && index < inputs.lastIndex) { + inputs[index + 1].requestFocus() + } + } + }) + input.setOnKeyListener { _, keyCode, event -> + if (keyCode == KeyEvent.KEYCODE_DEL && + event.action == KeyEvent.ACTION_DOWN && + input.text.isNullOrEmpty() && + index > 0 + ) { + inputs[index - 1].apply { + requestFocus() + text?.clear() + } + true + } else { + false + } + } + input.setOnEditorActionListener { _, actionId, event -> + val isEnter = event?.keyCode == KeyEvent.KEYCODE_ENTER && + event.action == KeyEvent.ACTION_UP + when { + actionId == EditorInfo.IME_ACTION_NEXT && index < inputs.lastIndex -> { + inputs[index + 1].requestFocus() + true + } + actionId == EditorInfo.IME_ACTION_NEXT || + actionId == EditorInfo.IME_ACTION_DONE || + isEnter -> onSubmit() + else -> false + } + } + } +} + +private fun collectMfaCode(inputs: List<EditText>): String? { + val digits = inputs.map { it.text?.toString().orEmpty() } + if (digits.any { it.length != 1 }) return null + return digits.take(4).joinToString("") + "-" + digits.drop(4).joinToString("") +} + +private fun Fragment.styleMfaDialogActions(dialog: androidx.appcompat.app.AlertDialog) { + styleDialogActionButton( + button = dialog.getButton(DialogInterface.BUTTON_POSITIVE), + backgroundAttr = androidx.appcompat.R.attr.colorPrimary, + textAttr = com.google.android.material.R.attr.colorOnPrimary, + ) + styleDialogActionButton( + button = dialog.getButton(DialogInterface.BUTTON_NEGATIVE), + backgroundAttr = com.google.android.material.R.attr.colorPrimaryContainer, + textAttr = com.google.android.material.R.attr.colorOnPrimaryContainer, + ) +} + +private fun Fragment.styleDialogActionButton( + button: Button?, + backgroundAttr: Int, + textAttr: Int, +) { + button ?: return + val backgroundColor = colorFromAttr(backgroundAttr) + button.isAllCaps = false + button.minHeight = dp(40) + button.minWidth = dp(96) + button.setPadding(dp(16), 0, dp(16), 0) + button.setTextColor(colorFromAttr(textAttr)) + button.backgroundTintList = ColorStateList.valueOf(backgroundColor) + button.background = GradientDrawable().apply { + shape = GradientDrawable.RECTANGLE + cornerRadius = dp(8).toFloat() + setColor(backgroundColor) + } +} + +private fun Fragment.colorFromAttr(attr: Int): Int { + return requireContext().obtainStyledAttributes(intArrayOf(attr)).use { + it.getColor(0, 0) + } +} + +private fun Fragment.colorStateListFromAttr(attr: Int): ColorStateList { + return ColorStateList.valueOf(colorFromAttr(attr)) +} + +private fun Fragment.dp(value: Int): Int = (value * resources.displayMetrics.density).toInt() diff --git a/taler-kotlin-android/src/main/res/layout/dialog_mfa_challenge.xml b/taler-kotlin-android/src/main/res/layout/dialog_mfa_challenge.xml @@ -21,9 +21,9 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" - android:paddingStart="24dp" + android:paddingStart="16dp" android:paddingTop="16dp" - android:paddingEnd="24dp" + android:paddingEnd="16dp" android:paddingBottom="8dp"> <TextView @@ -34,20 +34,60 @@ android:layout_marginBottom="16dp" android:textColor="?attr/colorOnSurface" /> - <com.google.android.material.textfield.TextInputLayout - android:id="@+id/mfaCodeInputLayout" + <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" - android:hint="@string/mfa_challenge_code_hint" - app:boxBackgroundMode="outline" - app:boxBackgroundColor="@android:color/transparent"> + android:gravity="center" + android:orientation="horizontal"> - <com.google.android.material.textfield.TextInputEditText - android:id="@+id/mfaCodeInput" - android:layout_width="match_parent" + <EditText + android:id="@+id/mfaCodeDigit1" + style="@style/Widget.Taler.MfaCodeDigit" /> + + <EditText + android:id="@+id/mfaCodeDigit2" + style="@style/Widget.Taler.MfaCodeDigit" /> + + <EditText + android:id="@+id/mfaCodeDigit3" + style="@style/Widget.Taler.MfaCodeDigit" /> + + <EditText + android:id="@+id/mfaCodeDigit4" + style="@style/Widget.Taler.MfaCodeDigit" /> + + <TextView + style="@style/TextAppearance.Material3.TitleLarge" + android:layout_width="wrap_content" android:layout_height="wrap_content" - android:imeOptions="actionDone" - android:inputType="number" /> - </com.google.android.material.textfield.TextInputLayout> + android:layout_marginHorizontal="4dp" + android:text="-" + android:textColor="?attr/colorPrimary" /> + + <EditText + android:id="@+id/mfaCodeDigit5" + style="@style/Widget.Taler.MfaCodeDigit" /> + + <EditText + android:id="@+id/mfaCodeDigit6" + style="@style/Widget.Taler.MfaCodeDigit" /> + + <EditText + android:id="@+id/mfaCodeDigit7" + style="@style/Widget.Taler.MfaCodeDigit" /> + + <EditText + android:id="@+id/mfaCodeDigit8" + style="@style/Widget.Taler.MfaCodeDigit" /> + </LinearLayout> + + <TextView + android:id="@+id/mfaCodeErrorView" + style="@style/TextAppearance.Material3.BodySmall" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="6dp" + android:textColor="?attr/colorError" + android:visibility="gone" /> </LinearLayout> diff --git a/taler-kotlin-android/src/main/res/values-de/strings.xml b/taler-kotlin-android/src/main/res/values-de/strings.xml @@ -23,8 +23,9 @@ <string name="mfa_challenge_title">Zwei-Faktor-Anmeldeverfahren</string> <string name="mfa_challenge_message">Ein Bestätigungscode wurde über %1$s (%2$s) gesendet. Geben Sie diesen Code ein, um fortzufahren.</string> <string name="mfa_challenge_code_hint">Bestätigungscode</string> + <string name="mfa_challenge_code_incomplete">Geben Sie alle 8 Ziffern ein.</string> <string name="mfa_choose_title">Wählen Sie die Art des Bestätigungscodes</string> <string name="mfa_challenge_invalid">Falscher Bestätigungscode. Bitte versuchen Sie diesen Vorgang nochmals.</string> <string name="mfa_challenge_retry">Zu viele fehlgeschlagene Versuche. Ein neuer Code wurde Ihnen zugesendet.</string> <string name="mfa_challenge_failed">Die Verifizierung ist fehlgeschlagen. Bitte versuchen Sie diesen Vorgang nochmals.</string> -</resources> -\ No newline at end of file +</resources> diff --git a/taler-kotlin-android/src/main/res/values-es/strings.xml b/taler-kotlin-android/src/main/res/values-es/strings.xml @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="mfa_challenge_title">Autenticación de dos factores</string> + <string name="mfa_challenge_message">Se envió un código de confirmación mediante %1$s (%2$s). Introduce el código para continuar.</string> + <string name="mfa_challenge_code_hint">Código de verificación</string> + <string name="mfa_challenge_code_incomplete">Introduce los 8 dígitos.</string> + <string name="mfa_choose_title">Elige el método de verificación</string> + <string name="mfa_challenge_invalid">Código incorrecto. Inténtalo de nuevo.</string> + <string name="mfa_challenge_retry">Demasiados intentos. Se ha enviado un código nuevo.</string> + <string name="mfa_challenge_failed">La verificación ha fallado. Inténtalo de nuevo más tarde.</string> +</resources> diff --git a/taler-kotlin-android/src/main/res/values-fi/strings.xml b/taler-kotlin-android/src/main/res/values-fi/strings.xml @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="mfa_challenge_title">Kaksivaiheinen tunnistautuminen</string> + <string name="mfa_challenge_message">Vahvistuskoodi lähetettiin kanavalla %1$s (%2$s). Jatka syöttämällä koodi.</string> + <string name="mfa_challenge_code_hint">Vahvistuskoodi</string> + <string name="mfa_challenge_code_incomplete">Syötä kaikki 8 numeroa.</string> + <string name="mfa_choose_title">Valitse vahvistustapa</string> + <string name="mfa_challenge_invalid">Virheellinen koodi. Yritä uudelleen.</string> + <string name="mfa_challenge_retry">Liian monta yritystä. Uusi koodi on lähetetty.</string> + <string name="mfa_challenge_failed">Vahvistus epäonnistui. Yritä myöhemmin uudelleen.</string> +</resources> diff --git a/taler-kotlin-android/src/main/res/values-fr/strings.xml b/taler-kotlin-android/src/main/res/values-fr/strings.xml @@ -19,4 +19,12 @@ <string name="close">Fermer</string> <string name="share">Partager</string> <string name="copy">Copier</string> -</resources> -\ No newline at end of file + <string name="mfa_challenge_title">Authentification à deux facteurs</string> + <string name="mfa_challenge_message">Un code de confirmation a été envoyé via %1$s (%2$s). Saisissez le code pour continuer.</string> + <string name="mfa_challenge_code_hint">Code de vérification</string> + <string name="mfa_challenge_code_incomplete">Saisissez les 8 chiffres.</string> + <string name="mfa_choose_title">Choisir la méthode de vérification</string> + <string name="mfa_challenge_invalid">Code incorrect. Veuillez réessayer.</string> + <string name="mfa_challenge_retry">Trop de tentatives. Un nouveau code a été envoyé.</string> + <string name="mfa_challenge_failed">La vérification a échoué. Veuillez réessayer plus tard.</string> +</resources> diff --git a/taler-kotlin-android/src/main/res/values-it/strings.xml b/taler-kotlin-android/src/main/res/values-it/strings.xml @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="mfa_challenge_title">Autenticazione a due fattori</string> + <string name="mfa_challenge_message">Un codice di conferma è stato inviato tramite %1$s (%2$s). Inserisci il codice per continuare.</string> + <string name="mfa_challenge_code_hint">Codice di verifica</string> + <string name="mfa_challenge_code_incomplete">Inserisci tutte le 8 cifre.</string> + <string name="mfa_choose_title">Scegli metodo di verifica</string> + <string name="mfa_challenge_invalid">Codice errato. Riprova.</string> + <string name="mfa_challenge_retry">Troppi tentativi. È stato inviato un nuovo codice.</string> + <string name="mfa_challenge_failed">Verifica non riuscita. Riprova più tardi.</string> +</resources> diff --git a/taler-kotlin-android/src/main/res/values-iw/strings.xml b/taler-kotlin-android/src/main/res/values-iw/strings.xml @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="mfa_challenge_title">אימות דו-שלבי</string> + <string name="mfa_challenge_message">קוד אישור נשלח דרך %1$s (%2$s). הזינו את הקוד כדי להמשיך.</string> + <string name="mfa_challenge_code_hint">קוד אימות</string> + <string name="mfa_challenge_code_incomplete">יש להזין את כל 8 הספרות.</string> + <string name="mfa_choose_title">בחירת שיטת אימות</string> + <string name="mfa_challenge_invalid">קוד שגוי. נסו שוב.</string> + <string name="mfa_challenge_retry">יותר מדי ניסיונות. קוד חדש נשלח.</string> + <string name="mfa_challenge_failed">האימות נכשל. נסו שוב מאוחר יותר.</string> +</resources> diff --git a/taler-kotlin-android/src/main/res/values-ru/strings.xml b/taler-kotlin-android/src/main/res/values-ru/strings.xml @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="mfa_challenge_title">Двухфакторная аутентификация</string> + <string name="mfa_challenge_message">Код подтверждения отправлен через %1$s (%2$s). Введите код, чтобы продолжить.</string> + <string name="mfa_challenge_code_hint">Код подтверждения</string> + <string name="mfa_challenge_code_incomplete">Введите все 8 цифр.</string> + <string name="mfa_choose_title">Выберите способ подтверждения</string> + <string name="mfa_challenge_invalid">Неверный код. Повторите попытку.</string> + <string name="mfa_challenge_retry">Слишком много попыток. Отправлен новый код.</string> + <string name="mfa_challenge_failed">Проверка не удалась. Повторите попытку позже.</string> +</resources> diff --git a/taler-kotlin-android/src/main/res/values-sv/strings.xml b/taler-kotlin-android/src/main/res/values-sv/strings.xml @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="mfa_challenge_title">Tvåfaktorsautentisering</string> + <string name="mfa_challenge_message">En bekräftelsekod skickades via %1$s (%2$s). Ange koden för att fortsätta.</string> + <string name="mfa_challenge_code_hint">Verifieringskod</string> + <string name="mfa_challenge_code_incomplete">Ange alla 8 siffror.</string> + <string name="mfa_choose_title">Välj verifieringsmetod</string> + <string name="mfa_challenge_invalid">Fel kod. Försök igen.</string> + <string name="mfa_challenge_retry">För många försök. En ny kod har skickats.</string> + <string name="mfa_challenge_failed">Verifieringen misslyckades. Försök igen senare.</string> +</resources> diff --git a/taler-kotlin-android/src/main/res/values-tr/strings.xml b/taler-kotlin-android/src/main/res/values-tr/strings.xml @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="mfa_challenge_title">İki faktörlü kimlik doğrulama</string> + <string name="mfa_challenge_message">%1$s (%2$s) üzerinden bir onay kodu gönderildi. Devam etmek için kodu girin.</string> + <string name="mfa_challenge_code_hint">Doğrulama kodu</string> + <string name="mfa_challenge_code_incomplete">8 hanenin tümünü girin.</string> + <string name="mfa_choose_title">Doğrulama yöntemini seçin</string> + <string name="mfa_challenge_invalid">Kod yanlış. Lütfen tekrar deneyin.</string> + <string name="mfa_challenge_retry">Çok fazla deneme yapıldı. Yeni bir kod gönderildi.</string> + <string name="mfa_challenge_failed">Doğrulama başarısız oldu. Lütfen daha sonra tekrar deneyin.</string> +</resources> diff --git a/taler-kotlin-android/src/main/res/values-uk/strings.xml b/taler-kotlin-android/src/main/res/values-uk/strings.xml @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="mfa_challenge_title">Двофакторна автентифікація</string> + <string name="mfa_challenge_message">Код підтвердження надіслано через %1$s (%2$s). Введіть код, щоб продовжити.</string> + <string name="mfa_challenge_code_hint">Код підтвердження</string> + <string name="mfa_challenge_code_incomplete">Введіть усі 8 цифр.</string> + <string name="mfa_choose_title">Виберіть спосіб підтвердження</string> + <string name="mfa_challenge_invalid">Неправильний код. Спробуйте ще раз.</string> + <string name="mfa_challenge_retry">Забагато спроб. Надіслано новий код.</string> + <string name="mfa_challenge_failed">Підтвердження не вдалося. Спробуйте пізніше.</string> +</resources> diff --git a/taler-kotlin-android/src/main/res/values/strings.xml b/taler-kotlin-android/src/main/res/values/strings.xml @@ -26,6 +26,7 @@ <string name="mfa_challenge_title">Two-factor authentication</string> <string name="mfa_challenge_message">A confirmation code was sent via %1$s (%2$s). Enter the code to continue.</string> <string name="mfa_challenge_code_hint">Verification code</string> + <string name="mfa_challenge_code_incomplete">Enter all 8 digits.</string> <string name="mfa_choose_title">Choose verification method</string> <string name="mfa_challenge_invalid">Incorrect code. Please try again.</string> <string name="mfa_challenge_retry">Too many attempts. A new code has been sent.</string>