commit 24f7b2498fd922637775bdc5cf5e512907eb76a0 parent 0243e8f00bc3c248fe8f6126b582c37c5cfb8d66 Author: Bohdan Potuzhnyi <bohdan.potuzhnyi@gmail.com> Date: Sun, 20 Sep 2026 15:46:58 +0200 [pos] add support for mixed payments and UI changes Diffstat:
199 files changed, 31606 insertions(+), 4302 deletions(-)
diff --git a/merchant-lib/src/main/java/net/taler/merchantlib/MerchantApi.kt b/merchant-lib/src/main/java/net/taler/merchantlib/MerchantApi.kt @@ -69,6 +69,14 @@ class MerchantApi( } } + suspend fun getUnits(merchantConfig: MerchantConfig): Response<MerchantUnitsResponse> = withContext(ioDispatcher) { + response { + httpClient.get(merchantConfig.urlFor("private/units")) { + auth(merchantConfig) + }.body() + } + } + suspend fun deleteOrder( merchantConfig: MerchantConfig, orderId: String, @@ -82,6 +90,23 @@ class MerchantApi( } } + /** + * Completes an order settled entirely outside Taler, exactly as a wallet paying the + * free order would. Only for orders with a zero amount; requires protocol v42. + */ + suspend fun collectOrder( + merchantConfig: MerchantConfig, + orderId: String, + ): Response<Unit> = withContext(ioDispatcher) { + response { + httpClient.post(merchantConfig.urlFor("private/orders/$orderId/collect")) { + auth(merchantConfig) + contentType(Json) + setBody(CollectOrderRequest()) + }.body() + } + } + suspend fun getOrderHistory( merchantConfig: MerchantConfig, limit: Int = -20, @@ -111,6 +136,21 @@ class MerchantApi( } } + /** Records a refund settled outside Taler; requires protocol v42. */ + suspend fun refundExternal( + merchantConfig: MerchantConfig, + orderId: String, + request: ExternalRefundRequest, + ): Response<Unit> = withContext(ioDispatcher) { + response { + httpClient.post(merchantConfig.urlFor("private/orders/$orderId/refund-external")) { + auth(merchantConfig) + contentType(Json) + setBody(request) + }.body() + } + } + private fun HttpRequestBuilder.auth(merchantConfig: MerchantConfig) { header(Authorization, "Bearer ${merchantConfig.apiKey}") } diff --git a/merchant-lib/src/main/java/net/taler/merchantlib/MerchantUnit.kt b/merchant-lib/src/main/java/net/taler/merchantlib/MerchantUnit.kt @@ -0,0 +1,42 @@ +/* + * 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.merchantlib + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import java.util.Locale + +@Serializable +data class MerchantUnitsResponse(val units: List<MerchantUnit>) + +@Serializable +data class MerchantUnit( + val unit: String, + @SerialName("unit_name_short") val nameShort: String, + @SerialName("unit_name_short_i18n") val nameShortI18n: Map<String, String>? = null, + @SerialName("unit_precision_level") val precision: Int = 0, +) { + fun shortLabel(locale: Locale = Locale.getDefault()): String { + val translations = nameShortI18n.orEmpty().mapKeys { it.key.replace('_', '-').lowercase(Locale.ROOT) } + var tag = locale.toLanguageTag().lowercase(Locale.ROOT) + while (tag.isNotEmpty()) { + translations[tag]?.takeIf(String::isNotBlank)?.let { return it } + tag = tag.substringBeforeLast('-', "") + } + return nameShort.takeIf(String::isNotBlank) ?: unit + } +} diff --git a/merchant-lib/src/main/java/net/taler/merchantlib/OrderHistory.kt b/merchant-lib/src/main/java/net/taler/merchantlib/OrderHistory.kt @@ -19,8 +19,10 @@ package net.taler.merchantlib import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient import kotlinx.serialization.json.JsonNames import net.taler.common.Amount +import net.taler.common.ExternalPayment import net.taler.common.Timestamp @Serializable @@ -42,7 +44,7 @@ data class OrderHistoryEntry( // when the order was created val timestamp: Timestamp, - // the amount of money the order is for + // Taler portion only; keep this unchanged for payment and refund operations. val amount: Amount, // the summary of the order @@ -67,7 +69,41 @@ data class OrderHistoryEntry( // whether the backend still reports wallet pickup as pending val refundPending: Boolean = false, + + // Null until details are loaded (older backends may omit them). + @SerialName("amount_external") + val amountExternal: List<ExternalPayment>? = null, + @SerialName("refunds_external") + val refundsExternal: List<ExternalRefundInfo> = emptyList(), + @Transient val claimed: Boolean = false, ) { + val externalAmount: Amount + get() = amountExternal.orEmpty().fold(Amount.zero(amount.currency)) { sum, payment -> sum + payment.amount } + + val totalAmount: Amount get() = amount + externalAmount + + val totalRefundAmount: Amount + get() = refundsExternal.fold(refundAmount ?: Amount.zero(amount.currency)) { sum, refund -> sum + refund.amount } + + val hasExternalPayments: Boolean get() = !externalAmount.isZero() + + val hasAnyRefund: Boolean get() = hasRefund || refundsExternal.isNotEmpty() + + /** Merge order status without substituting the full sale total for the Taler amount. */ + fun withPaymentDetails(response: CheckPaymentResponse): OrderHistoryEntry { + val updated = copy( + paid = response.paid, + claimed = response is CheckPaymentResponse.Claimed, + amountExternal = response.paymentDetails?.amountExternal ?: amountExternal, + ) + return if (response is CheckPaymentResponse.Paid) updated.copy( + refunded = response.refunded, + refundAmount = response.refundAmount ?: refundAmount, + refundPending = response.refundPending, + refundsExternal = response.refundsExternal, + ) else updated + } + val hasRefund: Boolean get() = refunded || refundAmount?.isZero() == false diff --git a/merchant-lib/src/main/java/net/taler/merchantlib/Orders.kt b/merchant-lib/src/main/java/net/taler/merchantlib/Orders.kt @@ -20,9 +20,13 @@ import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonClassDiscriminator +import kotlinx.serialization.json.JsonObject import net.taler.common.Amount -import net.taler.common.ContractTerms +import net.taler.common.ExternalPayment +import net.taler.common.Location +import net.taler.common.Merchant import net.taler.common.RelativeTime +import net.taler.common.Timestamp @Serializable data class PostOrderRequest( @@ -55,6 +59,16 @@ data class MinimalInventoryProduct( val productMoneyPot: Int? = null, ) +/** + * Body of `POST /private/orders/$ORDER_ID/collect`, which completes an order whose + * Taler amount is zero without a wallet (DD 96). [choiceIndex] is only for v1 orders. + */ +@Serializable +data class CollectOrderRequest( + @SerialName("choice_index") + val choiceIndex: Int? = null, +) + @Serializable data class PostOrderResponse( @SerialName("order_id") @@ -66,6 +80,7 @@ data class PostOrderResponse( @JsonClassDiscriminator("order_status") sealed class CheckPaymentResponse { abstract val paid: Boolean + abstract val paymentDetails: OrderPaymentDetails? @Serializable @SerialName("unpaid") @@ -74,13 +89,17 @@ sealed class CheckPaymentResponse { @SerialName("taler_pay_uri") val talerPayUri: String, @SerialName("already_paid_order_id") - val alreadyPaidOrderId: String? = null + val alreadyPaidOrderId: String? = null, + @SerialName("proto_contract_terms") + override val paymentDetails: OrderPaymentDetails? = null, ) : CheckPaymentResponse() @Serializable @SerialName("claimed") data class Claimed( override val paid: Boolean = false, + @SerialName("contract_terms") + override val paymentDetails: OrderPaymentDetails? = null, ) : CheckPaymentResponse() @Serializable @@ -92,6 +111,77 @@ sealed class CheckPaymentResponse { val refundPending: Boolean = false, @SerialName("refund_amount") val refundAmount: Amount? = null, + @SerialName("contract_terms") + override val paymentDetails: OrderPaymentDetails? = null, + @SerialName("refunds_external") + val refundsExternal: List<ExternalRefundInfo> = emptyList(), ) : CheckPaymentResponse() } + +/** Common payment fields shared by v0, v1 and unclaimed contract terms. */ +@Serializable +data class OrderPaymentDetails( + @SerialName("amount_external") + val amountExternal: List<ExternalPayment> = emptyList(), + val summary: String? = null, + @SerialName("summary_i18n") + val summaryI18n: Map<String, String>? = null, + val amount: Amount? = null, + val timestamp: Timestamp? = null, + val merchant: Merchant? = null, + val products: List<OrderDetailProduct> = emptyList(), + @SerialName("fulfillment_url") + val fulfillmentUrl: String? = null, + @SerialName("fulfillment_message") + val fulfillmentMessage: String? = null, + @SerialName("fulfillment_message_i18n") + val fulfillmentMessageI18n: Map<String, String>? = null, + @SerialName("public_reorder_url") + val publicReorderUrl: String? = null, + @SerialName("delivery_location") + val deliveryLocation: Location? = null, + @SerialName("delivery_date") + val deliveryDate: Timestamp? = null, + @SerialName("max_pickup_time") + val maxPickupTime: Timestamp? = null, + @SerialName("refund_deadline") + val refundDeadline: Timestamp? = null, + @SerialName("pay_deadline") + val payDeadline: Timestamp? = null, + val extra: JsonObject? = null, +) + +/** Read-only ProductSold fields. Backend prices already cover the entire quantity. */ +@Serializable +data class OrderDetailProduct( + val description: String, + @SerialName("product_name") + val productName: String? = null, + @SerialName("description_i18n") + val descriptionI18n: Map<String, String>? = null, + val quantity: Int = 1, + @SerialName("unit_quantity") + val unitQuantity: String? = null, + val unit: String? = null, + val price: Amount? = null, + val prices: List<Amount> = emptyList(), + @SerialName("prices_are_net") + val pricesAreNet: Boolean = false, + val image: String? = null, +) { + fun priceForCurrency(currency: String?): Amount? = + prices.firstOrNull { it.currency == currency } + ?: price?.takeIf { currency == null || it.currency == currency } +} + +@Serializable +data class ExternalRefundInfo( + val id: String, + val method: String, + val amount: Amount, + val reason: String, + val timestamp: Timestamp, + @SerialName("payment_id") + val paymentId: String? = null, +) diff --git a/merchant-lib/src/main/java/net/taler/merchantlib/Refunds.kt b/merchant-lib/src/main/java/net/taler/merchantlib/Refunds.kt @@ -40,3 +40,22 @@ data class RefundResponse( @SerialName("taler_refund_uri") val talerRefundUri: String ) + +/** + * Records a refund that was settled outside Taler, such as a card refund (DD 96). + * [id] is unique within the order and makes the call idempotent. + */ +@Serializable +data class ExternalRefundRequest( + /** External payment method receiving the refund record. */ + val method: String, + /** Refund ID, reused when retrying the same backend request. */ + val id: String, + /** The external payment being refunded, when it is known. */ + @SerialName("payment_id") + val paymentId: String? = null, + /** Amount already returned to the customer outside Taler. */ + val amount: Amount, + /** Explanation recorded with the refund. */ + val reason: String, +) diff --git a/merchant-lib/src/main/java/net/taler/merchantlib/Response.kt b/merchant-lib/src/main/java/net/taler/merchantlib/Response.kt @@ -24,6 +24,8 @@ import java.net.SocketTimeoutException import java.net.UnknownHostException import java.nio.channels.UnresolvedAddressException import kotlinx.coroutines.CancellationException +import kotlinx.serialization.SerializationException +import io.ktor.serialization.ContentConvertException import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json @@ -79,8 +81,10 @@ class Response<out T> private constructor( is UnresolvedAddressException, is ConnectException, is SocketTimeoutException -> NETWORK_ERROR_MESSAGE - is IOException -> exception.message?.takeIf(String::isNotBlank) ?: NETWORK_ERROR_MESSAGE - else -> exception.message?.takeIf(String::isNotBlank) ?: exception.toString() + is IOException -> NETWORK_ERROR_MESSAGE + is SerializationException, + is ContentConvertException -> "The merchant server returned an invalid response. Please try again." + else -> "The request failed. Please try again." } private suspend fun getExceptionString(e: ResponseException): String { diff --git a/merchant-lib/src/test/java/net/taler/merchantlib/MerchantApiTest.kt b/merchant-lib/src/test/java/net/taler/merchantlib/MerchantApiTest.kt @@ -24,6 +24,7 @@ import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.UnconfinedTestDispatcher import net.taler.common.Amount import net.taler.common.ContractProduct +import net.taler.common.ExternalPayment import net.taler.common.Order import net.taler.common.OrderProduct import net.taler.common.Timestamp @@ -44,6 +45,26 @@ class MerchantApiTest { private val orderId = "orderIdFoo" @Test + fun testGetUnitCatalogue() = runBlocking { + httpClient.giveJsonResponse("http://example.net/instances/testInstance/private/units") { + """{"units":[{"unit":"item","unit_name_long":"piece","unit_name_short":"pc", + "unit_name_short_i18n":{"de":"Stk."},"unit_precision_level":0,"unit_active":true}]}""" + } + api.getUnits(merchantConfig).assertSuccess { + assertEquals("pc", it.units.single().shortLabel(java.util.Locale.ENGLISH)) + assertEquals("Stk.", it.units.single().shortLabel(java.util.Locale.GERMAN)) + } + } + + @Test + fun testUnitCatalogueUnavailableOnLegacyBackend() = runBlocking { + httpClient.giveJsonResponse("http://example.net/instances/testInstance/private/units", statusCode = NotFound) { + """{"code":1,"hint":"Not found"}""" + } + assertTrue(api.getUnits(merchantConfig).isFailure) + } + + @Test fun testGetConfig() = runBlocking { httpClient.giveJsonResponse("https://backend.int.taler.net/config") { """ @@ -139,6 +160,98 @@ class MerchantApiTest { } @Test + fun testPostOrderWithExternalPayments() = runBlocking { + val cash = ExternalPayment(method = "cash", id = "cash1", amount = Amount("TEST", 5, 0)) + val card = ExternalPayment( + method = "sumup", + id = "card1", + amount = Amount("TEST", 2, 0), + transactionCode = "TX9", + ) + val contractTerms = Order( + summary = "mixed", + amount = Amount("TEST", 3, 0), + orderId = "pos-1", + amountExternal = listOf(cash, card), + ) + val expectedJson = """ + { + "order": { + "summary": "mixed", + "amount": "${contractTerms.amount.toJSONString()}", + "order_id": "pos-1", + "amount_external": [ + {"method": "cash", "id": "cash1", "amount": "${cash.amount.toJSONString()}"}, + { + "method": "sumup", + "id": "card1", + "amount": "${card.amount.toJSONString()}", + "transaction_code": "TX9" + } + ] + } + } + """.trimIndent() + httpClient.giveJsonResponse( + "http://example.net/instances/testInstance/private/orders", + expectedJson, + ) { + """{"order_id": "pos-1"}""" + } + api.postOrder(merchantConfig, PostOrderRequest(contractTerms)).assertSuccess { + assertEquals(PostOrderResponse("pos-1"), it) + } + } + + @Test + fun testRefundExternal() = runBlocking { + val request = ExternalRefundRequest( + method = "sumup", + id = "refund1", + paymentId = "card1", + amount = Amount("TEST", 5, 0), + reason = "damaged", + ) + val expectedJson = """ + { + "method": "sumup", + "id": "refund1", + "payment_id": "card1", + "amount": "${request.amount.toJSONString()}", + "reason": "damaged" + } + """.trimIndent() + httpClient.giveJsonResponse( + "http://example.net/instances/testInstance/private/orders/$orderId/refund-external", + expectedJson, + ) { + "{}" + } + api.refundExternal(merchantConfig, orderId, request).assertSuccess {} + } + + @Test + fun testCollectOrder() = runBlocking { + httpClient.giveJsonResponse( + "http://example.net/instances/testInstance/private/orders/$orderId/collect", + expectedBody = "{}", + ) { + "{}" + } + api.collectOrder(merchantConfig, orderId).assertSuccess {} + + httpClient.giveJsonResponse( + "http://example.net/instances/testInstance/private/orders/$orderId/collect", + statusCode = io.ktor.http.HttpStatusCode.Conflict, + ) { + """{"code": 2170, "hint": "order already claimed"}""" + } + api.collectOrder(merchantConfig, orderId).assertFailure { + assertTrue(it.contains("2170")) + } + } + + @Test fun testCheckOrder() = runBlocking { val unpaidResponse = CheckPaymentResponse.Unpaid(false, "http://taler.net/foo") httpClient.giveJsonResponse("http://example.net/instances/testInstance/private/orders/$orderId") { diff --git a/merchant-lib/src/test/java/net/taler/merchantlib/OrderDetailsTest.kt b/merchant-lib/src/test/java/net/taler/merchantlib/OrderDetailsTest.kt @@ -0,0 +1,85 @@ +/* + * 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.merchantlib + +import kotlinx.serialization.json.Json +import net.taler.common.Amount +import net.taler.common.Timestamp +import org.junit.Assert.* +import org.junit.Test + +class OrderDetailsTest { + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun `details survive decoding for unpaid claimed and paid contracts of either version`() { + for (version in 0..1) for (status in listOf("unpaid", "claimed", "paid")) { + val field = if (status == "unpaid") "proto_contract_terms" else "contract_terms" + val response = json.decodeFromString<CheckPaymentResponse>("""{ + "order_status":"$status", "refunded":false, "taler_pay_uri":"taler://pay/example/order", + "$field":{ + "version":$version, "summary":"Fruit", "amount":"CHF:20.4", + "merchant":{"name":"Mensa","address":{"town":"Biel"}}, + "products":[{"description":"Grape", "quantity":1, "unit_quantity":"0.200", "unit":"kg", + "image":"data:image/png;base64,aW1hZ2U=", "price":"CHF:2", "prices":["EUR:3","CHF:2"]}], + "public_reorder_url":"https://example.com/reorder", + "fulfillment_url":"https://example.com/content", + "delivery_location":{"address_lines":["Building A"],"town":"Biel"}, + "delivery_date":{"t_s":100}, "max_pickup_time":{"t_s":"never"}, + "extra":{"customer_reference":"00041209","nested":{"note":"Keep this"}} + } + }""") + val details = response.paymentDetails!! + assertEquals("Mensa", details.merchant!!.name) + assertEquals("0.200", details.products.single().unitQuantity) + assertEquals("data:image/png;base64,aW1hZ2U=", details.products.single().image) + assertEquals(Amount.fromJSONString("CHF:2"), details.products.single().priceForCurrency("CHF")) + assertEquals("Building A", details.deliveryLocation!!.addressLines!!.single()) + assertEquals(Timestamp.never(), details.maxPickupTime) + assertEquals("https://example.com/reorder", details.publicReorderUrl) + assertNotNull(details.extra!!["nested"]) + } + } + + @Test + fun `line totals are never multiplied and prices select the order currency`() { + val product = json.decodeFromString<OrderDetailProduct>("""{ + "description":"Apples", "quantity":2, "price":"CHF:7", "prices":["EUR:8","CHF:7"] + }""") + assertEquals(Amount.fromJSONString("CHF:7"), product.priceForCurrency("CHF")) + assertEquals(Amount.fromJSONString("EUR:8"), product.priceForCurrency("EUR")) + assertNull(product.priceForCurrency("USD")) + val withoutPrice = json.decodeFromString<OrderDetailProduct>("""{"description":"Bundled content"}""") + assertNull(withoutPrice.priceForCurrency("CHF")) + assertNull(withoutPrice.image) + } + + @Test + fun `minimal and legacy contracts keep optional sections absent`() { + val response = json.decodeFromString<CheckPaymentResponse>("""{ + "order_status":"claimed", "contract_terms":{"summary":"Counter sale"} + }""") + val details = response.paymentDetails!! + assertEquals("Counter sale", details.summary) + assertTrue(details.products.isEmpty()) + assertNull(details.merchant) + assertNull(details.deliveryDate) + assertNull(details.publicReorderUrl) + assertNull(details.extra) + assertNull(json.decodeFromString<CheckPaymentResponse>("""{"order_status":"paid","refunded":false}""").paymentDetails) + } +} diff --git a/merchant-lib/src/test/java/net/taler/merchantlib/OrderPaymentDetailsTest.kt b/merchant-lib/src/test/java/net/taler/merchantlib/OrderPaymentDetailsTest.kt @@ -0,0 +1,134 @@ +/* + * 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.merchantlib + +import kotlinx.serialization.json.Json +import net.taler.common.Amount +import net.taler.common.Timestamp +import org.junit.Assert.* +import org.junit.Test + +class OrderPaymentDetailsTest { + private val json = Json { ignoreUnknownKeys = true } + private fun chf(value: Int) = Amount("CHF", value.toLong(), 0) + private fun entry(amount: Int = 15) = OrderHistoryEntry( + orderId = "sale", timestamp = Timestamp(0), amount = chf(amount), + summary = "Lunch", paid = false, refundable = false, + ) + private val external = """[ + {"method":"cash","id":"cash1","amount":"CHF:10"}, + {"method":"sumup","id":"card1","amount":"CHF:5","transaction_code":"TX42","terminal":17} + ]""" + + @Test + fun `all order states expose payments through their contract terms`() { + for (status in listOf("unpaid", "claimed", "paid")) { + val field = if (status == "unpaid") "proto_contract_terms" else "contract_terms" + val response = json.decodeFromString<CheckPaymentResponse>("""{ + "order_status":"$status", "taler_pay_uri":"taler://pay/example/order", + "refunded":false, "$field":{"amount_external":$external,"summary":"Lunch"} + }""") + val item = entry().withPaymentDetails(response) + assertEquals(chf(30), item.totalAmount) + assertEquals(chf(15), item.amount) + assertEquals(chf(15), item.externalAmount) + assertEquals("TX42", item.amountExternal!![1].transactionCode) + assertEquals(status == "paid", item.paid) + assertEquals(status == "claimed", item.claimed) + assertTrue(item.hasExternalPayments) + } + } + + @Test + fun `fully external orders retain their full total and zero Taler amount`() { + val response = json.decodeFromString<CheckPaymentResponse>("""{ + "order_status":"paid", "refunded":false, + "contract_terms":{"amount_external":$external} + }""") + val item = entry(0).withPaymentDetails(response) + assertEquals(chf(15), item.totalAmount) + assertTrue(item.amount.isZero()) + assertTrue(item.paid) + } + + @Test + fun `external refunds add to the displayed refund total without changing Taler refund accounting`() { + val response = json.decodeFromString<CheckPaymentResponse>("""{ + "order_status":"paid", "refunded":true, "refund_amount":"CHF:2", + "contract_terms":{"amount_external":$external}, + "refunds_external":[ + {"id":"r1","method":"cash","amount":"CHF:3","reason":"Returned item", + "timestamp":{"t_s":100},"payment_id":"card1"}, + {"id":"r2","method":"voucher","amount":"CHF:1","reason":"Adjustment", + "timestamp":{"t_s":101}} + ] + }""") + val item = entry().withPaymentDetails(response) + assertEquals(chf(6), item.totalRefundAmount) + assertEquals(chf(2), item.refundAmount) + assertEquals(chf(30), item.totalAmount) + assertEquals("card1", item.refundsExternal[0].paymentId) + assertNull(item.refundsExternal[1].paymentId) + assertFalse(item.hasPendingRefund) + assertTrue(item.hasAnyRefund) + } + + @Test + fun `external refund of a pure Taler order does not imply wallet pickup`() { + val response = json.decodeFromString<CheckPaymentResponse>("""{ + "order_status":"paid", "refunded":false, + "contract_terms":{}, "refunds_external":[ + {"id":"r1","method":"cash","amount":"CHF:3","reason":"Return","timestamp":{"t_s":100}} + ] + }""") + val item = entry().withPaymentDetails(response) + assertTrue(item.hasAnyRefund) + assertFalse(item.hasRefund) + assertFalse(item.hasPendingRefund) + assertEquals(chf(3), item.totalRefundAmount) + } + + @Test + fun `older responses and empty payment lists remain compatible`() { + val legacy = json.decodeFromString<CheckPaymentResponse>("""{ + "order_status":"paid", "refunded":false + }""") + val item = entry().withPaymentDetails(legacy) + assertEquals(chf(15), item.totalAmount) + assertNull(item.amountExternal) + assertFalse(item.hasExternalPayments) + assertFalse(item.hasAnyRefund) + val current = json.decodeFromString<CheckPaymentResponse>("""{ + "order_status":"paid", "refunded":false, "contract_terms":{"amount_external":[]} + }""") + assertEquals(emptyList<net.taler.common.ExternalPayment>(), item.withPaymentDetails(current).amountExternal) + } + @Test + fun `history payload retains unknown methods and additive totals`() { + val history = json.decodeFromString<OrderHistory>("""{"orders":[{ + "order_id":"sale", "timestamp":{"t_s":0}, "amount":"CHF:15", + "summary":"Lunch", "paid":false, "refundable":false, + "amount_external":[{"id":"p1","method":"gift-card","amount":"CHF:5"}] + }]}""") + val item = history.orders.single() + assertEquals("gift-card", item.amountExternal!!.single().method) + assertEquals(chf(20), item.totalAmount) + val legacy = CheckPaymentResponse.Claimed() + assertEquals(item.amountExternal, item.withPaymentDetails(legacy).amountExternal) + } + +} diff --git a/merchant-lib/src/test/java/net/taler/merchantlib/ResponseCancellationTest.kt b/merchant-lib/src/test/java/net/taler/merchantlib/ResponseCancellationTest.kt @@ -13,6 +13,7 @@ * 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.merchantlib import java.io.IOException @@ -25,6 +26,25 @@ import org.junit.Test class ResponseCancellationTest { @Test + fun malformedRepliesDoNotExposeSerializerDetails() = runBlocking { + for (error in listOf( + kotlinx.serialization.SerializationException("Field amount is required for type net.taler.internal.Type"), + io.ktor.serialization.ContentConvertException("Unexpected JSON token at offset 99"), + )) { + Response.failure<Unit>(error).assertFailure { + assertEquals("The merchant server returned an invalid response. Please try again.", it) + } + } + } + + @Test + fun truncatedTransportDoesNotExposeSocketDetails() = runBlocking { + Response.failure<Unit>(IOException("expected 99 bytes, received 7")).assertFailure { + assertEquals("Network error: check your internet connection and merchant URL.", it) + } + } + + @Test fun cancellationPropagatesInsteadOfBecomingAnOrderError() = runBlocking { val cancellation = CancellationException("Parent job is Completed") try { @@ -38,7 +58,7 @@ class ResponseCancellationTest { @Test fun networkErrorsStillBecomeFailures() = runBlocking { Response.response<Unit> { throw IOException("Connection lost") }.assertFailure { - assertEquals("Connection lost", it) + assertEquals("Network error: check your internet connection and merchant URL.", it) } } } diff --git a/merchant-terminal/.editorconfig b/merchant-terminal/.editorconfig @@ -0,0 +1,49 @@ +root = true + +[*.{kt,kts}] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 4 +insert_final_newline = true +trim_trailing_whitespace = true +max_line_length = 120 +ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL +ij_kotlin_allow_trailing_comma = true +ij_kotlin_allow_trailing_comma_on_call_site = true +# Group POS imports by package, with aliases last; keep explicit imports. +ij_kotlin_imports_layout = android.**,|,androidx.**,|,com.**,|,junit.**,|,net.**,|,org.**,|,java.**,|,javax.**,|,*,|,^ +ij_kotlin_name_count_to_use_star_import = 2147483647 +ij_kotlin_name_count_to_use_star_import_for_members = 2147483647 +ij_kotlin_packages_to_use_import_on_demand = unset +ktlint_code_style = ktlint_official +ktlint_function_naming_ignore_when_annotated_with = Composable +ij_kotlin_if_rparen_on_new_line = false +ij_kotlin_continuation_indent_size = 4 +ij_kotlin_continuation_indent_in_if_conditions = true +ij_kotlin_lbrace_on_next_line = true +ij_kotlin_else_on_new_line = true +ij_kotlin_catch_on_new_line = true +ij_kotlin_finally_on_new_line = true +ij_kotlin_while_on_new_line = true +# Android Studio handles Allman layout; ktlint assumes same-line opening braces. +ktlint_standard_wrapping = disabled +ktlint_standard_curly-spacing = disabled +ktlint_standard_keyword-spacing = disabled +ktlint_standard_try-catch-finally-spacing = disabled +ktlint_standard_indent = disabled +ktlint_standard_class-signature = disabled +ktlint_standard_function-signature = disabled +ktlint_standard_function-start-of-body-spacing = disabled +ktlint_standard_no-line-break-after-else = disabled +ktlint_standard_type-parameter-list-spacing = disabled +# String-template indentation depends on ktlint's indentation rule. +ktlint_standard_string-template-indent = disabled +# These rules require wrapping; trailing commas remain enabled in Android Studio. +ktlint_standard_trailing-comma-on-call-site = disabled +ktlint_standard_trailing-comma-on-declaration-site = disabled + +[*.java] +# Keep the POS Java import grouping local to this module. +ij_java_layout_static_imports_separately = true +ij_java_imports_layout = $android.**,$androidx.**,$com.**,$junit.**,$net.**,$org.**,$java.**,$javax.**,$*,|,android.**,|,androidx.**,|,com.**,|,junit.**,|,net.**,|,org.**,|,java.**,|,javax.**,|,*,| diff --git a/merchant-terminal/.gitignore b/merchant-terminal/.gitignore @@ -1,3 +1,7 @@ /build /.bundle /src/debug/assets/screenshot-products/*.png + +# Local evaluation harness and evaluation-only tests +/evaluation/ +/src/test/java/net/taler/merchantpos/evaluation/ diff --git a/merchant-terminal/README.md b/merchant-terminal/README.md @@ -16,3 +16,112 @@ or build it with Gradle on the command line (from the repository root): More information can be found in the [Taler developer manual](https://docs.taler.net/developers-manual.html#build-apps-from-source). + +## Payment terminals + +Card terminals are organised as providers: one ecosystem per provider, each with +its own SDK, settings and readers. Settings → Payment terminals holds a master +switch for the feature and opens the provider list; each provider there has its own +switch and its own settings screen. A provider is only reached by the app when both +the master switch and its own switch are on. + +The terminal icon on the amount entry, inventory and payment screens opens a status +dialog summarising every active provider. Connection state is refreshed while the +app is resumed; being enabled or having a saved reader does not imply that the +reader is connected. Turning either switch off keeps stored settings and the +provider's session, so re-enabling does not require pairing again. Merchant logout +turns everything off and clears every provider session. + +### Packaging + +The SumUp SDK is bundled unconditionally today, which costs roughly 22 MB in the +APK: the SDK itself plus the sqlcipher native libraries it depends on. + +Android offers no way to add a terminal SDK after installation outside Google Play. +On-demand delivery needs an App Bundle and the proprietary Play Feature Delivery +library, split APKs cannot be installed from F-Droid or by sideloading, and the SDK +declares activities, services and permissions that only the installed manifest can +provide, so runtime class loading cannot work either. The intended direction is +therefore a companion app per ecosystem, talking to the POS over a standard +interface; [PaymentOption] is already that seam, and such an app would appear as one +more option in the registry. + +### Adding a provider + +Implement `TerminalProvider` (in `terminal/TerminalProvider.kt`) and list it in the +`TerminalRegistry` of the flavour that bundles it. The provider owns its status +ladder, its activity result contracts and the composable for its own settings +screen; navigation, the settings screens and the status indicator adapt on their +own. `TerminalRegistry.initialize` runs any SDK startup the flavour needs. + +### Taler devices + +The POS supports external Taler displays over USB and Bluetooth for payment and +refund links. Connect and manage displays under GNU Taler → Manage displays. + +### SumUp setup + +The app uses a bundled SumUp affiliate key by default. Merchants sign in with their +own SumUp account. A different affiliate key can be set under Advanced in the +SumUp settings. + +Supply the bundled key when building: + + $ ./gradlew :merchant-terminal:assembleDebug -PsumupAffiliateKey=<key> + +Alternatively, set `SUMUP_AFFILIATE_KEY` in the environment or `sumupAffiliateKey` +in `~/.gradle/gradle.properties`. + +Open Settings → Payment terminals → SumUp and select Pair reader to sign in +and connect a card reader. + +### Paying in several parts + +Enable cash or card payments to split a bill, with Taler covering the remaining +balance. Requires merchant backend protocol v42 or newer. Resume unfinished +checkouts from History using the same merchant account. Verify unresolved card +payments with the provider before continuing. + +### Card refunds + +SumUp refunds are currently unavailable in the POS. Use SumUp's app or another +refund method. + +## Testing + +Run unit tests with JDK 21: + + $ ./gradlew :merchant-terminal:testDebugUnitTest + +Testing SumUp payments requires an affiliate key, merchant account and compatible +card reader. + +## Kotlin formatting + +Use Android Studio and ktlint with the module's `.editorconfig`: four-space +indentation, a 120-character line limit and Allman braces. + +```sh +./gradlew :merchant-terminal:formatKotlin +./gradlew :merchant-terminal:checkKotlinFormat :merchant-terminal:ktlintCheck +``` + +If Android Studio is not detected, set `ANDROID_STUDIO_HOME` to its installation +directory (`Contents` on macOS). + +## Screenshots + +Use one capture flow with named subsets: + +```sh +python3 merchant-terminal/scripts/capture-screenshots.py --set tutorials --serial emulator-5560 +python3 merchant-terminal/scripts/capture-screenshots.py --set play-store --serial emulator-5560 +``` + +`tutorials` produces raw screenshots. `play-store` captures the selected screens +and applies the approved phone/tablet templates. `--set all` runs both. +Outputs are images only under `merchant-terminal/build/screenshots`. + +See [SCREENSHOTS.md](SCREENSHOTS.md) for setup, filters, and rebuilding the Play +Store images from existing captures. The [template sources](design/google-play/README.md) +contain the editable layouts, headlines, font and logo. diff --git a/merchant-terminal/SCREENSHOTS.md b/merchant-terminal/SCREENSHOTS.md @@ -6,6 +6,8 @@ This module now has a debug-only screenshot mode for deterministic captures of r - Launches the app directly into a named scenario using an intent extra. - Seeds fixed merchant config, products, totals, and product thumbnails in-process. +- Renders production Compose screens with fixed, inert state for settings, payment options, + payment receipts, order details, refunds, and checkout states. - Captures the resulting screen with `adb exec-out screencap`. Bundled fixture product images are generated locally by `merchant-terminal/scripts/generate-screenshot-product-images.sh`, so the default workflow does not depend on third-party image licenses. @@ -15,7 +17,23 @@ Bundled fixture product images are generated locally by `merchant-terminal/scrip - `amount-entry` - `order` - `payment` +- `payment-claimed` (claimed Taler payment and waiting state) +- `payment-external` (zero-balance checkout with local collection action) - `payment-success` +- `settings` and `settings-logout` (the first logout confirmation) +- `payment-options`, `payment-taler`, `payment-cash`, and `payment-sumup` +- `login`, `mfa-select`, and `mfa-code` +- `order-custom` and `order-custom-added` +- `refund`, `refund-mixed`, and `refund-qr` +- `checkout`, `checkout-partial`, `checkout-settled`, and `checkout-noreader` +- `navigation` +- `history-mixed` (mixed, partially paid and fully external orders) +- `history-external-refund` (external refund status and amount) +- `order-details` (a fully paid split payment: cash 5.00, card 10.00, Taler 5.40 CHF; rotates with the device) +- `order-details-partial` (cash and card paid: 15.00 CHF; Taler balance still due: 5.40 CHF) +- `order-details-minimal` (an order without optional contract fields) +- `order-details-loading` and `order-details-error` (loading and retry states) +- `history-order-details` (tap the history card to open details, then use Back) ## Generate fixture product images @@ -27,38 +45,105 @@ The images are written to `merchant-terminal/src/debug/assets/screenshot-product ## Capture screenshots -Connect an emulator or device, then run: +Use Python 3.10+, Pillow, adb and a dedicated emulator with the debug APK installed. +The activity hides Android system bars and uses the prepared café demo with generic +Card labels. It does not perform payments or test real Bluetooth/USB hardware. +The script requires an explicit emulator serial and never selects the connected +physical tablet automatically. -```bash -merchant-terminal/scripts/capture-screenshots.sh +```sh +python3 -m pip install -r merchant-terminal/design/google-play/requirements.txt +merchant-terminal/scripts/generate-screenshot-product-images.sh +./gradlew :merchant-terminal:assembleDebug +adb -s emulator-5560 install -r merchant-terminal/build/outputs/apk/debug/merchant-terminal-debug.apk ``` -Custom output directory: +Play Store rendering also needs `rsvg-convert` +from librsvg on PATH (`brew install librsvg` on macOS). -```bash -merchant-terminal/scripts/capture-screenshots.sh /tmp/merchant-terminal-shots +```sh +# All tutorial scenarios, both themes, phone and tablet. +python3 merchant-terminal/scripts/capture-screenshots.py --set tutorials --serial emulator-5560 + +# Eight selected screens per device, with the approved Play Store templates. +python3 merchant-terminal/scripts/capture-screenshots.py --set play-store --serial emulator-5560 + +# Both subsets in one run. +python3 merchant-terminal/scripts/capture-screenshots.py --set all --serial emulator-5560 ``` -Specific scenarios only: +`capture-screenshots.sh` forwards the same options to this command. It no longer +boots or chooses a device, builds the app, or accepts positional output/scenario +arguments. The separate `capture-tutorials.py` has been replaced by this flow. -```bash -merchant-terminal/scripts/capture-screenshots.sh /tmp/merchant-terminal-shots order payment +Outputs contain only images, with no website, manifests, contact sheets or RST: + +```text +merchant-terminal/build/screenshots/ + tutorials/en/{phone-portrait,tablet-landscape}/{light,dark}/*.png + play-store/captures/en/{phone-portrait,tablet-landscape}/{light,dark}/*.png + play-store/phone/01-products.png ... 08-dark-mode.png + play-store/tablet/01-products.png ... 08-dark-mode.png +``` + +Tutorial captures retain their existing numbered filenames and sizes: phone +1080 × 2340 and tablet 2560 × 1600. Play Store captures use phone 1080 × 2400 +and tablet 1920 × 1080 to fit the templates; finished cards are 1080 × 1920 and +2560 × 1440 respectively. The eight cards and their themes are defined once in +[design/google-play/cards.json](design/google-play/cards.json). + +Narrow any run using `--profiles`, `--themes`, and `--scenarios`, or set `--out`: + +```sh +python3 merchant-terminal/scripts/capture-screenshots.py \ + --set tutorials --serial emulator-5560 --scenarios order payment \ + --profiles phone-portrait --themes light --locales en de fr \ + --out merchant-terminal/build/screenshot-check +``` + +The default locale is English. Tutorial captures also support German and French; +Play Store headlines currently support English only. `--plan` lists the selected +captures without adb or file writes. `--resume` validates and reuses existing PNGs. +`--adb /path/to/adb` selects a custom executable. Display size, density, rotation +and font scale are restored even after failure. A missing ready signal, wrong +size/orientation or blank screenshot fails the run. + +To apply the templates to an existing set of Play Store captures without adb: + +```sh +python3 merchant-terminal/scripts/capture-screenshots.py \ + --set play-store --from-captures /path/to/captures \ + --out merchant-terminal/build/store-refresh +``` + +The input root must contain `en/phone-portrait` and `en/tablet-landscape` with the +filenames and themes from `cards.json`, at the Play Store capture sizes above. +This keeps UI capture and template rendering in one flow while allowing either +step to be rerun. Generated outputs do not overwrite Fastlane's published assets. + +For separately captured live-tablet images, hide the status bar, verify the PNGs, +and restore the tablet's display settings afterwards. + +Run the capture-flow tests with: + +```sh +python3 -m unittest discover -s merchant-terminal/scripts/tests -v ``` ## Intent hook -The debug hook is activated with: +The debug hook is activated by `ScreenshotActivity` with: ```text ---es taler_pos_screenshot_scenario <scenario> +--es screenshot_scenario <scenario> ``` Example: ```bash adb shell am start -W \ - -n net.taler.merchantpos/.MainActivity \ - --es taler_pos_screenshot_scenario order + -n net.taler.merchantpos/.debug.ScreenshotActivity \ + --es screenshot_scenario order --es screenshot_locale en ``` ## If you want real photos later @@ -72,3 +157,41 @@ Reasonable options: - Wikimedia Commons reuse guide: https://commons.wikimedia.org/wiki/Commons:Simple_media_reuse_guide For maximum simplicity in a repo, prefer public-domain or CC0 assets, or keep using locally generated fixture images for screenshot automation. + +## Order details layout checks + +The order details scenarios use `ScreenshotActivity` directly: + +```bash +adb shell am start -W -n net.taler.merchantpos/.debug.ScreenshotActivity \ + --es screenshot_scenario order-details --es screenshot_locale en +``` + +Capture phone portrait and landscape and tablet portrait and landscape. The detail +screen uses one column below 760 dp of effective width (accounting for font scale), +and two columns above that threshold, with a maximum content width of 1200 dp. +Check the lower sections by scrolling, long URLs, German labels, and large text. +The production history and details routes permit device rotation, including returning +from details to history. Verify this flow in both orientations. History cards keep their +status badge at the upper right and show total, paid, and remaining amounts. The Order +Details action row keeps refund and delete actions compact on narrow screens. Settings, +sign-in, payment options, refund forms, and refund QR layouts support both orientations. + +## Refund and delete checks + +- `refund-mixed` shows cash, card, and Taler refund choices with the original payment limits. + Select each method, enter a partial amount, and inspect the confirmation. Screenshot + fixtures never execute refunds. Cash/card confirm money already returned outside the app. +- `order-details-partial`: tap Delete, check the first warning, Continue, then the final + destructive confirmation. Cancel at either step must return without deleting anything. +- Paid details show a disabled Delete button. Refund Taler appears only while its portion + is still refundable; external payments use the general Refund screen. +- Refund forms and refund QR collection rotate with History and Order details. QR collection + stacks the QR, amount, order details, and action in portrait, and uses two columns in + landscape. Check phone and tablet portrait and landscape, large text, + the keyboard, and German translations. External refunds require the matching payment + record on this device. Provider-side card refunds remain manual. + +The `order-details` fixture includes a product image. Tap its 48 dp thumbnail to +open the larger preview; close it with Back or the close button. +Products without images reserve no thumbnail space. Check both orientations. diff --git a/merchant-terminal/build.gradle b/merchant-terminal/build.gradle @@ -1,23 +1,53 @@ +import groovy.json.JsonOutput + plugins { id 'com.android.application' id 'kotlin-android' id 'kotlinx-serialization' + id 'org.jlleitschuh.gradle.ktlint' version '14.2.0' id "org.jetbrains.kotlin.plugin.compose" version "$kotlin_version" } +ktlint { + version = '1.8.0' + filter { + exclude '**/build/**' + } +} + +apply from: 'formatting.gradle' + +tasks.withType(org.jlleitschuh.gradle.ktlint.tasks.KtLintFormatTask).configureEach { + dependsOn 'formatKotlinLayout' +} + +repositories { + maven { + url 'https://maven.sumup.com/releases' + content { + includeGroupByRegex 'com\\.sumup(\\..*)?' + includeGroup 'net.sf.smc' + } + } +} + +def sumupAffiliateKey = providers.gradleProperty('sumupAffiliateKey') + .orElse(providers.environmentVariable('SUMUP_AFFILIATE_KEY')).getOrElse('') + android { namespace 'net.taler.merchantpos' - compileSdkVersion 36 + compileSdkVersion 37 defaultConfig { applicationId "net.taler.merchantpos" - minSdkVersion 23 - targetSdkVersion 36 - versionCode 29 - versionName "1.6.6" + minSdkVersion 26 + targetSdkVersion 37 + versionCode 30 + versionName "1.7.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" buildConfigField("String", "BACKEND_API_VERSION", "\"20:0:8\"") + buildConfigField('String', 'SUMUP_AFFILIATE_KEY', JsonOutput.toJson(sumupAffiliateKey)) } buildTypes { @@ -32,6 +62,7 @@ android { } compileOptions { + coreLibraryDesugaringEnabled true sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } @@ -64,6 +95,8 @@ android { } dependencies { + coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.5' + implementation 'com.sumup:merchant-sdk:7.1.0' implementation project(":merchant-lib") implementation project(":taler-kotlin-android") @@ -91,11 +124,6 @@ dependencies { implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.2.0" - // Experimental external NFC URI broadcaster connected through Android USB host mode. - // Its source is kept under src/experimental and is intentionally excluded from builds. - // FIXME: Add an experimental build variant before enabling this dependency. - // implementation "com.github.mik3y:usb-serial-for-android:3.10.0" - testImplementation 'androidx.test.ext:junit:1.3.0' testImplementation 'org.robolectric:robolectric:4.16.1' diff --git a/merchant-terminal/design/google-play/README.md b/merchant-terminal/design/google-play/README.md @@ -0,0 +1,79 @@ +# Google Play screenshot templates + +Local versions of the eight phone and eight tablet designs on the Penpot +**Google Play** page. The source templates contain no app screenshots. +They use Taler blue (`#0042B3`), Raleway Bold, the outlined white Taler POS +logo, and a dark device frame (`#00134A`). + +## Setup + +Run commands from the repository root. Use Python 3 with the pinned Pillow +dependency and `rsvg-convert` (provided by librsvg) on PATH: + +```sh +python3 -m venv merchant-terminal/build/artwork-venv +merchant-terminal/build/artwork-venv/bin/pip install -r merchant-terminal/design/google-play/requirements.txt +``` + +On macOS, install the PNG renderer with `brew install librsvg`. +Alternatively, pass `--svg-only` to generate SVGs without that renderer. +The bundled Raleway font is used without installing it system-wide; its license +is in `assets/fonts/OFL.txt`. + +## Build empty templates + +```sh +merchant-terminal/build/artwork-venv/bin/python merchant-terminal/design/google-play/render.py \ + --blank \ + --out merchant-terminal/build/play-store-templates +``` + +Each card has a self-contained SVG and an opaque RGB PNG. Phone cards are +1080 × 1920; tablet cards are 2560 × 1440. The screen area stays empty until a +screenshot is supplied. No website or manifest is generated. Use `--png-only` +or `--svg-only` when only one image format is needed. + +## Insert fresh screenshots + +The [shared screenshot flow](../../SCREENSHOTS.md) captures and frames the +Play Store subset in one command: + +```sh +merchant-terminal/build/artwork-venv/bin/python merchant-terminal/scripts/capture-screenshots.py \ + --set play-store --serial emulator-5560 +``` + +To reuse existing captures, pass `--from-captures /path/to/captures` instead of +`--serial`. The shared flow outputs PNGs only, using temporary SVG intermediates. +`cards.json` defines the eight headlines, input filenames and themes for both +capture selection and rendering. + +The renderer remains available for editing a single template: + +```sh +merchant-terminal/build/artwork-venv/bin/python merchant-terminal/design/google-play/render.py \ + --screenshot /path/to/new-order-screen.png \ + --profile phone --card 01-products \ + --out merchant-terminal/build/play-store-single +``` + +The phone screen opening is 9:20; the tablet opening is 16:9. The standalone +renderer fits other aspect ratios without stretching, leaving margins inside the +frame. The shared capture flow uses matching sizes for an exact fit. + +## Edit the design + +- `assets/templates/phone.svg` and `assets/templates/tablet.svg`: background, device frame, screen opening, and + headline/logo positions. Keep the element IDs used by the renderer. +- `cards.json`: eight headlines, screenshot filenames, and themes. Newlines + control phone headline wrapping; tablet headlines use one line. +- `assets/templates/taler-pos-white.svg`: outlined logo, independent of installed fonts. +- `assets/fonts/Raleway-Bold.ttf`: exact headline font. + +The build rejects missing images, wrong orientation, overflowing headlines, and +PNGs larger than 8 MB. Review the generated images after changing the design or +screenshots. Outputs go under the ignored `merchant-terminal/build` directory; +keep screenshots and generated artwork out of this source folder. + +The layouts are local editable copies, not a live Penpot sync. If the Penpot +design changes, update the SVG masters or card configuration here as well. diff --git a/merchant-terminal/design/google-play/assets/fonts/OFL.txt b/merchant-terminal/design/google-play/assets/fonts/OFL.txt @@ -0,0 +1,49 @@ +Copyright (c) 2010, Matt McInerney (matt@pixelspread.com), +Copyright (c) 2011, Pablo Impallari (www.impallari.com|impallari@gmail.com), +Copyright (c) 2011, Rodrigo Fuenzalida (www.rfuenzalida.com|hello@rfuenzalida.com), with Reserved Font Name Raleway + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide development of collaborative font projects, to support the font creation efforts of academic and linguistic communities, and to provide a free and open framework in which fonts may be shared and improved in partnership with others. + +The OFL allows the licensed fonts to be used, studied, modified and redistributed freely as long as they are not sold by themselves. The fonts, including any derivative works, can be bundled, embedded, redistributed and/or sold with any software provided that any reserved names are not used by derivative works. The fonts and derivatives, however, cannot be released under any other type of license. The requirement for fonts to remain under this license does not apply to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright Holder(s) under this license and clearly marked as such. This may include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the copyright statement(s). + +"Original Version" refers to the collection of Font Software components as distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, or substituting -- in part or in whole -- any of the components of the Original Version, by changing formats or by porting the Font Software to a new environment. + +"Author" refers to any designer, engineer, programmer, technical writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining a copy of the Font Software, to use, study, copy, merge, embed, modify, redistribute, and sell modified and unmodified copies of the Font Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, redistributed and/or sold with any software, provided that each copy contains the above copyright notice and this license. These can be included either as stand-alone text files, human-readable headers or in the appropriate machine-readable metadata fields within text or binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font Name(s) unless explicit written permission is granted by the corresponding Copyright Holder. This restriction only applies to the primary font name as presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font Software shall not be used to promote, endorse or advertise any Modified Version, except to acknowledge the contribution(s) of the Copyright Holder(s) and the Author(s) or with their explicit written permission. + +5) The Font Software, modified or unmodified, in part or in whole, must be distributed entirely under this license, and must not be distributed under any other license. The requirement for fonts to remain under this license does not apply to any document created using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE. +\ No newline at end of file diff --git a/merchant-terminal/design/google-play/assets/fonts/Raleway-Bold.ttf b/merchant-terminal/design/google-play/assets/fonts/Raleway-Bold.ttf Binary files differ. diff --git a/merchant-terminal/design/google-play/assets/templates/phone.svg b/merchant-terminal/design/google-play/assets/templates/phone.svg @@ -0,0 +1,18 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="1080" height="1920" viewBox="0 0 1080 1920"> + <!-- Editable master. The renderer fills the logo, headline and screen slots. --> + <defs> + <clipPath id="screen-clip"><rect x="215" y="396" width="650" height="1444.444444" rx="40"/></clipPath> + </defs> + <rect width="1080" height="1920" fill="#0042B3"/> + <ellipse cx="1050" cy="1830" rx="750" ry="800" fill="#B4C5FF" opacity="0.13"/> + <ellipse cx="30" cy="1930" rx="850" ry="550" fill="#B4C5FF" opacity="0.07"/> + <g id="logo" transform="translate(415 48) scale(0.147754137116)"/> + <g id="headline" data-x="70" data-y="151" data-width="940" data-height="180" data-font-size="72" data-line-height="1.08"/> + <rect x="195" y="376" width="690" height="1484.444444" rx="58" fill="#00134A"/> + <rect x="196" y="377" width="688" height="1482.444444" rx="57" fill="none" stroke="#B4C5FF" stroke-opacity="0.65" stroke-width="2"/> + <g id="screen" clip-path="url(#screen-clip)"> + <rect id="screen-background" x="215" y="396" width="650" height="1444.444444" fill="#FFFFFF"/> + </g> + <rect x="508" y="384" width="64" height="4" rx="2" fill="#586A88"/> + <rect x="885" y="656" width="5" height="90" rx="2" fill="#586A88"/> +</svg> diff --git a/merchant-terminal/design/google-play/assets/templates/tablet.svg b/merchant-terminal/design/google-play/assets/templates/tablet.svg @@ -0,0 +1,16 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="2560" height="1440" viewBox="0 0 2560 1440"> + <defs> + <clipPath id="screen-clip"><rect x="400" y="394" width="1760" height="990" rx="20"/></clipPath> + </defs> + <rect width="2560" height="1440" fill="#0042B3"/> + <ellipse cx="2400" cy="1500" rx="1100" ry="1100" fill="#B4C5FF" opacity="0.13"/> + <ellipse cx="50" cy="1500" rx="1150" ry="650" fill="#B4C5FF" opacity="0.07"/> + <g id="logo" transform="translate(1130 44) scale(0.177304964539)"/> + <g id="headline" data-x="120" data-y="162" data-width="2320" data-height="144" data-font-size="92" data-line-height="1.08"/> + <rect x="372" y="366" width="1816" height="1046" rx="44" fill="#00134A"/> + <rect x="373" y="367" width="1814" height="1044" rx="43" fill="none" stroke="#B4C5FF" stroke-opacity="0.65" stroke-width="2"/> + <g id="screen" clip-path="url(#screen-clip)"> + <rect id="screen-background" x="400" y="394" width="1760" height="990" fill="#FFFFFF"/> + </g> + <ellipse cx="1280" cy="379.5" rx="3.5" ry="3.5" fill="#586A88"/> +</svg> diff --git a/merchant-terminal/design/google-play/assets/templates/taler-pos-white.svg b/merchant-terminal/design/google-play/assets/templates/taler-pos-white.svg @@ -0,0 +1,2 @@ +<?xml version='1.0' encoding='utf-8'?> +<svg xmlns="http://www.w3.org/2000/svg" width="1692" height="521" viewBox="0 0 1692 521" fill="none"><path d="M503.7958984375,6.490478515625C594.1533203125,6.490478515625,672.62890625,60.91259765625,712.2265625,140.80322265625L678.5087890625,140.80322265625C641.8828125,78.86376953125,577.3291015625,37.732421875,503.7958984375,37.732421875C389.66845703125,37.732421875,297.15087890625,136.7919921875,297.15087890625,258.987060546875C297.15087890625,318.796630859375,319.32470703125,373.05517578125,355.34033203125,412.872802734375C347.56005859375,419.361572265625,339.27587890625,425.19091796875,330.55908203125,430.278564453125C291.71533203125,385.270263671875,267.97119140625,325.103515625,267.97119140625,258.987060546875C267.97119140625,119.537353515625,373.55322265625,6.490478515625,503.7958984375,6.490478515625ZM711.5947265625,378.453857421875C671.7958984375,457.646484375,593.6708984375,511.48388671875,503.7958984375,511.48388671875C497.6943359375,511.48388671875,491.646484375,511.2353515625,485.6611328125,510.74853515625C503.3525390625,501.7109375,519.9365234375,490.600830078125,535.1494140625,477.70947265625C594.970703125,467.958740234375,646.3525390625,430.740478515625,677.7421875,378.453857421875Z" style="fill-rule: evenodd; stroke: none; stroke-width: 0.327943; fill: rgb(255, 255, 255); fill-opacity: 1;" /><path d="M373.67626953125,6.490478515625C379.77783203125,6.490478515625,385.82470703125,6.73876953125,391.81005859375,7.22607421875C374.11962890625,16.263427734375,357.53369140625,27.373291015625,342.32080078125,40.264892578125C243.07470703125,56.44091796875,167.03173828125,148.20458984375,167.03173828125,258.987060546875C167.03173828125,341.56298828125,209.28759765625,413.56201171875,271.90380859375,451.5771484375C262.66650390625,453.1240234375,253.19970703125,453.932861328125,243.55712890625,453.932861328125C236.37158203125,453.932861328125,229.28955078125,453.47265625,222.32275390625,452.605224609375C170.69189453125,406.287353515625,137.85205078125,336.7490234375,137.85205078125,258.987060546875C137.85205078125,119.537353515625,243.43408203125,6.490478515625,373.67626953125,6.490478515625ZM405.03173828125,477.70947265625C464.8525390625,467.958740234375,516.2333984375,430.73876953125,547.6240234375,378.451171875L581.4765625,378.451171875C541.6787109375,457.644775390625,463.552734375,511.48388671875,373.67626953125,511.48388671875C367.57470703125,511.48388671875,361.52783203125,511.2353515625,355.54150390625,510.74853515625C373.23095703125,501.7099609375,389.81884765625,490.601806640625,405.03173828125,477.70947265625ZM548.388671875,140.80322265625C530.1923828125,110.036865234375,505.1064453125,84.4033203125,475.4482421875,66.39697265625C484.685546875,64.85009765625,494.1513671875,64.041015625,503.7958984375,64.041015625C510.9794921875,64.041015625,518.0625,64.50146484375,525.029296875,65.368896484375C548.306640625,86.2509765625,567.7646484375,111.8544921875,582.1142578125,140.80322265625Z" style="fill-rule: evenodd; stroke: none; stroke-width: 0.327943; fill: rgb(255, 255, 255); fill-opacity: 1;" /><path d="M243.55712890625,6.490478515625C249.72509765625,6.490478515625,255.83740234375,6.747314453125,261.88720703125,7.244140625C244.22900390625,16.268798828125,227.67236328125,27.35791015625,212.48486328125,40.222412109375C113.10009765625,56.271484375,36.91259765625,148.099853515625,36.91259765625,258.987060546875C36.91259765625,381.18212890625,129.43017578125,480.24169921875,243.55712890625,480.24169921875C316.58447265625,480.24169921875,380.76220703125,439.6787109375,417.51708984375,378.451171875L451.357421875,378.451171875C411.55908203125,457.644775390625,333.43408203125,511.48388671875,243.55712890625,511.48388671875C113.31494140625,511.48388671875,7.73291015625,398.436767578125,7.73291015625,258.987060546875C7.73291015625,119.537353515625,113.31494140625,6.490478515625,243.55712890625,6.490478515625ZM418.26416015625,140.80322265625C410.66064453125,127.948486328125,401.85595703125,115.98583984375,392.01611328125,105.10595703125C399.79638671875,98.616943359375,408.07861328125,92.784912109375,416.79443359375,87.697265625C430.54736328125,103.63427734375,442.40625,121.473388671875,451.990234375,140.80322265625Z" style="fill-rule: evenodd; stroke: none; stroke-width: 0.327943; fill: rgb(255, 255, 255); fill-opacity: 1;" /><path d="M442.7822265625,199.190185546875L495.8798828125,199.190185546875L495.8798828125,169.99560546875L360.02783203125,169.99560546875L360.02783203125,199.190185546875L413.12548828125,199.190185546875L413.12548828125,349.256591796875L442.7822265625,349.256591796875Z" style="fill: rgb(255, 255, 255); fill-opacity: 1;" /><path d="M538.4873046875,305.9794921875L617.6552734375,305.9794921875L634.6376953125,349.257568359375L665.7314453125,349.257568359375L592.0634765625,168.716552734375L564.798828125,168.716552734375L491.130859375,349.257568359375L521.267578125,349.257568359375ZM606.892578125,278.063720703125L549.251953125,278.063720703125L577.9521484375,206.35986328125Z" style="fill: rgb(255, 255, 255); fill-opacity: 1;" /><path d="M719.083984375,169.9970703125L692.5361328125,169.9970703125L692.5361328125,349.257568359375L811.62890625,349.257568359375L811.62890625,320.83251953125C780.78125,320.83251953125,749.9326171875,320.83251953125,719.083984375,320.83251953125Z" style="fill: rgb(255, 255, 255); fill-opacity: 1;" /><path d="M966.3759765625,169.9970703125L842.482421875,169.9970703125L842.482421875,349.257568359375L967.572265625,349.257568359375L967.572265625,320.83251953125L871.6611328125,320.83251953125L871.6611328125,272.942626953125L955.61328125,272.942626953125L955.61328125,244.518310546875L871.6611328125,244.518310546875L871.6611328125,198.42236328125L966.3759765625,198.42236328125Z" style="fill: rgb(255, 255, 255); fill-opacity: 1;" /><path d="M1109.642578125,228.513916015625C1109.642578125,237.773193359375,1106.533203125,245.1591796875,1100.275390625,250.62109375C1094.056640625,256.127685546875,1085.6455078125,258.85791015625,1075.0810546875,258.85791015625L1031.9091796875,258.85791015625L1031.9091796875,198.42236328125L1074.841796875,198.42236328125C1085.8837890625,198.42236328125,1094.4541015625,200.94091796875,1100.5146484375,206.0185546875C1106.6142578125,211.056396484375,1109.642578125,218.5673828125,1109.642578125,228.513916015625ZM1144.8017578125,349.257568359375L1099.59765625,281.393310546875C1105.4970703125,279.686767578125,1110.87890625,277.25537109375,1115.7431640625,274.09619140625C1120.6044921875,270.9375,1124.7919921875,267.095703125,1128.298828125,262.572509765625C1131.8076171875,258.047119140625,1134.5576171875,252.8408203125,1136.55078125,246.9521484375C1138.54296875,241.0615234375,1139.5390625,234.35986328125,1139.5390625,226.847900390625C1139.5390625,218.140869140625,1138.1044921875,210.24560546875,1135.234375,203.15966796875C1132.3642578125,196.0751953125,1128.2587890625,190.0986328125,1122.91796875,185.23291015625C1117.5751953125,180.36767578125,1111.0390625,176.61181640625,1103.3056640625,173.965087890625C1095.5712890625,171.318115234375,1086.9208984375,169.9970703125,1077.3544921875,169.9970703125L1002.73046875,169.9970703125L1002.73046875,349.257568359375L1031.9091796875,349.257568359375L1031.9091796875,286.77294921875L1068.86328125,286.77294921875L1110.1201171875,349.257568359375Z" style="fill: rgb(255, 255, 255); fill-opacity: 1;" /><path d="M23.25,0.0L23.25,177.5L96.25,177.5Q107.5,177.5 117.0,172.75Q126.5,168.0 133.375,160.125Q140.25,152.25 144.125,142.375Q148.0,132.5 148.0,122.5Q148.0,111.75 144.375,101.75Q140.75,91.75 134.125,84.0Q127.5,76.25 118.25,71.625Q109.0,67.0 97.75,67.0L36.0,67.0L36.0,0.0ZM36.0,78.75L97.25,78.75Q106.0,78.75 113.0,82.375Q120.0,86.0 124.875,92.125Q129.75,98.25 132.375,106.125Q135.0,114.0 135.0,122.5Q135.0,131.25 131.875,139.125Q128.75,147.0 123.375,153.0Q118.0,159.0 110.875,162.5Q103.75,166.0 95.75,166.0L36.0,166.0ZM249.25,-1.25Q230.75,-1.25 215.5,6.5Q200.25,14.25 189.25,26.75Q178.25,39.25 172.25,55.5Q166.25,71.75 166.25,88.75Q166.25,106.5 172.625,122.75Q179.0,139.0 190.125,151.5Q201.25,164.0 216.5,171.375Q231.75,178.75 249.5,178.75Q268.0,178.75 283.375,170.875Q298.75,163.0 309.625,150.25Q320.5,137.5 326.5,121.375Q332.5,105.25 332.5,88.75Q332.5,70.75 326.125,54.5Q319.75,38.25 308.625,25.875Q297.5,13.5 282.25,6.125Q267.0,-1.25 249.25,-1.25ZM179.0,88.75Q179.0,73.5 184.125,59.375Q189.25,45.25 198.625,34.375Q208.0,23.5 221.0,17.0Q234.0,10.5 249.5,10.5Q265.5,10.5 278.375,17.25Q291.25,24.0 300.375,35.125Q309.5,46.25 314.5,60.25Q319.5,74.25 319.5,88.75Q319.5,104.0 314.25,118.125Q309.0,132.25 299.625,143.125Q290.25,154.0 277.5,160.5Q264.75,167.0 249.5,167.0Q233.5,167.0 220.5,160.25Q207.5,153.5 198.25,142.375Q189.0,131.25 184.0,117.25Q179.0,103.25 179.0,88.75ZM470.0,148.0Q452.75,167.25 421.25,167.25Q395.25,167.25 383.375,157.375Q371.5,147.5 371.5,130.75Q371.5,122.25 374.5,116.625Q377.5,111.0 383.875,107.0Q390.25,103.0 400.0,100.25Q409.75,97.5 423.5,94.5Q437.75,91.5 449.375,87.875Q461.0,84.25 469.125,78.75Q477.25,73.25 481.625,65.25Q486.0,57.25 486.0,45.5Q486.0,33.75 481.375,25.0Q476.75,16.25 468.375,10.375Q460.0,4.5 448.375,1.5Q436.75,-1.5 422.5,-1.5Q381.0,-1.5 352.25,25.0L359.0,35.25Q370.25,23.5 386.5,16.625Q402.75,9.75 423.0,9.75Q446.25,9.75 459.375,18.25Q472.5,26.75 472.5,44.0Q472.5,52.75 469.0,58.875Q465.5,65.0 458.625,69.5Q451.75,74.0 441.25,77.25Q430.75,80.5 416.75,83.5Q402.5,86.5 391.625,89.875Q380.75,93.25 373.375,98.375Q366.0,103.5 362.25,110.75Q358.5,118.0 358.5,129.0Q358.5,141.0 363.125,150.25Q367.75,159.5 376.0,165.875Q384.25,172.25 395.75,175.5Q407.25,178.75 421.25,178.75Q438.5,178.75 452.0,173.625Q465.5,168.5 476.5,158.0Z" fill="#ffffff" transform="translate(1191.8675365671643 349.0016174316406) scale(1.0 -1)" /></svg> +\ No newline at end of file diff --git a/merchant-terminal/design/google-play/cards.json b/merchant-terminal/design/google-play/cards.json @@ -0,0 +1,10 @@ +{ + "01-products": {"headline": "Take orders.\nKeep things moving.", "screenshot": "07-order.png", "theme": "light"}, + "02-amount": {"headline": "Enter an amount.\nMake the sale.", "screenshot": "13-amount-entry.png", "theme": "light"}, + "03-mixed-payments": {"headline": "Cash, card\nand Taler.", "screenshot": "17-checkout-partial.png", "theme": "light"}, + "04-taler-payment": {"headline": "Scan. Pay.\nDone.", "screenshot": "21-payment.png", "theme": "light"}, + "05-history": {"headline": "Every order.\nEasy to find.", "screenshot": "25-history.png", "theme": "light"}, + "06-refunds": {"headline": "Refunds,\nmade simple.", "screenshot": "33-refund.png", "theme": "light"}, + "07-displays": {"headline": "Connect your\nTaler display.", "screenshot": "49-displays-connected.png", "theme": "light"}, + "08-dark-mode": {"headline": "Ready for\ndark mode.", "screenshot": "06-navigation.png", "theme": "dark"} +} diff --git a/merchant-terminal/design/google-play/render.py b/merchant-terminal/design/google-play/render.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""Render the Penpot Google Play layouts with supplied screenshots or empty screens.""" + +import argparse +import base64 +import copy +import html +import io +import json +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import xml.etree.ElementTree as ET + +from PIL import Image, ImageFont + +ROOT = Path(__file__).resolve().parent +SVG = "http://www.w3.org/2000/svg" +XLINK = "http://www.w3.org/1999/xlink" +ET.register_namespace("", SVG) +ET.register_namespace("xlink", XLINK) +FONT = ROOT / "assets/fonts/Raleway-Bold.ttf" +TEMPLATES = ROOT / "assets/templates" +PROFILES = {"phone": "phone-portrait", "tablet": "tablet-landscape"} + + +def element(root, element_id): + return next(node for node in root.iter() if node.get("id") == element_id) + + +def make_svg(profile, card, screenshot): + root = ET.parse(TEMPLATES / f"{profile}.svg").getroot() + logo = ET.parse(TEMPLATES / "taler-pos-white.svg").getroot() + element(root, "logo").extend(copy.deepcopy(list(logo))) + + # Embed the exact font for standalone browser previews; librsvg uses the same + # file through the private Fontconfig configuration created below. + style = ET.SubElement(root, f"{{{SVG}}}style") + encoded_font = base64.b64encode(FONT.read_bytes()).decode("ascii") + style.text = ( + "@font-face{font-family:Raleway;font-style:normal;font-weight:700;" + f"src:url(data:font/ttf;base64,{encoded_font}) format('truetype');}}" + ) + heading = element(root, "headline") + x, y, width, height, size = [float(heading.get("data-" + key)) for key in + ("x", "y", "width", "height", "font-size")] + lines = card["headline"].splitlines() + if profile == "tablet": + lines = [" ".join(lines)] + font = ImageFont.truetype(str(FONT), round(size)) + leading = size * float(heading.get("data-line-height")) + if len(lines) * leading > height or any(font.getlength(line) > width for line in lines): + raise ValueError(f"Headline does not fit the {profile} template: {card['headline']!r}") + ascent, descent = font.getmetrics() + baseline = y + (height - len(lines) * leading) / 2 + (leading - ascent - descent) / 2 + ascent + for index, line in enumerate(lines): + text = ET.SubElement(heading, f"{{{SVG}}}text", { + "x": f"{x + width / 2:g}", "y": f"{baseline + index * leading:g}", + "text-anchor": "middle", "font-family": "Raleway", "font-weight": "700", + "font-size": f"{size:g}", "fill": "#FFFFFF", + }) + text.text = line + + screen = element(root, "screen-background") + screen.set("fill", "#1C1B1F" if card["theme"] == "dark" else "#FFFFFF") + if screenshot: + with Image.open(screenshot) as image: + if (image.width > image.height) != (profile == "tablet"): + raise ValueError(f"Wrong screenshot orientation for {profile}: {screenshot}") + # PNG inside the SVG keeps the supplied image lossless. The image is + # fitted inside the screen, never stretched or cropped to its bounds. + stream = io.BytesIO() + image.convert("RGB").save(stream, format="PNG") + attrs = {key: screen.get(key) for key in ("x", "y", "width", "height")} + attrs.update({"preserveAspectRatio": "xMidYMid meet", + f"{{{XLINK}}}href": "data:image/png;base64," + + base64.b64encode(stream.getvalue()).decode("ascii")}) + ET.SubElement(element(root, "screen"), f"{{{SVG}}}image", attrs) + return ET.tostring(root, encoding="utf-8", xml_declaration=True) + + +def main(): + cards = json.loads((ROOT / "cards.json").read_text()) + parser = argparse.ArgumentParser(description=__doc__) + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument("--screenshots", type=Path, help="Capture root containing en/phone-portrait and en/tablet-landscape") + source.add_argument("--screenshot", type=Path, help="One image; requires one --profile and --card") + source.add_argument("--blank", action="store_true", help="Render all designs without any screenshots") + parser.add_argument("--profile", choices=["both", *PROFILES], default="both") + parser.add_argument("--card", choices=cards, action="append", help="Only render these cards (repeatable)") + parser.add_argument("--out", type=Path, default=ROOT.parents[1] / "build/play-store-artwork") + output_format = parser.add_mutually_exclusive_group() + output_format.add_argument("--png-only", action="store_true", help="Write only PNGs, using temporary SVG intermediates") + output_format.add_argument("--svg-only", action="store_true", help="Generate self-contained SVGs without requiring librsvg") + args = parser.parse_args() + profiles = list(PROFILES) if args.profile == "both" else [args.profile] + selected = args.card or list(cards) + if args.screenshot and (len(profiles) != 1 or len(selected) != 1): + parser.error("--screenshot requires exactly one --profile and --card") + converter = shutil.which("rsvg-convert") + if not args.svg_only and not converter: + parser.error("rsvg-convert is required for PNG output (see README), or use --svg-only") + + # Resolve all inputs before writing outputs, to catch incomplete captures early. + jobs = [] + for profile in profiles: + for name in selected: + card = cards[name] + path = args.screenshot + if args.screenshots: + path = args.screenshots / "en" / PROFILES[profile] / card["theme"] / card["screenshot"] + if path and not path.is_file(): + parser.error(f"Screenshot not found: {path}") + jobs.append((profile, name, card, path)) + + with tempfile.TemporaryDirectory(prefix="taler-play-fonts-") as temp: + config = Path(temp) / "fonts.conf" + config.write_text("<?xml version='1.0'?><!DOCTYPE fontconfig SYSTEM 'fonts.dtd'>" + "<fontconfig><dir>" + html.escape(str(FONT.parent)) + + "</dir><cachedir>" + html.escape(temp) + "</cachedir></fontconfig>") + env = dict(os.environ, FONTCONFIG_FILE=str(config)) + for profile, name, card, path in jobs: + output = args.out / profile + output.mkdir(parents=True, exist_ok=True) + svg = (Path(temp) if args.png_only else output) / f"{name}.svg" + svg.write_bytes(make_svg(profile, card, path)) + if not args.svg_only: + png = output / f"{name}.png" + subprocess.run([converter, "--output", str(png), str(svg)], env=env, check=True) + with Image.open(png) as rendered: + expected = (1080, 1920) if profile == "phone" else (2560, 1440) + if rendered.size != expected: + raise ValueError(f"Unexpected output size: {rendered.size}") + rendered.convert("RGB").save(png) # Play Store requires no alpha. + if png.stat().st_size > 8 * 1024 * 1024: + raise ValueError(f"Output exceeds 8 MB: {png}") + print(f"Rendered {profile}/{name}") + + + +if __name__ == "__main__": + main() diff --git a/merchant-terminal/design/google-play/requirements.txt b/merchant-terminal/design/google-play/requirements.txt @@ -0,0 +1 @@ +Pillow==12.2.0 diff --git a/merchant-terminal/fastlane/metadata/android/en-US/changelogs/30.txt b/merchant-terminal/fastlane/metadata/android/en-US/changelogs/30.txt @@ -0,0 +1,11 @@ +What's new in 1.7.0: +- Split payments between cash, card and GNU Taler. +- Save unfinished checkouts and continue them later. +- Enter amounts faster with half, third, quarter and remaining-amount shortcuts. +- Improved phone and tablet layouts, in portrait and landscape. +- Clearer order status, payment breakdowns and refund options. +- Tap product photos to see a larger view. +- Customers can collect receipts in their Taler wallet, even after paying by cash or card. +- Bug fixes + +Requires Android 8.0 or newer. diff --git a/merchant-terminal/fastlane/metadata/android/en-US/full_description.txt b/merchant-terminal/fastlane/metadata/android/en-US/full_description.txt @@ -1,5 +1,14 @@ -The GNU Taler merchant POS (point of sale) terminal allows sellers to +Accept GNU Taler payments with your Android phone or tablet. Taler POS helps you create customer orders, take payments and review your sales. -* process customers’ orders by adding or removing products -* calculate the amount owed by the customer -* let the customer make a Taler payment via QR code or NFC -\ No newline at end of file +* Create orders from your product catalogue or enter an amount directly. +* Browse products by category, adjust quantities and add custom items. +* Let customers pay with their Taler wallet by scanning a QR code or tapping their phone using supported NFC hardware. +* Record cash payments and calculate change. +* Accept card payments through supported payment providers and compatible card readers. +* Split an order between cash, card and Taler payments. +* View order history, inspect payment details and manage refunds. +* Connect compatible Taler customer displays using Bluetooth or USB. + +Designed for phones and tablets, with layouts adapted to your screen. + +A GNU Taler merchant account and backend are required. Card payments require an account with a supported payment provider and a compatible reader. diff --git a/merchant-terminal/fastlane/metadata/android/en-US/images/phoneScreenshots/01-products.png b/merchant-terminal/fastlane/metadata/android/en-US/images/phoneScreenshots/01-products.png Binary files differ. diff --git a/merchant-terminal/fastlane/metadata/android/en-US/images/phoneScreenshots/02-amount.png b/merchant-terminal/fastlane/metadata/android/en-US/images/phoneScreenshots/02-amount.png Binary files differ. diff --git a/merchant-terminal/fastlane/metadata/android/en-US/images/phoneScreenshots/03-mixed-payments.png b/merchant-terminal/fastlane/metadata/android/en-US/images/phoneScreenshots/03-mixed-payments.png Binary files differ. diff --git a/merchant-terminal/fastlane/metadata/android/en-US/images/phoneScreenshots/04-taler-payment.png b/merchant-terminal/fastlane/metadata/android/en-US/images/phoneScreenshots/04-taler-payment.png Binary files differ. diff --git a/merchant-terminal/fastlane/metadata/android/en-US/images/phoneScreenshots/05-history.png b/merchant-terminal/fastlane/metadata/android/en-US/images/phoneScreenshots/05-history.png Binary files differ. diff --git a/merchant-terminal/fastlane/metadata/android/en-US/images/phoneScreenshots/06-refunds.png b/merchant-terminal/fastlane/metadata/android/en-US/images/phoneScreenshots/06-refunds.png Binary files differ. diff --git a/merchant-terminal/fastlane/metadata/android/en-US/images/phoneScreenshots/07-displays.png b/merchant-terminal/fastlane/metadata/android/en-US/images/phoneScreenshots/07-displays.png Binary files differ. diff --git a/merchant-terminal/fastlane/metadata/android/en-US/images/phoneScreenshots/08-dark-mode.png b/merchant-terminal/fastlane/metadata/android/en-US/images/phoneScreenshots/08-dark-mode.png Binary files differ. diff --git a/merchant-terminal/fastlane/metadata/android/en-US/images/phoneScreenshots/1_en-US.png b/merchant-terminal/fastlane/metadata/android/en-US/images/phoneScreenshots/1_en-US.png Binary files differ. diff --git a/merchant-terminal/fastlane/metadata/android/en-US/images/phoneScreenshots/2_en-US.png b/merchant-terminal/fastlane/metadata/android/en-US/images/phoneScreenshots/2_en-US.png Binary files differ. diff --git a/merchant-terminal/fastlane/metadata/android/en-US/images/phoneScreenshots/3_en-US.png b/merchant-terminal/fastlane/metadata/android/en-US/images/phoneScreenshots/3_en-US.png Binary files differ. diff --git a/merchant-terminal/fastlane/metadata/android/en-US/images/tenInchScreenshots/01-products.png b/merchant-terminal/fastlane/metadata/android/en-US/images/tenInchScreenshots/01-products.png Binary files differ. diff --git a/merchant-terminal/fastlane/metadata/android/en-US/images/tenInchScreenshots/02-amount.png b/merchant-terminal/fastlane/metadata/android/en-US/images/tenInchScreenshots/02-amount.png Binary files differ. diff --git a/merchant-terminal/fastlane/metadata/android/en-US/images/tenInchScreenshots/03-mixed-payments.png b/merchant-terminal/fastlane/metadata/android/en-US/images/tenInchScreenshots/03-mixed-payments.png Binary files differ. diff --git a/merchant-terminal/fastlane/metadata/android/en-US/images/tenInchScreenshots/04-taler-payment.png b/merchant-terminal/fastlane/metadata/android/en-US/images/tenInchScreenshots/04-taler-payment.png Binary files differ. diff --git a/merchant-terminal/fastlane/metadata/android/en-US/images/tenInchScreenshots/05-history.png b/merchant-terminal/fastlane/metadata/android/en-US/images/tenInchScreenshots/05-history.png Binary files differ. diff --git a/merchant-terminal/fastlane/metadata/android/en-US/images/tenInchScreenshots/06-refunds.png b/merchant-terminal/fastlane/metadata/android/en-US/images/tenInchScreenshots/06-refunds.png Binary files differ. diff --git a/merchant-terminal/fastlane/metadata/android/en-US/images/tenInchScreenshots/07-displays.png b/merchant-terminal/fastlane/metadata/android/en-US/images/tenInchScreenshots/07-displays.png Binary files differ. diff --git a/merchant-terminal/fastlane/metadata/android/en-US/images/tenInchScreenshots/08-dark-mode.png b/merchant-terminal/fastlane/metadata/android/en-US/images/tenInchScreenshots/08-dark-mode.png Binary files differ. diff --git a/merchant-terminal/fastlane/metadata/android/en-US/short_description.txt b/merchant-terminal/fastlane/metadata/android/en-US/short_description.txt @@ -1 +1 @@ -Process orders and take in Taler payments -\ No newline at end of file +Accept Taler payments, manage orders and handle cash and card sales. diff --git a/merchant-terminal/fastlane/metadata/android/en-US/title.txt b/merchant-terminal/fastlane/metadata/android/en-US/title.txt @@ -1 +1 @@ -Taler Point-of-Sale Terminal -\ No newline at end of file +Taler POS diff --git a/merchant-terminal/formatting.gradle b/merchant-terminal/formatting.gradle @@ -0,0 +1,53 @@ +def androidStudioHome = providers.gradleProperty('androidStudioHome') + .orElse(providers.environmentVariable('ANDROID_STUDIO_HOME')) + +['formatKotlinLayout': false, 'checkKotlinFormat': true].each { taskName, dryRun -> + tasks.register(taskName, Exec) { + group = dryRun ? 'verification' : 'formatting' + description = dryRun ? 'Check POS formatting with Android Studio.' : 'Format POS Kotlin sources with Android Studio.' + def systemDirectory + + doFirst { + def home = androidStudioHome.orNull + def candidates = home ? [file(home)] : [ + file('/Applications/Android Studio.app/Contents'), + file('/opt/android-studio'), + file("${System.getenv('ProgramFiles') ?: 'C:/Program Files'}/Android/Android Studio") + ] + def windows = System.getProperty('os.name').toLowerCase(Locale.ROOT).contains('windows') + def relativeLauncher = windows ? 'bin/format.bat' : 'bin/format.sh' + def launcher = candidates.collect { new File(it, relativeLauncher) }.find { it.isFile() } + if (launcher == null) { + throw new GradleException('Android Studio formatter not found. Set -PandroidStudioHome=<installation directory>.') + } + + def workspace = layout.buildDirectory.dir("formatting/${taskName}").get().asFile + workspace.mkdirs() + def properties = new Properties() + ['config', 'log', 'plugins'].each { directory -> + properties.setProperty("idea.${directory}.path", new File(workspace, directory).absolutePath) + } + // A reused VFS cache can hide changes to .editorconfig from the formatter. + systemDirectory = java.nio.file.Files.createTempDirectory(workspace.toPath(), 'system-').toFile() + properties.setProperty('idea.system.path', systemDirectory.absolutePath) + def propertiesFile = new File(workspace, 'idea.properties') + propertiesFile.withOutputStream { properties.store(it, null) } + environment 'STUDIO_PROPERTIES', propertiesFile.absolutePath + + def arguments = ['-r', '-m', '*.kt', '-allowDefaults'] + if (dryRun) arguments.add('-d') + arguments.add(file('src').absolutePath) + commandLine([launcher.absolutePath] + arguments) + } + + doLast { + delete systemDirectory + } + } +} + +tasks.register('formatKotlin') { + group = 'formatting' + description = 'Format POS Kotlin sources with Android Studio and ktlint.' + dependsOn 'ktlintFormat' +} diff --git a/merchant-terminal/scripts/capture-screenshots.py b/merchant-terminal/scripts/capture-screenshots.py @@ -0,0 +1,268 @@ +#!/usr/bin/env python3 +"""Capture POS screenshot subsets and optionally apply the Play Store templates. + +Requires an installed debug APK on an explicitly selected emulator, adb and Pillow. +Outputs images only. Use --plan to inspect the selected captures without a device. +""" +import argparse +from dataclasses import dataclass +import io +import json +from pathlib import Path +import shutil +import subprocess +import sys +import time + +from PIL import Image, ImageStat + +ROOT = Path(__file__).resolve().parents[1] +DESIGN = ROOT / 'design/google-play' +APP = 'net.taler.merchantpos' +# Keep this order stable: tutorial filenames are referenced by existing documents. +SCENARIOS = [ + 'login', + 'mfa-select', + 'mfa-code', + 'camera-rationale', + 'camera-blocked', + 'navigation', + 'order', + 'quantity-before', + 'quantity-after', + 'order-cleared', + 'order-custom', + 'order-custom-added', + 'amount-entry', + 'checkout', + 'checkout-cash-entry', + 'checkout-card-entry', + 'checkout-partial', + 'checkout-payments', + 'checkout-settled', + 'checkout-noreader', + 'payment', + 'payment-claimed', + 'payment-external', + 'payment-success', + 'history', + 'history-mixed', + 'history-external-refund', + 'order-details', + 'order-details-partial', + 'order-details-minimal', + 'order-details-loading', + 'order-details-error', + 'refund', + 'refund-mixed', + 'refund-qr', + 'settings', + 'settings-logout', + 'payment-options', + 'payment-cash', + 'payment-card', + 'displays-empty', + 'displays-add', + 'displays-access', + 'displays-settings', + 'displays-pair', + 'displays-find', + 'displays-pairing', + 'displays-ready', + 'displays-connected', + 'displays-test', + 'displays-disconnected', + 'displays-remove', + 'displays-usb', + 'displays-repair-android', + 'displays-repair-lcd', + 'order-details-products', + 'settings-about', + 'order-catalogue-more', +] +SCROLLED = { + 'order-details-products': 'order-details', + 'settings-about': 'settings', + 'order-catalogue-more': 'order', +} +# wm size, density, rotation, expected PNG size +PROFILES = { + 'tutorials': { + 'tablet-landscape': ('1600x2560', '320', '1', (2560, 1600)), + 'phone-portrait': ('1080x2340', '420', '0', (1080, 2340)), + }, + 'play-store': { + 'tablet-landscape': ('1080x1920', '240', '1', (1920, 1080)), + 'phone-portrait': ('1080x2400', '420', '0', (1080, 2400)), + }, +} + + +@dataclass(frozen=True) +class Capture: + subset: str + profile: str + locale: str + theme: str + scenario: str + path: Path + card: str | None = None + + +def plan(args, cards): + jobs = [] + subsets = list(PROFILES) if args.subset == 'all' else [args.subset] + for subset in subsets: + for profile in args.profiles or PROFILES[subset]: + if subset == 'tutorials': + choices = [(None, f'{i:02d}-{s}.png', s, theme) + for i, s in enumerate(SCENARIOS, 1) + for theme in args.themes or ['light', 'dark']] + else: + choices = [(name, card['screenshot'], Path(card['screenshot']).stem.split('-', 1)[1], card['theme']) + for name, card in cards.items()] + for locale in args.locales: + for card, filename, scenario, theme in choices: + if args.scenarios and scenario not in args.scenarios: + continue + if args.themes and theme not in args.themes: + continue + base = args.out / subset + if subset == 'play-store': + base = args.from_captures or base / 'captures' + jobs.append(Capture(subset, profile, locale, theme, scenario, + base / locale / profile / theme / filename, card)) + return jobs + + +def check_image(data, expected, label): + with Image.open(io.BytesIO(data)) as original: + image = original.convert('RGB') + if image.size != expected: + raise RuntimeError(f'Wrong screenshot size for {label}: {image.size}, expected {expected}') + if max(ImageStat.Stat(image).stddev) < 3: + raise RuntimeError(f'Blank screenshot: {label}') + + +def capture(jobs, args): + def adb(*cmd, binary=False): + return subprocess.check_output([args.adb, '-s', args.serial, *cmd], timeout=45, text=not binary) + + saved = {key: adb('shell', 'settings', 'get', 'system', key).strip() + for key in ['accelerometer_rotation', 'user_rotation', 'font_scale']} + originals = {key: adb('shell', 'wm', key) for key in ['size', 'density']} + active = None + try: + adb('shell', 'settings', 'put', 'system', 'accelerometer_rotation', '0') + adb('shell', 'settings', 'put', 'system', 'font_scale', '1.0') + for job in jobs: + size, density, rotation, expected = PROFILES[job.subset][job.profile] + if args.resume and job.path.exists(): + check_image(job.path.read_bytes(), expected, job.path) + continue + if active != (job.subset, job.profile): + adb('shell', 'am', 'force-stop', APP) + adb('shell', 'wm', 'size', size) + adb('shell', 'wm', 'density', density) + adb('shell', 'settings', 'put', 'system', 'user_rotation', rotation) + active = (job.subset, job.profile) + scenario = SCROLLED.get(job.scenario, job.scenario) + adb('shell', 'am', 'force-stop', APP) + adb('logcat', '-c') + adb('shell', 'am', 'start', '-W', '-n', APP + '/.debug.ScreenshotActivity', + '--es', 'screenshot_scenario', scenario, '--es', 'screenshot_locale', job.locale, + '--ez', 'screenshot_dark', str(job.theme == 'dark').lower()) + # Activity launch can restore a previous rotation on Android 17. + # Apply the capture orientation once the activity is foregrounded. + adb('shell', 'wm', 'user-rotation', 'lock', rotation) + deadline = time.monotonic() + 25 + while True: + log = adb('logcat', '-d', '-s', 'SCREENSHOT_READY:I', '*:S') + if any(line.rstrip().endswith(': ' + scenario) for line in log.splitlines()): + break + if time.monotonic() > deadline: + raise RuntimeError(f'No ready signal: {job.path}') + time.sleep(.2) + time.sleep(8 if scenario in ('payment', 'payment-external', 'refund-qr') else 1.5) + if job.scenario in SCROLLED: + w, h = expected + start_y = .5 if job.scenario == 'order-catalogue-more' else .65 + adb('shell', 'input', 'swipe', str(w // 2), str(int(h * start_y)), + str(w // 2), str(int(h * .18)), '700') + time.sleep(1) + png = adb('exec-out', 'screencap', '-p', binary=True) + check_image(png, expected, job.path) + job.path.parent.mkdir(parents=True, exist_ok=True) + job.path.write_bytes(png) + print(f'Captured {job.path}', flush=True) + finally: + # Attempt every restore even if a disconnected device makes one fail. + commands = [('shell', 'am', 'force-stop', APP)] + for command, original in originals.items(): + override = next((line.split(':', 1)[1].strip() for line in original.splitlines() + if line.startswith('Override')), 'reset') + commands.append(('shell', 'wm', command, override)) + for key, value in saved.items(): + commands.append(('shell', 'settings', 'delete' if value == 'null' else 'put', 'system', key, + *([] if value == 'null' else [value]))) + errors = [] + for command in commands: + try: + adb(*command) + except (OSError, subprocess.SubprocessError) as error: + errors.append(error) + print(f'Could not restore emulator setting: {command}: {error}', file=sys.stderr) + if errors and sys.exc_info()[0] is None: + raise RuntimeError('Could not restore all emulator settings') from errors[0] + + +def render(jobs, args): + selected = [job for job in jobs if job.subset == 'play-store'] + # Fail before generating a partial set when reusing incomplete or wrong-size captures. + for job in selected: + check_image(job.path.read_bytes(), PROFILES[job.subset][job.profile][3], job.path) + for job in selected: + profile = 'phone' if job.profile == 'phone-portrait' else 'tablet' + subprocess.run([sys.executable, str(DESIGN / 'render.py'), '--screenshot', str(job.path), + '--profile', profile, '--card', job.card, '--png-only', + '--out', str(args.out / 'play-store')], check=True) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--set', dest='subset', choices=[*PROFILES, 'all'], default='tutorials') + parser.add_argument('--serial', help='Dedicated emulator serial (required for new captures)') + parser.add_argument('--adb', default='adb') + parser.add_argument('--out', type=Path, default=ROOT / 'build/screenshots') + parser.add_argument('--profiles', nargs='+', choices=PROFILES['tutorials']) + parser.add_argument('--themes', nargs='+', choices=['light', 'dark']) + parser.add_argument('--locales', nargs='+', choices=['en', 'de', 'fr'], default=['en']) + parser.add_argument('--scenarios', nargs='+', choices=SCENARIOS) + parser.add_argument('--resume', action='store_true', help='Validate and reuse existing captures') + parser.add_argument('--from-captures', type=Path, help='For play-store: render an existing capture root without adb') + parser.add_argument('--plan', action='store_true', help='Print selected captures without writing files or using adb') + args = parser.parse_args() + if args.from_captures and args.subset != 'play-store': + parser.error('--from-captures requires --set play-store') + if args.subset in ('play-store', 'all') and args.locales != ['en']: + parser.error('Play Store headlines currently support English only; use --locales en') + cards = json.loads((DESIGN / 'cards.json').read_text()) + jobs = plan(args, cards) + if not jobs: + parser.error('No screenshots match the selected subset and filters') + if args.plan: + for job in jobs: + print(f'{job.subset}: {job.profile}/{job.locale}/{job.theme}/{job.scenario} → {job.path}') + print(f'{len(jobs)} captures') + return + if any(job.card for job in jobs) and not shutil.which('rsvg-convert'): + parser.error('Play Store templates require rsvg-convert (librsvg) on PATH') + if not args.from_captures: + if not args.serial or not args.serial.startswith('emulator-'): + parser.error('Select a dedicated emulator with --serial emulator-NNNN') + capture(jobs, args) + render(jobs, args) + + +if __name__ == '__main__': + main() diff --git a/merchant-terminal/scripts/capture-screenshots.sh b/merchant-terminal/scripts/capture-screenshots.sh @@ -1,153 +1,4 @@ #!/usr/bin/env bash - set -euo pipefail - SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" -OUT_DIR="${1:-$REPO_ROOT/merchant-terminal/build/screenshots}" -shift || true - -if [[ $# -gt 0 ]]; then - SCENARIOS=("$@") -else - SCENARIOS=("login" "mfa-select" "mfa-code" "amount-entry" "order" "order-custom" "order-custom-added" "payment" "payment-success" "history" "refund" "refund-qr" "navigation") -fi - -LOCALES=("en" "de" "fr") - -APP_ID="net.taler.merchantpos" -ACTIVITY="$APP_ID/.debug.ScreenshotActivity" -EXTRA_KEY="screenshot_scenario" -EXTRA_LOCALE_KEY="screenshot_locale" -LOGCAT_TAG="SCREENSHOT_READY" -WAIT_TIMEOUT=30 -BOOTED_EMULATOR="" - -mkdir -p "$OUT_DIR" - -if ! command -v adb >/dev/null 2>&1; then - echo "adb is required" >&2 - exit 1 -fi - -find_emulator_cmd() { - if command -v emulator >/dev/null 2>&1; then - echo "emulator" - elif [[ -x "${ANDROID_HOME:-}/emulator/emulator" ]]; then - echo "${ANDROID_HOME}/emulator/emulator" - elif [[ -x "${ANDROID_SDK_ROOT:-}/emulator/emulator" ]]; then - echo "${ANDROID_SDK_ROOT}/emulator/emulator" - else - return 1 - fi -} - -shutdown_emulator() { - if [[ -n "$BOOTED_EMULATOR" ]]; then - echo "Shutting down emulator ($BOOTED_EMULATOR)..." - adb -s "$BOOTED_EMULATOR" emu kill 2>/dev/null || true - BOOTED_EMULATOR="" - fi -} - -trap shutdown_emulator EXIT - -CONNECTED_DEVICES=() -while IFS= read -r serial; do - CONNECTED_DEVICES+=("$serial") -done < <(adb devices | awk 'NR > 1 && $2 == "device" { print $1 }') - -if [[ ${#CONNECTED_DEVICES[@]} -eq 0 ]]; then - EMU_CMD="$(find_emulator_cmd)" || { - echo "No device connected and emulator command not found. Set ANDROID_HOME or add emulator to PATH." >&2 - exit 1 - } - - AVDS=() - while IFS= read -r avd; do - [[ -n "$avd" ]] && AVDS+=("$avd") - done < <("$EMU_CMD" -list-avds 2>/dev/null) - - if [[ ${#AVDS[@]} -eq 0 ]]; then - echo "No device connected and no AVDs found." >&2 - exit 1 - fi - - AVD="${AVDS[0]}" - echo "No device connected. Booting emulator: $AVD" - "$EMU_CMD" -avd "$AVD" -no-audio -no-boot-anim &>/dev/null & - - echo "Waiting for emulator to boot..." - adb wait-for-device - local_elapsed=0 - while [[ "$(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" != "1" ]]; do - if [[ $local_elapsed -ge 120 ]]; then - echo "Emulator boot timed out after 120s" >&2 - exit 1 - fi - sleep 2 - local_elapsed=$((local_elapsed + 2)) - done - sleep 1 - echo "Emulator booted." - - while IFS= read -r serial; do - CONNECTED_DEVICES+=("$serial") - done < <(adb devices | awk 'NR > 1 && $2 == "device" { print $1 }') - - BOOTED_EMULATOR="${CONNECTED_DEVICES[0]}" -fi - -if [[ -n "${ANDROID_SERIAL:-}" ]]; then - ADB_SERIAL="$ANDROID_SERIAL" -elif [[ ${#CONNECTED_DEVICES[@]} -gt 1 ]]; then - ADB_SERIAL="${CONNECTED_DEVICES[0]}" - echo "Multiple devices detected; using $ADB_SERIAL. Set ANDROID_SERIAL to choose another device." >&2 -else - ADB_SERIAL="${CONNECTED_DEVICES[0]}" -fi - -if ! printf '%s\n' "${CONNECTED_DEVICES[@]}" | grep -Fxq "$ADB_SERIAL"; then - echo "Selected Android device '$ADB_SERIAL' is not connected and authorized" >&2 - adb devices -l >&2 - exit 1 -fi - -export ANDROID_SERIAL="$ADB_SERIAL" -ADB=(adb -s "$ADB_SERIAL") - -wait_for_ready() { - local scenario="$1" - local elapsed=0 - while [[ $elapsed -lt $WAIT_TIMEOUT ]]; do - if "${ADB[@]}" logcat -d -s "$LOGCAT_TAG:I" 2>/dev/null | grep -qF "$scenario"; then - return 0 - fi - sleep 0.5 - elapsed=$((elapsed + 1)) - done - echo " Warning: timed out waiting for ready signal, capturing anyway" >&2 - return 0 -} - -"$REPO_ROOT/gradlew" :merchant-terminal:installDebug - -for locale in "${LOCALES[@]}"; do - LOCALE_DIR="$OUT_DIR/$locale" - mkdir -p "$LOCALE_DIR" - echo "=== Locale: $locale ===" - - for scenario in "${SCENARIOS[@]}"; do - echo " Capturing $scenario ($locale)" - "${ADB[@]}" shell am force-stop "$APP_ID" >/dev/null 2>&1 || true - "${ADB[@]}" logcat -c 2>/dev/null || true - "${ADB[@]}" shell am start -W -n "$ACTIVITY" \ - --es "$EXTRA_KEY" "$scenario" \ - --es "$EXTRA_LOCALE_KEY" "$locale" >/dev/null - wait_for_ready "$scenario" "$WAIT_TIMEOUT" - sleep 3 - "${ADB[@]}" exec-out screencap -p > "$LOCALE_DIR/$scenario.png" - done -done - -echo "Saved screenshots to $OUT_DIR" +exec "${PYTHON:-python3}" "$SCRIPT_DIR/capture-screenshots.py" "$@" diff --git a/merchant-terminal/scripts/tests/test_capture_screenshots.py b/merchant-terminal/scripts/tests/test_capture_screenshots.py @@ -0,0 +1,132 @@ +"""Capture selection and emulator restoration, without a connected device.""" +import importlib.util +import io +import json +from pathlib import Path +import subprocess +import sys +import tempfile +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +from PIL import Image, ImageDraw + +SCRIPT = Path(__file__).resolve().parents[1] / 'capture-screenshots.py' +spec = importlib.util.spec_from_file_location('capture_screenshots', SCRIPT) +flow = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = flow +spec.loader.exec_module(flow) + + +class CaptureFlowTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.args = SimpleNamespace(subset='tutorials', profiles=None, themes=None, + scenarios=None, locales=['en'], from_captures=None, + out=Path(self.temp.name), serial='emulator-5560', adb='adb', resume=False) + self.cards = json.loads((flow.DESIGN / 'cards.json').read_text()) + + def test_play_store_selects_only_published_cards_and_themes(self): + self.args.subset = 'play-store' + jobs = flow.plan(self.args, self.cards) + self.assertEqual(len(jobs), 16) + self.assertEqual(sum(j.theme == 'dark' for j in jobs), 2) + self.assertEqual({j.scenario for j in jobs if j.theme == 'dark'}, {'navigation'}) + self.assertEqual({j.path.name for j in jobs}, {c['screenshot'] for c in self.cards.values()}) + + def test_tutorial_filters_keep_existing_filenames(self): + self.args.scenarios = ['order'] + self.args.themes = ['light'] + self.args.profiles = ['phone-portrait'] + self.args.locales = ['en', 'de', 'fr'] + jobs = flow.plan(self.args, self.cards) + self.assertEqual(len(jobs), 3) + self.assertTrue(all(j.path.name == '07-order.png' for j in jobs)) + self.assertTrue(all(j.card is None for j in jobs)) + + def test_all_keeps_different_capture_sizes_in_separate_directories(self): + self.args.subset = 'all' + jobs = flow.plan(self.args, self.cards) + self.assertEqual(len(jobs), len(flow.SCENARIOS) * 4 + 16) + self.assertEqual(len({j.path for j in jobs}), len(jobs)) + + def capture_with_fake_adb(self, fail_capture=False, fail_restore=False): + self.args.profiles = ['phone-portrait'] + self.args.scenarios = ['order'] + self.args.themes = ['light'] + job = flow.plan(self.args, self.cards)[0] + image = Image.new('RGB', (1080, 2340), 'white') + ImageDraw.Draw(image).rectangle((0, 0, 540, 2340), fill='blue') + buffer = io.BytesIO() + image.save(buffer, format='PNG') + png = buffer.getvalue() + commands = [] + + def adb(command, **kwargs): + cmd = tuple(command[3:]) + commands.append(cmd) + if cmd[:3] == ('shell', 'settings', 'get'): + return {'accelerometer_rotation': '1', 'user_rotation': '0', 'font_scale': 'null'}[cmd[-1]] + if cmd == ('shell', 'wm', 'size'): + return 'Physical size: 1080x2340\nOverride size: 900x1600\n' + if cmd == ('shell', 'wm', 'density'): + return 'Physical density: 420\n' + if cmd[:2] == ('logcat', '-d'): + return 'I SCREENSHOT_READY: order\n' + if cmd[:2] == ('exec-out', 'screencap'): + if fail_capture: + raise subprocess.CalledProcessError(1, command) + return png + if fail_restore and cmd == ('shell', 'wm', 'size', '900x1600'): + raise subprocess.CalledProcessError(1, command) + return '' + + with patch.object(flow.subprocess, 'check_output', side_effect=adb), patch.object(flow.time, 'sleep'): + if fail_capture: + with self.assertRaises(subprocess.CalledProcessError): + flow.capture([job], self.args) + else: + flow.capture([job], self.args) + self.assertEqual(job.path.read_bytes(), png) + self.assertEqual([p for p in self.args.out.rglob('*') if p.is_file()], [job.path]) + launch = next(i for i, cmd in enumerate(commands) if cmd[:3] == ('shell', 'am', 'start')) + self.assertGreater(commands.index(('shell', 'wm', 'user-rotation', 'lock', '0')), launch) + self.assertIn(('shell', 'wm', 'size', '900x1600'), commands) + self.assertIn(('shell', 'wm', 'density', 'reset'), commands) + self.assertIn(('shell', 'settings', 'put', 'system', 'accelerometer_rotation', '1'), commands) + self.assertIn(('shell', 'settings', 'delete', 'system', 'font_scale'), commands) + + def test_capture_writes_only_original_png_and_restores_settings(self): + self.capture_with_fake_adb() + + def test_capture_failure_still_attempts_every_restore(self): + self.capture_with_fake_adb(fail_capture=True, fail_restore=True) + + def test_resume_rejects_wrong_size_instead_of_silently_reusing(self): + self.args.profiles = ['phone-portrait'] + self.args.scenarios = ['order'] + self.args.themes = ['light'] + self.args.resume = True + job = flow.plan(self.args, self.cards)[0] + job.path.parent.mkdir(parents=True) + Image.new('RGB', (100, 100), 'white').save(job.path) + with patch.object(flow.subprocess, 'check_output', return_value='0'): + with self.assertRaisesRegex(RuntimeError, 'Wrong screenshot size'): + flow.capture([job], self.args) + + def test_ready_timeout_does_not_save_a_screenshot(self): + self.args.profiles = ['phone-portrait'] + self.args.scenarios = ['order'] + self.args.themes = ['light'] + job = flow.plan(self.args, self.cards)[0] + with patch.object(flow.subprocess, 'check_output', return_value='0'), \ + patch.object(flow.time, 'monotonic', side_effect=[0, 30]): + with self.assertRaisesRegex(RuntimeError, 'No ready signal'): + flow.capture([job], self.args) + self.assertFalse(job.path.exists()) + + +if __name__ == '__main__': + unittest.main() diff --git a/merchant-terminal/src/debug/AndroidManifest.xml b/merchant-terminal/src/debug/AndroidManifest.xml @@ -4,7 +4,8 @@ <activity android:name="net.taler.merchantpos.debug.ScreenshotActivity" android:exported="true" - android:screenOrientation="landscape" + android:screenOrientation="fullUser" + android:windowSoftInputMode="adjustResize" android:theme="@style/AppTheme.NoActionBar" /> </application> </manifest> diff --git a/merchant-terminal/src/debug/java/net/taler/merchantpos/compose/PaymentTilePreviews.kt b/merchant-terminal/src/debug/java/net/taler/merchantpos/compose/PaymentTilePreviews.kt @@ -0,0 +1,122 @@ +/* + * This file is part of GNU Taler + * (C) 2026 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.merchantpos.compose + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CreditCard +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +import net.taler.merchantpos.R + +@Preview(name = "Taler / Light", widthDp = 420, heightDp = 1200) +@Composable +private fun TalerTilesLightPreview() = PaymentTileGallery(taler = true, dark = false) + +@Preview(name = "Taler / Dark", widthDp = 420, heightDp = 1200) +@Composable +private fun TalerTilesDarkPreview() = PaymentTileGallery(taler = true, dark = true) + +@Preview(name = "Other methods / Light", widthDp = 420, heightDp = 1200) +@Composable +private fun PaymentMethodTilesLightPreview() = PaymentTileGallery(taler = false, dark = false) + +@Preview(name = "Other methods / Dark", widthDp = 420, heightDp = 1200) +@Composable +private fun PaymentMethodTilesDarkPreview() = PaymentTileGallery(taler = false, dark = true) + +@Composable +private fun PaymentTileGallery( + taler: Boolean, + dark: Boolean, +) +{ + PosTheme(darkTheme = dark) { + Surface { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + PaymentTileStyle.entries.forEach { style -> + Text(style.name, style = MaterialTheme.typography.labelLarge) + if (taler) + { + TalerPaymentTile( + title = stringResource(R.string.payment_brand_name), + detail = stringResource(R.string.checkout_charge_with_taler), + style = style, + onClick = {}, + ) + } + else + { + PaymentMethodTile( + icon = Icons.Default.CreditCard, + title = stringResource(R.string.checkout_card_payment_heading), + detail = stringResource(R.string.checkout_tap_to_enter_amount), + style = style, + onClick = {}, + ) + } + } + if (taler) + { + TalerPaymentTile( + title = stringResource(R.string.checkout_charge_remaining_taler), + detail = stringResource(R.string.checkout_complete_order_hint), + amount = "5.40 CHF", + enabled = false, + onClick = {}, + ) + } + else + { + PaymentMethodTile( + icon = Icons.Default.CreditCard, + title = stringResource(R.string.checkout_card_payment_heading), + detail = stringResource(R.string.checkout_fully_paid), + enabled = false, + onClick = {}, + ) + } + } + } + } +} + +@Preview(name = "Taler / Narrow / Large text", widthDp = 320, fontScale = 1.5f, locale = "de") +@Composable +private fun TalerPaymentTileLargeTextPreview() +{ + PosTheme(darkTheme = true) { + TalerPaymentTile( + title = stringResource(R.string.checkout_charge_remaining_taler), + detail = stringResource(R.string.checkout_complete_order_hint), + amount = "1’234’567.89 CHF", + onClick = {}, + ) + } +} diff --git a/merchant-terminal/src/debug/java/net/taler/merchantpos/debug/ScreenshotActivity.kt b/merchant-terminal/src/debug/java/net/taler/merchantpos/debug/ScreenshotActivity.kt @@ -1,22 +1,42 @@ +/* + * 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.debug +import android.content.pm.ActivityInfo import android.os.Bundle import android.os.Handler import android.os.Looper +import android.util.Base64 import android.util.Log + +import androidx.activity.compose.BackHandler import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatDelegate -import androidx.core.os.LocaleListCompat import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -29,12 +49,17 @@ import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.CreditCard import androidx.compose.material.icons.filled.Menu import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalDrawerSheet +import androidx.compose.material3.NavigationDrawerItem +import androidx.compose.material3.NavigationDrawerItemDefaults import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface @@ -49,30 +74,87 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.core.os.LocaleListCompat import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat + import net.taler.common.Amount import net.taler.common.Challenge +import net.taler.common.ExternalPayment +import net.taler.common.Location +import net.taler.common.Merchant import net.taler.common.TanChannel import net.taler.common.Timestamp +import net.taler.merchantlib.CheckPaymentResponse +import net.taler.merchantlib.ExternalRefundInfo +import net.taler.merchantlib.MerchantUnit +import net.taler.merchantlib.OrderDetailProduct import net.taler.merchantlib.OrderHistoryEntry +import net.taler.merchantlib.OrderPaymentDetails import net.taler.merchantpos.MainViewModel +import net.taler.merchantpos.OrderActionsControl import net.taler.merchantpos.R +import net.taler.merchantpos.amount.AmountEntryScreenContent +import net.taler.merchantpos.checkout.Checkout +import net.taler.merchantpos.checkout.CheckoutScreenContent +import net.taler.merchantpos.checkout.CheckoutStage +import net.taler.merchantpos.checkout.RecordedPayment import net.taler.merchantpos.compose.PosTheme +import net.taler.merchantpos.config.ConfigMode +import net.taler.merchantpos.config.ConfigProduct +import net.taler.merchantpos.config.ConfigScreenContent +import net.taler.merchantpos.config.GeneralSettingsScreenshotContent import net.taler.merchantpos.config.InitialOrderScreen +import net.taler.merchantpos.devices.DeviceStep +import net.taler.merchantpos.devices.TalerBleCandidate +import net.taler.merchantpos.devices.TalerBleState +import net.taler.merchantpos.devices.TalerBleStatus +import net.taler.merchantpos.devices.TalerDevicesContent +import net.taler.merchantpos.devices.TalerDevicesPreview +import net.taler.merchantpos.devices.TalerUsbDeviceState +import net.taler.merchantpos.devices.TalerUsbState +import net.taler.merchantpos.devices.TalerUsbStatus +import net.taler.merchantpos.history.HistoryResult +import net.taler.merchantpos.history.HistoryScreenContent +import net.taler.merchantpos.history.OrderDetailsResult +import net.taler.merchantpos.history.OrderDetailsScreen +import net.taler.merchantpos.order.Order +import net.taler.merchantpos.order.OrderScreenContent import net.taler.merchantpos.payment.Payment +import net.taler.merchantpos.payment.PaymentScreenContent +import net.taler.merchantpos.payment.PaymentSuccessScreenContent +import net.taler.merchantpos.paymentoptions.CashOption +import net.taler.merchantpos.paymentoptions.PaymentDevice +import net.taler.merchantpos.paymentoptions.PaymentOption +import net.taler.merchantpos.paymentoptions.PaymentOptionActions +import net.taler.merchantpos.paymentoptions.PaymentOptionDetailScreenshotContent +import net.taler.merchantpos.paymentoptions.PaymentOptionState +import net.taler.merchantpos.paymentoptions.PaymentOptionStatus +import net.taler.merchantpos.paymentoptions.PaymentOptionsEntry +import net.taler.merchantpos.paymentoptions.PaymentOptionsScreenshotContent +import net.taler.merchantpos.paymentoptions.PaymentOptionsSummary +import net.taler.merchantpos.paymentoptions.TalerOption +import net.taler.merchantpos.refund.RefundQrScreenContent import net.taler.merchantpos.refund.RefundResult +import net.taler.merchantpos.refund.RefundScreenContent -class ScreenshotActivity : AppCompatActivity() { +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive - companion object { +class ScreenshotActivity : AppCompatActivity() +{ + companion object + { private const val TAG = "SCREENSHOT_READY" private const val EXTRA_SCENARIO = "screenshot_scenario" private const val EXTRA_LOCALE = "screenshot_locale" @@ -81,8 +163,32 @@ class ScreenshotActivity : AppCompatActivity() { private val model: MainViewModel by viewModels() private var refundItem by mutableStateOf<OrderHistoryEntry?>(null) + private var refundCheckout by mutableStateOf<Checkout?>(null) + + private val drawerItems = + listOf( + DrawerItem("order", R.drawable.ic_move_money_24dp, R.string.menu_order), + DrawerItem("amount-entry", R.drawable.ic_dialpad, R.string.menu_amount_entry), + DrawerItem("history", R.drawable.ic_history_black_24dp, R.string.menu_history), + DrawerItem("settings", R.drawable.ic_menu_manage, R.string.menu_settings), + ) + + private val fixtureChallenges = + listOf( + Challenge( + challengeId = "CHALL-001", + tanChannel = TanChannel.EMAIL, + tanInfo = "s****x@demo.taler.net", + ), + Challenge( + challengeId = "CHALL-002", + tanChannel = TanChannel.SMS, + tanInfo = "+41 ** *** **42", + ), + ) - override fun onCreate(savedInstanceState: Bundle?) { + override fun onCreate(savedInstanceState: Bundle?) + { super.onCreate(savedInstanceState) WindowCompat.setDecorFitsSystemWindows(window, false) @@ -92,18 +198,21 @@ class ScreenshotActivity : AppCompatActivity() { WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE } - androidx.appcompat.app.AppCompatDelegate.setDefaultNightMode( - androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_NO, + val darkTheme = intent.getBooleanExtra("screenshot_dark", false) + AppCompatDelegate.setDefaultNightMode( + if (darkTheme) AppCompatDelegate.MODE_NIGHT_YES else AppCompatDelegate.MODE_NIGHT_NO, ) val localeTag = intent.getStringExtra(EXTRA_LOCALE) - if (!localeTag.isNullOrEmpty()) { + if (!localeTag.isNullOrEmpty()) + { AppCompatDelegate.setApplicationLocales( LocaleListCompat.forLanguageTags(localeTag), ) } val scenario = intent.getStringExtra(EXTRA_SCENARIO) ?: return + requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_FULL_USER val fixture = ScreenshotController.buildFixture(this) model.configManager.debugApplyFixture( @@ -114,113 +223,369 @@ class ScreenshotActivity : AppCompatActivity() { ) setContent { - PosTheme(darkTheme = false) { - when (scenario) { - "login" -> WithSettingsTopBar { LoginScreen() } - "mfa-select" -> Box(modifier = Modifier.fillMaxSize()) { - WithSettingsTopBar { LoginScreen() } - MfaOverlay { MfaSelectContent() } + PosTheme(darkTheme = darkTheme) { + when (scenario) + { + "camera-rationale", "camera-blocked" -> + { + WithSettingsTopBar { + ConfigScreenContent( + merchantUrl = "backend.demo.taler.net", + username = "sandbox", + token = "", + mode = ConfigMode.Qr, + cameraDialogVisible = true, + cameraNeedsSettings = scenario == "camera-blocked", + ) + } } - "mfa-code" -> Box(modifier = Modifier.fillMaxSize()) { + + "displays-empty", "displays-connected", "displays-disconnected", "displays-add", + "displays-access", "displays-settings", "displays-pair", "displays-find", + "displays-pairing", "displays-ready", "displays-test", "displays-remove", + "displays-usb", "displays-repair-android", "displays-repair-lcd" + -> + { + DisplayScreenshot(scenario) + } + + "login" -> + { WithSettingsTopBar { LoginScreen() } - MfaOverlay { MfaCodeContent() } } - "amount-entry" -> WithTopBar(stringResource(R.string.menu_amount_entry)) { - AmountEntryScreenshot() + + "mfa-select" -> + { + Box(modifier = Modifier.fillMaxSize()) { + WithSettingsTopBar { LoginScreen() } + MfaOverlay { MfaSelectContent() } + } } - "order" -> WithOrderTopBar { - OrderScreenshot(showCustomDialog = false) + + "mfa-code" -> + { + Box(modifier = Modifier.fillMaxSize()) { + WithSettingsTopBar { LoginScreen() } + MfaOverlay { MfaCodeContent() } + } } - "order-custom" -> WithOrderTopBar { - OrderScreenshot(showCustomDialog = true) + + "settings", "settings-logout" -> + { + WithTopBar(stringResource(R.string.menu_settings)) { + GeneralSettingsScreenshotContent( + paymentOptionsEntry = { + PaymentOptionsEntry( + summary = screenshotPaymentOptionsSummary(), + onOpen = {}, + ) + }, + logoutConfirmationStep = if (scenario == "settings-logout") 1 else 0, + ) + } } - "order-custom-added" -> WithOrderTopBar { - OrderScreenshot(showCustomDialog = false) + + "payment-options" -> + { + WithTopBar(stringResource(R.string.payment_options_title)) { + PaymentOptionsScreenshotContent( + options = screenshotPaymentOptions(), + summary = screenshotPaymentOptionsSummary(), + ) + } } - "payment" -> WithTopBar(stringResource(R.string.payment_process_label)) { - PaymentScreenshot() + + "payment-taler" -> + { + DisplayScreenshot("displays-connected") } - "payment-success" -> WithTopBar(stringResource(R.string.payment_received)) { - PaymentSuccessScreenshot() + + "payment-cash", "payment-card" -> + { + val optionId = + when (scenario) + { + "payment-taler" -> "taler" + "payment-cash" -> "cash" + else -> "card" + } + val option = screenshotPaymentOptions().firstOrNull { it.id == optionId } + if (option != null) + { + WithTopBar(stringResource(option.nameRes)) { + PaymentOptionDetailScreenshotContent( + option = option, + state = screenshotPaymentOptionsSummary().options.first { it.id == optionId }, + ) + } + } } - "history" -> WithHistoryTopBar { - HistoryScreenshot() + + "amount-entry" -> + { + WithTopBar(stringResource(R.string.menu_amount_entry)) { + AmountEntryScreenshot() + } } - "refund" -> WithTopBar(stringResource(R.string.history_refund)) { - RefundScreenshot() + + "order", "quantity-before", "quantity-after", "order-cleared" -> + { + WithOrderTopBar { + OrderScreenshot(showCustomDialog = false) + } + } + + "order-custom" -> + { + WithOrderTopBar { + OrderScreenshot(showCustomDialog = true) + } } - "refund-qr" -> WithTopBar(stringResource(R.string.history_refund)) { - RefundQrScreenshot() + + "order-custom-added" -> + { + WithOrderTopBar { + OrderScreenshot(showCustomDialog = false) + } + } + + "payment", "payment-claimed", "payment-external" -> + { + WithTopBar( + stringResource( + if (scenario == "payment-claimed") + { + R.string.checkout_payment_claimed_title + } + else + { + R.string.payment_process_label + }, + ), + ) { + PaymentScreenshot() + } + } + + "payment-success" -> + { + WithTopBar(stringResource(R.string.payment_received)) { + PaymentSuccessScreenshot() + } + } + + "history", "history-mixed", "history-external-refund" -> + { + WithHistoryTopBar { + HistoryScreenshot() + } + } + + "order-details", + "order-details-partial", + "order-details-minimal", + "order-details-error", + "order-details-loading", + "history-order-details", + -> + { + OrderDetailsScreenshot(scenario) + } + + "refund", "refund-mixed" -> + { + WithTopBar(stringResource(R.string.history_refund)) { + RefundScreenshot() + } + } + + "refund-qr" -> + { + WithTopBar(stringResource(R.string.history_refund)) { + RefundQrScreenshot() + } + } + + "checkout", "checkout-partial", "checkout-settled", "checkout-noreader", + "checkout-cash-entry", "checkout-card-entry", "checkout-payments" + -> + { + WithTopBar(stringResource(R.string.checkout_title)) { + CheckoutScreenshot(scenario) + } + } + + "navigation" -> + { + NavigationDrawerScreenshot() } - "navigation" -> NavigationDrawerScreenshot() } } } Handler(Looper.getMainLooper()).post { - when (scenario) { - "order", "order-custom", "order-custom-added", "navigation" -> seedOrder(scenario) - "payment" -> seedPayment() + when (scenario) + { + "order", "order-custom", "order-custom-added", "navigation", + "quantity-before", "quantity-after", "order-cleared" + -> seedOrder(scenario) + + "payment", "payment-claimed", "payment-external" -> seedPayment(scenario) + "history" -> seedHistory() - "refund", "refund-qr" -> seedRefund(scenario) + + "history-mixed", "history-external-refund" -> seedMixedHistory(scenario) + + "refund", "refund-mixed", "refund-qr" -> seedRefund(scenario) } signalReady(scenario) } } - private fun signalReady(scenario: String) { - Handler(Looper.getMainLooper()).postDelayed({ - Log.i(TAG, scenario) - }, 300) + private fun signalReady(scenario: String) + { + Handler(Looper.getMainLooper()).postDelayed( + { + Log.i(TAG, scenario) + }, + 300 + ) } - private fun seedOrder(scenario: String) { + private fun screenshotPaymentOptions() = + listOfNotNull( + model.paymentOptions.option("taler"), + model.paymentOptions.option("cash"), + ScreenshotCardOption, + ) + + private fun screenshotPaymentOptionsSummary() = + PaymentOptionsSummary( + options = + listOf( + PaymentOptionState( + id = "taler", + enabled = true, + status = PaymentOptionStatus.Ready, + ), + PaymentOptionState( + id = "cash", + enabled = true, + status = PaymentOptionStatus.Ready, + ), + PaymentOptionState( + id = "card", + enabled = true, + status = PaymentOptionStatus.Connected, + account = "Demo merchant", + devices = + listOf( + PaymentDevice( + id = "reader-demo", + name = "Card reader", + connected = true, + ), + ), + ), + ), + ) + + private fun seedOrder(scenario: String) + { model.orderManager.debugSeedCurrentOrder( listOf( - "lemonade", "lemonade", "lemonade", "lemonade", "lemonade", - "wrap", "wrap", + "lemonade", + "lemonade", + "lemonade", + "lemonade", + "lemonade", + "wrap", + "wrap", "chips", - "salad", "salad", + "salad", + "salad", ), ) - if (scenario == "order-custom-added") { - val orderId = model.orderManager.currentOrderId.value ?: 0 - val customProduct = net.taler.merchantpos.config.ConfigProduct( - description = getString(R.string.order_custom_product_default), - price = Amount.fromString(CURRENCY, "2.73"), - categories = listOf(Int.MIN_VALUE), + if (scenario in setOf("quantity-before", "quantity-after", "order-cleared")) + { + val live = model.orderManager.getOrder(model.orderManager.currentOrderId.value ?: 0) + live.selectOrderLine( + live.order.value + ?.products + ?.firstOrNull { it.id == "lemonade" } ) + if (scenario == "quantity-after") live.decreaseSelectedOrderLine() + if (scenario == "order-cleared") live.restartOrUndo() + } + if (scenario == "order-custom-added") + { + val orderId = model.orderManager.currentOrderId.value ?: 0 + val customProduct = + ConfigProduct( + description = getString(R.string.order_custom_product_default), + price = Amount.fromString(CURRENCY, "2.73"), + categories = listOf(Int.MIN_VALUE), + ) model.orderManager.addProduct(orderId, customProduct) } } - private fun seedPayment() { + private fun seedPayment(scenario: String) + { + val claimed = scenario == "payment-claimed" + val externalReceipt = scenario == "payment-external" val currentOrderId = model.orderManager.currentOrderId.value ?: 0 - val paymentProduct = net.taler.merchantpos.config.ConfigProduct( - description = "Order total", - price = Amount.fromString(CURRENCY, "123.45"), - categories = listOf(Int.MIN_VALUE), - ) - model.orderManager.addProduct(currentOrderId, paymentProduct) - val currentOrder = model.orderManager.getOrder(currentOrderId).order.value - ?: net.taler.merchantpos.order.Order( - id = currentOrderId, - currency = CURRENCY, - currencySpec = null, - availableCategories = emptyMap(), - ) + if (!externalReceipt) + { + val paymentProduct = + ConfigProduct( + description = if (claimed) "Coffee and sandwich" else "Order total", + price = Amount.fromString(CURRENCY, if (claimed) "5.40" else "123.45"), + categories = listOf(Int.MIN_VALUE), + ) + model.orderManager.addProduct(currentOrderId, paymentProduct) + } + val currentOrder = + model.orderManager + .getOrder(currentOrderId) + .order.value + ?: Order( + id = currentOrderId, + currency = CURRENCY, + currencySpec = null, + availableCategories = emptyMap(), + ) + val paymentOrder = if (externalReceipt) currentOrder.copy(products = emptyList()) else currentOrder model.paymentManager.debugSetPayment( Payment( - order = currentOrder, - summary = currentOrder.summary, + order = paymentOrder, + summary = + if (claimed) + { + "Coffee and sandwich" + } + else if (externalReceipt) + { + "Externally paid order" + } + else + { + currentOrder.summary + }, currency = CURRENCY, orderId = "2026-ORD-1042", talerPayUri = "taler://pay/example.invalid/2026-ORD-1042/ZXCVBNM123456", + claimed = claimed, + checkoutId = if (externalReceipt) "checkout-external-fixture" else null, + selfCollect = externalReceipt, + merchantName = "Sandbox merchant", + payDeadline = Timestamp.fromMillis(System.currentTimeMillis() + 3_600_000), ), ) } - private fun seedHistory() { + private fun seedHistory() + { val now = System.currentTimeMillis() val hour = 3_600_000L model.historyManager.debugSetHistory( @@ -287,20 +652,124 @@ class ScreenshotActivity : AppCompatActivity() { ) } - private fun seedRefund(scenario: String) { - val item = OrderHistoryEntry( - orderId = "2026-ORD-1047", - rowId = 47, - timestamp = Timestamp.now(), - amount = Amount.fromString(CURRENCY, "18.30"), - summary = "2x House Coffee, Veggie Sandwich", - paid = true, - refundable = true, + private fun seedMixedHistory(scenario: String) + { + val now = Timestamp.fromMillis(System.currentTimeMillis()) + val payments = + listOf( + ExternalPayment("cash", "cash1", Amount.fromString(CURRENCY, "10")), + ExternalPayment("card", "card1", Amount.fromString(CURRENCY, "5"), "TX-4281"), + ) + val base = + OrderHistoryEntry( + orderId = "2026-ORD-1050", + rowId = 50, + timestamp = now, + amount = Amount.fromString(CURRENCY, "15"), + summary = "Lunch for two", + paid = true, + refundable = true, + amountExternal = payments, + ) + model.historyManager.debugSetHistory( + if (scenario == "history-external-refund") + { + listOf( + base.copy( + refundsExternal = + listOf( + ExternalRefundInfo( + id = "refund1", + method = "cash", + amount = Amount.fromString(CURRENCY, "5"), + reason = "Returned drink", + timestamp = now, + paymentId = "card1", + ), + ), + ), + ) + } + else + { + listOf( + base, + base.copy(orderId = "2026-ORD-1049", rowId = 49, paid = false, refundable = false), + base.copy( + orderId = "2026-ORD-1048", + rowId = 48, + amount = Amount.zero(CURRENCY), + refundable = false, + ), + ) + }, ) - if (scenario == "refund") { + } + + private fun seedRefund(scenario: String) + { + if (scenario == "refund-mixed") + { + val payments = + listOf( + ExternalPayment("cash", "cash-1", Amount.fromString(CURRENCY, "5.00")), + ExternalPayment( + "Card", + "card-1", + Amount.fromString(CURRENCY, "10.00"), + "CARD-0041209", + ), + ) + val item = + OrderHistoryEntry( + "2026.138-030AM7RBBVARY", + timestamp = Timestamp.now(), + amount = Amount.fromString(CURRENCY, "5.40"), + summary = "Digital article access and some fruits", + paid = true, + refundable = true, + amountExternal = payments, + ) + refundCheckout = + Checkout( + id = "refund-preview", + createdAt = 0, + session = "preview", + posOrderId = 1, + contractTerms = net.taler.common.Order(item.summary, amount = Amount.fromString(CURRENCY, "20.40")), + backendOrderId = item.orderId, + stage = CheckoutStage.Completed, + payments = + payments.map { + RecordedPayment( + it.id, + it.method, + it.amount, + transactionCode = it.transactionCode, + recordedAt = 0, + ) + }, + ) + refundItem = item + return + } + val item = + OrderHistoryEntry( + orderId = "2026-ORD-1047", + rowId = 47, + timestamp = Timestamp.now(), + amount = Amount.fromString(CURRENCY, "18.30"), + summary = "2x House Coffee, Veggie Sandwich", + paid = true, + refundable = true, + ) + if (scenario == "refund") + { model.refundManager.startRefund(item) refundItem = item - } else { + } + else + { model.refundManager.debugSetRefundResult( RefundResult.Success( refundUri = "taler://refund/example.invalid/2026-ORD-1047/REFUND-ABC123", @@ -315,19 +784,45 @@ class ScreenshotActivity : AppCompatActivity() { // ── Top bar scaffolds ─────────────────────────────────────────────── @Composable - private fun WithTopBar(title: String, content: @Composable () -> Unit) { + private fun WithTopBar( + title: String, + onBack: (() -> Unit)? = null, + content: @Composable () -> Unit, + ) + { Scaffold( topBar = { Surface(shadowElevation = 2.dp) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 8.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { - IconButton(onClick = {}) { - Icon(Icons.Default.Menu, contentDescription = null) + IconButton(onClick = { onBack?.invoke() }) { + Icon( + if (onBack != + null) + { + Icons.AutoMirrored.Filled.ArrowBack + } + else + { + Icons.Default.Menu + }, + contentDescription = + if (onBack != + null) + { + stringResource(R.string.payment_back_button) + } + else + { + null + }, + ) } Text( text = title, @@ -339,9 +834,10 @@ class ScreenshotActivity : AppCompatActivity() { }, ) { innerPadding -> Box( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding), + modifier = + Modifier + .fillMaxSize() + .padding(innerPadding), ) { content() } @@ -349,14 +845,17 @@ class ScreenshotActivity : AppCompatActivity() { } @Composable - private fun WithOrderTopBar(content: @Composable () -> Unit) { + private fun WithOrderTopBar(content: @Composable () -> Unit) + { + var actionsExpanded by remember { mutableStateOf(false) } Scaffold( topBar = { Surface(shadowElevation = 2.dp) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 8.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { @@ -368,23 +867,28 @@ class ScreenshotActivity : AppCompatActivity() { modifier = Modifier.weight(1f), style = MaterialTheme.typography.titleLarge, ) - Row( - modifier = Modifier.horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - TopBarButton(stringResource(R.string.order_restart)) - TopBarButton(stringResource(R.string.order_previous)) - TopBarButton(stringResource(R.string.order_next)) - TopBarButton(stringResource(R.string.menu_reload)) - } + OrderActionsControl( + compact = LocalConfiguration.current.screenWidthDp < 600, + expanded = actionsExpanded, + onExpandedChange = { actionsExpanded = it }, + clearEnabled = true, + previousEnabled = false, + nextEnabled = false, + clearLabel = stringResource(R.string.order_restart), + onClear = {}, + onPrevious = {}, + onNext = {}, + onReload = {}, + ) } } }, ) { innerPadding -> Box( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding), + modifier = + Modifier + .fillMaxSize() + .padding(innerPadding), ) { content() } @@ -392,14 +896,16 @@ class ScreenshotActivity : AppCompatActivity() { } @Composable - private fun WithHistoryTopBar(content: @Composable () -> Unit) { + private fun WithHistoryTopBar(content: @Composable () -> Unit) + { Scaffold( topBar = { Surface(shadowElevation = 2.dp) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 8.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { @@ -417,9 +923,10 @@ class ScreenshotActivity : AppCompatActivity() { }, ) { innerPadding -> Box( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding), + modifier = + Modifier + .fillMaxSize() + .padding(innerPadding), ) { content() } @@ -427,18 +934,20 @@ class ScreenshotActivity : AppCompatActivity() { } @Composable - private fun WithSettingsTopBar(content: @Composable () -> Unit) { + private fun WithSettingsTopBar(content: @Composable () -> Unit) + { Scaffold( topBar = { Surface(shadowElevation = 2.dp) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 8.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { - androidx.compose.foundation.layout.Spacer( + Spacer( modifier = Modifier.size(48.dp), ) Text( @@ -451,9 +960,10 @@ class ScreenshotActivity : AppCompatActivity() { }, ) { innerPadding -> Box( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding), + modifier = + Modifier + .fillMaxSize() + .padding(innerPadding), ) { content() } @@ -461,25 +971,115 @@ class ScreenshotActivity : AppCompatActivity() { } @Composable - private fun TopBarButton(text: String, enabled: Boolean = true) { + private fun TopBarButton( + text: String, + enabled: Boolean = true, + ) + { Button( onClick = {}, enabled = enabled, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.secondaryContainer, - contentColor = MaterialTheme.colorScheme.onSecondaryContainer, - ), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + ), modifier = Modifier.heightIn(min = 40.dp), ) { Text(text) } } + @Composable + private fun DisplayScreenshot(scenario: String) + { + val display = TalerBleCandidate("02:00:00:00:12:34", "Taler 001234") + val status = + when (scenario) + { + "displays-access" -> TalerBleStatus.PermissionRequired + "displays-pairing" -> TalerBleStatus.Pairing + "displays-connected", "displays-ready", "displays-test", "displays-remove" -> TalerBleStatus.Ready + else -> TalerBleStatus.Disconnected + } + val step = + when (scenario) + { + "displays-add" -> DeviceStep.Choose + "displays-access" -> DeviceStep.Access + "displays-settings" -> DeviceStep.OpenSettings + "displays-pair" -> DeviceStep.Pair + "displays-find" -> DeviceStep.Find + "displays-pairing", "displays-ready" -> DeviceStep.Connect + "displays-test" -> DeviceStep.Test + "displays-remove" -> DeviceStep.Remove + "displays-usb" -> DeviceStep.Usb + "displays-repair-android" -> DeviceStep.RepairAndroid + "displays-repair-lcd" -> DeviceStep.RepairDisplay + else -> null + } + val selected = + scenario in + setOf( + "displays-connected", + "displays-disconnected", + "displays-pairing", + "displays-ready", + "displays-test", + "displays-remove", + "displays-repair-android", + "displays-repair-lcd" + ) + val preview = + TalerDevicesPreview( + bluetooth = + TalerBleState( + status = status, + selected = display.takeIf { selected }, + candidates = if (scenario == "displays-find") listOf(display) else emptyList(), + ), + usb = + TalerUsbState( + devices = + if (scenario == "displays-usb") + { + listOf(TalerUsbDeviceState("demo-usb", TalerUsbStatus.Ready)) + } + else + { + emptyList() + }, + checkId = 1, + ), + step = step, + ) + val realOption = checkNotNull(model.paymentOptions.option("taler")) + val option = + object : PaymentOption by realOption + { + @Composable + override fun DetailContent( + state: PaymentOptionState, + actions: PaymentOptionActions, + ) + { + TalerDevicesContent(model.talerBluetooth, model.talerUsb, preview) + } + } + WithTopBar(stringResource(R.string.payment_option_taler)) { + PaymentOptionDetailScreenshotContent( + option, + screenshotPaymentOptionsSummary().options.first { it.id == "taler" }, + ) + } + } + // ── Screenshot composables ────────────────────────────────────────── @Composable - private fun LoginScreen() { - net.taler.merchantpos.config.ConfigScreenContent( + private fun LoginScreen() + { + ConfigScreenContent( merchantUrl = "backend.demo.taler.net", username = "sandbox", token = "sandbox", @@ -488,15 +1088,17 @@ class ScreenshotActivity : AppCompatActivity() { } @Composable - private fun MfaSelectContent() { + private fun MfaSelectContent() + { Text( text = stringResource(net.taler.common.R.string.mfa_choose_title), style = MaterialTheme.typography.headlineSmall, ) Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()), + modifier = + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp), ) { fixtureChallenges.forEach { challenge -> @@ -519,17 +1121,19 @@ class ScreenshotActivity : AppCompatActivity() { } @Composable - private fun MfaCodeContent() { + private fun MfaCodeContent() + { Text( text = stringResource(net.taler.common.R.string.mfa_challenge_title), style = MaterialTheme.typography.headlineSmall, ) Text( - text = stringResource( - net.taler.common.R.string.mfa_challenge_message, - fixtureChallenges.first().tanChannel.name, - fixtureChallenges.first().tanInfo, - ), + text = + stringResource( + net.taler.common.R.string.mfa_challenge_message, + fixtureChallenges.first().tanChannel.name, + fixtureChallenges.first().tanInfo, + ), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -548,8 +1152,9 @@ class ScreenshotActivity : AppCompatActivity() { } @Composable - private fun AmountEntryScreenshot() { - net.taler.merchantpos.amount.AmountEntryScreenContent( + private fun AmountEntryScreenshot() + { + AmountEntryScreenContent( amountText = "123.45", selectedCurrency = CURRENCY, currencyOptions = listOf(CURRENCY), @@ -558,8 +1163,9 @@ class ScreenshotActivity : AppCompatActivity() { } @Composable - private fun OrderScreenshot(showCustomDialog: Boolean) { - net.taler.merchantpos.order.OrderScreenContent( + private fun OrderScreenshot(showCustomDialog: Boolean) + { + OrderScreenContent( viewModel = model, showCustomDialog = showCustomDialog, customDescription = if (showCustomDialog) stringResource(R.string.order_custom_product_default) else null, @@ -568,73 +1174,330 @@ class ScreenshotActivity : AppCompatActivity() { } @Composable - private fun PaymentScreenshot() { + private fun PaymentScreenshot() + { val payment by model.paymentManager.payment.observeAsState() payment?.let { - net.taler.merchantpos.payment.PaymentScreenContent(payment = it) + PaymentScreenContent(payment = it) } } @Composable - private fun PaymentSuccessScreenshot() { - net.taler.merchantpos.payment.PaymentSuccessScreenContent() + private fun PaymentSuccessScreenshot() + { + PaymentSuccessScreenContent() } @Composable - private fun HistoryScreenshot() { + private fun HistoryScreenshot() + { val result by model.historyManager.items.observeAsState() - val items = (result as? net.taler.merchantpos.history.HistoryResult.Success)?.items.orEmpty() - net.taler.merchantpos.history.HistoryScreenContent(items = items) + val items = (result as? HistoryResult.Success)?.items.orEmpty() + HistoryScreenContent(items = items) } @Composable - private fun RefundScreenshot() { + private fun OrderDetailsScreenshot(scenario: String) + { + var opened by remember { mutableStateOf(scenario != "history-order-details") } + BackHandler(opened) { opened = false } + val minimal = scenario == "order-details-minimal" + val partial = scenario == "order-details-partial" + val amount = Amount.fromString("CHF", if (minimal) "20.40" else "5.40") + val external = + if (minimal) + { + emptyList() + } + else + { + listOf( + ExternalPayment("Cash", "cash-1", Amount.fromString("CHF", "5.00")), + ExternalPayment( + "Card", + "card-1", + Amount.fromString("CHF", "10.00"), + transactionCode = "CARD-0041209", + ), + ) + } + val item = + OrderHistoryEntry( + orderId = "2026.138-030AM7RBBVARY", + timestamp = Timestamp(1777379700), + amount = amount, + summary = "Digital article access and some fruits", + paid = !partial, + refundable = !partial, + amountExternal = external, + ) + val details = + if (scenario == "order-details-minimal") + { + OrderPaymentDetails( + summary = "Counter sale", + amount = amount, + ) + } + else + { + OrderPaymentDetails( + summary = item.summary, + amount = amount, + timestamp = item.timestamp, + amountExternal = external, + merchant = + Merchant( + name = "Demo café", + address = + Location( + street = "Höheweg", + buildingNumber = "80", + postCode = "2502", + town = "Biel/Bienne", + country = "Switzerland", + ), + jurisdiction = Location(town = "Biel/Bienne", country = "Switzerland"), + ), + products = + listOf( + OrderDetailProduct( + "Apple", + unitQuantity = "2", + unit = "kg", + price = Amount.fromString("CHF", "7.00"), + image = + "data:image/png;base64," + + Base64.encodeToString( + assets.open("screenshot-products/fruit.png").use { + it.readBytes() + }, + Base64.NO_WRAP, + ), + ), + OrderDetailProduct( + "Grape", + unitQuantity = "0.200", + unit = "kg", + price = Amount.fromString("CHF", "2.00"), + ), + OrderDetailProduct( + "Banana", + quantity = 2, + unit = "pieces", + price = Amount.fromString("CHF", "1.40"), + ), + OrderDetailProduct( + "Digital article: Health and fruits", + unit = "pieces", + price = Amount.fromString("CHF", "10.00"), + ), + ), + fulfillmentUrl = "https://my.taler-ops.ch/demo-cafe/content", + publicReorderUrl = "https://my.taler-ops.ch/demo-cafe/reorder/2026.138-030AM7RBBVARY", + deliveryLocation = + Location( + street = "Höheweg", + buildingNumber = "80", + postCode = "2502", + town = "Biel/Bienne", + country = "Switzerland", + ), + deliveryDate = Timestamp(1777460400), + maxPickupTime = Timestamp(1777464000), + extra = + JsonObject( + mapOf("customer_reference" to JsonPrimitive("00041209")), + ), + ) + } + if (!opened) + { + WithHistoryTopBar { + HistoryScreenContent( + items = listOf(item), + onOpenOrder = { opened = true }, + ) + } + } + else + { + WithTopBar(stringResource(R.string.order_details_title), onBack = { opened = false }) { + OrderDetailsScreen( + orderId = item.orderId, + item = item, + units = + listOf( + MerchantUnit("kg", "kg", precision = 3), + MerchantUnit("pieces", "pc", mapOf("de" to "Stk.")), + ).associateBy { it.unit }, + result = + when (scenario) + { + "order-details-error" -> + { + OrderDetailsResult + .Error() + } + + "order-details-loading" -> + { + OrderDetailsResult.Loading + } + + "order-details-partial" -> + { + OrderDetailsResult.Success( + CheckPaymentResponse.Unpaid( + talerPayUri = "taler://pay/example.com/order/session", + paymentDetails = details, + ), + ) + } + + else -> + { + OrderDetailsResult.Success( + CheckPaymentResponse + .Paid(refunded = false, paymentDetails = details), + ) + } + }, + ) + } + } + } + + @Composable + private fun RefundScreenshot() + { val item = refundItem ?: return - net.taler.merchantpos.refund.RefundScreenContent( + RefundScreenContent( item = item, currencySpec = model.configManager.currencySpec, initialReason = stringResource(R.string.refund_reason_wrong_order), + checkout = refundCheckout, ) } @Composable - private fun RefundQrScreenshot() { + private fun RefundQrScreenshot() + { val refundResult by model.refundManager.refundResult.observeAsState() val result = refundResult as? RefundResult.Success ?: return - net.taler.merchantpos.refund.RefundQrScreenContent(result = result) + RefundQrScreenContent(result = result) + } + + @Composable + private fun CheckoutScreenshot(scenario: String) + { + val total = Amount.fromString(CURRENCY, "24.00") + val base = + Checkout( + id = "screenshot", + createdAt = 0, + session = "screenshot", + posOrderId = 0, + contractTerms = net.taler.common.Order(summary = "5 × Drinks, 2 × Snacks", amount = total), + ) + val checkout = + when (scenario) + { + "checkout-partial", "checkout-payments" -> + { + base + .withCash( + Amount.fromString(CURRENCY, "10.00"), + "cash1", + 0 + ).checkout + } + + "checkout-settled" -> + { + base.withCash(total, "cash1", 0).checkout + } + + else -> + { + base + } + } + val options = + listOf( + CashOption, + ScreenshotCardOption, + TalerOption(this), + ) + val states = + options.map { option -> + PaymentOptionState( + id = option.id, + enabled = true, + status = + if (option.tracksConnection) + { + if (scenario == "checkout-noreader") + { + PaymentOptionStatus.Disconnected + } + else + { + PaymentOptionStatus.Connected + } + } + else + { + PaymentOptionStatus.Ready + }, + ) + } + CheckoutScreenContent( + checkout, + options, + states, + initialCashOpen = scenario == "checkout-cash-entry", + initialCardOptionId = ScreenshotCardOption.id.takeIf { scenario == "checkout-card-entry" }, + initialShowPayments = scenario == "checkout-payments", + ) } @Composable - private fun NavigationDrawerScreenshot() { + private fun NavigationDrawerScreenshot() + { Box(modifier = Modifier.fillMaxSize()) { WithOrderTopBar { OrderScreenshot(showCustomDialog = false) } // Scrim Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.32f)), + modifier = + Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.32f)), ) // Drawer sheet - androidx.compose.material3.ModalDrawerSheet { + ModalDrawerSheet { Column( modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(6.dp), ) { - androidx.compose.foundation.Image( - painter = androidx.compose.ui.res.painterResource(R.drawable.ic_talerpos_logo), + Image( + painter = + painterResource(R.drawable.ic_talerpos_logo), contentDescription = null, - modifier = Modifier - .fillMaxWidth() - .height(72.dp) - .padding(horizontal = 8.dp, vertical = 8.dp), + modifier = + Modifier + .fillMaxWidth() + .height(72.dp) + .padding(horizontal = 8.dp, vertical = 8.dp), ) drawerItems.forEach { (destination, iconResId, labelResId) -> - androidx.compose.material3.NavigationDrawerItem( + NavigationDrawerItem( icon = { Icon( - painter = androidx.compose.ui.res.painterResource(iconResId), + painter = + painterResource(iconResId), contentDescription = null, ) }, @@ -648,7 +1511,9 @@ class ScreenshotActivity : AppCompatActivity() { onClick = {}, modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.large, - colors = androidx.compose.material3.NavigationDrawerItemDefaults.colors(), + colors = + NavigationDrawerItemDefaults + .colors(), ) } } @@ -662,27 +1527,23 @@ class ScreenshotActivity : AppCompatActivity() { val labelResId: Int, ) - private val drawerItems = listOf( - DrawerItem("order", R.drawable.ic_move_money_24dp, R.string.menu_order), - DrawerItem("amount-entry", R.drawable.ic_dialpad, R.string.menu_amount_entry), - DrawerItem("history", R.drawable.ic_history_black_24dp, R.string.menu_history), - DrawerItem("settings", R.drawable.ic_menu_manage, R.string.menu_settings), - ) - // ── MFA visual helpers ────────────────────────────────────────────── @Composable - private fun MfaOverlay(content: @Composable () -> Unit) { + private fun MfaOverlay(content: @Composable () -> Unit) + { Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.32f)), + modifier = + Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.32f)), contentAlignment = Alignment.Center, ) { Surface( - modifier = Modifier - .fillMaxWidth() - .padding(24.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(24.dp), shape = RoundedCornerShape(28.dp), color = MaterialTheme.colorScheme.surface, tonalElevation = 6.dp, @@ -699,7 +1560,8 @@ class ScreenshotActivity : AppCompatActivity() { } @Composable - private fun MfaCodeDisplay(code: String) { + private fun MfaCodeDisplay(code: String) + { val interactionSource = remember { MutableInteractionSource() } BasicTextField( value = code, @@ -718,7 +1580,8 @@ class ScreenshotActivity : AppCompatActivity() { verticalAlignment = Alignment.CenterVertically, ) { repeat(8) { index -> - if (index == 4) { + if (index == 4) + { Text( text = "-", modifier = Modifier.padding(horizontal = 3.dp), @@ -735,11 +1598,13 @@ class ScreenshotActivity : AppCompatActivity() { } @Composable - private fun MfaDigitBox(digit: Char?) { + private fun MfaDigitBox(digit: Char?) + { Box( - modifier = Modifier - .padding(horizontal = 1.dp) - .size(width = 30.dp, height = 52.dp), + modifier = + Modifier + .padding(horizontal = 1.dp) + .size(width = 30.dp, height = 52.dp), contentAlignment = Alignment.Center, ) { Surface( @@ -756,17 +1621,17 @@ class ScreenshotActivity : AppCompatActivity() { ) } } +} - private val fixtureChallenges = listOf( - Challenge( - challengeId = "CHALL-001", - tanChannel = TanChannel.EMAIL, - tanInfo = "s****x@demo.taler.net", - ), - Challenge( - challengeId = "CHALL-002", - tanChannel = TanChannel.SMS, - tanInfo = "+41 ** *** **42", - ), - ) +/** Stands in for a card terminal so the checkout can be shown without one. */ +private object ScreenshotCardOption : PaymentOption +{ + override val id = "card" + override val nameRes = R.string.screenshot_card + override val descriptionRes = R.string.screenshot_card_description + override val icon: ImageVector + @Composable + get() = Icons.Default.CreditCard + override val chargesExternally = true + override val tracksConnection = true } diff --git a/merchant-terminal/src/debug/java/net/taler/merchantpos/debug/ScreenshotController.kt b/merchant-terminal/src/debug/java/net/taler/merchantpos/debug/ScreenshotController.kt @@ -2,6 +2,7 @@ package net.taler.merchantpos.debug import android.content.Context import android.util.Base64 + import net.taler.common.Amount import net.taler.common.Tax import net.taler.merchantlib.MerchantConfig @@ -9,234 +10,243 @@ import net.taler.merchantpos.config.Category import net.taler.merchantpos.config.ConfigProduct import net.taler.merchantpos.config.PosConfig -object ScreenshotController { - - const val isActive: Boolean = false +object ScreenshotController +{ + const val IS_ACTIVE: Boolean = false private const val FIXTURE_ASSET_DIR = "screenshot-products" private const val CURRENCY = "CHF" - fun buildFixture(context: Context): FixtureData { - val categories = listOf( - Category(id = 1, name = "Drinks"), - Category(id = 2, name = "Bakery"), - Category(id = 3, name = "Lunch"), - Category(id = 4, name = "Snacks"), - Category(id = 5, name = "Specials"), - ) - val products = listOf( - ConfigProduct( - id = "coffee", - productId = "coffee", - productName = "House Coffee", - description = "Freshly brewed fair-trade blend", - price = Amount.fromString(CURRENCY, "3.80"), - categories = listOf(1), - image = assetDataUri(context, "coffee.png"), - taxes = setOf(vat("0.29")), - totalStock = 12, - totalSold = 4, - ), - ConfigProduct( - id = "tea", - productId = "tea", - productName = "Herbal Tea", - description = "Mint and lemon verbena infusion", - price = Amount.fromString(CURRENCY, "3.40"), - categories = listOf(1), - image = assetDataUri(context, "tea.png"), - taxes = setOf(vat("0.26")), - totalStock = 18, - totalSold = 6, - ), - ConfigProduct( - id = "juice", - productId = "juice", - productName = "Apple Juice", - description = "Pressed Swiss apples, 30 cl", - price = Amount.fromString(CURRENCY, "4.20"), - categories = listOf(1), - image = assetDataUri(context, "juice.png"), - taxes = setOf(vat("0.32")), - totalStock = 9, - totalSold = 1, - ), - ConfigProduct( - id = "espresso", - productId = "espresso", - productName = "Espresso", - description = "Single origin, short pull", - price = Amount.fromString(CURRENCY, "3.20"), - categories = listOf(1), - image = assetDataUri(context, "espresso.png"), - taxes = setOf(vat("0.24")), - totalStock = 20, - totalSold = 8, - ), - ConfigProduct( - id = "lemonade", - productId = "lemonade", - productName = "Lemonade", - description = "Sparkling lemon and ginger", - price = Amount.fromString(CURRENCY, "4.40"), - categories = listOf(1, 5), - image = assetDataUri(context, "lemonade.png"), - taxes = setOf(vat("0.33")), - totalStock = 14, - totalSold = 4, - ), - ConfigProduct( - id = "croissant", - productId = "croissant", - productName = "Butter Croissant", - description = "All-butter pastry baked this morning", - price = Amount.fromString(CURRENCY, "2.90"), - categories = listOf(2), - image = assetDataUri(context, "croissant.png"), - taxes = setOf(vat("0.22")), - totalStock = 16, - totalSold = 7, - ), - ConfigProduct( - id = "muffin", - productId = "muffin", - productName = "Blueberry Muffin", - description = "With lemon crumble topping", - price = Amount.fromString(CURRENCY, "4.60"), - categories = listOf(2, 4), - image = assetDataUri(context, "muffin.png"), - taxes = setOf(vat("0.35")), - totalStock = 10, - totalSold = 3, - ), - ConfigProduct( - id = "bagel", - productId = "bagel", - productName = "Sesame Bagel", - description = "Toasted with cream cheese", - price = Amount.fromString(CURRENCY, "5.20"), - categories = listOf(2), - image = assetDataUri(context, "bagel.png"), - taxes = setOf(vat("0.39")), - totalStock = 9, - totalSold = 2, - ), - ConfigProduct( - id = "sandwich", - productId = "sandwich", - productName = "Veggie Sandwich", - description = "Grilled vegetables, hummus, seeded roll", - price = Amount.fromString(CURRENCY, "8.50"), - categories = listOf(3), - image = assetDataUri(context, "sandwich.png"), - taxes = setOf(vat("0.64")), - totalStock = 8, - totalSold = 2, - ), - ConfigProduct( - id = "wrap", - productId = "wrap", - productName = "Chicken Wrap", - description = "Herbs, salad, yogurt dressing", - price = Amount.fromString(CURRENCY, "8.90"), - categories = listOf(3), - image = assetDataUri(context, "wrap.png"), - taxes = setOf(vat("0.67")), - totalStock = 7, - totalSold = 3, - ), - ConfigProduct( - id = "salad", - productId = "salad", - productName = "Market Salad", - description = "Greens, grains, roasted seeds", - price = Amount.fromString(CURRENCY, "9.80"), - categories = listOf(3, 5), - image = assetDataUri(context, "salad.png"), - taxes = setOf(vat("0.74")), - totalStock = 6, - totalSold = 1, - ), - ConfigProduct( - id = "soup", - productId = "soup", - productName = "Pumpkin Soup", - description = "Seasonal special, served warm", - price = Amount.fromString(CURRENCY, "7.20"), - categories = listOf(3, 5), - image = assetDataUri(context, "soup.png"), - taxes = setOf(vat("0.54")), - totalStock = 5, - totalSold = 5, - ), - ConfigProduct( - id = "quiche", - productId = "quiche", - productName = "Vegetable Quiche", - description = "Daily special with garden vegetables", - price = Amount.fromString(CURRENCY, "7.90"), - categories = listOf(3, 5), - image = assetDataUri(context, "quiche.png"), - taxes = setOf(vat("0.60")), - totalStock = 6, - totalSold = 2, - ), - ConfigProduct( - id = "granola", - productId = "granola", - productName = "Granola Cup", - description = "Yogurt, berries, toasted oats", - price = Amount.fromString(CURRENCY, "5.70"), - categories = listOf(4, 5), - image = assetDataUri(context, "granola.png"), - taxes = setOf(vat("0.43")), - totalStock = 7, - totalSold = 2, - ), - ConfigProduct( - id = "chips", - productId = "chips", - productName = "Lentil Chips", - description = "Sea salt snack bag", - price = Amount.fromString(CURRENCY, "3.60"), - categories = listOf(4), - image = assetDataUri(context, "chips.png"), - taxes = setOf(vat("0.27")), - totalStock = 15, - totalSold = 5, - ), - ConfigProduct( - id = "fruit", - productId = "fruit", - productName = "Fruit Cup", - description = "Seasonal sliced fruit", - price = Amount.fromString(CURRENCY, "4.90"), - categories = listOf(4, 5), - image = assetDataUri(context, "fruit.png"), - taxes = setOf(vat("0.37")), - totalStock = 8, - totalSold = 2, - ), - ) + fun buildFixture(context: Context): FixtureData + { + val categories = + listOf( + Category(id = 1, name = "Drinks"), + Category(id = 2, name = "Bakery"), + Category(id = 3, name = "Lunch"), + Category(id = 4, name = "Snacks"), + Category(id = 5, name = "Specials"), + ) + val products = + listOf( + ConfigProduct( + id = "coffee", + productId = "coffee", + productName = "House Coffee", + description = "Freshly brewed fair-trade blend", + price = Amount.fromString(CURRENCY, "3.80"), + categories = listOf(1), + image = assetDataUri(context, "coffee.png"), + taxes = setOf(vat("0.29")), + totalStock = 12, + totalSold = 4, + ), + ConfigProduct( + id = "tea", + productId = "tea", + productName = "Herbal Tea", + description = "Mint and lemon verbena infusion", + price = Amount.fromString(CURRENCY, "3.40"), + categories = listOf(1), + image = assetDataUri(context, "tea.png"), + taxes = setOf(vat("0.26")), + totalStock = 18, + totalSold = 6, + ), + ConfigProduct( + id = "juice", + productId = "juice", + productName = "Apple Juice", + description = "Pressed Swiss apples, 30 cl", + price = Amount.fromString(CURRENCY, "4.20"), + categories = listOf(1), + image = assetDataUri(context, "juice.png"), + taxes = setOf(vat("0.32")), + totalStock = 9, + totalSold = 1, + ), + ConfigProduct( + id = "espresso", + productId = "espresso", + productName = "Espresso", + description = "Single origin, short pull", + price = Amount.fromString(CURRENCY, "3.20"), + categories = listOf(1), + image = assetDataUri(context, "espresso.png"), + taxes = setOf(vat("0.24")), + totalStock = 20, + totalSold = 8, + ), + ConfigProduct( + id = "lemonade", + productId = "lemonade", + productName = "Lemonade", + description = "Sparkling lemon and ginger", + price = Amount.fromString(CURRENCY, "4.40"), + categories = listOf(1, 5), + image = assetDataUri(context, "lemonade.png"), + taxes = setOf(vat("0.33")), + totalStock = 14, + totalSold = 4, + ), + ConfigProduct( + id = "croissant", + productId = "croissant", + productName = "Butter Croissant", + description = "All-butter pastry baked this morning", + price = Amount.fromString(CURRENCY, "2.90"), + categories = listOf(2), + image = assetDataUri(context, "croissant.png"), + taxes = setOf(vat("0.22")), + totalStock = 16, + totalSold = 7, + ), + ConfigProduct( + id = "muffin", + productId = "muffin", + productName = "Blueberry Muffin", + description = "With lemon crumble topping", + price = Amount.fromString(CURRENCY, "4.60"), + categories = listOf(2, 4), + image = assetDataUri(context, "muffin.png"), + taxes = setOf(vat("0.35")), + totalStock = 10, + totalSold = 3, + ), + ConfigProduct( + id = "bagel", + productId = "bagel", + productName = "Sesame Bagel", + description = "Toasted with cream cheese", + price = Amount.fromString(CURRENCY, "5.20"), + categories = listOf(2), + image = assetDataUri(context, "bagel.png"), + taxes = setOf(vat("0.39")), + totalStock = 9, + totalSold = 2, + ), + ConfigProduct( + id = "sandwich", + productId = "sandwich", + productName = "Veggie Sandwich", + description = "Grilled vegetables, hummus, seeded roll", + price = Amount.fromString(CURRENCY, "8.50"), + categories = listOf(3), + image = assetDataUri(context, "sandwich.png"), + taxes = setOf(vat("0.64")), + totalStock = 8, + totalSold = 2, + ), + ConfigProduct( + id = "wrap", + productId = "wrap", + productName = "Chicken Wrap", + description = "Herbs, salad, yogurt dressing", + price = Amount.fromString(CURRENCY, "8.90"), + categories = listOf(3), + image = assetDataUri(context, "wrap.png"), + taxes = setOf(vat("0.67")), + totalStock = 7, + totalSold = 3, + ), + ConfigProduct( + id = "salad", + productId = "salad", + productName = "Market Salad", + description = "Greens, grains, roasted seeds", + price = Amount.fromString(CURRENCY, "9.80"), + categories = listOf(3, 5), + image = assetDataUri(context, "salad.png"), + taxes = setOf(vat("0.74")), + totalStock = 6, + totalSold = 1, + ), + ConfigProduct( + id = "soup", + productId = "soup", + productName = "Pumpkin Soup", + description = "Seasonal special, served warm", + price = Amount.fromString(CURRENCY, "7.20"), + categories = listOf(3, 5), + image = assetDataUri(context, "soup.png"), + taxes = setOf(vat("0.54")), + totalStock = 5, + totalSold = 5, + ), + ConfigProduct( + id = "quiche", + productId = "quiche", + productName = "Vegetable Quiche", + description = "Daily special with garden vegetables", + price = Amount.fromString(CURRENCY, "7.90"), + categories = listOf(3, 5), + image = assetDataUri(context, "quiche.png"), + taxes = setOf(vat("0.60")), + totalStock = 6, + totalSold = 2, + ), + ConfigProduct( + id = "granola", + productId = "granola", + productName = "Granola Cup", + description = "Yogurt, berries, toasted oats", + price = Amount.fromString(CURRENCY, "5.70"), + categories = listOf(4, 5), + image = assetDataUri(context, "granola.png"), + taxes = setOf(vat("0.43")), + totalStock = 7, + totalSold = 2, + ), + ConfigProduct( + id = "chips", + productId = "chips", + productName = "Lentil Chips", + description = "Sea salt snack bag", + price = Amount.fromString(CURRENCY, "3.60"), + categories = listOf(4), + image = assetDataUri(context, "chips.png"), + taxes = setOf(vat("0.27")), + totalStock = 15, + totalSold = 5, + ), + ConfigProduct( + id = "fruit", + productId = "fruit", + productName = "Fruit Cup", + description = "Seasonal sliced fruit", + price = Amount.fromString(CURRENCY, "4.90"), + categories = listOf(4, 5), + image = assetDataUri(context, "fruit.png"), + taxes = setOf(vat("0.37")), + totalStock = 8, + totalSold = 2, + ), + ) return FixtureData( posConfig = PosConfig(categories = categories, products = products), - merchantConfig = MerchantConfig( - baseUrl = "https://merchant.example.invalid/instances/demo", - apiKey = "", - ), + merchantConfig = + MerchantConfig( + baseUrl = "https://merchant.example.invalid/instances/demo", + apiKey = "", + ), ) } - private fun assetDataUri(context: Context, fileName: String): String { + private fun assetDataUri( + context: Context, + fileName: String, + ): String + { val bytes = context.assets.open("$FIXTURE_ASSET_DIR/$fileName").use { it.readBytes() } val encoded = Base64.encodeToString(bytes, Base64.NO_WRAP) return "data:image/png;base64,$encoded" } - private fun vat(amount: String) = Tax( - name = "VAT", - tax = Amount.fromString(CURRENCY, amount), - ) + private fun vat(amount: String) = + Tax( + name = "VAT", + tax = Amount.fromString(CURRENCY, amount), + ) data class FixtureData( val posConfig: PosConfig, diff --git a/merchant-terminal/src/debug/java/net/taler/merchantpos/payment/PaymentScreenPreviews.kt b/merchant-terminal/src/debug/java/net/taler/merchantpos/payment/PaymentScreenPreviews.kt @@ -0,0 +1,116 @@ +/* + * 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.payment + +import android.content.res.Configuration.UI_MODE_NIGHT_NO +import android.content.res.Configuration.UI_MODE_NIGHT_YES + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +import net.taler.common.Amount +import net.taler.common.Timestamp +import net.taler.merchantpos.R +import net.taler.merchantpos.compose.PosTheme +import net.taler.merchantpos.config.ConfigProduct +import net.taler.merchantpos.order.Order + +@Preview(name = "Phone portrait / Light", device = "spec:width=412dp,height=892dp,dpi=160", uiMode = UI_MODE_NIGHT_NO) +@Preview(name = "Phone portrait / Dark", device = "spec:width=412dp,height=892dp,dpi=160", uiMode = UI_MODE_NIGHT_YES) +@Preview(name = "Phone landscape / Light", device = "spec:width=892dp,height=412dp,dpi=160", uiMode = UI_MODE_NIGHT_NO) +@Preview(name = "Phone landscape / Dark", device = "spec:width=892dp,height=412dp,dpi=160", uiMode = UI_MODE_NIGHT_YES) +@Preview(name = "Tablet portrait / Light", device = "spec:width=800dp,height=1280dp,dpi=160", uiMode = UI_MODE_NIGHT_NO) +@Preview(name = "Tablet portrait / Dark", device = "spec:width=800dp,height=1280dp,dpi=160", uiMode = UI_MODE_NIGHT_YES) +@Preview( + name = "Tablet landscape / Light", + device = "spec:width=1280dp,height=800dp,dpi=160", + uiMode = UI_MODE_NIGHT_NO, +) +@Preview( + name = "Tablet landscape / Dark", + device = "spec:width=1280dp,height=800dp,dpi=160", + uiMode = UI_MODE_NIGHT_YES, +) +private annotation class PaymentLayouts + +@PaymentLayouts +@Composable +private fun ActivePaymentPreview() = PaymentPreview(claimed = false) + +@PaymentLayouts +@Composable +private fun ClaimedPaymentPreview() = PaymentPreview(claimed = true) + +@Preview(name = "Phone / Large German text", widthDp = 892, heightDp = 412, fontScale = 1.5f, locale = "de") +@Composable +private fun ClaimedPaymentLargeTextPreview() = PaymentPreview(claimed = true) + +@Composable +private fun PaymentPreview(claimed: Boolean) +{ + val order = + Order( + id = 1, + currency = "CHF", + currencySpec = null, + availableCategories = emptyMap(), + products = + listOf( + ConfigProduct( + description = "2 drinks and a snack", + price = Amount.fromString("CHF", "5.40"), + categories = listOf(Int.MIN_VALUE), + quantity = 1, + ), + ), + ) + PosTheme { + Surface { + Column(Modifier.fillMaxSize()) { + Text( + stringResource( + if (claimed) R.string.checkout_payment_claimed_title else R.string.payment_process_label, + ), + modifier = Modifier.padding(16.dp), + ) + Box(Modifier.weight(1f)) { + PaymentScreenContent( + Payment( + order = order, + summary = order.summary, + currency = "CHF", + orderId = "2026-ORD-1042", + talerPayUri = "taler://pay/example.invalid/2026-ORD-1042/ZXCVBNM123456", + claimed = claimed, + merchantName = "Sandbox merchant", + payDeadline = Timestamp.fromMillis(1_792_000_000_000), + ), + ) + } + } + } + } +} diff --git a/merchant-terminal/src/debug/res/values/screenshot_strings.xml b/merchant-terminal/src/debug/res/values/screenshot_strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string translatable="false" name="screenshot_card">Card</string> + <string translatable="false" name="screenshot_card_description">Customers pay by card on a reader paired with this device.</string> +</resources> diff --git a/merchant-terminal/src/experimental/java/net/taler/merchantpos/payment/UsbSerialNfcDevice.kt b/merchant-terminal/src/experimental/java/net/taler/merchantpos/payment/UsbSerialNfcDevice.kt @@ -1,184 +0,0 @@ -/* - * 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. If not, see <http://www.gnu.org/licenses/>. - */ - -package net.taler.merchantpos.payment - -import android.app.PendingIntent -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.hardware.usb.UsbDevice -import android.hardware.usb.UsbManager -import android.util.Log -import androidx.core.content.ContextCompat -import com.hoho.android.usbserial.driver.UsbSerialPort -import com.hoho.android.usbserial.driver.UsbSerialProber -import java.util.concurrent.ExecutorService -import java.util.concurrent.Executors - -/** - * Experimental USB-host integration; this source set is intentionally not built. - * - * Sends the active payment or refund URI to the optional USB-connected NFC broadcaster. - * - * The broadcaster accepts a URL terminated by a line feed and clears its NFC - * payload when it receives an empty line. - */ -class UsbSerialNfcDevice(private val context: Context) : AutoCloseable { - - private val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager - private val executor: ExecutorService = Executors.newSingleThreadExecutor() - private val lock = Any() - private val permissionAction = "${context.packageName}.USB_SERIAL_NFC_PERMISSION" - - private var started = false - private var desiredPayload = RESET_PAYLOAD - private var lastSentPayload: String? = null - - private val receiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - when (intent.action) { - permissionAction -> { - if (intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)) { - enqueueSend(force = true) - } else { - Log.w(TAG, "USB serial permission denied") - } - } - - UsbManager.ACTION_USB_DEVICE_ATTACHED -> enqueueSend(force = true) - } - } - } - - fun start() { - synchronized(lock) { - if (started) return - started = true - } - ContextCompat.registerReceiver( - context, - receiver, - IntentFilter().apply { - addAction(permissionAction) - addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED) - }, - ContextCompat.RECEIVER_NOT_EXPORTED, - ) - enqueueSend(force = true) - } - - fun setUri(uri: String?) { - val payload = uri?.takeIf(String::isNotBlank)?.plus("\n") ?: RESET_PAYLOAD - val changed = synchronized(lock) { - if (desiredPayload == payload) false else { - desiredPayload = payload - true - } - } - if (changed) enqueueSend() - } - - private fun enqueueSend(force: Boolean = false) { - val shouldSend = synchronized(lock) { - started && (force || desiredPayload != lastSentPayload) - } - if (!shouldSend) return - - executor.execute { - val payload = synchronized(lock) { - if (!started) return@execute - desiredPayload - } - send(if (payload == RESET_PAYLOAD) listOf(payload) else listOf(RESET_PAYLOAD, payload)) - } - } - - private fun send(payloads: List<String>) { - val driver = UsbSerialProber.getDefaultProber() - .findAllDrivers(usbManager) - .firstOrNull() - ?: return - val device = driver.device - val connection = usbManager.openDevice(device) - if (connection == null) { - requestPermission(device) - return - } - - val port = driver.ports.firstOrNull() ?: run { - connection.close() - return - } - try { - port.open(connection) - port.setParameters( - BAUD_RATE, - DATA_BITS, - UsbSerialPort.STOPBITS_1, - UsbSerialPort.PARITY_NONE, - ) - payloads.forEach { payload -> - port.write(payload.toByteArray(Charsets.UTF_8), WRITE_TIMEOUT_MILLIS) - } - synchronized(lock) { - if (desiredPayload == payloads.last()) lastSentPayload = payloads.last() - } - } catch (e: Exception) { - Log.w(TAG, "Failed to write to USB NFC device", e) - } finally { - try { - port.close() - } catch (e: Exception) { - Log.w(TAG, "Failed to close USB serial port", e) - } - connection.close() - } - } - - private fun requestPermission(device: UsbDevice) { - val permissionIntent = PendingIntent.getBroadcast( - context, - 0, - Intent(permissionAction).setPackage(context.packageName), - PendingIntent.FLAG_IMMUTABLE, - ) - usbManager.requestPermission(device, permissionIntent) - } - - override fun close() { - synchronized(lock) { - if (!started) return - desiredPayload = RESET_PAYLOAD - lastSentPayload = null - } - // Keep ordering with a preceding URI write, but do not skip the reset - // after marking this instance inactive. - executor.execute { send(listOf(RESET_PAYLOAD)) } - context.unregisterReceiver(receiver) - synchronized(lock) { started = false } - executor.shutdown() - } - - private companion object { - const val TAG = "UsbSerialNfcDevice" - const val BAUD_RATE = 115200 - const val DATA_BITS = 8 - const val WRITE_TIMEOUT_MILLIS = 1000 - const val RESET_PAYLOAD = "\n" - } -} diff --git a/merchant-terminal/src/main/AndroidManifest.xml b/merchant-terminal/src/main/AndroidManifest.xml @@ -19,6 +19,16 @@ <uses-permission android:name="android.permission.NFC" /> <uses-permission android:name="android.permission.CAMERA" /> + <uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" /> + <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> + <uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" /> + <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" /> + <uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" /> + <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> + <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> + + <uses-feature android:name="android.hardware.usb.host" android:required="false" /> + <uses-feature android:name="android.hardware.bluetooth_le" android:required="false" /> <uses-feature android:name="android.hardware.nfc.hce" @@ -30,6 +40,7 @@ android:name="android.hardware.camera" android:required="false" /> <application + android:name=".PosApplication" android:allowBackup="true" android:fullBackupContent="@xml/backup_descriptor" android:icon="@mipmap/ic_taler_pos_logo" @@ -48,7 +59,8 @@ android:name=".MainActivity" android:exported="true" android:label="@string/app_name" - android:screenOrientation="landscape" + android:screenOrientation="fullUser" + android:windowSoftInputMode="adjustResize" android:theme="@style/AppTheme.NoActionBar" tools:ignore="LockedOrientationActivity"> <intent-filter> diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/MainActivity.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/MainActivity.kt @@ -20,42 +20,53 @@ import android.content.Intent import android.content.Intent.ACTION_MAIN import android.content.Intent.CATEGORY_HOME import android.content.Intent.FLAG_ACTIVITY_NEW_TASK +import android.content.pm.ActivityInfo import android.net.Uri import android.os.Bundle import android.os.Handler import android.os.Looper -import android.util.Log import android.view.View import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.widget.FrameLayout import android.widget.Toast import android.widget.Toast.LENGTH_SHORT + import androidx.activity.compose.BackHandler import androidx.activity.compose.LocalActivity import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.Image +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.Image +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material.icons.filled.Menu +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.DrawerValue +import androidx.compose.material3.DropdownMenu import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -63,68 +74,150 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalDrawerSheet import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.NavigationDrawerItem +import androidx.compose.material3.NavigationDrawerItemDefaults import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentContainerView import androidx.fragment.app.commitNow +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.NavHostController +import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController -import kotlinx.coroutines.launch +import androidx.navigation.navArgument + import net.taler.lib.android.TalerNfcService +import net.taler.merchantpos.amount.AmountEntryFragment +import net.taler.merchantpos.checkout.CheckoutFragment import net.taler.merchantpos.compose.PosTheme import net.taler.merchantpos.config.Config import net.taler.merchantpos.config.ConfigFetcherFragment import net.taler.merchantpos.config.ConfigFragment import net.taler.merchantpos.config.GeneralSettingsFragment +import net.taler.merchantpos.config.InitialOrderScreen +import net.taler.merchantpos.config.LocalNetworkPermissionDialog +import net.taler.merchantpos.config.merchantSetupConfig +import net.taler.merchantpos.devices.TalerDevicesFragment import net.taler.merchantpos.history.HistoryFragment +import net.taler.merchantpos.history.OrderDetailsRoute import net.taler.merchantpos.order.OrderFragment -import net.taler.merchantpos.amount.AmountEntryFragment +import net.taler.merchantpos.order.RestartState.DISABLED +import net.taler.merchantpos.order.RestartState.UNDO import net.taler.merchantpos.payment.PaymentSuccessFragment import net.taler.merchantpos.payment.ProcessPaymentFragment +import net.taler.merchantpos.paymentoptions.PaymentOptionDetailFragment +import net.taler.merchantpos.paymentoptions.PaymentOptionsFragment +import net.taler.merchantpos.paymentoptions.PaymentOptionsSummary +import net.taler.merchantpos.paymentoptions.TALER_OPTION_ID +import net.taler.merchantpos.paymentoptions.TerminalStatusIndicator import net.taler.merchantpos.refund.RefundFragment import net.taler.merchantpos.refund.RefundUriFragment -import net.taler.merchantpos.order.RestartState -import net.taler.merchantpos.order.RestartState.DISABLED -import net.taler.merchantpos.order.RestartState.UNDO -class MainActivity : AppCompatActivity() { +import java.util.concurrent.TimeUnit - private val model: MainViewModel by viewModels() +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +class MainActivity : AppCompatActivity() +{ + private val model: MainViewModel by viewModels() private var navController: NavHostController? = null private var reallyExit = false - companion object { + companion object + { const val TAG = "taler-pos" } - override fun onCreate(savedInstanceState: Bundle?) { + override fun onCreate(savedInstanceState: Bundle?) + { super.onCreate(savedInstanceState) + model.paymentOptions.options.forEach { option -> + option.registerLaunchers(this, model.paymentOptions) + } + model.paymentOptions.summary.observe(this) { summary -> + val talerEnabled = + summary.options.any { + it.id == TALER_OPTION_ID && it.enabled + } + model.talerUsb.setTalerEnabled(talerEnabled) + model.talerBluetooth.setTalerEnabled(talerEnabled) + } + model.talerBluetooth.state.observe(this) { + lifecycleScope.launch { + withContext(Dispatchers.IO) { model.paymentOptions.refresh() } + } + } + model.talerUsb.state.observe(this) { + lifecycleScope.launch { + withContext(Dispatchers.IO) { model.paymentOptions.refresh() } + } + } + + // Reads payment option status off the main thread: probing a card terminal + // reaches its SDK and Bluetooth, which must not block drawing the till. + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.RESUMED) { + while (true) + { + withContext(Dispatchers.IO) { model.paymentOptions.refresh() } + delay(TimeUnit.SECONDS.toMillis(10)) + } + } + } + + // Renews refreshable sessions well inside the one hour a SumUp access token + // lasts, so a till is never left waiting for someone who can sign in. + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.RESUMED) { + while (true) + { + model.paymentOptions.maintainSessions() + delay(TimeUnit.MINUTES.toMillis(10)) + } + } + } + TalerNfcService.startService(this) model.paymentManager.payment.observe(this) { payment -> - val paymentNfcUri = payment?.talerPayUri?.takeIf { - !payment.claimed && !payment.paid && payment.error == null - } + val paymentNfcUri = + payment + ?.takeIf { + !it.claimed && !it.paid && it.error == null + }?.talerPayUri paymentNfcUri?.let { TalerNfcService.clearNdefPayload(this) TalerNfcService.setUri(this, it) @@ -133,7 +226,8 @@ class MainActivity : AppCompatActivity() { } } - model.configManager.sessionExpired.observe(this) { + model.configManager.sessionExpired.observe(this) { event -> + if (event.getIfNotConsumed() == null) return@observe showPosError(R.string.session_expired_toast) navigateTo(PosDestination.Config, clearBackStack = true) } @@ -146,50 +240,78 @@ class MainActivity : AppCompatActivity() { onNavControllerReady = { navController = it }, onExitRequested = ::handleExitRequest, ) + LocalNetworkPermissionDialog(model.localNetworkAccess) } } handleSetupIntent(intent) } - override fun onStart() { + override fun onStart() + { super.onStart() - if (!model.configManager.config.isValid()) { + model.talerBluetooth.setForeground(true) + if (!model.configManager.config.isValid()) + { navigateTo(PosDestination.Config, clearBackStack = true) - } else if (model.configManager.merchantConfig == null || model.configManager.currency == null) { - navigateTo(PosDestination.ConfigFetcher) - } else { + } + else if (model.configManager.merchantConfig == null || + model.configManager.currency == null) + { + if (navController?.currentDestination?.route != PosDestination.Config.route) + { + navigateTo(PosDestination.ConfigFetcher) + } + } + else + { model.configManager.refreshConfigInBackground() } } - override fun onResume() { + override fun onResume() + { super.onResume() TalerNfcService.setDefaultHandler(this) } - override fun onPause() { + override fun onPause() + { super.onPause() TalerNfcService.unsetDefaultHandler(this) } - override fun onDestroy() { + override fun onStop() + { + model.talerUsb.setUri(null) + model.talerBluetooth.setForeground(false) + super.onStop() + } + + override fun onDestroy() + { super.onDestroy() // the payload outlives the service, so stopping it is no longer enough TalerNfcService.clearNdefPayload(this) TalerNfcService.stopService(this) } - override fun onNewIntent(intent: Intent) { + override fun onNewIntent(intent: Intent) + { super.onNewIntent(intent) handleSetupIntent(intent) } - fun navigateTo(destination: PosDestination, clearBackStack: Boolean = false) { + fun navigateTo( + destination: PosDestination, + clearBackStack: Boolean = false, + ) + { val controller = navController ?: return controller.navigate(destination.route) { launchSingleTop = true - if (clearBackStack) { + if (clearBackStack) + { popUpTo(controller.graph.id) { inclusive = true } @@ -197,90 +319,127 @@ class MainActivity : AppCompatActivity() { } } - fun navigateBack() { + fun returnToCheckout() + { + val controller = navController ?: return + if (!controller.popBackStack(PosDestination.Checkout.route, false)) + { + controller.navigate(PosDestination.Checkout.route) { + popUpTo(PosDestination.ProcessPayment.route) { inclusive = true } + launchSingleTop = true + } + } + } + + fun navigateBack() + { val controller = navController ?: return - if (!controller.popBackStack()) { + if (!controller.popBackStack()) + { finish() } } - fun navigateToInitialOrderScreen() { + fun navigateToInitialOrderScreen() + { navigateTo(model.configManager.initialDestination(), clearBackStack = true) } - fun handleSetupIntent(intent: Intent) { + /** + * Returns to the screen the sale started from once it is paid, however many steps + * the payment took on the way (checkout, Taler, success). + */ + fun returnToOrderEntry() + { + val controller = navController ?: return + val entryRoutes = setOf(PosDestination.Order.route, PosDestination.AmountEntry.route) + while (controller.currentDestination?.route !in entryRoutes) + { + if (!controller.popBackStack()) + { + navigateToInitialOrderScreen() + return + } + } + } + + fun handleSetupIntent(intent: Intent) + { if (intent.action != Intent.ACTION_VIEW) return val data = intent.data ?: return if (data.scheme != "taler-pos") return - val host = data.host ?: return - val pathSegments = data.pathSegments - val pathStyleInstance = pathSegments - .takeIf { it.size >= 2 && it[0].equals("instances", ignoreCase = true) } - ?.get(1) - ?.takeIf(String::isNotBlank) - val rawFragment = data.fragment?.removePrefix("/")?.trim().orEmpty() - val params = rawFragment - .takeIf { '=' in it } - ?.split('&') - ?.associate { part -> - part.split('=', limit = 2).let { it[0] to Uri.decode(it.getOrElse(1) { "" }) } - } - - val instance = pathStyleInstance ?: params?.get("username") ?: return - val token = if (pathStyleInstance != null && params == null) { - Uri.decode(rawFragment).takeIf(String::isNotBlank) - } else { - params?.get("password") - } ?: return - - val merchantUrl = Uri.Builder() - .scheme("https") - .encodedAuthority(host) - .appendPath("instances") - .appendPath(instance) - .build() - .toString() - - val newConfig = Config.New( - merchantUrl = merchantUrl, - accessToken = token, - savePassword = true, - ) + // A payment option may own this link, for example an OAuth authorization + // coming back from the browser. + lifecycleScope.launch { + if (model.paymentOptions.handleRedirect(data)) return@launch + handleMerchantSetupUri(data) + } + } + + private fun handleMerchantSetupUri(data: Uri) + { + val newConfig = merchantSetupConfig(data) ?: return - Log.d("MainActivity", "Config URL: $merchantUrl") + model.localNetworkAccess.retry() model.configManager.config = newConfig navigateTo(PosDestination.ConfigFetcher) } - private fun determineStartDestination(): PosDestination { - return when { - !model.configManager.config.isValid() -> PosDestination.Config + private fun determineStartDestination(): PosDestination = + when + { + !model.configManager.config.isValid() -> + { + PosDestination.Config + } + model.configManager.merchantConfig == null || model.configManager.currency == null -> + { PosDestination.ConfigFetcher - else -> model.configManager.initialDestination() + } + + else -> + { + model.configManager.initialDestination() + } } - } - private fun handleExitRequest(currentRoute: String?) { - if (currentRoute == PosDestination.Order.route || currentRoute == PosDestination.AmountEntry.route) { - if (reallyExit) { + private fun handleExitRequest(currentRoute: String?) + { + if (currentRoute == PosDestination.Order.route || + currentRoute == PosDestination.AmountEntry.route) + { + if (reallyExit) + { super.onBackPressedDispatcher.onBackPressed() - } else { + } + else + { reallyExit = true Toast.makeText(this, R.string.toast_back_to_exit, LENGTH_SHORT).show() Handler(Looper.getMainLooper()).postDelayed({ reallyExit = false }, 3000) } - } else if (currentRoute == PosDestination.Settings.route || currentRoute == PosDestination.Config.route) { - if (!model.configManager.config.isValid()) { - startActivity(Intent(ACTION_MAIN).apply { - addCategory(CATEGORY_HOME) - flags = FLAG_ACTIVITY_NEW_TASK - }) - } else { + } + else if (currentRoute == PosDestination.Settings.route || + currentRoute == PosDestination.Config.route) + { + if (!model.configManager.config.isValid()) + { + startActivity( + Intent(ACTION_MAIN).apply { + addCategory(CATEGORY_HOME) + flags = FLAG_ACTIVITY_NEW_TASK + }, + ) + } + else + { navigateBack() } - } else { + } + else + { navigateBack() } } @@ -293,64 +452,155 @@ private fun MerchantTerminalApp( startDestination: PosDestination, onNavControllerReady: (NavHostController) -> Unit, onExitRequested: (String?) -> Unit, -) { +) +{ val navController = rememberNavController() val drawerState = rememberDrawerState(DrawerValue.Closed) val scope = rememberCoroutineScope() val backStackEntry by navController.currentBackStackEntryAsState() val currentRoute = backStackEntry?.destination?.route + val activity = LocalActivity.current + LaunchedEffect(currentRoute) { + if (currentRoute == null) return@LaunchedEffect + activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_FULL_USER + } val currentOrderId by viewModel.orderManager.currentOrderId.observeAsState() val currentOrderLive = remember(currentOrderId) { currentOrderId?.let { viewModel.orderManager.getOrder(it) } } val currentOrderState = currentOrderLive?.order?.observeAsState() val currentOrder = currentOrderState?.value - val restartState by currentOrderLive?.restartState?.observeAsState(DISABLED) ?: remember { androidx.compose.runtime.mutableStateOf(DISABLED) } + val restartState by currentOrderLive?.restartState?.observeAsState(DISABLED) + ?: remember { mutableStateOf(DISABLED) } val clearOrderEnabled = restartState != DISABLED || currentOrder?.products?.isNotEmpty() == true val hasPreviousOrder = currentOrderId?.let { viewModel.orderManager.hasPreviousOrder(it) } ?: false val hasNextOrder by currentOrderId?.let { viewModel.orderManager.hasNextOrder(it).observeAsState(false) } - ?: remember { androidx.compose.runtime.mutableStateOf(false) } + ?: remember { mutableStateOf(false) } val preferredInitialDestination by viewModel.configManager.initialOrderScreenLiveData.observeAsState( - viewModel.configManager.initialOrderScreen + viewModel.configManager.initialOrderScreen, ) val context = LocalContext.current + val optionsSummary by viewModel.paymentOptions.summary.observeAsState(PaymentOptionsSummary()) + val optionActionFailed by viewModel.paymentOptions.actionFailed.observeAsState(false) + val terminalStatusIndicator: @Composable () -> Unit = { + if (viewModel.paymentOptions.hasEnabledTerminal(optionsSummary)) + { + TerminalStatusIndicator( + status = viewModel.paymentOptions.terminalStatus(optionsSummary), + states = viewModel.paymentOptions.connectionStates(optionsSummary), + onOpenOptions = { + navController.navigate(PosDestination.PaymentOptions.route) { launchSingleTop = true } + }, + ) + } + } + if (optionActionFailed) + { + AlertDialog( + onDismissRequest = viewModel.paymentOptions::dismissError, + title = { Text(stringResource(R.string.payment_options_title)) }, + text = { Text(stringResource(R.string.terminal_action_failed)) }, + confirmButton = { + TextButton(onClick = viewModel.paymentOptions::dismissError) { + Text(stringResource(android.R.string.ok)) + } + }, + ) + } + // Screens pushed on top of a drawer destination need a way back that is not the + // system gesture, so they show a back arrow instead of the drawer button. + val isSubScreen = + currentRoute in + setOf( + PosDestination.Checkout.route, + PosDestination.OrderDetails.route, + PosDestination.Config.route, + PosDestination.PaymentOptions.route, + PosDestination.TalerDevices.route, + PosDestination.PaymentOptionDetail.route, + ) val hasValidConfig = viewModel.configManager.config.isValid() val lockMerchantSettingsNavigation = !hasValidConfig && (currentRoute == PosDestination.Settings.route || currentRoute == PosDestination.Config.route) - val screenTitle = when (currentRoute) { - PosDestination.Order.route -> currentOrder?.let { - stringResource(R.string.order_label_title, it.title) - } ?: stringResource(R.string.menu_order) - else -> stringResource(currentRoute.titleResId()) - } + val currentPayment by viewModel.paymentManager.payment.observeAsState() + val screenTitle = + when (currentRoute) + { + PosDestination.ProcessPayment.route -> + { + stringResource( + when + { + currentPayment?.statusLoading == true -> R.string.order_details_loading + currentPayment?.claimed == true -> R.string.checkout_payment_claimed_title + else -> R.string.payment_process_label + }, + ) + } + + PosDestination.Order.route -> + { + currentOrder?.let { + stringResource(R.string.order_label_title, it.title) + } ?: stringResource(R.string.menu_order) + } + + PosDestination.PaymentOptionDetail.route -> + { + val optionId = + backStackEntry + ?.arguments + ?.getString(PosDestination.PaymentOptionDetail.ARG_OPTION_ID) + val option = optionId?.let { viewModel.paymentOptions.option(it) } + stringResource(option?.nameRes ?: R.string.payment_options_title) + } + + else -> + { + stringResource(currentRoute.titleResId()) + } + } val reloadingMessage = stringResource(R.string.toast_reloading) + val configuration = LocalConfiguration.current + // Landscape phones have a wide window but still need a compact toolbar. + val compactTopBar = configuration.screenWidthDp < 600 || configuration.screenHeightDp < 480 + var orderActionsExpanded by remember { mutableStateOf(false) } - val drawerItems = listOf( - PosDestination.AmountEntry, - PosDestination.Order, - PosDestination.History, - PosDestination.Settings, - ).let { items -> - val preferredItem = when (preferredInitialDestination) { - net.taler.merchantpos.config.InitialOrderScreen.AmountEntry -> PosDestination.AmountEntry - net.taler.merchantpos.config.InitialOrderScreen.Inventory -> PosDestination.Order - null -> viewModel.configManager.initialDestination() - } - if (preferredItem in items) { - listOf(preferredItem) + items.filterNot { it == preferredItem } - } else { - items + val drawerItems = + listOf( + PosDestination.AmountEntry, + PosDestination.Order, + PosDestination.History, + PosDestination.Settings, + ).let { items -> + val preferredItem = + when (preferredInitialDestination) + { + InitialOrderScreen.AmountEntry -> PosDestination.AmountEntry + InitialOrderScreen.Inventory -> PosDestination.Order + null -> viewModel.configManager.initialDestination() + } + if (preferredItem in items) + { + listOf(preferredItem) + items.filterNot { it == preferredItem } + } + else + { + items + } } - } LaunchedEffect(navController) { onNavControllerReady(navController) } BackHandler { - if (drawerState.isOpen) { + if (drawerState.isOpen) + { scope.launch { drawerState.close() } - } else { + } + else + { onExitRequested(currentRoute) } } @@ -367,10 +617,11 @@ private fun MerchantTerminalApp( Image( painter = painterResource(R.drawable.ic_talerpos_logo), contentDescription = null, - modifier = Modifier - .fillMaxWidth() - .height(72.dp) - .padding(horizontal = 8.dp, vertical = 8.dp), + modifier = + Modifier + .fillMaxWidth() + .height(72.dp) + .padding(horizontal = 8.dp, vertical = 8.dp), ) drawerItems.forEach { item -> NavigationDrawerItem( @@ -383,7 +634,7 @@ private fun MerchantTerminalApp( label = { Text( text = stringResource(item.labelResId()), - fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold, + fontWeight = FontWeight.SemiBold, ) }, selected = currentRoute == item.route, @@ -399,7 +650,9 @@ private fun MerchantTerminalApp( modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.large, badge = null, - colors = androidx.compose.material3.NavigationDrawerItemDefaults.colors(), + colors = + NavigationDrawerItemDefaults + .colors(), ) } } @@ -408,132 +661,86 @@ private fun MerchantTerminalApp( ) { Scaffold( topBar = { - if (currentRoute == PosDestination.Order.route) { - Surface(shadowElevation = 2.dp) { - Row( - modifier = Modifier - .fillMaxWidth() - .systemBarsPadding() - .padding(horizontal = 8.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, - ) { - if (!lockMerchantSettingsNavigation) { - IconButton(onClick = { scope.launch { drawerState.open() } }) { - Icon(Icons.Default.Menu, contentDescription = null) - } - } else { - Spacer(modifier = Modifier.size(48.dp)) - } - Text( - text = screenTitle, - modifier = Modifier.weight(1f), - style = MaterialTheme.typography.titleLarge, - ) - Row( - modifier = Modifier.horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Button( - onClick = { currentOrderLive?.restartOrUndo() }, - enabled = clearOrderEnabled, - colors = topBarOrderButtonColors(), - modifier = Modifier.heightIn(min = 40.dp), - ) { - Text( - if (restartState == UNDO) { - stringResource(R.string.order_undo) - } else { - stringResource(R.string.order_restart) - }, - ) - } - Button( - onClick = { if (hasPreviousOrder) viewModel.orderManager.previousOrder() }, - enabled = hasPreviousOrder, - colors = topBarOrderButtonColors(), - modifier = Modifier.heightIn(min = 40.dp), - ) { - Text(stringResource(R.string.order_previous)) - } - Button( - onClick = { if (hasNextOrder) viewModel.orderManager.nextOrder() }, - enabled = hasNextOrder, - colors = topBarOrderButtonColors(), - modifier = Modifier.heightIn(min = 40.dp), - ) { - Text(stringResource(R.string.order_next)) - } - Button( - onClick = { - viewModel.configManager.reloadConfig() - Toast.makeText( - context, - reloadingMessage, - Toast.LENGTH_LONG, - ).show() - }, - colors = topBarOrderButtonColors(), - modifier = Modifier.heightIn(min = 40.dp), - ) { - Text(stringResource(R.string.menu_reload)) - } + PosTopBar( + title = screenTitle, + compact = compactTopBar, + navigationIcon = { + if (lockMerchantSettingsNavigation) + { + Spacer(modifier = Modifier.size(48.dp)) + } + else if (isSubScreen) + { + IconButton(onClick = { navController.popBackStack() }) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.navigation_back), + ) } } - } - } else if (currentRoute == PosDestination.History.route) { - Surface(shadowElevation = 2.dp) { - Row( - modifier = Modifier - .fillMaxWidth() - .systemBarsPadding() - .padding(horizontal = 8.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, - ) { - if (!lockMerchantSettingsNavigation) { - IconButton(onClick = { scope.launch { drawerState.open() } }) { - Icon(Icons.Default.Menu, contentDescription = null) - } - } else { - Spacer(modifier = Modifier.size(48.dp)) + else + { + IconButton(onClick = { scope.launch { drawerState.open() } }) { + Icon( + Icons.Default.Menu, + contentDescription = stringResource(R.string.navigation_menu_open) + ) } - Text( - text = screenTitle, - modifier = Modifier.weight(1f), - style = MaterialTheme.typography.titleLarge, + } + }, + ) { + when (currentRoute) + { + PosDestination.Order.route -> + { + terminalStatusIndicator() + OrderActionsControl( + compact = compactTopBar, + expanded = orderActionsExpanded, + onExpandedChange = { orderActionsExpanded = it }, + clearEnabled = clearOrderEnabled, + previousEnabled = hasPreviousOrder, + nextEnabled = hasNextOrder, + clearLabel = + if (restartState == UNDO) + { + stringResource(R.string.order_undo) + } + else + { + stringResource(R.string.order_restart) + }, + onClear = { currentOrderLive?.restartOrUndo() }, + onPrevious = { viewModel.orderManager.previousOrder() }, + onNext = { viewModel.orderManager.nextOrder() }, + onReload = { + viewModel.configManager.reloadConfig() + Toast.makeText(context, reloadingMessage, Toast.LENGTH_LONG).show() + }, ) + } + + PosDestination.History.route -> + { Button( onClick = { viewModel.historyManager.fetchHistory() }, colors = topBarOrderButtonColors(), modifier = Modifier.heightIn(min = 40.dp), ) { - Text(stringResource(R.string.history_refresh)) + Text( + stringResource(R.string.history_refresh), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } } - } - } else { - Surface(shadowElevation = 2.dp) { - Row( - modifier = Modifier - .fillMaxWidth() - .systemBarsPadding() - .padding(horizontal = 8.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, - ) { - if (!lockMerchantSettingsNavigation) { - IconButton(onClick = { scope.launch { drawerState.open() } }) { - Icon(Icons.Default.Menu, contentDescription = null) - } - } else { - Spacer(modifier = Modifier.size(48.dp)) - } - Text( - text = screenTitle, - modifier = Modifier.weight(1f), - style = MaterialTheme.typography.titleLarge, - ) + + PosDestination.AmountEntry.route, + PosDestination.Checkout.route, + PosDestination.ProcessPayment.route, + -> + { + terminalStatusIndicator() } } } @@ -542,9 +749,10 @@ private fun MerchantTerminalApp( NavHost( navController = navController, startDestination = startDestination.route, - modifier = Modifier - .fillMaxSize() - .padding(innerPadding), + modifier = + Modifier + .fillMaxSize() + .padding(innerPadding), ) { composable(PosDestination.AmountEntry.route) { FragmentScreenHost("amount-entry") { AmountEntryFragment() } @@ -555,15 +763,59 @@ private fun MerchantTerminalApp( composable(PosDestination.History.route) { FragmentScreenHost("history") { HistoryFragment() } } + composable( + route = PosDestination.OrderDetails.route, + arguments = + listOf( + navArgument(PosDestination.OrderDetails.ARG_ORDER_ID) { + type = NavType.StringType + }, + ), + ) { entry -> + OrderDetailsRoute( + model = viewModel, + orderId = requireNotNull(entry.arguments?.getString(PosDestination.OrderDetails.ARG_ORDER_ID)), + onDeleted = { navController.popBackStack(PosDestination.History.route, inclusive = false) }, + onNavigate = { destination -> navController.navigate(destination.route) }, + ) + } composable(PosDestination.Settings.route) { FragmentScreenHost("settings") { GeneralSettingsFragment() } } + composable(PosDestination.PaymentOptions.route) { + FragmentScreenHost("payment-options") { PaymentOptionsFragment() } + } + composable( + route = PosDestination.PaymentOptionDetail.route, + arguments = + listOf( + navArgument(PosDestination.PaymentOptionDetail.ARG_OPTION_ID) { + type = NavType.StringType + }, + ), + ) { entry -> + val optionId = + entry.arguments + ?.getString(PosDestination.PaymentOptionDetail.ARG_OPTION_ID) + .orEmpty() + FragmentScreenHost("payment-option-$optionId") { + PaymentOptionDetailFragment.create(optionId) + } + } + composable(PosDestination.TalerDevices.route) { + FragmentScreenHost("taler-devices") { + TalerDevicesFragment() + } + } composable(PosDestination.Config.route) { FragmentScreenHost("config") { ConfigFragment() } } composable(PosDestination.ConfigFetcher.route) { FragmentScreenHost("config-fetcher") { ConfigFetcherFragment() } } + composable(PosDestination.Checkout.route) { + FragmentScreenHost("checkout") { CheckoutFragment() } + } composable(PosDestination.ProcessPayment.route) { FragmentScreenHost("process-payment") { ProcessPaymentFragment() } } @@ -582,40 +834,233 @@ private fun MerchantTerminalApp( } @Composable -private fun topBarOrderButtonColors() = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.secondaryContainer, - contentColor = MaterialTheme.colorScheme.onSecondaryContainer, +private fun PosTopBar( + title: String, + compact: Boolean, + navigationIcon: @Composable () -> Unit, + actions: @Composable RowScope.() -> Unit, +) +{ + Surface(shadowElevation = 2.dp) { + Row( + modifier = + Modifier + .fillMaxWidth() + // Protect the status bar and camera cutout without adding the bottom + // navigation-bar inset to the header. Scaffold handles it in the body. + .windowInsetsPadding( + WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal), + ).height(if (compact) 56.dp else 64.dp) + .padding(horizontal = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + navigationIcon() + Text( + text = title, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.titleLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + actions() + } + } +} + +@Composable +private fun topBarOrderButtonColors() = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + ) + +@Composable +private fun orderActionMenuColors() = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant, + disabledContentColor = MaterialTheme.colorScheme.onSurfaceVariant, + ) + +@Composable +private fun OrderActionMenuButton( + text: String, + enabled: Boolean = true, + onClick: () -> Unit, ) +{ + Button( + onClick = onClick, + enabled = enabled, + colors = orderActionMenuColors(), + shape = + RoundedCornerShape(24.dp), + modifier = Modifier.fillMaxWidth().heightIn(min = 44.dp), + ) { + Text(text, modifier = Modifier.fillMaxWidth()) + } +} + +@Composable +internal fun OrderActionsControl( + compact: Boolean, + expanded: Boolean, + onExpandedChange: (Boolean) -> Unit, + clearEnabled: Boolean, + previousEnabled: Boolean, + nextEnabled: Boolean, + clearLabel: String, + onClear: () -> Unit, + onPrevious: () -> Unit, + onNext: () -> Unit, + onReload: () -> Unit, +) +{ + if (!compact) + { + Row( + modifier = Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Button( + onClick = onClear, + enabled = clearEnabled, + colors = topBarOrderButtonColors(), + modifier = Modifier.heightIn(min = 40.dp), + ) { + Text(clearLabel) + } + Button( + onClick = onPrevious, + enabled = previousEnabled, + colors = topBarOrderButtonColors(), + modifier = Modifier.heightIn(min = 40.dp), + ) { + Text(stringResource(R.string.order_previous)) + } + Button( + onClick = onNext, + enabled = nextEnabled, + colors = topBarOrderButtonColors(), + modifier = Modifier.heightIn(min = 40.dp), + ) { + Text(stringResource(R.string.order_next)) + } + Button(onClick = onReload, colors = topBarOrderButtonColors(), modifier = Modifier.heightIn(min = 40.dp)) { + Text(stringResource(R.string.menu_reload)) + } + } + return + } + Box { + Button( + onClick = { onExpandedChange(!expanded) }, + colors = orderActionMenuColors(), + shape = + RoundedCornerShape(24.dp), + modifier = Modifier.heightIn(min = 40.dp), + ) { + Text(stringResource(R.string.order_actions)) + Icon( + imageVector = if (expanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, + contentDescription = null, + ) + } + DropdownMenu(expanded = expanded, onDismissRequest = { onExpandedChange(false) }) { + Column( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + OrderActionMenuButton(clearLabel, clearEnabled) { + onExpandedChange(false) + onClear() + } + OrderActionMenuButton(stringResource(R.string.order_previous), previousEnabled) { + onExpandedChange(false) + onPrevious() + } + OrderActionMenuButton(stringResource(R.string.order_next), nextEnabled) { + onExpandedChange(false) + onNext() + } + OrderActionMenuButton(stringResource(R.string.menu_reload)) { + onExpandedChange(false) + onReload() + } + } + } + } +} @Composable private fun FragmentScreenHost( routeTag: String, createFragment: () -> Fragment, -) { +) +{ val activity = LocalActivity.current as? MainActivity ?: return val fragmentManager = activity.supportFragmentManager - val containerId = remember(routeTag) { View.generateViewId() } + // FragmentManager restores fragment container IDs across Activity recreation. + // Keep the Compose host's ID in saved state so it can reconnect to that fragment. + val containerId = rememberSaveable(routeTag) { View.generateViewId() } val fragmentTag = remember(routeTag, containerId) { "$routeTag-$containerId" } + var container by remember(routeTag) { mutableStateOf<FragmentContainerView?>(null) } AndroidView( modifier = Modifier.fillMaxSize(), factory = { context -> - FrameLayout(context).apply { + FragmentContainerView(context).apply { id = containerId layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT) } }, + update = { container = it }, ) - DisposableEffect(fragmentTag) { - if (fragmentManager.findFragmentByTag(fragmentTag) == null) { - fragmentManager.commitNow { - replace(containerId, createFragment(), fragmentTag) + val hostedContainer = container + DisposableEffect(fragmentTag, hostedContainer) { + val attachHostFragment = { + if (hostedContainer != null && + !fragmentManager.isStateSaved) + { + val restored = fragmentManager.findFragmentByTag(fragmentTag) + if (restored == null) + { + fragmentManager.commitNow { + replace(containerId, createFragment(), fragmentTag) + } + } + else if (restored.view?.parent !== hostedContainer) + { + // FragmentManager may restore the Fragment before Compose has recreated + // its AndroidView. Reattaching forces a fresh view into this container. + if (!restored.isDetached) + { + fragmentManager.commitNow { + detach(restored) + } + } + fragmentManager.commitNow { + attach(restored) + } + } } } + val lifecycleObserver = + LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_START) attachHostFragment() + } + activity.lifecycle.addObserver(lifecycleObserver) + attachHostFragment() onDispose { + activity.lifecycle.removeObserver(lifecycleObserver) + if (hostedContainer == null) return@onDispose fragmentManager.findFragmentByTag(fragmentTag)?.let { fragment -> - if (fragment.isAdded && !fragmentManager.isStateSaved) { + if (fragment.isAdded && + !fragmentManager.isStateSaved) + { fragmentManager.commitNow { remove(fragment) } @@ -625,31 +1070,41 @@ private fun FragmentScreenHost( } } -private fun PosDestination.labelResId(): Int = when (this) { - PosDestination.AmountEntry -> R.string.menu_amount_entry - PosDestination.Order -> R.string.menu_order - PosDestination.History -> R.string.menu_history - PosDestination.Settings -> R.string.menu_settings - else -> R.string.app_name_short -} +private fun PosDestination.labelResId(): Int = + when (this) + { + PosDestination.AmountEntry -> R.string.menu_amount_entry + PosDestination.Order -> R.string.menu_order + PosDestination.History -> R.string.menu_history + PosDestination.Settings -> R.string.menu_settings + else -> R.string.app_name_short + } -private fun PosDestination.drawerIconResId(): Int = when (this) { - PosDestination.AmountEntry -> R.drawable.ic_dialpad - PosDestination.Order -> R.drawable.ic_move_money_24dp - PosDestination.History -> R.drawable.ic_history_black_24dp - PosDestination.Settings -> R.drawable.ic_menu_manage - else -> R.drawable.ic_move_money_24dp -} +private fun PosDestination.drawerIconResId(): Int = + when (this) + { + PosDestination.AmountEntry -> R.drawable.ic_dialpad + PosDestination.Order -> R.drawable.ic_move_money_24dp + PosDestination.History -> R.drawable.ic_history_black_24dp + PosDestination.Settings -> R.drawable.ic_menu_manage + else -> R.drawable.ic_move_money_24dp + } -private fun String?.titleResId(): Int = when (this) { - PosDestination.AmountEntry.route -> R.string.menu_amount_entry - PosDestination.Order.route -> R.string.menu_order - PosDestination.History.route -> R.string.menu_history - PosDestination.Settings.route -> R.string.menu_settings - PosDestination.Config.route -> R.string.config_label - PosDestination.ConfigFetcher.route -> R.string.config_fetching_label - PosDestination.ProcessPayment.route -> R.string.payment_process_label - PosDestination.PaymentSuccess.route -> R.string.payment_received - PosDestination.Refund.route, PosDestination.RefundUri.route -> R.string.history_refund - else -> R.string.app_name_short -} +private fun String?.titleResId(): Int = + when (this) + { + PosDestination.AmountEntry.route -> R.string.menu_amount_entry + PosDestination.Order.route -> R.string.menu_order + PosDestination.History.route -> R.string.menu_history + PosDestination.OrderDetails.route -> R.string.order_details_title + PosDestination.Settings.route -> R.string.menu_settings + PosDestination.PaymentOptions.route -> R.string.payment_options_title + PosDestination.TalerDevices.route -> R.string.taler_devices_title + PosDestination.Config.route -> R.string.config_label + PosDestination.ConfigFetcher.route -> R.string.config_fetching_label + PosDestination.Checkout.route -> R.string.checkout_title + PosDestination.ProcessPayment.route -> R.string.payment_process_label + PosDestination.PaymentSuccess.route -> R.string.payment_received + PosDestination.Refund.route, PosDestination.RefundUri.route -> R.string.history_refund + else -> R.string.app_name_short + } diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/MainViewModel.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/MainViewModel.kt @@ -17,43 +17,82 @@ package net.taler.merchantpos import android.app.Application + import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope -import io.ktor.client.statement.* -import io.ktor.http.HttpStatusCode + import net.taler.common.createDefaultHttpClient import net.taler.merchantlib.MerchantApi +import net.taler.merchantpos.checkout.CheckoutManager +import net.taler.merchantpos.checkout.CheckoutStore import net.taler.merchantpos.config.ConfigManager +import net.taler.merchantpos.config.LocalNetworkAccess +import net.taler.merchantpos.devices.TalerBluetoothDevice +import net.taler.merchantpos.devices.TalerUsbDevices import net.taler.merchantpos.history.HistoryManager import net.taler.merchantpos.order.OrderManager import net.taler.merchantpos.payment.PaymentManager +import net.taler.merchantpos.paymentoptions.PaymentOptionsManager +import net.taler.merchantpos.paymentoptions.PaymentOptionsRegistry import net.taler.merchantpos.refund.RefundManager -class MainViewModel(app: Application) : AndroidViewModel(app) { +import java.io.File + +import io.ktor.client.statement.HttpResponsePipeline +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +class MainViewModel( + app: Application, +) : AndroidViewModel(app) +{ + // This client owns callbacks into this ViewModel and is closed in onCleared(). private val httpClient = createDefaultHttpClient(followRedirect = true) private val api = MerchantApi(httpClient) val orderManager = OrderManager(app) - val configManager = ConfigManager(app, viewModelScope, httpClient).apply { - addConfigurationReceiver(orderManager) - } + val talerUsb = TalerUsbDevices(app) + val talerBluetooth = TalerBluetoothDevice(app) + val paymentOptions = PaymentOptionsManager(app, PaymentOptionsRegistry.create(app, talerUsb, talerBluetooth)) + val localNetworkAccess = LocalNetworkAccess(app) + val configManager = + ConfigManager(app, viewModelScope, httpClient, beforeConnection = localNetworkAccess::ensureAccess).apply { + addConfigurationReceiver(orderManager) + } val paymentManager = PaymentManager(app, configManager, viewModelScope, api) val historyManager = HistoryManager(configManager, viewModelScope, api) val refundManager = RefundManager(configManager, viewModelScope, api) + val checkoutManager = + CheckoutManager( + store = CheckoutStore(File(app.filesDir, "checkouts.json")), + api = api, + configManager = configManager, + paymentOptions = paymentOptions, + orderManager = orderManager, + ) - init { - httpClient.responsePipeline.intercept(HttpResponsePipeline.Transform) { (info, body) -> - if (context.response.status == HttpStatusCode.Unauthorized && configManager.merchantConfig != null) { - configManager.forgetPassword() - configManager.notifySessionExpired() + init + { + httpClient.responsePipeline.intercept(HttpResponsePipeline.Transform) { _ -> + if (context.response.status == HttpStatusCode.Unauthorized) + { + withContext(Dispatchers.Main.immediate) { + configManager.handleUnauthorized( + context.request.url.toString(), + context.request.headers[HttpHeaders.Authorization], + ) + } + } + proceedWith(subject) } - proceedWith(subject) - } } - override fun onCleared() { + override fun onCleared() + { + talerUsb.close() + talerBluetooth.close() httpClient.close() } - } diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/PosApplication.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/PosApplication.kt @@ -0,0 +1,31 @@ +/* + * 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 android.app.Application + +import net.taler.merchantpos.paymentoptions.PaymentOptionsRegistry + +class PosApplication : Application() +{ + override fun onCreate() + { + super.onCreate() + // Card terminal SDKs that need startup before first use; a no-op where none are bundled. + PaymentOptionsRegistry.initialize(this) + } +} diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/PosDestination.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/PosDestination.kt @@ -16,27 +16,64 @@ package net.taler.merchantpos +import android.net.Uri + import net.taler.merchantpos.config.ConfigManager import net.taler.merchantpos.config.InitialOrderScreen sealed class PosDestination( val route: String, -) { +) +{ data object AmountEntry : PosDestination("amount-entry") + data object Order : PosDestination("order") + data object History : PosDestination("history") + + data object OrderDetails : PosDestination("history/{orderId}") + { + const val ARG_ORDER_ID = "orderId" + + fun forOrder(orderId: String) = Resolved("history/${Uri.encode(orderId)}") + } + data object Settings : PosDestination("settings") + + data object TalerDevices : PosDestination("taler-devices") + + data object PaymentOptions : PosDestination("payment-options") + + data object PaymentOptionDetail : PosDestination("payment-options/{optionId}") + { + const val ARG_OPTION_ID = "optionId" + + fun forOption(optionId: String) = Resolved("payment-options/$optionId") + } + data object Config : PosDestination("config") + data object ConfigFetcher : PosDestination("config-fetcher") + + data object Checkout : PosDestination("checkout") + data object ProcessPayment : PosDestination("process-payment") + data object PaymentSuccess : PosDestination("payment-success") + data object Refund : PosDestination("refund") + data object RefundUri : PosDestination("refund-uri") + + /** A destination whose route already has its arguments filled in. */ + class Resolved( + route: String, + ) : PosDestination(route) } -fun ConfigManager.initialDestination(): PosDestination { - return when (initialOrderScreen) { +fun ConfigManager.initialDestination(): PosDestination = + when (initialOrderScreen) + { InitialOrderScreen.AmountEntry -> PosDestination.AmountEntry InitialOrderScreen.Inventory -> PosDestination.Order } -} diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/PosErrorDialog.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/PosErrorDialog.kt @@ -18,29 +18,41 @@ package net.taler.merchantpos import android.app.Dialog import android.os.Bundle + import androidx.annotation.StringRes import androidx.fragment.app.DialogFragment import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity + import com.google.android.material.dialog.MaterialAlertDialogBuilder private const val POS_ERROR_DIALOG_TAG = "POS_ERROR_DIALOG" -class PosErrorDialogFragment : DialogFragment() { - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { +class PosErrorDialogFragment : DialogFragment() +{ + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog + { val args = requireArguments() val mainText = args.getString(ARG_MAIN_TEXT).orEmpty() val detailText = args.getString(ARG_DETAIL_TEXT).orEmpty() - val titleText = if (detailText.isBlank()) { - getString(R.string.app_name_short) - } else { - mainText - } - val messageText = if (detailText.isBlank()) { - mainText - } else { - detailText - } + val titleText = + if (detailText.isBlank()) + { + getString(R.string.app_name_short) + } + else + { + mainText + } + val messageText = + if (detailText.isBlank()) + { + mainText + } + else + { + detailText + } return MaterialAlertDialogBuilder(requireContext()) .setTitle(titleText) .setMessage(messageText) @@ -48,33 +60,55 @@ class PosErrorDialogFragment : DialogFragment() { .create() } - companion object { + companion object + { private const val ARG_MAIN_TEXT = "main_text" private const val ARG_DETAIL_TEXT = "detail_text" - fun newInstance(mainText: String, detailText: String = "") = PosErrorDialogFragment().apply { - arguments = Bundle().apply { - putString(ARG_MAIN_TEXT, mainText) - putString(ARG_DETAIL_TEXT, detailText) - } + fun newInstance( + mainText: String, + detailText: String = "", + ) = PosErrorDialogFragment().apply { + arguments = + Bundle().apply { + putString(ARG_MAIN_TEXT, mainText) + putString(ARG_DETAIL_TEXT, detailText) + } } } } -fun FragmentActivity.showPosError(mainText: String, detailText: String = "") { +fun FragmentActivity.showPosError( + mainText: String, + detailText: String = "", +) +{ (supportFragmentManager.findFragmentByTag(POS_ERROR_DIALOG_TAG) as? DialogFragment)?.dismissAllowingStateLoss() - PosErrorDialogFragment.newInstance(mainText, detailText) + PosErrorDialogFragment + .newInstance(mainText, detailText) .show(supportFragmentManager, POS_ERROR_DIALOG_TAG) } -fun FragmentActivity.showPosError(@StringRes mainId: Int, detailText: String = "") { +fun FragmentActivity.showPosError( + @StringRes mainId: Int, + detailText: String = "", +) +{ showPosError(getString(mainId), detailText) } -fun Fragment.showPosError(mainText: String, detailText: String = "") { +fun Fragment.showPosError( + mainText: String, + detailText: String = "", +) +{ requireActivity().showPosError(mainText, detailText) } -fun Fragment.showPosError(@StringRes mainId: Int, detailText: String = "") { +fun Fragment.showPosError( + @StringRes mainId: Int, + detailText: String = "", +) +{ requireActivity().showPosError(mainId, detailText) } diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/amount/AmountEntryFragment.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/amount/AmountEntryFragment.kt @@ -20,27 +20,23 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup + import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.Icon +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExposedDropdownMenuAnchorType import androidx.compose.material3.ExposedDropdownMenuBox @@ -56,25 +52,24 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.compose.ui.res.colorResource -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.ui.unit.TextUnit import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels + import net.taler.common.Amount -import net.taler.merchantpos.PosDestination +import net.taler.common.CurrencySpecification +import net.taler.merchantpos.MainActivity import net.taler.merchantpos.MainViewModel +import net.taler.merchantpos.PosDestination import net.taler.merchantpos.R +import net.taler.merchantpos.compose.AmountKeypad import net.taler.merchantpos.compose.PosTheme import net.taler.merchantpos.config.ConfigProduct import net.taler.merchantpos.order.Order @@ -82,12 +77,60 @@ import net.taler.merchantpos.showPosError private const val QUICK_AMOUNT_ORDER_ID = -1 private const val QUICK_AMOUNT_PRODUCT_ID = "quick_amount" +private const val STATE_AMOUNT_CURRENCY = "amount_entry.currency" +private const val STATE_AMOUNT_INTEGER = "amount_entry.integer" +private const val STATE_AMOUNT_FRACTION = "amount_entry.fraction" + +internal data class AmountEntryDraft( + val currency: String, + val amount: Amount, +) -class AmountEntryFragment : Fragment() { +internal fun saveAmountEntryDraft( + outState: Bundle, + currency: String?, + amount: Amount?, +) +{ + currency?.let { outState.putString(STATE_AMOUNT_CURRENCY, it) } + amount?.let { + outState.putLong(STATE_AMOUNT_INTEGER, it.value) + outState.putInt(STATE_AMOUNT_FRACTION, it.fraction) + } +} + +internal fun restoreAmountEntryDraft( + savedState: Bundle?, + configuredCurrency: String, + currencySpec: CurrencySpecification?, +): AmountEntryDraft? +{ + val state = savedState ?: return null + val currency = + state + .getString(STATE_AMOUNT_CURRENCY) + ?.takeIf { it == configuredCurrency } ?: return null + if (!state.containsKey(STATE_AMOUNT_INTEGER) || + !state.containsKey(STATE_AMOUNT_FRACTION)) + { + return null + } + return AmountEntryDraft( + currency = currency, + amount = + Amount( + currency = currency, + value = state.getLong(STATE_AMOUNT_INTEGER), + fraction = state.getInt(STATE_AMOUNT_FRACTION), + spec = currencySpec, + ), + ) +} +class AmountEntryFragment : Fragment() +{ private val viewModel: MainViewModel by activityViewModels() private val paymentManager by lazy { viewModel.paymentManager } - private var selectedCurrency by mutableStateOf<String?>(null) private var amount by mutableStateOf<Amount?>(null) @@ -95,8 +138,9 @@ class AmountEntryFragment : Fragment() { inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, - ): View { - initializeAmountState() + ): View + { + initializeAmountState(savedInstanceState) return ComposeView(requireContext()).apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { @@ -115,86 +159,121 @@ class AmountEntryFragment : Fragment() { } } - override fun onStart() { + override fun onSaveInstanceState(outState: Bundle) + { + super.onSaveInstanceState(outState) + saveAmountEntryDraft(outState, selectedCurrency, amount) + } + + override fun onStart() + { super.onStart() - if (!viewModel.configManager.config.isValid()) { - (requireActivity() as net.taler.merchantpos.MainActivity).navigateTo(PosDestination.Config) - } else if (viewModel.configManager.currency == null) { - (requireActivity() as net.taler.merchantpos.MainActivity).navigateTo(PosDestination.ConfigFetcher) + val activity = requireActivity() as MainActivity + val configManager = viewModel.configManager + if (!configManager.config.isValid()) + { + activity.navigateTo(PosDestination.Config) + } + else if (configManager.currency == null) + { + activity.navigateTo(PosDestination.ConfigFetcher) } } - private fun initializeAmountState() { + private fun initializeAmountState(savedInstanceState: Bundle?) + { val configuredCurrency = viewModel.configManager.currency ?: return - if (selectedCurrency == null) { - selectedCurrency = configuredCurrency - } - if (amount == null) { - amount = Amount.zero(configuredCurrency).withSpec(viewModel.configManager.currencySpec) - } + val currencySpec = viewModel.configManager.currencySpec + val restored = restoreAmountEntryDraft(savedInstanceState, configuredCurrency, currencySpec) + val currency = restored?.currency ?: configuredCurrency + selectedCurrency = currency + amount = restored?.amount ?: amount ?: Amount.zero(currency).withSpec(currencySpec) } - private fun setCurrency(currency: String) { + 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) + amount = + when + { + currentAmount == null -> Amount.zero(currency) + currentAmount.currency == currency -> currentAmount + else -> currentAmount.withCurrency(currency) + }.withSpec(spec) } - private fun onDigitPressed(digit: Char) { + private fun onDigitPressed(digit: Char) + { val currentAmount = amount ?: return amount = currentAmount.addInputDigit(digit)?.withSpec(currentAmount.spec) ?: currentAmount } - private fun onBackspacePressed() { + private fun onBackspacePressed() + { val currentAmount = amount ?: return amount = currentAmount.removeInputDigit()?.withSpec(currentAmount.spec) ?: currentAmount } - private fun clearAmount() { + private fun clearAmount() + { val currency = selectedCurrency ?: return amount = Amount.zero(currency).withSpec(viewModel.configManager.currencySpec) } - private fun onChargePressed() { - val configuredCurrency = viewModel.configManager.currency ?: run { - (requireActivity() as net.taler.merchantpos.MainActivity).navigateTo(PosDestination.ConfigFetcher) - return - } + private fun onChargePressed() + { + val activity = requireActivity() as MainActivity + val configuredCurrency = + viewModel.configManager.currency ?: run { + activity.navigateTo(PosDestination.ConfigFetcher) + return + } val enteredCurrency = selectedCurrency ?: configuredCurrency - val enteredAmount = amount - ?: Amount.zero(enteredCurrency).withSpec(viewModel.configManager.currencySpec) + val enteredAmount = + amount + ?: Amount.zero(enteredCurrency).withSpec(viewModel.configManager.currencySpec) - if (enteredAmount.isZero()) { + if (enteredAmount.isZero()) + { requireActivity().showPosError(R.string.amount_entry_error_zero) return } - if (enteredCurrency != configuredCurrency) { + if (enteredCurrency != configuredCurrency) + { requireActivity().showPosError(R.string.amount_entry_error_wrong_currency) return } - 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.withSpec(viewModel.configManager.currencySpec), - categories = listOf(Int.MIN_VALUE), - ) + 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.withSpec(viewModel.configManager.currencySpec), + categories = listOf(Int.MIN_VALUE), + ) val orderWithProduct = order + product // Backend doesn't require products; omit them for this "quick amount" flow. - paymentManager.createPayment(orderWithProduct, includeProducts = false) - (requireActivity() as net.taler.merchantpos.MainActivity).navigateTo(PosDestination.ProcessPayment) + val checkoutManager = viewModel.checkoutManager + if (checkoutManager.shouldUseCheckout()) + { + checkoutManager.start(orderWithProduct, includeProducts = false) + activity.navigateTo(PosDestination.Checkout) + } + else + { + paymentManager.createPayment(orderWithProduct, includeProducts = false) + activity.navigateTo(PosDestination.ProcessPayment) + } } } @@ -210,20 +289,68 @@ private fun AmountEntryScreen( onClearPressed: () -> Unit, onBackspacePressed: () -> Unit, onChargePressed: () -> Unit, -) { +) +{ PosTheme { val configuration = LocalConfiguration.current val isTabletLayout = configuration.smallestScreenWidthDp >= 600 + val portrait = configuration.screenHeightDp > configuration.screenWidthDp Box( - modifier = Modifier - .fillMaxSize() - .statusBarsPadding(), + modifier = Modifier.fillMaxSize(), ) { - if (!isTabletLayout) { + if (portrait) + { + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val contentHeight = maxHeight - 48.dp + val keypadHeight = + (contentHeight * if (isTabletLayout) 0.44f else 0.56f) + .coerceIn( + minimumValue = if (isTabletLayout) 460.dp else 280.dp, + maximumValue = if (isTabletLayout) 516.dp else 420.dp, + ) + Column( + modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + val contentWidth = + if (isTabletLayout) + { + Modifier.widthIn( + max = 560.dp, + ) + } + else + { + Modifier.fillMaxWidth() + } + AmountPane( + amountText = amountText, + selectedCurrency = selectedCurrency, + currencyOptions = currencyOptions, + isTabletLayout = isTabletLayout, + portrait = true, + onCurrencySelected = onCurrencySelected, + modifier = contentWidth.weight(1f), + ) + AmountKeypad( + isTabletLayout = isTabletLayout, + chargeEnabled = chargeEnabled, + onDigitPressed = onDigitPressed, + onClearPressed = onClearPressed, + onBackspacePressed = onBackspacePressed, + onChargePressed = onChargePressed, + modifier = contentWidth.height(keypadHeight), + ) + } + } + } + else if (!isTabletLayout) + { Row( - modifier = Modifier - .fillMaxSize() - .padding(8.dp), + modifier = + Modifier + .fillMaxSize() + .padding(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { AmountPane( @@ -232,27 +359,32 @@ private fun AmountEntryScreen( currencyOptions = currencyOptions, isTabletLayout = false, onCurrencySelected = onCurrencySelected, - modifier = Modifier - .weight(0.32f) - .padding(horizontal = 8.dp, vertical = 4.dp), + modifier = + Modifier + .weight(0.32f) + .padding(horizontal = 8.dp, vertical = 4.dp), ) - KeypadPane( + AmountKeypad( isTabletLayout = false, chargeEnabled = chargeEnabled, onDigitPressed = onDigitPressed, onClearPressed = onClearPressed, onBackspacePressed = onBackspacePressed, onChargePressed = onChargePressed, - modifier = Modifier - .weight(0.68f) - .padding(4.dp), + modifier = + Modifier + .weight(0.68f) + .padding(4.dp), ) } - } else { + } + else + { Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), + modifier = + Modifier + .fillMaxSize() + .padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { AmountPane( @@ -261,17 +393,19 @@ private fun AmountEntryScreen( currencyOptions = currencyOptions, isTabletLayout = true, onCurrencySelected = onCurrencySelected, - modifier = Modifier - .fillMaxWidth() - .weight(0.35f), + modifier = + Modifier + .fillMaxWidth() + .weight(0.35f), ) Row( - modifier = Modifier - .fillMaxWidth() - .weight(0.65f), + modifier = + Modifier + .fillMaxWidth() + .weight(0.65f), horizontalArrangement = Arrangement.Center, ) { - KeypadPane( + AmountKeypad( isTabletLayout = true, chargeEnabled = chargeEnabled, onDigitPressed = onDigitPressed, @@ -296,62 +430,160 @@ private fun AmountPane( isTabletLayout: Boolean, onCurrencySelected: (String) -> Unit, modifier: Modifier = Modifier, -) { + portrait: Boolean = false, +) +{ var expanded by remember { mutableStateOf(false) } + val availableCurrencies = currencyOptions.distinct() val dropdownComposable: @Composable () -> Unit = { - ExposedDropdownMenuBox( - expanded = expanded, - onExpandedChange = { if (currencyOptions.isNotEmpty()) expanded = !expanded }, - modifier = Modifier.wrapContentWidth(Alignment.CenterHorizontally), - ) { - OutlinedTextField( - modifier = Modifier - .menuAnchor( - type = ExposedDropdownMenuAnchorType.PrimaryNotEditable, - enabled = currencyOptions.isNotEmpty(), - ) - .widthIn(min = 96.dp), - value = selectedCurrency.orEmpty(), - onValueChange = {}, - readOnly = true, - singleLine = true, - label = { Text(stringResource(R.string.amount_entry_label)) }, - trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, - shape = RoundedCornerShape(14.dp), - 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, - ), + if (availableCurrencies.size <= 1) + { + Text( + text = availableCurrencies.singleOrNull() ?: selectedCurrency.orEmpty(), + style = MaterialTheme.typography.titleLarge.copy(fontSize = 24.sp, fontWeight = FontWeight.Medium), + color = MaterialTheme.colorScheme.onSurface, ) - - DropdownMenu( + } + else + { + ExposedDropdownMenuBox( expanded = expanded, - onDismissRequest = { expanded = false }, + onExpandedChange = { if (currencyOptions.isNotEmpty()) expanded = !expanded }, + modifier = + if (portrait) + { + Modifier.fillMaxWidth() + } + else + { + Modifier.wrapContentWidth( + Alignment.CenterHorizontally, + ) + }, ) { - currencyOptions.forEach { currency -> - DropdownMenuItem( - text = { Text(currency) }, - onClick = { - expanded = false - onCurrencySelected(currency) - }, - ) + OutlinedTextField( + modifier = + Modifier + .menuAnchor( + type = ExposedDropdownMenuAnchorType.PrimaryNotEditable, + enabled = currencyOptions.isNotEmpty(), + ).then(if (portrait) Modifier.fillMaxWidth() else Modifier.widthIn(min = 96.dp)), + value = selectedCurrency.orEmpty(), + onValueChange = {}, + readOnly = true, + singleLine = true, + label = { Text(stringResource(R.string.amount_entry_label)) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + shape = RoundedCornerShape(14.dp), + 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 = { expanded = false }, + ) { + availableCurrencies.forEach { currency -> + DropdownMenuItem( + text = { Text(currency) }, + onClick = { + expanded = false + onCurrencySelected(currency) + }, + ) + } } } } } - if (isTabletLayout) { + if (portrait) + { + val compactPortrait = LocalConfiguration.current.screenHeightDp <= 680 + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + if (availableCurrencies.size > 1) + { + Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterStart) { + dropdownComposable() + } + Spacer( + modifier = + Modifier.height( + if (isTabletLayout) + { + 44.dp + } + else if (compactPortrait) + { + 8.dp + } + else + { + 40.dp + }, + ), + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + amountText, + modifier = Modifier.weight(1f, fill = false).alignByBaseline(), + style = + MaterialTheme.typography.headlineLarge.copy( + fontSize = + if (isTabletLayout) + { + 64.sp + } + else if (compactPortrait) + { + 48.sp + } + else + { + 56.sp + }, + ), + maxLines = 1, + softWrap = false, + ) + if (availableCurrencies.size <= 1) + { + Text( + text = availableCurrencies.singleOrNull() ?: selectedCurrency.orEmpty(), + modifier = Modifier.alignByBaseline(), + style = + MaterialTheme.typography.titleLarge.copy( + fontSize = 24.sp, + fontWeight = FontWeight.Medium, + ), + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + Spacer(modifier = Modifier.height(if (compactPortrait) 8.dp else 24.dp)) + Text(stringResource(R.string.amount_entry_hint), style = MaterialTheme.typography.bodyLarge) + } + } + else if (isTabletLayout) + { Row( modifier = modifier, horizontalArrangement = Arrangement.Center, @@ -366,15 +598,19 @@ private fun AmountPane( Spacer(modifier = Modifier.width(12.dp)) dropdownComposable() } - } else { - val phoneAmountFontSize = when (amountText.length) { - in 0..6 -> 56.sp - in 7..8 -> 48.sp - in 9..10 -> 40.sp - in 11..12 -> 32.sp - in 13..14 -> 26.sp - else -> 22.sp - } + } + else + { + val phoneAmountFontSize = + when (amountText.length) + { + in 0..6 -> 56.sp + in 7..8 -> 48.sp + in 9..10 -> 40.sp + in 11..12 -> 32.sp + in 13..14 -> 26.sp + else -> 22.sp + } Column( modifier = modifier, @@ -396,154 +632,6 @@ private fun AmountPane( } } -@Composable -private fun KeypadPane( - isTabletLayout: Boolean, - chargeEnabled: Boolean, - onDigitPressed: (Char) -> Unit, - onClearPressed: () -> Unit, - onBackspacePressed: () -> Unit, - onChargePressed: () -> Unit, - modifier: Modifier = Modifier, -) { - val configuration = LocalConfiguration.current - val isCompactPhone = !isTabletLayout && configuration.screenHeightDp <= 720 - val rowSpacing = if (isCompactPhone) 6.dp else 8.dp - val digitFontSize = if (isCompactPhone) 24.sp else 28.sp - - Column( - modifier = modifier, - verticalArrangement = Arrangement.spacedBy(rowSpacing), - ) { - val keyContainerColor = colorResource(R.color.amount_entry_key_background) - val keyContentColor = colorResource(R.color.amount_entry_key_text) - val clearLabel = stringResource(R.string.amount_entry_clear) - val clearTextSize = when { - clearLabel.length >= 14 -> if (isCompactPhone) 12.sp else 14.sp - clearLabel.length >= 10 -> if (isCompactPhone) 14.sp else 16.sp - else -> if (isCompactPhone) 16.sp else 20.sp - } - - listOf( - listOf("1", "2", "3"), - listOf("4", "5", "6"), - listOf("7", "8", "9"), - ).forEach { row -> - Row( - modifier = Modifier - .fillMaxWidth() - .weight(1f), - horizontalArrangement = Arrangement.spacedBy(rowSpacing), - ) { - row.forEach { key -> - KeyButton( - text = key, - modifier = Modifier.weight(1f), - containerColor = keyContainerColor, - contentColor = keyContentColor, - fontSize = digitFontSize, - onClick = { onDigitPressed(key.first()) }, - ) - } - } - } - - Row( - modifier = Modifier - .fillMaxWidth() - .weight(1f), - horizontalArrangement = Arrangement.spacedBy(rowSpacing), - ) { - KeyButton( - text = clearLabel, - modifier = Modifier.weight(1f), - containerColor = keyContainerColor, - contentColor = keyContentColor, - fontSize = clearTextSize, - onClick = onClearPressed, - ) - KeyButton( - text = "0", - modifier = Modifier.weight(1f), - containerColor = keyContainerColor, - contentColor = keyContentColor, - onClick = { onDigitPressed('0') }, - ) - Button( - modifier = Modifier - .weight(1f) - .fillMaxSize(), - onClick = onBackspacePressed, - colors = ButtonDefaults.buttonColors( - containerColor = keyContainerColor, - contentColor = keyContentColor, - ), - contentPadding = PaddingValues(0.dp), - ) { - Icon( - painter = painterResource(R.drawable.ic_backspace), - contentDescription = stringResource(R.string.amount_entry_backspace), - tint = keyContentColor, - ) - } - } - - Button( - modifier = Modifier - .fillMaxWidth() - .height(56.dp), - onClick = onChargePressed, - enabled = chargeEnabled, - colors = ButtonDefaults.buttonColors( - containerColor = colorResource(R.color.colorPrimary), - contentColor = colorResource(R.color.colorOnPrimary), - disabledContainerColor = colorResource(R.color.colorSecondary).copy(alpha = 0.12f), - ), - ) { - Text( - text = stringResource(R.string.amount_entry_create_order_charge), - fontWeight = FontWeight.SemiBold, - ) - } - } -} - -@Composable -private fun KeyButton( - text: String, - modifier: Modifier, - containerColor: Color, - contentColor: Color, - fontSize: TextUnit? = null, - onClick: () -> Unit, -) { - Button( - modifier = modifier.fillMaxSize(), - onClick = onClick, - colors = ButtonDefaults.buttonColors( - containerColor = containerColor, - contentColor = contentColor, - ), - contentPadding = PaddingValues(horizontal = 2.dp, vertical = 0.dp), - ) { - Text( - text = text, - textAlign = TextAlign.Center, - maxLines = 1, - softWrap = false, - overflow = TextOverflow.Ellipsis, - style = fontSize?.let { - MaterialTheme.typography.headlineMedium.copy( - fontSize = it, - fontWeight = FontWeight.SemiBold, - ) - } ?: MaterialTheme.typography.headlineMedium.copy( - fontWeight = FontWeight.SemiBold, - ), - ) - } -} - @OptIn(ExperimentalMaterial3Api::class) @Composable internal fun AmountEntryScreenContent( @@ -551,7 +639,8 @@ internal fun AmountEntryScreenContent( selectedCurrency: String, currencyOptions: List<String>, chargeEnabled: Boolean, -) { +) +{ AmountEntryScreen( amountText = amountText, selectedCurrency = selectedCurrency, diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/checkout/Checkout.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/checkout/Checkout.kt @@ -0,0 +1,221 @@ +/* + * 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.checkout + +import net.taler.common.Amount +import net.taler.common.ExternalPayment +import net.taler.common.Order +import net.taler.common.RelativeTime +import net.taler.merchantlib.MinimalInventoryProduct +import net.taler.merchantlib.PostOrderRequest + +import kotlinx.serialization.Serializable + +const val METHOD_CASH = "cash" + +/** + * Collecting accepts external payments; AwaitingTaler waits for the wallet. + * Completed is settled. Abandoned retains payments that still need reconciliation. + */ +@Serializable +enum class CheckoutStage +{ + Collecting, + AwaitingTaler, + Completed, + Abandoned, +} + +/** + * Confirmed external payment. The ID also identifies its backend payment entry. + * For cash, amount excludes change; tendered and change are stored only when needed. + */ +@Serializable +data class RecordedPayment( + val id: String, + val method: String, + val amount: Amount, + val tendered: Amount? = null, + val change: Amount? = null, + val transactionCode: String? = null, + val recordedAt: Long, +) + +/** Refund already issued for an external payment. */ +@Serializable +data class RecordedRefund( + val id: String, + val paymentId: String, + val amount: Amount, + val reason: String, + val recordedAt: Long, +) + +/** Provider charge awaiting a confirmed outcome; retained for recovery after a restart. */ +@Serializable +data class PendingCharge( + val reference: String, + val method: String, + val amount: Amount, + val startedAt: Long, +) + +/** + * Checkout state for the full sale, including external payments and refunds. + * Updates return copies; CheckoutManager persists them and handles backend requests. + * + * The backend order requests only the remaining Taler amount. Its ID stays stable + * within each attempt so creation can be retried. Refunds do not reopen the sale. + */ +@Serializable +data class Checkout( + val id: String, + val createdAt: Long, + val session: String, + val posOrderId: Int, + val contractTerms: Order, + val inventoryProducts: List<MinimalInventoryProduct>? = null, + val payments: List<RecordedPayment> = emptyList(), + val refunds: List<RecordedRefund> = emptyList(), + val stage: CheckoutStage = CheckoutStage.Collecting, + val backendOrderId: String? = null, + val attempt: Int = 0, + val merchantId: String? = null, + val creationPending: Boolean = false, + val pendingCharge: PendingCharge? = null, +) +{ + val currency: String get() = contractTerms.amount.currency + + val total: Amount get() = contractTerms.amount + + val paid: Amount get() = payments.fold(Amount.zero(currency)) { sum, payment -> sum + payment.amount } + + val refunded: Amount + get() = refunds.fold(Amount.zero(currency)) { sum, refund -> sum + refund.amount } + + val open: Amount get() = total - paid + + val isFullyPaidExternally: Boolean get() = open.isZero() + + val backendOrderIdToCreate: String + get() = if (attempt == 0) "pos-$id" else "pos-$id-$attempt" + + val canTakeExternalPayments: Boolean + get() = stage == CheckoutStage.Collecting && !creationPending && pendingCharge == null && !open.isZero() + + fun refundableOf(payment: RecordedPayment): Amount = + payment.amount - + refunds + .filter { it.paymentId == payment.id } + .fold(Amount.zero(currency)) { sum, refund -> sum + refund.amount } + + fun withRefund( + refundId: String, + paymentId: String, + amount: Amount, + reason: String, + now: Long, + ): Checkout + { + val payment = payments.firstOrNull { it.id == paymentId } + requireNotNull(payment) { "Unknown payment" } + require(amount.currency == currency) { "Refund currency does not match the order" } + require(amount > Amount.zero(currency)) { "A refund must be positive" } + require(amount <= refundableOf(payment)) { "More than is left of this payment" } + return copy( + refunds = refunds + RecordedRefund(refundId, paymentId, amount, reason, now), + ) + } + + fun withCash( + tendered: Amount, + paymentId: String, + now: Long, + ): CashOutcome + { + requirePayable(tendered) + val applied = if (tendered > open) open else tendered + val change = tendered - applied + val payment = + RecordedPayment( + id = paymentId, + method = METHOD_CASH, + amount = applied, + tendered = tendered.takeIf { it != applied }, + change = change.takeUnless { it.isZero() }, + recordedAt = now, + ) + return CashOutcome(copy(payments = payments + payment), applied, change) + } + + fun withExternalPayment( + method: String, + amount: Amount, + paymentId: String, + transactionCode: String?, + now: Long, + ): Checkout + { + requirePayable(amount) + require(amount <= open) { "An external payment cannot exceed the open amount" } + require(method != "taler") { "Taler is paid through the backend order, not recorded" } + val payment = + RecordedPayment( + id = paymentId, + method = method, + amount = amount, + transactionCode = transactionCode, + recordedAt = now, + ) + return copy(payments = payments + payment) + } + + private fun requirePayable(amount: Amount) + { + check(canTakeExternalPayments) { "No further external payments can be taken" } + require(amount.currency == currency) { "Payment currency does not match the order" } + require(amount > Amount.zero(currency)) { "A payment must be positive" } + } + + fun toOrderRequest(refundDelay: RelativeTime?): PostOrderRequest = + PostOrderRequest( + contractTerms = + contractTerms.copy( + orderId = backendOrderIdToCreate, + amount = open, + amountExternal = + payments + .map { payment -> + ExternalPayment( + method = payment.method, + id = payment.id, + amount = payment.amount, + transactionCode = payment.transactionCode, + ) + }.ifEmpty { null }, + ), + refundDelay = refundDelay, + inventoryProducts = inventoryProducts, + ) +} + +data class CashOutcome( + val checkout: Checkout, + val applied: Amount, + val change: Amount, +) diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/checkout/CheckoutAmountEntry.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/checkout/CheckoutAmountEntry.kt @@ -0,0 +1,616 @@ +/* + * 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.checkout + +import androidx.compose.animation.Crossfade +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +import net.taler.common.Amount +import net.taler.common.CurrencySpecification +import net.taler.merchantpos.R +import net.taler.merchantpos.compose.PaymentMethodTile + +import java.math.RoundingMode + +/** The caller must persist payment state. */ +@Composable +internal fun CheckoutAmountEntry( + title: String, + remainingLabel: String, + amountLabel: String, + amount: String, + remainingAmount: Amount, + spec: CurrencySpecification?, + methodLabel: String, + methodIcon: ImageVector, + methodInstruction: String, + afterPaymentLabel: String, + helper: String, + helperIsError: Boolean = false, + submitLabel: String, + submitEnabled: Boolean, + onDigit: (Char) -> Unit, + onClear: () -> Unit, + onBackspace: () -> Unit, + onFractionAmount: (Int) -> Unit, + onSubmit: () -> Unit, + onDismiss: () -> Unit, +) +{ + Column(Modifier.fillMaxSize()) { + Row( + modifier = Modifier.fillMaxWidth().heightIn(min = 64.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton( + onClick = onDismiss, + modifier = Modifier.size(width = 56.dp, height = 56.dp), + ) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.navigation_back), + ) + } + Text( + title, + style = MaterialTheme.typography.titleLarge.copy(fontSize = 20.sp), + fontWeight = FontWeight.SemiBold, + ) + } + Surface( + modifier = Modifier.fillMaxWidth().height(1.dp), + color = MaterialTheme.colorScheme.outlineVariant, + ) {} + + BoxWithConstraints(Modifier.fillMaxSize()) { + val isLandscape = maxWidth > maxHeight + val isTablet = LocalConfiguration.current.smallestScreenWidthDp >= 600 + when + { + isTablet && isLandscape -> + { + val contentWidth = maxWidth.coerceAtMost(1120.dp) + val dividerX = (contentWidth - 64.dp) * 0.598f + 31.5.dp + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) { + Box(Modifier.width(contentWidth).fillMaxHeight()) { + Surface( + modifier = Modifier.offset(x = dividerX).width(1.dp).fillMaxHeight(), + color = MaterialTheme.colorScheme.outlineVariant, + ) {} + } + Row( + modifier = + Modifier + .widthIn(max = 1120.dp) + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(top = 40.dp, bottom = 24.dp), + horizontalArrangement = Arrangement.spacedBy(64.dp), + verticalAlignment = Alignment.Top, + ) { + Column(Modifier.weight(0.598f)) { + PaymentMethodTile( + icon = methodIcon, + title = methodLabel, + detail = methodInstruction, + ) + Spacer(Modifier.height(16.dp)) + CheckoutNumberPad( + modifier = Modifier.fillMaxWidth(), + keyHeight = 80.dp, + keyGap = 12.dp, + actionGap = 20.dp, + submitLabel = submitLabel, + enabled = submitEnabled, + onDigit = onDigit, + onClear = onClear, + onBackspace = onBackspace, + onSubmit = onSubmit, + ) + } + Column(Modifier.weight(0.402f)) { + AmountSummary( + remainingLabel, + amountLabel, + amount, + remainingAmount, + spec, + afterPaymentLabel, + helper = helper, + helperIsError = helperIsError, + landscape = true, + onFractionAmount = onFractionAmount, + ) + } + } + } + } + + isTablet -> + { + Column( + modifier = + Modifier + .widthIn(max = 640.dp) + .fillMaxWidth() + .align(Alignment.TopCenter) + .verticalScroll(rememberScrollState()) + .padding(top = 40.dp, bottom = 24.dp), + ) { + AmountSummary( + remainingLabel, + amountLabel, + amount, + remainingAmount, + spec, + afterPaymentLabel, + helper = null, + helperIsError = false, + landscape = false, + onFractionAmount = onFractionAmount, + ) + Spacer(Modifier.height(50.dp)) + CheckoutNumberPad( + modifier = Modifier.fillMaxWidth(), + keyHeight = 80.dp, + keyGap = 12.dp, + actionGap = 20.dp, + submitLabel = submitLabel, + enabled = submitEnabled, + onDigit = onDigit, + onClear = onClear, + onBackspace = onBackspace, + onSubmit = onSubmit, + ) + Spacer(Modifier.height(24.dp)) + Text( + helper, + style = MaterialTheme.typography.bodySmall, + color = + if (helperIsError) + { + MaterialTheme.colorScheme.error + } + else + { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } + + isLandscape -> + { + Row( + modifier = Modifier.fillMaxSize().padding(horizontal = 24.dp, vertical = 20.dp), + horizontalArrangement = Arrangement.spacedBy(36.dp), + verticalAlignment = Alignment.Top, + ) { + CheckoutNumberPad( + modifier = Modifier.weight(0.59f), + keyHeight = 48.dp, + keyGap = 8.dp, + actionGap = 16.dp, + submitLabel = submitLabel, + enabled = submitEnabled, + onDigit = onDigit, + onClear = onClear, + onBackspace = onBackspace, + onSubmit = onSubmit, + ) + Column(Modifier.weight(0.41f).fillMaxHeight().verticalScroll(rememberScrollState())) { + AmountSummary( + remainingLabel, + amountLabel, + amount, + remainingAmount, + spec, + afterPaymentLabel, + helper = helper, + helperIsError = helperIsError, + landscape = true, + onFractionAmount = onFractionAmount, + ) + } + } + } + + else -> + { + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp, vertical = 20.dp), + ) { + AmountSummary( + remainingLabel, + amountLabel, + amount, + remainingAmount, + spec, + afterPaymentLabel, + helper = null, + helperIsError = false, + landscape = false, + onFractionAmount = onFractionAmount, + ) + Spacer(Modifier.height(36.dp)) + CheckoutNumberPad( + modifier = Modifier.fillMaxWidth(), + keyHeight = 60.dp, + keyGap = 12.dp, + actionGap = 20.dp, + submitLabel = submitLabel, + enabled = submitEnabled, + onDigit = onDigit, + onClear = onClear, + onBackspace = onBackspace, + onSubmit = onSubmit, + ) + Spacer(Modifier.height(24.dp)) + Text( + helper, + style = MaterialTheme.typography.bodySmall, + color = + if (helperIsError) + { + MaterialTheme.colorScheme.error + } + else + { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } + } + } + } +} + +@Composable +private fun AmountSummary( + remainingLabel: String, + amountLabel: String, + amount: String, + remainingAmount: Amount, + spec: CurrencySpecification?, + afterPaymentLabel: String, + helper: String?, + helperIsError: Boolean, + landscape: Boolean, + onFractionAmount: (Int) -> Unit, +) +{ + val amountScroll = rememberScrollState() + LaunchedEffect(amount) { amountScroll.scrollTo(amountScroll.maxValue) } + Text( + remainingLabel, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(12.dp)) + Text(amountLabel, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.height(5.dp)) + Surface( + modifier = + Modifier.fillMaxWidth().heightIn( + min = + if (landscape && + LocalConfiguration.current.smallestScreenWidthDp < 600) + { + 66.dp + } + else + { + 80.dp + }, + ), + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary), + ) { + Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterStart) { + Row( + modifier = Modifier.horizontalScroll(amountScroll).padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + amount, + style = + MaterialTheme.typography.headlineLarge.copy( + fontSize = + if (landscape && + LocalConfiguration.current.smallestScreenWidthDp < 600) + { + 32.sp + } + else + { + 36.sp + }, + ), + fontWeight = FontWeight.SemiBold, + maxLines = 1, + softWrap = false, + ) + } + } + } + Spacer(Modifier.height(12.dp)) + val shortcutRows = + if (LocalDensity.current.fontScale > 1.3f) + { + listOf(listOf(2), listOf(3), listOf(4), listOf(1)) + } + else + { + listOf(listOf(2, 3), listOf(4, 1)) + } + shortcutRows.forEach { row -> + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + row.forEach { denominator -> + val shortcut = remainingAmount.fractionAmount(denominator, spec) + val amountText = shortcut.shown(spec) + val accessibilityText = + stringResource( + when (denominator) + { + 2 -> R.string.checkout_fraction_half_description + 3 -> R.string.checkout_fraction_third_description + 4 -> R.string.checkout_fraction_quarter_description + else -> R.string.checkout_fraction_remaining_description + }, + amountText, + ) + OutlinedButton( + onClick = { onFractionAmount(denominator) }, + enabled = !shortcut.isZero(), + modifier = + Modifier + .weight(1f) + .heightIn(min = if (landscape) 48.dp else 44.dp) + .semantics { contentDescription = accessibilityText }, + colors = + ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.primary, + ), + contentPadding = + PaddingValues( + horizontal = 8.dp, + vertical = 5.dp, + ), + ) { + val visibleLabel = + if (denominator == 1) + { + stringResource(R.string.checkout_fraction_remaining) + } + else + { + "1/$denominator" + } + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + visibleLabel, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.labelMedium, + ) + Text( + amountText, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.labelSmall, + ) + } + } + } + } + Spacer(Modifier.height(8.dp)) + } + Spacer(Modifier.height(8.dp)) + Crossfade(targetState = afterPaymentLabel, label = "after-payment-summary") { label -> + Text( + label, + modifier = Modifier.animateContentSize().heightIn(min = 44.dp), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + helper?.let { + Spacer(Modifier.height(16.dp)) + Text( + it, + style = MaterialTheme.typography.bodySmall, + color = if (helperIsError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +/** Returns a positive remaining-balance fraction rounded down to the currency's input precision. */ +internal fun Amount.fractionAmount( + denominator: Int, + spec: CurrencySpecification?, +): Amount +{ + require(denominator > 0) + if (denominator == 1) return withSpec(spec) + val decimals = (spec?.numFractionalInputDigits ?: Amount.DEFAULT_INPUT_DECIMALS).coerceIn(0, 8) + val fraction = + amountStr + .toBigDecimal() + .divide(denominator.toBigDecimal(), decimals, RoundingMode.DOWN) + return Amount.fromString(currency, fraction.stripTrailingZeros().toPlainString()).withSpec(spec) +} + +@Composable +private fun CheckoutNumberPad( + modifier: Modifier, + keyHeight: Dp, + keyGap: Dp, + actionGap: Dp, + submitLabel: String, + enabled: Boolean, + onDigit: (Char) -> Unit, + onClear: () -> Unit, + onBackspace: () -> Unit, + onSubmit: () -> Unit, +) +{ + val keyRows = listOf(listOf("1", "2", "3"), listOf("4", "5", "6"), listOf("7", "8", "9")) + Column(modifier) { + keyRows.forEachIndexed { index, row -> + if (index > 0) Spacer(Modifier.height(keyGap)) + Row(Modifier.fillMaxWidth().height(keyHeight), horizontalArrangement = Arrangement.spacedBy(keyGap)) { + row.forEach { key -> CheckoutKey(key, Modifier.weight(1f), keyHeight, keyGap) { onDigit(key.first()) } } + } + } + Spacer(Modifier.height(keyGap)) + Row(Modifier.fillMaxWidth().height(keyHeight), horizontalArrangement = Arrangement.spacedBy(keyGap)) { + CheckoutKey(stringResource(R.string.amount_entry_clear), Modifier.weight(1f), keyHeight, keyGap, onClear) + CheckoutKey("0", Modifier.weight(1f), keyHeight, keyGap) { onDigit('0') } + Button( + onClick = onBackspace, + modifier = Modifier.weight(1f).height(keyHeight), + shape = RoundedCornerShape(12.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + contentPadding = + PaddingValues(0.dp), + ) { + Icon( + painterResource(R.drawable.ic_backspace), + contentDescription = stringResource(R.string.amount_entry_backspace), + ) + } + } + Spacer(Modifier.height(actionGap)) + Button( + onClick = onSubmit, + enabled = enabled, + modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp), + shape = RoundedCornerShape(24.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant, + disabledContentColor = MaterialTheme.colorScheme.onSurfaceVariant, + ), + contentPadding = + PaddingValues(horizontal = 12.dp, vertical = 8.dp), + ) { + Text( + submitLabel, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + fontWeight = FontWeight.SemiBold, + ) + } + } +} + +@Composable +private fun CheckoutKey( + label: String, + modifier: Modifier, + keyHeight: Dp, + keyGap: Dp, + onClick: () -> Unit, +) +{ + Button( + onClick = onClick, + modifier = modifier.height(keyHeight), + shape = RoundedCornerShape(12.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + contentPadding = + PaddingValues(horizontal = keyGap, vertical = 0.dp), + ) { + Text( + label, + textAlign = TextAlign.Center, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Ellipsis, + fontSize = if (label.length > 2) 14.sp else 24.sp, + fontWeight = FontWeight.SemiBold, + ) + } +} diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/checkout/CheckoutBackend.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/checkout/CheckoutBackend.kt @@ -0,0 +1,118 @@ +/* + * 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.checkout + +import net.taler.merchantlib.CheckPaymentResponse +import net.taler.merchantlib.ExternalRefundRequest +import net.taler.merchantlib.MerchantApi +import net.taler.merchantlib.MerchantConfig +import net.taler.merchantlib.PostOrderRequest + +sealed interface BackendResult<out T> +{ + data class Success<T>( + val value: T, + ) : BackendResult<T> + + data class Failure( + val message: String, + ) : BackendResult<Nothing> +} + +interface CheckoutBackend +{ + /** Creates the order and returns its backend ID. */ + suspend fun createOrder(request: PostOrderRequest): BackendResult<String> + + /** True when paid, false when it exists unpaid, null when unknown or unreachable. */ + suspend fun isPaid(orderId: String): Boolean? + + /** Completes an order paid entirely outside Taler, without a wallet payment. */ + suspend fun collect(orderId: String): BackendResult<Unit> + + /** Records money given back outside Taler on the order. */ + suspend fun recordExternalRefund( + orderId: String, + request: ExternalRefundRequest, + ): BackendResult<Unit> + + /** Deletes the order; [force] is required when it already records external payments. */ + suspend fun deleteOrder( + orderId: String, + force: Boolean = false, + ): BackendResult<Unit> +} + +internal class MerchantCheckoutBackend( + private val api: MerchantApi, + private val config: MerchantConfig, +) : CheckoutBackend +{ + override suspend fun createOrder(request: PostOrderRequest): BackendResult<String> + { + var result: BackendResult<String> = noResponse + api.postOrder(config, request).handle({ result = BackendResult.Failure(it) }) { + result = BackendResult.Success(it.orderId) + } + return result + } + + override suspend fun isPaid(orderId: String): Boolean? + { + var paid: Boolean? = null + api.checkOrder(config, orderId).handle(null) { paid = it is CheckPaymentResponse.Paid } + return paid + } + + override suspend fun collect(orderId: String): BackendResult<Unit> + { + var result: BackendResult<Unit> = noResponse + api.collectOrder(config, orderId).handle({ result = BackendResult.Failure(it) }) { + result = BackendResult.Success(Unit) + } + return result + } + + override suspend fun recordExternalRefund( + orderId: String, + request: ExternalRefundRequest, + ): BackendResult<Unit> + { + var result: BackendResult<Unit> = noResponse + api.refundExternal(config, orderId, request).handle({ result = BackendResult.Failure(it) }) { + result = BackendResult.Success(Unit) + } + return result + } + + override suspend fun deleteOrder( + orderId: String, + force: Boolean, + ): BackendResult<Unit> + { + var result: BackendResult<Unit> = noResponse + api.deleteOrder(config, orderId, force = force).handle({ result = BackendResult.Failure(it) }) { + result = BackendResult.Success(Unit) + } + return result + } + + private companion object + { + val noResponse = BackendResult.Failure("No response from the merchant backend") + } +} diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/checkout/CheckoutFragment.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/checkout/CheckoutFragment.kt @@ -0,0 +1,1065 @@ +/* + * 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.checkout + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.Toast + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels + +import net.taler.common.Amount +import net.taler.common.CurrencySpecification +import net.taler.merchantpos.MainActivity +import net.taler.merchantpos.MainViewModel +import net.taler.merchantpos.PosDestination +import net.taler.merchantpos.R +import net.taler.merchantpos.compose.PaymentMethodTile +import net.taler.merchantpos.compose.PaymentTileStyle +import net.taler.merchantpos.compose.PosTheme +import net.taler.merchantpos.compose.TalerPaymentTile +import net.taler.merchantpos.paymentoptions.CashOption +import net.taler.merchantpos.paymentoptions.ChargeResult +import net.taler.merchantpos.paymentoptions.PaymentOption +import net.taler.merchantpos.paymentoptions.PaymentOptionState +import net.taler.merchantpos.paymentoptions.PaymentOptionsSummary +import net.taler.merchantpos.paymentoptions.TALER_OPTION_ID +import net.taler.merchantpos.paymentoptions.statusLabel +import net.taler.merchantpos.showPosError + +import kotlinx.coroutines.launch + +/** + * Takes a sale's payments in order: cash, card and other external methods first, then + * GNU Taler for whatever is still open (DD 96). + */ +class CheckoutFragment : Fragment() +{ + private val model: MainViewModel by activityViewModels() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ) = ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + val checkoutManager = model.checkoutManager + val paymentOptions = model.paymentOptions + setContent { + val checkouts by checkoutManager.checkouts.observeAsState(emptyList()) + val currentId by checkoutManager.currentId.observeAsState() + val summary by paymentOptions.summary.observeAsState(PaymentOptionsSummary()) + val chargeEvent by checkoutManager.chargeEvent.observeAsState() + val scope = rememberCoroutineScope() + var busy by remember { mutableStateOf(false) } + val spec = model.configManager.currencySpec + val checkout = currentId?.let { id -> checkouts.firstOrNull { it.id == id } } + + PosTheme { + if (checkout == null) + { + NoCheckout(onBack = { activity().navigateBack() }) + return@PosTheme + } + CheckoutScreen( + checkout = checkout, + spec = spec, + options = paymentOptions.options, + optionStates = summary.options, + busy = busy, + onRecordCash = { tendered -> + runCatching { checkoutManager.recordCash(tendered) } + .onFailure { + requireActivity().showPosError( + getString(R.string.checkout_record_failed), + it.message.orEmpty(), + ) + }.getOrNull() + }, + onCharge = { option, amount -> + checkoutManager.chargeWith(option, amount, checkout.contractTerms.summary) + }, + onResolvePending = { reference, paid, code -> + checkoutManager.resolvePendingCharge(reference, paid, code) + }, + onFinishWithTaler = { + busy = true + scope.launch { + when (val result = checkoutManager.finishWithTaler()) + { + is CheckoutResult.AwaitingWallet -> + { + model.paymentManager.showCheckoutOrder(result.checkout) + activity().navigateTo(PosDestination.ProcessPayment) + } + + is CheckoutResult.Failed -> + { + requireActivity().showPosError( + getString(R.string.error_order_creation), + result.message, + ) + } + + CheckoutResult.Completed -> + { + Unit + } + } + busy = false + } + }, + onAbandon = { + busy = true + scope.launch { + checkoutManager.abandon() + busy = false + activity().navigateBack() + } + }, + ) + + chargeEvent?.let { event -> + val amount = event.amount.withSpec(spec).toString() + when (val result = event.result) + { + is ChargeResult.Success -> + { + LaunchedEffect(event) { + Toast + .makeText( + requireContext(), + getString(R.string.checkout_card_recorded, amount), + Toast.LENGTH_SHORT, + ).show() + checkoutManager.consumeChargeEvent() + } + } + + ChargeResult.Cancelled -> + { + LaunchedEffect(event) { + checkoutManager.consumeChargeEvent() + } + } + + is ChargeResult.Failed -> + { + AlertDialog( + onDismissRequest = checkoutManager::consumeChargeEvent, + title = { Text(stringResource(R.string.checkout_card_title)) }, + text = { + Text( + listOfNotNull( + stringResource(R.string.checkout_card_failed), + result.message, + ).joinToString("\n\n"), + ) + }, + confirmButton = { + TextButton(onClick = checkoutManager::consumeChargeEvent) { + Text(stringResource(android.R.string.ok)) + } + }, + ) + } + + is ChargeResult.Unknown -> + { + AlertDialog( + onDismissRequest = {}, + title = { Text(stringResource(R.string.checkout_card_unknown_title)) }, + text = { Text(stringResource(R.string.checkout_card_unknown, amount)) }, + confirmButton = { + Button(onClick = checkoutManager::consumeChargeEvent) { + Text(stringResource(android.R.string.ok)) + } + }, + ) + } + } + } + } + } + } + + private fun activity() = requireActivity() as MainActivity +} + +@Composable +private fun CheckoutScreen( + checkout: Checkout, + spec: CurrencySpecification?, + options: List<PaymentOption>, + optionStates: List<PaymentOptionState>, + busy: Boolean, + onRecordCash: (Amount) -> CashOutcome?, + onCharge: (PaymentOption, Amount) -> Boolean, + onFinishWithTaler: () -> Unit, + onAbandon: () -> Unit, + onResolvePending: (String, Boolean, String?) -> Unit = { _, _, _ -> }, + initialCashOpen: Boolean = false, + initialCardOptionId: String? = null, + initialShowPayments: Boolean = false, +) +{ + var cashOpen by rememberSaveable { mutableStateOf(initialCashOpen) } + var cardOptionId by rememberSaveable { mutableStateOf(initialCardOptionId) } + val cardOption = options.firstOrNull { it.id == cardOptionId } + var showPayments by rememberSaveable { mutableStateOf(initialShowPayments) } + var changeDue by rememberSaveable { mutableStateOf<String?>(null) } + var showAbout by rememberSaveable { mutableStateOf(false) } + var cardNotReady by rememberSaveable { mutableStateOf(false) } + + fun stateOf(option: PaymentOption) = optionStates.firstOrNull { it.id == option.id } + val external = + options.filter { option -> + option.id != TALER_OPTION_ID && stateOf(option)?.enabled == true && + (option.id == CashOption.id || option.chargesExternally) + } + val taler = options.firstOrNull { it.id == TALER_OPTION_ID && stateOf(it)?.enabled == true } + val open = checkout.open + val canTake = !busy && checkout.canTakeExternalPayments + val anythingPaid = !checkout.paid.isZero() + val fullyPaid = open.isZero() + + BoxWithConstraints(Modifier.fillMaxSize().padding(16.dp)) { + // Use the available window, including split-screen and enlarged display settings. + val compact = maxHeight < 600.dp || maxWidth < 600.dp + val landscape = maxWidth > maxHeight + val tablet = LocalConfiguration.current.smallestScreenWidthDp >= 600 + val split = landscape || tablet + val phoneLandscape = landscape && !tablet + val paidProgress = + if (checkout.total.value == 0L && + checkout.total.fraction == 0) + { + 0f + } + else + { + val totalValue = checkout.total.value + checkout.total.fraction / 100_000_000.0 + val paidValue = checkout.paid.value + checkout.paid.fraction / 100_000_000.0 + (paidValue / totalValue).toFloat().coerceIn(0f, 1f) + } + val summary: @Composable () -> Unit = { + if (phoneLandscape) + { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + ) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + stringResource( + if (anythingPaid) + { + R.string.checkout_remaining_heading + } + else + { + R.string.checkout_order_total_heading + }, + ), + style = MaterialTheme.typography.titleSmall, + ) + Text( + if (anythingPaid) open.shown(spec) else checkout.total.shown(spec), + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = + if (anythingPaid) + { + MaterialTheme.colorScheme.primary + } + else + { + MaterialTheme.colorScheme.onSurface + }, + ) + Text( + checkout.contractTerms.summary, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + ) + Text( + stringResource(R.string.checkout_total_amount, checkout.total.shown(spec)), + style = MaterialTheme.typography.bodySmall, + ) + if (anythingPaid) + { + Text( + stringResource(R.string.checkout_paid_amount, checkout.paid.shown(spec)), + style = MaterialTheme.typography.bodySmall, + ) + } + OutlinedButton( + onClick = { showPayments = true }, + modifier = Modifier.fillMaxWidth().heightIn(min = 40.dp), + enabled = checkout.payments.isNotEmpty(), + ) { + Text( + stringResource(R.string.checkout_payments_button, checkout.payments.size), + style = MaterialTheme.typography.labelLarge, + ) + } + } + } + } + else + { + Column(verticalArrangement = Arrangement.spacedBy(20.dp)) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + ) { + Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + stringResource(R.string.checkout_order_total_heading), + style = MaterialTheme.typography.titleMedium, + ) + Text( + checkout.total.shown(spec), + style = + if (compact) + { + MaterialTheme.typography.headlineMedium + } + else + { + MaterialTheme.typography.headlineLarge + }, + fontWeight = FontWeight.Bold, + ) + if (checkout.posOrderId >= 0) + { + Text( + stringResource(R.string.checkout_order_reference, checkout.posOrderId), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + checkout.contractTerms.summary, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Box( + Modifier + .fillMaxWidth() + .height(6.dp) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant), + ) { + Box( + Modifier + .fillMaxWidth(paidProgress) + .fillMaxHeight() + .background(MaterialTheme.colorScheme.primary), + ) + } + Text( + stringResource(R.string.checkout_paid_amount, checkout.paid.shown(spec)), + style = MaterialTheme.typography.bodyMedium, + ) + Text( + stringResource(R.string.checkout_remaining_amount, open.shown(spec)), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + ) + } + } + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text( + stringResource(R.string.checkout_payments_heading), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.weight(1f), + ) + Text( + stringResource(R.string.checkout_payments_added, checkout.payments.size), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (checkout.payments.isEmpty()) + { + Text( + stringResource(R.string.checkout_payments_empty_inline), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + else + { + checkout.payments.forEach { PaymentLine(it, spec, options) } + } + } + } + } + val methods: @Composable (Modifier) -> Unit = { tileModifier -> + Column( + tileModifier, + verticalArrangement = Arrangement.spacedBy(if (phoneLandscape) 8.dp else 12.dp), + horizontalAlignment = Alignment.Start, + ) { + if (!phoneLandscape) + { + Text( + stringResource(R.string.checkout_choose_payment_method), + style = MaterialTheme.typography.titleLarge, + ) + Text( + stringResource(R.string.checkout_payment_method_helper), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + checkout.pendingCharge?.let { pending -> + PendingChargeRecovery(pending, spec, onResolvePending) + } + if (checkout.creationPending) + { + Text(stringResource(R.string.checkout_creation_pending)) + } + external.forEach { option -> + val name = stringResource(option.nameRes) + if (option.id == CashOption.id) + { + PaymentMethodTile( + icon = option.icon, + style = PaymentTileStyle.Surface, + title = name, + detail = + if (fullyPaid) + { + stringResource(R.string.checkout_fully_paid) + } + else + { + stringResource(R.string.checkout_tap_to_enter_amount) + }, + enabled = canTake, + modifier = tileModifier, + ) { + cashOpen = true + } + } + else + { + val state = stateOf(option) + val usable = state != null && option.canCharge(state) + PaymentMethodTile( + icon = option.icon, + style = PaymentTileStyle.Surface, + // Keep the provider's connection status visible until it is ready. + detail = + if (fullyPaid) + { + stringResource(R.string.checkout_fully_paid) + } + else if (usable) + { + stringResource(R.string.checkout_tap_to_enter_amount) + } + else + { + state?.let { statusLabel(it.status) } + }, + title = name, + enabled = canTake && usable, + modifier = tileModifier, + ) { + cardOptionId = option.id + } + } + } + taler?.let { + TalerPaymentTile( + title = + if (fullyPaid) + { + stringResource(R.string.checkout_complete_payment) + } + else if (anythingPaid) + { + stringResource(R.string.checkout_charge_remaining_taler) + } + else + { + stringResource(R.string.checkout_charge_with_taler) + }, + amount = open.takeUnless { fullyPaid }?.shown(spec), + detail = + if (fullyPaid) + { + stringResource(R.string.checkout_collect_receipt_hint) + } + else + { + stringResource(R.string.checkout_complete_order_hint) + }, + enabled = !busy && checkout.pendingCharge == null, + modifier = Modifier.heightIn(min = 88.dp), + onClick = onFinishWithTaler, + ) + } + } + } + val hint: @Composable () -> Unit = { + Text( + text = + stringResource( + when + { + fullyPaid -> R.string.checkout_collect_receipt_hint + checkout.stage == CheckoutStage.AwaitingTaler -> R.string.checkout_order_created_hint + open.isZero() -> R.string.checkout_settled_hint + taler == null -> R.string.checkout_no_taler_hint + else -> R.string.checkout_scan_to_pay_hint + }, + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + val footer: @Composable () -> Unit = { + // Once money has been taken, cancelling is no longer a simple discard. + if (anythingPaid) + { + OutlinedButton( + onClick = { showAbout = true }, + enabled = !busy, + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = if (compact) 48.dp else 64.dp), + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.primary), + ) { + Text(stringResource(R.string.checkout_about_action), style = MaterialTheme.typography.titleMedium) + } + } + else + { + OutlinedButton( + onClick = onAbandon, + enabled = !busy, + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = if (compact) 48.dp else 64.dp), + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), + ) { + Text(stringResource(R.string.checkout_cancel), style = MaterialTheme.typography.titleMedium) + } + } + } + if (split) + { + val methodWeight = + if (tablet && + !landscape) + { + 0.5f + } + else + { + 0.6f + } + Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(24.dp)) { + Column( + Modifier.weight(methodWeight).fillMaxHeight().verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(if (phoneLandscape) 8.dp else 12.dp), + ) { + methods(Modifier.fillMaxWidth()) + if (!phoneLandscape) hint() + footer() + } + Box(Modifier.width(1.dp).fillMaxHeight().background(MaterialTheme.colorScheme.outlineVariant)) + Column( + Modifier.weight(1f - methodWeight).fillMaxHeight().verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(if (compact) 12.dp else 20.dp), + ) { + summary() + } + } + } + else + { + Column( + Modifier.fillMaxSize().verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + summary() + methods(Modifier.fillMaxWidth()) + hint() + footer() + } + } + if (busy) CircularProgressIndicator(Modifier.align(Alignment.Center)) + } + + if (cashOpen) + { + CashDialog( + open = open, + spec = spec, + methodLabel = stringResource(CashOption.nameRes), + methodIcon = CashOption.icon, + methodInstruction = stringResource(R.string.checkout_cash_entry_instruction), + onRecord = { tendered -> + val outcome = onRecordCash(tendered) + if (outcome != null) + { + cashOpen = false + if (!outcome.change.isZero()) changeDue = outcome.change.shown(spec) + } + }, + onDismiss = { cashOpen = false }, + ) + } + + cardOption?.let { option -> + CardDialog( + open = open, + spec = spec, + methodLabel = stringResource(option.nameRes), + methodIcon = option.icon, + methodInstruction = stringResource(R.string.checkout_card_entry_instruction), + onCharge = { amount -> + cardOptionId = null + if (!onCharge(option, amount)) cardNotReady = true + }, + onDismiss = { cardOptionId = null }, + ) + } + + changeDue?.let { change -> + AlertDialog( + onDismissRequest = {}, + title = { Text(stringResource(R.string.checkout_change_due_title)) }, + text = { + Text( + text = stringResource(R.string.checkout_change_due, change), + style = MaterialTheme.typography.headlineSmall, + ) + }, + confirmButton = { + Button(onClick = { changeDue = null }) { Text(stringResource(android.R.string.ok)) } + }, + ) + } + + if (cardNotReady) + { + AlertDialog( + onDismissRequest = { cardNotReady = false }, + title = { Text(stringResource(R.string.checkout_card_title)) }, + text = { Text(stringResource(R.string.checkout_card_not_ready)) }, + confirmButton = { + TextButton(onClick = { cardNotReady = false }) { Text(stringResource(android.R.string.ok)) } + }, + ) + } + + if (showPayments) + { + PaymentsDialog(checkout, spec, options, onDismiss = { showPayments = false }) + } + + if (showAbout) + { + AlertDialog( + onDismissRequest = { showAbout = false }, + title = { Text(stringResource(R.string.checkout_about)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text(stringResource(R.string.checkout_about_message, checkout.paid.shown(spec))) + checkout.payments.forEach { payment -> PaymentLine(payment, spec, options) } + } + }, + confirmButton = { + TextButton(onClick = { showAbout = false }) { + Text(stringResource(R.string.terminal_close)) + } + }, + dismissButton = { + TextButton( + onClick = { + showAbout = false + onAbandon() + }, + colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error), + ) { + Text(stringResource(R.string.checkout_cancel_anyway)) + } + }, + ) + } +} + +/** Renders the checkout with fixed data, for screenshots and previews. */ +@Composable +internal fun CheckoutScreenContent( + checkout: Checkout, + options: List<PaymentOption>, + optionStates: List<PaymentOptionState>, + spec: CurrencySpecification? = null, + initialCashOpen: Boolean = false, + initialCardOptionId: String? = null, + initialShowPayments: Boolean = false, +) +{ + CheckoutScreen( + checkout = checkout, + spec = spec, + options = options, + optionStates = optionStates, + busy = false, + onRecordCash = { null }, + onCharge = { _, _ -> false }, + onFinishWithTaler = {}, + onAbandon = {}, + initialCashOpen = initialCashOpen, + initialCardOptionId = initialCardOptionId, + initialShowPayments = initialShowPayments, + ) +} + +@Composable +private fun PaymentsDialog( + checkout: Checkout, + spec: CurrencySpecification?, + options: List<PaymentOption>, + onDismiss: () -> Unit, +) +{ + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.checkout_payments_title)) }, + text = { + LazyColumn( + modifier = Modifier.heightIn(max = 360.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(checkout.payments, key = { it.id }) { payment -> + PaymentLine(payment, spec, options) + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.terminal_close)) } + }, + ) +} + +@Composable +private fun CashDialog( + open: Amount, + spec: CurrencySpecification?, + methodLabel: String, + methodIcon: ImageVector, + methodInstruction: String, + onRecord: (Amount) -> Unit, + onDismiss: () -> Unit, +) +{ + val zero = Amount.zero(open.currency).withSpec(spec) + var entered by rememberSaveable(open.currency, stateSaver = amountStateSaver(open.currency, spec)) { + mutableStateOf(zero) + } + FullScreenDialog(onDismiss) { + val returnedChange = if (entered > open) entered - open else null + val afterPayment = + if (returnedChange != null) + { + stringResource(R.string.checkout_change_to_return_amount, returnedChange.shown(spec)) + } + else + { + val remaining = if (entered >= open) Amount.zero(open.currency).withSpec(spec) else open - entered + stringResource(R.string.checkout_remaining_after_payment, remaining.shown(spec)) + } + CheckoutAmountEntry( + title = stringResource(R.string.checkout_cash_received_heading), + remainingLabel = stringResource(R.string.checkout_remaining_to_pay, open.shown(spec)), + amountLabel = stringResource(R.string.checkout_cash_received_heading), + amount = entered.shown(spec), + remainingAmount = open, + spec = spec, + methodLabel = methodLabel, + methodIcon = methodIcon, + methodInstruction = methodInstruction, + afterPaymentLabel = afterPayment, + helper = stringResource(R.string.checkout_cash_change_explanation), + submitLabel = stringResource(R.string.checkout_record_cash_action, entered.shown(spec)), + submitEnabled = !entered.isZero(), + onDigit = { digit -> entered = entered.addInputDigit(digit)?.withSpec(spec) ?: entered }, + onClear = { entered = zero }, + onBackspace = { entered = entered.removeInputDigit()?.withSpec(spec) ?: entered }, + onFractionAmount = { denominator -> entered = open.fractionAmount(denominator, spec) }, + onSubmit = { onRecord(entered) }, + onDismiss = onDismiss, + ) + } +} + +@Composable +private fun CardDialog( + open: Amount, + spec: CurrencySpecification?, + methodLabel: String, + methodIcon: ImageVector, + methodInstruction: String, + onCharge: (Amount) -> Unit, + onDismiss: () -> Unit, +) +{ + val zero = Amount.zero(open.currency).withSpec(spec) + var entered by rememberSaveable(open.currency, stateSaver = amountStateSaver(open.currency, spec)) { + mutableStateOf(open.withSpec(spec)) + } + val tooMuch = entered > open + FullScreenDialog(onDismiss) { + val afterPayment = if (entered >= open) Amount.zero(open.currency).withSpec(spec) else open - entered + CheckoutAmountEntry( + title = stringResource(R.string.checkout_card_payment_heading), + remainingLabel = stringResource(R.string.checkout_remaining_to_pay, open.shown(spec)), + amountLabel = stringResource(R.string.checkout_card_amount), + amount = entered.shown(spec), + remainingAmount = open, + spec = spec, + methodLabel = methodLabel, + methodIcon = methodIcon, + methodInstruction = methodInstruction, + afterPaymentLabel = stringResource(R.string.checkout_remaining_after_payment, afterPayment.shown(spec)), + helper = + stringResource( + if (tooMuch) R.string.checkout_card_too_much else R.string.checkout_card_amount_limit_hint, + ), + helperIsError = tooMuch, + submitLabel = stringResource(R.string.checkout_charge_card_action, entered.shown(spec)), + submitEnabled = !entered.isZero() && !tooMuch, + onDigit = { digit -> entered = entered.addInputDigit(digit)?.withSpec(spec) ?: entered }, + onClear = { entered = zero }, + onBackspace = { entered = entered.removeInputDigit()?.withSpec(spec) ?: entered }, + onFractionAmount = { denominator -> entered = open.fractionAmount(denominator, spec) }, + onSubmit = { onCharge(entered) }, + onDismiss = onDismiss, + ) + } +} + +@Composable +internal fun PaymentLine( + payment: RecordedPayment, + spec: CurrencySpecification?, + options: List<PaymentOption>, +) +{ + val method = + options.firstOrNull { it.id == payment.method }?.let { stringResource(it.nameRes) } + ?: payment.method + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Row(modifier = Modifier.fillMaxWidth()) { + Text(method, style = MaterialTheme.typography.titleSmall, modifier = Modifier.weight(1f)) + Text(payment.amount.shown(spec), style = MaterialTheme.typography.titleSmall) + } + if (payment.tendered != null && + payment.change != null) + { + Text( + text = + stringResource( + R.string.checkout_payment_line_change, + payment.tendered.shown(spec), + payment.change.shown(spec), + ), + style = MaterialTheme.typography.bodySmall, + ) + } + payment.transactionCode?.let { + Text( + stringResource(R.string.checkout_payment_line_reference, it), + style = MaterialTheme.typography.bodySmall, + ) + } + } +} + +@Composable +private fun FullScreenDialog( + onDismiss: () -> Unit, + content: @Composable () -> Unit, +) +{ + Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) { + Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) { + content() + } + } +} + +@Composable +private fun NoCheckout(onBack: () -> Unit) +{ + Column( + modifier = + Modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically), + ) { + Text(stringResource(R.string.checkout_none), style = MaterialTheme.typography.titleMedium) + Button(onClick = onBack) { Text(stringResource(R.string.payment_back_button)) } + } +} + +internal fun Amount.shown(spec: CurrencySpecification?): String = withSpec(spec).toString() + +private fun amountStateSaver( + currency: String, + spec: CurrencySpecification?, +) = Saver<Amount, LongArray>( + save = { amount -> longArrayOf(amount.value, amount.fraction.toLong()) }, + restore = { saved -> Amount(currency, saved[0], saved[1].toInt(), spec) }, +) + +/** A provider result is never guessed from a timeout or from an app restart. */ +@Composable +private fun PendingChargeRecovery( + pending: PendingCharge, + spec: CurrencySpecification?, + onResolve: (String, Boolean, String?) -> Unit, +) +{ + var outcome by rememberSaveable(pending.reference) { mutableStateOf<Boolean?>(null) } + var transaction by rememberSaveable(pending.reference) { mutableStateOf("") } + Text( + stringResource(R.string.checkout_pending_charge, pending.amount.shown(spec), pending.method, pending.reference), + ) + OutlinedButton(onClick = { outcome = true }, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.checkout_pending_paid)) + } + OutlinedButton(onClick = { outcome = false }, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.checkout_pending_not_paid)) + } + outcome?.let { paid -> + AlertDialog( + onDismissRequest = { outcome = null }, + title = { Text(stringResource(R.string.checkout_pending_confirm_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + stringResource( + if (paid) + { + R.string.checkout_pending_confirm_paid + } + else + { + R.string.checkout_pending_confirm_unpaid + }, + ), + ) + if (paid) + { + OutlinedTextField( + value = transaction, + onValueChange = { transaction = it }, + label = { Text(stringResource(R.string.checkout_pending_transaction)) }, + singleLine = true, + ) + } + } + }, + confirmButton = { + TextButton( + enabled = !paid || transaction.isNotBlank(), + onClick = { + onResolve(pending.reference, paid, transaction.takeIf { paid }) + outcome = null + } + ) { Text(stringResource(android.R.string.ok)) } + }, + dismissButton = { + TextButton(onClick = { outcome = null }) { Text(stringResource(android.R.string.cancel)) } + }, + ) + } +} diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/checkout/CheckoutManager.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/checkout/CheckoutManager.kt @@ -0,0 +1,718 @@ +/* + * 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.checkout + +import androidx.annotation.UiThread +import androidx.lifecycle.LiveData +import androidx.lifecycle.MediatorLiveData +import androidx.lifecycle.MutableLiveData + +import net.taler.common.Amount +import net.taler.common.Version +import net.taler.merchantlib.ExternalRefundRequest +import net.taler.merchantlib.MerchantApi +import net.taler.merchantpos.config.ConfigManager +import net.taler.merchantpos.order.Order +import net.taler.merchantpos.order.OrderManager +import net.taler.merchantpos.payment.POS_REFUND_DELAY +import net.taler.merchantpos.payment.toOrderParts +import net.taler.merchantpos.paymentoptions.ChargeResult +import net.taler.merchantpos.paymentoptions.ExternalCharge +import net.taler.merchantpos.paymentoptions.PaymentOption +import net.taler.merchantpos.paymentoptions.PaymentOptionsManager +import net.taler.merchantpos.paymentoptions.RefundOutcome +import net.taler.merchantpos.paymentoptions.TALER_OPTION_ID + +import java.util.UUID +import java.util.concurrent.TimeUnit + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +// DD 96: external payments and merchant collection. +const val MIXED_PAYMENTS_PROTOCOL = 42 + +private val KEEP_COMPLETED_MS = TimeUnit.DAYS.toMillis(30) + +sealed interface CheckoutResult +{ + data object Completed : CheckoutResult + + data class AwaitingWallet( + val checkout: Checkout, + ) : CheckoutResult + + data class Failed( + val message: String, + ) : CheckoutResult +} + +data class ChargeEvent( + val method: String, + val amount: Amount, + val result: ChargeResult, +) + +/** NotRecorded means the refund succeeded but its backend record still needs updating. */ +sealed interface ExternalRefundResult +{ + data object Done : ExternalRefundResult + + data class NotRecorded( + val message: String, + ) : ExternalRefundResult + + data class Failed( + val message: String, + ) : ExternalRefundResult +} + +sealed interface RevertResult +{ + data object Reverted : RevertResult + + data object AlreadyPaid : RevertResult + + data class Failed( + val message: String, + ) : RevertResult +} + +sealed interface AbandonResult +{ + data object Discarded : AbandonResult + + data class KeptForSettlement( + val checkout: Checkout, + ) : AbandonResult +} + +/** + * Persists checkout state and coordinates external payments with backend orders. + * Pending charges must be verified before resolving them; manual refunds must + * already have been returned to the customer before they are recorded here. + */ +class CheckoutManager internal constructor( + private val store: CheckoutStore, + private val backendForCurrentMerchant: () -> CheckoutBackend?, + private val merchantIdentity: LiveData<String?>, + private val backendVersion: () -> String?, + private val externalOptionsEnabled: () -> Boolean, + private val optionFor: (method: String) -> PaymentOption?, + private val onOrderSettled: (posOrderId: Int) -> Unit, + private val now: () -> Long, + private val newId: () -> String, +) +{ + constructor( + store: CheckoutStore, + api: MerchantApi, + configManager: ConfigManager, + paymentOptions: PaymentOptionsManager, + orderManager: OrderManager, + ) : this( + store = store, + backendForCurrentMerchant = { + configManager.merchantConfig?.let { MerchantCheckoutBackend(api, it) } + }, + merchantIdentity = configManager.merchantIdentity, + backendVersion = { configManager.backendVersion }, + externalOptionsEnabled = { + paymentOptions.options.any { it.id != TALER_OPTION_ID && paymentOptions.isEnabled(it) } + }, + optionFor = { method -> paymentOptions.options.firstOrNull { it.id == method } }, + onOrderSettled = orderManager::onOrderPaid, + now = System::currentTimeMillis, + newId = { UUID.randomUUID().toString() }, + ) + + private val session = newId() + private var allCheckouts = dropOldCompleted(store.load()) + private val creationMutex = Mutex() + private val mCheckouts = MediatorLiveData<List<Checkout>>() + private val mCurrentId = MutableLiveData<String?>(null) + private val mChargeEvent = MutableLiveData<ChargeEvent?>(null) + + val checkouts: LiveData<List<Checkout>> = mCheckouts + val currentId: LiveData<String?> = mCurrentId + val chargeEvent: LiveData<ChargeEvent?> = mChargeEvent + + val current: Checkout? get() = mCurrentId.value?.let(::find) + + init + { + mCheckouts.value = visibleCheckouts() + mCheckouts.addSource(merchantIdentity) { + if (mCurrentId.value?.let(::find) == null) mCurrentId.value = null + mChargeEvent.value = null + mCheckouts.value = visibleCheckouts() + } + } + + private fun belongsToCurrentMerchant(checkout: Checkout): Boolean = + checkout.merchantId != null && checkout.merchantId == merchantIdentity.value + + private fun visibleCheckouts() = allCheckouts.filter(::belongsToCurrentMerchant) + + private fun findStored(id: String) = allCheckouts.firstOrNull { it.id == id } + + fun find(id: String): Checkout? = findStored(id)?.takeIf(::belongsToCurrentMerchant) + + fun unfinished(): List<Checkout> = visibleCheckouts().filter { it.stage != CheckoutStage.Completed } + + fun backendSupportsMixedPayments(): Boolean + { + val current = backendVersion()?.let(Version::parse)?.current ?: return false + return current >= MIXED_PAYMENTS_PROTOCOL + } + + fun shouldUseCheckout(): Boolean = backendSupportsMixedPayments() && externalOptionsEnabled() + + @UiThread + fun start( + order: Order, + includeProducts: Boolean, + ): Checkout + { + val parts = order.toOrderParts(includeProducts) + val checkout = + Checkout( + id = newId(), + merchantId = checkNotNull(merchantIdentity.value) { "Merchant is not configured" }, + createdAt = now(), + session = session, + posOrderId = order.id, + contractTerms = parts.contractTerms, + inventoryProducts = parts.inventoryProducts, + ) + save(allCheckouts + checkout) + mCurrentId.value = checkout.id + return checkout + } + + @UiThread + fun resume(checkoutId: String): Boolean + { + val checkout = find(checkoutId) ?: return false + if (checkout.stage == CheckoutStage.Completed || + checkout.stage == CheckoutStage.Abandoned) + { + return false + } + mCurrentId.value = checkoutId + return true + } + + fun newPaymentId(): String = newId() + + @UiThread + fun recordCash(tendered: Amount): CashOutcome + { + val outcome = requireCurrent().withCash(tendered, newId(), now()) + replace(outcome.checkout) + return outcome + } + + @UiThread + fun recordExternalPayment( + method: String, + amount: Amount, + paymentId: String, + transactionCode: String?, + ): Checkout = recordExternal(requireCurrent(), method, amount, paymentId, transactionCode) + + private fun recordExternal( + checkout: Checkout, + method: String, + amount: Amount, + paymentId: String, + transactionCode: String?, + ): Checkout + { + if (checkout.payments.any { it.id == paymentId }) return checkout + val updated = checkout.withExternalPayment(method, amount, paymentId, transactionCode, now()) + replace(updated) + return updated + } + + @UiThread + fun consumeChargeEvent() + { + mChargeEvent.value = null + } + + @UiThread + fun chargeWith( + option: PaymentOption, + amount: Amount, + title: String, + ): Boolean + { + val checkout = requireCurrent() + if (!checkout.canTakeExternalPayments || + amount.currency != checkout.currency || + amount <= Amount.zero(checkout.currency) || + amount > checkout.open) + { + return false + } + val pending = PendingCharge(newId(), option.id, amount, now()) + // A failed disk write must prevent the terminal from being launched. + replace(checkout.copy(pendingCharge = pending)) + val started = + try + { + option.startCharge(ExternalCharge(pending.reference, amount, title)) { result -> + receiveChargeResult(checkout.id, pending.reference, result) + } + } + catch (e: Exception) + { + // Launch may already have reached the provider. Keep the unresolved attempt. + receiveChargeResult(checkout.id, pending.reference, ChargeResult.Unknown(e.message)) + true + } + if (!started) receiveChargeResult(checkout.id, pending.reference, ChargeResult.Cancelled) + return started + } + + private fun receiveChargeResult( + checkoutId: String, + reference: String, + result: ChargeResult, + ) + { + val checkout = findStored(checkoutId) ?: return + val pending = checkout.pendingCharge?.takeIf { it.reference == reference } ?: return + val reported = + try + { + when (result) + { + is ChargeResult.Success -> + { + replace( + checkout + .copy(pendingCharge = null) + .withExternalPayment( + pending.method, + pending.amount, + reference, + result.transactionCode, + now(), + ), + ) + } + + ChargeResult.Cancelled, is ChargeResult.Failed -> + { + replace(checkout.copy(pendingCharge = null)) + } + + is ChargeResult.Unknown -> + { + Unit + } + } + result + } + catch (e: Exception) + { + ChargeResult.Unknown(e.message) + } + if (belongsToCurrentMerchant(checkout)) + { + mChargeEvent.value = ChargeEvent(pending.method, pending.amount, reported) + } + } + + @UiThread + fun resolvePendingCharge( + reference: String, + paid: Boolean, + transactionCode: String? = null, + ) + { + val checkout = requireCurrent() + require(checkout.pendingCharge?.reference == reference) { "Payment is no longer pending" } + require(!paid || !transactionCode.isNullOrBlank()) { "Enter the confirmed transaction reference" } + receiveChargeResult( + checkout.id, + reference, + if (paid) ChargeResult.Success(transactionCode!!.trim()) else ChargeResult.Cancelled, + ) + } + + suspend fun finishWithTaler(): CheckoutResult + { + val checkout = current ?: return CheckoutResult.Failed("No checkout in progress") + val backend = backendForCurrentMerchant() ?: return CheckoutResult.Failed("Merchant is not configured") + return when (val order = ensureBackendOrder(checkout, backend)) + { + is BackendResult.Success -> + { + if (belongsToCurrentMerchant(checkout)) + { + CheckoutResult.AwaitingWallet(findStored(checkout.id)!!) + } + else + { + CheckoutResult.Failed("Merchant account changed") + } + } + + is BackendResult.Failure -> + { + CheckoutResult.Failed(order.message) + } + } + } + + suspend fun collectByMerchant(): CheckoutResult + { + val checkout = current ?: return CheckoutResult.Failed("No checkout in progress") + if (!checkout.isFullyPaidExternally) + { + return CheckoutResult.Failed("Part of this order still has to be paid with Taler") + } + val backend = backendForCurrentMerchant() ?: return CheckoutResult.Failed("Merchant is not configured") + val orderId = + when (val order = ensureBackendOrder(checkout, backend)) + { + is BackendResult.Success -> order.value + is BackendResult.Failure -> return CheckoutResult.Failed(order.message) + } + if (!belongsToCurrentMerchant(checkout)) return CheckoutResult.Failed("Merchant account changed") + return when (val collected = backend.collect(orderId)) + { + is BackendResult.Success -> + { + complete(orderId, checkout.merchantId) + if (belongsToCurrentMerchant(checkout)) + { + CheckoutResult.Completed + } + else + { + CheckoutResult.Failed("Merchant account changed") + } + } + + // The customer may have scanned the free order in the meantime. + is BackendResult.Failure -> + { + if (backend.isPaid(orderId) == true) + { + complete(orderId, checkout.merchantId) + if (belongsToCurrentMerchant(checkout)) + { + CheckoutResult.Completed + } + else + { + CheckoutResult.Failed("Merchant account changed") + } + } + else + { + CheckoutResult.Failed(collected.message) + } + } + } + } + + @UiThread + fun onBackendOrderPaid(orderId: String) = complete(orderId) + + suspend fun refundExternalPayment( + checkoutId: String, + paymentId: String, + amount: Amount, + reason: String, + ): ExternalRefundResult + { + val checkout = find(checkoutId) ?: return ExternalRefundResult.Failed("Unknown payment") + val backend = backendForCurrentMerchant() ?: return ExternalRefundResult.Failed("Merchant is not configured") + val payment = + checkout.payments.firstOrNull { it.id == paymentId } + ?: return ExternalRefundResult.Failed("Unknown payment") + if (amount.currency != checkout.currency || + amount <= Amount.zero(checkout.currency) || + amount > checkout.refundableOf(payment)) + { + return ExternalRefundResult.Failed("More than is left of this payment") + } + val option = optionFor(payment.method) + if (option == null || + !option.canRefund) + { + return ExternalRefundResult.Failed("This payment has to be refunded in its own app") + } + when (val outcome = option.refund(payment.transactionCode, amount, reason)) + { + RefundOutcome.Success -> + { + Unit + } + + RefundOutcome.NotSupported -> + { + return ExternalRefundResult.Failed("This payment has to be refunded in its own app") + } + + is RefundOutcome.Failed -> + { + return ExternalRefundResult.Failed(outcome.message) + } + } + val refundId = newId() + replace(checkout.withRefund(refundId, paymentId, amount, reason, now())) + val orderId = checkout.backendOrderId ?: return ExternalRefundResult.Done + val request = + ExternalRefundRequest( + method = payment.method, + id = refundId, + paymentId = paymentId, + amount = amount, + reason = reason, + ) + return when (val recorded = backend.recordExternalRefund(orderId, request)) + { + is BackendResult.Success -> ExternalRefundResult.Done + is BackendResult.Failure -> ExternalRefundResult.NotRecorded(recorded.message) + } + } + + suspend fun recordManualRefund( + checkoutId: String, + paymentId: String, + amount: Amount, + reason: String, + ): ExternalRefundResult + { + val checkout = find(checkoutId) ?: return ExternalRefundResult.Failed("Unknown payment") + val backend = backendForCurrentMerchant() ?: return ExternalRefundResult.Failed("Merchant is not configured") + val payment = + checkout.payments.firstOrNull { it.id == paymentId } + ?: return ExternalRefundResult.Failed("Unknown payment") + if (amount.currency != checkout.currency || + amount <= Amount.zero(checkout.currency) || + amount > checkout.refundableOf(payment)) + { + return ExternalRefundResult.Failed("More than is left of this payment") + } + val refundId = newId() + // Persist before contacting the backend. A retry must never return this money twice. + replace(checkout.withRefund(refundId, paymentId, amount, reason, now())) + val orderId = checkout.backendOrderId ?: return ExternalRefundResult.Done + return when ( + val recorded = + backend.recordExternalRefund( + orderId, + ExternalRefundRequest(payment.method, refundId, paymentId, amount, reason), + ) + ) + { + is BackendResult.Success -> ExternalRefundResult.Done + is BackendResult.Failure -> ExternalRefundResult.NotRecorded(recorded.message) + } + } + + suspend fun revertTalerStep(): RevertResult + { + val checkout = current ?: return RevertResult.Failed("No checkout in progress") + val backend = backendForCurrentMerchant() ?: return RevertResult.Failed("Merchant is not configured") + if (checkout.creationPending) return RevertResult.Failed("Retry order creation before changing payments") + val orderId = checkout.backendOrderId ?: return RevertResult.Reverted + return when (backend.isPaid(orderId)) + { + true -> + { + complete(orderId, checkout.merchantId) + RevertResult.AlreadyPaid + } + + null -> + { + RevertResult.Failed("The merchant backend could not be reached") + } + + false -> + { + when (val deleted = backend.deleteOrder(orderId, checkout.payments.isNotEmpty())) + { + is BackendResult.Success -> + { + replace( + checkout.copy( + stage = CheckoutStage.Collecting, + backendOrderId = null, + attempt = checkout.attempt + 1, + ), + ) + RevertResult.Reverted + } + + is BackendResult.Failure -> + { + RevertResult.Failed(deleted.message) + } + } + } + } + } + + suspend fun abandon(): AbandonResult? + { + val checkout = current ?: return null + mCurrentId.value = null + // Do not discard or abandon a record that still needs reconciliation. + if (checkout.pendingCharge != null || + checkout.creationPending) + { + return AbandonResult.KeptForSettlement(checkout) + } + val backend = backendForCurrentMerchant() ?: return AbandonResult.KeptForSettlement(checkout) + if (checkout.payments.isEmpty()) + { + checkout.backendOrderId?.let { orderId -> + // A failed delete may hide a paid order or a lost response. Keep the + // checkout recoverable until the backend confirms it is gone. + if (backend.deleteOrder(orderId) is BackendResult.Failure) + { + return AbandonResult.KeptForSettlement(checkout) + } + } + save(allCheckouts.filterNot { it.id == checkout.id }) + return AbandonResult.Discarded + } + val kept = checkout.copy(stage = CheckoutStage.Abandoned) + replace(kept) + return AbandonResult.KeptForSettlement(kept) + } + + @UiThread + fun onBackendOrderDeleted(orderId: String) + { + val checkout = visibleCheckouts().find { it.backendOrderId == orderId } ?: return + replace(checkout.copy(stage = CheckoutStage.Abandoned, backendOrderId = null)) + if (mCurrentId.value == checkout.id) mCurrentId.value = null + } + + @UiThread + fun dismiss(checkoutId: String) + { + val checkout = find(checkoutId) ?: return + if (checkout.pendingCharge != null || + checkout.creationPending) + { + return + } + if (checkout.stage != CheckoutStage.Completed && + checkout.stage != CheckoutStage.Abandoned) + { + return + } + save(allCheckouts.filterNot { it.id == checkoutId }) + } + + @UiThread + fun close() + { + mCurrentId.value = null + } + + private suspend fun ensureBackendOrder( + original: Checkout, + backend: CheckoutBackend, + ): BackendResult<String> = + creationMutex.withLock { + val checkout = find(original.id) ?: return@withLock BackendResult.Failure("Merchant account changed") + if (checkout.pendingCharge != + null) + { + return@withLock BackendResult.Failure("Resolve the pending card payment first") + } + if (checkout.stage == + CheckoutStage.Abandoned) + { + return@withLock BackendResult.Failure("Checkout was abandoned") + } + checkout.backendOrderId?.let { return@withLock BackendResult.Success(it) } + val frozen = checkout.copy(creationPending = true) + replace(frozen) + val orderId = + when (val created = backend.createOrder(frozen.toOrderRequest(POS_REFUND_DELAY))) + { + is BackendResult.Success -> + { + created.value + } + + is BackendResult.Failure -> + { + // Creation may have succeeded before its response was lost. + val existing = frozen.backendOrderIdToCreate + if (backend.isPaid(existing) == null) return@withLock created + existing + } + } + replace(frozen.copy(stage = CheckoutStage.AwaitingTaler, backendOrderId = orderId, creationPending = false)) + BackendResult.Success(orderId) + } + + private fun complete( + orderId: String, + merchantId: String? = merchantIdentity.value, + ) + { + val checkout = + allCheckouts.firstOrNull { + it.backendOrderId == orderId && it.merchantId == merchantId && merchantId != null + } + ?: return + if (checkout.stage == CheckoutStage.Completed) return + replace(checkout.copy(stage = CheckoutStage.Completed)) + if (mCurrentId.value == checkout.id) mCurrentId.value = null + // After a restart the order list is new; its ids no longer mean that sale. + if (checkout.session == session && + belongsToCurrentMerchant(checkout)) + { + onOrderSettled(checkout.posOrderId) + } + } + + private fun requireCurrent(): Checkout = checkNotNull(current) { "No checkout in progress" } + + private fun replace(checkout: Checkout) + { + save(allCheckouts.map { if (it.id == checkout.id) checkout else it }) + } + + private fun save(checkouts: List<Checkout>) + { + store.save(checkouts) + allCheckouts = checkouts + mCheckouts.value = visibleCheckouts() + } + + private fun dropOldCompleted(checkouts: List<Checkout>): List<Checkout> + { + val cutoff = now() - KEEP_COMPLETED_MS + return checkouts.filterNot { it.stage == CheckoutStage.Completed && it.createdAt < cutoff } + } +} diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/checkout/CheckoutStore.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/checkout/CheckoutStore.kt @@ -0,0 +1,105 @@ +/* + * 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.checkout + +import android.util.AtomicFile +import android.util.Log + +import java.io.File +import java.io.FileNotFoundException +import java.io.IOException + +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.Json + +/** Writes synchronously with AtomicFile so a crash preserves either the previous or the new checkout state. */ +class CheckoutStore( + private val file: File, +) +{ + private val atomicFile = AtomicFile(file) + + private val json = + Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + private val serializer = ListSerializer(Checkout.serializer()) + + @Synchronized + fun load(): List<Checkout> + { + val bytes = + try + { + atomicFile.readFully() + } + catch (e: FileNotFoundException) + { + // Only a genuinely absent store is empty. Android may also report an + // inaccessible file as "not found"; never replace that payment history. + val backup = File("${file.path}.bak") + if (file.exists() || + backup.exists() || + file.absoluteFile.parentFile?.canRead() != true) + { + throw e + } + return emptyList() + } + return try + { + json.decodeFromString(serializer, bytes.decodeToString()) + } + catch (e: Exception) + { + // Unreadable records are set aside, not deleted: they describe real money. + val quarantine = File(file.parentFile, "${file.name}.unreadable-${System.currentTimeMillis()}") + if (!file.renameTo(quarantine)) + { + throw IOException("Could not preserve unreadable checkout store", e) + } + Log.e(TAG, "Checkout store unreadable, kept as ${quarantine.name}", e) + emptyList() + } + } + + @Synchronized + fun save(checkouts: List<Checkout>) + { + // Serialize before opening the transaction, so an encoding failure cannot + // leave an unfinished write or an open stream behind. + val bytes = json.encodeToString(serializer, checkouts).encodeToByteArray() + val stream = atomicFile.startWrite() + try + { + stream.write(bytes) + atomicFile.finishWrite(stream) + } + catch (e: IOException) + { + atomicFile.failWrite(stream) + throw e + } + } + + private companion object + { + const val TAG = "taler-pos-checkout" + } +} diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/compose/AmountKeypad.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/compose/AmountKeypad.kt @@ -0,0 +1,208 @@ +/* + * This file is part of GNU Taler + * (C) 2026 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.merchantpos.compose + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +import net.taler.merchantpos.R + +@Composable +fun AmountKeypad( + isTabletLayout: Boolean, + chargeEnabled: Boolean, + onDigitPressed: (Char) -> Unit, + onClearPressed: () -> Unit, + onBackspacePressed: () -> Unit, + onChargePressed: () -> Unit, + modifier: Modifier = Modifier, + /** Label of the action button; defaults to creating the order. */ + chargeLabel: String? = null, +) +{ + val configuration = LocalConfiguration.current + val isCompactPhone = !isTabletLayout && configuration.screenHeightDp <= 720 + val rowSpacing = if (isCompactPhone) 6.dp else 8.dp + val digitFontSize = if (isCompactPhone) 24.sp else 28.sp + + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(rowSpacing), + ) { + val keyContainerColor = MaterialTheme.colorScheme.primaryContainer + val keyContentColor = MaterialTheme.colorScheme.onPrimaryContainer + val clearLabel = stringResource(R.string.amount_entry_clear) + val clearTextSize = + when + { + clearLabel.length >= 14 -> if (isCompactPhone) 12.sp else 14.sp + clearLabel.length >= 10 -> if (isCompactPhone) 14.sp else 16.sp + else -> if (isCompactPhone) 16.sp else 20.sp + } + + listOf( + listOf("1", "2", "3"), + listOf("4", "5", "6"), + listOf("7", "8", "9"), + ).forEach { row -> + Row( + modifier = + Modifier + .fillMaxWidth() + .weight(1f), + horizontalArrangement = Arrangement.spacedBy(rowSpacing), + ) { + row.forEach { key -> + AmountKeyButton( + text = key, + modifier = Modifier.weight(1f), + containerColor = keyContainerColor, + contentColor = keyContentColor, + fontSize = digitFontSize, + onClick = { onDigitPressed(key.first()) }, + ) + } + } + } + + Row( + modifier = + Modifier + .fillMaxWidth() + .weight(1f), + horizontalArrangement = Arrangement.spacedBy(rowSpacing), + ) { + AmountKeyButton( + text = clearLabel, + modifier = Modifier.weight(1f), + containerColor = keyContainerColor, + contentColor = keyContentColor, + fontSize = clearTextSize, + onClick = onClearPressed, + ) + AmountKeyButton( + text = "0", + modifier = Modifier.weight(1f), + containerColor = keyContainerColor, + contentColor = keyContentColor, + onClick = { onDigitPressed('0') }, + ) + Button( + modifier = + Modifier + .weight(1f) + .fillMaxSize(), + onClick = onBackspacePressed, + colors = + ButtonDefaults.buttonColors( + containerColor = keyContainerColor, + contentColor = keyContentColor, + ), + contentPadding = PaddingValues(0.dp), + ) { + Icon( + painter = painterResource(R.drawable.ic_backspace), + contentDescription = stringResource(R.string.amount_entry_backspace), + tint = keyContentColor, + ) + } + } + + Button( + modifier = + Modifier + .fillMaxWidth() + .height(56.dp), + onClick = onChargePressed, + enabled = chargeEnabled, + colors = + ButtonDefaults.buttonColors( + containerColor = colorResource(R.color.colorPrimary), + contentColor = colorResource(R.color.colorOnPrimary), + disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant, + disabledContentColor = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) { + Text( + text = chargeLabel ?: stringResource(R.string.amount_entry_create_order_charge), + fontWeight = FontWeight.SemiBold, + ) + } + } +} + +@Composable +private fun AmountKeyButton( + text: String, + modifier: Modifier, + containerColor: Color, + contentColor: Color, + fontSize: TextUnit? = null, + onClick: () -> Unit, +) +{ + Button( + modifier = modifier.fillMaxSize(), + onClick = onClick, + colors = + ButtonDefaults.buttonColors( + containerColor = containerColor, + contentColor = contentColor, + ), + contentPadding = PaddingValues(horizontal = 2.dp, vertical = 0.dp), + ) { + Text( + text = text, + textAlign = TextAlign.Center, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Ellipsis, + style = + fontSize?.let { + MaterialTheme.typography.headlineMedium.copy( + fontSize = it, + fontWeight = FontWeight.SemiBold, + ) + } ?: MaterialTheme.typography.headlineMedium.copy( + fontWeight = FontWeight.SemiBold, + ), + ) + } +} diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/compose/PaymentTiles.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/compose/PaymentTiles.kt @@ -0,0 +1,233 @@ +/* + * This file is part of GNU Taler + * (C) 2026 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.merchantpos.compose + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp + +import net.taler.merchantpos.R + +enum class PaymentTileStyle +{ + Primary, + PrimaryContainer, + Secondary, + SecondaryContainer, + Tertiary, + TertiaryContainer, + Surface, +} + +private data class PaymentTileColors( + val container: Color, + val content: Color, +) + +private fun PaymentTileStyle.colors(scheme: ColorScheme): PaymentTileColors = + with(scheme) { + when (this@colors) + { + PaymentTileStyle.Primary -> PaymentTileColors(primary, onPrimary) + PaymentTileStyle.PrimaryContainer -> PaymentTileColors(primaryContainer, onPrimaryContainer) + PaymentTileStyle.Secondary -> PaymentTileColors(secondary, onSecondary) + PaymentTileStyle.SecondaryContainer -> PaymentTileColors(secondaryContainer, onSecondaryContainer) + PaymentTileStyle.Tertiary -> PaymentTileColors(tertiary, onTertiary) + PaymentTileStyle.TertiaryContainer -> PaymentTileColors(tertiaryContainer, onTertiaryContainer) + PaymentTileStyle.Surface -> PaymentTileColors(surface, onSurface) + } + } + +/** Omit [onClick] to display a non-interactive header. */ +@Composable +fun TalerPaymentTile( + title: String, + detail: String?, + modifier: Modifier = Modifier, + style: PaymentTileStyle = PaymentTileStyle.SecondaryContainer, + amount: String? = null, + enabled: Boolean = true, + onClick: (() -> Unit)? = null, +) +{ + PaymentTile(title, detail, modifier, style, amount, enabled, onClick) { + val logoModifier = Modifier.size(width = 56.dp, height = 26.dp) + val contentColor = LocalContentColor.current + // Dark themes also have light tiles: choose the logo from the tile's foreground, + // not the system night mode. Disabled logos follow the disabled foreground. + if (!enabled || + contentColor.luminance() > 0.5f) + { + Icon( + painter = painterResource(R.drawable.ic_taler_logo_full), + contentDescription = null, + modifier = logoModifier, + tint = if (enabled) Color.White else contentColor, + ) + } + else + { + Image( + painter = painterResource(R.drawable.ic_taler_logo_brand_on_light), + contentDescription = null, + modifier = logoModifier, + ) + } + } +} + +/** Omit [onClick] to display a non-interactive header. */ +@Composable +fun PaymentMethodTile( + icon: ImageVector, + title: String, + detail: String?, + modifier: Modifier = Modifier, + style: PaymentTileStyle = PaymentTileStyle.PrimaryContainer, + amount: String? = null, + enabled: Boolean = true, + onClick: (() -> Unit)? = null, +) +{ + PaymentTile(title, detail, modifier, style, amount, enabled, onClick) { + Icon(imageVector = icon, contentDescription = null, modifier = Modifier.size(32.dp)) + } +} + +@Composable +private fun PaymentTile( + title: String, + detail: String?, + modifier: Modifier, + style: PaymentTileStyle, + amount: String?, + enabled: Boolean, + onClick: (() -> Unit)?, + icon: @Composable () -> Unit, +) +{ + val scheme = MaterialTheme.colorScheme + val colors = + if (enabled) + { + style.colors(scheme) + } + else + { + PaymentTileColors(scheme.surfaceVariant, scheme.onSurfaceVariant) + } + val border = + BorderStroke( + 1.dp, + if (!enabled || + style == PaymentTileStyle.Surface) + { + scheme.outlineVariant + } + else + { + colors.content.copy(alpha = 0.35f) + }, + ) + val shape = RoundedCornerShape(16.dp) + val tileModifier = modifier.fillMaxWidth().heightIn(min = 72.dp) + val fontScale = LocalDensity.current.fontScale + val content: @Composable () -> Unit = { + BoxWithConstraints(Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) { + // Keep long amounts out of the title's width on phones and with large text. + val trailingAmount = amount != null && maxWidth >= 400.dp && fontScale <= 1.3f + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + icon() + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + detail?.let { Text(it, style = MaterialTheme.typography.bodyMedium) } + if (amount != null && + !trailingAmount) + { + Text(amount, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + } + } + if (trailingAmount) + { + Text( + text = amount.orEmpty(), + modifier = Modifier.weight(0.6f), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.End, + ) + } + } + } + } + if (onClick == null) + { + Surface( + modifier = tileModifier, + shape = shape, + color = colors.container, + contentColor = colors.content, + border = border, + content = content, + ) + } + else + { + Surface( + onClick = onClick, + enabled = enabled, + modifier = tileModifier.semantics { role = Role.Button }, + shape = shape, + color = colors.container, + contentColor = colors.content, + border = border, + content = content, + ) + } +} diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/compose/PosButtons.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/compose/PosButtons.kt @@ -0,0 +1,39 @@ +/* + * This file is part of GNU Taler + * (C) 2026 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.merchantpos.compose + +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable + +@Composable +internal fun orderControlButtonColors() = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant, + disabledContentColor = MaterialTheme.colorScheme.onSurfaceVariant, + ) + +@Composable +internal fun completeButtonColors() = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant, + disabledContentColor = MaterialTheme.colorScheme.onSurfaceVariant, + ) diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/compose/PosOutlinedCard.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/compose/PosOutlinedCard.kt @@ -31,14 +31,16 @@ fun PosOutlinedCard( modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues(16.dp), content: @Composable (PaddingValues) -> Unit, -) { +) +{ Card( modifier = modifier.fillMaxWidth(), border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface, - contentColor = MaterialTheme.colorScheme.onSurface, - ), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface, + ), ) { content(contentPadding) } diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/compose/PosTheme.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/compose/PosTheme.kt @@ -18,119 +18,122 @@ package net.taler.merchantpos.compose import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Typography import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme -import androidx.compose.material3.Typography import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color -private val PosLightColorScheme = lightColorScheme( - primary = Color(0xFF0042B3), - onPrimary = Color(0xFFFFFFFF), - primaryContainer = Color(0xFFD3DEFF), - onPrimaryContainer = Color(0xFF00134A), - inversePrimary = Color(0xFFB4C5FF), - secondary = Color(0xFF586A88), - onSecondary = Color(0xFFFFFFFF), - secondaryContainer = Color(0xFFD9E3F9), - onSecondaryContainer = Color(0xFF111C2B), - tertiary = Color(0xFF338AF0), - onTertiary = Color(0xFFFFFFFF), - tertiaryContainer = Color(0xFFD1E4FF), - onTertiaryContainer = Color(0xFF001C39), - error = Color(0xFFB3261E), - onError = Color(0xFFFFFFFF), - errorContainer = Color(0xFFF9DEDC), - onErrorContainer = Color(0xFF410E0B), - background = Color(0xFFFDFDFF), - onBackground = Color(0xFF1A1C1F), - surface = Color(0xFFFDFDFF), - onSurface = Color(0xFF1C1B1F), - surfaceVariant = Color(0xFFE0E3EC), - onSurfaceVariant = Color(0xFF45474F), - outline = Color(0xFF767880), - outlineVariant = Color(0xFFC4C6D0), - inverseSurface = Color(0xFF2C2F3A), - inverseOnSurface = Color(0xFFF0F2FF), - primaryFixed = Color(0xFFD3DEFF), - onPrimaryFixed = Color(0xFF00134A), - primaryFixedDim = Color(0xFFB4C5FF), - onPrimaryFixedVariant = Color(0xFF00379C), - secondaryFixed = Color(0xFFD9E3F9), - onSecondaryFixed = Color(0xFF111C2B), - secondaryFixedDim = Color(0xFFB0BDD3), - onSecondaryFixedVariant = Color(0xFF445670), - tertiaryFixed = Color(0xFFD1E4FF), - onTertiaryFixed = Color(0xFF001C39), - tertiaryFixedDim = Color(0xFFA2CDFF), - onTertiaryFixedVariant = Color(0xFF0C5EA5), - surfaceDim = Color(0xFFDADDE5), - surfaceBright = Color(0xFFFFFFFF), - surfaceContainerLowest = Color(0xFFFFFFFF), - surfaceContainerLow = Color(0xFFF5F7FC), - surfaceContainer = Color(0xFFF0F2F7), - surfaceContainerHigh = Color(0xFFEAECEF), - surfaceContainerHighest = Color(0xFFE3E6EB), - surfaceTint = Color(0xFF0042B3), - scrim = Color(0xFF000000), -) +private val PosLightColorScheme = + lightColorScheme( + primary = Color(0xFF0042B3), + onPrimary = Color(0xFFFFFFFF), + primaryContainer = Color(0xFFD3DEFF), + onPrimaryContainer = Color(0xFF00134A), + inversePrimary = Color(0xFFB4C5FF), + secondary = Color(0xFF586A88), + onSecondary = Color(0xFFFFFFFF), + secondaryContainer = Color(0xFFD9E3F9), + onSecondaryContainer = Color(0xFF111C2B), + tertiary = Color(0xFF338AF0), + onTertiary = Color(0xFFFFFFFF), + tertiaryContainer = Color(0xFFD1E4FF), + onTertiaryContainer = Color(0xFF001C39), + error = Color(0xFFB3261E), + onError = Color(0xFFFFFFFF), + errorContainer = Color(0xFFF9DEDC), + onErrorContainer = Color(0xFF410E0B), + background = Color(0xFFFDFDFF), + onBackground = Color(0xFF1A1C1F), + surface = Color(0xFFFDFDFF), + onSurface = Color(0xFF1C1B1F), + surfaceVariant = Color(0xFFE0E3EC), + onSurfaceVariant = Color(0xFF45474F), + outline = Color(0xFF767880), + outlineVariant = Color(0xFFC4C6D0), + inverseSurface = Color(0xFF2C2F3A), + inverseOnSurface = Color(0xFFF0F2FF), + primaryFixed = Color(0xFFD3DEFF), + onPrimaryFixed = Color(0xFF00134A), + primaryFixedDim = Color(0xFFB4C5FF), + onPrimaryFixedVariant = Color(0xFF00379C), + secondaryFixed = Color(0xFFD9E3F9), + onSecondaryFixed = Color(0xFF111C2B), + secondaryFixedDim = Color(0xFFB0BDD3), + onSecondaryFixedVariant = Color(0xFF445670), + tertiaryFixed = Color(0xFFD1E4FF), + onTertiaryFixed = Color(0xFF001C39), + tertiaryFixedDim = Color(0xFFA2CDFF), + onTertiaryFixedVariant = Color(0xFF0C5EA5), + surfaceDim = Color(0xFFDADDE5), + surfaceBright = Color(0xFFFFFFFF), + surfaceContainerLowest = Color(0xFFFFFFFF), + surfaceContainerLow = Color(0xFFF5F7FC), + surfaceContainer = Color(0xFFF0F2F7), + surfaceContainerHigh = Color(0xFFEAECEF), + surfaceContainerHighest = Color(0xFFE3E6EB), + surfaceTint = Color(0xFF0042B3), + scrim = Color(0xFF000000), + ) -private val PosDarkColorScheme = darkColorScheme( - primary = Color(0xFFB4C5FF), - onPrimary = Color(0xFF002A78), - primaryContainer = Color(0xFF0042B3), - onPrimaryContainer = Color(0xFFE5EBFF), - inversePrimary = Color(0xFF2756C7), - secondary = Color(0xFFA4C9FF), - onSecondary = Color(0xFF00315D), - secondaryContainer = Color(0xFF72A3E5), - onSecondaryContainer = Color(0xFF003869), - tertiary = Color(0xFF8DD1E5), - onTertiary = Color(0xFF003641), - tertiaryContainer = Color(0xFF166577), - onTertiaryContainer = Color(0xFF9CE0F5), - error = Color(0xFFFFB4AA), - onError = Color(0xFF690003), - errorContainer = Color(0xFFB3261E), - onErrorContainer = Color(0xFFFFCBC4), - background = Color(0xFF11131A), - onBackground = Color(0xFFE2E2EB), - surface = Color(0xFF11131A), - onSurface = Color(0xFFE5E2E1), - surfaceVariant = Color(0xFF45474B), - onSurfaceVariant = Color(0xFFC6C6CB), - outline = Color(0xFF8F9095), - outlineVariant = Color(0xFF45474B), - inverseSurface = Color(0xFFE5E2E1), - inverseOnSurface = Color(0xFF313030), - primaryFixed = Color(0xFFDBE1FF), - onPrimaryFixed = Color(0xFF00174B), - primaryFixedDim = Color(0xFFB4C5FF), - onPrimaryFixedVariant = Color(0xFF003EA8), - secondaryFixed = Color(0xFFD4E3FF), - onSecondaryFixed = Color(0xFF001C39), - secondaryFixedDim = Color(0xFFA4C9FF), - onSecondaryFixedVariant = Color(0xFF004883), - tertiaryFixed = Color(0xFFAFECFF), - onTertiaryFixed = Color(0xFF001F27), - tertiaryFixedDim = Color(0xFF8DD1E5), - onTertiaryFixedVariant = Color(0xFF004E5D), - surfaceDim = Color(0xFF11131A), - surfaceBright = Color(0xFF3A3939), - surfaceContainerLowest = Color(0xFF0E0E0E), - surfaceContainerLow = Color(0xFF1C1B1B), - surfaceContainer = Color(0xFF201F1F), - surfaceContainerHigh = Color(0xFF2A2A2A), - surfaceContainerHighest = Color(0xFF353434), - surfaceTint = Color(0xFFB4C5FF), - scrim = Color(0xFF000000), -) +private val PosDarkColorScheme = + darkColorScheme( + primary = Color(0xFFB4C5FF), + onPrimary = Color(0xFF002A78), + primaryContainer = Color(0xFF0042B3), + onPrimaryContainer = Color(0xFFE5EBFF), + inversePrimary = Color(0xFF2756C7), + secondary = Color(0xFFA4C9FF), + onSecondary = Color(0xFF00315D), + secondaryContainer = Color(0xFF72A3E5), + onSecondaryContainer = Color(0xFF003869), + tertiary = Color(0xFF8DD1E5), + onTertiary = Color(0xFF003641), + tertiaryContainer = Color(0xFF166577), + onTertiaryContainer = Color(0xFF9CE0F5), + error = Color(0xFFFFB4AA), + onError = Color(0xFF690003), + errorContainer = Color(0xFFB3261E), + onErrorContainer = Color(0xFFFFCBC4), + background = Color(0xFF11131A), + onBackground = Color(0xFFE2E2EB), + surface = Color(0xFF11131A), + onSurface = Color(0xFFE5E2E1), + surfaceVariant = Color(0xFF45474B), + onSurfaceVariant = Color(0xFFC6C6CB), + outline = Color(0xFF8F9095), + outlineVariant = Color(0xFF45474B), + inverseSurface = Color(0xFFE5E2E1), + inverseOnSurface = Color(0xFF313030), + primaryFixed = Color(0xFFDBE1FF), + onPrimaryFixed = Color(0xFF00174B), + primaryFixedDim = Color(0xFFB4C5FF), + onPrimaryFixedVariant = Color(0xFF003EA8), + secondaryFixed = Color(0xFFD4E3FF), + onSecondaryFixed = Color(0xFF001C39), + secondaryFixedDim = Color(0xFFA4C9FF), + onSecondaryFixedVariant = Color(0xFF004883), + tertiaryFixed = Color(0xFFAFECFF), + onTertiaryFixed = Color(0xFF001F27), + tertiaryFixedDim = Color(0xFF8DD1E5), + onTertiaryFixedVariant = Color(0xFF004E5D), + surfaceDim = Color(0xFF11131A), + surfaceBright = Color(0xFF3A3939), + surfaceContainerLowest = Color(0xFF0E0E0E), + surfaceContainerLow = Color(0xFF1C1B1B), + surfaceContainer = Color(0xFF201F1F), + surfaceContainerHigh = Color(0xFF2A2A2A), + surfaceContainerHighest = Color(0xFF353434), + surfaceTint = Color(0xFFB4C5FF), + scrim = Color(0xFF000000), + ) @Composable fun PosTheme( darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit, -) { +) +{ MaterialTheme( colorScheme = if (darkTheme) PosDarkColorScheme else PosLightColorScheme, typography = Typography(), diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/config/ConfigDraftViewModel.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/config/ConfigDraftViewModel.kt @@ -0,0 +1,348 @@ +/* + * This file is part of GNU Taler + * (C) 2026 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.merchantpos.config + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope + +import net.taler.common.Challenge +import net.taler.common.ChallengesResponse +import net.taler.common.TokenDuration +import net.taler.lib.android.ChallengeCancelledException +import net.taler.lib.android.formatMfaCode +import net.taler.lib.android.normalizeMfaCode +import net.taler.merchantpos.R + +import io.ktor.client.plugins.ClientRequestException +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +import net.taler.common.R as CommonR + +/** No Activity or Fragment references: a login attempt can outlive its screen. */ +internal interface LoginBackend +{ + suspend fun fetchToken(challengeIds: List<String>): String + + suspend fun requestChallenge(id: String) + + suspend fun confirmChallenge( + id: String, + code: String, + ) + + fun configure(token: String) +} + +internal class MerchantLoginBackend( + private val manager: ConfigManager, + private val baseUrl: String, + private val username: String, + private val secret: String, + private val savePassword: Boolean, +) : LoginBackend +{ + override suspend fun fetchToken(challengeIds: List<String>) = + withContext(Dispatchers.IO) { + manager.fetchLimitedAccessToken(baseUrl, username, secret, TokenDuration.Forever, challengeIds) + } + + override suspend fun requestChallenge(id: String) = + withContext(Dispatchers.IO) { + manager.requestChallenge(baseUrl, username, id) + } + + override suspend fun confirmChallenge( + id: String, + code: String, + ) = withContext(Dispatchers.IO) { + manager.confirmChallenge(baseUrl, username, id, code) + } + + override fun configure(token: String) + { + manager.fetchConfig(Config.New("$baseUrl/instances/$username", token, savePassword), true) + } +} + +internal sealed class LoginPrompt +{ + abstract fun cancel() + + class Method( + val challenges: List<Challenge>, + ) : LoginPrompt() + { + internal val answer = CompletableDeferred<Challenge?>() + + fun select(challenge: Challenge) + { + if (challenge in challenges) answer.complete(challenge) + } + + override fun cancel() + { + answer.complete(null) + } + } + + class Code( + val challenge: Challenge, + initialError: Int? = null, + ) : LoginPrompt() + { + internal val answer = CompletableDeferred<String?>() + var code by mutableStateOf("") + private set + var error by mutableStateOf(initialError) + private set + + fun updateCode(value: String) + { + code = value.filter(Char::isDigit).take(8) + error = null + } + + fun submit() + { + val normalized = normalizeMfaCode(code) + if (normalized == null) + { + error = CommonR.string.mfa_challenge_code_incomplete + } + else + { + answer.complete(formatMfaCode(normalized)) + } + } + + override fun cancel() + { + answer.complete(null) + } + } +} + +/** Credentials and MFA state survive rotation in process memory only, never saved state. */ +internal class ConfigDraftViewModel : ViewModel() +{ + private var loginJob: Job? = null + private var attempt: Any? = null + + var initialized = false + var mode: ConfigMode? = null + var merchantUrl: String? = null + var username: String? = null + var token: String? = null + var saveToken: Boolean? = null + + var awaitingConfigUpdate = false + private set + + var isSubmitting by mutableStateOf(false) + private set + + var prompt by mutableStateOf<LoginPrompt?>(null) + private set + + val loginError = MutableLiveData<Int?>() + + fun startLogin(backend: LoginBackend) + { + if (isSubmitting) return + val currentAttempt = Any().also { attempt = it } + isSubmitting = true + loginError.value = null + loginJob = + viewModelScope.launch { + try + { + var solvedIds = emptyList<String>() + while (true) + { + val limitedToken = + try + { + backend.fetchToken(solvedIds) + } + catch (e: ChallengeRequiredException) + { + solvedIds = solveChallenges(backend, e.challengeResponse) + continue + } + awaitingConfigUpdate = true + backend.configure(limitedToken) + break + } + } + catch (_: ChallengeCancelledException) + { + // The user cancelled MFA. Keep the form available for another attempt. + } + catch (e: CancellationException) + { + throw e + } + catch (_: LocalNetworkAccessDenied) + { + awaitingConfigUpdate = false + loginError.value = R.string.config_local_network_required + } + catch (e: Exception) + { + awaitingConfigUpdate = false + loginError.value = + if (e is ClientRequestException && + e.response.status.value == 401) + { + R.string.config_auth_error + } + else + { + R.string.config_error_network + } + } + finally + { + if (attempt === currentAttempt) + { + prompt = null + isSubmitting = awaitingConfigUpdate + loginJob = null + } + } + } + } + + private suspend fun solveChallenges( + backend: LoginBackend, + response: ChallengesResponse, + ): List<String> + { + if (response.challenges.isEmpty()) throw ChallengeCancelledException() + val challenges = + if (response.combiAnd) + { + response.challenges + } + else + { + val selection = LoginPrompt.Method(response.challenges) + prompt = selection + val selected = + try + { + selection.answer.await() + } + finally + { + if (prompt === selection) prompt = null + } + listOf(selected ?: throw ChallengeCancelledException()) + } + val solved = mutableListOf<String>() + for (challenge in challenges) + { + backend.requestChallenge(challenge.challengeId) + var error: Int? = null + while (true) + { + val entry = LoginPrompt.Code(challenge, error) + prompt = entry + val code = + try + { + entry.answer.await() + } + finally + { + if (prompt === entry) prompt = null + } + ?: throw ChallengeCancelledException() + try + { + backend.confirmChallenge(challenge.challengeId, code) + solved.add(challenge.challengeId) + break + } + catch (e: CancellationException) + { + throw e + } + catch (e: ClientRequestException) + { + when (e.response.status.value) + { + 409 -> + { + error = CommonR.string.mfa_challenge_invalid + } + + 429 -> + { + backend.requestChallenge(challenge.challengeId) + error = CommonR.string.mfa_challenge_retry + } + + else -> + { + throw e + } + } + } + } + } + return solved + } + + fun finishConfigUpdate() + { + awaitingConfigUpdate = false + isSubmitting = false + } + + fun cancelLogin() + { + attempt = null + loginJob?.cancel() + loginJob = null + prompt = null + isSubmitting = false + loginError.value = null + awaitingConfigUpdate = false + } + + fun clear() + { + cancelLogin() + initialized = false + mode = null + merchantUrl = null + username = null + token = null + saveToken = null + awaitingConfigUpdate = false + } +} 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 @@ -17,6 +17,11 @@ package net.taler.merchantpos.config import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup + +import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize @@ -24,6 +29,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -33,48 +39,76 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels -import net.taler.merchantpos.MainViewModel + import net.taler.merchantpos.MainActivity +import net.taler.merchantpos.MainViewModel import net.taler.merchantpos.PosDestination import net.taler.merchantpos.R import net.taler.merchantpos.compose.PosTheme import net.taler.merchantpos.showPosError -class ConfigFetcherFragment : Fragment() { - +class ConfigFetcherFragment : Fragment() +{ private val model: MainViewModel by activityViewModels() private val configManager by lazy { model.configManager } - private var navigating = false override fun onCreateView( - inflater: android.view.LayoutInflater, - container: android.view.ViewGroup?, + inflater: LayoutInflater, + container: ViewGroup?, savedInstanceState: Bundle?, ) = ComposeView(requireContext()).apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { - ConfigFetcherScreen() + ConfigFetcherScreen(onCancel = ::cancelFetch) } } - override fun onViewCreated(view: android.view.View, savedInstanceState: Bundle?) { - if (configManager.config.savePassword()) { + private fun cancelFetch() + { + if (navigating) return + navigating = true + configManager.cancelConfigFetch() + (requireActivity() as MainActivity).navigateTo(PosDestination.Config, clearBackStack = true) + } + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) + { + if (configManager.config.savePassword()) + { configManager.fetchConfig(configManager.config, true) - } else if (!navigating) { + } + else if (!navigating) + { navigating = true (requireActivity() as MainActivity).navigateTo(PosDestination.Config, clearBackStack = true) } configManager.configUpdateResult.observe(viewLifecycleOwner) { result -> - when (result) { - null -> return@observe - is ConfigUpdateResult.Error -> { - requireActivity().showPosError(result.msg) + when (result) + { + null -> + { + return@observe } - is ConfigUpdateResult.Success -> { - if (!navigating) { + is ConfigUpdateResult.Error -> + { + if (!navigating) + { + navigating = true + (requireActivity() as MainActivity).navigateTo(PosDestination.Config, clearBackStack = true) + if (!result.returnToConfig) requireActivity().showPosError(result.msg) + } + } + + is ConfigUpdateResult.Success -> + { + if (!navigating) + { navigating = true (requireActivity() as MainActivity).navigateToInitialOrderScreen() } @@ -85,12 +119,15 @@ class ConfigFetcherFragment : Fragment() { } @Composable -private fun ConfigFetcherScreen() { +private fun ConfigFetcherScreen(onCancel: () -> Unit) +{ + BackHandler(onBack = onCancel) PosTheme { Column( - modifier = Modifier - .fillMaxSize() - .padding(24.dp), + modifier = + Modifier + .fillMaxSize() + .padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Top, ) { @@ -100,6 +137,9 @@ private fun ConfigFetcherScreen() { modifier = Modifier.padding(top = 16.dp), style = MaterialTheme.typography.titleMedium, ) + TextButton(onClick = onCancel) { + Text(stringResource(android.R.string.cancel)) + } } } } 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,16 +17,18 @@ package net.taler.merchantpos.config import android.Manifest +import android.content.Context import android.content.Intent import android.content.pm.PackageManager -import android.media.Image import android.os.Bundle -import android.util.Log +import android.provider.Settings +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup import android.widget.Toast + import androidx.activity.result.contract.ActivityResultContracts -import androidx.annotation.OptIn import androidx.camera.core.CameraSelector -import androidx.camera.core.ExperimentalGetImage import androidx.camera.core.ImageAnalysis import androidx.camera.core.Preview import androidx.camera.lifecycle.ProcessCameraProvider @@ -38,88 +40,101 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Checkbox import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.focus.FocusDirection -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.focus.FocusManager import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.SoftwareKeyboardController import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import androidx.core.content.ContextCompat import androidx.core.net.toUri import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels -import androidx.lifecycle.lifecycleScope + import com.google.zxing.BarcodeFormat import com.google.zxing.BinaryBitmap import com.google.zxing.DecodeHintType import com.google.zxing.MultiFormatReader -import com.google.zxing.NotFoundException -import com.google.zxing.PlanarYUVLuminanceSource +import com.google.zxing.ReaderException import com.google.zxing.common.HybridBinarizer -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import net.taler.common.TokenDuration -import net.taler.lib.android.ChallengeCancelledException -import net.taler.lib.android.handleChallengeResponse + +import net.taler.lib.android.MfaCodeContent +import net.taler.lib.android.MfaMethodContent import net.taler.merchantpos.MainActivity import net.taler.merchantpos.MainViewModel import net.taler.merchantpos.R import net.taler.merchantpos.compose.PosTheme import net.taler.merchantpos.showPosError -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ExpandLess -import androidx.compose.material.icons.filled.ExpandMore -import androidx.compose.material.icons.filled.Visibility -import androidx.compose.material.icons.filled.VisibilityOff -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.TextButton -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import io.ktor.client.plugins.ClientRequestException -import io.ktor.http.HttpStatusCode.Companion.Unauthorized -private enum class ConfigMode { Manual, Qr } +internal enum class ConfigMode +{ + Manual, + Qr +} private const val DEMO_MERCHANT_HOST = "backend.demo.taler.net" private const val DEMO_INSTANCE = "sandbox" @@ -127,41 +142,52 @@ private const val DEMO_PASSWORD = "sandbox" private const val INSTANCE_SETUP_TUTORIAL_URL = "https://tutorials.taler.net/merchant/merchant-backoffice/obtain-instance/index" -class ConfigFragment : Fragment() { - +class ConfigFragment : Fragment() +{ private val model: MainViewModel by activityViewModels() + private val draft: ConfigDraftViewModel by activityViewModels() private val configManager by lazy { model.configManager } - - private var awaitingConfigUpdate = false private var mode by mutableStateOf(ConfigMode.Manual) private var merchantUrlText by mutableStateOf("") private var usernameText by mutableStateOf("") private var tokenText by mutableStateOf("") private var saveToken by mutableStateOf(true) - private var isSubmitting by mutableStateOf(false) private var isQrLoading by mutableStateOf(false) private var previewView: PreviewView? = null + private var cameraGranted by mutableStateOf(false) + private var cameraDialogVisible by mutableStateOf(false) + private var cameraNeedsSettings by mutableStateOf(false) + private var cameraRequestInFlight = false + private var cameraBindingTarget: PreviewView? = null + private var cameraGeneration = 0 private val cameraExecutor by lazy { ContextCompat.getMainExecutor(requireContext()) } - private val qrReader = MultiFormatReader().apply { - setHints( - mapOf( - DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE), - DecodeHintType.CHARACTER_SET to "UTF-8", - ), - ) - } + private val qrReader = + MultiFormatReader().apply { + setHints( + mapOf( + DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE), + DecodeHintType.CHARACTER_SET to "UTF-8", + ), + ) + } + + private val requestCameraPerm = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { + cameraRequestInFlight = false + if (isAdded) refreshCameraPermission() + } override fun onCreateView( - inflater: android.view.LayoutInflater, - container: android.view.ViewGroup?, + inflater: LayoutInflater, + container: ViewGroup?, savedInstanceState: Bundle?, ) = ComposeView(requireContext()).apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - initializeState(savedInstanceState == null) + initializeState(savedInstanceState) setContent { ConfigScreen( mode = mode, @@ -169,298 +195,401 @@ class ConfigFragment : Fragment() { username = usernameText, token = tokenText, saveToken = saveToken, - isSubmitting = isSubmitting, + isSubmitting = draft.isSubmitting, + onCancelLogin = { + configManager.cancelConfigFetch() + draft.cancelLogin() + }, + connectionPermissionPending = model.localNetworkAccess.host != null, + mfaPrompt = draft.prompt, isQrLoading = isQrLoading, - onModeChanged = { - mode = it - if (it == ConfigMode.Qr) requestCameraIfNeeded() else stopCamera() + onModeChanged = ::changeMode, + cameraDialogVisible = cameraDialogVisible, + cameraNeedsSettings = cameraNeedsSettings, + onCameraPermissionConfirm = ::confirmCameraPermission, + onCameraPermissionDismiss = { changeMode(ConfigMode.Manual) }, + onMerchantUrlChanged = { + merchantUrlText = it + draft.merchantUrl = it + }, + onUsernameChanged = { + usernameText = it + draft.username = it + }, + onTokenChanged = { + tokenText = it + draft.token = it + }, + onSaveTokenChanged = { + saveToken = it + draft.saveToken = it }, - onMerchantUrlChanged = { merchantUrlText = it }, - onUsernameChanged = { usernameText = it }, - onTokenChanged = { tokenText = it }, - onSaveTokenChanged = { saveToken = it }, previewContent = { - AndroidView( - modifier = Modifier.fillMaxSize(), - factory = { context -> - PreviewView(context).also { - it.implementationMode = PreviewView.ImplementationMode.COMPATIBLE - it.scaleType = PreviewView.ScaleType.FIT_CENTER - previewView = it - if (mode == ConfigMode.Qr) { - requestCameraIfNeeded() + if (cameraGranted) + { + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { context -> + PreviewView(context).also { + it.implementationMode = PreviewView.ImplementationMode.COMPATIBLE + it.scaleType = PreviewView.ScaleType.FIT_CENTER + previewView = it + if (mode == ConfigMode.Qr) + { + startCamera() + } } + }, + update = { view -> + view.implementationMode = PreviewView.ImplementationMode.COMPATIBLE + view.scaleType = PreviewView.ScaleType.FIT_CENTER + previewView = view + }, + ) + } + else + { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + OutlinedButton(onClick = ::refreshCameraPermission) { + Text(stringResource(R.string.config_camera_allow)) } - }, - update = { view -> - view.implementationMode = PreviewView.ImplementationMode.COMPATIBLE - view.scaleType = PreviewView.ScaleType.FIT_CENTER - previewView = view - if (mode == ConfigMode.Qr) { - requestCameraIfNeeded() - } - }, - ) + } + } }, onConnect = ::submitManualConfig, ) } } - override fun onViewCreated(view: android.view.View, savedInstanceState: Bundle?) { + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) + { + draft.loginError.observe(viewLifecycleOwner) { error -> + if (error != null) + { + draft.loginError.value = null + requireActivity().showPosError(error) + } + } configManager.configUpdateResult.observe(viewLifecycleOwner) { result -> onConfigUpdate(result) } } - override fun onResume() { + override fun onSaveInstanceState(outState: Bundle) + { + super.onSaveInstanceState(outState) + // Keep the sign-in draft useful across rotation without writing the credential to disk. + outState.putString(STATE_MODE, mode.name) + outState.putString(STATE_MERCHANT_URL, merchantUrlText) + outState.putString(STATE_USERNAME, usernameText) + outState.putBoolean(STATE_SAVE_TOKEN, saveToken) + outState.putBoolean(STATE_CAMERA_REQUEST, cameraRequestInFlight) + } + + override fun onResume() + { super.onResume() - if (mode == ConfigMode.Qr) { - requestCameraIfNeeded() + if (mode == ConfigMode.Qr) + { + refreshCameraPermission() } } - override fun onDestroyView() { + override fun onDestroyView() + { stopCamera() previewView = null + // The Compose host also detaches/reattaches this view after rotation. + // Only an actual removal means the user is leaving login. + if (isRemoving && + activity?.isChangingConfigurations != true) + { + draft.clear() + } super.onDestroyView() } - private fun initializeState(isInitialization: Boolean) { - val cfg = configManager.config - if (isInitialization) { - merchantUrlText = NEW_CONFIG_URL_DEMO - saveToken = cfg.savePassword() - if (cfg is Config.New && cfg.merchantUrl.isNotBlank()) { - merchantUrlText = cfg.merchantUrl - sanitizeMerchantUrlAndUpdateFields() - } - } + override fun onDestroy() + { + if (activity?.isChangingConfigurations != true) draft.clear() + super.onDestroy() } - private fun submitManualConfig() { - lifecycleScope.launch { - isSubmitting = true - val baseUrl = sanitizeMerchantUrlAndUpdateFields() - val username = usernameText.trim() - val initialSecret = tokenText.trim() - val duration = TokenDuration.Forever - - val limitedToken = try { - fetchLimitedAccessTokenWithMfa(baseUrl, username, initialSecret, duration) - } catch (_: ChallengeCancelledException) { - isSubmitting = false - return@launch - } catch (e: Exception) { - isSubmitting = false - Log.e("ConfigFragment", "Error fetching limited token: ${e.message}") - val errorRes = if (e is ClientRequestException && e.response.status == Unauthorized) { - R.string.config_auth_error - } else { - R.string.config_error_network + private fun initializeState(savedInstanceState: Bundle?) + { + val cfg = configManager.config + if (savedInstanceState != null) + { + cameraRequestInFlight = savedInstanceState.getBoolean(STATE_CAMERA_REQUEST) + mode = draft.mode ?: savedInstanceState + .getString(STATE_MODE) + ?.let { runCatching { ConfigMode.valueOf(it) }.getOrNull() } ?: ConfigMode.Manual + merchantUrlText = draft.merchantUrl ?: savedInstanceState.getString(STATE_MERCHANT_URL).orEmpty() + usernameText = draft.username ?: savedInstanceState.getString(STATE_USERNAME).orEmpty() + tokenText = draft.token.orEmpty() // Activity ViewModel only; never copied into saved state. + saveToken = draft.saveToken ?: savedInstanceState.getBoolean(STATE_SAVE_TOKEN, cfg.savePassword()) + } + else + { + if (!draft.initialized) + { + merchantUrlText = NEW_CONFIG_URL_DEMO + saveToken = cfg.savePassword() + if (cfg is Config.New && + cfg.merchantUrl.isNotBlank()) + { + merchantUrlText = cfg.merchantUrl + sanitizeMerchantUrlAndUpdateFields() } - requireActivity().showPosError(errorRes) - return@launch + draft.initialized = true + draft.mode = mode + draft.merchantUrl = merchantUrlText + draft.username = usernameText + draft.saveToken = saveToken + } + else + { + mode = draft.mode ?: ConfigMode.Manual + merchantUrlText = draft.merchantUrl.orEmpty() + usernameText = draft.username.orEmpty() + tokenText = draft.token.orEmpty() + saveToken = draft.saveToken ?: cfg.savePassword() } - - val configUrl = "$baseUrl/instances/$username" - val config = Config.New( - merchantUrl = configUrl, - accessToken = limitedToken, - savePassword = saveToken, - ) - awaitingConfigUpdate = true - configManager.fetchConfig(config, true) } + draft.initialized = true + draft.mode = mode + draft.merchantUrl = merchantUrlText + draft.username = usernameText + draft.saveToken = saveToken + } + + private fun submitManualConfig() + { + if (draft.isSubmitting) return + model.localNetworkAccess.retry() + val baseUrl = sanitizeMerchantUrlAndUpdateFields() + draft.startLogin( + MerchantLoginBackend(configManager, baseUrl, usernameText.trim(), tokenText.trim(), saveToken), + ) } - private fun sanitizeMerchantUrlAndUpdateFields(): String { + private fun sanitizeMerchantUrlAndUpdateFields(): String + { val rawInput = merchantUrlText.trim() if (rawInput.isEmpty()) return "" - val normalizedInput = if (rawInput.startsWith("http://") || rawInput.startsWith("https://")) { - rawInput - } else { - "https://$rawInput" - } + 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 baseHost = "$host$port" val segments = uri.pathSegments - if (segments.size >= 2 && segments[0].equals("instances", true)) { + if (segments.size >= 2 && + segments[0].equals("instances", true)) + { usernameText = segments[1] } merchantUrlText = baseHost + draft.merchantUrl = merchantUrlText + draft.username = usernameText return if (baseHost.isBlank()) "" else "https://$baseHost" } - private fun onConfigUpdate(result: ConfigUpdateResult?) { - if (!awaitingConfigUpdate) return - when (result) { - null -> Unit - is ConfigUpdateResult.Error -> { - awaitingConfigUpdate = false - isSubmitting = false + private fun onConfigUpdate(result: ConfigUpdateResult?) + { + if (!draft.awaitingConfigUpdate) return + when (result) + { + null -> + { + Unit + } + + is ConfigUpdateResult.Error -> + { + draft.finishConfigUpdate() requireActivity().showPosError(result.msg) } - is ConfigUpdateResult.Success -> { - awaitingConfigUpdate = false - isSubmitting = false - Toast.makeText( - requireContext(), - getString(R.string.config_changed, result.currency), - Toast.LENGTH_LONG, - ).show() + is ConfigUpdateResult.Success -> + { + draft.finishConfigUpdate() + Toast + .makeText( + requireContext(), + getString(R.string.config_changed, result.currency), + Toast.LENGTH_LONG, + ).show() + draft.clear() (requireActivity() as MainActivity).navigateToInitialOrderScreen() } } } - private suspend fun fetchLimitedAccessTokenWithMfa( - baseUrl: String, - username: String, - initialSecret: String, - duration: TokenDuration, - ): String { - var challengeIds: List<String> = emptyList() - while (true) { - try { - return withContext(Dispatchers.IO) { - configManager.fetchLimitedAccessToken( - baseUrl, - username, - initialSecret, - duration, - challengeIds, - ) - } - } catch (e: ChallengeRequiredException) { - val solvedIds = handleChallengeResponse( - e.challengeResponse.challenges, - e.challengeResponse.combiAnd, - onRequestChallenge = { challengeId -> - withContext(Dispatchers.IO) { - configManager.requestChallenge(baseUrl, username, challengeId) - } - }, - onConfirmChallenge = { challengeId, tan -> - withContext(Dispatchers.IO) { - configManager.confirmChallenge(baseUrl, username, challengeId, tan) - } - }, - ) - if (solvedIds.isEmpty()) throw ChallengeCancelledException() - challengeIds = solvedIds - } - } + private fun changeMode(next: ConfigMode) + { + mode = next + draft.mode = next + cameraDialogVisible = false + if (next == ConfigMode.Qr) refreshCameraPermission() else stopCamera() } - private val requestCameraPerm = - registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> - if (granted) startCamera() - else Toast.makeText( - requireContext(), - R.string.config_fragment_camera_needed_text, - Toast.LENGTH_SHORT, - ).show() - } - - private fun requestCameraIfNeeded() { - if (ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.CAMERA) == + private fun refreshCameraPermission() + { + cameraGranted = ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED - ) { + if (mode != ConfigMode.Qr) return + cameraNeedsSettings = + requireContext() + .getSharedPreferences(CAMERA_PREFS, Context.MODE_PRIVATE) + .getBoolean(CAMERA_REQUESTED, false) && + !shouldShowRequestPermissionRationale(Manifest.permission.CAMERA) + cameraDialogVisible = !cameraGranted && !cameraRequestInFlight + if (cameraGranted) + { startCamera() - } else { + } + else + { + stopCamera() + } + } + + private fun confirmCameraPermission() + { + if (cameraRequestInFlight || mode != ConfigMode.Qr) return + cameraDialogVisible = false + if (cameraNeedsSettings) + { + startActivity( + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, "package:${requireContext().packageName}".toUri()), + ) + } + else + { + requireContext() + .getSharedPreferences(CAMERA_PREFS, Context.MODE_PRIVATE) + .edit() + .putBoolean(CAMERA_REQUESTED, true) + .apply() + cameraRequestInFlight = true requestCameraPerm.launch(Manifest.permission.CAMERA) } } - @OptIn(ExperimentalGetImage::class) - private fun startCamera() { + private fun startCamera() + { val previewTarget = previewView ?: return + if (!cameraGranted || mode != ConfigMode.Qr || isQrLoading || cameraBindingTarget === previewTarget) return + cameraBindingTarget = previewTarget + val generation = cameraGeneration val providerFuture = ProcessCameraProvider.getInstance(requireContext()) - providerFuture.addListener({ - val provider = providerFuture.get() - val preview = Preview.Builder().build().also { - it.setSurfaceProvider(previewTarget.surfaceProvider) - } - val analysis = ImageAnalysis.Builder() - .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) - .build().also { useCase -> - useCase.setAnalyzer(cameraExecutor) { proxy -> - val mediaImage = proxy.image - if (mediaImage != null) { - val nv21 = yuv420888ToNv21(mediaImage) - val width = mediaImage.width - val height = mediaImage.height - val source = PlanarYUVLuminanceSource( - nv21, - width, - height, - 0, - 0, - width, - height, - false, - ) - val rotated = when (proxy.imageInfo.rotationDegrees) { - 90, 270 -> source.rotateCounterClockwise() - else -> source - } - val bitmap = BinaryBitmap(HybridBinarizer(rotated)) - try { - val result = qrReader.decodeWithState(bitmap) - onQrDecoded(result.text) - } catch (_: NotFoundException) { - } finally { - proxy.close() + providerFuture.addListener( + { + if (generation != cameraGeneration || previewView !== previewTarget || + mode != ConfigMode.Qr || view == null || !cameraGranted) { + return@addListener + } + val provider = providerFuture.get() + val preview = + Preview.Builder().build().also { + it.setSurfaceProvider(previewTarget.surfaceProvider) + } + val analysis = + ImageAnalysis + .Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + .also { useCase -> + useCase.setAnalyzer(cameraExecutor) { proxy -> + try + { + val plane = proxy.planes.firstOrNull() ?: return@setAnalyzer + val source = + qrLuminance( + plane.buffer, + proxy.width, + proxy.height, + plane.rowStride, + plane.pixelStride, + proxy.imageInfo.rotationDegrees, + ) + val result = qrReader.decodeWithState(BinaryBitmap(HybridBinarizer(source))) + onQrDecoded(result.text) + } + catch (_: ReaderException) + { + // Most camera frames do not contain a complete, readable QR code. + } + catch (_: IllegalArgumentException) + { + // Ignore an incomplete camera frame and try the next one. + } + finally + { + qrReader.reset() + proxy.close() + } } - } else { - proxy.close() } - } - } - provider.unbindAll() - provider.bindToLifecycle( - viewLifecycleOwner, - CameraSelector.DEFAULT_BACK_CAMERA, - preview, - analysis, - ) - }, cameraExecutor) - } - - private fun yuv420888ToNv21(image: Image): ByteArray { - val yPlane = image.planes[0].buffer - val uPlane = image.planes[1].buffer - val vPlane = image.planes[2].buffer - val ySize = yPlane.remaining() - val uSize = uPlane.remaining() - val vSize = vPlane.remaining() - val nv21 = ByteArray(ySize + uSize + vSize) - yPlane.get(nv21, 0, ySize) - vPlane.get(nv21, ySize, vSize) - uPlane.get(nv21, ySize + vSize, uSize) - return nv21 + provider.unbindAll() + provider.bindToLifecycle( + viewLifecycleOwner, + CameraSelector.DEFAULT_BACK_CAMERA, + preview, + analysis, + ) + }, + cameraExecutor + ) } - private fun onQrDecoded(raw: String) { - if (!raw.startsWith("taler-pos://")) return + private fun onQrDecoded(raw: String) + { + if (merchantSetupConfig(raw.toUri()) == null) return stopCamera() isQrLoading = true val intent = Intent(Intent.ACTION_VIEW, raw.toUri()) (requireActivity() as MainActivity).handleSetupIntent(intent) } - private fun stopCamera() { - try { + private fun stopCamera() + { + cameraGeneration++ + if (cameraBindingTarget == null) return + cameraBindingTarget = null + try + { ProcessCameraProvider.getInstance(requireContext()).get().unbindAll() - } catch (_: Exception) { + } + catch (_: Exception) + { } } + + companion object + { + private const val STATE_MODE = "config.mode" + private const val STATE_MERCHANT_URL = "config.merchantUrl" + private const val STATE_USERNAME = "config.username" + private const val STATE_SAVE_TOKEN = "config.saveToken" + private const val STATE_CAMERA_REQUEST = "config.cameraRequest" + private const val CAMERA_PREFS = "config.cameraPermission" + private const val CAMERA_REQUESTED = "requested" + } } @Composable @@ -479,22 +608,72 @@ private fun ConfigScreen( onSaveTokenChanged: (Boolean) -> Unit, previewContent: @Composable () -> Unit, onConnect: () -> Unit, + onCancelLogin: () -> Unit = {}, initialPasswordVisible: Boolean = false, -) { + mfaPrompt: LoginPrompt? = null, + connectionPermissionPending: Boolean = false, + cameraDialogVisible: Boolean = false, + cameraNeedsSettings: Boolean = false, + onCameraPermissionConfirm: () -> Unit = {}, + onCameraPermissionDismiss: () -> Unit = {}, +) +{ PosTheme { + if (cameraDialogVisible) + { + AlertDialog( + onDismissRequest = onCameraPermissionDismiss, + title = { Text(stringResource(R.string.config_camera_title)) }, + text = { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text(stringResource(R.string.config_camera_explanation)) + if (cameraNeedsSettings) Text(stringResource(R.string.config_camera_settings_instructions)) + } + }, + confirmButton = { + TextButton(onClick = onCameraPermissionConfirm) { + Text( + stringResource( + if (cameraNeedsSettings) + { + R.string.config_camera_open_settings + } + else + { + R.string.config_camera_allow + }, + ), + ) + } + }, + dismissButton = { + TextButton(onClick = onCameraPermissionDismiss) { + Text(stringResource(R.string.config_camera_manual)) + } + }, + ) + } val isTabletLayout = LocalConfiguration.current.smallestScreenWidthDp >= 720 val focusManager = LocalFocusManager.current val keyboardController = LocalSoftwareKeyboardController.current val formListState = rememberLazyListState() Box( - modifier = Modifier - .fillMaxSize() - .systemBarsPadding(), + modifier = + Modifier + .fillMaxSize() + .systemBarsPadding() + .imePadding(), + contentAlignment = Alignment.TopCenter, ) { Column( - modifier = Modifier - .fillMaxSize() - .padding(20.dp), + modifier = + Modifier + .widthIn(max = if (isTabletLayout) 960.dp else 640.dp) + .fillMaxSize() + .padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp), ) { RowButtons( @@ -502,11 +681,13 @@ private fun ConfigScreen( onManual = { onModeChanged(ConfigMode.Manual) }, onQr = { onModeChanged(ConfigMode.Qr) }, ) - if (mode == ConfigMode.Manual) { + if (mode == ConfigMode.Manual) + { ManualConfigScreen( - modifier = Modifier - .fillMaxWidth() - .weight(1f, fill = false), + modifier = + Modifier + .fillMaxWidth() + .weight(1f), merchantUrl = merchantUrl, username = username, token = token, @@ -522,20 +703,27 @@ private fun ConfigScreen( keyboardController = keyboardController, initialPasswordVisible = initialPasswordVisible, ) - } else { - if (isTabletLayout) { + } + else + { + if (isTabletLayout) + { TabletQrConfigScreen( - modifier = Modifier - .fillMaxWidth() - .weight(1f), + modifier = + Modifier + .fillMaxWidth() + .weight(1f), isQrLoading = isQrLoading, previewContent = previewContent, ) - } else { + } + else + { PhoneQrConfigScreen( - modifier = Modifier - .fillMaxWidth() - .weight(1f), + modifier = + Modifier + .fillMaxWidth() + .weight(1f), isQrLoading = isQrLoading, previewContent = previewContent, ) @@ -543,17 +731,54 @@ private fun ConfigScreen( } } - if (isSubmitting) { - Dialog(onDismissRequest = {}) { + if (mfaPrompt != null && !connectionPermissionPending) + { + Dialog( + onDismissRequest = mfaPrompt::cancel, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + when (mfaPrompt) + { + is LoginPrompt.Method -> + { + MfaMethodContent( + mfaPrompt.challenges, + mfaPrompt::select, + mfaPrompt::cancel, + ) + } + + is LoginPrompt.Code -> + { + MfaCodeContent( + mfaPrompt.challenge, + mfaPrompt.code, + mfaPrompt.error, + mfaPrompt::updateCode, + mfaPrompt::submit, + mfaPrompt::cancel, + ) + } + } + } + } + else if (isSubmitting && !connectionPermissionPending) + { + Dialog(onDismissRequest = onCancelLogin) { Surface( shape = MaterialTheme.shapes.large, tonalElevation = 6.dp, ) { - Box( + Column( modifier = Modifier.padding(24.dp), - contentAlignment = Alignment.Center, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), ) { CircularProgressIndicator() + Text(stringResource(R.string.config_connecting)) + TextButton(onClick = onCancelLogin) { + Text(stringResource(android.R.string.cancel)) + } } } } @@ -570,16 +795,17 @@ private fun ManualConfigScreen( token: String, saveToken: Boolean, isSubmitting: Boolean, - formListState: androidx.compose.foundation.lazy.LazyListState, + formListState: LazyListState, onMerchantUrlChanged: (String) -> Unit, onUsernameChanged: (String) -> Unit, onTokenChanged: (String) -> Unit, onSaveTokenChanged: (Boolean) -> Unit, onConnect: () -> Unit, - focusManager: androidx.compose.ui.focus.FocusManager, - keyboardController: androidx.compose.ui.platform.SoftwareKeyboardController?, + focusManager: FocusManager, + keyboardController: SoftwareKeyboardController?, initialPasswordVisible: Boolean = false, -) { +) +{ LazyColumn( modifier = modifier, state = formListState, @@ -591,14 +817,24 @@ private fun ManualConfigScreen( onValueChange = onMerchantUrlChanged, modifier = Modifier.fillMaxWidth(), label = { Text(stringResource(R.string.config_merchant_url)) }, - prefix = { Text("https://") }, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Uri, - imeAction = ImeAction.Next, - ), - keyboardActions = KeyboardActions( - onNext = { focusManager.moveFocus(FocusDirection.Down) }, - ), + prefix = { + Text( + text = "https://", + modifier = Modifier.padding(end = 8.dp), + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + singleLine = true, + keyboardOptions = + KeyboardOptions( + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Next, + ), + keyboardActions = + KeyboardActions( + onNext = { focusManager.moveFocus(FocusDirection.Down) }, + ), ) } item { @@ -607,12 +843,14 @@ private fun ManualConfigScreen( onValueChange = onUsernameChanged, modifier = Modifier.fillMaxWidth(), label = { Text(stringResource(R.string.config_username)) }, - keyboardOptions = KeyboardOptions( - imeAction = ImeAction.Next, - ), - keyboardActions = KeyboardActions( - onNext = { focusManager.moveFocus(FocusDirection.Down) }, - ), + keyboardOptions = + KeyboardOptions( + imeAction = ImeAction.Next, + ), + keyboardActions = + KeyboardActions( + onNext = { focusManager.moveFocus(FocusDirection.Down) }, + ), ) } item { @@ -622,47 +860,63 @@ private fun ManualConfigScreen( onValueChange = onTokenChanged, modifier = Modifier.fillMaxWidth(), label = { Text(stringResource(R.string.config_password)) }, - visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), + visualTransformation = + if (passwordVisible) + { + VisualTransformation.None + } + else + { + PasswordVisualTransformation() + }, trailingIcon = { IconButton( onClick = { passwordVisible = !passwordVisible }, ) { Icon( imageVector = if (passwordVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff, - contentDescription = null, + contentDescription = + stringResource( + if (passwordVisible) R.string.password_hide else R.string.password_show, + ), ) } }, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Done, - ), - keyboardActions = KeyboardActions( - onDone = { - focusManager.clearFocus() - keyboardController?.hide() - }, - ), + keyboardOptions = + KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + ), + keyboardActions = + KeyboardActions( + onDone = { + focusManager.clearFocus() + keyboardController?.hide() + }, + ), ) } item { - Row( + Column( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + verticalArrangement = Arrangement.spacedBy(12.dp), ) { Row( + modifier = + Modifier.fillMaxWidth().toggleable( + value = saveToken, + role = Role.Checkbox, + onValueChange = onSaveTokenChanged, + ), verticalAlignment = Alignment.CenterVertically, ) { - Checkbox( - checked = saveToken, - onCheckedChange = { onSaveTokenChanged(it) }, - ) - Text(stringResource(R.string.config_save_password)) + Checkbox(checked = saveToken, onCheckedChange = null) + Text(stringResource(R.string.config_save_password), modifier = Modifier.weight(1f)) } Button( onClick = onConnect, enabled = !isSubmitting, + modifier = Modifier.fillMaxWidth(), ) { Text(stringResource(R.string.config_ok)) } @@ -698,9 +952,10 @@ private fun InstanceHelpCard( onOpenTutorial: () -> Unit, onFillDemoCredentials: () -> Unit, onFillReviewUrl: () -> Unit, -) { +) +{ var expanded by rememberSaveable { mutableStateOf(false) } - val uriHandler = androidx.compose.ui.platform.LocalUriHandler.current + val uriHandler = LocalUriHandler.current Surface( modifier = Modifier.fillMaxWidth(), @@ -709,10 +964,11 @@ private fun InstanceHelpCard( ) { Column { Row( - modifier = Modifier - .fillMaxWidth() - .clickable { expanded = !expanded } - .padding(12.dp), + modifier = + Modifier + .fillMaxWidth() + .clickable { expanded = !expanded } + .padding(12.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( @@ -782,7 +1038,8 @@ private fun TabletQrConfigScreen( modifier: Modifier, isQrLoading: Boolean, previewContent: @Composable () -> Unit, -) { +) +{ BoxWithConstraints( modifier = modifier, contentAlignment = Alignment.Center, @@ -793,14 +1050,16 @@ private fun TabletQrConfigScreen( verticalArrangement = Arrangement.spacedBy(16.dp), ) { Box( - modifier = Modifier - .size(previewSize) - .clipToBounds(), + modifier = + Modifier + .size(previewSize) + .clipToBounds(), ) { previewContent() } Text(stringResource(R.string.scan_qr_hint)) - if (isQrLoading) { + if (isQrLoading) + { CircularProgressIndicator() } } @@ -812,28 +1071,32 @@ private fun PhoneQrConfigScreen( modifier: Modifier, isQrLoading: Boolean, previewContent: @Composable () -> Unit, -) { +) +{ BoxWithConstraints( modifier = modifier, ) { val previewSize = minOf(maxWidth * 0.8f, maxHeight * 0.6f) Column( - modifier = Modifier - .fillMaxWidth() - .padding(top = 12.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(top = 12.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp), ) { Box( - modifier = Modifier - .size(previewSize) - .clipToBounds(), + modifier = + Modifier + .size(previewSize) + .clipToBounds(), ) { previewContent() } Text(stringResource(R.string.scan_qr_hint)) - if (isQrLoading) { + if (isQrLoading) + { CircularProgressIndicator() } } @@ -845,19 +1108,21 @@ private fun RowButtons( mode: ConfigMode, onManual: () -> Unit, onQr: () -> Unit, -) { +) +{ SingleChoiceSegmentedButtonRow( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min), ) { SegmentedButton( selected = mode == ConfigMode.Manual, onClick = onManual, icon = {}, - shape = SegmentedButtonDefaults.itemShape( - index = 0, - count = 2, - ), - modifier = Modifier.weight(1f), + shape = + SegmentedButtonDefaults.itemShape( + index = 0, + count = 2, + ), + modifier = Modifier.weight(1f).fillMaxHeight(), ) { Text(stringResource(R.string.config_manual_label)) } @@ -865,11 +1130,12 @@ private fun RowButtons( selected = mode == ConfigMode.Qr, onClick = onQr, icon = {}, - shape = SegmentedButtonDefaults.itemShape( - index = 1, - count = 2, - ), - modifier = Modifier.weight(1f), + shape = + SegmentedButtonDefaults.itemShape( + index = 1, + count = 2, + ), + modifier = Modifier.weight(1f).fillMaxHeight(), ) { Text(stringResource(R.string.config_qr_label)) } @@ -882,9 +1148,13 @@ internal fun ConfigScreenContent( username: String, token: String, passwordVisible: Boolean = false, -) { + mode: ConfigMode = ConfigMode.Manual, + cameraDialogVisible: Boolean = false, + cameraNeedsSettings: Boolean = false, +) +{ ConfigScreen( - mode = ConfigMode.Manual, + mode = mode, merchantUrl = merchantUrl, username = username, token = token, @@ -899,5 +1169,7 @@ internal fun ConfigScreenContent( previewContent = {}, onConnect = {}, initialPasswordVisible = passwordVisible, + cameraDialogVisible = cameraDialogVisible, + cameraNeedsSettings = cameraNeedsSettings, ) } 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 @@ -19,14 +19,30 @@ package net.taler.merchantpos.config import android.content.Context import android.content.Context.MODE_PRIVATE import android.util.Log + import androidx.annotation.UiThread import androidx.annotation.WorkerThread import androidx.core.net.toUri import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData + +import net.taler.common.ChallengeConfirmRequest +import net.taler.common.ChallengesResponse +import net.taler.common.CurrencySpecification +import net.taler.common.Event +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.MerchantConfig +import net.taler.merchantpos.BuildConfig +import net.taler.merchantpos.R + import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.plugins.ClientRequestException +import io.ktor.client.plugins.timeout +import io.ktor.client.request.HttpRequestBuilder import io.ktor.client.request.get import io.ktor.client.request.header import io.ktor.client.request.post @@ -37,6 +53,8 @@ import io.ktor.http.HttpHeaders.Authorization import io.ktor.http.HttpStatusCode import io.ktor.http.HttpStatusCode.Companion.Unauthorized import io.ktor.http.contentType +import io.ktor.serialization.ContentConvertException +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -44,34 +62,12 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext -import kotlinx.serialization.KSerializer import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable 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.Json -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.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" @@ -101,25 +97,30 @@ private const val SETTINGS_CACHED_RUNTIME_CONFIG = "cachedRuntimeConfig" internal const val NEW_CONFIG_URL_DEMO = "my.taler-ops.ch" private val VERSION = Version.parse(BuildConfig.BACKEND_API_VERSION)!! -private val json = Json { - ignoreUnknownKeys = true - encodeDefaults = true -} +private val json = + Json { + ignoreUnknownKeys = true + encodeDefaults = true + } private val TAG = ConfigManager::class.java.simpleName -enum class InitialOrderScreen(val prefValue: String) { +enum class InitialOrderScreen( + val prefValue: String, +) +{ AmountEntry("amountEntry"), - Inventory("inventory"); + Inventory("inventory"), + ; - companion object { - fun fromPrefValue(value: String?): InitialOrderScreen { - return entries.firstOrNull { it.prefValue == value } ?: AmountEntry - } + companion object + { + fun fromPrefValue(value: String?): InitialOrderScreen = + entries.firstOrNull { it.prefValue == value } ?: AmountEntry } } -@kotlinx.serialization.Serializable +@Serializable private data class MerchantBackendConfigResponse( val version: String, val currency: String, @@ -146,25 +147,27 @@ private data class CachedRuntimeConfig( val currencySpec: CurrencySpecification? = null, ) -/* -- Limited access token -- */ -@kotlinx.serialization.Serializable +// -- Limited access token -- +@Serializable private data class LimitedTokenResponse( val token: String, val scope: String, val refreshable: Boolean, // Using Unit here as a placeholder if we don't need the actual expiration object // or we could use net.taler.common.TokenExpiration - val expiration: kotlinx.serialization.json.JsonElement + val expiration: JsonElement, ) -class ChallengeRequiredException(val challengeResponse: ChallengesResponse) : Exception() - -/* -- Limited access token END -- */ +class ChallengeRequiredException( + val challengeResponse: ChallengesResponse, +) : Exception() +// -- Limited access token END -- -interface ConfigurationReceiver { +interface ConfigurationReceiver +{ /** - * Returns null if the configuration was valid, or a error string for user display otherwise. + * Returns null if the configuration was valid, or an error string for user display otherwise. */ suspend fun onConfigurationReceived( posConfig: PosConfig, @@ -172,6 +175,7 @@ interface ConfigurationReceiver { currencySpec: CurrencySpecification?, ): String? + /** Applies an inventory refresh; returns null on success or an error for display. */ suspend fun onInventoryUpdated( posConfig: PosConfig, currency: String, @@ -183,20 +187,57 @@ class ConfigManager( private val context: Context, private val scope: CoroutineScope, private val httpClient: HttpClient, -) { - - private val _sessionExpired = MutableLiveData<Unit>() - val sessionExpired: LiveData<Unit> = _sessionExpired - + private val requestTimeoutMillis: Long = 30_000, + private val beforeConnection: suspend (String) -> Unit = {}, +) +{ private val prefs = context.getSharedPreferences(SETTINGS_NAME, MODE_PRIVATE) private val configurationReceivers = ArrayList<ConfigurationReceiver>() private var inventoryRefreshJob: Job? = null + private var configFetchJob: Job? = null + private var fetchingConfig: Config? = null + private var fetchingSilently = false + + private fun HttpRequestBuilder.configTimeout() + { + timeout { + requestTimeoutMillis = this@ConfigManager.requestTimeoutMillis + socketTimeoutMillis = this@ConfigManager.requestTimeoutMillis + connectTimeoutMillis = minOf(15_000, this@ConfigManager.requestTimeoutMillis) + } + } + + @UiThread + fun cancelConfigFetch() + { + configFetchJob?.cancel() + configFetchJob = null + fetchingConfig = null + mConfigUpdateResult.value = null + } + private var cachedRuntimeConfig: CachedRuntimeConfig? = null - init { + init + { migrateLegacyPrefsIfNeeded() } + private val _sessionExpired = MutableLiveData<Event<Unit>>() + private val mMerchantIdentity = MutableLiveData<String?>(null) + private val mInitialOrderScreen = + MutableLiveData( + InitialOrderScreen.fromPrefValue( + prefs.getString(SETTINGS_INITIAL_ORDER_SCREEN, InitialOrderScreen.AmountEntry.prefValue), + ), + ) + private val mConfigUpdateResult = MutableLiveData<ConfigUpdateResult?>() + + val sessionExpired: LiveData<Event<Unit>> = _sessionExpired + val merchantIdentity: LiveData<String?> = mMerchantIdentity + val initialOrderScreenLiveData: LiveData<InitialOrderScreen> = mInitialOrderScreen + val configUpdateResult: LiveData<ConfigUpdateResult?> = mConfigUpdateResult + @Volatile var config: Config = Config.New( @@ -205,13 +246,14 @@ class ConfigManager( savePassword = prefs.getBoolean(SETTINGS_SAVE_PASSWORD, true), ) - init { - restoreCachedRuntimeConfig() - } - @Volatile var merchantConfig: MerchantConfig? = null - private set + private set(value) + { + field = value + val identity = value?.baseUrl?.trimEnd('/') + if (mMerchantIdentity.value != identity) mMerchantIdentity.value = identity + } @Volatile var currency: String? = null @@ -225,26 +267,24 @@ class ConfigManager( var backendVersion: String? = null private set - private val mInitialOrderScreen = MutableLiveData( - InitialOrderScreen.fromPrefValue( - prefs.getString(SETTINGS_INITIAL_ORDER_SCREEN, InitialOrderScreen.AmountEntry.prefValue), - ) - ) - val initialOrderScreenLiveData: LiveData<InitialOrderScreen> = mInitialOrderScreen - var initialOrderScreen: InitialOrderScreen get() = mInitialOrderScreen.value ?: InitialOrderScreen.AmountEntry - set(value) { - prefs.edit() + set(value) + { + prefs + .edit() .putString(SETTINGS_INITIAL_ORDER_SCREEN, value.prefValue) .apply() mInitialOrderScreen.value = value } - private val mConfigUpdateResult = MutableLiveData<ConfigUpdateResult?>() - val configUpdateResult: LiveData<ConfigUpdateResult?> = mConfigUpdateResult + init + { + restoreCachedRuntimeConfig() + } - fun addConfigurationReceiver(receiver: ConfigurationReceiver) { + fun addConfigurationReceiver(receiver: ConfigurationReceiver) + { configurationReceivers.add(receiver) cachedRuntimeConfig?.let { cached -> scope.launch { @@ -259,24 +299,28 @@ class ConfigManager( currency: String, currencySpec: CurrencySpecification? = null, initialOrderScreen: InitialOrderScreen = InitialOrderScreen.Inventory, - ) { - config = Config.New( - merchantUrl = merchantConfig.baseUrl, - accessToken = "", - savePassword = false, - ) + ) + { + config = + Config.New( + merchantUrl = merchantConfig.baseUrl, + accessToken = "", + savePassword = false, + ) this.merchantConfig = merchantConfig this.currency = currency this.currencySpec = currencySpec this.initialOrderScreen = initialOrderScreen - val snapshot = CachedRuntimeConfig( - posConfig = posConfig, - merchantConfig = merchantConfig, - currency = currency, - currencySpec = currencySpec, - ) - saveCachedRuntimeConfig(snapshot) + val snapshot = + CachedRuntimeConfig( + posConfig = posConfig, + merchantConfig = merchantConfig, + currency = currency, + currencySpec = currencySpec, + ) + // Screenshot fixtures must not replace the signed-in merchant's saved cache. + cachedRuntimeConfig = snapshot runBlocking { configurationReceivers.forEach { receiver -> receiver.onConfigurationReceived(posConfig, currency, currencySpec) @@ -284,35 +328,49 @@ class ConfigManager( } } - private fun migrateLegacyPrefsIfNeeded() { + private fun migrateLegacyPrefsIfNeeded() + { val legacyVersion = prefs.getInt(SETTINGS_CONFIG_VERSION, CONFIG_VERSION_NEW) - if (legacyVersion == CONFIG_VERSION_OLD) { + if (legacyVersion == CONFIG_VERSION_OLD) + { prefs.edit().clear().apply() } } @UiThread - fun reloadConfig() { + fun reloadConfig() + { fetchConfig(config, save = true, inventoryOnly = true, silent = false) } @UiThread - fun refreshConfigInBackground() { - if (!config.isValid() || !config.hasPassword()) return + fun refreshConfigInBackground() + { + if (!config.isValid() || + !config.hasPassword()) + { + return + } fetchConfig(config, save = false, inventoryOnly = true, silent = true) } @UiThread - fun refreshInventory() { + fun refreshInventory() + { inventoryRefreshJob?.cancel() - inventoryRefreshJob = scope.launch { - delay(350) - fetchConfig(config, save = false, inventoryOnly = true, silent = true) - } + inventoryRefreshJob = + scope.launch { + delay(350) + fetchConfig(config, save = false, inventoryOnly = true, silent = true) + } } @UiThread - fun fetchConfig(config: Config, save: Boolean) { + fun fetchConfig( + config: Config, + save: Boolean, + ) + { fetchConfig(config, save, inventoryOnly = false, silent = false) } @@ -322,39 +380,78 @@ class ConfigManager( save: Boolean, inventoryOnly: Boolean, silent: Boolean, - ) { + ) + { + if (configFetchJob?.isActive == true) + { + if (silent || (!fetchingSilently && fetchingConfig == config)) return + configFetchJob?.cancel() + } + fetchingConfig = config + fetchingSilently = silent if (!silent) mConfigUpdateResult.value = null - val configToSave = if (save) { - if (config.savePassword()) config else when (val c = config) { - //is Config.Old -> c.copy(password = "") - is Config.New -> c.copy(accessToken = "") + val configToSave = + if (save) + { + if (config.savePassword()) + { + config + } + else + { + when (val c = config) + { + // is Config.Old -> c.copy(password = "") + is Config.New -> c.copy(accessToken = "") + } + } + } + else + { + null } - } else null - scope.launch(Dispatchers.IO) { - try { - val url = when(val c = config) { - //is Config.Old -> c.configUrl - is Config.New -> c.merchantUrl.toUri() - .buildUpon() - .appendPath("private/pos") - .build() - .toString() - } + configFetchJob = + scope.launch(Dispatchers.IO) { + try + { + val url = + when (val c = config) + { + // is Config.Old -> c.configUrl + is Config.New -> + { + c.merchantUrl + .toUri() + .buildUpon() + .appendPath("private") + .appendPath("pos") + .build() + .toString() + } + } - val rawToken = when (val c = config) { - is Config.New -> c.accessToken - } + val rawToken = + when (val c = config) + { + is Config.New -> c.accessToken + } + beforeConnection(url) val (posConfig, apiKey) = fetchPosConfig(url, rawToken) - val merchantConfig = when (config) { - //is Config.Old -> posConfig.merchantConfig!! - is Config.New -> MerchantConfig(config.merchantUrl, apiKey) - } + val merchantConfig = + when (config) + { + // is Config.Old -> posConfig.merchantConfig!! + is Config.New -> MerchantConfig(config.merchantUrl, apiKey) + } val backendConfig: MerchantBackendConfigResponse = - httpClient.get(merchantConfig.urlFor("config")).body() + httpClient + .get(merchantConfig.urlFor("config")) { + configTimeout() + }.body() onMerchantConfigReceived( configToSave, posConfig, @@ -363,35 +460,90 @@ class ConfigManager( inventoryOnly, silent, ) - } catch (e: Exception) { - Log.e(TAG, "Error retrieving merchant config", e) - val msg = if (e is ClientRequestException) { - context.getString( - if (e.response.status == Unauthorized) R.string.config_auth_error - else R.string.config_error_network - ) - } else { - context.getString(R.string.config_error_malformed) + } + catch (e: CancellationException) + { + throw e + } + catch (_: LocalNetworkAccessDenied) + { + if (!silent) + { + withContext(Dispatchers.Main) { + mConfigUpdateResult.value = + ConfigUpdateResult.Error( + context.getString(R.string.config_local_network_required), + returnToConfig = true, + ) + } } + } + catch (e: Exception) + { + Log.e(TAG, "Error retrieving merchant config", e) + val msg = + if (e is ClientRequestException) + { + context.getString( + if (e.response.status == Unauthorized) + { + R.string.config_auth_error + } + else + { + R.string.config_error_network + }, + ) + } + else + { + context.getString( + if (e is SerializationException || e is ContentConvertException) + { + R.string.config_error_malformed + } + else + { + R.string.config_error_network + }, + ) + } if (!silent) onNetworkError(msg) } } } @WorkerThread - private suspend fun fetchPosConfig(url: String, rawToken: String): Pair<PosConfig, String> { - try { - val posConfig: PosConfig = httpClient.get(url) { - header(Authorization, "Bearer $rawToken") - }.body() + private suspend fun fetchPosConfig( + url: String, + rawToken: String, + ): Pair<PosConfig, String> + { + try + { + val posConfig: PosConfig = + httpClient + .get(url) { + configTimeout() + header(Authorization, "Bearer $rawToken") + }.body() return posConfig to rawToken - } catch (e: ClientRequestException) { - if (e.response.status != Unauthorized || rawToken.startsWith("secret-token:")) throw e + } + catch (e: ClientRequestException) + { + if (e.response.status != Unauthorized || + rawToken.startsWith("secret-token:")) + { + throw e + } } val prefixedToken = "secret-token:$rawToken" - val posConfig: PosConfig = httpClient.get(url) { - header(Authorization, "Bearer $prefixedToken") - }.body() + val posConfig: PosConfig = + httpClient + .get(url) { + configTimeout() + header(Authorization, "Bearer $prefixedToken") + }.body() return posConfig to prefixedToken } @@ -403,29 +555,45 @@ class ConfigManager( configResponse: MerchantBackendConfigResponse, inventoryOnly: Boolean, silent: Boolean, - ) { + ) + { val versionIncompatible = VERSION.getIncompatibleStringOrNull(context, configResponse.version) - if (versionIncompatible != null) { + if (versionIncompatible != null) + { Log.e(TAG, "Versions incompatible $configResponse") - if (!silent) mConfigUpdateResult.postValue(ConfigUpdateResult.Error(versionIncompatible)) + if (!silent) onNetworkError(versionIncompatible) return } val currencySpec = configResponse.currencies[configResponse.currency] val posConfigWithCachedStock = applyCachedStockData(posConfig) - for (receiver in configurationReceivers) { - val result = try { - if (inventoryOnly) { - receiver.onInventoryUpdated(posConfigWithCachedStock, configResponse.currency, currencySpec) - } else { - receiver.onConfigurationReceived(posConfigWithCachedStock, configResponse.currency, currencySpec) + for (receiver in configurationReceivers) + { + val result = + try + { + if (inventoryOnly) + { + receiver.onInventoryUpdated(posConfigWithCachedStock, configResponse.currency, currencySpec) + } + else + { + receiver.onConfigurationReceived( + posConfigWithCachedStock, + 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 - if (!silent) mConfigUpdateResult.postValue(ConfigUpdateResult.Error(result)) + catch (e: Exception) + { + if (e is CancellationException) throw e + Log.e(TAG, "Error handling configuration by ${receiver::class.java.simpleName}", e) + context.getString(R.string.config_error_unknown) + } + if (result != null) + { // error + if (!silent) onNetworkError(result) return } } @@ -444,19 +612,26 @@ class ConfigManager( merchantConfig = merchantConfig, currency = configResponse.currency, currencySpec = currencySpec, - ) + ), ) - if (!silent) { + if (!silent) + { mConfigUpdateResult.value = ConfigUpdateResult.Success(configResponse.currency) } } scope.launch(Dispatchers.IO) { val enrichedPosConfig = enrichProductsWithStockDetails(posConfig, merchantConfig) - if (enrichedPosConfig !== posConfig) { - for (receiver in configurationReceivers) { - try { + if (enrichedPosConfig !== posConfig) + { + for (receiver in configurationReceivers) + { + try + { receiver.onInventoryUpdated(enrichedPosConfig, configResponse.currency, currencySpec) - } catch (e: Exception) { + } + catch (e: Exception) + { + if (e is CancellationException) throw e Log.e(TAG, "Error updating stock for ${receiver::class.java.simpleName}", e) } } @@ -466,32 +641,40 @@ class ConfigManager( merchantConfig = merchantConfig, currency = configResponse.currency, currencySpec = currencySpec, - ) + ), ) } } } - private fun applyCachedStockData(posConfig: PosConfig): PosConfig { + private fun applyCachedStockData(posConfig: PosConfig): PosConfig + { val cached = cachedRuntimeConfig ?: return posConfig - val cachedByProductId = cached.posConfig.products - .filter { it.productId != null } - .associateBy { it.productId } + val cachedByProductId = + cached.posConfig.products + .filter { it.productId != null } + .associateBy { it.productId } var changed = false - val products = posConfig.products.map { product -> - val cachedProduct = product.productId?.let { cachedByProductId[it] } - ?: return@map product - if (cachedProduct.totalSold == null && cachedProduct.totalLost == null && - cachedProduct.unitTotalSold == null && cachedProduct.unitTotalLost == null - ) return@map product - changed = true - product.copy( - totalSold = cachedProduct.totalSold, - totalLost = cachedProduct.totalLost, - unitTotalSold = cachedProduct.unitTotalSold, - unitTotalLost = cachedProduct.unitTotalLost, - ) - } + val products = + posConfig.products.map { product -> + val cachedProduct = + product.productId?.let { cachedByProductId[it] } + ?: return@map product + if (cachedProduct.totalSold == null && + cachedProduct.totalLost == null && + cachedProduct.unitTotalSold == null && + cachedProduct.unitTotalLost == null) + { + return@map product + } + changed = true + product.copy( + totalSold = cachedProduct.totalSold, + totalLost = cachedProduct.totalLost, + unitTotalSold = cachedProduct.unitTotalSold, + unitTotalLost = cachedProduct.unitTotalLost, + ) + } return if (changed) posConfig.copy(products = products) else posConfig } @@ -499,26 +682,35 @@ class ConfigManager( private suspend fun enrichProductsWithStockDetails( posConfig: PosConfig, merchantConfig: MerchantConfig, - ): PosConfig { - val enrichedProducts = posConfig.products.map { product -> - val pid = product.productId ?: return@map product - if (product.stockLimit == null) return@map product - try { - val url = merchantConfig.urlFor("private/products/" + pid) - val detail: ProductDetail = httpClient.get(url) { - header(Authorization, "Bearer " + merchantConfig.apiKey) - }.body() - product.copy( - totalLost = detail.totalLost ?: product.totalLost, - totalSold = detail.totalSold ?: product.totalSold, - unitTotalLost = detail.unitTotalLost ?: product.unitTotalLost, - unitTotalSold = detail.unitTotalSold ?: product.unitTotalSold, - ) - } catch (e: Exception) { - Log.w(TAG, "Failed to fetch stock details for " + pid, e) - product + ): PosConfig + { + val enrichedProducts = + posConfig.products.map { product -> + val pid = product.productId ?: return@map product + if (product.stockLimit == null) return@map product + try + { + val url = merchantConfig.urlFor("private/products/" + pid) + val detail: ProductDetail = + httpClient + .get(url) { + configTimeout() + header(Authorization, "Bearer " + merchantConfig.apiKey) + }.body() + product.copy( + totalLost = detail.totalLost ?: product.totalLost, + totalSold = detail.totalSold ?: product.totalSold, + unitTotalLost = detail.unitTotalLost ?: product.unitTotalLost, + unitTotalSold = detail.unitTotalSold ?: product.unitTotalSold, + ) + } + catch (e: Exception) + { + if (e is CancellationException) throw e + Log.w(TAG, "Failed to fetch stock details for " + pid, e) + product + } } - } if (enrichedProducts == posConfig.products) return posConfig return posConfig.copy(products = enrichedProducts) } @@ -533,28 +725,36 @@ class ConfigManager( username: String, initialSecret: String, duration: TokenDuration, - challengeIds: List<String> = emptyList() - ): String { - val tokenUrl = baseUrl.toUri() - .buildUpon() - .appendPath("instances") - .appendPath(username) - .appendPath("private") - .appendPath("token") - .build() - .toString() - + challengeIds: List<String> = emptyList(), + ): String + { + val tokenUrl = + baseUrl + .toUri() + .buildUpon() + .appendPath("instances") + .appendPath(username) + .appendPath("private") + .appendPath("token") + .build() + .toString() + + beforeConnection(baseUrl) val bearer = "Bearer secret-token:$initialSecret" - val response = httpClient.post(tokenUrl) { - header(HttpHeaders.Authorization, bearer) - if (challengeIds.isNotEmpty()) { - header("Taler-Challenge-Ids", challengeIds.joinToString(",")) + val response = + httpClient.post(tokenUrl) { + configTimeout() + header(HttpHeaders.Authorization, bearer) + if (challengeIds.isNotEmpty()) + { + header("Taler-Challenge-Ids", challengeIds.joinToString(",")) + } + contentType(ContentType.Application.Json) + setBody(TokenRequest(scope = "write", duration = duration)) } - contentType(ContentType.Application.Json) - setBody(TokenRequest(scope = "write", duration = duration)) - } - if (response.status == HttpStatusCode.Accepted) { + if (response.status == HttpStatusCode.Accepted) + { val challenge: ChallengesResponse = response.body() throw ChallengeRequiredException(challenge) } @@ -567,18 +767,23 @@ class ConfigManager( suspend fun requestChallenge( baseUrl: String, username: String, - challengeId: String - ) { - val challengeUrl = baseUrl.toUri() - .buildUpon() - .appendPath("instances") - .appendPath(username) - .appendPath("challenge") - .appendPath(challengeId) - .build() - .toString() - + challengeId: String, + ) + { + val challengeUrl = + baseUrl + .toUri() + .buildUpon() + .appendPath("instances") + .appendPath(username) + .appendPath("challenge") + .appendPath(challengeId) + .build() + .toString() + + beforeConnection(baseUrl) httpClient.post(challengeUrl) { + configTimeout() contentType(ContentType.Application.Json) setBody(buildJsonObject { }) } @@ -588,30 +793,38 @@ class ConfigManager( baseUrl: String, username: String, challengeId: String, - tan: String - ) { - val confirmUrl = baseUrl.toUri() - .buildUpon() - .appendPath("instances") - .appendPath(username) - .appendPath("challenge") - .appendPath(challengeId) - .appendPath("confirm") - .build() - .toString() - + tan: String, + ) + { + val confirmUrl = + baseUrl + .toUri() + .buildUpon() + .appendPath("instances") + .appendPath(username) + .appendPath("challenge") + .appendPath(challengeId) + .appendPath("confirm") + .build() + .toString() + + beforeConnection(baseUrl) httpClient.post(confirmUrl) { + configTimeout() contentType(ContentType.Application.Json) setBody(ChallengeConfirmRequest(tan = tan)) } } @UiThread - fun forgetPassword() { - config = when (val c = config) { - //is Config.Old -> c.copy(password = "") - is Config.New -> c.copy(accessToken = "") - } + fun forgetPassword() + { + config = + when (val c = config) + { + // is Config.Old -> c.copy(password = "") + is Config.New -> c.copy(accessToken = "") + } saveConfig(config) clearCachedRuntimeConfig() merchantConfig = null @@ -620,14 +833,16 @@ class ConfigManager( } @UiThread - fun logout() { + fun logout() + { inventoryRefreshJob?.cancel() val savePassword = config.savePassword() - config = Config.New( - merchantUrl = "", - accessToken = "", - savePassword = savePassword, - ) + config = + Config.New( + merchantUrl = "", + accessToken = "", + savePassword = savePassword, + ) saveConfig(config) clearCachedRuntimeConfig() merchantConfig = null @@ -637,8 +852,10 @@ class ConfigManager( } @UiThread - private fun saveConfig(config: Config) { - when (val c = config) { + private fun saveConfig(config: Config) + { + when (val c = config) + { // is Config.Old -> prefs.edit() // .putInt(SETTINGS_CONFIG_VERSION, CONFIG_VERSION_OLD) // .putString(SETTINGS_CONFIG_URL, c.configUrl) @@ -646,57 +863,88 @@ class ConfigManager( // .putString(SETTINGS_PASSWORD, c.password) // .putBoolean(SETTINGS_SAVE_PASSWORD, c.savePassword) // .apply() - is Config.New -> prefs.edit() - .putInt(SETTINGS_CONFIG_VERSION, CONFIG_VERSION_NEW) - .putString(SETTINGS_MERCHANT_URL, c.merchantUrl) - .putString(SETTINGS_ACCESS_TOKEN, c.accessToken) - .putBoolean(SETTINGS_SAVE_PASSWORD, c.savePassword) - .apply() + is Config.New -> + { + prefs + .edit() + .putInt(SETTINGS_CONFIG_VERSION, CONFIG_VERSION_NEW) + .putString(SETTINGS_MERCHANT_URL, c.merchantUrl) + .putString(SETTINGS_ACCESS_TOKEN, c.accessToken) + .putBoolean(SETTINGS_SAVE_PASSWORD, c.savePassword) + .apply() + } } } - private fun onNetworkError(msg: String) = scope.launch(Dispatchers.Main) { - mConfigUpdateResult.value = ConfigUpdateResult.Error(msg) - } + private suspend fun onNetworkError(msg: String) = + withContext(Dispatchers.Main) { + mConfigUpdateResult.value = ConfigUpdateResult.Error(msg) + } - private fun restoreCachedRuntimeConfig() { - if (!config.isValid() || !config.hasPassword()) { + private fun restoreCachedRuntimeConfig() + { + if (!config.isValid() || + !config.hasPassword()) + { clearCachedRuntimeConfig() return } val encoded = prefs.getString(SETTINGS_CACHED_RUNTIME_CONFIG, null) ?: return - val restored = runCatching { - json.decodeFromString<CachedRuntimeConfig>(encoded) - }.getOrElse { error -> - Log.e(TAG, "Failed to restore cached runtime config", error) - clearCachedRuntimeConfig() - return - } + val restored = + runCatching { + json.decodeFromString<CachedRuntimeConfig>(encoded) + }.getOrElse { error -> + Log.e(TAG, "Failed to restore cached runtime config", error) + clearCachedRuntimeConfig() + return + } cachedRuntimeConfig = restored merchantConfig = restored.merchantConfig currency = restored.currency currencySpec = restored.currencySpec } - private fun saveCachedRuntimeConfig(snapshot: CachedRuntimeConfig) { + private fun saveCachedRuntimeConfig(snapshot: CachedRuntimeConfig) + { cachedRuntimeConfig = snapshot - prefs.edit() + prefs + .edit() .putString(SETTINGS_CACHED_RUNTIME_CONFIG, json.encodeToString(CachedRuntimeConfig.serializer(), snapshot)) .apply() } - private fun clearCachedRuntimeConfig() { + private fun clearCachedRuntimeConfig() + { cachedRuntimeConfig = null prefs.edit().remove(SETTINGS_CACHED_RUNTIME_CONFIG).apply() } - internal fun notifySessionExpired() { - // do it on the Main thread - _sessionExpired.postValue(Unit) + @UiThread + internal fun handleUnauthorized(requestUrl: String, authorization: String?) + { + val merchant = merchantConfig ?: return + val privateUrl = merchant.urlFor("private").trimEnd('/') + "/" + // Login, MFA and token-prefix probes share the HTTP client with the + // active merchant. Their failures must not invalidate its session. + if (!requestUrl.startsWith(privateUrl) || + requestUrl.substringBefore('?') == privateUrl + "token" || + authorization != "Bearer ${merchant.apiKey}") + { + return + } + forgetPassword() + _sessionExpired.value = Event(Unit) } } -sealed class ConfigUpdateResult { - data class Error(val msg: String) : ConfigUpdateResult() - data class Success(val currency: String) : ConfigUpdateResult() +sealed class ConfigUpdateResult +{ + data class Error( + val msg: String, + val returnToConfig: Boolean = false, + ) : ConfigUpdateResult() + + data class Success( + val currency: String, + ) : ConfigUpdateResult() } 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 @@ -20,58 +20,67 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup + import androidx.appcompat.app.AppCompatDelegate import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuAnchorType import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuDefaults -import androidx.compose.material3.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.TextButton -import androidx.compose.material3.ExposedDropdownMenuAnchorType -import androidx.compose.material3.DropdownMenu import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.core.os.LocaleListCompat -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.withStyle -import androidx.compose.ui.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 androidx.core.net.toUri +import androidx.core.os.LocaleListCompat +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels + import net.taler.merchantpos.BuildConfig +import net.taler.merchantpos.MainActivity +import net.taler.merchantpos.MainViewModel +import net.taler.merchantpos.PosDestination +import net.taler.merchantpos.R import net.taler.merchantpos.compose.PosOutlinedCard import net.taler.merchantpos.compose.PosTheme -import net.taler.merchantpos.R -import net.taler.merchantpos.PosDestination +import net.taler.merchantpos.paymentoptions.PaymentOptionsEntry +import net.taler.merchantpos.paymentoptions.PaymentOptionsSummary + import java.util.Locale private data class LanguageOption( @@ -85,29 +94,51 @@ private data class InitialOrderOption( ) private val SettingsControlShape: Shape = RoundedCornerShape(14.dp) +private val SettingsActionShape: Shape = RoundedCornerShape(28.dp) -class GeneralSettingsFragment : Fragment() { - - private val viewModel: net.taler.merchantpos.MainViewModel by activityViewModels() +class GeneralSettingsFragment : Fragment() +{ + private val viewModel: MainViewModel by activityViewModels() + private val configDraft: ConfigDraftViewModel by activityViewModels() private val configManager by lazy { viewModel.configManager } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, - ): View { + ): View + { val languageOptions = createLanguageOptions() val initialOrderOptions = createInitialOrderOptions() val selectedTag = getCurrentLanguageTag() return ComposeView(requireContext()).apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) val config = configManager.config - val username = if (config is Config.New && config.merchantUrl.isNotBlank()) { - config.merchantUrl.toUri().pathSegments - ?.lastOrNull { it.isNotBlank() } - } else null + val username = + if (config is Config.New && + config.merchantUrl.isNotBlank()) + { + config.merchantUrl + .toUri() + .pathSegments + ?.lastOrNull { it.isNotBlank() } + } + else + { + null + } setContent { + val optionsSummary by viewModel.paymentOptions.summary.observeAsState(PaymentOptionsSummary()) GeneralSettingsScreen( + paymentOptionsEntry = { + PaymentOptionsEntry( + summary = optionsSummary, + onOpen = { + (requireActivity() as MainActivity) + .navigateTo(PosDestination.PaymentOptions) + }, + ) + }, languageOptions = languageOptions, initialSelectedTag = selectedTag, onLanguageSelected = ::applyLanguage, @@ -115,11 +146,13 @@ class GeneralSettingsFragment : Fragment() { initialSelectedOrderScreen = configManager.initialOrderScreen, onInitialOrderSelected = { configManager.initialOrderScreen = it }, onInstanceSettingsClick = { - (requireActivity() as net.taler.merchantpos.MainActivity).navigateTo(PosDestination.Config) + (requireActivity() as MainActivity).navigateTo(PosDestination.Config) }, onLogoutClick = { + configDraft.clear() + viewModel.paymentOptions.logout() configManager.logout() - (requireActivity() as net.taler.merchantpos.MainActivity).navigateTo( + (requireActivity() as MainActivity).navigateTo( PosDestination.Config, clearBackStack = true, ) @@ -132,8 +165,8 @@ class GeneralSettingsFragment : Fragment() { } } - private fun createInitialOrderOptions(): List<InitialOrderOption> { - return listOf( + private fun createInitialOrderOptions(): List<InitialOrderOption> = + listOf( InitialOrderOption( screen = InitialOrderScreen.AmountEntry, label = getString(R.string.menu_amount_entry), @@ -143,36 +176,49 @@ class GeneralSettingsFragment : Fragment() { label = getString(R.string.menu_order), ), ) - } - private fun createLanguageOptions(): List<LanguageOption> { + private fun createLanguageOptions(): List<LanguageOption> + { val values = resources.getStringArray(R.array.settings_language_values) val labels = resources.getStringArray(R.array.settings_language_labels) return values.indices.map { index -> val tag = values[index] - val label = if (index < labels.size && labels[index].isNotBlank()) { - labels[index] - } else if (tag.isBlank()) { - getString(R.string.settings_language_system_default) - } else { - val locale = Locale.forLanguageTag(tag) - locale.getDisplayName(Locale.getDefault()).replaceFirstChar { it.titlecase(locale) } - } + val label = + if (index < labels.size && + labels[index].isNotBlank()) + { + labels[index] + } + else if (tag.isBlank()) + { + getString(R.string.settings_language_system_default) + } + else + { + val locale = Locale.forLanguageTag(tag) + locale.getDisplayName(Locale.getDefault()).replaceFirstChar { it.titlecase(locale) } + } LanguageOption(tag, label) } } - private fun getCurrentLanguageTag(): String { + private fun getCurrentLanguageTag(): String + { val locales = AppCompatDelegate.getApplicationLocales() return if (locales.isEmpty) "" else locales[0]?.toLanguageTag().orEmpty() } - private fun applyLanguage(languageTag: String) { - val locales = if (languageTag.isBlank()) { - LocaleListCompat.getEmptyLocaleList() - } else { - LocaleListCompat.forLanguageTags(languageTag) - } + private fun applyLanguage(languageTag: String) + { + val locales = + if (languageTag.isBlank()) + { + LocaleListCompat.getEmptyLocaleList() + } + else + { + LocaleListCompat.forLanguageTags(languageTag) + } AppCompatDelegate.setApplicationLocales(locales) } } @@ -180,6 +226,7 @@ class GeneralSettingsFragment : Fragment() { @OptIn(ExperimentalMaterial3Api::class) @Composable private fun GeneralSettingsScreen( + paymentOptionsEntry: @Composable () -> Unit, languageOptions: List<LanguageOption>, initialSelectedTag: String, onLanguageSelected: (String) -> Unit, @@ -191,152 +238,159 @@ private fun GeneralSettingsScreen( backendVersion: String? = null, username: String? = null, merchantUrl: String? = null, -) { + initialLogoutConfirmationStep: Int = 0, +) +{ var languageExpanded by remember { mutableStateOf(false) } var selectedTag by rememberSaveable { mutableStateOf(initialSelectedTag) } - val selectedLabel = languageOptions.firstOrNull { it.languageTag == selectedTag }?.label - ?: languageOptions.firstOrNull()?.label.orEmpty() + val selectedLabel = + languageOptions.firstOrNull { it.languageTag == selectedTag }?.label + ?: languageOptions.firstOrNull()?.label.orEmpty() var initialOrderExpanded by remember { mutableStateOf(false) } var selectedInitialOrderScreen by rememberSaveable { mutableStateOf(initialSelectedOrderScreen) } - var logoutConfirmationStep by rememberSaveable { mutableStateOf(0) } + var logoutConfirmationStep by rememberSaveable { mutableIntStateOf(initialLogoutConfirmationStep) } val selectedInitialOrderLabel = initialOrderOptions.firstOrNull { it.screen == selectedInitialOrderScreen }?.label ?: initialOrderOptions.firstOrNull()?.label.orEmpty() PosTheme { - when (logoutConfirmationStep) { - 1 -> AlertDialog( - onDismissRequest = { logoutConfirmationStep = 0 }, - title = { Text(stringResource(R.string.settings_logout_button)) }, - text = { Text(stringResource(R.string.settings_logout_warning)) }, - confirmButton = { - Button(onClick = { logoutConfirmationStep = 2 }) { - Text(stringResource(R.string.payment_back_button)) - } - }, - dismissButton = { - TextButton(onClick = { logoutConfirmationStep = 0 }) { - Text(stringResource(android.R.string.cancel)) - } - }, - ) - - 2 -> AlertDialog( - onDismissRequest = { logoutConfirmationStep = 0 }, - title = { Text(stringResource(R.string.settings_logout_button)) }, - text = { Text(stringResource(R.string.settings_logout_confirmation)) }, - confirmButton = { - Button( - onClick = { - logoutConfirmationStep = 0 - onLogoutClick() - }, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.error, - ), - ) { - Text(stringResource(R.string.settings_logout_button)) - } - }, - dismissButton = { - TextButton(onClick = { logoutConfirmationStep = 0 }) { - Text(stringResource(android.R.string.cancel)) - } - }, - ) - } + when (logoutConfirmationStep) + { + 1 -> + { + AlertDialog( + onDismissRequest = { logoutConfirmationStep = 0 }, + title = { Text(stringResource(R.string.settings_logout_button)) }, + text = { Text(stringResource(R.string.settings_logout_warning)) }, + confirmButton = { + Button(onClick = { logoutConfirmationStep = 2 }) { + Text(stringResource(R.string.payment_back_button)) + } + }, + dismissButton = { + TextButton(onClick = { logoutConfirmationStep = 0 }) { + Text(stringResource(android.R.string.cancel)) + } + }, + ) + } - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(20.dp), - verticalArrangement = Arrangement.spacedBy(14.dp), - ) { - item { - SettingsCard( - title = stringResource(R.string.settings_app_title), - ) { - SettingsDropdown( - expanded = languageExpanded, - onExpandedChange = { languageExpanded = it }, - value = selectedLabel, - label = stringResource(R.string.settings_language_hint), - options = languageOptions, - optionLabel = { it.label }, - onOptionSelected = { option -> - selectedTag = option.languageTag - languageExpanded = false - onLanguageSelected(option.languageTag) - }, - ) - SettingsDropdown( - expanded = initialOrderExpanded, - onExpandedChange = { initialOrderExpanded = it }, - value = selectedInitialOrderLabel, - label = stringResource(R.string.settings_initial_order_hint), - options = initialOrderOptions, - optionLabel = { it.label }, - onOptionSelected = { option -> - selectedInitialOrderScreen = option.screen - initialOrderExpanded = false - onInitialOrderSelected(option.screen) - }, - ) - } + 2 -> + { + AlertDialog( + onDismissRequest = { logoutConfirmationStep = 0 }, + title = { Text(stringResource(R.string.settings_logout_button)) }, + text = { Text(stringResource(R.string.settings_logout_confirmation)) }, + confirmButton = { + Button( + onClick = { + logoutConfirmationStep = 0 + onLogoutClick() + }, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + ), + ) { + Text(stringResource(R.string.settings_logout_button)) + } + }, + dismissButton = { + TextButton(onClick = { logoutConfirmationStep = 0 }) { + Text(stringResource(android.R.string.cancel)) + } + }, + ) } + } - item { - SettingsCard( - title = stringResource(R.string.settings_instance_title), - description = stringResource(R.string.settings_instance_description), - ) { - Button( - modifier = Modifier.fillMaxWidth(), - onClick = onInstanceSettingsClick, - shape = SettingsControlShape, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer, - ), + BoxWithConstraints(Modifier.fillMaxSize()) { + val horizontalInset = maxOf(20.dp, (maxWidth - 920.dp) / 2) + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = horizontalInset, vertical = 20.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + item { + SettingsCard( + title = stringResource(R.string.settings_app_title), ) { - Icon( - painter = painterResource(R.drawable.ic_menu_manage), - contentDescription = null, + 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) + }, ) - Spacer(Modifier.width(8.dp)) - Text( - text = stringResource(R.string.settings_instance_button), + 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) + }, ) } - Button( - modifier = Modifier.fillMaxWidth(), - onClick = { logoutConfirmationStep = 1 }, - shape = SettingsControlShape, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.errorContainer, - contentColor = MaterialTheme.colorScheme.onErrorContainer, - ), + } + + item { + SettingsCard( + title = stringResource(R.string.payment_options_title), + description = stringResource(R.string.payment_options_description), ) { - Text( - text = stringResource(R.string.settings_logout_button), - ) + paymentOptionsEntry() } } - } - item { - SettingsCard( - title = stringResource(R.string.settings_about_title), - ) { - AboutRow(stringResource(R.string.app_name), BuildConfig.VERSION_NAME) - if (username != null) { - AboutRow(stringResource(R.string.config_username), username) + item { + SettingsCard( + title = stringResource(R.string.settings_instance_title), + description = stringResource(R.string.settings_instance_description), + ) { + OutlinedButton( + modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp), + onClick = onInstanceSettingsClick, + shape = SettingsActionShape, + ) { + Text(stringResource(R.string.settings_manage_connection_action)) + } + OutlinedButton( + modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp), + onClick = { logoutConfirmationStep = 1 }, + shape = SettingsActionShape, + ) { + Text(stringResource(R.string.settings_logout_action)) + } } - if (merchantUrl != null) { - val suffix = if (backendVersion != null) " ($backendVersion)" else "" - AboutRow(stringResource(R.string.config_merchant_url), "$merchantUrl$suffix") + } + + item { + SettingsCard( + title = stringResource(R.string.settings_about_title), + ) { + AboutRow(stringResource(R.string.app_name), BuildConfig.VERSION_NAME) + if (username != null) + { + AboutRow(stringResource(R.string.config_username), username) + } + if (merchantUrl != null) + { + val suffix = if (backendVersion != null) " ($backendVersion)" else "" + AboutRow(stringResource(R.string.config_merchant_url), "$merchantUrl$suffix") + } } } } @@ -344,12 +398,47 @@ private fun GeneralSettingsScreen( } } +/** Production settings UI with fixed, offline fixture values for screenshot rendering. */ +@Composable +internal fun GeneralSettingsScreenshotContent( + paymentOptionsEntry: @Composable () -> Unit, + logoutConfirmationStep: Int = 0, +) +{ + val displayLocale = LocalConfiguration.current.locales[0] ?: Locale.ENGLISH + val languageOptions = + listOf( + LanguageOption("en", Locale.forLanguageTag("en").getDisplayLanguage(displayLocale)), + LanguageOption("de", Locale.forLanguageTag("de").getDisplayLanguage(displayLocale)), + ) + val initialOrderOptions = + listOf( + InitialOrderOption(InitialOrderScreen.AmountEntry, stringResource(R.string.menu_amount_entry)), + InitialOrderOption(InitialOrderScreen.Inventory, stringResource(R.string.menu_order)), + ) + GeneralSettingsScreen( + paymentOptionsEntry = paymentOptionsEntry, + languageOptions = languageOptions, + initialSelectedTag = "en", + onLanguageSelected = {}, + initialOrderOptions = initialOrderOptions, + initialSelectedOrderScreen = InitialOrderScreen.Inventory, + onInitialOrderSelected = {}, + onInstanceSettingsClick = {}, + onLogoutClick = {}, + username = "sandbox", + merchantUrl = "https://backend.demo.taler.net/instances/sandbox", + initialLogoutConfirmationStep = logoutConfirmationStep, + ) +} + @Composable private fun SettingsCard( title: String, description: String? = null, content: @Composable () -> Unit, -) { +) +{ PosOutlinedCard( contentPadding = PaddingValues(16.dp), ) { contentPadding -> @@ -357,11 +446,9 @@ private fun SettingsCard( modifier = Modifier.padding(contentPadding), verticalArrangement = Arrangement.spacedBy(12.dp), ) { - Text( - text = title, - style = MaterialTheme.typography.titleSmall, - ) - if (description != null) { + Text(text = title, style = MaterialTheme.typography.titleMedium) + if (description != null) + { Text( text = description, style = MaterialTheme.typography.bodyMedium, @@ -373,14 +460,19 @@ private fun SettingsCard( } @Composable -private fun AboutRow(label: String, value: String) { +private fun AboutRow( + label: String, + value: String, +) +{ Text( - text = buildAnnotatedString { - withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { - append("$label: ") - } - append(value) - }, + text = + buildAnnotatedString { + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { + append("$label: ") + } + append(value) + }, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -396,18 +488,20 @@ private fun <T> SettingsDropdown( 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, - ), + modifier = + Modifier + .fillMaxWidth() + .menuAnchor( + type = ExposedDropdownMenuAnchorType.PrimaryNotEditable, + enabled = true, + ), value = value, onValueChange = {}, readOnly = true, @@ -415,19 +509,20 @@ private fun <T> SettingsDropdown( 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, - ), + 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, diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/config/LocalNetworkAccess.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/config/LocalNetworkAccess.kt @@ -0,0 +1,158 @@ +/* + * This file is part of GNU Taler + * (C) 2026 Taler Systems S.A. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package net.taler.merchantpos.config + +import android.content.Context +import android.content.pm.PackageManager +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.os.Build + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.core.content.ContextCompat + +import java.io.IOException +import java.net.InetAddress +import java.net.URI +import java.net.UnknownHostException + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +internal const val LOCAL_NETWORK_PERMISSION = "android.permission.ACCESS_LOCAL_NETWORK" + +internal class LocalNetworkAccessDenied : IOException("Local network access is required") + +/** Owned by the activity ViewModel: permission requests survive activity recreation. */ +class LocalNetworkAccess internal constructor( + private val sdk: Int, + private val granted: () -> Boolean, + private val isLocal: suspend (String) -> Boolean, +) +{ + constructor(context: Context) : this( + Build.VERSION.SDK_INT, + { ContextCompat.checkSelfPermission(context, LOCAL_NETWORK_PERMISSION) == PackageManager.PERMISSION_GRANTED }, + { host -> + withContext(Dispatchers.IO) { + isLocalMerchantHost(host) { address -> + val manager = context.getSystemService(ConnectivityManager::class.java) + val network = manager.activeNetwork + val capabilities = manager.getNetworkCapabilities(network) + val lan = + capabilities != null && + !capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) && + (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) || + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) + lan && + manager.getLinkProperties(network)?.routes.orEmpty().any { route -> + route.destination.prefixLength > 0 && (route.gateway?.isAnyLocalAddress != false) && + route.matches(address) + } + } + } + }, + ) + + var host by mutableStateOf<String?>(null) + private set + var permissionResults by mutableIntStateOf(0) + private set + private var pending: CompletableDeferred<Boolean>? = null + private var waiters = 0 + private var dismissed = false + + fun hasAccess(): Boolean = sdk < 37 || granted() + + /** A deliberate new setup attempt may show the explanation again. */ + fun retry() + { + dismissed = false + } + + suspend fun ensureAccess(url: String) + { + if (hasAccess()) return + val hostname = runCatching { URI(url).host?.removeSurrounding("[", "]") }.getOrNull() ?: return + if (!isLocal(hostname)) return + withContext(Dispatchers.Main.immediate) { + if (hasAccess()) return@withContext + if (dismissed) throw LocalNetworkAccessDenied() + val request = + pending ?: CompletableDeferred<Boolean>().also { + pending = it + host = hostname + } + waiters++ + try + { + if (!request.await()) throw LocalNetworkAccessDenied() + } + finally + { + waiters-- + if (waiters == 0 && pending === request) + { + pending = null + host = null + } + } + } + } + + fun permissionResult() + { + permissionResults++ + if (hasAccess()) + { + pending?.complete(true) + host = null + dismissed = false + } + } + + fun dismiss() + { + dismissed = true + pending?.complete(false) + host = null + } +} + +/** Check names before DNS because resolving .local itself requires LAN permission. */ +internal fun isLocalMerchantHost( + host: String, + resolve: (String) -> Array<InetAddress> = InetAddress::getAllByName, + directlyConnected: (InetAddress) -> Boolean = { false }, +): Boolean +{ + val name = host.removeSurrounding("[", "]").trimEnd('.').lowercase() + if (name == "localhost" || name.endsWith(".localhost")) return false + if (name.endsWith(".local") || ('.' !in name && ':' !in name)) return true + val addresses = + try + { + resolve(name) + } + catch (_: UnknownHostException) + { + // Let the normal connection report an invalid or unavailable public hostname. + return false + } + return addresses.any { address -> + val bytes = address.address.map { it.toInt() and 0xff } + !address.isLoopbackAddress && + (address.isLinkLocalAddress || address.isSiteLocalAddress || address.isMulticastAddress || + (bytes.size == 4 && bytes[0] == 100 && bytes[1] in 64..127) || + (bytes.size == 4 && bytes.all { it == 255 }) || + (bytes.size == 16 && bytes[0] and 0xfe == 0xfc) || directlyConnected(address)) + } +} diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/config/LocalNetworkPermissionDialog.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/config/LocalNetworkPermissionDialog.kt @@ -0,0 +1,90 @@ +/* + * This file is part of GNU Taler + * (C) 2026 Taler Systems S.A. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package net.taler.merchantpos.config + +import android.content.Context +import android.content.Intent +import android.provider.Settings + +import androidx.activity.compose.LocalActivity +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.core.app.ActivityCompat +import androidx.core.content.edit +import androidx.core.net.toUri + +import net.taler.merchantpos.R + +@Composable +fun LocalNetworkPermissionDialog(access: LocalNetworkAccess) +{ + val context = LocalContext.current + val activity = LocalActivity.current ?: return + val prefs = remember { context.getSharedPreferences("local-network-permission", Context.MODE_PRIVATE) } + val permission = + rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { + access.permissionResult() + } + val settings = + rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { + access.permissionResult() + } + val host = access.host ?: return + val blocked = + remember(access.permissionResults, host) { + prefs.getBoolean("requested", false) && + !ActivityCompat.shouldShowRequestPermissionRationale(activity, LOCAL_NETWORK_PERMISSION) + } + AlertDialog( + onDismissRequest = access::dismiss, + title = { Text(stringResource(R.string.config_local_network_title)) }, + text = { + Text( + stringResource( + if (blocked) R.string.config_local_network_settings else R.string.config_local_network_explanation, + host, + ), + ) + }, + confirmButton = { + TextButton( + onClick = { + if (blocked) + { + settings.launch( + Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + "package:${context.packageName}".toUri(), + ) + ) + } + else + { + prefs.edit { putBoolean("requested", true) } + permission.launch(LOCAL_NETWORK_PERMISSION) + } + } + ) { + Text( + stringResource( + if (blocked) R.string.config_camera_open_settings else R.string.config_local_network_allow + ) + ) + } + }, + dismissButton = { + TextButton(onClick = access::dismiss) { Text(stringResource(android.R.string.cancel)) } + }, + ) +} diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/config/MerchantSetupUri.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/config/MerchantSetupUri.kt @@ -0,0 +1,72 @@ +/* + * This file is part of GNU Taler + * (C) 2026 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.merchantpos.config + +import android.net.Uri + +internal fun merchantSetupConfig(data: Uri): Config.New? +{ + if (data.scheme != "taler-pos") return null + val host = data.host?.takeIf(String::isNotBlank) ?: return null + val authority = if (data.port == -1) host else "$host:${data.port}" + val pathSegments = data.pathSegments + val pathStyleInstance = + pathSegments + .takeIf { it.size >= 2 && it[0].equals("instances", ignoreCase = true) } + ?.get(1) + ?.takeIf(String::isNotBlank) + val rawFragment = + data.fragment + ?.removePrefix("/") + ?.trim() + .orEmpty() + val params = + rawFragment + .takeIf { '=' in it } + ?.split('&') + ?.associate { part -> + part.split('=', limit = 2).let { it[0] to Uri.decode(it.getOrElse(1) { "" }) } + } + + val instance = (pathStyleInstance ?: params?.get("username"))?.takeIf(String::isNotBlank) ?: return null + val token = + if (pathStyleInstance != null && + params == null) + { + Uri.decode(rawFragment).takeIf(String::isNotBlank) + } + else + { + params?.get("password")?.takeIf(String::isNotBlank) + } ?: return null + + val merchantUrl = + Uri + .Builder() + .scheme("https") + .encodedAuthority(authority) + .appendPath("instances") + .appendPath(instance) + .build() + .toString() + + return Config.New( + merchantUrl = merchantUrl, + accessToken = token, + savePassword = true, + ) +} 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 @@ -17,39 +17,28 @@ package net.taler.merchantpos.config import android.os.Build.VERSION.SDK_INT -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable + import net.taler.common.Amount import net.taler.common.ContractProduct import net.taler.common.OrderProduct import net.taler.common.TalerUtils import net.taler.common.Tax import net.taler.merchantlib.MerchantConfig + +import java.math.BigDecimal import java.math.RoundingMode import java.util.UUID -sealed class Config { +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +sealed class Config +{ abstract fun isValid(): Boolean + abstract fun hasPassword(): Boolean - abstract fun savePassword(): Boolean - /** - * JSON config URL + user/password - * - * @Deprecated("Use New instead") - */ - /* - data class Old( - val configUrl: String, - val username: String, - val password: String, - val savePassword: Boolean, - ): Config() { - override fun isValid() = configUrl.isNotBlank() - override fun hasPassword() = password.isNotBlank() - override fun savePassword() = savePassword - } - */ + abstract fun savePassword(): Boolean /** * Merchant URL + access token @@ -58,9 +47,12 @@ sealed class Config { val merchantUrl: String, val accessToken: String, val savePassword: Boolean, - ): Config() { + ) : Config() + { override fun isValid() = merchantUrl.isNotBlank() + override fun hasPassword() = accessToken.isNotBlank() + override fun savePassword() = savePassword } } @@ -68,9 +60,9 @@ sealed class Config { @Serializable data class PosConfig( @SerialName("config") - val merchantConfig: MerchantConfig? = null , + val merchantConfig: MerchantConfig? = null, val categories: List<Category>, - val products: List<ConfigProduct> + val products: List<ConfigProduct>, ) @Serializable @@ -78,8 +70,9 @@ data class Category( val id: Int, val name: String, @SerialName("name_i18n") - val nameI18n: Map<String, String>? = null -) { + val nameI18n: Map<String, String>? = null, +) +{ var selected: Boolean = false val localizedName: String get() = if (SDK_INT >= 26) TalerUtils.getLocalizedString(nameI18n, name) else name @@ -119,75 +112,94 @@ data class ConfigProduct( val availableToSell: Boolean = true, val remainingStock: Int? = null, val currencyMismatch: Boolean = false, -) : OrderProduct() { - val totalPrice: Amount - get() = (price * quantity).withSpec(price.spec) +) : OrderProduct() +{ private val normalizedProductName: String? get() = productName?.sanitizeVisibleText()?.takeIf { it.isNotEmpty() } + private val normalizedDescription: String get() = localizedDescription.sanitizeVisibleText() + + val totalPrice: Amount + get() = (price * quantity).withSpec(price.spec) + val displayName: String get() = normalizedProductName ?: normalizedDescription + + // Description translations only name the product when no separate name is available. + val displayNameI18n: Map<String, String>? + get() = if (normalizedProductName == null) descriptionI18n else null + val displayDescription: String? - get() = normalizedProductName - ?.takeIf { it != normalizedDescription } - ?.let { normalizedDescription.takeIf(String::isNotEmpty) } + get() = + normalizedProductName + ?.takeIf { it != normalizedDescription } + ?.let { normalizedDescription.takeIf(String::isNotEmpty) } + val displayPrice: String 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("|") + 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 - val total = totalStock - ?: unitTotalStock?.toBigDecimalOrNull() - ?.setScale(0, RoundingMode.DOWN) - ?.toInt() - ?: return null - val sold = totalSold - ?: unitTotalSold?.toBigDecimalOrNull() - ?.setScale(0, RoundingMode.DOWN) - ?.toInt() - ?: 0 - val lost = totalLost - ?: unitTotalLost?.toBigDecimalOrNull() - ?.setScale(0, RoundingMode.DOWN) - ?.toInt() - ?: 0 - return (total - sold - lost).coerceAtLeast(0) + get() + { + if (totalStock == -1 || + unitTotalStock == "-1") + { + return null + } + val total = unitTotalStock?.toBigDecimalOrNull() ?: totalStock?.toBigDecimal() ?: return null + val sold = unitTotalSold?.toBigDecimalOrNull() ?: totalSold?.toBigDecimal() ?: BigDecimal.ZERO + val lost = unitTotalLost?.toBigDecimalOrNull() ?: totalLost?.toBigDecimal() ?: BigDecimal.ZERO + return (total - sold - lost) + .coerceIn(BigDecimal.ZERO, Int.MAX_VALUE.toBigDecimal()) + .setScale(0, RoundingMode.DOWN) + .toInt() } - fun toContractProduct() = ContractProduct( - productId = productId, - productName = productName ?: description, - description = description, - descriptionI18n = descriptionI18n?.takeIf { it.isNotEmpty() }, - price = price, - pricesAreNet = pricesAreNet, - location = null, - image = image?.takeIf { it.isNotBlank() }, - taxes = taxes?.takeIf { it.isNotEmpty() }, - quantity = quantity - ) + fun toContractProduct() = + ContractProduct( + productId = productId, + productName = productName ?: description, + description = description, + descriptionI18n = descriptionI18n?.takeIf { it.isNotEmpty() }, + price = price, + pricesAreNet = pricesAreNet, + location = null, + image = image?.takeIf { it.isNotBlank() }, + taxes = taxes?.takeIf { it.isNotEmpty() }, + quantity = quantity, + ) } -private fun String.sanitizeVisibleText(): String { - return filterNot { char -> - when (Character.getType(char)) { - Character.FORMAT.toInt(), - Character.CONTROL.toInt(), - Character.SURROGATE.toInt(), - Character.PRIVATE_USE.toInt(), - Character.UNASSIGNED.toInt() -> true - else -> false +private fun String.sanitizeVisibleText(): String = + buildString { + var offset = 0 + while (offset < this@sanitizeVisibleText.length) + { + val codePoint = Character.codePointAt(this@sanitizeVisibleText, offset) + when (Character.getType(codePoint)) + { + Character.FORMAT.toInt(), + Character.CONTROL.toInt(), + Character.SURROGATE.toInt(), + Character.PRIVATE_USE.toInt(), + Character.UNASSIGNED.toInt() + -> Unit + + else -> appendCodePoint(codePoint) + } + offset += Character.charCount(codePoint) } }.trim() -} diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/config/QrLuminance.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/config/QrLuminance.kt @@ -0,0 +1,67 @@ +/* + * This file is part of GNU Taler + * (C) 2026 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.merchantpos.config + +import com.google.zxing.PlanarYUVLuminanceSource + +import java.nio.ByteBuffer + +/** Camera planes may contain padding between rows and between individual pixels. */ +internal fun qrLuminance( + buffer: ByteBuffer, + width: Int, + height: Int, + rowStride: Int, + pixelStride: Int, + rotation: Int, +): PlanarYUVLuminanceSource +{ + require(width > 0 && height > 0 && pixelStride > 0) + require(rowStride.toLong() >= (width - 1L) * pixelStride + 1) + require(rotation in setOf(0, 90, 180, 270)) + require((height - 1L) * rowStride + (width - 1L) * pixelStride < buffer.remaining()) + val swapped = rotation == 90 || rotation == 270 + val outputWidth = if (swapped) height else width + val outputHeight = if (swapped) width else height + val pixels = ByteArray(Math.multiplyExact(width, height)) + val offset = buffer.position() + for (y in 0 until height) + { + for (x in 0 until width) + { + val destination = + when (rotation) + { + 90 -> x * outputWidth + height - 1 - y + 180 -> (height - 1 - y) * outputWidth + width - 1 - x + 270 -> (width - 1 - x) * outputWidth + y + else -> y * outputWidth + x + } + pixels[destination] = buffer.get(offset + y * rowStride + x * pixelStride) + } + } + return PlanarYUVLuminanceSource( + pixels, + outputWidth, + outputHeight, + 0, + 0, + outputWidth, + outputHeight, + false, + ) +} diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/devices/AndroidTalerUsbTransport.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/devices/AndroidTalerUsbTransport.kt @@ -0,0 +1,62 @@ +/* + * 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.devices + +import android.hardware.usb.UsbConstants +import android.hardware.usb.UsbManager + +/** The vendor-control protocol from the device's Android example; not USB serial. */ +internal class AndroidTalerUsbTransport( + private val manager: UsbManager, +) : TalerUsbTransport +{ + override fun devices() = + manager.deviceList.values + .filter { TalerUsbSession.supports(it.vendorId, it.productId) } + .sortedBy { it.deviceName } + .map { TalerUsbDevice(it.deviceName, manager.hasPermission(it)) } + + override fun write( + device: TalerUsbDevice, + payload: ByteArray, + ): Boolean + { + val attached = manager.deviceList[device.id] ?: return false + if (!TalerUsbSession.supports(attached.vendorId, attached.productId) || + !manager.hasPermission(attached)) + { + return false + } + val connection = manager.openDevice(attached) ?: return false + return try + { + connection.controlTransfer( + UsbConstants.USB_DIR_OUT or UsbConstants.USB_TYPE_VENDOR, + 0, + 0, + 0, + payload, + payload.size, + 1000, + ) == payload.size + } + finally + { + connection.close() + } + } +} diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/devices/TalerBleProtocol.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/devices/TalerBleProtocol.kt @@ -0,0 +1,82 @@ +/* + * 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.devices + +import java.util.UUID + +internal object TalerBleProtocol +{ + const val NAME = "Taler NFC Terminal" + val SERVICE: UUID = UUID.fromString("04942ad3-8352-4da9-b0e8-22323c7f18b7") + val URL: UUID = UUID.fromString("829f9e88-206d-47f0-bd07-ada608deaa67") + val CONTROL: UUID = UUID.fromString("829f9e88-206d-47f0-bd07-ada608deaa68") + val STATUS: UUID = UUID.fromString("829f9e88-206d-47f0-bd07-ada608deaa69") + + // The physical LCD also ships with the SIG URI characteristic in the same Taler service. + val STANDARD_URI: UUID = UUID.fromString("00002ab6-0000-1000-8000-00805f9b34fb") + const val TEST_URL = "https://taler.net/#display-test" + const val CAPACITY = 255 + + fun isBondedFirmwareName(name: String?) = name?.matches(Regex("Taler [0-9A-Fa-f]{6}")) == true + + fun isCandidate( + name: String?, + services: List<UUID>, + ) = name == NAME || isBondedFirmwareName(name) || SERVICE in services +} + +/** One acknowledged write at a time. Never split URLs: firmware replaces on every write. */ +internal class TalerBleUrlQueue +{ + private var acknowledged: String? = null + private var inFlight: String? = null + + private val target get() = if (tooLong) "" else desired + + var desired = "" + var mtu = 23 + + val tooLong get() = desired.toByteArray(Charsets.UTF_8).size > minOf(TalerBleProtocol.CAPACITY, mtu - 3) + + val settled get() = inFlight == null && acknowledged == target + + val writing get() = inFlight != null + + fun next(): ByteArray? + { + if (inFlight != null || + acknowledged == target) + { + return null + } + inFlight = target + return target.toByteArray(Charsets.UTF_8) + } + + fun complete(success: Boolean) + { + acknowledged = if (success) inFlight else null + inFlight = null + } + + fun reset() + { + acknowledged = null + inFlight = null + mtu = 23 + } +} diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/devices/TalerBluetoothDevice.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/devices/TalerBluetoothDevice.kt @@ -0,0 +1,1000 @@ +/* + * 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.devices + +import android.Manifest +import android.annotation.SuppressLint +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGattCallback +import android.bluetooth.BluetoothGattCharacteristic +import android.bluetooth.BluetoothManager +import android.bluetooth.BluetoothProfile +import android.bluetooth.BluetoothStatusCodes +import android.bluetooth.le.ScanCallback +import android.bluetooth.le.ScanResult +import android.bluetooth.le.ScanSettings +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageManager +import android.location.LocationManager +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.util.Log + +import androidx.core.content.ContextCompat +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData + +import net.taler.merchantpos.BuildConfig + +enum class TalerBleStatus +{ + Disabled, + Unsupported, + PermissionRequired, + BluetoothOff, + LocationOff, + Disconnected, + Scanning, + Connecting, + Pairing, + PairingRequired, + Connected, + Sending, + Ready, + TooLong, + Failed, +} + +data class TalerBleCandidate( + val address: String, + val name: String, +) + +data class TalerBleState( + val enabled: Boolean = true, + val status: TalerBleStatus = TalerBleStatus.Disabled, + val selected: TalerBleCandidate? = null, + val candidates: List<TalerBleCandidate> = emptyList(), + val hasResetLimitation: Boolean = false, +) + +/** + * Owns a single selected LCD connection, independent of the Activity across rotation. + * All Bluetooth callbacks and state transitions are serialized on the main looper. + */ +@SuppressLint("MissingPermission") // Runtime checks precede scanning/connecting; revocation is caught. +class TalerBluetoothDevice( + context: Context, +) : AutoCloseable +{ + private val app = context.applicationContext + private val adapter = (app.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter + private val preferences = app.getSharedPreferences("taler-bluetooth-device", Context.MODE_PRIVATE) + private val handler = Handler(Looper.getMainLooper()) + private val queue = TalerBleUrlQueue() + private var enabled = true // Pairing, rather than a transport switch, controls access. + + private var selected = + preferences.getString("address", null)?.let { + TalerBleCandidate(it, preferences.getString("name", null) ?: TalerBleProtocol.NAME) + } + + private var foreground = false + private var talerEnabled = true + private var closed = false + private var owner: Any? = null + private var uri: String? = null + private var scan: ScanCallback? = null + private var gatt: BluetoothGatt? = null + private var characteristic: BluetoothGattCharacteristic? = null + private var clearCharacteristic: BluetoothGattCharacteristic? = null + private var statusCharacteristic: BluetoothGattCharacteristic? = null + private var writeCharacteristic: BluetoothGattCharacteristic? = null + private var pairing = false + private var pairingRequired = false + private var ready = false + private var deadline: Runnable? = null + private var reconnect: Runnable? = null + private var failures = 0 + private var hasResetLimitation = false + private val found = linkedMapOf<String, TalerBleCandidate>() + private val mutableState = MutableLiveData(TalerBleState(enabled, selected = selected)) + + private val active get() = enabled && talerEnabled && foreground && !closed + + private val bondReceiver = + object : BroadcastReceiver() + { + override fun onReceive( + context: Context, + intent: Intent, + ) + { + if (intent.action != BluetoothDevice.ACTION_BOND_STATE_CHANGED || + closed) + { + return + } + @Suppress("DEPRECATION") + val device = intent.getParcelableExtra<BluetoothDevice>(BluetoothDevice.EXTRA_DEVICE) ?: return + handler.post { + if (closed || + device.address != selected?.address || + !permitted()) + { + return@post + } + try + { + when (device.bondState) + { + BluetoothDevice.BOND_BONDING -> + { + if (active || + pairing) + { + pairing = true + publish(TalerBleStatus.Pairing) + timeout(60_000) + } + } + + BluetoothDevice.BOND_BONDED -> + { + if (pairing) + { + pairing = false + cancelDeadline() + if (!active) + { + releaseGatt() + publish(TalerBleStatus.Disconnected) + } + else + { + gatt?.let { negotiateMtu(it) } ?: connect() + } + } + } + + BluetoothDevice.BOND_NONE -> + { + if (pairing || + ready) + { + requirePairing() + } + } + } + } + catch ( + _: SecurityException, + ) + { + pairing = false + releaseGatt() + publish(TalerBleStatus.PermissionRequired) + } + } + } + } + + private val callback = + object : BluetoothGattCallback() + { + override fun onConnectionStateChange( + connection: BluetoothGatt, + status: Int, + newState: Int, + ) = dispatch(connection) { + if (BuildConfig.DEBUG) Log.d("taler-pos-ble", "Connection state: $newState, status: $status") + if (status != BluetoothGatt.GATT_SUCCESS || + newState == BluetoothProfile.STATE_DISCONNECTED) + { + if (pairing || + authenticationFailure(status)) + { + requirePairing() + return@dispatch + } + fail() + return@dispatch + } + if (newState == BluetoothProfile.STATE_CONNECTED) + { + if (!active) + { + releaseGatt() + return@dispatch + } + if (connection.discoverServices()) timeout() else fail() + } + } + + override fun onServicesDiscovered( + connection: BluetoothGatt, + status: Int, + ) = dispatch(connection) { + if (BuildConfig.DEBUG) + { + Log.d("taler-pos-ble", "Service discovery status: $status") + connection.services.forEach { service -> + Log.d( + "taler-pos-ble", + "Service: ${service.uuid}, characteristics: ${ + service.characteristics.map { + "${it.uuid} (${it.properties})" + } + }", + ) + } + } + val service = connection.getService(TalerBleProtocol.SERVICE) + val url = + listOf(TalerBleProtocol.URL, TalerBleProtocol.STANDARD_URI) + .mapNotNull { service?.getCharacteristic(it) } + .firstOrNull { it.properties and BluetoothGattCharacteristic.PROPERTY_WRITE != 0 } + if (status != BluetoothGatt.GATT_SUCCESS || + url == null || + url.properties and BluetoothGattCharacteristic.PROPERTY_WRITE == 0) + { + fail(retry = false) + return@dispatch + } + characteristic = url + clearCharacteristic = + service + ?.getCharacteristic(TalerBleProtocol.CONTROL) + ?.takeIf { it.properties and BluetoothGattCharacteristic.PROPERTY_WRITE != 0 } + statusCharacteristic = + service + ?.getCharacteristic(TalerBleProtocol.STATUS) + ?.takeIf { it.properties and BluetoothGattCharacteristic.PROPERTY_READ != 0 } + // This physical firmware acknowledges empty writes but leaves the QR visible. + // Expose the limitation instead of substituting an unrelated website URL. + hasResetLimitation = url.uuid == TalerBleProtocol.STANDARD_URI && clearCharacteristic == null + if (statusCharacteristic != null && + connection.device.bondState != BluetoothDevice.BOND_BONDED) + { + beginPairing(connection.device) + } + else + { + negotiateMtu(connection) + } + } + + override fun onMtuChanged( + connection: BluetoothGatt, + mtu: Int, + status: Int, + ) = dispatch(connection) { + if (BuildConfig.DEBUG) Log.d("taler-pos-ble", "MTU: $mtu, status: $status") + if (status == BluetoothGatt.GATT_SUCCESS) queue.mtu = mtu.coerceAtLeast(23) + if (characteristic != null && + !ready) + { + verifyConnection(connection) + } + } + + override fun onCharacteristicWrite( + connection: BluetoothGatt, + value: BluetoothGattCharacteristic, + status: Int, + ) = dispatch(connection) { + if (value.uuid != writeCharacteristic?.uuid || + !queue.writing) + { + return@dispatch + } + if (BuildConfig.DEBUG) Log.d("taler-pos-ble", "Write acknowledged, status: $status") + cancelDeadline() + queue.complete(status == BluetoothGatt.GATT_SUCCESS) + if (status != BluetoothGatt.GATT_SUCCESS) + { + if (authenticationFailure(status)) requirePairing() else fail() + return@dispatch + } + failures = 0 + updateUrl() + } + + @Deprecated("Used by Android before API 33") + override fun onCharacteristicRead( + connection: BluetoothGatt, + value: BluetoothGattCharacteristic, + status: Int, + ) + { + @Suppress("DEPRECATION") + readStatus(connection, value, value.value ?: byteArrayOf(), status) + } + + override fun onCharacteristicRead( + connection: BluetoothGatt, + value: BluetoothGattCharacteristic, + bytes: ByteArray, + status: Int, + ) + { + readStatus(connection, value, bytes, status) + } + } + + val state: LiveData<TalerBleState> = mutableState + + init + { + // Bluetooth broadcasts can come from the privileged Bluetooth process. + ContextCompat.registerReceiver( + app, + bondReceiver, + IntentFilter(BluetoothDevice.ACTION_BOND_STATE_CHANGED), + ContextCompat.RECEIVER_EXPORTED, + ) + } + + fun requiredPermissions(): Array<String> = + if (Build.VERSION.SDK_INT >= 31) + { + arrayOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT) + } + else + { + arrayOf(Manifest.permission.ACCESS_FINE_LOCATION) + } + + private fun permitted() = + requiredPermissions().all { + ContextCompat.checkSelfPermission(app, it) == PackageManager.PERMISSION_GRANTED + } + + private fun availability(scanning: Boolean = false): TalerBleStatus? = + when + { + adapter == null -> TalerBleStatus.Unsupported + + !permitted() -> TalerBleStatus.PermissionRequired + + !adapter.isEnabled -> TalerBleStatus.BluetoothOff + + scanning && Build.VERSION.SDK_INT < 31 && + !(app.getSystemService(Context.LOCATION_SERVICE) as LocationManager).let { + if (Build.VERSION.SDK_INT >= 28) + { + it.isLocationEnabled + } + else + { + it.isProviderEnabled(LocationManager.GPS_PROVIDER) || + it.isProviderEnabled(LocationManager.NETWORK_PROVIDER) + } + } -> TalerBleStatus.LocationOff + + else -> null + } + + private fun publish(status: TalerBleStatus) + { + val updated = TalerBleState(enabled, status, selected, found.values.toList(), hasResetLimitation) + if (mutableState.value == updated) return + if (BuildConfig.DEBUG && + mutableState.value?.status != status) + { + Log.d("taler-pos-ble", "State: $status") + } + mutableState.value = updated + } + + fun setEnabled(value: Boolean) + { + enabled = value + preferences.edit().putBoolean("enabled", value).apply() + if (value) refresh() else suspendConnection() + } + + fun setForeground(value: Boolean) + { + foreground = value + if (value) + { + refresh() + } + else + { + uri = null + owner = null + // The Android pairing dialog may temporarily stop the Activity. + // Never send payment data in the background; let the bounded pairing finish. + if (!pairing) suspendConnection() + } + } + + fun setTalerEnabled(value: Boolean) + { + if (talerEnabled == value) return + talerEnabled = value + if (value) refresh() else suspendConnection() + } + + fun present( + source: Any, + value: String?, + ) + { + owner = source + uri = value + updateUrl() + } + + fun dismiss(source: Any) + { + if (owner !== source) return + owner = null + uri = null + updateUrl() + } + + /** Harmless QR/NFC demonstration, automatically reset to idle; never creates an order. */ + fun testDisplay() + { + if (!active || + !ready) + { + return + } + val test = Any() + present(test, TalerBleProtocol.TEST_URL) + handler.postDelayed({ dismiss(test) }, 5000) + } + + fun refresh() + { + if (closed) return + if (!active) + { + publish(if (enabled) TalerBleStatus.Disconnected else TalerBleStatus.Disabled) + return + } + try + { + availability()?.let { + pairing = false + stopScan() + releaseGatt() + publish(it) + return + } + if (pairingRequired) + { + publish(TalerBleStatus.PairingRequired) + return + } + if (pairing) + { + publish(TalerBleStatus.Pairing) + return + } + if (gatt == null && + scan == null && + reconnect == null) + { + if (selected == null) publish(TalerBleStatus.Disconnected) else connect() + } + else if (ready) + { + updateUrl() + } + } + catch (_: RuntimeException) + { + fail() + } + } + + fun startScan() + { + if (!active) return + try + { + availability(scanning = true)?.let { + publish(it) + return + } + stopScan() + // Keep an existing connection until the user explicitly disconnects it. + if (gatt != null) return + cancelReconnect() + found.clear() + val callback = + object : ScanCallback() + { + override fun onScanResult( + callbackType: Int, + result: ScanResult, + ) + { + handler.post { + if (scan !== this || + !active) + { + return@post + } + val record = result.scanRecord ?: return@post + if (!TalerBleProtocol.isCandidate( + record.deviceName, + record.serviceUuids.orEmpty().map { it.uuid }, + )) + { + return@post + } + try + { + val candidate = + TalerBleCandidate( + result.device.address, + record.deviceName ?: TalerBleProtocol.NAME, + ) + found[candidate.address] = candidate + publish(TalerBleStatus.Scanning) + } + catch (_: SecurityException) + { + stopScan() + publish(TalerBleStatus.PermissionRequired) + } + } + } + + override fun onScanFailed(errorCode: Int) + { + handler.post { + if (scan === this) + { + stopScan() + publish(TalerBleStatus.Failed) + } + } + } + } + scan = callback + // Legacy firmware advertises only Battery Service; match names as well as UUIDs. + val scanner = + adapter?.bluetoothLeScanner ?: run { + scan = null + publish(TalerBleStatus.BluetoothOff) + return + } + scanner.startScan( + null, + ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build(), + callback, + ) + publish(TalerBleStatus.Scanning) + handler.postDelayed( + { + if (scan === callback) + { + stopScan() + publish(TalerBleStatus.Disconnected) + } + }, + 10000 + ) + } + catch (_: RuntimeException) + { + stopScan() + publish(TalerBleStatus.Failed) + } + } + + fun cancelDiscovery() + { + if (scan == null) return + stopScan() + publish(TalerBleStatus.Disconnected) + } + + fun select(candidate: TalerBleCandidate) + { + if (!active || + found[candidate.address] != candidate || + gatt != null) + { + return + } + selected = candidate + pairingRequired = false + hasResetLimitation = false + preferences + .edit() + .putString("address", candidate.address) + .putString("name", candidate.name) + .apply() + failures = 0 + stopScan() + connect() + } + + fun forget() + { + pairing = false + pairingRequired = false + selected = null + hasResetLimitation = false + preferences + .edit() + .remove("address") + .remove("name") + .apply() + uri = null + owner = null + suspendConnection() + } + + private fun connect() + { + if (!active || + selected == null || + gatt != null) + { + return + } + try + { + availability()?.let { + publish(it) + return + } + val device = adapter?.getRemoteDevice(selected!!.address) ?: return + if (TalerBleProtocol.isBondedFirmwareName(selected?.name) && + device.bondState != BluetoothDevice.BOND_BONDED) + { + beginPairing(device) + return + } + publish(TalerBleStatus.Connecting) + queue.reset() + gatt = device.connectGatt(app, false, callback, BluetoothDevice.TRANSPORT_LE) + if (gatt == null) fail() else timeout() + } + catch (_: RuntimeException) + { + fail() + } + } + + private fun beginPairing(device: BluetoothDevice) + { + pairing = true + cancelReconnect() + publish(TalerBleStatus.Pairing) + timeout(60_000) + if (device.bondState != BluetoothDevice.BOND_BONDING && + !device.createBond()) + { + requirePairing() + } + } + + private fun negotiateMtu(connection: BluetoothGatt) + { + if (characteristic == null) + { + if (connection.discoverServices()) timeout() else fail() + } + else if (connection.requestMtu(TalerBleProtocol.CAPACITY + 3)) + { + timeout() + } + else + { + verifyConnection(connection) + } + } + + private fun verifyConnection(connection: BluetoothGatt) + { + val status = statusCharacteristic + if (status != null) + { + if (connection.readCharacteristic(status)) timeout() else fail() + } + else + { + ready = true + cancelDeadline() + updateUrl() + } + } + + private fun readStatus( + connection: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + bytes: ByteArray, + status: Int, + ) = dispatch(connection) { + if (characteristic.uuid != statusCharacteristic?.uuid || + ready) + { + return@dispatch + } + if (BuildConfig.DEBUG) + { + Log.d( + "taler-pos-ble", + "Status read: result=$status, length=${bytes.size}, handle=${characteristic.instanceId}", + ) + } + cancelDeadline() + if (status != BluetoothGatt.GATT_SUCCESS) + { + if (authenticationFailure(status)) requirePairing() else fail() + } + else if (bytes.size != 6 || + bytes[0] != 1.toByte()) + { + fail(retry = false) + } + else + { + ready = true + failures = 0 + updateUrl() + } + } + + private fun authenticationFailure(status: Int) = + status == BluetoothGatt.GATT_INSUFFICIENT_AUTHENTICATION || + status == BluetoothGatt.GATT_INSUFFICIENT_ENCRYPTION + + private fun requirePairing() + { + pairing = false + pairingRequired = true + cancelReconnect() + releaseGatt() + publish(TalerBleStatus.PairingRequired) + } + + fun retryPairing() + { + pairingRequired = false + failures = 0 + refresh() + } + + private fun dispatch( + connection: BluetoothGatt, + action: () -> Unit, + ) + { + handler.post { + if (gatt !== connection) return@post // Ignore callbacks from an old connection. + try + { + action() + } + catch (_: RuntimeException) + { + fail() + } + } + } + + private fun updateUrl() + { + queue.desired = + if (active && + selected != null) + { + uri.orEmpty() + } + else + { + "" + } + if (!ready) return + val connection = gatt ?: return + val url = characteristic ?: return + val bytes = queue.next() + if (bytes == null) + { + if (queue.settled) + { + if (!active || + selected == null) + { + releaseGatt() + publish(if (enabled) TalerBleStatus.Disconnected else TalerBleStatus.Disabled) + } + else + { + publish(if (queue.tooLong) TalerBleStatus.TooLong else TalerBleStatus.Ready) + } + } + return + } + try + { + val target = if (bytes.isEmpty()) clearCharacteristic ?: url else url + val payload = + if (bytes.isEmpty() && + clearCharacteristic != null) + { + byteArrayOf(1) + } + else + { + bytes + } + writeCharacteristic = target + if (BuildConfig.DEBUG) Log.d("taler-pos-ble", "Writing ${bytes.size} bytes") + val started = + if (Build.VERSION.SDK_INT >= 33) + { + connection.writeCharacteristic(target, payload, BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT) == + BluetoothStatusCodes.SUCCESS + } + else + { + @Suppress("DEPRECATION") + target.value = payload + target.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT + @Suppress("DEPRECATION") + connection.writeCharacteristic(target) + } + if (started) + { + publish(TalerBleStatus.Sending) + timeout() + } + else + { + fail() + } + } + catch (_: RuntimeException) + { + fail() + } + } + + private fun timeout(milliseconds: Long = 10000) + { + cancelDeadline() + deadline = Runnable { if (pairing) requirePairing() else fail() }.also { handler.postDelayed(it, milliseconds) } + } + + private fun cancelDeadline() + { + deadline?.let(handler::removeCallbacks) + deadline = null + } + + private fun cancelReconnect() + { + reconnect?.let(handler::removeCallbacks) + reconnect = null + } + + private fun fail(retry: Boolean = true) + { + if (pairing) + { + requirePairing() + return + } + releaseGatt() + publish(TalerBleStatus.Failed) + if (retry && + active && + selected != null && + failures++ < 3) + { + reconnect = + Runnable { + reconnect = null + connect() + }.also { handler.postDelayed(it, 3000L * failures) } + } + } + + private fun stopScan() + { + val previous = scan + scan = null + try + { + if (previous != null) adapter?.bluetoothLeScanner?.stopScan(previous) + } + catch (_: RuntimeException) + { + // The adapter may be off or its permission revoked during cleanup. + } + } + + private fun suspendConnection() + { + pairing = false + stopScan() + cancelReconnect() + if (ready) + { + updateUrl() + } + else + { + releaseGatt() + publish(if (enabled) TalerBleStatus.Disconnected else TalerBleStatus.Disabled) + } + } + + private fun releaseGatt() + { + cancelDeadline() + ready = false + characteristic = null + clearCharacteristic = null + statusCharacteristic = null + writeCharacteristic = null + val previous = gatt + gatt = null + queue.reset() + try + { + previous?.disconnect() + } + catch (_: RuntimeException) + { + // Still close the GATT handle if the adapter rejects disconnection. + } + try + { + previous?.close() + } + catch (_: RuntimeException) + { + // Local state is already cleared; an unavailable adapter cannot retain it. + } + } + + fun logout() + { + forget() + setEnabled(true) + } + + override fun close() + { + if (closed) return + closed = true + pairing = false + app.unregisterReceiver(bondReceiver) + uri = null + owner = null + // Keep the callback alive just long enough to acknowledge the idle screen. + suspendConnection() + } +} diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/devices/TalerBluetoothSettings.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/devices/TalerBluetoothSettings.kt @@ -0,0 +1,41 @@ +/* + * 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.devices + +import androidx.annotation.StringRes + +import net.taler.merchantpos.R + +@StringRes +internal fun TalerBleStatus.label(): Int = + when (this) + { + TalerBleStatus.Disabled -> R.string.payment_option_disabled + TalerBleStatus.Unsupported -> R.string.taler_ble_unsupported + TalerBleStatus.PermissionRequired -> R.string.taler_ble_permission + TalerBleStatus.BluetoothOff -> R.string.taler_ble_off + TalerBleStatus.LocationOff -> R.string.taler_ble_location + TalerBleStatus.Disconnected -> R.string.payment_device_status_disconnected + TalerBleStatus.Scanning -> R.string.taler_ble_scanning + TalerBleStatus.Connecting -> R.string.taler_ble_connecting + TalerBleStatus.Pairing -> R.string.taler_ble_pairing + TalerBleStatus.PairingRequired -> R.string.taler_ble_pairing_required + TalerBleStatus.Connected, TalerBleStatus.Ready -> R.string.payment_device_status_connected + TalerBleStatus.Sending -> R.string.taler_ble_sending + TalerBleStatus.TooLong -> R.string.taler_ble_too_long + TalerBleStatus.Failed -> R.string.taler_ble_failed + } diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/devices/TalerDevicesFragment.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/devices/TalerDevicesFragment.kt @@ -0,0 +1,711 @@ +/* + * 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.devices + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.provider.Settings +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup + +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels + +import net.taler.merchantpos.BuildConfig +import net.taler.merchantpos.MainViewModel +import net.taler.merchantpos.R +import net.taler.merchantpos.compose.PosOutlinedCard +import net.taler.merchantpos.compose.PosTheme + +class TalerDevicesFragment : Fragment() +{ + private val model: MainViewModel by activityViewModels() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View = + ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { PosTheme { TalerDevicesScreen(model.talerBluetooth, model.talerUsb) } } + } +} + +internal enum class DeviceStep +{ + Choose, + Replace, + Access, + OpenSettings, + Pair, + Find, + Connect, + Usb, + Test, + Remove, + RepairAndroid, + RepairDisplay, +} + +@Composable +private fun TalerDevicesScreen( + bluetooth: TalerBluetoothDevice, + usb: TalerUsbDevices, +) +{ + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) { + Column( + Modifier + .widthIn(max = 720.dp) + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(20.dp), + ) { + TalerDevicesContent(bluetooth, usb) + } + } +} + +/** Fixed presentation state for documentation captures; never enables device transports. */ +internal data class TalerDevicesPreview( + val bluetooth: TalerBleState, + val usb: TalerUsbState = TalerUsbState(), + val step: DeviceStep? = null, +) + +@Composable +internal fun TalerDevicesContent( + bluetooth: TalerBluetoothDevice, + usb: TalerUsbDevices, + preview: TalerDevicesPreview? = null, +) +{ + check(preview == null || BuildConfig.DEBUG) + LaunchedEffect(bluetooth, usb, preview) { + if (preview != null) return@LaunchedEffect + // Connection management replaces the old transport opt-in switches. + usb.setEnabled(true) + bluetooth.setEnabled(true) + } + val observedBle by bluetooth.state.observeAsState(TalerBleState()) + val observedUsb by usb.state.observeAsState(TalerUsbState()) + val ble = preview?.bluetooth ?: observedBle + val wired = preview?.usb ?: observedUsb + var step by rememberSaveable { mutableStateOf(preview?.step) } + var permissionDenied by rememberSaveable { mutableStateOf(false) } + var addingBluetooth by rememberSaveable { mutableStateOf(false) } + var usbCheck by rememberSaveable { mutableLongStateOf(-1L) } + + fun close() + { + bluetooth.cancelDiscovery() + // Cancelling an unfinished new pairing must not reconnect in the background. + if (addingBluetooth && + step == DeviceStep.Connect && + ble.status != TalerBleStatus.Ready) + { + bluetooth.forget() + } + addingBluetooth = false + step = null + } + + fun checkUsb() + { + usbCheck = wired.checkId + usb.refresh(forceTransfer = true) + } + + val permission = + rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result -> + permissionDenied = result.values.any { !it } + bluetooth.refresh() + } + val systemSettings = + rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { + bluetooth.refresh() + } + LaunchedEffect(step, ble.status) { + if (preview != null) return@LaunchedEffect + if (step == DeviceStep.Access && + ble.status !in + setOf( + TalerBleStatus.PermissionRequired, + TalerBleStatus.BluetoothOff, + TalerBleStatus.Unsupported, + TalerBleStatus.LocationOff, + )) + { + step = DeviceStep.OpenSettings + } + } + DisposableEffect(bluetooth, preview) { onDispose { if (preview == null) bluetooth.cancelDiscovery() } } + BackHandler(enabled = step != null) { close() } + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + if (ble.selected == null && + wired.devices.isEmpty()) + { + Text(stringResource(R.string.taler_devices_empty), style = MaterialTheme.typography.bodyLarge) + } + ble.selected?.let { selected -> + DeviceCard(selected.name, stringResource(ble.status.label())) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (ble.status == TalerBleStatus.Ready) + { + OutlinedButton( + onClick = { + bluetooth.testDisplay() + step = DeviceStep.Test + } + ) { + Text(stringResource(R.string.taler_ble_test)) + } + } + else + { + OutlinedButton( + onClick = { + if (ble.status == TalerBleStatus.PairingRequired) + { + step = DeviceStep.RepairAndroid + } + else + { + bluetooth.refresh() + step = DeviceStep.Connect + } + } + ) { Text(stringResource(R.string.taler_ble_reconnect)) } + } + TextButton( + onClick = { step = DeviceStep.Remove }, + ) { Text(stringResource(R.string.taler_devices_remove)) } + } + } + } + wired.devices.forEachIndexed { index, device -> + DeviceCard( + stringResource(R.string.taler_usb_device, index + 1), + stringResource(usbStatus(device.status)), + ) { + OutlinedButton( + onClick = { + step = DeviceStep.Usb + checkUsb() + } + ) { + Text(stringResource(R.string.taler_usb_refresh)) + } + } + } + Button(onClick = { step = DeviceStep.Choose }, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.taler_devices_add)) + } + OutlinedButton( + onClick = { + step = DeviceStep.Usb + checkUsb() + }, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.taler_devices_check_usb)) + } + } + val current = step ?: return + val title = + when (current) + { + DeviceStep.Choose -> + { + R.string.taler_devices_add + } + + DeviceStep.Replace -> + { + R.string.taler_devices_replace + } + + DeviceStep.Access -> + { + R.string.taler_devices_access + } + + DeviceStep.OpenSettings -> + { + R.string.taler_devices_open_lcd + } + + DeviceStep.Pair -> + { + R.string.taler_devices_pair_lcd + } + + DeviceStep.Find -> + { + R.string.taler_devices_choose_display + } + + DeviceStep.Connect -> + { + if (ble.status == + TalerBleStatus.Ready) + { + R.string.taler_devices_connected + } + else + { + R.string.taler_devices_connect + } + } + + DeviceStep.Usb -> + { + R.string.taler_devices_check_usb + } + + DeviceStep.Test -> + { + R.string.taler_ble_test + } + + DeviceStep.Remove -> + { + R.string.taler_devices_remove + } + + DeviceStep.RepairAndroid, DeviceStep.RepairDisplay -> + { + R.string.taler_devices_pair_again + } + } + AlertDialog( + onDismissRequest = { close() }, + title = { Text(stringResource(title)) }, + text = { + Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp)) { + when (current) + { + DeviceStep.Choose -> + { + OutlinedButton( + onClick = { + if (ble.selected != null) + { + step = DeviceStep.Replace + } + else + { + bluetooth.refresh() + step = DeviceStep.Access + } + }, + modifier = Modifier.fillMaxWidth() + ) { + Text( + stringResource(R.string.taler_devices_bluetooth), + ) + } + OutlinedButton( + onClick = { + step = DeviceStep.Usb + checkUsb() + }, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.taler_devices_usb)) + } + } + + DeviceStep.Replace -> + { + Text(stringResource(R.string.taler_devices_replace_hint)) + } + + DeviceStep.Access -> + { + Text(stringResource(ble.status.label())) + } + + DeviceStep.OpenSettings -> + { + Text(stringResource(R.string.taler_devices_open_lcd_hint)) + } + + DeviceStep.Pair -> + { + Text(stringResource(R.string.taler_devices_pair_lcd_hint)) + } + + DeviceStep.Find -> + { + Text( + stringResource( + if (ble.status == + TalerBleStatus.Disconnected) + { + R.string.taler_devices_scan_finished + } + else + { + ble.status.label() + }, + ), + ) + ble.candidates.forEach { candidate -> + OutlinedButton( + onClick = { + addingBluetooth = true + bluetooth.select(candidate) + step = + DeviceStep.Connect + }, + modifier = Modifier.fillMaxWidth() + ) { + Text("${candidate.name} ${candidate.address.takeLast(5)}") + } + } + } + + DeviceStep.Connect -> + { + Text( + stringResource( + if (ble.status == TalerBleStatus.Ready) + { + R.string.taler_devices_connected_hint + } + else + { + ble.status.label() + }, + ), + ) + } + + DeviceStep.Usb -> + { + if (wired.unavailable) + { + Text(stringResource(R.string.taler_usb_failed)) + } + else if (wired.checkId <= usbCheck) + { + Text(stringResource(R.string.taler_devices_checking)) + } + else if (wired.devices.isEmpty()) + { + Text(stringResource(R.string.taler_usb_not_attached)) + } + else + { + wired.devices.forEachIndexed { index, device -> + Text(stringResource(R.string.taler_usb_device, index + 1)) + Text(stringResource(usbStatus(device.status))) + if (device.status == TalerUsbStatus.PermissionRequired) + { + OutlinedButton(onClick = { usb.requestPermission(device.id) }) { + Text(stringResource(R.string.taler_usb_allow_device, index + 1)) + } + } + } + } + } + + DeviceStep.Test -> + { + Text( + stringResource( + if (ble.hasResetLimitation) + { + R.string.taler_ble_reset_limitation + } + else + { + R.string.taler_devices_test_hint + }, + ), + ) + } + + DeviceStep.Remove -> + { + Text(stringResource(R.string.taler_ble_forget_explanation)) + } + + DeviceStep.RepairAndroid -> + { + Text(stringResource(R.string.taler_devices_repair_android)) + } + + DeviceStep.RepairDisplay -> + { + Text(stringResource(R.string.taler_devices_repair_lcd)) + } + } + } + }, + confirmButton = { + when (current) + { + DeviceStep.Choose -> + { + Unit + } + + DeviceStep.Replace -> + { + DialogAction(R.string.taler_devices_replace) { + bluetooth.forget() + bluetooth.refresh() + step = DeviceStep.Access + } + } + + DeviceStep.Access, DeviceStep.Find, DeviceStep.Connect -> + { + when (ble.status) + { + TalerBleStatus.PermissionRequired -> + { + DialogAction( + if (permissionDenied) + { + R.string.taler_ble_open_settings + } + else + { + R.string.taler_ble_allow + }, + ) { + if (permissionDenied) + { + systemSettings.launch( + Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.parse("package:net.taler.merchantpos"), + ), + ) + } + else + { + permission.launch(bluetooth.requiredPermissions()) + } + } + } + + TalerBleStatus.BluetoothOff, TalerBleStatus.LocationOff -> + { + DialogAction(R.string.taler_ble_open_settings) { + systemSettings.launch( + Intent( + if (ble.status == TalerBleStatus.LocationOff) + { + Settings.ACTION_LOCATION_SOURCE_SETTINGS + } + else + { + Settings.ACTION_BLUETOOTH_SETTINGS + }, + ), + ) + } + } + + TalerBleStatus.Unsupported -> + { + DialogAction(android.R.string.ok) { close() } + } + + TalerBleStatus.Ready -> + { + DialogAction(R.string.taler_devices_done) { close() } + } + + TalerBleStatus.PairingRequired -> + { + DialogAction(R.string.taler_devices_pair_again) { + step = + DeviceStep.RepairAndroid + } + } + + else -> + { + if (current == DeviceStep.Find && + ble.status != TalerBleStatus.Scanning) + { + DialogAction(R.string.taler_devices_search_again) { bluetooth.startScan() } + } + else if (current == DeviceStep.Connect && + ble.status in setOf(TalerBleStatus.Failed, TalerBleStatus.Disconnected)) + { + DialogAction(R.string.taler_ble_reconnect) { bluetooth.refresh() } + } + } + } + } + + DeviceStep.OpenSettings -> + { + DialogAction(R.string.taler_devices_next) { step = DeviceStep.Pair } + } + + DeviceStep.Pair -> + { + DialogAction(R.string.taler_ble_scan) { + step = DeviceStep.Find + bluetooth.startScan() + } + } + + DeviceStep.Usb -> + { + DialogAction(R.string.taler_usb_refresh) { checkUsb() } + } + + DeviceStep.Test -> + { + DialogAction(R.string.taler_devices_done) { close() } + } + + DeviceStep.Remove -> + { + DialogAction(R.string.taler_devices_remove) { + bluetooth.forget() + step = null + } + } + + DeviceStep.RepairAndroid -> + { + DialogAction(R.string.taler_devices_next) { step = DeviceStep.RepairDisplay } + } + + DeviceStep.RepairDisplay -> + { + DialogAction(R.string.taler_ble_reconnect) { + bluetooth.retryPairing() + step = + DeviceStep.Connect + } + } + } + }, + dismissButton = { + if (current == DeviceStep.RepairAndroid) + { + DialogAction( + R.string.taler_ble_open_settings, + ) { systemSettings.launch(Intent(Settings.ACTION_BLUETOOTH_SETTINGS)) } + } + else if (current != DeviceStep.Test && + !(current == DeviceStep.Connect && + ble.status == TalerBleStatus.Ready)) + { + DialogAction( + if (current == + DeviceStep.Usb) + { + R.string.taler_devices_done + } + else + { + android.R.string.cancel + }, + ) { close() } + } + }, + ) +} + +@Composable +private fun DialogAction( + label: Int, + action: () -> Unit, +) +{ + TextButton(onClick = action) { Text(stringResource(label)) } +} + +@Composable +private fun DeviceCard( + name: String, + status: String, + actions: @Composable () -> Unit, +) +{ + PosOutlinedCard { padding -> + Column(Modifier.padding(padding), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(name, style = MaterialTheme.typography.titleMedium) + Text( + status, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + actions() + } + } +} + +private fun usbStatus(status: TalerUsbStatus): Int = + when (status) + { + TalerUsbStatus.Ready -> R.string.payment_device_status_connected + TalerUsbStatus.PermissionRequired -> R.string.taler_usb_permission_required + TalerUsbStatus.Failed -> R.string.taler_usb_failed + } diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/devices/TalerUsbDevices.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/devices/TalerUsbDevices.kt @@ -0,0 +1,246 @@ +/* + * 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.devices + +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.hardware.usb.UsbDevice +import android.hardware.usb.UsbManager + +import androidx.core.content.ContextCompat +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData + +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors + +data class TalerUsbState( + val enabled: Boolean = true, + val devices: List<TalerUsbDeviceState> = emptyList(), + val unavailable: Boolean = false, + val checkId: Long = 0, +) + +/** Application context only: permission and queued transfers survive screen rotation. */ +class TalerUsbDevices internal constructor( + context: Context, + private val executor: ExecutorService, + transport: TalerUsbTransport?, +) : AutoCloseable +{ + constructor(context: Context) : this(context, Executors.newSingleThreadExecutor(), null) + + private val app = context.applicationContext + private val manager = app.getSystemService(Context.USB_SERVICE) as UsbManager + private val preferences = app.getSharedPreferences("taler-usb-devices", Context.MODE_PRIVATE) + private val session = TalerUsbSession(transport ?: AndroidTalerUsbTransport(manager)) + private val permissionAction = "${app.packageName}.TALER_USB_PERMISSION" + + @Volatile + private var enabled = true + + // Older transport opt-out switches no longer gate connections. + @Volatile + private var talerEnabled = true + + @Volatile + private var uri: String? = null + private var uriOwner: Any? = null + + @Volatile + private var closed = false + private var checkId = 0L // Accessed only on the USB worker. + private val pendingPermissions = mutableSetOf<String>() + private val mutableState = MutableLiveData(TalerUsbState(enabled)) + + private val receiver = + object : BroadcastReceiver() + { + override fun onReceive( + context: Context, + intent: Intent, + ) + { + if (closed) return + @Suppress("DEPRECATION") + val device = intent.getParcelableExtra<UsbDevice>(UsbManager.EXTRA_DEVICE) + if (intent.action == permissionAction) + { + // Immutable PendingIntent extras identify the request even on Android + // versions that do not fill in USB permission result extras. + intent.getStringExtra("deviceId")?.let { pendingPermissions.remove(it) } + } + if (intent.action == UsbManager.ACTION_USB_DEVICE_DETACHED && + device != null) + { + pendingPermissions.remove(device.deviceName) + executor.execute { session.detached(device.deviceName) } + } + // Re-enumerate and check hasPermission; never trust a broadcast's grant flag. + refresh() + } + } + + val state: LiveData<TalerUsbState> = mutableState + + init + { + ContextCompat.registerReceiver( + app, + receiver, + IntentFilter().apply { + addAction(permissionAction) + addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED) + addAction(UsbManager.ACTION_USB_DEVICE_DETACHED) + }, + ContextCompat.RECEIVER_NOT_EXPORTED, + ) + } + + fun setEnabled(value: Boolean) + { + enabled = value + preferences.edit().putBoolean("enabled", value).apply() + refresh() + } + + fun setTalerEnabled(value: Boolean) + { + if (talerEnabled == value) return + talerEnabled = value + refresh() + } + + /** Only the visible payment/refund screen supplies a URI; leaving it supplies null. */ + fun setUri(value: String?) + { + if (uri == value) return + uri = value + refresh() + } + + fun present( + owner: Any, + value: String?, + ) + { + uriOwner = owner + setUri(value) + } + + /** A departing screen cannot clear the link already supplied by its successor. */ + fun dismiss(owner: Any) + { + if (uriOwner !== owner) return + uriOwner = null + setUri(null) + } + + @Synchronized + fun refresh(forceTransfer: Boolean = false) + { + if (closed) return + executor.execute { + if (forceTransfer) + { + session.invalidateTransfers() + checkId++ + } + val current = + try + { + TalerUsbState(enabled, session.synchronize(enabled && talerEnabled, uri), checkId = checkId) + } + catch (_: RuntimeException) + { + TalerUsbState(enabled, unavailable = true, checkId = checkId) + } + if (!closed && + mutableState.value != current) + { + mutableState.postValue(current) + } + } + } + + /** Explicit action only: denying access never causes a repeated permission popup. */ + fun requestPermission(id: String) + { + if (closed || + !enabled || + !talerEnabled || + id in pendingPermissions) + { + return + } + val device = manager.deviceList[id] ?: return + if (!TalerUsbSession.supports(device.vendorId, device.productId)) return + if (manager.hasPermission(device)) + { + refresh() + return + } + pendingPermissions.add(id) + try + { + val intent = + PendingIntent.getBroadcast( + app, + device.deviceId, + Intent(permissionAction).setPackage(app.packageName).putExtra("deviceId", id), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + manager.requestPermission(device, intent) + } + catch (_: RuntimeException) + { + pendingPermissions.remove(id) + mutableState.value = TalerUsbState(enabled, unavailable = true, checkId = mutableState.value?.checkId ?: 0) + } + } + + fun logout() + { + uriOwner = null + uri = null + setEnabled(true) + } + + @Synchronized + override fun close() + { + if (closed) return + closed = true + uriOwner = null + uri = null + app.unregisterReceiver(receiver) + executor.execute { + try + { + session.synchronize(false, null) + } + catch (_: RuntimeException) + { + // The device may detach before the final clear reaches the worker. + } + } + executor.shutdown() + } +} diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/devices/TalerUsbSession.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/devices/TalerUsbSession.kt @@ -0,0 +1,135 @@ +/* + * 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.devices + +internal data class TalerUsbDevice( + val id: String, + val permitted: Boolean, +) + +internal interface TalerUsbTransport +{ + /** Lists attached supported devices, including those still awaiting USB permission. */ + fun devices(): List<TalerUsbDevice> + + /** True only if the entire payload was transferred. An empty URL requests idle. */ + fun write( + device: TalerUsbDevice, + payload: ByteArray, + ): Boolean +} + +enum class TalerUsbStatus +{ + PermissionRequired, + Ready, + Failed +} + +data class TalerUsbDeviceState( + val id: String, + val status: TalerUsbStatus, +) + +/** Runs on one worker, so a late write can never overtake cancellation or the next order. */ +internal class TalerUsbSession( + private val transport: TalerUsbTransport, +) +{ + private val sent = mutableMapOf<String, String>() + + fun invalidateTransfers() + { + sent.replaceAll { _, _ -> "\u0000" } + } + + fun detached(id: String) + { + sent.remove(id) + } + + fun synchronize( + enabled: Boolean, + uri: String?, + ): List<TalerUsbDeviceState> + { + val devices = transport.devices() + sent.keys.retainAll(devices.map { it.id }.toSet()) + val payload = if (enabled) uri.orEmpty() else "" + return devices.map { device -> + val status = + when + { + !device.permitted -> + { + sent.remove(device.id) + TalerUsbStatus.PermissionRequired + } + + // Never touch an unused device while the integration is off. Clear a + // device previously used by this session even when being switched off. + !enabled && device.id !in sent -> + { + TalerUsbStatus.Ready + } + + sent[device.id] == payload -> + { + TalerUsbStatus.Ready + } + + else -> + { + // Remember that it may have received data even on a short write, + // so disabling the integration still attempts to clear it. + sent[device.id] = "\u0000" + val bytes = payload.toByteArray(Charsets.UTF_8) + val success = + try + { + bytes.size <= MAX_PAYLOAD_BYTES && transport.write(device, bytes) + } + catch (_: RuntimeException) + { + false + } + if (success) + { + sent[device.id] = payload + TalerUsbStatus.Ready + } + else + { + TalerUsbStatus.Failed + } + } + } + TalerUsbDeviceState(device.id, status) + } + } + + companion object + { + // Android versions before P limit USB transfers to 16 KiB. + const val MAX_PAYLOAD_BYTES = 16 * 1024 + + fun supports( + vendorId: Int, + productId: Int, + ) = vendorId == 0xd00d && productId == 0x8f71 + } +} 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 @@ -17,10 +17,16 @@ package net.taler.merchantpos.history import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup import android.widget.Toast + +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -29,11 +35,16 @@ import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Surface @@ -41,44 +52,61 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.lifecycle.Lifecycle -import androidx.compose.runtime.livedata.observeAsState -import kotlinx.coroutines.flow.collectLatest + +import net.taler.common.Amount +import net.taler.common.CurrencySpecification +import net.taler.lib.android.order.OrderDetailStatusBadge import net.taler.lib.android.toRelativeTime -import net.taler.merchantpos.MainActivity import net.taler.merchantlib.OrderHistoryEntry +import net.taler.merchantpos.MainActivity import net.taler.merchantpos.MainViewModel import net.taler.merchantpos.PosDestination import net.taler.merchantpos.R +import net.taler.merchantpos.checkout.Checkout +import net.taler.merchantpos.checkout.CheckoutStage +import net.taler.merchantpos.checkout.PaymentLine +import net.taler.merchantpos.checkout.shown import net.taler.merchantpos.compose.PosTheme import net.taler.merchantpos.payment.Payment +import net.taler.merchantpos.paymentoptions.PaymentOption import net.taler.merchantpos.showPosError -internal interface HistoryActionListener { +import kotlinx.coroutines.flow.collectLatest + +internal interface HistoryActionListener +{ fun onRefundClicked(item: OrderHistoryEntry) + fun onDeleteClicked(item: OrderHistoryEntry) + fun onShowPaymentClicked(item: OrderHistoryEntry) + fun onShowRefundClicked(item: OrderHistoryEntry) } -class HistoryFragment : Fragment(), HistoryActionListener { - +class HistoryFragment : + Fragment(), + HistoryActionListener +{ private val model: MainViewModel by activityViewModels() private val historyManager by lazy { model.historyManager } private val refundManager by lazy { model.refundManager } override fun onCreateView( - inflater: android.view.LayoutInflater, - container: android.view.ViewGroup?, + inflater: LayoutInflater, + container: ViewGroup?, savedInstanceState: Bundle?, ) = ComposeView(requireContext()).apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) @@ -89,13 +117,24 @@ class HistoryFragment : Fragment(), HistoryActionListener { val pendingRefundOrderId by refundManager.pendingRefundOrderId.observeAsState() val activePayment by model.paymentManager.payment.observeAsState() val forceDeleteOrderId by historyManager.forceDeleteOrderId.observeAsState() + val checkouts by model.checkoutManager.checkouts.observeAsState(emptyList()) HistoryScreen( + unfinishedCheckouts = + checkouts + .filter { it.stage != CheckoutStage.Completed } + .sortedByDescending { it.createdAt }, + checkoutOptions = model.paymentOptions.options, + currencySpec = model.configManager.currencySpec, + onContinueCheckout = ::onContinueCheckout, + onDismissCheckout = { checkout -> model.checkoutManager.dismiss(checkout.id) }, isLoading = isLoading, isLoadingMore = isLoadingMore, result = result, pendingRefundOrderId = pendingRefundOrderId, activePayment = activePayment, - onRefresh = { historyManager.fetchHistory() }, + onOpenOrder = { item -> + (requireActivity() as MainActivity).navigateTo(PosDestination.OrderDetails.forOrder(item.orderId)) + }, onLoadMore = historyManager::loadMoreHistoryIfNeeded, onRefundClicked = ::onRefundClicked, onDeleteClicked = ::onDeleteClicked, @@ -111,56 +150,94 @@ class HistoryFragment : Fragment(), HistoryActionListener { } } - override fun onViewCreated(view: android.view.View, savedInstanceState: Bundle?) { + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) + { super.onViewCreated(view, savedInstanceState) historyManager.error.observe(viewLifecycleOwner) { error -> - if (error != null) { + if (error != null) + { requireActivity().showPosError(error.mainResId, error.msg) historyManager.clearError() } } } - override fun onStart() { + override fun onStart() + { super.onStart() - if (model.configManager.merchantConfig?.baseUrl == null) { + if (model.configManager.merchantConfig?.baseUrl == null) + { (requireActivity() as MainActivity).navigateTo(PosDestination.Config) - } else { - Toast.makeText( - requireContext(), - R.string.toast_history_updating, - Toast.LENGTH_SHORT, - ).show() + } + else + { + Toast + .makeText( + requireContext(), + R.string.toast_history_updating, + Toast.LENGTH_SHORT, + ).show() historyManager.fetchHistory { - if (isAdded && lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) { - Toast.makeText( - requireContext(), - R.string.toast_history_updated, - Toast.LENGTH_SHORT, - ).show() + if (isAdded && + lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) + { + Toast + .makeText( + requireContext(), + R.string.toast_history_updated, + Toast.LENGTH_SHORT, + ).show() } } } } - override fun onRefundClicked(item: OrderHistoryEntry) { + override fun onRefundClicked(item: OrderHistoryEntry) + { refundManager.startRefund(item) (requireActivity() as MainActivity).navigateTo(PosDestination.Refund) } - override fun onDeleteClicked(item: OrderHistoryEntry) { + override fun onDeleteClicked(item: OrderHistoryEntry) + { historyManager.deleteOrder(item.orderId) } - override fun onShowPaymentClicked(item: OrderHistoryEntry) { + private fun onContinueCheckout(checkout: Checkout) + { + if (!model.checkoutManager.resume(checkout.id)) return + val resumed = model.checkoutManager.current ?: return + val destination = + if (resumed.stage == CheckoutStage.AwaitingTaler && + resumed.backendOrderId != null) + { + model.paymentManager.showCheckoutOrder(resumed) + PosDestination.ProcessPayment + } + else + { + PosDestination.Checkout + } + (requireActivity() as MainActivity).navigateTo(destination) + } + + override fun onShowPaymentClicked(item: OrderHistoryEntry) + { model.paymentManager.resumePayment(item) (requireActivity() as MainActivity).navigateTo(PosDestination.ProcessPayment) } - override fun onShowRefundClicked(item: OrderHistoryEntry) { - if (refundManager.resumeRefund(item)) { + override fun onShowRefundClicked(item: OrderHistoryEntry) + { + if (refundManager.resumeRefund(item)) + { (requireActivity() as MainActivity).navigateTo(PosDestination.RefundUri) - } else { + } + else + { requireActivity().showPosError(R.string.refund_state_missing) historyManager.fetchHistory() } @@ -169,56 +246,113 @@ class HistoryFragment : Fragment(), HistoryActionListener { @Composable private fun HistoryScreen( + /** Local checkouts merged with backend history by order ID. */ + unfinishedCheckouts: List<Checkout> = emptyList(), + checkoutOptions: List<PaymentOption> = emptyList(), + currencySpec: CurrencySpecification? = null, + onContinueCheckout: (Checkout) -> Unit = {}, + onDismissCheckout: (Checkout) -> Unit = {}, isLoading: Boolean, isLoadingMore: Boolean, result: HistoryResult?, pendingRefundOrderId: String?, activePayment: Payment?, - onRefresh: () -> Unit, + onOpenOrder: (OrderHistoryEntry) -> Unit = {}, onLoadMore: (Int) -> Unit, onRefundClicked: (OrderHistoryEntry) -> Unit, onDeleteClicked: (OrderHistoryEntry) -> Unit, onShowPaymentClicked: (OrderHistoryEntry) -> Unit, onShowRefundClicked: (OrderHistoryEntry) -> Unit, -) { +) +{ PosTheme { val listState = rememberLazyListState() val historyItemKeys = (result as? HistoryResult.Success)?.items?.map { it.orderId }.orEmpty() val firstHistoryItemKey = historyItemKeys.firstOrNull() LaunchedEffect(firstHistoryItemKey) { - if (firstHistoryItemKey != null) { + if (firstHistoryItemKey != null) + { listState.scrollToItem(0) } } - LaunchedEffect(listState, historyItemKeys.size) { - snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index } - .collectLatest { index -> - if (index != null) onLoadMore(index) + val historyItems = (result as? HistoryResult.Success)?.items.orEmpty() + val historyById = historyItems.associateBy { it.orderId } + val entries = mergeHistoryEntries(unfinishedCheckouts, historyItems, activePayment) + val localCheckouts = entries.checkouts + val backendItems = entries.orders + val headerCount = localCheckouts.size + + LaunchedEffect(listState, historyItemKeys, localCheckouts.map { it.id }) { + snapshotFlow { + listState.layoutInfo.visibleItemsInfo + .lastOrNull() + ?.index + }.collectLatest { index -> + if (index == null) return@collectLatest + val backendIndex = index - headerCount + val orderId = backendItems.getOrNull(backendIndex)?.orderId + if (orderId != null) + { + onLoadMore(historyItems.indexOfFirst { it.orderId == orderId }) } + else if (index >= headerCount + backendItems.size - 1) + { + onLoadMore(historyItems.lastIndex) + } + } } Column( - modifier = Modifier - .fillMaxSize() - .statusBarsPadding() - .padding(16.dp), + modifier = + Modifier + .fillMaxSize() + .statusBarsPadding() + .padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { - if (isLoading && result !is HistoryResult.Success) { + if (isLoading && + result !is HistoryResult.Success) + { CircularProgressIndicator() } - when (result) { - is HistoryResult.Success -> { - LazyColumn( - state = listState, - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - items(result.items, key = { it.orderId }) { item -> + LazyColumn( + state = listState, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (localCheckouts.isNotEmpty()) + { + items(localCheckouts, key = { "checkout-${it.id}" }) { checkout -> + UnfinishedCheckoutCard( + checkout = checkout, + status = + checkoutHistoryStatus( + checkout, + historyById[checkout.backendOrderId], + activePayment, + ), + onOpenOrder = + historyById[checkout.backendOrderId]?.let { item -> + { onOpenOrder(item) } + }, + options = checkoutOptions, + spec = currencySpec, + onContinue = { onContinueCheckout(checkout) }, + onDismiss = { onDismissCheckout(checkout) }, + ) + } + } + when (result) + { + is HistoryResult.Success -> + { + items(backendItems, key = { it.orderId }) { item -> HistoryItemCard( item = item, + spec = currencySpec, + onOpenOrder = { onOpenOrder(item) }, hasPendingRefund = pendingRefundOrderId == item.orderId, activePayment = activePayment?.takeIf { it.orderId == item.orderId }, onRefundClicked = { onRefundClicked(item) }, @@ -227,23 +361,28 @@ private fun HistoryScreen( onShowRefundClicked = { onShowRefundClicked(item) }, ) } - if (isLoadingMore) { + if (isLoadingMore) + { item(key = "loading-more") { Box( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), ) { CircularProgressIndicator( - modifier = Modifier.align(androidx.compose.ui.Alignment.Center), + modifier = Modifier.align(Alignment.Center), ) } } } } - } - null -> Unit + null -> + { + Unit + } + } } } } @@ -252,29 +391,28 @@ private fun HistoryScreen( @Composable private fun HistoryItemCard( item: OrderHistoryEntry, + spec: CurrencySpecification?, + onOpenOrder: () -> Unit, hasPendingRefund: Boolean, activePayment: Payment?, onRefundClicked: () -> Unit, onDeleteClicked: () -> Unit, onShowPaymentClicked: () -> Unit, onShowRefundClicked: () -> Unit, -) { - val status = when { - item.hasPendingRefund -> HistoryStatus.RefundPending - item.hasRefund -> HistoryStatus.Refunded - hasPendingRefund -> HistoryStatus.RefundPending - activePayment?.paid == true -> HistoryStatus.Paid - activePayment?.claimed == true -> HistoryStatus.PaymentClaimed - activePayment?.orderId == item.orderId && activePayment.error == null -> HistoryStatus.PaymentPending - item.paid -> HistoryStatus.Paid - else -> HistoryStatus.Unpaid - } +) +{ + val status = historyStatus(item, hasPendingRefund, activePayment) - Card { + Card( + onClick = onOpenOrder, + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { Row( @@ -289,39 +427,86 @@ private fun HistoryItemCard( text = item.summary, style = MaterialTheme.typography.titleMedium, ) + val remaining = + if (item.paid || + activePayment?.paid == true) + { + Amount + .zero(item.totalAmount.currency) + } + else + { + item.amount + } + val paid = + if (remaining >= + item.totalAmount) + { + Amount + .zero(item.totalAmount.currency) + } + else + { + item.totalAmount - remaining + } Text( - text = item.amount.toString(), - style = MaterialTheme.typography.bodyLarge, + stringResource( + R.string.history_total_paid_remaining, + item.totalAmount.shown(spec), + paid.shown(spec), + remaining.shown(spec), + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } Column( - horizontalAlignment = androidx.compose.ui.Alignment.End, + horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(6.dp), ) { HistoryStatusBadge(status = status) - if (item.hasRefund && item.refundAmount != null) { + if (item.hasAnyRefund && + !item.totalRefundAmount.isZero()) + { HistoryAmountBadge( - amount = item.refundAmount.toString(), + amount = item.totalRefundAmount.shown(spec), status = status, ) } } } Text( - item.timestamp.ms.toRelativeTime(androidx.compose.ui.platform.LocalContext.current).toString(), + item.timestamp.ms + .toRelativeTime(LocalContext.current) + .toString(), style = MaterialTheme.typography.bodyMedium, ) - Text(stringResource(R.string.history_ref_no, item.orderId)) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - if (item.hasPendingRefund || hasPendingRefund) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(stringResource(R.string.history_ref_no, item.orderId), modifier = Modifier.weight(1f)) + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = stringResource(R.string.order_details_title), + ) + } + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (item.hasPendingRefund || + hasPendingRefund) + { Button(onClick = onShowRefundClicked) { Text(stringResource(R.string.history_show_refund)) } - } else if (item.refundable) { + } + else if (item.refundable) + { Button(onClick = onRefundClicked) { Text(stringResource(R.string.history_refund)) } - } else if (!item.paid) { + } + else if (!item.paid) + { Button(onClick = onShowPaymentClicked) { Text(stringResource(R.string.history_show_payment)) } @@ -334,10 +519,189 @@ private fun HistoryItemCard( } } -private enum class HistoryStatus( +/** An unfinished local checkout, including any payments already recorded. */ +@Composable +private fun UnfinishedCheckoutCard( + checkout: Checkout, + status: HistoryStatus, + onOpenOrder: (() -> Unit)?, + options: List<PaymentOption>, + spec: CurrencySpecification?, + onContinue: () -> Unit, + onDismiss: () -> Unit, +) +{ + val abandoned = checkout.stage == CheckoutStage.Abandoned + Card( + modifier = if (onOpenOrder != null) Modifier.clickable(onClick = onOpenOrder) else Modifier, + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + text = checkout.contractTerms.summary, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.weight(1f), + ) + HistoryStatusBadge(status) + } + val displayedPaid = if (checkout.isFullyPaidExternally) checkout.total else checkout.paid + val displayedRemaining = + if (checkout.isFullyPaidExternally) + { + Amount + .zero(checkout.total.currency) + } + else + { + checkout.open + } + Text( + stringResource( + R.string.history_checkout_total_paid_remaining, + checkout.total.shown(spec), + displayedPaid.shown(spec), + displayedRemaining.shown(spec), + ), + style = MaterialTheme.typography.bodySmall, + ) + Text( + checkout.createdAt.toRelativeTime(LocalContext.current).toString(), + style = MaterialTheme.typography.bodyMedium, + ) + checkout.backendOrderId?.let { orderId -> + Text(stringResource(R.string.history_ref_no, orderId)) + } + if (checkout.payments.isNotEmpty()) + { + checkout.payments.forEach { payment -> PaymentLine(payment, spec, options) } + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (abandoned) + { + OutlinedButton(onClick = onDismiss) { + Text(stringResource(R.string.checkout_history_dismiss)) + } + } + else + { + Button(onClick = onContinue) { + Text( + stringResource( + if (checkout.stage == CheckoutStage.AwaitingTaler && + checkout.backendOrderId != null) + { + R.string.history_show_payment + } + else + { + R.string.checkout_history_continue + }, + ), + ) + } + } + } + } + } +} + +internal data class HistoryEntries( + val checkouts: List<Checkout>, + val orders: List<OrderHistoryEntry>, +) + +internal fun mergeHistoryEntries( + checkouts: List<Checkout>, + historyItems: List<OrderHistoryEntry>, + activePayment: Payment?, +): HistoryEntries +{ + val paidOrderIds = historyItems.filter { it.paid }.mapTo(mutableSetOf()) { it.orderId } + activePayment?.takeIf { it.paid }?.orderId?.let { paidOrderIds.add(it) } + val unfinished = checkouts.filter { it.stage != CheckoutStage.Completed && it.backendOrderId !in paidOrderIds } + val localOrderIds = unfinished.mapNotNull { it.backendOrderId }.toSet() + return HistoryEntries(unfinished, historyItems.filterNot { it.orderId in localOrderIds }) +} + +internal fun checkoutHistoryStatus( + checkout: Checkout, + historyItem: OrderHistoryEntry? = null, + activePayment: Payment? = null, +): HistoryStatus = + when + { + historyItem?.let { it.orderId == checkout.backendOrderId && it.claimed } == true -> + { + HistoryStatus.PaymentClaimed + } + + activePayment?.let { it.orderId != null && it.orderId == checkout.backendOrderId && it.claimed } == true -> + { + HistoryStatus.PaymentClaimed + } + + checkout.isFullyPaidExternally -> + { + HistoryStatus.AwaitingCollection + } + + checkout.stage == CheckoutStage.AwaitingTaler -> + { + HistoryStatus.PaymentPending + } + + checkout.paid.isZero() && checkout.stage == CheckoutStage.Abandoned -> + { + HistoryStatus.Unpaid + } + + checkout.paid.isZero() -> + { + HistoryStatus.PaymentPending + } + + else -> + { + HistoryStatus.PartiallyPaid + } + } + +internal fun historyStatus( + item: OrderHistoryEntry, + hasPendingRefund: Boolean = false, + activePayment: Payment? = null, +): HistoryStatus = + when + { + item.hasPendingRefund -> HistoryStatus.RefundPending + item.hasAnyRefund -> HistoryStatus.Refunded + hasPendingRefund -> HistoryStatus.RefundPending + activePayment?.let { it.orderId == item.orderId && it.paid } == true -> HistoryStatus.Paid + item.paid -> HistoryStatus.Paid + item.claimed -> HistoryStatus.PaymentClaimed + activePayment?.let { it.orderId == item.orderId && it.claimed } == true -> HistoryStatus.PaymentClaimed + activePayment?.let { it.orderId == item.orderId && it.error != null } == true -> HistoryStatus.Unpaid + activePayment?.orderId == item.orderId -> HistoryStatus.PaymentPending + item.hasExternalPayments && item.amount.isZero() -> HistoryStatus.AwaitingCollection + item.hasExternalPayments -> HistoryStatus.PartiallyPaid + else -> HistoryStatus.Unpaid + } + +internal enum class HistoryStatus( val labelResId: Int, -) { +) +{ Unpaid(R.string.history_status_unpaid), + PartiallyPaid(R.string.history_status_partially_paid), + AwaitingCollection(R.string.history_status_awaiting_collection), Paid(R.string.history_status_paid), PaymentPending(R.string.history_status_payment_pending), PaymentClaimed(R.string.history_status_payment_claimed), @@ -346,38 +710,80 @@ private enum class HistoryStatus( } @Composable -private fun HistoryStatusBadge(status: HistoryStatus) { - val colors = when (status) { - HistoryStatus.Unpaid -> MaterialTheme.colorScheme.errorContainer to MaterialTheme.colorScheme.onErrorContainer - HistoryStatus.Paid -> MaterialTheme.colorScheme.primaryContainer to MaterialTheme.colorScheme.onPrimaryContainer - HistoryStatus.PaymentPending -> MaterialTheme.colorScheme.secondaryContainer to MaterialTheme.colorScheme.onSecondaryContainer - HistoryStatus.PaymentClaimed -> MaterialTheme.colorScheme.tertiaryContainer to MaterialTheme.colorScheme.onTertiaryContainer - HistoryStatus.RefundPending -> MaterialTheme.colorScheme.secondaryContainer to MaterialTheme.colorScheme.onSecondaryContainer - HistoryStatus.Refunded -> MaterialTheme.colorScheme.tertiaryContainer to MaterialTheme.colorScheme.onTertiaryContainer - } - Surface( - color = colors.first, +internal fun HistoryStatusBadge(status: HistoryStatus) +{ + val colors = + when (status) + { + HistoryStatus.Unpaid -> + { + MaterialTheme.colorScheme.errorContainer to MaterialTheme.colorScheme.onErrorContainer + } + + HistoryStatus.Paid -> + { + MaterialTheme.colorScheme.primaryContainer to MaterialTheme.colorScheme.onPrimaryContainer + } + + HistoryStatus.PartiallyPaid, HistoryStatus.AwaitingCollection, + HistoryStatus.PaymentPending, + -> + { + MaterialTheme.colorScheme.secondaryContainer to + MaterialTheme.colorScheme.onSecondaryContainer + } + + HistoryStatus.PaymentClaimed -> + { + MaterialTheme.colorScheme.tertiaryContainer to + MaterialTheme.colorScheme.onTertiaryContainer + } + + HistoryStatus.RefundPending -> + { + MaterialTheme.colorScheme.secondaryContainer to + MaterialTheme.colorScheme.onSecondaryContainer + } + + HistoryStatus.Refunded -> + { + MaterialTheme.colorScheme.tertiaryContainer to + MaterialTheme.colorScheme.onTertiaryContainer + } + } + OrderDetailStatusBadge( + label = stringResource(status.labelResId), + containerColor = colors.first, contentColor = colors.second, - shape = MaterialTheme.shapes.small, - ) { - Text( - text = stringResource(status.labelResId), - modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), - style = MaterialTheme.typography.labelLarge, - ) - } + ) } @Composable private fun HistoryAmountBadge( amount: String, status: HistoryStatus, -) { - val colors = when (status) { - HistoryStatus.RefundPending -> MaterialTheme.colorScheme.secondaryContainer to MaterialTheme.colorScheme.onSecondaryContainer - HistoryStatus.Refunded -> MaterialTheme.colorScheme.tertiaryContainer to MaterialTheme.colorScheme.onTertiaryContainer - else -> MaterialTheme.colorScheme.surfaceVariant to MaterialTheme.colorScheme.onSurfaceVariant - } +) +{ + val colors = + when (status) + { + HistoryStatus.RefundPending -> + { + MaterialTheme.colorScheme.secondaryContainer to + MaterialTheme.colorScheme.onSecondaryContainer + } + + HistoryStatus.Refunded -> + { + MaterialTheme.colorScheme.tertiaryContainer to + MaterialTheme.colorScheme.onTertiaryContainer + } + + else -> + { + MaterialTheme.colorScheme.surfaceVariant to MaterialTheme.colorScheme.onSurfaceVariant + } + } Surface( color = colors.first, contentColor = colors.second, @@ -395,7 +801,8 @@ private fun HistoryAmountBadge( private fun ForceDeleteOrderDialog( onConfirm: () -> Unit, onDismiss: () -> Unit, -) { +) +{ AlertDialog( onDismissRequest = onDismiss, title = { Text(stringResource(R.string.force_delete_dialog_title)) }, @@ -403,9 +810,10 @@ private fun ForceDeleteOrderDialog( confirmButton = { Button( onClick = onConfirm, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.error, - ), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + ), ) { Text(stringResource(R.string.force_delete_dialog_confirm)) } @@ -421,14 +829,16 @@ private fun ForceDeleteOrderDialog( @Composable internal fun HistoryScreenContent( items: List<OrderHistoryEntry>, -) { + onOpenOrder: (OrderHistoryEntry) -> Unit = {}, +) +{ HistoryScreen( isLoading = false, isLoadingMore = false, result = HistoryResult.Success(items), + onOpenOrder = onOpenOrder, pendingRefundOrderId = null, activePayment = null, - onRefresh = {}, onLoadMore = {}, onRefundClicked = {}, onDeleteClicked = {}, 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 @@ -20,18 +20,37 @@ import androidx.annotation.StringRes import androidx.annotation.UiThread import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch + import net.taler.lib.android.assertUiThread import net.taler.merchantlib.CheckPaymentResponse import net.taler.merchantlib.MerchantApi +import net.taler.merchantlib.MerchantUnit import net.taler.merchantlib.OrderHistoryEntry import net.taler.merchantpos.R import net.taler.merchantpos.config.ConfigManager -sealed class HistoryResult { - class Success(val items: List<OrderHistoryEntry>) : HistoryResult() +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch + +sealed class HistoryResult +{ + class Success( + val items: List<OrderHistoryEntry>, + ) : HistoryResult() +} + +internal sealed interface OrderDetailsResult +{ + data object Loading : OrderDetailsResult + + data class Success( + val response: CheckPaymentResponse, + ) : OrderDetailsResult + + data class Error( + val message: String? = null, + ) : OrderDetailsResult } class HistoryError( @@ -42,51 +61,97 @@ class HistoryError( class HistoryManager( private val configManager: ConfigManager, private val scope: CoroutineScope, - private val api: MerchantApi -) { - companion object { + private val api: MerchantApi, +) +{ + companion object + { private const val PAGE_SIZE = 20 private const val LOAD_MORE_THRESHOLD = 5 } + private val pagination = HistoryPagination() + private var historyLoadJob: Job? = null + private var historyLoadGeneration = 0 private val mIsLoading = MutableLiveData(false) - val isLoading: LiveData<Boolean> = mIsLoading private val mIsLoadingMore = MutableLiveData(false) - val isLoadingMore: LiveData<Boolean> = mIsLoadingMore - private val mItems = MutableLiveData<HistoryResult>() - val items: LiveData<HistoryResult> = mItems private val mError = MutableLiveData<HistoryError?>(null) + private val mForceDeleteOrderId = MutableLiveData<String?>(null) + + val isLoading: LiveData<Boolean> = mIsLoading + val isLoadingMore: LiveData<Boolean> = mIsLoadingMore + val items: LiveData<HistoryResult> = mItems val error: LiveData<HistoryError?> = mError + val forceDeleteOrderId: LiveData<String?> = mForceDeleteOrderId - private val pagination = HistoryPagination() - private var historyLoadJob: Job? = null - private var historyLoadGeneration = 0 + internal suspend fun fetchUnits(): Map<String, MerchantUnit> + { + val config = configManager.merchantConfig ?: return emptyMap() + var units = emptyMap<String, MerchantUnit>() + // Older backends and restricted tokens may not expose the catalogue. + api.getUnits(config).handle(onSuccess = { units = it.units.associateBy(MerchantUnit::unit) }) + return units + } + + /** Called by the details composition, so leaving the page cancels the request. */ + internal suspend fun fetchOrderDetails(orderId: String): OrderDetailsResult + { + val config = configManager.merchantConfig ?: return OrderDetailsResult.Error() + var result: OrderDetailsResult = OrderDetailsResult.Error() + api.checkOrder(config, orderId).handle( + { result = OrderDetailsResult.Error(it) }, + ) { response -> + updateItem(orderId) { it.withPaymentDetails(response) } + result = OrderDetailsResult.Success(response) + } + return result + } @UiThread - internal fun fetchHistory(onFinished: (() -> Unit)? = null) { + internal fun fetchHistory(onFinished: (() -> Unit)? = null) + { startHistoryLoad(reset = true, onFinished = onFinished) } @UiThread - internal fun loadMoreHistoryIfNeeded(lastVisibleIndex: Int) { + internal fun loadMoreHistoryIfNeeded(lastVisibleIndex: Int) + { val currentItems = (mItems.value as? HistoryResult.Success)?.items ?: return - if (mIsLoading.value == true || mIsLoadingMore.value == true || pagination.reachedEnd) return + if (mIsLoading.value == true || + mIsLoadingMore.value == true || + pagination.reachedEnd) + { + return + } if (currentItems.isEmpty()) return if (lastVisibleIndex < currentItems.lastIndex - LOAD_MORE_THRESHOLD) return startHistoryLoad(reset = false) } @UiThread - private fun startHistoryLoad(reset: Boolean, onFinished: (() -> Unit)? = null) { - if (!reset && historyLoadJob?.isActive == true) return + private fun startHistoryLoad( + reset: Boolean, + onFinished: (() -> Unit)? = null, + ) + { + if (!reset && + historyLoadJob?.isActive == true) + { + return + } val generation = ++historyLoadGeneration historyLoadJob?.cancel() - historyLoadJob = scope.launch { - val succeeded = fetchHistoryPages(reset, generation) - if (succeeded && generation == historyLoadGeneration) onFinished?.invoke() - } + historyLoadJob = + scope.launch { + val succeeded = fetchHistoryPages(reset, generation) + if (succeeded && + generation == historyLoadGeneration) + { + onFinished?.invoke() + } + } } /** @@ -94,23 +159,32 @@ class HistoryManager( * history sequentially. This lets timestamp sorting cover the complete * history without requiring the user to scroll through every page first. */ - private suspend fun fetchHistoryPages(reset: Boolean, generation: Int): Boolean { - if (reset) { + private suspend fun fetchHistoryPages( + reset: Boolean, + generation: Int, + ): Boolean + { + if (reset) + { mIsLoading.value = true mIsLoadingMore.value = false - } else { + } + else + { mIsLoadingMore.value = true } val merchantConfig = configManager.merchantConfig!! var resetPage = reset - while (true) { + while (true) + { val offset = if (resetPage) null else pagination.nextOffset - val response = api.getOrderHistory( - merchantConfig = merchantConfig, - limit = -PAGE_SIZE, - offset = offset, - ) + val response = + api.getOrderHistory( + merchantConfig = merchantConfig, + limit = -PAGE_SIZE, + offset = offset, + ) // A refresh may have superseded this request while it was in flight. if (generation != historyLoadGeneration) return false @@ -124,7 +198,7 @@ class HistoryManager( mIsLoading.value = false mIsLoadingMore.value = hasMorePages publishItems() - enrichOrders(newItems) + enrichOrders(newItems, generation) } if (!succeeded) return false @@ -133,75 +207,97 @@ class HistoryManager( } } - private val mForceDeleteOrderId = MutableLiveData<String?>(null) - val forceDeleteOrderId: LiveData<String?> = mForceDeleteOrderId - - @UiThread - internal fun deleteOrder(orderId: String) = scope.launch { - mIsLoading.value = true - mIsLoadingMore.value = false - val merchantConfig = configManager.merchantConfig!! - api.deleteOrder(merchantConfig, orderId).handle({ errorMsg -> - assertUiThread() - mIsLoading.value = false - mForceDeleteOrderId.value = orderId - }) { - assertUiThread() + /** Called only after both confirmations. A paid order must never be deleted. */ + internal suspend fun deleteConfirmedOrder(item: OrderHistoryEntry): String? + { + val config = configManager.merchantConfig ?: return "Merchant is not configured" + var current: CheckPaymentResponse? = null + var error: String? = null + api.checkOrder(config, item.orderId).handle({ error = it }) { current = it } + if (error != null) return error + val checked = current ?: return "Could not verify order status" + if (checked.paid) return "This order has been paid and cannot be deleted" + val force = + checked is CheckPaymentResponse.Claimed || + (checked.paymentDetails?.amountExternal?.isNotEmpty() ?: item.hasExternalPayments) + api.deleteOrder(config, item.orderId, force = force).handle({ error = it }) { configManager.refreshInventory() fetchHistory() } + return error } @UiThread - internal fun forceDeleteOrder(orderId: String) = scope.launch { - mForceDeleteOrderId.postValue(null) - mIsLoading.value = true - val merchantConfig = configManager.merchantConfig!! - api.deleteOrder(merchantConfig, orderId, force = true).handle({ errorMsg -> - onError(R.string.error_delete_order, errorMsg) - }) { - assertUiThread() - configManager.refreshInventory() - fetchHistory() + internal fun deleteOrder(orderId: String) = + scope.launch { + mIsLoading.value = true + mIsLoadingMore.value = false + val merchantConfig = configManager.merchantConfig!! + api.deleteOrder(merchantConfig, orderId).handle( + { _ -> + assertUiThread() + mIsLoading.value = false + mForceDeleteOrderId.value = orderId + } + ) { + assertUiThread() + configManager.refreshInventory() + fetchHistory() + } } - } @UiThread - internal fun clearForceDeletePrompt() { + internal fun forceDeleteOrder(orderId: String) = + scope.launch { + mForceDeleteOrderId.postValue(null) + mIsLoading.value = true + val merchantConfig = configManager.merchantConfig!! + api.deleteOrder(merchantConfig, orderId, force = true).handle( + { errorMsg -> + onError(R.string.error_delete_order, errorMsg) + } + ) { + assertUiThread() + configManager.refreshInventory() + fetchHistory() + } + } + + @UiThread + internal fun clearForceDeletePrompt() + { mForceDeleteOrderId.value = null } @UiThread - internal fun clearError() { + internal fun clearError() + { mError.value = null } - private fun publishItems() { + private fun publishItems() + { mItems.value = HistoryResult.Success(pagination.items) } @UiThread - internal fun debugSetHistory(items: List<OrderHistoryEntry>) { + internal fun debugSetHistory(items: List<OrderHistoryEntry>) + { pagination.replaceItems(items) publishItems() } - private fun enrichOrders(items: List<OrderHistoryEntry>) = scope.launch { + private fun enrichOrders( + items: List<OrderHistoryEntry>, + generation: Int, + ) = scope.launch { val merchantConfig = configManager.merchantConfig ?: return@launch - items.filter { it.paid }.forEach { item -> + items.forEach { item -> + if (generation != historyLoadGeneration) return@launch api.checkOrder(merchantConfig, item.orderId).handle(null) { response -> assertUiThread() - val paidResponse = response as? CheckPaymentResponse.Paid ?: return@handle - updateItem( - orderId = item.orderId, - transform = { current -> - current.copy( - refunded = paidResponse.refunded, - refundAmount = paidResponse.refundAmount ?: current.refundAmount, - refundPending = paidResponse.refundPending, - ) - } - ) + if (generation != historyLoadGeneration) return@handle + updateItem(item.orderId) { it.withPaymentDetails(response) } } } } @@ -209,12 +305,17 @@ class HistoryManager( private fun updateItem( orderId: String, transform: (OrderHistoryEntry) -> OrderHistoryEntry, - ) { + ) + { if (!pagination.updateItem(orderId, transform)) return publishItems() } - private fun onError(@StringRes mainResId: Int, msg: String) { + private fun onError( + @StringRes mainResId: Int, + msg: String, + ) + { assertUiThread() mIsLoading.value = false mIsLoadingMore.value = false @@ -222,10 +323,12 @@ class HistoryManager( } } -internal class HistoryPagination { - private val newestFirst = compareByDescending<OrderHistoryEntry> { it.timestamp.ms } - .thenByDescending { it.rowId ?: Long.MIN_VALUE } - .thenByDescending { it.orderId } +internal class HistoryPagination +{ + private val newestFirst = + compareByDescending<OrderHistoryEntry> { it.timestamp.ms } + .thenByDescending { it.rowId ?: Long.MIN_VALUE } + .thenByDescending { it.orderId } private val loadedItems = mutableListOf<OrderHistoryEntry>() @@ -245,8 +348,13 @@ internal class HistoryPagination { * iteration is complete. Depending on backend cursor semantics, a page may * also repeat the order at [nextOffset]. In that case it is de-duplicated. */ - fun addPage(page: List<OrderHistoryEntry>, reset: Boolean): List<OrderHistoryEntry> { - if (reset) { + fun addPage( + page: List<OrderHistoryEntry>, + reset: Boolean, + ): List<OrderHistoryEntry> + { + if (reset) + { loadedItems.clear() nextOffset = null reachedEnd = false @@ -268,7 +376,8 @@ internal class HistoryPagination { return newItems } - fun replaceItems(items: List<OrderHistoryEntry>) { + fun replaceItems(items: List<OrderHistoryEntry>) + { loadedItems.clear() loadedItems.addAll(items) loadedItems.sortWith(newestFirst) @@ -279,7 +388,8 @@ internal class HistoryPagination { fun updateItem( orderId: String, transform: (OrderHistoryEntry) -> OrderHistoryEntry, - ): Boolean { + ): Boolean + { val index = loadedItems.indexOfFirst { it.orderId == orderId } if (index == -1) return false loadedItems[index] = transform(loadedItems[index]) diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/history/OrderDetailsScreen.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/history/OrderDetailsScreen.kt @@ -0,0 +1,994 @@ +/* + * 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.history + +import android.content.ActivityNotFoundException +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.widget.Toast + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.OpenInNew +import androidx.compose.material.icons.automirrored.outlined.ReceiptLong +import androidx.compose.material.icons.outlined.CalendarToday +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.ContentCopy +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.Link +import androidx.compose.material.icons.outlined.LocalShipping +import androidx.compose.material.icons.outlined.LocationOn +import androidx.compose.material.icons.outlined.Payments +import androidx.compose.material.icons.outlined.Refresh +import androidx.compose.material.icons.outlined.Schedule +import androidx.compose.material.icons.outlined.ShoppingCart +import androidx.compose.material.icons.outlined.Storefront +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle + +import net.taler.common.Amount +import net.taler.common.CurrencySpecification +import net.taler.common.Location +import net.taler.common.TalerUtils.getLocalizedString +import net.taler.common.Timestamp +import net.taler.lib.android.order.OrderDetailAdditionalBlock +import net.taler.lib.android.order.OrderDetailCopyableValue +import net.taler.lib.android.order.OrderDetailDeliveryBlock +import net.taler.lib.android.order.OrderDetailMerchantBlock +import net.taler.lib.android.order.OrderDetailPaymentRow +import net.taler.lib.android.order.OrderDetailPaymentsBlock +import net.taler.lib.android.order.OrderDetailProductRow +import net.taler.lib.android.order.OrderDetailProductsBlock +import net.taler.lib.android.order.OrderDetailReorderBlock +import net.taler.lib.android.order.OrderDetailSummaryBlock +import net.taler.merchantlib.CheckPaymentResponse +import net.taler.merchantlib.MerchantUnit +import net.taler.merchantlib.OrderHistoryEntry +import net.taler.merchantlib.OrderPaymentDetails +import net.taler.merchantpos.MainViewModel +import net.taler.merchantpos.PosDestination +import net.taler.merchantpos.R +import net.taler.merchantpos.checkout.shown +import net.taler.merchantpos.payment.Payment +import net.taler.merchantpos.refund.talerRefundRemaining +import net.taler.merchantpos.showPosError + +import java.net.URI +import java.text.DateFormat +import java.util.Date + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive + +import net.taler.lib.android.order.OrderDetailBalanceStrip as SharedOrderDetailBalanceStrip +import net.taler.lib.android.order.OrderDetailHeader as SharedOrderDetailHeader +import net.taler.lib.android.order.OrderDetailSection as SharedOrderDetailSection +import net.taler.lib.android.order.OrderDetailValue as SharedOrderDetailValue + +@Composable +internal fun OrderDetailsRoute( + model: MainViewModel, + orderId: String, + onNavigate: (PosDestination) -> Unit, + onDeleted: () -> Unit, +) +{ + var refresh by remember(orderId) { mutableIntStateOf(0) } + val history by model.historyManager.items.observeAsState() + val activePayment by model.paymentManager.payment.observeAsState() + val pendingRefund by model.refundManager.pendingRefundOrderId.observeAsState() + val lifecycleOwner = LocalLifecycleOwner.current + val merchantIdentity by model.configManager.merchantIdentity.observeAsState() + val units by produceState(emptyMap(), merchantIdentity, refresh) { + value = emptyMap() + value = model.historyManager.fetchUnits() + } + val result by produceState<OrderDetailsResult>(OrderDetailsResult.Loading, orderId, refresh, lifecycleOwner) { + // A single owner handles initial entry and returning from payment/refund screens. + // Fetching here AND observing ON_RESUME separately restarts the initial request. + lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { + value = OrderDetailsResult.Loading + value = model.historyManager.fetchOrderDetails(orderId) + } + } + // Navigation retains the ID across process recreation; history restores the action metadata. + LaunchedEffect(orderId) { + if (history == null && + model.configManager.merchantConfig != null) + { + model.historyManager.fetchHistory() + } + } + val response = (result as? OrderDetailsResult.Success)?.response + val item = + (history as? HistoryResult.Success) + ?.items + ?.find { it.orderId == orderId } + ?.let { if (response != null) it.withPaymentDetails(response) else it } + val context = LocalContext.current + val scope = rememberCoroutineScope() + var deleting by remember { mutableStateOf(false) } + var deleteError by remember { mutableStateOf<String?>(null) } + deleteError?.let { message -> + AlertDialog( + onDismissRequest = { deleteError = null }, + title = { Text(stringResource(R.string.error_delete_order)) }, + text = { + Text(message) + }, + confirmButton = { + TextButton( + onClick = { deleteError = null }, + ) { Text(stringResource(android.R.string.ok)) } + }, + ) + } + OrderDetailsScreen( + orderId = orderId, + item = item, + result = result, + currencySpec = model.configManager.currencySpec, + units = units, + activePayment = activePayment?.takeIf { it.orderId == orderId }, + hasPendingRefund = pendingRefund == orderId, + onRefresh = { refresh++ }, + deleting = deleting, + onDelete = { + item?.let { order -> + if (!deleting) + { + scope.launch { + deleting = true + try + { + val error = model.historyManager.deleteConfirmedOrder(order) + if (error == null) + { + model.checkoutManager.onBackendOrderDeleted(order.orderId) + model.paymentManager.onOrderDeleted(order.orderId) + onDeleted() + } + else + { + deleteError = error + } + } + finally + { + deleting = false + } + } + } + } + }, + onRefundTaler = { + item?.let { model.refundManager.startRefund(it, preferTaler = true) } + onNavigate(PosDestination.Refund) + }, + onRefund = { + item?.let { model.refundManager.startRefund(it) } + onNavigate(PosDestination.Refund) + }, + onShowPayment = { + item?.let { model.paymentManager.resumePayment(it) } + onNavigate(PosDestination.ProcessPayment) + }, + onShowRefund = { + if (item != null && + model.refundManager.resumeRefund(item)) + { + onNavigate(PosDestination.RefundUri) + } + else + { + (context as? FragmentActivity)?.showPosError(R.string.refund_state_missing) + refresh++ + } + }, + ) +} + +/** Uses the available window width, so rotation and split-screen follow the same layout rules. */ +@Composable +internal fun OrderDetailsScreen( + orderId: String, + item: OrderHistoryEntry?, + result: OrderDetailsResult, + currencySpec: CurrencySpecification? = null, + activePayment: Payment? = null, + hasPendingRefund: Boolean = false, + onRefresh: () -> Unit = {}, + onRefund: () -> Unit = {}, + onRefundTaler: () -> Unit = onRefund, + onDelete: () -> Unit = {}, + deleting: Boolean = false, + onShowPayment: () -> Unit = {}, + onShowRefund: () -> Unit = {}, + units: Map<String, MerchantUnit> = emptyMap(), +) +{ + val response = (result as? OrderDetailsResult.Success)?.response + val details = response?.paymentDetails + val total = + item?.totalAmount ?: details?.amount?.let { taler -> + details.amountExternal.fold(taler) { sum, payment -> sum + payment.amount } + } + val status = + when + { + item != null -> + { + if (response is CheckPaymentResponse.Claimed && + !item.hasAnyRefund && + !hasPendingRefund) + { + HistoryStatus.PaymentClaimed + } + else + { + historyStatus(item, hasPendingRefund, activePayment) + } + } + + response is CheckPaymentResponse.Paid -> + { + when + { + response.refundPending -> HistoryStatus.RefundPending + response.refunded || response.refundsExternal.isNotEmpty() -> HistoryStatus.Refunded + else -> HistoryStatus.Paid + } + } + + response is CheckPaymentResponse.Claimed -> + { + HistoryStatus.PaymentClaimed + } + + response is CheckPaymentResponse.Unpaid -> + { + HistoryStatus.Unpaid + } + + else -> + { + null + } + } + var deleteStep by rememberSaveable(orderId) { mutableIntStateOf(0) } + if (deleteStep != 0) + { + AlertDialog( + onDismissRequest = { deleteStep = 0 }, + title = { + Text( + stringResource( + if (deleteStep == + 1) + { + R.string.order_delete + } + else + { + R.string.order_delete_final_title + }, + ), + ) + }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text(orderId) + Text( + stringResource( + if (deleteStep == + 1) + { + R.string.order_delete_warning + } + else + { + R.string.order_delete_final_warning + }, + ), + ) + } + }, + confirmButton = { + Button( + onClick = { + if (deleteStep == 1) + { + deleteStep = 2 + } + else + { + deleteStep = 0 + onDelete() + } + }, + colors = + ButtonDefaults.buttonColors( + containerColor = + if (deleteStep == + 2) + { + MaterialTheme.colorScheme.error + } + else + { + MaterialTheme.colorScheme.primary + }, + ), + ) { + Text(stringResource(if (deleteStep == 1) R.string.order_delete_continue else R.string.order_delete)) + } + }, + dismissButton = { + TextButton( + onClick = { deleteStep = 0 }, + ) { Text(stringResource(android.R.string.cancel)) } + }, + ) + } + val scroll = rememberScrollState() + BoxWithConstraints(Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) { + val wide = maxWidth.coerceAtMost(1200.dp) / LocalDensity.current.fontScale >= 760.dp + Column( + Modifier + .widthIn(max = 1200.dp) + .fillMaxWidth() + .verticalScroll(scroll) + .padding(if (wide) 24.dp else 20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + OrderHeader(orderId, item, details, total, status, currencySpec, wide, onRefresh) + if (result is OrderDetailsResult.Success) + { + val talerAmount = item?.amount ?: details?.amount + val isPaid = response?.paid ?: (item?.paid == true) + talerAmount?.takeIf { !isPaid && !it.isZero() }?.let { amount -> + OrderBalanceStrip( + amount = amount, + spec = currencySpec, + ) + } + } + when (result) + { + OrderDetailsResult.Loading -> + { + LinearProgressIndicator(Modifier.fillMaxWidth()) + Text(stringResource(R.string.order_details_loading), style = MaterialTheme.typography.bodyMedium) + } + + is OrderDetailsResult.Error -> + { + DetailSection(Icons.Outlined.Info, stringResource(R.string.order_details_unavailable)) { + Text(stringResource(R.string.order_details_load_error)) + result.message?.takeIf { it.isNotBlank() }?.let { + Text(it, style = MaterialTheme.typography.bodySmall) + } + OutlinedButton(onClick = onRefresh) { Text(stringResource(R.string.order_details_retry)) } + } + } + + is OrderDetailsResult.Success -> + { + if (item != null) + { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (!hasPendingRefund && + !item.talerRefundRemaining().isZero()) + { + Button( + onClick = onRefundTaler, + enabled = !deleting, + contentPadding = PaddingValues(horizontal = 8.dp), + ) { + Text( + stringResource(R.string.refund_taler_button), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + if (item.hasPendingRefund || + hasPendingRefund) + { + OutlinedButton( + onClick = onShowRefund, + enabled = !deleting, + contentPadding = PaddingValues(horizontal = 8.dp), + ) { + Text( + stringResource(R.string.history_show_refund), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + if (item.paid && + (item.hasExternalPayments || + item.refundable)) + { + OutlinedButton( + onClick = onRefund, + enabled = !deleting, + contentPadding = PaddingValues(horizontal = 8.dp), + ) { + Text( + stringResource(R.string.history_refund), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + if (!item.paid && + activePayment?.paid != true) + { + OutlinedButton( + onClick = onShowPayment, + enabled = !deleting, + contentPadding = PaddingValues(horizontal = 8.dp), + ) { + Text( + stringResource(R.string.history_show_payment), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + OutlinedButton( + onClick = { deleteStep = 1 }, + enabled = !item.paid && activePayment?.paid != true && !deleting, + contentPadding = PaddingValues(horizontal = 8.dp), + colors = + ButtonDefaults.outlinedButtonColors( + disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant, + disabledContentColor = MaterialTheme.colorScheme.onSurfaceVariant, + ), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + ) { + Text( + stringResource(R.string.order_details_delete_action), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + if (deleting) LinearProgressIndicator(Modifier.fillMaxWidth()) + } + if (details == null) + { + DetailSection(Icons.Outlined.Info, stringResource(R.string.order_details_title)) { + Text(stringResource(R.string.order_details_not_provided)) + } + } + if (wide) + { + Row(horizontalArrangement = Arrangement.spacedBy(20.dp)) { + Column(Modifier.weight(1.15f), verticalArrangement = Arrangement.spacedBy(16.dp)) { + OrderPayments(item, details, currencySpec, response?.paid == true) + OrderSummary(item, details) + OrderProducts(details, total, currencySpec, units) + } + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(16.dp)) { + OrderSupportingDetails(details) + } + } + } + else + { + OrderPayments(item, details, currencySpec, response?.paid == true) + OrderSummary(item, details) + OrderProducts(details, total, currencySpec, units) + OrderSupportingDetails(details) + } + } + } + } + } +} + +@Composable +private fun OrderBalanceStrip( + amount: Amount, + spec: CurrencySpecification?, +) +{ + SharedOrderDetailBalanceStrip( + label = stringResource(R.string.order_details_balance), + amount = amount.shown(spec), + ) +} + +@Composable +private fun OrderHeader( + orderId: String, + item: OrderHistoryEntry?, + details: OrderPaymentDetails?, + total: Amount?, + status: HistoryStatus?, + spec: CurrencySpecification?, + wide: Boolean, + onRefresh: () -> Unit, +) +{ + SharedOrderDetailHeader( + merchantName = details?.merchant?.name ?: stringResource(R.string.order_details_title), + total = total?.shown(spec), + leadingIcon = Icons.Outlined.Storefront, + wide = wide, + orderIdDisplay = { _ -> + CopyableValue(stringResource(R.string.order_details_order_id), orderId, compactAction = true) + }, + statusAndMetadata = { + status?.let { HistoryStatusBadge(it) } + (details?.timestamp ?: item?.timestamp)?.let { + Text( + formatOrderTimestamp(it), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + trailingActions = { + IconButton(onClick = onRefresh) { + Icon(Icons.Outlined.Refresh, stringResource(R.string.history_refresh)) + } + }, + footer = { + if (item?.hasAnyRefund == true && + !item.totalRefundAmount.isZero()) + { + DetailValue(stringResource(R.string.order_details_refund_total), item.totalRefundAmount.shown(spec)) + } + }, + ) +} + +@Composable +private fun OrderSummary( + item: OrderHistoryEntry?, + details: OrderPaymentDetails?, +) +{ + val summary = details?.summary?.let { getLocalizedString(details.summaryI18n, it) } ?: item?.summary + if (!summary.isNullOrBlank()) + { + OrderDetailSummaryBlock( + Icons.AutoMirrored.Outlined.ReceiptLong, + stringResource(R.string.order_details_summary), + ) { + SelectionContainer { Text(summary, style = MaterialTheme.typography.bodyMedium) } + details?.fulfillmentMessage?.takeIf { it.isNotBlank() }?.let { + Text( + getLocalizedString(details.fulfillmentMessageI18n, it), + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } +} + +@Composable +private fun OrderProducts( + details: OrderPaymentDetails?, + total: Amount?, + spec: CurrencySpecification?, + units: Map<String, MerchantUnit>, +) +{ + if (details == null) return + var preview by remember { mutableStateOf<Pair<String, ImageBitmap>?>(null) } + preview?.let { (name, bitmap) -> + Dialog(onDismissRequest = { preview = null }, properties = DialogProperties(usePlatformDefaultWidth = false)) { + BoxWithConstraints(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { + Surface( + Modifier.widthIn(max = 640.dp).fillMaxWidth().heightIn(max = maxHeight), + shape = RoundedCornerShape(16.dp), + ) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + name, + Modifier.weight(1f), + style = MaterialTheme.typography.titleMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + IconButton( + onClick = { preview = null }, + ) { Icon(Icons.Outlined.Close, stringResource(R.string.product_image_close)) } + } + Image( + bitmap, + name, + Modifier.fillMaxWidth().weight(1f, fill = false), + contentScale = ContentScale.Fit, + ) + } + } + } + } + } + OrderDetailProductsBlock( + icon = Icons.Outlined.ShoppingCart, + title = stringResource(R.string.order_details_products), + productCount = details.products.size, + emptyMessage = stringResource(R.string.order_details_no_products), + totalLabel = total?.let { stringResource(R.string.checkout_total) }, + total = total?.shown(spec), + ) { index -> + val product = details.products[index] + val description = getLocalizedString(product.descriptionI18n, product.description) + val name = product.productName?.takeIf { it.isNotBlank() } ?: description + val bitmap by produceState<ImageBitmap?>(null, product.image) { + value = withContext(Dispatchers.Default) { decodeProductImage(product.image) } + } + val thumbnail: (@Composable () -> Unit)? = + bitmap?.let { image -> + { + Image( + image, + stringResource(R.string.product_image_open, name), + Modifier.size(48.dp).clip(RoundedCornerShape(8.dp)).clickable { preview = name to image }, + contentScale = ContentScale.Fit, + ) + } + } + // A sold-product price is a line total, never multiplied by its quantity again. + OrderDetailProductRow( + name = name, + description = description, + quantity = product.quantityLabel(units), + unitPrice = + product.unitPrice(total?.currency)?.let { unitPrice -> + val amount = unitPrice.amount.shown(spec) + val labelled = + if (product.pricesAreNet) + { + stringResource( + R.string.order_details_net_price, + amount, + ) + } + else + { + amount + } + if (unitPrice.approximate) "≈ $labelled" else labelled + }, + price = + product.priceForCurrency(total?.currency)?.let { price -> + if (product.pricesAreNet) + { + stringResource(R.string.order_details_net_price, price.shown(spec)) + } + else + { + price.shown(spec) + } + }, + thumbnail = thumbnail, + ) + } +} + +@Composable +private fun OrderPayments( + item: OrderHistoryEntry?, + details: OrderPaymentDetails?, + spec: CurrencySpecification?, + paid: Boolean, +) +{ + val external = item?.amountExternal ?: details?.amountExternal.orEmpty() + val taler = item?.amount ?: details?.amount + if (external.isEmpty() && + taler == null) + { + return + } + OrderDetailPaymentsBlock( + icon = Icons.Outlined.Payments, + title = stringResource(R.string.order_details_payments), + paymentCount = external.size + if (taler != null) 1 else 0, + ) { index -> + if (index < external.size) + { + val payment = external[index] + OrderDetailPaymentRow( + method = payment.method.replaceFirstChar { it.uppercase() }, + amount = payment.amount.shown(spec), + reference = payment.transactionCode?.takeIf { it.isNotBlank() }, + ) + } + else + { + val amount = requireNotNull(taler) + OrderDetailPaymentRow( + amount = (if (paid) amount else Amount.zero(amount.currency)).shown(spec), + status = if (paid) null else stringResource(R.string.history_status_unpaid), + methodContent = { + Image( + painter = painterResource(R.drawable.ic_taler_logo_brand), + contentDescription = stringResource(R.string.payment_option_taler), + modifier = Modifier.size(width = 54.dp, height = 24.dp), + ) + }, + ) + } + } +} + +@Composable +private fun OrderSupportingDetails(details: OrderPaymentDetails?) +{ + if (details == null) return + val delivery = details.deliveryLocation?.orderAddress().orEmpty() + if (delivery.isNotBlank() || + details.deliveryDate != null || + details.maxPickupTime != null) + { + OrderDetailDeliveryBlock(Icons.Outlined.LocalShipping, stringResource(R.string.order_details_delivery)) { + if (delivery.isNotBlank()) + { + DetailValue( + stringResource(R.string.order_details_delivery_location), + delivery, + Icons.Outlined.LocationOn, + ) + } + details.deliveryDate?.let { + DetailValue( + stringResource(R.string.order_details_delivery_date), + formatOrderTimestamp(it), + Icons.Outlined.CalendarToday, + ) + } + details.maxPickupTime?.let { + DetailValue( + stringResource(R.string.order_details_pickup_time), + formatOrderTimestamp(it), + Icons.Outlined.Schedule, + ) + } + } + } + details.publicReorderUrl?.takeIf { it.isNotBlank() }?.let { url -> + OrderDetailReorderBlock(Icons.Outlined.Link, stringResource(R.string.order_details_reorder)) { + Text(stringResource(R.string.order_details_reorder_hint), style = MaterialTheme.typography.bodyMedium) + CopyableValue(stringResource(R.string.order_details_reorder_link), url, isLink = isOrderWebUrl(url)) + } + } + val extras = + details.extra + .orEmpty() + .filterKeys { !it.startsWith("$") } + .filterValues { it !is JsonNull } + val fulfillmentUrl = details.fulfillmentUrl?.takeIf { it.isNotBlank() } + if (extras.isNotEmpty() || + fulfillmentUrl != null || + details.payDeadline != null || + details.refundDeadline != null) + { + OrderDetailAdditionalBlock(Icons.Outlined.Info, stringResource(R.string.order_details_additional)) { + fulfillmentUrl?.let { url -> + CopyableValue(stringResource(R.string.order_details_fulfillment_link), url, isLink = isOrderWebUrl(url)) + } + details.payDeadline?.let { + DetailValue( + stringResource(R.string.order_details_pay_deadline), + formatOrderTimestamp(it), + ) + } + details.refundDeadline?.let { + DetailValue(stringResource(R.string.order_details_refund_deadline), formatOrderTimestamp(it)) + } + extras.forEach { (key, value) -> + DetailValue(key.replace('_', ' '), if (value is JsonPrimitive) value.content else value.toString()) + } + } + } + details.merchant?.let { merchant -> + OrderDetailMerchantBlock(Icons.Outlined.Storefront, stringResource(R.string.order_details_merchant)) { + Text(merchant.name, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium) + merchant.address + ?.orderAddress() + ?.takeIf { + it.isNotBlank() + }?.let { Text(it, style = MaterialTheme.typography.bodyMedium) } + merchant.jurisdiction?.orderAddress()?.takeIf { it.isNotBlank() }?.let { + DetailValue(stringResource(R.string.order_details_jurisdiction), it) + } + merchant.email?.takeIf { it.isNotBlank() }?.let { + CopyableValue( + stringResource(R.string.order_details_email), + it, + ) + } + merchant.website?.takeIf { it.isNotBlank() }?.let { + CopyableValue(stringResource(R.string.order_details_website), it, isLink = isOrderWebUrl(it)) + } + } + } +} + +@Composable +private fun DetailSection( + icon: ImageVector, + title: String, + content: @Composable ColumnScope.() -> Unit, +) +{ + SharedOrderDetailSection(icon, title, content = content) +} + +@Composable +private fun DetailValue( + label: String, + value: String, + icon: ImageVector? = null, +) +{ + SharedOrderDetailValue(label, value, icon) +} + +@Composable +private fun CopyableValue( + label: String, + value: String, + isLink: Boolean = false, + compactAction: Boolean = false, +) +{ + val context = LocalContext.current + OrderDetailCopyableValue( + label = label, + value = value, + isLink = isLink, + compactAction = compactAction, + openAction = { + IconButton(onClick = { openOrderUrl(context, value) }) { + Icon(Icons.AutoMirrored.Outlined.OpenInNew, stringResource(R.string.order_details_open_link)) + } + }, + copyAction = { + IconButton( + onClick = { + context + .getSystemService( + ClipboardManager::class.java, + ).setPrimaryClip(ClipData.newPlainText(label, value)) + Toast.makeText(context, R.string.order_details_copied, Toast.LENGTH_SHORT).show() + }, + modifier = if (compactAction) Modifier.size(48.dp) else Modifier + ) { + Icon(Icons.Outlined.ContentCopy, stringResource(R.string.order_details_copy, label)) + } + }, + ) +} + +internal fun Location.orderAddress(): String = + buildList { + addAll(addressLines.orEmpty()) + addAll(listOfNotNull(buildingName, listOfNotNull(street, buildingNumber).joinToString(" "))) + add(listOfNotNull(postCode, town).joinToString(" ")) + addAll(listOfNotNull(townLocation, district, countrySubdivision, country)) + }.filter { it.isNotBlank() }.distinct().joinToString("\n") + +@Composable +private fun formatOrderTimestamp(timestamp: Timestamp): String = + if (timestamp == Timestamp.never()) + { + stringResource(R.string.never_expires) + } + else + { + DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(Date(timestamp.ms)) + } + +internal fun isOrderWebUrl(value: String): Boolean = + runCatching { + val uri = URI(value) + (uri.scheme.equals("https", true) || uri.scheme.equals("http", true)) && !uri.host.isNullOrBlank() + }.getOrDefault(false) + +private fun openOrderUrl( + context: Context, + url: String, +) +{ + if (!isOrderWebUrl(url)) return + try + { + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) + } + catch (_: ActivityNotFoundException) + { + Toast.makeText(context, R.string.order_details_open_failed, Toast.LENGTH_SHORT).show() + } +} diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/history/ProductImage.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/history/ProductImage.kt @@ -0,0 +1,55 @@ +/* + * 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.history + +import android.graphics.BitmapFactory +import android.util.Base64 + +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap + +/** Product contract images use data URLs, as in the existing inventory screen. */ +internal fun decodeProductImage(data: String?): ImageBitmap? +{ + if (data == null || + !data.startsWith("data:image/") || + data.length > 12_000_000) + { + return null + } + val separator = data.indexOf(";base64,") + if (separator < 0) return null + return try + { + val bytes = Base64.decode(data.substring(separator + 8), Base64.DEFAULT) + val options = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options) + if (options.outWidth <= 0 || + options.outHeight <= 0) + { + return null + } + options.inSampleSize = 1 + while (maxOf(options.outWidth, options.outHeight) / options.inSampleSize > 1600) options.inSampleSize *= 2 + options.inJustDecodeBounds = false + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options)?.asImageBitmap() + } + catch (_: IllegalArgumentException) + { + null + } +} diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/history/ProductPresentation.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/history/ProductPresentation.kt @@ -0,0 +1,80 @@ +/* + * 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.history + +import net.taler.common.Amount +import net.taler.merchantlib.MerchantUnit +import net.taler.merchantlib.OrderDetailProduct + +import java.math.BigDecimal +import java.math.RoundingMode +import java.text.NumberFormat +import java.util.Locale + +private val decimalQuantity = Regex("[0-9]+(?:\\.[0-9]{1,8})?") + +private fun OrderDetailProduct.numericQuantity(): BigDecimal? +{ + val raw = unitQuantity ?: quantity.toString() + return raw.takeIf(decimalQuantity::matches)?.toBigDecimalOrNull() +} + +internal fun OrderDetailProduct.quantityLabel( + units: Map<String, MerchantUnit>, + locale: Locale = Locale.getDefault(), +): String +{ + val definition = units[unit] + val label = definition?.shortLabel(locale) ?: unit?.takeIf(String::isNotBlank) + val count = + numericQuantity()?.let { + NumberFormat + .getNumberInstance(locale) + .apply { + maximumFractionDigits = definition?.precision?.coerceIn(0, 8) ?: 8 + minimumFractionDigits = 0 + roundingMode = RoundingMode.HALF_EVEN + isGroupingUsed = false + }.format(it) + } ?: (unitQuantity ?: quantity.toString()) + return if (label == null) count else "$count\u202f$label" +} + +internal data class ProductUnitPrice( + val amount: Amount, + val approximate: Boolean, +) + +/** Derive the historic unit price from the sold line, never from current inventory prices. */ +internal fun OrderDetailProduct.unitPrice(currency: String?): ProductUnitPrice? +{ + val count = numericQuantity()?.takeIf { it.signum() > 0 } ?: return null + val total = priceForCurrency(currency) ?: return null + val decimalTotal = BigDecimal.valueOf(total.value).add(BigDecimal.valueOf(total.fraction.toLong(), 8)) + val perUnit = decimalTotal.divide(count, 8, RoundingMode.HALF_EVEN) + if (perUnit.signum() < 0 || + perUnit > BigDecimal.valueOf(Amount.MAX_VALUE).add(BigDecimal("0.99999999"))) + { + return null + } + val whole = perUnit.toLong() + val fraction = perUnit.subtract(BigDecimal.valueOf(whole)).movePointRight(8).intValueExact() + return ProductUnitPrice( + Amount(total.currency, whole, fraction), + perUnit.multiply(count).compareTo(decimalTotal) != 0, + ) +} 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 @@ -20,6 +20,7 @@ import androidx.annotation.UiThread import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.map + import net.taler.common.Amount import net.taler.common.CombinedLiveData import net.taler.common.CurrencySpecification @@ -29,19 +30,28 @@ import net.taler.merchantpos.order.RestartState.DISABLED import net.taler.merchantpos.order.RestartState.ENABLED import net.taler.merchantpos.order.RestartState.UNDO -internal enum class RestartState { ENABLED, DISABLED, UNDO } +internal enum class RestartState +{ + ENABLED, + DISABLED, + UNDO +} -internal interface LiveOrder { +internal interface LiveOrder +{ val order: LiveData<Order?> val orderTotal: LiveData<Amount> val restartState: LiveData<RestartState> val modifyOrderAllowed: LiveData<Boolean> val increaseOrderAllowed: LiveData<Boolean> - val lastAddedProduct: ConfigProduct? - val selectedProductKey: String? + val selectedProductKey: LiveData<String?> + fun restartOrUndo() + fun selectOrderLine(product: ConfigProduct?) + fun increaseSelectedOrderLine() + fun decreaseSelectedOrderLine() } @@ -52,41 +62,49 @@ internal class MutableLiveOrder( private val productsByCategory: HashMap<Category, ArrayList<ConfigProduct>>, private val canAddProduct: (ConfigProduct) -> Boolean, private val onChanged: () -> Unit, -) : LiveOrder { +) : LiveOrder +{ + private val selectedOrderLine = MutableLiveData<ConfigProduct?>() + private var undoOrder: Order? = null + private var undoSelection: String? = null + private val availableCategories: Map<Int, Category> get() = productsByCategory.keys.map { it.id to it }.toMap() + override val order: MutableLiveData<Order?> = 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? - get() = selectedOrderLine.value?.id + + override val selectedProductKey: LiveData<String?> = selectedOrderLine.map { it?.id } + override val modifyOrderAllowed = 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) { + internal fun addProduct(product: ConfigProduct) + { if (!canAddProduct(product)) return - lastAddedProduct = product - order.value = order.value!! + product + updateOrder(order.value!! + product, product.id) restartState.value = ENABLED onChanged() } @UiThread - internal fun removeProduct(product: ConfigProduct) { + internal fun removeProduct(product: ConfigProduct) + { val modifiedOrder = order.value!! - product - order.value = modifiedOrder + updateOrder(modifiedOrder) restartState.value = if (modifiedOrder.products.isEmpty()) DISABLED else ENABLED onChanged() } @@ -95,34 +113,53 @@ internal class MutableLiveOrder( internal fun isEmpty() = order.value!!.products.isEmpty() @UiThread - override fun restartOrUndo() { - if (restartState.value == UNDO) { - order.value = undoOrder + override fun restartOrUndo() + { + if (restartState.value == UNDO) + { + updateOrder(checkNotNull(undoOrder), undoSelection) restartState.value = ENABLED undoOrder = null - } else { + undoSelection = null + } + else + { undoOrder = order.value - order.value = Order(id, currency, currencySpec, availableCategories) + undoSelection = selectedOrderLine.value?.id + updateOrder(Order(id, currency, currencySpec, availableCategories)) restartState.value = UNDO } onChanged() } @UiThread - override fun selectOrderLine(product: ConfigProduct?) { - selectedOrderLine.value = product + override fun selectOrderLine(product: ConfigProduct?) + { + selectedOrderLine.value = order.value?.products?.find { it.id == product?.id } + } + + private fun updateOrder( + updated: Order, + preferredProductKey: String? = selectedOrderLine.value?.id, + ) + { + // Quantity edits preserve selection. Only a removed row needs a fallback. + selectedOrderLine.value = + updated.products.find { it.id == preferredProductKey } ?: updated.products.lastOrNull() + order.value = updated } @UiThread - override fun increaseSelectedOrderLine() { + override fun increaseSelectedOrderLine() + { val orderLine = selectedOrderLine.value ?: throw IllegalStateException() addProduct(orderLine) } @UiThread - override fun decreaseSelectedOrderLine() { + override fun decreaseSelectedOrderLine() + { val orderLine = selectedOrderLine.value ?: throw IllegalStateException() removeProduct(orderLine) } - } 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 @@ -27,17 +27,29 @@ data class Order( val currencySpec: CurrencySpecification?, val availableCategories: Map<Int, Category>, val products: List<ConfigProduct> = emptyList(), -) { +) +{ val title: String = id.toString() + val summary: String - get() { - if (products.size == 1) return products[0].description - return getCategoryQuantities().map { (category: Category, quantity: Int) -> - "$quantity x ${category.localizedName}" - }.joinToString() + get() + { + if (products.size == 1) return products[0].displayName + val categories = + getCategoryQuantities() + .map { (category: Category, quantity: Int) -> + "$quantity × ${category.localizedName}" + } + val uncategorized = + products + .filter { categoryOf(it) == null } + .map { "${it.quantity} × ${it.displayName}" } + return (categories + uncategorized).joinToString() } + val total: Amount - get() { + get() + { var total = Amount.zero(currency).withSpec(currencySpec) products.forEach { product -> total += product.price * product.quantity @@ -45,77 +57,93 @@ data class Order( return total.withSpec(currencySpec) } - operator fun plus(product: ConfigProduct): Order { + /** + * Returns a map of i18n summaries for each locale present in *all* given [Category]s + * or null if there's no locale that fulfills this criteria. + */ + private val summaryI18n: Map<String, String>? + get() + { + if (products.size == 1) return products[0].displayNameI18n + // A category-only translation would omit custom/uncategorized lines. + if (products.any { categoryOf(it) == null }) return null + val categoryQuantities = getCategoryQuantities() + // get all available locales + val availableLocales = + categoryQuantities + .mapNotNull { (category, _) -> + val nameI18n = category.nameI18n + // if one category doesn't have locales, we can return null here already + nameI18n?.keys ?: return null + }.flatten() + .toHashSet() + // remove all locales not supported by all categories + categoryQuantities.forEach { (category, _) -> + // category.nameI18n should be non-null now + availableLocales.retainAll(category.nameI18n!!.keys) + if (availableLocales.isEmpty()) return null + } + return availableLocales.associateWith { locale -> + categoryQuantities + .map { (category, quantity) -> + // category.nameI18n should be non-null now + "$quantity × ${category.nameI18n!![locale]}" + }.joinToString() + } + } + + operator fun plus(product: ConfigProduct): Order + { val updatedProducts = products.toMutableList() val i = updatedProducts.indexOfFirst { it.id == product.id } - if (i == -1) { + if (i == -1) + { updatedProducts.add(product.copy(quantity = 1)) - } else { + } + else + { val quantity = updatedProducts[i].quantity updatedProducts[i] = updatedProducts[i].copy(quantity = quantity + 1) } return copy(products = updatedProducts) } - operator fun minus(product: ConfigProduct): Order { + operator fun minus(product: ConfigProduct): Order + { val updatedProducts = products.toMutableList() val i = updatedProducts.indexOfFirst { it.id == product.id } if (i == -1) return this val quantity = updatedProducts[i].quantity - if (quantity <= 1) { + if (quantity <= 1) + { updatedProducts.removeAt(i) - } else { + } + else + { updatedProducts[i] = updatedProducts[i].copy(quantity = quantity - 1) } return copy(products = updatedProducts) } - private fun getCategoryQuantities(): HashMap<Category, Int> { - val categories = HashMap<Category, Int>() + private fun categoryOf(product: ConfigProduct): Category? = + product.categories.firstOrNull()?.let(availableCategories::get) + + private fun getCategoryQuantities(): Map<Category, Int> + { + val categories = linkedMapOf<Category, Int>() products.forEach { product -> - val categoryId = product.categories[0] - val category = availableCategories[categoryId] ?: return@forEach // custom products + val category = categoryOf(product) ?: return@forEach val oldQuantity = categories[category] ?: 0 categories[category] = oldQuantity + product.quantity } return categories } - /** - * Returns a map of i18n summaries for each locale present in *all* given [Category]s - * or null if there's no locale that fulfills this criteria. - */ - private val summaryI18n: Map<String, String>? - get() { - if (products.size == 1) return products[0].descriptionI18n - val categoryQuantities = getCategoryQuantities() - // get all available locales - val availableLocales = categoryQuantities.mapNotNull { (category, _) -> - val nameI18n = category.nameI18n - // if one category doesn't have locales, we can return null here already - nameI18n?.keys ?: return null - }.flatten().toHashSet() - // remove all locales not supported by all categories - categoryQuantities.forEach { (category, _) -> - // category.nameI18n should be non-null now - availableLocales.retainAll(category.nameI18n!!.keys) - if (availableLocales.isEmpty()) return null - } - return availableLocales.associateWith { locale -> - categoryQuantities.map { (category, quantity) -> - // category.nameI18n should be non-null now - "$quantity x ${category.nameI18n!![locale]}" - }.joinToString() - } - } - - fun toContractTerms(includeProducts: Boolean = true): net.taler.common.Order { - return net.taler.common.Order( + fun toContractTerms(includeProducts: Boolean = true): net.taler.common.Order = + net.taler.common.Order( summary = summary, summaryI18n = summaryI18n, amount = total, products = if (includeProducts) products.map { it.toContractProduct() } else null, ) - } - } 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 @@ -17,12 +17,15 @@ package net.taler.merchantpos.order import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup + import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -34,17 +37,19 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState -import androidx.compose.material3.AlertDialog +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.HorizontalDivider @@ -66,41 +71,49 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels + import net.taler.common.Amount import net.taler.common.AmountParserException +import net.taler.common.CurrencySpecification import net.taler.lib.android.base64Bitmap import net.taler.merchantpos.MainActivity import net.taler.merchantpos.MainViewModel import net.taler.merchantpos.PosDestination import net.taler.merchantpos.R import net.taler.merchantpos.compose.PosTheme +import net.taler.merchantpos.compose.completeButtonColors +import net.taler.merchantpos.compose.orderControlButtonColors import net.taler.merchantpos.config.Category import net.taler.merchantpos.config.ConfigProduct -import net.taler.merchantpos.showPosError -import kotlinx.coroutines.delay -import net.taler.merchantpos.order.RestartState.DISABLED -import androidx.compose.foundation.shape.RoundedCornerShape -class OrderFragment : Fragment() { +import kotlinx.coroutines.delay +class OrderFragment : Fragment() +{ private val viewModel: MainViewModel by activityViewModels() private val orderManager by lazy { viewModel.orderManager } private val paymentManager by lazy { viewModel.paymentManager } override fun onCreateView( - inflater: android.view.LayoutInflater, - container: android.view.ViewGroup?, + inflater: LayoutInflater, + container: ViewGroup?, savedInstanceState: Bundle?, ) = ComposeView(requireContext()).apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) @@ -110,18 +123,19 @@ class OrderFragment : Fragment() { onNavigate = { destination -> (requireActivity() as MainActivity).navigateTo(destination) }, - onShowMessage = { message -> - requireActivity().showPosError(message) - }, ) } } - override fun onStart() { + override fun onStart() + { super.onStart() - if (!viewModel.configManager.config.isValid()) { + if (!viewModel.configManager.config.isValid()) + { (requireActivity() as MainActivity).navigateTo(PosDestination.Config) - } else if (viewModel.configManager.currency == null) { + } + else if (viewModel.configManager.currency == null) + { (requireActivity() as MainActivity).navigateTo(PosDestination.ConfigFetcher) } } @@ -131,10 +145,11 @@ class OrderFragment : Fragment() { private fun OrderRoute( viewModel: MainViewModel, onNavigate: (PosDestination) -> Unit, - onShowMessage: (String) -> Unit, -) { +) +{ val orderManager = remember { viewModel.orderManager } val paymentManager = remember { viewModel.paymentManager } + val checkoutManager = remember { viewModel.checkoutManager } val currentOrderId by orderManager.currentOrderId.observeAsState() val categories by orderManager.categories.observeAsState(emptyList()) val products by orderManager.products.observeAsState(emptyList()) @@ -147,55 +162,49 @@ private fun OrderRoute( val orderTotal by liveOrder.orderTotal.observeAsState( Amount.zero(currency ?: "").withSpec(currencySpec), ) - val restartState by liveOrder.restartState.observeAsState(DISABLED) val modifyAllowed by liveOrder.modifyOrderAllowed.observeAsState(false) val increaseAllowed by liveOrder.increaseOrderAllowed.observeAsState(false) - val hasNextOrder by orderManager.hasNextOrder(orderId).observeAsState(false) - var selectedProductKey by rememberSaveable(orderId) { mutableStateOf(liveOrder.selectedProductKey) } + val selectedProductKey by liveOrder.selectedProductKey.observeAsState() var selectedCategoryId by rememberSaveable { mutableStateOf(categories.firstOrNull { it.selected }?.id) } var showCustomDialog by rememberSaveable { mutableStateOf(false) } - val reloadingText = stringResource(R.string.toast_reloading) LaunchedEffect(Unit) { - while (true) { + while (true) + { delay(30_000) viewModel.configManager.refreshInventory() } } - LaunchedEffect(order?.products, liveOrder.lastAddedProduct?.id) { - val productsInOrder = order?.products.orEmpty() - val selected = selectedProductKey?.let { key -> productsInOrder.find { it.id == key } } - val nextSelection = liveOrder.lastAddedProduct?.takeIf { added -> - productsInOrder.any { it.id == added.id } - } ?: selected ?: productsInOrder.lastOrNull() - selectedProductKey = nextSelection?.id - liveOrder.selectOrderLine(nextSelection) - } - LaunchedEffect(categories.map { it.id }) { - if (selectedCategoryId == null) { + if (selectedCategoryId == null) + { selectedCategoryId = categories.firstOrNull { it.selected }?.id - } else if (categories.none { it.id == selectedCategoryId }) { + } + else if (categories.none { it.id == selectedCategoryId }) + { selectedCategoryId = categories.firstOrNull { it.selected }?.id } } - if (showCustomDialog && currency != null) { + if (showCustomDialog && + currency != null) + { CustomProductDialog( currency = currency, currencySpec = currencySpec, onDismiss = { showCustomDialog = false }, onAdd = { description, amount -> - val product = ConfigProduct( - description = description, - price = amount, - categories = listOf(Int.MIN_VALUE), - ) + val product = + ConfigProduct( + description = description, + price = amount, + categories = listOf(Int.MIN_VALUE), + ) orderManager.addProduct(orderId, product) showCustomDialog = false }, @@ -204,13 +213,15 @@ private fun OrderRoute( PosTheme { Box( - modifier = Modifier - .fillMaxSize() - .statusBarsPadding(), + modifier = Modifier.fillMaxSize(), ) { - val isTabletLayout = LocalConfiguration.current.smallestScreenWidthDp >= 720 - if (isTabletLayout) { + val configuration = LocalConfiguration.current + val isTabletLayout = configuration.smallestScreenWidthDp >= 600 + val portrait = configuration.screenHeightDp > configuration.screenWidthDp + if (isTabletLayout) + { TabletOrderScreen( + portrait = portrait, categories = categories, selectedCategoryId = selectedCategoryId, products = products, @@ -227,7 +238,6 @@ private fun OrderRoute( orderManager.addProduct(orderId, product) }, onSelectProduct = { - selectedProductKey = it?.id liveOrder.selectOrderLine(it) }, onIncrease = { liveOrder.increaseSelectedOrderLine() }, @@ -235,12 +245,24 @@ private fun OrderRoute( onAddCustom = { showCustomDialog = true }, onComplete = { val currentOrder = order ?: return@TabletOrderScreen - paymentManager.createPayment(currentOrder) - onNavigate(PosDestination.ProcessPayment) + if (checkoutManager.shouldUseCheckout()) + { + // Other payment options are on: take them first, Taler last. + checkoutManager.start(currentOrder, includeProducts = true) + onNavigate(PosDestination.Checkout) + } + else + { + paymentManager.createPayment(currentOrder) + onNavigate(PosDestination.ProcessPayment) + } }, ) - } else { + } + else + { PhoneOrderScreen( + portrait = portrait, categories = categories, selectedCategoryId = selectedCategoryId, products = products, @@ -257,7 +279,6 @@ private fun OrderRoute( orderManager.addProduct(orderId, product) }, onSelectProduct = { - selectedProductKey = it?.id liveOrder.selectOrderLine(it) }, onIncrease = { liveOrder.increaseSelectedOrderLine() }, @@ -265,8 +286,17 @@ private fun OrderRoute( onAddCustom = { showCustomDialog = true }, onComplete = { val currentOrder = order ?: return@PhoneOrderScreen - paymentManager.createPayment(currentOrder) - onNavigate(PosDestination.ProcessPayment) + if (checkoutManager.shouldUseCheckout()) + { + // Other payment options are on: take them first, Taler last. + checkoutManager.start(currentOrder, includeProducts = true) + onNavigate(PosDestination.Checkout) + } + else + { + paymentManager.createPayment(currentOrder) + onNavigate(PosDestination.ProcessPayment) + } }, ) } @@ -276,6 +306,7 @@ private fun OrderRoute( @Composable private fun TabletOrderScreen( + portrait: Boolean, categories: List<Category>, selectedCategoryId: Int?, products: List<ConfigProduct>, @@ -291,7 +322,30 @@ private fun TabletOrderScreen( onDecrease: () -> Unit, onAddCustom: () -> Unit, onComplete: () -> Unit, -) { +) +{ + if (portrait) + { + PortraitOrderScreen( + categories, + selectedCategoryId, + products, + order, + increaseAllowed, + modifyAllowed, + orderTotal, + selectedProductKey, + true, + onCategorySelected, + onProductSelected, + onSelectProduct, + onIncrease, + onDecrease, + onAddCustom, + onComplete, + ) + return + } Row( modifier = Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(0.dp), @@ -304,10 +358,11 @@ private fun TabletOrderScreen( onCategorySelected = onCategorySelected, ) Spacer( - modifier = Modifier - .width(1.dp) - .fillMaxHeight() - .background(MaterialTheme.colorScheme.outlineVariant), + modifier = + Modifier + .width(1.dp) + .fillMaxHeight() + .background(MaterialTheme.colorScheme.outlineVariant), ) ProductsPane( products = products, @@ -316,10 +371,11 @@ private fun TabletOrderScreen( onProductSelected = onProductSelected, ) Spacer( - modifier = Modifier - .width(1.dp) - .fillMaxHeight() - .background(MaterialTheme.colorScheme.outlineVariant), + modifier = + Modifier + .width(1.dp) + .fillMaxHeight() + .background(MaterialTheme.colorScheme.outlineVariant), ) OrderColumnPane( order = order, @@ -341,6 +397,7 @@ private fun TabletOrderScreen( @Composable private fun PhoneOrderScreen( + portrait: Boolean, categories: List<Category>, selectedCategoryId: Int?, products: List<ConfigProduct>, @@ -356,11 +413,35 @@ private fun PhoneOrderScreen( onDecrease: () -> Unit, onAddCustom: () -> Unit, onComplete: () -> Unit, -) { +) +{ + if (portrait) + { + PortraitOrderScreen( + categories, + selectedCategoryId, + products, + order, + increaseAllowed, + modifyAllowed, + orderTotal, + selectedProductKey, + false, + onCategorySelected, + onProductSelected, + onSelectProduct, + onIncrease, + onDecrease, + onAddCustom, + onComplete, + ) + return + } Row( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 8.dp, vertical = 10.dp), + modifier = + Modifier + .fillMaxSize() + .padding(horizontal = 8.dp, vertical = 10.dp), horizontalArrangement = Arrangement.spacedBy(0.dp), ) { CategoriesPane( @@ -371,10 +452,11 @@ private fun PhoneOrderScreen( onCategorySelected = onCategorySelected, ) Spacer( - modifier = Modifier - .width(1.dp) - .fillMaxHeight() - .background(MaterialTheme.colorScheme.outlineVariant), + modifier = + Modifier + .width(1.dp) + .fillMaxHeight() + .background(MaterialTheme.colorScheme.outlineVariant), ) ProductsPane( products = products, @@ -383,10 +465,11 @@ private fun PhoneOrderScreen( onProductSelected = onProductSelected, ) Spacer( - modifier = Modifier - .width(1.dp) - .fillMaxHeight() - .background(MaterialTheme.colorScheme.outlineVariant), + modifier = + Modifier + .width(1.dp) + .fillMaxHeight() + .background(MaterialTheme.colorScheme.outlineVariant), ) OrderColumnPane( order = order, @@ -407,22 +490,211 @@ private fun PhoneOrderScreen( } @Composable +private fun PortraitOrderScreen( + categories: List<Category>, + selectedCategoryId: Int?, + products: List<ConfigProduct>, + order: Order?, + increaseAllowed: Boolean, + modifyAllowed: Boolean, + orderTotal: String, + selectedProductKey: String?, + tablet: Boolean, + onCategorySelected: (Category) -> Unit, + onProductSelected: (ConfigProduct) -> Unit, + onSelectProduct: (ConfigProduct?) -> Unit, + onIncrease: () -> Unit, + onDecrease: () -> Unit, + onAddCustom: () -> Unit, + onComplete: () -> Unit, +) +{ + val maxCategoryWidth = (LocalConfiguration.current.screenWidthDp * 0.8f).dp + Column( + modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + categories.forEach { category -> + Box( + modifier = + Modifier + .widthIn(min = minOf(120.dp, maxCategoryWidth), max = maxCategoryWidth) + .width(IntrinsicSize.Max), + ) { + CategoryButton( + text = category.localizedName, + selected = category.id == selectedCategoryId, + compact = true, + onClick = { onCategorySelected(category) }, + ) + } + } + } + if (tablet) + { + Row(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(16.dp)) { + ProductsPane( + products = products, + modifier = Modifier.weight(0.56f), + compact = false, + minimumTileWidth = 170.dp, + onProductSelected = onProductSelected, + ) + Spacer(Modifier.width(1.dp).fillMaxHeight().background(MaterialTheme.colorScheme.outlineVariant)) + Column( + modifier = Modifier.weight(0.44f).fillMaxHeight(), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + OrderPane( + order = order, + selectedProductKey = selectedProductKey, + compact = false, + modifier = Modifier.weight(1f), + onSelectProduct = onSelectProduct, + ) + OrderActionBar( + modifyAllowed = modifyAllowed, + increaseAllowed = increaseAllowed, + orderTotal = orderTotal, + orderIsEmpty = order?.total?.isZero() != false, + onIncrease = onIncrease, + onDecrease = onDecrease, + onAddCustom = onAddCustom, + onComplete = onComplete, + ) + } + } + } + else + { + ProductsPane( + products = products, + modifier = Modifier.weight(1f), + compact = true, + minimumTileWidth = 150.dp, + hideDescription = true, + onProductSelected = onProductSelected, + ) + OrderCard( + order = order, + selectedProductKey = selectedProductKey, + increaseAllowed = increaseAllowed, + modifyAllowed = modifyAllowed, + compact = true, + modifier = Modifier.height(220.dp), + onSelectProduct = onSelectProduct, + onIncrease = onIncrease, + onDecrease = onDecrease, + onAddCustom = onAddCustom, + ) + BillButton(orderIsEmpty = order?.total?.isZero() != false, orderTotal = orderTotal, onComplete = onComplete) + } + } +} + +@Composable +private fun OrderCard( + order: Order?, + selectedProductKey: String?, + increaseAllowed: Boolean, + modifyAllowed: Boolean, + compact: Boolean, + modifier: Modifier, + onSelectProduct: (ConfigProduct?) -> Unit, + onIncrease: () -> Unit, + onDecrease: () -> Unit, + onAddCustom: () -> Unit, +) +{ + Surface( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + ) { + Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + stringResource(R.string.order_current), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + LazyColumn(modifier = Modifier.weight(1f)) { + items(order?.products.orEmpty(), key = { it.id }) { product -> + OrderRow(product, selectedProductKey == product.id, compact, { onSelectProduct(product) }) + } + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button( + onClick = onIncrease, + enabled = increaseAllowed, + colors = orderControlButtonColors(), + ) { Text("+1") } + Button( + onClick = onDecrease, + enabled = modifyAllowed, + colors = orderControlButtonColors(), + ) { Text("−1") } + Button(onClick = onAddCustom, colors = orderControlButtonColors()) { + Icon( + painter = painterResource(R.drawable.ic_dialpad), + contentDescription = stringResource(R.string.order_custom), + modifier = Modifier.size(if (compact) 20.dp else 24.dp), + ) + } + } + } + } +} + +@Composable +private fun BillButton( + orderIsEmpty: Boolean, + orderTotal: String, + onComplete: () -> Unit, +) +{ + Button( + onClick = onComplete, + enabled = !orderIsEmpty, + colors = completeButtonColors(), + modifier = Modifier.fillMaxWidth().height(56.dp), + ) { + Text( + if (orderIsEmpty) + { + stringResource(R.string.order_complete) + } + else + { + stringResource(R.string.order_complete_with_amount, orderTotal) + }, + fontWeight = FontWeight.SemiBold, + ) + } +} + +@Composable private fun CategoriesPane( categories: List<Category>, selectedCategoryId: Int?, modifier: Modifier = Modifier, compact: Boolean = false, onCategorySelected: (Category) -> Unit, -) { +) +{ Surface(modifier = modifier.fillMaxWidth()) { LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding( - start = if (compact) 4.dp else 8.dp, - top = if (compact) 8.dp else 12.dp, - end = if (compact) 4.dp else 8.dp, - ), + modifier = + Modifier + .fillMaxSize() + .padding( + start = if (compact) 4.dp else 8.dp, + top = if (compact) 8.dp else 12.dp, + end = if (compact) 4.dp else 8.dp, + ), verticalArrangement = Arrangement.spacedBy(if (compact) 6.dp else 8.dp), ) { items(categories, key = { it.id }) { category -> @@ -443,35 +715,49 @@ private fun CategoryButton( selected: Boolean, compact: Boolean = false, onClick: () -> Unit, -) { +) +{ Surface( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick), - color = if (selected) { - MaterialTheme.colorScheme.secondary - } else { - MaterialTheme.colorScheme.secondaryContainer - }, - contentColor = if (selected) { - MaterialTheme.colorScheme.onSecondary - } else { - MaterialTheme.colorScheme.onSecondaryContainer - }, + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + color = + if (selected) + { + MaterialTheme.colorScheme.secondary + } + else + { + MaterialTheme.colorScheme.secondaryContainer + }, + contentColor = + if (selected) + { + MaterialTheme.colorScheme.onSecondary + } + else + { + MaterialTheme.colorScheme.onSecondaryContainer + }, shape = RoundedCornerShape(30.dp), border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), ) { Text( text = text, - modifier = Modifier - .fillMaxWidth() - .padding( - horizontal = if (compact) 8.dp else 16.dp, - vertical = if (compact) 8.dp else 12.dp, - ), + modifier = + Modifier + .fillMaxWidth() + .padding( + horizontal = 16.dp, + vertical = if (compact) 8.dp else 12.dp, + ), textAlign = TextAlign.Center, fontWeight = FontWeight.SemiBold, style = if (compact) MaterialTheme.typography.bodySmall else MaterialTheme.typography.bodyMedium, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Ellipsis, ) } } @@ -481,20 +767,32 @@ private fun ProductsPane( products: List<ConfigProduct>, modifier: Modifier = Modifier, compact: Boolean = false, + minimumTileWidth: Dp? = null, + hideDescription: Boolean = false, onProductSelected: (ConfigProduct) -> Unit, -) { +) +{ Surface(modifier = modifier.fillMaxWidth()) { BoxWithConstraints( - modifier = Modifier - .fillMaxSize() - .padding( - start = if (compact) 4.dp else 8.dp, - top = if (compact) 8.dp else 12.dp, - end = if (compact) 4.dp else 8.dp, - ), + modifier = + Modifier + .fillMaxSize() + .padding( + start = if (compact) 4.dp else 8.dp, + top = if (compact) 8.dp else 12.dp, + end = if (compact) 4.dp else 8.dp, + ), ) { + if (products.isEmpty()) + { + Text( + text = stringResource(R.string.order_empty_inventory), + modifier = Modifier.align(Alignment.Center).padding(16.dp), + textAlign = TextAlign.Center, + ) + } val spacing = if (compact) 6.dp else 8.dp - val minTileWidth = if (compact) 96.dp else 150.dp + val minTileWidth = minimumTileWidth ?: if (compact) 96.dp else 150.dp val columns = maxOf(1, ((maxWidth + spacing) / (minTileWidth + spacing)).toInt()) val productRows = remember(products, columns) { products.chunked(columns) } @@ -505,9 +803,10 @@ private fun ProductsPane( productRows.forEach { rowProducts -> val rowHasImage = rowProducts.any { !it.image.isNullOrBlank() } Row( - modifier = Modifier - .fillMaxWidth() - .height(IntrinsicSize.Min), + modifier = + Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min), horizontalArrangement = Arrangement.spacedBy(spacing), ) { rowProducts.forEach { product -> @@ -515,10 +814,12 @@ private fun ProductsPane( product = product, rowHasImage = rowHasImage, compact = compact, + hideDescription = hideDescription, onClick = { onProductSelected(product) }, - modifier = Modifier - .weight(1f) - .fillMaxHeight(), + modifier = + Modifier + .weight(1f) + .fillMaxHeight(), ) } repeat(columns - rowProducts.size) { @@ -535,52 +836,70 @@ private fun ProductsPane( private fun ProductCard( product: ConfigProduct, rowHasImage: Boolean, - compact: Boolean = false, onClick: () -> Unit, modifier: Modifier = Modifier, -) { - val cardContainerColor = if (product.availableToSell) { - MaterialTheme.colorScheme.surface - } else { - MaterialTheme.colorScheme.surfaceVariant - } - val cardBorderColor = if (product.availableToSell) { - MaterialTheme.colorScheme.outlineVariant - } else { - MaterialTheme.colorScheme.error.copy(alpha = 0.4f) - } + compact: Boolean = false, + hideDescription: Boolean = false, +) +{ + val cardContainerColor = + if (product.availableToSell) + { + MaterialTheme.colorScheme.surface + } + else + { + MaterialTheme.colorScheme.surfaceVariant + } + val cardBorderColor = + if (product.availableToSell) + { + MaterialTheme.colorScheme.outlineVariant + } + else + { + MaterialTheme.colorScheme.error.copy(alpha = 0.4f) + } Card( - modifier = modifier - .fillMaxWidth() - .fillMaxHeight() - .clickable(enabled = product.availableToSell, onClick = onClick), - colors = CardDefaults.cardColors( - containerColor = cardContainerColor, - ), + modifier = + modifier + .fillMaxWidth() + .fillMaxHeight() + .clickable(enabled = product.availableToSell, onClick = onClick), + colors = + CardDefaults.cardColors( + containerColor = cardContainerColor, + ), border = BorderStroke(1.dp, cardBorderColor), elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), ) { Column( - modifier = Modifier - .fillMaxSize() - .padding(if (compact) 6.dp else 8.dp), + modifier = + Modifier + .fillMaxSize() + .padding(if (compact) 6.dp else 8.dp), verticalArrangement = Arrangement.spacedBy(if (compact) 3.dp else 4.dp), ) { val productBitmap = product.image?.base64Bitmap val imageSize = if (compact) 40.dp else 64.dp - if (productBitmap != null) { + if (productBitmap != null) + { Image( bitmap = productBitmap.asImageBitmap(), contentDescription = product.displayName, - modifier = Modifier - .size(imageSize) - .align(Alignment.CenterHorizontally), + modifier = + Modifier + .size(imageSize) + .align(Alignment.CenterHorizontally), ) - } else if (rowHasImage) { + } + else if (rowHasImage) + { Spacer( - modifier = Modifier - .height(imageSize) - .fillMaxWidth(), + modifier = + Modifier + .height(imageSize) + .fillMaxWidth(), ) } Text( @@ -592,7 +911,7 @@ private fun ProductCard( modifier = Modifier.fillMaxWidth(), style = if (compact) MaterialTheme.typography.bodySmall else MaterialTheme.typography.bodyMedium, ) - product.displayDescription?.let { + product.displayDescription?.takeUnless { hideDescription }?.let { Text( text = it, style = if (compact) MaterialTheme.typography.labelSmall else MaterialTheme.typography.bodySmall, @@ -609,13 +928,16 @@ private fun ProductCard( textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth(), ) - if (!product.availableToSell) { + if (!product.availableToSell) + { Text( - text = when { - product.currencyMismatch -> stringResource(R.string.product_wrong_currency) - product.remainingStock == 0 -> stringResource(R.string.product_out_of_stock) - else -> stringResource(R.string.product_unavailable) - }, + text = + when + { + product.currencyMismatch -> stringResource(R.string.product_wrong_currency) + product.remainingStock == 0 -> stringResource(R.string.product_out_of_stock) + else -> stringResource(R.string.product_unavailable) + }, color = MaterialTheme.colorScheme.error, style = if (compact) MaterialTheme.typography.labelSmall else MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Bold, @@ -634,7 +956,8 @@ private fun OrderPane( modifier: Modifier = Modifier, compact: Boolean = false, onSelectProduct: (ConfigProduct?) -> Unit, -) { +) +{ Surface(modifier = modifier.fillMaxWidth()) { Column(modifier = Modifier.fillMaxSize()) { LazyColumn( @@ -661,18 +984,19 @@ private fun OrderRow( selected: Boolean, compact: Boolean = false, onClick: () -> Unit, -) { +) +{ val rowPadding = if (compact) 6.dp else 8.dp val imageSize = if (compact) 24.dp else 32.dp val countWidth = if (compact) 18.dp else 24.dp Row( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .background( - if (selected) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent, - ) - .padding(rowPadding), + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .background( + if (selected) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent, + ).padding(rowPadding), horizontalArrangement = Arrangement.spacedBy(if (compact) 6.dp else 8.dp), verticalAlignment = Alignment.Top, ) { @@ -694,7 +1018,10 @@ private fun OrderRow( style = if (compact) MaterialTheme.typography.bodySmall else MaterialTheme.typography.bodyMedium, ) product.displayDescription?.let { - Text(it, style = if (compact) MaterialTheme.typography.labelSmall else MaterialTheme.typography.bodySmall) + Text( + it, + style = if (compact) MaterialTheme.typography.labelSmall else MaterialTheme.typography.bodySmall, + ) } } Text( @@ -716,22 +1043,30 @@ private fun OrderActionBar( onDecrease: () -> Unit, onAddCustom: () -> Unit, onComplete: () -> Unit, -) { +) +{ Column( modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp), ) { Row( - modifier = Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()), + modifier = + Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(if (compact) 8.dp else 12.dp), ) { Button(onClick = onIncrease, enabled = increaseAllowed, colors = orderControlButtonColors()) { - Text("+1", style = if (compact) MaterialTheme.typography.labelLarge else MaterialTheme.typography.bodyMedium) + Text( + "+1", + style = if (compact) MaterialTheme.typography.labelLarge else MaterialTheme.typography.bodyMedium, + ) } Button(onClick = onDecrease, enabled = modifyAllowed, colors = orderControlButtonColors()) { - Text("-1", style = if (compact) MaterialTheme.typography.labelLarge else MaterialTheme.typography.bodyMedium) + Text( + "-1", + style = if (compact) MaterialTheme.typography.labelLarge else MaterialTheme.typography.bodyMedium, + ) } Button(onClick = onAddCustom, colors = orderControlButtonColors()) { Icon( @@ -744,15 +1079,19 @@ private fun OrderActionBar( Button( onClick = onComplete, enabled = !orderIsEmpty, - modifier = Modifier - .fillMaxWidth() - .height(if (compact) 72.dp else 96.dp), + modifier = + Modifier + .fillMaxWidth() + .height(if (compact) 72.dp else 96.dp), colors = completeButtonColors(), ) { Text( - if (orderIsEmpty) { + if (orderIsEmpty) + { stringResource(R.string.order_complete) - } else { + } + else + { stringResource(R.string.order_complete_with_amount, orderTotal) }, style = if (compact) MaterialTheme.typography.bodyLarge else MaterialTheme.typography.titleMedium, @@ -778,11 +1117,13 @@ private fun OrderColumnPane( onDecrease: () -> Unit, onAddCustom: () -> Unit, onComplete: () -> Unit, -) { +) +{ Column( - modifier = modifier - .fillMaxSize() - .padding(top = 12.dp, end = 12.dp, bottom = 12.dp), + modifier = + modifier + .fillMaxSize() + .padding(top = 12.dp, end = 12.dp, bottom = 12.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { OrderPane( @@ -808,76 +1149,96 @@ private fun OrderColumnPane( } @Composable -private fun orderControlButtonColors() = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer, - disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant, - disabledContentColor = MaterialTheme.colorScheme.onSurfaceVariant, -) - -@Composable -private fun completeButtonColors() = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary, - disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant, - disabledContentColor = MaterialTheme.colorScheme.onSurfaceVariant, -) - -@Composable private fun CustomProductDialog( currency: String, - currencySpec: net.taler.common.CurrencySpecification?, + currencySpec: CurrencySpecification?, onDismiss: () -> Unit, onAdd: (String, Amount) -> Unit, initialDescription: String? = null, initialAmount: String? = null, -) { +) +{ val defaultDescription = stringResource(R.string.order_custom_product_default) val invalidAmountText = stringResource(R.string.refund_error_invalid_amount) var description by rememberSaveable { mutableStateOf(initialDescription ?: defaultDescription) } var amountText by rememberSaveable { mutableStateOf(initialAmount ?: "") } var errorText by rememberSaveable { mutableStateOf<String?>(null) } - AlertDialog( + Dialog( onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.order_custom)) }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - OutlinedTextField( - value = description, - onValueChange = { description = it }, - label = { Text(stringResource(R.string.order_custom_product)) }, - ) - OutlinedTextField( - value = amountText, - onValueChange = { - amountText = it - errorText = null - }, - label = { Text(currency) }, - supportingText = errorText?.let { { Text(it) } }, - ) - } - }, - confirmButton = { - Button(onClick = { - val amount = try { - Amount.fromString(currency, amountText).withSpec(currencySpec) - } catch (_: AmountParserException) { - errorText = invalidAmountText - return@Button + properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false), + ) { + Box( + modifier = + Modifier + .fillMaxSize() + .systemBarsPadding() + .imePadding() + .pointerInput(onDismiss) { detectTapGestures { onDismiss() } } + .padding(horizontal = 24.dp, vertical = 12.dp), + contentAlignment = Alignment.Center, + ) { + Surface( + modifier = Modifier.widthIn(max = 560.dp).pointerInput(Unit) { detectTapGestures { } }, + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + tonalElevation = 6.dp, + ) { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()).padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text(stringResource(R.string.order_custom), style = MaterialTheme.typography.headlineSmall) + OutlinedTextField( + value = description, + onValueChange = { description = it }, + label = { Text(stringResource(R.string.order_custom_product)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + ) + OutlinedTextField( + value = amountText, + onValueChange = { + amountText = it + errorText = null + }, + label = { Text(currency) }, + supportingText = errorText?.let { { Text(it) } }, + isError = errorText != null, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + ) { + OutlinedButton(onClick = onDismiss) { + Text(stringResource(R.string.refund_abort)) + } + Button( + onClick = { + val amount = + try + { + Amount.fromString(currency, amountText).withSpec(currencySpec) + } + catch (_: AmountParserException) + { + errorText = invalidAmountText + return@Button + } + onAdd(description, amount) + }, + ) { + Text(stringResource(R.string.order_custom_add_button)) + } + } } - onAdd(description, amount) - }) { - Text(stringResource(R.string.order_custom_add_button)) - } - }, - dismissButton = { - OutlinedButton(onClick = onDismiss) { - Text(stringResource(R.string.refund_abort)) } - }, - ) + } + } } @Composable @@ -886,7 +1247,8 @@ internal fun OrderScreenContent( showCustomDialog: Boolean = false, customDescription: String? = null, customAmount: String? = null, -) { +) +{ val orderManager = remember { viewModel.orderManager } val currentOrderId by orderManager.currentOrderId.observeAsState() val categories by orderManager.categories.observeAsState(emptyList()) @@ -902,12 +1264,14 @@ internal fun OrderScreenContent( ) val modifyAllowed by liveOrder.modifyOrderAllowed.observeAsState(false) val increaseAllowed by liveOrder.increaseOrderAllowed.observeAsState(false) - var selectedProductKey by rememberSaveable(orderId) { mutableStateOf(liveOrder.selectedProductKey) } + val selectedProductKey by liveOrder.selectedProductKey.observeAsState() var selectedCategoryId by rememberSaveable { mutableStateOf(categories.firstOrNull { it.selected }?.id) } - if (showCustomDialog && currency != null) { + if (showCustomDialog && + currency != null) + { CustomProductDialog( currency = currency, currencySpec = currencySpec, @@ -922,9 +1286,13 @@ internal fun OrderScreenContent( Box( modifier = Modifier.fillMaxSize(), ) { - val isTabletLayout = LocalConfiguration.current.smallestScreenWidthDp >= 720 - if (isTabletLayout) { + val configuration = LocalConfiguration.current + val isTabletLayout = configuration.smallestScreenWidthDp >= 600 + val portrait = configuration.screenHeightDp > configuration.screenWidthDp + if (isTabletLayout) + { TabletOrderScreen( + portrait = portrait, categories = categories, selectedCategoryId = selectedCategoryId, products = products, @@ -935,14 +1303,17 @@ internal fun OrderScreenContent( selectedProductKey = selectedProductKey, onCategorySelected = { selectedCategoryId = it.id }, onProductSelected = {}, - onSelectProduct = { selectedProductKey = it?.id }, + onSelectProduct = { liveOrder.selectOrderLine(it) }, onIncrease = {}, onDecrease = {}, onAddCustom = {}, onComplete = {}, ) - } else { + } + else + { PhoneOrderScreen( + portrait = portrait, categories = categories, selectedCategoryId = selectedCategoryId, products = products, @@ -953,7 +1324,7 @@ internal fun OrderScreenContent( selectedProductKey = selectedProductKey, onCategorySelected = { selectedCategoryId = it.id }, onProductSelected = {}, - onSelectProduct = { selectedProductKey = it?.id }, + onSelectProduct = { liveOrder.selectOrderLine(it) }, onIncrease = {}, onDecrease = {}, onAddCustom = {}, 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 @@ -18,10 +18,12 @@ package net.taler.merchantpos.order import android.content.Context import android.util.Log + 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 @@ -30,9 +32,12 @@ import net.taler.merchantpos.config.ConfigurationReceiver import net.taler.merchantpos.config.PosConfig import net.taler.merchantpos.order.RestartState.ENABLED -class OrderManager(private val context: Context) : ConfigurationReceiver { - - companion object { +class OrderManager( + private val context: Context, +) : ConfigurationReceiver +{ + companion object + { val TAG: String = OrderManager::class.java.simpleName private const val ALL_PRODUCTS_CATEGORY_ID = -1 private const val UNCATEGORIZED_CATEGORY_ID = -2 @@ -42,19 +47,17 @@ 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 var currentCategory: Category? = null private val productsByCategory = HashMap<Category, ArrayList<ConfigProduct>>() private val productsById = HashMap<String, ConfigProduct>() private val orders = LinkedHashMap<Int, MutableLiveOrder>() - + private val mCurrentOrderId = MutableLiveData<Int>() private val mProducts = MutableLiveData<List<ConfigProduct>>() - internal val products: LiveData<List<ConfigProduct>> = mProducts - private val mCategories = MutableLiveData<List<Category>>() + + internal val currentOrderId: LiveData<Int> = mCurrentOrderId + internal val products: LiveData<List<ConfigProduct>> = mProducts internal val categories: LiveData<List<Category>> = mCategories - private var currentCategory: Category? = null override suspend fun onConfigurationReceived( posConfig: PosConfig, @@ -73,27 +76,26 @@ class OrderManager(private val context: Context) : ConfigurationReceiver { currency: String, currencySpec: CurrencySpecification?, resetOrders: Boolean, - ): String? { + ): 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) - ) - val uncategorizedCategory = Category( - UNCATEGORIZED_CATEGORY_ID, - context.getString(R.string.product_category_uncategorized) - ) + val allProductsCategory = + Category( + ALL_PRODUCTS_CATEGORY_ID, + context.getString(R.string.product_category_all_objects), + ) + val uncategorizedCategory = + Category( + UNCATEGORIZED_CATEGORY_ID, + context.getString(R.string.product_category_uncategorized), + ) val visibleCategories = posConfig.categories.filterNot(::isLegacyDefaultCategory) - val legacyDefaultCategoryIds = posConfig.categories - .filter(::isLegacyDefaultCategory) - .map { it.id } - .toSet() + val legacyDefaultCategoryIds = + posConfig.categories + .filter(::isLegacyDefaultCategory) + .map { it.id } + .toSet() productsByCategory.clear() productsById.clear() @@ -107,32 +109,39 @@ class OrderManager(private val context: Context) : ConfigurationReceiver { posConfig.products.forEach { product -> val productCurrency = product.price.currency val currencyMismatch = productCurrency != currency - if (currencyMismatch) { + if (currencyMismatch) + { Log.w(TAG, "Product $product has currency $productCurrency, $currency expected") } val remainingStock = product.stockLimit - val productWithSpec = product.copy( - id = existingProductsByStableKey[product.stableKey]?.id ?: product.id, - price = product.price.withSpec(currencySpec), - availableToSell = !currencyMismatch && (remainingStock == null || remainingStock > 0), - remainingStock = remainingStock, - currencyMismatch = currencyMismatch, - ) + val productWithSpec = + product.copy( + id = existingProductsByStableKey[product.stableKey]?.id ?: product.id, + price = product.price.withSpec(currencySpec), + availableToSell = !currencyMismatch && (remainingStock == null || remainingStock > 0), + remainingStock = remainingStock, + currencyMismatch = currencyMismatch, + ) productsById[productWithSpec.id] = productWithSpec productsByCategory.getValue(allProductsCategory).add(productWithSpec) - if (product.categories.isEmpty()) { + if (product.categories.isEmpty()) + { productsByCategory.getValue(uncategorizedCategory).add(productWithSpec) } product.categories.forEach { categoryId -> - if (categoryId in legacyDefaultCategoryIds) { + if (categoryId in legacyDefaultCategoryIds) + { productsByCategory.getValue(uncategorizedCategory).add(productWithSpec) return@forEach } val category = visibleCategories.find { it.id == categoryId } - if (category == null) { + if (category == null) + { Log.e(TAG, "Product $product has unknown category $categoryId") productsByCategory.getValue(uncategorizedCategory).add(productWithSpec) - } else { + } + else + { productsByCategory.getValue(category).add(productWithSpec) } } @@ -140,15 +149,19 @@ class OrderManager(private val context: Context) : ConfigurationReceiver { this.currency = currency this.currencySpec = currencySpec - val categoryList = buildList { - add(allProductsCategory) - addAll(visibleCategories) - if (productsByCategory.getValue(uncategorizedCategory).isNotEmpty()) { - add(uncategorizedCategory) - } else { - productsByCategory.remove(uncategorizedCategory) + val categoryList = + buildList { + add(allProductsCategory) + addAll(visibleCategories) + if (productsByCategory.getValue(uncategorizedCategory).isNotEmpty()) + { + add(uncategorizedCategory) + } + else + { + productsByCategory.remove(uncategorizedCategory) + } } - } val selectedCategory = categoryList.firstOrNull { it.id == selectedCategoryId } ?: allProductsCategory categoryList.forEach { it.selected = it.id == selectedCategory.id } @@ -156,95 +169,116 @@ class OrderManager(private val context: Context) : ConfigurationReceiver { mCategories.postValue(categoryList) mProducts.postValue(getVisibleProducts()) - if (resetOrders) { + if (resetOrders) + { orders.clear() orderCounter = 0 orders[0] = createOrder(0) mCurrentOrderId.postValue(0) - } else { + } + else + { trimOrdersToStockLimits() } return null } @UiThread - internal fun getOrder(orderId: Int): LiveOrder { - return orders[orderId] ?: throw IllegalArgumentException("Order not found: $orderId") - } + internal fun getOrder(orderId: Int): LiveOrder = + orders[orderId] ?: throw IllegalArgumentException("Order not found: $orderId") @UiThread - internal fun nextOrder() { + internal fun nextOrder() + { val currentId = currentOrderId.value!! var foundCurrentOrder = false var nextId: Int? = null - for (orderId in orders.keys) { - if (foundCurrentOrder) { + for (orderId in orders.keys) + { + if (foundCurrentOrder) + { nextId = orderId break } if (orderId == currentId) foundCurrentOrder = true } - if (nextId == null) { + if (nextId == null) + { nextId = ++orderCounter orders[nextId] = createOrder(nextId) } val currentOrder = order(currentId) - if (currentOrder.isEmpty()) orders.remove(currentId) - else currentOrder.lastAddedProduct = null + if (currentOrder.isEmpty()) + { + orders.remove(currentId) + } mCurrentOrderId.value = requireNotNull(nextId) updateVisibleProducts() } @UiThread - internal fun previousOrder() { + internal fun previousOrder() + { val currentId = currentOrderId.value!! var previousId: Int? = null var foundCurrentOrder = false - for (orderId in orders.keys) { - if (orderId == currentId) { + for (orderId in orders.keys) + { + if (orderId == currentId) + { foundCurrentOrder = true break } previousId = orderId } - if (previousId == null || !foundCurrentOrder) { + if (previousId == null || + !foundCurrentOrder) + { throw AssertionError("Could not find previous order for $currentId") } val currentOrder = order(currentId) - if (currentOrder.isEmpty()) orders.remove(currentId) - else currentOrder.lastAddedProduct = null + if (currentOrder.isEmpty()) + { + orders.remove(currentId) + } mCurrentOrderId.value = requireNotNull(previousId) updateVisibleProducts() } - fun hasPreviousOrder(currentOrderId: Int): Boolean { - return currentOrderId != orders.keys.first() - } + fun hasPreviousOrder(currentOrderId: Int): Boolean = currentOrderId != orders.keys.first() - fun hasNextOrder(currentOrderId: Int) = order(currentOrderId).restartState.map { state -> - state == ENABLED || currentOrderId != orders.keys.last() - } + fun hasNextOrder(currentOrderId: Int) = + order(currentOrderId).restartState.map { state -> + state == ENABLED || currentOrderId != orders.keys.last() + } - internal fun setCurrentCategory(category: Category) { + internal fun setCurrentCategory(category: Category) + { currentCategory = category val currentCategories = categories.value.orEmpty() - val newCategories = currentCategories.map { existing -> - existing.copy().also { copied -> - copied.selected = existing.id == category.id + val newCategories = + currentCategories.map { existing -> + existing.copy().also { copied -> + copied.selected = existing.id == category.id + } } - } currentCategory = newCategories.firstOrNull { it.id == category.id } ?: category mCategories.postValue(newCategories) updateVisibleProducts() } @UiThread - internal fun addProduct(orderId: Int, product: ConfigProduct) { + internal fun addProduct( + orderId: Int, + product: ConfigProduct, + ) + { order(orderId).addProduct(product) } @UiThread - internal fun debugSeedCurrentOrder(productIds: List<String>) { + internal fun debugSeedCurrentOrder(productIds: List<String>) + { val orderId = currentOrderId.value ?: return val liveOrder = order(orderId) productIds.forEach { productId -> @@ -254,45 +288,62 @@ class OrderManager(private val context: Context) : ConfigurationReceiver { } @UiThread - internal fun onOrderPaid(orderId: Int) { - if (currentOrderId.value == orderId) { - if (hasPreviousOrder(orderId)) previousOrder() - else nextOrder() + internal fun onOrderPaid(orderId: Int) + { + if (currentOrderId.value == orderId) + { + if (hasPreviousOrder(orderId)) + { + previousOrder() + } + else + { + nextOrder() + } } orders.remove(orderId) updateVisibleProducts() } @UiThread - internal fun deleteCurrentOrder() { + 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 + val replacementId = + when + { + orders.isEmpty() -> + { + orders[currentId] = createOrder(currentId) + currentId + } + + currentIndex < orderIds.lastIndex -> + { + orderIds[currentIndex + 1] + } + + else -> + { + orderIds[currentIndex - 1] + } } - currentIndex < orderIds.lastIndex -> orderIds[currentIndex + 1] - else -> orderIds[currentIndex - 1] - } mCurrentOrderId.value = replacementId updateVisibleProducts() } - private fun order(orderId: Int): MutableLiveOrder { - return orders[orderId] ?: throw IllegalStateException() - } + private fun order(orderId: Int): MutableLiveOrder = orders[orderId] ?: throw IllegalStateException() - private fun isLegacyDefaultCategory(category: Category): Boolean { - return category.name.equals(LEGACY_DEFAULT_CATEGORY_NAME, ignoreCase = true) - } + private fun isLegacyDefaultCategory(category: Category): Boolean = + category.name.equals(LEGACY_DEFAULT_CATEGORY_NAME, ignoreCase = true) - private fun createOrder(orderId: Int): MutableLiveOrder { - return MutableLiveOrder( + private fun createOrder(orderId: Int): MutableLiveOrder = + MutableLiveOrder( orderId, currency, currencySpec, @@ -300,18 +351,20 @@ class OrderManager(private val context: Context) : ConfigurationReceiver { ::canAddProduct, ::updateVisibleProducts, ) - } - private fun getVisibleProducts(): List<ConfigProduct> { + private fun getVisibleProducts(): List<ConfigProduct> + { val category = currentCategory ?: return emptyList() return productsByCategory[category].orEmpty().map(::decorateProduct) } - private fun updateVisibleProducts() { + private fun updateVisibleProducts() + { mProducts.postValue(getVisibleProducts()) } - private fun decorateProduct(product: ConfigProduct): ConfigProduct { + private fun decorateProduct(product: ConfigProduct): ConfigProduct + { val remainingStock = remainingStock(product) return product.copy( availableToSell = !product.currencyMismatch && (remainingStock == null || remainingStock > 0), @@ -319,34 +372,44 @@ class OrderManager(private val context: Context) : ConfigurationReceiver { ) } - private fun canAddProduct(product: ConfigProduct): Boolean { - return remainingStock(product)?.let { it > 0 } ?: true - } + private fun canAddProduct(product: ConfigProduct): Boolean = remainingStock(product)?.let { it > 0 } ?: true - private fun remainingStock(product: ConfigProduct): Int? { + 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 - } + val reserved = + orders.values.sumOf { liveOrder -> + liveOrder.order.value + ?.products + ?.find { it.id == product.id } + ?.quantity + ?: 0 + } return (stockLimit - reserved).coerceAtLeast(0) } - private fun trimOrdersToStockLimits() { - for (liveOrder in orders.values) { + private fun trimOrdersToStockLimits() + { + for (liveOrder in orders.values) + { val order = liveOrder.order.value ?: continue var modified = false - val trimmedProducts = order.products.mapNotNull { orderProduct -> - val stockLimit = productsById[orderProduct.id]?.stockLimit ?: return@mapNotNull orderProduct - if (orderProduct.quantity <= stockLimit) return@mapNotNull orderProduct - modified = true - if (stockLimit <= 0) null - else orderProduct.copy(quantity = stockLimit) - } - if (modified) { + val trimmedProducts = + order.products.mapNotNull { orderProduct -> + val stockLimit = productsById[orderProduct.id]?.stockLimit ?: return@mapNotNull orderProduct + if (orderProduct.quantity <= stockLimit) return@mapNotNull orderProduct + modified = true + if (stockLimit <= 0) + { + null + } + else + { + orderProduct.copy(quantity = stockLimit) + } + } + if (modified) + { liveOrder.order.postValue(order.copy(products = trimmedProducts)) } } diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/payment/OrderRequest.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/payment/OrderRequest.kt @@ -0,0 +1,71 @@ +/* + * 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.payment + +import net.taler.common.RelativeTime +import net.taler.merchantlib.MinimalInventoryProduct +import net.taler.merchantpos.order.Order + +import java.util.concurrent.TimeUnit.HOURS + +/** Refund window for orders created at the till. */ +internal val POS_REFUND_DELAY: RelativeTime = RelativeTime.fromMillis(HOURS.toMillis(1)) + +internal data class OrderParts( + val contractTerms: net.taler.common.Order, + val inventoryProducts: List<MinimalInventoryProduct>?, +) + +/** + * The backend expands inventory lines and merges them with explicit custom lines. + */ +internal fun Order.toOrderParts(includeProducts: Boolean): OrderParts +{ + val inventoryProducts = + if (includeProducts) + { + products.mapNotNull { product -> + val productId = product.productId ?: return@mapNotNull null + MinimalInventoryProduct(productId = productId, quantity = product.quantity) + } + } + else + { + emptyList() + } + val useInventoryProducts = includeProducts && inventoryProducts.isNotEmpty() + return OrderParts( + contractTerms = + toContractTerms(includeProducts = includeProducts).let { terms -> + if (useInventoryProducts) + { + terms.copy( + products = + products + .filter { it.productId == null } + .map { it.toContractProduct() } + .ifEmpty { null }, + ) + } + else + { + terms + } + }, + inventoryProducts = inventoryProducts.takeIf { useInventoryProducts }, + ) +} diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/payment/Payment.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/payment/Payment.kt @@ -16,6 +16,7 @@ package net.taler.merchantpos.payment +import net.taler.common.Timestamp import net.taler.merchantpos.order.Order data class Payment( @@ -29,12 +30,23 @@ data class Payment( val insecureUriAccepted: Boolean = false, val claimed: Boolean = false, val paid: Boolean = false, - val error: String? = null + val error: String? = null, + /** Set when this is the Taler part of a mixed checkout. */ + val checkoutId: String? = null, + /** + * Whether the merchant may collect the order themselves instead of the customer's + * wallet. Only ever true for a fully settled checkout reached from its own screen. + */ + val selfCollect: Boolean = false, + val merchantName: String? = null, + val payDeadline: Timestamp? = null, + val statusLoading: Boolean = false, ) private const val INSECURE_TALER_SCHEME = "taler+http://" -internal fun upgradeTalerPayUriToHttps(uri: String): String? { +internal fun upgradeTalerPayUriToHttps(uri: String): String? +{ if (!uri.startsWith(INSECURE_TALER_SCHEME, ignoreCase = true)) return null return "taler://${uri.substring(INSECURE_TALER_SCHEME.length)}" } 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 @@ -19,93 +19,100 @@ package net.taler.merchantpos.payment import android.content.Context import android.os.CountDownTimer import android.util.Log + import androidx.annotation.UiThread import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import kotlinx.serialization.json.Json -import net.taler.common.RelativeTime + import net.taler.lib.android.assertUiThread import net.taler.merchantlib.CheckPaymentResponse import net.taler.merchantlib.MerchantApi -import net.taler.merchantlib.MinimalInventoryProduct import net.taler.merchantlib.OrderHistoryEntry import net.taler.merchantlib.PostOrderRequest import net.taler.merchantpos.MainActivity.Companion.TAG import net.taler.merchantpos.R +import net.taler.merchantpos.checkout.Checkout import net.taler.merchantpos.config.ConfigManager import net.taler.merchantpos.config.ConfigProduct import net.taler.merchantpos.order.Order -import java.util.concurrent.TimeUnit.HOURS + import java.util.concurrent.TimeUnit.SECONDS +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + private const val TIMEOUT = Long.MAX_VALUE private val CHECK_INTERVAL = SECONDS.toMillis(1) +/** Order list id of the synthetic order shown for a checkout's Taler part. */ +private const val CHECKOUT_DISPLAY_ORDER_ID = -3 + class PaymentManager( private val context: Context, private val configManager: ConfigManager, private val scope: CoroutineScope, private val api: MerchantApi, -) { - - private val mPayment = MutableLiveData<Payment>() - val payment: LiveData<Payment> = mPayment +) +{ private var checkJob: Job? = null + private val mPayment = MutableLiveData<Payment?>(null) + private val mDeleteNeedsForce = MutableLiveData<Boolean?>(null) + + val payment: LiveData<Payment?> = mPayment + val deleteNeedsForce: LiveData<Boolean?> = mDeleteNeedsForce - private val checkTimer: CountDownTimer = object : CountDownTimer(TIMEOUT, CHECK_INTERVAL) { - override fun onTick(millisUntilFinished: Long) { - val orderId = payment.value?.orderId - if (orderId == null) cancel() - // only start new job if old one doesn't exist or is complete - else if (checkJob == null || checkJob?.isCompleted == true) { - checkJob = checkPayment(orderId) + private val checkTimer: CountDownTimer = + object : CountDownTimer(TIMEOUT, CHECK_INTERVAL) + { + override fun onTick(millisUntilFinished: Long) + { + val orderId = payment.value?.orderId + if (orderId == null) + { + cancel() + } + else if (checkJob == null || + checkJob?.isCompleted == true) + { + checkJob = checkPayment(orderId) + } } - } - override fun onFinish() { - cancelPayment(context.getString(R.string.error_timeout)) + override fun onFinish() + { + cancelPayment(context.getString(R.string.error_timeout)) + } } - } @UiThread - fun createPayment(order: Order, includeProducts: Boolean = true) = scope.launch { + fun createPayment( + order: Order, + includeProducts: Boolean = true, + ) = scope.launch { val merchantConfig = configManager.merchantConfig!! val payment = Payment(order, order.summary, configManager.currency!!) mPayment.value = payment - val inventoryProducts = if (includeProducts) { - order.products.mapNotNull { product -> - val productId = product.productId ?: return@mapNotNull null - MinimalInventoryProduct(productId = productId, quantity = product.quantity) - } - } else { - emptyList() - } - val useInventoryProducts = includeProducts && inventoryProducts.isNotEmpty() - val request = PostOrderRequest( - contractTerms = order.toContractTerms( - includeProducts = includeProducts && !useInventoryProducts - ), - refundDelay = RelativeTime.fromMillis(HOURS.toMillis(1)) - , - inventoryProducts = if (useInventoryProducts) inventoryProducts else null, - ) - val requestJson = Json { - encodeDefaults = false - ignoreUnknownKeys = true - }.encodeToString(request) - Log.d(TAG, "PostOrderRequest: $requestJson") - api.postOrder(merchantConfig, request).handle({ error -> - if (looksLikeInventoryError(error)) { - configManager.refreshInventory() + val parts = order.toOrderParts(includeProducts) + val request = + PostOrderRequest( + contractTerms = parts.contractTerms, + refundDelay = POS_REFUND_DELAY, + inventoryProducts = parts.inventoryProducts, + ) + api.postOrder(merchantConfig, request).handle( + { error -> + if (looksLikeInventoryError(error)) + { + configManager.refreshInventory() + } + onNetworkError(error) } - onNetworkError(error) - }) { orderResponse -> + ) { orderResponse -> assertUiThread() - if (mPayment.value !== payment) { + if (mPayment.value !== payment) + { Log.d(TAG, "Ignoring order ${orderResponse.orderId} for abandoned payment") // cancelPayment() could not delete it yet, it had no order ID back then scope.launch { api.deleteOrder(merchantConfig, orderResponse.orderId) } @@ -117,128 +124,232 @@ class PaymentManager( } @UiThread - fun resumePayment(item: OrderHistoryEntry) { + fun resumePayment(item: OrderHistoryEntry) + { val current = mPayment.value - if (current?.orderId == item.orderId && !current.paid && current.error == null) { - if (checkJob == null || checkJob?.isCompleted == true) { + if (current?.orderId == item.orderId && + !current.paid && + current.error == null) + { + if (checkJob == null || + checkJob?.isCompleted == true) + { checkJob = checkPayment(item.orderId) } checkTimer.start() return } - val order = Order( - id = -2, - currency = item.amount.currency, - currencySpec = item.amount.spec, - availableCategories = emptyMap(), - products = listOf( - ConfigProduct( - description = item.summary, - price = item.amount, - categories = listOf(Int.MIN_VALUE), - quantity = 1, - ) - ), - ) - mPayment.value = Payment( - order = order, - summary = item.summary, - currency = item.amount.currency, - orderId = item.orderId, - paid = item.paid, - ) - if (!item.paid) { + stopPaymentChecks() + val order = + Order( + id = -2, + currency = item.amount.currency, + currencySpec = item.amount.spec, + availableCategories = emptyMap(), + products = + listOf( + ConfigProduct( + description = item.summary, + price = item.amount, + categories = listOf(Int.MIN_VALUE), + quantity = 1, + ), + ), + ) + mPayment.value = + Payment( + order = order, + summary = item.summary, + currency = item.amount.currency, + orderId = item.orderId, + paid = item.paid, + statusLoading = !item.paid, + ) + if (!item.paid) + { checkJob = checkPayment(item.orderId) checkTimer.start() } } + /** + * Shows the Taler part of a mixed checkout. Its backend order already exists and + * carries the external payments, so this screen only waits for the wallet. + */ @UiThread - internal fun debugSetPayment(payment: Payment) { + fun showCheckoutOrder(checkout: Checkout) + { + val orderId = checkout.backendOrderId ?: return + stopPaymentChecks() + val current = mPayment.value + if (current?.checkoutId == checkout.id && + current.orderId == orderId && + !current.paid && + current.error == null) + { + checkJob = checkPayment(orderId) + checkTimer.start() + return + } + val spec = configManager.currencySpec + val open = checkout.open.withSpec(spec) + val order = + Order( + id = CHECKOUT_DISPLAY_ORDER_ID, + currency = open.currency, + currencySpec = spec, + availableCategories = emptyMap(), + products = + listOf( + ConfigProduct( + description = checkout.contractTerms.summary, + price = open, + categories = listOf(Int.MIN_VALUE), + quantity = 1, + ), + ), + ) + mPayment.value = + Payment( + order = order, + summary = checkout.contractTerms.summary, + currency = open.currency, + orderId = orderId, + checkoutId = checkout.id, + // Nothing is left for the wallet, so the merchant can finish it themselves. + selfCollect = checkout.open.isZero(), + statusLoading = true, + ) + checkJob = checkPayment(orderId) + checkTimer.start() + } + + @UiThread + internal fun debugSetPayment(payment: Payment) + { checkTimer.cancel() checkJob = null mPayment.value = payment } - private fun checkPayment(orderId: String) = scope.launch { - val merchantConfig = configManager.merchantConfig!! - api.checkOrder(merchantConfig, orderId).handle({ error -> - // don't call onNetworkError() to not cancel payment, just keep trying - Log.d(TAG, "Network error: $error") - }) { response -> - assertUiThread() - if (!isActive) return@handle // don't continue if job was cancelled - val currentValue = mPayment.value - if (currentValue?.orderId != orderId) { - Log.d(TAG, "Ignoring stale check result for order $orderId") - return@handle - } - when (response) { - is CheckPaymentResponse.Unpaid -> { - val upgradedUri = upgradeTalerPayUriToHttps(response.talerPayUri) - mPayment.value = when { - upgradedUri == null -> currentValue.copy( - talerPayUri = response.talerPayUri, - insecureTalerPayUri = null, - ) - currentValue.httpsUpgradeAccepted -> currentValue.copy( - talerPayUri = upgradedUri, - insecureTalerPayUri = null, - ) - currentValue.insecureUriAccepted -> currentValue.copy( - talerPayUri = response.talerPayUri, - insecureTalerPayUri = null, - ) - else -> currentValue.copy( - talerPayUri = null, - insecureTalerPayUri = response.talerPayUri, - ) - } + private fun checkPayment(orderId: String) = + scope.launch { + val merchantConfig = configManager.merchantConfig!! + api.checkOrder(merchantConfig, orderId).handle( + { error -> + // don't call onNetworkError() to not cancel payment, just keep trying + Log.d(TAG, "Network error: $error") } - is CheckPaymentResponse.Claimed -> { - mPayment.value = currentValue.copy(claimed = true) + ) { response -> + assertUiThread() + if (!isActive) return@handle // don't continue if job was cancelled + val currentValue = mPayment.value + if (currentValue?.orderId != orderId) + { + Log.d(TAG, "Ignoring stale check result for order $orderId") + return@handle } - is CheckPaymentResponse.Paid -> { - mPayment.value = currentValue.copy(paid = true) - checkTimer.cancel() + val updated = + currentValue.copy( + statusLoading = false, + merchantName = response.paymentDetails?.merchant?.name ?: currentValue.merchantName, + payDeadline = response.paymentDetails?.payDeadline ?: currentValue.payDeadline, + ) + when (response) + { + is CheckPaymentResponse.Unpaid -> + { + val upgradedUri = upgradeTalerPayUriToHttps(response.talerPayUri) + mPayment.value = + when + { + upgradedUri == null -> + { + updated.copy( + talerPayUri = response.talerPayUri, + insecureTalerPayUri = null, + ) + } + + currentValue.httpsUpgradeAccepted -> + { + updated.copy( + talerPayUri = upgradedUri, + insecureTalerPayUri = null, + ) + } + + currentValue.insecureUriAccepted -> + { + updated.copy( + talerPayUri = response.talerPayUri, + insecureTalerPayUri = null, + ) + } + + else -> + { + updated.copy( + talerPayUri = null, + insecureTalerPayUri = response.talerPayUri, + ) + } + } + } + + is CheckPaymentResponse.Claimed -> + { + mPayment.value = updated.copy(claimed = true) + } + + is CheckPaymentResponse.Paid -> + { + mPayment.value = updated.copy(paid = true) + checkTimer.cancel() + } } } } - } @UiThread - fun tryHttpsPaymentUri() { + fun tryHttpsPaymentUri() + { val currentValue = mPayment.value ?: return val insecureUri = currentValue.insecureTalerPayUri ?: return val upgradedUri = upgradeTalerPayUriToHttps(insecureUri) ?: return - mPayment.value = currentValue.copy( - talerPayUri = upgradedUri, - insecureTalerPayUri = null, - httpsUpgradeAccepted = true, - insecureUriAccepted = false, - ) + mPayment.value = + currentValue.copy( + talerPayUri = upgradedUri, + insecureTalerPayUri = null, + httpsUpgradeAccepted = true, + insecureUriAccepted = false, + ) } @UiThread - fun continueWithInsecurePaymentUri() { + fun continueWithInsecurePaymentUri() + { val currentValue = mPayment.value ?: return val insecureUri = currentValue.insecureTalerPayUri ?: return - mPayment.value = currentValue.copy( - talerPayUri = insecureUri, - insecureTalerPayUri = null, - httpsUpgradeAccepted = false, - insecureUriAccepted = true, - ) + mPayment.value = + currentValue.copy( + talerPayUri = insecureUri, + insecureTalerPayUri = null, + httpsUpgradeAccepted = false, + insecureUriAccepted = true, + ) } - private fun onNetworkError(error: String) { + private fun onNetworkError(error: String) + { assertUiThread() Log.d(TAG, "Network error: $error") cancelPayment(error) } - private fun looksLikeInventoryError(error: String): Boolean { + private fun looksLikeInventoryError(error: String): Boolean + { val normalized = error.lowercase() return "inventory" in normalized || "stock" in normalized || @@ -248,14 +359,27 @@ class PaymentManager( } @UiThread - fun cancelPayment(error: String? = null) { + fun onOrderDeleted(orderId: String) + { + if (mPayment.value?.orderId != orderId) return + stopPaymentChecks() + mPayment.value = null + } + + fun cancelPayment(error: String? = null) + { stopPaymentChecks() val merchantConfig = configManager.merchantConfig!! mPayment.value?.let { payment -> - if (!payment.paid) payment.orderId?.let { orderId -> - Log.d(TAG, "Deleting cancelled and unpaid order $orderId") - scope.launch { - api.deleteOrder(merchantConfig, orderId) + // A checkout order records cash or card already taken, so it stays for resuming. + if (!payment.paid && + payment.checkoutId == null) + { + payment.orderId?.let { orderId -> + Log.d(TAG, "Deleting cancelled and unpaid order $orderId") + scope.launch { + api.deleteOrder(merchantConfig, orderId) + } } } } @@ -264,12 +388,16 @@ class PaymentManager( } } - private val mDeleteNeedsForce = MutableLiveData<Boolean?>(null) - val deleteNeedsForce: LiveData<Boolean?> = mDeleteNeedsForce - @UiThread - fun tryDeleteOrder() { + fun tryDeleteOrder() + { stopPaymentChecks() + if (mPayment.value?.checkoutId != null) + { + // Leave the checkout order alone and simply go back to the checkout. + mDeleteNeedsForce.value = false + return + } val merchantConfig = configManager.merchantConfig!! val orderId = mPayment.value?.orderId ?: return scope.launch { @@ -289,7 +417,8 @@ class PaymentManager( } @UiThread - fun forceDeleteOrder() { + fun forceDeleteOrder() + { val merchantConfig = configManager.merchantConfig!! val orderId = mPayment.value?.orderId ?: return Log.d(TAG, "Force deleting claimed order $orderId") @@ -303,11 +432,13 @@ class PaymentManager( mDeleteNeedsForce.value = null } - fun clearDeleteNeedsForce() { + fun clearDeleteNeedsForce() + { mDeleteNeedsForce.value = null } - private fun stopPaymentChecks() { + private fun stopPaymentChecks() + { checkTimer.cancel() checkJob?.cancel() checkJob = null diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/payment/PaymentSuccessFragment.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/payment/PaymentSuccessFragment.kt @@ -17,6 +17,10 @@ package net.taler.merchantpos.payment import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup + +import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -32,6 +36,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.res.painterResource @@ -40,27 +45,23 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.fragment.app.Fragment -import net.taler.merchantpos.R + import net.taler.merchantpos.MainActivity +import net.taler.merchantpos.R import net.taler.merchantpos.compose.PosTheme -import androidx.compose.foundation.Image -import androidx.compose.ui.graphics.ColorFilter - -class PaymentSuccessFragment : Fragment() { +class PaymentSuccessFragment : Fragment() +{ override fun onCreateView( - inflater: android.view.LayoutInflater, - container: android.view.ViewGroup?, + inflater: LayoutInflater, + container: ViewGroup?, savedInstanceState: Bundle?, ) = ComposeView(requireContext()).apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { PaymentSuccessScreen( onContinue = { - (requireActivity() as MainActivity).apply { - navigateBack() - navigateBack() - } + (requireActivity() as MainActivity).returnToOrderEntry() }, ) } @@ -68,9 +69,8 @@ class PaymentSuccessFragment : Fragment() { } @Composable -private fun PaymentSuccessScreen( - onContinue: () -> Unit, -) { +private fun PaymentSuccessScreen(onContinue: () -> Unit) +{ PosTheme { BoxWithConstraints( modifier = Modifier.fillMaxSize(), @@ -80,23 +80,27 @@ private fun PaymentSuccessScreen( val iconSize = (maxHeight * 0.22f).coerceIn(84.dp, 164.dp) val buttonMinHeight = (maxHeight * 0.11f).coerceIn(52.dp, 72.dp) val buttonWidthFraction = if (maxWidth < 600.dp) 0.78f else 0.6f - val titleStyle = when { - maxHeight < 560.dp -> MaterialTheme.typography.titleMedium - maxHeight < 720.dp -> MaterialTheme.typography.titleLarge - else -> MaterialTheme.typography.headlineMedium - } + val titleStyle = + when + { + maxHeight < 560.dp -> MaterialTheme.typography.titleMedium + maxHeight < 720.dp -> MaterialTheme.typography.titleLarge + else -> MaterialTheme.typography.headlineMedium + } Column( - modifier = Modifier - .fillMaxSize() - .padding(vertical = verticalPadding, horizontal = 24.dp), + modifier = + Modifier + .fillMaxSize() + .padding(vertical = verticalPadding, horizontal = 24.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.SpaceBetween, ) { Box( - modifier = Modifier - .fillMaxWidth() - .weight(1f), + modifier = + Modifier + .fillMaxWidth() + .weight(1f), contentAlignment = Alignment.Center, ) { Column( @@ -121,9 +125,10 @@ private fun PaymentSuccessScreen( } Button( onClick = onContinue, - modifier = Modifier - .fillMaxWidth(buttonWidthFraction) - .heightIn(min = buttonMinHeight), + modifier = + Modifier + .fillMaxWidth(buttonWidthFraction) + .heightIn(min = buttonMinHeight), ) { Text( text = stringResource(R.string.payment_back_button), @@ -136,6 +141,7 @@ private fun PaymentSuccessScreen( } @Composable -internal fun PaymentSuccessScreenContent() { +internal fun PaymentSuccessScreenContent() +{ PaymentSuccessScreen(onContinue = {}) } diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/payment/ProcessPaymentFragment.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/payment/ProcessPaymentFragment.kt @@ -17,45 +17,62 @@ package net.taler.merchantpos.payment import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup + +import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.luminance import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels -import androidx.compose.runtime.livedata.observeAsState + +import net.taler.common.Timestamp import net.taler.lib.android.AnimatedQrCodeComposable +import net.taler.lib.android.AnimatedQrCodeFrame import net.taler.lib.android.TalerNfcService.Companion.hasNfc import net.taler.lib.android.copyToClipBoard import net.taler.lib.android.shareText @@ -63,37 +80,81 @@ import net.taler.merchantpos.MainActivity import net.taler.merchantpos.MainViewModel import net.taler.merchantpos.PosDestination import net.taler.merchantpos.R +import net.taler.merchantpos.checkout.CheckoutResult +import net.taler.merchantpos.checkout.RevertResult import net.taler.merchantpos.compose.PosTheme +import net.taler.merchantpos.devices.TalerBleStatus +import net.taler.merchantpos.devices.TalerUsbStatus import net.taler.merchantpos.showPosError -class ProcessPaymentFragment : Fragment() { +import java.text.DateFormat +import java.util.Date + +import kotlinx.coroutines.launch +class ProcessPaymentFragment : Fragment() +{ private val model: MainViewModel by activityViewModels() private val paymentManager by lazy { model.paymentManager } - private var deviceHasNfc = false override fun onCreateView( - inflater: android.view.LayoutInflater, - container: android.view.ViewGroup?, + inflater: LayoutInflater, + container: ViewGroup?, savedInstanceState: Bundle?, ) = ComposeView(requireContext()).apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { + val scope = rememberCoroutineScope() val payment by paymentManager.payment.observeAsState() + val usb by model.talerUsb.state.observeAsState() + val bluetooth by model.talerBluetooth.state.observeAsState() val deleteNeedsForce by paymentManager.deleteNeedsForce.observeAsState() payment?.let { ProcessPaymentScreen( payment = it, - deviceHasNfc = deviceHasNfc, + deviceHasNfc = + deviceHasNfc || bluetooth?.status == TalerBleStatus.Ready || ( + usb?.enabled == true && + usb?.devices?.any { it.status == TalerUsbStatus.Ready } == true + ), showForceDeleteDialog = deleteNeedsForce == true, onTryHttps = paymentManager::tryHttpsPaymentUri, onContinueAsIs = paymentManager::continueWithInsecurePaymentUri, onCancelInsecurePayment = paymentManager::tryDeleteOrder, onCancel = { - if (it.claimed) { + if (it.checkoutId != null) + { + scope.launch { + when (val result = model.checkoutManager.revertTalerStep()) + { + RevertResult.Reverted -> + { + paymentManager.cancelPayment() + (requireActivity() as MainActivity).returnToCheckout() + } + + RevertResult.AlreadyPaid -> + { + (requireActivity() as MainActivity).navigateTo(PosDestination.PaymentSuccess) + } + + is RevertResult.Failed -> + { + requireActivity().showPosError( + getString(R.string.checkout_revert_failed), + result.message, + ) + } + } + } + } + else if (it.claimed) + { paymentManager.tryDeleteOrder() - } else { + } + else + { onPaymentCancel() } }, @@ -105,12 +166,59 @@ class ProcessPaymentFragment : Fragment() { onCopy = { uri -> copyToClipBoard(requireContext(), "Payment URI", uri) }, + // For a checkout this step is reversible, so it is not a cancellation. + cancelLabel = + stringResource( + if (it.checkoutId != null) + { + R.string.checkout_back_to_options + } + else + { + R.string.payment_cancel + }, + ), + onSelfCollect = + if (it.selfCollect) + { + { + scope.launch { + when (val result = model.checkoutManager.collectByMerchant()) + { + CheckoutResult.Completed -> + { + (requireActivity() as MainActivity).navigateTo( + PosDestination.PaymentSuccess, + ) + } + + is CheckoutResult.Failed -> + { + requireActivity().showPosError( + getString(R.string.error_payment), + result.message, + ) + } + + is CheckoutResult.AwaitingWallet -> + { + Unit + } + } + } + } + } + else + { + null + }, ) } // Navigate back on successful (non-force) delete LaunchedEffect(deleteNeedsForce) { - if (deleteNeedsForce == false) { + if (deleteNeedsForce == false) + { paymentManager.clearDeleteNeedsForce() (requireActivity() as MainActivity).navigateBack() } @@ -118,56 +226,103 @@ class ProcessPaymentFragment : Fragment() { } } - override fun onViewCreated(view: android.view.View, savedInstanceState: Bundle?) { + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) + { deviceHasNfc = hasNfc(requireContext()) paymentManager.payment.observe(viewLifecycleOwner) { payment -> onPaymentStateChanged(payment) } } - override fun onDestroy() { - super.onDestroy() + override fun onStart() + { + super.onStart() + updateUsbUri(paymentManager.payment.value) + } + + override fun onStop() + { + model.talerUsb.dismiss(this) + model.talerBluetooth.dismiss(this) + super.onStop() + } + + private fun updateUsbUri(payment: Payment?) + { + val uri = + payment + ?.takeIf { + !it.claimed && !it.paid && it.error == null + }?.talerPayUri + model.talerUsb.present(this, uri) + model.talerBluetooth.present(this, uri) } - private fun onPaymentStateChanged(payment: Payment) { - if (payment.error != null) { + private fun onPaymentStateChanged(payment: Payment?) + { + updateUsbUri(payment) + payment ?: return + if (payment.error != null) + { val (mainText, detailText) = getPaymentErrorDisplay(payment) requireActivity().showPosError(mainText, detailText) (requireActivity() as MainActivity).navigateBack() return } - if (payment.paid) { - model.orderManager.onOrderPaid(payment.order.id) + if (payment.paid) + { + val orderId = payment.orderId + if (payment.checkoutId != null && + orderId != null) + { + model.checkoutManager.onBackendOrderPaid(orderId) + } + else + { + model.orderManager.onOrderPaid(payment.order.id) + } (requireActivity() as MainActivity).navigateTo(PosDestination.PaymentSuccess) } } - private fun onPaymentCancel() { + private fun onPaymentCancel() + { paymentManager.cancelPayment() (requireActivity() as MainActivity).navigateBack() } - private fun onForceDeleteOrder() { + private fun onForceDeleteOrder() + { paymentManager.forceDeleteOrder() (requireActivity() as MainActivity).navigateBack() } - private fun getPaymentErrorDisplay(payment: Payment): Pair<String, String> { + private fun getPaymentErrorDisplay(payment: Payment): Pair<String, String> + { val error = payment.error.orEmpty() - if (payment.orderId != null) { + if (payment.orderId != null) + { return getString(R.string.error_payment) to error } val normalized = error.lowercase() - return when { + 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 + } } } } @@ -185,8 +340,13 @@ private fun ProcessPaymentScreen( onDismissForceDelete: () -> Unit, onShare: (String) -> Unit, onCopy: (String) -> Unit, -) { - if (showForceDeleteDialog) { + /** Offered only when the merchant reached this from a fully settled checkout. */ + onSelfCollect: (() -> Unit)?, + cancelLabel: String, +) +{ + if (showForceDeleteDialog) + { AlertDialog( onDismissRequest = onDismissForceDelete, title = { Text(stringResource(R.string.force_delete_dialog_title)) }, @@ -194,9 +354,10 @@ private fun ProcessPaymentScreen( confirmButton = { Button( onClick = onForceDelete, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.error, - ), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + ), ) { Text(stringResource(R.string.force_delete_dialog_confirm)) } @@ -207,7 +368,9 @@ private fun ProcessPaymentScreen( } }, ) - } else if (payment.insecureTalerPayUri != null) { + } + else if (payment.insecureTalerPayUri != null) + { AlertDialog( onDismissRequest = {}, title = { Text(stringResource(R.string.insecure_payment_dialog_title)) }, @@ -232,9 +395,10 @@ private fun ProcessPaymentScreen( OutlinedButton( onClick = onCancelInsecurePayment, modifier = Modifier.fillMaxWidth(), - colors = ButtonDefaults.outlinedButtonColors( - contentColor = MaterialTheme.colorScheme.error, - ), + colors = + ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), ) { Text(stringResource(R.string.insecure_payment_dialog_cancel_order)) } @@ -244,250 +408,458 @@ private fun ProcessPaymentScreen( } PosTheme { - val introText = if (payment.claimed) { - stringResource(R.string.payment_claimed) - } else if (deviceHasNfc && payment.talerPayUri != null) { - stringResource(R.string.payment_intro_nfc) - } else { - stringResource(R.string.payment_intro) + val introText = + when + { + payment.claimed -> stringResource(R.string.checkout_complete_in_wallet) + payment.selfCollect -> stringResource(R.string.checkout_collect_receipt_title) + deviceHasNfc && payment.talerPayUri != null -> stringResource(R.string.payment_intro_nfc) + else -> stringResource(R.string.checkout_scan_qr_prompt) + } + val isTablet = LocalConfiguration.current.smallestScreenWidthDp >= 600 + Surface(Modifier.fillMaxSize()) { + if (payment.statusLoading) + { + Column( + modifier = Modifier.fillMaxSize().padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically), + ) { + CircularProgressIndicator() + Text(stringResource(R.string.order_details_loading), textAlign = TextAlign.Center) + OutlinedButton(onClick = onCancel) { Text(cancelLabel) } + } + return@Surface + } + BoxWithConstraints(Modifier.fillMaxSize()) { + if (maxWidth > maxHeight) + { + LandscapePaymentScreen( + payment, + introText, + isTablet, + onCancel, + onShare, + onCopy, + onSelfCollect, + cancelLabel, + ) + } + else + { + PortraitPaymentScreen( + payment, + introText, + isTablet, + onCancel, + onShare, + onCopy, + onSelfCollect, + cancelLabel, + ) + } + } } - val isTabletLayout = LocalConfiguration.current.smallestScreenWidthDp >= 720 + } +} - if (isTabletLayout) { - TabletProcessPaymentScreen(payment, introText, onCancel, onShare, onCopy) - } else { - PhoneProcessPaymentScreen(payment, introText, onCancel, onShare, onCopy) +@Composable +private fun PaymentQrContent( + payment: Payment, + modifier: Modifier = Modifier, +) +{ + if (payment.claimed) + { + AnimatedQrCodeFrame(modifier) { + Column( + modifier = + Modifier + .padding(24.dp) + .verticalScroll(rememberScrollState()) + .semantics { liveRegion = LiveRegionMode.Polite }, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + // The QR frame stays light in both themes. + Text( + stringResource(R.string.checkout_order_claimed), + style = MaterialTheme.typography.titleMedium, + color = Color(0xFF1C1B1F), + textAlign = TextAlign.Center, + ) + Text( + stringResource(R.string.checkout_waiting_customer_payment), + style = MaterialTheme.typography.titleMedium, + color = Color(0xFF1C1B1F), + textAlign = TextAlign.Center, + ) + } + } + } + else + { + val payUri = payment.talerPayUri + if (payUri == null) + { + Box(modifier, contentAlignment = Alignment.Center) { + CircularProgressIndicator(Modifier.size(48.dp)) + } + } + else + { + AnimatedQrCodeComposable( + link = payUri, + logoPainter = painterResource(R.drawable.ic_taler_logo_qr), + modifier = modifier, + ) } } } @Composable -private fun TabletProcessPaymentScreen( - payment: Payment, - introText: String, - onCancel: () -> Unit, - onShare: (String) -> Unit, - onCopy: (String) -> Unit, -) { +private fun PaymentBrandHeading(compact: Boolean = false) +{ Row( - modifier = Modifier - .fillMaxSize() - .statusBarsPadding() - .padding(16.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, ) { - Column( - modifier = Modifier - .weight(0.54f) - .fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(12.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Box( - modifier = Modifier - .fillMaxWidth() - .weight(1f), - contentAlignment = Alignment.Center, - ) { - if (!payment.claimed) { - BoxWithConstraints( - modifier = Modifier - .fillMaxSize() - .padding(12.dp), - contentAlignment = Alignment.Center, - ) { - val payUri = payment.talerPayUri - val qrSize = minOf(maxWidth, maxHeight) - Box( - modifier = Modifier.size(qrSize), - contentAlignment = Alignment.Center, - ) { - if (payUri == null) { - CircularProgressIndicator() - } else { - AnimatedQrCodeComposable( - link = payUri, - logoPainter = painterResource(R.drawable.ic_taler_logo_qr), - modifier = Modifier.fillMaxSize(), - ) - } - } - } + Text( + stringResource(R.string.payment_pay_with), + style = if (compact) MaterialTheme.typography.titleMedium else MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + Image( + painter = painterResource(R.drawable.ic_taler_logo_brand_on_light), + contentDescription = stringResource(R.string.payment_brand_name), + modifier = + Modifier.size( + width = if (compact) 72.dp else 80.dp, + height = if (compact) 32.dp else 36.dp, + ), + colorFilter = + if (MaterialTheme.colorScheme.surface.luminance() < 0.5f) + { + ColorFilter.tint(Color.White) } + else + { + null + }, + ) + } +} + +@Composable +private fun PaymentDetailRow( + label: String, + value: String, + amount: Boolean = false, + compact: Boolean = false, +) +{ + if (compact && + LocalDensity.current.fontScale > 1.2f) + { + Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text( + value, + style = if (amount) MaterialTheme.typography.headlineSmall else MaterialTheme.typography.bodyMedium, + fontWeight = if (amount) FontWeight.Bold else FontWeight.Normal, + ) + } + return + } + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.Top, + ) { + Text( + label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.width(if (compact) 68.dp else 100.dp), + ) + Text( + value, + style = if (amount) MaterialTheme.typography.headlineSmall else MaterialTheme.typography.bodyMedium, + fontWeight = if (amount) FontWeight.Bold else FontWeight.Normal, + modifier = Modifier.weight(1f), + ) + } +} + +@Composable +private fun PaymentDeadline(payment: Payment) +{ + payment.payDeadline?.takeUnless { it == Timestamp.never() }?.let { deadline -> + val locale = LocalConfiguration.current.locales[0] + val formatted = + remember(deadline, locale) { + DateFormat + .getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT, locale) + .format(Date(deadline.ms)) } + Text( + stringResource(R.string.checkout_payment_possible_until, formatted), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } +} - if (!payment.claimed) payment.talerPayUri?.let { payUri -> - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), +@Composable +private fun PaymentActions( + payment: Payment, + onCancel: () -> Unit, + onShare: (String) -> Unit, + onCopy: (String) -> Unit, + onSelfCollect: (() -> Unit)?, + cancelLabel: String, +) +{ + Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { + payment.talerPayUri?.let { payUri -> + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Button(onClick = { onShare(payUri) }, modifier = Modifier.weight(1f).heightIn(min = 48.dp)) { + Text(stringResource(R.string.share)) + } + OutlinedButton( + onClick = { onCopy(payUri) }, + modifier = Modifier.weight(1f).heightIn(min = 48.dp), + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.primary), ) { - Button(onClick = { onShare(payUri) }, modifier = Modifier.weight(1f)) { - Text(stringResource(R.string.share)) - } - Button(onClick = { onCopy(payUri) }, modifier = Modifier.weight(1f)) { - Text(stringResource(R.string.copy)) - } + Text(stringResource(R.string.copy)) } } } + onSelfCollect?.let { collect -> + Button(onClick = collect, modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)) { + Text(stringResource(R.string.checkout_collect_receipt_on_device)) + } + } + OutlinedButton( + onClick = onCancel, + modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp), + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.primary), + ) { + Text(cancelLabel) + } + } +} +@Composable +private fun PortraitPaymentScreen( + payment: Payment, + introText: String, + isTablet: Boolean, + onCancel: () -> Unit, + onShare: (String) -> Unit, + onCopy: (String) -> Unit, + onSelfCollect: (() -> Unit)?, + cancelLabel: String, +) +{ + BoxWithConstraints(Modifier.fillMaxSize()) { + val horizontalPadding = if (isTablet) 48.dp else 24.dp + val qrSize = + if (isTablet) + { + minOf(maxWidth * 0.56f, maxHeight * 0.44f, 420.dp) + } + else + { + minOf(maxWidth - horizontalPadding * 2, 320.dp) + } Column( - modifier = Modifier - .weight(0.46f) - .fillMaxSize() - .padding(horizontal = 8.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = horizontalPadding, vertical = 16.dp), horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), ) { + if (!payment.selfCollect) PaymentBrandHeading() + Text(introText, style = MaterialTheme.typography.bodyMedium, textAlign = TextAlign.Center) Text( - text = introText, - style = MaterialTheme.typography.headlineSmall, - textAlign = TextAlign.Center, - ) - Text( - text = payment.order.total.toString(), - style = MaterialTheme.typography.headlineMedium, + text = + if (payment.selfCollect) + { + stringResource(R.string.checkout_receipt_paid_in_full) + } + else + { + payment.order.total.toString() + }, + style = MaterialTheme.typography.headlineLarge, + fontWeight = FontWeight.Bold, textAlign = TextAlign.Center, ) - payment.orderId?.let { - Text( - text = stringResource(R.string.payment_order_id, it), - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center, - ) + PaymentQrContent(payment, Modifier.size(qrSize)) + PaymentDeadline(payment) + if (payment.selfCollect) + { + Text(stringResource(R.string.checkout_receipt_scan_hint), textAlign = TextAlign.Center) } - Spacer(modifier = Modifier.weight(1f)) - OutlinedButton( - onClick = onCancel, - modifier = Modifier.fillMaxWidth(), - colors = ButtonDefaults.outlinedButtonColors( - containerColor = MaterialTheme.colorScheme.error, - contentColor = MaterialTheme.colorScheme.onError, - ), - ) { - Text(stringResource(R.string.payment_cancel)) + payment.orderId?.let { id -> + if (isTablet) + { + PaymentDetailRow(stringResource(R.string.checkout_payment_detail_order), "#$id") + } + else + { + Text(stringResource(R.string.payment_order_id, id), textAlign = TextAlign.Center) + } } + if (payment.summary.isNotBlank()) + { + if (isTablet) + { + PaymentDetailRow(stringResource(R.string.checkout_payment_detail_summary), payment.summary) + } + else + { + Text( + payment.summary, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + } + PaymentActions(payment, onCancel, onShare, onCopy, onSelfCollect, cancelLabel) } } } @Composable -private fun PhoneProcessPaymentScreen( +private fun LandscapePaymentScreen( payment: Payment, introText: String, + isTablet: Boolean, onCancel: () -> Unit, onShare: (String) -> Unit, onCopy: (String) -> Unit, -) { - Row( - modifier = Modifier - .fillMaxSize() - .statusBarsPadding() - .padding(16.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { + onSelfCollect: (() -> Unit)?, + cancelLabel: String, +) +{ + BoxWithConstraints(Modifier.fillMaxSize()) { + val bodyHeight = + minOf( + (maxHeight - 128.dp * LocalDensity.current.fontScale).coerceAtLeast(0.dp), + 460.dp, + ) Column( - modifier = Modifier - .weight(0.5f) - .fillMaxSize(), - verticalArrangement = Arrangement.Center, + modifier = + Modifier + .fillMaxSize() + .padding( + horizontal = if (isTablet) 48.dp else 24.dp, + vertical = 16.dp, + ), horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(if (isTablet) 24.dp else 12.dp, Alignment.CenterVertically), ) { - if (!payment.claimed) { - Box( - modifier = Modifier - .fillMaxWidth() - .weight(1f), - contentAlignment = Alignment.Center, + if (isTablet) + { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically), + ) { + if (!payment.selfCollect) PaymentBrandHeading() + Text(introText, style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Center) + } + } + Row( + modifier = + if (isTablet) + { + Modifier.fillMaxWidth().height(bodyHeight) + } + else + { + Modifier.weight(1f).fillMaxWidth() + }, + horizontalArrangement = Arrangement.spacedBy(if (isTablet) 48.dp else 32.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier.weight(if (isTablet) 1f else 0.85f).fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically), ) { BoxWithConstraints( - modifier = Modifier - .fillMaxSize() - .padding(12.dp), + Modifier.weight(1f, fill = false).fillMaxWidth(), contentAlignment = Alignment.Center, ) { - val payUri = payment.talerPayUri - val qrSize = minOf(maxWidth, maxHeight) - Box( - modifier = Modifier.size(qrSize), - contentAlignment = Alignment.Center, - ) { - if (payUri == null) { - CircularProgressIndicator() - } else { - AnimatedQrCodeComposable( - link = payUri, - logoPainter = painterResource(R.drawable.ic_taler_logo_qr), - modifier = Modifier.fillMaxSize(), - ) - } - } + val qrSize = minOf(maxWidth, maxHeight, if (isTablet) 420.dp else 360.dp) + PaymentQrContent(payment, Modifier.size(qrSize)) } + PaymentDeadline(payment) } - } - } - - Column( - modifier = Modifier - .weight(0.5f) - .fillMaxSize() - .padding(horizontal = 8.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - text = introText, - style = MaterialTheme.typography.headlineSmall, - textAlign = TextAlign.Center, - ) - Text( - text = payment.order.total.toString(), - style = MaterialTheme.typography.headlineMedium, - textAlign = TextAlign.Center, - ) - payment.orderId?.let { - Text( - text = stringResource(R.string.payment_order_id, it), - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center, - ) - } - Spacer(modifier = Modifier.height(8.dp)) - payment.talerPayUri?.let { payUri -> - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), + Column( + modifier = Modifier.weight(1f).verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(if (isTablet) 20.dp else 12.dp), ) { - Button( - onClick = { onShare(payUri) }, - modifier = Modifier.weight(1f), - ) { - Text(stringResource(R.string.share)) + if (!isTablet) + { + if (!payment.selfCollect) PaymentBrandHeading(compact = true) + Text(introText, style = MaterialTheme.typography.bodyMedium) } - OutlinedButton( - onClick = { onCopy(payUri) }, - modifier = Modifier.weight(1f), - ) { - Text(stringResource(R.string.copy)) + payment.orderId?.let { id -> + PaymentDetailRow( + stringResource(R.string.checkout_payment_detail_order), + "#$id", + compact = !isTablet, + ) + } + if (payment.summary.isNotBlank()) + { + PaymentDetailRow( + stringResource(R.string.checkout_payment_detail_summary), + payment.summary, + compact = !isTablet, + ) + } + if (payment.selfCollect) + { + Text( + stringResource(R.string.checkout_receipt_paid_in_full), + style = MaterialTheme.typography.headlineSmall, + ) + Text(stringResource(R.string.checkout_receipt_scan_hint)) + } + else + { + PaymentDetailRow( + stringResource(R.string.amount_entry_label), + payment.order.total.toString(), + amount = true, + compact = !isTablet, + ) } + if (isTablet) + { + payment.merchantName?.takeIf { it.isNotBlank() }?.let { name -> + PaymentDetailRow(stringResource(R.string.checkout_payment_detail_merchant), name) + } + } + PaymentActions(payment, onCancel, onShare, onCopy, onSelfCollect, cancelLabel) } } - OutlinedButton( - onClick = onCancel, - modifier = Modifier.fillMaxWidth(), - colors = ButtonDefaults.outlinedButtonColors( - containerColor = MaterialTheme.colorScheme.error, - contentColor = MaterialTheme.colorScheme.onError, - ), - ) { - Text(stringResource(R.string.payment_cancel)) - } } } } @Composable -internal fun PaymentScreenContent(payment: Payment) { +internal fun PaymentScreenContent(payment: Payment) +{ ProcessPaymentScreen( payment = payment, deviceHasNfc = false, @@ -500,5 +872,10 @@ internal fun PaymentScreenContent(payment: Payment) { onDismissForceDelete = {}, onShare = {}, onCopy = {}, + onSelfCollect = if (payment.selfCollect) ({}) else null, + cancelLabel = + stringResource( + if (payment.checkoutId != null) R.string.checkout_back_to_options else R.string.payment_cancel, + ), ) } diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/paymentoptions/BuiltInOptions.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/paymentoptions/BuiltInOptions.kt @@ -0,0 +1,155 @@ +/* + * 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.paymentoptions + +import android.content.Context +import android.nfc.NfcAdapter + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Payments +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource + +import net.taler.merchantpos.R +import net.taler.merchantpos.devices.TalerBleStatus +import net.taler.merchantpos.devices.TalerBluetoothDevice +import net.taler.merchantpos.devices.TalerDevicesContent +import net.taler.merchantpos.devices.TalerUsbDevices +import net.taler.merchantpos.devices.TalerUsbStatus +import net.taler.merchantpos.devices.label + +/** Taler is always the last payment of a checkout, so others look it up by id. */ +const val TALER_OPTION_ID = "taler" + +/** + * QR payments remain available regardless of NFC or external display status. + */ +class TalerOption( + private val context: Context, + private val usb: TalerUsbDevices? = null, + private val bluetooth: TalerBluetoothDevice? = null, +) : PaymentOption +{ + override val id = TALER_OPTION_ID + override val nameRes = R.string.payment_option_taler + override val descriptionRes = R.string.payment_option_taler_description + override val enabledByDefault = true + override val alwaysEnabled = true + override val hasSettings get() = usb != null && bluetooth != null + override val settingsTitleRes = R.string.taler_devices_manage + + override val icon: ImageVector + @Composable get() = ImageVector.vectorResource(R.drawable.ic_taler_logo) + + override val logo: ImageVector + @Composable get() = ImageVector.vectorResource(R.drawable.ic_taler_logo_full) + + override fun probe(): PaymentOptionState + { + usb?.refresh() + return PaymentOptionState( + id = id, + enabled = true, + status = PaymentOptionStatus.Ready, + devices = + listOfNotNull(nfcDevice(), bluetoothDevice()) + + usb + ?.state + ?.value + ?.takeIf { it.enabled } + ?.devices + .orEmpty() + .mapIndexed { index, device -> + PaymentDevice( + id = "usb:${device.id}", + name = context.getString(R.string.taler_usb_device, index + 1), + connected = device.status == TalerUsbStatus.Ready, + statusRes = + when (device.status) + { + TalerUsbStatus.Ready -> null + TalerUsbStatus.PermissionRequired -> R.string.taler_usb_permission_required + TalerUsbStatus.Failed -> R.string.taler_usb_failed + }, + ) + }, + ) + } + + override fun onLogout() + { + usb?.logout() + bluetooth?.logout() + } + + private fun bluetoothDevice(): PaymentDevice? + { + val state = bluetooth?.state?.value?.takeIf { it.enabled } ?: return null + val selected = state.selected ?: return null + return PaymentDevice( + id = "ble:${selected.address}", + name = selected.name, + connected = state.status in setOf(TalerBleStatus.Ready, TalerBleStatus.Sending, TalerBleStatus.TooLong), + statusRes = state.status.label(), + ) + } + + @Composable + override fun DetailContent( + state: PaymentOptionState, + actions: PaymentOptionActions, + ) + { + if (bluetooth != null && usb != null) + { + TalerDevicesContent(bluetooth, usb) + } + } + + private fun nfcDevice(): PaymentDevice? + { + // A tablet without NFC hardware has nothing to report; the QR code still works. + val adapter = NfcAdapter.getDefaultAdapter(context) ?: return null + val enabled = adapter.isEnabled + return PaymentDevice( + id = "nfc", + nameRes = R.string.payment_device_nfc, + connected = enabled, + statusRes = + if (enabled) + { + R.string.payment_device_nfc_available + } + else + { + R.string.payment_option_disabled + }, + ) + } +} + +/** Taking cash at the counter. Settled outside the app, so it only records the choice. */ +object CashOption : PaymentOption +{ + override val id = "cash" + override val nameRes = R.string.payment_option_cash + override val descriptionRes = R.string.payment_option_cash_description + + override val icon: ImageVector + @Composable get() = Icons.Default.Payments +} diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/paymentoptions/ExternalCharge.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/paymentoptions/ExternalCharge.kt @@ -0,0 +1,44 @@ +/* + * 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.paymentoptions + +import net.taler.common.Amount + +data class ExternalCharge( + /** Stable reference used to reconcile this attempt with the provider. */ + val reference: String, + val amount: Amount, + val title: String, +) + +sealed interface ChargeResult +{ + data class Success( + val transactionCode: String?, + ) : ChargeResult + + data object Cancelled : ChargeResult + + data class Failed( + val message: String?, + ) : ChargeResult + + /** Requires reconciliation; do not treat as success or failure. */ + data class Unknown( + val message: String?, + ) : ChargeResult +} diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/paymentoptions/PaymentOption.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/paymentoptions/PaymentOption.kt @@ -0,0 +1,159 @@ +/* + * 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.paymentoptions + +import android.net.Uri + +import androidx.activity.ComponentActivity +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.vector.ImageVector + +import net.taler.common.Amount +import net.taler.merchantpos.R + +enum class PaymentOptionStatus +{ + Disabled, + Unavailable, + NotConfigured, + SignInRequired, + PermissionRequired, + NotPaired, + Disconnected, + + /** Payments are available without an external device connection. */ + Ready, + + Connected, +} + +data class PaymentDevice( + /** Stable across device status updates. */ + val id: String, + val name: String? = null, + /** Fallback when [name] is absent. */ + @StringRes val nameRes: Int? = null, + val serialNumber: String? = null, + val batteryPercent: Int? = null, + val connected: Boolean = false, + /** Device error; does not change payment availability. */ + @StringRes val statusRes: Int? = null, +) + +data class PaymentOptionState( + val id: String, + val enabled: Boolean = false, + val status: PaymentOptionStatus = PaymentOptionStatus.Disabled, + val account: String? = null, + /** Includes disconnected devices. */ + val devices: List<PaymentDevice> = emptyList(), +) + +class PaymentOptionActions( + val setEnabled: (Boolean) -> Unit, + val refresh: () -> Unit, + val reportFailure: () -> Unit, +) + +/** Payment method implementation and settings UI. */ +interface PaymentOption +{ + /** Stable identifier used in preferences and navigation. */ + val id: String + + @get:StringRes + val nameRes: Int + + @get:StringRes + val descriptionRes: Int + + val icon: ImageVector + @Composable get + + val enabledByDefault: Boolean get() = false + + val alwaysEnabled: Boolean get() = false + + /** + * Tracks the option's own connection. + * Optional displays are reported through [PaymentOptionState.devices]. + */ + val tracksConnection: Boolean get() = false + + /** Full logo for checkout tiles; [icon] is used in lists. */ + val logo: ImageVector? + @Composable get() = null + + val hasSettings: Boolean get() = false + + @get:StringRes + val settingsTitleRes: Int get() = R.string.payment_option_settings_title + + val chargesExternally: Boolean get() = false + + /** Must be acknowledged before enabling the option. */ + @get:StringRes + val activationNoticeRes: Int? get() = null + + val canRefund: Boolean get() = false + + /** + * Returns false if charging cannot start; otherwise calls [onResult] on the main thread. + * Persist the pending charge first: process death can prevent callback delivery. + */ + fun startCharge( + charge: ExternalCharge, + onResult: (ChargeResult) -> Unit, + ): Boolean = false + + /** Whether the required application credentials are configured. */ + fun isConfigured(): Boolean = true + + /** Called only while enabled. Exceptions become [PaymentOptionStatus.Unavailable]. */ + fun probe(): PaymentOptionState = PaymentOptionState(id, true, PaymentOptionStatus.Ready) + + /** Called for every option in [net.taler.merchantpos.MainActivity.onCreate], before STARTED. */ + fun registerLaunchers( + activity: ComponentActivity, + manager: PaymentOptionsManager, + ) = Unit + + /** Called at startup and periodically while the app is in the foreground. */ + suspend fun maintainSession() = Unit + + /** Returns true if this option consumed the redirect. */ + suspend fun handleRedirect(uri: Uri): Boolean = false + + fun canCharge(state: PaymentOptionState): Boolean = state.enabled && state.status == PaymentOptionStatus.Connected + + /** [transactionCode] is the reference returned by the original charge. */ + suspend fun refund( + transactionCode: String?, + amount: Amount, + reason: String, + ): RefundOutcome = RefundOutcome.NotSupported + + /** Clear the session on merchant logout. */ + fun onLogout() = Unit + + @Composable + fun DetailContent( + state: PaymentOptionState, + actions: PaymentOptionActions, + ) = Unit +} diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/paymentoptions/PaymentOptionControls.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/paymentoptions/PaymentOptionControls.kt @@ -0,0 +1,378 @@ +/* + * 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.paymentoptions + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.Contactless +import androidx.compose.material.icons.filled.Link +import androidx.compose.material.icons.filled.LinkOff +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.dp + +import net.taler.merchantpos.R +import net.taler.merchantpos.compose.PosOutlinedCard + +internal val PaymentOptionControlShape = RoundedCornerShape(14.dp) + +@Composable +internal fun statusLabel(status: PaymentOptionStatus): String = + stringResource( + when (status) + { + PaymentOptionStatus.Disabled -> R.string.payment_option_disabled + PaymentOptionStatus.Unavailable -> R.string.terminal_unavailable + PaymentOptionStatus.NotConfigured -> R.string.terminal_not_configured + PaymentOptionStatus.SignInRequired -> R.string.terminal_sign_in_required + PaymentOptionStatus.PermissionRequired -> R.string.terminal_permission_required + PaymentOptionStatus.NotPaired -> R.string.terminal_not_paired + PaymentOptionStatus.Disconnected -> R.string.terminal_disconnected + PaymentOptionStatus.Ready -> R.string.payment_option_ready + PaymentOptionStatus.Connected -> R.string.terminal_connected + }, + ) + +@Composable +fun PaymentOptionsEntry( + summary: PaymentOptionsSummary, + onOpen: () -> Unit, +) +{ + Text( + text = stringResource(R.string.payment_options_enabled_count, summary.enabledCount), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + OutlinedButton( + onClick = onOpen, + modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp), + shape = RoundedCornerShape(28.dp), + ) { + Text(stringResource(R.string.payment_options_manage_action)) + Spacer(Modifier.width(8.dp)) + Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null) + } +} + +@Composable +fun PaymentOptionRow( + option: PaymentOption, + state: PaymentOptionState, + onEnabled: (Boolean) -> Unit, +) +{ + val name = stringResource(option.nameRes) + val configured = option.isConfigured() + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Icon( + imageVector = option.icon, + contentDescription = null, + tint = statusTint(state.status, state.enabled), + ) + Spacer(Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text(name, style = MaterialTheme.typography.titleSmall) + if (option.id != TALER_OPTION_ID || + !configured || + state.status != PaymentOptionStatus.Ready) + { + Text( + text = + if (configured) + { + statusLabel(state.status) + } + else + { + statusLabel(PaymentOptionStatus.NotConfigured) + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (configured) DeviceLines(state.devices, MaterialTheme.typography.bodySmall) + } + if (!option.alwaysEnabled) + { + Switch( + checked = state.enabled, + onCheckedChange = onEnabled, + enabled = configured, + modifier = Modifier.semantics { contentDescription = name }, + ) + } + Spacer(Modifier.width(8.dp)) + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = stringResource(R.string.payment_option_open, name), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun deviceName(device: PaymentDevice): String = + device.name ?: device.nameRes?.let { stringResource(it) } ?: device.id + +@Composable +fun DeviceLines( + devices: List<PaymentDevice>, + style: TextStyle, +) +{ + devices.forEach { device -> + Text( + text = + device.statusRes?.let { "${deviceName(device)} ${stringResource(it)}" } ?: stringResource( + if (device.connected) + { + R.string.payment_device_connected + } + else + { + R.string.payment_device_disconnected + }, + deviceName(device), + ), + style = style, + color = + if (device.connected) + { + MaterialTheme.colorScheme.primary + } + else + { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } +} + +@Composable +fun ActivationNoticeDialog( + @StringRes noticeRes: Int, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) +{ + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.payment_option_activation_title)) }, + text = { Text(stringResource(noticeRes)) }, + confirmButton = { + Button(onClick = onConfirm) { + Text(stringResource(R.string.payment_option_activation_confirm)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } + }, + ) +} + +@Composable +fun PaymentOptionDetails(state: PaymentOptionState) +{ + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (state.id != TALER_OPTION_ID || + state.status != PaymentOptionStatus.Ready) + { + Text(statusLabel(state.status), style = MaterialTheme.typography.bodyMedium) + } + state.account?.let { + Text(stringResource(R.string.terminal_account, it), style = MaterialTheme.typography.bodyMedium) + } + } +} + +@Composable +fun DeviceCard(device: PaymentDevice) +{ + PosOutlinedCard(contentPadding = PaddingValues(16.dp)) { contentPadding -> + Row( + modifier = Modifier.padding(contentPadding), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.Contactless, + contentDescription = null, + tint = + if (device.connected) + { + MaterialTheme.colorScheme.primary + } + else + { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + Spacer(Modifier.width(12.dp)) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text(deviceName(device), style = MaterialTheme.typography.titleSmall) + Text( + text = + stringResource( + device.statusRes ?: if (device.connected) + { + R.string.payment_device_status_connected + } + else + { + R.string.payment_device_status_disconnected + }, + ), + style = MaterialTheme.typography.bodyMedium, + color = + if (device.connected) + { + MaterialTheme.colorScheme.primary + } + else + { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + device.serialNumber?.let { + Text( + text = stringResource(R.string.terminal_serial, it), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + device.batteryPercent?.let { + Text( + text = stringResource(R.string.payment_device_battery, it), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} + +@Composable +private fun statusTint( + status: PaymentOptionStatus, + enabled: Boolean, +) = when +{ + status == PaymentOptionStatus.Connected || status == PaymentOptionStatus.Ready -> + { + MaterialTheme.colorScheme.primary + } + + enabled -> + { + MaterialTheme.colorScheme.error + } + + else -> + { + MaterialTheme.colorScheme.onSurfaceVariant + } +} + +@Composable +fun TerminalStatusIndicator( + status: PaymentOptionStatus, + states: List<Pair<PaymentOption, PaymentOptionState>>, + onOpenOptions: () -> Unit, +) +{ + var showStatus by rememberSaveable { mutableStateOf(false) } + val connected = status == PaymentOptionStatus.Connected + IconButton(onClick = { showStatus = true }) { + Icon( + imageVector = if (connected) Icons.Default.Link else Icons.Default.LinkOff, + contentDescription = stringResource(R.string.terminal_indicator_description, statusLabel(status)), + tint = if (connected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error, + ) + } + if (showStatus) + { + AlertDialog( + onDismissRequest = { showStatus = false }, + title = { Text(stringResource(R.string.payment_options_title)) }, + text = { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + states.forEach { (option, state) -> + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + stringResource(option.nameRes), + style = MaterialTheme.typography.titleSmall, + ) + PaymentOptionDetails(state) + DeviceLines(state.devices, MaterialTheme.typography.bodyMedium) + } + } + } + }, + confirmButton = { + TextButton( + onClick = { + showStatus = false + onOpenOptions() + } + ) { + Text(stringResource(R.string.payment_options_button)) + } + }, + dismissButton = { + TextButton(onClick = { showStatus = false }) { Text(stringResource(R.string.terminal_close)) } + }, + ) + } +} diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/paymentoptions/PaymentOptionDetailFragment.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/paymentoptions/PaymentOptionDetailFragment.kt @@ -0,0 +1,233 @@ +/* + * This file is part of GNU Taler + * (C) 2026 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.merchantpos.paymentoptions + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels + +import net.taler.merchantpos.MainViewModel +import net.taler.merchantpos.R +import net.taler.merchantpos.compose.PosOutlinedCard +import net.taler.merchantpos.compose.PosTheme + +class PaymentOptionDetailFragment : Fragment() +{ + private val viewModel: MainViewModel by activityViewModels() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View = + ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + val manager = viewModel.paymentOptions + val optionId = requireArguments().getString(ARG_OPTION_ID).orEmpty() + val option = manager.option(optionId) + setContent { + val summary by manager.summary.observeAsState(PaymentOptionsSummary()) + if (option == null) + { + PosTheme { + Text( + text = stringResource(R.string.payment_option_unknown), + modifier = Modifier.padding(20.dp), + ) + } + } + else + { + val state = + summary.options.firstOrNull { it.id == optionId } + ?: PaymentOptionState(optionId) + PaymentOptionDetailScreen( + option = option, + state = state, + actions = + PaymentOptionActions( + setEnabled = { manager.setEnabled(optionId, it) }, + refresh = manager::refresh, + reportFailure = manager::reportActionFailure, + ), + ) + } + } + } + + companion object + { + private const val ARG_OPTION_ID = "optionId" + + fun create(optionId: String) = + PaymentOptionDetailFragment().apply { + arguments = bundleOf(ARG_OPTION_ID to optionId) + } + } +} + +@Composable +private fun PaymentOptionDetailScreen( + option: PaymentOption, + state: PaymentOptionState, + actions: PaymentOptionActions, +) +{ + val name = stringResource(option.nameRes) + val configured = option.isConfigured() + var pending by remember { mutableStateOf<Int?>(null) } + PosTheme { + pending?.let { noticeRes -> + ActivationNoticeDialog( + noticeRes = noticeRes, + onConfirm = { + pending = null + actions.setEnabled(true) + }, + onDismiss = { pending = null }, + ) + } + LazyColumn( + modifier = + Modifier + .fillMaxSize() + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + item { + DetailCard { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Text(name, style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f)) + if (!option.alwaysEnabled) + { + Switch( + checked = state.enabled, + onCheckedChange = { enable -> + val notice = option.activationNoticeRes + if (enable && + notice != null) + { + pending = notice + } + else + { + actions.setEnabled(enable) + } + }, + enabled = configured, + modifier = Modifier.semantics { contentDescription = name }, + ) + } + } + Text( + text = stringResource(option.descriptionRes), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + PaymentOptionDetails( + if (configured) + { + state + } + else + { + state.copy(status = PaymentOptionStatus.NotConfigured) + }, + ) + } + } + items(if (option.id == TALER_OPTION_ID) emptyList() else state.devices, key = { it.id }) { device -> + DeviceCard(device) + } + if (option.hasSettings) + { + item { + DetailCard(title = stringResource(option.settingsTitleRes)) { + option.DetailContent(state, actions) + } + } + } + } + } +} + +/** Production payment-option detail UI with inert actions for offline screenshots. */ +@Composable +internal fun PaymentOptionDetailScreenshotContent( + option: PaymentOption, + state: PaymentOptionState, +) +{ + PaymentOptionDetailScreen( + option = option, + state = state, + actions = + PaymentOptionActions( + setEnabled = {}, + refresh = {}, + reportFailure = {}, + ), + ) +} + +@Composable +private fun DetailCard( + title: String? = null, + content: @Composable () -> Unit, +) +{ + PosOutlinedCard(contentPadding = PaddingValues(16.dp)) { contentPadding -> + Column( + modifier = Modifier.padding(contentPadding), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (title != null) Text(title, style = MaterialTheme.typography.titleMedium) + content() + } + } +} diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/paymentoptions/PaymentOptionsFragment.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/paymentoptions/PaymentOptionsFragment.kt @@ -0,0 +1,184 @@ +/* + * 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.paymentoptions + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels + +import net.taler.merchantpos.MainActivity +import net.taler.merchantpos.MainViewModel +import net.taler.merchantpos.PosDestination +import net.taler.merchantpos.R +import net.taler.merchantpos.compose.PosOutlinedCard +import net.taler.merchantpos.compose.PosTheme + +class PaymentOptionsFragment : Fragment() +{ + private val viewModel: MainViewModel by activityViewModels() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View = + ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + val manager = viewModel.paymentOptions + setContent { + val summary by manager.summary.observeAsState(PaymentOptionsSummary()) + PaymentOptionsScreen( + mixedPaymentsUnsupportedBy = + viewModel.configManager.backendVersion + ?.takeUnless { viewModel.checkoutManager.backendSupportsMixedPayments() }, + options = manager.options, + summary = summary, + onEnabled = manager::setEnabled, + onOpen = { optionId -> + (requireActivity() as MainActivity).navigateTo( + PosDestination.PaymentOptionDetail.forOption(optionId), + ) + }, + ) + } + } +} + +@Composable +private fun PaymentOptionsScreen( + mixedPaymentsUnsupportedBy: String?, + options: List<PaymentOption>, + summary: PaymentOptionsSummary, + onEnabled: (String, Boolean) -> Unit, + onOpen: (String) -> Unit, +) +{ + var pending by remember { mutableStateOf<Pair<PaymentOption, Int>?>(null) } + + PosTheme { + pending?.let { (option, noticeRes) -> + ActivationNoticeDialog( + noticeRes = noticeRes, + onConfirm = { + pending = null + onEnabled(option.id, true) + }, + onDismiss = { pending = null }, + ) + } + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) { + LazyColumn( + modifier = Modifier.widthIn(max = 960.dp).fillMaxSize().padding(20.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + item { + Text( + text = stringResource(R.string.payment_options_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (mixedPaymentsUnsupportedBy != null) + { + item { + Text( + text = + stringResource( + R.string.payment_options_mixed_unsupported, + mixedPaymentsUnsupportedBy, + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + } + } + items(options, key = { it.id }) { option -> + val state = + summary.options.firstOrNull { it.id == option.id } + ?: PaymentOptionState(option.id) + PosOutlinedCard( + modifier = Modifier.clickable { onOpen(option.id) }, + contentPadding = PaddingValues(16.dp), + ) { contentPadding -> + Column(modifier = Modifier.padding(contentPadding)) { + PaymentOptionRow( + option = option, + state = state, + onEnabled = { enable -> + val notice = option.activationNoticeRes + if (enable && + notice != null) + { + pending = option to notice + } + else + { + onEnabled(option.id, enable) + } + }, + ) + } + } + } + } + } + } +} + +/** Production payment-options list with offline fixture state for screenshot rendering. */ +@Composable +internal fun PaymentOptionsScreenshotContent( + options: List<PaymentOption>, + summary: PaymentOptionsSummary, +) +{ + PaymentOptionsScreen( + mixedPaymentsUnsupportedBy = null, + options = options, + summary = summary, + onEnabled = { _, _ -> }, + onOpen = {}, + ) +} diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/paymentoptions/PaymentOptionsManager.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/paymentoptions/PaymentOptionsManager.kt @@ -0,0 +1,271 @@ +/* + * 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.paymentoptions + +import android.content.Context +import android.net.Uri +import android.os.Looper +import android.util.Log + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData + +import kotlinx.coroutines.CancellationException + +data class PaymentOptionsSummary( + val options: List<PaymentOptionState> = emptyList(), +) +{ + val enabled: List<PaymentOptionState> get() = options.filter { it.enabled } + + val enabledCount: Int get() = enabled.size +} + +/** + * Owns the per-option switches and the polled state of each option in [options]. + * + * Essential options stay enabled; others are reached while their switch is on. + * Turning an optional method off keeps its + * stored settings and any session, so that re-enabling does not require setting it + * up again; only [logout] clears sessions. + */ +class PaymentOptionsManager( + context: Context, + val options: List<PaymentOption>, +) +{ + private val preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE) + private val mutableSummary = MutableLiveData(PaymentOptionsSummary()) + private val mutableActionFailed = MutableLiveData(false) + + val summary: LiveData<PaymentOptionsSummary> = mutableSummary + val actionFailed: LiveData<Boolean> = mutableActionFailed + + /** Card providers and enabled options that currently report payment devices. */ + fun connectionStates(summary: PaymentOptionsSummary): List<Pair<PaymentOption, PaymentOptionState>> = + options.mapNotNull { option -> + summary.options + .firstOrNull { it.id == option.id } + ?.takeIf { it.enabled && (option.tracksConnection || it.devices.isNotEmpty()) } + ?.let { option to it } + } + + init + { + // Defer SDK and Bluetooth connection checks until the first poll. + publish(options.map { PaymentOptionState(it.id, enabled = isEnabled(it), status = setupStatus(it)) }) + } + + private fun setupStatus(option: PaymentOption): PaymentOptionStatus = + try + { + if (option.isConfigured()) PaymentOptionStatus.Disabled else PaymentOptionStatus.NotConfigured + } + catch (_: RuntimeException) + { + PaymentOptionStatus.Unavailable + } + + fun option(id: String): PaymentOption? = options.firstOrNull { it.id == id } + + fun state(id: String): PaymentOptionState = + mutableSummary.value?.options?.firstOrNull { it.id == id } ?: PaymentOptionState(id) + + fun isEnabled(option: PaymentOption): Boolean = + option.alwaysEnabled || preferences.getBoolean(optionKey(option.id), option.enabledByDefault) + + fun setEnabled( + id: String, + enabled: Boolean, + ) + { + val option = option(id) ?: return + preferences.edit().putBoolean(optionKey(id), option.alwaysEnabled || enabled).apply() + dismissError() + refresh() + } + + fun refresh() + { + val states = + options.map { option -> + val enabled = isEnabled(option) + try + { + if (!enabled) + { + // Setup can change while an option is off. Publish that + // change so its enable switch updates without probing a reader. + PaymentOptionState( + option.id, + enabled = false, + status = setupStatus(option), + ) + } + else + { + option.probe().copy(id = option.id, enabled = true) + } + } + catch (_: RuntimeException) + { + PaymentOptionState(option.id, enabled = enabled, status = PaymentOptionStatus.Unavailable) + } + } + publish(states) + } + + private fun publish(states: List<PaymentOptionState>) + { + val updated = PaymentOptionsSummary(states) + if (mutableSummary.value == updated) return + logTransitions(mutableSummary.value, updated) + // Probing talks to payment SDKs, so it is polled off the main thread; only the + // one-off calls from the settings screens arrive on it. + if (Looper.myLooper() == Looper.getMainLooper()) + { + mutableSummary.value = updated + } + else + { + mutableSummary.postValue(updated) + } + } + + /** + * Reports terminal status, wooooow :) + */ + fun terminalStatus(summary: PaymentOptionsSummary): PaymentOptionStatus = + connectionStates(summary).maxOfOrNull { (option, state) -> + if (option.tracksConnection) + { + state.status + } + else if (state.devices.any { it.connected }) + { + PaymentOptionStatus.Connected + } + else + { + PaymentOptionStatus.Disconnected + } + } ?: PaymentOptionStatus.Disabled + + fun hasEnabledTerminal(summary: PaymentOptionsSummary): Boolean = connectionStates(summary).isNotEmpty() + + /** Renews any refreshable session the enabled options hold, then re-reads status. */ + suspend fun maintainSessions() + { + options.forEach { option -> + if (!isEnabled(option)) return@forEach + try + { + option.maintainSession() + } + catch (e: CancellationException) + { + throw e + } + catch (_: Exception) + { + // An option that cannot renew its session must not stop the others. + } + } + refresh() + } + + /** Offers a deep link to each option; true once one has taken it. */ + suspend fun handleRedirect(uri: Uri): Boolean + { + options.forEach { option -> + val consumed = + try + { + option.handleRedirect(uri) + } + catch (e: CancellationException) + { + throw e + } + catch (_: Exception) + { + false + } + if (consumed) + { + refresh() + return true + } + } + return false + } + + /** + * One line per status change, so that a session lapsing can be placed on a + * timeline afterwards instead of guessed at. Never logs tokens or account data. + */ + private fun logTransitions( + old: PaymentOptionsSummary?, + new: PaymentOptionsSummary, + ) + { + new.options.forEach { state -> + val before = old?.options?.firstOrNull { it.id == state.id }?.status + if (before != state.status) + { + Log.d(LOG_TAG, "${state.id}: $before → ${state.status} (enabled=${state.enabled})") + } + } + } + + fun reportActionFailure() + { + mutableActionFailed.value = true + refresh() + } + + fun dismissError() + { + if (mutableActionFailed.value != false) mutableActionFailed.value = false + } + + /** Merchant logout: resets every switch to its default and drops every session. */ + fun logout() + { + preferences.edit().clear().apply() + options.forEach { option -> + try + { + option.onLogout() + } + catch (_: RuntimeException) + { + // An option that cannot clear its session must not block merchant logout. + } + } + dismissError() + refresh() + } + + private companion object + { + const val LOG_TAG = "taler-pos-payments" + const val PREFERENCES = "payment-terminals" + + fun optionKey(id: String) = "option.$id.enabled" + } +} diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/paymentoptions/PaymentOptionsRegistry.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/paymentoptions/PaymentOptionsRegistry.kt @@ -0,0 +1,52 @@ +/* + * 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.paymentoptions + +import android.app.Application +import android.content.Context + +import com.sumup.reader.sdk.api.SumUpState + +import net.taler.merchantpos.devices.TalerBluetoothDevice +import net.taler.merchantpos.devices.TalerUsbDevices +import net.taler.merchantpos.paymentoptions.sumup.SumUpOption + +/** + * The payment options this flavour offers. + * + * Adding one means implementing [PaymentOption] and listing it here; navigation, + * settings and the terminal indicator adapt on their own. + */ +object PaymentOptionsRegistry +{ + /** Startup any bundled SDK requires before it is used. */ + fun initialize(app: Application) + { + SumUpState.init(app) + } + + fun create( + context: Context, + talerUsb: TalerUsbDevices? = null, + talerBluetooth: TalerBluetoothDevice? = null, + ): List<PaymentOption> = + listOf( + TalerOption(context, talerUsb, talerBluetooth), + CashOption, + SumUpOption(context), + ) +} diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/paymentoptions/RefundOutcome.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/paymentoptions/RefundOutcome.kt @@ -0,0 +1,31 @@ +/* + * 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.paymentoptions + +sealed interface RefundOutcome +{ + /** The provider confirmed that the refund was issued. */ + data object Success : RefundOutcome + + /** This option cannot issue refunds through the POS. */ + data object NotSupported : RefundOutcome + + /** The provider did not confirm the refund; [message] describes the error. */ + data class Failed( + val message: String, + ) : RefundOutcome +} diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/paymentoptions/sumup/SumUpAuth.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/paymentoptions/sumup/SumUpAuth.kt @@ -0,0 +1,437 @@ +/* + * 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.paymentoptions.sumup + +import android.content.Context +import android.net.Uri +import android.os.Looper + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData + +import net.taler.common.getDefaultHttpClient + +import java.util.UUID +import java.util.concurrent.TimeUnit + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.plugins.ResponseException +import io.ktor.client.request.forms.submitForm +import io.ktor.client.statement.bodyAsText +import io.ktor.http.Parameters +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +internal const val SUMUP_AUTHORIZE_URL = "https://api.sumup.com/authorize" +internal const val SUMUP_TOKEN_URL = "https://api.sumup.com/token" + +/** Registered as the OAuth redirect URI; the app already owns the taler-pos scheme. */ +internal const val SUMUP_REDIRECT_HOST = "sumup-oauth" +internal const val SUMUP_REDIRECT_URI = "taler-pos://$SUMUP_REDIRECT_HOST" + +/** + * Scopes requested when a merchant authorizes this installation. + * + * Deliberately only the ones a merchant can grant themselves. SumUp gates `payments` + * and `payment_instruments` behind a manual review, so asking for them here would + * block every merchant on a support ticket; they are only needed to actually charge + * a card, not to hold a session and manage a reader, so they are added once granted. + * `user.profile_readonly` is enough to read the merchant code the screen shows. + */ +internal const val SUMUP_SCOPES = + "user.profile_readonly transactions.history readers.read readers.write" + +/** Refreshed this long before the access token actually expires. */ +private val REFRESH_MARGIN_MS = TimeUnit.MINUTES.toMillis(15) + +internal data class TokenSet( + val accessToken: String, + val refreshToken: String, + val expiresAt: Long, +) + +/** Distinguishes a lapsed grant, which needs the merchant, from a network blip, which does not. */ +internal sealed interface TokenResult +{ + /** Tokens returned by a successful authorization or refresh. */ + data class Success( + val tokens: TokenSet, + ) : TokenResult + + /** The refresh token is no longer usable: the merchant has to authorize again. */ + data object GrantInvalid : TokenResult + + /** The request failed without an explicit rejection of the authorization grant. */ + data class Failed( + val message: String, + ) : TokenResult +} + +internal interface TokenEndpoint +{ + suspend fun exchangeCode( + clientId: String, + clientSecret: String, + code: String, + ): TokenResult + + suspend fun refresh( + clientId: String, + clientSecret: String, + refreshToken: String, + ): TokenResult +} + +@Serializable +private data class TokenResponse( + @SerialName("access_token") val accessToken: String, + @SerialName("refresh_token") val refreshToken: String? = null, + @SerialName("expires_in") val expiresIn: Long = 3600, +) + +internal class SumUpTokenEndpoint( + private val client: HttpClient = getDefaultHttpClient(followRedirect = true), + private val now: () -> Long = System::currentTimeMillis, +) : TokenEndpoint +{ + override suspend fun exchangeCode( + clientId: String, + clientSecret: String, + code: String, + ) = post( + Parameters.build { + append("grant_type", "authorization_code") + append("client_id", clientId) + append("client_secret", clientSecret) + append("code", code) + append("redirect_uri", SUMUP_REDIRECT_URI) + }, + previousRefreshToken = null, + ) + + override suspend fun refresh( + clientId: String, + clientSecret: String, + refreshToken: String, + ) = post( + Parameters.build { + append("grant_type", "refresh_token") + append("client_id", clientId) + append("client_secret", clientSecret) + append("refresh_token", refreshToken) + }, + previousRefreshToken = refreshToken, + ) + + private suspend fun post( + form: Parameters, + previousRefreshToken: String?, + ): TokenResult = + try + { + val response: TokenResponse = client.submitForm(SUMUP_TOKEN_URL, form).body() + TokenResult.Success( + TokenSet( + accessToken = response.accessToken, + // SumUp rotates refresh tokens, so keep the newest one it hands back. + refreshToken = response.refreshToken ?: previousRefreshToken.orEmpty(), + expiresAt = now() + TimeUnit.SECONDS.toMillis(response.expiresIn), + ), + ) + } + catch (e: CancellationException) + { + throw e + } + catch (e: ResponseException) + { + val body = + try + { + e.response.bodyAsText() + } + catch (cancelled: CancellationException) + { + throw cancelled + } + catch (_: Exception) + { + "" + } + if ("invalid_grant" in body) + { + TokenResult.GrantInvalid + } + else + { + TokenResult.Failed("${e.response.status.value}: ${body.take(200)}") + } + } + catch (e: Exception) + { + TokenResult.Failed(e.message ?: e.javaClass.simpleName) + } +} + +data class SumUpAuthState( + val hasClientCredentials: Boolean = false, + val authorized: Boolean = false, + val expiresAt: Long = 0, + val lastRefresh: Long = 0, + val refreshCount: Int = 0, + val lastError: String? = null, +) + +/** + * Renews the SumUp session after the merchant authorizes this installation. + * + * Refresh tokens rotate and are stored for the next renewal. A rejected grant clears + * the tokens so the merchant can authorize again; transient failures retain them. + */ +internal class SumUpAuth( + context: Context, + private val endpoint: TokenEndpoint = SumUpTokenEndpoint(), + private val now: () -> Long = System::currentTimeMillis, +) +{ + private val preferences = context.getSharedPreferences("payment-terminals", Context.MODE_PRIVATE) + private val tokenMutex = Mutex() + private var authorizationGeneration = 0L + private val mutableState = MutableLiveData(SumUpAuthState()) + + private val clientSecret: String + get() = preferences.getString(KEY_CLIENT_SECRET, "").orEmpty() + + private val accessToken: String? get() = preferences.getString(KEY_ACCESS_TOKEN, null) + + private val expiresAt: Long get() = preferences.getLong(KEY_EXPIRES_AT, 0) + + val state: LiveData<SumUpAuthState> = mutableState + + var clientId: String + get() = preferences.getString(KEY_CLIENT_ID, "").orEmpty() + private set(value) = preferences.edit().putString(KEY_CLIENT_ID, value).apply() + + val refreshToken: String? get() = preferences.getString(KEY_REFRESH_TOKEN, null) + + init + { + publish() + } + + fun hasClientCredentials() = clientId.isNotBlank() && clientSecret.isNotBlank() + + fun isAuthorized() = !refreshToken.isNullOrBlank() + + fun setClientCredentials( + id: String, + secret: String, + ) + { + clearAuthorization() + preferences + .edit() + .remove(KEY_PENDING_STATE) + .putString(KEY_CLIENT_ID, id.trim()) + .putString(KEY_CLIENT_SECRET, secret.trim()) + .apply() + publish() + } + + /** Starts one authorization attempt, retaining its nonce across activity recreation. */ + fun authorizeUrl(): String + { + val state = UUID.randomUUID().toString() + check(preferences.edit().putString(KEY_PENDING_STATE, state).commit()) + return Uri + .parse(SUMUP_AUTHORIZE_URL) + .buildUpon() + .appendQueryParameter("response_type", "code") + .appendQueryParameter("client_id", clientId) + .appendQueryParameter("redirect_uri", SUMUP_REDIRECT_URI) + .appendQueryParameter("scope", SUMUP_SCOPES) + .appendQueryParameter("state", state) + .build() + .toString() + } + + /** Consume before exchanging the code, so unsolicited and replayed redirects cannot sign in. */ + fun consumeAuthorizationState(state: String?): Boolean + { + val expected = preferences.getString(KEY_PENDING_STATE, null) ?: return false + if (state != expected) return false + return preferences.edit().remove(KEY_PENDING_STATE).commit() + } + + suspend fun exchangeCode(code: String): TokenResult + { + val generation = authorizationGeneration + return tokenMutex.withLock { + if (generation != authorizationGeneration || + !hasClientCredentials()) + { + return@withLock TokenResult.Failed("OAuth authorization changed") + } + val result = endpoint.exchangeCode(clientId, clientSecret, code) + storeIfCurrent(generation, result) + } + } + + /** A token still valid right now, or null when one has to be fetched first. */ + fun validAccessToken(): String? = accessToken?.takeIf { it.isNotBlank() && expiresAt > now() } + + /** + * Buys a new access token when the current one is close to expiring. Returns null + * when there is nothing to do, so a caller can tell "fine" from "tried and failed". + */ + suspend fun refreshIfNeeded(force: Boolean = false): TokenResult? + { + val generation = authorizationGeneration + // Refresh tokens rotate: concurrent callers must use the previous call's new token. + return tokenMutex.withLock { + if (generation != authorizationGeneration) return@withLock null + val token = refreshToken + if (!hasClientCredentials() || + token.isNullOrBlank()) + { + return@withLock null + } + if (!force && + expiresAt - now() > REFRESH_MARGIN_MS) + { + return@withLock null + } + val result = endpoint.refresh(clientId, clientSecret, token) + storeIfCurrent(generation, result) + } + } + + private fun storeIfCurrent( + generation: Long, + result: TokenResult, + ): TokenResult + { + // A response arriving after logout must never restore the old merchant's tokens. + if (generation != authorizationGeneration) return TokenResult.Failed("OAuth authorization changed") + store(result) + return result + } + + private fun store(result: TokenResult) + { + val editor = preferences.edit() + when (result) + { + is TokenResult.Success -> + { + editor + .putString(KEY_ACCESS_TOKEN, result.tokens.accessToken) + .putString(KEY_REFRESH_TOKEN, result.tokens.refreshToken) + .putLong(KEY_EXPIRES_AT, result.tokens.expiresAt) + .putLong(KEY_LAST_REFRESH, now()) + .putInt(KEY_REFRESH_COUNT, preferences.getInt(KEY_REFRESH_COUNT, 0) + 1) + .remove(KEY_LAST_ERROR) + } + + // The grant is gone, so the stored tokens are worthless: drop them rather + // than retry forever, and let the screen ask for a new authorization. + TokenResult.GrantInvalid -> + { + editor + .remove(KEY_ACCESS_TOKEN) + .remove(KEY_REFRESH_TOKEN) + .remove(KEY_EXPIRES_AT) + .putString(KEY_LAST_ERROR, "invalid_grant") + } + + is TokenResult.Failed -> + { + editor.putString(KEY_LAST_ERROR, result.message) + } + } + editor.apply() + publish() + } + + /** Forgets the authorization but keeps the client credentials for a new one. */ + fun clearAuthorization() + { + authorizationGeneration++ + preferences + .edit() + .remove(KEY_PENDING_STATE) + .remove(KEY_ACCESS_TOKEN) + .remove(KEY_REFRESH_TOKEN) + .remove(KEY_EXPIRES_AT) + .remove(KEY_LAST_REFRESH) + .remove(KEY_REFRESH_COUNT) + .remove(KEY_LAST_ERROR) + .apply() + publish() + } + + fun clearAll() + { + preferences + .edit() + .remove(KEY_CLIENT_ID) + .remove(KEY_CLIENT_SECRET) + .apply() + clearAuthorization() + } + + private fun publish() + { + val updated = + SumUpAuthState( + hasClientCredentials = hasClientCredentials(), + authorized = isAuthorized(), + expiresAt = expiresAt, + lastRefresh = preferences.getLong(KEY_LAST_REFRESH, 0), + refreshCount = preferences.getInt(KEY_REFRESH_COUNT, 0), + lastError = preferences.getString(KEY_LAST_ERROR, null), + ) + if (mutableState.value == updated) return + // Publish synchronously on the main thread so callers can read the new state; + // otherwise enqueue the update for the main looper. + if (Looper.myLooper() == Looper.getMainLooper()) + { + mutableState.value = updated + } + else + { + mutableState.postValue(updated) + } + } + + private companion object + { + const val KEY_PENDING_STATE = "sumup.oauth.pending-state" + const val KEY_CLIENT_ID = "sumup.oauth.client-id" + const val KEY_CLIENT_SECRET = "sumup.oauth.client-secret" + const val KEY_ACCESS_TOKEN = "sumup.oauth.access-token" + const val KEY_REFRESH_TOKEN = "sumup.oauth.refresh-token" + const val KEY_EXPIRES_AT = "sumup.oauth.expires-at" + const val KEY_LAST_REFRESH = "sumup.oauth.last-refresh" + const val KEY_REFRESH_COUNT = "sumup.oauth.refresh-count" + const val KEY_LAST_ERROR = "sumup.oauth.last-error" + } +} diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/paymentoptions/sumup/SumUpOption.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/paymentoptions/sumup/SumUpOption.kt @@ -0,0 +1,900 @@ +/* + * 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.paymentoptions.sumup + +import android.Manifest +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CreditCard +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner + +import com.sumup.merchant.reader.api.SumUpAPI +import com.sumup.merchant.reader.api.SumUpCardReaderPageContract +import com.sumup.merchant.reader.api.SumUpCheckoutContract +import com.sumup.merchant.reader.api.SumUpLogin +import com.sumup.merchant.reader.api.SumUpLoginContract +import com.sumup.merchant.reader.api.SumUpPayment +import com.sumup.merchant.reader.models.SavedCardReaderDetailsResult.SavedCardReaderDetails + +import net.taler.common.Amount +import net.taler.merchantpos.BuildConfig +import net.taler.merchantpos.R +import net.taler.merchantpos.paymentoptions.ChargeResult +import net.taler.merchantpos.paymentoptions.ExternalCharge +import net.taler.merchantpos.paymentoptions.PaymentDevice +import net.taler.merchantpos.paymentoptions.PaymentOption +import net.taler.merchantpos.paymentoptions.PaymentOptionActions +import net.taler.merchantpos.paymentoptions.PaymentOptionState +import net.taler.merchantpos.paymentoptions.PaymentOptionStatus +import net.taler.merchantpos.paymentoptions.PaymentOptionsManager +import net.taler.merchantpos.paymentoptions.RefundOutcome + +import java.math.BigDecimal +import java.math.RoundingMode +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +internal interface ReaderConnection +{ + /** Whether the reader SDK has a signed-in merchant session. */ + fun isLoggedIn(): Boolean + + /** Merchant code of the SumUp account signed in on this device, if any. */ + fun merchant(): String? + + /** Hands the SDK a freshly minted access token for the session it already holds. */ + fun updateAccessToken(token: String) + + /** Saved readers with their last known device details and current connection state. */ + fun devices(): List<PaymentDevice> + + /** Clears the reader settings and signs out of the SDK. */ + fun logout() +} + +/** + * Per-installation SumUp settings. + * + * The affiliate key identifies the *application* to SumUp, not the merchant: every + * merchant signs into their own SumUp account on their own device. A key built into + * the app is therefore the normal case, but a merchant running their own SumUp + * partner registration can override it here, and a build that ships without a key + * stays usable. + */ +internal class SumUpSettings( + context: Context, + /** Key this build ships with; injected so behaviour does not depend on the build environment. */ + private val buildKey: String = BuildConfig.SUMUP_AFFILIATE_KEY, +) +{ + private val preferences = context.getSharedPreferences("payment-terminals", Context.MODE_PRIVATE) + + fun affiliateKey(): String = storedKey() ?: buildKey + + fun hasCustomKey(): Boolean = storedKey() != null + + fun setAffiliateKey(key: String) + { + val trimmed = key.trim() + if (trimmed.isEmpty()) + { + preferences.edit().remove(KEY_AFFILIATE).apply() + } + else + { + preferences.edit().putString(KEY_AFFILIATE, trimmed).apply() + } + } + + private fun storedKey() = preferences.getString(KEY_AFFILIATE, null)?.takeIf { it.isNotBlank() } + + private companion object + { + const val KEY_AFFILIATE = "provider.sumup.affiliate-key" + } +} + +private class SumUpConnection : ReaderConnection +{ + override fun isLoggedIn(): Boolean = SumUpAPI.isLoggedIn() + + override fun merchant(): String? = SumUpAPI.getCurrentMerchant()?.merchantCode + + override fun updateAccessToken(token: String) = SumUpAPI.updateAccessToken(token) + + override fun devices(): List<PaymentDevice> + { + val reader = SumUpAPI.getSavedCardReaderDetails() as? SavedCardReaderDetails ?: return emptyList() + val serial = reader.serialNumber + return listOf( + PaymentDevice( + id = serial ?: SUMUP_ID, + name = reader.readerType?.name?.replace('_', ' '), + serialNumber = serial, + batteryPercent = reader.lastKnownBatteryPercentage, + connected = SumUpAPI.isCardReaderConnected(), + ), + ) + } + + override fun logout() + { + SumUpAPI.clearCardTerminalSettings() + SumUpAPI.logout() + } +} + +internal const val SUMUP_ID = "sumup" + +class SumUpOption internal constructor( + private val context: Context, + private val connection: ReaderConnection, + private val settings: SumUpSettings, + internal val auth: SumUpAuth, + private val rest: SumUpRestApi = SumUpHttpRestApi(), +) : PaymentOption +{ + constructor(context: Context) : this( + context.applicationContext, + SumUpConnection(), + SumUpSettings(context.applicationContext), + SumUpAuth(context.applicationContext), + ) + + private var transparentLoginTried = false + private var host: Host? = null + private var pendingCharge: ((ChargeResult) -> Unit)? = null + + override val id = SUMUP_ID + override val nameRes = R.string.terminal_sumup + override val descriptionRes = R.string.terminal_sumup_description + override val hasSettings = true + override val tracksConnection = true + override val chargesExternally = true + override val activationNoticeRes = R.string.terminal_sumup_activation_notice + + /** Refunds require the REST API and the separately approved `payments` scope. */ + override val canRefund = false + + override val icon: ImageVector + @Composable get() = Icons.Default.CreditCard + + override fun isConfigured() = settings.affiliateKey().isNotBlank() + + override fun probe(): PaymentOptionState + { + if (!isConfigured()) return PaymentOptionState(id, status = PaymentOptionStatus.NotConfigured) + if (!connection.isLoggedIn()) return PaymentOptionState(id, status = PaymentOptionStatus.SignInRequired) + val account = connection.merchant() + if (!hasBluetoothPermission()) + { + return PaymentOptionState(id, status = PaymentOptionStatus.PermissionRequired, account = account) + } + val devices = connection.devices() + return PaymentOptionState( + id = id, + status = + when + { + devices.any { it.connected } -> PaymentOptionStatus.Connected + devices.isEmpty() -> PaymentOptionStatus.NotPaired + else -> PaymentOptionStatus.Disconnected + }, + account = account, + devices = devices, + ) + } + + private fun hasBluetoothPermission(): Boolean = + Build.VERSION.SDK_INT < 31 || ContextCompat.checkSelfPermission( + context, + Manifest.permission.BLUETOOTH_CONNECT, + ) == PackageManager.PERMISSION_GRANTED + + /** + * Renews the access token before it expires and updates the SDK session. + * Revoked grants still require the merchant to authorize again. + */ + override suspend fun maintainSession() + { + if (!isConfigured() || + !auth.isAuthorized()) + { + return + } + auth.refreshIfNeeded() + val token = auth.validAccessToken() ?: return + if (connection.isLoggedIn()) + { + // Already signed in: swapping the token in place keeps the session alive. + runCatching { connection.updateAccessToken(token) } + } + else + { + // Signed out: SumUp accepts the token instead of credentials, but that is + // an activity launch, so only try it once per activity. + requestTransparentLogin(token) + } + } + + override suspend fun handleRedirect(uri: Uri): Boolean + { + if (uri.scheme != "taler-pos" || + uri.host != SUMUP_REDIRECT_HOST) + { + return false + } + if (!auth.consumeAuthorizationState(uri.getQueryParameter("state"))) return true + val code = uri.getQueryParameter("code") + if (code.isNullOrBlank()) return true + auth.exchangeCode(code) + auth.validAccessToken()?.let { token -> + if (!connection.isLoggedIn()) + { + transparentLoginTried = false + requestTransparentLogin(token) + } + } + return true + } + + private fun requestTransparentLogin(token: String) + { + if (transparentLoginTried) return + val host = host ?: return + transparentLoginTried = true + runCatching { host.openLogin(token) } + } + + override fun onLogout() + { + auth.clearAll() + connection.logout() + } + + // Activity result plumbing, rebound on every activity creation. + + private class Host( + val activity: ComponentActivity, + val manager: PaymentOptionsManager, + val openReaderPage: () -> Unit, + val openLogin: (String?) -> Unit, + val openCheckout: (SumUpPayment) -> Unit, + ) + + override fun registerLaunchers( + activity: ComponentActivity, + manager: PaymentOptionsManager, + ) + { + val readerLauncher = + activity.registerForActivityResult(SumUpCardReaderPageContract()) { result -> + val code = result.data?.getIntExtra(SumUpAPI.Response.RESULT_CODE, 0) ?: 0 + if (code != 0 && + code != SumUpAPI.Response.ResultCode.SUCCESSFUL) + { + manager.reportActionFailure() + } + manager.refresh() + } + val loginLauncher = + activity.registerForActivityResult(SumUpLoginContract()) { result -> + val code = result.data?.getIntExtra(SumUpAPI.Response.RESULT_CODE, 0) ?: 0 + if (code == SumUpAPI.Response.ResultCode.SUCCESSFUL) + { + launchSetup() + } + else if (code != 0) + { + manager.reportActionFailure() + } + manager.refresh() + } + val checkoutLauncher = + activity.registerForActivityResult(SumUpCheckoutContract()) { result -> + val data = result.data + val outcome = + when (data?.getIntExtra(SumUpAPI.Response.RESULT_CODE, 0) ?: 0) + { + SumUpAPI.Response.ResultCode.SUCCESSFUL -> + { + ChargeResult.Success(data?.getStringExtra(SumUpAPI.Response.TX_CODE)) + } + + // Neither can be booked: the reference may or may not have been charged. + SumUpAPI.Response.ResultCode.ERROR_UNKNOWN_TRANSACTION_STATUS, + SumUpAPI.Response.ResultCode.ERROR_DUPLICATE_FOREIGN_TX_ID, + -> + { + ChargeResult.Unknown(data?.getStringExtra(SumUpAPI.Response.MESSAGE)) + } + + 0 -> + { + ChargeResult.Cancelled + } + + else -> + { + ChargeResult.Failed(data?.getStringExtra(SumUpAPI.Response.MESSAGE)) + } + } + val pending = pendingCharge + pendingCharge = null + pending?.invoke(outcome) + manager.refresh() + } + transparentLoginTried = false + host = + Host( + activity = activity, + manager = manager, + openReaderPage = { readerLauncher.launch(null) }, + openLogin = { token -> + val login = + SumUpLogin + .builder(settings.affiliateKey()) + .let { if (token != null) it.accessToken(token) else it } + .build() + loginLauncher.launch(login) + }, + openCheckout = { payment -> checkoutLauncher.launch(payment) }, + ) + activity.lifecycle.addObserver( + object : DefaultLifecycleObserver + { + override fun onDestroy(owner: LifecycleOwner) + { + if (host?.activity === activity) host = null + } + }, + ) + } + + /** + * Refunds through SumUp's REST API: the reader SDK cannot do it. That needs an + * authorised device, since SumUp refuses refunds on client credentials tokens. + */ + override suspend fun refund( + transactionCode: String?, + amount: Amount, + reason: String, + ): RefundOutcome + { + if (transactionCode.isNullOrBlank()) + { + return RefundOutcome.Failed("This payment has no SumUp transaction to refund") + } + auth.refreshIfNeeded() + val token = + auth.validAccessToken() + ?: return RefundOutcome.Failed("Authorise this device with SumUp before refunding") + val merchantCode = + runCatching { connection.merchant() }.getOrNull() + ?: return RefundOutcome.Failed("Sign in to SumUp before refunding") + val transactionId = + when (val found = rest.transactionId(merchantCode, transactionCode, token)) + { + is RestResult.Success -> found.value + is RestResult.Failure -> return RefundOutcome.Failed(found.message) + } + return when (val refunded = rest.refund(merchantCode, transactionId, amount, token)) + { + is RestResult.Success -> RefundOutcome.Success + is RestResult.Failure -> RefundOutcome.Failed(refunded.message) + } + } + + override fun startCharge( + charge: ExternalCharge, + onResult: (ChargeResult) -> Unit, + ): Boolean + { + val host = host ?: return false + if (pendingCharge != null || + !isConfigured()) + { + return false + } + val currency = + runCatching { SumUpPayment.Currency.valueOf(charge.amount.currency) }.getOrNull() + ?: return false + // SumUp refuses more than two decimals; a finer amount cannot be charged at all. + val total = + runCatching { + BigDecimal + .valueOf(charge.amount.value) + .add(BigDecimal.valueOf(charge.amount.fraction.toLong(), 8)) + .setScale(2, RoundingMode.UNNECESSARY) + }.getOrNull() ?: return false + if (!runCatching { connection.isLoggedIn() }.getOrDefault(false)) return false + val payment = + SumUpPayment + .builder() + .total(total) + .currency(currency) + .title(charge.title) + .foreignTransactionId(charge.reference) + .skipSuccessScreen() + .build() + pendingCharge = onResult + return try + { + host.openCheckout(payment) + true + } + catch (_: RuntimeException) + { + pendingCharge = null + false + } + } + + /** Signs in when needed, then opens SumUp's own reader setup. */ + fun launchSetup() + { + val host = host ?: return + host.manager.refresh() + if (!host.manager.state(id).enabled || + !isConfigured()) + { + return + } + host.manager.dismissError() + try + { + if (connection.isLoggedIn()) + { + host.openReaderPage() + } + else + { + host.openLogin(auth.validAccessToken()) + } + } + catch (_: RuntimeException) + { + host.manager.reportActionFailure() + } + } + + @Composable + override fun DetailContent( + state: PaymentOptionState, + actions: PaymentOptionActions, + ) + { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + if (state.enabled && + state.status != PaymentOptionStatus.NotConfigured) + { + Button( + onClick = ::launchSetup, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + ) { + Text( + stringResource( + when (state.status) + { + PaymentOptionStatus.SignInRequired -> + { + R.string.terminal_sumup_sign_in + } + + else -> + { + if (state.devices.isEmpty()) + { + R.string.terminal_pair + } + else + { + R.string.terminal_manage + } + } + }, + ), + ) + } + if (state.status != PaymentOptionStatus.SignInRequired) + { + OutlinedButton( + onClick = { + signOut(actions) + actions.refresh() + }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + ) { + Text(stringResource(R.string.terminal_sumup_sign_out)) + } + } + } + UnattendedSession(actions) + AdvancedSettings(actions) + } + } + + /** Displays token expiry and refresh status. */ + @Composable + private fun UnattendedSession(actions: PaymentOptionActions) + { + val authState by auth.state.observeAsState(SumUpAuthState()) + val scope = rememberCoroutineScope() + val context = LocalContext.current + var now by remember { mutableLongStateOf(System.currentTimeMillis()) } + LaunchedEffect(Unit) { + while (true) + { + now = System.currentTimeMillis() + delay(1_000) + } + } + + Text( + text = stringResource(R.string.terminal_sumup_session_help), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + if (!authState.hasClientCredentials) + { + Text( + text = stringResource(R.string.terminal_sumup_session_no_client), + style = MaterialTheme.typography.bodyMedium, + ) + return + } + + if (!authState.authorized) + { + Text( + text = stringResource(R.string.terminal_sumup_session_not_authorized), + style = MaterialTheme.typography.bodyMedium, + ) + } + else + { + val remaining = ((authState.expiresAt - now) / 1000).coerceAtLeast(0) + Text( + text = + stringResource( + R.string.terminal_sumup_session_expires, + "%d:%02d".format(remaining / 60, remaining % 60), + ), + style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = + stringResource( + R.string.terminal_sumup_session_refreshes, + authState.refreshCount, + if (authState.lastRefresh == 0L) "-" else timeOfDay(authState.lastRefresh), + ), + style = MaterialTheme.typography.bodyMedium, + ) + } + authState.lastError?.let { + Text( + text = stringResource(R.string.terminal_sumup_session_error, it), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + } + + Button( + onClick = { + context.startActivity( + Intent(Intent.ACTION_VIEW, Uri.parse(auth.authorizeUrl())), + ) + }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + ) { + Text( + stringResource( + if (authState.authorized) + { + R.string.terminal_sumup_reauthorize + } + else + { + R.string.terminal_sumup_authorize + }, + ), + ) + } + + if (authState.authorized) + { + // Forces the refresh the app would otherwise do on its own, so the counter + // and the countdown above can be watched moving without waiting. + OutlinedButton( + onClick = { + scope.launch { + auth.refreshIfNeeded(force = true) + auth.validAccessToken()?.let { token -> + runCatching { connection.updateAccessToken(token) } + } + actions.refresh() + } + }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + ) { + Text(stringResource(R.string.terminal_sumup_refresh_now)) + } + OutlinedButton( + onClick = { + auth.clearAuthorization() + actions.refresh() + }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + ) { + Text(stringResource(R.string.terminal_sumup_revoke)) + } + } + } + + private fun timeOfDay(millis: Long): String = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date(millis)) + + /** + * The affiliate key identifies the app rather than the merchant, and the OAuth + * client belongs to the merchant. Neither is ever displayed: this only allows + * replacing them. + */ + @Composable + private fun AdvancedSettings(actions: PaymentOptionActions) + { + var expanded by rememberSaveable { mutableStateOf(false) } + var key by rememberSaveable { mutableStateOf("") } + var revealed by rememberSaveable { mutableStateOf(false) } + // Held as state: refreshing may leave the option's status unchanged, which + // would not on its own recompose this section after the key is replaced. + var custom by rememberSaveable { mutableStateOf(settings.hasCustomKey()) } + + TextButton( + onClick = { expanded = !expanded }, + modifier = Modifier.fillMaxWidth(), + ) { + Icon( + imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = null, + ) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.terminal_sumup_advanced), modifier = Modifier.weight(1f)) + } + if (!expanded) return + + Text( + text = stringResource(R.string.terminal_sumup_advanced_help), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = + stringResource( + if (custom) + { + R.string.terminal_sumup_key_custom + } + else + { + R.string.terminal_sumup_key_builtin + }, + ), + style = MaterialTheme.typography.bodyMedium, + ) + OutlinedTextField( + value = key, + onValueChange = { key = it }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + shape = RoundedCornerShape(14.dp), + label = { Text(stringResource(R.string.terminal_sumup_key_hint)) }, + visualTransformation = + if (revealed) VisualTransformation.None else PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + trailingIcon = { + IconButton(onClick = { revealed = !revealed }) { + Icon( + imageVector = if (revealed) Icons.Default.VisibilityOff else Icons.Default.Visibility, + contentDescription = + stringResource( + if (revealed) + { + R.string.terminal_sumup_key_hide + } + else + { + R.string.terminal_sumup_key_show + }, + ), + ) + } + }, + ) + Text( + text = stringResource(R.string.terminal_sumup_key_warning), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + OutlinedButton( + onClick = { + // The session belongs to the previous application identity, so drop it. + signOut(actions) + settings.setAffiliateKey(key) + custom = settings.hasCustomKey() + key = "" + revealed = false + actions.refresh() + }, + enabled = key.isNotBlank(), + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + ) { + Text(stringResource(R.string.terminal_sumup_save_key)) + } + if (custom) + { + OutlinedButton( + onClick = { + signOut(actions) + settings.setAffiliateKey("") + custom = false + key = "" + actions.refresh() + }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + ) { + Text(stringResource(R.string.terminal_sumup_reset_key)) + } + } + + OAuthClientFields() + } + + /** + * The merchant's own OAuth application, registered in their SumUp dashboard. Its + * secret is what lets this installation renew the session without a person. + */ + @Composable + private fun OAuthClientFields() + { + var clientId by rememberSaveable { mutableStateOf(auth.clientId) } + var clientSecret by rememberSaveable { mutableStateOf("") } + var saved by rememberSaveable { mutableStateOf(auth.hasClientCredentials()) } + + Text( + text = stringResource(R.string.terminal_sumup_oauth_help, SUMUP_REDIRECT_URI), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = + stringResource( + if (saved) R.string.terminal_sumup_oauth_set else R.string.terminal_sumup_oauth_unset, + ), + style = MaterialTheme.typography.bodyMedium, + ) + OutlinedTextField( + value = clientId, + onValueChange = { clientId = it }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + shape = RoundedCornerShape(14.dp), + label = { Text(stringResource(R.string.terminal_sumup_client_id)) }, + ) + OutlinedTextField( + value = clientSecret, + onValueChange = { clientSecret = it }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + shape = RoundedCornerShape(14.dp), + label = { Text(stringResource(R.string.terminal_sumup_client_secret)) }, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + ) + OutlinedButton( + onClick = { + auth.setClientCredentials(clientId, clientSecret) + clientSecret = "" + saved = auth.hasClientCredentials() + }, + enabled = clientId.isNotBlank() && clientSecret.isNotBlank(), + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + ) { + Text(stringResource(R.string.terminal_sumup_save_client)) + } + } + + private fun signOut(actions: PaymentOptionActions) + { + try + { + connection.logout() + } + catch (_: RuntimeException) + { + actions.reportFailure() + } + } +} diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/paymentoptions/sumup/SumUpRestApi.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/paymentoptions/sumup/SumUpRestApi.kt @@ -0,0 +1,155 @@ +/* + * 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.paymentoptions.sumup + +import net.taler.common.Amount +import net.taler.common.getDefaultHttpClient + +import java.math.BigDecimal + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.plugins.ResponseException +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.request.parameter +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.contentType +import kotlinx.coroutines.CancellationException +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * The parts of SumUp's REST API a refund needs. The reader SDK cannot refund at all, + * so this is the only way to give card money back from the app. + */ +internal interface SumUpRestApi +{ + /** Resolves the code a checkout returned into the transaction id a refund needs. */ + suspend fun transactionId( + merchantCode: String, + transactionCode: String, + token: String, + ): RestResult<String> + + /** Refunds [amount], or the full payment when [amount] is null. */ + suspend fun refund( + merchantCode: String, + transactionId: String, + amount: Amount?, + token: String, + ): RestResult<Unit> +} + +internal sealed interface RestResult<out T> +{ + data class Success<T>( + val value: T, + ) : RestResult<T> + + data class Failure( + val message: String, + ) : RestResult<Nothing> +} + +@Serializable +private data class TransactionResponse( + val id: String, + @SerialName("transaction_code") val transactionCode: String? = null, + val status: String? = null, +) + +@Serializable +private data class RefundBody( + val amount: Double, +) + +internal class SumUpHttpRestApi( + private val client: HttpClient = getDefaultHttpClient(followRedirect = true), + private val baseUrl: String = "https://api.sumup.com", +) : SumUpRestApi +{ + override suspend fun transactionId( + merchantCode: String, + transactionCode: String, + token: String, + ): RestResult<String> = + call { + val response: TransactionResponse = + client + .get("$baseUrl/v2.1/merchants/$merchantCode/transactions") { + header(HttpHeaders.Authorization, "Bearer $token") + parameter("transaction_code", transactionCode) + }.body() + response.id + } + + override suspend fun refund( + merchantCode: String, + transactionId: String, + amount: Amount?, + token: String, + ): RestResult<Unit> = + call { + client.post("$baseUrl/v1.0/merchants/$merchantCode/payments/$transactionId/refunds") { + header(HttpHeaders.Authorization, "Bearer $token") + contentType(ContentType.Application.Json) + // No amount at all means a full refund, which is not what a part refund wants. + amount?.let { setBody(RefundBody(it.toBigDecimal().toDouble())) } + } + Unit + } + + private suspend fun <T> call(block: suspend () -> T): RestResult<T> = + try + { + RestResult.Success(block()) + } + catch (e: CancellationException) + { + throw e + } + catch (e: ResponseException) + { + val body = + try + { + e.response.bodyAsText() + } + catch (cancelled: CancellationException) + { + throw cancelled + } + catch (_: Exception) + { + "" + } + RestResult.Failure("${e.response.status.value}: ${body.take(200)}") + } + catch (e: Exception) + { + RestResult.Failure(e.message ?: e.javaClass.simpleName) + } +} + +/** SumUp takes a plain decimal amount, so the fractional part has to be exact. */ +internal fun Amount.toBigDecimal(): BigDecimal = + BigDecimal.valueOf(value).add(BigDecimal.valueOf(fraction.toLong(), 8)).stripTrailingZeros() diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/refund/RefundAvailability.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/refund/RefundAvailability.kt @@ -0,0 +1,56 @@ +/* + * 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.refund + +import net.taler.common.Amount +import net.taler.common.ExternalPayment +import net.taler.merchantlib.OrderHistoryEntry +import net.taler.merchantpos.checkout.Checkout + +internal fun OrderHistoryEntry.talerRefundRemaining(): Amount +{ + val zero = Amount.zero(amount.currency) + if (!paid || + !refundable || + hasPendingRefund) + { + return zero + } + val refunded = refundAmount ?: if (refunded) amount else zero + return if (refunded >= amount) zero else amount - refunded +} + +/** Count a locally recorded refund only once when it also appears on the backend. */ +internal fun OrderHistoryEntry.externalRefundRemaining( + payment: ExternalPayment, + checkout: Checkout?, +): Amount +{ + val zero = Amount.zero(payment.amount.currency) + val refunds = + refundsExternal + .filter { + it.paymentId == payment.id || + (it.paymentId == null && it.method == payment.method) + }.associate { it.id to it.amount } + .toMutableMap() + checkout?.refunds?.filter { it.paymentId == payment.id }?.forEach { + refunds[it.id] = maxOf(refunds[it.id] ?: zero, it.amount) + } + val returned = refunds.values.fold(zero) { sum, value -> sum + value } + return if (returned >= payment.amount) zero else payment.amount - returned +} 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 @@ -17,222 +17,516 @@ package net.taler.merchantpos.refund import android.os.Bundle -import androidx.compose.foundation.gestures.detectTapGestures +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedCard import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text -import androidx.compose.runtime.LaunchedEffect +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels -import androidx.compose.runtime.livedata.observeAsState -import net.taler.merchantpos.MainActivity + import net.taler.common.Amount import net.taler.common.AmountParserException +import net.taler.common.CurrencySpecification import net.taler.merchantlib.OrderHistoryEntry +import net.taler.merchantpos.MainActivity import net.taler.merchantpos.MainViewModel import net.taler.merchantpos.PosDestination import net.taler.merchantpos.R +import net.taler.merchantpos.checkout.Checkout +import net.taler.merchantpos.checkout.METHOD_CASH +import net.taler.merchantpos.checkout.shown import net.taler.merchantpos.compose.PosTheme -import net.taler.merchantpos.refund.RefundResult.AlreadyRefunded -import net.taler.merchantpos.refund.RefundResult.Error -import net.taler.merchantpos.refund.RefundResult.PastDeadline -import net.taler.merchantpos.refund.RefundResult.Success +import net.taler.merchantpos.history.OrderDetailsResult import net.taler.merchantpos.showPosError -class RefundFragment : Fragment() { - +class RefundFragment : Fragment() +{ private val model: MainViewModel by activityViewModels() private val refundManager by lazy { model.refundManager } override fun onCreateView( - inflater: android.view.LayoutInflater, - container: android.view.ViewGroup?, + inflater: LayoutInflater, + container: ViewGroup?, savedInstanceState: Bundle?, ) = ComposeView(requireContext()).apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { val item = refundManager.toBeRefunded - if (item == null) { - requireActivity().showPosError(R.string.refund_state_missing) - (requireActivity() as MainActivity).navigateBack() + if (item == null) + { + LaunchedEffect(Unit) { (requireActivity() as MainActivity).navigateBack() } return@setContent } - RefundScreen( - item = item, - currencySpec = model.configManager.currencySpec, - onAbort = { (requireActivity() as MainActivity).navigateBack() }, - onRefund = ::onRefundButtonClicked, - ) + var refresh by remember { mutableIntStateOf(0) } + val details by produceState<OrderDetailsResult>(OrderDetailsResult.Loading, item.orderId, refresh) { + value = OrderDetailsResult.Loading + value = model.historyManager.fetchOrderDetails(item.orderId) + } + val response = (details as? OrderDetailsResult.Success)?.response + if (response == null) + { + PosTheme { + Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + if (details is OrderDetailsResult.Loading) + { + LinearProgressIndicator(Modifier.fillMaxWidth()) + } + else + { + Text(stringResource(R.string.order_details_load_error)) + Button(onClick = { refresh++ }) { Text(stringResource(R.string.order_details_retry)) } + } + } + } + return@setContent + } + val currentItem = item.withPaymentDetails(response) + val checkouts by model.checkoutManager.checkouts.observeAsState(emptyList()) + val submitting by refundManager.submitting.observeAsState(false) + val result by refundManager.refundResult.observeAsState() + val checkout = checkouts.find { it.backendOrderId == item.orderId } + PosTheme { + RefundScreen( + currentItem, + model.configManager.currencySpec, + checkout = checkout, + preferTaler = refundManager.preferTaler, + submitting = submitting, + onAbort = { (requireActivity() as MainActivity).navigateBack() }, + onRefund = { choice, amount, reason -> + if (choice.paymentId == null) + { + refundManager.refund(currentItem, amount, reason) + } + else if (checkout != null) + { + refundManager.recordExternalRefund( + model.checkoutManager, + currentItem, + checkout.id, + choice.paymentId, + amount, + reason, + ) + } + }, + ) + (result as? RefundResult.ExternalCompleted)?.let { completed -> + AlertDialog( + onDismissRequest = {}, + title = { Text(stringResource(R.string.refund_recorded)) }, + text = { + Text( + if (completed.warning == + null) + { + stringResource(R.string.refund_recorded_message) + } + else + { + stringResource(R.string.refund_recorded_local, completed.warning) + }, + ) + }, + confirmButton = { + TextButton( + onClick = { + refundManager.completeRefund() + model.historyManager.fetchHistory() + (requireActivity() as MainActivity).navigateBack() + } + ) { Text(stringResource(android.R.string.ok)) } + }, + ) + } + } } } - override fun onViewCreated(view: android.view.View, savedInstanceState: Bundle?) { + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) + { refundManager.refundResult.observe(viewLifecycleOwner) { result -> - when (result) { - is Error -> onError(R.string.refund_error_backend, result.msg) - PastDeadline -> onError(R.string.refund_error_deadline) - AlreadyRefunded -> onError(R.string.refund_error_already_refunded) - is Success -> (requireActivity() as MainActivity).navigateTo(PosDestination.RefundUri) - null -> Unit - } - } - } + when (result) + { + is RefundResult.Error -> + { + requireActivity().showPosError(R.string.refund_error_backend, result.msg) + refundManager.clearResult() + } - private fun onRefundButtonClicked(item: OrderHistoryEntry, amount: Amount, reason: String) { - refundManager.refund(item, amount, reason) - } + RefundResult.PastDeadline -> + { + requireActivity().showPosError(R.string.refund_error_deadline) + refundManager.clearResult() + } - private fun onError(mainResId: Int, details: String = "") { - requireActivity().showPosError(mainResId, details) + RefundResult.AlreadyRefunded -> + { + requireActivity().showPosError(R.string.refund_error_already_refunded) + refundManager.clearResult() + } + + is RefundResult.Success -> + { + (requireActivity() as MainActivity).navigateTo(PosDestination.RefundUri) + } + + else -> + { + Unit + } + } + } } } +private data class RefundChoice( + val key: String, + val paymentId: String?, + val label: String, + val remaining: Amount, + val enabled: Boolean, + val cash: Boolean = false, + val reference: String? = null, +) + @Composable private fun RefundScreen( item: OrderHistoryEntry, - currencySpec: net.taler.common.CurrencySpecification?, + currencySpec: CurrencySpecification?, initialReason: String? = null, + checkout: Checkout? = null, + preferTaler: Boolean = false, + submitting: Boolean = false, onAbort: () -> Unit, - onRefund: (OrderHistoryEntry, Amount, String) -> Unit, -) { - var amountText by remember { - mutableStateOf(item.amount.withSpec(currencySpec).amountStr) + onRefund: (RefundChoice, Amount, String) -> Unit, +) +{ + val cashLabel = stringResource(R.string.payment_option_cash) + val talerLabel = stringResource(R.string.payment_option_taler) + val choices = + item.amountExternal.orEmpty().map { payment -> + val remaining = item.externalRefundRemaining(payment, checkout) + val local = checkout?.payments?.find { it.id == payment.id } + RefundChoice( + payment.id, + payment.id, + if (payment.method.equals(METHOD_CASH, true)) + { + cashLabel + } + else + { + payment.method.replaceFirstChar { it.uppercase() } + }, + remaining, + item.paid && !remaining.isZero() && local != null, + payment.method.equals(METHOD_CASH, true), + payment.transactionCode, + ) + } + RefundChoice("taler", null, talerLabel, item.talerRefundRemaining(), !item.talerRefundRemaining().isZero()) + var selected by rememberSaveable(item.orderId) { + mutableStateOf( + (if (preferTaler) choices.find { it.paymentId == null && it.enabled } else null)?.key + ?: choices.firstOrNull { it.enabled }?.key ?: choices.last().key, + ) } - var reason by remember { mutableStateOf(initialReason ?: "") } - var errorText by remember { mutableStateOf<String?>(null) } - val amountFocusRequester = remember { FocusRequester() } - val reasonFocusRequester = remember { FocusRequester() } - val focusManager = LocalFocusManager.current - val keyboardController = LocalSoftwareKeyboardController.current - val invalidAmountText = stringResource(R.string.refund_error_invalid_amount) - val zeroAmountText = stringResource(R.string.refund_error_zero) - val maxAmountTemplate = stringResource(R.string.refund_error_max_amount, "%s") - val submitRefund = submit@{ - val maxAmount = item.amount.withSpec(currencySpec) - val normalizedAmountText = amountText.trim() - val inputAmount = try { - if (normalizedAmountText.isEmpty()) { - maxAmount - } else { - Amount.fromString(item.amount.currency, normalizedAmountText).withSpec(currencySpec) - } - } catch (_: AmountParserException) { - errorText = invalidAmountText - return@submit - } - if (inputAmount > maxAmount) { - errorText = maxAmountTemplate.replace("%s", maxAmount.toString(showSymbol = false)) - return@submit - } - if (inputAmount.isZero()) { - errorText = zeroAmountText - return@submit - } - focusManager.clearFocus(force = true) - keyboardController?.hide() - onRefund(item, inputAmount, reason) + val choice = choices.first { it.key == selected } + var amountText by rememberSaveable(selected, choice.remaining.toString()) { + mutableStateOf(choice.remaining.withSpec(currencySpec).amountStr) } - + var reason by rememberSaveable(item.orderId) { mutableStateOf(initialReason ?: "") } + var error by remember { mutableStateOf<String?>(null) } + var reviewAmount by remember { mutableStateOf<Amount?>(null) } + val focusManager = LocalFocusManager.current + val invalid = stringResource(R.string.refund_error_invalid_amount) + val zero = stringResource(R.string.refund_error_zero) + val tooMuch = stringResource(R.string.refund_error_max_amount, choice.remaining.shown(currencySpec)) + BackHandler(submitting) { } PosTheme { - LaunchedEffect(Unit) { - amountFocusRequester.requestFocus() - } - Column( - modifier = Modifier - .fillMaxSize() - .imePadding() - .padding(20.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - Text(item.summary, style = MaterialTheme.typography.bodyLarge) - OutlinedTextField( - value = amountText, - onValueChange = { - amountText = it - errorText = null - }, - modifier = Modifier + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) { + Column( + Modifier + .widthIn(max = 1000.dp) .fillMaxWidth() - .focusRequester(amountFocusRequester), - label = { Text(stringResource(R.string.refund_amount)) }, - supportingText = errorText?.let { { Text(it) } }, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Decimal, - imeAction = ImeAction.Next, - ), - keyboardActions = KeyboardActions( - onNext = { reasonFocusRequester.requestFocus() }, - ), - singleLine = true, - ) - OutlinedTextField( - value = reason, - onValueChange = { reason = it }, - modifier = Modifier - .fillMaxWidth() - .focusRequester(reasonFocusRequester), - label = { Text(stringResource(R.string.refund_reason)) }, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Text, - imeAction = ImeAction.Done, - ), - keyboardActions = KeyboardActions( - onDone = { submitRefund() }, - ), - singleLine = true, - ) - Button( - onClick = { submitRefund() }, - modifier = Modifier.fillMaxWidth(), + .imePadding() + .verticalScroll(rememberScrollState()) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), ) { - Text(stringResource(R.string.refund_confirm)) - } - OutlinedButton( - onClick = onAbort, - modifier = Modifier.fillMaxWidth(), - ) { - Text(stringResource(R.string.refund_abort)) + Text(item.summary, style = MaterialTheme.typography.titleSmall) + Text(item.orderId, style = MaterialTheme.typography.bodySmall) + Text(stringResource(R.string.refund_choose_method), style = MaterialTheme.typography.titleMedium) + BoxWithConstraints(Modifier.fillMaxWidth()) { + val optionWidth = if (maxWidth < 600.dp) maxWidth else (maxWidth - 24.dp) / 3 + FlowRow( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + choices.forEach { option -> + OutlinedCard( + onClick = { + if (option.enabled) + { + selected = option.key + error = null + } + }, + enabled = option.enabled && !submitting, + colors = + CardDefaults.outlinedCardColors( + containerColor = + if (selected == option.key) + { + MaterialTheme.colorScheme.primaryContainer + } + else + { + MaterialTheme.colorScheme.surface + }, + ), + modifier = Modifier.width(optionWidth), + ) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Modifier.weight(1f)) { + if (option.paymentId == + null) + { + Image( + painterResource(R.drawable.ic_taler_logo_brand), + talerLabel, + Modifier.size(54.dp, 24.dp), + ) + } + else + { + Text(option.label, style = MaterialTheme.typography.labelLarge) + } + } + Text( + option.remaining.shown(currencySpec), + style = MaterialTheme.typography.bodyMedium, + ) + } + Text( + stringResource( + if (option.enabled) + { + R.string.refund_available + } + else + { + R.string.refund_unavailable + }, + ), + style = MaterialTheme.typography.bodySmall, + ) + option.reference?.let { Text(it, style = MaterialTheme.typography.bodySmall) } + } + } + } + } + } + Text( + stringResource( + when + { + !choice.enabled -> R.string.refund_unavailable_hint + choice.paymentId == null -> R.string.refund_taler_hint + choice.cash -> R.string.refund_cash_hint + else -> R.string.refund_card_hint + }, + ), + style = MaterialTheme.typography.bodyMedium, + ) + OutlinedTextField( + amountText, + { + amountText = it + error = null + }, + Modifier.fillMaxWidth(), + enabled = choice.enabled && !submitting, + label = { Text(stringResource(R.string.refund_amount)) }, + supportingText = error?.let { { Text(it) } }, + isError = error != null, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + singleLine = true, + ) + OutlinedTextField( + reason, + { reason = it }, + Modifier.fillMaxWidth(), + enabled = choice.enabled && !submitting, + label = { Text(stringResource(R.string.refund_reason)) }, + ) + FlowRow(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Button( + enabled = choice.enabled && !submitting, + onClick = { + val value = + try + { + Amount.fromString(item.amount.currency, amountText.trim()).withSpec(currencySpec) + } + catch ( + _: AmountParserException, + ) + { + error = invalid + null + } + if (value != null) + { + when + { + value.isZero() -> { + error = zero + } + + value > choice.remaining -> { + error = tooMuch + } + + else -> + { + focusManager.clearFocus() + reviewAmount = value + } + } + } + } + ) { + Text( + stringResource( + if (choice.paymentId == + null) + { + R.string.refund_taler_button + } + else + { + R.string.refund_record_external + }, + ), + ) + } + OutlinedButton( + onClick = onAbort, + enabled = !submitting, + ) { Text(stringResource(android.R.string.cancel)) } + } + if (submitting) LinearProgressIndicator(Modifier.fillMaxWidth()) } - Spacer( - modifier = Modifier - .weight(1f) - .fillMaxWidth() - .pointerInput(Unit) { - detectTapGestures( - onTap = { - focusManager.clearFocus(force = true) - keyboardController?.hide() - }, + } + reviewAmount?.let { amount -> + AlertDialog( + onDismissRequest = { reviewAmount = null }, + title = { Text(stringResource(R.string.refund_confirm)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("${choice.label}: ${amount.shown(currencySpec)}") + if (reason.isNotBlank()) Text(reason) + Text( + stringResource( + if (choice.paymentId == null) + { + R.string.refund_taler_hint + } + else if (choice.cash) + { + R.string.refund_cash_confirm + } + else + { + R.string.refund_card_confirm + }, + ), ) - }, + } + }, + confirmButton = { + Button( + onClick = { + reviewAmount = null + onRefund(choice, amount, reason) + } + ) { + Text( + stringResource( + if (choice.paymentId == + null) + { + R.string.refund_confirm + } + else + { + R.string.refund_returned_confirm + }, + ), + ) + } + }, + dismissButton = { + TextButton( + onClick = { reviewAmount = null }, + ) { Text(stringResource(android.R.string.cancel)) } + }, ) } } @@ -241,14 +535,10 @@ private fun RefundScreen( @Composable internal fun RefundScreenContent( item: OrderHistoryEntry, - currencySpec: net.taler.common.CurrencySpecification?, + currencySpec: CurrencySpecification?, initialReason: String? = null, -) { - RefundScreen( - item = item, - currencySpec = currencySpec, - initialReason = initialReason, - onAbort = {}, - onRefund = { _, _, _ -> }, - ) + checkout: Checkout? = null, +) +{ + RefundScreen(item, currencySpec, initialReason, checkout = checkout, onAbort = {}, onRefund = { _, _, _ -> }) } diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/refund/RefundManager.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/refund/RefundManager.kt @@ -17,50 +17,79 @@ package net.taler.merchantpos.refund import androidx.annotation.UiThread +import androidx.core.net.toUri import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch + import net.taler.common.Amount import net.taler.lib.android.assertUiThread -import net.taler.merchantlib.OrderHistoryEntry +import net.taler.merchantlib.CheckPaymentResponse import net.taler.merchantlib.MerchantApi +import net.taler.merchantlib.OrderHistoryEntry import net.taler.merchantlib.RefundRequest +import net.taler.merchantpos.checkout.CheckoutManager +import net.taler.merchantpos.checkout.ExternalRefundResult import net.taler.merchantpos.config.ConfigManager -sealed class RefundResult { - class Error(val msg: String) : RefundResult() +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +sealed class RefundResult +{ + class Error( + val msg: String, + ) : RefundResult() + + class ExternalCompleted( + val warning: String? = null, + ) : RefundResult() + object PastDeadline : RefundResult() + object AlreadyRefunded : RefundResult() + class Success( val refundUri: String, val item: OrderHistoryEntry, val amount: Amount, - val reason: String + val reason: String, ) : RefundResult() } class RefundManager( private val configManager: ConfigManager, private val scope: CoroutineScope, - private val api: MerchantApi -) { + private val api: MerchantApi, +) +{ private var refundStatusJob: Job? = null - - var toBeRefunded: OrderHistoryEntry? = null - private set - + private var refundMerchantUrl: String? = null + private val mSubmitting = MutableLiveData(false) private val mRefundResult = MutableLiveData<RefundResult?>() - internal val refundResult: LiveData<RefundResult?> = mRefundResult private val mPendingRefundOrderId = MutableLiveData<String?>(null) - internal val pendingRefundOrderId: LiveData<String?> = mPendingRefundOrderId private val mRefundReceived = MutableLiveData(false) + + internal val submitting: LiveData<Boolean> = mSubmitting + internal val refundResult: LiveData<RefundResult?> = mRefundResult + internal val pendingRefundOrderId: LiveData<String?> = mPendingRefundOrderId internal val refundReceived: LiveData<Boolean> = mRefundReceived + internal var preferTaler: Boolean = false + private set + + var toBeRefunded: OrderHistoryEntry? = null + private set + @UiThread - internal fun startRefund(item: OrderHistoryEntry) { + internal fun startRefund( + item: OrderHistoryEntry, + preferTaler: Boolean = false, + ) + { + if (mSubmitting.value == true) return + this.preferTaler = preferTaler refundStatusJob?.cancel() toBeRefunded = item mRefundResult.value = null @@ -69,7 +98,8 @@ class RefundManager( } @UiThread - internal fun abortRefund() { + internal fun abortRefund() + { refundStatusJob?.cancel() toBeRefunded = null mRefundResult.value = null @@ -78,7 +108,8 @@ class RefundManager( } @UiThread - internal fun completeRefund() { + internal fun completeRefund() + { refundStatusJob?.cancel() toBeRefunded = null mRefundResult.value = null @@ -87,74 +118,187 @@ class RefundManager( } @UiThread - internal fun debugSetRefundResult(result: RefundResult) { + internal fun debugSetRefundResult(result: RefundResult) + { mRefundResult.value = result } @UiThread - internal fun resumeRefund(item: OrderHistoryEntry): Boolean { - val current = mRefundResult.value as? RefundResult.Success ?: return false - if (current.item.orderId != item.orderId) return false + internal fun resumeRefund(item: OrderHistoryEntry): Boolean + { + val merchant = configManager.merchantConfig ?: return false + val current = mRefundResult.value as? RefundResult.Success + if (current?.item?.orderId != item.orderId || refundMerchantUrl != merchant.baseUrl) + { + if (!item.paid || !item.hasPendingRefund) return false + val amount = item.pendingRefundAmount ?: item.refundAmount ?: return false + if (amount <= Amount.zero(item.amount.currency)) return false + val uri = refundUri(merchant.baseUrl, item.orderId) ?: return false + // Recover an already approved refund; never POST another refund here. + mRefundResult.value = RefundResult.Success(uri, item, amount.withSpec(configManager.currencySpec), "") + refundMerchantUrl = merchant.baseUrl + mRefundReceived.value = false + } toBeRefunded = item mPendingRefundOrderId.value = item.orderId - if (mRefundReceived.value != true) { + if (mRefundReceived.value != true) + { observeRefundStatus(item.orderId) } return true } @UiThread - internal fun refund(item: OrderHistoryEntry, amount: Amount, reason: String) = scope.launch { - val merchantConfig = configManager.merchantConfig!! - val request = RefundRequest(amount, reason) - api.giveRefund(merchantConfig, item.orderId, request).handle(::onRefundError) { - assertUiThread() - mRefundResult.value = RefundResult.Success( - refundUri = it.talerRefundUri, - item = item, - amount = amount, - reason = reason - ) - mPendingRefundOrderId.value = item.orderId - mRefundReceived.value = false - observeRefundStatus(item.orderId) + internal fun refund( + item: OrderHistoryEntry, + amount: Amount, + reason: String, + ) = scope.launch { + if (mSubmitting.value == true) return@launch + val merchantConfig = configManager.merchantConfig ?: return@launch + if (amount.currency != item.amount.currency || + amount <= Amount.zero(item.amount.currency) || + amount > item.talerRefundRemaining()) + { + mRefundResult.value = RefundResult.AlreadyRefunded + return@launch + } + mSubmitting.value = true + val request = RefundRequest((item.refundAmount ?: Amount.zero(amount.currency)) + amount, reason) + try + { + api.giveRefund(merchantConfig, item.orderId, request).handle(::onRefundError) { + assertUiThread() + refundMerchantUrl = merchantConfig.baseUrl + mRefundResult.value = + RefundResult.Success( + refundUri = it.talerRefundUri, + item = item, + amount = amount, + reason = reason, + ) + mPendingRefundOrderId.value = item.orderId + mRefundReceived.value = false + observeRefundStatus(item.orderId) + } + } + finally + { + mSubmitting.value = false + } + } + + @UiThread + internal fun recordExternalRefund( + checkoutManager: CheckoutManager, + item: OrderHistoryEntry, + checkoutId: String, + paymentId: String, + amount: Amount, + reason: String, + ) = scope.launch { + if (mSubmitting.value == true) return@launch + val checkout = checkoutManager.find(checkoutId) ?: return@launch + val payment = item.amountExternal.orEmpty().find { it.id == paymentId } ?: return@launch + if (!item.paid || + checkout.backendOrderId != item.orderId || + amount.currency != payment.amount.currency || + amount <= Amount.zero(payment.amount.currency) || + amount > item.externalRefundRemaining(payment, checkout)) + { + return@launch } + mSubmitting.value = true + try + { + mRefundResult.value = + when (val result = checkoutManager.recordManualRefund(checkoutId, paymentId, amount, reason)) + { + ExternalRefundResult.Done -> RefundResult.ExternalCompleted() + is ExternalRefundResult.NotRecorded -> RefundResult.ExternalCompleted(result.message) + is ExternalRefundResult.Failed -> RefundResult.Error(result.message) + } + } + finally + { + mSubmitting.value = false + } + } + + @UiThread + internal fun clearResult() + { + mRefundResult.value = null } @UiThread - private fun onRefundError(msg: String) { + private fun onRefundError(msg: String) + { assertUiThread() refundStatusJob?.cancel() mPendingRefundOrderId.postValue(null) mRefundReceived.postValue(false) - if (msg.contains("2602")) { + if (msg.contains("2602")) + { mRefundResult.postValue(RefundResult.AlreadyRefunded) - } else mRefundResult.postValue(RefundResult.Error(msg)) + } + else + { + mRefundResult.postValue(RefundResult.Error(msg)) + } } @UiThread - private fun observeRefundStatus(orderId: String) { + private fun observeRefundStatus(orderId: String) + { refundStatusJob?.cancel() - refundStatusJob = scope.launch { - val merchantConfig = configManager.merchantConfig ?: return@launch - while (true) { - var wasRefunded = false - api.checkOrder(merchantConfig, orderId).handle(null) { response -> - assertUiThread() - val paidResponse = response as? net.taler.merchantlib.CheckPaymentResponse.Paid - if ( - paidResponse != null && - paidResponse.refunded && - !paidResponse.refundPending && - paidResponse.refundAmount?.isZero() == false - ) { - mRefundReceived.value = true - wasRefunded = true + refundStatusJob = + scope.launch { + val merchantConfig = configManager.merchantConfig ?: return@launch + while (true) + { + var wasRefunded = false + api.checkOrder(merchantConfig, orderId).handle(null) { response -> + assertUiThread() + val paidResponse = response as? CheckPaymentResponse.Paid + if (paidResponse != null && + paidResponse.refunded && + !paidResponse.refundPending && + paidResponse.refundAmount?.isZero() == false) + { + mRefundReceived.value = true + wasRefunded = true + } } + if (wasRefunded || + mRefundReceived.value == true) + { + break + } + delay(2_000) } - if (wasRefunded || mRefundReceived.value == true) break - delay(2_000) } - } } } + +/** Standard refund URI, scoped to the currently configured merchant instance. */ +internal fun refundUri(merchantUrl: String, orderId: String): String? +{ + val base = merchantUrl.toUri() + val scheme = + when (base.scheme) + { + "https" -> "taler" + "http" -> "taler+http" + else -> return null + } + if (base.host.isNullOrBlank() || base.userInfo != null || orderId.isBlank()) return null + val path = base.encodedPath.orEmpty().trimEnd('/') + return "$scheme://refund/${base.encodedAuthority}$path/" + .toUri() + .buildUpon() + .appendPath(orderId) + .appendPath("") + .build() + .toString() +} diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/refund/RefundUriFragment.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/refund/RefundUriFragment.kt @@ -16,25 +16,33 @@ package net.taler.merchantpos.refund +import android.content.res.Configuration import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup + import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.material3.Button +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView @@ -46,35 +54,45 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels -import net.taler.merchantpos.MainActivity + import net.taler.lib.android.AnimatedQrCodeComposable import net.taler.lib.android.TalerNfcService.Companion.hasNfc +import net.taler.merchantpos.MainActivity import net.taler.merchantpos.MainViewModel import net.taler.merchantpos.R import net.taler.merchantpos.compose.PosTheme +import net.taler.merchantpos.devices.TalerBleStatus +import net.taler.merchantpos.devices.TalerUsbStatus import net.taler.merchantpos.showPosError -class RefundUriFragment : Fragment() { - +class RefundUriFragment : Fragment() +{ private val model: MainViewModel by activityViewModels() private val refundManager by lazy { model.refundManager } override fun onCreateView( - inflater: android.view.LayoutInflater, - container: android.view.ViewGroup?, + inflater: LayoutInflater, + container: ViewGroup?, savedInstanceState: Bundle?, ) = ComposeView(requireContext()).apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) val result = refundManager.refundResult.value as? RefundResult.Success - if (result == null) { + if (result == null) + { requireActivity().showPosError(R.string.refund_state_missing) (requireActivity() as MainActivity).navigateBack() return@apply } setContent { + val usb by model.talerUsb.state.observeAsState() + val bluetooth by model.talerBluetooth.state.observeAsState() RefundUriScreen( result = result, - deviceHasNfc = hasNfc(requireContext()), + deviceHasNfc = + hasNfc(requireContext()) || bluetooth?.status == TalerBleStatus.Ready || ( + usb?.enabled == true && + usb?.devices?.any { it.status == TalerUsbStatus.Ready } == true + ), onAbort = { refundManager.abortRefund() (requireActivity() as MainActivity).navigateBack() @@ -83,10 +101,29 @@ class RefundUriFragment : Fragment() { } } - override fun onViewCreated(view: android.view.View, savedInstanceState: Bundle?) { + override fun onStart() + { + super.onStart() + model.talerUsb.present(this, (refundManager.refundResult.value as? RefundResult.Success)?.refundUri) + model.talerBluetooth.present(this, (refundManager.refundResult.value as? RefundResult.Success)?.refundUri) + } + + override fun onStop() + { + model.talerUsb.dismiss(this) + model.talerBluetooth.dismiss(this) + super.onStop() + } + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) + { super.onViewCreated(view, savedInstanceState) refundManager.refundReceived.observe(viewLifecycleOwner) { received -> - if (received == true) { + if (received == true) + { refundManager.completeRefund() (requireActivity() as MainActivity).apply { navigateBack() @@ -102,109 +139,123 @@ private fun RefundUriScreen( result: RefundResult.Success, deviceHasNfc: Boolean, onAbort: () -> Unit, -) { +) +{ PosTheme { - val introText = if (deviceHasNfc) { - stringResource(R.string.refund_intro_nfc) - } else { - stringResource(R.string.refund_intro) + val introText = + if (deviceHasNfc) + { + stringResource(R.string.refund_intro_nfc) + } + else + { + stringResource(R.string.refund_intro) + } + val landscape = + LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE + if (landscape) + { + LandscapeRefundUriScreen(result, introText, onAbort) } - val isTabletLayout = LocalConfiguration.current.smallestScreenWidthDp >= 720 - - if (isTabletLayout) { - TabletRefundUriScreen(result, introText, onAbort) - } else { - PhoneRefundUriScreen(result, introText, onAbort) + else + { + PortraitRefundUriScreen(result, introText, onAbort) } } } @Composable -private fun TabletRefundUriScreen( +private fun LandscapeRefundUriScreen( result: RefundResult.Success, introText: String, onAbort: () -> Unit, -) { - Row( - modifier = Modifier - .fillMaxSize() - .statusBarsPadding() - .padding(16.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { - Column( - modifier = Modifier - .weight(0.54f) - .fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, +) +{ + BoxWithConstraints(Modifier.fillMaxSize().statusBarsPadding(), contentAlignment = Alignment.TopCenter) { + Row( + modifier = Modifier.widthIn(max = 1100.dp).fillMaxSize().padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), ) { - Box( - modifier = Modifier - .fillMaxWidth() - .weight(1f), - contentAlignment = Alignment.Center, + Column( + modifier = + Modifier + .weight(0.54f) + .fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, ) { - BoxWithConstraints( - modifier = Modifier - .fillMaxSize() - .padding(12.dp), + Box( + modifier = + Modifier + .fillMaxWidth() + .weight(1f), contentAlignment = Alignment.Center, ) { - val qrSize = minOf(maxWidth, maxHeight) - Box( - modifier = Modifier.size(qrSize), + BoxWithConstraints( + modifier = + Modifier + .fillMaxSize() + .padding(12.dp), contentAlignment = Alignment.Center, ) { - AnimatedQrCodeComposable( - link = result.refundUri, - logoPainter = painterResource(R.drawable.ic_taler_logo_qr), - modifier = Modifier.fillMaxSize(), - ) + val qrSize = minOf(maxWidth, maxHeight, 480.dp) + Box( + modifier = Modifier.size(qrSize), + contentAlignment = Alignment.Center, + ) { + AnimatedQrCodeComposable( + link = result.refundUri, + logoPainter = painterResource(R.drawable.ic_taler_logo_qr), + modifier = Modifier.fillMaxSize(), + ) + } } } } - } - Column( - modifier = Modifier - .weight(0.46f) - .fillMaxSize() - .padding(horizontal = 8.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - text = introText, - style = MaterialTheme.typography.headlineSmall, - textAlign = TextAlign.Center, - ) - Text( - text = result.amount.toString(), - style = MaterialTheme.typography.headlineMedium, - textAlign = TextAlign.Center, - ) - Text( - text = stringResource( - R.string.refund_order_ref, - result.item.orderId, - result.reason, - ), - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center, - ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), + Column( + modifier = + Modifier + .weight(0.46f) + .fillMaxSize() + .padding(horizontal = 8.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, ) { - OutlinedButton( - onClick = onAbort, - modifier = Modifier.weight(1f), - colors = ButtonDefaults.outlinedButtonColors( - containerColor = MaterialTheme.colorScheme.error, - contentColor = MaterialTheme.colorScheme.onError, - ), + Text( + text = introText, + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center, + ) + Text( + text = result.amount.toString(), + style = MaterialTheme.typography.headlineMedium, + textAlign = TextAlign.Center, + ) + Text( + text = + stringResource( + R.string.refund_order_ref, + result.item.orderId, + result.reason, + ), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - Text(stringResource(R.string.refund_abort)) + OutlinedButton( + onClick = onAbort, + modifier = Modifier.weight(1f), + colors = + ButtonDefaults.outlinedButtonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + ) { + Text(stringResource(R.string.refund_abort)) + } } } } @@ -212,122 +263,58 @@ private fun TabletRefundUriScreen( } @Composable -private fun PhoneRefundUriScreen( +private fun PortraitRefundUriScreen( result: RefundResult.Success, introText: String, onAbort: () -> Unit, -) { - Row( - modifier = Modifier - .fillMaxSize() - .statusBarsPadding() - .padding(16.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { +) +{ + BoxWithConstraints(Modifier.fillMaxSize().statusBarsPadding(), contentAlignment = Alignment.TopCenter) { + val availableWidth = maxWidth + val availableHeight = maxHeight Column( - modifier = Modifier - .weight(0.5f) - .fillMaxSize(), - verticalArrangement = Arrangement.Center, + modifier = + Modifier + .widthIn( + max = 720.dp, + ).fillMaxSize() + .verticalScroll(rememberScrollState()) + .imePadding() + .padding(horizontal = 20.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { - Box( - modifier = Modifier - .fillMaxWidth() - .weight(1f), - contentAlignment = Alignment.Center, - ) { - BoxWithConstraints( - modifier = Modifier - .fillMaxSize() - .padding(12.dp), - contentAlignment = Alignment.Center, - ) { - val qrSize = minOf(maxWidth, maxHeight) - Box( - modifier = Modifier.size(qrSize), - contentAlignment = Alignment.Center, - ) { - AnimatedQrCodeComposable( - link = result.refundUri, - logoPainter = painterResource(R.drawable.ic_taler_logo_qr), - modifier = Modifier.fillMaxSize(), - ) - } - } - } - } - - Column( - modifier = Modifier - .weight(0.5f) - .fillMaxSize() - .padding(horizontal = 8.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - BoxWithConstraints( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - val verticalGap = (maxHeight * 0.02f).coerceIn(2.dp, 8.dp) - val isCompactHeight = maxHeight < 420.dp - val introStyle = if (isCompactHeight) { - MaterialTheme.typography.titleMedium - } else { - MaterialTheme.typography.headlineSmall - } - val amountStyle = if (isCompactHeight) { - MaterialTheme.typography.titleLarge - } else { - MaterialTheme.typography.headlineMedium - } - val detailsStyle = if (isCompactHeight) { - MaterialTheme.typography.bodyMedium - } else { - MaterialTheme.typography.bodyLarge - } - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(verticalGap, Alignment.CenterVertically), - ) { - Text( - text = introText, - style = introStyle, - textAlign = TextAlign.Center, - ) - Text( - text = result.amount.toString(), - style = amountStyle, - textAlign = TextAlign.Center, - ) - Text( - text = stringResource( - R.string.refund_order_ref, - result.item.orderId, - result.reason, - ), - style = detailsStyle, - textAlign = TextAlign.Center, - ) - OutlinedButton( - onClick = onAbort, - modifier = Modifier.fillMaxWidth(), - colors = ButtonDefaults.outlinedButtonColors( - containerColor = MaterialTheme.colorScheme.error, - contentColor = MaterialTheme.colorScheme.onError, - ), - ) { - Text(stringResource(R.string.refund_abort)) - } - } + val qrSize = minOf(availableWidth - 40.dp, availableHeight * 0.48f).coerceAtLeast(120.dp) + Box(Modifier.size(qrSize), contentAlignment = Alignment.Center) { + AnimatedQrCodeComposable( + link = result.refundUri, + logoPainter = painterResource(R.drawable.ic_taler_logo_qr), + modifier = Modifier.fillMaxSize(), + ) } + Text(introText, style = MaterialTheme.typography.titleLarge, textAlign = TextAlign.Center) + Text(result.amount.toString(), style = MaterialTheme.typography.headlineSmall, textAlign = TextAlign.Center) + Text( + stringResource(R.string.refund_order_ref, result.item.orderId, result.reason), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + ) + OutlinedButton( + onClick = onAbort, + modifier = Modifier.fillMaxWidth(), + colors = + ButtonDefaults.outlinedButtonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + ) { Text(stringResource(R.string.refund_abort)) } } } } @Composable -internal fun RefundQrScreenContent(result: RefundResult.Success) { +internal fun RefundQrScreenContent(result: RefundResult.Success) +{ RefundUriScreen( result = result, deviceHasNfc = false, diff --git a/merchant-terminal/src/main/res/drawable-night/ic_talerpos_logo.xml b/merchant-terminal/src/main/res/drawable-night/ic_talerpos_logo.xml @@ -0,0 +1,44 @@ +<!-- Taler POS logo from the Penpot design guide. Lettering is outlined. + The TALER-to-POS gap is twice the visible E-to-R gap. --> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="338.4dp" + android:height="104.2dp" + android:viewportWidth="1692" + android:viewportHeight="521"> + <path + android:fillColor="#FFFFFF" + android:fillType="evenOdd" + android:pathData="M503.7958984375,6.490478515625C594.1533203125,6.490478515625,672.62890625,60.91259765625,712.2265625,140.80322265625L678.5087890625,140.80322265625C641.8828125,78.86376953125,577.3291015625,37.732421875,503.7958984375,37.732421875C389.66796875,37.732421875,297.150390625,136.7919921875,297.150390625,258.987060546875C297.150390625,318.796630859375,319.32421875,373.05517578125,355.33984375,412.872802734375C347.560546875,419.361572265625,339.275390625,425.19091796875,330.55859375,430.278564453125C291.71484375,385.270263671875,267.970703125,325.103515625,267.970703125,258.987060546875C267.970703125,119.537353515625,373.552734375,6.490478515625,503.7958984375,6.490478515625ZM711.5947265625,378.453857421875C671.7958984375,457.646484375,593.6708984375,511.48388671875,503.7958984375,511.48388671875C497.6943359375,511.48388671875,491.646484375,511.2353515625,485.6611328125,510.74853515625C503.3525390625,501.7109375,519.9365234375,490.600830078125,535.1494140625,477.70947265625C594.970703125,467.958740234375,646.3525390625,430.740478515625,677.7421875,378.453857421875Z" /> + <path + android:fillColor="#FFFFFF" + android:fillType="evenOdd" + android:pathData="M373.67578125,6.490478515625C379.77734375,6.490478515625,385.82421875,6.73876953125,391.810546875,7.22607421875C374.119140625,16.263427734375,357.533203125,27.373291015625,342.3203125,40.264892578125C243.07421875,56.44091796875,167.03125,148.20458984375,167.03125,258.987060546875C167.03125,341.56298828125,209.287109375,413.56201171875,271.904296875,451.5771484375C262.666015625,453.1240234375,253.19921875,453.932861328125,243.556640625,453.932861328125C236.37109375,453.932861328125,229.2890625,453.47265625,222.322265625,452.605224609375C170.69140625,406.287353515625,137.8515625,336.7490234375,137.8515625,258.987060546875C137.8515625,119.537353515625,243.43359375,6.490478515625,373.67578125,6.490478515625ZM405.03125,477.70947265625C464.8525390625,467.958740234375,516.2333984375,430.73876953125,547.6240234375,378.451171875L581.4765625,378.451171875C541.6787109375,457.644775390625,463.552734375,511.48388671875,373.67578125,511.48388671875C367.57421875,511.48388671875,361.52734375,511.2353515625,355.541015625,510.74853515625C373.23046875,501.7099609375,389.818359375,490.601806640625,405.03125,477.70947265625ZM548.388671875,140.80322265625C530.1923828125,110.036865234375,505.1064453125,84.4033203125,475.4482421875,66.39697265625C484.685546875,64.85009765625,494.1513671875,64.041015625,503.7958984375,64.041015625C510.9794921875,64.041015625,518.0625,64.50146484375,525.029296875,65.368896484375C548.306640625,86.2509765625,567.7646484375,111.8544921875,582.1142578125,140.80322265625Z" /> + <path + android:fillColor="#FFFFFF" + android:fillType="evenOdd" + android:pathData="M243.556640625,6.490478515625C249.724609375,6.490478515625,255.837890625,6.747314453125,261.88671875,7.244140625C244.228515625,16.268798828125,227.671875,27.35791015625,212.484375,40.222412109375C113.099609375,56.271484375,36.912109375,148.099853515625,36.912109375,258.987060546875C36.912109375,381.18212890625,129.4296875,480.24169921875,243.556640625,480.24169921875C316.583984375,480.24169921875,380.76171875,439.6787109375,417.517578125,378.451171875L451.357421875,378.451171875C411.55859375,457.644775390625,333.43359375,511.48388671875,243.556640625,511.48388671875C113.314453125,511.48388671875,7.732421875,398.436767578125,7.732421875,258.987060546875C7.732421875,119.537353515625,113.314453125,6.490478515625,243.556640625,6.490478515625ZM418.263671875,140.80322265625C410.66015625,127.948486328125,401.85546875,115.98583984375,392.015625,105.10595703125C399.796875,98.616943359375,408.078125,92.784912109375,416.794921875,87.697265625C430.546875,103.63427734375,442.40625,121.473388671875,451.990234375,140.80322265625Z" /> + <path + android:fillColor="#FFFFFF" + android:pathData="M442.7822265625,199.190185546875L495.8798828125,199.190185546875L495.8798828125,169.99560546875L360.02734375,169.99560546875L360.02734375,199.190185546875L413.125,199.190185546875L413.125,349.256591796875L442.7822265625,349.256591796875Z" /> + <path + android:fillColor="#FFFFFF" + android:pathData="M538.4873046875,305.9794921875L617.6552734375,305.9794921875L634.6376953125,349.257568359375L665.7314453125,349.257568359375L592.0634765625,168.716552734375L564.798828125,168.716552734375L491.130859375,349.257568359375L521.267578125,349.257568359375ZM606.892578125,278.063720703125L549.251953125,278.063720703125L577.9521484375,206.35986328125Z" /> + <path + android:fillColor="#FFFFFF" + android:pathData="M719.083984375,169.9970703125L692.5361328125,169.9970703125L692.5361328125,349.257568359375L811.62890625,349.257568359375L811.62890625,320.83251953125C780.78125,320.83251953125,749.9326171875,320.83251953125,719.083984375,320.83251953125Z" /> + <path + android:fillColor="#FFFFFF" + android:pathData="M966.3759765625,169.9970703125L842.482421875,169.9970703125L842.482421875,349.257568359375L967.572265625,349.257568359375L967.572265625,320.83251953125L871.6611328125,320.83251953125L871.6611328125,272.942626953125L955.61328125,272.942626953125L955.61328125,244.518310546875L871.6611328125,244.518310546875L871.6611328125,198.42236328125L966.3759765625,198.42236328125Z" /> + <path + android:fillColor="#FFFFFF" + android:pathData="M1109.642578125,228.513916015625C1109.642578125,237.773193359375,1106.533203125,245.1591796875,1100.275390625,250.62109375C1094.056640625,256.127685546875,1085.6455078125,258.85791015625,1075.0810546875,258.85791015625L1031.9091796875,258.85791015625L1031.9091796875,198.42236328125L1074.841796875,198.42236328125C1085.8837890625,198.42236328125,1094.4541015625,200.94091796875,1100.5146484375,206.0185546875C1106.6142578125,211.056396484375,1109.642578125,218.5673828125,1109.642578125,228.513916015625ZM1144.8017578125,349.257568359375L1099.59765625,281.393310546875C1105.4970703125,279.686767578125,1110.87890625,277.25537109375,1115.7431640625,274.09619140625C1120.6044921875,270.9375,1124.7919921875,267.095703125,1128.298828125,262.572509765625C1131.8076171875,258.047119140625,1134.5576171875,252.8408203125,1136.55078125,246.9521484375C1138.54296875,241.0615234375,1139.5390625,234.35986328125,1139.5390625,226.847900390625C1139.5390625,218.140869140625,1138.1044921875,210.24560546875,1135.234375,203.15966796875C1132.3642578125,196.0751953125,1128.2587890625,190.0986328125,1122.91796875,185.23291015625C1117.5751953125,180.36767578125,1111.0390625,176.61181640625,1103.3056640625,173.965087890625C1095.5712890625,171.318115234375,1086.9208984375,169.9970703125,1077.3544921875,169.9970703125L1002.73046875,169.9970703125L1002.73046875,349.257568359375L1031.9091796875,349.257568359375L1031.9091796875,286.77294921875L1068.86328125,286.77294921875L1110.1201171875,349.257568359375Z" /> + <group + android:translateX="1192.8675365671643" + android:translateY="349.0016174316406" + android:scaleX="1.0" + android:scaleY="-1"> + <path + android:fillColor="#ffffff" + android:pathData="M22.25,0.0L22.25,177.5L95.75,177.5Q107.25,177.5 116.875,172.625Q126.5,167.75 133.5,159.875Q140.5,152.0 144.5,142.0Q148.5,132.0 148.5,121.75Q148.5,110.75 144.75,100.625Q141.0,90.5 134.25,82.75Q127.5,75.0 118.125,70.25Q108.75,65.5 97.25,65.5L39.75,65.5L39.75,0.0ZM39.75,81.0L96.5,81.0Q104.25,81.0 110.625,84.375Q117.0,87.75 121.5,93.375Q126.0,99.0 128.5,106.375Q131.0,113.75 131.0,121.75Q131.0,130.0 128.125,137.375Q125.25,144.75 120.375,150.25Q115.5,155.75 108.875,158.875Q102.25,162.0 95.0,162.0L39.75,162.0ZM249.25,-1.25Q230.75,-1.25 215.375,6.375Q200.0,14.0 189.0,26.625Q178.0,39.25 172.0,55.5Q166.0,71.75 166.0,88.75Q166.0,106.5 172.375,122.75Q178.75,139.0 190.0,151.5Q201.25,164.0 216.5,171.375Q231.75,178.75 249.5,178.75Q268.0,178.75 283.375,170.875Q298.75,163.0 309.625,150.25Q320.5,137.5 326.5,121.375Q332.5,105.25 332.5,88.5Q332.5,70.75 326.125,54.5Q319.75,38.25 308.625,25.875Q297.5,13.5 282.25,6.125Q267.0,-1.25 249.25,-1.25ZM183.5,88.75Q183.5,74.25 188.25,60.875Q193.0,47.5 201.75,37.125Q210.5,26.75 222.5,20.625Q234.5,14.5 249.25,14.5Q264.25,14.5 276.5,21.0Q288.75,27.5 297.25,38.0Q305.75,48.5 310.375,61.75Q315.0,75.0 315.0,88.75Q315.0,103.25 310.125,116.625Q305.25,130.0 296.5,140.25Q287.75,150.5 275.75,156.625Q263.75,162.75 249.25,162.75Q234.25,162.75 222.0,156.375Q209.75,150.0 201.25,139.5Q192.75,129.0 188.125,115.75Q183.5,102.5 183.5,88.75ZM469.25,144.25Q461.75,152.75 449.25,158.0Q436.75,163.25 420.75,163.25Q397.25,163.25 386.5,154.375Q375.75,145.5 375.75,130.25Q375.75,122.25 378.625,117.125Q381.5,112.0 387.5,108.25Q393.5,104.5 402.875,101.75Q412.25,99.0 425.0,96.25Q439.25,93.25 450.625,89.5Q462.0,85.75 470.0,80.125Q478.0,74.5 482.25,66.5Q486.5,58.5 486.5,46.5Q486.5,34.25 481.75,25.25Q477.0,16.25 468.5,10.25Q460.0,4.25 448.25,1.375Q436.5,-1.5 422.5,-1.5Q381.25,-1.5 351.25,24.25L360.0,38.5Q364.75,33.5 371.375,29.0Q378.0,24.5 386.125,21.125Q394.25,17.75 403.5,15.875Q412.75,14.0 423.0,14.0Q444.25,14.0 456.125,21.625Q468.0,29.25 468.0,44.75Q468.0,53.0 464.625,58.625Q461.25,64.25 454.5,68.375Q447.75,72.5 437.75,75.5Q427.75,78.5 414.5,81.5Q400.5,84.75 390.0,88.25Q379.5,91.75 372.25,97.0Q365.0,102.25 361.375,109.625Q357.75,117.0 357.75,127.75Q357.75,140.0 362.375,149.625Q367.0,159.25 375.5,165.625Q384.0,172.0 395.5,175.375Q407.0,178.75 421.0,178.75Q438.75,178.75 452.625,173.375Q466.5,168.0 477.75,158.25Z" /> + </group> +</vector> diff --git a/merchant-terminal/src/main/res/drawable/ic_taler_logo.xml b/merchant-terminal/src/main/res/drawable/ic_taler_logo.xml @@ -0,0 +1,39 @@ +<!-- + ~ 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/> + --> +<!-- The Taler mark from the app icon, without the POS lettering, centred on a square + canvas and drawn in one colour so it can be tinted to whatever it sits on. --> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="723.90" + android:viewportHeight="723.90"> + <group + android:translateX="-150.00" + android:translateY="3.45"> + <path + android:pathData="M617,358.5C617,373.9 614.2,388.8 613.3,395.2C612.3,401.7 611.1,408 609.7,414.3C608.3,420.6 606.6,426.9 604.7,433.1C602.9,439.3 600.7,445.4 598.4,451.4C596.1,457.4 593.5,463.3 590.8,469.1C588,474.9 585,480.6 581.8,486.1C578.7,491.7 575.2,497 571.7,502.3C568,507.6 564.3,512.7 560.3,517.6C556.4,522.6 552.2,527.4 547.9,532C543.6,536.6 539.1,540.9 534.5,545.2H534.4C533.4,546.1 532.3,547 531.2,547.9C529.9,549.1 528.5,550.2 527.2,551.4C524.9,553.4 522.5,555.4 520.1,557.3C516.4,560.1 512.7,562.8 508.9,565.4C507.6,566.3 506.2,567.2 504.9,568.1C500.6,570.9 496.2,573.6 491.8,576.1C490.8,576.6 489.9,577.1 488.9,577.6C483.5,580.6 478,583.3 472.4,585.8C466.7,588.3 460.9,590.5 455.1,592.5C450.3,594.2 445.4,595.7 440.5,597.1C439.5,597.3 438.5,597.6 437.5,597.8C431.6,599.4 425.6,600.6 419.6,601.6C415.4,602.3 411.2,602.9 407,603.3C405.2,603.6 403.3,603.8 401.4,603.9C395.5,604.4 389.4,604.7 383.4,604.7H383.2C381.1,604.7 379.1,604.6 376.9,604.6C374.9,604.5 372.8,604.5 370.7,604.4C368.7,604.2 366.6,604.1 364.6,603.9C362.5,603.7 360.5,603.5 358.4,603.3C356.4,603 354.3,602.7 352.2,602.5C350.2,602.2 348.2,601.9 346.1,601.5C344.1,601.2 342,600.8 340,600.4C338,600 335.9,599.5 333.9,599.1C331.9,598.6 329.9,598.1 327.9,597.6C325.9,597.1 323.8,596.5 321.9,595.9C319.9,595.3 317.9,594.7 315.9,594.1C313.9,593.5 312,592.8 310,592.1C308,591.4 306.1,590.7 304.1,590C302.2,589.2 300.3,588.4 298.3,587.6C296.4,586.8 294.5,586 292.6,585.1C290.7,584.3 288.8,583.4 286.9,582.5C285,581.6 283.1,580.7 281.3,579.7C280.3,579.2 279.3,578.6 278.3,578.1C277.5,577.6 276.6,577.2 275.7,576.7C273.9,575.7 272.1,574.6 270.3,573.6C268.5,572.5 266.7,571.4 264.9,570.3C263.1,569.2 261.3,568 259.6,566.8C257.9,565.7 256.1,564.5 254.4,563.3C252.6,562.1 251,560.8 249.3,559.6C247.6,558.3 245.9,557 244.2,555.7C242.6,554.4 240.9,553 239.3,551.7C237.7,550.3 236.1,548.9 234.5,547.5C232.9,546.1 231.3,544.7 229.8,543.2C228.2,541.8 226.7,540.3 225.2,538.9C223.6,537.3 222.1,535.8 220.6,534.3C219.1,532.7 217.7,531.2 216.3,529.6C214.8,528 213.4,526.4 212,524.8C210.6,523.2 209.2,521.5 207.9,519.9C206.5,518.2 205.2,516.5 203.8,514.9C202.5,513.2 201.2,511.4 199.9,509.7C198.6,508 197.4,506.2 196.2,504.4C195,502.7 193.7,500.9 192.5,499.1C191.3,497.3 190.2,495.5 189,493.7C187.9,491.8 186.8,489.9 185.7,488.1C184.6,486.2 183.5,484.3 182.5,482.4C181.4,480.5 180.4,478.6 179.4,476.7C178.4,474.8 177.4,472.8 176.4,470.9C175.5,468.9 174.5,466.9 173.7,465C172.8,463 171.8,461 171,459C170.2,457 169.3,454.9 168.5,452.9C167.7,450.9 167,448.9 166.2,446.8C165.4,444.7 164.7,442.7 164,440.6C163.3,438.5 162.6,436.5 162,434.4C161.4,432.3 160.7,430.2 160.1,428C159.5,425.9 158.9,423.9 158.4,421.7C157.9,419.6 157.3,417.4 156.8,415.3C156.4,413.2 155.9,411 155.4,408.9C155,406.7 154.6,404.5 154.2,402.4C153.8,400.2 153.5,398 153.2,395.9C152.8,393.7 152.5,391.5 152.2,389.3C152,387.1 151.7,385 151.5,382.8C151.3,380.6 151.1,378.4 150.9,376.2C150.8,374.2 150.2,367 150.2,365.4C150.1,363.1 150,360.8 150,358.5C150,353.4 150.2,348.3 150.5,343.3C152.5,307.7 161.6,273.2 176.8,242C177.1,241.5 177.3,241 177.5,240.5C180.1,235.3 182.9,230.3 185.7,225.3C188.5,220.6 191.5,215.9 194.6,211.4H194.7C197.5,207.1 200.6,202.9 203.8,198.9C206.3,195.6 208.9,192.4 211.5,189.3C211.9,188.9 212.3,188.5 212.7,188C214.6,185.8 216.4,183.5 218.4,181.4C236.1,162.4 256.4,146.8 278.3,135C287.2,130.1 296.2,125.9 305.6,122.4C306.7,121.9 307.9,121.6 309,121.2C327.6,114.4 347.1,110.2 367,108.7C372.5,108.3 378,108 383.5,108C386.3,108 389,108.1 391.8,108.1L390.8,108.7L390.6,108.8C374.3,117.7 358.8,128.3 344.4,140.7C332.3,143.4 320.5,147.3 309,152.3C307.9,152.9 306.7,153.3 305.6,153.8C296.2,158.2 287,163.2 278.3,169.1C264.8,178.1 252.1,188.8 240.8,201.1L240,201.9C237.7,204.4 235.5,207 233.3,209.6C230.8,212.5 228.4,215.5 226,218.5C224.4,220.7 222.8,222.9 221.2,225.1C221,225.4 220.8,225.6 220.6,225.9C219.8,227.1 218.9,228.2 218.1,229.4C215,233.9 212.2,238.5 209.5,243.2C206.7,248.1 204.1,253.1 201.7,258.2C199.2,263.3 197,268.5 195,273.9C192.9,279.1 191,284.5 189.4,290C187.7,295.5 186.2,301 185,306.6C183.8,312.2 182.7,317.8 181.9,323.5C181,329.2 180.4,334.8 180,340.6C179.9,341.5 179.9,342.4 179.8,343.3C179.5,348.1 179.4,353 179.4,357.8C179.4,359.7 179.4,361.7 179.4,363.6C179.4,365.6 179.5,367.5 179.6,369.5V370.3C179.8,372 179.9,373.7 180,375.3C180.2,377.2 180.3,379.2 180.5,381.1C180.7,383.1 180.9,385 181.1,386.9C181.4,388.9 181.6,390.8 181.9,392.7C182.2,394.6 182.6,396.6 182.9,398.4C183.3,400.3 183.6,402.3 183.9,404.2C184.3,406.1 184.7,408 185.2,409.9C185.6,411.8 186.1,413.6 186.6,415.5C187,417.4 187.6,419.3 188.1,421.1C188.6,423 189.1,424.8 189.7,426.7C190.3,428.6 190.9,430.4 191.5,432.2C192.1,434.1 192.7,435.8 193.4,437.7C194,439.5 194.7,441.3 195.5,443.1C196.2,444.9 196.9,446.7 197.6,448.5C198.4,450.2 199.1,452 199.9,453.7C200.7,455.5 201.5,457.2 202.4,459C203.2,460.7 204.1,462.4 205,464.1C205.8,465.8 206.8,467.5 207.7,469.2C208.6,470.8 209.5,472.5 210.5,474.2C211.5,475.8 212.4,477.4 213.5,479.1C214.4,480.7 215.5,482.3 216.5,483.9C217.5,485.5 218.6,487.1 219.7,488.6C220.8,490.2 221.9,491.7 223,493.3C224.1,494.8 225.3,496.3 226.4,497.8C227.6,499.3 228.8,500.8 230,502.3C231.2,503.7 232.4,505.2 233.6,506.6C234.9,508 236.1,509.4 237.4,510.8C238.6,512.2 239.9,513.6 241.2,514.9C242.5,516.3 243.9,517.6 245.2,519C246.5,520.3 247.9,521.6 249.3,522.9C250,523.6 250.8,524.3 251.6,525C252.2,525.6 252.7,526.1 253.4,526.6C253.4,526.7 253.4,526.7 253.4,526.8C254.8,528 256.2,529.2 257.6,530.3C258.4,531 259.3,531.7 260.1,532.4C260.7,532.9 261.3,533.4 261.9,533.9C262.2,534.1 262.5,534.3 262.8,534.5C267.8,538.6 272.9,542.6 278.3,546.2C310.1,567.4 346.9,578.9 384.9,578.9C389.8,578.9 394.8,578.7 399.8,578.4C402.2,578.1 404.6,577.8 407,577.5C408.9,577.3 410.9,577.1 412.8,576.8C425.8,574.9 438.5,571.6 450.8,567C463.8,562.2 476.3,555.9 488.2,548.4L489.7,547.4C492.3,545.7 494.8,543.8 497.4,542C498.7,541 500.1,540.1 501.5,539.1C505.5,536 509.6,532.8 513.4,529.3C518.9,524.6 524,519.5 529,514.1C564.4,475.6 585.5,424.4 588.3,370.3C588.6,366.1 588.7,361.9 588.7,357.6C588.7,352.8 588.5,348 588.2,343.3C585.1,289.6 564.3,241.1 532.1,204.6L530.7,203C532.6,201.5 534.4,199.9 536.4,198.4C540.2,195.5 544,192.7 548,190C549.8,188.8 551.5,187.7 553.4,186.6C589.2,227.4 612.4,281 616.3,340.3C616.4,341.3 616.5,342.3 616.6,343.3C616.6,343.7 616.6,344.2 616.6,344.6C616.8,349.2 617,353.8 617,358.5Z" + android:fillColor="#FF000000" /> + <path + android:pathData="M506.3,112.5C497.5,118 488.9,124.1 480.7,130.7C477.3,133.4 473.9,136.2 470.6,139.1C462.3,141 454.1,143.5 446.1,146.5C445.1,146.9 443.9,147.3 442.8,147.7C440,148.8 437.1,150 434.3,151.3C433.9,151.5 433.6,151.6 433.2,151.8C432.2,152.3 431.2,152.8 430.2,153.3C428.1,154.3 425.9,155.4 423.8,156.5C422.6,157 421.5,157.7 420.3,158.3C418.4,159.4 416.4,160.5 414.6,161.6C413.4,162.3 412.3,163 411.1,163.8C410.4,164.2 409.7,164.5 409.1,165C408.4,165.4 407.7,165.9 407,166.4C406.9,166.4 406.8,166.6 406.6,166.6C404.5,168.1 402.4,169.5 400.3,171C399,171.9 397.6,172.9 396.3,173.9C394.2,175.4 392.2,177.1 390.1,178.7C388.9,179.7 387.7,180.7 386.5,181.8C384.3,183.6 382.2,185.5 380.1,187.4C379.1,188.4 378,189.3 377.1,190.2C374,193.2 371,196.2 368.1,199.3C356.6,211.8 346.6,225.6 338.3,240.5C329.8,255.6 323,271.8 318.1,288.6L317.6,290C316,295.5 314.5,301 313.2,306.6C312,312.2 310.9,317.8 310.1,323.5C309.6,326.9 309.3,330.4 309,333.9C308.8,336.1 308.4,338.3 308.3,340.6C308.2,341.5 308.1,342.4 308.1,343.3C307.7,348.1 307.6,353 307.6,357.8C307.6,359.7 307.6,361.7 307.7,363.6C307.7,365.6 307.8,367.5 307.9,369.5V370.3C308,372 308.1,373.7 308.3,375.3C308.4,377.2 308.6,379.2 308.8,381.1C308.9,383.1 309.2,385 309.4,386.9C309.7,388.9 309.9,390.8 310.2,392.7C310.5,394.6 310.8,396.6 311.1,398.4C311.5,400.3 311.9,402.3 312.2,404.2C312.6,406.1 313,408 313.5,409.9C313.9,411.8 314.3,413.6 314.8,415.5C315.3,417.4 315.8,419.3 316.3,421.1C316.9,423 317.4,424.8 317.9,426.7C318.5,428.6 319.1,430.4 319.7,432.2C320.3,434.1 321,435.8 321.7,437.7C322.3,439.5 323,441.3 323.7,443.1C324.4,444.9 325.2,446.7 325.9,448.5C326.7,450.2 327.4,452 328.2,453.7C329,455.5 329.8,457.2 330.7,459C331.5,460.7 332.3,462.4 333.3,464.1C334.1,465.8 335,467.5 335.9,469.2C336.9,470.8 337.8,472.5 338.8,474.2C339.7,475.8 340.7,477.4 341.7,479.1C342.7,480.7 343.7,482.3 344.8,483.9C345.8,485.5 346.9,487.1 348,488.6C349.1,490.2 350.2,491.7 351.3,493.3C352.4,494.8 353.5,496.3 354.7,497.8C355.8,499.3 357,500.8 358.2,502.3C359.5,503.7 360.7,505.2 361.9,506.6C363.1,508 364.4,509.4 365.6,510.8C366.9,512.2 368.2,513.6 369.5,514.9C370.7,516.3 372.1,517.6 373.4,519C374.8,520.3 376.1,521.6 377.5,522.9C378.8,524.2 380.2,525.4 381.6,526.6C383,527.9 384.5,529.2 385.9,530.3C387.3,531.5 388.8,532.7 390.2,533.9C391.7,535.1 393.1,536.2 394.6,537.3C396.1,538.4 397.6,539.5 399.1,540.6C400.6,541.7 402.2,542.7 403.7,543.7C404.8,544.5 405.9,545.2 407,545.9C407.4,546.2 407.9,546.5 408.4,546.8C409.4,547.5 410.4,548.1 411.5,548.7C410,548.9 408.5,549.1 407,549.2C404.4,549.6 401.9,550 399.3,550.2C394.6,550.6 390,550.8 385.3,550.8C383.7,550.8 382.1,550.8 380.5,550.7C378.9,550.7 377.3,550.6 375.7,550.5C374.2,550.4 372.6,550.3 371,550.2C369.4,550 367.9,549.9 366.3,549.7C364.7,549.5 363.1,549.3 361.6,549.1C361,549 360.5,548.9 359.9,548.8C355.4,544.6 351,540.2 346.7,535.6C322,509.2 303.4,477.5 291.9,442.9C291.2,440.7 290.5,438.5 289.8,436.2C289.7,435.8 289.6,435.4 289.5,435C288.8,432.7 288.2,430.4 287.6,428.2C286.9,425.7 286.3,423.3 285.8,420.9C285.3,418.7 284.7,416.5 284.3,414.3C283.8,411.8 283.3,409.2 282.8,406.6C282.4,404.5 282.1,402.3 281.8,400.2C281.4,398 281.1,395.9 280.8,393.7C280.6,391.5 280.3,389.3 280.1,387.1C279.9,384.9 279.7,382.7 279.5,380.5C279.4,378.3 279.2,376.1 279.1,374C279.1,372.8 279,371.5 279,370.3C279,369.3 278.9,368.3 278.9,367.3C278.8,365.1 278.8,362.9 278.8,360.7C278.8,354.9 279,349.1 279.4,343.3C279.4,342.6 279.5,341.9 279.5,341.3C280,334.7 280.7,328.3 281.6,321.9C282.6,315.5 283.8,309.1 285.3,302.7C286.6,296.4 288.3,290.2 290.2,284C292.1,277.8 294.2,271.7 296.6,265.7C298.8,259.7 301.4,253.8 304.2,248C304.6,247 305.1,246.1 305.6,245.2C306.4,243.6 307.1,242 307.9,240.5C308.3,239.9 308.7,239.2 309,238.5C310.3,236 311.7,233.5 313.1,231C316.3,225.4 319.7,220 323.3,214.7C326.9,209.5 330.7,204.4 334.6,199.5C338.6,194.5 342.7,189.7 347,185.1C351.4,180.6 355.8,176.1 360.5,171.9C363.5,169.1 366.7,166.6 369.9,163.9C371.6,162.6 373.2,161.1 374.8,159.8C379.8,156 384.9,152.4 390,149C395.1,145.7 400.3,142.6 405.6,139.7L406,139.5C406.4,139.3 406.6,139.1 407,139C407.7,138.6 408.4,138.2 409.2,137.8C409.6,137.6 410,137.4 410.5,137.1C414.5,135.1 418.5,133.1 422.6,131.3C426.5,129.6 430.4,128.1 434.3,126.6C436.1,125.9 437.9,125.2 439.8,124.6C444.8,122.9 449.8,121.3 454.9,119.9C455.7,119.7 456.6,119.5 457.4,119.2C463.3,117.8 469.3,116.5 475.3,115.5C481.4,114.4 487.4,113.7 493.5,113.2C497.8,112.8 502,112.6 506.3,112.5Z" + android:fillColor="#FF000000" /> + <path + android:pathData="M744.8,360.7C744.8,363.9 744.7,367.1 744.6,370.3C744.5,373.6 744.2,376.9 744,380.2C743.6,386.7 742.8,393.1 741.9,399.5C741,405.3 739.9,411 738.7,416.7C738.5,417.7 738.3,418.6 738.1,419.6C737.5,422.2 736.9,424.7 736.2,427.2C734.9,432.2 733.5,437.1 731.9,441.9C720.5,476.9 701.8,509 676.9,535.6C661.8,551.8 644.9,565.6 626.6,576.6H626.8C623.8,578.5 620.7,580.3 617.5,582C617.4,582 617.2,582.2 617,582.2C611.7,585.1 606.4,587.7 600.9,590.1C595.3,592.6 589.5,594.8 583.8,596.8C581.2,597.8 578.7,598.6 576.2,599.4C555.4,605.7 533.8,609 511.8,609H509C507.8,609 506.5,608.9 505.3,608.9H505.1C517.5,602 529.5,594 540.9,585.1C546.1,581 551.2,576.6 556.2,572.1L556.3,571.9C563.1,570.3 569.8,568.3 576.4,565.9L576.6,566C581.3,564.4 586,562.5 590.6,560.5C595.6,558.2 600.4,555.8 605.2,553.3C609.2,551 613.2,548.6 617.1,546.1C617.8,545.7 618.5,545.3 619.2,544.8C623.8,541.8 628.3,538.6 632.6,535.2C636.9,531.9 641.1,528.4 645.2,524.6C649.2,520.8 653.2,516.9 657,512.9C660.8,508.9 664.4,504.6 667.9,500.3C671.4,495.9 674.7,491.4 677.8,486.7C681,482.1 684,477.3 686.7,472.4C689.5,467.5 692.2,462.5 694.6,457.4C697.1,452.2 699.3,447 701.4,441.8C703.4,436.4 705.3,431 706.9,425.6C708.6,420.1 710,414.6 711.3,409C712.5,403.4 713.6,397.8 714.4,392.1C715.2,386.5 716,370.3 716,370.3C716.2,366.5 716.3,362.7 716.3,358.9C716.3,354.4 716.2,350 715.9,345.7C715.9,344.9 715.7,344.1 715.7,343.3C714.8,325.5 712,308.3 707.5,291.9C707.3,291.3 707.1,290.7 706.9,290C706.9,290 706.9,289.9 706.9,289.9C692.1,238.3 660.4,194.8 618.8,167.1C618.2,166.7 617.6,166.3 617.1,166C616.8,165.8 616.6,165.7 616.4,165.5C615.5,165 614.6,164.4 613.7,163.8C614.5,163.6 615.5,163.6 616.4,163.4C616.6,163.4 616.8,163.4 617.1,163.3C620.5,162.8 624,162.3 627.4,162C632,161.6 636.7,161.4 641.4,161.4C644.7,161.4 647.9,161.5 651.2,161.7C653.1,163.3 655.1,164.9 657,166.5C659.1,168.3 661.1,170.1 663.1,171.9C667.8,176.1 672.2,180.6 676.6,185.1C680.9,189.7 685,194.5 689,199.5C692.9,204.4 696.7,209.5 700.3,214.7C703.9,220 707.3,225.4 710.5,231C713.7,236.5 716.7,242.2 719.4,248C722.2,253.8 724.8,259.7 727,265.7C729.4,271.7 731.5,277.8 733.4,284C735.3,290.2 737,296.4 738.3,302.7C739.8,309.1 741,315.5 742,321.9C742.9,328.3 743.6,334.7 744.1,341.3C744.1,341.9 744.2,342.6 744.2,343.3C744.6,349.1 744.8,354.9 744.8,360.7H744.8Z" + android:fillColor="#FF000000" /> + <path + android:pathData="M873.8,351.2C873.7,348.8 873.7,346.4 873.5,344C873.5,344.4 873.6,344.8 873.6,345.1C873.6,344.5 873.5,343.8 873.5,343.2C868.4,251.8 817.6,173.6 745.2,134.6C745.2,134.6 745.1,134.5 745,134.4C723,122.6 698.9,114.3 673.5,110.4C668.7,109.7 663.7,109.1 658.7,108.7C658.2,108.7 657.6,108.7 657.1,108.6C656.1,108.6 655.1,108.5 654.2,108.5C651,108.3 647.9,108.2 644.7,108.1C643.6,108.1 642.4,108.1 641.3,108.1C639,108.1 636.9,108.1 634.6,108.2C633.2,108.2 631.9,108.3 630.5,108.3C628.8,108.4 627.1,108.5 625.4,108.7C624.6,108.7 623.8,108.7 623,108.9C621,109 619,109.3 617.1,109.5C616.8,109.5 616.6,109.5 616.4,109.5C612.5,110 608.6,110.5 604.8,111.2C598.8,112.2 592.8,113.4 586.9,114.9C583.5,115.8 580.1,116.8 576.7,117.8C574.3,118.6 571.9,119.4 569.5,120.2C569.5,120.2 569.3,120.2 569.3,120.2C563.5,122.2 557.7,124.5 552.1,127C546.5,129.5 540.9,132.2 535.5,135.1C535.1,135.4 534.7,135.6 534.3,135.8C532.4,136.9 530.4,137.9 528.5,139C528.5,139 528.4,139 528.4,139.1C525.4,140.9 522.5,142.7 519.5,144.6C517.1,146.1 514.7,147.8 512.4,149.5C509.6,151.4 507,153.4 504.3,155.4C501.5,157.6 498.9,159.9 496.3,162.2C494.1,164 492,165.6 490,167.5C489.9,167.6 489.8,167.6 489.7,167.7C489.3,168.2 488.8,168.6 488.3,169C484.2,172.8 480.3,176.7 476.5,180.7C475.1,182.2 473.8,183.8 472.5,185.3C472,185.8 471.5,186.3 471.1,186.9C470.2,188 469.3,188.9 468.5,190C467,191.7 465.5,193.3 464.1,195C463.6,195.6 463.2,196.3 462.7,196.9C459.9,200.5 457,204.1 454.4,207.9C453,210 451.7,212.2 450.3,214.2C447.2,218.8 444.4,223.5 441.6,228.3C439.4,232.2 437.2,236.2 435.2,240.3C434.9,240.9 434.6,241.5 434.3,242C434.1,242.5 433.9,243 433.6,243.5C430.9,249.3 428.3,255.2 426,261.2C423.6,267.2 421.5,273.3 419.7,279.5C417.8,285.7 416.1,291.9 414.7,298.2C413.2,304.6 412,310.9 411.1,317.3C410.1,323.8 409.1,330.1 408.6,336.6C408.4,338.7 407.6,350.5 407.5,352.6C407.3,357 407.3,370.2 407.3,370.2C409.9,428.9 431.7,484.9 468.9,528.1C470.1,527.4 471.3,526.6 472.6,525.8C474.6,524.6 476.7,523.4 478.7,522.1C482.7,519.5 486.5,516.7 490.3,513.7C490.9,513.2 491.6,512.6 492.2,512.1C492.9,511.5 493.6,511 494.2,510.5C493,509.1 491.8,507.8 490.6,506.4C489.4,505 488.2,503.6 487,502.1C485.8,500.6 484.6,499.2 483.4,497.7C482.3,496.2 481.1,494.6 480,493.1C478.8,491.6 477.7,490 476.7,488.5C475.6,486.9 474.5,485.3 473.4,483.7C472.4,482.1 471.4,480.5 470.3,478.9C469.3,477.3 468.3,475.6 467.4,474C466.4,472.3 465.5,470.7 464.6,469C463.7,467.3 462.8,465.6 461.9,463.9C461.1,462.2 460.2,460.5 459.3,458.8C458.5,457.1 457.7,455.3 456.9,453.6C456.1,451.8 455.3,450.1 454.6,448.3C453.8,446.5 453.1,444.7 452.4,443C451.7,441.2 451,439.3 450.3,437.6C449.6,435.7 449,434 448.4,432.1C447.8,430.3 447.2,428.4 446.7,426.6C446.1,424.8 445.5,422.9 445,421C444.5,419.2 444,417.3 443.5,415.4C443.1,413.5 442.6,411.6 442.1,409.7C441.7,407.9 441.3,406 440.9,404.1C440.5,402.2 440.2,400.3 439.8,398.3C439.5,396.4 439.2,394.5 438.9,392.6C438.6,390.6 438.4,388.8 438.1,386.8C437.9,384.9 437.7,383 437.5,381C437.3,379.1 437.1,377.1 437,375.2C436.9,374.2 436.9,373.1 436.7,372.1C436.7,371.5 436.7,370.9 436.6,370.3V369.4C436.5,367.5 436.5,365.5 436.4,363.6C436.4,361.6 436.3,359.7 436.3,357.7C436.3,352.9 436.5,348 436.8,343.2C436.8,342.8 436.9,342.2 436.9,341.8C436.9,341.4 436.9,341 437,340.5C437.4,334.8 438,329.1 438.9,323.4C439.7,317.7 440.8,312.1 442,306.5C443.3,300.9 444.7,295.4 446.4,289.9C448,284.5 449.9,279.1 451.9,273.8C454,268.5 456.2,263.3 458.6,258.1C461.1,253 463.7,247.9 466.5,243.1C467,242.2 467.5,241.2 468.1,240.4C470.4,236.4 472.8,232.6 475.4,228.7C478.5,224.1 481.9,219.6 485.4,215.2C488.9,210.8 492.5,206.6 496.3,202.5C500,198.4 504,194.5 508.1,190.8C509.5,189.5 511,188.2 512.5,186.9C515.2,184.6 517.9,182.3 520.7,180.2C525,176.8 529.5,173.6 534,170.6C534.8,170.1 535.7,169.5 536.5,169C539,167.4 541.6,165.8 544.3,164.3C545.5,163.6 546.8,162.9 548,162.2C552.8,159.6 557.7,157.2 562.7,155C567.2,153 571.9,151.1 576.6,149.4L577.7,149C579.1,148.6 580.5,148 581.9,147.6C580.8,147.1 580.8,147.1 581.9,147.6C585.7,146.4 589.4,145.3 593.2,144.3C598.4,143 603.7,141.8 608.9,140.9C611.4,140.5 613.9,140.2 616.3,139.8C616.6,139.8 616.8,139.8 617,139.7C617.4,139.7 617.8,139.6 618.2,139.5C620.4,139.3 622.5,139.1 624.7,138.9H624.9C630.3,138.4 635.6,138.2 641,138.2C646.3,138.2 651.7,138.4 657,138.9C662.4,139.3 667.7,140 673,140.9C675.4,141.3 677.8,141.7 680.2,142.2C683.1,142.8 685.9,143.5 688.7,144.2C694,145.6 699.1,147.2 704.2,148.9C709.3,150.7 714.4,152.7 719.3,154.9C724.2,157 729.1,159.4 733.9,162.1C737.7,164.2 741.4,166.5 745,168.8C745.1,168.8 745.2,168.9 745.2,168.9C746.1,169.5 747,169.9 747.9,170.5C752.4,173.4 756.9,176.7 761.2,180C765.6,183.4 769.8,187 773.8,190.7C777.9,194.4 781.9,198.3 785.6,202.4C789.4,206.5 793.1,210.7 796.5,215.1C800,219.4 803.4,224 806.5,228.6C809.6,233.3 812.6,238 815.4,243C818.2,247.8 820.8,252.9 823.3,258C825.7,263.1 828,268.3 830,273.6C831.9,278.7 833.6,283.8 835.2,288.9C835.5,289.8 835.8,290.6 836,291.5C840.6,307.9 843.4,325.2 844.4,343C844.4,343.7 844.5,344.4 844.5,345C844.8,349.5 844.9,354.1 844.9,358.6C844.9,362.5 844.8,366.3 844.6,370.1C844.6,370.1 843.9,386.2 843.1,391.9C842.2,397.6 841.2,403.2 839.9,408.8C838.7,414.4 837.2,420 835.6,425.4C833.9,430.9 832.1,436.2 830,441.6C828,446.9 825.7,452.1 823.3,457.2C820.9,462.3 818.3,467.4 815.5,472.3C812.6,477.1 809.7,482 806.5,486.6C803.4,491.2 800,495.8 796.6,500.1C793.1,504.5 789.5,508.7 785.7,512.8C781.9,516.8 778,520.7 773.9,524.5C769.8,528.2 765.6,531.7 761.3,535.1C756.9,538.5 752.5,541.7 747.9,544.7C747.1,545.3 746.1,545.7 745.3,546.3C741.6,548.7 737.8,551 733.9,553.1C729.1,555.7 724.2,558.1 719.3,560.3C714.4,562.5 709.3,564.5 704.2,566.3C699.9,567.8 695.4,569.1 690.9,570.3C675.6,585.3 658.9,598.2 641,608.8C677.6,608.8 713.2,599.4 745.2,582.1C767.3,570.2 787.8,554.6 805.5,535.4C846.7,491.2 870.9,432.2 873.7,370.1C873.7,369.3 873.7,368.3 873.7,367.5C873.8,364.4 873.9,361.3 873.9,358.3C873.9,355.9 873.8,353.5 873.7,351.1L873.8,351.2Z" + android:fillColor="#FF000000" /> + </group> +</vector> diff --git a/merchant-terminal/src/main/res/drawable/ic_taler_logo_brand.xml b/merchant-terminal/src/main/res/drawable/ic_taler_logo_brand.xml @@ -0,0 +1,54 @@ +<!-- + ~ This file is part of GNU Taler + ~ (C) 2026 Taler Systems S.A. + ~ + ~ GNU Taler is free software; you can redistribute it and/or modify it under the + ~ terms of the GNU General Public License as published by the Free Software + ~ Foundation; either version 3, or (at your option) any later version. + ~ + ~ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + ~ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + ~ A PARTICULAR PURPOSE. See the GNU General Public License for more details. + ~ + ~ You should have received a copy of the GNU General Public License along with + ~ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + --> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="201dp" + android:height="90dp" + android:viewportWidth="201" + android:viewportHeight="90"> + <path + android:pathData="m86.66,1.12c15.59,0 29.13,9.4 35.96,23.2h-5.82C110.49,13.62 99.35,6.52 86.66,6.52c-19.69,0 -35.65,17.11 -35.65,38.22 0,10.33 3.83,19.7 10.04,26.58 -1.34,1.12 -2.77,2.13 -4.28,3.01C50.07,66.55 45.97,56.16 45.97,44.74c0,-24.09 18.22,-43.62 40.69,-43.62zM122.51,65.38c-6.87,13.68 -20.35,22.98 -35.85,22.98 -1.05,0 -2.1,-0.04 -3.13,-0.13 3.05,-1.56 5.91,-3.48 8.54,-5.71 10.32,-1.68 19.19,-8.11 24.6,-17.15z" + android:strokeWidth="0.327943" + android:fillColor="#0042b3" + android:strokeColor="#00000000" + android:fillType="evenOdd"/> + <path + android:pathData="m64.21,1.12c1.05,0 2.1,0.04 3.13,0.13C64.29,2.81 61.43,4.73 58.8,6.96 41.68,9.75 28.56,25.6 28.56,44.74c0,14.26 7.29,26.7 18.09,33.27 -1.59,0.27 -3.23,0.41 -4.89,0.41 -1.24,0 -2.46,-0.08 -3.66,-0.23C29.19,70.18 23.53,58.17 23.53,44.74 23.53,20.65 41.74,1.12 64.21,1.12ZM69.62,82.52C79.94,80.84 88.81,74.41 94.22,65.38h5.84c-6.87,13.68 -20.35,22.98 -35.85,22.98 -1.05,0 -2.1,-0.04 -3.13,-0.13 3.05,-1.56 5.91,-3.48 8.54,-5.71zM94.36,24.32c-3.14,-5.31 -7.47,-9.74 -12.58,-12.85 1.59,-0.27 3.23,-0.41 4.89,-0.41 1.24,0 2.46,0.08 3.66,0.23 4.02,3.61 7.37,8.03 9.85,13.03z" + android:strokeWidth="0.327943" + android:fillColor="#0042b3" + android:strokeColor="#00000000" + android:fillType="evenOdd"/> + <path + android:pathData="m41.76,1.12c1.06,0 2.12,0.04 3.16,0.13 -3.05,1.56 -5.9,3.47 -8.52,5.7C19.25,9.72 6.11,25.58 6.11,44.74c0,21.11 15.96,38.22 35.65,38.22 12.6,0 23.67,-7.01 30.01,-17.58h5.84C70.75,79.06 57.27,88.36 41.76,88.36c-22.47,0 -40.69,-19.53 -40.69,-43.62 0,-24.09 18.22,-43.62 40.69,-43.62zM71.91,24.32c-1.31,-2.22 -2.83,-4.29 -4.53,-6.17 1.34,-1.12 2.77,-2.13 4.28,-3.01 2.37,2.75 4.42,5.83 6.07,9.17z" + android:strokeWidth="0.327943" + android:fillColor="#0042b3" + android:strokeColor="#00000000" + android:fillType="evenOdd"/> + <path + android:fillColor="@color/talerPosLogoText" + android:pathData="m76.14,34.41h9.16V29.37H61.86v5.04h9.16v25.92h5.12z"/> + <path + android:fillColor="@color/talerPosLogoText" + android:pathData="m92.65,52.86h13.66l2.93,7.48h5.36L101.89,29.14L97.19,29.14L84.48,60.33h5.2zM104.45,48.03h-9.94l4.95,-12.39z"/> + <path + android:fillColor="@color/talerPosLogoText" + android:pathData="m123.81,29.37h-4.58v30.97h20.55v-4.91c-5.32,0 -10.64,0 -15.97,0z"/> + <path + android:fillColor="@color/talerPosLogoText" + android:pathData="m166.47,29.37h-21.38v30.97h21.58v-4.91h-16.55v-8.27h14.48V42.24h-14.48v-7.96h16.34z"/> + <path + android:fillColor="@color/talerPosLogoText" + android:pathData="m191.19,39.47c0,1.6 -0.54,2.88 -1.62,3.82 -1.07,0.95 -2.52,1.42 -4.35,1.42h-7.45L177.78,34.28h7.41c1.91,0 3.38,0.44 4.43,1.31 1.05,0.87 1.57,2.17 1.57,3.89zM197.26,60.33 L189.46,48.61c1.02,-0.29 1.95,-0.71 2.79,-1.26 0.84,-0.55 1.56,-1.21 2.17,-1.99 0.61,-0.78 1.08,-1.68 1.42,-2.7 0.34,-1.02 0.52,-2.18 0.52,-3.47 0,-1.5 -0.25,-2.87 -0.74,-4.09 -0.5,-1.22 -1.2,-2.26 -2.12,-3.1 -0.92,-0.84 -2.05,-1.49 -3.38,-1.95 -1.33,-0.46 -2.83,-0.69 -4.48,-0.69h-12.88v30.97h5.03L177.78,49.54h6.38l7.12,10.79z"/> +</vector> diff --git a/merchant-terminal/src/main/res/drawable/ic_taler_logo_brand_on_light.xml b/merchant-terminal/src/main/res/drawable/ic_taler_logo_brand_on_light.xml @@ -0,0 +1,55 @@ +<!-- + ~ 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/> + --> +<!-- Fixed brand colours for light tile backgrounds, including in dark mode. --> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="201dp" + android:height="90dp" + android:viewportWidth="201" + android:viewportHeight="90"> + <path + android:pathData="m86.66,1.12c15.59,0 29.13,9.4 35.96,23.2h-5.82C110.49,13.62 99.35,6.52 86.66,6.52c-19.69,0 -35.65,17.11 -35.65,38.22 0,10.33 3.83,19.7 10.04,26.58 -1.34,1.12 -2.77,2.13 -4.28,3.01C50.07,66.55 45.97,56.16 45.97,44.74c0,-24.09 18.22,-43.62 40.69,-43.62zM122.51,65.38c-6.87,13.68 -20.35,22.98 -35.85,22.98 -1.05,0 -2.1,-0.04 -3.13,-0.13 3.05,-1.56 5.91,-3.48 8.54,-5.71 10.32,-1.68 19.19,-8.11 24.6,-17.15z" + android:strokeWidth="0.327943" + android:fillColor="#0042b3" + android:strokeColor="#00000000" + android:fillType="evenOdd"/> + <path + android:pathData="m64.21,1.12c1.05,0 2.1,0.04 3.13,0.13C64.29,2.81 61.43,4.73 58.8,6.96 41.68,9.75 28.56,25.6 28.56,44.74c0,14.26 7.29,26.7 18.09,33.27 -1.59,0.27 -3.23,0.41 -4.89,0.41 -1.24,0 -2.46,-0.08 -3.66,-0.23C29.19,70.18 23.53,58.17 23.53,44.74 23.53,20.65 41.74,1.12 64.21,1.12ZM69.62,82.52C79.94,80.84 88.81,74.41 94.22,65.38h5.84c-6.87,13.68 -20.35,22.98 -35.85,22.98 -1.05,0 -2.1,-0.04 -3.13,-0.13 3.05,-1.56 5.91,-3.48 8.54,-5.71zM94.36,24.32c-3.14,-5.31 -7.47,-9.74 -12.58,-12.85 1.59,-0.27 3.23,-0.41 4.89,-0.41 1.24,0 2.46,0.08 3.66,0.23 4.02,3.61 7.37,8.03 9.85,13.03z" + android:strokeWidth="0.327943" + android:fillColor="#0042b3" + android:strokeColor="#00000000" + android:fillType="evenOdd"/> + <path + android:pathData="m41.76,1.12c1.06,0 2.12,0.04 3.16,0.13 -3.05,1.56 -5.9,3.47 -8.52,5.7C19.25,9.72 6.11,25.58 6.11,44.74c0,21.11 15.96,38.22 35.65,38.22 12.6,0 23.67,-7.01 30.01,-17.58h5.84C70.75,79.06 57.27,88.36 41.76,88.36c-22.47,0 -40.69,-19.53 -40.69,-43.62 0,-24.09 18.22,-43.62 40.69,-43.62zM71.91,24.32c-1.31,-2.22 -2.83,-4.29 -4.53,-6.17 1.34,-1.12 2.77,-2.13 4.28,-3.01 2.37,2.75 4.42,5.83 6.07,9.17z" + android:strokeWidth="0.327943" + android:fillColor="#0042b3" + android:strokeColor="#00000000" + android:fillType="evenOdd"/> + <path + android:fillColor="#FF000000" + android:pathData="m76.14,34.41h9.16V29.37H61.86v5.04h9.16v25.92h5.12z"/> + <path + android:fillColor="#FF000000" + android:pathData="m92.65,52.86h13.66l2.93,7.48h5.36L101.89,29.14L97.19,29.14L84.48,60.33h5.2zM104.45,48.03h-9.94l4.95,-12.39z"/> + <path + android:fillColor="#FF000000" + android:pathData="m123.81,29.37h-4.58v30.97h20.55v-4.91c-5.32,0 -10.64,0 -15.97,0z"/> + <path + android:fillColor="#FF000000" + android:pathData="m166.47,29.37h-21.38v30.97h21.58v-4.91h-16.55v-8.27h14.48V42.24h-14.48v-7.96h16.34z"/> + <path + android:fillColor="#FF000000" + android:pathData="m191.19,39.47c0,1.6 -0.54,2.88 -1.62,3.82 -1.07,0.95 -2.52,1.42 -4.35,1.42h-7.45L177.78,34.28h7.41c1.91,0 3.38,0.44 4.43,1.31 1.05,0.87 1.57,2.17 1.57,3.89zM197.26,60.33 L189.46,48.61c1.02,-0.29 1.95,-0.71 2.79,-1.26 0.84,-0.55 1.56,-1.21 2.17,-1.99 0.61,-0.78 1.08,-1.68 1.42,-2.7 0.34,-1.02 0.52,-2.18 0.52,-3.47 0,-1.5 -0.25,-2.87 -0.74,-4.09 -0.5,-1.22 -1.2,-2.26 -2.12,-3.1 -0.92,-0.84 -2.05,-1.49 -3.38,-1.95 -1.33,-0.46 -2.83,-0.69 -4.48,-0.69h-12.88v30.97h5.03L177.78,49.54h6.38l7.12,10.79z"/> +</vector> diff --git a/merchant-terminal/src/main/res/drawable/ic_taler_logo_full.xml b/merchant-terminal/src/main/res/drawable/ic_taler_logo_full.xml @@ -0,0 +1,47 @@ +<!-- + ~ 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/> + --> +<!-- The full Taler logo, mark and wordmark, from the wallet. Drawn in one colour so it + can be tinted to whatever it sits on. --> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="201dp" + android:height="90dp" + android:viewportWidth="201" + android:viewportHeight="90"> + <path + android:pathData="m86.66,1.12c15.59,0 29.13,9.4 35.96,23.2h-5.82C110.49,13.62 99.35,6.52 86.66,6.52c-19.69,0 -35.65,17.11 -35.65,38.22 0,10.33 3.83,19.7 10.04,26.58 -1.34,1.12 -2.77,2.13 -4.28,3.01C50.07,66.55 45.97,56.16 45.97,44.74c0,-24.09 18.22,-43.62 40.69,-43.62zM122.51,65.38c-6.87,13.68 -20.35,22.98 -35.85,22.98 -1.05,0 -2.1,-0.04 -3.13,-0.13 3.05,-1.56 5.91,-3.48 8.54,-5.71 10.32,-1.68 19.19,-8.11 24.6,-17.15z" + android:fillColor="#FF000000" /> + <path + android:pathData="m64.21,1.12c1.05,0 2.1,0.04 3.13,0.13C64.29,2.81 61.43,4.73 58.8,6.96 41.68,9.75 28.56,25.6 28.56,44.74c0,14.26 7.29,26.7 18.09,33.27 -1.59,0.27 -3.23,0.41 -4.89,0.41 -1.24,0 -2.46,-0.08 -3.66,-0.23C29.19,70.18 23.53,58.17 23.53,44.74 23.53,20.65 41.74,1.12 64.21,1.12ZM69.62,82.52C79.94,80.84 88.81,74.41 94.22,65.38h5.84c-6.87,13.68 -20.35,22.98 -35.85,22.98 -1.05,0 -2.1,-0.04 -3.13,-0.13 3.05,-1.56 5.91,-3.48 8.54,-5.71zM94.36,24.32c-3.14,-5.31 -7.47,-9.74 -12.58,-12.85 1.59,-0.27 3.23,-0.41 4.89,-0.41 1.24,0 2.46,0.08 3.66,0.23 4.02,3.61 7.37,8.03 9.85,13.03z" + android:fillColor="#FF000000" /> + <path + android:pathData="m41.76,1.12c1.06,0 2.12,0.04 3.16,0.13 -3.05,1.56 -5.9,3.47 -8.52,5.7C19.25,9.72 6.11,25.58 6.11,44.74c0,21.11 15.96,38.22 35.65,38.22 12.6,0 23.67,-7.01 30.01,-17.58h5.84C70.75,79.06 57.27,88.36 41.76,88.36c-22.47,0 -40.69,-19.53 -40.69,-43.62 0,-24.09 18.22,-43.62 40.69,-43.62zM71.91,24.32c-1.31,-2.22 -2.83,-4.29 -4.53,-6.17 1.34,-1.12 2.77,-2.13 4.28,-3.01 2.37,2.75 4.42,5.83 6.07,9.17z" + android:fillColor="#FF000000" /> + <path + android:pathData="m76.14,34.41h9.16V29.37H61.86v5.04h9.16v25.92h5.12z" + android:fillColor="#FF000000" /> + <path + android:pathData="m92.65,52.86h13.66l2.93,7.48h5.36L101.89,29.14L97.19,29.14L84.48,60.33h5.2zM104.45,48.03h-9.94l4.95,-12.39z" + android:fillColor="#FF000000" /> + <path + android:pathData="m123.81,29.37h-4.58v30.97h20.55v-4.91c-5.32,0 -10.64,0 -15.97,0z" + android:fillColor="#FF000000" /> + <path + android:pathData="m166.47,29.37h-21.38v30.97h21.58v-4.91h-16.55v-8.27h14.48V42.24h-14.48v-7.96h16.34z" + android:fillColor="#FF000000" /> + <path + android:pathData="m191.19,39.47c0,1.6 -0.54,2.88 -1.62,3.82 -1.07,0.95 -2.52,1.42 -4.35,1.42h-7.45L177.78,34.28h7.41c1.91,0 3.38,0.44 4.43,1.31 1.05,0.87 1.57,2.17 1.57,3.89zM197.26,60.33 L189.46,48.61c1.02,-0.29 1.95,-0.71 2.79,-1.26 0.84,-0.55 1.56,-1.21 2.17,-1.99 0.61,-0.78 1.08,-1.68 1.42,-2.7 0.34,-1.02 0.52,-2.18 0.52,-3.47 0,-1.5 -0.25,-2.87 -0.74,-4.09 -0.5,-1.22 -1.2,-2.26 -2.12,-3.1 -0.92,-0.84 -2.05,-1.49 -3.38,-1.95 -1.33,-0.46 -2.83,-0.69 -4.48,-0.69h-12.88v30.97h5.03L177.78,49.54h6.38l7.12,10.79z" + android:fillColor="#FF000000" /> +</vector> diff --git a/merchant-terminal/src/main/res/drawable/ic_talerpos_logo.xml b/merchant-terminal/src/main/res/drawable/ic_talerpos_logo.xml @@ -1,36 +1,44 @@ +<!-- Taler POS logo from the Penpot design guide. Lettering is outlined. + The TALER-to-POS gap is twice the visible E-to-R gap. --> <vector xmlns:android="http://schemas.android.com/apk/res/android" - android:width="334dp" - android:height="104dp" - android:viewportWidth="1670" + android:width="338.4dp" + android:height="104.2dp" + android:viewportWidth="1692" android:viewportHeight="521"> <path android:fillColor="#0042B3" android:fillType="evenOdd" - android:pathData="M503.795,6.49023C594.153,6.49023 672.628,60.9124 712.226,140.803H678.508C641.882,78.8635 577.329,37.7322 503.795,37.7322C389.668,37.7322 297.15,136.792 297.15,258.987C297.15,318.796 319.324,373.055 355.34,412.873C347.56,419.361 339.276,425.191 330.559,430.278C291.715,385.27 267.971,325.103 267.971,258.987C267.971,119.537 373.553,6.49023 503.795,6.49023ZM711.594,378.454C671.796,457.646 593.67,511.484 503.795,511.484C497.694,511.484 491.646,511.235 485.661,510.748C503.352,501.711 519.937,490.601 535.149,477.709C594.97,467.958 646.352,430.74 677.742,378.454H711.594Z" /> + android:pathData="M503.7963562011719,6.490492343902588C594.15380859375,6.490492343902588,672.62939453125,60.91261291503906,712.22705078125,140.80323791503906L678.50927734375,140.80323791503906C641.88330078125,78.86378479003906,577.32958984375,37.73243713378906,503.7963562011719,37.73243713378906C389.6684265136719,37.73243713378906,297.1508483886719,136.79200744628906,297.1508483886719,258.987060546875C297.1508483886719,318.796630859375,319.3241882324219,373.05517578125,355.3403015136719,412.872802734375C347.5600280761719,419.361572265625,339.2763366699219,425.19091796875,330.5595397949219,430.278564453125C291.7153015136719,385.270263671875,267.9711608886719,325.103515625,267.9711608886719,258.987060546875C267.9711608886719,119.53736877441406,373.5536804199219,6.490492343902588,503.7963562011719,6.490492343902588ZM711.59521484375,378.453857421875C671.79638671875,457.646484375,593.67138671875,511.48382568359375,503.7963562011719,511.48382568359375C497.6947937011719,511.48382568359375,491.6469421386719,511.23529052734375,485.6615905761719,510.74847412109375C503.3529968261719,501.71087646484375,519.93701171875,490.60076904296875,535.14990234375,477.70947265625C594.97119140625,467.958740234375,646.35302734375,430.740478515625,677.74267578125,378.453857421875Z" /> <path android:fillColor="#0042B3" android:fillType="evenOdd" - android:pathData="M373.676,6.49023C379.777,6.49023 385.824,6.73853 391.81,7.22583C374.12,16.2632 357.534,27.373 342.321,40.2646C243.075,56.4407 167.031,148.204 167.031,258.987C167.031,341.563 209.288,413.562 271.904,451.577C262.667,453.124 253.2,453.933 243.557,453.933C236.372,453.933 229.29,453.472 222.323,452.605C170.692,406.287 137.852,336.749 137.852,258.987C137.852,119.537 243.434,6.49023 373.676,6.49023ZM405.031,477.709C464.853,467.958 516.234,430.739 547.625,378.451H581.477C541.679,457.645 463.553,511.484 373.676,511.484C367.575,511.484 361.527,511.235 355.542,510.748C373.231,501.71 389.819,490.602 405.031,477.709ZM548.389,140.803C530.192,110.037 505.106,84.4031 475.448,66.3967C484.686,64.8499 494.151,64.0408 503.796,64.0408C510.979,64.0408 518.062,64.5012 525.029,65.3687C548.307,86.2507 567.765,111.854 582.115,140.803H548.389Z" /> + android:pathData="M373.6757507324219,6.490492343902588C379.7773132324219,6.490492343902588,385.8241882324219,6.738783359527588,391.8100280761719,7.226088047027588C374.1195983886719,16.26344108581543,357.5341491699219,27.37330436706543,342.3212585449219,40.26490783691406C243.07518005371094,56.44093322753906,167.03123474121094,148.20460510253906,167.03123474121094,258.987060546875C167.03123474121094,341.56298828125,209.28807067871094,413.56201171875,271.9037780761719,451.5771484375C262.6669616699219,453.1240234375,253.20018005371094,453.932861328125,243.55662536621094,453.932861328125C236.37205505371094,453.932861328125,229.29002380371094,453.47265625,222.32322692871094,452.605224609375C170.69236755371094,406.287353515625,137.85154724121094,336.7490234375,137.85154724121094,258.987060546875C137.85154724121094,119.53736877441406,243.43406677246094,6.490492343902588,373.6757507324219,6.490492343902588ZM405.0312194824219,477.70947265625C464.8529968261719,467.958740234375,516.23388671875,430.73876953125,547.62451171875,378.451171875L581.47705078125,378.451171875C541.67919921875,457.644775390625,463.5531921386719,511.48382568359375,373.6757507324219,511.48382568359375C367.5746765136719,511.48382568359375,361.5273132324219,511.23529052734375,355.5419616699219,510.74847412109375C373.2314147949219,501.70989990234375,389.8193054199219,490.60174560546875,405.0312194824219,477.70947265625ZM548.38916015625,140.80323791503906C530.19287109375,110.03688049316406,505.1069030761719,84.40333557128906,475.4486999511719,66.39698791503906C484.6860046386719,64.85011291503906,494.1518249511719,64.04103088378906,503.7963562011719,64.04103088378906C510.9799499511719,64.04103088378906,518.06298828125,64.50148010253906,525.02978515625,65.36891174316406C548.30712890625,86.25099182128906,567.76513671875,111.85450744628906,582.11474609375,140.80323791503906Z" /> <path android:fillColor="#0042B3" android:fillType="evenOdd" - android:pathData="M243.556,6.49023C249.725,6.49023 255.837,6.74707 261.887,7.2439C244.229,16.2686 227.672,27.3577 212.484,40.2222C113.1,56.2712 36.9116,148.1 36.9116,258.987C36.9116,381.182 129.43,480.241 243.556,480.241C316.584,480.241 380.762,439.678 417.517,378.451H451.357C411.559,457.645 333.433,511.484 243.556,511.484C113.314,511.484 7.73242,398.437 7.73242,258.987C7.73242,119.537 113.314,6.49023 243.556,6.49023ZM418.264,140.803C410.661,127.948 401.856,115.986 392.015,105.106C399.796,98.6167 408.078,92.7847 416.794,87.697C430.547,103.634 442.406,121.473 451.99,140.803H418.264Z" /> - <path - android:fillColor="@color/talerPosLogoText" - android:pathData="M442.782,199.19H495.88V169.995H360.027V199.19H413.126V349.256H442.782V199.19Z" /> - <path - android:fillColor="@color/talerPosLogoText" - android:pathData="M538.487,305.98H617.655L634.638,349.258H665.731L592.063,168.717H564.799L491.131,349.258H521.268L538.487,305.98ZM606.893,278.064H549.252L577.953,206.36L606.893,278.064Z" /> - <path - android:fillColor="@color/talerPosLogoText" - android:pathData="M719.083,169.997H692.535V349.258H811.628V320.833C780.78,320.833 749.932,320.833 719.083,320.833V169.997Z" /> - <path - android:fillColor="@color/talerPosLogoText" - android:pathData="M966.376,169.997H842.482V349.258H967.572V320.833H871.662V272.943H955.613V244.518H871.662V198.422H966.376V169.997Z" /> - <path - android:fillColor="@color/talerPosLogoText" - android:pathData="M1109.64,228.514C1109.64,237.773 1106.53,245.159 1100.28,250.621C1094.06,256.128 1085.65,258.858 1075.08,258.858H1031.91V198.422H1074.84C1085.88,198.422 1094.45,200.941 1100.52,206.019C1106.61,211.056 1109.64,218.567 1109.64,228.514ZM1144.8,349.258L1099.6,281.393C1105.5,279.687 1110.88,277.255 1115.74,274.096C1120.6,270.938 1124.79,267.096 1128.3,262.573C1131.81,258.047 1134.56,252.841 1136.55,246.952C1138.54,241.062 1139.54,234.36 1139.54,226.848C1139.54,218.141 1138.1,210.246 1135.23,203.16C1132.36,196.075 1128.26,190.099 1122.92,185.233C1117.58,180.368 1111.04,176.612 1103.31,173.965C1095.57,171.318 1086.92,169.997 1077.35,169.997H1002.73V349.258H1031.91V286.773H1068.86L1110.12,349.258H1144.8Z" /> - <path - android:fillColor="@color/talerPosLogoText" - android:pathData="M1215.18,352V167.4H1291.1C1298.9,167.4 1306.01,169.047 1312.42,172.34C1319.01,175.633 1324.73,180.053 1329.58,185.6C1334.43,190.973 1338.16,197.04 1340.76,203.8C1343.53,210.56 1344.92,217.493 1344.92,224.6C1344.92,234.48 1342.67,243.84 1338.16,252.68C1333.83,261.347 1327.76,268.453 1319.96,274C1312.33,279.547 1303.23,282.32 1292.66,282.32H1228.44V352H1215.18ZM1228.44,270.1H1292.14C1300.29,270.1 1307.31,267.933 1313.2,263.6C1319.09,259.267 1323.6,253.633 1326.72,246.7C1329.84,239.767 1331.4,232.4 1331.4,224.6C1331.4,216.453 1329.49,209 1325.68,202.24C1322.04,195.307 1317.1,189.76 1310.86,185.6C1304.79,181.44 1298.03,179.36 1290.58,179.36H1228.44V270.1ZM1427.16,354.6C1417.63,354.6 1408.79,352.78 1400.64,349.14C1392.67,345.5 1385.73,340.473 1379.84,334.06C1373.95,327.473 1369.35,319.933 1366.06,311.44C1362.77,302.947 1361.12,294.02 1361.12,284.66C1361.12,275.127 1362.77,266.2 1366.06,257.88C1369.35,249.387 1373.95,241.933 1379.84,235.52C1385.91,228.933 1392.93,223.82 1400.9,220.18C1409.05,216.367 1417.8,214.46 1427.16,214.46C1436.52,214.46 1445.19,216.367 1453.16,220.18C1461.13,223.82 1468.15,228.933 1474.22,235.52C1480.29,241.933 1484.97,249.387 1488.26,257.88C1491.55,266.2 1493.2,275.127 1493.2,284.66C1493.2,294.02 1491.55,302.947 1488.26,311.44C1484.97,319.933 1480.29,327.473 1474.22,334.06C1468.33,340.473 1461.31,345.5 1453.16,349.14C1445.19,352.78 1436.52,354.6 1427.16,354.6ZM1374.12,285.18C1374.12,295.753 1376.46,305.46 1381.14,314.3C1385.99,322.967 1392.41,329.9 1400.38,335.1C1408.35,340.3 1417.19,342.9 1426.9,342.9C1436.61,342.9 1445.45,340.3 1453.42,335.1C1461.57,329.727 1467.98,322.62 1472.66,313.78C1477.51,304.767 1479.94,294.973 1479.94,284.4C1479.94,273.827 1477.51,264.12 1472.66,255.28C1467.98,246.44 1461.57,239.42 1453.42,234.22C1445.45,228.847 1436.69,226.16 1427.16,226.16C1417.45,226.16 1408.61,228.847 1400.64,234.22C1392.67,239.593 1386.25,246.7 1381.4,255.54C1376.55,264.38 1374.12,274.26 1374.12,285.18ZM1634.1,198.08C1631.16,194.787 1627.86,191.927 1624.22,189.5C1620.58,186.9 1616.68,184.82 1612.52,183.26C1608.36,181.527 1603.86,180.227 1599,179.36C1594.15,178.493 1588.95,178.06 1583.4,178.06C1565.38,178.06 1552.2,181.527 1543.88,188.46C1535.74,195.22 1531.66,204.407 1531.66,216.02C1531.66,223.82 1533.48,230.06 1537.12,234.74C1540.94,239.247 1546.83,242.887 1554.8,245.66C1562.78,248.433 1573.09,251.12 1585.74,253.72C1599.09,256.493 1610.62,259.787 1620.32,263.6C1630.03,267.24 1637.48,272.267 1642.68,278.68C1648.06,285.093 1650.74,293.76 1650.74,304.68C1650.74,312.827 1649.18,320.02 1646.06,326.26C1642.94,332.327 1638.44,337.353 1632.54,341.34C1626.65,345.327 1619.63,348.36 1611.48,350.44C1603.51,352.52 1594.58,353.56 1584.7,353.56C1575.17,353.56 1566.07,352.52 1557.4,350.44C1548.74,348.36 1540.59,345.327 1532.96,341.34C1525.34,337.353 1518.23,332.24 1511.64,326L1518.66,315.34C1522.48,319.5 1526.72,323.227 1531.4,326.52C1536.26,329.64 1541.46,332.327 1547,334.58C1552.72,336.833 1558.79,338.653 1565.2,340.04C1571.62,341.253 1578.29,341.86 1585.22,341.86C1601.34,341.86 1613.91,338.913 1622.92,333.02C1632.11,326.953 1636.7,318.027 1636.7,306.24C1636.7,298.093 1634.62,291.507 1630.46,286.48C1626.3,281.453 1619.89,277.38 1611.22,274.26C1602.56,270.967 1591.72,267.933 1578.72,265.16C1565.72,262.387 1554.63,259.267 1545.44,255.8C1536.43,252.333 1529.58,247.653 1524.9,241.76C1520.4,235.867 1518.14,227.893 1518.14,217.84C1518.14,206.747 1520.83,197.387 1526.2,189.76C1531.75,181.96 1539.46,176.067 1549.34,172.08C1559.22,168.093 1570.58,166.1 1583.4,166.1C1591.38,166.1 1598.74,166.88 1605.5,168.44C1612.44,170 1618.76,172.427 1624.48,175.72C1630.38,178.84 1635.84,182.827 1640.86,187.68L1634.1,198.08Z" /> + android:pathData="M243.55662536621094,6.490492343902588C249.72557067871094,6.490492343902588,255.83738708496094,6.747328281402588,261.8876647949219,7.244154453277588C244.22947692871094,16.26881217956543,227.67283630371094,27.35792350769043,212.48435974121094,40.22242736816406C113.10008239746094,56.27149963378906,36.91209411621094,148.09986877441406,36.91209411621094,258.987060546875C36.91209411621094,381.18212890625,129.43064880371094,480.24169921875,243.55662536621094,480.24169921875C316.5849304199219,480.24169921875,380.7626647949219,439.6787109375,417.5170593261719,378.451171875L451.3578796386719,378.451171875C411.5595397949219,457.644775390625,333.4335632324219,511.48382568359375,243.55662536621094,511.48382568359375C113.31492614746094,511.48382568359375,7.732894420623779,398.436767578125,7.732894420623779,258.987060546875C7.732894420623779,119.53736877441406,113.31492614746094,6.490492343902588,243.55662536621094,6.490492343902588ZM418.2646179199219,140.80323791503906C410.6611022949219,127.94850158691406,401.8564147949219,115.98585510253906,392.0155944824219,105.10597229003906C399.7963562011719,98.61695861816406,408.0780944824219,92.78492736816406,416.7944030761719,87.69728088378906C430.5478210449219,103.63429260253906,442.4067077636719,121.47340393066406,451.9906921386719,140.80323791503906Z" /> + <path + android:fillColor="#000000" + android:pathData="M442.7826843261719,199.19020080566406L495.8803405761719,199.19020080566406L495.8803405761719,169.99562072753906L360.0273132324219,169.99562072753906L360.0273132324219,199.19020080566406L413.1259460449219,199.19020080566406L413.1259460449219,349.256591796875L442.7826843261719,349.256591796875Z" /> + <path + android:fillColor="#000000" + android:pathData="M538.48779296875,305.9794921875L617.65576171875,305.9794921875L634.63818359375,349.257568359375L665.73193359375,349.257568359375L592.06396484375,168.71656799316406L564.79931640625,168.71656799316406L491.1313171386719,349.257568359375L521.26806640625,349.257568359375ZM606.89306640625,278.063720703125L549.25244140625,278.063720703125L577.95263671875,206.35987854003906Z" /> + <path + android:fillColor="#000000" + android:pathData="M719.08447265625,169.99708557128906L692.53662109375,169.99708557128906L692.53662109375,349.257568359375L811.62939453125,349.257568359375L811.62939453125,320.83251953125C780.78173828125,320.83251953125,749.93310546875,320.83251953125,719.08447265625,320.83251953125Z" /> + <path + android:fillColor="#000000" + android:pathData="M966.37646484375,169.99708557128906L842.48291015625,169.99708557128906L842.48291015625,349.257568359375L967.57275390625,349.257568359375L967.57275390625,320.83251953125L871.66162109375,320.83251953125L871.66162109375,272.942626953125L955.61376953125,272.942626953125L955.61376953125,244.51832580566406L871.66162109375,244.51832580566406L871.66162109375,198.42237854003906L966.37646484375,198.42237854003906Z" /> + <path + android:fillColor="#000000" + android:pathData="M1109.64306640625,228.51393127441406C1109.64306640625,237.77320861816406,1106.53369140625,245.15919494628906,1100.27587890625,250.62110900878906C1094.05712890625,256.127685546875,1085.64599609375,258.85791015625,1075.08154296875,258.85791015625L1031.90966796875,258.85791015625L1031.90966796875,198.42237854003906L1074.84228515625,198.42237854003906C1085.88427734375,198.42237854003906,1094.45458984375,200.94093322753906,1100.51513671875,206.01856994628906C1106.61474609375,211.05641174316406,1109.64306640625,218.56739807128906,1109.64306640625,228.51393127441406ZM1144.80224609375,349.257568359375L1099.59814453125,281.393310546875C1105.49755859375,279.686767578125,1110.87939453125,277.25537109375,1115.74365234375,274.09619140625C1120.60498046875,270.9375,1124.79248046875,267.095703125,1128.29931640625,262.572509765625C1131.80810546875,258.047119140625,1134.55810546875,252.84083557128906,1136.55126953125,246.95216369628906C1138.54345703125,241.06153869628906,1139.53955078125,234.35987854003906,1139.53955078125,226.84791564941406C1139.53955078125,218.14088439941406,1138.10498046875,210.24562072753906,1135.23486328125,203.15968322753906C1132.36474609375,196.07521057128906,1128.25927734375,190.09864807128906,1122.91845703125,185.23292541503906C1117.57568359375,180.36769104003906,1111.03955078125,176.61183166503906,1103.30615234375,173.96510314941406C1095.57177734375,171.31813049316406,1086.92138671875,169.99708557128906,1077.35498046875,169.99708557128906L1002.73095703125,169.99708557128906L1002.73095703125,349.257568359375L1031.90966796875,349.257568359375L1031.90966796875,286.77294921875L1068.86376953125,286.77294921875L1110.12060546875,349.257568359375Z" /> + <group + android:translateX="1191.8675365671625" + android:translateY="349.0016174316406" + android:scaleX="1.0" + android:scaleY="-1"> + <path + android:fillColor="#000000" + android:pathData="M23.25,0.0L23.25,177.5L96.25,177.5Q107.5,177.5 117.0,172.75Q126.5,168.0 133.375,160.125Q140.25,152.25 144.125,142.375Q148.0,132.5 148.0,122.5Q148.0,111.75 144.375,101.75Q140.75,91.75 134.125,84.0Q127.5,76.25 118.25,71.625Q109.0,67.0 97.75,67.0L36.0,67.0L36.0,0.0ZM36.0,78.75L97.25,78.75Q106.0,78.75 113.0,82.375Q120.0,86.0 124.875,92.125Q129.75,98.25 132.375,106.125Q135.0,114.0 135.0,122.5Q135.0,131.25 131.875,139.125Q128.75,147.0 123.375,153.0Q118.0,159.0 110.875,162.5Q103.75,166.0 95.75,166.0L36.0,166.0ZM249.25,-1.25Q230.75,-1.25 215.5,6.5Q200.25,14.25 189.25,26.75Q178.25,39.25 172.25,55.5Q166.25,71.75 166.25,88.75Q166.25,106.5 172.625,122.75Q179.0,139.0 190.125,151.5Q201.25,164.0 216.5,171.375Q231.75,178.75 249.5,178.75Q268.0,178.75 283.375,170.875Q298.75,163.0 309.625,150.25Q320.5,137.5 326.5,121.375Q332.5,105.25 332.5,88.75Q332.5,70.75 326.125,54.5Q319.75,38.25 308.625,25.875Q297.5,13.5 282.25,6.125Q267.0,-1.25 249.25,-1.25ZM179.0,88.75Q179.0,73.5 184.125,59.375Q189.25,45.25 198.625,34.375Q208.0,23.5 221.0,17.0Q234.0,10.5 249.5,10.5Q265.5,10.5 278.375,17.25Q291.25,24.0 300.375,35.125Q309.5,46.25 314.5,60.25Q319.5,74.25 319.5,88.75Q319.5,104.0 314.25,118.125Q309.0,132.25 299.625,143.125Q290.25,154.0 277.5,160.5Q264.75,167.0 249.5,167.0Q233.5,167.0 220.5,160.25Q207.5,153.5 198.25,142.375Q189.0,131.25 184.0,117.25Q179.0,103.25 179.0,88.75ZM470.0,148.0Q452.75,167.25 421.25,167.25Q395.25,167.25 383.375,157.375Q371.5,147.5 371.5,130.75Q371.5,122.25 374.5,116.625Q377.5,111.0 383.875,107.0Q390.25,103.0 400.0,100.25Q409.75,97.5 423.5,94.5Q437.75,91.5 449.375,87.875Q461.0,84.25 469.125,78.75Q477.25,73.25 481.625,65.25Q486.0,57.25 486.0,45.5Q486.0,33.75 481.375,25.0Q476.75,16.25 468.375,10.375Q460.0,4.5 448.375,1.5Q436.75,-1.5 422.5,-1.5Q381.0,-1.5 352.25,25.0L359.0,35.25Q370.25,23.5 386.5,16.625Q402.75,9.75 423.0,9.75Q446.25,9.75 459.375,18.25Q472.5,26.75 472.5,44.0Q472.5,52.75 469.0,58.875Q465.5,65.0 458.625,69.5Q451.75,74.0 441.25,77.25Q430.75,80.5 416.75,83.5Q402.5,86.5 391.625,89.875Q380.75,93.25 373.375,98.375Q366.0,103.5 362.25,110.75Q358.5,118.0 358.5,129.0Q358.5,141.0 363.125,150.25Q367.75,159.5 376.0,165.875Q384.25,172.25 395.75,175.5Q407.25,178.75 421.25,178.75Q438.5,178.75 452.0,173.625Q465.5,168.5 476.5,158.0Z" /> + </group> </vector> diff --git a/merchant-terminal/src/main/res/values-de/strings.xml b/merchant-terminal/src/main/res/values-de/strings.xml @@ -8,7 +8,7 @@ <string name="order_label_title">Bestellung #%s</string> <string name="order_total">Summe: %s</string> <string name="order_restart">Leeren</string> - <string name="order_undo">Rückgängig machen</string> + <string name="order_undo">Rückgängig</string> <string name="order_previous">Zurück</string> <string name="order_next">Weiter</string> <string name="order_complete">Kassieren</string> @@ -28,7 +28,7 @@ <string name="config_fetching_label">Konfiguration wird abgerufen</string> <string name="payment_intro_nfc">Bitte zum Bezahlen den QR-Code scannen oder per NFC empfangen.</string> <string name="payment_intro">Bitte lassen Sie zum Bezahlen den QR-Code scannen.</string> - <string name="payment_cancel">Bezahlvorgang abbrechen</string> + <string name="payment_cancel">Zahlung abbrechen</string> <string name="payment_received">Zahlung erhalten</string> <string name="payment_back_button">Weiter</string> <string name="payment_process_label">Zahlung erforderlich</string> @@ -56,9 +56,9 @@ <string name="config_error_malformed">Fehler: Die JSON-Konfiguration ist fehlerhaft</string> <string name="config_error_unknown">Fehler: Ungültige Konfiguration</string> <string name="config_docs">Informationen zum Konfigurationsformat finden Sie in <a href="https://docs.taler.net/taler-merchant-pos-terminal.html#apis-and-data-formats">der Dokumentation</a>.</string> - <string name="config_no_instance">Keine Instanz?</string> + <string name="config_no_instance">Kein Konto?</string> <string name="config_instance_tutorial_description">1. Sehen Sie sich die Einrichtungsanleitungen an unter:</string> - <string name="config_instance_demo_description">2. Sie können die App mit der öffentlichen Demo-Instanz ausprobieren. Beachten Sie, dass diese Instanz gemeinsam genutzt wird—jeder kann ihre Daten jederzeit ändern. Verwenden Sie sie nur, um die App und ihre Funktionen kennenzulernen.</string> + <string name="config_instance_demo_description">2. Sie können die App mit dem öffentlichen Demokonto ausprobieren. Beachten Sie, dass dieses Konto gemeinsam genutzt wird - jeder kann seine Daten jederzeit ändern. Verwenden Sie es nur, um die App und ihre Funktionen kennenzulernen.</string> <string name="config_fill_demo_credentials">Demo-Zugangsdaten einfügen</string> <string name="config_instance_review_description">3. Wenn Sie diese App prüfen, haben Sie höchstwahrscheinlich bereits Zugriff auf einen kontrollierten Testbenutzer. Verwenden Sie die Schaltfläche unten, um die URL des Händlerportals einzufügen.</string> <string name="config_fill_review_url">Prüf-URL einfügen</string> @@ -96,9 +96,9 @@ <string name="config_setup_password">Passwort</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="order_custom">Eigenen Artikel hinzufügen</string> <string name="menu_reload">Bestand neu laden</string> - <string name="order_custom_product">Bezeichnung des Einzelartikels</string> + <string name="order_custom_product">Artikelbezeichnung</string> <string name="host_apdu_service_desc">NFC-Zahlungen mit Taler Merchant</string> <string name="toast_reloading">Bestand wird neu geladen</string> <string name="toast_history_updating">Bestellverlauf wird aktualisiert</string> @@ -115,8 +115,8 @@ <string name="expires_on">Gültig bis…</string> <string name="never_expires">Unbegrenzte Gültigkeit</string> <string name="no_deadline_set">Es wurde keine Verfallsfrist festgelegt</string> - <string name="pick_date">Datum auswählen</string> - <string name="pick_time">Zeitpunkt auswählen</string> + <string name="pick_date">Datum wählen</string> + <string name="pick_time">Uhrzeit wählen</string> <string name="session_expired_toast">Die Sitzung ist abgelaufen – bitte geben Sie Ihre Anmeldedaten erneut ein</string> <string name="config_fragment_camera_needed_text">Für den QR-Scan müssen Sie die Berechtigung für die Kamera erteilen</string> <string name="amount_entry_label">Betrag</string> @@ -187,4 +187,356 @@ <string name="error_order_creation">Fehler: Bestellung konnte nicht erstellt werden</string> <string name="error_inventory_unavailable">Fehler: Nicht genügend Bestand verfügbar</string> <string name="error_delete_order">Fehler: Bestellung konnte nicht gelöscht werden</string> + <!-- Payment options --> + <string name="payment_options_title">Zahlungsarten</string> + <string name="payment_options_button">Zahlungsarten</string> + <string name="payment_options_description">Taler ist immer verfügbar. Aktivieren Sie zusätzliche Zahlungsarten oder öffnen Sie eine Zahlungsart zur Einrichtung.</string> + <string name="payment_options_enabled_count">%1$d aktiviert</string> + <string name="payment_option_open">Einstellungen von %1$s öffnen</string> + <string name="payment_option_settings_title">Einstellungen</string> + <string name="payment_option_unknown">Diese Zahlungsart ist in dieser App-Version nicht verfügbar.</string> + <string name="payment_option_disabled">Deaktiviert</string> + <string name="payment_option_ready">Bereit</string> + <string name="payment_option_taler">GNU Taler</string> + <string name="payment_option_taler_description">Zahlung aus einer Taler-Wallet durch Scannen des QR-Codes oder Auflegen des Telefons.</string> + <string name="payment_option_cash">Bargeld</string> + <string name="payment_option_cash_description">Zahlung in bar an der Kasse. Die Bestellung wird ausserhalb der App beglichen.</string> + + <!-- Card terminals --> + <string name="terminal_not_configured">Die Einrichtung ist in dieser App-Version nicht verfügbar. Wenden Sie sich an Ihre App-Administration.</string> + <string name="terminal_sign_in_required">Melden Sie sich an, um ein Lesegerät zu koppeln.</string> + <string name="terminal_permission_required">Die Berechtigung für Geräte in der Nähe ist erforderlich. Öffnen Sie die Geräteeinrichtung, um fortzufahren.</string> + <string name="terminal_not_paired">Kein Lesegerät gekoppelt</string> + <string name="terminal_disconnected">Lesegerät nicht verbunden</string> + <string name="terminal_connected">Lesegerät verbunden</string> + <string name="terminal_unavailable">Verbindung zum Lesegerät kann nicht geprüft werden</string> + <string name="terminal_pair">Lesegerät koppeln</string> + <string name="terminal_manage">Lesegerät verwalten</string> + <string name="terminal_account">Konto: %1$s</string> + <string name="terminal_serial">Seriennummer: %1$s</string> + <string name="terminal_indicator_description">Zahlungsgeräte: %1$s</string> + <string name="terminal_close">Schließen</string> + <string name="terminal_action_failed">Die Terminal-Einrichtung konnte nicht abgeschlossen werden. Prüfen Sie Bluetooth, Berechtigungen und Internetverbindung und versuchen Sie es erneut.</string> + +<!-- SumUp provider --> + <string name="terminal_sumup">SumUp</string> + <string name="terminal_sumup_description">Kartenzahlung über ein mit diesem Gerät gekoppeltes SumUp-Lesegerät.</string> + <string name="terminal_sumup_sign_in">Bei SumUp anmelden</string> + <string name="terminal_sumup_sign_out">Von SumUp abmelden</string> + <string name="terminal_sumup_save_key">Affiliate-Schlüssel speichern</string> + <string name="terminal_sumup_advanced">Erweitert</string> + <string name="terminal_sumup_advanced_help">Der Affiliate-Schlüssel identifiziert diese App gegenüber SumUp, nicht Ihr Unternehmen. Sie müssen ihn fast nie ändern. Ersetzen Sie ihn nur, wenn Sie eine eigene SumUp-Partnerregistrierung haben.</string> + <string name="terminal_sumup_key_builtin">Es wird der in dieser App enthaltene Affiliate-Schlüssel verwendet.</string> + <string name="terminal_sumup_key_custom">Es wird ein eigener Affiliate-Schlüssel verwendet.</string> + <string name="terminal_sumup_key_hint">Neuer Affiliate-Schlüssel</string> + <string name="terminal_sumup_key_show">Schlüssel anzeigen</string> + <string name="terminal_sumup_key_hide">Schlüssel verbergen</string> + <string name="terminal_sumup_key_warning">Beim Speichern eines anderen Schlüssels werden Sie von SumUp abgemeldet.</string> + <string name="terminal_sumup_reset_key">Integrierten Schlüssel verwenden</string> + <string name="terminal_sumup_session_help">Eine SumUp-Anmeldung gilt etwa eine Stunde. Mit einer eigenen OAuth-Anwendung erneuert die App sie selbst, sodass sich das Personal nie erneut anmelden muss.</string> + <string name="terminal_sumup_session_no_client">Keine OAuth-Anwendung konfiguriert. Fügen Sie unter Erweitert eine hinzu, um die Sitzung aufrechtzuerhalten.</string> + <string name="terminal_sumup_session_not_authorized">Noch nicht autorisiert. Autorisieren Sie einmalig, damit dieses Gerät angemeldet bleibt.</string> + <string name="terminal_sumup_session_expires">Zugriffstoken läuft ab in %1$s</string> + <string name="terminal_sumup_session_refreshes">%1$d-mal erneuert, zuletzt um %2$s</string> + <string name="terminal_sumup_session_error">Letztes Erneuerungsproblem: %1$s</string> + <string name="terminal_sumup_authorize">Dieses Gerät autorisieren</string> + <string name="terminal_sumup_reauthorize">Erneut autorisieren</string> + <string name="terminal_sumup_refresh_now">Token jetzt erneuern</string> + <string name="terminal_sumup_revoke">Autorisierung entfernen</string> + <string name="terminal_sumup_oauth_help">Registrieren Sie in Ihrem SumUp-Dashboard eine OAuth-Anwendung mit der Weiterleitungs-URL %1$s und fügen Sie hier deren Client-ID und Secret ein. Beide verbleiben auf diesem Gerät.</string> + <string name="terminal_sumup_oauth_set">Eine OAuth-Anwendung ist konfiguriert.</string> + <string name="terminal_sumup_oauth_unset">Keine OAuth-Anwendung konfiguriert.</string> + <string name="terminal_sumup_client_id">OAuth-Client-ID</string> + <string name="terminal_sumup_client_secret">OAuth-Client-Secret</string> + <string name="terminal_sumup_save_client">OAuth-Anwendung speichern</string> + <string name="payment_device_connected">%1$s verbunden</string> + <string name="payment_device_disconnected">%1$s nicht verbunden</string> + <string name="payment_device_nfc">NFC</string> + <string name="payment_device_nfc_available">verfügbar</string> + <string name="payment_device_status_connected">Verbunden</string> + <string name="payment_device_status_disconnected">Nicht verbunden</string> + <string name="payment_device_battery">Akku %1$d%%</string> + + <!-- Checkout: paying in several parts --> + <string name="checkout_title">Zahlung</string> + <string name="checkout_total">Gesamt</string> + <string name="checkout_paid">Bezahlt</string> + <string name="checkout_open">Noch offen</string> + <string name="checkout_payments_button">Bisherige Zahlungen (%1$d)</string> + <string name="checkout_payments_title">Bisherige Zahlungen</string> + <string name="checkout_payments_empty">Noch keine Zahlungen erfasst.</string> + <string name="checkout_taler_last_hint">Zuerst Bargeld und Karte annehmen. GNU Taler kommt immer zuletzt und bezahlt, was noch offen ist.</string> + <string name="checkout_no_taler_hint">GNU Taler ist deaktiviert, daher muss der ganze Betrag mit den anderen Zahlungsarten bezahlt werden.</string> + <string name="checkout_order_created_hint">Die Bestellung wurde erstellt, daher kann kein weiteres Bargeld und keine Karte mehr hinzugefügt werden. GNU Taler bezahlt den Rest.</string> + <string name="checkout_cancel">Zahlung abbrechen</string> + <string name="checkout_cash_title">Bargeld</string> + <string name="checkout_cash_received">Erhalten</string> + <string name="checkout_cash_change">Rückgeld</string> + <string name="checkout_cash_exact">Passender Betrag</string> + <string name="checkout_cash_record">Bargeld erfassen</string> + <string name="checkout_change_due_title">Rückgeld geben</string> + <string name="checkout_change_due">Geben Sie %1$s an die Kundschaft zurück.</string> + <string name="checkout_card_title">Kartenzahlung</string> + <string name="checkout_card_amount">Zu belastender Betrag</string> + <string name="checkout_card_full">Ganzer offener Betrag</string> + <string name="checkout_card_charge">Karte belasten</string> + <string name="checkout_card_too_much">Mehr als noch offen ist</string> + <string name="checkout_card_not_ready">Das Kartenlesegerät ist nicht bereit. Prüfen Sie, ob Sie angemeldet sind und das Lesegerät verbunden ist.</string> + <string name="checkout_card_recorded">Kartenzahlung über %1$s erfasst</string> + <string name="checkout_card_failed">Die Kartenzahlung ist nicht durchgegangen.</string> + <string name="checkout_card_unknown_title">Kartenzahlung unbestätigt</string> + <string name="checkout_card_unknown">Es konnte nicht bestätigt werden, ob %1$s belastet wurde. Prüfen Sie die Transaktion in der App des Kartenanbieters, bevor Sie erneut belasten. Es wurde nichts erfasst.</string> + <string name="checkout_record_failed">Die Zahlung konnte nicht gespeichert werden. Geben Sie kein Rückgeld, bevor sie erfasst ist.</string> + <string name="checkout_none">Diese Zahlung ist nicht mehr offen.</string> + <string name="checkout_payment_line_change">Erhalten %1$s, Rückgeld %2$s</string> + <string name="checkout_payment_line_reference">Referenz %1$s</string> + <string name="checkout_history_title">Offene Zahlungen</string> + <string name="checkout_history_continue">Fortsetzen</string> + <string name="checkout_history_dismiss">Als erledigt markieren</string> + <string name="checkout_history_amounts">Bezahlt %1$s von %2$s</string> + <string name="checkout_stage_collecting">Zahlungen werden angenommen</string> + <string name="checkout_stage_awaiting_taler">Wartet auf GNU Taler</string> + <string name="checkout_stage_abandoned">Abgebrochen, Geld zurückgeben</string> + <string name="payment_options_mixed_unsupported">Zahlen in mehreren Teilen erfordert das Merchant-Backend-Protokoll v42 oder neuer. Dieses Backend meldet %1$s.</string> + <string name="checkout_total_amount">Gesamt: %1$s</string> + <string name="checkout_paid_amount">Bezahlt: %1$s</string> + <string name="checkout_total_to_pay">Zu zahlen: %1$s</string> + <string name="checkout_about">Zur Zahlung</string> + <string name="checkout_about_message">%1$s wurde bereits angenommen. Ein Abbruch bedeutet, das Bargeld zurückzugeben und eine Kartenzahlung in der App des Kartenanbieters zu erstatten.</string> + <string name="checkout_cancel_anyway">Zahlung trotzdem abbrechen</string> + <string name="checkout_settled_hint">Alles wurde bezahlt. Schliessen Sie die Bestellung ab.</string> + <string name="checkout_tile_finish_order">Bestellung abschliessen</string> + <string name="checkout_self_collect">Für die Kundschaft abholen</string> + <string name="checkout_back_to_options">Zurück zu den Zahlungsarten</string> + <string name="checkout_revert_failed">Die Bestellung konnte nicht zurückgenommen werden.</string> + <string name="payment_option_activation_title">Bevor Sie dies aktivieren</string> + <string name="payment_option_activation_confirm">Aktivieren</string> + <string name="terminal_sumup_activation_notice">Mit der aktuellen SumUp-Software kann diese App Karten belasten, aber kein Geld zurückgeben. Eine Rückerstattung muss in bar erfolgen, über eine andere Zahlungsart abgewickelt oder in der SumUp-App selbst ausgeführt werden.\n\nBitte klären Sie mit Ihrer Treuhand oder Rechtsberatung, ob das zu den Rückgaberegeln Ihres Betriebs passt.</string> + <string name="history_taler_remaining">Offen mit GNU Taler: %1$s</string> + <string name="history_status_partially_paid">Teilweise bezahlt</string> + <string name="history_status_awaiting_collection">Abholung ausstehend</string> + <string name="history_taler_paid">Mit GNU Taler bezahlt: %1$s</string> + + <!-- Order details --> + <string name="order_details_title">Bestelldetails</string> + <string name="order_details_loading">Bestelldetails werden geladen…</string> + <string name="order_details_unavailable">Details nicht verfügbar</string> + <string name="order_details_load_error">Die Bestellung konnte nicht geladen werden. Bitte prüfen Sie die Verbindung und versuchen Sie es erneut.</string> + <string name="order_details_retry">Erneut versuchen</string> + <string name="order_details_fulfillment_link">Link zur Bestellabwicklung</string> + <string name="order_details_not_provided">Der Händler hat keine weiteren Details zu dieser Bestellung bereitgestellt.</string> + <string name="order_details_order_id">Bestellreferenz</string> + <string name="order_details_refund_total">Erstatteter Betrag</string> + <string name="order_details_summary">Zusammenfassung</string> + <string name="order_details_products">Produkte</string> + <string name="order_details_no_products">Es wurden keine einzelnen Produkte angegeben.</string> + <string name="order_details_net_price">%1$s (netto)</string> + <string name="order_details_payments">Zahlungsaufteilung</string> + <string name="order_details_paid_total">Bereits bezahlt</string> + <string name="order_details_balance">Noch zu bezahlen</string> + <string name="order_details_delivery">Lieferung / Abholung</string> + <string name="order_details_delivery_location">Lieferadresse</string> + <string name="order_details_delivery_date">Lieferdatum</string> + <string name="order_details_pickup_time">Späteste Abholzeit</string> + <string name="order_details_reorder">Erneut bestellen</string> + <string name="order_details_reorder_hint">Teilen Sie diesen Link, um dieselben Produkte erneut zu bestellen.</string> + <string name="order_details_reorder_link">Link zur erneuten Bestellung</string> + <string name="order_details_additional">Weitere Informationen</string> + <string name="order_details_pay_deadline">Zahlungsfrist</string> + <string name="order_details_refund_deadline">Erstattungsfrist</string> + <string name="order_details_merchant">Händlerdetails</string> + <string name="order_details_jurisdiction">Gerichtsstand</string> + <string name="order_details_email">E-Mail</string> + <string name="order_details_website">Webseite</string> + <string name="order_details_open_link">Link öffnen</string> + <string name="order_details_copied">Kopiert</string> + <string name="order_details_copy">%1$s kopieren</string> + <string name="order_details_open_failed">Keine App zum Öffnen dieses Links verfügbar.</string> + + <!-- Refund method selection and order deletion --> + <string name="refund_choose_method">Erstattungsmethode</string> + <string name="refund_available">Zur Erstattung verfügbar</string> + <string name="refund_unavailable">Nicht verfügbar</string> + <string name="refund_unavailable_hint">Diese Methode kann hier nicht erstattet werden. Externe Erstattungen erfordern eine bezahlte Bestellung und den Zahlungsbeleg auf diesem Gerät. Taler-Erstattungen müssen noch verfügbar sein.</string> + <string name="refund_taler_button">Taler erstatten</string> + <string name="refund_taler_hint">Genehmigen Sie die Erstattung. Danach kann der Kunde sie mit seiner Taler-Wallet abholen.</string> + <string name="refund_cash_hint">Geben Sie dem Kunden das Bargeld zurück und erfassen Sie danach den zurückgegebenen Betrag.</string> + <string name="refund_card_hint">Erstatten Sie die ursprüngliche Kartenzahlung in der App des Kartenanbieters. Erfassen Sie danach hier den zurückgezahlten Betrag. Dieser Bildschirm überweist kein Geld auf die Karte.</string> + <string name="refund_cash_confirm">Bestätigen Sie, dass dieses Bargeld dem Kunden zurückgegeben wurde.</string> + <string name="refund_card_confirm">Bestätigen Sie, dass der Kartenanbieter diese Erstattung abgeschlossen hat. Die Erfassung hier löst keine weitere Erstattung aus.</string> + <string name="refund_returned_confirm">Geld zurückgegeben – erfassen</string> + <string name="refund_record_external">Erstattung erfassen</string> + <string name="refund_recorded">Erstattung erfasst</string> + <string name="refund_recorded_message">Der zurückgezahlte Betrag wurde in der Bestellung erfasst.</string> + <string name="refund_recorded_local">Auf diesem Gerät gespeichert. Die Bestellung konnte jedoch nicht aktualisiert werden. Zahlen Sie diesen Betrag nicht erneut zurück. Backend-Meldung: %1$s</string> + <string name="order_delete_final_title">Diese Bestellung endgültig löschen?</string> + <string name="order_delete_warning">Das Löschen entfernt diese unbezahlte Bestellung. Es gibt weder Bargeld zurück noch erstattet es Karten- oder Taler-Zahlungen. Klären Sie alle Zahlungen mit dem Kunden, bevor Sie fortfahren.</string> + <string name="order_delete_final_warning">Dies kann nicht rückgängig gemacht werden. Bestätigen Sie, dass alle Zahlungen geklärt sind und Sie diese Bestellung endgültig löschen möchten.</string> + <string name="order_delete_continue">Weiter</string> + <string name="product_image_open">Bild anzeigen: %1$s</string> + <string name="product_image_close">Bild schließen</string> + <string name="payment_pay_with">Bezahlen mit</string> + + <string name="checkout_complete_payment">Zahlung abschliessen</string> + <string name="checkout_collect_receipt_hint">Die Kundschaft kann den QR-Code auf dem nächsten Bildschirm mit einer Taler-Wallet scannen, um die Quittung zu erhalten.</string> + <string name="checkout_collect_receipt_title">Quittung abholen</string> + <string name="checkout_receipt_paid_in_full">Vollständig bezahlt</string> + <string name="checkout_receipt_scan_hint">Mit einer Taler-Wallet scannen, um die Quittung zu erhalten.</string> + <string name="checkout_collect_receipt_on_device">Quittung auf diesem Gerät abholen</string> + <string name="checkout_payment_claimed_hint">Warten auf Bestätigung durch die Kundschaft</string> + <string name="checkout_payment_claimed_title">Zahlung wird verarbeitet</string> + <string name="checkout_customer_opened_payment">Die Kundschaft hat diese Zahlung in ihrer Wallet geöffnet.</string> + <string name="checkout_enter_cash_amount">Barbetrag eingeben %1$s offen</string> + <string name="checkout_enter_card_amount">Kartenbetrag eingeben %1$s offen</string> + <string name="checkout_tap_to_enter_amount">Tippen, um Betrag einzugeben</string> + <string name="checkout_cash_entry_instruction">Erhaltenen Betrag eingeben</string> + <string name="checkout_card_entry_instruction">Zu belastenden Betrag eingeben</string> + <string name="checkout_fraction_full">Gesamtbetrag</string> + <string name="checkout_fraction_full_short">Ganz</string> + <string name="checkout_fraction_remaining">Restbetrag</string> + <string name="checkout_fraction_half_description">Die Hälfte des Restbetrags, %1$s</string> + <string name="checkout_fraction_third_description">Ein Drittel des Restbetrags, %1$s</string> + <string name="checkout_fraction_quarter_description">Ein Viertel des Restbetrags, %1$s</string> + <string name="checkout_fraction_remaining_description">Restbetrag, %1$s</string> + <string name="checkout_charge_with_taler">Mit Taler bezahlen</string> + <string name="checkout_charge_remaining_taler">Restbetrag mit Taler bezahlen</string> + <string name="checkout_order_total_heading">Bestellsumme</string> + <string name="checkout_payments_heading">Zahlungen</string> + <string name="checkout_payments_added">%1$d hinzugefügt</string> + <string name="checkout_payments_empty_inline">Hinzugefügte Zahlungen erscheinen hier.</string> + <string name="checkout_order_reference">Bestellung #%1$d</string> + <string name="checkout_choose_payment_method">Zahlungsart wählen</string> + <string name="checkout_payment_method_helper">Bar oder mit Karte bezahlen oder den Restbetrag mit Taler begleichen.</string> + <string name="checkout_complete_order_hint">Schliesst diese Bestellung ab</string> + <string name="checkout_charge_remaining_hint">Begleicht den offenen Restbetrag</string> + <string name="checkout_collect_receipt_action">Quittung mit Taler abholen</string> + <string name="checkout_remaining_amount">Offen: %1$s</string> + <string name="checkout_about_action">Zahlungsdetails</string> + <string name="checkout_fully_paid">Vollständig bezahlt</string> + <string name="checkout_scan_to_pay_hint">Die Kundschaft kann den QR-Code auf dem nächsten Bildschirm scannen, um zu bezahlen.</string> + <string name="checkout_scan_qr_prompt">Den QR-Code mit der mobilen Wallet scannen.</string> + <string name="checkout_payment_detail_order">Bestellung</string> + <string name="checkout_payment_detail_summary">Zusammenfassung</string> + <string name="checkout_waiting_customer_payment">Warten auf Zahlung…</string> + <string name="checkout_remaining_heading">Offen</string> + <string name="checkout_remaining_to_pay">Noch zu bezahlen %1$s</string> + <string name="checkout_exact_cash_amount">Exakter Betrag %1$s</string> + <string name="checkout_full_card_amount">Voller Restbetrag %1$s</string> + <string name="checkout_remaining_after_payment">Restbetrag nach Zahlung: %1$s</string> + <string name="checkout_change_to_return_amount">Rückgeld: %1$s</string> + <string name="checkout_cash_change_explanation">Zu viel bezahltes Bargeld wird als Rückgeld angezeigt.</string> + <string name="checkout_record_cash_action">%1$s Bargeld erfassen</string> + <string name="checkout_charge_card_action">%1$s mit Karte belasten</string> + <string name="checkout_cash_received_heading">Erhaltenes Bargeld</string> + <string name="checkout_card_payment_heading">Kartenzahlung</string> + <string name="checkout_card_amount_limit_hint">Der Kartenbetrag darf den offenen Restbetrag nicht überschreiten.</string> + + <string name="checkout_creation_pending">Die Bestellung wurde möglicherweise bereits erstellt. Wiederholen Sie den Taler-Schritt, bevor Sie Zahlungen ändern.</string> + <string name="checkout_pending_charge">Klären Sie die ausstehende Kartenzahlung von %1$s mit %2$s. Prüfen Sie den endgültigen Status beim Anbieter anhand der Referenz %3$s, bevor Sie fortfahren.</string> + <string name="checkout_pending_paid">Zahlung beim Anbieter bestätigt</string> + <string name="checkout_pending_not_paid">Anbieter bestätigt keine Belastung</string> + <string name="checkout_pending_confirm_title">Zahlungsergebnis bestätigen</string> + <string name="checkout_pending_confirm_paid">Bestätigen Sie, dass genau diese Zahlung erfolgreich war. Geben Sie die Transaktionsreferenz des Anbieters ein.</string> + <string name="checkout_pending_confirm_unpaid">Fahren Sie nur fort, wenn der Anbieter bestätigt, dass diese Zahlung fehlgeschlagen ist oder storniert wurde und nicht mehr ausgeführt werden kann. Ein ausstehendes oder fehlendes Ergebnis ist keine Bestätigung.</string> + <string name="checkout_pending_transaction">Transaktionsreferenz des Anbieters</string> + + <string name="history_total_paid_remaining">Gesamt: %1$s Bezahlt: %2$s Offen: %3$s</string> + <string name="history_checkout_total_paid_remaining">Gesamt: %1$s Bezahlt: %2$s Offen: %3$s</string> + <string name="order_details_delete_action">Löschen</string> + <string name="settings_manage_connection_action">Händlereinstellungen</string> + <string name="settings_logout_action">Abmelden</string> + <string name="payment_options_manage_action">Zahlungsarten verwalten</string> + <string name="history_checkout_progress">Bezahlt: %1$s Verbleibend: %2$s</string> + + <string name="order_actions">Aktionen</string> + <string name="order_actions_collapsed">Aktionen</string> + <string name="order_actions_expanded">Aktionen</string> + <string name="order_current">Aktuelle Bestellung</string> + <string name="order_custom_short">Individuell</string> + <string name="amount_entry_hint">Betrag eingeben</string> + + <string name="taler_ble_title">Taler-Bluetooth-Display</string> + <string name="taler_ble_description">Zahlungs- und Erstattungs-QR-Codes auf einem Taler-LCD anzeigen und NFC-Zahlungen ermöglichen.</string> + <string name="taler_ble_unsupported">Bluetooth ist auf diesem Gerät nicht verfügbar.</string> + <string name="taler_ble_permission">Zugriff auf Geräte in der Nähe erlauben, um das Display zu verbinden.</string> + <string name="taler_ble_off">Bluetooth einschalten, um das Display zu verbinden.</string> + <string name="taler_ble_location">Diese Android-Version benötigt die aktivierte Standortfunktion zur Bluetooth-Gerätesuche.</string> + <string name="taler_ble_scanning">Taler-Displays werden gesucht…</string> + <string name="taler_ble_connecting">Verbindung zum Display wird hergestellt…</string> + <string name="taler_ble_sending">Display wird aktualisiert…</string> + <string name="taler_ble_too_long">Dieser Link ist für das Display zu lang. Den QR-Code auf diesem Gerät verwenden.</string> + <string name="taler_ble_failed">Display konnte nicht aktualisiert werden. Prüfen, ob es eingeschaltet und in der Nähe ist.</string> + <string name="taler_ble_allow">Gerätezugriff erlauben</string> + <string name="taler_ble_open_settings">Geräteeinstellungen öffnen</string> + <string name="taler_ble_scan">Taler-Display suchen</string> + <string name="taler_ble_test">Display testen</string> + <string name="taler_ble_test_hint">Sendet einen Test-QR-Code für fünf Sekunden und fordert danach das Zurücksetzen an. Es wird keine Zahlung erstellt.</string> + <string name="taler_ble_reset_limitation">Diese Anzeige kann nach einer Zahlung oder einem Test den vorherigen QR-Code behalten. Starten Sie die Anzeige neu, um ihn zu löschen, bis ihre Firmware das Zurücksetzen unterstützt.</string> + <string name="taler_ble_reconnect">Erneut verbinden</string> + <string name="taler_ble_forget">Aus dieser Kasse entfernen</string> + <string name="taler_ble_pair_instructions">Auf dem LCD nach links wischen und Settings → Bluetooth → Pair a POS öffnen. Dann hier das passende Taler-Display suchen und auswählen.</string> + <string name="taler_ble_pairing">Kopplung läuft… Den auf dem LCD angezeigten Code im Android-Kopplungsdialog eingeben.</string> + <string name="taler_ble_pairing_required">Die Kopplung wurde nicht abgeschlossen oder die gespeicherte Kopplung funktioniert nicht mehr.</string> + <string name="taler_ble_repair_instructions">Zum erneuten Koppeln dieses Display in den Android-Bluetooth-Einstellungen entfernen. Auf dem LCD die alte Kasse entfernen, falls vorhanden, und Pair a POS wählen. Danach hier erneut verbinden.</string> + <string name="taler_ble_forget_explanation">Dieses Display nicht mehr mit dieser Kasse verwenden? Die gespeicherte Auswahl wird aus der App entfernt. Zum Aufheben der Bluetooth-Kopplung das Display auch in den Android-Bluetooth-Einstellungen entfernen und auf dem LCD Forget this POS wählen.</string> + + <string name="taler_devices_manage">Displays verwalten</string> + <string name="taler_devices_title">Taler-Displays</string> + <string name="taler_devices_empty">Noch keine Displays hinzugefügt.</string> + <string name="taler_devices_add">Gerät hinzufügen</string> + <string name="taler_devices_check_usb">USB-Verbindung prüfen</string> + <string name="taler_devices_remove">Display entfernen</string> + <string name="taler_devices_replace">Display ersetzen</string> + <string name="taler_devices_replace_hint">Diese Kasse verwendet ein Bluetooth-Display gleichzeitig. Aktuelles Display ersetzen?</string> + <string name="taler_devices_access">Bluetooth-Verbindung erlauben</string> + <string name="taler_devices_open_lcd">Display-Einstellungen öffnen</string> + <string name="taler_devices_open_lcd_hint">Auf dem Display nach links wischen und auf das Zahnrad tippen.</string> + <string name="taler_devices_pair_lcd">Kopplung am Display starten</string> + <string name="taler_devices_pair_lcd_hint">Auf dem Display Bluetooth → Pair a POS wählen.</string> + <string name="taler_devices_choose_display">Display auswählen</string> + <string name="taler_devices_connect">Display verbinden</string> + <string name="taler_devices_connected">Display verbunden</string> + <string name="taler_devices_connected_hint">Das Display ist bereit für Zahlungen.</string> + <string name="taler_devices_bluetooth">Bluetooth-Display</string> + <string name="taler_devices_usb">USB-Gerät</string> + <string name="taler_devices_scan_finished">Display unten auswählen oder erneut suchen, falls es fehlt.</string> + <string name="taler_devices_checking">Verbindung wird geprüft…</string> + <string name="taler_devices_done">Fertig</string> + <string name="taler_devices_next">Weiter</string> + <string name="taler_devices_search_again">Erneut suchen</string> + <string name="taler_devices_pair_again">Erneut koppeln</string> + <string name="taler_devices_repair_android">Dieses Display in den Android-Bluetooth-Einstellungen entfernen und hierher zurückkehren.</string> + <string name="taler_devices_repair_lcd">Auf dem Display Bluetooth öffnen, die alte Kasse entfernen und Pair a POS wählen.</string> + <string name="taler_devices_test_hint">Ein Test-QR-Code wird fünf Sekunden angezeigt und dann gelöscht. Es wird keine Zahlung erstellt.</string> + + <string name="taler_usb_title">Taler-USB-Gerät</string> + <string name="taler_usb_description">Den Zahlungs- oder Erstattungslink an ein verbundenes Taler-NFC-Gerät senden. QR-Codes bleiben verfügbar.</string> + <string name="taler_usb_experimental">Experimentelle Unterstützung. Das Löschen des vorherigen Links muss mit dieser Firmware noch geprüft werden. Bis dahin das Gerät nach Gebrauch trennen.</string> + <string name="taler_usb_not_attached">Ein unterstütztes Taler-Gerät per USB anschließen.</string> + <string name="taler_usb_device">Taler-USB-Gerät %1$d</string> + <string name="taler_usb_permission_required">USB-Zugriff ist erforderlich.</string> + <string name="taler_usb_allow_device">USB-Zugriff erlauben Gerät %1$d</string> + <string name="taler_usb_failed">Gerät konnte nicht aktualisiert werden. Erneut verbinden oder nochmals versuchen.</string> + <string name="taler_usb_refresh">Verbindung prüfen</string> + <string name="checkout_order_claimed">Bestellung von der Wallet übernommen.</string> + <string name="checkout_complete_in_wallet">Zahlung in der Wallet abschliessen</string> + <string name="checkout_payment_possible_until">Zahlung möglich bis %1$s</string> + <string name="checkout_payment_detail_merchant">Händler</string> + <string name="config_local_network_title">Mit einem lokalen Händler verbinden</string> + <string name="config_local_network_explanation">Ihr Händlerserver unter %1$s befindet sich in einem lokalen Netzwerk. Erlauben Sie den Zugriff auf Geräte in der Nähe, damit Taler POS sich verbinden und Bestellungen verarbeiten kann.</string> + <string name="config_local_network_settings">Der Zugriff auf das lokale Netzwerk ist blockiert. Öffnen Sie die App-Einstellungen, wählen Sie Berechtigungen → Geräte in der Nähe und erlauben Sie den Zugriff. Kehren Sie dann hierher zurück, um sich mit %1$s zu verbinden.</string> + <string name="config_local_network_allow">Zugriff erlauben</string> + <string name="config_local_network_required">Erlauben Sie den Zugriff auf das lokale Netzwerk, um sich mit diesem Händler zu verbinden.</string> + <string name="config_camera_title">Kamerazugriff für die QR-Einrichtung</string> + <string name="config_camera_explanation">Die Kamera scannt den Einrichtungs-QR-Code aus dem Merchant Backoffice, um Ihre Kontoeinstellungen auszufüllen. Sie können diese Angaben auch manuell eingeben.</string> + <string name="config_camera_settings_instructions">Der Kamerazugriff ist blockiert. Öffnen Sie die App-Einstellungen, wählen Sie Berechtigungen → Kamera und erlauben Sie den Zugriff während der Nutzung der App. Kehren Sie dann zum Scannen hierher zurück.</string> + <string name="config_camera_allow">Kamera erlauben</string> + <string name="config_camera_open_settings">Einstellungen öffnen</string> + <string name="config_camera_manual">Manuell konfigurieren</string> + <string name="config_connecting">Verbindung wird hergestellt…</string> + <string name="password_show">Passwort anzeigen</string> + <string name="password_hide">Passwort ausblenden</string> + <string name="navigation_back">Zurück</string> + <string name="navigation_menu_open">Navigationsmenü öffnen</string> + <string name="order_empty_inventory">Keine Produkte vorhanden. Verwenden Sie Bestellung nach Betrag oder fügen Sie ein eigenes Produkt hinzu.</string> </resources> diff --git a/merchant-terminal/src/main/res/values-es/strings.xml b/merchant-terminal/src/main/res/values-es/strings.xml @@ -75,9 +75,9 @@ <string name="config_error_currency">Error: Producto %1$s tiene una divisa %2$s, pero %3$s esperada</string> <string name="config_changed">Cambiado al nuevo comercio usando %s</string> <string name="config_docs">Por favor consulta <a href="https://docs.taler.net/taler-merchant-pos-terminal.html#apis-and-data-formats">la documentación</a> para la configuración del formato.</string> - <string name="config_no_instance">¿No tienes una instancia?</string> + <string name="config_no_instance">¿No tienes una cuenta?</string> <string name="config_instance_tutorial_description">1. Consulta los tutoriales de configuración en:</string> - <string name="config_instance_demo_description">2. Puedes probar la aplicación con la instancia pública de demostración. Ten en cuenta que esta instancia es compartida: cualquiera puede modificar sus datos en cualquier momento. Úsala únicamente para explorar la aplicación y sus funciones.</string> + <string name="config_instance_demo_description">2. Puedes probar la aplicación con la cuenta pública de demostración. Ten en cuenta que esta cuenta es compartida: cualquiera puede modificar sus datos en cualquier momento. Úsala únicamente para explorar la aplicación y sus funciones.</string> <string name="config_fill_demo_credentials">Rellenar credenciales de demostración</string> <string name="config_instance_review_description">3. Si estás revisando esta aplicación, probablemente ya tengas acceso a un usuario de prueba controlado. Usa el botón de abajo para rellenar la URL del portal del vendedor.</string> <string name="config_fill_review_url">Rellenar URL de revisión</string> @@ -157,4 +157,304 @@ <string name="insecure_payment_dialog_try_https">Probar enlace corregido</string> <string name="insecure_payment_dialog_continue_as_is">Continuar tal cual</string> <string name="insecure_payment_dialog_cancel_order">Cancelar pedido</string> + <string name="payment_device_nfc_available">disponible</string> + <string name="settings_about_title">Acerca de</string> + <string name="refund_reason_wrong_order">Pedido incorrecto</string> + <string name="payment_options_title">Opciones de pago</string> + <string name="payment_options_button">Opciones de pago</string> + <string name="payment_options_description">Taler siempre está disponible. Activa otros métodos de pago o abre una opción para configurarla.</string> + <string name="payment_options_enabled_count">%1$d activados</string> + <string name="payment_option_open">Abrir ajustes de %1$s</string> + <string name="payment_option_settings_title">Ajustes</string> + <string name="payment_option_unknown">Esta opción de pago no está disponible en esta versión.</string> + <string name="payment_option_disabled">Desactivado</string> + <string name="payment_option_ready">Listo</string> + <string name="payment_option_taler">GNU Taler</string> + <string name="payment_option_taler_description">Los clientes pagan desde una cartera Taler escaneando el código QR o acercando el teléfono.</string> + <string name="payment_option_cash">Efectivo</string> + <string name="payment_option_cash_description">Los clientes pagan en efectivo en el mostrador. El pedido se liquida fuera de la aplicación.</string> + <string name="terminal_not_configured">La configuración no está disponible en esta versión. Contacta con el administrador de la aplicación.</string> + <string name="terminal_sign_in_required">Inicia sesión para emparejar un lector.</string> + <string name="terminal_permission_required">Se necesita permiso para acceder a dispositivos cercanos. Abre la configuración del lector para continuar.</string> + <string name="terminal_not_paired">Ningún lector emparejado</string> + <string name="terminal_disconnected">Lector desconectado</string> + <string name="terminal_connected">Lector conectado</string> + <string name="terminal_unavailable">No se puede comprobar la conexión del lector</string> + <string name="terminal_pair">Emparejar lector</string> + <string name="terminal_manage">Gestionar lector</string> + <string name="terminal_account">Cuenta: %1$s</string> + <string name="terminal_serial">Número de serie: %1$s</string> + <string name="terminal_indicator_description">Dispositivos de pago: %1$s</string> + <string name="terminal_close">Cerrar</string> + <string name="terminal_action_failed">No se ha podido completar la configuración del terminal. Comprueba Bluetooth, los permisos y la conexión a Internet e inténtalo de nuevo.</string> + <string name="terminal_sumup">SumUp</string> + <string name="terminal_sumup_description">Los clientes pagan con tarjeta en un lector SumUp emparejado con este dispositivo.</string> + <string name="terminal_sumup_sign_in">Iniciar sesión en SumUp</string> + <string name="terminal_sumup_sign_out">Cerrar sesión de SumUp</string> + <string name="terminal_sumup_save_key">Guardar clave de afiliado</string> + <string name="terminal_sumup_advanced">Avanzado</string> + <string name="terminal_sumup_advanced_help">La clave de afiliado identifica esta aplicación ante SumUp, no tu negocio. Casi nunca necesitas cambiarla. Sustitúyela solo si tienes tu propio registro como socio de SumUp.</string> + <string name="terminal_sumup_key_builtin">Se usa la clave de afiliado integrada en esta aplicación.</string> + <string name="terminal_sumup_key_custom">Se usa una clave de afiliado personalizada.</string> + <string name="terminal_sumup_key_hint">Nueva clave de afiliado</string> + <string name="terminal_sumup_key_show">Mostrar clave</string> + <string name="terminal_sumup_key_hide">Ocultar clave</string> + <string name="terminal_sumup_key_warning">Guardar otra clave cierra tu sesión de SumUp.</string> + <string name="terminal_sumup_reset_key">Usar la clave integrada</string> + <string name="terminal_sumup_session_help">Una sesión de SumUp dura aproximadamente una hora. Con tu propia aplicación OAuth, la aplicación la renueva automáticamente para que el personal no tenga que volver a iniciar sesión.</string> + <string name="terminal_sumup_session_no_client">No hay ninguna aplicación OAuth configurada. Añade una en Avanzado para mantener la sesión activa.</string> + <string name="terminal_sumup_session_not_authorized">Aún no está autorizado. Autoriza este dispositivo una vez para que la sesión permanezca abierta.</string> + <string name="terminal_sumup_session_expires">El token de acceso caduca en %1$s</string> + <string name="terminal_sumup_session_refreshes">Renovado %1$d veces, la última a las %2$s</string> + <string name="terminal_sumup_session_error">Último problema de renovación: %1$s</string> + <string name="terminal_sumup_authorize">Autorizar este dispositivo</string> + <string name="terminal_sumup_reauthorize">Autorizar de nuevo</string> + <string name="terminal_sumup_refresh_now">Renovar token ahora</string> + <string name="terminal_sumup_revoke">Olvidar autorización</string> + <string name="terminal_sumup_oauth_help">Registra una aplicación OAuth en tu panel de SumUp con la URL de redirección %1$s y pega aquí su ID y secreto de cliente. Se guardan solo en este dispositivo.</string> + <string name="terminal_sumup_oauth_set">Hay una aplicación OAuth configurada.</string> + <string name="terminal_sumup_oauth_unset">No hay ninguna aplicación OAuth configurada.</string> + <string name="terminal_sumup_client_id">ID de cliente OAuth</string> + <string name="terminal_sumup_client_secret">Secreto de cliente OAuth</string> + <string name="terminal_sumup_save_client">Guardar aplicación OAuth</string> + <string name="payment_device_connected">%1$s conectado</string> + <string name="payment_device_disconnected">%1$s no conectado</string> + <string name="payment_device_nfc">NFC</string> + <string name="payment_device_status_connected">Conectado</string> + <string name="payment_device_status_disconnected">No conectado</string> + <string name="payment_device_battery">Batería %1$d%%</string> + <string name="checkout_title">Pago</string> + <string name="checkout_total">Total</string> + <string name="checkout_payments_button">Pagos registrados (%1$d)</string> + <string name="checkout_payments_title">Pagos registrados</string> + <string name="checkout_no_taler_hint">GNU Taler está desactivado, así que toda la cantidad debe pagarse con las otras opciones.</string> + <string name="checkout_order_created_hint">El pedido se ha creado y ya no se pueden añadir pagos en efectivo o con tarjeta. GNU Taler paga el resto.</string> + <string name="checkout_cancel">Cancelar pago</string> + <string name="checkout_change_due_title">Dar cambio</string> + <string name="checkout_change_due">Devuelve %1$s al cliente.</string> + <string name="checkout_card_title">Pago con tarjeta</string> + <string name="checkout_card_amount">Cantidad a cobrar</string> + <string name="checkout_card_too_much">Supera el saldo pendiente</string> + <string name="checkout_card_not_ready">El lector de tarjetas no está listo. Comprueba que has iniciado sesión y que el lector está conectado.</string> + <string name="checkout_card_recorded">Pago con tarjeta de %1$s registrado</string> + <string name="checkout_card_failed">El pago con tarjeta no se ha completado.</string> + <string name="checkout_card_unknown_title">Pago con tarjeta sin confirmar</string> + <string name="checkout_card_unknown">No se ha podido confirmar si se ha cobrado %1$s. Comprueba la transacción en la aplicación del proveedor de pagos antes de volver a cobrar. No se ha registrado nada.</string> + <string name="checkout_record_failed">No se ha podido guardar el pago. No entregues el cambio hasta que se registre.</string> + <string name="checkout_none">Este pago ya no está pendiente.</string> + <string name="checkout_payment_line_change">Recibido %1$s, cambio %2$s</string> + <string name="checkout_payment_line_reference">Referencia %1$s</string> + <string name="checkout_history_continue">Continuar</string> + <string name="checkout_history_dismiss">Marcar como saldado</string> + <string name="payment_options_mixed_unsupported">El pago en varias partes necesita la versión 42 o posterior del protocolo del backend del comerciante. Este backend indica %1$s.</string> + <string name="checkout_total_amount">Total: %1$s</string> + <string name="checkout_paid_amount">Pagado: %1$s</string> + <string name="checkout_about">Acerca del pago</string> + <string name="checkout_about_message">Ya se ha cobrado %1$s. Si cancelas ahora, debes devolver el efectivo y reembolsar cualquier pago con tarjeta en la aplicación del proveedor de pagos.</string> + <string name="checkout_cancel_anyway">Cancelar el pago de todos modos</string> + <string name="checkout_settled_hint">Todo está pagado. Finaliza el pedido para completarlo.</string> + <string name="checkout_back_to_options">Volver a las opciones de pago</string> + <string name="checkout_revert_failed">No se ha podido retirar el pedido.</string> + <string name="payment_option_activation_title">Antes de activar esta opción</string> + <string name="payment_option_activation_confirm">Activar</string> + <string name="terminal_sumup_activation_notice">Con el software actual de SumUp, esta aplicación puede cobrar tarjetas, pero no devolver dinero. Los reembolsos deben entregarse en efectivo, hacerse con otro método de pago o procesarse en la aplicación de SumUp.\n\nConsulta con tu asesor contable o jurídico que esto se ajuste a las obligaciones de tu negocio para gestionar devoluciones.</string> + <string name="history_status_partially_paid">Pagado parcialmente</string> + <string name="history_status_awaiting_collection">Pendiente de recogida</string> + <string name="order_details_title">Detalles del pedido</string> + <string name="order_details_loading">Cargando detalles del pedido…</string> + <string name="order_details_unavailable">Detalles no disponibles</string> + <string name="order_details_load_error">No se ha podido cargar este pedido. Comprueba la conexión e inténtalo de nuevo.</string> + <string name="order_details_retry">Reintentar</string> + <string name="order_details_fulfillment_link">Enlace de entrega del pedido</string> + <string name="order_details_not_provided">El comerciante no ha proporcionado más detalles de este pedido.</string> + <string name="order_details_order_id">Referencia del pedido</string> + <string name="order_details_refund_total">Cantidad reembolsada</string> + <string name="order_details_summary">Resumen del pedido</string> + <string name="order_details_products">Productos</string> + <string name="order_details_no_products">No se ha proporcionado una lista detallada de productos.</string> + <string name="order_details_net_price">%1$s (neto)</string> + <string name="order_details_payments">Desglose de pagos</string> + <string name="order_details_balance">Pendiente de pago</string> + <string name="order_details_delivery">Entrega / Recogida</string> + <string name="order_details_delivery_location">Lugar de entrega</string> + <string name="order_details_delivery_date">Fecha de entrega</string> + <string name="order_details_pickup_time">Hora límite de recogida</string> + <string name="order_details_reorder">Volver a pedir</string> + <string name="order_details_reorder_hint">Comparte este enlace para volver a pedir los mismos productos.</string> + <string name="order_details_reorder_link">Enlace para volver a pedir</string> + <string name="order_details_additional">Información adicional</string> + <string name="order_details_pay_deadline">Fecha límite de pago</string> + <string name="order_details_refund_deadline">Fecha límite de reembolso</string> + <string name="order_details_merchant">Datos del comerciante</string> + <string name="order_details_jurisdiction">Jurisdicción</string> + <string name="order_details_email">Correo electrónico</string> + <string name="order_details_website">Sitio web</string> + <string name="order_details_open_link">Abrir enlace</string> + <string name="order_details_copied">Copiado al portapapeles</string> + <string name="order_details_copy">Copiar %1$s</string> + <string name="order_details_open_failed">No hay ninguna aplicación disponible para abrir este enlace.</string> + <string name="refund_choose_method">Método de reembolso</string> + <string name="refund_available">Disponible para reembolsar</string> + <string name="refund_unavailable">No disponible</string> + <string name="refund_unavailable_hint">Este método no se puede reembolsar aquí. Los reembolsos externos requieren un pedido pagado y su registro de pago en este dispositivo. Los reembolsos Taler deben seguir disponibles.</string> + <string name="refund_taler_button">Reembolsar Taler</string> + <string name="refund_taler_hint">Aprueba el reembolso y deja que el cliente lo recoja con su cartera Taler.</string> + <string name="refund_cash_hint">Devuelve el efectivo al cliente y registra la cantidad devuelta.</string> + <string name="refund_card_hint">Reembolsa el pago original con tarjeta en la aplicación del proveedor y registra aquí la cantidad devuelta. Esta pantalla no envía dinero a la tarjeta.</string> + <string name="refund_cash_confirm">Confirma que se ha devuelto este efectivo al cliente.</string> + <string name="refund_card_confirm">Confirma que el proveedor de pagos ha completado este reembolso. Registrarlo aquí no emite otro reembolso.</string> + <string name="refund_returned_confirm">Dinero devuelto - registrar reembolso</string> + <string name="refund_record_external">Registrar reembolso</string> + <string name="refund_recorded">Reembolso registrado</string> + <string name="refund_recorded_message">La cantidad devuelta se ha registrado en el pedido.</string> + <string name="refund_recorded_local">Guardado en este dispositivo, pero no se ha podido actualizar el pedido. No devuelvas este dinero otra vez. Mensaje del backend: %1$s</string> + <string name="order_delete_final_title">¿Eliminar este pedido permanentemente?</string> + <string name="order_delete_warning">Al eliminar se borra este pedido no pagado. No se devuelve efectivo ni se reembolsa ningún pago con tarjeta o Taler. Salda los pagos con el cliente antes de continuar.</string> + <string name="order_delete_final_warning">Esta acción no se puede deshacer. Confirma que todos los pagos están saldados y que quieres eliminar este pedido permanentemente.</string> + <string name="order_delete_continue">Continuar</string> + <string name="product_image_open">Ver imagen: %1$s</string> + <string name="product_image_close">Cerrar imagen</string> + <string name="payment_pay_with">Pagar con</string> + + <string name="checkout_complete_payment">Completar pago</string> + <string name="checkout_collect_receipt_hint">El cliente puede escanear la siguiente pantalla para recoger el recibo en una cartera Taler.</string> + <string name="checkout_collect_receipt_title">Recoger recibo</string> + <string name="checkout_receipt_paid_in_full">Pagado por completo</string> + <string name="checkout_receipt_scan_hint">Escanea con una cartera Taler para recoger el recibo.</string> + <string name="checkout_collect_receipt_on_device">Recoger recibo en este dispositivo</string> + <string name="checkout_payment_claimed_title">Pago en curso</string> + <string name="checkout_customer_opened_payment">El cliente ha abierto este pago en su cartera.</string> + <string name="checkout_payment_claimed_hint">Esperando la confirmación del cliente</string> + <string name="checkout_tap_to_enter_amount">Toca para introducir la cantidad</string> + <string name="checkout_cash_entry_instruction">Introduce la cantidad recibida</string> + <string name="checkout_card_entry_instruction">Introduce la cantidad a cobrar</string> + <string name="checkout_fraction_remaining">Pendiente</string> + <string name="checkout_fraction_half_description">Mitad del saldo pendiente, %1$s</string> + <string name="checkout_fraction_third_description">Tercio del saldo pendiente, %1$s</string> + <string name="checkout_fraction_quarter_description">Cuarto del saldo pendiente, %1$s</string> + <string name="checkout_fraction_remaining_description">Saldo pendiente, %1$s</string> + <string name="checkout_charge_with_taler">Cobrar con Taler</string> + <string name="checkout_charge_remaining_taler">Cobrar el saldo con Taler</string> + <string name="checkout_order_total_heading">Total del pedido</string> + <string name="checkout_payments_heading">Pagos</string> + <string name="checkout_payments_added">%1$d añadidos</string> + <string name="checkout_payments_empty_inline">Los pagos añadidos aparecerán aquí.</string> + <string name="checkout_about_action">Acerca de este cobro</string> + <string name="checkout_fully_paid">Pagado por completo</string> + <string name="checkout_scan_to_pay_hint">Pide al cliente que escanee el código QR de la siguiente pantalla para pagar.</string> + <string name="checkout_scan_qr_prompt">Escanea el código QR con tu cartera móvil.</string> + <string name="checkout_payment_detail_order">Pedido</string> + <string name="checkout_payment_detail_summary">Resumen</string> + <string name="checkout_waiting_customer_payment">Esperando el pago…</string> + <string name="checkout_remaining_heading">Pendiente</string> + <string name="checkout_order_reference">Pedido n.º %1$d</string> + <string name="checkout_choose_payment_method">Elegir método de pago</string> + <string name="checkout_payment_method_helper">Añade efectivo o tarjeta, o paga el saldo con Taler.</string> + <string name="checkout_complete_order_hint">Completa este pedido</string> + <string name="checkout_remaining_amount">Pendiente: %1$s</string> + <string name="checkout_remaining_to_pay">Pendiente de pago %1$s</string> + <string name="checkout_remaining_after_payment">Pendiente después del pago: %1$s</string> + <string name="checkout_change_to_return_amount">Cambio a devolver: %1$s</string> + <string name="checkout_cash_change_explanation">El efectivo que supere el saldo se muestra como cambio a devolver.</string> + <string name="checkout_record_cash_action">Registrar %1$s en efectivo</string> + <string name="checkout_charge_card_action">Cobrar %1$s con tarjeta</string> + <string name="checkout_cash_received_heading">Efectivo recibido</string> + <string name="checkout_card_payment_heading">Pago con tarjeta</string> + <string name="checkout_card_amount_limit_hint">La cantidad con tarjeta no puede superar el saldo pendiente.</string> + + <string name="checkout_creation_pending">Puede que el pedido ya exista. Reintenta el paso de Taler para recuperarlo antes de cambiar los pagos.</string> + <string name="checkout_pending_charge">Resuelve el pago con tarjeta pendiente de %1$s con %2$s. Comprueba el estado final en los registros del proveedor con la referencia %3$s antes de continuar.</string> + <string name="checkout_pending_paid">Pago confirmado en los registros del proveedor</string> + <string name="checkout_pending_not_paid">El proveedor ha confirmado que no hubo cobro</string> + <string name="checkout_pending_confirm_title">Confirmar el resultado del pago</string> + <string name="checkout_pending_confirm_paid">Confirma que este pago concreto se ha completado. Introduce la referencia de la transacción del proveedor.</string> + <string name="checkout_pending_confirm_unpaid">Continúa solo si el proveedor confirma que este pago ha fallado o se ha cancelado y que no puede liquidarse más tarde. Un resultado pendiente o ausente no es una confirmación.</string> + <string name="checkout_pending_transaction">Referencia de transacción del proveedor</string> + + <string name="history_total_paid_remaining">Total: %1$s Pagado: %2$s Pendiente: %3$s</string> + <string name="history_checkout_total_paid_remaining">Total: %1$s Pagado: %2$s Pendiente: %3$s</string> + <string name="order_details_delete_action">Eliminar</string> + <string name="settings_manage_connection_action">Ajustes del comerciante</string> + <string name="settings_logout_action">Cerrar sesión</string> + <string name="payment_options_manage_action">Gestionar opciones de pago</string> + + <string name="order_actions">Acciones</string> + <string name="order_current">Pedido actual</string> + <string name="amount_entry_hint">Introduce la cantidad del pedido</string> + + <string name="taler_ble_unsupported">Bluetooth no está disponible en este dispositivo.</string> + <string name="taler_ble_permission">Permite el acceso a dispositivos cercanos para conectar la pantalla.</string> + <string name="taler_ble_off">Activa Bluetooth para conectar la pantalla.</string> + <string name="taler_ble_location">Esta versión de Android requiere activar la ubicación para buscar dispositivos Bluetooth.</string> + <string name="taler_ble_scanning">Buscando pantallas Taler…</string> + <string name="taler_ble_connecting">Conectando a la pantalla…</string> + <string name="taler_ble_sending">Actualizando la pantalla…</string> + <string name="taler_ble_too_long">Este enlace es demasiado largo para la pantalla. Usa el código QR de este dispositivo.</string> + <string name="taler_ble_failed">No se ha podido actualizar la pantalla. Comprueba que esté encendida y cerca.</string> + <string name="taler_ble_allow">Permitir acceso al dispositivo</string> + <string name="taler_ble_open_settings">Abrir ajustes del dispositivo</string> + <string name="taler_ble_scan">Buscar pantalla Taler</string> + <string name="taler_ble_test">Probar pantalla</string> + <string name="taler_ble_reset_limitation">Esta pantalla puede conservar el código QR anterior después de un pago o una prueba. Reinicia la pantalla para borrarlo hasta que su firmware permita restablecerla.</string> + <string name="taler_ble_reconnect">Reconectar</string> + <string name="taler_ble_pairing">Emparejando… Introduce el código de la pantalla LCD en la solicitud de emparejamiento de Android.</string> + <string name="taler_ble_pairing_required">El emparejamiento no se ha completado o la vinculación guardada ya no funciona.</string> + <string name="taler_ble_forget_explanation">¿Dejar de usar esta pantalla en este punto de venta? Esto elimina la selección guardada en la aplicación. Para eliminar también el emparejamiento Bluetooth, olvida la pantalla en los ajustes Bluetooth de Android y elige Olvidar este punto de venta en la pantalla LCD.</string> + + <string name="taler_devices_manage">Gestionar pantallas</string> + <string name="taler_devices_title">Pantallas Taler</string> + <string name="taler_devices_empty">Todavía no se han añadido pantallas.</string> + <string name="taler_devices_add">Añadir dispositivo</string> + <string name="taler_devices_check_usb">Comprobar conexión USB</string> + <string name="taler_devices_remove">Eliminar pantalla</string> + <string name="taler_devices_replace">Reemplazar pantalla</string> + <string name="taler_devices_replace_hint">Este punto de venta usa una sola pantalla Bluetooth a la vez. ¿Reemplazar la pantalla actual?</string> + <string name="taler_devices_access">Permitir conexión Bluetooth</string> + <string name="taler_devices_open_lcd">Abrir ajustes de la pantalla</string> + <string name="taler_devices_open_lcd_hint">En la pantalla, desliza hacia la izquierda y toca el engranaje de ajustes.</string> + <string name="taler_devices_pair_lcd">Iniciar emparejamiento en la pantalla</string> + <string name="taler_devices_pair_lcd_hint">Elige Bluetooth → Emparejar un punto de venta en la pantalla.</string> + <string name="taler_devices_choose_display">Elige tu pantalla</string> + <string name="taler_devices_connect">Conectar pantalla</string> + <string name="taler_devices_connected">Pantalla conectada</string> + <string name="taler_devices_connected_hint">Tu pantalla está lista para los pagos.</string> + <string name="taler_devices_bluetooth">Pantalla Bluetooth</string> + <string name="taler_devices_usb">Dispositivo USB</string> + <string name="taler_devices_scan_finished">Selecciona tu pantalla abajo o busca de nuevo si no aparece.</string> + <string name="taler_devices_checking">Comprobando conexión…</string> + <string name="taler_devices_done">Hecho</string> + <string name="taler_devices_next">Siguiente</string> + <string name="taler_devices_search_again">Buscar de nuevo</string> + <string name="taler_devices_pair_again">Emparejar de nuevo</string> + <string name="taler_devices_repair_android">Olvida esta pantalla en los ajustes Bluetooth de Android y vuelve aquí.</string> + <string name="taler_devices_repair_lcd">En la pantalla, abre Bluetooth, olvida el punto de venta anterior y elige Emparejar un punto de venta.</string> + <string name="taler_devices_test_hint">Se muestra un código QR de prueba durante cinco segundos y luego se borra. No se crea ningún pago.</string> + + <string name="taler_usb_not_attached">Conecta un dispositivo Taler compatible por USB.</string> + <string name="taler_usb_device">Dispositivo USB Taler %1$d</string> + <string name="taler_usb_permission_required">Se necesita acceso USB.</string> + <string name="taler_usb_allow_device">Permitir acceso USB Dispositivo %1$d</string> + <string name="taler_usb_failed">No se ha podido actualizar el dispositivo. Reconéctalo o inténtalo de nuevo.</string> + <string name="taler_usb_refresh">Comprobar conexión</string> + <string name="checkout_order_claimed">Pedido reclamado por la cartera.</string> + <string name="checkout_complete_in_wallet">Completa el pago en tu cartera</string> + <string name="checkout_payment_possible_until">Se puede pagar hasta %1$s</string> + <string name="checkout_payment_detail_merchant">Comerciante</string> + <string name="config_local_network_title">Conectar con un comercio local</string> + <string name="config_local_network_explanation">El servidor del comercio en %1$s está en una red local. Permite el acceso a dispositivos cercanos para que Taler POS pueda conectarse y procesar pedidos.</string> + <string name="config_local_network_settings">El acceso a la red local está bloqueado. Abre los ajustes de la aplicación, elige Permisos → Dispositivos cercanos y permite el acceso. Después vuelve aquí para conectarte a %1$s.</string> + <string name="config_local_network_allow">Permitir acceso</string> + <string name="config_local_network_required">Permite el acceso a la red local para conectarte a este comercio.</string> + <string name="config_camera_title">Acceso a la cámara para configurar con QR</string> + <string name="config_camera_explanation">La cámara escanea el código QR de configuración de Merchant Backoffice para rellenar los ajustes de tu cuenta. También puedes introducir estos datos manualmente.</string> + <string name="config_camera_settings_instructions">El acceso a la cámara está bloqueado. Abre los ajustes de la aplicación, selecciona Permisos → Cámara y permite el acceso mientras usas la aplicación. Después vuelve aquí para escanear.</string> + <string name="config_camera_allow">Permitir cámara</string> + <string name="config_camera_open_settings">Abrir ajustes</string> + <string name="config_camera_manual">Configurar manualmente</string> + <string name="config_connecting">Conectando…</string> + <string name="password_show">Mostrar contraseña</string> + <string name="password_hide">Ocultar contraseña</string> + <string name="navigation_back">Atrás</string> + <string name="navigation_menu_open">Abrir menú de navegación</string> + <string name="order_empty_inventory">No hay productos para mostrar. Use Pedido por importe o añada un producto personalizado.</string> </resources> diff --git a/merchant-terminal/src/main/res/values-fi/strings.xml b/merchant-terminal/src/main/res/values-fi/strings.xml @@ -82,9 +82,9 @@ <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="config_no_instance">Ei instanssia?</string> + <string name="config_no_instance">Ei tiliä?</string> <string name="config_instance_tutorial_description">1. Tutustu käyttöönotto-ohjeisiin osoitteessa:</string> - <string name="config_instance_demo_description">2. Voit kokeilla sovellusta julkisella demoinstanssilla. Huomaa, että instanssi on yhteiskäytössä—kuka tahansa voi muuttaa sen tietoja milloin tahansa. Käytä sitä vain sovellukseen ja sen ominaisuuksiin tutustumiseen.</string> + <string name="config_instance_demo_description">2. Voit kokeilla sovellusta julkisella demotilillä. Huomaa, että tili on yhteiskäytössä - kuka tahansa voi muuttaa sen tietoja milloin tahansa. Käytä sitä vain sovellukseen ja sen ominaisuuksiin tutustumiseen.</string> <string name="config_fill_demo_credentials">Täytä demotunnukset</string> <string name="config_instance_review_description">3. Jos arvioit tätä sovellusta, sinulla on todennäköisesti jo hallittu testikäyttäjä. Esitäytä kauppiasportaalin URL-osoite alla olevalla painikkeella.</string> <string name="config_fill_review_url">Täytä arviointi-URL</string> @@ -157,4 +157,304 @@ <string name="insecure_payment_dialog_try_https">Kokeile korjattua linkkiä</string> <string name="insecure_payment_dialog_continue_as_is">Jatka sellaisenaan</string> <string name="insecure_payment_dialog_cancel_order">Peruuta tilaus</string> + <string name="payment_device_nfc_available">käytettävissä</string> + <string name="settings_about_title">Tietoja</string> + <string name="refund_reason_wrong_order">Väärä tilaus</string> + <string name="payment_options_title">Maksutavat</string> + <string name="payment_options_button">Maksutavat</string> + <string name="payment_options_description">Taler on aina käytettävissä. Ota käyttöön muita maksutapoja tai avaa maksutapa määrittääksesi sen.</string> + <string name="payment_options_enabled_count">Käytössä: %1$d</string> + <string name="payment_option_open">Avaa maksutavan %1$s asetukset</string> + <string name="payment_option_settings_title">Asetukset</string> + <string name="payment_option_unknown">Tämä maksutapa ei ole saatavilla tässä versiossa.</string> + <string name="payment_option_disabled">Pois käytöstä</string> + <string name="payment_option_ready">Valmis</string> + <string name="payment_option_taler">GNU Taler</string> + <string name="payment_option_taler_description">Asiakkaat maksavat Taler-lompakosta skannaamalla QR-koodin tai tuomalla puhelimen laitteen lähelle.</string> + <string name="payment_option_cash">Käteinen</string> + <string name="payment_option_cash_description">Asiakkaat maksavat käteisellä kassalla. Tilaus maksetaan sovelluksen ulkopuolella.</string> + <string name="terminal_not_configured">Määritys ei ole saatavilla tässä versiossa. Ota yhteyttä sovelluksen ylläpitäjään.</string> + <string name="terminal_sign_in_required">Kirjaudu sisään muodostaaksesi pariliitoksen lukijaan.</string> + <string name="terminal_permission_required">Lähellä olevien laitteiden käyttöoikeus vaaditaan. Avaa lukijan asetukset jatkaaksesi.</string> + <string name="terminal_not_paired">Lukijaan ei ole pariliitosta</string> + <string name="terminal_disconnected">Lukijan yhteys katkennut</string> + <string name="terminal_connected">Lukija yhdistetty</string> + <string name="terminal_unavailable">Lukijan yhteyttä ei voida tarkistaa</string> + <string name="terminal_pair">Yhdistä lukija</string> + <string name="terminal_manage">Hallitse lukijaa</string> + <string name="terminal_account">Tili: %1$s</string> + <string name="terminal_serial">Sarjanumero: %1$s</string> + <string name="terminal_indicator_description">Maksulaitteet: %1$s</string> + <string name="terminal_close">Sulje</string> + <string name="terminal_action_failed">Maksupäätteen määritystä ei voitu suorittaa loppuun. Tarkista Bluetooth, käyttöoikeudet ja internetyhteys ja yritä uudelleen.</string> + <string name="terminal_sumup">SumUp</string> + <string name="terminal_sumup_description">Asiakkaat maksavat kortilla tähän laitteeseen pariliitetyllä SumUp-lukijalla.</string> + <string name="terminal_sumup_sign_in">Kirjaudu SumUpiin</string> + <string name="terminal_sumup_sign_out">Kirjaudu ulos SumUpista</string> + <string name="terminal_sumup_save_key">Tallenna kumppaniavain</string> + <string name="terminal_sumup_advanced">Lisäasetukset</string> + <string name="terminal_sumup_advanced_help">Kumppaniavain tunnistaa tämän sovelluksen SumUpille, ei yritystäsi. Sitä ei yleensä tarvitse vaihtaa. Vaihda se vain, jos sinulla on oma SumUp-kumppanirekisteröinti.</string> + <string name="terminal_sumup_key_builtin">Käytetään sovellukseen sisältyvää kumppaniavainta.</string> + <string name="terminal_sumup_key_custom">Käytetään omaa kumppaniavainta.</string> + <string name="terminal_sumup_key_hint">Uusi kumppaniavain</string> + <string name="terminal_sumup_key_show">Näytä avain</string> + <string name="terminal_sumup_key_hide">Piilota avain</string> + <string name="terminal_sumup_key_warning">Eri avaimen tallentaminen kirjaa sinut ulos SumUpista.</string> + <string name="terminal_sumup_reset_key">Käytä sisältyvää avainta</string> + <string name="terminal_sumup_session_help">SumUp-kirjautuminen kestää noin tunnin. Omalla OAuth-sovelluksella sovellus uusii sen automaattisesti, joten henkilökunnan ei tarvitse kirjautua uudelleen.</string> + <string name="terminal_sumup_session_no_client">OAuth-sovellusta ei ole määritetty. Lisää se Lisäasetuksissa pitääksesi istunnon voimassa.</string> + <string name="terminal_sumup_session_not_authorized">Ei vielä valtuutettu. Valtuuta kerran pitääksesi tämän laitteen kirjautuneena.</string> + <string name="terminal_sumup_session_expires">Käyttöoikeustunniste vanhenee: %1$s</string> + <string name="terminal_sumup_session_refreshes">Uusittu %1$d kertaa, viimeksi %2$s</string> + <string name="terminal_sumup_session_error">Viimeisin uusimisongelma: %1$s</string> + <string name="terminal_sumup_authorize">Valtuuta tämä laite</string> + <string name="terminal_sumup_reauthorize">Valtuuta uudelleen</string> + <string name="terminal_sumup_refresh_now">Uusi tunniste nyt</string> + <string name="terminal_sumup_revoke">Unohda valtuutus</string> + <string name="terminal_sumup_oauth_help">Rekisteröi OAuth-sovellus SumUpin hallintapaneelissa uudelleenohjausosoitteella %1$s ja liitä sen asiakastunnus ja salaisuus tähän. Ne pysyvät tällä laitteella.</string> + <string name="terminal_sumup_oauth_set">OAuth-sovellus on määritetty.</string> + <string name="terminal_sumup_oauth_unset">OAuth-sovellusta ei ole määritetty.</string> + <string name="terminal_sumup_client_id">OAuth-asiakastunnus</string> + <string name="terminal_sumup_client_secret">OAuth-asiakassalaisuus</string> + <string name="terminal_sumup_save_client">Tallenna OAuth-sovellus</string> + <string name="payment_device_connected">%1$s yhdistetty</string> + <string name="payment_device_disconnected">%1$s ei yhdistetty</string> + <string name="payment_device_nfc">NFC</string> + <string name="payment_device_status_connected">Yhdistetty</string> + <string name="payment_device_status_disconnected">Ei yhdistetty</string> + <string name="payment_device_battery">Akku %1$d%%</string> + <string name="checkout_title">Maksu</string> + <string name="checkout_total">Yhteensä</string> + <string name="checkout_payments_button">Kirjatut maksut (%1$d)</string> + <string name="checkout_payments_title">Kirjatut maksut</string> + <string name="checkout_no_taler_hint">GNU Taler on pois käytöstä, joten koko summa on maksettava muilla maksutavoilla.</string> + <string name="checkout_order_created_hint">Tilaus on luotu, joten käteis- tai korttimaksuja ei voi enää lisätä. Loppusumma maksetaan GNU Talerilla.</string> + <string name="checkout_cancel">Peruuta maksu</string> + <string name="checkout_change_due_title">Anna vaihtoraha</string> + <string name="checkout_change_due">Palauta asiakkaalle %1$s.</string> + <string name="checkout_card_title">Korttimaksu</string> + <string name="checkout_card_amount">Veloitettava summa</string> + <string name="checkout_card_too_much">Ylittää jäljellä olevan summan</string> + <string name="checkout_card_not_ready">Kortinlukija ei ole valmis. Tarkista, että olet kirjautunut sisään ja lukija on yhdistetty.</string> + <string name="checkout_card_recorded">Korttimaksu %1$s kirjattu</string> + <string name="checkout_card_failed">Korttimaksu ei onnistunut.</string> + <string name="checkout_card_unknown_title">Korttimaksua ei ole vahvistettu</string> + <string name="checkout_card_unknown">Ei voitu vahvistaa, veloitettiinko %1$s. Tarkista tapahtuma korttipalveluntarjoajan sovelluksesta ennen uutta veloitusta. Mitään ei kirjattu.</string> + <string name="checkout_record_failed">Maksua ei voitu tallentaa. Älä anna vaihtorahaa ennen kuin maksu on kirjattu.</string> + <string name="checkout_none">Tämä maksu ei ole enää avoinna.</string> + <string name="checkout_payment_line_change">Vastaanotettu %1$s, vaihtoraha %2$s</string> + <string name="checkout_payment_line_reference">Viite %1$s</string> + <string name="checkout_history_continue">Jatka</string> + <string name="checkout_history_dismiss">Merkitse suoritetuksi</string> + <string name="payment_options_mixed_unsupported">Maksaminen useassa osassa edellyttää kauppiaan taustapalvelun protokollaversiota 42 tai uudempaa. Tämä taustapalvelu ilmoittaa version %1$s.</string> + <string name="checkout_total_amount">Yhteensä: %1$s</string> + <string name="checkout_paid_amount">Maksettu: %1$s</string> + <string name="checkout_about">Tietoja maksusta</string> + <string name="checkout_about_message">%1$s on jo vastaanotettu. Jos peruutat nyt, käteinen on palautettava ja korttimaksut hyvitettävä korttipalveluntarjoajan omassa sovelluksessa.</string> + <string name="checkout_cancel_anyway">Peruuta maksu silti</string> + <string name="checkout_settled_hint">Kaikki on maksettu. Viimeistele tilaus päättääksesi sen.</string> + <string name="checkout_back_to_options">Takaisin maksutapoihin</string> + <string name="checkout_revert_failed">Tilausta ei voitu vetää takaisin.</string> + <string name="payment_option_activation_title">Ennen käyttöönottoa</string> + <string name="payment_option_activation_confirm">Ota käyttöön</string> + <string name="terminal_sumup_activation_notice">SumUpin nykyisellä ohjelmistolla tämä sovellus voi veloittaa kortteja mutta ei palauttaa rahaa. Palautus on annettava käteisenä, tehtävä toisella maksutavalla tai käsiteltävä SumUpin omassa sovelluksessa.\n\nVarmista kirjanpitäjältäsi tai oikeudelliselta neuvonantajalta, että tämä sopii yrityksesi palautuskäytäntöjen vaatimuksiin.</string> + <string name="history_status_partially_paid">Maksettu osittain</string> + <string name="history_status_awaiting_collection">Odottaa noutoa</string> + <string name="order_details_title">Tilauksen tiedot</string> + <string name="order_details_loading">Ladataan tilauksen tietoja…</string> + <string name="order_details_unavailable">Tiedot eivät ole saatavilla</string> + <string name="order_details_load_error">Tilausta ei voitu ladata. Tarkista yhteys ja yritä uudelleen.</string> + <string name="order_details_retry">Yritä uudelleen</string> + <string name="order_details_fulfillment_link">Tilauksen toimituslinkki</string> + <string name="order_details_not_provided">Kauppias ei antanut lisätietoja tästä tilauksesta.</string> + <string name="order_details_order_id">Tilauksen viite</string> + <string name="order_details_refund_total">Palautettu summa</string> + <string name="order_details_summary">Tilauksen yhteenveto</string> + <string name="order_details_products">Tuotteet</string> + <string name="order_details_no_products">Eriteltyä tuoteluetteloa ei annettu.</string> + <string name="order_details_net_price">%1$s (netto)</string> + <string name="order_details_payments">Maksuerittely</string> + <string name="order_details_balance">Maksettavaa jäljellä</string> + <string name="order_details_delivery">Toimitus / Nouto</string> + <string name="order_details_delivery_location">Toimituspaikka</string> + <string name="order_details_delivery_date">Toimituspäivä</string> + <string name="order_details_pickup_time">Viimeinen noutoaika</string> + <string name="order_details_reorder">Tilaa uudelleen</string> + <string name="order_details_reorder_hint">Jaa tämä linkki tilataksesi samat tuotteet uudelleen.</string> + <string name="order_details_reorder_link">Uudelleentilauksen linkki</string> + <string name="order_details_additional">Lisätiedot</string> + <string name="order_details_pay_deadline">Maksun määräaika</string> + <string name="order_details_refund_deadline">Palautuksen määräaika</string> + <string name="order_details_merchant">Kauppiaan tiedot</string> + <string name="order_details_jurisdiction">Lainkäyttöalue</string> + <string name="order_details_email">Sähköposti</string> + <string name="order_details_website">Verkkosivusto</string> + <string name="order_details_open_link">Avaa linkki</string> + <string name="order_details_copied">Kopioitu leikepöydälle</string> + <string name="order_details_copy">Kopioi %1$s</string> + <string name="order_details_open_failed">Mikään sovellus ei voi avata tätä linkkiä.</string> + <string name="refund_choose_method">Palautustapa</string> + <string name="refund_available">Palautettavissa</string> + <string name="refund_unavailable">Ei saatavilla</string> + <string name="refund_unavailable_hint">Tällä maksutavalla ei voi tehdä palautusta tässä. Ulkoiset palautukset edellyttävät maksettua tilausta ja sen maksutietoa tällä laitteella. Taler-palautusten on oltava yhä käytettävissä.</string> + <string name="refund_taler_button">Palauta Taler-maksu</string> + <string name="refund_taler_hint">Hyväksy palautus ja anna asiakkaan noutaa se Taler-lompakollaan.</string> + <string name="refund_cash_hint">Palauta käteinen asiakkaalle ja kirjaa sitten palautettu summa.</string> + <string name="refund_card_hint">Hyvitä alkuperäinen korttimaksu korttipalveluntarjoajan sovelluksessa ja kirjaa sitten palautettu summa tähän. Tämä näyttö ei lähetä rahaa kortille.</string> + <string name="refund_cash_confirm">Vahvista, että tämä käteinen on palautettu asiakkaalle.</string> + <string name="refund_card_confirm">Vahvista, että korttipalveluntarjoaja on suorittanut tämän palautuksen. Sen kirjaaminen tähän ei tee uutta palautusta.</string> + <string name="refund_returned_confirm">Rahat palautettu - kirjaa palautus</string> + <string name="refund_record_external">Kirjaa palautus</string> + <string name="refund_recorded">Palautus kirjattu</string> + <string name="refund_recorded_message">Palautettu summa on kirjattu tilaukseen.</string> + <string name="refund_recorded_local">Tallennettu tälle laitteelle, mutta tilausta ei voitu päivittää. Älä palauta tätä rahaa uudelleen. Taustapalvelun viesti: %1$s</string> + <string name="order_delete_final_title">Poistetaanko tämä tilaus pysyvästi?</string> + <string name="order_delete_warning">Poistaminen poistaa tämän maksamattoman tilauksen. Se ei palauta käteistä eikä hyvitä kortti- tai Taler-maksua. Selvitä maksut asiakkaan kanssa ennen jatkamista.</string> + <string name="order_delete_final_warning">Tätä ei voi kumota. Vahvista, että kaikki maksut on selvitetty ja haluat poistaa tilauksen pysyvästi.</string> + <string name="order_delete_continue">Jatka</string> + <string name="product_image_open">Näytä kuva: %1$s</string> + <string name="product_image_close">Sulje kuva</string> + <string name="payment_pay_with">Maksa käyttäen</string> + + <string name="checkout_complete_payment">Viimeistele maksu</string> + <string name="checkout_collect_receipt_hint">Asiakas voi skannata seuraavan näytön noutaakseen kuitin Taler-lompakkoon.</string> + <string name="checkout_collect_receipt_title">Nouda kuitti</string> + <string name="checkout_receipt_paid_in_full">Maksettu kokonaan</string> + <string name="checkout_receipt_scan_hint">Skannaa Taler-lompakolla noutaaksesi kuitin.</string> + <string name="checkout_collect_receipt_on_device">Nouda kuitti tällä laitteella</string> + <string name="checkout_payment_claimed_title">Maksu käynnissä</string> + <string name="checkout_customer_opened_payment">Asiakas on avannut tämän maksun lompakossaan.</string> + <string name="checkout_payment_claimed_hint">Odotetaan asiakkaan vahvistusta</string> + <string name="checkout_tap_to_enter_amount">Napauta syöttääksesi summan</string> + <string name="checkout_cash_entry_instruction">Syötä vastaanotettu summa</string> + <string name="checkout_card_entry_instruction">Syötä veloitettava summa</string> + <string name="checkout_fraction_remaining">Jäljellä</string> + <string name="checkout_fraction_half_description">Puolet jäljellä olevasta saldosta, %1$s</string> + <string name="checkout_fraction_third_description">Kolmasosa jäljellä olevasta saldosta, %1$s</string> + <string name="checkout_fraction_quarter_description">Neljäsosa jäljellä olevasta saldosta, %1$s</string> + <string name="checkout_fraction_remaining_description">Jäljellä oleva saldo, %1$s</string> + <string name="checkout_charge_with_taler">Maksa Talerilla</string> + <string name="checkout_charge_remaining_taler">Maksa loppusumma Talerilla</string> + <string name="checkout_order_total_heading">Tilauksen kokonaissumma</string> + <string name="checkout_payments_heading">Maksut</string> + <string name="checkout_payments_added">Lisätty: %1$d</string> + <string name="checkout_payments_empty_inline">Lisätyt maksut näkyvät tässä.</string> + <string name="checkout_about_action">Tietoja tästä maksutapahtumasta</string> + <string name="checkout_fully_paid">Maksettu kokonaan</string> + <string name="checkout_scan_to_pay_hint">Pyydä asiakasta skannaamaan seuraavan näytön QR-koodi maksaakseen.</string> + <string name="checkout_scan_qr_prompt">Skannaa QR-koodi mobiililompakollasi.</string> + <string name="checkout_payment_detail_order">Tilaus</string> + <string name="checkout_payment_detail_summary">Yhteenveto</string> + <string name="checkout_waiting_customer_payment">Odotetaan maksua…</string> + <string name="checkout_remaining_heading">Jäljellä</string> + <string name="checkout_order_reference">Tilaus #%1$d</string> + <string name="checkout_choose_payment_method">Valitse maksutapa</string> + <string name="checkout_payment_method_helper">Lisää käteis- tai korttimaksu tai maksa loppusumma Talerilla.</string> + <string name="checkout_complete_order_hint">Viimeistelee tämän tilauksen</string> + <string name="checkout_remaining_amount">Jäljellä: %1$s</string> + <string name="checkout_remaining_to_pay">Maksettavaa jäljellä %1$s</string> + <string name="checkout_remaining_after_payment">Jäljellä maksun jälkeen: %1$s</string> + <string name="checkout_change_to_return_amount">Palautettava vaihtoraha: %1$s</string> + <string name="checkout_cash_change_explanation">Saldon ylittävä käteissumma näytetään palautettavana vaihtorahana.</string> + <string name="checkout_record_cash_action">Kirjaa %1$s käteistä</string> + <string name="checkout_charge_card_action">Veloita kortilta %1$s</string> + <string name="checkout_cash_received_heading">Vastaanotettu käteinen</string> + <string name="checkout_card_payment_heading">Korttimaksu</string> + <string name="checkout_card_amount_limit_hint">Kortilta veloitettava summa ei voi ylittää jäljellä olevaa saldoa.</string> + + <string name="checkout_creation_pending">Tilaus saattaa jo olla olemassa. Yritä Taler-vaihetta uudelleen palauttaaksesi sen ennen maksujen muuttamista.</string> + <string name="checkout_pending_charge">Selvitä odottava korttimaksu %1$s palveluntarjoajan %2$s kanssa. Tarkista lopullinen tila palveluntarjoajan tiedoista viitteellä %3$s ennen jatkamista.</string> + <string name="checkout_pending_paid">Maksu vahvistettu palveluntarjoajan tiedoissa</string> + <string name="checkout_pending_not_paid">Palveluntarjoaja vahvisti, ettei veloitusta tehty</string> + <string name="checkout_pending_confirm_title">Vahvista maksun lopputulos</string> + <string name="checkout_pending_confirm_paid">Vahvista, että juuri tämä maksu onnistui. Syötä palveluntarjoajan tapahtumaviite.</string> + <string name="checkout_pending_confirm_unpaid">Jatka vain, jos palveluntarjoaja vahvistaa maksun epäonnistuneen tai peruuntuneen eikä sitä voida veloittaa myöhemmin. Odottava tai puuttuva tulos ei ole vahvistus.</string> + <string name="checkout_pending_transaction">Palveluntarjoajan tapahtumaviite</string> + + <string name="history_total_paid_remaining">Yhteensä: %1$s Maksettu: %2$s Jäljellä: %3$s</string> + <string name="history_checkout_total_paid_remaining">Yhteensä: %1$s Maksettu: %2$s Jäljellä: %3$s</string> + <string name="order_details_delete_action">Poista</string> + <string name="settings_manage_connection_action">Kauppiaan asetukset</string> + <string name="settings_logout_action">Kirjaudu ulos</string> + <string name="payment_options_manage_action">Hallitse maksutapoja</string> + + <string name="order_actions">Toiminnot</string> + <string name="order_current">Nykyinen tilaus</string> + <string name="amount_entry_hint">Syötä tilauksen summa</string> + + <string name="taler_ble_unsupported">Bluetooth ei ole käytettävissä tällä laitteella.</string> + <string name="taler_ble_permission">Salli lähellä olevien laitteiden käyttö yhdistääksesi näytön.</string> + <string name="taler_ble_off">Ota Bluetooth käyttöön yhdistääksesi näytön.</string> + <string name="taler_ble_location">Tämä Android-versio edellyttää sijainnin käyttöönottoa Bluetooth-laitteiden etsimiseen.</string> + <string name="taler_ble_scanning">Etsitään Taler-näyttöjä…</string> + <string name="taler_ble_connecting">Yhdistetään näyttöön…</string> + <string name="taler_ble_sending">Päivitetään näyttöä…</string> + <string name="taler_ble_too_long">Tämä linkki on liian pitkä näytölle. Käytä tämän laitteen QR-koodia.</string> + <string name="taler_ble_failed">Näyttöä ei voitu päivittää. Tarkista, että se on päällä ja lähellä.</string> + <string name="taler_ble_allow">Salli laitteen käyttö</string> + <string name="taler_ble_open_settings">Avaa laitteen asetukset</string> + <string name="taler_ble_scan">Etsi Taler-näyttö</string> + <string name="taler_ble_test">Testaa näyttö</string> + <string name="taler_ble_reset_limitation">Näyttö saattaa säilyttää edellisen QR-koodin maksun tai testin jälkeen. Käynnistä näyttö uudelleen tyhjentääksesi sen, kunnes laiteohjelmisto tukee nollausta.</string> + <string name="taler_ble_reconnect">Yhdistä uudelleen</string> + <string name="taler_ble_pairing">Muodostetaan pariliitosta… Syötä LCD-näytön koodi Androidin pariliitospyyntöön.</string> + <string name="taler_ble_pairing_required">Pariliitos ei valmistunut tai tallennettu pariliitos ei enää toimi.</string> + <string name="taler_ble_forget_explanation">Lopetetaanko tämän näytön käyttö tällä kassalla? Tämä poistaa tallennetun valinnan sovelluksesta. Poista myös Bluetooth-pariliitos unohtamalla näyttö Androidin Bluetooth-asetuksissa ja valitsemalla LCD-näytöstä Unohda tämä kassa.</string> + + <string name="taler_devices_manage">Hallitse näyttöjä</string> + <string name="taler_devices_title">Taler-näytöt</string> + <string name="taler_devices_empty">Näyttöjä ei ole vielä lisätty.</string> + <string name="taler_devices_add">Lisää laite</string> + <string name="taler_devices_check_usb">Tarkista USB-yhteys</string> + <string name="taler_devices_remove">Poista näyttö</string> + <string name="taler_devices_replace">Vaihda näyttö</string> + <string name="taler_devices_replace_hint">Tämä kassa käyttää yhtä Bluetooth-näyttöä kerrallaan. Vaihdetaanko nykyinen näyttö?</string> + <string name="taler_devices_access">Salli Bluetooth-yhteys</string> + <string name="taler_devices_open_lcd">Avaa näytön asetukset</string> + <string name="taler_devices_open_lcd_hint">Pyyhkäise näytöllä vasemmalle ja napauta asetusten rataskuvaketta.</string> + <string name="taler_devices_pair_lcd">Aloita pariliitos näytöllä</string> + <string name="taler_devices_pair_lcd_hint">Valitse näytöltä Bluetooth → Yhdistä kassa.</string> + <string name="taler_devices_choose_display">Valitse näyttö</string> + <string name="taler_devices_connect">Yhdistä näyttö</string> + <string name="taler_devices_connected">Näyttö yhdistetty</string> + <string name="taler_devices_connected_hint">Näyttö on valmis maksuihin.</string> + <string name="taler_devices_bluetooth">Bluetooth-näyttö</string> + <string name="taler_devices_usb">USB-laite</string> + <string name="taler_devices_scan_finished">Valitse näyttö alta tai etsi uudelleen, jos sitä ei näy.</string> + <string name="taler_devices_checking">Tarkistetaan yhteyttä…</string> + <string name="taler_devices_done">Valmis</string> + <string name="taler_devices_next">Seuraava</string> + <string name="taler_devices_search_again">Etsi uudelleen</string> + <string name="taler_devices_pair_again">Muodosta pariliitos uudelleen</string> + <string name="taler_devices_repair_android">Unohda tämä näyttö Androidin Bluetooth-asetuksissa ja palaa sitten tähän.</string> + <string name="taler_devices_repair_lcd">Avaa näytöltä Bluetooth, unohda vanha kassa ja valitse sitten Yhdistä kassa.</string> + <string name="taler_devices_test_hint">Testin QR-koodi näkyy viisi sekuntia ja poistuu sitten. Maksua ei luoda.</string> + + <string name="taler_usb_not_attached">Yhdistä tuettu Taler-laite USB:llä.</string> + <string name="taler_usb_device">Taler USB -laite %1$d</string> + <string name="taler_usb_permission_required">USB-käyttöoikeus vaaditaan.</string> + <string name="taler_usb_allow_device">Salli USB-käyttö Laite %1$d</string> + <string name="taler_usb_failed">Laitetta ei voitu päivittää. Yhdistä se uudelleen tai yritä uudelleen.</string> + <string name="taler_usb_refresh">Tarkista yhteys</string> + <string name="checkout_order_claimed">Lompakko on lunastanut tilauksen.</string> + <string name="checkout_complete_in_wallet">Viimeistele maksu lompakossasi</string> + <string name="checkout_payment_possible_until">Maksu mahdollinen %1$s asti</string> + <string name="checkout_payment_detail_merchant">Kauppias</string> + <string name="config_local_network_title">Yhdistä paikalliseen kauppiaaseen</string> + <string name="config_local_network_explanation">Kauppiaspalvelimesi %1$s on paikallisverkossa. Salli lähellä olevien laitteiden käyttö, jotta Taler POS voi muodostaa yhteyden ja käsitellä tilauksia.</string> + <string name="config_local_network_settings">Pääsy paikallisverkkoon on estetty. Avaa sovelluksen asetukset, valitse Käyttöoikeudet → Lähellä olevat laitteet ja salli käyttö. Palaa sitten tähän yhdistääksesi palvelimeen %1$s.</string> + <string name="config_local_network_allow">Salli käyttö</string> + <string name="config_local_network_required">Salli paikallisverkon käyttö yhdistääksesi tähän kauppiaaseen.</string> + <string name="config_camera_title">Kameran käyttö QR-määritystä varten</string> + <string name="config_camera_explanation">Kamera lukee Merchant Backofficen määrityskoodin ja täyttää tilisi asetukset. Voit myös syöttää tiedot käsin.</string> + <string name="config_camera_settings_instructions">Kameran käyttö on estetty. Avaa sovelluksen asetukset, valitse Käyttöoikeudet → Kamera ja salli käyttö sovelluksen käytön aikana. Palaa sitten tänne lukemaan koodi.</string> + <string name="config_camera_allow">Salli kamera</string> + <string name="config_camera_open_settings">Avaa asetukset</string> + <string name="config_camera_manual">Määritä käsin</string> + <string name="config_connecting">Yhdistetään…</string> + <string name="password_show">Näytä salasana</string> + <string name="password_hide">Piilota salasana</string> + <string name="navigation_back">Takaisin</string> + <string name="navigation_menu_open">Avaa navigointivalikko</string> + <string name="order_empty_inventory">Ei näytettäviä tuotteita. Käytä summan mukaista tilausta tai lisää oma tuote.</string> </resources> diff --git a/merchant-terminal/src/main/res/values-fr/strings.xml b/merchant-terminal/src/main/res/values-fr/strings.xml @@ -8,9 +8,9 @@ <string name="config_error_network">Erreur : impossible de se connecter au serveur de configuration</string> <string name="config_error_product_zero">Erreur : aucun produit valide trouvé</string> <string name="config_docs">Veuillez vous référer à <a href="https://docs.taler.net/taler-merchant-pos-terminal.html#apis-and-data-formats">la documentation</a> pour le format de configuration.</string> - <string name="config_no_instance">Pas d’instance ?</string> + <string name="config_no_instance">Pas de compte ?</string> <string name="config_instance_tutorial_description">1. Consultez les tutoriels de configuration à l’adresse :</string> - <string name="config_instance_demo_description">2. Vous pouvez essayer l’application avec l’instance de démonstration publique. Cette instance est partagée : n’importe qui peut modifier ses données à tout moment. Utilisez-la uniquement pour découvrir l’application et ses fonctionnalités.</string> + <string name="config_instance_demo_description">2. Vous pouvez essayer l’application avec le compte de démonstration public. Ce compte est partagé : n’importe qui peut modifier ses données à tout moment. Utilisez-le uniquement pour découvrir l’application et ses fonctionnalités.</string> <string name="config_fill_demo_credentials">Renseigner les identifiants de démonstration</string> <string name="config_instance_review_description">3. Si vous évaluez cette application, vous avez probablement déjà accès à un utilisateur de test contrôlé. Utilisez le bouton ci-dessous pour préremplir l’URL du portail commerçant.</string> <string name="config_fill_review_url">Renseigner l’URL d’évaluation</string> @@ -20,7 +20,7 @@ <string name="app_name">Taler POS</string> <string name="app_name_short">Taler POS</string> <string name="menu_order">Commande par inventaire</string> - <string name="order_label_title">Commande n° %s</string> + <string name="order_label_title">Commande n° %s</string> <string name="order_restart">Effacer</string> <string name="order_undo">Annuler</string> <string name="order_previous">Précédent</string> @@ -30,7 +30,7 @@ <string name="config_url">URL de configuration</string> <string name="config_manual_label">Configuration manuelle</string> <string name="config_ok">Se connecter</string> - <string name="config_auth_error">Erreur : identifiant ou mot de passe invalide</string> + <string name="config_auth_error">Erreur : identifiant ou mot de passe invalide</string> <string name="config_error_category">Erreur : aucune catégorie de produit valide n\'a été trouvée</string> <string name="config_error_malformed">Erreur : la configuration JSON est incorrecte</string> <string name="config_error_currency">Erreur : Le produit %1$s a la devise %2$s, mais devrait avoir %3$s</string> @@ -45,7 +45,7 @@ <string name="payment_cancel">Annuler le paiement</string> <string name="payment_received">Paiement reçu</string> <string name="payment_back_button">Continuer</string> - <string name="payment_order_id">Reçu n° %s</string> + <string name="payment_order_id">Reçu n° %s</string> <string name="payment_process_label">Paiement requis</string> <string name="payment_canceled">Paiement annulé</string> <string name="payment_share_uri">Partager l’URI de paiement</string> @@ -107,7 +107,7 @@ <string name="forever">Illimité</string> <string name="custom_duration">Durée personnalisée</string> <string name="duration">Durée</string> - <string name="token_validity_deadline">Date limite de validité du mot de passe :</string> + <string name="token_validity_deadline">Date limite de validité du mot de passe :</string> <string name="expires_on">Expire le …</string> <string name="no_deadline_set">Aucune date limite</string> <string name="pick_date">Choisir la date</string> @@ -153,7 +153,7 @@ <string name="error_inventory_unavailable">Erreur : stock disponible insuffisant</string> <string name="error_delete_order">Erreur : impossible de supprimer la commande</string> <string name="product_wrong_currency">Devise incorrecte</string> - <string name="force_delete_dialog_title">Forcer la suppression de la commande ?</string> + <string name="force_delete_dialog_title">Forcer la suppression de la commande ?</string> <string name="force_delete_dialog_message">Cette commande a été réclamée par un portefeuille (wallet). Forcer sa suppression libère les verrous de stock et empêche le portefeuille de finaliser le paiement.</string> <string name="force_delete_dialog_confirm">Forcer la suppression</string> <string name="insecure_payment_dialog_title">Problème avec le lien de paiement</string> @@ -161,4 +161,302 @@ <string name="insecure_payment_dialog_try_https">Essayer le lien corrigé</string> <string name="insecure_payment_dialog_continue_as_is">Continuer tel quel</string> <string name="insecure_payment_dialog_cancel_order">Annuler la commande</string> + <string name="payment_device_nfc_available">disponible</string> + <string name="payment_options_title">Modes de paiement</string> + <string name="payment_options_button">Modes de paiement</string> + <string name="payment_options_description">Taler est toujours disponible. Activez d’autres modes de paiement ou ouvrez un mode pour le configurer.</string> + <string name="payment_options_enabled_count">%1$d activés</string> + <string name="payment_option_open">Ouvrir les paramètres de %1$s</string> + <string name="payment_option_settings_title">Paramètres</string> + <string name="payment_option_unknown">Ce mode de paiement n’est pas disponible dans cette version.</string> + <string name="payment_option_disabled">Désactivé</string> + <string name="payment_option_ready">Prêt</string> + <string name="payment_option_taler">GNU Taler</string> + <string name="payment_option_taler_description">Les clients paient depuis un portefeuille Taler en scannant le code QR ou en approchant leur téléphone.</string> + <string name="payment_option_cash">Espèces</string> + <string name="payment_option_cash_description">Les clients paient en espèces au comptoir. La commande est réglée en dehors de l’application.</string> + <string name="terminal_not_configured">La configuration n’est pas disponible dans cette version. Contactez l’administrateur de l’application.</string> + <string name="terminal_sign_in_required">Connectez-vous pour appairer un lecteur.</string> + <string name="terminal_permission_required">L’autorisation d’accès aux appareils à proximité est requise. Ouvrez la configuration du lecteur pour continuer.</string> + <string name="terminal_not_paired">Aucun lecteur appairé</string> + <string name="terminal_disconnected">Lecteur déconnecté</string> + <string name="terminal_connected">Lecteur connecté</string> + <string name="terminal_unavailable">Impossible de vérifier la connexion du lecteur</string> + <string name="terminal_pair">Appairer le lecteur</string> + <string name="terminal_manage">Gérer le lecteur</string> + <string name="terminal_account">Compte : %1$s</string> + <string name="terminal_serial">Numéro de série : %1$s</string> + <string name="terminal_indicator_description">Appareils de paiement : %1$s</string> + <string name="terminal_close">Fermer</string> + <string name="terminal_action_failed">Impossible de terminer la configuration du terminal. Vérifiez le Bluetooth, les autorisations et votre connexion Internet, puis réessayez.</string> + <string name="terminal_sumup">SumUp</string> + <string name="terminal_sumup_description">Les clients paient par carte sur un lecteur SumUp appairé à cet appareil.</string> + <string name="terminal_sumup_sign_in">Se connecter à SumUp</string> + <string name="terminal_sumup_sign_out">Se déconnecter de SumUp</string> + <string name="terminal_sumup_save_key">Enregistrer la clé d’affiliation</string> + <string name="terminal_sumup_advanced">Avancé</string> + <string name="terminal_sumup_advanced_help">La clé d’affiliation identifie cette application auprès de SumUp, pas votre entreprise. Vous n’avez presque jamais besoin de la modifier. Remplacez-la uniquement si vous avez votre propre inscription partenaire SumUp.</string> + <string name="terminal_sumup_key_builtin">Utilisation de la clé d’affiliation intégrée à cette application.</string> + <string name="terminal_sumup_key_custom">Utilisation d’une clé d’affiliation personnalisée.</string> + <string name="terminal_sumup_key_hint">Nouvelle clé d’affiliation</string> + <string name="terminal_sumup_key_show">Afficher la clé</string> + <string name="terminal_sumup_key_hide">Masquer la clé</string> + <string name="terminal_sumup_key_warning">Enregistrer une autre clé vous déconnecte de SumUp.</string> + <string name="terminal_sumup_reset_key">Utiliser la clé intégrée</string> + <string name="terminal_sumup_session_help">Une connexion SumUp dure environ une heure. Avec votre propre application OAuth, l’application la renouvelle automatiquement et le personnel n’a plus à se reconnecter.</string> + <string name="terminal_sumup_session_no_client">Aucune application OAuth configurée. Ajoutez-en une dans Avancé pour maintenir la session active.</string> + <string name="terminal_sumup_session_not_authorized">Pas encore autorisé. Autorisez une fois cet appareil pour qu’il reste connecté.</string> + <string name="terminal_sumup_session_expires">Le jeton d’accès expire dans %1$s</string> + <string name="terminal_sumup_session_refreshes">Renouvelé %1$d fois, la dernière à %2$s</string> + <string name="terminal_sumup_session_error">Dernier problème de renouvellement : %1$s</string> + <string name="terminal_sumup_authorize">Autoriser cet appareil</string> + <string name="terminal_sumup_reauthorize">Autoriser à nouveau</string> + <string name="terminal_sumup_refresh_now">Renouveler le jeton maintenant</string> + <string name="terminal_sumup_revoke">Oublier l’autorisation</string> + <string name="terminal_sumup_oauth_help">Enregistrez une application OAuth dans votre tableau de bord SumUp avec l’URL de redirection %1$s, puis collez ici son identifiant client et son secret. Ils restent sur cet appareil.</string> + <string name="terminal_sumup_oauth_set">Une application OAuth est configurée.</string> + <string name="terminal_sumup_oauth_unset">Aucune application OAuth configurée.</string> + <string name="terminal_sumup_client_id">Identifiant client OAuth</string> + <string name="terminal_sumup_client_secret">Secret client OAuth</string> + <string name="terminal_sumup_save_client">Enregistrer l’application OAuth</string> + <string name="payment_device_connected">%1$s connecté</string> + <string name="payment_device_disconnected">%1$s non connecté</string> + <string name="payment_device_nfc">NFC</string> + <string name="payment_device_status_connected">Connecté</string> + <string name="payment_device_status_disconnected">Non connecté</string> + <string name="payment_device_battery">Batterie : %1$d%%</string> + <string name="checkout_title">Paiement</string> + <string name="checkout_total">Total</string> + <string name="checkout_payments_button">Paiements enregistrés (%1$d)</string> + <string name="checkout_payments_title">Paiements enregistrés</string> + <string name="checkout_no_taler_hint">GNU Taler est désactivé. Le montant total doit donc être payé avec les autres modes de paiement.</string> + <string name="checkout_order_created_hint">La commande a été créée. Vous ne pouvez plus ajouter d’espèces ni de paiement par carte. GNU Taler règle le solde.</string> + <string name="checkout_cancel">Annuler le paiement</string> + <string name="checkout_change_due_title">Rendre la monnaie</string> + <string name="checkout_change_due">Rendez %1$s au client.</string> + <string name="checkout_card_title">Paiement par carte</string> + <string name="checkout_card_amount">Montant à débiter</string> + <string name="checkout_card_too_much">Supérieur au solde restant</string> + <string name="checkout_card_not_ready">Le lecteur de carte n’est pas prêt. Vérifiez que vous êtes connecté et que le lecteur est connecté.</string> + <string name="checkout_card_recorded">Paiement par carte de %1$s enregistré</string> + <string name="checkout_card_failed">Le paiement par carte n’a pas abouti.</string> + <string name="checkout_card_unknown_title">Paiement par carte non confirmé</string> + <string name="checkout_card_unknown">Impossible de confirmer si %1$s a été débité. Vérifiez la transaction dans l’application du prestataire de paiement avant de débiter à nouveau. Rien n’a été enregistré.</string> + <string name="checkout_record_failed">Impossible d’enregistrer le paiement. Ne rendez pas la monnaie avant son enregistrement.</string> + <string name="checkout_none">Ce paiement n’est plus en attente.</string> + <string name="checkout_payment_line_change">Reçu : %1$s, monnaie : %2$s</string> + <string name="checkout_payment_line_reference">Référence %1$s</string> + <string name="checkout_history_continue">Continuer</string> + <string name="checkout_history_dismiss">Marquer comme réglé</string> + <string name="payment_options_mixed_unsupported">Le paiement en plusieurs parties nécessite la version 42 ou ultérieure du protocole du backend commerçant. Ce backend indique %1$s.</string> + <string name="checkout_total_amount">Total : %1$s</string> + <string name="checkout_paid_amount">Payé : %1$s</string> + <string name="checkout_about">À propos du paiement</string> + <string name="checkout_about_message">%1$s a déjà été encaissé. Si vous annulez maintenant, vous devez rendre les espèces et rembourser tout paiement par carte dans l’application du prestataire de paiement.</string> + <string name="checkout_cancel_anyway">Annuler quand même le paiement</string> + <string name="checkout_settled_hint">Tout a été payé. Terminez la commande pour la finaliser.</string> + <string name="checkout_back_to_options">Retour aux modes de paiement</string> + <string name="checkout_revert_failed">Impossible de retirer la commande.</string> + <string name="payment_option_activation_title">Avant d’activer cette option</string> + <string name="payment_option_activation_confirm">Activer</string> + <string name="terminal_sumup_activation_notice">Avec le logiciel actuel de SumUp, cette application peut débiter les cartes, mais pas rembourser l’argent. Un remboursement doit être effectué en espèces, avec un autre mode de paiement ou dans l’application SumUp.\n\nVérifiez auprès de votre comptable ou conseiller juridique que cela convient aux obligations de votre entreprise en matière de retours.</string> + <string name="history_status_partially_paid">Partiellement payé</string> + <string name="history_status_awaiting_collection">En attente de récupération</string> + <string name="order_details_title">Détails de la commande</string> + <string name="order_details_loading">Chargement des détails de la commande…</string> + <string name="order_details_unavailable">Détails indisponibles</string> + <string name="order_details_load_error">Impossible de charger cette commande. Vérifiez votre connexion et réessayez.</string> + <string name="order_details_retry">Réessayer</string> + <string name="order_details_fulfillment_link">Lien d’exécution de la commande</string> + <string name="order_details_not_provided">Le commerçant n’a pas fourni de détails supplémentaires pour cette commande.</string> + <string name="order_details_order_id">Référence de la commande</string> + <string name="order_details_refund_total">Montant remboursé</string> + <string name="order_details_summary">Récapitulatif de la commande</string> + <string name="order_details_products">Produits</string> + <string name="order_details_no_products">Aucune liste détaillée des produits n’a été fournie.</string> + <string name="order_details_net_price">%1$s (net)</string> + <string name="order_details_payments">Détail des paiements</string> + <string name="order_details_balance">Reste à payer</string> + <string name="order_details_delivery">Livraison / Retrait</string> + <string name="order_details_delivery_location">Lieu de livraison</string> + <string name="order_details_delivery_date">Date de livraison</string> + <string name="order_details_pickup_time">Heure limite de retrait</string> + <string name="order_details_reorder">Commander à nouveau</string> + <string name="order_details_reorder_hint">Partagez ce lien pour commander à nouveau les mêmes produits.</string> + <string name="order_details_reorder_link">Lien pour commander à nouveau</string> + <string name="order_details_additional">Informations complémentaires</string> + <string name="order_details_pay_deadline">Date limite de paiement</string> + <string name="order_details_refund_deadline">Date limite de remboursement</string> + <string name="order_details_merchant">Coordonnées du commerçant</string> + <string name="order_details_jurisdiction">Juridiction</string> + <string name="order_details_email">E-mail</string> + <string name="order_details_website">Site web</string> + <string name="order_details_open_link">Ouvrir le lien</string> + <string name="order_details_copied">Copié dans le presse-papiers</string> + <string name="order_details_copy">Copier %1$s</string> + <string name="order_details_open_failed">Aucune application ne permet d’ouvrir ce lien.</string> + <string name="refund_choose_method">Mode de remboursement</string> + <string name="refund_available">Montant remboursable</string> + <string name="refund_unavailable">Indisponible</string> + <string name="refund_unavailable_hint">Ce mode ne peut pas être remboursé ici. Les remboursements externes nécessitent une commande payée et son enregistrement de paiement sur cet appareil. Les remboursements Taler doivent encore être disponibles.</string> + <string name="refund_taler_button">Rembourser avec Taler</string> + <string name="refund_taler_hint">Approuvez le remboursement, puis laissez le client le récupérer avec son portefeuille Taler.</string> + <string name="refund_cash_hint">Rendez les espèces au client, puis enregistrez le montant rendu.</string> + <string name="refund_card_hint">Remboursez le paiement par carte initial dans l’application du prestataire, puis enregistrez ici le montant remboursé. Cet écran n’envoie pas d’argent sur la carte.</string> + <string name="refund_cash_confirm">Confirmez que ces espèces ont été rendues au client.</string> + <string name="refund_card_confirm">Confirmez que le prestataire de paiement a effectué ce remboursement. L’enregistrer ici n’effectue pas un autre remboursement.</string> + <string name="refund_returned_confirm">Argent rendu - enregistrer le remboursement</string> + <string name="refund_record_external">Enregistrer le remboursement</string> + <string name="refund_recorded">Remboursement enregistré</string> + <string name="refund_recorded_message">Le montant remboursé a été enregistré sur la commande.</string> + <string name="refund_recorded_local">Enregistré sur cet appareil, mais la commande n’a pas pu être mise à jour. Ne remboursez pas cette somme à nouveau. Message du backend : %1$s</string> + <string name="order_delete_final_title">Supprimer définitivement cette commande ?</string> + <string name="order_delete_warning">La suppression retire cette commande impayée. Elle ne rend pas les espèces et n’effectue aucun remboursement par carte ou Taler. Réglez les paiements avec le client avant de continuer.</string> + <string name="order_delete_final_warning">Cette action est irréversible. Confirmez que tous les paiements ont été réglés et que vous souhaitez supprimer définitivement cette commande.</string> + <string name="order_delete_continue">Continuer</string> + <string name="product_image_open">Afficher l’image : %1$s</string> + <string name="product_image_close">Fermer l’image</string> + <string name="payment_pay_with">Payer avec</string> + + <string name="checkout_complete_payment">Terminer le paiement</string> + <string name="checkout_collect_receipt_hint">Le client peut scanner l’écran suivant pour récupérer le reçu dans un portefeuille Taler.</string> + <string name="checkout_collect_receipt_title">Récupérer le reçu</string> + <string name="checkout_receipt_paid_in_full">Payé intégralement</string> + <string name="checkout_receipt_scan_hint">Scannez avec un portefeuille Taler pour récupérer le reçu.</string> + <string name="checkout_collect_receipt_on_device">Récupérer le reçu sur cet appareil</string> + <string name="checkout_payment_claimed_title">Paiement en cours</string> + <string name="checkout_customer_opened_payment">Le client a ouvert ce paiement dans son portefeuille.</string> + <string name="checkout_payment_claimed_hint">En attente de confirmation du client</string> + <string name="checkout_tap_to_enter_amount">Appuyez pour saisir le montant</string> + <string name="checkout_cash_entry_instruction">Saisissez le montant reçu</string> + <string name="checkout_card_entry_instruction">Saisissez le montant à débiter</string> + <string name="checkout_fraction_remaining">Solde</string> + <string name="checkout_fraction_half_description">Moitié du solde restant, %1$s</string> + <string name="checkout_fraction_third_description">Tiers du solde restant, %1$s</string> + <string name="checkout_fraction_quarter_description">Quart du solde restant, %1$s</string> + <string name="checkout_fraction_remaining_description">Solde restant, %1$s</string> + <string name="checkout_charge_with_taler">Payer avec Taler</string> + <string name="checkout_charge_remaining_taler">Payer le solde avec Taler</string> + <string name="checkout_order_total_heading">Total de la commande</string> + <string name="checkout_payments_heading">Paiements</string> + <string name="checkout_payments_added">%1$d ajoutés</string> + <string name="checkout_payments_empty_inline">Les paiements ajoutés apparaîtront ici.</string> + <string name="checkout_about_action">À propos de cet encaissement</string> + <string name="checkout_fully_paid">Entièrement payé</string> + <string name="checkout_scan_to_pay_hint">Demandez au client de scanner le code QR sur l’écran suivant pour payer.</string> + <string name="checkout_scan_qr_prompt">Scannez le code QR avec votre portefeuille mobile.</string> + <string name="checkout_payment_detail_order">Commande</string> + <string name="checkout_payment_detail_summary">Récapitulatif</string> + <string name="checkout_waiting_customer_payment">En attente du paiement…</string> + <string name="checkout_remaining_heading">Restant</string> + <string name="checkout_order_reference">Commande n° %1$d</string> + <string name="checkout_choose_payment_method">Choisir le mode de paiement</string> + <string name="checkout_payment_method_helper">Ajoutez des espèces ou un paiement par carte, ou payez le solde avec Taler.</string> + <string name="checkout_complete_order_hint">Termine cette commande</string> + <string name="checkout_remaining_amount">Restant : %1$s</string> + <string name="checkout_remaining_to_pay">Reste à payer %1$s</string> + <string name="checkout_remaining_after_payment">Solde après paiement : %1$s</string> + <string name="checkout_change_to_return_amount">Monnaie à rendre : %1$s</string> + <string name="checkout_cash_change_explanation">Un montant en espèces supérieur au solde indique la monnaie à rendre.</string> + <string name="checkout_record_cash_action">Enregistrer %1$s en espèces</string> + <string name="checkout_charge_card_action">Débiter %1$s par carte</string> + <string name="checkout_cash_received_heading">Espèces reçues</string> + <string name="checkout_card_payment_heading">Paiement par carte</string> + <string name="checkout_card_amount_limit_hint">Le montant par carte ne peut pas dépasser le solde restant.</string> + + <string name="checkout_creation_pending">La commande existe peut-être déjà. Réessayez l’étape Taler pour la récupérer avant de modifier les paiements.</string> + <string name="checkout_pending_charge">Vérifiez le paiement par carte en attente de %1$s auprès de %2$s. Consultez son état final dans les registres du prestataire avec la référence %3$s avant de continuer.</string> + <string name="checkout_pending_paid">Paiement confirmé dans les registres du prestataire</string> + <string name="checkout_pending_not_paid">Absence de débit confirmée par le prestataire</string> + <string name="checkout_pending_confirm_title">Confirmer le résultat du paiement</string> + <string name="checkout_pending_confirm_paid">Confirmez que ce paiement précis a réussi. Saisissez sa référence de transaction fournie par le prestataire.</string> + <string name="checkout_pending_confirm_unpaid">Continuez uniquement si le prestataire confirme que ce paiement a échoué ou a été annulé et qu’il ne pourra pas être encaissé ultérieurement. Un résultat en attente ou absent ne constitue pas une confirmation.</string> + <string name="checkout_pending_transaction">Référence de transaction du prestataire</string> + + <string name="history_total_paid_remaining">Total : %1$s Payé : %2$s Restant : %3$s</string> + <string name="history_checkout_total_paid_remaining">Total : %1$s Payé : %2$s Restant : %3$s</string> + <string name="order_details_delete_action">Supprimer</string> + <string name="settings_manage_connection_action">Paramètres du commerçant</string> + <string name="settings_logout_action">Se déconnecter</string> + <string name="payment_options_manage_action">Gérer les modes de paiement</string> + + <string name="order_actions">Actions</string> + <string name="order_current">Commande en cours</string> + <string name="amount_entry_hint">Saisissez le montant de la commande</string> + + <string name="taler_ble_unsupported">Le Bluetooth n’est pas disponible sur cet appareil.</string> + <string name="taler_ble_permission">Autorisez l’accès aux appareils à proximité pour connecter l’écran.</string> + <string name="taler_ble_off">Activez le Bluetooth pour connecter l’écran.</string> + <string name="taler_ble_location">Cette version d’Android nécessite l’activation de la localisation pour rechercher les appareils Bluetooth.</string> + <string name="taler_ble_scanning">Recherche d’écrans Taler…</string> + <string name="taler_ble_connecting">Connexion à l’écran…</string> + <string name="taler_ble_sending">Mise à jour de l’écran…</string> + <string name="taler_ble_too_long">Ce lien est trop long pour l’écran. Utilisez le code QR sur cet appareil.</string> + <string name="taler_ble_failed">Impossible de mettre à jour l’écran. Vérifiez qu’il est allumé et à proximité.</string> + <string name="taler_ble_allow">Autoriser l’accès à l’appareil</string> + <string name="taler_ble_open_settings">Ouvrir les paramètres de l’appareil</string> + <string name="taler_ble_scan">Rechercher un écran Taler</string> + <string name="taler_ble_test">Tester l’écran</string> + <string name="taler_ble_reset_limitation">Cet écran peut conserver le code QR précédent après un paiement ou un test. Redémarrez-le pour l’effacer tant que son micrologiciel ne permet pas la réinitialisation.</string> + <string name="taler_ble_reconnect">Reconnecter</string> + <string name="taler_ble_pairing">Appairage… Saisissez le code affiché sur l’écran LCD dans la demande d’appairage Android.</string> + <string name="taler_ble_pairing_required">L’appairage n’a pas abouti ou l’association enregistrée ne fonctionne plus.</string> + <string name="taler_ble_forget_explanation">Ne plus utiliser cet écran avec ce point de vente ? Cela supprime la sélection enregistrée dans l’application. Pour supprimer également l’appairage Bluetooth, oubliez l’écran dans les paramètres Bluetooth Android et choisissez Oublier ce point de vente sur l’écran LCD.</string> + + <string name="taler_devices_manage">Gérer les écrans</string> + <string name="taler_devices_title">Écrans Taler</string> + <string name="taler_devices_empty">Aucun écran ajouté pour le moment.</string> + <string name="taler_devices_add">Ajouter un appareil</string> + <string name="taler_devices_check_usb">Vérifier la connexion USB</string> + <string name="taler_devices_remove">Supprimer l’écran</string> + <string name="taler_devices_replace">Remplacer l’écran</string> + <string name="taler_devices_replace_hint">Ce point de vente utilise un seul écran Bluetooth à la fois. Remplacer l’écran actuel ?</string> + <string name="taler_devices_access">Autoriser la connexion Bluetooth</string> + <string name="taler_devices_open_lcd">Ouvrir les paramètres de l’écran</string> + <string name="taler_devices_open_lcd_hint">Sur l’écran, balayez vers la gauche et appuyez sur l’icône des paramètres.</string> + <string name="taler_devices_pair_lcd">Lancer l’appairage sur l’écran</string> + <string name="taler_devices_pair_lcd_hint">Choisissez Bluetooth → Appairer un point de vente sur l’écran.</string> + <string name="taler_devices_choose_display">Choisissez votre écran</string> + <string name="taler_devices_connect">Connecter l’écran</string> + <string name="taler_devices_connected">Écran connecté</string> + <string name="taler_devices_connected_hint">Votre écran est prêt pour les paiements.</string> + <string name="taler_devices_bluetooth">Écran Bluetooth</string> + <string name="taler_devices_usb">Appareil USB</string> + <string name="taler_devices_scan_finished">Sélectionnez votre écran ci-dessous ou relancez la recherche s’il n’apparaît pas.</string> + <string name="taler_devices_checking">Vérification de la connexion…</string> + <string name="taler_devices_done">Terminé</string> + <string name="taler_devices_next">Suivant</string> + <string name="taler_devices_search_again">Rechercher à nouveau</string> + <string name="taler_devices_pair_again">Appairer à nouveau</string> + <string name="taler_devices_repair_android">Oubliez cet écran dans les paramètres Bluetooth Android, puis revenez ici.</string> + <string name="taler_devices_repair_lcd">Sur l’écran, ouvrez Bluetooth, oubliez l’ancien point de vente, puis choisissez Appairer un point de vente.</string> + <string name="taler_devices_test_hint">Un code QR de test s’affiche pendant cinq secondes, puis s’efface. Aucun paiement n’est créé.</string> + + <string name="taler_usb_not_attached">Connectez un appareil Taler compatible par USB.</string> + <string name="taler_usb_device">Appareil USB Taler %1$d</string> + <string name="taler_usb_permission_required">L’accès USB est requis.</string> + <string name="taler_usb_allow_device">Autoriser l’accès USB Appareil %1$d</string> + <string name="taler_usb_failed">Impossible de mettre à jour l’appareil. Reconnectez-le ou réessayez.</string> + <string name="taler_usb_refresh">Vérifier la connexion</string> + <string name="checkout_order_claimed">Commande réclamée par le portefeuille.</string> + <string name="checkout_complete_in_wallet">Terminez le paiement dans votre portefeuille</string> + <string name="checkout_payment_possible_until">Paiement possible jusqu’au %1$s</string> + <string name="checkout_payment_detail_merchant">Commerçant</string> + <string name="config_local_network_title">Connexion à un commerçant local</string> + <string name="config_local_network_explanation">Votre serveur marchand à %1$s se trouve sur un réseau local. Autorisez l’accès aux appareils à proximité pour que Taler POS puisse s’y connecter et traiter les commandes.</string> + <string name="config_local_network_settings">L’accès au réseau local est bloqué. Ouvrez les paramètres de l’application, choisissez Autorisations → Appareils à proximité et autorisez l’accès. Revenez ensuite ici pour vous connecter à %1$s.</string> + <string name="config_local_network_allow">Autoriser l’accès</string> + <string name="config_local_network_required">Autorisez l’accès au réseau local pour vous connecter à ce commerçant.</string> + <string name="config_camera_title">Accès à la caméra pour la configuration QR</string> + <string name="config_camera_explanation">La caméra scanne le code QR de configuration de Merchant Backoffice pour renseigner les paramètres de votre compte. Vous pouvez aussi saisir ces informations manuellement.</string> + <string name="config_camera_settings_instructions">L’accès à la caméra est bloqué. Ouvrez les paramètres de l’application, choisissez Autorisations → Caméra et autorisez l’accès pendant l’utilisation de l’application. Revenez ensuite ici pour scanner.</string> + <string name="config_camera_allow">Autoriser la caméra</string> + <string name="config_camera_open_settings">Ouvrir les paramètres</string> + <string name="config_camera_manual">Configurer manuellement</string> + <string name="config_connecting">Connexion…</string> + <string name="password_show">Afficher le mot de passe</string> + <string name="password_hide">Masquer le mot de passe</string> + <string name="navigation_back">Retour</string> + <string name="navigation_menu_open">Ouvrir le menu de navigation</string> + <string name="order_empty_inventory">Aucun produit à afficher. Utilisez la commande par montant ou ajoutez un produit personnalisé.</string> </resources> diff --git a/merchant-terminal/src/main/res/values-it/strings.xml b/merchant-terminal/src/main/res/values-it/strings.xml @@ -108,9 +108,9 @@ <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_no_instance">Nessuna istanza?</string> + <string name="config_no_instance">Nessun account?</string> <string name="config_instance_tutorial_description">1. Consulta i tutorial di configurazione su:</string> - <string name="config_instance_demo_description">2. Puoi provare l’app con l’istanza demo pubblica. Tieni presente che questa istanza è condivisa: chiunque può modificarne i dati in qualsiasi momento. Usala solo per esplorare l’app e le sue funzionalità.</string> + <string name="config_instance_demo_description">2. Puoi provare l’app con l’account demo pubblico. Tieni presente che questo account è condiviso: chiunque può modificarne i dati in qualsiasi momento. Usalo solo per esplorare l’app e le sue funzionalità.</string> <string name="config_fill_demo_credentials">Inserisci credenziali demo</string> <string name="config_instance_review_description">3. Se stai esaminando questa app, molto probabilmente hai già accesso a un utente di test controllato. Usa il pulsante qui sotto per precompilare l’URL del portale commerciante.</string> <string name="config_fill_review_url">Inserisci URL per la revisione</string> @@ -168,4 +168,304 @@ <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 cancellare l’ordine</string> + <string name="payment_device_nfc_available">disponibile</string> + <string name="settings_about_title">Informazioni</string> + <string name="refund_reason_wrong_order">Ordine errato</string> + <string name="payment_options_title">Opzioni di pagamento</string> + <string name="payment_options_button">Opzioni di pagamento</string> + <string name="payment_options_description">Taler è sempre disponibile. Attiva altri metodi di pagamento o apri un’opzione per configurarla.</string> + <string name="payment_options_enabled_count">%1$d attivati</string> + <string name="payment_option_open">Apri le impostazioni di %1$s</string> + <string name="payment_option_settings_title">Impostazioni</string> + <string name="payment_option_unknown">Questa opzione di pagamento non è disponibile in questa versione.</string> + <string name="payment_option_disabled">Disattivato</string> + <string name="payment_option_ready">Pronto</string> + <string name="payment_option_taler">GNU Taler</string> + <string name="payment_option_taler_description">I clienti pagano da un portafoglio Taler scansionando il codice QR o avvicinando il telefono.</string> + <string name="payment_option_cash">Contanti</string> + <string name="payment_option_cash_description">I clienti pagano in contanti al banco. L’ordine viene saldato al di fuori dell’app.</string> + <string name="terminal_not_configured">La configurazione non è disponibile in questa versione. Contatta l’amministratore dell’app.</string> + <string name="terminal_sign_in_required">Accedi per associare un lettore.</string> + <string name="terminal_permission_required">È richiesta l’autorizzazione per i dispositivi nelle vicinanze. Apri la configurazione del lettore per continuare.</string> + <string name="terminal_not_paired">Nessun lettore associato</string> + <string name="terminal_disconnected">Lettore disconnesso</string> + <string name="terminal_connected">Lettore connesso</string> + <string name="terminal_unavailable">Impossibile verificare la connessione del lettore</string> + <string name="terminal_pair">Associa lettore</string> + <string name="terminal_manage">Gestisci lettore</string> + <string name="terminal_account">Account: %1$s</string> + <string name="terminal_serial">Numero di serie: %1$s</string> + <string name="terminal_indicator_description">Dispositivi di pagamento: %1$s</string> + <string name="terminal_close">Chiudi</string> + <string name="terminal_action_failed">Impossibile completare la configurazione del terminale. Controlla Bluetooth, autorizzazioni e connessione Internet, poi riprova.</string> + <string name="terminal_sumup">SumUp</string> + <string name="terminal_sumup_description">I clienti pagano con carta su un lettore SumUp associato a questo dispositivo.</string> + <string name="terminal_sumup_sign_in">Accedi a SumUp</string> + <string name="terminal_sumup_sign_out">Esci da SumUp</string> + <string name="terminal_sumup_save_key">Salva chiave affiliato</string> + <string name="terminal_sumup_advanced">Avanzate</string> + <string name="terminal_sumup_advanced_help">La chiave affiliato identifica questa app presso SumUp, non la tua attività. Quasi mai è necessario cambiarla. Sostituiscila solo se hai una tua registrazione come partner SumUp.</string> + <string name="terminal_sumup_key_builtin">È in uso la chiave affiliato integrata nell’app.</string> + <string name="terminal_sumup_key_custom">È in uso una chiave affiliato personalizzata.</string> + <string name="terminal_sumup_key_hint">Nuova chiave affiliato</string> + <string name="terminal_sumup_key_show">Mostra chiave</string> + <string name="terminal_sumup_key_hide">Nascondi chiave</string> + <string name="terminal_sumup_key_warning">Salvare una chiave diversa comporta la disconnessione da SumUp.</string> + <string name="terminal_sumup_reset_key">Usa la chiave integrata</string> + <string name="terminal_sumup_session_help">Un accesso SumUp dura circa un’ora. Con una tua applicazione OAuth, l’app lo rinnova automaticamente e il personale non deve più accedere di nuovo.</string> + <string name="terminal_sumup_session_no_client">Nessuna applicazione OAuth configurata. Aggiungine una in Avanzate per mantenere attiva la sessione.</string> + <string name="terminal_sumup_session_not_authorized">Non ancora autorizzato. Autorizza una volta per mantenere l’accesso su questo dispositivo.</string> + <string name="terminal_sumup_session_expires">Il token di accesso scade tra %1$s</string> + <string name="terminal_sumup_session_refreshes">Rinnovato %1$d volte, l’ultima alle %2$s</string> + <string name="terminal_sumup_session_error">Ultimo problema di rinnovo: %1$s</string> + <string name="terminal_sumup_authorize">Autorizza questo dispositivo</string> + <string name="terminal_sumup_reauthorize">Autorizza di nuovo</string> + <string name="terminal_sumup_refresh_now">Rinnova token ora</string> + <string name="terminal_sumup_revoke">Dimentica autorizzazione</string> + <string name="terminal_sumup_oauth_help">Registra un’applicazione OAuth nel pannello SumUp con l’URL di reindirizzamento %1$s, quindi incolla qui il suo ID client e il segreto. Rimangono su questo dispositivo.</string> + <string name="terminal_sumup_oauth_set">È configurata un’applicazione OAuth.</string> + <string name="terminal_sumup_oauth_unset">Nessuna applicazione OAuth configurata.</string> + <string name="terminal_sumup_client_id">ID client OAuth</string> + <string name="terminal_sumup_client_secret">Segreto client OAuth</string> + <string name="terminal_sumup_save_client">Salva applicazione OAuth</string> + <string name="payment_device_connected">%1$s connesso</string> + <string name="payment_device_disconnected">%1$s non connesso</string> + <string name="payment_device_nfc">NFC</string> + <string name="payment_device_status_connected">Connesso</string> + <string name="payment_device_status_disconnected">Non connesso</string> + <string name="payment_device_battery">Batteria %1$d%%</string> + <string name="checkout_title">Pagamento</string> + <string name="checkout_total">Totale</string> + <string name="checkout_payments_button">Pagamenti registrati (%1$d)</string> + <string name="checkout_payments_title">Pagamenti registrati</string> + <string name="checkout_no_taler_hint">GNU Taler è disattivato, quindi l’intero importo deve essere pagato con le altre opzioni.</string> + <string name="checkout_order_created_hint">L’ordine è stato creato, quindi non si possono più aggiungere contanti o pagamenti con carta. GNU Taler paga il resto.</string> + <string name="checkout_cancel">Annulla pagamento</string> + <string name="checkout_change_due_title">Dai il resto</string> + <string name="checkout_change_due">Restituisci %1$s al cliente.</string> + <string name="checkout_card_title">Pagamento con carta</string> + <string name="checkout_card_amount">Importo da addebitare</string> + <string name="checkout_card_too_much">Supera il saldo residuo</string> + <string name="checkout_card_not_ready">Il lettore di carte non è pronto. Verifica di aver effettuato l’accesso e che il lettore sia connesso.</string> + <string name="checkout_card_recorded">Pagamento con carta di %1$s registrato</string> + <string name="checkout_card_failed">Il pagamento con carta non è riuscito.</string> + <string name="checkout_card_unknown_title">Pagamento con carta non confermato</string> + <string name="checkout_card_unknown">Non è stato possibile confermare l’addebito di %1$s. Controlla la transazione nell’app del fornitore prima di addebitare nuovamente. Non è stato registrato nulla.</string> + <string name="checkout_record_failed">Impossibile salvare il pagamento. Non consegnare il resto finché non viene registrato.</string> + <string name="checkout_none">Questo pagamento non è più in sospeso.</string> + <string name="checkout_payment_line_change">Ricevuti %1$s, resto %2$s</string> + <string name="checkout_payment_line_reference">Riferimento %1$s</string> + <string name="checkout_history_continue">Continua</string> + <string name="checkout_history_dismiss">Segna come saldato</string> + <string name="payment_options_mixed_unsupported">Il pagamento in più parti richiede il protocollo del backend commerciante versione 42 o successiva. Questo backend indica %1$s.</string> + <string name="checkout_total_amount">Totale: %1$s</string> + <string name="checkout_paid_amount">Pagato: %1$s</string> + <string name="checkout_about">Informazioni sul pagamento</string> + <string name="checkout_about_message">Sono già stati incassati %1$s. Annullando ora, devi restituire i contanti e rimborsare eventuali pagamenti con carta nell’app del fornitore di pagamenti.</string> + <string name="checkout_cancel_anyway">Annulla comunque il pagamento</string> + <string name="checkout_settled_hint">Tutto è stato pagato. Termina l’ordine per completarlo.</string> + <string name="checkout_back_to_options">Torna alle opzioni di pagamento</string> + <string name="checkout_revert_failed">Impossibile ritirare l’ordine.</string> + <string name="payment_option_activation_title">Prima di attivare questa opzione</string> + <string name="payment_option_activation_confirm">Attiva</string> + <string name="terminal_sumup_activation_notice">Con il software attuale di SumUp, questa app può addebitare le carte ma non restituire denaro. I rimborsi devono essere effettuati in contanti, con un altro metodo di pagamento o nell’app di SumUp.\n\nVerifica con il tuo commercialista o consulente legale che questa procedura rispetti gli obblighi della tua attività in materia di resi.</string> + <string name="history_status_partially_paid">Parzialmente pagato</string> + <string name="history_status_awaiting_collection">In attesa di ritiro</string> + <string name="order_details_title">Dettagli ordine</string> + <string name="order_details_loading">Caricamento dei dettagli dell’ordine…</string> + <string name="order_details_unavailable">Dettagli non disponibili</string> + <string name="order_details_load_error">Impossibile caricare questo ordine. Controlla la connessione e riprova.</string> + <string name="order_details_retry">Riprova</string> + <string name="order_details_fulfillment_link">Link per l’evasione dell’ordine</string> + <string name="order_details_not_provided">Il commerciante non ha fornito ulteriori dettagli per questo ordine.</string> + <string name="order_details_order_id">Riferimento ordine</string> + <string name="order_details_refund_total">Importo rimborsato</string> + <string name="order_details_summary">Riepilogo ordine</string> + <string name="order_details_products">Prodotti</string> + <string name="order_details_no_products">Non è stato fornito un elenco dettagliato dei prodotti.</string> + <string name="order_details_net_price">%1$s (netto)</string> + <string name="order_details_payments">Dettaglio dei pagamenti</string> + <string name="order_details_balance">Resta da pagare</string> + <string name="order_details_delivery">Consegna / Ritiro</string> + <string name="order_details_delivery_location">Luogo di consegna</string> + <string name="order_details_delivery_date">Data di consegna</string> + <string name="order_details_pickup_time">Orario limite per il ritiro</string> + <string name="order_details_reorder">Ordina di nuovo</string> + <string name="order_details_reorder_hint">Condividi questo link per ordinare nuovamente gli stessi prodotti.</string> + <string name="order_details_reorder_link">Link per riordinare</string> + <string name="order_details_additional">Informazioni aggiuntive</string> + <string name="order_details_pay_deadline">Scadenza pagamento</string> + <string name="order_details_refund_deadline">Scadenza rimborso</string> + <string name="order_details_merchant">Dati del commerciante</string> + <string name="order_details_jurisdiction">Giurisdizione</string> + <string name="order_details_email">Email</string> + <string name="order_details_website">Sito web</string> + <string name="order_details_open_link">Apri link</string> + <string name="order_details_copied">Copiato negli appunti</string> + <string name="order_details_copy">Copia %1$s</string> + <string name="order_details_open_failed">Non è disponibile alcuna app per aprire questo link.</string> + <string name="refund_choose_method">Metodo di rimborso</string> + <string name="refund_available">Disponibile per il rimborso</string> + <string name="refund_unavailable">Non disponibile</string> + <string name="refund_unavailable_hint">Questo metodo non può essere rimborsato qui. I rimborsi esterni richiedono un ordine pagato e la registrazione del pagamento su questo dispositivo. I rimborsi Taler devono essere ancora disponibili.</string> + <string name="refund_taler_button">Rimborsa Taler</string> + <string name="refund_taler_hint">Approva il rimborso, quindi consenti al cliente di ritirarlo con il proprio portafoglio Taler.</string> + <string name="refund_cash_hint">Restituisci i contanti al cliente, quindi registra l’importo restituito.</string> + <string name="refund_card_hint">Rimborsa il pagamento originale con carta nell’app del fornitore, quindi registra qui l’importo restituito. Questa schermata non invia denaro alla carta.</string> + <string name="refund_cash_confirm">Conferma che questi contanti sono stati restituiti al cliente.</string> + <string name="refund_card_confirm">Conferma che il fornitore della carta ha completato questo rimborso. Registrarlo qui non emette un altro rimborso.</string> + <string name="refund_returned_confirm">Denaro restituito - registra rimborso</string> + <string name="refund_record_external">Registra rimborso</string> + <string name="refund_recorded">Rimborso registrato</string> + <string name="refund_recorded_message">L’importo restituito è stato registrato nell’ordine.</string> + <string name="refund_recorded_local">Salvato su questo dispositivo, ma non è stato possibile aggiornare l’ordine. Non restituire nuovamente questo denaro. Messaggio del backend: %1$s</string> + <string name="order_delete_final_title">Eliminare definitivamente questo ordine?</string> + <string name="order_delete_warning">L’eliminazione rimuove questo ordine non pagato. Non restituisce contanti e non emette rimborsi su carta o Taler. Regola tutti i pagamenti con il cliente prima di continuare.</string> + <string name="order_delete_final_warning">Questa operazione non può essere annullata. Conferma che tutti i pagamenti sono stati saldati e che vuoi eliminare definitivamente questo ordine.</string> + <string name="order_delete_continue">Continua</string> + <string name="product_image_open">Visualizza immagine: %1$s</string> + <string name="product_image_close">Chiudi immagine</string> + <string name="payment_pay_with">Paga con</string> + + <string name="checkout_complete_payment">Completa pagamento</string> + <string name="checkout_collect_receipt_hint">Il cliente può scansionare la schermata successiva per ritirare la ricevuta in un portafoglio Taler.</string> + <string name="checkout_collect_receipt_title">Ritira ricevuta</string> + <string name="checkout_receipt_paid_in_full">Pagato per intero</string> + <string name="checkout_receipt_scan_hint">Scansiona con un portafoglio Taler per ritirare la ricevuta.</string> + <string name="checkout_collect_receipt_on_device">Ritira la ricevuta su questo dispositivo</string> + <string name="checkout_payment_claimed_title">Pagamento in corso</string> + <string name="checkout_customer_opened_payment">Il cliente ha aperto questo pagamento nel proprio portafoglio.</string> + <string name="checkout_payment_claimed_hint">In attesa della conferma del cliente</string> + <string name="checkout_tap_to_enter_amount">Tocca per inserire l’importo</string> + <string name="checkout_cash_entry_instruction">Inserisci l’importo ricevuto</string> + <string name="checkout_card_entry_instruction">Inserisci l’importo da addebitare</string> + <string name="checkout_fraction_remaining">Residuo</string> + <string name="checkout_fraction_half_description">Metà del saldo residuo, %1$s</string> + <string name="checkout_fraction_third_description">Un terzo del saldo residuo, %1$s</string> + <string name="checkout_fraction_quarter_description">Un quarto del saldo residuo, %1$s</string> + <string name="checkout_fraction_remaining_description">Saldo residuo, %1$s</string> + <string name="checkout_charge_with_taler">Paga con Taler</string> + <string name="checkout_charge_remaining_taler">Paga il saldo con Taler</string> + <string name="checkout_order_total_heading">Totale ordine</string> + <string name="checkout_payments_heading">Pagamenti</string> + <string name="checkout_payments_added">%1$d aggiunti</string> + <string name="checkout_payments_empty_inline">I pagamenti aggiunti appariranno qui.</string> + <string name="checkout_about_action">Informazioni su questo incasso</string> + <string name="checkout_fully_paid">Interamente pagato</string> + <string name="checkout_scan_to_pay_hint">Chiedi al cliente di scansionare il codice QR nella schermata successiva per pagare.</string> + <string name="checkout_scan_qr_prompt">Scansiona il codice QR con il tuo portafoglio mobile.</string> + <string name="checkout_payment_detail_order">Ordine</string> + <string name="checkout_payment_detail_summary">Riepilogo</string> + <string name="checkout_waiting_customer_payment">In attesa del pagamento…</string> + <string name="checkout_remaining_heading">Residuo</string> + <string name="checkout_order_reference">Ordine n. %1$d</string> + <string name="checkout_choose_payment_method">Scegli il metodo di pagamento</string> + <string name="checkout_payment_method_helper">Aggiungi contanti o carta oppure paga il saldo con Taler.</string> + <string name="checkout_complete_order_hint">Completa questo ordine</string> + <string name="checkout_remaining_amount">Residuo: %1$s</string> + <string name="checkout_remaining_to_pay">Resta da pagare %1$s</string> + <string name="checkout_remaining_after_payment">Residuo dopo il pagamento: %1$s</string> + <string name="checkout_change_to_return_amount">Resto da restituire: %1$s</string> + <string name="checkout_cash_change_explanation">I contanti che superano il saldo indicano il resto da restituire.</string> + <string name="checkout_record_cash_action">Registra %1$s in contanti</string> + <string name="checkout_charge_card_action">Addebita %1$s sulla carta</string> + <string name="checkout_cash_received_heading">Contanti ricevuti</string> + <string name="checkout_card_payment_heading">Pagamento con carta</string> + <string name="checkout_card_amount_limit_hint">L’importo su carta non può superare il saldo residuo.</string> + + <string name="checkout_creation_pending">L’ordine potrebbe già esistere. Riprova il passaggio Taler per recuperarlo prima di modificare i pagamenti.</string> + <string name="checkout_pending_charge">Risolvi il pagamento con carta in sospeso di %1$s con %2$s. Verifica lo stato finale nei registri del fornitore usando il riferimento %3$s prima di continuare.</string> + <string name="checkout_pending_paid">Pagamento confermato nei registri del fornitore</string> + <string name="checkout_pending_not_paid">Il fornitore ha confermato l’assenza di addebito</string> + <string name="checkout_pending_confirm_title">Conferma l’esito del pagamento</string> + <string name="checkout_pending_confirm_paid">Conferma che questo specifico pagamento è riuscito. Inserisci il riferimento della transazione fornito dal fornitore.</string> + <string name="checkout_pending_confirm_unpaid">Continua solo se il fornitore conferma che questo pagamento è fallito o è stato annullato e non può essere liquidato in seguito. Un risultato in sospeso o mancante non è una conferma.</string> + <string name="checkout_pending_transaction">Riferimento della transazione del fornitore</string> + + <string name="history_total_paid_remaining">Totale: %1$s Pagato: %2$s Residuo: %3$s</string> + <string name="history_checkout_total_paid_remaining">Totale: %1$s Pagato: %2$s Residuo: %3$s</string> + <string name="order_details_delete_action">Elimina</string> + <string name="settings_manage_connection_action">Impostazioni commerciante</string> + <string name="settings_logout_action">Esci</string> + <string name="payment_options_manage_action">Gestisci opzioni di pagamento</string> + + <string name="order_actions">Azioni</string> + <string name="order_current">Ordine corrente</string> + <string name="amount_entry_hint">Inserisci l’importo dell’ordine</string> + + <string name="taler_ble_unsupported">Il Bluetooth non è disponibile su questo dispositivo.</string> + <string name="taler_ble_permission">Consenti l’accesso ai dispositivi nelle vicinanze per connettere il display.</string> + <string name="taler_ble_off">Attiva il Bluetooth per connettere il display.</string> + <string name="taler_ble_location">Questa versione di Android richiede che la posizione sia attiva per cercare dispositivi Bluetooth.</string> + <string name="taler_ble_scanning">Ricerca di display Taler…</string> + <string name="taler_ble_connecting">Connessione al display…</string> + <string name="taler_ble_sending">Aggiornamento del display…</string> + <string name="taler_ble_too_long">Questo link è troppo lungo per il display. Usa il codice QR su questo dispositivo.</string> + <string name="taler_ble_failed">Impossibile aggiornare il display. Verifica che sia acceso e nelle vicinanze.</string> + <string name="taler_ble_allow">Consenti accesso al dispositivo</string> + <string name="taler_ble_open_settings">Apri impostazioni dispositivo</string> + <string name="taler_ble_scan">Cerca display Taler</string> + <string name="taler_ble_test">Prova display</string> + <string name="taler_ble_reset_limitation">Questo display potrebbe mantenere il codice QR precedente dopo un pagamento o un test. Riavvia il display per cancellarlo finché il firmware non supporta il ripristino.</string> + <string name="taler_ble_reconnect">Riconnetti</string> + <string name="taler_ble_pairing">Associazione… Inserisci il codice mostrato sul display LCD nella richiesta di associazione di Android.</string> + <string name="taler_ble_pairing_required">L’associazione non è stata completata o quella salvata non funziona più.</string> + <string name="taler_ble_forget_explanation">Smettere di usare questo display su questo punto vendita? Verrà rimossa la selezione salvata nell’app. Per rimuovere anche l’associazione Bluetooth, dimentica il display nelle impostazioni Bluetooth di Android e scegli Dimentica questo punto vendita sul display LCD.</string> + + <string name="taler_devices_manage">Gestisci display</string> + <string name="taler_devices_title">Display Taler</string> + <string name="taler_devices_empty">Nessun display aggiunto.</string> + <string name="taler_devices_add">Aggiungi dispositivo</string> + <string name="taler_devices_check_usb">Verifica connessione USB</string> + <string name="taler_devices_remove">Rimuovi display</string> + <string name="taler_devices_replace">Sostituisci display</string> + <string name="taler_devices_replace_hint">Questo punto vendita usa un solo display Bluetooth alla volta. Sostituire il display attuale?</string> + <string name="taler_devices_access">Consenti connessione Bluetooth</string> + <string name="taler_devices_open_lcd">Apri impostazioni display</string> + <string name="taler_devices_open_lcd_hint">Sul display, scorri verso sinistra e tocca l’ingranaggio delle impostazioni.</string> + <string name="taler_devices_pair_lcd">Avvia l’associazione sul display</string> + <string name="taler_devices_pair_lcd_hint">Sul display scegli Bluetooth → Associa un punto vendita.</string> + <string name="taler_devices_choose_display">Scegli il tuo display</string> + <string name="taler_devices_connect">Connetti display</string> + <string name="taler_devices_connected">Display connesso</string> + <string name="taler_devices_connected_hint">Il display è pronto per i pagamenti.</string> + <string name="taler_devices_bluetooth">Display Bluetooth</string> + <string name="taler_devices_usb">Dispositivo USB</string> + <string name="taler_devices_scan_finished">Seleziona il display qui sotto o ripeti la ricerca se non compare.</string> + <string name="taler_devices_checking">Verifica della connessione…</string> + <string name="taler_devices_done">Fine</string> + <string name="taler_devices_next">Avanti</string> + <string name="taler_devices_search_again">Cerca di nuovo</string> + <string name="taler_devices_pair_again">Associa di nuovo</string> + <string name="taler_devices_repair_android">Dimentica questo display nelle impostazioni Bluetooth di Android, poi torna qui.</string> + <string name="taler_devices_repair_lcd">Sul display, apri Bluetooth, dimentica il vecchio punto vendita e scegli Associa un punto vendita.</string> + <string name="taler_devices_test_hint">Viene mostrato un codice QR di prova per cinque secondi, poi viene cancellato. Non viene creato alcun pagamento.</string> + + <string name="taler_usb_not_attached">Collega un dispositivo Taler supportato tramite USB.</string> + <string name="taler_usb_device">Dispositivo USB Taler %1$d</string> + <string name="taler_usb_permission_required">È richiesto l’accesso USB.</string> + <string name="taler_usb_allow_device">Consenti accesso USB Dispositivo %1$d</string> + <string name="taler_usb_failed">Impossibile aggiornare il dispositivo. Riconnettilo o riprova.</string> + <string name="taler_usb_refresh">Verifica connessione</string> + <string name="checkout_order_claimed">Ordine acquisito dal portafoglio.</string> + <string name="checkout_complete_in_wallet">Completa il pagamento nel portafoglio</string> + <string name="checkout_payment_possible_until">Pagamento possibile fino al %1$s</string> + <string name="checkout_payment_detail_merchant">Commerciante</string> + <string name="config_local_network_title">Connessione a un commerciante locale</string> + <string name="config_local_network_explanation">Il server del commerciante %1$s si trova su una rete locale. Consenti l’accesso ai dispositivi nelle vicinanze per permettere a Taler POS di connettersi ed elaborare gli ordini.</string> + <string name="config_local_network_settings">L’accesso alla rete locale è bloccato. Apri le impostazioni dell’app, scegli Autorizzazioni → Dispositivi nelle vicinanze e consenti l’accesso. Poi torna qui per connetterti a %1$s.</string> + <string name="config_local_network_allow">Consenti accesso</string> + <string name="config_local_network_required">Consenti l’accesso alla rete locale per connetterti a questo commerciante.</string> + <string name="config_camera_title">Accesso alla fotocamera per la configurazione QR</string> + <string name="config_camera_explanation">La fotocamera scansiona il codice QR di configurazione di Merchant Backoffice per compilare le impostazioni del tuo account. Puoi anche inserire questi dati manualmente.</string> + <string name="config_camera_settings_instructions">L’accesso alla fotocamera è bloccato. Apri le impostazioni dell’app, scegli Autorizzazioni → Fotocamera e consenti l’accesso mentre usi l’app. Poi torna qui per scansionare.</string> + <string name="config_camera_allow">Consenti fotocamera</string> + <string name="config_camera_open_settings">Apri impostazioni</string> + <string name="config_camera_manual">Configura manualmente</string> + <string name="config_connecting">Connessione…</string> + <string name="password_show">Mostra password</string> + <string name="password_hide">Nascondi password</string> + <string name="navigation_back">Indietro</string> + <string name="navigation_menu_open">Apri menu di navigazione</string> + <string name="order_empty_inventory">Nessun prodotto da mostrare. Usa Ordine per importo o aggiungi un prodotto personalizzato.</string> </resources> diff --git a/merchant-terminal/src/main/res/values-iw/strings.xml b/merchant-terminal/src/main/res/values-iw/strings.xml @@ -71,9 +71,9 @@ <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_no_instance">אין מופע?</string> + <string name="config_no_instance">אין חשבון?</string> <string name="config_instance_tutorial_description">1. עיינו במדריכי ההגדרה בכתובת:</string> - <string name="config_instance_demo_description">2. אפשר לנסות את היישום באמצעות מופע ההדגמה הציבורי. שימו לב שזהו מופע משותף—כל אחד יכול לשנות את הנתונים שלו בכל עת. השתמשו בו רק כדי להכיר את היישום ואת התכונות שלו.</string> + <string name="config_instance_demo_description">2. אפשר לנסות את היישום באמצעות חשבון ההדגמה הציבורי. שימו לב שזהו חשבון משותף - כל אחד יכול לשנות את הנתונים שלו בכל עת. השתמשו בו רק כדי להכיר את היישום ואת התכונות שלו.</string> <string name="config_fill_demo_credentials">מילוי פרטי התחברות להדגמה</string> <string name="config_instance_review_description">3. אם אתם בודקים את היישום, סביר שכבר יש לכם גישה למשתמש בדיקה מבוקר. השתמשו בכפתור למטה כדי למלא מראש את כתובת פורטל הסוחר.</string> <string name="config_fill_review_url">מילוי כתובת לבדיקה</string> @@ -155,4 +155,304 @@ <string name="insecure_payment_dialog_try_https">ניסיון עם קישור מתוקן</string> <string name="insecure_payment_dialog_continue_as_is">המשך כפי שהוא</string> <string name="insecure_payment_dialog_cancel_order">ביטול ההזמנה</string> + <string name="payment_device_nfc_available">זמין</string> + <string name="settings_about_title">על אודות</string> + <string name="refund_reason_wrong_order">הזמנה שגויה</string> + <string name="payment_options_title">אפשרויות תשלום</string> + <string name="payment_options_button">אפשרויות תשלום</string> + <string name="payment_options_description">Taler זמין תמיד. אפשר להפעיל אמצעי תשלום נוספים או לפתוח אפשרות כדי להגדיר אותה.</string> + <string name="payment_options_enabled_count">מופעלות: %1$d</string> + <string name="payment_option_open">פתיחת הגדרות %1$s</string> + <string name="payment_option_settings_title">הגדרות</string> + <string name="payment_option_unknown">אפשרות תשלום זו אינה זמינה בגרסה הזאת.</string> + <string name="payment_option_disabled">כבוי</string> + <string name="payment_option_ready">מוכן</string> + <string name="payment_option_taler">GNU Taler</string> + <string name="payment_option_taler_description">הלקוחות משלמים מארנק Taler באמצעות סריקת קוד ה־QR או הצמדת הטלפון.</string> + <string name="payment_option_cash">מזומן</string> + <string name="payment_option_cash_description">הלקוחות משלמים במזומן בקופה. התשלום על ההזמנה מוסדר מחוץ ליישומון.</string> + <string name="terminal_not_configured">ההגדרה אינה זמינה בגרסה הזאת. יש לפנות למנהל היישומון.</string> + <string name="terminal_sign_in_required">יש להיכנס לחשבון כדי לצמד קורא.</string> + <string name="terminal_permission_required">נדרשת הרשאה למכשירים בקרבת מקום. יש לפתוח את הגדרת הקורא כדי להמשיך.</string> + <string name="terminal_not_paired">לא צומד קורא</string> + <string name="terminal_disconnected">הקורא מנותק</string> + <string name="terminal_connected">הקורא מחובר</string> + <string name="terminal_unavailable">לא ניתן לבדוק את החיבור לקורא</string> + <string name="terminal_pair">צימוד קורא</string> + <string name="terminal_manage">ניהול הקורא</string> + <string name="terminal_account">חשבון: %1$s</string> + <string name="terminal_serial">מספר סידורי: %1$s</string> + <string name="terminal_indicator_description">מכשירי תשלום: %1$s</string> + <string name="terminal_close">סגירה</string> + <string name="terminal_action_failed">לא ניתן להשלים את הגדרת המסוף. יש לבדוק את ה־Bluetooth, ההרשאות והחיבור לאינטרנט, ואז לנסות שוב.</string> + <string name="terminal_sumup">SumUp</string> + <string name="terminal_sumup_description">הלקוחות משלמים בכרטיס בקורא SumUp שמצומד למכשיר הזה.</string> + <string name="terminal_sumup_sign_in">כניסה ל־SumUp</string> + <string name="terminal_sumup_sign_out">יציאה מ־SumUp</string> + <string name="terminal_sumup_save_key">שמירת מפתח השותף</string> + <string name="terminal_sumup_advanced">מתקדם</string> + <string name="terminal_sumup_advanced_help">מפתח השותף מזהה את היישומון הזה מול SumUp, לא את העסק שלך. בדרך כלל אין צורך לשנות אותו. יש להחליף אותו רק אם יש לך רישום שותף משלך ב־SumUp.</string> + <string name="terminal_sumup_key_builtin">נעשה שימוש במפתח השותף המובנה ביישומון.</string> + <string name="terminal_sumup_key_custom">נעשה שימוש במפתח שותף מותאם אישית.</string> + <string name="terminal_sumup_key_hint">מפתח שותף חדש</string> + <string name="terminal_sumup_key_show">הצגת המפתח</string> + <string name="terminal_sumup_key_hide">הסתרת המפתח</string> + <string name="terminal_sumup_key_warning">שמירת מפתח אחר תוציא אותך מחשבון SumUp.</string> + <string name="terminal_sumup_reset_key">שימוש במפתח המובנה</string> + <string name="terminal_sumup_session_help">כניסה ל־SumUp תקפה לכשעה. עם יישום OAuth משלך, היישומון מחדש אותה אוטומטית כך שהצוות לא יצטרך להיכנס שוב.</string> + <string name="terminal_sumup_session_no_client">לא הוגדר יישום OAuth. יש להוסיף אחד תחת מתקדם כדי לשמור על החיבור לחשבון.</string> + <string name="terminal_sumup_session_not_authorized">עדיין לא ניתן אישור. יש לאשר פעם אחת כדי שהמכשיר הזה יישאר מחובר לחשבון.</string> + <string name="terminal_sumup_session_expires">תוקף אסימון הגישה יפוג בעוד %1$s</string> + <string name="terminal_sumup_session_refreshes">מספר חידושים: %1$d, האחרון ב־%2$s</string> + <string name="terminal_sumup_session_error">הבעיה האחרונה בחידוש: %1$s</string> + <string name="terminal_sumup_authorize">אישור המכשיר הזה</string> + <string name="terminal_sumup_reauthorize">אישור מחדש</string> + <string name="terminal_sumup_refresh_now">חידוש האסימון כעת</string> + <string name="terminal_sumup_revoke">שכחת ההרשאה</string> + <string name="terminal_sumup_oauth_help">יש לרשום יישום OAuth בלוח הבקרה של SumUp עם כתובת ההפניה %1$s, ואז להדביק כאן את מזהה הלקוח והסוד שלו. הם נשארים במכשיר הזה.</string> + <string name="terminal_sumup_oauth_set">הוגדר יישום OAuth.</string> + <string name="terminal_sumup_oauth_unset">לא הוגדר יישום OAuth.</string> + <string name="terminal_sumup_client_id">מזהה לקוח OAuth</string> + <string name="terminal_sumup_client_secret">סוד לקוח OAuth</string> + <string name="terminal_sumup_save_client">שמירת יישום OAuth</string> + <string name="payment_device_connected">%1$s מחובר</string> + <string name="payment_device_disconnected">%1$s לא מחובר</string> + <string name="payment_device_nfc">NFC</string> + <string name="payment_device_status_connected">מחובר</string> + <string name="payment_device_status_disconnected">לא מחובר</string> + <string name="payment_device_battery">סוללה %1$d%%</string> + <string name="checkout_title">תשלום</string> + <string name="checkout_total">סה״כ</string> + <string name="checkout_payments_button">תשלומים שנרשמו (%1$d)</string> + <string name="checkout_payments_title">תשלומים שנרשמו</string> + <string name="checkout_no_taler_hint">GNU Taler כבוי, ולכן יש לשלם את כל הסכום באמצעות האפשרויות האחרות.</string> + <string name="checkout_order_created_hint">ההזמנה נוצרה ולכן אי אפשר להוסיף עוד תשלומים במזומן או בכרטיס. היתרה תשולם באמצעות GNU Taler.</string> + <string name="checkout_cancel">ביטול התשלום</string> + <string name="checkout_change_due_title">מתן עודף</string> + <string name="checkout_change_due">יש להחזיר ללקוח %1$s.</string> + <string name="checkout_card_title">תשלום בכרטיס</string> + <string name="checkout_card_amount">סכום לחיוב</string> + <string name="checkout_card_too_much">יותר מהיתרה לתשלום</string> + <string name="checkout_card_not_ready">קורא הכרטיסים אינו מוכן. יש לבדוק שנכנסת לחשבון ושהקורא מחובר.</string> + <string name="checkout_card_recorded">נרשם תשלום בכרטיס בסך %1$s</string> + <string name="checkout_card_failed">התשלום בכרטיס לא הושלם.</string> + <string name="checkout_card_unknown_title">התשלום בכרטיס לא אושר</string> + <string name="checkout_card_unknown">לא ניתן לאשר אם חויבו %1$s. יש לבדוק את העסקה ביישומון של ספק התשלומים לפני חיוב נוסף. דבר לא נרשם.</string> + <string name="checkout_record_failed">לא ניתן לשמור את התשלום. אין לתת עודף לפני שהתשלום נרשם.</string> + <string name="checkout_none">התשלום הזה אינו פתוח עוד.</string> + <string name="checkout_payment_line_change">התקבלו %1$s, עודף %2$s</string> + <string name="checkout_payment_line_reference">אסמכתה %1$s</string> + <string name="checkout_history_continue">המשך</string> + <string name="checkout_history_dismiss">סימון כמוסדר</string> + <string name="payment_options_mixed_unsupported">תשלום במספר חלקים מחייב גרסה 42 ומעלה של פרוטוקול שרת בית העסק. השרת הזה מדווח על %1$s.</string> + <string name="checkout_total_amount">סה״כ: %1$s</string> + <string name="checkout_paid_amount">שולם: %1$s</string> + <string name="checkout_about">על התשלום</string> + <string name="checkout_about_message">כבר התקבלו %1$s. ביטול כעת מחייב להחזיר את המזומן ולבצע החזר של תשלומי הכרטיס ביישומון של ספק התשלומים.</string> + <string name="checkout_cancel_anyway">ביטול התשלום בכל זאת</string> + <string name="checkout_settled_hint">הכול שולם. יש לסיים את ההזמנה כדי להשלים אותה.</string> + <string name="checkout_back_to_options">חזרה לאפשרויות התשלום</string> + <string name="checkout_revert_failed">לא ניתן לבטל את יצירת ההזמנה.</string> + <string name="payment_option_activation_title">לפני ההפעלה</string> + <string name="payment_option_activation_confirm">הפעלה</string> + <string name="terminal_sumup_activation_notice">עם התוכנה הנוכחית של SumUp, היישומון יכול לחייב כרטיסים אך לא להחזיר כסף. יש לבצע החזר במזומן, באמצעי תשלום אחר או ביישומון של SumUp עצמו.\n\nיש לבדוק עם רואה החשבון או היועץ המשפטי שהדבר מתאים לחובות העסק בנוגע לטיפול בהחזרות.</string> + <string name="history_status_partially_paid">שולם חלקית</string> + <string name="history_status_awaiting_collection">בהמתנה לאיסוף</string> + <string name="order_details_title">פרטי ההזמנה</string> + <string name="order_details_loading">פרטי ההזמנה נטענים…</string> + <string name="order_details_unavailable">הפרטים אינם זמינים</string> + <string name="order_details_load_error">לא ניתן לטעון את ההזמנה. יש לבדוק את החיבור ולנסות שוב.</string> + <string name="order_details_retry">ניסיון חוזר</string> + <string name="order_details_fulfillment_link">קישור למימוש ההזמנה</string> + <string name="order_details_not_provided">בית העסק לא סיפק פרטים נוספים להזמנה הזאת.</string> + <string name="order_details_order_id">אסמכתת הזמנה</string> + <string name="order_details_refund_total">הסכום שהוחזר</string> + <string name="order_details_summary">סיכום ההזמנה</string> + <string name="order_details_products">מוצרים</string> + <string name="order_details_no_products">לא סופקה רשימת מוצרים מפורטת.</string> + <string name="order_details_net_price">%1$s (נטו)</string> + <string name="order_details_payments">פירוט התשלומים</string> + <string name="order_details_balance">יתרה לתשלום</string> + <string name="order_details_delivery">משלוח / איסוף</string> + <string name="order_details_delivery_location">מקום המשלוח</string> + <string name="order_details_delivery_date">תאריך משלוח</string> + <string name="order_details_pickup_time">מועד אחרון לאיסוף</string> + <string name="order_details_reorder">הזמנה חוזרת</string> + <string name="order_details_reorder_hint">אפשר לשתף קישור זה כדי להזמין שוב את אותם מוצרים.</string> + <string name="order_details_reorder_link">קישור להזמנה חוזרת</string> + <string name="order_details_additional">מידע נוסף</string> + <string name="order_details_pay_deadline">מועד אחרון לתשלום</string> + <string name="order_details_refund_deadline">מועד אחרון להחזר</string> + <string name="order_details_merchant">פרטי בית העסק</string> + <string name="order_details_jurisdiction">סמכות שיפוט</string> + <string name="order_details_email">דוא״ל</string> + <string name="order_details_website">אתר</string> + <string name="order_details_open_link">פתיחת קישור</string> + <string name="order_details_copied">הועתק ללוח</string> + <string name="order_details_copy">העתקת %1$s</string> + <string name="order_details_open_failed">אין יישומון זמין לפתיחת הקישור הזה.</string> + <string name="refund_choose_method">אמצעי החזר</string> + <string name="refund_available">זמין להחזר</string> + <string name="refund_unavailable">לא זמין</string> + <string name="refund_unavailable_hint">אי אפשר לבצע כאן החזר באמצעי זה. החזרים חיצוניים מחייבים הזמנה ששולמה ורישום התשלום שלה במכשיר הזה. החזרי Taler חייבים עדיין להיות זמינים.</string> + <string name="refund_taler_button">החזר באמצעות Taler</string> + <string name="refund_taler_hint">יש לאשר את ההחזר ואז לאפשר ללקוח לקבל אותו בארנק Taler שלו.</string> + <string name="refund_cash_hint">יש להחזיר את המזומן ללקוח ואז לרשום את הסכום שהוחזר.</string> + <string name="refund_card_hint">יש להחזיר את התשלום המקורי בכרטיס דרך יישומון הספק, ואז לרשום כאן את הסכום שהוחזר. מסך זה אינו שולח כסף לכרטיס.</string> + <string name="refund_cash_confirm">יש לאשר שהמזומן הזה הוחזר ללקוח.</string> + <string name="refund_card_confirm">יש לאשר שספק תשלומי הכרטיס השלים החזר זה. רישומו כאן אינו מבצע החזר נוסף.</string> + <string name="refund_returned_confirm">הכסף הוחזר - רישום החזר</string> + <string name="refund_record_external">רישום החזר</string> + <string name="refund_recorded">ההחזר נרשם</string> + <string name="refund_recorded_message">הסכום שהוחזר נרשם בהזמנה.</string> + <string name="refund_recorded_local">נשמר במכשיר הזה, אך לא ניתן לעדכן את ההזמנה. אין להחזיר את הכסף הזה שוב. הודעת השרת: %1$s</string> + <string name="order_delete_final_title">למחוק את ההזמנה הזאת לצמיתות?</string> + <string name="order_delete_warning">המחיקה מסירה את ההזמנה שלא שולמה. היא לא מחזירה מזומן ולא מבצעת החזר בכרטיס או ב־Taler. יש להסדיר את התשלומים עם הלקוח לפני המשך.</string> + <string name="order_delete_final_warning">לא ניתן לבטל פעולה זו. יש לאשר שכל התשלומים הוסדרו ושברצונך למחוק את ההזמנה לצמיתות.</string> + <string name="order_delete_continue">המשך</string> + <string name="product_image_open">הצגת תמונה: %1$s</string> + <string name="product_image_close">סגירת התמונה</string> + <string name="payment_pay_with">תשלום באמצעות</string> + + <string name="checkout_complete_payment">השלמת התשלום</string> + <string name="checkout_collect_receipt_hint">הלקוח יכול לסרוק את המסך הבא כדי לקבל את הקבלה בארנק Taler.</string> + <string name="checkout_collect_receipt_title">קבלת קבלה</string> + <string name="checkout_receipt_paid_in_full">שולם במלואו</string> + <string name="checkout_receipt_scan_hint">יש לסרוק באמצעות ארנק Taler כדי לקבל את הקבלה.</string> + <string name="checkout_collect_receipt_on_device">קבלת הקבלה במכשיר הזה</string> + <string name="checkout_payment_claimed_title">התשלום מתבצע</string> + <string name="checkout_customer_opened_payment">הלקוח פתח את התשלום הזה בארנק שלו.</string> + <string name="checkout_payment_claimed_hint">בהמתנה לאישור הלקוח</string> + <string name="checkout_tap_to_enter_amount">יש להקיש להזנת סכום</string> + <string name="checkout_cash_entry_instruction">יש להזין את הסכום שהתקבל</string> + <string name="checkout_card_entry_instruction">יש להזין את הסכום לחיוב</string> + <string name="checkout_fraction_remaining">יתרה</string> + <string name="checkout_fraction_half_description">חצי מהיתרה לתשלום, %1$s</string> + <string name="checkout_fraction_third_description">שליש מהיתרה לתשלום, %1$s</string> + <string name="checkout_fraction_quarter_description">רבע מהיתרה לתשלום, %1$s</string> + <string name="checkout_fraction_remaining_description">יתרה לתשלום, %1$s</string> + <string name="checkout_charge_with_taler">תשלום באמצעות Taler</string> + <string name="checkout_charge_remaining_taler">תשלום היתרה באמצעות Taler</string> + <string name="checkout_order_total_heading">סכום ההזמנה</string> + <string name="checkout_payments_heading">תשלומים</string> + <string name="checkout_payments_added">נוספו: %1$d</string> + <string name="checkout_payments_empty_inline">תשלומים שיתווספו יופיעו כאן.</string> + <string name="checkout_about_action">על תהליך התשלום הזה</string> + <string name="checkout_fully_paid">שולם במלואו</string> + <string name="checkout_scan_to_pay_hint">יש לבקש מהלקוח לסרוק את קוד ה־QR במסך הבא כדי לשלם.</string> + <string name="checkout_scan_qr_prompt">יש לסרוק את קוד ה־QR באמצעות הארנק הנייד.</string> + <string name="checkout_payment_detail_order">הזמנה</string> + <string name="checkout_payment_detail_summary">סיכום</string> + <string name="checkout_waiting_customer_payment">בהמתנה לתשלום…</string> + <string name="checkout_remaining_heading">יתרה</string> + <string name="checkout_order_reference">הזמנה מס׳ %1$d</string> + <string name="checkout_choose_payment_method">בחירת אמצעי תשלום</string> + <string name="checkout_payment_method_helper">אפשר להוסיף מזומן או תשלום בכרטיס, או לשלם את היתרה באמצעות Taler.</string> + <string name="checkout_complete_order_hint">משלים את ההזמנה הזאת</string> + <string name="checkout_remaining_amount">יתרה: %1$s</string> + <string name="checkout_remaining_to_pay">נותר לתשלום %1$s</string> + <string name="checkout_remaining_after_payment">יתרה לאחר התשלום: %1$s</string> + <string name="checkout_change_to_return_amount">עודף להחזרה: %1$s</string> + <string name="checkout_cash_change_explanation">מזומן מעבר ליתרה מוצג כעודף להחזרה.</string> + <string name="checkout_record_cash_action">רישום %1$s במזומן</string> + <string name="checkout_charge_card_action">חיוב %1$s בכרטיס</string> + <string name="checkout_cash_received_heading">מזומן שהתקבל</string> + <string name="checkout_card_payment_heading">תשלום בכרטיס</string> + <string name="checkout_card_amount_limit_hint">סכום החיוב בכרטיס לא יכול לעלות על היתרה לתשלום.</string> + + <string name="checkout_creation_pending">ייתכן שההזמנה כבר קיימת. יש לנסות שוב את שלב Taler כדי לשחזר אותה לפני שינוי התשלומים.</string> + <string name="checkout_pending_charge">יש לברר את התשלום הממתין בכרטיס בסך %1$s מול %2$s. לפני המשך יש לבדוק את המצב הסופי ברישומי הספק באמצעות האסמכתה %3$s.</string> + <string name="checkout_pending_paid">התשלום אושר ברישומי הספק</string> + <string name="checkout_pending_not_paid">הספק אישר שלא בוצע חיוב</string> + <string name="checkout_pending_confirm_title">אישור תוצאת התשלום</string> + <string name="checkout_pending_confirm_paid">יש לאשר שהתשלום המסוים הזה הצליח. יש להזין את אסמכתת העסקה של הספק.</string> + <string name="checkout_pending_confirm_unpaid">יש להמשיך רק אם הספק מאשר שהתשלום נכשל או בוטל ושלא ניתן יהיה לחייבו מאוחר יותר. תוצאה ממתינה או חסרה אינה אישור.</string> + <string name="checkout_pending_transaction">אסמכתת העסקה של הספק</string> + + <string name="history_total_paid_remaining">סה״כ: %1$s שולם: %2$s יתרה: %3$s</string> + <string name="history_checkout_total_paid_remaining">סה״כ: %1$s שולם: %2$s יתרה: %3$s</string> + <string name="order_details_delete_action">מחיקה</string> + <string name="settings_manage_connection_action">הגדרות בית העסק</string> + <string name="settings_logout_action">יציאה מהחשבון</string> + <string name="payment_options_manage_action">ניהול אפשרויות תשלום</string> + + <string name="order_actions">פעולות</string> + <string name="order_current">ההזמנה הנוכחית</string> + <string name="amount_entry_hint">יש להזין את סכום ההזמנה</string> + + <string name="taler_ble_unsupported">Bluetooth אינו זמין במכשיר הזה.</string> + <string name="taler_ble_permission">יש לאפשר גישה למכשירים בקרבת מקום כדי לחבר את הצג.</string> + <string name="taler_ble_off">יש להפעיל Bluetooth כדי לחבר את הצג.</string> + <string name="taler_ble_location">גרסה זו של Android מחייבת להפעיל מיקום לצורך חיפוש מכשירי Bluetooth.</string> + <string name="taler_ble_scanning">מתבצע חיפוש צגי Taler…</string> + <string name="taler_ble_connecting">מתבצע חיבור לצג…</string> + <string name="taler_ble_sending">הצג מתעדכן…</string> + <string name="taler_ble_too_long">הקישור הזה ארוך מדי לצג. יש להשתמש בקוד ה־QR במכשיר הזה.</string> + <string name="taler_ble_failed">לא ניתן לעדכן את הצג. יש לבדוק שהוא פועל ונמצא בקרבת מקום.</string> + <string name="taler_ble_allow">מתן גישה למכשיר</string> + <string name="taler_ble_open_settings">פתיחת הגדרות המכשיר</string> + <string name="taler_ble_scan">חיפוש צג Taler</string> + <string name="taler_ble_test">בדיקת הצג</string> + <string name="taler_ble_reset_limitation">ייתכן שהצג ישמור את קוד ה־QR הקודם לאחר תשלום או בדיקה. יש להפעיל את הצג מחדש כדי לנקות אותו עד שהקושחה תתמוך באיפוס.</string> + <string name="taler_ble_reconnect">חיבור מחדש</string> + <string name="taler_ble_pairing">מתבצע צימוד… יש להזין את הקוד המוצג בצג ה־LCD בבקשת הצימוד של Android.</string> + <string name="taler_ble_pairing_required">הצימוד לא הושלם או שהצימוד השמור כבר אינו פועל.</string> + <string name="taler_ble_forget_explanation">להפסיק להשתמש בצג הזה בקופה הזאת? הבחירה השמורה תוסר מהיישומון. כדי להסיר גם את צימוד ה־Bluetooth, יש לשכוח את הצג בהגדרות ה־Bluetooth של Android ולבחור בשכחת הקופה הזאת בצג ה־LCD.</string> + + <string name="taler_devices_manage">ניהול צגים</string> + <string name="taler_devices_title">צגי Taler</string> + <string name="taler_devices_empty">עדיין לא נוספו צגים.</string> + <string name="taler_devices_add">הוספת מכשיר</string> + <string name="taler_devices_check_usb">בדיקת חיבור USB</string> + <string name="taler_devices_remove">הסרת צג</string> + <string name="taler_devices_replace">החלפת צג</string> + <string name="taler_devices_replace_hint">קופה זו משתמשת בצג Bluetooth אחד בכל פעם. להחליף את הצג הנוכחי?</string> + <string name="taler_devices_access">מתן אפשרות לחיבור Bluetooth</string> + <string name="taler_devices_open_lcd">פתיחת הגדרות הצג</string> + <string name="taler_devices_open_lcd_hint">בצג, יש להחליק שמאלה ולהקיש על גלגל השיניים של ההגדרות.</string> + <string name="taler_devices_pair_lcd">התחלת צימוד בצג</string> + <string name="taler_devices_pair_lcd_hint">בצג, יש לבחור Bluetooth ← צימוד קופה.</string> + <string name="taler_devices_choose_display">בחירת הצג שלך</string> + <string name="taler_devices_connect">חיבור הצג</string> + <string name="taler_devices_connected">הצג מחובר</string> + <string name="taler_devices_connected_hint">הצג שלך מוכן לתשלומים.</string> + <string name="taler_devices_bluetooth">צג Bluetooth</string> + <string name="taler_devices_usb">מכשיר USB</string> + <string name="taler_devices_scan_finished">יש לבחור את הצג למטה, או לחפש שוב אם הוא אינו מופיע.</string> + <string name="taler_devices_checking">החיבור נבדק…</string> + <string name="taler_devices_done">סיום</string> + <string name="taler_devices_next">הבא</string> + <string name="taler_devices_search_again">חיפוש חוזר</string> + <string name="taler_devices_pair_again">צימוד מחדש</string> + <string name="taler_devices_repair_android">יש לשכוח את הצג הזה בהגדרות ה־Bluetooth של Android ואז לחזור לכאן.</string> + <string name="taler_devices_repair_lcd">בצג, יש לפתוח את Bluetooth, לשכוח את הקופה הישנה ואז לבחור בצימוד קופה.</string> + <string name="taler_devices_test_hint">קוד QR לבדיקה יוצג במשך חמש שניות ואז יימחק. לא נוצר תשלום.</string> + + <string name="taler_usb_not_attached">יש לחבר מכשיר Taler נתמך באמצעות USB.</string> + <string name="taler_usb_device">מכשיר USB של Taler מס׳ %1$d</string> + <string name="taler_usb_permission_required">נדרשת גישת USB.</string> + <string name="taler_usb_allow_device">מתן גישת USB מכשיר %1$d</string> + <string name="taler_usb_failed">לא ניתן לעדכן את המכשיר. יש לחבר אותו מחדש או לנסות שוב.</string> + <string name="taler_usb_refresh">בדיקת החיבור</string> + <string name="checkout_order_claimed">ההזמנה נתבעה על ידי הארנק.</string> + <string name="checkout_complete_in_wallet">יש להשלים את התשלום בארנק</string> + <string name="checkout_payment_possible_until">ניתן לשלם עד %1$s</string> + <string name="checkout_payment_detail_merchant">בית עסק</string> + <string name="config_local_network_title">חיבור לסוחר ברשת המקומית</string> + <string name="config_local_network_explanation">שרת הסוחר בכתובת %1$s נמצא ברשת מקומית. יש לאפשר גישה למכשירים בקרבת מקום כדי ש־Taler POS יוכל להתחבר ולעבד הזמנות.</string> + <string name="config_local_network_settings">הגישה לרשת המקומית חסומה. יש לפתוח את הגדרות האפליקציה, לבחור הרשאות → מכשירים בקרבת מקום ולאפשר גישה. לאחר מכן יש לחזור לכאן כדי להתחבר אל %1$s.</string> + <string name="config_local_network_allow">אישור גישה</string> + <string name="config_local_network_required">יש לאפשר גישה לרשת המקומית כדי להתחבר לסוחר זה.</string> + <string name="config_camera_title">גישה למצלמה להגדרה באמצעות QR</string> + <string name="config_camera_explanation">המצלמה סורקת את קוד ה-QR להגדרה מתוך Merchant Backoffice כדי למלא את הגדרות החשבון. אפשר גם להזין את הפרטים ידנית.</string> + <string name="config_camera_settings_instructions">הגישה למצלמה חסומה. פתחו את הגדרות היישום, בחרו הרשאות → מצלמה ואפשרו גישה בזמן השימוש ביישום. לאחר מכן חזרו לכאן לסריקה.</string> + <string name="config_camera_allow">אפשרו מצלמה</string> + <string name="config_camera_open_settings">פתיחת הגדרות</string> + <string name="config_camera_manual">הגדרה ידנית</string> + <string name="config_connecting">מתחבר…</string> + <string name="password_show">הצגת סיסמה</string> + <string name="password_hide">הסתרת סיסמה</string> + <string name="navigation_back">חזרה</string> + <string name="navigation_menu_open">פתיחת תפריט הניווט</string> + <string name="order_empty_inventory">אין מוצרים להצגה. אפשר להזמין לפי סכום או להוסיף מוצר מותאם אישית.</string> </resources> diff --git a/merchant-terminal/src/main/res/values-ru/strings.xml b/merchant-terminal/src/main/res/values-ru/strings.xml @@ -63,9 +63,9 @@ <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_no_instance">Нет экземпляра?</string> + <string name="config_no_instance">Нет учётной записи?</string> <string name="config_instance_tutorial_description">1. Ознакомьтесь с инструкциями по настройке:</string> - <string name="config_instance_demo_description">2. Вы можете попробовать приложение с общедоступным демонстрационным экземпляром. Учтите, что этот экземпляр используется совместно—любой пользователь может изменить его данные в любое время. Используйте его только для знакомства с приложением и его возможностями.</string> + <string name="config_instance_demo_description">2. Вы можете попробовать приложение с общедоступной демонстрационной учётной записью. Учтите, что эта учётная запись используется совместно - любой пользователь может изменить её данные в любое время. Используйте её только для знакомства с приложением и его возможностями.</string> <string name="config_fill_demo_credentials">Заполнить демо-данные</string> <string name="config_instance_review_description">3. Если вы проверяете это приложение, скорее всего, у вас уже есть доступ к контролируемому тестовому пользователю. Используйте кнопку ниже, чтобы заполнить URL портала продавца.</string> <string name="config_fill_review_url">Заполнить URL для проверки</string> @@ -157,4 +157,304 @@ <string name="insecure_payment_dialog_try_https">Попробовать исправленную ссылку</string> <string name="insecure_payment_dialog_continue_as_is">Продолжить как есть</string> <string name="insecure_payment_dialog_cancel_order">Отменить заказ</string> + <string name="payment_device_nfc_available">доступно</string> + <string name="settings_about_title">О приложении</string> + <string name="refund_reason_wrong_order">Ошибочный заказ</string> + <string name="payment_options_title">Способы оплаты</string> + <string name="payment_options_button">Способы оплаты</string> + <string name="payment_options_description">Taler доступен всегда. Включите дополнительные способы оплаты или откройте способ для настройки.</string> + <string name="payment_options_enabled_count">Включено: %1$d</string> + <string name="payment_option_open">Открыть настройки %1$s</string> + <string name="payment_option_settings_title">Настройки</string> + <string name="payment_option_unknown">Этот способ оплаты недоступен в данной сборке.</string> + <string name="payment_option_disabled">Выключено</string> + <string name="payment_option_ready">Готово</string> + <string name="payment_option_taler">GNU Taler</string> + <string name="payment_option_taler_description">Клиенты платят из кошелька Taler, сканируя QR-код или прикладывая телефон.</string> + <string name="payment_option_cash">Наличные</string> + <string name="payment_option_cash_description">Клиенты платят наличными на кассе. Заказ оплачивается вне приложения.</string> + <string name="terminal_not_configured">Настройка недоступна в этой сборке. Обратитесь к администратору приложения.</string> + <string name="terminal_sign_in_required">Войдите в аккаунт для сопряжения картридера.</string> + <string name="terminal_permission_required">Необходимо разрешение на доступ к устройствам поблизости. Откройте настройку картридера для продолжения.</string> + <string name="terminal_not_paired">Нет сопряжённого картридера</string> + <string name="terminal_disconnected">Картридер отключён</string> + <string name="terminal_connected">Картридер подключён</string> + <string name="terminal_unavailable">Не удалось проверить соединение с картридером</string> + <string name="terminal_pair">Сопрячь картридер</string> + <string name="terminal_manage">Управление картридером</string> + <string name="terminal_account">Аккаунт: %1$s</string> + <string name="terminal_serial">Серийный номер: %1$s</string> + <string name="terminal_indicator_description">Платёжные устройства: %1$s</string> + <string name="terminal_close">Закрыть</string> + <string name="terminal_action_failed">Не удалось завершить настройку терминала. Проверьте Bluetooth, разрешения и интернет-соединение, затем повторите попытку.</string> + <string name="terminal_sumup">SumUp</string> + <string name="terminal_sumup_description">Клиенты платят картой через картридер SumUp, сопряжённый с этим устройством.</string> + <string name="terminal_sumup_sign_in">Войти в SumUp</string> + <string name="terminal_sumup_sign_out">Выйти из SumUp</string> + <string name="terminal_sumup_save_key">Сохранить партнёрский ключ</string> + <string name="terminal_sumup_advanced">Дополнительно</string> + <string name="terminal_sumup_advanced_help">Партнёрский ключ идентифицирует это приложение в SumUp, а не вашу организацию. Обычно менять его не нужно. Замените его только при наличии собственной регистрации партнёра SumUp.</string> + <string name="terminal_sumup_key_builtin">Используется встроенный в приложение партнёрский ключ.</string> + <string name="terminal_sumup_key_custom">Используется собственный партнёрский ключ.</string> + <string name="terminal_sumup_key_hint">Новый партнёрский ключ</string> + <string name="terminal_sumup_key_show">Показать ключ</string> + <string name="terminal_sumup_key_hide">Скрыть ключ</string> + <string name="terminal_sumup_key_warning">Сохранение другого ключа завершит сеанс SumUp.</string> + <string name="terminal_sumup_reset_key">Использовать встроенный ключ</string> + <string name="terminal_sumup_session_help">Сеанс SumUp длится около часа. С собственным приложением OAuth сеанс обновляется автоматически, поэтому сотрудникам не нужно входить снова.</string> + <string name="terminal_sumup_session_no_client">Приложение OAuth не настроено. Добавьте его в разделе «Дополнительно», чтобы поддерживать сеанс.</string> + <string name="terminal_sumup_session_not_authorized">Ещё не авторизовано. Авторизуйте устройство один раз, чтобы сохранять вход в аккаунт.</string> + <string name="terminal_sumup_session_expires">Токен доступа истекает через %1$s</string> + <string name="terminal_sumup_session_refreshes">Количество обновлений: %1$d, последнее в %2$s</string> + <string name="terminal_sumup_session_error">Последняя ошибка обновления: %1$s</string> + <string name="terminal_sumup_authorize">Авторизовать устройство</string> + <string name="terminal_sumup_reauthorize">Авторизовать снова</string> + <string name="terminal_sumup_refresh_now">Обновить токен сейчас</string> + <string name="terminal_sumup_revoke">Забыть авторизацию</string> + <string name="terminal_sumup_oauth_help">Зарегистрируйте приложение OAuth в панели SumUp с адресом перенаправления %1$s, затем вставьте сюда идентификатор и секрет клиента. Они останутся на этом устройстве.</string> + <string name="terminal_sumup_oauth_set">Приложение OAuth настроено.</string> + <string name="terminal_sumup_oauth_unset">Приложение OAuth не настроено.</string> + <string name="terminal_sumup_client_id">Идентификатор клиента OAuth</string> + <string name="terminal_sumup_client_secret">Секрет клиента OAuth</string> + <string name="terminal_sumup_save_client">Сохранить приложение OAuth</string> + <string name="payment_device_connected">%1$s подключено</string> + <string name="payment_device_disconnected">%1$s не подключено</string> + <string name="payment_device_nfc">NFC</string> + <string name="payment_device_status_connected">Подключено</string> + <string name="payment_device_status_disconnected">Не подключено</string> + <string name="payment_device_battery">Батарея %1$d%%</string> + <string name="checkout_title">Оплата</string> + <string name="checkout_total">Итого</string> + <string name="checkout_payments_button">Записанные платежи (%1$d)</string> + <string name="checkout_payments_title">Записанные платежи</string> + <string name="checkout_no_taler_hint">GNU Taler выключен, поэтому всю сумму необходимо оплатить другими способами.</string> + <string name="checkout_order_created_hint">Заказ создан, поэтому добавить наличные или оплату картой больше нельзя. Остаток оплачивается через GNU Taler.</string> + <string name="checkout_cancel">Отменить платёж</string> + <string name="checkout_change_due_title">Выдать сдачу</string> + <string name="checkout_change_due">Верните клиенту %1$s.</string> + <string name="checkout_card_title">Оплата картой</string> + <string name="checkout_card_amount">Сумма списания</string> + <string name="checkout_card_too_much">Больше оставшейся суммы</string> + <string name="checkout_card_not_ready">Картридер не готов. Проверьте, что вы вошли в аккаунт и устройство подключено.</string> + <string name="checkout_card_recorded">Оплата картой на сумму %1$s записана</string> + <string name="checkout_card_failed">Оплата картой не прошла.</string> + <string name="checkout_card_unknown_title">Оплата картой не подтверждена</string> + <string name="checkout_card_unknown">Не удалось подтвердить, было ли списано %1$s. Перед повторным списанием проверьте транзакцию в приложении поставщика платёжных услуг. Ничего не записано.</string> + <string name="checkout_record_failed">Не удалось сохранить платёж. Не выдавайте сдачу, пока платёж не будет записан.</string> + <string name="checkout_none">Этот платёж больше не открыт.</string> + <string name="checkout_payment_line_change">Получено %1$s, сдача %2$s</string> + <string name="checkout_payment_line_reference">Номер %1$s</string> + <string name="checkout_history_continue">Продолжить</string> + <string name="checkout_history_dismiss">Отметить как урегулированное</string> + <string name="payment_options_mixed_unsupported">Для оплаты частями требуется версия 42 или новее протокола сервера продавца. Этот сервер сообщает %1$s.</string> + <string name="checkout_total_amount">Итого: %1$s</string> + <string name="checkout_paid_amount">Оплачено: %1$s</string> + <string name="checkout_about">О платеже</string> + <string name="checkout_about_message">Уже получено %1$s. При отмене необходимо вернуть наличные и оформить возврат карточных платежей в приложении поставщика платёжных услуг.</string> + <string name="checkout_cancel_anyway">Всё равно отменить платёж</string> + <string name="checkout_settled_hint">Всё оплачено. Завершите заказ, чтобы закончить операцию.</string> + <string name="checkout_back_to_options">Назад к способам оплаты</string> + <string name="checkout_revert_failed">Не удалось отозвать заказ.</string> + <string name="payment_option_activation_title">Перед включением</string> + <string name="payment_option_activation_confirm">Включить</string> + <string name="terminal_sumup_activation_notice">С текущим программным обеспечением SumUp приложение может списывать средства с карт, но не возвращать деньги. Возврат необходимо выдать наличными, выполнить другим способом оплаты или оформить в приложении SumUp.\n\nУточните у бухгалтера или юриста, соответствует ли это требованиям к обработке возвратов в вашей организации.</string> + <string name="history_status_partially_paid">Частично оплачено</string> + <string name="history_status_awaiting_collection">Ожидает получения</string> + <string name="order_details_title">Сведения о заказе</string> + <string name="order_details_loading">Загрузка сведений о заказе…</string> + <string name="order_details_unavailable">Сведения недоступны</string> + <string name="order_details_load_error">Не удалось загрузить заказ. Проверьте соединение и повторите попытку.</string> + <string name="order_details_retry">Повторить попытку</string> + <string name="order_details_fulfillment_link">Ссылка на исполнение заказа</string> + <string name="order_details_not_provided">Продавец не предоставил дополнительных сведений об этом заказе.</string> + <string name="order_details_order_id">Номер заказа</string> + <string name="order_details_refund_total">Возвращённая сумма</string> + <string name="order_details_summary">Сводка заказа</string> + <string name="order_details_products">Товары</string> + <string name="order_details_no_products">Список отдельных товаров не предоставлен.</string> + <string name="order_details_net_price">%1$s (нетто)</string> + <string name="order_details_payments">Разбивка платежей</string> + <string name="order_details_balance">Осталось оплатить</string> + <string name="order_details_delivery">Доставка / Самовывоз</string> + <string name="order_details_delivery_location">Место доставки</string> + <string name="order_details_delivery_date">Дата доставки</string> + <string name="order_details_pickup_time">Крайнее время получения</string> + <string name="order_details_reorder">Заказать снова</string> + <string name="order_details_reorder_hint">Поделитесь этой ссылкой, чтобы снова заказать те же товары.</string> + <string name="order_details_reorder_link">Ссылка для повторного заказа</string> + <string name="order_details_additional">Дополнительная информация</string> + <string name="order_details_pay_deadline">Срок оплаты</string> + <string name="order_details_refund_deadline">Срок возврата</string> + <string name="order_details_merchant">Сведения о продавце</string> + <string name="order_details_jurisdiction">Юрисдикция</string> + <string name="order_details_email">Электронная почта</string> + <string name="order_details_website">Сайт</string> + <string name="order_details_open_link">Открыть ссылку</string> + <string name="order_details_copied">Скопировано в буфер обмена</string> + <string name="order_details_copy">Копировать %1$s</string> + <string name="order_details_open_failed">Нет приложения для открытия этой ссылки.</string> + <string name="refund_choose_method">Способ возврата</string> + <string name="refund_available">Доступно для возврата</string> + <string name="refund_unavailable">Недоступно</string> + <string name="refund_unavailable_hint">Возврат этим способом здесь невозможен. Для внешнего возврата необходимы оплаченный заказ и запись его оплаты на этом устройстве. Возврат через Taler должен оставаться доступным.</string> + <string name="refund_taler_button">Вернуть через Taler</string> + <string name="refund_taler_hint">Подтвердите возврат, затем дайте клиенту получить его в кошелёк Taler.</string> + <string name="refund_cash_hint">Верните клиенту наличные, затем запишите возвращённую сумму.</string> + <string name="refund_card_hint">Верните исходный карточный платёж в приложении поставщика, затем запишите здесь возвращённую сумму. Этот экран не отправляет деньги на карту.</string> + <string name="refund_cash_confirm">Подтвердите, что эти наличные возвращены клиенту.</string> + <string name="refund_card_confirm">Подтвердите, что поставщик карточных платежей выполнил этот возврат. Запись здесь не оформляет повторный возврат.</string> + <string name="refund_returned_confirm">Деньги возвращены - записать возврат</string> + <string name="refund_record_external">Записать возврат</string> + <string name="refund_recorded">Возврат записан</string> + <string name="refund_recorded_message">Возвращённая сумма записана в заказе.</string> + <string name="refund_recorded_local">Сохранено на этом устройстве, но заказ обновить не удалось. Не возвращайте эти деньги повторно. Сообщение сервера: %1$s</string> + <string name="order_delete_final_title">Удалить этот заказ навсегда?</string> + <string name="order_delete_warning">Удаление убирает этот неоплаченный заказ. Оно не возвращает наличные и не оформляет возврат на карту или через Taler. Урегулируйте все платежи с клиентом перед продолжением.</string> + <string name="order_delete_final_warning">Это действие нельзя отменить. Подтвердите, что все платежи урегулированы и вы хотите навсегда удалить этот заказ.</string> + <string name="order_delete_continue">Продолжить</string> + <string name="product_image_open">Посмотреть изображение: %1$s</string> + <string name="product_image_close">Закрыть изображение</string> + <string name="payment_pay_with">Оплатить через</string> + + <string name="checkout_complete_payment">Завершить оплату</string> + <string name="checkout_collect_receipt_hint">Клиент может отсканировать следующий экран, чтобы получить квитанцию в кошелёк Taler.</string> + <string name="checkout_collect_receipt_title">Получить квитанцию</string> + <string name="checkout_receipt_paid_in_full">Оплачено полностью</string> + <string name="checkout_receipt_scan_hint">Отсканируйте кошельком Taler, чтобы получить квитанцию.</string> + <string name="checkout_collect_receipt_on_device">Получить квитанцию на этом устройстве</string> + <string name="checkout_payment_claimed_title">Оплата выполняется</string> + <string name="checkout_customer_opened_payment">Клиент открыл этот платёж в своём кошельке.</string> + <string name="checkout_payment_claimed_hint">Ожидание подтверждения клиента</string> + <string name="checkout_tap_to_enter_amount">Нажмите, чтобы ввести сумму</string> + <string name="checkout_cash_entry_instruction">Введите полученную сумму</string> + <string name="checkout_card_entry_instruction">Введите сумму списания</string> + <string name="checkout_fraction_remaining">Остаток</string> + <string name="checkout_fraction_half_description">Половина остатка, %1$s</string> + <string name="checkout_fraction_third_description">Треть остатка, %1$s</string> + <string name="checkout_fraction_quarter_description">Четверть остатка, %1$s</string> + <string name="checkout_fraction_remaining_description">Остаток, %1$s</string> + <string name="checkout_charge_with_taler">Оплатить через Taler</string> + <string name="checkout_charge_remaining_taler">Оплатить остаток через Taler</string> + <string name="checkout_order_total_heading">Сумма заказа</string> + <string name="checkout_payments_heading">Платежи</string> + <string name="checkout_payments_added">Добавлено: %1$d</string> + <string name="checkout_payments_empty_inline">Здесь появятся добавленные платежи.</string> + <string name="checkout_about_action">Об этой оплате</string> + <string name="checkout_fully_paid">Полностью оплачено</string> + <string name="checkout_scan_to_pay_hint">Попросите клиента отсканировать QR-код на следующем экране для оплаты.</string> + <string name="checkout_scan_qr_prompt">Отсканируйте QR-код мобильным кошельком.</string> + <string name="checkout_payment_detail_order">Заказ</string> + <string name="checkout_payment_detail_summary">Сводка</string> + <string name="checkout_waiting_customer_payment">Ожидание оплаты…</string> + <string name="checkout_remaining_heading">Остаток</string> + <string name="checkout_order_reference">Заказ № %1$d</string> + <string name="checkout_choose_payment_method">Выбрать способ оплаты</string> + <string name="checkout_payment_method_helper">Добавьте наличные или оплату картой либо оплатите остаток через Taler.</string> + <string name="checkout_complete_order_hint">Завершает этот заказ</string> + <string name="checkout_remaining_amount">Остаток: %1$s</string> + <string name="checkout_remaining_to_pay">Осталось оплатить %1$s</string> + <string name="checkout_remaining_after_payment">Остаток после оплаты: %1$s</string> + <string name="checkout_change_to_return_amount">Сдача к возврату: %1$s</string> + <string name="checkout_cash_change_explanation">Наличные сверх остатка отображаются как сдача к возврату.</string> + <string name="checkout_record_cash_action">Записать %1$s наличными</string> + <string name="checkout_charge_card_action">Списать с карты %1$s</string> + <string name="checkout_cash_received_heading">Получено наличными</string> + <string name="checkout_card_payment_heading">Оплата картой</string> + <string name="checkout_card_amount_limit_hint">Сумма оплаты картой не может превышать остаток.</string> + + <string name="checkout_creation_pending">Возможно, заказ уже существует. Повторите шаг Taler, чтобы восстановить его, прежде чем изменять платежи.</string> + <string name="checkout_pending_charge">Выясните результат ожидающего карточного платежа на %1$s у %2$s. Перед продолжением проверьте окончательный статус в записях поставщика по номеру %3$s.</string> + <string name="checkout_pending_paid">Платёж подтверждён в записях поставщика</string> + <string name="checkout_pending_not_paid">Поставщик подтвердил отсутствие списания</string> + <string name="checkout_pending_confirm_title">Подтвердить результат платежа</string> + <string name="checkout_pending_confirm_paid">Подтвердите, что именно этот платёж прошёл успешно. Введите номер транзакции, предоставленный поставщиком.</string> + <string name="checkout_pending_confirm_unpaid">Продолжайте только в том случае, если поставщик подтвердил, что платёж не прошёл или отменён и не может быть списан позднее. Ожидающий или отсутствующий результат не является подтверждением.</string> + <string name="checkout_pending_transaction">Номер транзакции у поставщика</string> + + <string name="history_total_paid_remaining">Итого: %1$s Оплачено: %2$s Остаток: %3$s</string> + <string name="history_checkout_total_paid_remaining">Итого: %1$s Оплачено: %2$s Остаток: %3$s</string> + <string name="order_details_delete_action">Удалить</string> + <string name="settings_manage_connection_action">Настройки продавца</string> + <string name="settings_logout_action">Выйти</string> + <string name="payment_options_manage_action">Управление способами оплаты</string> + + <string name="order_actions">Действия</string> + <string name="order_current">Текущий заказ</string> + <string name="amount_entry_hint">Введите сумму заказа</string> + + <string name="taler_ble_unsupported">Bluetooth недоступен на этом устройстве.</string> + <string name="taler_ble_permission">Разрешите доступ к устройствам поблизости для подключения дисплея.</string> + <string name="taler_ble_off">Включите Bluetooth для подключения дисплея.</string> + <string name="taler_ble_location">Эта версия Android требует включить геолокацию для поиска устройств Bluetooth.</string> + <string name="taler_ble_scanning">Поиск дисплеев Taler…</string> + <string name="taler_ble_connecting">Подключение к дисплею…</string> + <string name="taler_ble_sending">Обновление дисплея…</string> + <string name="taler_ble_too_long">Эта ссылка слишком длинная для дисплея. Используйте QR-код на этом устройстве.</string> + <string name="taler_ble_failed">Не удалось обновить дисплей. Проверьте, что он включён и находится рядом.</string> + <string name="taler_ble_allow">Разрешить доступ к устройству</string> + <string name="taler_ble_open_settings">Открыть настройки устройства</string> + <string name="taler_ble_scan">Найти дисплей Taler</string> + <string name="taler_ble_test">Проверить дисплей</string> + <string name="taler_ble_reset_limitation">Дисплей может сохранять предыдущий QR-код после оплаты или теста. Перезапустите дисплей, чтобы очистить его, пока прошивка не поддерживает сброс.</string> + <string name="taler_ble_reconnect">Подключить снова</string> + <string name="taler_ble_pairing">Сопряжение… Введите код с ЖК-дисплея в запросе сопряжения Android.</string> + <string name="taler_ble_pairing_required">Сопряжение не завершено или сохранённое сопряжение больше не работает.</string> + <string name="taler_ble_forget_explanation">Прекратить использование этого дисплея на этой кассе? Сохранённый выбор будет удалён из приложения. Чтобы также удалить сопряжение Bluetooth, забудьте дисплей в настройках Bluetooth Android и выберите «Забыть эту кассу» на ЖК-дисплее.</string> + + <string name="taler_devices_manage">Управление дисплеями</string> + <string name="taler_devices_title">Дисплеи Taler</string> + <string name="taler_devices_empty">Дисплеи ещё не добавлены.</string> + <string name="taler_devices_add">Добавить устройство</string> + <string name="taler_devices_check_usb">Проверить USB-соединение</string> + <string name="taler_devices_remove">Удалить дисплей</string> + <string name="taler_devices_replace">Заменить дисплей</string> + <string name="taler_devices_replace_hint">Эта касса использует только один дисплей Bluetooth одновременно. Заменить текущий дисплей?</string> + <string name="taler_devices_access">Разрешить подключение Bluetooth</string> + <string name="taler_devices_open_lcd">Открыть настройки дисплея</string> + <string name="taler_devices_open_lcd_hint">На дисплее проведите влево и нажмите на значок шестерёнки.</string> + <string name="taler_devices_pair_lcd">Начать сопряжение на дисплее</string> + <string name="taler_devices_pair_lcd_hint">На дисплее выберите Bluetooth → Сопрячь кассу.</string> + <string name="taler_devices_choose_display">Выберите свой дисплей</string> + <string name="taler_devices_connect">Подключить дисплей</string> + <string name="taler_devices_connected">Дисплей подключён</string> + <string name="taler_devices_connected_hint">Дисплей готов к платежам.</string> + <string name="taler_devices_bluetooth">Дисплей Bluetooth</string> + <string name="taler_devices_usb">USB-устройство</string> + <string name="taler_devices_scan_finished">Выберите свой дисплей ниже или повторите поиск, если его нет.</string> + <string name="taler_devices_checking">Проверка соединения…</string> + <string name="taler_devices_done">Готово</string> + <string name="taler_devices_next">Далее</string> + <string name="taler_devices_search_again">Искать снова</string> + <string name="taler_devices_pair_again">Сопрячь снова</string> + <string name="taler_devices_repair_android">Забудьте этот дисплей в настройках Bluetooth Android, затем вернитесь сюда.</string> + <string name="taler_devices_repair_lcd">На дисплее откройте Bluetooth, забудьте старую кассу и выберите «Сопрячь кассу».</string> + <string name="taler_devices_test_hint">Тестовый QR-код показывается пять секунд, затем удаляется. Платёж не создаётся.</string> + + <string name="taler_usb_not_attached">Подключите поддерживаемое устройство Taler по USB.</string> + <string name="taler_usb_device">USB-устройство Taler %1$d</string> + <string name="taler_usb_permission_required">Необходим доступ USB.</string> + <string name="taler_usb_allow_device">Разрешить доступ USB Устройство %1$d</string> + <string name="taler_usb_failed">Не удалось обновить устройство. Подключите его заново или повторите попытку.</string> + <string name="taler_usb_refresh">Проверить соединение</string> + <string name="checkout_order_claimed">Заказ принят кошельком.</string> + <string name="checkout_complete_in_wallet">Завершите оплату в кошельке</string> + <string name="checkout_payment_possible_until">Оплата возможна до %1$s</string> + <string name="checkout_payment_detail_merchant">Продавец</string> + <string name="config_local_network_title">Подключение к локальному продавцу</string> + <string name="config_local_network_explanation">Сервер продавца %1$s находится в локальной сети. Разрешите доступ к устройствам поблизости, чтобы Taler POS мог подключаться к нему и обрабатывать заказы.</string> + <string name="config_local_network_settings">Доступ к локальной сети заблокирован. Откройте настройки приложения, выберите Разрешения → Устройства поблизости и разрешите доступ. Затем вернитесь сюда для подключения к %1$s.</string> + <string name="config_local_network_allow">Разрешить доступ</string> + <string name="config_local_network_required">Разрешите доступ к локальной сети для подключения к этому продавцу.</string> + <string name="config_camera_title">Доступ к камере для настройки по QR-коду</string> + <string name="config_camera_explanation">Камера сканирует QR-код настройки из Merchant Backoffice, чтобы заполнить настройки учётной записи. Эти данные также можно ввести вручную.</string> + <string name="config_camera_settings_instructions">Доступ к камере заблокирован. Откройте настройки приложения, выберите Разрешения → Камера и разрешите доступ во время использования приложения. Затем вернитесь сюда для сканирования.</string> + <string name="config_camera_allow">Разрешить камеру</string> + <string name="config_camera_open_settings">Открыть настройки</string> + <string name="config_camera_manual">Настроить вручную</string> + <string name="config_connecting">Подключение…</string> + <string name="password_show">Показать пароль</string> + <string name="password_hide">Скрыть пароль</string> + <string name="navigation_back">Назад</string> + <string name="navigation_menu_open">Открыть меню навигации</string> + <string name="order_empty_inventory">Нет товаров для отображения. Создайте заказ по сумме или добавьте свой товар.</string> </resources> diff --git a/merchant-terminal/src/main/res/values-sv/strings.xml b/merchant-terminal/src/main/res/values-sv/strings.xml @@ -72,9 +72,9 @@ <string name="config_error_currency">Fel: Produkten %1$s har valutan %2$s, men %3$s förväntas</string> <string name="config_fetching_label">Hämtar konfiguration</string> <string name="config_docs">Se <a href="https://docs.taler.net/taler-merchant-pos-terminal.html#apis-and-data-formats">dokumentationen</a> för konfigurationsformat.</string> - <string name="config_no_instance">Ingen instans?</string> + <string name="config_no_instance">Inget konto?</string> <string name="config_instance_tutorial_description">1. Läs installationsguiderna på:</string> - <string name="config_instance_demo_description">2. Du kan prova appen med den offentliga demoinstansen. Observera att instansen delas—vem som helst kan ändra dess data när som helst. Använd den endast för att utforska appen och dess funktioner.</string> + <string name="config_instance_demo_description">2. Du kan prova appen med det offentliga demokontot. Observera att kontot delas - vem som helst kan ändra dess data när som helst. Använd det endast för att utforska appen och dess funktioner.</string> <string name="config_fill_demo_credentials">Fyll i demouppgifter</string> <string name="config_instance_review_description">3. Om du granskar appen har du troligen redan tillgång till en kontrollerad testanvändare. Använd knappen nedan för att fylla i handlarportalens URL.</string> <string name="config_fill_review_url">Fyll i gransknings-URL</string> @@ -157,4 +157,304 @@ <string name="insecure_payment_dialog_try_https">Prova korrigerad länk</string> <string name="insecure_payment_dialog_continue_as_is">Fortsätt som den är</string> <string name="insecure_payment_dialog_cancel_order">Avbryt beställning</string> + <string name="payment_device_nfc_available">tillgänglig</string> + <string name="settings_about_title">Om</string> + <string name="refund_reason_wrong_order">Fel beställning</string> + <string name="payment_options_title">Betalningsalternativ</string> + <string name="payment_options_button">Betalningsalternativ</string> + <string name="payment_options_description">Taler är alltid tillgängligt. Aktivera fler betalningsmetoder eller öppna ett alternativ för att konfigurera det.</string> + <string name="payment_options_enabled_count">%1$d aktiverade</string> + <string name="payment_option_open">Öppna inställningarna för %1$s</string> + <string name="payment_option_settings_title">Inställningar</string> + <string name="payment_option_unknown">Det här betalningsalternativet finns inte i denna version.</string> + <string name="payment_option_disabled">Avstängd</string> + <string name="payment_option_ready">Redo</string> + <string name="payment_option_taler">GNU Taler</string> + <string name="payment_option_taler_description">Kunderna betalar från en Taler-plånbok genom att skanna QR-koden eller hålla telefonen mot enheten.</string> + <string name="payment_option_cash">Kontanter</string> + <string name="payment_option_cash_description">Kunderna betalar kontant vid kassan. Beställningen regleras utanför appen.</string> + <string name="terminal_not_configured">Konfiguration är inte tillgänglig i denna version. Kontakta appens administratör.</string> + <string name="terminal_sign_in_required">Logga in för att parkoppla en läsare.</string> + <string name="terminal_permission_required">Behörighet för enheter i närheten krävs. Öppna läsarens inställningar för att fortsätta.</string> + <string name="terminal_not_paired">Ingen läsare parkopplad</string> + <string name="terminal_disconnected">Läsare frånkopplad</string> + <string name="terminal_connected">Läsare ansluten</string> + <string name="terminal_unavailable">Det går inte att kontrollera läsarens anslutning</string> + <string name="terminal_pair">Parkoppla läsare</string> + <string name="terminal_manage">Hantera läsare</string> + <string name="terminal_account">Konto: %1$s</string> + <string name="terminal_serial">Serienummer: %1$s</string> + <string name="terminal_indicator_description">Betalningsenheter: %1$s</string> + <string name="terminal_close">Stäng</string> + <string name="terminal_action_failed">Terminalen kunde inte konfigureras. Kontrollera Bluetooth, behörigheter och internetanslutningen och försök igen.</string> + <string name="terminal_sumup">SumUp</string> + <string name="terminal_sumup_description">Kunderna betalar med kort på en SumUp-läsare som är parkopplad med den här enheten.</string> + <string name="terminal_sumup_sign_in">Logga in på SumUp</string> + <string name="terminal_sumup_sign_out">Logga ut från SumUp</string> + <string name="terminal_sumup_save_key">Spara partnernyckel</string> + <string name="terminal_sumup_advanced">Avancerat</string> + <string name="terminal_sumup_advanced_help">Partnernyckeln identifierar appen för SumUp, inte ditt företag. Du behöver nästan aldrig ändra den. Byt den bara om du har en egen partnerregistrering hos SumUp.</string> + <string name="terminal_sumup_key_builtin">Använder partnernyckeln som är inbyggd i appen.</string> + <string name="terminal_sumup_key_custom">Använder en egen partnernyckel.</string> + <string name="terminal_sumup_key_hint">Ny partnernyckel</string> + <string name="terminal_sumup_key_show">Visa nyckel</string> + <string name="terminal_sumup_key_hide">Dölj nyckel</string> + <string name="terminal_sumup_key_warning">Om du sparar en annan nyckel loggas du ut från SumUp.</string> + <string name="terminal_sumup_reset_key">Använd den inbyggda nyckeln</string> + <string name="terminal_sumup_session_help">En SumUp-inloggning varar ungefär en timme. Med en egen OAuth-applikation förnyar appen den automatiskt, så att personalen inte behöver logga in igen.</string> + <string name="terminal_sumup_session_no_client">Ingen OAuth-applikation är konfigurerad. Lägg till en under Avancerat för att hålla sessionen aktiv.</string> + <string name="terminal_sumup_session_not_authorized">Ännu inte auktoriserad. Auktorisera en gång för att hålla enheten inloggad.</string> + <string name="terminal_sumup_session_expires">Åtkomsttoken löper ut om %1$s</string> + <string name="terminal_sumup_session_refreshes">Förnyad %1$d gånger, senast %2$s</string> + <string name="terminal_sumup_session_error">Senaste förnyelseproblemet: %1$s</string> + <string name="terminal_sumup_authorize">Auktorisera denna enhet</string> + <string name="terminal_sumup_reauthorize">Auktorisera igen</string> + <string name="terminal_sumup_refresh_now">Förnya token nu</string> + <string name="terminal_sumup_revoke">Glöm auktorisering</string> + <string name="terminal_sumup_oauth_help">Registrera en OAuth-applikation i SumUps kontrollpanel med omdirigeringsadressen %1$s och klistra sedan in dess klient-ID och hemlighet här. De stannar på denna enhet.</string> + <string name="terminal_sumup_oauth_set">En OAuth-applikation är konfigurerad.</string> + <string name="terminal_sumup_oauth_unset">Ingen OAuth-applikation är konfigurerad.</string> + <string name="terminal_sumup_client_id">OAuth-klient-ID</string> + <string name="terminal_sumup_client_secret">OAuth-klienthemlighet</string> + <string name="terminal_sumup_save_client">Spara OAuth-applikation</string> + <string name="payment_device_connected">%1$s ansluten</string> + <string name="payment_device_disconnected">%1$s inte ansluten</string> + <string name="payment_device_nfc">NFC</string> + <string name="payment_device_status_connected">Ansluten</string> + <string name="payment_device_status_disconnected">Inte ansluten</string> + <string name="payment_device_battery">Batteri %1$d%%</string> + <string name="checkout_title">Betalning</string> + <string name="checkout_total">Totalt</string> + <string name="checkout_payments_button">Registrerade betalningar (%1$d)</string> + <string name="checkout_payments_title">Registrerade betalningar</string> + <string name="checkout_no_taler_hint">GNU Taler är avstängt, så hela beloppet måste betalas med de andra alternativen.</string> + <string name="checkout_order_created_hint">Beställningen har skapats, så inga fler kontant- eller kortbetalningar kan läggas till. GNU Taler betalar resten.</string> + <string name="checkout_cancel">Avbryt betalningen</string> + <string name="checkout_change_due_title">Ge växel</string> + <string name="checkout_change_due">Lämna tillbaka %1$s till kunden.</string> + <string name="checkout_card_title">Kortbetalning</string> + <string name="checkout_card_amount">Belopp att debitera</string> + <string name="checkout_card_too_much">Mer än återstående belopp</string> + <string name="checkout_card_not_ready">Kortläsaren är inte redo. Kontrollera att du är inloggad och att läsaren är ansluten.</string> + <string name="checkout_card_recorded">Kortbetalning på %1$s registrerad</string> + <string name="checkout_card_failed">Kortbetalningen gick inte igenom.</string> + <string name="checkout_card_unknown_title">Kortbetalningen är inte bekräftad</string> + <string name="checkout_card_unknown">Det gick inte att bekräfta om %1$s debiterades. Kontrollera transaktionen i kortleverantörens app innan du debiterar igen. Inget registrerades.</string> + <string name="checkout_record_failed">Betalningen kunde inte sparas. Lämna inte ut växel förrän den har registrerats.</string> + <string name="checkout_none">Den här betalningen är inte längre öppen.</string> + <string name="checkout_payment_line_change">Mottaget %1$s, växel %2$s</string> + <string name="checkout_payment_line_reference">Referens %1$s</string> + <string name="checkout_history_continue">Fortsätt</string> + <string name="checkout_history_dismiss">Markera som reglerad</string> + <string name="payment_options_mixed_unsupported">Betalning i flera delar kräver version 42 eller senare av handlarserverns protokoll. Denna server rapporterar %1$s.</string> + <string name="checkout_total_amount">Totalt: %1$s</string> + <string name="checkout_paid_amount">Betalt: %1$s</string> + <string name="checkout_about">Om betalningen</string> + <string name="checkout_about_message">%1$s har redan tagits emot. Om du avbryter nu måste du lämna tillbaka kontanterna och återbetala eventuella kortbetalningar i kortleverantörens egen app.</string> + <string name="checkout_cancel_anyway">Avbryt betalningen ändå</string> + <string name="checkout_settled_hint">Allt är betalt. Avsluta beställningen för att slutföra den.</string> + <string name="checkout_back_to_options">Tillbaka till betalningsalternativ</string> + <string name="checkout_revert_failed">Beställningen kunde inte tas tillbaka.</string> + <string name="payment_option_activation_title">Innan du slår på detta</string> + <string name="payment_option_activation_confirm">Slå på</string> + <string name="terminal_sumup_activation_notice">Med SumUps nuvarande programvara kan appen debitera kort men inte betala tillbaka pengar. Återbetalning måste ske kontant, med en annan betalningsmetod eller i SumUps egen app.\n\nKontrollera med din redovisningskonsult eller juridiska rådgivare att detta uppfyller verksamhetens krav för hantering av returer.</string> + <string name="history_status_partially_paid">Delvis betald</string> + <string name="history_status_awaiting_collection">Väntar på hämtning</string> + <string name="order_details_title">Beställningsdetaljer</string> + <string name="order_details_loading">Laddar beställningsdetaljer…</string> + <string name="order_details_unavailable">Detaljer saknas</string> + <string name="order_details_load_error">Beställningen kunde inte laddas. Kontrollera anslutningen och försök igen.</string> + <string name="order_details_retry">Försök igen</string> + <string name="order_details_fulfillment_link">Länk för leverans av beställningen</string> + <string name="order_details_not_provided">Handlaren angav inga ytterligare detaljer för beställningen.</string> + <string name="order_details_order_id">Beställningsreferens</string> + <string name="order_details_refund_total">Återbetalt belopp</string> + <string name="order_details_summary">Beställningsöversikt</string> + <string name="order_details_products">Produkter</string> + <string name="order_details_no_products">Ingen specificerad produktlista angavs.</string> + <string name="order_details_net_price">%1$s (netto)</string> + <string name="order_details_payments">Betalningsfördelning</string> + <string name="order_details_balance">Kvar att betala</string> + <string name="order_details_delivery">Leverans / Hämtning</string> + <string name="order_details_delivery_location">Leveransplats</string> + <string name="order_details_delivery_date">Leveransdatum</string> + <string name="order_details_pickup_time">Senaste hämtningstid</string> + <string name="order_details_reorder">Beställ igen</string> + <string name="order_details_reorder_hint">Dela länken för att beställa samma produkter igen.</string> + <string name="order_details_reorder_link">Länk för ny beställning</string> + <string name="order_details_additional">Ytterligare information</string> + <string name="order_details_pay_deadline">Sista betalningstid</string> + <string name="order_details_refund_deadline">Sista återbetalningstid</string> + <string name="order_details_merchant">Handlaruppgifter</string> + <string name="order_details_jurisdiction">Jurisdiktion</string> + <string name="order_details_email">E-post</string> + <string name="order_details_website">Webbplats</string> + <string name="order_details_open_link">Öppna länk</string> + <string name="order_details_copied">Kopierat till urklipp</string> + <string name="order_details_copy">Kopiera %1$s</string> + <string name="order_details_open_failed">Ingen app kan öppna den här länken.</string> + <string name="refund_choose_method">Återbetalningsmetod</string> + <string name="refund_available">Tillgängligt för återbetalning</string> + <string name="refund_unavailable">Inte tillgänglig</string> + <string name="refund_unavailable_hint">Den här metoden kan inte återbetalas här. Externa återbetalningar kräver en betald beställning och dess betalningspost på enheten. Taler-återbetalningar måste fortfarande vara tillgängliga.</string> + <string name="refund_taler_button">Återbetala Taler</string> + <string name="refund_taler_hint">Godkänn återbetalningen och låt kunden hämta den med sin Taler-plånbok.</string> + <string name="refund_cash_hint">Lämna tillbaka kontanterna till kunden och registrera sedan beloppet.</string> + <string name="refund_card_hint">Återbetala den ursprungliga kortbetalningen i kortleverantörens app och registrera sedan det återbetalda beloppet här. Den här skärmen skickar inte pengar till kortet.</string> + <string name="refund_cash_confirm">Bekräfta att kontanterna har lämnats tillbaka till kunden.</string> + <string name="refund_card_confirm">Bekräfta att kortleverantören har slutfört återbetalningen. Att registrera den här gör ingen ytterligare återbetalning.</string> + <string name="refund_returned_confirm">Pengarna återlämnade - registrera återbetalning</string> + <string name="refund_record_external">Registrera återbetalning</string> + <string name="refund_recorded">Återbetalning registrerad</string> + <string name="refund_recorded_message">Det återbetalda beloppet har registrerats på beställningen.</string> + <string name="refund_recorded_local">Sparat på denna enhet, men beställningen kunde inte uppdateras. Lämna inte tillbaka pengarna igen. Servermeddelande: %1$s</string> + <string name="order_delete_final_title">Ta bort denna beställning permanent?</string> + <string name="order_delete_warning">Borttagning tar bort denna obetalda beställning. Den lämnar inte tillbaka kontanter och återbetalar inte kort eller Taler. Reglera eventuella betalningar med kunden innan du fortsätter.</string> + <string name="order_delete_final_warning">Detta kan inte ångras. Bekräfta att alla betalningar är reglerade och att du vill ta bort beställningen permanent.</string> + <string name="order_delete_continue">Fortsätt</string> + <string name="product_image_open">Visa bild: %1$s</string> + <string name="product_image_close">Stäng bilden</string> + <string name="payment_pay_with">Betala med</string> + + <string name="checkout_complete_payment">Slutför betalningen</string> + <string name="checkout_collect_receipt_hint">Kunden kan skanna nästa skärm för att hämta kvittot i en Taler-plånbok.</string> + <string name="checkout_collect_receipt_title">Hämta kvitto</string> + <string name="checkout_receipt_paid_in_full">Helt betald</string> + <string name="checkout_receipt_scan_hint">Skanna med en Taler-plånbok för att hämta kvittot.</string> + <string name="checkout_collect_receipt_on_device">Hämta kvittot på den här enheten</string> + <string name="checkout_payment_claimed_title">Betalning pågår</string> + <string name="checkout_customer_opened_payment">Kunden har öppnat betalningen i sin plånbok.</string> + <string name="checkout_payment_claimed_hint">Väntar på kundens bekräftelse</string> + <string name="checkout_tap_to_enter_amount">Tryck för att ange belopp</string> + <string name="checkout_cash_entry_instruction">Ange mottaget belopp</string> + <string name="checkout_card_entry_instruction">Ange beloppet att debitera</string> + <string name="checkout_fraction_remaining">Återstående</string> + <string name="checkout_fraction_half_description">Hälften av återstående saldo, %1$s</string> + <string name="checkout_fraction_third_description">En tredjedel av återstående saldo, %1$s</string> + <string name="checkout_fraction_quarter_description">En fjärdedel av återstående saldo, %1$s</string> + <string name="checkout_fraction_remaining_description">Återstående saldo, %1$s</string> + <string name="checkout_charge_with_taler">Betala med Taler</string> + <string name="checkout_charge_remaining_taler">Betala resten med Taler</string> + <string name="checkout_order_total_heading">Beställningens totalbelopp</string> + <string name="checkout_payments_heading">Betalningar</string> + <string name="checkout_payments_added">%1$d tillagda</string> + <string name="checkout_payments_empty_inline">Tillagda betalningar visas här.</string> + <string name="checkout_about_action">Om denna betalning</string> + <string name="checkout_fully_paid">Helt betald</string> + <string name="checkout_scan_to_pay_hint">Be kunden skanna QR-koden på nästa skärm för att betala.</string> + <string name="checkout_scan_qr_prompt">Skanna QR-koden med din mobila plånbok.</string> + <string name="checkout_payment_detail_order">Beställning</string> + <string name="checkout_payment_detail_summary">Sammanfattning</string> + <string name="checkout_waiting_customer_payment">Väntar på betalning…</string> + <string name="checkout_remaining_heading">Återstående</string> + <string name="checkout_order_reference">Beställning #%1$d</string> + <string name="checkout_choose_payment_method">Välj betalningsmetod</string> + <string name="checkout_payment_method_helper">Lägg till kontanter eller kort, eller betala saldot med Taler.</string> + <string name="checkout_complete_order_hint">Slutför denna beställning</string> + <string name="checkout_remaining_amount">Återstående: %1$s</string> + <string name="checkout_remaining_to_pay">Kvar att betala %1$s</string> + <string name="checkout_remaining_after_payment">Återstående efter betalningen: %1$s</string> + <string name="checkout_change_to_return_amount">Växel att lämna tillbaka: %1$s</string> + <string name="checkout_cash_change_explanation">Kontanter utöver saldot visas som växel att lämna tillbaka.</string> + <string name="checkout_record_cash_action">Registrera %1$s kontant</string> + <string name="checkout_charge_card_action">Debitera %1$s på kortet</string> + <string name="checkout_cash_received_heading">Mottagna kontanter</string> + <string name="checkout_card_payment_heading">Kortbetalning</string> + <string name="checkout_card_amount_limit_hint">Kortbeloppet får inte överstiga det återstående saldot.</string> + + <string name="checkout_creation_pending">Beställningen kanske redan finns. Försök igen med Taler-steget för att hämta den innan du ändrar betalningarna.</string> + <string name="checkout_pending_charge">Red ut den väntande kortbetalningen på %1$s med %2$s. Kontrollera slutstatus i leverantörens register med referensen %3$s innan du fortsätter.</string> + <string name="checkout_pending_paid">Betalningen bekräftad i leverantörens register</string> + <string name="checkout_pending_not_paid">Leverantören bekräftade att inget debiterades</string> + <string name="checkout_pending_confirm_title">Bekräfta betalningens resultat</string> + <string name="checkout_pending_confirm_paid">Bekräfta att just denna betalning lyckades. Ange transaktionsreferensen från leverantören.</string> + <string name="checkout_pending_confirm_unpaid">Fortsätt endast om leverantören bekräftar att betalningen misslyckades eller avbröts och inte kan slutföras senare. Ett väntande eller saknat resultat är inte en bekräftelse.</string> + <string name="checkout_pending_transaction">Leverantörens transaktionsreferens</string> + + <string name="history_total_paid_remaining">Totalt: %1$s Betalt: %2$s Återstående: %3$s</string> + <string name="history_checkout_total_paid_remaining">Totalt: %1$s Betalt: %2$s Återstående: %3$s</string> + <string name="order_details_delete_action">Ta bort</string> + <string name="settings_manage_connection_action">Handlarinställningar</string> + <string name="settings_logout_action">Logga ut</string> + <string name="payment_options_manage_action">Hantera betalningsalternativ</string> + + <string name="order_actions">Åtgärder</string> + <string name="order_current">Aktuell beställning</string> + <string name="amount_entry_hint">Ange beställningsbeloppet</string> + + <string name="taler_ble_unsupported">Bluetooth är inte tillgängligt på den här enheten.</string> + <string name="taler_ble_permission">Tillåt åtkomst till enheter i närheten för att ansluta skärmen.</string> + <string name="taler_ble_off">Slå på Bluetooth för att ansluta skärmen.</string> + <string name="taler_ble_location">Den här Android-versionen kräver att platsåtkomst är påslagen för att hitta Bluetooth-enheter.</string> + <string name="taler_ble_scanning">Söker efter Taler-skärmar…</string> + <string name="taler_ble_connecting">Ansluter till skärmen…</string> + <string name="taler_ble_sending">Uppdaterar skärmen…</string> + <string name="taler_ble_too_long">Länken är för lång för skärmen. Använd QR-koden på den här enheten.</string> + <string name="taler_ble_failed">Skärmen kunde inte uppdateras. Kontrollera att den är påslagen och i närheten.</string> + <string name="taler_ble_allow">Tillåt åtkomst till enheten</string> + <string name="taler_ble_open_settings">Öppna enhetsinställningar</string> + <string name="taler_ble_scan">Hitta Taler-skärm</string> + <string name="taler_ble_test">Testa skärmen</string> + <string name="taler_ble_reset_limitation">Skärmen kan behålla den tidigare QR-koden efter en betalning eller ett test. Starta om skärmen för att rensa den tills dess inbyggda programvara stöder återställning.</string> + <string name="taler_ble_reconnect">Anslut igen</string> + <string name="taler_ble_pairing">Parkopplar… Ange koden från LCD-skärmen i Androids parkopplingsdialog.</string> + <string name="taler_ble_pairing_required">Parkopplingen slutfördes inte eller den sparade parkopplingen fungerar inte längre.</string> + <string name="taler_ble_forget_explanation">Sluta använda denna skärm på denna kassa? Det sparade valet tas bort från appen. För att även ta bort Bluetooth-parkopplingen, glöm skärmen i Androids Bluetooth-inställningar och välj Glöm denna kassa på LCD-skärmen.</string> + + <string name="taler_devices_manage">Hantera skärmar</string> + <string name="taler_devices_title">Taler-skärmar</string> + <string name="taler_devices_empty">Inga skärmar har lagts till ännu.</string> + <string name="taler_devices_add">Lägg till enhet</string> + <string name="taler_devices_check_usb">Kontrollera USB-anslutningen</string> + <string name="taler_devices_remove">Ta bort skärm</string> + <string name="taler_devices_replace">Byt skärm</string> + <string name="taler_devices_replace_hint">Den här kassan använder en Bluetooth-skärm åt gången. Vill du byta ut den nuvarande skärmen?</string> + <string name="taler_devices_access">Tillåt Bluetooth-anslutning</string> + <string name="taler_devices_open_lcd">Öppna skärminställningar</string> + <string name="taler_devices_open_lcd_hint">Svep åt vänster på skärmen och tryck på kugghjulet för inställningar.</string> + <string name="taler_devices_pair_lcd">Starta parkoppling på skärmen</string> + <string name="taler_devices_pair_lcd_hint">Välj Bluetooth → Parkoppla en kassa på skärmen.</string> + <string name="taler_devices_choose_display">Välj din skärm</string> + <string name="taler_devices_connect">Anslut skärmen</string> + <string name="taler_devices_connected">Skärmen är ansluten</string> + <string name="taler_devices_connected_hint">Skärmen är redo för betalningar.</string> + <string name="taler_devices_bluetooth">Bluetooth-skärm</string> + <string name="taler_devices_usb">USB-enhet</string> + <string name="taler_devices_scan_finished">Välj skärmen nedan eller sök igen om den saknas.</string> + <string name="taler_devices_checking">Kontrollerar anslutningen…</string> + <string name="taler_devices_done">Klart</string> + <string name="taler_devices_next">Nästa</string> + <string name="taler_devices_search_again">Sök igen</string> + <string name="taler_devices_pair_again">Parkoppla igen</string> + <string name="taler_devices_repair_android">Glöm skärmen i Androids Bluetooth-inställningar och återvänd sedan hit.</string> + <string name="taler_devices_repair_lcd">Öppna Bluetooth på skärmen, glöm den gamla kassan och välj sedan Parkoppla en kassa.</string> + <string name="taler_devices_test_hint">En QR-kod för test visas i fem sekunder och rensas sedan. Ingen betalning skapas.</string> + + <string name="taler_usb_not_attached">Anslut en Taler-enhet som stöds via USB.</string> + <string name="taler_usb_device">Taler USB-enhet %1$d</string> + <string name="taler_usb_permission_required">USB-åtkomst krävs.</string> + <string name="taler_usb_allow_device">Tillåt USB-åtkomst Enhet %1$d</string> + <string name="taler_usb_failed">Enheten kunde inte uppdateras. Anslut den igen eller försök på nytt.</string> + <string name="taler_usb_refresh">Kontrollera anslutningen</string> + <string name="checkout_order_claimed">Beställningen har hämtats av plånboken.</string> + <string name="checkout_complete_in_wallet">Slutför betalningen i plånboken</string> + <string name="checkout_payment_possible_until">Betalning möjlig till %1$s</string> + <string name="checkout_payment_detail_merchant">Handlare</string> + <string name="config_local_network_title">Anslut till en lokal handlare</string> + <string name="config_local_network_explanation">Din handlarserver på %1$s finns i ett lokalt nätverk. Tillåt åtkomst till enheter i närheten så att Taler POS kan ansluta och behandla beställningar.</string> + <string name="config_local_network_settings">Åtkomst till det lokala nätverket är blockerad. Öppna appinställningarna, välj Behörigheter → Enheter i närheten och tillåt åtkomst. Återvänd sedan hit för att ansluta till %1$s.</string> + <string name="config_local_network_allow">Tillåt åtkomst</string> + <string name="config_local_network_required">Tillåt åtkomst till det lokala nätverket för att ansluta till den här handlaren.</string> + <string name="config_camera_title">Kameraåtkomst för QR-konfiguration</string> + <string name="config_camera_explanation">Kameran skannar konfigurationens QR-kod från Merchant Backoffice för att fylla i dina kontoinställningar. Du kan också ange uppgifterna manuellt.</string> + <string name="config_camera_settings_instructions">Kameraåtkomsten är blockerad. Öppna appens inställningar, välj Behörigheter → Kamera och tillåt åtkomst när appen används. Återvänd sedan hit för att skanna.</string> + <string name="config_camera_allow">Tillåt kamera</string> + <string name="config_camera_open_settings">Öppna inställningar</string> + <string name="config_camera_manual">Konfigurera manuellt</string> + <string name="config_connecting">Ansluter…</string> + <string name="password_show">Visa lösenord</string> + <string name="password_hide">Dölj lösenord</string> + <string name="navigation_back">Tillbaka</string> + <string name="navigation_menu_open">Öppna navigeringsmenyn</string> + <string name="order_empty_inventory">Inga produkter att visa. Använd beställning efter belopp eller lägg till en egen produkt.</string> </resources> diff --git a/merchant-terminal/src/main/res/values-tr/strings.xml b/merchant-terminal/src/main/res/values-tr/strings.xml @@ -32,9 +32,9 @@ <string name="config_changed">%s kullanılarak yeni satıcı olarak değiştirildi</string> <string name="config_fetching_label">Kurulum getiriliyor</string> <string name="config_docs">Lütfen kurulum formatı için <a href="https://docs.taler.net/taler-merchant-pos-terminal.html#apis-and-data-formats">belgelere</a> bakın.</string> - <string name="config_no_instance">Örneğiniz yok mu?</string> + <string name="config_no_instance">Hesabınız yok mu?</string> <string name="config_instance_tutorial_description">1. Kurulum eğitimlerine göz atın:</string> - <string name="config_instance_demo_description">2. Uygulamayı herkese açık demo örneğiyle deneyebilirsiniz. Bu örneğin paylaşıldığını ve herkesin verilerini istediği zaman değiştirebileceğini unutmayın. Yalnızca uygulamayı ve özelliklerini keşfetmek için kullanın.</string> + <string name="config_instance_demo_description">2. Uygulamayı herkese açık demo hesabıyla deneyebilirsiniz. Bu hesabın paylaşıldığını ve herkesin verilerini istediği zaman değiştirebileceğini unutmayın. Yalnızca uygulamayı ve özelliklerini keşfetmek için kullanın.</string> <string name="config_fill_demo_credentials">Demo bilgilerini doldur</string> <string name="config_instance_review_description">3. Bu uygulamayı inceliyorsanız büyük olasılıkla kontrollü bir test kullanıcısına zaten erişiminiz vardır. Satıcı portalı URL’sini doldurmak için aşağıdaki düğmeyi kullanın.</string> <string name="config_fill_review_url">İnceleme URL’sini doldur</string> @@ -157,4 +157,304 @@ <string name="insecure_payment_dialog_try_https">Düzeltilmiş bağlantıyı dene</string> <string name="insecure_payment_dialog_continue_as_is">Olduğu gibi devam et</string> <string name="insecure_payment_dialog_cancel_order">Siparişi iptal et</string> + <string name="payment_device_nfc_available">kullanılabilir</string> + <string name="settings_about_title">Hakkında</string> + <string name="refund_reason_wrong_order">Yanlış sipariş</string> + <string name="payment_options_title">Ödeme seçenekleri</string> + <string name="payment_options_button">Ödeme seçenekleri</string> + <string name="payment_options_description">Taler her zaman kullanılabilir. Ek ödeme yöntemlerini açın veya yapılandırmak için bir seçenek açın.</string> + <string name="payment_options_enabled_count">%1$d açık</string> + <string name="payment_option_open">%1$s ayarlarını aç</string> + <string name="payment_option_settings_title">Ayarlar</string> + <string name="payment_option_unknown">Bu ödeme seçeneği bu sürümde kullanılamıyor.</string> + <string name="payment_option_disabled">Kapalı</string> + <string name="payment_option_ready">Hazır</string> + <string name="payment_option_taler">GNU Taler</string> + <string name="payment_option_taler_description">Müşteriler QR kodunu tarayarak veya telefonlarını yaklaştırarak Taler cüzdanından ödeme yapar.</string> + <string name="payment_option_cash">Nakit</string> + <string name="payment_option_cash_description">Müşteriler kasada nakit öder. Sipariş uygulama dışında ödenir.</string> + <string name="terminal_not_configured">Kurulum bu sürümde kullanılamıyor. Uygulama yöneticinize başvurun.</string> + <string name="terminal_sign_in_required">Okuyucu eşleştirmek için oturum açın.</string> + <string name="terminal_permission_required">Yakındaki cihazlar izni gerekiyor. Devam etmek için okuyucu kurulumunu açın.</string> + <string name="terminal_not_paired">Eşleştirilmiş okuyucu yok</string> + <string name="terminal_disconnected">Okuyucu bağlı değil</string> + <string name="terminal_connected">Okuyucu bağlı</string> + <string name="terminal_unavailable">Okuyucu bağlantısı kontrol edilemiyor</string> + <string name="terminal_pair">Okuyucuyu eşleştir</string> + <string name="terminal_manage">Okuyucuyu yönet</string> + <string name="terminal_account">Hesap: %1$s</string> + <string name="terminal_serial">Seri numarası: %1$s</string> + <string name="terminal_indicator_description">Ödeme cihazları: %1$s</string> + <string name="terminal_close">Kapat</string> + <string name="terminal_action_failed">Terminal kurulumu tamamlanamadı. Bluetooth’u, izinleri ve internet bağlantınızı kontrol edip tekrar deneyin.</string> + <string name="terminal_sumup">SumUp</string> + <string name="terminal_sumup_description">Müşteriler bu cihazla eşleştirilmiş bir SumUp okuyucusunda kartla ödeme yapar.</string> + <string name="terminal_sumup_sign_in">SumUp oturumu aç</string> + <string name="terminal_sumup_sign_out">SumUp oturumunu kapat</string> + <string name="terminal_sumup_save_key">İş ortaklığı anahtarını kaydet</string> + <string name="terminal_sumup_advanced">Gelişmiş</string> + <string name="terminal_sumup_advanced_help">İş ortaklığı anahtarı SumUp’a işletmenizi değil bu uygulamayı tanıtır. Neredeyse hiç değiştirmeniz gerekmez. Yalnızca kendi SumUp iş ortağı kaydınız varsa değiştirin.</string> + <string name="terminal_sumup_key_builtin">Uygulamaya gömülü iş ortaklığı anahtarı kullanılıyor.</string> + <string name="terminal_sumup_key_custom">Özel bir iş ortaklığı anahtarı kullanılıyor.</string> + <string name="terminal_sumup_key_hint">Yeni iş ortaklığı anahtarı</string> + <string name="terminal_sumup_key_show">Anahtarı göster</string> + <string name="terminal_sumup_key_hide">Anahtarı gizle</string> + <string name="terminal_sumup_key_warning">Farklı bir anahtar kaydetmek SumUp oturumunuzu kapatır.</string> + <string name="terminal_sumup_reset_key">Gömülü anahtarı kullan</string> + <string name="terminal_sumup_session_help">SumUp oturumu yaklaşık bir saat sürer. Kendi OAuth uygulamanızla bu uygulama oturumu otomatik yeniler; böylece personelin yeniden oturum açması gerekmez.</string> + <string name="terminal_sumup_session_no_client">OAuth uygulaması yapılandırılmamış. Oturumu açık tutmak için Gelişmiş bölümünde bir tane ekleyin.</string> + <string name="terminal_sumup_session_not_authorized">Henüz yetkilendirilmedi. Bu cihazda oturumu açık tutmak için bir kez yetkilendirin.</string> + <string name="terminal_sumup_session_expires">Erişim belirtecinin süresinin dolmasına: %1$s</string> + <string name="terminal_sumup_session_refreshes">%1$d kez yenilendi, son yenileme %2$s</string> + <string name="terminal_sumup_session_error">Son yenileme sorunu: %1$s</string> + <string name="terminal_sumup_authorize">Bu cihazı yetkilendir</string> + <string name="terminal_sumup_reauthorize">Tekrar yetkilendir</string> + <string name="terminal_sumup_refresh_now">Belirteci şimdi yenile</string> + <string name="terminal_sumup_revoke">Yetkilendirmeyi unut</string> + <string name="terminal_sumup_oauth_help">SumUp panelinizde %1$s yönlendirme URL’siyle bir OAuth uygulaması kaydedin, ardından istemci kimliğini ve sırrını buraya yapıştırın. Bunlar bu cihazda kalır.</string> + <string name="terminal_sumup_oauth_set">Bir OAuth uygulaması yapılandırılmış.</string> + <string name="terminal_sumup_oauth_unset">OAuth uygulaması yapılandırılmamış.</string> + <string name="terminal_sumup_client_id">OAuth istemci kimliği</string> + <string name="terminal_sumup_client_secret">OAuth istemci sırrı</string> + <string name="terminal_sumup_save_client">OAuth uygulamasını kaydet</string> + <string name="payment_device_connected">%1$s bağlı</string> + <string name="payment_device_disconnected">%1$s bağlı değil</string> + <string name="payment_device_nfc">NFC</string> + <string name="payment_device_status_connected">Bağlı</string> + <string name="payment_device_status_disconnected">Bağlı değil</string> + <string name="payment_device_battery">Pil %1$d%%</string> + <string name="checkout_title">Ödeme</string> + <string name="checkout_total">Toplam</string> + <string name="checkout_payments_button">Kaydedilen ödemeler (%1$d)</string> + <string name="checkout_payments_title">Kaydedilen ödemeler</string> + <string name="checkout_no_taler_hint">GNU Taler kapalı olduğundan tutarın tamamı diğer seçeneklerle ödenmelidir.</string> + <string name="checkout_order_created_hint">Sipariş oluşturulduğundan başka nakit veya kart ödemesi eklenemez. Kalan tutar GNU Taler ile ödenir.</string> + <string name="checkout_cancel">Ödemeyi iptal et</string> + <string name="checkout_change_due_title">Para üstünü ver</string> + <string name="checkout_change_due">Müşteriye %1$s geri verin.</string> + <string name="checkout_card_title">Kart ödemesi</string> + <string name="checkout_card_amount">Tahsil edilecek tutar</string> + <string name="checkout_card_too_much">Kalan tutardan fazla</string> + <string name="checkout_card_not_ready">Kart okuyucu hazır değil. Oturum açtığınızı ve okuyucunun bağlı olduğunu kontrol edin.</string> + <string name="checkout_card_recorded">%1$s tutarındaki kart ödemesi kaydedildi</string> + <string name="checkout_card_failed">Kart ödemesi gerçekleşmedi.</string> + <string name="checkout_card_unknown_title">Kart ödemesi doğrulanmadı</string> + <string name="checkout_card_unknown">%1$s tahsil edilip edilmediği doğrulanamadı. Yeniden tahsil etmeden önce kart sağlayıcısının uygulamasında işlemi kontrol edin. Hiçbir şey kaydedilmedi.</string> + <string name="checkout_record_failed">Ödeme kaydedilemedi. Kaydedilene kadar para üstünü vermeyin.</string> + <string name="checkout_none">Bu ödeme artık açık değil.</string> + <string name="checkout_payment_line_change">Alınan %1$s, para üstü %2$s</string> + <string name="checkout_payment_line_reference">Referans %1$s</string> + <string name="checkout_history_continue">Devam et</string> + <string name="checkout_history_dismiss">Hesabı kapatıldı olarak işaretle</string> + <string name="payment_options_mixed_unsupported">Birden fazla parçada ödeme için satıcı arka uç protokolü v42 veya üzeri gerekir. Bu arka uç %1$s bildiriyor.</string> + <string name="checkout_total_amount">Toplam: %1$s</string> + <string name="checkout_paid_amount">Ödenen: %1$s</string> + <string name="checkout_about">Ödeme hakkında</string> + <string name="checkout_about_message">%1$s zaten alındı. Şimdi iptal ederseniz nakdi geri vermeniz ve kart ödemelerini kart sağlayıcısının kendi uygulamasından iade etmeniz gerekir.</string> + <string name="checkout_cancel_anyway">Yine de ödemeyi iptal et</string> + <string name="checkout_settled_hint">Her şey ödendi. İşlemi tamamlamak için siparişi bitirin.</string> + <string name="checkout_back_to_options">Ödeme seçeneklerine dön</string> + <string name="checkout_revert_failed">Sipariş geri alınamadı.</string> + <string name="payment_option_activation_title">Bunu açmadan önce</string> + <string name="payment_option_activation_confirm">Aç</string> + <string name="terminal_sumup_activation_notice">SumUp’ın mevcut yazılımıyla bu uygulama kartlardan tahsilat yapabilir ancak para iade edemez. Geri ödeme nakit olarak, başka bir ödeme yöntemiyle veya SumUp’ın kendi uygulamasında yapılmalıdır.\n\nBunun işletmenizin iadeleri işleme yükümlülüklerine uygun olduğunu muhasebeciniz veya hukuk danışmanınızla kontrol edin.</string> + <string name="history_status_partially_paid">Kısmen ödendi</string> + <string name="history_status_awaiting_collection">Teslim alınması bekleniyor</string> + <string name="order_details_title">Sipariş ayrıntıları</string> + <string name="order_details_loading">Sipariş ayrıntıları yükleniyor…</string> + <string name="order_details_unavailable">Ayrıntılar kullanılamıyor</string> + <string name="order_details_load_error">Bu sipariş yüklenemedi. Bağlantınızı kontrol edip tekrar deneyin.</string> + <string name="order_details_retry">Tekrar dene</string> + <string name="order_details_fulfillment_link">Siparişin yerine getirilmesi bağlantısı</string> + <string name="order_details_not_provided">Satıcı bu sipariş için ek ayrıntı sağlamadı.</string> + <string name="order_details_order_id">Sipariş referansı</string> + <string name="order_details_refund_total">Geri ödenen tutar</string> + <string name="order_details_summary">Sipariş özeti</string> + <string name="order_details_products">Ürünler</string> + <string name="order_details_no_products">Ayrıntılı ürün listesi sağlanmadı.</string> + <string name="order_details_net_price">%1$s (net)</string> + <string name="order_details_payments">Ödeme dökümü</string> + <string name="order_details_balance">Ödenecek kalan tutar</string> + <string name="order_details_delivery">Teslimat / Teslim alma</string> + <string name="order_details_delivery_location">Teslimat yeri</string> + <string name="order_details_delivery_date">Teslimat tarihi</string> + <string name="order_details_pickup_time">Son teslim alma saati</string> + <string name="order_details_reorder">Tekrar sipariş ver</string> + <string name="order_details_reorder_hint">Aynı ürünleri tekrar sipariş etmek için bu bağlantıyı paylaşın.</string> + <string name="order_details_reorder_link">Tekrar sipariş bağlantısı</string> + <string name="order_details_additional">Ek bilgiler</string> + <string name="order_details_pay_deadline">Son ödeme tarihi</string> + <string name="order_details_refund_deadline">Son geri ödeme tarihi</string> + <string name="order_details_merchant">Satıcı bilgileri</string> + <string name="order_details_jurisdiction">Yargı yetkisi</string> + <string name="order_details_email">E-posta</string> + <string name="order_details_website">Web sitesi</string> + <string name="order_details_open_link">Bağlantıyı aç</string> + <string name="order_details_copied">Panoya kopyalandı</string> + <string name="order_details_copy">%1$s kopyala</string> + <string name="order_details_open_failed">Bu bağlantıyı açabilecek bir uygulama yok.</string> + <string name="refund_choose_method">Geri ödeme yöntemi</string> + <string name="refund_available">Geri ödenebilir tutar</string> + <string name="refund_unavailable">Kullanılamıyor</string> + <string name="refund_unavailable_hint">Bu yöntem burada geri ödenemez. Harici geri ödemeler için ödenmiş bir sipariş ve ödeme kaydının bu cihazda bulunması gerekir. Taler geri ödemeleri hâlâ kullanılabilir olmalıdır.</string> + <string name="refund_taler_button">Taler geri ödemesi yap</string> + <string name="refund_taler_hint">Geri ödemeyi onaylayın, ardından müşterinin Taler cüzdanıyla almasını sağlayın.</string> + <string name="refund_cash_hint">Nakdi müşteriye geri verin, ardından geri verilen tutarı kaydedin.</string> + <string name="refund_card_hint">Asıl kart ödemesini kart sağlayıcısının uygulamasında iade edin, ardından geri verilen tutarı buraya kaydedin. Bu ekran karta para göndermez.</string> + <string name="refund_cash_confirm">Bu nakdin müşteriye geri verildiğini onaylayın.</string> + <string name="refund_card_confirm">Kart sağlayıcısının bu geri ödemeyi tamamladığını onaylayın. Burada kaydetmek başka bir geri ödeme yapmaz.</string> + <string name="refund_returned_confirm">Para geri verildi - geri ödemeyi kaydet</string> + <string name="refund_record_external">Geri ödemeyi kaydet</string> + <string name="refund_recorded">Geri ödeme kaydedildi</string> + <string name="refund_recorded_message">Geri verilen tutar siparişe kaydedildi.</string> + <string name="refund_recorded_local">Bu cihaza kaydedildi ancak sipariş güncellenemedi. Bu parayı tekrar geri vermeyin. Arka uç mesajı: %1$s</string> + <string name="order_delete_final_title">Bu sipariş kalıcı olarak silinsin mi?</string> + <string name="order_delete_warning">Silme işlemi bu ödenmemiş siparişi kaldırır. Nakdi geri vermez, kart veya Taler geri ödemesi yapmaz. Devam etmeden önce müşteriyle tüm ödemeleri sonuçlandırın.</string> + <string name="order_delete_final_warning">Bu işlem geri alınamaz. Tüm ödemelerin sonuçlandırıldığını ve bu siparişi kalıcı olarak silmek istediğinizi onaylayın.</string> + <string name="order_delete_continue">Devam et</string> + <string name="product_image_open">Görseli görüntüle: %1$s</string> + <string name="product_image_close">Görseli kapat</string> + <string name="payment_pay_with">Ödeme yöntemi</string> + + <string name="checkout_complete_payment">Ödemeyi tamamla</string> + <string name="checkout_collect_receipt_hint">Müşteri, makbuzu Taler cüzdanına almak için sonraki ekranı tarayabilir.</string> + <string name="checkout_collect_receipt_title">Makbuzu al</string> + <string name="checkout_receipt_paid_in_full">Tamamı ödendi</string> + <string name="checkout_receipt_scan_hint">Makbuzu almak için Taler cüzdanıyla tarayın.</string> + <string name="checkout_collect_receipt_on_device">Makbuzu bu cihazda al</string> + <string name="checkout_payment_claimed_title">Ödeme sürüyor</string> + <string name="checkout_customer_opened_payment">Müşteri bu ödemeyi cüzdanında açtı.</string> + <string name="checkout_payment_claimed_hint">Müşterinin onayı bekleniyor</string> + <string name="checkout_tap_to_enter_amount">Tutar girmek için dokunun</string> + <string name="checkout_cash_entry_instruction">Alınan tutarı girin</string> + <string name="checkout_card_entry_instruction">Tahsil edilecek tutarı girin</string> + <string name="checkout_fraction_remaining">Kalan</string> + <string name="checkout_fraction_half_description">Kalan bakiyenin yarısı, %1$s</string> + <string name="checkout_fraction_third_description">Kalan bakiyenin üçte biri, %1$s</string> + <string name="checkout_fraction_quarter_description">Kalan bakiyenin çeyreği, %1$s</string> + <string name="checkout_fraction_remaining_description">Kalan bakiye, %1$s</string> + <string name="checkout_charge_with_taler">Taler ile tahsil et</string> + <string name="checkout_charge_remaining_taler">Kalan tutarı Taler ile tahsil et</string> + <string name="checkout_order_total_heading">Sipariş toplamı</string> + <string name="checkout_payments_heading">Ödemeler</string> + <string name="checkout_payments_added">%1$d eklendi</string> + <string name="checkout_payments_empty_inline">Eklenen ödemeler burada görünecek.</string> + <string name="checkout_about_action">Bu ödeme işlemi hakkında</string> + <string name="checkout_fully_paid">Tamamı ödendi</string> + <string name="checkout_scan_to_pay_hint">Ödemek için müşteriden sonraki ekrandaki QR kodunu taramasını isteyin.</string> + <string name="checkout_scan_qr_prompt">QR kodunu mobil cüzdanınızla tarayın.</string> + <string name="checkout_payment_detail_order">Sipariş</string> + <string name="checkout_payment_detail_summary">Özet</string> + <string name="checkout_waiting_customer_payment">Ödeme bekleniyor…</string> + <string name="checkout_remaining_heading">Kalan</string> + <string name="checkout_order_reference">Sipariş #%1$d</string> + <string name="checkout_choose_payment_method">Ödeme yöntemi seç</string> + <string name="checkout_payment_method_helper">Nakit veya kart ödemesi ekleyin ya da kalan bakiyeyi Taler ile ödeyin.</string> + <string name="checkout_complete_order_hint">Bu siparişi tamamlar</string> + <string name="checkout_remaining_amount">Kalan: %1$s</string> + <string name="checkout_remaining_to_pay">Ödenecek kalan tutar %1$s</string> + <string name="checkout_remaining_after_payment">Ödemeden sonra kalan: %1$s</string> + <string name="checkout_change_to_return_amount">Verilecek para üstü: %1$s</string> + <string name="checkout_cash_change_explanation">Bakiyeyi aşan nakit, verilecek para üstü olarak gösterilir.</string> + <string name="checkout_record_cash_action">%1$s nakit kaydet</string> + <string name="checkout_charge_card_action">Karttan %1$s tahsil et</string> + <string name="checkout_cash_received_heading">Alınan nakit</string> + <string name="checkout_card_payment_heading">Kart ödemesi</string> + <string name="checkout_card_amount_limit_hint">Kart tutarı kalan bakiyeyi aşamaz.</string> + + <string name="checkout_creation_pending">Sipariş zaten oluşturulmuş olabilir. Ödemeleri değiştirmeden önce siparişi kurtarmak için Taler adımını yeniden deneyin.</string> + <string name="checkout_pending_charge">%1$s tutarındaki bekleyen kart ödemesini %2$s ile çözümleyin. Devam etmeden önce %3$s referansını kullanarak sağlayıcının kayıtlarında son durumu kontrol edin.</string> + <string name="checkout_pending_paid">Ödeme sağlayıcının kayıtlarında doğrulandı</string> + <string name="checkout_pending_not_paid">Sağlayıcı tahsilat yapılmadığını doğruladı</string> + <string name="checkout_pending_confirm_title">Ödeme sonucunu onayla</string> + <string name="checkout_pending_confirm_paid">Tam olarak bu ödemenin başarılı olduğunu onaylayın. Sağlayıcının işlem referansını girin.</string> + <string name="checkout_pending_confirm_unpaid">Yalnızca sağlayıcı bu ödemenin başarısız olduğunu veya iptal edildiğini ve daha sonra tahsil edilemeyeceğini doğrularsa devam edin. Bekleyen veya eksik bir sonuç onay sayılmaz.</string> + <string name="checkout_pending_transaction">Sağlayıcının işlem referansı</string> + + <string name="history_total_paid_remaining">Toplam: %1$s Ödenen: %2$s Kalan: %3$s</string> + <string name="history_checkout_total_paid_remaining">Toplam: %1$s Ödenen: %2$s Kalan: %3$s</string> + <string name="order_details_delete_action">Sil</string> + <string name="settings_manage_connection_action">Satıcı ayarları</string> + <string name="settings_logout_action">Oturumu kapat</string> + <string name="payment_options_manage_action">Ödeme seçeneklerini yönet</string> + + <string name="order_actions">İşlemler</string> + <string name="order_current">Geçerli sipariş</string> + <string name="amount_entry_hint">Sipariş tutarını girin</string> + + <string name="taler_ble_unsupported">Bu cihazda Bluetooth kullanılamıyor.</string> + <string name="taler_ble_permission">Ekranı bağlamak için yakındaki cihazlara erişime izin verin.</string> + <string name="taler_ble_off">Ekranı bağlamak için Bluetooth’u açın.</string> + <string name="taler_ble_location">Bu Android sürümü, Bluetooth cihazlarını bulmak için konumun açık olmasını gerektirir.</string> + <string name="taler_ble_scanning">Taler ekranları aranıyor…</string> + <string name="taler_ble_connecting">Ekrana bağlanılıyor…</string> + <string name="taler_ble_sending">Ekran güncelleniyor…</string> + <string name="taler_ble_too_long">Bu bağlantı ekran için çok uzun. Bu cihazdaki QR kodunu kullanın.</string> + <string name="taler_ble_failed">Ekran güncellenemedi. Açık ve yakında olduğunu kontrol edin.</string> + <string name="taler_ble_allow">Cihaz erişimine izin ver</string> + <string name="taler_ble_open_settings">Cihaz ayarlarını aç</string> + <string name="taler_ble_scan">Taler ekranı bul</string> + <string name="taler_ble_test">Ekranı test et</string> + <string name="taler_ble_reset_limitation">Bu ekran ödeme veya test sonrasında önceki QR kodunu göstermeye devam edebilir. Cihaz yazılımı sıfırlamayı destekleyene kadar kodu temizlemek için ekranı yeniden başlatın.</string> + <string name="taler_ble_reconnect">Yeniden bağlan</string> + <string name="taler_ble_pairing">Eşleştiriliyor… LCD ekranda gösterilen kodu Android eşleştirme istemine girin.</string> + <string name="taler_ble_pairing_required">Eşleştirme tamamlanmadı veya kayıtlı eşleştirme artık çalışmıyor.</string> + <string name="taler_ble_forget_explanation">Bu ekrandan bu satış noktasında vazgeçilsin mi? Uygulamadaki kayıtlı seçim kaldırılır. Bluetooth eşleştirmesini de kaldırmak için Android Bluetooth ayarlarında ekranı unutun ve LCD ekranda Bu satış noktasını unut seçeneğini seçin.</string> + + <string name="taler_devices_manage">Ekranları yönet</string> + <string name="taler_devices_title">Taler ekranları</string> + <string name="taler_devices_empty">Henüz ekran eklenmedi.</string> + <string name="taler_devices_add">Cihaz ekle</string> + <string name="taler_devices_check_usb">USB bağlantısını kontrol et</string> + <string name="taler_devices_remove">Ekranı kaldır</string> + <string name="taler_devices_replace">Ekranı değiştir</string> + <string name="taler_devices_replace_hint">Bu satış noktası aynı anda tek bir Bluetooth ekranı kullanır. Mevcut ekran değiştirilsin mi?</string> + <string name="taler_devices_access">Bluetooth bağlantısına izin ver</string> + <string name="taler_devices_open_lcd">Ekran ayarlarını aç</string> + <string name="taler_devices_open_lcd_hint">Ekranda sola kaydırıp ayarlar dişlisine dokunun.</string> + <string name="taler_devices_pair_lcd">Ekranda eşleştirmeyi başlat</string> + <string name="taler_devices_pair_lcd_hint">Ekranda Bluetooth → Bir satış noktası eşleştir seçeneğini seçin.</string> + <string name="taler_devices_choose_display">Ekranınızı seçin</string> + <string name="taler_devices_connect">Ekranı bağla</string> + <string name="taler_devices_connected">Ekran bağlandı</string> + <string name="taler_devices_connected_hint">Ekranınız ödemeler için hazır.</string> + <string name="taler_devices_bluetooth">Bluetooth ekranı</string> + <string name="taler_devices_usb">USB cihazı</string> + <string name="taler_devices_scan_finished">Aşağıdan ekranınızı seçin veya görünmüyorsa tekrar arayın.</string> + <string name="taler_devices_checking">Bağlantı kontrol ediliyor…</string> + <string name="taler_devices_done">Bitti</string> + <string name="taler_devices_next">İleri</string> + <string name="taler_devices_search_again">Tekrar ara</string> + <string name="taler_devices_pair_again">Tekrar eşleştir</string> + <string name="taler_devices_repair_android">Android Bluetooth ayarlarında bu ekranı unutun, ardından buraya dönün.</string> + <string name="taler_devices_repair_lcd">Ekranda Bluetooth’u açın, eski satış noktasını unutun ve Bir satış noktası eşleştir seçeneğini seçin.</string> + <string name="taler_devices_test_hint">Beş saniye boyunca bir test QR kodu gösterilir, ardından temizlenir. Ödeme oluşturulmaz.</string> + + <string name="taler_usb_not_attached">Desteklenen bir Taler cihazını USB üzerinden bağlayın.</string> + <string name="taler_usb_device">Taler USB cihazı %1$d</string> + <string name="taler_usb_permission_required">USB erişimi gerekiyor.</string> + <string name="taler_usb_allow_device">USB erişimine izin ver Cihaz %1$d</string> + <string name="taler_usb_failed">Cihaz güncellenemedi. Yeniden bağlayın veya tekrar deneyin.</string> + <string name="taler_usb_refresh">Bağlantıyı kontrol et</string> + <string name="checkout_order_claimed">Sipariş cüzdan tarafından alındı.</string> + <string name="checkout_complete_in_wallet">Ödemeyi cüzdanınızda tamamlayın</string> + <string name="checkout_payment_possible_until">Son ödeme zamanı: %1$s</string> + <string name="checkout_payment_detail_merchant">Satıcı</string> + <string name="config_local_network_title">Yerel bir satıcıya bağlan</string> + <string name="config_local_network_explanation">%1$s adresindeki satıcı sunucunuz yerel bir ağda. Taler POS uygulamasının bağlanıp siparişleri işleyebilmesi için yakındaki cihazlara erişime izin verin.</string> + <string name="config_local_network_settings">Yerel ağ erişimi engellendi. Uygulama ayarlarını açın, İzinler → Yakındaki cihazlar yolunu izleyin ve erişime izin verin. Ardından %1$s adresine bağlanmak için buraya dönün.</string> + <string name="config_local_network_allow">Erişime izin ver</string> + <string name="config_local_network_required">Bu satıcıya bağlanmak için yerel ağ erişimine izin verin.</string> + <string name="config_camera_title">QR kurulumu için kamera erişimi</string> + <string name="config_camera_explanation">Kamera, hesap ayarlarınızı doldurmak için Merchant Backoffice kurulum QR kodunu tarar. Bu bilgileri elle de girebilirsiniz.</string> + <string name="config_camera_settings_instructions">Kamera erişimi engellendi. Uygulama ayarlarını açın, İzinler → Kamera seçeneğini seçin ve uygulamayı kullanırken erişime izin verin. Ardından taramak için buraya dönün.</string> + <string name="config_camera_allow">Kameraya izin ver</string> + <string name="config_camera_open_settings">Ayarları aç</string> + <string name="config_camera_manual">Elle yapılandır</string> + <string name="config_connecting">Bağlanıyor…</string> + <string name="password_show">Parolayı göster</string> + <string name="password_hide">Parolayı gizle</string> + <string name="navigation_back">Geri</string> + <string name="navigation_menu_open">Gezinme menüsünü aç</string> + <string name="order_empty_inventory">Görüntülenecek ürün yok. Tutara göre sipariş verin veya özel ürün ekleyin.</string> </resources> diff --git a/merchant-terminal/src/main/res/values-uk/strings.xml b/merchant-terminal/src/main/res/values-uk/strings.xml @@ -88,9 +88,9 @@ <string name="config_error_product_category_id">Помилка: продукт %1$s посилається на невідомий ідентифікатор категорії %2$d</string> <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="config_no_instance">Немає екземпляра?</string> + <string name="config_no_instance">Немає облікового запису?</string> <string name="config_instance_tutorial_description">1. Ознайомтеся з інструкціями з налаштування:</string> - <string name="config_instance_demo_description">2. Ви можете спробувати застосунок із загальнодоступним демонстраційним екземпляром. Зверніть увагу, що цей екземпляр використовується спільно—будь-хто може змінити його дані в будь-який час. Використовуйте його лише для ознайомлення із застосунком та його можливостями.</string> + <string name="config_instance_demo_description">2. Ви можете спробувати застосунок із загальнодоступним демонстраційним обліковим записом. Зверніть увагу, що цей обліковий запис використовується спільно - будь-хто може змінити його дані в будь-який час. Використовуйте його лише для ознайомлення із застосунком та його можливостями.</string> <string name="config_fill_demo_credentials">Заповнити демо-дані</string> <string name="config_instance_review_description">3. Якщо ви перевіряєте цей застосунок, найімовірніше, у вас уже є доступ до контрольованого тестового користувача. Скористайтеся кнопкою нижче, щоб заповнити URL порталу продавця.</string> <string name="config_fill_review_url">Заповнити URL для перевірки</string> @@ -157,4 +157,304 @@ <string name="insecure_payment_dialog_try_https">Спробувати виправлене посилання</string> <string name="insecure_payment_dialog_continue_as_is">Продовжити як є</string> <string name="insecure_payment_dialog_cancel_order">Скасувати замовлення</string> + <string name="payment_device_nfc_available">доступно</string> + <string name="settings_about_title">Про застосунок</string> + <string name="refund_reason_wrong_order">Помилкове замовлення</string> + <string name="payment_options_title">Способи оплати</string> + <string name="payment_options_button">Способи оплати</string> + <string name="payment_options_description">Taler доступний завжди. Увімкніть додаткові способи оплати або відкрийте спосіб, щоб налаштувати його.</string> + <string name="payment_options_enabled_count">Увімкнено: %1$d</string> + <string name="payment_option_open">Відкрити налаштування %1$s</string> + <string name="payment_option_settings_title">Налаштування</string> + <string name="payment_option_unknown">Цей спосіб оплати недоступний у цій збірці.</string> + <string name="payment_option_disabled">Вимкнено</string> + <string name="payment_option_ready">Готово</string> + <string name="payment_option_taler">GNU Taler</string> + <string name="payment_option_taler_description">Клієнти платять із гаманця Taler, скануючи QR-код або прикладаючи телефон.</string> + <string name="payment_option_cash">Готівка</string> + <string name="payment_option_cash_description">Клієнти платять готівкою на касі. Замовлення оплачується поза застосунком.</string> + <string name="terminal_not_configured">Налаштування недоступне в цій збірці. Зверніться до адміністратора застосунку.</string> + <string name="terminal_sign_in_required">Увійдіть, щоб сполучити зчитувач.</string> + <string name="terminal_permission_required">Потрібен дозвіл на доступ до пристроїв поблизу. Відкрийте налаштування зчитувача, щоб продовжити.</string> + <string name="terminal_not_paired">Немає сполученого зчитувача</string> + <string name="terminal_disconnected">Зчитувач відключено</string> + <string name="terminal_connected">Зчитувач підключено</string> + <string name="terminal_unavailable">Не вдалося перевірити з’єднання зі зчитувачем</string> + <string name="terminal_pair">Сполучити зчитувач</string> + <string name="terminal_manage">Керувати зчитувачем</string> + <string name="terminal_account">Обліковий запис: %1$s</string> + <string name="terminal_serial">Серійний номер: %1$s</string> + <string name="terminal_indicator_description">Платіжні пристрої: %1$s</string> + <string name="terminal_close">Закрити</string> + <string name="terminal_action_failed">Не вдалося завершити налаштування термінала. Перевірте Bluetooth, дозволи та підключення до інтернету, а потім спробуйте ще раз.</string> + <string name="terminal_sumup">SumUp</string> + <string name="terminal_sumup_description">Клієнти платять карткою через зчитувач SumUp, сполучений із цим пристроєм.</string> + <string name="terminal_sumup_sign_in">Увійти до SumUp</string> + <string name="terminal_sumup_sign_out">Вийти із SumUp</string> + <string name="terminal_sumup_save_key">Зберегти партнерський ключ</string> + <string name="terminal_sumup_advanced">Додатково</string> + <string name="terminal_sumup_advanced_help">Партнерський ключ ідентифікує цей застосунок у SumUp, а не вашу організацію. Зазвичай його не потрібно змінювати. Замініть його лише якщо маєте власну реєстрацію партнера SumUp.</string> + <string name="terminal_sumup_key_builtin">Використовується вбудований у застосунок партнерський ключ.</string> + <string name="terminal_sumup_key_custom">Використовується власний партнерський ключ.</string> + <string name="terminal_sumup_key_hint">Новий партнерський ключ</string> + <string name="terminal_sumup_key_show">Показати ключ</string> + <string name="terminal_sumup_key_hide">Приховати ключ</string> + <string name="terminal_sumup_key_warning">Збереження іншого ключа завершить сеанс SumUp.</string> + <string name="terminal_sumup_reset_key">Використати вбудований ключ</string> + <string name="terminal_sumup_session_help">Сеанс SumUp триває близько години. З власним застосунком OAuth сеанс поновлюється автоматично, тож працівникам не потрібно входити знову.</string> + <string name="terminal_sumup_session_no_client">Застосунок OAuth не налаштовано. Додайте його в розділі «Додатково», щоб підтримувати сеанс.</string> + <string name="terminal_sumup_session_not_authorized">Ще не авторизовано. Авторизуйте цей пристрій один раз, щоб залишатися в обліковому записі.</string> + <string name="terminal_sumup_session_expires">Термін дії токена доступу спливає через %1$s</string> + <string name="terminal_sumup_session_refreshes">Кількість оновлень: %1$d, останнє о %2$s</string> + <string name="terminal_sumup_session_error">Остання проблема оновлення: %1$s</string> + <string name="terminal_sumup_authorize">Авторизувати цей пристрій</string> + <string name="terminal_sumup_reauthorize">Авторизувати знову</string> + <string name="terminal_sumup_refresh_now">Оновити токен зараз</string> + <string name="terminal_sumup_revoke">Забути авторизацію</string> + <string name="terminal_sumup_oauth_help">Зареєструйте застосунок OAuth на панелі SumUp з URL-адресою перенаправлення %1$s, а потім вставте сюди його ідентифікатор і секрет клієнта. Вони залишаються на цьому пристрої.</string> + <string name="terminal_sumup_oauth_set">Застосунок OAuth налаштовано.</string> + <string name="terminal_sumup_oauth_unset">Застосунок OAuth не налаштовано.</string> + <string name="terminal_sumup_client_id">Ідентифікатор клієнта OAuth</string> + <string name="terminal_sumup_client_secret">Секрет клієнта OAuth</string> + <string name="terminal_sumup_save_client">Зберегти застосунок OAuth</string> + <string name="payment_device_connected">%1$s підключено</string> + <string name="payment_device_disconnected">%1$s не підключено</string> + <string name="payment_device_nfc">NFC</string> + <string name="payment_device_status_connected">Підключено</string> + <string name="payment_device_status_disconnected">Не підключено</string> + <string name="payment_device_battery">Акумулятор %1$d%%</string> + <string name="checkout_title">Оплата</string> + <string name="checkout_total">Загалом</string> + <string name="checkout_payments_button">Записані платежі (%1$d)</string> + <string name="checkout_payments_title">Записані платежі</string> + <string name="checkout_no_taler_hint">GNU Taler вимкнено, тому всю суму потрібно сплатити іншими способами.</string> + <string name="checkout_order_created_hint">Замовлення створено, тому більше не можна додавати готівку чи оплату карткою. Залишок сплачується через GNU Taler.</string> + <string name="checkout_cancel">Скасувати платіж</string> + <string name="checkout_change_due_title">Видати решту</string> + <string name="checkout_change_due">Поверніть клієнту %1$s.</string> + <string name="checkout_card_title">Оплата карткою</string> + <string name="checkout_card_amount">Сума списання</string> + <string name="checkout_card_too_much">Більше за залишок</string> + <string name="checkout_card_not_ready">Картковий зчитувач не готовий. Перевірте, чи ви ввійшли в обліковий запис і чи зчитувач підключено.</string> + <string name="checkout_card_recorded">Оплату карткою на суму %1$s записано</string> + <string name="checkout_card_failed">Оплата карткою не пройшла.</string> + <string name="checkout_card_unknown_title">Оплату карткою не підтверджено</string> + <string name="checkout_card_unknown">Не вдалося підтвердити, чи списано %1$s. Перед повторним списанням перевірте транзакцію в застосунку постачальника платіжних послуг. Нічого не записано.</string> + <string name="checkout_record_failed">Не вдалося зберегти платіж. Не видавайте решту, доки його не буде записано.</string> + <string name="checkout_none">Цей платіж більше не відкритий.</string> + <string name="checkout_payment_line_change">Отримано %1$s, решта %2$s</string> + <string name="checkout_payment_line_reference">Номер %1$s</string> + <string name="checkout_history_continue">Продовжити</string> + <string name="checkout_history_dismiss">Позначити як урегульоване</string> + <string name="payment_options_mixed_unsupported">Оплата частинами потребує версії 42 або новішої протоколу сервера продавця. Цей сервер повідомляє %1$s.</string> + <string name="checkout_total_amount">Загальна сума: %1$s</string> + <string name="checkout_paid_amount">Сплачено: %1$s</string> + <string name="checkout_about">Про платіж</string> + <string name="checkout_about_message">Уже отримано %1$s. Якщо скасувати зараз, потрібно повернути готівку та оформити повернення карткових платежів у застосунку постачальника платіжних послуг.</string> + <string name="checkout_cancel_anyway">Усе одно скасувати платіж</string> + <string name="checkout_settled_hint">Усе сплачено. Завершіть замовлення, щоб закінчити операцію.</string> + <string name="checkout_back_to_options">Назад до способів оплати</string> + <string name="checkout_revert_failed">Не вдалося відкликати замовлення.</string> + <string name="payment_option_activation_title">Перш ніж увімкнути</string> + <string name="payment_option_activation_confirm">Увімкнути</string> + <string name="terminal_sumup_activation_notice">З поточним програмним забезпеченням SumUp цей застосунок може списувати кошти з карток, але не повертати гроші. Повернення слід видати готівкою, виконати іншим способом оплати або оформити в застосунку SumUp.\n\nПеревірте з бухгалтером чи юридичним радником, чи це відповідає вимогам до обробки повернень у вашій організації.</string> + <string name="history_status_partially_paid">Частково оплачено</string> + <string name="history_status_awaiting_collection">Очікує отримання</string> + <string name="order_details_title">Відомості про замовлення</string> + <string name="order_details_loading">Завантаження відомостей про замовлення…</string> + <string name="order_details_unavailable">Відомості недоступні</string> + <string name="order_details_load_error">Не вдалося завантажити замовлення. Перевірте з’єднання та спробуйте ще раз.</string> + <string name="order_details_retry">Спробувати ще раз</string> + <string name="order_details_fulfillment_link">Посилання на виконання замовлення</string> + <string name="order_details_not_provided">Продавець не надав додаткових відомостей про це замовлення.</string> + <string name="order_details_order_id">Номер замовлення</string> + <string name="order_details_refund_total">Повернена сума</string> + <string name="order_details_summary">Підсумок замовлення</string> + <string name="order_details_products">Товари</string> + <string name="order_details_no_products">Детальний перелік товарів не надано.</string> + <string name="order_details_net_price">%1$s (нетто)</string> + <string name="order_details_payments">Розподіл платежів</string> + <string name="order_details_balance">Залишилося сплатити</string> + <string name="order_details_delivery">Доставка / Самовивіз</string> + <string name="order_details_delivery_location">Місце доставки</string> + <string name="order_details_delivery_date">Дата доставки</string> + <string name="order_details_pickup_time">Кінцевий час отримання</string> + <string name="order_details_reorder">Замовити знову</string> + <string name="order_details_reorder_hint">Поділіться цим посиланням, щоб знову замовити ті самі товари.</string> + <string name="order_details_reorder_link">Посилання для повторного замовлення</string> + <string name="order_details_additional">Додаткова інформація</string> + <string name="order_details_pay_deadline">Кінцевий термін оплати</string> + <string name="order_details_refund_deadline">Кінцевий термін повернення</string> + <string name="order_details_merchant">Відомості про продавця</string> + <string name="order_details_jurisdiction">Юрисдикція</string> + <string name="order_details_email">Електронна пошта</string> + <string name="order_details_website">Вебсайт</string> + <string name="order_details_open_link">Відкрити посилання</string> + <string name="order_details_copied">Скопійовано до буфера обміну</string> + <string name="order_details_copy">Копіювати %1$s</string> + <string name="order_details_open_failed">Немає застосунку для відкриття цього посилання.</string> + <string name="refund_choose_method">Спосіб повернення</string> + <string name="refund_available">Доступно для повернення</string> + <string name="refund_unavailable">Недоступно</string> + <string name="refund_unavailable_hint">Цей спосіб не можна повернути тут. Для зовнішніх повернень потрібні оплачене замовлення та запис його оплати на цьому пристрої. Повернення через Taler мають залишатися доступними.</string> + <string name="refund_taler_button">Повернути через Taler</string> + <string name="refund_taler_hint">Затвердьте повернення, а потім дайте клієнту отримати його у свій гаманець Taler.</string> + <string name="refund_cash_hint">Поверніть готівку клієнту, а потім запишіть повернену суму.</string> + <string name="refund_card_hint">Поверніть початковий картковий платіж у застосунку постачальника, а потім запишіть тут повернену суму. Цей екран не надсилає гроші на картку.</string> + <string name="refund_cash_confirm">Підтвердьте, що цю готівку повернуто клієнту.</string> + <string name="refund_card_confirm">Підтвердьте, що постачальник карткових платежів виконав це повернення. Запис тут не виконує ще одного повернення.</string> + <string name="refund_returned_confirm">Гроші повернуто - записати повернення</string> + <string name="refund_record_external">Записати повернення</string> + <string name="refund_recorded">Повернення записано</string> + <string name="refund_recorded_message">Повернену суму записано в замовленні.</string> + <string name="refund_recorded_local">Збережено на цьому пристрої, але замовлення не вдалося оновити. Не повертайте ці гроші повторно. Повідомлення сервера: %1$s</string> + <string name="order_delete_final_title">Видалити це замовлення назавжди?</string> + <string name="order_delete_warning">Видалення прибирає це неоплачене замовлення. Воно не повертає готівку та не оформлює повернення на картку чи через Taler. Урегулюйте всі платежі з клієнтом, перш ніж продовжувати.</string> + <string name="order_delete_final_warning">Цю дію неможливо скасувати. Підтвердьте, що всі платежі врегульовано й ви хочете назавжди видалити це замовлення.</string> + <string name="order_delete_continue">Продовжити</string> + <string name="product_image_open">Переглянути зображення: %1$s</string> + <string name="product_image_close">Закрити зображення</string> + <string name="payment_pay_with">Оплатити через</string> + + <string name="checkout_complete_payment">Завершити оплату</string> + <string name="checkout_collect_receipt_hint">Клієнт може відсканувати наступний екран, щоб отримати квитанцію в гаманець Taler.</string> + <string name="checkout_collect_receipt_title">Отримати квитанцію</string> + <string name="checkout_receipt_paid_in_full">Сплачено повністю</string> + <string name="checkout_receipt_scan_hint">Відскануйте гаманцем Taler, щоб отримати квитанцію.</string> + <string name="checkout_collect_receipt_on_device">Отримати квитанцію на цьому пристрої</string> + <string name="checkout_payment_claimed_title">Оплата триває</string> + <string name="checkout_customer_opened_payment">Клієнт відкрив цей платіж у своєму гаманці.</string> + <string name="checkout_payment_claimed_hint">Очікування підтвердження клієнта</string> + <string name="checkout_tap_to_enter_amount">Натисніть, щоб ввести суму</string> + <string name="checkout_cash_entry_instruction">Введіть отриману суму</string> + <string name="checkout_card_entry_instruction">Введіть суму списання</string> + <string name="checkout_fraction_remaining">Залишок</string> + <string name="checkout_fraction_half_description">Половина залишку, %1$s</string> + <string name="checkout_fraction_third_description">Третина залишку, %1$s</string> + <string name="checkout_fraction_quarter_description">Чверть залишку, %1$s</string> + <string name="checkout_fraction_remaining_description">Залишок, %1$s</string> + <string name="checkout_charge_with_taler">Оплатити через Taler</string> + <string name="checkout_charge_remaining_taler">Оплатити залишок через Taler</string> + <string name="checkout_order_total_heading">Загальна сума замовлення</string> + <string name="checkout_payments_heading">Платежі</string> + <string name="checkout_payments_added">Додано: %1$d</string> + <string name="checkout_payments_empty_inline">Тут з’являться додані платежі.</string> + <string name="checkout_about_action">Про цю оплату</string> + <string name="checkout_fully_paid">Повністю оплачено</string> + <string name="checkout_scan_to_pay_hint">Попросіть клієнта відсканувати QR-код на наступному екрані для оплати.</string> + <string name="checkout_scan_qr_prompt">Відскануйте QR-код мобільним гаманцем.</string> + <string name="checkout_payment_detail_order">Замовлення</string> + <string name="checkout_payment_detail_summary">Підсумок</string> + <string name="checkout_waiting_customer_payment">Очікування оплати…</string> + <string name="checkout_remaining_heading">Залишок</string> + <string name="checkout_order_reference">Замовлення № %1$d</string> + <string name="checkout_choose_payment_method">Виберіть спосіб оплати</string> + <string name="checkout_payment_method_helper">Додайте готівку чи оплату карткою або сплатіть залишок через Taler.</string> + <string name="checkout_complete_order_hint">Завершує це замовлення</string> + <string name="checkout_remaining_amount">Залишок: %1$s</string> + <string name="checkout_remaining_to_pay">Залишилося сплатити %1$s</string> + <string name="checkout_remaining_after_payment">Залишок після оплати: %1$s</string> + <string name="checkout_change_to_return_amount">Решта до повернення: %1$s</string> + <string name="checkout_cash_change_explanation">Готівка понад залишок відображається як решта до повернення.</string> + <string name="checkout_record_cash_action">Записати %1$s готівкою</string> + <string name="checkout_charge_card_action">Списати з картки %1$s</string> + <string name="checkout_cash_received_heading">Отримано готівкою</string> + <string name="checkout_card_payment_heading">Оплата карткою</string> + <string name="checkout_card_amount_limit_hint">Сума оплати карткою не може перевищувати залишок.</string> + + <string name="checkout_creation_pending">Можливо, замовлення вже існує. Повторіть крок Taler, щоб відновити його, перш ніж змінювати платежі.</string> + <string name="checkout_pending_charge">З’ясуйте результат карткового платежу на %1$s, що очікує, у %2$s. Перед продовженням перевірте остаточний стан у записах постачальника за номером %3$s.</string> + <string name="checkout_pending_paid">Платіж підтверджено в записах постачальника</string> + <string name="checkout_pending_not_paid">Постачальник підтвердив відсутність списання</string> + <string name="checkout_pending_confirm_title">Підтвердити результат платежу</string> + <string name="checkout_pending_confirm_paid">Підтвердьте, що саме цей платіж успішний. Введіть номер транзакції від постачальника.</string> + <string name="checkout_pending_confirm_unpaid">Продовжуйте лише якщо постачальник підтвердив, що платіж не пройшов або був скасований і не може бути списаний пізніше. Результат в очікуванні або відсутній результат не є підтвердженням.</string> + <string name="checkout_pending_transaction">Номер транзакції в постачальника</string> + + <string name="history_total_paid_remaining">Загалом: %1$s Сплачено: %2$s Залишок: %3$s</string> + <string name="history_checkout_total_paid_remaining">Загалом: %1$s Сплачено: %2$s Залишок: %3$s</string> + <string name="order_details_delete_action">Видалити</string> + <string name="settings_manage_connection_action">Налаштування продавця</string> + <string name="settings_logout_action">Вийти</string> + <string name="payment_options_manage_action">Керувати способами оплати</string> + + <string name="order_actions">Дії</string> + <string name="order_current">Поточне замовлення</string> + <string name="amount_entry_hint">Введіть суму замовлення</string> + + <string name="taler_ble_unsupported">Bluetooth недоступний на цьому пристрої.</string> + <string name="taler_ble_permission">Дозвольте доступ до пристроїв поблизу, щоб підключити дисплей.</string> + <string name="taler_ble_off">Увімкніть Bluetooth, щоб підключити дисплей.</string> + <string name="taler_ble_location">Ця версія Android потребує ввімкнення геолокації для пошуку пристроїв Bluetooth.</string> + <string name="taler_ble_scanning">Пошук дисплеїв Taler…</string> + <string name="taler_ble_connecting">Підключення до дисплея…</string> + <string name="taler_ble_sending">Оновлення дисплея…</string> + <string name="taler_ble_too_long">Це посилання задовге для дисплея. Скористайтеся QR-кодом на цьому пристрої.</string> + <string name="taler_ble_failed">Не вдалося оновити дисплей. Перевірте, чи він увімкнений і поруч.</string> + <string name="taler_ble_allow">Дозволити доступ до пристрою</string> + <string name="taler_ble_open_settings">Відкрити налаштування пристрою</string> + <string name="taler_ble_scan">Знайти дисплей Taler</string> + <string name="taler_ble_test">Перевірити дисплей</string> + <string name="taler_ble_reset_limitation">Дисплей може зберігати попередній QR-код після платежу чи перевірки. Перезапустіть дисплей, щоб очистити його, доки прошивка не підтримує скидання.</string> + <string name="taler_ble_reconnect">Підключити знову</string> + <string name="taler_ble_pairing">Сполучення… Введіть код із РК-дисплея в запиті сполучення Android.</string> + <string name="taler_ble_pairing_required">Сполучення не завершилося або збережене сполучення більше не працює.</string> + <string name="taler_ble_forget_explanation">Припинити використання цього дисплея на цій касі? Збережений вибір буде видалено із застосунку. Щоб також видалити сполучення Bluetooth, забудьте дисплей у налаштуваннях Bluetooth Android і виберіть «Забути цю касу» на РК-дисплеї.</string> + + <string name="taler_devices_manage">Керувати дисплеями</string> + <string name="taler_devices_title">Дисплеї Taler</string> + <string name="taler_devices_empty">Дисплеї ще не додані.</string> + <string name="taler_devices_add">Додати пристрій</string> + <string name="taler_devices_check_usb">Перевірити USB-з’єднання</string> + <string name="taler_devices_remove">Видалити дисплей</string> + <string name="taler_devices_replace">Замінити дисплей</string> + <string name="taler_devices_replace_hint">Ця каса використовує лише один дисплей Bluetooth одночасно. Замінити поточний дисплей?</string> + <string name="taler_devices_access">Дозволити підключення Bluetooth</string> + <string name="taler_devices_open_lcd">Відкрити налаштування дисплея</string> + <string name="taler_devices_open_lcd_hint">На дисплеї проведіть ліворуч і натисніть значок шестерні налаштувань.</string> + <string name="taler_devices_pair_lcd">Почати сполучення на дисплеї</string> + <string name="taler_devices_pair_lcd_hint">На дисплеї виберіть Bluetooth → Сполучити касу.</string> + <string name="taler_devices_choose_display">Виберіть свій дисплей</string> + <string name="taler_devices_connect">Підключити дисплей</string> + <string name="taler_devices_connected">Дисплей підключено</string> + <string name="taler_devices_connected_hint">Ваш дисплей готовий до платежів.</string> + <string name="taler_devices_bluetooth">Дисплей Bluetooth</string> + <string name="taler_devices_usb">USB-пристрій</string> + <string name="taler_devices_scan_finished">Виберіть свій дисплей нижче або повторіть пошук, якщо його немає.</string> + <string name="taler_devices_checking">Перевірка з’єднання…</string> + <string name="taler_devices_done">Готово</string> + <string name="taler_devices_next">Далі</string> + <string name="taler_devices_search_again">Шукати знову</string> + <string name="taler_devices_pair_again">Сполучити знову</string> + <string name="taler_devices_repair_android">Забудьте цей дисплей у налаштуваннях Bluetooth Android, а потім поверніться сюди.</string> + <string name="taler_devices_repair_lcd">На дисплеї відкрийте Bluetooth, забудьте стару касу й виберіть «Сполучити касу».</string> + <string name="taler_devices_test_hint">Тестовий QR-код з’явиться на п’ять секунд, а потім зникне. Платіж не створюється.</string> + + <string name="taler_usb_not_attached">Підключіть підтримуваний пристрій Taler через USB.</string> + <string name="taler_usb_device">USB-пристрій Taler %1$d</string> + <string name="taler_usb_permission_required">Потрібен доступ USB.</string> + <string name="taler_usb_allow_device">Дозволити доступ USB Пристрій %1$d</string> + <string name="taler_usb_failed">Не вдалося оновити пристрій. Підключіть його знову або повторіть спробу.</string> + <string name="taler_usb_refresh">Перевірити з’єднання</string> + <string name="checkout_order_claimed">Замовлення прийнято гаманцем.</string> + <string name="checkout_complete_in_wallet">Завершіть оплату в гаманці</string> + <string name="checkout_payment_possible_until">Оплата можлива до %1$s</string> + <string name="checkout_payment_detail_merchant">Продавець</string> + <string name="config_local_network_title">Підключення до локального продавця</string> + <string name="config_local_network_explanation">Сервер продавця %1$s розташований у локальній мережі. Дозвольте доступ до пристроїв поблизу, щоб Taler POS міг підключатися до нього й обробляти замовлення.</string> + <string name="config_local_network_settings">Доступ до локальної мережі заблоковано. Відкрийте налаштування застосунку, виберіть Дозволи → Пристрої поблизу та дозвольте доступ. Потім поверніться сюди для підключення до %1$s.</string> + <string name="config_local_network_allow">Дозволити доступ</string> + <string name="config_local_network_required">Дозвольте доступ до локальної мережі для підключення до цього продавця.</string> + <string name="config_camera_title">Доступ до камери для налаштування за QR-кодом</string> + <string name="config_camera_explanation">Камера сканує QR-код налаштування з Merchant Backoffice, щоб заповнити налаштування облікового запису. Ці дані також можна ввести вручну.</string> + <string name="config_camera_settings_instructions">Доступ до камери заблоковано. Відкрийте налаштування застосунку, виберіть Дозволи → Камера й дозвольте доступ під час використання застосунку. Потім поверніться сюди для сканування.</string> + <string name="config_camera_allow">Дозволити камеру</string> + <string name="config_camera_open_settings">Відкрити налаштування</string> + <string name="config_camera_manual">Налаштувати вручну</string> + <string name="config_connecting">Підключення…</string> + <string name="password_show">Показати пароль</string> + <string name="password_hide">Приховати пароль</string> + <string name="navigation_back">Назад</string> + <string name="navigation_menu_open">Відкрити меню навігації</string> + <string name="order_empty_inventory">Немає товарів для відображення. Створіть замовлення за сумою або додайте власний товар.</string> </resources> diff --git a/merchant-terminal/src/main/res/values/strings.xml b/merchant-terminal/src/main/res/values/strings.xml @@ -81,10 +81,10 @@ <string name="config_changed">Changed to new merchant using %s</string> <string name="config_fetching_label">Fetching configuration</string> <string name="config_docs">Please refer to <a href="https://docs.taler.net/taler-merchant-pos-terminal.html#apis-and-data-formats">the documentation</a> for the configuration format.</string> - <string name="config_no_instance">No instance?</string> + <string name="config_no_instance">No account?</string> <string name="config_instance_tutorial_description">1. Check out the setup tutorials at:</string> <string name="config_instance_tutorial_link" translatable="false">tutorials.taler.net</string> - <string name="config_instance_demo_description">2. You can try the app with the public demo instance. Note that this instance is shared—anyone can modify its data at any time. Use it only to explore the app and its features.</string> + <string name="config_instance_demo_description">2. You can try the app with the public demo account. Note that this account is shared - anyone can modify its data at any time. Use it only to explore the app and its features.</string> <string name="config_fill_demo_credentials">Fill demo credentials</string> <string name="config_instance_review_description">3. If you are reviewing this app, you most likely already have access to a controlled test user. Use the button below to prefill the merchant portal URL.</string> <string name="config_fill_review_url">Fill review URL</string> @@ -168,6 +168,17 @@ <string name="pick_date">Pick date</string> <string name="pick_time">Pick time</string> <string name="session_expired_toast">Session expired – please re-enter your credentials</string> + <string name="config_local_network_title">Connect to a local merchant</string> + <string name="config_local_network_explanation">Your merchant server at %1$s is on a local network. Allow nearby device access so Taler POS can connect to it and process orders.</string> + <string name="config_local_network_settings">Local network access is blocked. Open app settings, choose Permissions → Nearby devices, and allow access. Then return here to connect to %1$s.</string> + <string name="config_local_network_allow">Allow access</string> + <string name="config_local_network_required">Allow local network access to connect to this merchant.</string> + <string name="config_camera_title">Camera access for QR setup</string> + <string name="config_camera_explanation">The camera scans the setup QR code from Merchant Backoffice to fill in your account settings. You can also enter these details manually.</string> + <string name="config_camera_settings_instructions">Camera access is blocked. Open app settings, choose Permissions → Camera, and allow access while using the app. Then return here to scan.</string> + <string name="config_camera_allow">Allow camera</string> + <string name="config_camera_open_settings">Open settings</string> + <string name="config_camera_manual">Use manual config</string> <string name="config_fragment_camera_needed_text">Camera permission is required for QR scanning</string> <string-array name="settings_language_values"> @@ -199,4 +210,346 @@ <item>Українська</item> <item>עברית</item> </string-array> + <!-- Payment options --> + <string name="payment_options_title">Payment options</string> + <string name="payment_options_button">Payment options</string> + <string name="payment_options_description">Taler is always available. Enable additional payment methods or open an option to set it up.</string> + <string name="payment_options_enabled_count">%1$d turned on</string> + <string name="payment_option_open">Open %1$s settings</string> + <string name="payment_option_settings_title">Settings</string> + <string name="payment_option_unknown">This payment option is not available in this build.</string> + <string name="payment_option_disabled">Turned off</string> + <string name="payment_option_ready">Ready</string> + <string name="payment_option_taler">GNU Taler</string> + <string name="payment_option_taler_description">Customers pay from a Taler wallet by scanning the QR code or tapping their phone.</string> + <string name="payment_option_cash">Cash</string> + <string name="payment_option_cash_description">Customers pay in cash at the counter. The order is settled outside the app.</string> + + <!-- Card terminals --> + <string name="terminal_not_configured">Setup is unavailable in this build. Contact your app administrator.</string> + <string name="terminal_sign_in_required">Sign in to pair a reader.</string> + <string name="terminal_permission_required">Nearby devices permission is required. Open reader setup to continue.</string> + <string name="terminal_not_paired">No reader paired</string> + <string name="terminal_disconnected">Reader disconnected</string> + <string name="terminal_connected">Reader connected</string> + <string name="terminal_unavailable">Unable to check reader connection</string> + <string name="terminal_pair">Pair reader</string> + <string name="terminal_manage">Manage reader</string> + <string name="terminal_account">Account: %1$s</string> + <string name="terminal_serial">Serial number: %1$s</string> + <string name="terminal_indicator_description">Payment devices: %1$s</string> + <string name="terminal_close">Close</string> + <string name="terminal_action_failed">Terminal setup could not be completed. Check Bluetooth, permissions and your internet connection, then try again.</string> + +<!-- SumUp provider --> + <string name="terminal_sumup">SumUp</string> + <string name="terminal_sumup_description">Customers pay by card on a SumUp reader paired with this device.</string> + <string name="terminal_sumup_sign_in">Sign in to SumUp</string> + <string name="terminal_sumup_sign_out">Sign out of SumUp</string> + <string name="terminal_sumup_save_key">Save affiliate key</string> + <string name="terminal_sumup_advanced">Advanced</string> + <string name="terminal_sumup_advanced_help">The affiliate key identifies this app to SumUp, not your business. You almost never need to change it. Replace it only if you have your own SumUp partner registration.</string> + <string name="terminal_sumup_key_builtin">Using the affiliate key built into this app.</string> + <string name="terminal_sumup_key_custom">Using a custom affiliate key.</string> + <string name="terminal_sumup_key_hint">New affiliate key</string> + <string name="terminal_sumup_key_show">Show key</string> + <string name="terminal_sumup_key_hide">Hide key</string> + <string name="terminal_sumup_key_warning">Saving a different key signs you out of SumUp.</string> + <string name="terminal_sumup_reset_key">Use the built-in key</string> + <string name="terminal_sumup_session_help">A SumUp sign-in lasts about an hour. With your own OAuth application the app renews it by itself, so staff never have to sign in again.</string> + <string name="terminal_sumup_session_no_client">No OAuth application configured. Add one under Advanced to keep the session alive.</string> + <string name="terminal_sumup_session_not_authorized">Not authorised yet. Authorise once to keep this device signed in.</string> + <string name="terminal_sumup_session_expires">Access token expires in %1$s</string> + <string name="terminal_sumup_session_refreshes">Renewed %1$d times, last at %2$s</string> + <string name="terminal_sumup_session_error">Last renewal problem: %1$s</string> + <string name="terminal_sumup_authorize">Authorise this device</string> + <string name="terminal_sumup_reauthorize">Authorise again</string> + <string name="terminal_sumup_refresh_now">Renew token now</string> + <string name="terminal_sumup_revoke">Forget authorisation</string> + <string name="terminal_sumup_oauth_help">Register an OAuth application in your SumUp dashboard with the redirect URL %1$s, then paste its client ID and secret here. They stay on this device.</string> + <string name="terminal_sumup_oauth_set">An OAuth application is configured.</string> + <string name="terminal_sumup_oauth_unset">No OAuth application configured.</string> + <string name="terminal_sumup_client_id">OAuth client ID</string> + <string name="terminal_sumup_client_secret">OAuth client secret</string> + <string name="terminal_sumup_save_client">Save OAuth application</string> + <string name="payment_device_connected">%1$s connected</string> + <string name="payment_device_disconnected">%1$s not connected</string> + <string name="payment_device_nfc">NFC</string> + <string name="payment_device_nfc_available">available</string> + <string name="payment_device_status_connected">Connected</string> + <string name="payment_device_status_disconnected">Not connected</string> + <string name="payment_device_battery">Battery %1$d%%</string> + + <!-- Checkout: paying in several parts --> + <string name="checkout_title">Payment</string> + <string name="checkout_total">Total</string> + <string name="checkout_paid">Paid</string> + <string name="checkout_open">Still to pay</string> + <string name="checkout_payments_button">Payments so far (%1$d)</string> + <string name="checkout_payments_title">Payments so far</string> + <string name="checkout_payments_empty">No payments taken yet.</string> + <string name="checkout_taler_last_hint">Take cash and card first. GNU Taler always comes last and pays whatever is still open.</string> + <string name="checkout_no_taler_hint">GNU Taler is turned off, so the whole amount has to be paid with the other options.</string> + <string name="checkout_order_created_hint">The order has been created, so no more cash or card can be added. GNU Taler pays the rest.</string> + <string name="checkout_cancel">Cancel payment</string> + <string name="checkout_cash_title">Cash</string> + <string name="checkout_cash_received">Received</string> + <string name="checkout_cash_change">Change</string> + <string name="checkout_cash_exact">Exact amount</string> + <string name="checkout_cash_record">Record cash</string> + <string name="checkout_change_due_title">Give change</string> + <string name="checkout_change_due">Hand %1$s back to the customer.</string> + <string name="checkout_card_title">Card payment</string> + <string name="checkout_card_amount">Amount to charge</string> + <string name="checkout_card_full">Whole open amount</string> + <string name="checkout_card_charge">Charge card</string> + <string name="checkout_card_too_much">More than what is still open</string> + <string name="checkout_card_not_ready">The card reader is not ready. Check that you are signed in and the reader is connected.</string> + <string name="checkout_card_recorded">Card payment of %1$s recorded</string> + <string name="checkout_card_failed">The card payment did not go through.</string> + <string name="checkout_card_unknown_title">Card payment unconfirmed</string> + <string name="checkout_card_unknown">It could not be confirmed whether %1$s was charged. Check the transaction in the card provider\'s app before charging again. Nothing was recorded.</string> + <string name="checkout_record_failed">The payment could not be saved. Do not hand over change until it is recorded.</string> + <string name="checkout_none">This payment is no longer open.</string> + <string name="checkout_payment_line_change">Received %1$s, change %2$s</string> + <string name="checkout_payment_line_reference">Reference %1$s</string> + <string name="checkout_history_title">Unfinished payments</string> + <string name="checkout_history_continue">Continue</string> + <string name="checkout_history_dismiss">Mark as settled</string> + <string name="checkout_history_amounts">Paid %1$s of %2$s</string> + <string name="checkout_stage_collecting">Taking payments</string> + <string name="checkout_stage_awaiting_taler">Waiting for GNU Taler</string> + <string name="checkout_stage_abandoned">Cancelled, money to hand back</string> + <string name="payment_options_mixed_unsupported">Paying in several parts needs merchant backend protocol v42 or newer. This backend reports %1$s.</string> + <string name="checkout_total_amount">Total: %1$s</string> + <string name="checkout_paid_amount">Paid: %1$s</string> + <string name="checkout_total_to_pay">To pay: %1$s</string> + <string name="checkout_about">About payment</string> + <string name="checkout_about_message">%1$s has already been taken. Cancelling now means handing the cash back, and refunding any card payment in the card provider\'s own app.</string> + <string name="checkout_cancel_anyway">Cancel payment anyway</string> + <string name="checkout_settled_hint">Everything has been paid. Finish the order to complete it.</string> + <string name="checkout_tile_finish_order">Finish order</string> + <string name="checkout_self_collect">Collect for the customer</string> + <string name="checkout_back_to_options">Back to payment options</string> + <string name="checkout_revert_failed">The order could not be taken back.</string> + <string name="payment_option_activation_title">Before you turn this on</string> + <string name="payment_option_activation_confirm">Turn on</string> + <string name="terminal_sumup_activation_notice">With SumUp\'s current software this app can charge cards, but it cannot give money back. A refund has to be handed over in cash, done with another payment method, or processed in SumUp\'s own app.\n\nPlease check with your accountant or legal advisor that this fits how your business has to handle returns.</string> + <string name="history_taler_remaining">Remaining with GNU Taler: %1$s</string> + <string name="history_status_partially_paid">Partially paid</string> + <string name="history_status_awaiting_collection">Awaiting collection</string> + <string name="history_taler_paid">Paid with GNU Taler: %1$s</string> + + <!-- Order details --> + <string name="order_details_title">Order details</string> + <string name="order_details_loading">Loading order details…</string> + <string name="order_details_unavailable">Details unavailable</string> + <string name="order_details_load_error">Could not load this order. Check your connection and try again.</string> + <string name="order_details_retry">Try again</string> + <string name="order_details_fulfillment_link">Fulfillment link</string> + <string name="order_details_not_provided">The merchant did not provide further details for this order.</string> + <string name="order_details_order_id">Order reference</string> + <string name="order_details_refund_total">Refunded amount</string> + <string name="order_details_summary">Order summary</string> + <string name="order_details_products">Products</string> + <string name="order_details_no_products">No itemized products were provided.</string> + <string name="order_details_net_price">%1$s (net)</string> + <string name="order_details_payments">Payment breakdown</string> + <string name="order_details_paid_total">Total paid</string> + <string name="order_details_balance">Remaining to pay</string> + <string name="order_details_delivery">Delivery / Pickup</string> + <string name="order_details_delivery_location">Delivery location</string> + <string name="order_details_delivery_date">Delivery date</string> + <string name="order_details_pickup_time">Maximum pickup time</string> + <string name="order_details_reorder">Reorder</string> + <string name="order_details_reorder_hint">Share this link to order the same products again.</string> + <string name="order_details_reorder_link">Reorder link</string> + <string name="order_details_additional">Additional information</string> + <string name="order_details_pay_deadline">Payment deadline</string> + <string name="order_details_refund_deadline">Refund deadline</string> + <string name="order_details_merchant">Merchant details</string> + <string name="order_details_jurisdiction">Jurisdiction</string> + <string name="order_details_email">Email</string> + <string name="order_details_website">Website</string> + <string name="order_details_open_link">Open link</string> + <string name="order_details_copied">Copied to clipboard</string> + <string name="order_details_copy">Copy %1$s</string> + <string name="order_details_open_failed">No app is available to open this link.</string> + + <!-- Refund method selection and order deletion --> + <string name="refund_choose_method">Refund method</string> + <string name="refund_available">Available to refund</string> + <string name="refund_unavailable">Unavailable</string> + <string name="refund_unavailable_hint">This method cannot be refunded here. External refunds require a paid order and its payment record on this device. Taler refunds must still be available.</string> + <string name="refund_taler_button">Refund Taler</string> + <string name="refund_taler_hint">Approve the refund, then let the customer collect it with their Taler wallet.</string> + <string name="refund_cash_hint">Hand the cash back to the customer, then record the amount returned.</string> + <string name="refund_card_hint">Refund the original card payment in the card provider’s app, then record the amount returned here. This screen does not send money to the card.</string> + <string name="refund_cash_confirm">Confirm that this cash has been handed back to the customer.</string> + <string name="refund_card_confirm">Confirm that the card provider has completed this refund. Recording it here does not issue another refund.</string> + <string name="refund_returned_confirm">Money returned - record refund</string> + <string name="refund_record_external">Record refund</string> + <string name="refund_recorded">Refund recorded</string> + <string name="refund_recorded_message">The returned amount has been recorded on the order.</string> + <string name="refund_recorded_local">Saved on this device, but the order could not be updated. Do not return this money again. Backend message: %1$s</string> + <string name="order_delete_final_title">Delete this order permanently?</string> + <string name="order_delete_warning">Deleting removes this unpaid order. It does not return cash, refund a card payment, or issue a Taler refund. Settle any payments with the customer before continuing.</string> + <string name="order_delete_final_warning">This cannot be undone. Confirm that all payments have been settled and that you want to permanently delete this order.</string> + <string name="order_delete_continue">Continue</string> + <string name="product_image_open">View image: %1$s</string> + <string name="product_image_close">Close image</string> + <string name="payment_pay_with">Pay with</string> + <string name="payment_brand_name" translatable="false">Taler</string> + + <string name="checkout_complete_payment">Complete payment</string> + <string name="checkout_collect_receipt_hint">The customer can scan the next screen to collect the receipt in a Taler wallet.</string> + <string name="checkout_collect_receipt_title">Collect receipt</string> + <string name="checkout_receipt_paid_in_full">Paid in full</string> + <string name="checkout_receipt_scan_hint">Scan with a Taler wallet to collect the receipt.</string> + <string name="checkout_collect_receipt_on_device">Collect receipt on this device</string> + <string name="checkout_payment_claimed_title">Payment in progress</string> + <string name="checkout_customer_opened_payment">The customer has opened this payment in their wallet.</string> + <string name="checkout_payment_claimed_hint">Waiting for customer confirmation</string> + <string name="checkout_enter_cash_amount">Enter cash amount %1$s remaining</string> + <string name="checkout_enter_card_amount">Enter card amount %1$s remaining</string> + <string name="checkout_tap_to_enter_amount">Tap to enter amount</string> + <string name="checkout_cash_entry_instruction">Enter the amount received</string> + <string name="checkout_card_entry_instruction">Enter the amount to charge</string> + <string name="checkout_fraction_full">Full amount</string> + <string name="checkout_fraction_full_short">Full</string> + <string name="checkout_fraction_remaining">Remaining</string> + <string name="checkout_fraction_half_description">Half of remaining balance, %1$s</string> + <string name="checkout_fraction_third_description">Third of remaining balance, %1$s</string> + <string name="checkout_fraction_quarter_description">Quarter of remaining balance, %1$s</string> + <string name="checkout_fraction_remaining_description">Remaining balance, %1$s</string> + <string name="checkout_charge_with_taler">Charge with Taler</string> + <string name="checkout_charge_remaining_taler">Charge remaining with Taler</string> + <string name="checkout_order_total_heading">Order total</string> + <string name="checkout_payments_heading">Payments</string> + <string name="checkout_payments_added">%1$d added</string> + <string name="checkout_payments_empty_inline">Added payments will appear here.</string> + <string name="checkout_about_action">About this checkout</string> + <string name="checkout_fully_paid">Fully paid</string> + <string name="checkout_scan_to_pay_hint">Ask the customer to scan the QR code on the next screen to pay.</string> + <string name="checkout_scan_qr_prompt">Scan the QR code with your mobile wallet.</string> + <string name="checkout_payment_detail_order">Order</string> + <string name="checkout_payment_detail_summary">Summary</string> + <string name="checkout_waiting_customer_payment">Waiting for payment…</string> + <string name="checkout_remaining_heading">Remaining</string> + <string name="checkout_order_reference">Order #%1$d</string> + <string name="checkout_choose_payment_method">Choose payment method</string> + <string name="checkout_payment_method_helper">Add cash or card, or pay the balance with Taler.</string> + <string name="checkout_complete_order_hint">Completes this order</string> + <string name="checkout_charge_remaining_hint">Pays the remaining balance</string> + <string name="checkout_collect_receipt_action">Collect receipt with Taler</string> + <string name="checkout_remaining_amount">Remaining: %1$s</string> + <string name="checkout_remaining_to_pay">Remaining to pay %1$s</string> + <string name="checkout_exact_cash_amount">Exact amount %1$s</string> + <string name="checkout_full_card_amount">Full remaining amount %1$s</string> + <string name="checkout_remaining_after_payment">Remaining after payment: %1$s</string> + <string name="checkout_change_to_return_amount">Change to return: %1$s</string> + <string name="checkout_cash_change_explanation">Cash above the balance shows change to return.</string> + <string name="checkout_record_cash_action">Record %1$s cash</string> + <string name="checkout_charge_card_action">Charge %1$s on card</string> + <string name="checkout_cash_received_heading">Cash received</string> + <string name="checkout_card_payment_heading">Card payment</string> + <string name="checkout_card_amount_limit_hint">Card amount cannot exceed the remaining balance.</string> + + <string name="checkout_creation_pending">The order may already exist. Retry the Taler step to recover it before changing payments.</string> + <string name="checkout_pending_charge">Resolve the pending card payment of %1$s with %2$s. Check the final status in the provider’s records using reference %3$s before continuing.</string> + <string name="checkout_pending_paid">Payment confirmed in provider records</string> + <string name="checkout_pending_not_paid">Provider confirmed no charge</string> + <string name="checkout_pending_confirm_title">Confirm the payment outcome</string> + <string name="checkout_pending_confirm_paid">Confirm that this exact payment succeeded. Enter its transaction reference from the provider.</string> + <string name="checkout_pending_confirm_unpaid">Continue only if the provider confirms this payment failed or was cancelled and cannot settle later. A pending or missing result is not confirmation.</string> + <string name="checkout_pending_transaction">Provider transaction reference</string> + + <string name="history_checkout_progress">Paid: %1$s Remaining: %2$s</string> + <string name="history_total_paid_remaining">Total: %1$s Paid: %2$s Remaining: %3$s</string> + <string name="history_checkout_total_paid_remaining">Total: %1$s Paid: %2$s Remaining: %3$s</string> + <string name="order_details_delete_action">Delete</string> + <string name="settings_manage_connection_action">Merchant settings</string> + <string name="settings_logout_action">Logout</string> + <string name="payment_options_manage_action">Manage payment options</string> + + <string name="order_actions">Actions</string> + <string name="order_actions_collapsed">Actions</string> + <string name="order_actions_expanded">Actions</string> + <string name="order_current">Current order</string> + <string name="order_custom_short">Custom</string> + <string name="amount_entry_hint">Enter the order amount</string> + + <string name="taler_ble_title">Taler Bluetooth display</string> + <string name="taler_ble_description">Show payment and refund QR codes on a Taler LCD and let customers tap it with NFC.</string> + <string name="taler_ble_unsupported">Bluetooth is not available on this device.</string> + <string name="taler_ble_permission">Allow nearby device access to connect the display.</string> + <string name="taler_ble_off">Turn on Bluetooth to connect the display.</string> + <string name="taler_ble_location">This Android version requires location to be enabled for Bluetooth discovery.</string> + <string name="taler_ble_scanning">Looking for Taler displays…</string> + <string name="taler_ble_connecting">Connecting to the display…</string> + <string name="taler_ble_sending">Updating the display…</string> + <string name="taler_ble_too_long">This link is too long for the display. Use the QR code on this device.</string> + <string name="taler_ble_failed">Could not update the display. Check that it is switched on and nearby.</string> + <string name="taler_ble_allow">Allow device access</string> + <string name="taler_ble_open_settings">Open device settings</string> + <string name="taler_ble_scan">Find Taler display</string> + <string name="taler_ble_test">Test display</string> + <string name="taler_ble_test_hint">Sends a test QR code for five seconds, then requests a reset. No payment is created.</string> + <string name="taler_ble_reset_limitation">This display may keep the previous QR code after a payment or test. Restart the display to clear it until its firmware supports resetting.</string> + <string name="taler_ble_reconnect">Reconnect</string> + <string name="taler_ble_forget">Remove from this POS</string> + <string name="taler_ble_pair_instructions">On the LCD, swipe left, open Settings → Bluetooth → Pair a POS. Then find and select the matching Taler display here.</string> + <string name="taler_ble_pairing">Pairing… Enter the code shown on the LCD in the Android pairing prompt.</string> + <string name="taler_ble_pairing_required">Pairing did not complete or the saved bond no longer works.</string> + <string name="taler_ble_repair_instructions">To pair again, forget this display in Android Bluetooth settings. On the LCD, forget the old POS if listed, then choose Pair a POS. Return here and reconnect.</string> + <string name="taler_ble_forget_explanation">Stop using this display on this POS? This removes the saved selection from the app. To release the Bluetooth pairing too, forget the display in Android Bluetooth settings and choose Forget this POS on the LCD.</string> + + <string name="taler_devices_manage">Manage displays</string> + <string name="taler_devices_title">Taler displays</string> + <string name="taler_devices_empty">No displays added yet.</string> + <string name="taler_devices_add">Add device</string> + <string name="taler_devices_check_usb">Check USB connection</string> + <string name="taler_devices_remove">Remove display</string> + <string name="taler_devices_replace">Replace display</string> + <string name="taler_devices_replace_hint">This POS uses one Bluetooth display at a time. Replace the current display?</string> + <string name="taler_devices_access">Allow Bluetooth connection</string> + <string name="taler_devices_open_lcd">Open display settings</string> + <string name="taler_devices_open_lcd_hint">On the display, swipe left and tap the settings gear.</string> + <string name="taler_devices_pair_lcd">Start pairing on the display</string> + <string name="taler_devices_pair_lcd_hint">Choose Bluetooth → Pair a POS on the display.</string> + <string name="taler_devices_choose_display">Choose your display</string> + <string name="taler_devices_connect">Connect display</string> + <string name="taler_devices_connected">Display connected</string> + <string name="taler_devices_connected_hint">Your display is ready for payments.</string> + <string name="taler_devices_bluetooth">Bluetooth display</string> + <string name="taler_devices_usb">USB device</string> + <string name="taler_devices_scan_finished">Select your display below, or search again if it is missing.</string> + <string name="taler_devices_checking">Checking connection…</string> + <string name="taler_devices_done">Done</string> + <string name="taler_devices_next">Next</string> + <string name="taler_devices_search_again">Search again</string> + <string name="taler_devices_pair_again">Pair again</string> + <string name="taler_devices_repair_android">Forget this display in Android Bluetooth settings, then return here.</string> + <string name="taler_devices_repair_lcd">On the display, open Bluetooth, forget the old POS, then choose Pair a POS.</string> + <string name="taler_devices_test_hint">A test QR code appears for five seconds, then clears. No payment is created.</string> + + <string name="taler_usb_title">Taler USB device</string> + <string name="taler_usb_description">Send the payment or refund link to a connected Taler NFC device. QR codes remain available.</string> + <string name="taler_usb_experimental">Experimental support. Clearing the previous link still needs verification with this firmware. Disconnect the device after use until this has been verified.</string> + <string name="taler_usb_not_attached">Connect a supported Taler device by USB.</string> + <string name="taler_usb_device">Taler USB device %1$d</string> + <string name="taler_usb_permission_required">USB access is required.</string> + <string name="taler_usb_allow_device">Allow USB access Device %1$d</string> + <string name="taler_usb_failed">Could not update the device. Reconnect it or try again.</string> + <string name="taler_usb_refresh">Check connection</string> + <string name="checkout_order_claimed">Order claimed by wallet.</string> + <string name="checkout_complete_in_wallet">Complete payment in your wallet</string> + <string name="checkout_payment_possible_until">Payment possible until %1$s</string> + <string name="checkout_payment_detail_merchant">Merchant</string> + <string name="config_connecting">Connecting…</string> + <string name="password_show">Show password</string> + <string name="password_hide">Hide password</string> + <string name="navigation_back">Back</string> + <string name="navigation_menu_open">Open navigation menu</string> + <string name="order_empty_inventory">No products to display. Use Order by amount or add a custom product.</string> </resources> diff --git a/merchant-terminal/src/release/java/net/taler/merchantpos/debug/ScreenshotController.kt b/merchant-terminal/src/release/java/net/taler/merchantpos/debug/ScreenshotController.kt @@ -1,5 +1,6 @@ package net.taler.merchantpos.debug -object ScreenshotController { - const val isActive: Boolean = false +object ScreenshotController +{ + const val IS_ACTIVE: Boolean = false } diff --git a/merchant-terminal/src/test/java/net/taler/merchantpos/amount/AmountEntryDraftTest.kt b/merchant-terminal/src/test/java/net/taler/merchantpos/amount/AmountEntryDraftTest.kt @@ -0,0 +1,58 @@ +/* + * 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.amount + +import android.os.Bundle + +import net.taler.common.Amount +import net.taler.common.CurrencySpecification + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28]) +class AmountEntryDraftTest +{ + @Test + fun `saved draft preserves integer and high precision fraction with currency spec`() + { + val spec = + CurrencySpecification( + name = "CHF", + numFractionalInputDigits = 2, + numFractionalNormalDigits = 2, + numFractionalTrailingZeroDigits = 2, + altUnitNames = mapOf(0 to "CHF"), + ) + val draft = Amount("CHF", 1_234_567L, 12_345_678, spec) + val state = Bundle() + + saveAmountEntryDraft(state, draft.currency, draft) + val restored = restoreAmountEntryDraft(state, "CHF", spec) + + assertNotNull(restored) + assertEquals("CHF", restored?.currency) + assertEquals(draft.value, restored?.amount?.value) + assertEquals(draft.fraction, restored?.amount?.fraction) + assertEquals(spec, restored?.amount?.spec) + } +} diff --git a/merchant-terminal/src/test/java/net/taler/merchantpos/checkout/CheckoutAmountFractionTest.kt b/merchant-terminal/src/test/java/net/taler/merchantpos/checkout/CheckoutAmountFractionTest.kt @@ -0,0 +1,54 @@ +/* + * 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.checkout + +import net.taler.common.Amount +import net.taler.common.CurrencySpecification + +import org.junit.Assert.assertEquals +import org.junit.Test + +class CheckoutAmountFractionTest +{ + private val spec = + CurrencySpecification( + name = "CHF", + numFractionalInputDigits = 2, + numFractionalNormalDigits = 0, + numFractionalTrailingZeroDigits = 2, + altUnitNames = mapOf(0 to "CHF"), + ) + + @Test + fun `fractions round down at input precision and full stays exact`() + { + val remaining = Amount.fromString("CHF", "10.01") + + assertEquals(Amount.fromString("CHF", "5.00"), remaining.fractionAmount(2, spec)) + assertEquals(Amount.fromString("CHF", "3.33"), remaining.fractionAmount(3, spec)) + assertEquals(Amount.fromString("CHF", "2.50"), remaining.fractionAmount(4, spec)) + assertEquals(remaining, remaining.fractionAmount(1, spec)) + } + + @Test + fun `fraction smaller than one input unit becomes zero`() + { + val remaining = Amount.fromString("CHF", "0.01") + + assertEquals(Amount.zero("CHF"), remaining.fractionAmount(2, spec)) + } +} diff --git a/merchant-terminal/src/test/java/net/taler/merchantpos/checkout/CheckoutManagerTest.kt b/merchant-terminal/src/test/java/net/taler/merchantpos/checkout/CheckoutManagerTest.kt @@ -0,0 +1,1063 @@ +/* + * 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.checkout + +import android.app.Application + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CreditCard +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Observer + +import net.taler.common.Amount +import net.taler.common.ExternalPayment +import net.taler.common.Timestamp +import net.taler.merchantlib.ExternalRefundInfo +import net.taler.merchantlib.ExternalRefundRequest +import net.taler.merchantlib.OrderHistoryEntry +import net.taler.merchantlib.PostOrderRequest +import net.taler.merchantpos.R +import net.taler.merchantpos.config.ConfigProduct +import net.taler.merchantpos.order.Order +import net.taler.merchantpos.paymentoptions.ChargeResult +import net.taler.merchantpos.paymentoptions.ExternalCharge +import net.taler.merchantpos.paymentoptions.PaymentOption +import net.taler.merchantpos.paymentoptions.RefundOutcome +import net.taler.merchantpos.refund.externalRefundRemaining + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +import java.io.File +import java.io.IOException + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.runBlocking + +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class, sdk = [28]) +class CheckoutManagerTest +{ + @get:Rule + val folder = TemporaryFolder() + + private lateinit var file: File + private val backend = FakeBackend() + private val merchant = MutableLiveData<String?>("merchant-a") + private var backendLookup: () -> CheckoutBackend? = { backend } + private val settled = mutableListOf<Int>() + private var version: String? = "42:0:0" + private var externalEnabled = true + private var ids = 0 + private val card = FakeCardOption(ChargeResult.Success("TX1")) + + @Before + fun setUp() + { + file = File(folder.root, "checkouts.json") + } + + private fun manager() = + CheckoutManager( + store = CheckoutStore(file), + backendForCurrentMerchant = { backendLookup() }, + merchantIdentity = merchant, + backendVersion = { version }, + externalOptionsEnabled = { externalEnabled }, + optionFor = { method -> card.takeIf { it.id == method } }, + onOrderSettled = { settled += it }, + now = { 1_000 }, + newId = { "id${ids++}" }, + ) + + private fun chf(value: Long) = Amount("CHF", value, 0) + + private fun order(total: Long = 20) = + Order( + id = 7, + currency = "CHF", + currencySpec = null, + availableCategories = emptyMap(), + products = + listOf( + ConfigProduct( + description = "Lunch", + price = chf(total), + categories = listOf(Int.MIN_VALUE), + quantity = 1, + ), + ), + ) + + @Test + fun `mixed payments need a backend that speaks protocol 42`() + { + version = "41:0:5" + assertFalse(manager().shouldUseCheckout()) + version = "42:0:0" + assertTrue(manager().shouldUseCheckout()) + version = null + assertFalse(manager().shouldUseCheckout()) + } + + @Test + fun `with only Taler enabled the checkout is skipped`() + { + externalEnabled = false + assertFalse(manager().shouldUseCheckout()) + } + + @Test + fun `recorded cash survives the app being restarted`() + { + val first = manager() + val checkout = first.start(order(), includeProducts = false) + first.recordCash(chf(5)) + + val restarted = manager() + + val loaded = restarted.find(checkout.id)!! + assertEquals(chf(5), loaded.paid) + assertEquals(chf(15), loaded.open) + assertEquals(listOf(loaded), restarted.unfinished()) + } + + @Test + fun `finishing creates one backend order for the open amount with every external payment`() = + runBlocking { + val manager = manager() + manager.start(order(), includeProducts = false) + manager.recordCash(chf(5)) + manager.recordExternalPayment("sumup", chf(10), "card-1", "TX1") + + val result = manager.finishWithTaler() + + assertTrue(result is CheckoutResult.AwaitingWallet) + val request = backend.created.single() + assertEquals(chf(5), request.contractTerms.amount) + assertEquals(listOf("cash", "sumup"), request.contractTerms.amountExternal!!.map { it.method }) + assertEquals(CheckoutStage.AwaitingTaler, manager.current!!.stage) + + // Finishing again, for example after going back, does not create a second order. + manager.finishWithTaler() + assertEquals(1, backend.created.size) + } + + @Test + fun `an order created just before a crash is adopted instead of duplicated`() = + runBlocking { + val manager = manager() + val checkout = manager.start(order(), includeProducts = false) + manager.recordCash(chf(5)) + // The backend already has the order under its fixed id, so creating it again fails. + backend.existing[checkout.backendOrderIdToCreate] = false + backend.createFails = true + + val result = manager.finishWithTaler() + + assertTrue(result is CheckoutResult.AwaitingWallet) + assertEquals(checkout.backendOrderIdToCreate, manager.current!!.backendOrderId) + } + + @Test + fun `an unreachable backend freezes the checkout for another try`() = + runBlocking { + val manager = manager() + manager.start(order(), includeProducts = false) + backend.createFails = true + + val result = manager.finishWithTaler() + + assertTrue(result is CheckoutResult.Failed) + assertEquals(CheckoutStage.Collecting, manager.current!!.stage) + assertTrue(manager.current!!.creationPending) + assertFalse(manager.current!!.canTakeExternalPayments) + assertNull(manager.current!!.backendOrderId) + } + + @Test + fun `a sale paid fully in cash is collected by the merchant and clears the order`() = + runBlocking { + val manager = manager() + manager.start(order(), includeProducts = false) + manager.recordCash(chf(50)) + + val result = manager.collectByMerchant() + + assertEquals(CheckoutResult.Completed, result) + assertTrue( + backend.created + .single() + .contractTerms.amount + .isZero(), + ) + assertEquals(1, backend.collected.size) + assertEquals(listOf(7), settled) + assertNull(manager.current) + assertTrue(manager.unfinished().isEmpty()) + } + + @Test + fun `collect is refused while Taler still has something to pay`() = + runBlocking { + val manager = manager() + manager.start(order(), includeProducts = false) + manager.recordCash(chf(5)) + + assertTrue(manager.collectByMerchant() is CheckoutResult.Failed) + assertTrue(backend.created.isEmpty()) + } + + @Test + fun `a failed collect still completes when the customer's wallet already took the free order`() = + runBlocking { + val manager = manager() + manager.start(order(), includeProducts = false) + manager.recordCash(chf(20)) + backend.collectFails = true + backend.paidAfterCollect = true + + assertEquals(CheckoutResult.Completed, manager.collectByMerchant()) + } + + @Test + fun `the wallet paying the rest completes the checkout`() = + runBlocking { + val manager = manager() + manager.start(order(), includeProducts = false) + manager.recordCash(chf(5)) + val awaiting = manager.finishWithTaler() as CheckoutResult.AwaitingWallet + + manager.onBackendOrderPaid(awaiting.checkout.backendOrderId!!) + + assertEquals(CheckoutStage.Completed, manager.find(awaiting.checkout.id)!!.stage) + assertEquals(listOf(7), settled) + } + + @Test + fun `after a restart a completed checkout does not touch the new order list`() = + runBlocking { + val before = manager() + before.start(order(), includeProducts = false) + before.recordCash(chf(5)) + val orderId = (before.finishWithTaler() as CheckoutResult.AwaitingWallet).checkout.backendOrderId!! + + manager().onBackendOrderPaid(orderId) + + assertTrue(settled.isEmpty()) + } + + @Test + fun `abandoning before any payment discards the checkout`() = + runBlocking { + val manager = manager() + manager.start(order(), includeProducts = false) + + assertEquals(AbandonResult.Discarded, manager.abandon()) + assertTrue(manager().checkouts.value!!.isEmpty()) + } + + @Test + fun `abandoning keeps the checkout when its backend order cannot be deleted`() = + runBlocking { + val manager = manager() + val checkout = manager.start(order(), includeProducts = false) + manager.finishWithTaler() + backend.deleteFails = true + + assertTrue(manager.abandon() is AbandonResult.KeptForSettlement) + assertNotNull(manager().find(checkout.id)?.backendOrderId) + assertTrue(backend.deleted.isEmpty()) + } + + @Test + fun `abandoning after taking cash keeps the record and never deletes its order`() = + runBlocking { + val manager = manager() + manager.start(order(), includeProducts = false) + manager.recordCash(chf(5)) + manager.finishWithTaler() + + val result = manager.abandon() + + assertTrue(result is AbandonResult.KeptForSettlement) + assertTrue(backend.deleted.isEmpty()) + val kept = manager().unfinished().single() + assertEquals(CheckoutStage.Abandoned, kept.stage) + assertEquals(chf(5), kept.paid) + } + + @Test + fun `a card charge reported twice is only recorded once`() + { + val manager = manager() + manager.start(order(), includeProducts = false) + manager.recordExternalPayment("sumup", chf(5), "card-1", "TX1") + manager.recordExternalPayment("sumup", chf(5), "card-1", "TX1") + assertEquals(chf(5), manager.current!!.paid) + } + + @Test + fun `a confirmed card charge is recorded with its reference and transaction code`() + { + val manager = manager() + manager.start(order(), includeProducts = false) + card.chargeResult = ChargeResult.Success("TX42") + + assertTrue(manager.chargeWith(card, chf(12), "Lunch")) + + val payment = manager.current!!.payments.single() + assertEquals(card.started!!.reference, payment.id) + assertEquals("TX42", payment.transactionCode) + assertEquals("card", payment.method) + assertEquals(chf(8), manager.current!!.open) + assertTrue(manager.chargeEvent.value!!.result is ChargeResult.Success) + } + + @Test + fun `an unconfirmed card charge is reported but never booked`() + { + val manager = manager() + manager.start(order(), includeProducts = false) + + card.chargeResult = ChargeResult.Unknown("timeout") + manager.chargeWith(card, chf(12), "Lunch") + + assertTrue(manager.current!!.payments.isEmpty()) + assertTrue(manager.chargeEvent.value!!.result is ChargeResult.Unknown) + } + + @Test + fun `a card charge above what is open never reaches the terminal`() + { + val manager = manager() + manager.start(order(), includeProducts = false) + assertFalse(manager.chargeWith(card, chf(21), "Lunch")) + assertNull(card.started) + } + + @Test + fun `payments are on disk before anything is posted and survive a rollback and a restart`() = + runBlocking { + val manager = manager() + manager.start(order(), includeProducts = false) + manager.recordCash(chf(5)) + manager.recordExternalPayment("sumup", chf(3), "card-1", "TX1") + + // Saved before the backend has heard anything about this sale. + assertTrue(backend.created.isEmpty()) + assertEquals(chf(8), manager().unfinished().single().paid) + + manager.finishWithTaler() + assertEquals(RevertResult.Reverted, manager.revertTalerStep()) + + val checkout = manager().unfinished().single() + assertEquals(CheckoutStage.Collecting, checkout.stage) + assertEquals(chf(8), checkout.paid) + assertEquals(listOf("cash", "sumup"), checkout.payments.map { it.method }) + assertTrue(checkout.canTakeExternalPayments) + } + + @Test + fun `going back from Taler deletes the order and lets cash be added again`() = + runBlocking { + val manager = manager() + manager.start(order(), includeProducts = false) + manager.recordCash(chf(5)) + manager.finishWithTaler() + + assertEquals(RevertResult.Reverted, manager.revertTalerStep()) + + val checkout = manager.current!! + assertEquals(CheckoutStage.Collecting, checkout.stage) + assertNull(checkout.backendOrderId) + assertTrue(checkout.canTakeExternalPayments) + // The order held cash, so removing it has to be forced. + assertEquals(listOf(true), backend.forcedDeletes) + // The cash itself is untouched. + assertEquals(chf(5), checkout.paid) + } + + @Test + fun `a second try after going back creates a new order rather than reusing the old id`() = + runBlocking { + val manager = manager() + manager.start(order(), includeProducts = false) + manager.recordCash(chf(5)) + manager.finishWithTaler() + manager.revertTalerStep() + manager.recordCash(chf(5)) + + manager.finishWithTaler() + + val ids = backend.created.map { it.contractTerms.orderId } + assertEquals(2, ids.size) + assertEquals(ids[0] + "-1", ids[1]) + // The new order asks Taler for what is left after the second cash payment. + assertEquals(chf(10), backend.created[1].contractTerms.amount) + } + + @Test + fun `going back completes the sale when the wallet paid in the meantime`() = + runBlocking { + val manager = manager() + manager.start(order(), includeProducts = false) + manager.recordCash(chf(5)) + val awaiting = manager.finishWithTaler() as CheckoutResult.AwaitingWallet + backend.existing[awaiting.checkout.backendOrderId!!] = true + + assertEquals(RevertResult.AlreadyPaid, manager.revertTalerStep()) + + assertEquals(CheckoutStage.Completed, manager.find(awaiting.checkout.id)!!.stage) + assertTrue(backend.deleted.isEmpty()) + } + + @Test + fun `an unreachable backend leaves the order and the record alone`() = + runBlocking { + val manager = manager() + manager.start(order(), includeProducts = false) + manager.recordCash(chf(5)) + val awaiting = manager.finishWithTaler() as CheckoutResult.AwaitingWallet + // Nothing is known about the order any more. + backend.existing.remove(awaiting.checkout.backendOrderId!!) + + assertTrue(manager.revertTalerStep() is RevertResult.Failed) + + assertEquals(CheckoutStage.AwaitingTaler, manager.current!!.stage) + assertEquals(awaiting.checkout.backendOrderId, manager.current!!.backendOrderId) + assertTrue(backend.deleted.isEmpty()) + } + + @Test + fun `a refused deletion keeps the checkout waiting for Taler`() = + runBlocking { + val manager = manager() + manager.start(order(), includeProducts = false) + manager.recordCash(chf(5)) + manager.finishWithTaler() + backend.deleteFails = true + + assertTrue(manager.revertTalerStep() is RevertResult.Failed) + + assertEquals(CheckoutStage.AwaitingTaler, manager.current!!.stage) + assertNotNull(manager.current!!.backendOrderId) + } + + @Test + fun `refunding a card payment gives the money back and records it on the order`() = + runBlocking { + val manager = manager() + val checkout = manager.start(order(), includeProducts = false) + manager.chargeWith(card, chf(20), "Lunch") + manager.collectByMerchant() + val paymentId = + manager + .find(checkout.id)!! + .payments + .single() + .id + + val result = manager.refundExternalPayment(checkout.id, paymentId, chf(8), "damaged") + + assertEquals(ExternalRefundResult.Done, result) + assertEquals(listOf("TX1" to chf(8)), card.refunded) + val stored = manager().find(checkout.id)!! + assertEquals(chf(8), stored.refunded) + assertEquals(chf(12), stored.refundableOf(stored.payments.single())) + val recorded = backend.refunds.single() + assertEquals("card", recorded.method) + assertEquals(paymentId, recorded.paymentId) + assertEquals(chf(8), recorded.amount) + } + + @Test + fun `refunding more than is left of a payment is refused before any money moves`() = + runBlocking { + val manager = manager() + val checkout = manager.start(order(), includeProducts = false) + manager.chargeWith(card, chf(20), "Lunch") + val paymentId = + manager + .find(checkout.id)!! + .payments + .single() + .id + manager.refundExternalPayment(checkout.id, paymentId, chf(15), "part") + + val result = manager.refundExternalPayment(checkout.id, paymentId, chf(6), "rest") + + assertTrue(result is ExternalRefundResult.Failed) + assertEquals(1, card.refunded.size) + } + + @Test + fun `a refund the provider rejected is not recorded anywhere`() = + runBlocking { + val manager = manager() + val checkout = manager.start(order(), includeProducts = false) + manager.chargeWith(card, chf(20), "Lunch") + val paymentId = + manager + .find(checkout.id)!! + .payments + .single() + .id + card.refundOutcome = RefundOutcome.Failed("not refundable") + + val result = manager.refundExternalPayment(checkout.id, paymentId, chf(5), "oops") + + assertTrue(result is ExternalRefundResult.Failed) + assertTrue(manager.find(checkout.id)!!.refunds.isEmpty()) + assertTrue(backend.refunds.isEmpty()) + } + + @Test + fun `money already given back is kept on the device even if the order cannot be updated`() = + runBlocking { + val manager = manager() + val checkout = manager.start(order(), includeProducts = false) + manager.chargeWith(card, chf(20), "Lunch") + manager.collectByMerchant() + val paymentId = + manager + .find(checkout.id)!! + .payments + .single() + .id + backend.refundRecordingFails = true + + val result = manager.refundExternalPayment(checkout.id, paymentId, chf(5), "damaged") + + assertTrue(result is ExternalRefundResult.NotRecorded) + assertEquals(chf(5), manager().find(checkout.id)!!.refunded) + } + + @Test + fun `cash has to be handed back by the merchant, not refunded through an option`() = + runBlocking { + val manager = manager() + val checkout = manager.start(order(), includeProducts = false) + manager.recordCash(chf(20)) + val paymentId = + manager + .find(checkout.id)!! + .payments + .single() + .id + + val result = manager.refundExternalPayment(checkout.id, paymentId, chf(5), "damaged") + + assertTrue(result is ExternalRefundResult.Failed) + assertTrue(manager.find(checkout.id)!!.refunds.isEmpty()) + } + + @Test + fun `confirmed manual cash refund persists and prevents refunding it twice`() = + runBlocking { + val manager = manager() + val checkout = manager.start(order(), includeProducts = false) + manager.recordCash(chf(5)) + val paymentId = + manager + .find(checkout.id)!! + .payments + .single() + .id + assertEquals( + ExternalRefundResult.Done, + manager.recordManualRefund(checkout.id, paymentId, chf(5), "returned"), + ) + assertEquals(chf(5), manager().find(checkout.id)!!.refunded) + assertTrue( + manager.recordManualRefund(checkout.id, paymentId, chf(1), "again") is ExternalRefundResult.Failed, + ) + assertTrue(card.refunded.isEmpty()) + } + + @Test + fun `manual card refund is retained when backend recording fails without calling provider`() = + runBlocking { + val manager = manager() + val checkout = manager.start(order(), includeProducts = false) + manager.chargeWith(card, chf(20), "Lunch") + manager.collectByMerchant() + val payment = manager.find(checkout.id)!!.payments.single() + backend.refundRecordingFails = true + assertTrue( + manager.recordManualRefund( + checkout.id, + payment.id, + chf(5), + "returned", + ) is ExternalRefundResult.NotRecorded, + ) + assertEquals(chf(5), manager().find(checkout.id)!!.refunded) + assertTrue(card.refunded.isEmpty()) + } + + @Test + fun `same refund seen locally and on backend only reduces the limit once`() = + runBlocking { + val manager = manager() + val checkout = manager.start(order(), includeProducts = false) + manager.recordCash(chf(5)) + val payment = manager.find(checkout.id)!!.payments.single() + manager.recordManualRefund(checkout.id, payment.id, chf(2), "returned") + val updated = manager.find(checkout.id)!! + val local = updated.refunds.single() + val item = + OrderHistoryEntry( + "order", + timestamp = Timestamp(1), + amount = chf(15), + summary = "Lunch", + paid = true, + refundable = true, + refundsExternal = + listOf( + ExternalRefundInfo( + local.id, + "cash", + chf(2), + "returned", + Timestamp(1), + payment.id, + ), + ), + ) + val external = ExternalPayment("cash", payment.id, chf(5)) + assertEquals(chf(3), item.externalRefundRemaining(external, updated)) + } + + @Test + fun `deleted backend order cannot be resumed and retains its payment trail`() = + runBlocking { + val manager = manager() + val checkout = manager.start(order(), includeProducts = false) + manager.chargeWith(card, chf(20), "Lunch") + manager.collectByMerchant() + val saved = manager.find(checkout.id)!! + manager.onBackendOrderDeleted(saved.backendOrderId!!) + val deleted = manager.find(checkout.id)!! + assertEquals(CheckoutStage.Abandoned, deleted.stage) + assertNull(deleted.backendOrderId) + assertEquals(saved.payments, deleted.payments) + assertFalse(manager.resume(deleted.id)) + } + + @Test + fun `lost creation response freezes terms across restart until the order is adopted`() = + runBlocking { + val first = manager() + val checkout = first.start(order(), includeProducts = false) + first.recordCash(chf(5)) + val acceptedRequest = first.current!!.toOrderRequest(null) + backend.onCreate = { + assertTrue(CheckoutStore(file).load().single().creationPending) + backend.existing[checkout.backendOrderIdToCreate] = false + } + backend.createFails = true + backend.statusUnavailable = true + assertTrue(first.finishWithTaler() is CheckoutResult.Failed) + assertThrows(IllegalStateException::class.java) { first.recordCash(chf(5)) } + assertFalse(first.chargeWith(card, chf(5), "Lunch")) + assertTrue(first.abandon() is AbandonResult.KeptForSettlement) + val restarted = manager() + assertTrue(restarted.resume(checkout.id)) + assertTrue(restarted.current!!.creationPending) + backend.statusUnavailable = false + val result = restarted.finishWithTaler() as CheckoutResult.AwaitingWallet + assertEquals(acceptedRequest.contractTerms.amount, result.checkout.open) + assertEquals(chf(15), result.checkout.open) + assertFalse(result.checkout.creationPending) + assertEquals(RevertResult.Reverted, restarted.revertTalerStep()) + restarted.recordCash(chf(5)) + assertEquals(chf(10), restarted.current!!.open) + } + + @Test + fun `cancellation during creation leaves a durable frozen checkout`() = + runBlocking { + val first = manager() + val checkout = first.start(order(), false) + backend.onCreate = { throw CancellationException("Activity destroyed") } + try + { + first.finishWithTaler() + throw AssertionError("Expected cancellation") + } + catch (_: CancellationException) + { + } + val restarted = manager() + assertTrue(restarted.resume(checkout.id)) + assertTrue(restarted.current!!.creationPending) + assertFalse(restarted.current!!.canTakeExternalPayments) + backend.onCreate = {} + assertTrue(restarted.finishWithTaler() is CheckoutResult.AwaitingWallet) + } + + @Test + fun `pending card is saved before launch and blocks further payments after restart`() = + runBlocking { + val first = manager() + val checkout = first.start(order(), false) + card.deferred = true + card.onLaunch = { + val saved = CheckoutStore(file).load().single().pendingCharge!! + assertEquals(card.started!!.reference, saved.reference) + assertEquals(chf(5), saved.amount) + assertEquals(card.id, saved.method) + } + assertTrue(first.chargeWith(card, chf(5), "Lunch")) + val restarted = manager() + assertTrue(restarted.resume(checkout.id)) + assertFalse(restarted.chargeWith(card, chf(5), "Lunch")) + assertThrows(IllegalStateException::class.java) { restarted.recordCash(chf(5)) } + assertTrue(restarted.finishWithTaler() is CheckoutResult.Failed) + assertTrue(backend.created.isEmpty()) + assertEquals(1, card.launches) + assertTrue(restarted.abandon() is AbandonResult.KeptForSettlement) + restarted.dismiss(checkout.id) + assertNotNull(restarted.find(checkout.id)!!.pendingCharge) + } + + @Test + fun `verified successful card is recovered once with the original reference`() + { + val first = manager() + val checkout = first.start(order(), false) + card.deferred = true + first.chargeWith(card, chf(5), "Lunch") + val restarted = manager() + restarted.resume(checkout.id) + val reference = restarted.current!!.pendingCharge!!.reference + assertThrows(IllegalArgumentException::class.java) { + restarted.resolvePendingCharge(reference, true, "") + } + restarted.resolvePendingCharge(reference, true, "TX-recovered") + val payment = restarted.current!!.payments.single() + assertEquals(reference, payment.id) + assertEquals("TX-recovered", payment.transactionCode) + assertEquals(chf(15), restarted.current!!.open) + assertNull(restarted.current!!.pendingCharge) + assertThrows(IllegalArgumentException::class.java) { + restarted.resolvePendingCharge(reference, true, "TX-recovered") + } + assertEquals(1, manager().find(checkout.id)!!.payments.size) + } + + @Test + fun `unknown card outcome stays pending until verified not charged`() + { + val manager = manager() + manager.start(order(), false) + card.chargeResult = ChargeResult.Unknown("Connection lost") + manager.chargeWith(card, chf(5), "Lunch") + val reference = manager.current!!.pendingCharge!!.reference + manager.consumeChargeEvent() + assertFalse(manager.current!!.canTakeExternalPayments) + manager.resolvePendingCharge(reference, false) + assertTrue(manager.current!!.canTakeExternalPayments) + assertTrue(manager.current!!.payments.isEmpty()) + card.chargeResult = ChargeResult.Success("TX-new") + assertTrue(manager.chargeWith(card, chf(5), "Lunch")) + assertTrue( + reference != + manager.current!! + .payments + .single() + .id, + ) + } + + @Test + fun `provider cancellation failure and refused launch clear pending attempt`() + { + val manager = manager() + manager.start(order(), false) + for (result in listOf(ChargeResult.Cancelled, ChargeResult.Failed("Declined"))) + { + card.chargeResult = result + assertTrue(manager.chargeWith(card, chf(5), "Lunch")) + assertNull(manager.current!!.pendingCharge) + assertTrue(manager.current!!.canTakeExternalPayments) + } + card.accepted = false + assertFalse(manager.chargeWith(card, chf(5), "Lunch")) + assertNull(manager.current!!.pendingCharge) + } + + @Test + fun `duplicate terminal callback records only one payment`() + { + val manager = manager() + manager.start(order(), false) + card.deferred = true + manager.chargeWith(card, chf(5), "Lunch") + card.callback!!(ChargeResult.Success("TX1")) + card.callback!!(ChargeResult.Success("TX1")) + assertEquals(1, manager.current!!.payments.size) + assertEquals(chf(15), manager.current!!.open) + } + + @Test + fun `account changes hide and block foreign checkouts without deleting them`() = + runBlocking { + val manager = manager() + val observer = Observer<List<Checkout>> { } + manager.checkouts.observeForever(observer) + try + { + val a = manager.start(order(), false) + manager.recordCash(chf(5)) + val payment = manager.current!!.payments.single() + merchant.value = null + assertTrue(manager.checkouts.value!!.isEmpty()) + assertNull(manager.current) + merchant.value = "merchant-b" + assertTrue(manager.checkouts.value!!.isEmpty()) + assertFalse(manager.resume(a.id)) + assertNull(manager().find(a.id)) + assertTrue(manager.finishWithTaler() is CheckoutResult.Failed) + assertTrue( + manager.recordManualRefund(a.id, payment.id, chf(5), "return") is ExternalRefundResult.Failed, + ) + manager.dismiss(a.id) + manager.onBackendOrderDeleted(a.backendOrderIdToCreate) + val b = manager.start(order(), false) + assertEquals(listOf(b.id), manager.checkouts.value!!.map { it.id }) + merchant.value = "merchant-a" + assertEquals(listOf(a.id), manager.checkouts.value!!.map { it.id }) + assertTrue(manager.resume(a.id)) + assertEquals(chf(5), manager.current!!.paid) + assertEquals(2, CheckoutStore(file).load().size) + assertTrue(backend.created.isEmpty()) + } + finally + { + manager.checkouts.removeObserver(observer) + } + } + + @Test + fun `late card result is recorded for its original merchant only`() + { + val manager = manager() + val a = manager.start(order(), false) + card.deferred = true + manager.chargeWith(card, chf(5), "Lunch") + merchant.value = "merchant-b" + val b = manager.start(order(), false) + card.callback!!(ChargeResult.Success("TX-a")) + assertTrue(manager.find(b.id)!!.payments.isEmpty()) + assertNull(manager.chargeEvent.value) + merchant.value = "merchant-a" + assertEquals(chf(5), manager.find(a.id)!!.paid) + } + + @Test + fun `backend captured before account switch keeps recovery requests on original merchant`() = + runBlocking { + val otherBackend = FakeBackend() + backendLookup = { if (merchant.value == "merchant-a") backend else otherBackend } + val manager = manager() + val a = manager.start(order(), false) + backend.createFails = true + backend.existing[a.backendOrderIdToCreate] = false + backend.onCreate = { merchant.value = "merchant-b" } + assertTrue(manager.finishWithTaler() is CheckoutResult.Failed) + assertTrue(otherBackend.created.isEmpty()) + assertNull(manager.find(a.id)) + merchant.value = "merchant-a" + assertEquals(a.backendOrderIdToCreate, manager.find(a.id)!!.backendOrderId) + } + + @Test + fun `legacy unowned records remain on disk and are never adopted by a new merchant`() + { + val first = manager() + val legacy = first.start(order(), false).copy(merchantId = null) + CheckoutStore(file).save(listOf(legacy)) + val restored = manager() + assertNull(restored.find(legacy.id)) + assertFalse(restored.resume(legacy.id)) + assertTrue(restored.unfinished().isEmpty()) + restored.start(order(), false) + assertEquals(legacy, CheckoutStore(file).load().first()) + } + + @Test + fun `a failed persistence write never launches a card charge`() + { + val parent = folder.newFolder("blocked-parent") + file = File(parent, "checkouts.json") + val manager = manager() + manager.start(order(), false) + assertTrue(file.delete()) + assertTrue(parent.delete()) + parent.writeText("Not a directory") + assertThrows(IOException::class.java) { manager.chargeWith(card, chf(5), "Lunch") } + assertEquals(0, card.launches) + } + + @Test + fun `exception after starting a terminal keeps the attempt unresolved`() + { + val first = manager() + val checkout = first.start(order(), false) + card.onLaunch = { throw IllegalStateException("Provider interrupted") } + assertTrue(first.chargeWith(card, chf(5), "Lunch")) + assertTrue(first.chargeEvent.value!!.result is ChargeResult.Unknown) + val restarted = manager() + assertNotNull(restarted.find(checkout.id)!!.pendingCharge) + assertTrue(restarted.resume(checkout.id)) + assertFalse(restarted.chargeWith(card, chf(5), "Lunch")) + assertEquals(1, card.launches) + } + + private class FakeCardOption( + var chargeResult: ChargeResult, + ) : PaymentOption + { + var deferred = false + var accepted = true + var onLaunch: () -> Unit = {} + var callback: ((ChargeResult) -> Unit)? = null + var launches = 0 + var started: ExternalCharge? = null + var refundOutcome: RefundOutcome = RefundOutcome.Success + val refunded = mutableListOf<Pair<String?, Amount>>() + override val canRefund = true + override val id = "card" + override val nameRes = R.string.payment_options_title + override val descriptionRes = R.string.payment_options_description + + override val icon: ImageVector + @Composable get() = Icons.Default.CreditCard + + override val chargesExternally = true + + override suspend fun refund( + transactionCode: String?, + amount: Amount, + reason: String, + ): RefundOutcome + { + if (refundOutcome is RefundOutcome.Success) refunded += transactionCode to amount + return refundOutcome + } + + override fun startCharge( + charge: ExternalCharge, + onResult: (ChargeResult) -> Unit, + ): Boolean + { + launches++ + started = charge + callback = onResult + onLaunch() + if (!accepted) return false + if (!deferred) onResult(chargeResult) + return true + } + } + + private class FakeBackend : CheckoutBackend + { + val created = mutableListOf<PostOrderRequest>() + val collected = mutableListOf<String>() + val deleted = mutableListOf<String>() + val refunds = mutableListOf<ExternalRefundRequest>() + var refundRecordingFails = false + val forcedDeletes = mutableListOf<Boolean>() + var deleteFails = false + val existing = mutableMapOf<String, Boolean>() + var createFails = false + var statusUnavailable = false + var onCreate: suspend () -> Unit = {} + var collectFails = false + var paidAfterCollect = false + + override suspend fun createOrder(request: PostOrderRequest): BackendResult<String> + { + onCreate() + if (createFails) return BackendResult.Failure("unreachable") + created += request + val id = request.contractTerms.orderId!! + existing[id] = false + return BackendResult.Success(id) + } + + override suspend fun isPaid(orderId: String): Boolean? = + if (statusUnavailable) + { + null + } + else if (paidAfterCollect && + orderId in collected) + { + true + } + else + { + existing[orderId] + } + + override suspend fun collect(orderId: String): BackendResult<Unit> + { + collected += orderId + return if (collectFails) BackendResult.Failure("already claimed") else BackendResult.Success(Unit) + } + + override suspend fun recordExternalRefund( + orderId: String, + request: ExternalRefundRequest, + ): BackendResult<Unit> + { + if (refundRecordingFails) return BackendResult.Failure("backend down") + refunds += request + return BackendResult.Success(Unit) + } + + override suspend fun deleteOrder( + orderId: String, + force: Boolean, + ): BackendResult<Unit> + { + if (deleteFails) return BackendResult.Failure("refused") + deleted += orderId + forcedDeletes += force + existing.remove(orderId) + return BackendResult.Success(Unit) + } + } +} diff --git a/merchant-terminal/src/test/java/net/taler/merchantpos/checkout/CheckoutStoreTest.kt b/merchant-terminal/src/test/java/net/taler/merchantpos/checkout/CheckoutStoreTest.kt @@ -0,0 +1,119 @@ +/* + * 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.checkout + +import android.app.Application + +import net.taler.common.Amount +import net.taler.common.Order + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +import java.io.File +import java.io.IOException + +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class, sdk = [28]) +class CheckoutStoreTest +{ + @get:Rule + val folder = TemporaryFolder() + + private fun checkout(id: String) = + Checkout( + id = id, + createdAt = 1, + session = "s", + posOrderId = 3, + contractTerms = Order(summary = "Coffee", amount = Amount("CHF", 4, 50_000_000)), + ).withCash(Amount("CHF", 10, 0), "cash-$id", now = 2).checkout + + @Test + fun `a checkout survives the store being recreated, payments and change included`() + { + val file = File(folder.root, "checkouts.json") + val original = + listOf(checkout("a"), checkout("b").copy(stage = CheckoutStage.AwaitingTaler, backendOrderId = "pos-b")) + CheckoutStore(file).save(original) + + val loaded = CheckoutStore(file).load() + + assertEquals(original, loaded) + assertEquals(Amount("CHF", 5, 50_000_000), loaded[0].payments.single().change) + } + + @Test + fun `a missing store is simply empty`() + { + assertTrue(CheckoutStore(File(folder.root, "none.json")).load().isEmpty()) + } + + @Test + fun `an existing store that cannot be read is not treated as empty`() + { + val file = folder.newFolder("checkouts.json") + + assertThrows(IOException::class.java) { CheckoutStore(file).load() } + + assertTrue(file.isDirectory) + } + + @Test + fun `a failed quarantine preserves the original and reports an error`() + { + val file = + object : File(folder.root, "checkouts.json") + { + override fun renameTo(dest: File): Boolean = false + }.apply { writeText("{ not json") } + + assertThrows(IOException::class.java) { CheckoutStore(file).load() } + + assertEquals("{ not json", file.readText()) + } + + @Test + fun `an interrupted write restores the atomic backup`() + { + val file = File(folder.root, "checkouts.json") + val original = listOf(checkout("a")) + CheckoutStore(file).save(original) + assertTrue(file.renameTo(File("${file.path}.bak"))) + + assertEquals(original, CheckoutStore(file).load()) + } + + @Test + fun `an unreadable store is set aside rather than deleted`() + { + val file = File(folder.root, "checkouts.json").apply { writeText("{ not json") } + + assertTrue(CheckoutStore(file).load().isEmpty()) + + val kept = folder.root.listFiles()!!.filter { it.name.startsWith("checkouts.json.unreadable-") } + assertEquals(1, kept.size) + assertEquals("{ not json", kept.single().readText()) + } +} diff --git a/merchant-terminal/src/test/java/net/taler/merchantpos/checkout/CheckoutTest.kt b/merchant-terminal/src/test/java/net/taler/merchantpos/checkout/CheckoutTest.kt @@ -0,0 +1,185 @@ +/* + * 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.checkout + +import net.taler.common.Amount +import net.taler.common.Order +import net.taler.merchantlib.MinimalInventoryProduct + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +class CheckoutTest +{ + private fun chf( + value: Long, + cents: Int = 0, + ) = Amount("CHF", value, cents * 1_000_000) + + private fun checkout(total: Amount = chf(20)) = + Checkout( + id = "c1", + createdAt = 0, + session = "s", + posOrderId = 1, + contractTerms = Order(summary = "Lunch", amount = total), + inventoryProducts = listOf(MinimalInventoryProduct(productId = "soup", quantity = 2)), + ) + + @Test + fun `exact cash settles the order and gives no change`() + { + val outcome = checkout().withCash(chf(20), "p1", now = 1) + assertEquals(chf(20), outcome.applied) + assertTrue(outcome.change.isZero()) + assertTrue(outcome.checkout.isFullyPaidExternally) + assertNull( + outcome.checkout.payments + .single() + .change, + ) + assertNull( + outcome.checkout.payments + .single() + .tendered, + ) + } + + @Test + fun `partial cash leaves the rest open for Taler`() + { + val outcome = checkout().withCash(chf(8), "p1", now = 1) + assertEquals(chf(8), outcome.checkout.paid) + assertEquals(chf(12), outcome.checkout.open) + assertFalse(outcome.checkout.isFullyPaidExternally) + } + + @Test + fun `overpaid cash only settles what is open and returns the rest as change`() + { + val outcome = checkout(total = chf(12, 40)).withCash(chf(20), "p1", now = 1) + assertEquals(chf(12, 40), outcome.applied) + assertEquals(chf(7, 60), outcome.change) + val payment = outcome.checkout.payments.single() + assertEquals(chf(20), payment.tendered) + assertEquals(chf(7, 60), payment.change) + assertTrue(outcome.checkout.open.isZero()) + } + + @Test + fun `change is worked out against what is still open, not the total`() + { + val afterCard = checkout().withExternalPayment("sumup", chf(15), "card", "TX1", now = 1) + val outcome = afterCard.withCash(chf(10), "cash", now = 2) + assertEquals(chf(5), outcome.applied) + assertEquals(chf(5), outcome.change) + assertEquals(chf(20), outcome.checkout.paid) + } + + @Test + fun `nothing more can be taken once the order is settled`() + { + val settled = checkout().withCash(chf(20), "p1", now = 1).checkout + assertFalse(settled.canTakeExternalPayments) + assertThrows(IllegalStateException::class.java) { settled.withCash(chf(1), "p2", now = 2) } + } + + @Test + fun `no payment can be added after the backend order exists`() + { + val awaiting = checkout().copy(stage = CheckoutStage.AwaitingTaler) + assertThrows(IllegalStateException::class.java) { awaiting.withCash(chf(1), "p1", now = 1) } + } + + @Test + fun `a payment in another currency is refused`() + { + assertThrows(IllegalArgumentException::class.java) { + checkout().withCash(Amount("EUR", 5, 0), "p1", now = 1) + } + } + + @Test + fun `a zero payment is refused`() + { + assertThrows(IllegalArgumentException::class.java) { checkout().withCash(chf(0), "p1", now = 1) } + } + + @Test + fun `a card payment cannot exceed what is open`() + { + assertThrows(IllegalArgumentException::class.java) { + checkout().withExternalPayment("sumup", chf(21), "card", null, now = 1) + } + } + + @Test + fun `Taler is never recorded as an external payment`() + { + assertThrows(IllegalArgumentException::class.java) { + checkout().withExternalPayment("taler", chf(5), "t", null, now = 1) + } + } + + @Test + fun `the backend order charges Taler only what is open and lists every external payment`() + { + val paid = + checkout() + .withExternalPayment("sumup", chf(9), "card", "TX9", now = 1) + .withCash(chf(10), "cash", now = 2) + .checkout + val request = paid.toOrderRequest(refundDelay = null) + val terms = request.contractTerms + + assertEquals(chf(1), terms.amount) + assertEquals("pos-c1", terms.orderId) + val external = terms.amountExternal!! + assertEquals(listOf("card", "cash"), external.map { it.id }) + assertEquals(listOf("sumup", "cash"), external.map { it.method }) + assertEquals(chf(9), external[0].amount) + assertEquals("TX9", external[0].transactionCode) + assertEquals(chf(10), external[1].amount) + // The inventory lock still covers the whole sale. + assertEquals(paid.inventoryProducts, request.inventoryProducts) + } + + @Test + fun `an order paid entirely in Taler carries no external payments`() + { + val request = checkout().toOrderRequest(refundDelay = null) + assertNull(request.contractTerms.amountExternal) + assertEquals(chf(20), request.contractTerms.amount) + } + + @Test + fun `an order settled entirely externally asks Taler for nothing`() + { + val request = checkout().withCash(chf(50), "cash", now = 1).checkout.toOrderRequest(null) + assertTrue(request.contractTerms.amount.isZero()) + assertEquals( + chf(20), + request.contractTerms.amountExternal!! + .single() + .amount, + ) + } +} diff --git a/merchant-terminal/src/test/java/net/taler/merchantpos/config/ConfigCameraPermissionTest.kt b/merchant-terminal/src/test/java/net/taler/merchantpos/config/ConfigCameraPermissionTest.kt @@ -0,0 +1,186 @@ +package net.taler.merchantpos.config + +import android.Manifest +import android.app.Application +import android.content.Context +import android.content.pm.PackageManager +import android.content.res.Configuration +import android.os.Bundle +import android.os.Looper +import android.provider.Settings +import android.widget.FrameLayout + +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.ViewModelProvider +import androidx.test.core.app.ApplicationProvider + +import net.taler.merchantpos.R + +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.android.controller.ActivityController +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowChoreographer +import org.robolectric.shadows.ShadowToast + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], application = Application::class) +class ConfigCameraPermissionTest +{ + private lateinit var controller: ActivityController<CameraTestActivity> + private val fragment: ConfigFragment + get() = controller.get().supportFragmentManager.findFragmentByTag("config") as ConfigFragment + + @Before + fun setUp() + { + val app = ApplicationProvider.getApplicationContext<Application>() + shadowOf(app).grantPermissions("${app.packageName}.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION") + shadowOf(app).denyPermissions(Manifest.permission.CAMERA) + app + .getSharedPreferences("config.cameraPermission", Context.MODE_PRIVATE) + .edit() + .clear() + .commit() + ShadowChoreographer.setPaused(true) + controller = Robolectric.buildActivity(CameraTestActivity::class.java).setup() + ViewModelProvider(controller.get())[ConfigDraftViewModel::class.java].apply { + initialized = true + mode = ConfigMode.Qr + merchantUrl = "example.invalid" + } + controller + .get() + .supportFragmentManager + .beginTransaction() + .add(android.R.id.content, ConfigFragment(), "config") + .commitNow() + idle() + } + + @After + fun tearDown() + { + controller.pause().stop().destroy() + idle() + } + + @Test + fun `entering and resuming QR setup do not automatically request permission or toast`() + { + repeat(3) { + controller.pause().resume() + idle() + } + assertNull(shadowOf(controller.get()).lastRequestedPermission) + assertEquals(0, ShadowToast.shownToastCount()) + } + + @Test + fun `only confirmation launches permission and repeated taps cannot launch duplicates`() + { + confirm() + val request = shadowOf(controller.get()).lastRequestedPermission + assertNotNull(request) + assertEquals(listOf(Manifest.permission.CAMERA), request.requestedPermissions.toList()) + + confirm() + assertSame(request, shadowOf(controller.get()).lastRequestedPermission) + } + + @Test + fun `two denials stop requesting and offer app settings without toasts`() + { + confirm() + deny(canAskAgain = true) + val first = shadowOf(controller.get()).lastRequestedPermission + controller.pause().resume() + idle() + assertSame(first, shadowOf(controller.get()).lastRequestedPermission) + + confirm() + val second = shadowOf(controller.get()).lastRequestedPermission + assertNotSame(first, second) + deny(canAskAgain = false) + + repeat(3) { + controller.pause().resume() + idle() + } + assertSame(second, shadowOf(controller.get()).lastRequestedPermission) + assertEquals(0, ShadowToast.shownToastCount()) + + confirm() + val settings = shadowOf(controller.get()).nextStartedActivity + assertEquals(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, settings.action) + assertEquals("package:${controller.get().packageName}", settings.dataString) + assertSame(second, shadowOf(controller.get()).lastRequestedPermission) + } + + @Test + fun `blocked permission still opens settings after rotation`() + { + confirm() + deny(canAskAgain = false) + controller.configurationChange( + Configuration(controller.get().resources.configuration).apply { + orientation = Configuration.ORIENTATION_LANDSCAPE + }, + ) + idle() + + confirm() + assertEquals( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + shadowOf(controller.get()).nextStartedActivity.action + ) + assertNull(shadowOf(controller.get()).lastRequestedPermission) + assertEquals(0, ShadowToast.shownToastCount()) + } + + private fun confirm() + { + // Exercise the same action wired to the Compose dialog button without a camera backend. + ConfigFragment::class.java + .getDeclaredMethod("confirmCameraPermission") + .apply { + isAccessible = true + }.invoke(fragment) + idle() + } + + private fun deny(canAskAgain: Boolean) + { + shadowOf(controller.get().packageManager) + .setShouldShowRequestPermissionRationale(Manifest.permission.CAMERA, canAskAgain) + val request = shadowOf(controller.get()).lastRequestedPermission + controller.get().onRequestPermissionsResult( + request.requestCode, + request.requestedPermissions, + intArrayOf(PackageManager.PERMISSION_DENIED), + ) + idle() + } + + private fun idle() = shadowOf(Looper.getMainLooper()).idle() + + class CameraTestActivity : FragmentActivity() + { + override fun onCreate(savedInstanceState: Bundle?) + { + setTheme(R.style.AppTheme) + super.onCreate(savedInstanceState) + setContentView(FrameLayout(this)) + } + } +} diff --git a/merchant-terminal/src/test/java/net/taler/merchantpos/config/ConfigDraftViewModelTest.kt b/merchant-terminal/src/test/java/net/taler/merchantpos/config/ConfigDraftViewModelTest.kt @@ -0,0 +1,57 @@ +/* + * This file is part of GNU Taler + * (C) 2026 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.merchantpos.config + +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.ViewModelStoreOwner + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28]) +class ConfigDraftViewModelTest +{ + @Test + fun `activity scoped provider can retain credential draft in memory and clear it`() + { + val owner = + object : ViewModelStoreOwner + { + override val viewModelStore = ViewModelStore() + } + val firstProvider = ViewModelProvider(owner) + val draft = firstProvider[ConfigDraftViewModel::class.java] + draft.token = "in-memory-only" + draft.username = "sandbox" + + assertSame(draft, ViewModelProvider(owner)[ConfigDraftViewModel::class.java]) + assertEquals("in-memory-only", draft.token) + assertEquals("sandbox", draft.username) + + draft.clear() + assertNull(draft.token) + assertNull(draft.username) + owner.viewModelStore.clear() + } +} diff --git a/merchant-terminal/src/test/java/net/taler/merchantpos/config/ConfigLoginLifecycleTest.kt b/merchant-terminal/src/test/java/net/taler/merchantpos/config/ConfigLoginLifecycleTest.kt @@ -0,0 +1,379 @@ +/* + * This file is part of GNU Taler + * (C) 2026 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.merchantpos.config + +import android.app.Application +import android.content.res.Configuration +import android.os.Bundle +import android.os.Looper +import android.widget.FrameLayout + +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.ViewModelProvider +import androidx.test.core.app.ApplicationProvider + +import net.taler.common.Challenge +import net.taler.common.ChallengesResponse +import net.taler.common.TanChannel +import net.taler.merchantpos.R + +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.android.controller.ActivityController +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowChoreographer +import org.robolectric.shadows.ShadowToast + +import kotlinx.coroutines.CompletableDeferred + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], application = Application::class) +class ConfigLoginLifecycleTest +{ + private lateinit var controller: ActivityController<LoginTestActivity> + private lateinit var draft: ConfigDraftViewModel + private val challenge = Challenge("test-challenge", TanChannel.EMAIL, "test@example.invalid") + + @Before + fun setUp() + { + // ContextCompat protects dynamic receivers on pre-33 Android using the + // app's signature permission. Robolectric does not grant it automatically. + val app = ApplicationProvider.getApplicationContext<Application>() + shadowOf(app).grantPermissions("${app.packageName}.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION") + // Pending requests legitimately keep an infinite progress animation running. + ShadowChoreographer.setPaused(true) + controller = Robolectric.buildActivity(LoginTestActivity::class.java) + controller.setup() + draft = + ViewModelProvider(controller.get())[ConfigDraftViewModel::class.java].apply { + initialized = true + mode = ConfigMode.Manual + merchantUrl = "example.invalid" + username = "test" + token = "test-only" + } + controller + .get() + .supportFragmentManager + .beginTransaction() + .add(android.R.id.content, ConfigFragment(), "login") + .commitNow() + } + + @After + fun tearDown() + { + controller.pause().stop().destroy() + idle() + } + + @Test + fun `cancel during network request keeps draft and permits a fresh login`() + { + val gate = CompletableDeferred<Unit>() + val backend = FakeBackend(requestGate = gate) + draft.startLogin(backend) + idle() + (draft.prompt as LoginPrompt.Method).select(challenge) + idle() + assertTrue(draft.isSubmitting) + + draft.cancelLogin() + idle() + gate.complete(Unit) + idle() + assertFalse(draft.isSubmitting) + assertFalse(draft.awaitingConfigUpdate) + assertNull(draft.prompt) + assertEquals("test-only", draft.token) + assertTrue(backend.configuredTokens.isEmpty()) + + draft.startLogin(FakeBackend()) + idle() + assertTrue(draft.prompt is LoginPrompt.Method) + } + + @Test + fun `rotation preserves method popup and does not restart token request`() + { + val backend = FakeBackend() + draft.startLogin(backend) + idle() + val popup = draft.prompt as LoginPrompt.Method + + rotate() + rotate() + + assertSame(popup, draft.prompt) + assertTrue(draft.isSubmitting) + assertEquals(listOf(emptyList<String>()), backend.tokenRequests) + assertTrue(backend.challengeRequests.isEmpty()) + assertEquals("test-only", draft.token) + popup.select(challenge) + idle() + assertTrue(draft.prompt is LoginPrompt.Code) + assertEquals(listOf(challenge.challengeId), backend.challengeRequests) + } + + @Test + fun `rotation preserves partial code and completes the same challenge without resend`() + { + val backend = beginCodeEntry() + val popup = draft.prompt as LoginPrompt.Code + popup.updateCode("1234") + + rotate() + + assertSame(popup, draft.prompt) + assertEquals("1234", (draft.prompt as LoginPrompt.Code).code) + assertEquals(listOf(challenge.challengeId), backend.challengeRequests) + popup.updateCode("12345678") + popup.submit() + idle() + + assertEquals(listOf(challenge.challengeId to "1234-5678"), backend.confirmations) + assertEquals(listOf(emptyList(), listOf(challenge.challengeId)), backend.tokenRequests) + assertEquals(listOf("limited-token"), backend.configuredTokens) + assertTrue(draft.awaitingConfigUpdate) + assertNull(draft.prompt) + } + + @Test + fun `reattaching the restored view preserves the popup and partial code`() + { + val backend = beginCodeEntry() + val popup = draft.prompt as LoginPrompt.Code + popup.updateCode("1234") + rotate() + val manager = controller.get().supportFragmentManager + val fragment = manager.findFragmentByTag("login")!! + + manager.beginTransaction().detach(fragment).commitNow() + manager.beginTransaction().attach(fragment).commitNow() + idle() + + assertSame(popup, draft.prompt) + assertEquals("1234", popup.code) + assertEquals(listOf(challenge.challengeId), backend.challengeRequests) + assertTrue(draft.isSubmitting) + } + + @Test + fun `rotation during code delivery waits for the original request`() + { + val gate = CompletableDeferred<Unit>() + val backend = FakeBackend(requestGate = gate) + draft.startLogin(backend) + idle() + (draft.prompt as LoginPrompt.Method).select(challenge) + idle() + assertNull(draft.prompt) + + rotate() + draft.startLogin(backend) // Repeated Connect must not launch another attempt. + gate.complete(Unit) + idle() + + assertEquals(listOf(challenge.challengeId), backend.challengeRequests) + assertTrue(draft.prompt is LoginPrompt.Code) + assertTrue(draft.isSubmitting) + } + + @Test + fun `rotation during confirmation completes once without rechecking or restarting`() + { + val gate = CompletableDeferred<Unit>() + val backend = beginCodeEntry(FakeBackend(confirmGate = gate)) + (draft.prompt as LoginPrompt.Code).apply { + updateCode("12345678") + submit() + } + idle() + + rotate() + assertTrue(draft.isSubmitting) + assertNull(draft.prompt) + gate.complete(Unit) + idle() + + assertEquals(1, backend.confirmations.size) + assertEquals(listOf("limited-token"), backend.configuredTokens) + assertTrue(draft.awaitingConfigUpdate) + } + + @Test + fun `rotation keeps validation feedback and entered digits`() + { + val backend = beginCodeEntry() + val popup = draft.prompt as LoginPrompt.Code + popup.updateCode("123") + popup.submit() + assertNotNull(popup.error) + + rotate() + + assertSame(popup, draft.prompt) + assertEquals("123", popup.code) + assertNotNull(popup.error) + assertTrue(backend.confirmations.isEmpty()) + } + + @Test + fun `leaving login cancels delivery and clears sensitive state`() + { + val gate = CompletableDeferred<Unit>() + val backend = FakeBackend(requestGate = gate) + draft.startLogin(backend) + idle() + (draft.prompt as LoginPrompt.Method).select(challenge) + idle() + val fragment = controller.get().supportFragmentManager.findFragmentByTag("login")!! + + controller + .get() + .supportFragmentManager + .beginTransaction() + .remove(fragment) + .commitNow() + gate.complete(Unit) + idle() + + assertNull(draft.prompt) + assertNull(draft.token) + assertFalse(draft.isSubmitting) + assertTrue(backend.configuredTokens.isEmpty()) + assertEquals(0, ShadowToast.shownToastCount()) + } + + @Test + fun `cancelling a restored popup ends MFA without configuring the merchant`() + { + val backend = beginCodeEntry() + rotate() + + draft.prompt!!.cancel() + idle() + + assertNull(draft.prompt) + assertFalse(draft.isSubmitting) + assertEquals("test-only", draft.token) + assertTrue(backend.configuredTokens.isEmpty()) + assertNull(draft.loginError.value) + } + + private fun beginCodeEntry(backend: FakeBackend = FakeBackend()): FakeBackend + { + draft.startLogin(backend) + idle() + (draft.prompt as LoginPrompt.Method).select(challenge) + idle() + return backend + } + + private fun rotate() + { + val previous = controller.get().supportFragmentManager.findFragmentByTag("login") + val configuration = + Configuration(controller.get().resources.configuration).apply { + orientation = + if (orientation == Configuration.ORIENTATION_LANDSCAPE) + { + Configuration.ORIENTATION_PORTRAIT + } + else + { + Configuration.ORIENTATION_LANDSCAPE + } + } + controller.configurationChange(configuration) + idle() + assertNotSame(previous, controller.get().supportFragmentManager.findFragmentByTag("login")) + assertSame(draft, ViewModelProvider(controller.get())[ConfigDraftViewModel::class.java]) + assertEquals(0, ShadowToast.shownToastCount()) + } + + private fun idle() + { + shadowOf(Looper.getMainLooper()).idle() + } + + class LoginTestActivity : FragmentActivity() + { + override fun onCreate(savedInstanceState: Bundle?) + { + setTheme(R.style.AppTheme) + super.onCreate(savedInstanceState) + setContentView(FrameLayout(this)) + } + } + + private inner class FakeBackend( + private val requestGate: CompletableDeferred<Unit>? = null, + private val confirmGate: CompletableDeferred<Unit>? = null, + ) : LoginBackend + { + val tokenRequests = mutableListOf<List<String>>() + val challengeRequests = mutableListOf<String>() + val confirmations = mutableListOf<Pair<String, String>>() + val configuredTokens = mutableListOf<String>() + + override suspend fun fetchToken(challengeIds: List<String>): String + { + tokenRequests.add(challengeIds) + if (challengeIds.isEmpty()) + { + throw ChallengeRequiredException( + ChallengesResponse(listOf(challenge), combiAnd = false), + ) + } + return "limited-token" + } + + override suspend fun requestChallenge(id: String) + { + challengeRequests.add(id) + requestGate?.await() + } + + override suspend fun confirmChallenge( + id: String, + code: String, + ) + { + confirmations.add(id to code) + confirmGate?.await() + } + + override fun configure(token: String) + { + configuredTokens.add(token) + } + } +} diff --git a/merchant-terminal/src/test/java/net/taler/merchantpos/config/ConfigMfaBranchesTest.kt b/merchant-terminal/src/test/java/net/taler/merchantpos/config/ConfigMfaBranchesTest.kt @@ -0,0 +1,155 @@ +/* + * This file is part of GNU Taler + * (C) 2026 Taler Systems S.A. + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package net.taler.merchantpos.config + +import android.app.Application +import android.os.Looper + +import net.taler.common.Challenge +import net.taler.common.ChallengesResponse +import net.taler.common.TanChannel +import net.taler.merchantpos.R + +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +import java.io.IOException + +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class, sdk = [28]) +class ConfigMfaBranchesTest +{ + private val draft = ConfigDraftViewModel() + private val email = Challenge("email", TanChannel.EMAIL, "test@example.invalid") + private val sms = Challenge("sms", TanChannel.SMS, "0000") + + private fun idle() = shadowOf(Looper.getMainLooper()).idle() + + @After + fun cleanup() + { + draft.clear() + idle() + } + + @Test + fun `AND challenges must both be confirmed in order before token creation`() + { + val backend = Backend(ChallengesResponse(listOf(email, sms), combiAnd = true)) + draft.startLogin(backend) + idle() + assertEquals(email, (draft.prompt as LoginPrompt.Code).challenge) + submit() + assertEquals(sms, (draft.prompt as LoginPrompt.Code).challenge) + assertTrue(backend.configured.isEmpty()) + submit() + assertEquals(listOf("email", "sms"), backend.deliveries) + assertEquals(listOf("email", "sms"), backend.confirmations) + assertEquals(listOf(emptyList<String>(), listOf("email", "sms")), backend.tokenRequests) + assertEquals(listOf("limited"), backend.configured) + assertTrue(draft.awaitingConfigUpdate) + draft.finishConfigUpdate() + assertFalse(draft.isSubmitting) + } + + @Test + fun `cancelling second required challenge never configures a partially verified session`() + { + val backend = Backend(ChallengesResponse(listOf(email, sms), combiAnd = true)) + draft.startLogin(backend) + idle() + submit() + draft.prompt!!.cancel() + idle() + assertFalse(draft.isSubmitting) + assertNull(draft.loginError.value) + assertEquals(listOf(emptyList<String>()), backend.tokenRequests) + assertTrue(backend.configured.isEmpty()) + } + + @Test + fun `empty challenge response returns to editable form without a token`() + { + val backend = Backend(ChallengesResponse(emptyList(), combiAnd = false)) + draft.startLogin(backend) + idle() + assertNull(draft.prompt) + assertFalse(draft.isSubmitting) + assertTrue(backend.configured.isEmpty()) + } + + @Test + fun `delivery and local access errors clear busy state and allow retry`() + { + val backend = Backend(ChallengesResponse(listOf(email), combiAnd = true)) + backend.failure = LocalNetworkAccessDenied() + draft.startLogin(backend) + idle() + assertEquals(R.string.config_local_network_required, draft.loginError.value) + assertFalse(draft.isSubmitting) + backend.failure = IOException("offline") + draft.startLogin(backend) + idle() + assertEquals(R.string.config_error_network, draft.loginError.value) + assertFalse(draft.isSubmitting) + backend.failure = null + draft.startLogin(backend) + idle() + submit() + assertEquals(listOf("limited"), backend.configured) + assertNull(draft.loginError.value) + } + + private fun submit() + { + (draft.prompt as LoginPrompt.Code).apply { + updateCode("12345678") + submit() + } + idle() + } + + private class Backend(private val challenges: ChallengesResponse) : LoginBackend + { + var failure: Exception? = null + val deliveries = mutableListOf<String>() + val confirmations = mutableListOf<String>() + val configured = mutableListOf<String>() + val tokenRequests = mutableListOf<List<String>>() + + override suspend fun fetchToken(challengeIds: List<String>): String + { + tokenRequests += challengeIds + if (challengeIds.isEmpty()) throw ChallengeRequiredException(challenges) + return "limited" + } + + override suspend fun requestChallenge(id: String) + { + failure?.let { throw it } + deliveries += id + } + + override suspend fun confirmChallenge(id: String, code: String) + { + assertEquals("1234-5678", code) + confirmations += id + } + + override fun configure(token: String) + { + configured += token + } + } +} 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 @@ -2,119 +2,137 @@ 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 -class ConfigProductTest { - +class ConfigProductTest +{ @Test - fun `display description is hidden when same as product name`() { - val product = ConfigProduct( - productName = "Coffee", - description = "Coffee", - price = Amount("KUDOS", 2, 0), - categories = listOf(1) - ) + fun `display description is hidden when same as product name`() + { + val product = + ConfigProduct( + productName = "Coffee", + description = "Coffee", + price = Amount("KUDOS", 2, 0), + categories = listOf(1), + ) assertEquals("Coffee", product.displayName) assertNull(product.displayDescription) } @Test - fun `display price formats amount`() { - val product = ConfigProduct( - description = "Coffee", - price = Amount("KUDOS", 2, 50000000), - categories = listOf(1) - ) + fun `display price formats amount`() + { + val product = + ConfigProduct( + description = "Coffee", + price = Amount("KUDOS", 2, 50000000), + categories = listOf(1), + ) // Plain JVM tests use Amount's pre-Android-11 currency-code formatter. assertEquals("KUDOS 2.50", product.displayPrice) } @Test - fun `display price uses currency code with spec on legacy formatter`() { - 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) - ) + fun `display price uses currency code with spec on legacy formatter`() + { + 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("CHF 2.50", product.displayPrice) } @Test - fun `stockLimit subtracts sold and lost from total stock`() { - val product = ConfigProduct( - description = "finite_quantity", - price = Amount("KUDOS", 10, 0), - categories = emptyList(), - totalStock = 10, - unitTotalStock = "10", - totalSold = 0, - totalLost = 2, - ) + fun `stockLimit subtracts sold and lost from total stock`() + { + val product = + ConfigProduct( + description = "finite_quantity", + price = Amount("KUDOS", 10, 0), + categories = emptyList(), + totalStock = 10, + unitTotalStock = "10", + totalSold = 0, + totalLost = 2, + ) assertEquals(8, product.stockLimit) } @Test - fun `stockLimit subtracts unit sold and lost`() { - val product = ConfigProduct( - description = "test", - price = Amount("KUDOS", 5, 0), - categories = emptyList(), - unitTotalStock = "10", - unitTotalSold = "2", - unitTotalLost = "3", - ) + fun `stockLimit subtracts unit sold and lost`() + { + val product = + ConfigProduct( + description = "test", + price = Amount("KUDOS", 5, 0), + categories = emptyList(), + unitTotalStock = "10", + unitTotalSold = "2", + unitTotalLost = "3", + ) assertEquals(5, product.stockLimit) } @Test - fun `stockLimit with no sold or lost returns total stock`() { - val product = ConfigProduct( - description = "test", - price = Amount("KUDOS", 5, 0), - categories = emptyList(), - totalStock = 10, - ) + fun `stockLimit with no sold or lost returns total stock`() + { + val product = + ConfigProduct( + description = "test", + price = Amount("KUDOS", 5, 0), + categories = emptyList(), + totalStock = 10, + ) assertEquals(10, product.stockLimit) } @Test - fun `stockLimit returns null for unlimited stock`() { - val product = ConfigProduct( - description = "test", - price = Amount("KUDOS", 5, 0), - categories = emptyList(), - totalStock = -1, - ) + fun `stockLimit returns null for unlimited stock`() + { + val product = + ConfigProduct( + description = "test", + price = Amount("KUDOS", 5, 0), + categories = emptyList(), + totalStock = -1, + ) assertNull(product.stockLimit) } @Test - fun `stockLimit clamps to zero when sold and lost exceed stock`() { - val product = ConfigProduct( - description = "test", - price = Amount("KUDOS", 5, 0), - categories = emptyList(), - totalStock = 5, - totalSold = 3, - totalLost = 5, - ) + fun `stockLimit clamps to zero when sold and lost exceed stock`() + { + val product = + ConfigProduct( + description = "test", + price = Amount("KUDOS", 5, 0), + categories = emptyList(), + totalStock = 5, + totalSold = 3, + totalLost = 5, + ) assertEquals(0, product.stockLimit) } diff --git a/merchant-terminal/src/test/java/net/taler/merchantpos/config/ConfigRequestsTest.kt b/merchant-terminal/src/test/java/net/taler/merchantpos/config/ConfigRequestsTest.kt @@ -0,0 +1,233 @@ +/* + * This file is part of GNU Taler + * (C) 2026 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.merchantpos.config + +import android.app.Application +import android.os.Looper + +import androidx.test.core.app.ApplicationProvider + +import net.taler.common.TokenDuration +import net.taler.common.createDefaultHttpClient +import net.taler.merchantpos.BuildConfig +import net.taler.merchantpos.R +import net.taler.merchantpos.order.OrderManager + +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf + +import java.io.IOException +import java.net.ServerSocket +import java.net.Socket +import java.util.concurrent.CopyOnWriteArrayList + +import kotlin.concurrent.thread +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking + +import org.robolectric.annotation.Config as TestConfig + +@RunWith(RobolectricTestRunner::class) +@TestConfig(sdk = [28], application = Application::class) +class ConfigRequestsTest +{ + private val app = ApplicationProvider.getApplicationContext<Application>() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private val client = createDefaultHttpClient(logging = false) + + @After + fun cleanup() + { + scope.cancel() + client.close() + app + .getSharedPreferences("taler-merchant-terminal", 0) + .edit() + .clear() + .commit() + } + + @Test + fun `empty inventory loads and persists through the real unescaped endpoint`() + { + TestServer { path -> + if (path.endsWith("/private/pos")) { + """{"categories":[],"products":[]}""" + } else { + """{"version":"${BuildConfig.BACKEND_API_VERSION}","currency":"KUDOS"}""" + } + }.use { server -> + val manager = ConfigManager(app, scope, client) + val orders = OrderManager(app) + manager.addConfigurationReceiver(orders) + manager.fetchConfig(Config.New(server.url, "test-only", true), true) + waitUntil { manager.configUpdateResult.value != null } + assertTrue(manager.configUpdateResult.value is ConfigUpdateResult.Success) + assertEquals(listOf("/instances/test/private/pos", "/instances/test/config"), server.paths) + assertEquals("KUDOS", manager.currency) + assertNotNull(manager.merchantConfig) + assertTrue(orders.products.value!!.isEmpty()) + val restored = ConfigManager(app, scope, client) + assertEquals("KUDOS", restored.currency) + assertEquals(server.url, (restored.config as Config.New).merchantUrl) + } + } + + @Test + fun `unresponsive configuration times out with a network error and can be retried`() + { + TestServer { null }.use { server -> + val manager = ConfigManager(app, scope, client, requestTimeoutMillis = 250) + val config = Config.New(server.url, "test-only", true) + manager.fetchConfig(config, true) + waitUntil { manager.configUpdateResult.value != null } + val error = manager.configUpdateResult.value as ConfigUpdateResult.Error + assertEquals(app.getString(R.string.config_error_network), error.msg) + assertNull(manager.merchantConfig) + manager.fetchConfig(config, true) + waitUntil { server.paths.size >= 2 } + manager.cancelConfigFetch() + assertNull(manager.configUpdateResult.value) + } + } + + @Test + fun `configuration cancellation stops pending requests and repeated attachment does not duplicate them`() + { + val gate = CompletableDeferred<Unit>() + val calls = CopyOnWriteArrayList<String>() + val manager = + ConfigManager(app, scope, client) { + calls += it + gate.await() + } + val config = Config.New("https://example.invalid/instances/test", "test-only", true) + manager.fetchConfig(config, true) + waitUntil { calls.size == 1 } + manager.fetchConfig(config, true) + manager.cancelConfigFetch() + gate.complete(Unit) + waitUntil { scope.coroutineContext[Job]!!.children.none { it.isActive } } + assertEquals(1, calls.size) + assertNull(manager.configUpdateResult.value) + assertNull(manager.merchantConfig) + } + + @Test + fun `login timeout starts after permission wait and applies to MFA requests too`() + { + TestServer { null }.use { server -> + val gate = CompletableDeferred<Unit>() + val manager = ConfigManager(app, scope, client, requestTimeoutMillis = 250) { gate.await() } + val request = + scope.async(Dispatchers.IO) { + runCatching { manager.fetchLimitedAccessToken(server.url, "test", "test", TokenDuration.Forever) } + } + Thread.sleep(350) + assertFalse(request.isCompleted) + assertTrue(server.paths.isEmpty()) + gate.complete(Unit) + waitUntil { request.isCompleted } + assertTrue(runBlocking { request.await() }.isFailure) + for (confirm in listOf(false, true)) + { + val mfa = + scope.async(Dispatchers.IO) { + runCatching { + if (confirm) { + manager.confirmChallenge(server.url, "test", "challenge", "12345678") + } else { + manager.requestChallenge(server.url, "test", "challenge") + } + } + } + waitUntil { mfa.isCompleted } + assertTrue(runBlocking { mfa.await() }.isFailure) + } + assertEquals(3, server.paths.size) + } + } + + private fun waitUntil(condition: () -> Boolean) + { + val deadline = System.nanoTime() + 10_000_000_000L + while (!condition() && System.nanoTime() < deadline) + { + shadowOf(Looper.getMainLooper()).idle() + Thread.sleep(10) + } + shadowOf(Looper.getMainLooper()).idle() + assertTrue("Request did not finish", condition()) + } + + private class TestServer(private val response: (String) -> String?) : AutoCloseable + { + private val server = ServerSocket(0) + private val sockets = CopyOnWriteArrayList<Socket>() + val paths = CopyOnWriteArrayList<String>() + val url = "http://127.0.0.1:${server.localPort}/instances/test" + private val worker = + thread(isDaemon = true) { + while (!server.isClosed) + { + val socket = + try + { + server.accept() + } + catch (_: IOException) + { + break + } + sockets += socket + val reader = socket.getInputStream().bufferedReader() + val path = reader.readLine().split(' ')[1] + while (!reader.readLine().isNullOrEmpty()) + { + } + paths += path + val body = response(path) ?: continue + socket.getOutputStream().write( + ("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n" + + "Content-Length: ${body.toByteArray().size}\r\nConnection: close\r\n\r\n" + body).toByteArray(), + ) + socket.close() + } + } + + override fun close() + { + server.close() + sockets.forEach { it.close() } + worker.join(2000) + } + } +} diff --git a/merchant-terminal/src/test/java/net/taler/merchantpos/config/ConfigSessionTest.kt b/merchant-terminal/src/test/java/net/taler/merchantpos/config/ConfigSessionTest.kt @@ -0,0 +1,109 @@ +/* + * This file is part of GNU Taler + * (C) 2026 Taler Systems S.A. + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package net.taler.merchantpos.config + +import android.app.Application + +import androidx.test.core.app.ApplicationProvider + +import net.taler.common.EventObserver +import net.taler.common.createDefaultHttpClient +import net.taler.merchantlib.MerchantConfig + +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel + +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class, sdk = [28]) +class ConfigSessionTest +{ + private val app = ApplicationProvider.getApplicationContext<Application>() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private val client = createDefaultHttpClient(logging = false) + private val manager = ConfigManager(app, scope, client) + private val base = "https://example.invalid/instances/test" + + private fun configure() + { + manager.debugApplyFixture( + PosConfig(categories = emptyList(), products = emptyList()), + MerchantConfig(base, "secret-token:current"), + "CHF", + ) + } + + @After + fun cleanup() + { + scope.cancel() + client.close() + app + .getSharedPreferences("taler-merchant-terminal", 0) + .edit() + .clear() + .commit() + } + + @Test + fun `token prefix probe and login failures leave the active merchant configured`() + { + configure() + manager.handleUnauthorized("$base/private/pos", "Bearer current") + manager.handleUnauthorized("$base/private/token", "Bearer secret-token:current") + manager.handleUnauthorized("$base/challenge/challenge/confirm", null) + assertNotNull(manager.merchantConfig) + assertNull(manager.sessionExpired.value) + } + + @Test + fun `late responses from another merchant or old credentials cannot expire current session`() + { + configure() + manager.handleUnauthorized( + "https://elsewhere.invalid/instances/test/private/orders", + "Bearer secret-token:current" + ) + manager.handleUnauthorized("${base}2/private/orders", "Bearer secret-token:current") + manager.handleUnauthorized("$base/private/orders", "Bearer secret-token:old") + manager.handleUnauthorized("$base/private/orders", null) + assertNotNull(manager.merchantConfig) + assertNull(manager.sessionExpired.value) + } + + @Test + fun `real session rejection clears credentials and is consumed once across observer recreation`() + { + configure() + var notifications = 0 + val first = EventObserver<Unit> { notifications++ } + manager.sessionExpired.observeForever(first) + manager.handleUnauthorized("$base/private/orders?limit=20", "Bearer secret-token:current") + assertNull(manager.merchantConfig) + assertEquals("", (manager.config as net.taler.merchantpos.config.Config.New).accessToken) + assertEquals(1, notifications) + manager.sessionExpired.removeObserver(first) + val recreated = EventObserver<Unit> { notifications++ } + manager.sessionExpired.observeForever(recreated) + assertEquals(1, notifications) + manager.handleUnauthorized("$base/private/orders", "Bearer secret-token:current") + assertEquals(1, notifications) + configure() + manager.handleUnauthorized("$base/private/orders", "Bearer secret-token:current") + assertEquals(2, notifications) + manager.sessionExpired.removeObserver(recreated) + } +} diff --git a/merchant-terminal/src/test/java/net/taler/merchantpos/config/LocalNetworkAccessTest.kt b/merchant-terminal/src/test/java/net/taler/merchantpos/config/LocalNetworkAccessTest.kt @@ -0,0 +1,351 @@ +/* + * This file is part of GNU Taler + * (C) 2026 Taler Systems S.A. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package net.taler.merchantpos.config + +import android.app.Application +import android.content.res.Configuration +import android.os.Looper + +import androidx.activity.ComponentActivity +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.test.core.app.ApplicationProvider + +import net.taler.common.TokenDuration +import net.taler.common.createDefaultHttpClient +import net.taler.merchantpos.R + +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +import java.net.InetAddress +import java.net.UnknownHostException + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], application = Application::class) +@OptIn(ExperimentalCoroutinesApi::class) +class LocalNetworkAccessTest +{ + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private var granted = false + private val access = LocalNetworkAccess(37, { granted }, { true }) + + @After + fun tearDown() + { + scope.cancel() + idle() + } + + @Test + fun `private addresses include IPv4 CGNAT link local and IPv6 ULA`() + { + listOf( + "10.0.0.2", + "172.16.1.2", + "172.31.255.254", + "192.168.1.1", + "169.254.2.1", + "100.64.0.1", + "100.127.255.254", + "fd12::1", + "fc00::2", + "fe80::1" + ).forEach { + assertTrue(it, isLocalMerchantHost(it)) + } + } + + @Test + fun `public and loopback addresses need no LAN permission`() + { + listOf( + "8.8.8.8", + "172.15.1.2", + "172.32.1.2", + "100.63.1.1", + "100.128.0.1", + "127.0.0.1", + "::1", + "2001:4860:4860::8888", + "localhost", + "test.localhost" + ).forEach { + assertFalse(it, isLocalMerchantHost(it)) + } + } + + @Test + fun `mDNS and single label names do not require a DNS lookup before permission`() + { + listOf("merchant.local", "MERCHANT.LOCAL.", "merchant").forEach { + assertTrue(isLocalMerchantHost(it, resolve = { error("Must not resolve before permission") })) + } + } + + @Test + fun `DNS names resolving to a local address are protected`() + { + assertTrue( + isLocalMerchantHost( + "merchant.example", + resolve = { + arrayOf(InetAddress.getByName("8.8.8.8"), InetAddress.getByName("192.168.4.2")) + } + ) + ) + assertFalse(isLocalMerchantHost("merchant.example", resolve = { throw UnknownHostException() })) + } + + @Test + fun `directly connected IPv6 global subnet is local`() + { + assertTrue(isLocalMerchantHost("2001:db8::2", directlyConnected = { true })) + } + + @Test + fun `older Android and already granted permission do not resolve or prompt`() = + runBlocking { + LocalNetworkAccess(36, { false }, { error("No lookup expected") }).ensureAccess("https://merchant.local") + LocalNetworkAccess(37, { true }, { error("No lookup expected") }).ensureAccess("https://merchant.local") + } + + @Test + fun `public merchant and invalid URL do not prompt`() = + runBlocking { + val public = LocalNetworkAccess(37, { false }, { false }) + public.ensureAccess("https://merchant.example") + public.ensureAccess("invalid url") + assertNull(public.host) + } + + @Test + fun `concurrent requests wait for one prompt then all continue on grant`() + { + val first = request() + val second = request() + idle() + assertEquals("merchant.local", access.host) + assertFalse(first.isCompleted) + assertFalse(second.isCompleted) + granted = true + access.permissionResult() + idle() + assertTrue(first.getCompleted().isSuccess) + assertTrue(second.getCompleted().isSuccess) + assertNull(access.host) + } + + @Test + fun `denial waits for settings and grant resumes original request`() + { + val request = request() + idle() + repeat(2) { access.permissionResult() } + assertFalse(request.isCompleted) + assertEquals(2, access.permissionResults) + granted = true + access.permissionResult() + idle() + assertTrue(request.getCompleted().isSuccess) + } + + @Test + fun `dismissal suppresses background prompts until explicit retry`() + { + val first = request() + idle() + access.dismiss() + idle() + assertTrue(first.getCompleted().exceptionOrNull() is LocalNetworkAccessDenied) + val background = request() + idle() + assertTrue(background.getCompleted().exceptionOrNull() is LocalNetworkAccessDenied) + assertNull(access.host) + access.retry() + val retried = request() + idle() + assertEquals("merchant.local", access.host) + assertFalse(retried.isCompleted) + granted = true + access.permissionResult() + idle() + assertTrue(retried.getCompleted().isSuccess) + } + + @Test + fun `cancelled login removes prompt without leaving an orphan request`() + { + val first = request() + idle() + first.cancel() + idle() + assertNull(access.host) + val second = request() + idle() + granted = true + access.permissionResult() + idle() + assertTrue(second.getCompleted().isSuccess) + } + + @Test + fun `rotation keeps the pending connection and grant continues it once`() + { + val activity = Robolectric.buildActivity(ComponentActivity::class.java).setup() + try + { + val model = ViewModelProvider(activity.get())[NetworkTestModel::class.java] + var calls = 0 + val request = + scope.async { + model.access.ensureAccess("https://merchant.local") + calls++ + } + idle() + activity.configurationChange( + Configuration(activity.get().resources.configuration).apply { + orientation = Configuration.ORIENTATION_LANDSCAPE + } + ) + val retained = ViewModelProvider(activity.get())[NetworkTestModel::class.java] + assertSame(model, retained) + assertEquals("merchant.local", retained.access.host) + assertFalse(request.isCompleted) + retained.granted = true + retained.access.permissionResult() + idle() + assertTrue(request.isCompleted) + assertEquals(1, calls) + } + finally + { + activity.pause().stop().destroy() + } + } + + @Test + fun `login and MFA are gated before any HTTP request`() = + runBlocking { + val client = createDefaultHttpClient() + val checked = mutableListOf<String>() + val manager = + ConfigManager(ApplicationProvider.getApplicationContext(), scope, client) { + checked += it + throw LocalNetworkAccessDenied() + } + try + { + val url = "https://merchant.local:8443" + assertTrue( + runCatching { + manager.fetchLimitedAccessToken( + url, + "test", + "test", + TokenDuration.Forever + ) + }.exceptionOrNull() is LocalNetworkAccessDenied + ) + assertTrue( + runCatching { + manager.requestChallenge( + url, + "test", + "test" + ) + }.exceptionOrNull() is LocalNetworkAccessDenied + ) + assertTrue( + runCatching { + manager.confirmChallenge( + url, + "test", + "test", + "123456" + ) + }.exceptionOrNull() is LocalNetworkAccessDenied + ) + assertEquals(listOf(url, url, url), checked) + } + finally + { + client.close() + } + } + + @Test + fun `QR and saved configuration stop before connecting when permission is denied`() + { + val client = createDefaultHttpClient() + val job = SupervisorJob() + val app = ApplicationProvider.getApplicationContext<Application>() + val checked = mutableListOf<String>() + val manager = + ConfigManager(app, CoroutineScope(job + Dispatchers.Main.immediate), client) { + checked += it + throw LocalNetworkAccessDenied() + } + try + { + val config = + net.taler.merchantpos.config.Config.New( + "https://merchant.local:8443/instances/test", + "test", + true + ) + manager.fetchConfig(config, true) + val deadline = System.nanoTime() + 5_000_000_000L + while (job.children.any { it.isActive } && System.nanoTime() < deadline) + { + idle() + Thread.sleep(10) + } + assertFalse("Configuration request did not finish", job.children.any { it.isActive }) + idle() + assertEquals(1, checked.size) + assertTrue(checked.single().startsWith(config.merchantUrl)) + val result = manager.configUpdateResult.value as ConfigUpdateResult.Error + assertTrue(result.returnToConfig) + assertEquals(app.getString(R.string.config_local_network_required), result.msg) + } + finally + { + job.cancel() + client.close() + } + } + + private fun request() = + scope.async { runCatching { access.ensureAccess("https://merchant.local:8443/instances/test") } } + + private fun idle() = shadowOf(Looper.getMainLooper()).idle() +} + +class NetworkTestModel : ViewModel() +{ + var granted = false + val access = LocalNetworkAccess(37, { granted }, { true }) +} diff --git a/merchant-terminal/src/test/java/net/taler/merchantpos/config/MerchantSetupUriTest.kt b/merchant-terminal/src/test/java/net/taler/merchantpos/config/MerchantSetupUriTest.kt @@ -0,0 +1,60 @@ +/* + * This file is part of GNU Taler + * (C) 2026 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.merchantpos.config + +import android.net.Uri + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28]) +class MerchantSetupUriTest +{ + @Test + fun `setup links preserve custom ports and both credential formats`() + { + for (link in listOf( + "taler-pos://example.invalid:8443/instances/cafe#test-token", + "taler-pos://example.invalid:8443/#username=cafe&password=test-token", + )) + { + val config = merchantSetupConfig(Uri.parse(link))!! + assertEquals("https://example.invalid:8443/instances/cafe", config.merchantUrl) + assertEquals("test-token", config.accessToken) + } + } + + @Test + fun `incomplete setup links do not begin fetching`() + { + for (link in listOf( + "taler-pos:///instances/cafe#token", + "taler-pos://example.invalid/instances/cafe#", + "taler-pos://example.invalid/#username=&password=token", + "taler-pos://example.invalid/#username=cafe&password=", + "https://example.invalid/instances/cafe#token", + )) + { + assertNull(link, merchantSetupConfig(Uri.parse(link))) + } + } +} diff --git a/merchant-terminal/src/test/java/net/taler/merchantpos/config/PriceLocaleTest.kt b/merchant-terminal/src/test/java/net/taler/merchantpos/config/PriceLocaleTest.kt @@ -0,0 +1,103 @@ +package net.taler.merchantpos.config + +import android.app.Application +import android.icu.util.Currency +import android.icu.util.ULocale + +import net.taler.common.Amount +import net.taler.common.CurrencySpecification + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +import java.text.DecimalFormatSymbols +import java.util.Locale + +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class, sdk = [34]) +class PriceLocaleTest +{ + private val price = + Amount("CHF", 2, 50000000).withSpec( + CurrencySpecification( + name = "Swiss Francs", + numFractionalInputDigits = 2, + numFractionalNormalDigits = 2, + numFractionalTrailingZeroDigits = 2, + altUnitNames = mapOf(0 to "Fr."), + ), + ) + + @Test + fun `language-only app locale renders inventory prices and order totals`() + { + val previous = Locale.getDefault() + try + { + Locale.setDefault(Locale.ENGLISH) + assertNull(Currency.getInstance(ULocale.forLocale(Locale.getDefault()))) + val product = ConfigProduct(description = "Coffee", price = price, categories = emptyList()) + assertEquals("Fr. 2.50", normalizeSpace(product.displayPrice)) + assertEquals("KUDOS 0.00", normalizeSpace(Amount("KUDOS", 0, 0).toString())) + } + finally + { + Locale.setDefault(previous) + } + } + + @Test + fun `regionless locales keep decimal formatting symbol and full precision`() + { + for ((locale, expected) in listOf(Locale.ENGLISH to "Fr. 2.50", Locale.GERMAN to "2,50 Fr.")) + { + assertEquals( + expected, + normalizeSpace(price.toString(locale = locale, symbols = DecimalFormatSymbols(locale))), + ) + } + assertEquals( + "-KUDOS 2.50000001", + normalizeSpace( + Amount("KUDOS", 2, 50000001).toString( + negative = true, + locale = Locale.ENGLISH, + symbols = DecimalFormatSymbols(Locale.ENGLISH), + ), + ), + ) + assertEquals( + "2.50", + price + .toString( + showSymbol = false, + locale = Locale.ROOT, + symbols = DecimalFormatSymbols(Locale.ROOT) + ).trim(), + ) + } + + @Test + fun `regional locale retains existing currency layout`() + { + assertEquals( + "Fr. 2.50", + normalizeSpace(price.toString(locale = Locale.US, symbols = DecimalFormatSymbols(Locale.US))), + ) + assertEquals( + "-2.50", + price.toString( + showSymbol = false, + negative = true, + locale = Locale.US, + symbols = DecimalFormatSymbols(Locale.US), + ), + ) + } + + private fun normalizeSpace(value: String) = value.replace('\u00a0', ' ').replace('\u202f', ' ') +} diff --git a/merchant-terminal/src/test/java/net/taler/merchantpos/config/QrLuminanceTest.kt b/merchant-terminal/src/test/java/net/taler/merchantpos/config/QrLuminanceTest.kt @@ -0,0 +1,87 @@ +/* + * This file is part of GNU Taler + * (C) 2026 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.merchantpos.config + +import com.google.zxing.BarcodeFormat +import com.google.zxing.BinaryBitmap +import com.google.zxing.MultiFormatReader +import com.google.zxing.common.HybridBinarizer +import com.google.zxing.qrcode.QRCodeWriter + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +import java.nio.ByteBuffer + +class QrLuminanceTest +{ + @Test + fun `padded interleaved camera frames decode at every rotation without consuming the buffer`() + { + val payload = "taler-pos://example.invalid:8443/instances/cafe#test-only" + val qr = QRCodeWriter().encode(payload, BarcodeFormat.QR_CODE, 191, 173) + for (pixelStride in listOf(1, 2)) + { + val rowStride = qr.width * pixelStride + 11 + val offset = 7 + val buffer = ByteBuffer.allocate(offset + rowStride * qr.height) + for (y in 0 until qr.height) + { + for (x in 0 until qr.width) + { + buffer.put(offset + y * rowStride + x * pixelStride, if (qr[x, y]) 0 else 255.toByte()) + } + } + buffer.position(offset) + for (rotation in listOf(0, 90, 180, 270)) + { + val source = qrLuminance(buffer, qr.width, qr.height, rowStride, pixelStride, rotation) + val decoded = MultiFormatReader().decode(BinaryBitmap(HybridBinarizer(source))) + assertEquals(payload, decoded.text) + assertEquals(offset, buffer.position()) + } + } + } + + @Test + fun `rotation preserves exact rectangular pixel geometry`() + { + val buffer = ByteBuffer.wrap(byteArrayOf(1, 2, 3, 4, 5, 6)) + val expected = + mapOf( + 0 to byteArrayOf(1, 2, 3, 4, 5, 6), + 90 to byteArrayOf(4, 1, 5, 2, 6, 3), + 180 to byteArrayOf(6, 5, 4, 3, 2, 1), + 270 to byteArrayOf(3, 6, 2, 5, 1, 4), + ) + expected.forEach { (rotation, pixels) -> + val source = qrLuminance(buffer, 3, 2, 3, 1, rotation) + assertArrayEquals(pixels, source.matrix) + assertEquals(if (rotation % 180 == 0) 3 else 2, source.width) + } + } + + @Test + fun `truncated camera plane is rejected`() + { + assertThrows(IllegalArgumentException::class.java) { + qrLuminance(ByteBuffer.allocate(5), 3, 2, 3, 1, 0) + } + } +} diff --git a/merchant-terminal/src/test/java/net/taler/merchantpos/devices/AndroidTalerUsbTransportTest.kt b/merchant-terminal/src/test/java/net/taler/merchantpos/devices/AndroidTalerUsbTransportTest.kt @@ -0,0 +1,196 @@ +/* + * 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.devices + +import android.app.Application +import android.content.Context +import android.hardware.usb.UsbDevice +import android.hardware.usb.UsbDeviceConnection +import android.hardware.usb.UsbManager + +import androidx.test.core.app.ApplicationProvider + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.Implementation +import org.robolectric.annotation.Implements +import org.robolectric.util.ReflectionHelpers + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], application = Application::class, shadows = [RecordingUsbConnection::class]) +class AndroidTalerUsbTransportTest +{ + private lateinit var manager: UsbManager + private lateinit var transport: AndroidTalerUsbTransport + + @Before + fun setup() + { + val context = ApplicationProvider.getApplicationContext<Context>() + manager = context.getSystemService(Context.USB_SERVICE) as UsbManager + transport = AndroidTalerUsbTransport(manager) + RecordingUsbConnection.calls.clear() + RecordingUsbConnection.closed = 0 + RecordingUsbConnection.result = null + RecordingUsbConnection.fail = false + } + + @Test + fun `enumerates only the supported device and reports actual USB permission`() + { + attach("taler", permitted = false) + attach("other", vendor = 0x1234) + assertEquals(listOf(TalerUsbDevice("taler", false)), transport.devices()) + } + + @Test + fun `sends exact vendor request and UTF8 bytes then closes connection`() + { + attach("taler") + val bytes = "taler://pay/example/é".toByteArray(Charsets.UTF_8) + assertTrue(transport.write(transport.devices().single(), bytes)) + val call = RecordingUsbConnection.calls.single() + assertEquals(listOf(0x40, 0, 0, 0, bytes.size, 1000), call.parameters) + assertArrayEquals(bytes, call.bytes) + assertEquals(1, RecordingUsbConnection.closed) + } + + @Test + fun `short writes and negative responses fail and close the connection`() + { + attach("taler") + val device = transport.devices().single() + RecordingUsbConnection.result = 2 + assertFalse(transport.write(device, ByteArray(5))) + RecordingUsbConnection.result = -1 + assertFalse(transport.write(device, ByteArray(5))) + assertEquals(2, RecordingUsbConnection.closed) + } + + @Test + fun `empty URL uses a zero length transfer without adding serial newline`() + { + attach("taler") + assertTrue(transport.write(transport.devices().single(), byteArrayOf())) + assertEquals( + 0, + RecordingUsbConnection.calls + .single() + .bytes.size, + ) + } + + @Test + fun `permission is rechecked immediately before opening the device`() + { + val attached = attach("taler") + val snapshot = transport.devices().single() + shadowOf(manager).addOrUpdateUsbDevice(attached, false) + assertFalse(transport.write(snapshot, byteArrayOf(1))) + assertTrue(RecordingUsbConnection.calls.isEmpty()) + } + + @Test + fun `unplugged or unsupported devices are never opened`() + { + val attached = attach("taler") + val snapshot = transport.devices().single() + shadowOf(manager).removeUsbDevice(attached) + assertFalse(transport.write(snapshot, byteArrayOf(1))) + attach("taler", vendor = 0x1234) + assertFalse(transport.write(snapshot, byteArrayOf(1))) + assertTrue(RecordingUsbConnection.calls.isEmpty()) + } + + @Test + fun `connection closes when a transfer throws`() + { + attach("taler") + RecordingUsbConnection.fail = true + try + { + transport.write(transport.devices().single(), byteArrayOf(1)) + fail("Expected transfer failure") + } + catch (_: IllegalStateException) + { + assertEquals(1, RecordingUsbConnection.closed) + } + } + + private fun attach( + name: String, + vendor: Int = 0xd00d, + permitted: Boolean = true, + ): UsbDevice + { + val device = ReflectionHelpers.newInstance(UsbDevice::class.java) + ReflectionHelpers.setField(device, "mName", name) + ReflectionHelpers.setField(device, "mVendorId", vendor) + ReflectionHelpers.setField(device, "mProductId", 0x8f71) + shadowOf(manager).addOrUpdateUsbDevice(device, permitted) + return device + } +} + +@Implements(UsbDeviceConnection::class) +class RecordingUsbConnection +{ + data class Call( + val parameters: List<Int>, + val bytes: ByteArray, + ) + + companion object + { + val calls = mutableListOf<Call>() + var closed = 0 + var result: Int? = null + var fail = false + } + + @Implementation + fun controlTransfer( + type: Int, + request: Int, + value: Int, + index: Int, + buffer: ByteArray, + length: Int, + timeout: Int, + ): Int + { + if (fail) throw IllegalStateException("USB disconnected") + calls += Call(listOf(type, request, value, index, length, timeout), buffer.copyOf(length)) + return result ?: length + } + + @Implementation + fun close() + { + closed++ + } +} diff --git a/merchant-terminal/src/test/java/net/taler/merchantpos/devices/TalerBleAdvertisementTest.kt b/merchant-terminal/src/test/java/net/taler/merchantpos/devices/TalerBleAdvertisementTest.kt @@ -0,0 +1,80 @@ +/* + * 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.devices + +import android.app.Application +import android.bluetooth.le.ScanRecord + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.util.ReflectionHelpers +import org.robolectric.util.ReflectionHelpers.ClassParameter + +/** Wire-format compatibility with the Gen2 service-first discovery layout. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], application = Application::class) +class TalerBleAdvertisementTest +{ + // Flags + Complete List of 128-bit Service UUIDs, little-endian on air. + private val advertisement = hex("02 01 06 11 07 b7 18 7f 3c 32 22 e8 b0 a9 4d 52 83 d3 2a 94 04") + private val scanResponse = hex("0d 09") + "Taler FA0188".toByteArray(Charsets.UTF_8) + + @Test + fun `finds service-only advertisements before the scan response arrives`() + { + val record = parse(advertisement) + assertNull(record.deviceName) + val services = record.serviceUuids.orEmpty().map { it.uuid } + assertEquals(listOf(TalerBleProtocol.SERVICE), services) + assertTrue(TalerBleProtocol.isCandidate(record.deviceName, services)) + } + + @Test + fun `scan response retains the unique name used for pairing`() + { + assertTrue(advertisement.size <= 31) + assertTrue(scanResponse.size <= 31) + val record = parse(advertisement + scanResponse) + assertEquals("Taler FA0188", record.deviceName) + assertTrue(TalerBleProtocol.isBondedFirmwareName(record.deviceName)) + assertTrue(TalerBleProtocol.isCandidate(record.deviceName, record.serviceUuids.orEmpty().map { it.uuid })) + } + + @Test + fun `does not discover a different service without a matching device name`() + { + val other = advertisement.copyOf().apply { this[lastIndex] = 0x05 } + val record = parse(other) + assertFalse(TalerBleProtocol.isCandidate(record.deviceName, record.serviceUuids.orEmpty().map { it.uuid })) + } + + // Android's real parser is hidden from the public compile SDK. + private fun parse(bytes: ByteArray): ScanRecord = + ReflectionHelpers.callStaticMethod( + ScanRecord::class.java, + "parseFromBytes", + ClassParameter.from(ByteArray::class.java, bytes), + ) + + private fun hex(value: String) = value.split(" ").map { it.toInt(16).toByte() }.toByteArray() +} diff --git a/merchant-terminal/src/test/java/net/taler/merchantpos/devices/TalerBleProtocolTest.kt b/merchant-terminal/src/test/java/net/taler/merchantpos/devices/TalerBleProtocolTest.kt @@ -0,0 +1,172 @@ +/* + * 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.devices + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +import java.util.UUID + +class TalerBleProtocolTest +{ + private val queue = TalerBleUrlQueue() + + @Test + fun `discovers hardware identity name without requiring service advertisement`() + { + assertTrue(TalerBleProtocol.isCandidate("Taler FA0188", emptyList())) + assertTrue(TalerBleProtocol.isBondedFirmwareName("Taler FA0188")) + assertFalse(TalerBleProtocol.isBondedFirmwareName("Taler NFC Terminal")) + assertFalse(TalerBleProtocol.isCandidate("Taler FA0188 headphones", emptyList())) + } + + @Test + fun `discovers this firmware even though it advertises the battery service`() + { + assertTrue( + TalerBleProtocol.isCandidate( + "Taler NFC Terminal", + listOf(UUID.fromString("0000180f-0000-1000-8000-00805f9b34fb")), + ), + ) + assertTrue(TalerBleProtocol.isCandidate("renamed", listOf(TalerBleProtocol.SERVICE))) + assertFalse(TalerBleProtocol.isCandidate("Headphones", emptyList())) + } + + @Test + fun `idle is a zero length write without a newline or padding`() + { + assertArrayEquals(byteArrayOf(), queue.next()) + queue.complete(true) + assertTrue(queue.settled) + assertNull(queue.next()) + } + + @Test + fun `only one write is in flight and cancellation supersedes the next URL`() + { + queue.mtu = 255 + queue.desired = "taler://pay/order-a" + assertEquals(queue.desired, queue.next()!!.decodeToString()) + queue.desired = "taler://pay/order-b" + assertNull(queue.next()) + queue.desired = "" + queue.complete(true) + assertArrayEquals(byteArrayOf(), queue.next()) + } + + @Test + fun `write failure never counts as delivery`() + { + queue.desired = "test" + queue.next() + queue.complete(false) + assertFalse(queue.settled) + assertEquals("test", queue.next()!!.decodeToString()) + } + + @Test + fun `connection reset discards acknowledgements and negotiated MTU`() + { + queue.mtu = 255 + queue.desired = "test" + queue.next() + queue.complete(true) + queue.reset() + assertEquals(23, queue.mtu) + assertFalse(queue.settled) + assertEquals("test", queue.next()!!.decodeToString()) + } + + @Test + fun `firmware MTU 255 allows at most 252 bytes in one write`() + { + queue.mtu = 255 + queue.desired = "a".repeat(252) + assertFalse(queue.tooLong) + assertEquals(252, queue.next()!!.size) + queue.complete(true) + queue.desired += "b" + assertTrue(queue.tooLong) + // Clear the previous link, never send fragments or a truncated URL. + assertArrayEquals(byteArrayOf(), queue.next()) + } + + @Test + fun `large negotiated MTU still respects the firmware URL buffer`() + { + queue.mtu = 517 + queue.desired = "a".repeat(256) + assertTrue(queue.tooLong) + assertArrayEquals(byteArrayOf(), queue.next()) + } + + @Test + fun `size limit uses UTF8 bytes rather than character count`() + { + queue.mtu = 255 + queue.desired = "é".repeat(126) + assertFalse(queue.tooLong) + queue.desired += "é" + assertTrue(queue.tooLong) + } + + @Test + fun `cancelled and oversized payment links never substitute a website`() + { + assertArrayEquals(byteArrayOf(), queue.next()) + queue.complete(true) + queue.desired = "taler://pay/order" + assertEquals(queue.desired, queue.next()!!.decodeToString()) + queue.complete(true) + queue.desired = "x".repeat(256) + assertTrue(queue.tooLong) + assertArrayEquals(byteArrayOf(), queue.next()) + queue.complete(true) + queue.desired = "" + assertNull(queue.next()) + queue.reset() + } + + @Test + fun `clean reconnect does not create a website QR`() + { + queue.desired = "taler://pay/order" + queue.next() + queue.complete(true) + queue.desired = "" + assertArrayEquals(byteArrayOf(), queue.next()) + queue.complete(true) + assertNull(queue.next()) + queue.reset() + assertArrayEquals(byteArrayOf(), queue.next()) + } + + @Test + fun `interrupted payment write sends an empty reset on reconnect`() + { + queue.desired = "taler://pay/order" + queue.next() + queue.reset() + queue.desired = "" + assertArrayEquals(byteArrayOf(), queue.next()) + } +} diff --git a/merchant-terminal/src/test/java/net/taler/merchantpos/devices/TalerBluetoothDeviceTest.kt b/merchant-terminal/src/test/java/net/taler/merchantpos/devices/TalerBluetoothDeviceTest.kt @@ -0,0 +1,461 @@ +/* + * 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.devices + +import android.Manifest +import android.app.Application +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGattCharacteristic +import android.bluetooth.BluetoothGattService +import android.bluetooth.BluetoothManager +import android.bluetooth.BluetoothProfile +import android.content.Context +import android.content.Intent +import android.os.Looper + +import androidx.test.core.app.ApplicationProvider + +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.Implementation +import org.robolectric.annotation.Implements +import org.robolectric.shadows.ShadowBluetoothGatt +import org.robolectric.util.ReflectionHelpers + +import java.time.Duration +import java.util.UUID + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], application = Application::class, shadows = [PendingReadGatt::class]) +class TalerBluetoothDeviceTest +{ + private lateinit var app: Application + private lateinit var display: TalerBluetoothDevice + private lateinit var adapter: BluetoothAdapter + private lateinit var gatt: BluetoothGatt + + @Before + fun setup() + { + app = ApplicationProvider.getApplicationContext() + shadowOf(app).grantPermissions(Manifest.permission.ACCESS_FINE_LOCATION) + adapter = (app.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager).adapter + shadowOf(adapter).setState(BluetoothAdapter.STATE_ON) + app + .getSharedPreferences("taler-bluetooth-device", Context.MODE_PRIVATE) + .edit() + .clear() + .putBoolean("enabled", true) + .putString("address", "00:11:22:33:44:55") + .commit() + display = TalerBluetoothDevice(app) + } + + @After + fun cleanup() + { + display.close() + idle() + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofSeconds(11)) + } + + private fun connect( + withService: Boolean = true, + urlUuid: UUID = TalerBleProtocol.URL, + serviceUuid: UUID = TalerBleProtocol.SERVICE, + modern: Boolean = false, + ) + { + if (modern) shadowOf(adapter.getRemoteDevice("00:11:22:33:44:55")).setBondState(BluetoothDevice.BOND_BONDED) + display.setForeground(true) + gatt = ReflectionHelpers.getField(display, "gatt") + if (withService) + { + val service = BluetoothGattService(serviceUuid, BluetoothGattService.SERVICE_TYPE_PRIMARY) + service.addCharacteristic( + BluetoothGattCharacteristic( + urlUuid, + BluetoothGattCharacteristic.PROPERTY_WRITE, + BluetoothGattCharacteristic.PERMISSION_WRITE, + ), + ) + if (modern) + { + service.addCharacteristic( + BluetoothGattCharacteristic( + TalerBleProtocol.CONTROL, + BluetoothGattCharacteristic.PROPERTY_WRITE, + BluetoothGattCharacteristic.PERMISSION_WRITE_ENCRYPTED_MITM, + ), + ) + service.addCharacteristic( + BluetoothGattCharacteristic( + TalerBleProtocol.STATUS, + BluetoothGattCharacteristic.PROPERTY_READ, + BluetoothGattCharacteristic.PERMISSION_READ_ENCRYPTED_MITM, + ), + ) + } + shadowOf(gatt).addDiscoverableService(service) + } + shadowOf( + gatt, + ).gattCallback.onConnectionStateChange(gatt, BluetoothGatt.GATT_SUCCESS, BluetoothProfile.STATE_CONNECTED) + idle() + } + + @Test + fun `verifies the URL service before reporting ready`() + { + connect() + assertEquals(TalerBleStatus.Ready, display.state.value!!.status) + assertArrayEquals(byteArrayOf(), shadowOf(gatt).latestWrittenBytes) + } + + @Test + fun `Bluetooth is enabled by default but does not connect without selecting a display`() + { + display.close() + app + .getSharedPreferences("taler-bluetooth-device", Context.MODE_PRIVATE) + .edit() + .clear() + .commit() + display = TalerBluetoothDevice(app) + display.setForeground(true) + assertTrue(display.state.value!!.enabled) + assertNull(display.state.value!!.selected) + assertEquals(TalerBleStatus.Disconnected, display.state.value!!.status) + assertNull(ReflectionHelpers.getField<BluetoothGatt?>(display, "gatt")) + } + + @Test + fun `old Bluetooth opt out does not block the switch-free connection flow`() + { + display.setEnabled(false) + display.close() + display = TalerBluetoothDevice(app) + display.setForeground(true) + assertTrue(display.state.value!!.enabled) + } + + @Test + fun `incompatible peripheral is not reported ready`() + { + connect(false) + assertEquals(TalerBleStatus.Failed, display.state.value!!.status) + assertTrue(shadowOf(gatt).isClosed) + } + + @Test + fun `physical LCD standard URI characteristic requests empty reset after test`() + { + connect(urlUuid = TalerBleProtocol.STANDARD_URI) + assertEquals(TalerBleStatus.Ready, display.state.value!!.status) + assertTrue(display.state.value!!.hasResetLimitation) + assertArrayEquals(byteArrayOf(), shadowOf(gatt).latestWrittenBytes) + display.testDisplay() + idle() + assertEquals(TalerBleProtocol.TEST_URL, shadowOf(gatt).latestWrittenBytes.decodeToString()) + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofSeconds(5)) + assertArrayEquals(byteArrayOf(), shadowOf(gatt).latestWrittenBytes) + assertEquals(TalerBleStatus.Ready, display.state.value!!.status) + } + + @Test + fun `physical LCD requests empty reset before background disconnect`() + { + connect(urlUuid = TalerBleProtocol.STANDARD_URI) + display.present(this, "taler://pay/order") + idle() + display.setForeground(false) + idle() + assertArrayEquals(byteArrayOf(), shadowOf(gatt).latestWrittenBytes) + assertTrue(shadowOf(gatt).isClosed) + } + + @Test + fun `standard URI on an unrelated service is rejected`() + { + connect( + urlUuid = TalerBleProtocol.STANDARD_URI, + serviceUuid = UUID.fromString("0000180f-0000-1000-8000-00805f9b34fb"), + ) + assertEquals(TalerBleStatus.Failed, display.state.value!!.status) + assertNull(shadowOf(gatt).latestWrittenBytes) + } + + @Test + fun `back from payment resets physical LCD without replacing it with a website`() + { + connect(urlUuid = TalerBleProtocol.STANDARD_URI) + display.present(this, "taler://pay/order") + idle() + assertEquals("taler://pay/order", shadowOf(gatt).latestWrittenBytes.decodeToString()) + display.dismiss(this) + idle() + assertArrayEquals(byteArrayOf(), shadowOf(gatt).latestWrittenBytes) + assertEquals(TalerBleStatus.Ready, display.state.value!!.status) + assertFalse(shadowOf(gatt).isClosed) + } + + @Test + fun `leaving the app clears the display before disconnecting`() + { + connect() + display.present(this, "taler://pay/order") + idle() + assertEquals("taler://pay/order", shadowOf(gatt).latestWrittenBytes.decodeToString()) + display.setForeground(false) + idle() + assertArrayEquals(byteArrayOf(), shadowOf(gatt).latestWrittenBytes) + assertTrue(shadowOf(gatt).isClosed) + } + + @Test + fun `late old screen dismissal cannot erase a new screen link`() + { + connect() + val old = Any() + val next = Any() + display.present(old, "taler://pay/old") + display.present(next, "taler://pay/new") + display.dismiss(old) + idle() + assertEquals("taler://pay/new", shadowOf(gatt).latestWrittenBytes.decodeToString()) + } + + @Test + fun `test display clears automatically without a payment`() + { + connect() + display.testDisplay() + idle() + assertEquals(TalerBleProtocol.TEST_URL, shadowOf(gatt).latestWrittenBytes.decodeToString()) + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofSeconds(5)) + assertArrayEquals(byteArrayOf(), shadowOf(gatt).latestWrittenBytes) + } + + @Test + fun `a previous test timer cannot clear a newer payment`() + { + connect() + display.testDisplay() + idle() + display.present(this, "taler://pay/new") + idle() + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofSeconds(5)) + assertEquals("taler://pay/new", shadowOf(gatt).latestWrittenBytes.decodeToString()) + } + + @Test + fun `oversized URL clears previous display and reports fallback`() + { + connect() + display.present(this, "taler://pay/order") + idle() + display.present(this, "x".repeat(256)) + idle() + assertEquals(TalerBleStatus.TooLong, display.state.value!!.status) + assertArrayEquals(byteArrayOf(), shadowOf(gatt).latestWrittenBytes) + } + + @Test + fun `logout clears display and forgets saved hardware`() + { + connect() + display.present(this, "taler://pay/order") + idle() + display.logout() + idle() + assertArrayEquals(byteArrayOf(), shadowOf(gatt).latestWrittenBytes) + assertTrue(shadowOf(gatt).isClosed) + assertNull(display.state.value!!.selected) + assertTrue(display.state.value!!.enabled) + } + + @Test + fun `denied permission does not initiate a connection`() + { + shadowOf(app).denyPermissions(Manifest.permission.ACCESS_FINE_LOCATION) + display.setForeground(true) + assertEquals(TalerBleStatus.PermissionRequired, display.state.value!!.status) + assertNull(ReflectionHelpers.getField<BluetoothGatt?>(display, "gatt")) + } + + private fun idle() + { + shadowOf(Looper.getMainLooper()).idle() + } + + private fun beginModernPairing(): BluetoothDevice + { + display.close() + app + .getSharedPreferences("taler-bluetooth-device", Context.MODE_PRIVATE) + .edit() + .putString("name", "Taler FA0188") + .commit() + val device = adapter.getRemoteDevice("00:11:22:33:44:55") + shadowOf(device).setCreatedBond(true) + display = TalerBluetoothDevice(app) + display.setForeground(true) + assertEquals(TalerBleStatus.Pairing, display.state.value!!.status) + return device + } + + private fun bondChanged( + device: BluetoothDevice, + state: Int, + ) + { + shadowOf(device).setBondState(state) + app.sendBroadcast( + Intent(BluetoothDevice.ACTION_BOND_STATE_CHANGED).putExtra(BluetoothDevice.EXTRA_DEVICE, device), + ) + idle() + } + + private fun statusResult( + status: Int = BluetoothGatt.GATT_SUCCESS, + bytes: ByteArray = byteArrayOf(1, 0, 0, 0, 0, 0), + ) + { + val characteristic = gatt.getService(TalerBleProtocol.SERVICE).getCharacteristic(TalerBleProtocol.STATUS) + @Suppress("DEPRECATION") + characteristic.value = bytes + @Suppress("DEPRECATION") + shadowOf(gatt).gattCallback.onCharacteristicRead(gatt, characteristic, status) + idle() + } + + @Test + fun `new LCD waits for system bonding before GATT connection`() + { + val device = beginModernPairing() + assertNull(ReflectionHelpers.getField<BluetoothGatt?>(display, "gatt")) + bondChanged(device, BluetoothDevice.BOND_BONDED) + assertEquals(TalerBleStatus.Connecting, display.state.value!!.status) + assertNotNull(ReflectionHelpers.getField<BluetoothGatt?>(display, "gatt")) + } + + @Test + fun `pairing timeout is bounded even while system dialog backgrounds app`() + { + beginModernPairing() + display.setForeground(false) + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofSeconds(60)) + assertEquals(TalerBleStatus.PairingRequired, display.state.value!!.status) + display.setForeground(true) + assertEquals(TalerBleStatus.PairingRequired, display.state.value!!.status) + } + + @Test + fun `bond completion in background waits for foreground to connect`() + { + val device = beginModernPairing() + display.setForeground(false) + bondChanged(device, BluetoothDevice.BOND_BONDED) + assertNull(ReflectionHelpers.getField<BluetoothGatt?>(display, "gatt")) + display.setForeground(true) + assertEquals(TalerBleStatus.Connecting, display.state.value!!.status) + } + + @Test + fun `cancelled pairing needs explicit retry`() + { + val device = beginModernPairing() + bondChanged(device, BluetoothDevice.BOND_NONE) + assertEquals(TalerBleStatus.PairingRequired, display.state.value!!.status) + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofSeconds(90)) + assertNull(ReflectionHelpers.getField<BluetoothGatt?>(display, "gatt")) + display.retryPairing() + assertEquals(TalerBleStatus.Pairing, display.state.value!!.status) + } + + @Test + fun `disabling during pairing can be enabled again without stuck pairing state`() + { + val device = beginModernPairing() + display.setEnabled(false) + bondChanged(device, BluetoothDevice.BOND_BONDED) + display.setEnabled(true) + assertEquals(TalerBleStatus.Connecting, display.state.value!!.status) + } + + @Test + fun `authenticated status gates payment and explicit clear replaces empty write`() + { + connect(urlUuid = TalerBleProtocol.STANDARD_URI, modern = true) + display.present(this, "taler://pay/order") + idle() + assertNull(shadowOf(gatt).latestWrittenBytes) + statusResult() + assertEquals("taler://pay/order", shadowOf(gatt).latestWrittenBytes.decodeToString()) + assertFalse(display.state.value!!.hasResetLimitation) + display.dismiss(this) + idle() + assertArrayEquals(byteArrayOf(1), shadowOf(gatt).latestWrittenBytes) + display.setForeground(false) + idle() + assertTrue(shadowOf(gatt).isClosed) + } + + @Test + fun `lost authentication never sends pending payment or silently retries`() + { + connect(modern = true) + display.present(this, "taler://pay/order") + statusResult(BluetoothGatt.GATT_INSUFFICIENT_AUTHENTICATION) + assertEquals(TalerBleStatus.PairingRequired, display.state.value!!.status) + assertNull(shadowOf(gatt).latestWrittenBytes) + assertTrue(shadowOf(gatt).isClosed) + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofSeconds(30)) + assertNull(ReflectionHelpers.getField<BluetoothGatt?>(display, "gatt")) + } + + @Test + fun `unknown status protocol cannot report ready`() + { + connect(modern = true) + statusResult(bytes = byteArrayOf(2, 0, 0, 0, 0, 0)) + assertEquals(TalerBleStatus.Failed, display.state.value!!.status) + assertNull(shadowOf(gatt).latestWrittenBytes) + } +} + +/** Robolectric has no readCharacteristic implementation; allow explicit asynchronous responses. */ +@Implements(BluetoothGatt::class) +class PendingReadGatt : ShadowBluetoothGatt() +{ + @Implementation + protected fun readCharacteristic(characteristic: BluetoothGattCharacteristic): Boolean = + characteristic.properties and BluetoothGattCharacteristic.PROPERTY_READ != 0 +} diff --git a/merchant-terminal/src/test/java/net/taler/merchantpos/devices/TalerUsbDevicesTest.kt b/merchant-terminal/src/test/java/net/taler/merchantpos/devices/TalerUsbDevicesTest.kt @@ -0,0 +1,279 @@ +/* + * 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.devices + +import android.app.Application +import android.content.Context +import android.content.Intent +import android.hardware.usb.UsbManager +import android.os.Looper + +import androidx.test.core.app.ApplicationProvider + +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +import java.util.concurrent.AbstractExecutorService +import java.util.concurrent.TimeUnit + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], application = Application::class) +class TalerUsbDevicesTest +{ + private lateinit var app: Application + private lateinit var devices: TalerUsbDevices + private val worker = QueuedExecutor() + private val transport = FakeTransport() + + @Before + fun setup() + { + app = ApplicationProvider.getApplicationContext() + shadowOf(app).grantPermissions("${app.packageName}.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION") + app + .getSharedPreferences("taler-usb-devices", Context.MODE_PRIVATE) + .edit() + .clear() + .commit() + devices = TalerUsbDevices(app, worker, transport) + } + + @After + fun cleanup() + { + devices.close() + drain() + } + + @Test + fun `USB is available by default without an extra enable step`() + { + assertTrue(devices.state.value!!.enabled) + devices.setUri("taler://pay/order") + drain() + assertEquals(listOf("taler://pay/order"), transport.writes) + } + + @Test + fun `old USB opt out does not block automatic connections`() + { + devices.close() + drain() + app + .getSharedPreferences("taler-usb-devices", Context.MODE_PRIVATE) + .edit() + .putBoolean("enabled", false) + .commit() + devices = TalerUsbDevices(app, QueuedExecutor(), transport) + assertTrue(devices.state.value!!.enabled) + } + + @Test + fun `explicit connection check retries the transfer rather than using cached success`() + { + devices.refresh() + drain() + devices.refresh() + drain() + assertEquals(listOf(""), transport.writes) + devices.refresh(forceTransfer = true) + drain() + assertEquals(listOf("", ""), transport.writes) + assertEquals(1L, devices.state.value!!.checkId) + } + + @Test + fun `queued writes read the latest link rather than an abandoned order`() + { + devices.setEnabled(true) + devices.setUri("taler://pay/old") + devices.setUri("taler://pay/new") + drain() + assertEquals(listOf("taler://pay/new"), transport.writes) + } + + @Test + fun `leaving payment before a queued transfer prevents sending that payment`() + { + devices.setEnabled(true) + devices.setUri("taler://pay/old") + devices.setUri(null) + drain() + assertEquals(listOf(""), transport.writes) + } + + @Test + fun `shutdown drains cleanup and does not send a queued payment`() + { + devices.setEnabled(true) + devices.setUri("taler://pay/old") + devices.close() + drain() + assertFalse(transport.writes.contains("taler://pay/old")) + devices.refresh() // No work is accepted after shutdown. + devices.close() + } + + @Test + fun `turning off Taler clears the accessory even when USB setting remains on`() + { + devices.setEnabled(true) + devices.setUri("taler://pay/order") + drain() + devices.setTalerEnabled(false) + drain() + assertEquals(listOf("taler://pay/order", ""), transport.writes) + } + + @Test + fun `logout clears link and saved USB preference`() + { + devices.setEnabled(true) + devices.setUri("taler://pay/order") + drain() + devices.logout() + drain() + assertEquals(listOf("taler://pay/order", ""), transport.writes) + assertTrue(devices.state.value!!.enabled) + assertTrue( + app + .getSharedPreferences("taler-usb-devices", Context.MODE_PRIVATE) + .getBoolean("enabled", true), + ) + } + + @Test + fun `a permission result rechecks permission instead of trusting granted extra`() + { + transport.permitted = false + devices.setEnabled(true) + devices.setUri("taler://pay/order") + app.sendBroadcast( + Intent("${app.packageName}.TALER_USB_PERMISSION") + .setPackage(app.packageName) + .putExtra(UsbManager.EXTRA_PERMISSION_GRANTED, true), + ) + drain() + assertTrue(transport.writes.isEmpty()) + assertEquals( + TalerUsbStatus.PermissionRequired, + devices.state.value!! + .devices + .single() + .status, + ) + } + + @Test + fun `a departing screen cannot clear its successor link`() + { + val old = Any() + val next = Any() + devices.setEnabled(true) + devices.present(old, "taler://pay/old") + devices.present(next, "taler://pay/new") + devices.dismiss(old) + drain() + assertEquals(listOf("taler://pay/new"), transport.writes) + devices.dismiss(next) + drain() + assertEquals(listOf("taler://pay/new", ""), transport.writes) + } + + @Test + fun `late permission grant after cancellation only sends idle`() + { + transport.permitted = false + devices.setEnabled(true) + devices.setUri("taler://pay/order") + drain() + devices.setUri(null) + transport.permitted = true + app.sendBroadcast(Intent("${app.packageName}.TALER_USB_PERMISSION").setPackage(app.packageName)) + drain() + assertEquals(listOf(""), transport.writes) + } + + private fun drain() + { + shadowOf(Looper.getMainLooper()).idle() + worker.runAll() + shadowOf(Looper.getMainLooper()).idle() + } + + private class FakeTransport : TalerUsbTransport + { + var permitted = true + val writes = mutableListOf<String>() + + override fun devices() = listOf(TalerUsbDevice("device", permitted)) + + override fun write( + device: TalerUsbDevice, + payload: ByteArray, + ): Boolean + { + writes += payload.toString(Charsets.UTF_8) + return true + } + } + + private class QueuedExecutor : AbstractExecutorService() + { + private val queue = ArrayDeque<Runnable>() + private var stopped = false + + fun runAll() + { + while (queue.isNotEmpty()) queue.removeFirst().run() + } + + override fun execute(command: Runnable) + { + check(!stopped) + queue.addLast(command) + } + + override fun shutdown() + { + stopped = true + } + + override fun shutdownNow(): MutableList<Runnable> = + queue.toMutableList().also { + stopped = true + queue.clear() + } + + override fun isShutdown() = stopped + + override fun isTerminated() = stopped && queue.isEmpty() + + override fun awaitTermination( + timeout: Long, + unit: TimeUnit, + ) = isTerminated + } +} diff --git a/merchant-terminal/src/test/java/net/taler/merchantpos/devices/TalerUsbSessionTest.kt b/merchant-terminal/src/test/java/net/taler/merchantpos/devices/TalerUsbSessionTest.kt @@ -0,0 +1,174 @@ +/* + * 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.devices + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class TalerUsbSessionTest +{ + private val device = TalerUsbDevice("usb-1", true) + private val transport = FakeTransport() + private val session = TalerUsbSession(transport) + private val uri = "taler://pay/merchant.example/order/session" + + @Test + fun `matches only the vendor and product in the supplied example`() + { + assertTrue(TalerUsbSession.supports(0xd00d, 0x8f71)) + assertFalse(TalerUsbSession.supports(0xd00d, 0x8f70)) + assertFalse(TalerUsbSession.supports(0x1234, 0x8f71)) + } + + @Test + fun `off by default does not write even to an already permitted device`() + { + transport.attached = listOf(device) + session.synchronize(false, uri) + assertTrue(transport.writes.isEmpty()) + } + + @Test + fun `permission grant sends only the current order`() + { + transport.attached = listOf(device.copy(permitted = false)) + assertEquals(TalerUsbStatus.PermissionRequired, session.synchronize(true, uri).single().status) + assertTrue(transport.writes.isEmpty()) + transport.attached = listOf(device) + session.synchronize(true, "$uri-new") + assertEquals(listOf(device.id to "$uri-new"), transport.writes) + } + + @Test + fun `a cancelled order is not sent after late permission grant`() + { + transport.attached = listOf(device.copy(permitted = false)) + session.synchronize(true, uri) + transport.attached = listOf(device) + session.synchronize(true, null) + assertEquals(listOf(device.id to ""), transport.writes) + } + + @Test + fun `polling and rotation do not repeatedly write an unchanged URL`() + { + transport.attached = listOf(device) + repeat(4) { session.synchronize(true, uri) } + assertEquals(1, transport.writes.size) + } + + @Test + fun `cancellation clears before the next order is advertised`() + { + transport.attached = listOf(device) + session.synchronize(true, uri) + session.synchronize(true, null) + session.synchronize(true, "$uri-next") + assertEquals(listOf(uri, "", "$uri-next"), transport.writes.map { it.second }) + } + + @Test + fun `disable and logout clear a device used by the session`() + { + transport.attached = listOf(device) + session.synchronize(true, uri) + session.synchronize(false, uri) + session.synchronize(false, null) + assertEquals(listOf(uri, ""), transport.writes.map { it.second }) + } + + @Test + fun `reconnect resends even if the order did not change`() + { + transport.attached = listOf(device) + session.synchronize(true, uri) + session.detached(device.id) + session.synchronize(true, uri) + assertEquals(listOf(uri, uri), transport.writes.map { it.second }) + transport.attached = emptyList() + assertTrue(session.synchronize(true, uri).isEmpty()) + transport.attached = listOf(device) + session.synchronize(true, uri) + assertEquals(3, transport.writes.size) + } + + @Test + fun `failed and short transfers are retried and never reported ready`() + { + transport.attached = listOf(device) + transport.success = false + assertEquals(TalerUsbStatus.Failed, session.synchronize(true, uri).single().status) + transport.success = true + assertEquals(TalerUsbStatus.Ready, session.synchronize(true, uri).single().status) + assertEquals(2, transport.writes.size) + } + + @Test + fun `failed partial transfer still needs clearing when disabled`() + { + transport.attached = listOf(device) + transport.success = false + session.synchronize(true, uri) + transport.success = true + session.synchronize(false, null) + assertEquals(listOf(uri, ""), transport.writes.map { it.second }) + } + + @Test + fun `one failed device does not block another`() + { + transport.attached = listOf(device, TalerUsbDevice("usb-2", true)) + transport.throwFor = device.id + assertEquals( + listOf(TalerUsbStatus.Failed, TalerUsbStatus.Ready), + session.synchronize(true, uri).map { it.status }, + ) + } + + @Test + fun `oversized UTF8 payload is refused without truncation`() + { + transport.attached = listOf(device) + assertEquals( + TalerUsbStatus.Failed, + session.synchronize(true, "é".repeat(8193)).single().status, + ) + assertTrue(transport.writes.isEmpty()) + } + + private class FakeTransport : TalerUsbTransport + { + var attached = emptyList<TalerUsbDevice>() + var success = true + var throwFor: String? = null + val writes = mutableListOf<Pair<String, String>>() + + override fun devices() = attached + + override fun write( + device: TalerUsbDevice, + payload: ByteArray, + ): Boolean + { + if (device.id == throwFor) throw SecurityException("permission revoked") + writes += device.id to payload.toString(Charsets.UTF_8) + return success + } + } +} diff --git a/merchant-terminal/src/test/java/net/taler/merchantpos/history/HistoryEntriesTest.kt b/merchant-terminal/src/test/java/net/taler/merchantpos/history/HistoryEntriesTest.kt @@ -0,0 +1,127 @@ +/* + * 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.history + +import net.taler.common.Amount +import net.taler.common.Timestamp +import net.taler.merchantlib.OrderHistoryEntry +import net.taler.merchantpos.checkout.Checkout +import net.taler.merchantpos.checkout.CheckoutStage +import net.taler.merchantpos.checkout.RecordedPayment +import net.taler.merchantpos.config.ConfigProduct +import net.taler.merchantpos.order.Order +import net.taler.merchantpos.payment.Payment + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class HistoryEntriesTest +{ + private val order = + Order( + id = 1, + currency = "CHF", + currencySpec = null, + availableCategories = emptyMap(), + products = + listOf( + ConfigProduct( + description = "Lunch", + price = Amount("CHF", 2, 0), + categories = emptyList(), + quantity = 1, + ), + ), + ) + private val checkout = + Checkout( + id = "checkout", + createdAt = 1, + session = "session", + posOrderId = 1, + contractTerms = order.toContractTerms(), + stage = CheckoutStage.AwaitingTaler, + backendOrderId = "sale", + payments = listOf(RecordedPayment("cash-1", "cash", Amount("CHF", 1, 0), recordedAt = 1)), + ) + private val item = + OrderHistoryEntry( + orderId = "sale", + timestamp = Timestamp(0), + amount = Amount("CHF", 1, 0), + summary = "Lunch", + paid = false, + refundable = false, + claimed = true, + ) + + @Test + fun `local and backend copies produce one entry retaining cash and claim status`() + { + val entries = mergeHistoryEntries(listOf(checkout), listOf(item), null) + assertEquals(listOf(checkout), entries.checkouts) + assertTrue(entries.orders.isEmpty()) + assertEquals(Amount("CHF", 1, 0), entries.checkouts.single().paid) + assertEquals(HistoryStatus.PaymentClaimed, checkoutHistoryStatus(checkout, item)) + } + + @Test + fun `matching uses order IDs rather than identical descriptions`() + { + val other = item.copy(orderId = "other-sale") + val entries = mergeHistoryEntries(listOf(checkout), listOf(item, other), null) + assertEquals(listOf(other), entries.orders) + assertEquals(listOf(checkout), entries.checkouts) + } + + @Test + fun `local entry remains available before history has loaded`() + { + val entries = mergeHistoryEntries(listOf(checkout), emptyList(), null) + assertEquals(listOf(checkout), entries.checkouts) + assertTrue(entries.orders.isEmpty()) + } + + @Test + fun `paid backend order replaces stale local checkout and retains refund actions`() + { + val paid = item.copy(paid = true, refundable = true, claimed = false) + val entries = mergeHistoryEntries(listOf(checkout), listOf(paid), null) + assertTrue(entries.checkouts.isEmpty()) + assertEquals(listOf(paid), entries.orders) + assertEquals(HistoryStatus.Paid, historyStatus(paid)) + } + + @Test + fun `draft without a backend order does not hide other orders`() + { + val draft = checkout.copy(backendOrderId = null, stage = CheckoutStage.Collecting) + val entries = mergeHistoryEntries(listOf(draft), listOf(item), null) + assertEquals(listOf(draft), entries.checkouts) + assertEquals(listOf(item), entries.orders) + } + + @Test + fun `claim status survives switching the active payment to another order`() + { + val active = Payment(order, "Lunch", "CHF", orderId = "other-sale", claimed = true) + assertEquals(HistoryStatus.PaymentClaimed, checkoutHistoryStatus(checkout, item, active)) + assertEquals(HistoryStatus.PaymentClaimed, historyStatus(item, activePayment = active)) + assertEquals(HistoryStatus.PaymentPending, checkoutHistoryStatus(checkout, item.copy(claimed = false), active)) + } +} diff --git a/merchant-terminal/src/test/java/net/taler/merchantpos/history/HistoryPaginationTest.kt b/merchant-terminal/src/test/java/net/taler/merchantpos/history/HistoryPaginationTest.kt @@ -19,15 +19,17 @@ package net.taler.merchantpos.history import net.taler.common.Amount import net.taler.common.Timestamp import net.taler.merchantlib.OrderHistoryEntry + import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test -class HistoryPaginationTest { - +class HistoryPaginationTest +{ @Test - fun `short intermediate page keeps pagination open`() { + fun `short intermediate page keeps pagination open`() + { val pagination = HistoryPagination() pagination.addPage((100L downTo 81L).map(::entry), reset = true) @@ -45,7 +47,8 @@ class HistoryPaginationTest { } @Test - fun `next page de-duplicates an inclusive cursor and advances`() { + fun `next page de-duplicates an inclusive cursor and advances`() + { val pagination = HistoryPagination() pagination.addPage((100L downTo 81L).map(::entry), reset = true) @@ -58,7 +61,8 @@ class HistoryPaginationTest { } @Test - fun `page containing only the cursor marks the end`() { + fun `page containing only the cursor marks the end`() + { val pagination = HistoryPagination() pagination.addPage(listOf(entry(2), entry(1)), reset = true) @@ -69,7 +73,8 @@ class HistoryPaginationTest { } @Test - fun `empty page marks the end`() { + fun `empty page marks the end`() + { val pagination = HistoryPagination() pagination.addPage(listOf(entry(1)), reset = true) @@ -79,7 +84,8 @@ class HistoryPaginationTest { } @Test - fun `newer order from a later page moves to the top`() { + fun `newer order from a later page moves to the top`() + { val pagination = HistoryPagination() pagination.addPage( listOf( @@ -102,7 +108,8 @@ class HistoryPaginationTest { } @Test - fun `equal timestamps use newest row first`() { + fun `equal timestamps use newest row first`() + { val pagination = HistoryPagination() pagination.addPage( diff --git a/merchant-terminal/src/test/java/net/taler/merchantpos/history/OrderDetailsFormattingTest.kt b/merchant-terminal/src/test/java/net/taler/merchantpos/history/OrderDetailsFormattingTest.kt @@ -0,0 +1,63 @@ +/* + * 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.history + +import net.taler.common.Location + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class OrderDetailsFormattingTest +{ + @Test + fun `partial addresses omit empty lines and retain supplied fields`() + { + assertEquals( + "Building A\nHöheweg 80\n2502 Biel\nSwitzerland", + Location( + addressLines = listOf("Building A", ""), + buildingName = "Building A", + street = "Höheweg", + buildingNumber = "80", + postCode = "2502", + town = "Biel", + country = "Switzerland", + ).orderAddress(), + ) + assertEquals("", Location().orderAddress()) + assertEquals("Biel", Location(town = "Biel").orderAddress()) + } + + @Test + fun `external order links require an absolute web URL`() + { + assertTrue(isOrderWebUrl("https://example.com/orders/123")) + assertTrue(isOrderWebUrl("http://example.com/content")) + listOf( + "javascript:alert(1)", + "file:///tmp/data", + "intent://example.com", + "/orders/123", + "https:///missing-host", + "not a URL", + ).forEach { + assertFalse(it, isOrderWebUrl(it)) + } + } +} diff --git a/merchant-terminal/src/test/java/net/taler/merchantpos/history/ProductImageTest.kt b/merchant-terminal/src/test/java/net/taler/merchantpos/history/ProductImageTest.kt @@ -0,0 +1,62 @@ +/* + * 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.history + +import android.app.Application +import android.graphics.Bitmap +import android.util.Base64 + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +import java.io.ByteArrayOutputStream + +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class, sdk = [28]) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class ProductImageTest +{ + @Test + fun `missing invalid and remote images leave no thumbnail`() + { + listOf( + null, + "", + "https://example.com/image.png", + "data:image/png;base64,???", + "data:image/png;base64,bm90YW5pbWFnZQ==", + ).forEach { + assertNull(decodeProductImage(it)) + } + } + + @Test + fun `large images are sampled and preserve their proportions`() + { + val bitmap = Bitmap.createBitmap(3200, 1600, Bitmap.Config.ARGB_8888) + val bytes = ByteArrayOutputStream().also { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) }.toByteArray() + bitmap.recycle() + val image = decodeProductImage("data:image/png;base64," + Base64.encodeToString(bytes, Base64.NO_WRAP))!! + assertEquals(1600, image.width) + assertEquals(800, image.height) + } +} diff --git a/merchant-terminal/src/test/java/net/taler/merchantpos/history/ProductPresentationTest.kt b/merchant-terminal/src/test/java/net/taler/merchantpos/history/ProductPresentationTest.kt @@ -0,0 +1,99 @@ +/* + * 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.history + +import net.taler.common.Amount +import net.taler.merchantlib.MerchantUnit +import net.taler.merchantlib.OrderDetailProduct + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +import java.util.Locale + +class ProductPresentationTest +{ + private val units = + listOf( + MerchantUnit("item", "pc", mapOf("de" to "Stk.", "de-CH" to "Stk")), + MerchantUnit("WeightUnitKg", "kg", precision = 3), + ).associateBy { it.unit } + + @Test + fun `quantity uses localized catalogue abbreviation and raw unknown unit fallback`() + { + val product = OrderDetailProduct("Juice", quantity = 2, unit = "item") + assertEquals("2\u202fpc", product.quantityLabel(units, Locale.ENGLISH)) + assertEquals("2\u202fStk", product.quantityLabel(units, Locale.forLanguageTag("de-CH"))) + assertEquals("2\u202fStk.", product.quantityLabel(units, Locale.GERMAN)) + assertEquals("2\u202fitem", product.quantityLabel(emptyMap(), Locale.ENGLISH)) + assertEquals("2", product.copy(unit = null).quantityLabel(units, Locale.ENGLISH)) + } + + @Test + fun `decimal quantity takes precedence trims zeros and honors unit precision`() + { + val product = OrderDetailProduct("Fruit", quantity = 10, unitQuantity = "0.200", unit = "WeightUnitKg") + assertEquals("0.2\u202fkg", product.quantityLabel(units, Locale.ENGLISH)) + assertEquals("0,2\u202fkg", product.quantityLabel(units, Locale.GERMAN)) + assertEquals("0.234\u202fkg", product.copy(unitQuantity = "0.2345").quantityLabel(units, Locale.ENGLISH)) + assertEquals("0.200", product.unitQuantity) + } + + @Test + fun `unit price divides recorded line total using integer and fractional quantities`() + { + val juice = OrderDetailProduct("Juice", quantity = 2, price = Amount.fromJSONString("CHF:0.02")) + assertEquals(ProductUnitPrice(Amount.fromJSONString("CHF:0.01"), false), juice.unitPrice("CHF")) + val grapes = juice.copy(unitQuantity = "0.200", price = Amount.fromJSONString("CHF:2")) + assertEquals(ProductUnitPrice(Amount.fromJSONString("CHF:10"), false), grapes.unitPrice("CHF")) + assertEquals(Amount.fromJSONString("CHF:2"), grapes.priceForCurrency("CHF")) + } + + @Test + fun `unit price selects matching currency and marks repeating division approximate`() + { + val product = + OrderDetailProduct( + "Fruit", + quantity = 3, + price = Amount.fromJSONString("CHF:9"), + prices = listOf(Amount.fromJSONString("EUR:6"), Amount.fromJSONString("CHF:1")), + ) + assertEquals(ProductUnitPrice(Amount.fromJSONString("CHF:0.33333333"), true), product.unitPrice("CHF")) + assertEquals(ProductUnitPrice(Amount.fromJSONString("EUR:2"), false), product.unitPrice("EUR")) + assertNull(product.unitPrice("USD")) + } + + @Test + fun `invalid zero and excessive unit prices are omitted without losing line totals`() + { + val product = OrderDetailProduct("Fruit", price = Amount.fromJSONString("CHF:10")) + for (quantity in listOf("0", "-1", "1e2", "NaN", "", "0.000000001")) + { + assertNull(quantity, product.copy(unitQuantity = quantity).unitPrice("CHF")) + } + assertNull(product.copy(quantity = 0).unitPrice("CHF")) + assertNull(product.copy(price = null).unitPrice("CHF")) + assertNull(product.copy(unitQuantity = "0.00000001", price = Amount.max("CHF")).unitPrice("CHF")) + assertEquals( + ProductUnitPrice(Amount.zero("CHF"), false), + product.copy(price = Amount.zero("CHF")).unitPrice("CHF"), + ) + } +} 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 @@ -17,17 +17,19 @@ package net.taler.merchantpos.order import android.app.Application + import androidx.lifecycle.LiveData import androidx.lifecycle.Observer import androidx.test.core.app.ApplicationProvider.getApplicationContext import androidx.test.ext.junit.runners.AndroidJUnit4 -import kotlinx.coroutines.runBlocking + import net.taler.common.Amount import net.taler.merchantlib.MerchantConfig import net.taler.merchantpos.R import net.taler.merchantpos.config.Category import net.taler.merchantpos.config.ConfigProduct import net.taler.merchantpos.config.PosConfig + import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull @@ -37,58 +39,97 @@ import org.junit.Test import org.junit.runner.RunWith import org.robolectric.annotation.Config import org.robolectric.shadows.ShadowLooper.shadowMainLooper + import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit +import kotlinx.coroutines.runBlocking + @Config(sdk = [28]) // API 29 needs at least Java 9 @RunWith(AndroidJUnit4::class) -class OrderManagerTest { - +class OrderManagerTest +{ private val app: Application = getApplicationContext() private val orderManager = OrderManager(app) - private val posConfig = PosConfig( - merchantConfig = MerchantConfig( - baseUrl = "http://example.org", - apiKey = "sandbox" - ), - categories = listOf( - Category(1, "one"), - Category(2, "two") - ), - products = listOf( - ConfigProduct( - description = "foo", - price = Amount("KUDOS", 1, 0), - categories = listOf(1) - ), - ConfigProduct( - description = "bar", - price = Amount("KUDOS", 1, 50000), - categories = listOf(2) - ) + private val posConfig = + PosConfig( + merchantConfig = + MerchantConfig( + baseUrl = "http://example.org", + apiKey = "sandbox", + ), + categories = + listOf( + Category(1, "one"), + Category(2, "two"), + ), + products = + listOf( + ConfigProduct( + description = "foo", + price = Amount("KUDOS", 1, 0), + categories = listOf(1), + ), + ConfigProduct( + description = "bar", + price = Amount("KUDOS", 1, 50000), + categories = listOf(2), + ), + ), ) - ) @Test - fun `config test missing categories`() = runBlocking { - val config = posConfig.copy(categories = emptyList()) - val result = orderManager.onConfigurationReceived(config, "KUDOS", null) - assertEquals(app.getString(R.string.config_error_category), result) - } + fun `products without categories remain available in uncategorized`() = + runBlocking { + val config = posConfig.copy(categories = emptyList()) + val result = orderManager.onConfigurationReceived(config, "KUDOS", null) + assertNull(result) + shadowMainLooper().idle() + assertEquals(2, orderManager.products.awaitValue().size) + assertTrue( + orderManager.categories.awaitValue().any { + it.name == app.getString(R.string.product_category_uncategorized) + } + ) + } @Test - fun `currency mismatch product is accepted but unavailable`() = 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", null) - shadowMainLooper().idle() - - assertNull(result) - val product = orderManager.products.awaitValue().single() - assertEquals("WRONGCUR", product.price.currency) - assertTrue(product.currencyMismatch) - assertFalse(product.availableToSell) - } + fun `empty inventory accepts configuration and supports custom orders`() = + runBlocking { + val result = + orderManager.onConfigurationReceived( + posConfig.copy(categories = emptyList(), products = emptyList()), + "KUDOS", + null, + ) + shadowMainLooper().idle() + assertNull(result) + assertTrue(orderManager.products.awaitValue().isEmpty()) + assertEquals(1, orderManager.categories.awaitValue().size) + val orderId = orderManager.currentOrderId.awaitValue() + val price = Amount.fromString("KUDOS", "2.50") + orderManager.addProduct( + orderId, + ConfigProduct(description = "Custom item", price = price, categories = emptyList()) + ) + shadowMainLooper().idle() + assertEquals(price, orderManager.getOrder(orderId).orderTotal.awaitValue()) + } + + @Test + fun `currency mismatch product is accepted but unavailable`() = + 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", null) + shadowMainLooper().idle() + + assertNull(result) + val product = orderManager.products.awaitValue().single() + assertEquals("WRONGCUR", product.price.currency) + assertTrue(product.currencyMismatch) + assertFalse(product.availableToSell) + } // TODO: re-enable test based on orderManager.categories contents! // challenge: LiveData is not easily observable in unit tests @@ -104,90 +145,106 @@ class OrderManagerTest { // } @Test - fun `config test valid config gets accepted`() = runBlocking { - val result = orderManager.onConfigurationReceived(posConfig, "KUDOS", null) - assertNull(result) - } + fun `config test valid config gets accepted`() = + runBlocking { + 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", null) - shadowMainLooper().idle() - - val categories = orderManager.categories.awaitValue() - val products = orderManager.products.awaitValue() - - assertNotNull(categories) - assertNotNull(products) - assertEquals(app.getString(R.string.product_category_all_objects), categories[0].name) - assertTrue(categories[0].selected) - assertFalse(categories[1].selected) - assertEquals(posConfig.products, products) - } + fun `all objects is selected by default and shown first`() = + runBlocking { + orderManager.onConfigurationReceived(posConfig, "KUDOS", null) + shadowMainLooper().idle() + + val categories = orderManager.categories.awaitValue() + val products = orderManager.products.awaitValue() + + assertNotNull(categories) + assertNotNull(products) + assertEquals(app.getString(R.string.product_category_all_objects), categories[0].name) + assertTrue(categories[0].selected) + assertFalse(categories[1].selected) + assertEquals(posConfig.products, products) + } @Test - fun `uncategorized is appended at the end when needed`() = runBlocking { - val uncategorizedProduct = ConfigProduct( - description = "baz", - price = Amount("KUDOS", 2, 0), - categories = emptyList() - ) - val config = posConfig.copy(products = posConfig.products + uncategorizedProduct) - - orderManager.onConfigurationReceived(config, "KUDOS", null) - shadowMainLooper().idle() - - val categories = orderManager.categories.awaitValue() - val uncategorized = categories.last() - - assertEquals(app.getString(R.string.product_category_uncategorized), uncategorized.name) - orderManager.setCurrentCategory(uncategorized) - shadowMainLooper().idle() - assertEquals(listOf(uncategorizedProduct), orderManager.products.awaitValue()) - } + fun `uncategorized is appended at the end when needed`() = + runBlocking { + val uncategorizedProduct = + ConfigProduct( + description = "baz", + price = Amount("KUDOS", 2, 0), + categories = emptyList(), + ) + val config = posConfig.copy(products = posConfig.products + uncategorizedProduct) + + orderManager.onConfigurationReceived(config, "KUDOS", null) + shadowMainLooper().idle() + + val categories = orderManager.categories.awaitValue() + val uncategorized = categories.last() + + assertEquals(app.getString(R.string.product_category_uncategorized), uncategorized.name) + orderManager.setCurrentCategory(uncategorized) + shadowMainLooper().idle() + assertEquals(listOf(uncategorizedProduct), orderManager.products.awaitValue()) + } @Test - fun `legacy default category is hidden and mapped to uncategorized`() = runBlocking { - val defaultCategory = Category(3, "Default") - val defaultProduct = ConfigProduct( - description = "legacy", - price = Amount("KUDOS", 3, 0), - categories = listOf(3) - ) - val config = posConfig.copy( - categories = posConfig.categories + defaultCategory, - products = posConfig.products + defaultProduct - ) - - orderManager.onConfigurationReceived(config, "KUDOS", null) - shadowMainLooper().idle() - - val categories = orderManager.categories.awaitValue() - assertFalse(categories.any { it.name == "Default" }) - assertEquals(app.getString(R.string.product_category_uncategorized), categories.last().name) - - orderManager.setCurrentCategory(categories.last()) - shadowMainLooper().idle() - assertEquals(listOf(defaultProduct), orderManager.products.awaitValue()) - } - + fun `legacy default category is hidden and mapped to uncategorized`() = + runBlocking { + val defaultCategory = Category(3, "Default") + val defaultProduct = + ConfigProduct( + description = "legacy", + price = Amount("KUDOS", 3, 0), + categories = listOf(3), + ) + val config = + posConfig.copy( + categories = posConfig.categories + defaultCategory, + products = posConfig.products + defaultProduct, + ) + + orderManager.onConfigurationReceived(config, "KUDOS", null) + shadowMainLooper().idle() + + val categories = orderManager.categories.awaitValue() + assertFalse(categories.any { it.name == "Default" }) + assertEquals(app.getString(R.string.product_category_uncategorized), categories.last().name) + + orderManager.setCurrentCategory(categories.last()) + shadowMainLooper().idle() + assertEquals(listOf(defaultProduct), orderManager.products.awaitValue()) + } } -private fun <T> LiveData<T>.awaitValue(timeout: Long = 2, unit: TimeUnit = TimeUnit.SECONDS): T { +private fun <T> LiveData<T>.awaitValue( + timeout: Long = 2, + unit: TimeUnit = TimeUnit.SECONDS, +): T +{ val latch = CountDownLatch(1) var result: T? = null - val observer = object : Observer<T> { - override fun onChanged(value: T) { - result = value - latch.countDown() - removeObserver(this) + val observer = + object : Observer<T> + { + override fun onChanged(value: T) + { + result = value + latch.countDown() + removeObserver(this) + } } - } observeForever(observer) - if (this.value != null) { + if (this.value != null) + { result = this.value removeObserver(observer) - } else if (!latch.await(timeout, unit)) { + } + else if (!latch.await(timeout, unit)) + { removeObserver(observer) throw AssertionError("LiveData value was never set.") } diff --git a/merchant-terminal/src/test/java/net/taler/merchantpos/order/OrderSelectionTest.kt b/merchant-terminal/src/test/java/net/taler/merchantpos/order/OrderSelectionTest.kt @@ -0,0 +1,162 @@ +package net.taler.merchantpos.order + +import android.app.Application + +import androidx.lifecycle.Observer + +import net.taler.common.Amount +import net.taler.merchantpos.config.ConfigProduct + +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class, sdk = [28]) +class OrderSelectionTest +{ + private val twix = product("twix") + private val juice = product("juice") + private var allowAdding = true + private val liveOrder = + MutableLiveOrder( + id = 0, + currency = "CHF", + currencySpec = null, + productsByCategory = hashMapOf(), + canAddProduct = { allowAdding }, + onChanged = {}, + ) + private val selectionObserver = Observer<String?> {} + + @Before + fun observeSelection() + { + liveOrder.selectedProductKey.observeForever(selectionObserver) + } + + @After + fun removeObserver() + { + liveOrder.selectedProductKey.removeObserver(selectionObserver) + } + + @Test + fun `repeated minus keeps manually selected row and only reduces its quantity`() + { + repeat(3) { liveOrder.addProduct(twix) } + liveOrder.addProduct(juice) + liveOrder.selectOrderLine(twix) + + liveOrder.decreaseSelectedOrderLine() + assertEquals(twix.id, liveOrder.selectedProductKey.value) + assertEquals(2, quantity(twix)) + assertEquals(1, quantity(juice)) + + liveOrder.decreaseSelectedOrderLine() + assertEquals(twix.id, liveOrder.selectedProductKey.value) + assertEquals(1, quantity(twix)) + assertEquals(1, quantity(juice)) + } + + @Test + fun `selection moves only when selected row is removed and clears with empty order`() + { + liveOrder.addProduct(twix) + liveOrder.addProduct(juice) + liveOrder.selectOrderLine(twix) + + liveOrder.decreaseSelectedOrderLine() + assertEquals(juice.id, liveOrder.selectedProductKey.value) + assertEquals( + listOf(juice.id), + liveOrder.order.value!! + .products + .map { it.id } + ) + + liveOrder.decreaseSelectedOrderLine() + assertNull(liveOrder.selectedProductKey.value) + assertEquals(emptyList<ConfigProduct>(), liveOrder.order.value!!.products) + } + + @Test + fun `catalogue addition selects added row including an existing product`() + { + liveOrder.addProduct(twix) + liveOrder.addProduct(juice) + assertEquals(juice.id, liveOrder.selectedProductKey.value) + + liveOrder.addProduct(twix) + assertEquals(twix.id, liveOrder.selectedProductKey.value) + assertEquals(2, quantity(twix)) + } + + @Test + fun `plus and minus after manual selection affect the same row`() + { + liveOrder.addProduct(twix) + liveOrder.addProduct(juice) + liveOrder.selectOrderLine(twix) + + liveOrder.increaseSelectedOrderLine() + liveOrder.decreaseSelectedOrderLine() + + assertEquals(twix.id, liveOrder.selectedProductKey.value) + assertEquals(1, quantity(twix)) + assertEquals(1, quantity(juice)) + } + + @Test + fun `blocked addition leaves selected row and quantities unchanged`() + { + liveOrder.addProduct(twix) + allowAdding = false + + liveOrder.addProduct(juice) + + assertEquals(twix.id, liveOrder.selectedProductKey.value) + assertEquals( + listOf(twix.id), + liveOrder.order.value!! + .products + .map { it.id } + ) + assertEquals(1, quantity(twix)) + } + + @Test + fun `clear and undo restore manually selected row`() + { + liveOrder.addProduct(twix) + liveOrder.addProduct(juice) + liveOrder.selectOrderLine(twix) + + liveOrder.restartOrUndo() + assertNull(liveOrder.selectedProductKey.value) + liveOrder.restartOrUndo() + + assertEquals(twix.id, liveOrder.selectedProductKey.value) + assertEquals(1, quantity(twix)) + assertEquals(1, quantity(juice)) + } + + private fun quantity(product: ConfigProduct) = + liveOrder.order.value!! + .products + .single { it.id == product.id } + .quantity + + private fun product(id: String) = + ConfigProduct( + id = id, + description = id, + price = Amount("CHF", 1, 0), + categories = emptyList(), + ) +} diff --git a/merchant-terminal/src/test/java/net/taler/merchantpos/order/OrderSummaryTest.kt b/merchant-terminal/src/test/java/net/taler/merchantpos/order/OrderSummaryTest.kt @@ -0,0 +1,81 @@ +package net.taler.merchantpos.order + +import net.taler.common.Amount +import net.taler.merchantpos.config.Category +import net.taler.merchantpos.config.ConfigProduct + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class OrderSummaryTest +{ + private val snacks = Category(1, "Snacks", mapOf("de" to "Imbisse")) + private val twix = + ConfigProduct( + productName = "Twix", + description = "50g", + descriptionI18n = mapOf("de" to "50 Gramm"), + price = Amount("CHF", 0, 1_000_000), + categories = listOf(snacks.id), + quantity = 1, + ) + + private fun order(vararg products: ConfigProduct) = + Order( + id = 0, + currency = "CHF", + currencySpec = null, + availableCategories = mapOf(snacks.id to snacks), + products = products.toList(), + ) + + @Test + fun `single product summary uses name without replacing contract product description`() + { + val order = order(twix) + val terms = order.toContractTerms() + + assertEquals("Twix", order.summary) + assertEquals("Twix", terms.summary) + assertNull(terms.summaryI18n) + assertEquals("50g", terms.products!!.single().description) + assertEquals(twix.descriptionI18n, terms.products!!.single().descriptionI18n) + } + + @Test + fun `summary retains product name when product details are omitted`() + { + val terms = order(twix.copy(productName = " Twix\u200B ")).toContractTerms(includeProducts = false) + + assertEquals("Twix", terms.summary) + assertNull(terms.summaryI18n) + assertNull(terms.products) + } + + @Test + fun `unnamed legacy and custom products retain description and translations`() + { + for (name in listOf(null, "", " ", "\u200B")) + { + val product = twix.copy(productName = name, description = "Chocolate", categories = emptyList()) + val terms = order(product).toContractTerms() + + assertEquals("Chocolate", terms.summary) + assertEquals(product.descriptionI18n, terms.summaryI18n) + } + } + + @Test + fun `multiple product summaries keep category quantities and translations`() + { + val terms = + order( + twix.copy(quantity = 2), + twix.copy(id = "other", productName = "Other snack", quantity = 1), + ).toContractTerms() + + assertEquals("3 × Snacks", terms.summary) + assertEquals(mapOf("de" to "3 × Imbisse"), terms.summaryI18n) + } +} diff --git a/merchant-terminal/src/test/java/net/taler/merchantpos/payment/PaymentResumeTest.kt b/merchant-terminal/src/test/java/net/taler/merchantpos/payment/PaymentResumeTest.kt @@ -0,0 +1,196 @@ +/* + * 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.payment + +import android.app.Application + +import androidx.test.core.app.ApplicationProvider + +import net.taler.common.Amount +import net.taler.common.Timestamp +import net.taler.common.getDefaultHttpClient +import net.taler.merchantlib.MerchantApi +import net.taler.merchantlib.OrderHistoryEntry +import net.taler.merchantpos.checkout.Checkout +import net.taler.merchantpos.checkout.CheckoutStage +import net.taler.merchantpos.config.ConfigManager +import net.taler.merchantpos.config.ConfigProduct +import net.taler.merchantpos.order.Order + +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel + +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class, sdk = [28]) +class PaymentResumeTest +{ + private val pendingChecks = ArrayDeque<Runnable>() + private val scope = + CoroutineScope( + SupervisorJob() + + object : CoroutineDispatcher() + { + override fun dispatch( + context: CoroutineContext, + block: Runnable, + ) + { + pendingChecks.addLast(block) + } + }, + ) + private val client = getDefaultHttpClient() + private lateinit var manager: PaymentManager + private val order = + Order( + id = 1, + currency = "CHF", + currencySpec = null, + availableCategories = emptyMap(), + products = + listOf( + ConfigProduct( + description = "Lunch", + price = Amount("CHF", 5, 0), + categories = emptyList(), + quantity = 1, + ), + ), + ) + private val checkout = + Checkout( + id = "checkout-1", + createdAt = 1, + session = "session", + posOrderId = 1, + contractTerms = order.toContractTerms(), + stage = CheckoutStage.AwaitingTaler, + backendOrderId = "order-1", + ) + + @Before + fun setUp() + { + val context = ApplicationProvider.getApplicationContext<Application>() + manager = PaymentManager(context, ConfigManager(context, scope, client), scope, MerchantApi(client)) + } + + @After + fun tearDown() + { + manager.onOrderDeleted( + manager.payment.value + ?.orderId + .orEmpty(), + ) + scope.cancel() + client.close() + } + + @Test + fun `reopening a claimed checkout keeps its state before the backend responds`() + { + val claimed = + Payment( + order = order, + summary = "Lunch", + currency = "CHF", + orderId = "order-1", + checkoutId = checkout.id, + claimed = true, + talerPayUri = "taler://pay/example.invalid/order-1/session", + merchantName = "Merchant", + payDeadline = Timestamp.fromMillis(100_000), + ) + manager.debugSetPayment(claimed) + + manager.showCheckoutOrder(checkout) + + assertSame(claimed, manager.payment.value) + assertFalse(manager.payment.value!!.statusLoading) + assertTrue(pendingChecks.isNotEmpty()) + } + + @Test + fun `a checkout restored after restart checks status before showing payment instructions`() + { + manager.showCheckoutOrder(checkout) + + val payment = manager.payment.value!! + assertEquals(checkout.id, payment.checkoutId) + assertEquals(checkout.backendOrderId, payment.orderId) + assertTrue(payment.statusLoading) + assertNull(payment.talerPayUri) + } + + @Test + fun `another checkout does not inherit a claimed order or its QR code`() + { + manager.debugSetPayment( + Payment( + order = order, + summary = "Lunch", + currency = "CHF", + orderId = "old-order", + checkoutId = "old-checkout", + claimed = true, + talerPayUri = "taler://pay/example.invalid/old-order/session", + ), + ) + + manager.showCheckoutOrder(checkout) + + val payment = manager.payment.value!! + assertEquals("order-1", payment.orderId) + assertFalse(payment.claimed) + assertTrue(payment.statusLoading) + assertNull(payment.talerPayUri) + } + + @Test + fun `history entry without claim information loads status before showing a QR screen`() + { + manager.resumePayment( + OrderHistoryEntry( + orderId = "history-order", + timestamp = Timestamp.fromMillis(1_000), + amount = order.total, + summary = "Lunch", + paid = false, + refundable = false, + ), + ) + + assertTrue(manager.payment.value!!.statusLoading) + assertEquals("history-order", manager.payment.value!!.orderId) + } +} diff --git a/merchant-terminal/src/test/java/net/taler/merchantpos/payment/PaymentUriTest.kt b/merchant-terminal/src/test/java/net/taler/merchantpos/payment/PaymentUriTest.kt @@ -20,10 +20,11 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Test -class PaymentUriTest { - +class PaymentUriTest +{ @Test - fun upgradesInsecurePaymentUriWithoutChangingPayload() { + fun upgradesInsecurePaymentUriWithoutChangingPayload() + { val insecureUri = "taler+http://pay/merchant.example/instances/shop/order/session?c=token" assertEquals( @@ -33,7 +34,8 @@ class PaymentUriTest { } @Test - fun recognizesInsecureSchemeCaseInsensitively() { + fun recognizesInsecureSchemeCaseInsensitively() + { assertEquals( "taler://pay/merchant.example/order/session", upgradeTalerPayUriToHttps("TALER+HTTP://pay/merchant.example/order/session"), @@ -41,7 +43,8 @@ class PaymentUriTest { } @Test - fun leavesOtherUrisAlone() { + fun leavesOtherUrisAlone() + { assertNull(upgradeTalerPayUriToHttps("taler://pay/merchant.example/order/session")) assertNull(upgradeTalerPayUriToHttps("https://merchant.example/order/session")) } diff --git a/merchant-terminal/src/test/java/net/taler/merchantpos/paymentoptions/PaymentOptionsManagerTest.kt b/merchant-terminal/src/test/java/net/taler/merchantpos/paymentoptions/PaymentOptionsManagerTest.kt @@ -0,0 +1,325 @@ +/* + * 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.paymentoptions + +import android.app.Application +import android.content.Context +import android.net.Uri + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CreditCard +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.test.core.app.ApplicationProvider + +import net.taler.merchantpos.R + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.runBlocking + +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class, sdk = [28]) +class PaymentOptionsManagerTest +{ + private lateinit var context: Context + private val terminal = FakeOption("terminal", tracksConnection = true) + private val plain = FakeOption("plain") + + private fun manager(vararg options: PaymentOption) = PaymentOptionsManager(context, options.toList()) + + @Before + fun setUp() + { + context = ApplicationProvider.getApplicationContext() + context + .getSharedPreferences("payment-terminals", Context.MODE_PRIVATE) + .edit() + .clear() + .commit() + } + + @Test + fun `Taler ignores old disabled preference and cannot be switched off`() + { + context + .getSharedPreferences("payment-terminals", Context.MODE_PRIVATE) + .edit() + .putBoolean("option.taler.enabled", false) + .commit() + val taler = TalerOption(context) + val manager = manager(taler, plain) + assertTrue(taler.alwaysEnabled) + assertTrue(manager.state(taler.id).enabled) + manager.setEnabled(taler.id, false) + assertTrue(manager.state(taler.id).enabled) + assertEquals(PaymentOptionStatus.Ready, manager.state(taler.id).status) + assertTrue(manager(taler).state(taler.id).enabled) + manager.logout() + assertTrue(manager.state(taler.id).enabled) + assertFalse(manager.state(plain.id).enabled) + } + + @Test + fun `an option on by default is enabled without the merchant touching it`() + { + val default = FakeOption("default", enabledByDefault = true) + val manager = manager(default, plain) + + assertTrue(manager.state(default.id).enabled) + assertFalse(manager.state(plain.id).enabled) + } + + @Test + fun `turning a default on option off survives recreation`() + { + val default = FakeOption("default", enabledByDefault = true) + manager(default).setEnabled(default.id, false) + + assertFalse(manager(default).state(default.id).enabled) + } + + @Test + fun `a disabled option is never probed`() + { + val manager = manager(plain) + assertEquals(0, plain.probes) + assertEquals(PaymentOptionStatus.Disabled, manager.state(plain.id).status) + } + + @Test + fun `saving setup while disabled publishes availability without probing the SDK`() + { + plain.configured = false + val manager = manager(plain) + assertEquals(PaymentOptionStatus.NotConfigured, manager.state(plain.id).status) + plain.configured = true + manager.refresh() + assertEquals(PaymentOptionStatus.Disabled, manager.state(plain.id).status) + assertFalse(manager.state(plain.id).enabled) + assertEquals(0, plain.probes) + plain.configured = false + manager.refresh() + assertEquals(PaymentOptionStatus.NotConfigured, manager.state(plain.id).status) + } + + @Test + fun `an enabled option without hardware is simply ready`() + { + val manager = manager(plain) + manager.setEnabled(plain.id, true) + assertEquals(PaymentOptionStatus.Ready, manager.state(plain.id).status) + } + + @Test + fun `a failing option cannot keep a stale connected status`() + { + val manager = manager(terminal) + terminal.status = PaymentOptionStatus.Connected + manager.setEnabled(terminal.id, true) + terminal.fail = true + manager.refresh() + assertEquals(PaymentOptionStatus.Unavailable, manager.state(terminal.id).status) + } + + @Test + fun `options without hardware never drive the terminal indicator`() + { + val manager = manager(plain, terminal) + manager.setEnabled(plain.id, true) + + assertFalse(manager.hasEnabledTerminal(manager.summary.value!!)) + + terminal.status = PaymentOptionStatus.Disconnected + manager.setEnabled(terminal.id, true) + + assertTrue(manager.hasEnabledTerminal(manager.summary.value!!)) + assertEquals(PaymentOptionStatus.Disconnected, manager.terminalStatus(manager.summary.value!!)) + } + + @Test + fun `one connected reader is enough for the terminal indicator`() + { + val second = FakeOption("second", tracksConnection = true) + val manager = manager(terminal, second) + terminal.status = PaymentOptionStatus.NotPaired + second.status = PaymentOptionStatus.Connected + manager.setEnabled(terminal.id, true) + manager.setEnabled(second.id, true) + + assertEquals(PaymentOptionStatus.Connected, manager.terminalStatus(manager.summary.value!!)) + } + + @Test + fun `Taler display is listed without any card provider enabled`() + { + val taler = FakeOption(TALER_OPTION_ID) + taler.devices = listOf(PaymentDevice("ble:lcd", name = "Taler NFC Terminal", connected = true)) + val manager = manager(taler, terminal) + manager.setEnabled(taler.id, true) + + val summary = manager.summary.value!! + assertTrue(manager.hasEnabledTerminal(summary)) + assertEquals(PaymentOptionStatus.Connected, manager.terminalStatus(summary)) + assertEquals(listOf(taler.id), manager.connectionStates(summary).map { it.first.id }) + assertEquals( + taler.devices, + manager + .connectionStates(summary) + .single() + .second.devices, + ) + } + + @Test + fun `ready QR payments do not hide a disconnected Taler display`() + { + plain.devices = listOf(PaymentDevice("ble:lcd", connected = false)) + val manager = manager(plain) + manager.setEnabled(plain.id, true) + + assertEquals(PaymentOptionStatus.Ready, manager.state(plain.id).status) + assertEquals(PaymentOptionStatus.Disconnected, manager.terminalStatus(manager.summary.value!!)) + plain.devices = emptyList() + manager.refresh() + assertFalse(manager.hasEnabledTerminal(manager.summary.value!!)) + } + + @Test + fun `device popup includes card and Taler hardware but excludes disabled options`() + { + plain.devices = listOf(PaymentDevice("usb:taler", connected = true)) + val manager = manager(plain, terminal) + manager.setEnabled(plain.id, true) + manager.setEnabled(terminal.id, true) + assertEquals( + listOf(plain.id, terminal.id), + manager.connectionStates(manager.summary.value!!).map { it.first.id }, + ) + + manager.setEnabled(plain.id, false) + assertEquals(listOf(terminal.id), manager.connectionStates(manager.summary.value!!).map { it.first.id }) + } + + @Test + fun `merchant logout returns every switch to its default and drops sessions`() + { + val default = FakeOption("default", enabledByDefault = true) + val manager = manager(default, terminal) + manager.setEnabled(default.id, false) + manager.setEnabled(terminal.id, true) + + manager.logout() + + assertTrue(manager.state(default.id).enabled) + assertFalse(manager.state(terminal.id).enabled) + assertTrue(default.loggedOut) + assertTrue(terminal.loggedOut) + } + + @Test + fun `an option that cannot clear its session does not block merchant logout`() + { + terminal.failLogout = true + val manager = manager(terminal, plain) + manager.setEnabled(terminal.id, true) + + manager.logout() + + assertFalse(manager.state(terminal.id).enabled) + assertTrue(plain.loggedOut) + } + + @Test + fun `session maintenance propagates cancellation`() + { + val manager = manager(terminal) + manager.setEnabled(terminal.id, true) + terminal.cancelSession = true + + assertThrows(CancellationException::class.java) { + runBlocking { manager.maintainSessions() } + } + } + + @Test + fun `redirect handling propagates cancellation`() + { + val manager = manager(terminal) + terminal.cancelRedirect = true + + assertThrows(CancellationException::class.java) { + runBlocking { manager.handleRedirect(Uri.parse("taler-pos://sumup-oauth")) } + } + } + + private class FakeOption( + override val id: String, + override val enabledByDefault: Boolean = false, + override val tracksConnection: Boolean = false, + ) : PaymentOption + { + var configured = true + var status = PaymentOptionStatus.Ready + var fail = false + var failLogout = false + var cancelSession = false + var cancelRedirect = false + var probes = 0 + var loggedOut = false + var devices: List<PaymentDevice> = emptyList() + override val nameRes = R.string.payment_options_title + override val descriptionRes = R.string.payment_options_description + override val icon: ImageVector + @Composable get() = Icons.Default.CreditCard + + override fun isConfigured() = configured + + override fun probe(): PaymentOptionState + { + probes++ + if (fail) throw IllegalStateException("option unavailable") + return PaymentOptionState(id, status = status, devices = devices) + } + + override suspend fun maintainSession() + { + if (cancelSession) throw CancellationException("session cancelled") + } + + override suspend fun handleRedirect(uri: Uri): Boolean + { + if (cancelRedirect) throw CancellationException("redirect cancelled") + return false + } + + override fun onLogout() + { + if (failLogout) throw IllegalStateException("cannot clear session") + loggedOut = true + } + } +} diff --git a/merchant-terminal/src/test/java/net/taler/merchantpos/paymentoptions/sumup/SumUpAuthTest.kt b/merchant-terminal/src/test/java/net/taler/merchantpos/paymentoptions/sumup/SumUpAuthTest.kt @@ -0,0 +1,287 @@ +/* + * 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.paymentoptions.sumup + +import android.app.Application +import android.content.Context +import android.net.Uri + +import androidx.test.core.app.ApplicationProvider + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +import java.net.URLEncoder +import java.util.concurrent.TimeUnit + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking + +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class, sdk = [28]) +class SumUpAuthTest +{ + private lateinit var context: Context + private val endpoint = FakeEndpoint() + private var now = 1_000_000L + + private fun auth() = SumUpAuth(context, endpoint) { now } + + @Before + fun setUp() + { + context = ApplicationProvider.getApplicationContext() + context + .getSharedPreferences("payment-terminals", Context.MODE_PRIVATE) + .edit() + .clear() + .commit() + } + + private fun authorized(): SumUpAuth + { + val auth = auth() + auth.setClientCredentials("client", "secret") + runBlocking { auth.exchangeCode("code-1") } + return auth + } + + @Test + fun `without an OAuth client nothing is sent and no token is claimed`() = + runBlocking { + val auth = auth() + assertNull(auth.refreshIfNeeded(force = true)) + assertEquals(0, endpoint.calls) + assertFalse(auth.isAuthorized()) + assertNull(auth.validAccessToken()) + } + + @Test + fun `authorising once yields a usable token and survives recreation`() + { + val auth = authorized() + assertTrue(auth.isAuthorized()) + assertEquals("access-1", auth.validAccessToken()) + assertEquals("access-1", auth().validAccessToken()) + assertTrue(auth().isAuthorized()) + } + + @Test + fun `a fresh token is not renewed again`() = + runBlocking { + val auth = authorized() + val calls = endpoint.calls + assertNull(auth.refreshIfNeeded()) + assertEquals(calls, endpoint.calls) + } + + @Test + fun `a token close to expiry is renewed before it lapses`() = + runBlocking { + val auth = authorized() + // Ten minutes before expiry the app is inside the renewal margin. + now += TimeUnit.MINUTES.toMillis(50) + val result = auth.refreshIfNeeded() + assertTrue(result is TokenResult.Success) + assertEquals("access-2", auth.validAccessToken()) + assertTrue(auth.validAccessToken() != null) + } + + @Test + fun `the rotated refresh token replaces the old one`() = + runBlocking { + val auth = authorized() + assertEquals("refresh-1", auth.refreshToken) + auth.refreshIfNeeded(force = true) + assertEquals("refresh-2", auth.refreshToken) + // The next renewal must present the newest token, not the original one. + auth.refreshIfNeeded(force = true) + assertEquals("refresh-2", endpoint.lastRefreshTokenSent) + } + + @Test + fun `an expired token is not offered to the SDK`() = + runBlocking { + val auth = authorized() + now += TimeUnit.HOURS.toMillis(2) + assertNull(auth.validAccessToken()) + } + + @Test + fun `a network failure keeps the authorisation for the next attempt`() = + runBlocking { + val auth = authorized() + endpoint.fail = TokenResult.Failed("timeout") + assertTrue(auth.refreshIfNeeded(force = true) is TokenResult.Failed) + + assertTrue(auth.isAuthorized()) + assertEquals("timeout", auth.state.value?.lastError) + + endpoint.fail = null + assertTrue(auth.refreshIfNeeded(force = true) is TokenResult.Success) + assertNull(auth.state.value?.lastError) + } + + @Test + fun `a rejected grant drops the tokens so a person is asked once, not endlessly`() = + runBlocking { + val auth = authorized() + endpoint.fail = TokenResult.GrantInvalid + auth.refreshIfNeeded(force = true) + + assertFalse(auth.isAuthorized()) + assertNull(auth.validAccessToken()) + assertEquals("invalid_grant", auth.state.value?.lastError) + + // The client stays, so the merchant can authorise again without retyping it. + assertTrue(auth.hasClientCredentials()) + val calls = endpoint.calls + assertNull(auth.refreshIfNeeded(force = true)) + assertEquals(calls, endpoint.calls) + } + + @Test + fun `renewals are counted so the screen can show the session working`() = + runBlocking { + val auth = authorized() + assertEquals(1, auth.state.value?.refreshCount) + auth.refreshIfNeeded(force = true) + auth.refreshIfNeeded(force = true) + assertEquals(3, auth.state.value?.refreshCount) + assertEquals(now, auth.state.value?.lastRefresh) + } + + @Test + fun `forgetting the authorisation keeps the OAuth client`() + { + val auth = authorized() + auth.clearAuthorization() + assertFalse(auth.isAuthorized()) + assertTrue(auth.hasClientCredentials()) + } + + @Test + fun `the authorize url carries the redirect and scopes SumUp needs`() + { + val auth = authorized() + val url = auth.authorizeUrl() + assertTrue(url.startsWith("$SUMUP_AUTHORIZE_URL?")) + assertTrue(url.contains("response_type=code")) + assertTrue(url.contains("client_id=client")) + assertTrue(url.contains("readers.write")) + // payments is gated behind SumUp review, so it must not be requested up front. + assertFalse(url.contains("scope=payments")) + assertTrue(url.contains(URLEncoder.encode(SUMUP_REDIRECT_URI, "UTF-8"))) + } + + @Test + fun `authorization state survives recreation and can only be consumed once`() + { + val auth = authorized() + val state = Uri.parse(auth.authorizeUrl()).getQueryParameter("state")!! + assertFalse(auth().consumeAuthorizationState(null)) + assertFalse(auth().consumeAuthorizationState("unrelated")) + assertTrue(auth().consumeAuthorizationState(state)) + assertFalse(auth().consumeAuthorizationState(state)) + } + + @Test + fun `new authorization and logout invalidate previous state`() + { + val auth = authorized() + val oldState = Uri.parse(auth.authorizeUrl()).getQueryParameter("state")!! + val newState = Uri.parse(auth.authorizeUrl()).getQueryParameter("state")!! + assertFalse(auth.consumeAuthorizationState(oldState)) + auth.clearAll() + assertFalse(auth.consumeAuthorizationState(newState)) + } + + @Test + fun `a refresh completing after logout cannot restore authorization`() = + runBlocking { + val auth = authorized() + val release = CompletableDeferred<Unit>() + endpoint.beforeRefresh = { release.await() } + val refresh = async(start = CoroutineStart.UNDISPATCHED) { auth.refreshIfNeeded(force = true) } + auth.clearAll() + release.complete(Unit) + refresh.await() + + assertFalse(auth.isAuthorized()) + assertNull(auth.validAccessToken()) + assertFalse(auth().isAuthorized()) + } + + @Test + fun `concurrent refreshes use the latest rotated token`() = + runBlocking { + val auth = authorized() + val release = CompletableDeferred<Unit>() + endpoint.beforeRefresh = { release.await() } + val first = async(start = CoroutineStart.UNDISPATCHED) { auth.refreshIfNeeded(force = true) } + val second = async(start = CoroutineStart.UNDISPATCHED) { auth.refreshIfNeeded(force = true) } + release.complete(Unit) + first.await() + second.await() + + assertEquals("refresh-2", endpoint.lastRefreshTokenSent) + assertEquals("refresh-2", auth.refreshToken) + } + + private inner class FakeEndpoint : TokenEndpoint + { + var calls = 0 + var beforeRefresh: suspend () -> Unit = {} + var fail: TokenResult? = null + var lastRefreshTokenSent: String? = null + + override suspend fun exchangeCode( + clientId: String, + clientSecret: String, + code: String, + ): TokenResult + { + calls++ + return fail ?: TokenResult.Success( + TokenSet("access-1", "refresh-1", now + TimeUnit.HOURS.toMillis(1)), + ) + } + + override suspend fun refresh( + clientId: String, + clientSecret: String, + refreshToken: String, + ): TokenResult + { + beforeRefresh() + calls++ + lastRefreshTokenSent = refreshToken + return fail ?: TokenResult.Success( + TokenSet("access-2", "refresh-2", now + TimeUnit.HOURS.toMillis(1)), + ) + } + } +} diff --git a/merchant-terminal/src/test/java/net/taler/merchantpos/paymentoptions/sumup/SumUpHttpRestApiTest.kt b/merchant-terminal/src/test/java/net/taler/merchantpos/paymentoptions/sumup/SumUpHttpRestApiTest.kt @@ -0,0 +1,174 @@ +/* + * This file is part of GNU Taler + * (C) 2026 Taler Systems S.A. + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package net.taler.merchantpos.paymentoptions.sumup + +import android.app.Application + +import net.taler.common.Amount +import net.taler.common.createDefaultHttpClient + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +import java.io.IOException +import java.net.ServerSocket +import java.util.concurrent.CopyOnWriteArrayList + +import kotlin.concurrent.thread +import kotlinx.coroutines.runBlocking + +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class, sdk = [28]) +class SumUpHttpRestApiTest +{ + @Test + fun `transaction lookup authenticates and escapes the returned checkout code`() = + runBlocking { + Server(200, """{"id":"transaction-1","transaction_code":"A+B /?"}""").use { server -> + val client = createDefaultHttpClient(logging = false) + try + { + val result = SumUpHttpRestApi(client, server.url).transactionId("merchant", "A+B /?", "test-token") + assertEquals(RestResult.Success("transaction-1"), result) + val request = server.requests.single() + assertTrue( + request.startsWith("GET /v2.1/merchants/merchant/transactions?transaction_code=A%2BB+%2F%3F ") + ) + assertTrue(request.contains("Authorization: Bearer test-token", ignoreCase = true)) + } + finally + { + client.close() + } + } + } + + @Test + fun `partial refund sends the exact decimal while full refund omits the amount`() = + runBlocking { + Server(204, "").use { server -> + val client = createDefaultHttpClient(logging = false) + try + { + val api = SumUpHttpRestApi(client, server.url) + assertEquals( + RestResult.Success(Unit), + api.refund("merchant", "transaction", Amount.fromString("CHF", "0.23"), "test-token") + ) + assertEquals(RestResult.Success(Unit), api.refund("merchant", "transaction", null, "test-token")) + assertEquals("{\"amount\":0.23}", server.requests[0].substringAfter("\r\n\r\n")) + assertEquals("", server.requests[1].substringAfter("\r\n\r\n")) + assertTrue(server.requests[0].startsWith("POST /v1.0/merchants/merchant/payments/transaction/refunds ")) + } + finally + { + client.close() + } + } + } + + @Test + fun `HTTP rejection returns bounded diagnostic text rather than refund success`() = + runBlocking { + Server(409, "x".repeat(300)).use { server -> + val client = createDefaultHttpClient(logging = false) + try + { + val result = SumUpHttpRestApi(client, server.url).refund("merchant", "transaction", null, "test-token") + assertEquals(RestResult.Failure("409: " + "x".repeat(200)), result) + } + finally + { + client.close() + } + } + } + + @Test + fun `malformed successful lookup cannot return a transaction id`() = + runBlocking { + Server(200, "{}").use { server -> + val client = createDefaultHttpClient(logging = false) + try + { + assertTrue( + SumUpHttpRestApi(client, server.url).transactionId( + "merchant", + "code", + "test-token" + ) is RestResult.Failure + ) + } + finally + { + client.close() + } + } + } + + private class Server(private val status: Int, private val body: String) : AutoCloseable + { + private val server = ServerSocket(0) + val url = "http://127.0.0.1:${server.localPort}" + val requests = CopyOnWriteArrayList<String>() + private val worker = + thread(isDaemon = true) { + while (!server.isClosed) + { + val socket = + try + { + server.accept() + } + catch (_: IOException) + { + break + } + socket.use { + val reader = socket.getInputStream().bufferedReader() + val headers = mutableListOf<String>() + while (true) + { + val line = reader.readLine() ?: break + if (line.isEmpty()) break + headers += line + } + val length = + headers + .firstOrNull { it.startsWith("Content-Length:", true) } + ?.substringAfter(':') + ?.trim() + ?.toInt() ?: 0 + val chars = CharArray(length) + var count = 0 + while (count < length) + { + val read = reader.read(chars, count, length - count) + if (read < 0) break + count += read + } + requests += headers.joinToString("\r\n") + "\r\n\r\n" + String(chars) + val data = body.toByteArray() + socket.getOutputStream().write( + ("HTTP/1.1 $status Result\r\nContent-Type: application/json\r\n" + + "Content-Length: ${data.size}\r\nConnection: close\r\n\r\n").toByteArray(), + ) + socket.getOutputStream().write(data) + } + } + } + + override fun close() + { + server.close() + worker.join(2000) + } + } +} diff --git a/merchant-terminal/src/test/java/net/taler/merchantpos/paymentoptions/sumup/SumUpOptionTest.kt b/merchant-terminal/src/test/java/net/taler/merchantpos/paymentoptions/sumup/SumUpOptionTest.kt @@ -0,0 +1,310 @@ +/* + * 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.paymentoptions.sumup + +import android.app.Application +import android.content.Context + +import androidx.test.core.app.ApplicationProvider + +import net.taler.common.Amount +import net.taler.merchantpos.paymentoptions.PaymentDevice +import net.taler.merchantpos.paymentoptions.PaymentOptionStatus +import net.taler.merchantpos.paymentoptions.RefundOutcome + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +import kotlinx.coroutines.runBlocking + +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class, sdk = [28]) +class SumUpOptionTest +{ + private lateinit var context: Context + private val connection = FakeConnection() + private val rest = FakeRest() + + @Before + fun setUp() + { + context = ApplicationProvider.getApplicationContext() + context + .getSharedPreferences("payment-terminals", Context.MODE_PRIVATE) + .edit() + .clear() + .commit() + } + + private fun option( + affiliateKey: String = "test-key", + auth: SumUpAuth = SumUpAuth(context, NoTokenEndpoint), + ): SumUpOption = + SumUpOption( + context, + connection, + SumUpSettings(context, buildKey = affiliateKey), + auth, + rest, + ) + + /** An authorised device, which SumUp requires before it will refund anything. */ + private fun authorisedAuth(): SumUpAuth + { + val endpoint = + object : TokenEndpoint + { + override suspend fun exchangeCode( + clientId: String, + clientSecret: String, + code: String, + ) = TokenResult.Success(TokenSet("access", "refresh", System.currentTimeMillis() + 3_600_000)) + + override suspend fun refresh( + clientId: String, + clientSecret: String, + refreshToken: String, + ) = TokenResult.Success(TokenSet("access", "refresh", System.currentTimeMillis() + 3_600_000)) + } + return SumUpAuth(context, endpoint).apply { + setClientCredentials("client", "secret") + runBlocking { exchangeCode("code") } + } + } + + @Test + fun `missing affiliate key cannot appear connected and never reaches the SDK`() + { + connection.devices = listOf(connectedReader()) + assertEquals(PaymentOptionStatus.NotConfigured, option(affiliateKey = "").probe().status) + assertEquals(0, connection.reads) + } + + @Test + fun `missing login hides any saved reader`() + { + connection.loggedIn = false + connection.devices = listOf(connectedReader()) + val state = option().probe() + assertEquals(PaymentOptionStatus.SignInRequired, state.status) + assertEquals(emptyList<PaymentDevice>(), state.devices) + } + + @Test + @Config(sdk = [31]) + fun `revoked Bluetooth permission cannot leave a reader marked connected`() + { + connection.devices = listOf(connectedReader()) + assertEquals(PaymentOptionStatus.PermissionRequired, option().probe().status) + } + + @Test + fun `no saved reader reports not paired`() + { + assertEquals(PaymentOptionStatus.NotPaired, option().probe().status) + } + + @Test + fun `a saved but unreachable reader reports disconnected`() + { + connection.devices = listOf(connectedReader().copy(connected = false)) + val state = option().probe() + assertEquals(PaymentOptionStatus.Disconnected, state.status) + assertEquals("SOLO 1", state.devices.single().name) + } + + @Test + fun `a reachable reader reports connected with its serial number`() + { + connection.devices = listOf(connectedReader()) + val state = option().probe() + assertEquals(PaymentOptionStatus.Connected, state.status) + assertEquals("SN-1", state.devices.single().serialNumber) + } + + @Test + fun `logout drops the SDK session`() + { + option().onLogout() + assertFalse(connection.loggedIn) + } + + @Test + fun `a merchant supplied key makes a build that ships without one usable`() + { + val settings = SumUpSettings(context, buildKey = "") + assertFalse(SumUpOption(context, connection, settings, auth()).isConfigured()) + + settings.setAffiliateKey(" merchant-own-key ") + + assertTrue(SumUpOption(context, connection, settings, auth()).isConfigured()) + assertEquals("merchant-own-key", settings.affiliateKey()) + } + + @Test + fun `a merchant key overrides the build key and clearing falls back to it`() + { + SumUpSettings(context, buildKey = "built-in").setAffiliateKey("merchant-own-key") + assertEquals("merchant-own-key", SumUpSettings(context, buildKey = "built-in").affiliateKey()) + + SumUpSettings(context, buildKey = "built-in").setAffiliateKey("") + + assertFalse(SumUpSettings(context, buildKey = "built-in").hasCustomKey()) + assertEquals("built-in", SumUpSettings(context, buildKey = "built-in").affiliateKey()) + } + + @Test + fun `the signed in merchant is reported once logged in`() + { + connection.devices = listOf(connectedReader()) + assertEquals("MC-1", option().probe().account) + } + + @Test + fun `no merchant is reported before sign in`() + { + connection.loggedIn = false + assertEquals(null, option().probe().account) + } + + @Test + fun `a refund needs the device to be authorised, since the reader SDK cannot refund`() = + runBlocking { + val outcome = option().refund("TX1", Amount("CHF", 5, 0), "damaged") + + assertTrue(outcome is RefundOutcome.Failed) + assertNull(rest.refunded) + } + + @Test + fun `a refund looks the transaction up by its code and gives the money back`() = + runBlocking { + val outcome = option(auth = authorisedAuth()).refund("TX1", Amount("CHF", 5, 50_000_000), "damaged") + + assertEquals(RefundOutcome.Success, outcome) + assertEquals("MC-1" to "TX1", rest.lookedUp) + assertEquals("txn-1", rest.refunded?.first) + assertEquals(Amount("CHF", 5, 50_000_000), rest.refunded?.second) + } + + @Test + fun `a payment without a transaction code cannot be refunded`() = + runBlocking { + val outcome = option(auth = authorisedAuth()).refund(null, Amount("CHF", 5, 0), "damaged") + + assertTrue(outcome is RefundOutcome.Failed) + assertNull(rest.refunded) + } + + @Test + fun `a refund SumUp rejects is reported and not treated as done`() = + runBlocking { + rest.refundFailure = "409: not refundable" + + val outcome = option(auth = authorisedAuth()).refund("TX1", Amount("CHF", 5, 0), "damaged") + + assertEquals("409: not refundable", (outcome as RefundOutcome.Failed).message) + } + + private class FakeRest : SumUpRestApi + { + var lookedUp: Pair<String, String>? = null + var refunded: Pair<String, Amount?>? = null + var refundFailure: String? = null + + override suspend fun transactionId( + merchantCode: String, + transactionCode: String, + token: String, + ): RestResult<String> + { + lookedUp = merchantCode to transactionCode + return RestResult.Success("txn-1") + } + + override suspend fun refund( + merchantCode: String, + transactionId: String, + amount: Amount?, + token: String, + ): RestResult<Unit> + { + refundFailure?.let { return RestResult.Failure(it) } + refunded = transactionId to amount + return RestResult.Success(Unit) + } + } + + private fun connectedReader() = PaymentDevice(id = "SN-1", name = "SOLO 1", serialNumber = "SN-1", connected = true) + + private fun auth() = SumUpAuth(context, NoTokenEndpoint) + + private object NoTokenEndpoint : TokenEndpoint + { + override suspend fun exchangeCode( + clientId: String, + clientSecret: String, + code: String, + ) = TokenResult.Failed("not used") + + override suspend fun refresh( + clientId: String, + clientSecret: String, + refreshToken: String, + ) = TokenResult.Failed("not used") + } + + private class FakeConnection : ReaderConnection + { + var loggedIn = true + var merchant: String? = "MC-1" + var tokenUpdates = 0 + var devices = emptyList<PaymentDevice>() + var reads = 0 + + override fun isLoggedIn(): Boolean + { + reads++ + return loggedIn + } + + override fun merchant(): String? = merchant + + override fun updateAccessToken(token: String) + { + tokenUpdates++ + } + + override fun devices(): List<PaymentDevice> + { + reads++ + return devices + } + + override fun logout() + { + loggedIn = false + } + } +} diff --git a/merchant-terminal/src/test/java/net/taler/merchantpos/refund/RefundAvailabilityTest.kt b/merchant-terminal/src/test/java/net/taler/merchantpos/refund/RefundAvailabilityTest.kt @@ -0,0 +1,81 @@ +/* + * 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.refund + +import net.taler.common.Amount +import net.taler.common.ExternalPayment +import net.taler.common.Timestamp +import net.taler.merchantlib.ExternalRefundInfo +import net.taler.merchantlib.OrderHistoryEntry + +import org.junit.Assert.assertEquals +import org.junit.Test + +class RefundAvailabilityTest +{ + private val item = + OrderHistoryEntry( + "order", + timestamp = Timestamp(1), + amount = chf(5), + summary = "Lunch", + paid = true, + refundable = true, + ) + + private fun chf(n: Long) = Amount("CHF", n, 0) + + @Test + fun `Taler availability excludes unpaid expired pending and exhausted payments`() + { + assertEquals(chf(5), item.talerRefundRemaining()) + assertEquals(chf(3), item.copy(refundAmount = chf(2)).talerRefundRemaining()) + listOf( + item.copy(paid = false), + item.copy(refundable = false), + item.copy(refundPending = true), + item.copy(refundAmount = chf(5)), + item.copy(refunded = true), + ).forEach { + assertEquals(chf(0), it.talerRefundRemaining()) + } + } + + @Test + fun `external refund cap is per original payment and never negative`() + { + val cash = ExternalPayment("cash", "cash-1", chf(5)) + val refund = ExternalRefundInfo("refund-1", "cash", chf(2), "return", Timestamp(1), cash.id) + assertEquals(chf(3), item.copy(refundsExternal = listOf(refund)).externalRefundRemaining(cash, null)) + assertEquals( + chf(5), + item.copy(refundsExternal = listOf(refund.copy(paymentId = "cash-2"))).externalRefundRemaining(cash, null), + ) + assertEquals( + chf(0), + item.copy(refundsExternal = listOf(refund.copy(amount = chf(8)))).externalRefundRemaining(cash, null), + ) + } + + @Test + fun `legacy unassigned refunds reduce availability conservatively`() + { + val cash = ExternalPayment("cash", "cash-1", chf(5)) + val refund = ExternalRefundInfo("refund-1", "cash", chf(2), "return", Timestamp(1)) + assertEquals(chf(3), item.copy(refundsExternal = listOf(refund)).externalRefundRemaining(cash, null)) + } +} diff --git a/taler-kotlin-android/build.gradle b/taler-kotlin-android/build.gradle @@ -61,10 +61,10 @@ android { } dependencies { - implementation platform('androidx.compose:compose-bom:2026.02.01') - implementation 'androidx.compose.material3:material3' - implementation 'androidx.compose.ui:ui' - implementation 'androidx.compose.foundation:foundation' + api platform('androidx.compose:compose-bom:2026.02.01') + api 'androidx.compose.material3:material3' + api 'androidx.compose.ui:ui' + api 'androidx.compose.foundation:foundation' implementation 'androidx.appcompat:appcompat:1.7.1' implementation "com.google.android.material:material:$material_version" diff --git a/taler-kotlin-android/src/main/java/net/taler/common/Amount.kt b/taler-kotlin-android/src/main/java/net/taler/common/Amount.kt @@ -35,7 +35,6 @@ import java.text.NumberFormat import java.util.Locale import kotlin.math.floor import kotlin.math.pow -import kotlin.math.roundToInt class AmountParserException(msg: String? = null, cause: Throwable? = null) : Exception(msg, cause) @@ -79,6 +78,7 @@ data class Amount( private const val FRACTIONAL_BASE: Int = 100000000 // 1e8 private val REGEX_CURRENCY = Regex("""^[-_*A-Za-z0-9]{1,12}$""") + private val REGEX_AMOUNT = Regex("[0-9]+(\\.[0-9]{0,8})?") val MAX_VALUE: Long = 2.0.pow(52).toLong() private const val MAX_FRACTION_LENGTH = 8 const val MAX_FRACTION: Int = 99_999_999 @@ -94,8 +94,11 @@ data class Amount( } fun fromString(currency: String, str: String): Amount { + if (!REGEX_AMOUNT.matches(str)) + throw AmountParserException("Invalid Amount Format") // value val valueSplit = str.split(".") + if (valueSplit.size > 2) throw AmountParserException("Invalid Amount Format") val value = checkValue(valueSplit[0].toLongOrNull()) // fraction val fraction: Int = if (valueSplit.size > 1) { @@ -108,6 +111,7 @@ data class Amount( } fun isValidAmountStr(str: String): Boolean { + if (!REGEX_AMOUNT.matches(str)) return false if (str.count { it == '.' } > 1) return false val split = str.split(".") try { @@ -126,9 +130,7 @@ data class Amount( } private fun String.getFraction(): Int? { - return "0.$this".toDoubleOrNull() - ?.times(FRACTIONAL_BASE) - ?.roundToInt() + return padEnd(MAX_FRACTION_LENGTH, '0').toIntOrNull() } fun min(currency: String): Amount = Amount(currency, 0, 1) @@ -142,13 +144,13 @@ data class Amount( } internal fun checkValue(value: Long?): Long { - if (value == null || value > MAX_VALUE) + if (value == null || value < 0 || value > MAX_VALUE) throw AmountParserException("Value $value greater than $MAX_VALUE") return value } internal fun checkFraction(fraction: Int?): Int { - if (fraction == null || fraction > MAX_FRACTION) + if (fraction == null || fraction < 0 || fraction > MAX_FRACTION) throw AmountParserException("Fraction $fraction greater than $MAX_FRACTION") return fraction } diff --git a/taler-kotlin-android/src/main/java/net/taler/common/Order.kt b/taler-kotlin-android/src/main/java/net/taler/common/Order.kt @@ -40,7 +40,38 @@ data class Order( @SerialName("refund_deadline") val refundDeadline: Timestamp? = null, @SerialName("pay_deadline") - val payDeadline: Timestamp? = null + val payDeadline: Timestamp? = null, + /** + * Identifier chosen by the client. Creating an order twice with the same id cannot + * produce two orders, which makes creation safe to retry after a crash. + */ + @SerialName("order_id") + val orderId: String? = null, + /** + * Parts of the order already settled outside Taler, such as cash or card (DD 96). + * [amount] then covers only what the wallet still pays. Fixed at creation. + */ + @SerialName("amount_external") + val amountExternal: List<ExternalPayment>? = null, +) + +/** + * One payment taken outside Taler before the wallet pays the rest (DD 96). + * + * Entries are flat by design: [method] names the channel and is never "taler", [id] is + * unique within the order, and anything else is a method specific string. + */ +@Serializable +data class ExternalPayment( + /** External payment method, such as cash or card; never Taler. */ + val method: String, + /** Payment reference unique within the order. */ + val id: String, + /** Amount already settled through this method. */ + val amount: Amount, + /** Reference the external system assigned, such as a card transaction code. */ + @SerialName("transaction_code") + val transactionCode: String? = null, ) @Serializable diff --git a/taler-kotlin-android/src/main/java/net/taler/common/Time.kt b/taler-kotlin-android/src/main/java/net/taler/common/Time.kt @@ -41,7 +41,7 @@ data class Timestamp( fun fromMillis(ms: Long) = Timestamp(ms / 1000L) } - val ms: Long = s * 1000L + val ms: Long = if (s == NEVER) NEVER else s * 1000L operator fun minus(other: Timestamp): RelativeTime = when { ms == NEVER -> RelativeTime.fromMillis(RelativeTime.FOREVER) @@ -98,14 +98,18 @@ data class RelativeTime( @Serializable(ForeverSerializer::class) private val us: Long, ) { - val ms: Long = us / 1000L + val ms: Long = if (us == FOREVER) FOREVER else us / 1000L fun toHours(): Long = ms / (60 * 60 * 1000L) companion object { internal const val FOREVER: Long = -1 - fun forever(): RelativeTime = fromMillis(FOREVER) - fun fromMillis(ms: Long) = RelativeTime(ms / 100L) + fun forever(): RelativeTime = RelativeTime(FOREVER) + fun fromMillis(ms: Long): RelativeTime { + if (ms == FOREVER) return forever() + require(ms in 0..9_007_199_254_740L) { "Duration outside the supported range" } + return RelativeTime(ms * 1000L) + } } } diff --git a/taler-kotlin-android/src/main/java/net/taler/lib/android/AnimatedQrCodeComposable.kt b/taler-kotlin-android/src/main/java/net/taler/lib/android/AnimatedQrCodeComposable.kt @@ -30,6 +30,7 @@ import androidx.compose.foundation.Canvas import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size @@ -57,14 +58,14 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex -import androidx.core.graphics.toColorInt import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel import net.taler.lib.android.QrCodeManager.makeQrCode -const val QR_CORNER_RADIUS = 0.08f -const val QR_STRIPE_WIDTH = 0.025f -const val QR_DATA_SIZE = 0.88f -const val QR_LOGO_SIZE = 0.30f * QR_DATA_SIZE +// Proportions of the official 760 x 760 payment QR reference. +const val QR_CORNER_RADIUS = 60f / 760f +const val QR_STRIPE_WIDTH = 20f / 760f +const val QR_DATA_SIZE = 665f / 760f +const val QR_LOGO_SIZE = 200f / 760f @Composable fun AnimatedQrCodeComposable( @@ -73,6 +74,65 @@ fun AnimatedQrCodeComposable( logoPainter: Painter? = null, qrCornerRadiusFraction: Float = QR_CORNER_RADIUS, ) { + val density = LocalDensity.current + var drawSize by remember { mutableStateOf<Dp?>(379.dp) } + AnimatedQrCodeFrame( + modifier = modifier.onGloballyPositioned { + drawSize = with(density) { it.size.width.toDp() } + }, + qrCornerRadiusFraction = qrCornerRadiusFraction, + ) { + drawSize?.let { drawSize -> + val qrSize = drawSize * QR_DATA_SIZE + val qrSizePx = with(density) { + qrSize.roundToPx().coerceAtLeast(256) } + val logoSize = drawSize * QR_LOGO_SIZE + + val qrBitmap by produceState<ImageBitmap?>(null, link, qrSizePx) { + value = null + value = makeQrCode( + text = link, + size = qrSizePx, + margin = 0, + errorCorrection = ErrorCorrectionLevel.H, + centerLogo = null, + centerLogoSize = null, + drawBackground = true, + darkColor = android.graphics.Color.BLACK, + lightColor = android.graphics.Color.WHITE, + trimQuietZone = true, + ).asImageBitmap() + } + + qrBitmap?.let { qr -> + Image( + bitmap = qr, + contentDescription = null, + contentScale = ContentScale.FillBounds, + modifier = Modifier + .size(qrSize) + .background(Color.White), + ) + + if (logoPainter != null) { + Image( + painter = logoPainter, + contentDescription = null, + modifier = Modifier.width(logoSize), + ) + } + } + } + } +} + +/** The fixed Taler QR frame, also used while waiting for a claimed payment. */ +@Composable +fun AnimatedQrCodeFrame( + modifier: Modifier = Modifier, + qrCornerRadiusFraction: Float = QR_CORNER_RADIUS, + content: @Composable BoxScope.() -> Unit, +) { val infinite = rememberInfiniteTransition(label = "qrStripe") val angle by infinite.animateFloat( initialValue = 0f, @@ -83,8 +143,8 @@ fun AnimatedQrCodeComposable( ), label = "qrStripeAngle", ) - val stripeColor = Color("#3047a3".toColorInt()) // FIXME: load from light-mode primary - val stripeBase = Color.White //MaterialTheme.colorScheme.surfaceVariant + val stripeColor = Color(0xFF0042B3) + val stripeBase = Color(0xFFF1F1F4) val stripeSoft = remember(stripeBase, stripeColor) { lerp(stripeBase, stripeColor, 0.55f) } @@ -119,7 +179,6 @@ fun AnimatedQrCodeComposable( contentAlignment = Alignment.Center, ) { val density = LocalDensity.current - var drawSize by remember { mutableStateOf<Dp?>(379.dp) } Box( modifier = Modifier @@ -127,15 +186,11 @@ fun AnimatedQrCodeComposable( .clip(RoundedCornerShape( percent = (qrCornerRadiusFraction * 100).toInt())) .zIndex(-1f) - .background(Color.White), //TODO: VLADA design MaterialTheme.colorScheme.surfaceVariant), + .background(stripeBase), ) Canvas( - modifier = Modifier - .fillMaxSize() - .onGloballyPositioned { with(density) { - drawSize = it.size.width.toDp() - } }, + modifier = Modifier.fillMaxSize(), ) { val s = with(density) { size.width.toDp() } val cornerRadius = s * qrCornerRadiusFraction @@ -162,45 +217,6 @@ fun AnimatedQrCodeComposable( ) } - drawSize?.let { drawSize -> - val qrSize = drawSize * QR_DATA_SIZE - val qrSizePx = with(density) { - qrSize.roundToPx().coerceAtLeast(256) } - val logoSize = drawSize * QR_LOGO_SIZE - - val qrBitmap by produceState<ImageBitmap?>(null) { - value = makeQrCode( - text = link, - size = qrSizePx, - margin = 0, - errorCorrection = ErrorCorrectionLevel.H, - centerLogo = null, - centerLogoSize = null, - drawBackground = true, - darkColor = android.graphics.Color.BLACK, - lightColor = android.graphics.Color.WHITE, - trimQuietZone = true, - ).asImageBitmap() - } - - qrBitmap?.let { qr -> - Image( - bitmap = qr, - contentDescription = null, - contentScale = ContentScale.FillBounds, - modifier = Modifier - .size(qrSize) - .background(Color.White), - ) - - if (logoPainter != null) { - Image( - painter = logoPainter, - contentDescription = null, - modifier = Modifier.width(logoSize), - ) - } - } - } + content() } } 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 @@ -17,6 +17,8 @@ package net.taler.lib.android import android.app.Dialog +import android.os.Handler +import android.os.Looper import android.content.Context import android.content.res.Configuration.UI_MODE_NIGHT_MASK import android.content.res.Configuration.UI_MODE_NIGHT_YES @@ -70,15 +72,18 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.fragment.app.Fragment import com.google.android.material.color.MaterialColors import io.ktor.client.plugins.ClientRequestException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import net.taler.common.Challenge @@ -117,6 +122,8 @@ suspend fun Fragment.handleChallengeResponse( onConfirmChallenge(challenge.challengeId, tan) solvedIds.add(challenge.challengeId) break + } catch (e: CancellationException) { + throw e } catch (e: Exception) { when (handleChallengeConfirmError(e)) { ChallengeRetryDecision.Retry -> continue @@ -134,83 +141,92 @@ suspend fun Fragment.handleChallengeResponse( suspend fun Fragment.selectChallenge(challenges: List<Challenge>): Challenge? = showMfaDialog(cancelResult = null) { finish -> - MfaDialogSurface { - Text( - text = stringResource(R.string.mfa_choose_title), - style = MaterialTheme.typography.headlineSmall, - ) - Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - challenges.forEach { challenge -> - Button( - onClick = { finish(challenge) }, - modifier = Modifier.fillMaxWidth(), - ) { - Text("${challenge.tanChannel}: ${challenge.tanInfo}") - } - } - } - DialogActions( - onCancel = { finish(null) }, - ) - } + MfaMethodContent(challenges, { finish(it) }, { finish(null) }) } -suspend fun Fragment.promptForTan( - challenge: Challenge, -): String? = +suspend fun Fragment.promptForTan(challenge: Challenge): String? = showMfaDialog(cancelResult = null) { finish -> var code by remember { mutableStateOf("") } - var showIncompleteError by remember { mutableStateOf(false) } - val submit = { - val normalizedCode = normalizeMfaCode(code) - if (normalizedCode == null) { - showIncompleteError = true - } else { - finish(formatMfaCode(normalizedCode)) + var error by remember { mutableStateOf<Int?>(null) } + MfaCodeContent( + challenge = challenge, + code = code, + error = error, + onCodeChanged = { code = it; error = null }, + onConfirm = { + val normalized = normalizeMfaCode(code) + if (normalized == null) error = R.string.mfa_challenge_code_incomplete + else finish(formatMfaCode(normalized)) + }, + onCancel = { finish(null) }, + ) + } + +/** Dialog content whose state can be owned by a retained login ViewModel. */ +@Composable +fun MfaMethodContent( + challenges: List<Challenge>, + onSelected: (Challenge) -> Unit, + onCancel: () -> Unit, +) { + MfaDialogSurface { + Text( + text = stringResource(R.string.mfa_choose_title), + style = MaterialTheme.typography.headlineSmall, + ) + Column( + modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + challenges.forEach { challenge -> + Button(onClick = { onSelected(challenge) }, modifier = Modifier.fillMaxWidth()) { + Text("${challenge.tanChannel}: ${challenge.tanInfo}") + } } } + DialogActions(onCancel = onCancel) + } +} - MfaDialogSurface { - Text( - text = stringResource(R.string.mfa_challenge_title), - style = MaterialTheme.typography.headlineSmall, - ) +@Composable +fun MfaCodeContent( + challenge: Challenge, + code: String, + error: Int?, + onCodeChanged: (String) -> Unit, + onConfirm: () -> Unit, + onCancel: () -> Unit, +) { + MfaDialogSurface { + Text( + text = stringResource(R.string.mfa_challenge_title), + style = MaterialTheme.typography.headlineSmall, + ) + Text( + text = stringResource( + R.string.mfa_challenge_message, + challenge.tanChannel.name, + challenge.tanInfo, + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + MfaCodeInput( + code = code, + isError = error != null, + onCodeChanged = onCodeChanged, + onSubmit = onConfirm, + ) + if (error != null) { Text( - text = stringResource( - R.string.mfa_challenge_message, - challenge.tanChannel.name, - challenge.tanInfo, - ), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - MfaCodeInput( - code = code, - isError = showIncompleteError, - onCodeChanged = { - code = it - showIncompleteError = false - }, - onSubmit = submit, - ) - if (showIncompleteError) { - Text( - text = stringResource(R.string.mfa_challenge_code_incomplete), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - ) - } - DialogActions( - onCancel = { finish(null) }, - onConfirm = submit, + text = stringResource(error), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, ) } + DialogActions(onCancel = onCancel, onConfirm = onConfirm) } +} @Composable private fun MfaDialogSurface( @@ -249,9 +265,9 @@ private fun MfaCodeInput( } BasicTextField( - value = code, + value = TextFieldValue(code, selection = TextRange(code.length)), onValueChange = { value -> - onCodeChanged(value.filter(Char::isDigit).take(MFA_CODE_DIGITS)) + onCodeChanged(value.text.filter(Char::isDigit).take(MFA_CODE_DIGITS)) }, modifier = Modifier .fillMaxWidth() @@ -383,13 +399,18 @@ private suspend fun <T> Fragment.showMfaDialog( dialog.window?.setLayout(MATCH_PARENT, WRAP_CONTENT) continuation.invokeOnCancellation { - composeView.post { dialog.dismiss() } + if (Looper.myLooper() == Looper.getMainLooper()) { + dialog.dismiss() + } else { + Handler(Looper.getMainLooper()).post { dialog.dismiss() } + } } } } suspend fun Fragment.handleChallengeConfirmError(e: Exception): ChallengeRetryDecision = withContext(Dispatchers.Main) { + if (e is CancellationException) throw e if (e is ClientRequestException) { when (e.response.status.value) { 409 -> { @@ -418,12 +439,12 @@ suspend fun Fragment.handleChallengeConfirmError(e: Exception): ChallengeRetryDe ChallengeRetryDecision.Abort } -internal fun normalizeMfaCode(value: String): String? { +fun normalizeMfaCode(value: String): String? { val digits = value.filter(Char::isDigit) return digits.takeIf { it.length == MFA_CODE_DIGITS } } -internal fun formatMfaCode(value: String): String = +fun formatMfaCode(value: String): String = value.take(MFA_CODE_DIGITS / 2) + "-" + value.drop(MFA_CODE_DIGITS / 2) private fun mfaColorScheme(context: Context): ColorScheme { diff --git a/taler-kotlin-android/src/main/java/net/taler/lib/android/order/OrderDetailComponents.kt b/taler-kotlin-android/src/main/java/net/taler/lib/android/order/OrderDetailComponents.kt @@ -0,0 +1,332 @@ +/* + * This file is part of GNU Taler + * (C) 2026 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.lib.android.order + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.FlowRowScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp + +@Composable +public fun OrderDetailHeader( + merchantName: String, + total: String?, + leadingIcon: ImageVector, + wide: Boolean, + orderIdDisplay: @Composable (compact: Boolean) -> Unit, + modifier: Modifier = Modifier, + statusAndMetadata: @Composable FlowRowScope.() -> Unit = {}, + trailingActions: @Composable RowScope.() -> Unit = {}, + footer: @Composable ColumnScope.() -> Unit = {}, +) { + OutlinedCard( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.surface), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + ) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) { + OrderDetailIcon(leadingIcon) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(merchantName, style = MaterialTheme.typography.titleMedium) + total?.let { Text(it, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Medium) } + } + if (wide) { + androidx.compose.foundation.layout.Box(Modifier.weight(1.2f)) { orderIdDisplay(true) } + } + trailingActions() + } + FlowRow(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + statusAndMetadata() + } + if (!wide) orderIdDisplay(false) + footer() + } + } +} + +@Composable +public fun OrderDetailStatusBadge( + label: String, + containerColor: Color = MaterialTheme.colorScheme.secondaryContainer, + contentColor: Color = MaterialTheme.colorScheme.onSecondaryContainer, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier, + color = containerColor, + contentColor = contentColor, + shape = MaterialTheme.shapes.small, + ) { + Text(label, modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), + style = MaterialTheme.typography.labelLarge) + } +} + +@Composable +public fun OrderDetailBalanceStrip( + label: String, + amount: String, + modifier: Modifier = Modifier, + containerColor: Color = MaterialTheme.colorScheme.errorContainer, + contentColor: Color = MaterialTheme.colorScheme.onErrorContainer, +) { + Surface(modifier = modifier.fillMaxWidth(), shape = RoundedCornerShape(12.dp), + color = containerColor, contentColor = contentColor) { + FlowRow( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text(label, style = MaterialTheme.typography.titleMedium) + Text(amount, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold) + } + } +} + +@Composable +public fun OrderDetailSection( + icon: ImageVector, + title: String, + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + OutlinedCard( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.surface), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + ) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + OrderDetailIcon(icon) + Text(title, style = MaterialTheme.typography.titleMedium) + } + content() + } + } +} + +@Composable +public fun OrderDetailIcon(icon: ImageVector, modifier: Modifier = Modifier) { + Surface(modifier = modifier, shape = CircleShape, color = MaterialTheme.colorScheme.secondaryContainer) { + Icon(icon, contentDescription = null, modifier = Modifier.padding(10.dp).size(22.dp), + tint = MaterialTheme.colorScheme.secondary) + } +} + +@Composable +public fun OrderDetailValue( + label: String, + value: String, + icon: ImageVector? = null, + modifier: Modifier = Modifier, +) { + Row(modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) { + icon?.let { Icon(it, contentDescription = null, modifier = Modifier.size(24.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) } + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + SelectionContainer { Text(value, style = MaterialTheme.typography.bodyMedium) } + } + } +} + +/** The caller supplies localized action labels and open/copy callbacks. */ +@Composable +public fun OrderDetailCopyableValue( + label: String, + value: String, + modifier: Modifier = Modifier, + isLink: Boolean = false, + compactAction: Boolean = false, + openAction: @Composable RowScope.() -> Unit = {}, + copyAction: @Composable RowScope.() -> Unit, +) { + Column(modifier) { + Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + SelectionContainer(if (compactAction) Modifier.weight(1f, fill = false) else Modifier.weight(1f)) { + Text(value, style = MaterialTheme.typography.bodyMedium, + color = if (isLink) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface) + } + if (isLink) openAction() + copyAction() + } + } +} + +@Composable +public fun OrderDetailSummaryBlock( + icon: ImageVector, + title: String, + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) = OrderDetailSection(icon, title, modifier, content) + +@Composable +public fun OrderDetailProductsBlock( + icon: ImageVector, + title: String, + productCount: Int, + emptyMessage: String, + totalLabel: String?, + total: String?, + modifier: Modifier = Modifier, + product: @Composable ColumnScope.(index: Int) -> Unit, +) { + OrderDetailSection(icon, title, modifier) { + if (productCount == 0) Text(emptyMessage, style = MaterialTheme.typography.bodyMedium) + repeat(productCount) { index -> + if (index > 0) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + product(index) + } + if (total != null && totalLabel != null) { + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + FlowRow(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, + verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(totalLabel, fontWeight = FontWeight.Medium) + Text(total, fontWeight = FontWeight.Medium) + } + } + } +} + +@Composable +public fun OrderDetailPaymentsBlock( + icon: ImageVector, + title: String, + paymentCount: Int, + modifier: Modifier = Modifier, + payment: @Composable ColumnScope.(index: Int) -> Unit, +) { + OrderDetailSection(icon, title, modifier) { + repeat(paymentCount) { index -> + if (index > 0) HorizontalDivider(Modifier.padding(vertical = 4.dp), color = MaterialTheme.colorScheme.outlineVariant) + payment(index) + } + } +} + +@Composable +public fun OrderDetailProductRow( + name: String, + description: String?, + quantity: String, + price: String?, + modifier: Modifier = Modifier, + thumbnail: (@Composable () -> Unit)? = null, + unitPrice: String? = null, +) { + Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) { + thumbnail?.invoke() + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(name, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium) + description?.takeIf { it.isNotBlank() && it != name }?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.Bottom) { + Text(if (unitPrice == null) quantity else "$quantity × $unitPrice", + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + price?.let { Text(it, modifier = Modifier.widthIn(max = 180.dp), + style = MaterialTheme.typography.bodyMedium, textAlign = TextAlign.End) } + } + } + } +} + +@Composable +public fun OrderDetailPaymentRow( + amount: String, + method: String? = null, + reference: String? = null, + status: String? = null, + modifier: Modifier = Modifier, + methodContent: (@Composable ColumnScope.() -> Unit)? = null, +) { + Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.Top) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + if (methodContent != null) methodContent() else { + method?.let { Text(it, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium) } + } + reference?.let { SelectionContainer { Text(it, style = MaterialTheme.typography.bodySmall) } } + status?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } + } + Text(amount, modifier = Modifier.widthIn(max = 180.dp).align(Alignment.Bottom), + style = MaterialTheme.typography.bodyMedium, textAlign = TextAlign.End) + } +} + +@Composable +public fun OrderDetailDeliveryBlock( + icon: ImageVector, + title: String, + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) = OrderDetailSection(icon, title, modifier, content) + +@Composable +public fun OrderDetailReorderBlock( + icon: ImageVector, + title: String, + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) = OrderDetailSection(icon, title, modifier, content) + +@Composable +public fun OrderDetailAdditionalBlock( + icon: ImageVector, + title: String, + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) = OrderDetailSection(icon, title, modifier, content) + +@Composable +public fun OrderDetailMerchantBlock( + icon: ImageVector, + title: String, + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) = OrderDetailSection(icon, title, modifier, content) diff --git a/taler-kotlin-android/src/main/res/values-de/strings.xml b/taler-kotlin-android/src/main/res/values-de/strings.xml @@ -16,6 +16,9 @@ <resources> <string name="version_invalid">Invalid version. Please try again later!</string> + <string name="version_too_old">Die App ist veraltet. Bitte aktualisieren!</string> + <string name="version_too_new">Der Dienst ist veraltet. Bitte warten, bis er aktualisiert wurde.</string> + <string name="close">Schließen</string> <string name="share">Teilen</string> <string name="copy">Kopieren</string> diff --git a/taler-kotlin-android/src/main/res/values-es/strings.xml b/taler-kotlin-android/src/main/res/values-es/strings.xml @@ -1,5 +1,13 @@ <?xml version="1.0" encoding="utf-8"?> <resources> + <string name="version_invalid">Versión no válida. ¡Inténtalo de nuevo más tarde!</string> + <string name="version_too_old">La aplicación está desactualizada. ¡Actualízala!</string> + <string name="version_too_new">El servicio está desactualizado. Espera a que se actualice.</string> + + <string name="close">Cerrar</string> + <string name="share">Compartir</string> + <string name="copy">Copiar</string> + <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> diff --git a/taler-kotlin-android/src/main/res/values-fi/strings.xml b/taler-kotlin-android/src/main/res/values-fi/strings.xml @@ -1,5 +1,13 @@ <?xml version="1.0" encoding="utf-8"?> <resources> + <string name="version_invalid">Virheellinen versio. Yritä myöhemmin uudelleen!</string> + <string name="version_too_old">Sovellus on vanhentunut. Päivitä se!</string> + <string name="version_too_new">Palvelu on vanhentunut. Odota sen päivittämistä.</string> + + <string name="close">Sulje</string> + <string name="share">Jaa</string> + <string name="copy">Kopioi</string> + <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> diff --git a/taler-kotlin-android/src/main/res/values-fr/strings.xml b/taler-kotlin-android/src/main/res/values-fr/strings.xml @@ -16,9 +16,13 @@ <resources> <string name="version_invalid">Invalid version. Please try again later!</string> + <string name="version_too_old">L’application est trop ancienne. Veuillez la mettre à jour !</string> + <string name="version_too_new">Le service est obsolète. Veuillez attendre sa mise à jour.</string> + <string name="close">Fermer</string> <string name="share">Partager</string> <string name="copy">Copier</string> + <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> diff --git a/taler-kotlin-android/src/main/res/values-it/strings.xml b/taler-kotlin-android/src/main/res/values-it/strings.xml @@ -1,5 +1,13 @@ <?xml version="1.0" encoding="utf-8"?> <resources> + <string name="version_invalid">Versione non valida. Riprova più tardi!</string> + <string name="version_too_old">L’app è obsoleta. Aggiornala!</string> + <string name="version_too_new">Il servizio è obsoleto. Attendi che venga aggiornato.</string> + + <string name="close">Chiudi</string> + <string name="share">Condividi</string> + <string name="copy">Copia</string> + <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> diff --git a/taler-kotlin-android/src/main/res/values-iw/strings.xml b/taler-kotlin-android/src/main/res/values-iw/strings.xml @@ -1,5 +1,13 @@ <?xml version="1.0" encoding="utf-8"?> <resources> + <string name="version_invalid">גרסה לא תקינה. יש לנסות שוב מאוחר יותר!</string> + <string name="version_too_old">היישומון מיושן. יש לעדכן אותו!</string> + <string name="version_too_new">השירות מיושן. יש להמתין לעדכון שלו.</string> + + <string name="close">סגירה</string> + <string name="share">שיתוף</string> + <string name="copy">העתקה</string> + <string name="mfa_challenge_title">אימות דו-שלבי</string> <string name="mfa_challenge_message">קוד אישור נשלח דרך %1$s (%2$s). הזינו את הקוד כדי להמשיך.</string> <string name="mfa_challenge_code_hint">קוד אימות</string> diff --git a/taler-kotlin-android/src/main/res/values-ru/strings.xml b/taler-kotlin-android/src/main/res/values-ru/strings.xml @@ -1,5 +1,13 @@ <?xml version="1.0" encoding="utf-8"?> <resources> + <string name="version_invalid">Недопустимая версия. Повторите попытку позже!</string> + <string name="version_too_old">Приложение устарело. Обновите его!</string> + <string name="version_too_new">Сервис устарел. Дождитесь его обновления.</string> + + <string name="close">Закрыть</string> + <string name="share">Поделиться</string> + <string name="copy">Копировать</string> + <string name="mfa_challenge_title">Двухфакторная аутентификация</string> <string name="mfa_challenge_message">Код подтверждения отправлен через %1$s (%2$s). Введите код, чтобы продолжить.</string> <string name="mfa_challenge_code_hint">Код подтверждения</string> diff --git a/taler-kotlin-android/src/main/res/values-sv/strings.xml b/taler-kotlin-android/src/main/res/values-sv/strings.xml @@ -1,5 +1,13 @@ <?xml version="1.0" encoding="utf-8"?> <resources> + <string name="version_invalid">Ogiltig version. Försök igen senare!</string> + <string name="version_too_old">Appen är för gammal. Uppdatera den!</string> + <string name="version_too_new">Tjänsten är för gammal. Vänta tills den har uppdaterats.</string> + + <string name="close">Stäng</string> + <string name="share">Dela</string> + <string name="copy">Kopiera</string> + <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> diff --git a/taler-kotlin-android/src/main/res/values-tr/strings.xml b/taler-kotlin-android/src/main/res/values-tr/strings.xml @@ -1,5 +1,13 @@ <?xml version="1.0" encoding="utf-8"?> <resources> + <string name="version_invalid">Geçersiz sürüm. Lütfen daha sonra tekrar deneyin!</string> + <string name="version_too_old">Uygulama çok eski. Lütfen güncelleyin!</string> + <string name="version_too_new">Hizmet güncel değil. Lütfen güncellenmesini bekleyin.</string> + + <string name="close">Kapat</string> + <string name="share">Paylaş</string> + <string name="copy">Kopyala</string> + <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> diff --git a/taler-kotlin-android/src/main/res/values-uk/strings.xml b/taler-kotlin-android/src/main/res/values-uk/strings.xml @@ -1,5 +1,13 @@ <?xml version="1.0" encoding="utf-8"?> <resources> + <string name="version_invalid">Неприпустима версія. Спробуйте пізніше!</string> + <string name="version_too_old">Застосунок застарів. Оновіть його!</string> + <string name="version_too_new">Сервіс застарів. Зачекайте на його оновлення.</string> + + <string name="close">Закрити</string> + <string name="share">Поділитися</string> + <string name="copy">Копіювати</string> + <string name="mfa_challenge_title">Двофакторна автентифікація</string> <string name="mfa_challenge_message">Код підтвердження надіслано через %1$s (%2$s). Введіть код, щоб продовжити.</string> <string name="mfa_challenge_code_hint">Код підтвердження</string> diff --git a/taler-kotlin-android/src/test/java/net/taler/common/AmountTest.kt b/taler-kotlin-android/src/test/java/net/taler/common/AmountTest.kt @@ -27,6 +27,32 @@ import java.util.Locale class AmountTest { @Test + fun rejectsSignedAndMalformedAmounts() { + for (value in listOf("-1", "-0.5", "-0", "+1", "1.-5", "1.2e2", "1.NaN", "1.000000001")) { + org.junit.Assert.assertThrows(AmountParserException::class.java) { Amount.fromString("CHF", value) } + assertFalse(Amount.isValidAmountStr(value)) + } + for (value in listOf("0", "0.00000001", "1.", "4503599627370496.99999999")) { + assertTrue(Amount.isValidAmountStr(value)) + Amount.fromString("CHF", value) + } + } + + @Test + fun rejectsExtraDecimalSeparators() { + for (value in listOf("1.0.0", "1.000.50", "12.34.56", "10.5.0", "1..", "1.2.")) { + org.junit.Assert.assertThrows(AmountParserException::class.java) { + Amount.fromString("CHF", value) + } + org.junit.Assert.assertThrows(AmountParserException::class.java) { + Amount.fromJSONString("CHF:$value") + } + assertFalse(Amount.isValidAmountStr(value)) + } + } + + + @Test fun testFromJSONString() { var str = "TESTKUDOS:23.42" var amount = Amount.fromJSONString(str) diff --git a/taler-kotlin-android/src/test/java/net/taler/common/TimeTest.kt b/taler-kotlin-android/src/test/java/net/taler/common/TimeTest.kt @@ -26,6 +26,21 @@ import kotlin.random.Random class TimeTest { @Test + fun refundDurationUsesMicrosecondsAndPreservesInfinity() { + val hour = RelativeTime.fromMillis(3_600_000) + assertEquals("""{"d_us":3600000000}""", encodeToString(RelativeTime.serializer(), hour)) + assertEquals(3_600_000L, hour.ms) + assertEquals(1L, hour.toHours()) + assertEquals("""{"d_us":"forever"}""", encodeToString(RelativeTime.serializer(), RelativeTime.forever())) + assertEquals(-1L, decodeFromString(RelativeTime.serializer(), """{"d_us":"forever"}""").ms) + val start = Timestamp.fromMillis(10_000) + assertEquals(3_610_000L, (start + hour).ms) + assertEquals(Timestamp.never(), start + RelativeTime.forever()) + assertEquals(RelativeTime.forever(), Timestamp.never() - start) + org.junit.Assert.assertThrows(IllegalArgumentException::class.java) { RelativeTime.fromMillis(Long.MAX_VALUE) } + } + + @Test fun testSerialize() { for (i in 0 until 42) { val t = Random.nextLong() diff --git a/wallet/src/main/res/values-uk/strings.xml b/wallet/src/main/res/values-uk/strings.xml @@ -19,7 +19,7 @@ <string name="google_play_title" tools:keep="@string/google_play_title">Гаманець Taler(експериментальний)</string> <string name="google_play_short_desc" tools:keep="@string/google_play_short_desc">GNU Taler робить конфіденційні онлайн-транзакції швидкими та легкими.</string> - <string name="google_play_full_desc" tools:keep="@string/google_play_full_desc">Цей додаток є гаманцем для GNU Taler. Він є дуже експериментальним і ще не готовий до використання в продукції. GNU Taler — це система платежів, що зберігає конфіденційність. Клієнти можуть залишатися анонімними, але продавці не можуть приховати свої доходи через платежі за допомогою GNU Taler. Це допомагає уникнути ухилення від сплати податків і відмивання грошей. Основний випадок використання GNU Taler — це платежі; він не призначений для зберігання вартості. Платежі завжди забезпечуються існуючою валютою. Платежі здійснюються після обміну існуючих грошей на електронні гроші за допомогою служби обміну, тобто постачальника платіжних послуг для Taler. Під час здійснення платежу клієнтам потрібен лише заряджений гаманець. Продавець може приймати платежі без реєстрації клієнтів на вебсайті продавця. GNU Taler стійкий до багатьох видів шахрайства, таких як фішинг інформації про кредитні картки або шахрайство з поверненням платежів. У разі втрати або крадіжки зникнути може лише обмежена сума грошей, що залишилася в гаманці.</string> + <string name="google_play_full_desc" tools:keep="@string/google_play_full_desc">Цей додаток є гаманцем для GNU Taler. Він є дуже експериментальним і ще не готовий до використання в продукції. GNU Taler - це система платежів, що зберігає конфіденційність. Клієнти можуть залишатися анонімними, але продавці не можуть приховати свої доходи через платежі за допомогою GNU Taler. Це допомагає уникнути ухилення від сплати податків і відмивання грошей. Основний випадок використання GNU Taler - це платежі; він не призначений для зберігання вартості. Платежі завжди забезпечуються існуючою валютою. Платежі здійснюються після обміну існуючих грошей на електронні гроші за допомогою служби обміну, тобто постачальника платіжних послуг для Taler. Під час здійснення платежу клієнтам потрібен лише заряджений гаманець. Продавець може приймати платежі без реєстрації клієнтів на вебсайті продавця. GNU Taler стійкий до багатьох видів шахрайства, таких як фішинг інформації про кредитні картки або шахрайство з поверненням платежів. У разі втрати або крадіжки зникнути може лише обмежена сума грошей, що залишилася в гаманці.</string> <!-- Navigation --> <string name="nav_error">Помилка</string> diff --git a/wallet/src/test/java/net/taler/wallet/AmountCompatibilityTest.kt b/wallet/src/test/java/net/taler/wallet/AmountCompatibilityTest.kt @@ -0,0 +1,124 @@ +/* + * 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.wallet + +import kotlinx.serialization.json.Json +import net.taler.common.Amount +import net.taler.wallet.balances.BalanceItem +import net.taler.wallet.transactions.AmountType +import net.taler.wallet.transactions.Transactions +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test +import java.util.Locale +import kotlin.random.Random + +/** Wallet-facing regressions for the shared unsigned, exact-decimal amount parser. */ +class AmountCompatibilityTest { + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun inputHelperRejectsMalformedAmountsWithoutThrowing() { + for (text in listOf("", "-1", "-0.5", "+1", "1.-5", "1e2", "NaN", "1.000000001", "1,23", "4503599627370497")) { + assertNull(text, getAmount("KUDOS", text)) + } + assertNull(getAmount("invalid currency", "1")) + } + + @Test + fun inputHelperPreservesExactAmountsAndEditingTrailingDot() { + val cases = mapOf( + "0" to Amount("KUDOS", 0, 0), + "1." to Amount("KUDOS", 1, 0), + "0.00000001" to Amount("KUDOS", 0, 1), + "42.1337" to Amount("KUDOS", 42, 13_370_000), + "4503599627370496.99999999" to Amount.max("KUDOS"), + ) + cases.forEach { (input, expected) -> assertEquals(input, expected, getAmount("KUDOS", input)) } + } + + @Test + fun walletBalanceDecodesZeroMinimumAndMaximumAmounts() { + val balance = json.decodeFromString(BalanceItem.serializer(), """ + {"scopeInfo":{"type":"exchange","currency":"KUDOS","url":"https://exchange.demo.taler.net/"}, + "available":"KUDOS:4503599627370496.99999999", + "pendingIncoming":"KUDOS:0.00000001", "pendingOutgoing":"KUDOS:0", + "disablePeerPayments":false} + """.trimIndent()) + assertEquals(Amount.max("KUDOS"), balance.available) + assertEquals(Amount.min("KUDOS"), balance.pendingIncoming) + assertEquals(Amount.zero("KUDOS"), balance.pendingOutgoing) + } + + @Test + fun debitAndRefundHistoryUseUnsignedAmountsWithSeparateDirection() { + val transactions = json.decodeFromString(Transactions.serializer(), """ + {"transactions":[ + {"type":"payment","transactionId":"payment-test","timestamp":{"t_s":1}, + "txState":{"major":"done"},"txActions":[],"scopes":[], + "amountRaw":"KUDOS:1.23","amountEffective":"KUDOS:1.23000001"}, + {"type":"refund","transactionId":"refund-test","refundedTransactionId":"payment-test", + "timestamp":{"t_s":2},"txState":{"major":"done"},"txActions":[],"scopes":[], + "amountRaw":"KUDOS:0.23","amountEffective":"KUDOS:0.22999999"} + ]} + """.trimIndent()).transactions + assertEquals(AmountType.Negative, transactions[0].amountType) + assertEquals(Amount("KUDOS", 1, 23_000_001), transactions[0].amountEffective) + assertEquals(AmountType.Positive, transactions[1].amountType) + assertEquals(Amount("KUDOS", 0, 22_999_999), transactions[1].amountEffective) + } + + @Test + fun exactAmountRoundTripsAcrossWalletLocales() { + val original = Locale.getDefault() + try { + for (locale in listOf(Locale.US, Locale.GERMANY, Locale.FRANCE, Locale.forLanguageTag("ar"))) { + Locale.setDefault(locale) + val random = Random(1401) + repeat(500) { + val amount = Amount("KUDOS", random.nextLong(0, Amount.MAX_VALUE + 1), random.nextInt(100_000_000)) + assertEquals(amount, getAmount(amount.currency, amount.amountStr)) + assertEquals(amount, json.decodeFromString(Amount.serializer(), json.encodeToString(Amount.serializer(), amount))) + } + } + } finally { + Locale.setDefault(original) + } + } + + @Test + fun paymentAndRefundArithmeticRemainsExact() { + val balance = getAmount("KUDOS", "10")!! + val payment = getAmount("KUDOS", "1.23000001")!! + val refund = getAmount("KUDOS", "0.23000001")!! + assertEquals(Amount("KUDOS", 9, 0), balance - payment + refund) + assertNotNull(getAmount("KUDOS", (payment - payment).amountStr)) + } + + @Test + fun walletDigitEditingHandlesBackspaceResetAndOverflowWithoutThrowing() { + var amount = Amount.zero("KUDOS") + for (digit in "123") amount = amount.addInputDigit(digit)!! + assertEquals(Amount("KUDOS", 1, 23_000_000), amount) + assertEquals(Amount("KUDOS", 0, 12_000_000), amount.removeInputDigit()) + assertEquals(Amount.zero("KUDOS"), amount - amount) + for (character in "-+.eNaN") assertNull(amount.addInputDigit(character)) + assertNull(Amount.max("KUDOS").addInputDigit('9')) + assertEquals(Amount.zero("KUDOS"), Amount.zero("KUDOS").removeInputDigit()) + } +}