taler-android

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

commit 5bf1f8c6655e5a39d3202edd32365097e5d043cf
parent acdc361c6d0b6c53293e628f128db473cf9b86c1
Author: Bohdan Potuzhnyi <bohdan.potuzhnyi@gmail.com>
Date:   Mon, 18 May 2026 16:36:26 +0200

[pos] tiny update (migrate to compose)

Diffstat:
M.idea/gradle.xml | 1+
Mmerchant-lib/src/main/java/net/taler/merchantlib/MerchantApi.kt | 9++++++++-
Mmerchant-lib/src/main/java/net/taler/merchantlib/OrderHistory.kt | 31+++++++++++++++++++++++++++++--
Mmerchant-lib/src/main/java/net/taler/merchantlib/Orders.kt | 7++++++-
Mmerchant-lib/src/main/java/net/taler/merchantlib/Response.kt | 50+++++++++++++++++++++++++++++++++++++++++++++-----
Mmerchant-lib/src/test/java/net/taler/merchantlib/MerchantApiTest.kt | 78+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mmerchant-terminal/build.gradle | 15+++++++--------
Dmerchant-terminal/src/main/java/net/taler/merchantpos/InitialOrderNavigation.kt | 30------------------------------
Mmerchant-terminal/src/main/java/net/taler/merchantpos/MainActivity.kt | 656+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Amerchant-terminal/src/main/java/net/taler/merchantpos/PosDestination.kt | 42++++++++++++++++++++++++++++++++++++++++++
Amerchant-terminal/src/main/java/net/taler/merchantpos/PosErrorDialog.kt | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mmerchant-terminal/src/main/java/net/taler/merchantpos/amount/AmountEntryFragment.kt | 60++++++++++++++++++++++++++++++++++++------------------------
Mmerchant-terminal/src/main/java/net/taler/merchantpos/config/ConfigFetcherFragment.kt | 75+++++++++++++++++++++++++++++++++++++++++++++++++++------------------------
Mmerchant-terminal/src/main/java/net/taler/merchantpos/config/ConfigFragment.kt | 753++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
Mmerchant-terminal/src/main/java/net/taler/merchantpos/config/ConfigManager.kt | 84++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mmerchant-terminal/src/main/java/net/taler/merchantpos/config/GeneralSettingsFragment.kt | 147+++++++++++++++++++++++++++++++++++++++----------------------------------------
Mmerchant-terminal/src/main/java/net/taler/merchantpos/config/PosConfig.kt | 20+++++++++++++++-----
Mmerchant-terminal/src/main/java/net/taler/merchantpos/history/HistoryFragment.kt | 359++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Dmerchant-terminal/src/main/java/net/taler/merchantpos/history/HistoryItemAdapter.kt | 100-------------------------------------------------------------------------------
Mmerchant-terminal/src/main/java/net/taler/merchantpos/history/HistoryManager.kt | 111++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Dmerchant-terminal/src/main/java/net/taler/merchantpos/order/CategoriesFragment.kt | 68--------------------------------------------------------------------
Dmerchant-terminal/src/main/java/net/taler/merchantpos/order/CategoryAdapter.kt | 62--------------------------------------------------------------
Dmerchant-terminal/src/main/java/net/taler/merchantpos/order/CustomDialogFragment.kt | 80-------------------------------------------------------------------------------
Mmerchant-terminal/src/main/java/net/taler/merchantpos/order/Order.kt | 24+++++++++++++-----------
Dmerchant-terminal/src/main/java/net/taler/merchantpos/order/OrderAdapter.kt | 136-------------------------------------------------------------------------------
Mmerchant-terminal/src/main/java/net/taler/merchantpos/order/OrderFragment.kt | 952+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Mmerchant-terminal/src/main/java/net/taler/merchantpos/order/OrderManager.kt | 11+++++++----
Dmerchant-terminal/src/main/java/net/taler/merchantpos/order/OrderStateFragment.kt | 119-------------------------------------------------------------------------------
Dmerchant-terminal/src/main/java/net/taler/merchantpos/order/ProductsFragment.kt | 173-------------------------------------------------------------------------------
Mmerchant-terminal/src/main/java/net/taler/merchantpos/payment/PaymentManager.kt | 40++++++++++++++++++++++++++++++++++++++++
Mmerchant-terminal/src/main/java/net/taler/merchantpos/payment/PaymentSuccessFragment.kt | 125+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Mmerchant-terminal/src/main/java/net/taler/merchantpos/payment/ProcessPaymentFragment.kt | 469+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
Mmerchant-terminal/src/main/java/net/taler/merchantpos/refund/RefundFragment.kt | 246+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Mmerchant-terminal/src/main/java/net/taler/merchantpos/refund/RefundManager.kt | 68+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mmerchant-terminal/src/main/java/net/taler/merchantpos/refund/RefundUriFragment.kt | 331+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Amerchant-terminal/src/main/res/drawable/ic_talerpos_logo.xml | 36++++++++++++++++++++++++++++++++++++
Dmerchant-terminal/src/main/res/layout/activity_main.xml | 41-----------------------------------------
Dmerchant-terminal/src/main/res/layout/app_bar_main.xml | 51---------------------------------------------------
Dmerchant-terminal/src/main/res/layout/fragment_amount_entry.xml | 292-------------------------------------------------------------------------------
Dmerchant-terminal/src/main/res/layout/fragment_categories.xml | 45---------------------------------------------
Dmerchant-terminal/src/main/res/layout/fragment_config_fetcher.xml | 44--------------------------------------------
Dmerchant-terminal/src/main/res/layout/fragment_custom_dialog.xml | 117-------------------------------------------------------------------------------
Dmerchant-terminal/src/main/res/layout/fragment_merchant_config.xml | 223-------------------------------------------------------------------------------
Dmerchant-terminal/src/main/res/layout/fragment_merchant_history.xml | 28----------------------------
Dmerchant-terminal/src/main/res/layout/fragment_order.xml | 154-------------------------------------------------------------------------------
Dmerchant-terminal/src/main/res/layout/fragment_order_state.xml | 33---------------------------------
Dmerchant-terminal/src/main/res/layout/fragment_payment_success.xml | 82-------------------------------------------------------------------------------
Dmerchant-terminal/src/main/res/layout/fragment_process_payment.xml | 173-------------------------------------------------------------------------------
Dmerchant-terminal/src/main/res/layout/fragment_products.xml | 43-------------------------------------------
Dmerchant-terminal/src/main/res/layout/fragment_refund.xml | 122-------------------------------------------------------------------------------
Dmerchant-terminal/src/main/res/layout/fragment_refund_uri.xml | 106-------------------------------------------------------------------------------
Dmerchant-terminal/src/main/res/layout/list_item_category.xml | 34----------------------------------
Dmerchant-terminal/src/main/res/layout/list_item_history.xml | 112-------------------------------------------------------------------------------
Dmerchant-terminal/src/main/res/layout/list_item_order.xml | 88-------------------------------------------------------------------------------
Dmerchant-terminal/src/main/res/layout/list_item_product.xml | 96-------------------------------------------------------------------------------
Dmerchant-terminal/src/main/res/layout/nav_header_main.xml | 54------------------------------------------------------
Dmerchant-terminal/src/main/res/menu/activity_main_drawer.xml | 40----------------------------------------
Dmerchant-terminal/src/main/res/menu/order.xml | 39---------------------------------------
Dmerchant-terminal/src/main/res/navigation/nav_graph.xml | 162-------------------------------------------------------------------------------
Mmerchant-terminal/src/main/res/values/strings.xml | 13+++++++++++++
60 files changed, 3867 insertions(+), 3973 deletions(-)

diff --git a/.idea/gradle.xml b/.idea/gradle.xml @@ -6,6 +6,7 @@ <GradleProjectSettings> <option name="testRunner" value="CHOOSE_PER_TEST" /> <option name="externalProjectPath" value="$PROJECT_DIR$" /> + <option name="gradleJvm" value="jbr-17" /> <option name="modules"> <set> <option value="$PROJECT_DIR$" /> diff --git a/merchant-lib/src/main/java/net/taler/merchantlib/MerchantApi.kt b/merchant-lib/src/main/java/net/taler/merchantlib/MerchantApi.kt @@ -23,6 +23,7 @@ import io.ktor.client.request.delete import io.ktor.client.request.get import io.ktor.client.request.header import io.ktor.client.request.post +import io.ktor.client.request.parameter import io.ktor.client.request.setBody import io.ktor.http.ContentType.Application.Json import io.ktor.http.HttpHeaders.Authorization @@ -79,11 +80,17 @@ class MerchantApi( } } - suspend fun getOrderHistory(merchantConfig: MerchantConfig): Response<OrderHistory> = + suspend fun getOrderHistory( + merchantConfig: MerchantConfig, + limit: Int = -20, + offset: Long? = null, + ): Response<OrderHistory> = withContext(ioDispatcher) { response { httpClient.get(merchantConfig.urlFor("private/orders")) { auth(merchantConfig) + parameter("limit", limit) + offset?.let { parameter("offset", it) } }.body() } } diff --git a/merchant-lib/src/main/java/net/taler/merchantlib/OrderHistory.kt b/merchant-lib/src/main/java/net/taler/merchantlib/OrderHistory.kt @@ -16,8 +16,10 @@ package net.taler.merchantlib +import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonNames import net.taler.common.Amount import net.taler.common.Timestamp @@ -26,12 +28,17 @@ data class OrderHistory( val orders: List<OrderHistoryEntry> ) +@OptIn(ExperimentalSerializationApi::class) @Serializable data class OrderHistoryEntry( // order ID of the transaction related to this entry. @SerialName("order_id") val orderId: String, + // row ID of the order in the database + @SerialName("row_id") + val rowId: Long? = null, + // when the order was created val timestamp: Timestamp, @@ -45,5 +52,25 @@ data class OrderHistoryEntry( val paid: Boolean, // whether some part of the order is refundable - val refundable: Boolean -) + val refundable: Boolean, + + // whether the order has already been refunded + val refunded: Boolean = false, + + // total refunded amount approved for this order + @JsonNames("refund_amount", "refunded_amount") + val refundAmount: Amount? = null, + + // portion of refund amount not yet obtained by the wallet + @JsonNames("pending_refund_amount", "refund_pending_amount") + val pendingRefundAmount: Amount? = null, + + // whether the backend still reports wallet pickup as pending + val refundPending: Boolean = false, +) { + val hasRefund: Boolean + get() = refunded || refundAmount?.isZero() == false + + val hasPendingRefund: Boolean + get() = refundPending || pendingRefundAmount?.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,6 +20,7 @@ import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonClassDiscriminator +import net.taler.common.Amount import net.taler.common.ContractTerms import net.taler.common.RelativeTime @@ -86,7 +87,11 @@ sealed class CheckPaymentResponse { @SerialName("paid") data class Paid( override val paid: Boolean = true, - val refunded: Boolean + val refunded: Boolean, + @SerialName("refund_pending") + val refundPending: Boolean = false, + @SerialName("refund_amount") + val refundAmount: Amount? = null, ) : CheckPaymentResponse() } diff --git a/merchant-lib/src/main/java/net/taler/merchantlib/Response.kt b/merchant-lib/src/main/java/net/taler/merchantlib/Response.kt @@ -18,12 +18,20 @@ package net.taler.merchantlib import io.ktor.client.call.body import io.ktor.client.plugins.ResponseException +import java.io.IOException +import java.net.ConnectException +import java.net.SocketTimeoutException +import java.net.UnknownHostException +import java.nio.channels.UnresolvedAddressException import kotlinx.serialization.Serializable class Response<out T> private constructor( private val value: Any? ) { companion object { + private const val NETWORK_ERROR_MESSAGE = + "Network error: check your internet connection and merchant URL." + suspend fun <T> response(request: suspend () -> T): Response<T> { return try { success(request()) @@ -61,21 +69,53 @@ class Response<out T> private constructor( } } - private suspend fun getFailureString(failure: Failure): String = when (failure.exception) { - is ResponseException -> getExceptionString(failure.exception) - else -> failure.exception.toString() + private suspend fun getFailureString(failure: Failure): String = when (val exception = failure.exception) { + is ResponseException -> getExceptionString(exception) + is UnknownHostException, + 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() } private suspend fun getExceptionString(e: ResponseException): String { val response = e.response return try { val error: Error = response.body() - "Error ${error.code} (${response.status.value}): ${error.hint} ${error.detail}" + buildString { + append("Error") + error.code?.let { + append(' ') + append(it) + } + append(" (") + append(response.status.value) + append(")") + error.hint?.takeIf(String::isNotBlank)?.let { + append(": ") + append(it) + } + error.detail?.takeIf(String::isNotBlank)?.let { + append(" - ") + append(it) + } + } } catch (ex: Exception) { - "Status code: ${response.status.value}" + fallbackStatusMessage(response.status.value) } } + private fun fallbackStatusMessage(statusCode: Int): String = when (statusCode) { + 400 -> "Bad request (400)" + 401 -> "Unauthorized (401)" + 403 -> "Forbidden (403)" + 404 -> "Not found (404): check the merchant URL and instance path." + 408 -> "Request timed out (408)" + in 500..599 -> "Server error ($statusCode)" + else -> "HTTP error ($statusCode)" + } + private class Failure(val exception: Throwable) @Serializable diff --git a/merchant-lib/src/test/java/net/taler/merchantlib/MerchantApiTest.kt b/merchant-lib/src/test/java/net/taler/merchantlib/MerchantApiTest.kt @@ -17,6 +17,7 @@ package net.taler.merchantlib import io.ktor.http.HttpStatusCode.Companion.NotFound +import java.net.UnknownHostException import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -128,6 +129,22 @@ class MerchantApiTest { assertEquals(unpaidResponse, it) } + httpClient.giveJsonResponse("http://example.net/instances/testInstance/private/orders/$orderId") { + """{ + "order_status": "paid", + "paid": true, + "refunded": true, + "refund_pending": false, + "refund_amount": "TESTKUDOS:1.5" + }""".trimIndent() + } + api.checkOrder(merchantConfig, orderId).assertSuccess { + val paidResponse = it as CheckPaymentResponse.Paid + assertEquals(true, paidResponse.refunded) + assertEquals(false, paidResponse.refundPending) + assertEquals(Amount("TESTKUDOS", 1, 50000000), paidResponse.refundAmount) + } + httpClient.giveJsonResponse( "http://example.net/instances/testInstance/private/orders/$orderId", statusCode = NotFound @@ -168,7 +185,7 @@ class MerchantApiTest { @Test fun testGetOrderHistory() = runBlocking { - httpClient.giveJsonResponse("http://example.net/instances/testInstance/private/orders") { + httpClient.giveJsonResponse("http://example.net/instances/testInstance/private/orders?limit=-20") { """{ "orders": [ { "order_id": "2020.217-0281FGXCS25P2", @@ -178,6 +195,8 @@ class MerchantApiTest { }, "amount": "TESTKUDOS:1", "summary": "Chips", + "refund_amount": "TESTKUDOS:0.8", + "pending_refund_amount": "TESTKUDOS:0.3", "refundable": true, "paid": true }, @@ -205,6 +224,10 @@ class MerchantApiTest { assertEquals(true, order1.refundable) assertEquals("Chips", order1.summary) assertEquals(Timestamp.fromMillis(1596542338000), order1.timestamp) + assertEquals(Amount("TESTKUDOS", 0, 80000000), order1.refundAmount) + assertEquals(Amount("TESTKUDOS", 0, 30000000), order1.pendingRefundAmount) + assertEquals(true, order1.hasRefund) + assertEquals(true, order1.hasPendingRefund) val order2 = it.orders[1] assertEquals(Amount("TESTKUDOS", 0, 80000000), order2.amount) @@ -213,6 +236,59 @@ class MerchantApiTest { assertEquals(false, order2.refundable) assertEquals("Peanuts", order2.summary) assertEquals(Timestamp.fromMillis(1596468174000), order2.timestamp) + assertEquals(false, order2.hasRefund) + assertEquals(false, order2.hasPendingRefund) + } + } + + @Test + fun testGetOrderHistoryLegacyRefundFields() = runBlocking { + httpClient.giveJsonResponse("http://example.net/instances/testInstance/private/orders?limit=-20") { + """{ "orders": [ + { + "order_id": "legacy-refund-order", + "timestamp": { + "t_s": 1596542338 + }, + "amount": "TESTKUDOS:1", + "summary": "Legacy refund", + "refundable": false, + "paid": true, + "refunded_amount": "TESTKUDOS:0.8", + "refund_pending_amount": "TESTKUDOS:0.2" + } + ] + }""".trimIndent() + } + api.getOrderHistory(merchantConfig).assertSuccess { + assertEquals(1, it.orders.size) + val order = it.orders.single() + assertEquals(Amount("TESTKUDOS", 0, 80000000), order.refundAmount) + assertEquals(Amount("TESTKUDOS", 0, 20000000), order.pendingRefundAmount) + assertEquals(true, order.hasRefund) + assertEquals(true, order.hasPendingRefund) + } + } + + @Test + fun testGetOrderHistoryNotFoundFallbackMessage() = runBlocking { + httpClient.giveJsonResponse( + "http://example.net/instances/testInstance/private/orders?limit=-20", + statusCode = NotFound + ) { + "not-json" + } + api.getOrderHistory(merchantConfig).assertFailure { + assertEquals("Not found (404): check the merchant URL and instance path.", it) + } + } + + @Test + fun testResponseNetworkFailureMessage() = runBlocking { + Response.response<String> { + throw UnknownHostException("backend.int.taler.net") + }.assertFailure { + assertEquals("Network error: check your internet connection and merchant URL.", it) } } diff --git a/merchant-terminal/build.gradle b/merchant-terminal/build.gradle @@ -2,7 +2,6 @@ plugins { id 'com.android.application' id 'kotlin-android' id 'kotlinx-serialization' - id 'androidx.navigation.safeargs.kotlin' id "org.jetbrains.kotlin.plugin.compose" version "$kotlin_version" } @@ -14,8 +13,8 @@ android { applicationId "net.taler.merchantpos" minSdkVersion 23 targetSdkVersion 36 - versionCode 20 - versionName "1.3.2" + versionCode 21 + versionName "1.5.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" buildConfigField("String", "BACKEND_API_VERSION", "\"20:0:8\"") @@ -43,7 +42,6 @@ android { buildFeatures { buildConfig = true - viewBinding = true compose = true } @@ -73,8 +71,13 @@ dependencies { implementation "androidx.constraintlayout:constraintlayout:$constraintlayout_version" implementation platform('androidx.compose:compose-bom:2026.02.01') implementation 'androidx.compose.material3:material3' + implementation 'androidx.compose.material:material-icons-extended' implementation 'androidx.compose.ui:ui' implementation 'androidx.compose.ui:ui-tooling-preview' + implementation "androidx.compose.runtime:runtime-livedata" + implementation 'androidx.activity:activity-compose:1.13.0' + implementation "androidx.navigation:navigation-compose:$nav_version" + implementation "androidx.fragment:fragment-ktx:1.8.9" implementation "androidx.recyclerview:recyclerview:1.4.0" implementation "androidx.recyclerview:recyclerview-selection:1.2.0" @@ -86,10 +89,6 @@ dependencies { // ZXING core – on-device barcode/QR detector implementation "com.google.zxing:core:3.5.4" - // Navigation - implementation "androidx.navigation:navigation-fragment-ktx:$nav_version" - implementation "androidx.navigation:navigation-ui-ktx:$nav_version" - implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.2.0" testImplementation 'androidx.test.ext:junit:1.3.0' diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/InitialOrderNavigation.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/InitialOrderNavigation.kt @@ -1,30 +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; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -package net.taler.merchantpos - -import androidx.navigation.NavController -import net.taler.merchantpos.config.ConfigManager -import net.taler.merchantpos.config.InitialOrderScreen - -fun NavController.navigateToInitialOrderScreen(configManager: ConfigManager) { - navigate( - when (configManager.initialOrderScreen) { - InitialOrderScreen.AmountEntry -> R.id.action_global_amountEntry - InitialOrderScreen.Inventory -> R.id.action_global_order - } - ) -} diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/MainActivity.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/MainActivity.kt @@ -23,52 +23,100 @@ import android.content.Intent.FLAG_ACTIVITY_NEW_TASK import android.net.Uri import android.os.Bundle import android.os.Handler +import android.os.Looper import android.util.Log -import android.view.MenuItem +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.setContent import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity -import androidx.core.view.GravityCompat.START -import androidx.navigation.NavController -import androidx.navigation.NavOptions -import androidx.navigation.fragment.NavHostFragment -import androidx.navigation.ui.AppBarConfiguration -import androidx.navigation.ui.setupWithNavController -import com.google.android.material.navigation.NavigationView.OnNavigationItemSelectedListener +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.Image +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.DrawerValue +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalDrawerSheet +import androidx.compose.material3.ModalNavigationDrawer +import androidx.compose.material3.NavigationDrawerItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +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.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.fragment.app.Fragment +import androidx.fragment.app.commitNow +import androidx.navigation.NavHostController +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 net.taler.lib.android.TalerNfcService +import net.taler.merchantpos.compose.PosTheme import net.taler.merchantpos.config.Config +import net.taler.merchantpos.config.ConfigFetcherFragment import net.taler.merchantpos.config.ConfigUpdateResult -import net.taler.merchantpos.databinding.ActivityMainBinding - -class MainActivity : AppCompatActivity(), OnNavigationItemSelectedListener { +import net.taler.merchantpos.config.ConfigFragment +import net.taler.merchantpos.config.GeneralSettingsFragment +import net.taler.merchantpos.history.HistoryFragment +import net.taler.merchantpos.order.OrderFragment +import net.taler.merchantpos.amount.AmountEntryFragment +import net.taler.merchantpos.payment.PaymentSuccessFragment +import net.taler.merchantpos.payment.ProcessPaymentFragment +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() { private val model: MainViewModel by viewModels() - private lateinit var ui: ActivityMainBinding - private lateinit var nav: NavController - + private var navController: NavHostController? = null private var reallyExit = false companion object { const val TAG = "taler-pos" } - private fun navigateToInstanceSettings(resetBackStack: Boolean) { - if (nav.currentDestination?.id == R.id.nav_instanceSettings) return - val options = NavOptions.Builder() - .setLaunchSingleTop(true) - .apply { - if (resetBackStack) setPopUpTo(R.id.nav_graph, true) - } - .build() - nav.navigate(R.id.nav_instanceSettings, null, options) - } - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - ui = ActivityMainBinding.inflate(layoutInflater) - setContentView(ui.root) TalerNfcService.startService(this) @@ -80,39 +128,20 @@ class MainActivity : AppCompatActivity(), OnNavigationItemSelectedListener { } } - // If the backend session expires, force the user back into instance setup. model.configManager.sessionExpired.observe(this) { - Toast - .makeText(this, R.string.session_expired_toast, Toast.LENGTH_LONG) - .show() - navigateToInstanceSettings(resetBackStack = true) + showPosError(R.string.session_expired_toast) + navigateTo(PosDestination.Config, clearBackStack = true) } - val navHostFragment = - supportFragmentManager.findFragmentById(R.id.navHostFragment) as NavHostFragment - nav = navHostFragment.navController - - ui.navView.setupWithNavController(nav) - ui.navView.setNavigationItemSelectedListener(this) - - setSupportActionBar(ui.main.toolbar) - val appBarConfiguration = AppBarConfiguration( - setOf( - R.id.nav_order, - R.id.nav_amountEntry, - R.id.nav_history, - R.id.nav_settings, - ), - ui.drawerLayout, - ) - ui.main.toolbar.setupWithNavController(nav, appBarConfiguration) - - if (savedInstanceState == null && - intent.action != Intent.ACTION_VIEW && - model.configManager.config.isValid() && - model.configManager.merchantConfig != null - ) { - nav.navigateToInitialOrderScreen(model.configManager) + setContent { + PosTheme { + MerchantTerminalApp( + viewModel = model, + startDestination = determineStartDestination(), + onNavControllerReady = { navController = it }, + onExitRequested = ::handleExitRequest, + ) + } } handleSetupIntent(intent) @@ -121,10 +150,11 @@ class MainActivity : AppCompatActivity(), OnNavigationItemSelectedListener { override fun onStart() { super.onStart() if (!model.configManager.config.isValid()) { - navigateToInstanceSettings(resetBackStack = true) - } else if (model.configManager.merchantConfig == null - && nav.currentDestination?.id != R.id.configFetcher) { - nav.navigate(R.id.action_global_configFetcher) + navigateTo(PosDestination.Config, clearBackStack = true) + } else if (model.configManager.merchantConfig == null || model.configManager.currency == null) { + navigateTo(PosDestination.ConfigFetcher) + } else { + model.configManager.refreshConfigInBackground() } } @@ -143,76 +173,60 @@ class MainActivity : AppCompatActivity(), OnNavigationItemSelectedListener { TalerNfcService.stopService(this) } - override fun onNavigationItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - R.id.nav_order -> nav.navigate(R.id.action_global_order) - R.id.nav_amountEntry -> nav.navigate(R.id.action_global_amountEntry) - R.id.nav_history -> nav.navigate(R.id.action_global_merchantHistory) - R.id.nav_settings-> { - if (model.configManager.config.isValid()) { - nav.navigate(R.id.action_global_merchantSettings) - } else { - navigateToInstanceSettings(resetBackStack = true) + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + handleSetupIntent(intent) + } + + fun navigateTo(destination: PosDestination, clearBackStack: Boolean = false) { + val controller = navController ?: return + controller.navigate(destination.route) { + launchSingleTop = true + if (clearBackStack) { + popUpTo(controller.graph.id) { + inclusive = true } } } - ui.drawerLayout.closeDrawer(START) - return true } - override fun onNewIntent(intent: Intent) { - super.onNewIntent(intent) - handleSetupIntent(intent) + fun navigateBack() { + val controller = navController ?: return + if (!controller.popBackStack()) { + finish() + } } - @Deprecated("Deprecated in Java") - override fun onBackPressed() { - val currentDestination = nav.currentDestination?.id - if (ui.drawerLayout.isDrawerOpen(START)) { - ui.drawerLayout.closeDrawer(START) - } else if ((currentDestination == R.id.nav_settings || currentDestination == R.id.nav_instanceSettings) - && !model.configManager.config.isValid()) { - // we are in settings and need a valid config to continue - val intent = Intent(ACTION_MAIN).apply { - addCategory(CATEGORY_HOME) - flags = FLAG_ACTIVITY_NEW_TASK - } - startActivity(intent) - } else if (currentDestination == R.id.nav_order || currentDestination == R.id.nav_amountEntry) { - if (reallyExit) super.onBackPressed() - else { - // this closes the app and causes orders to be lost, so let's confirm first - reallyExit = true - Toast.makeText(this, R.string.toast_back_to_exit, LENGTH_SHORT).show() - Handler().postDelayed({ reallyExit = false }, 3000) - } - } else super.onBackPressed() + fun navigateToInitialOrderScreen() { + navigateTo(model.configManager.initialDestination(), clearBackStack = true) } - /** - * Handle the setup intent from the URL scheme. E.g. scanned the QR code from the camera - * - * This is the URL format: - * taler-pos://backend.demo.taler.net/#/username=<username>&password=<password> - */ - fun handleSetupIntent(intent: Intent) { + 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 params = data.fragment - ?.removePrefix("/") + 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) { "" }) } - } ?: return + } - val instance = params["username"] ?: return - val token = params["password"] ?: return + val instance = pathStyleInstance ?: params?.get("username") ?: return + val token = if (pathStyleInstance != null) { + Uri.decode(rawFragment).takeIf(String::isNotBlank) + } else { + params?.get("password") + } ?: return - // Build a regular Merchant-API URL: https://<host>/instances/<instance> val merchantUrl = Uri.Builder() .scheme("https") .encodedAuthority(host) @@ -221,27 +235,17 @@ class MainActivity : AppCompatActivity(), OnNavigationItemSelectedListener { .build() .toString() - // Re-use the existing “new config” class val newConfig = Config.New( - merchantUrl = merchantUrl, - accessToken = token, - savePassword = true + merchantUrl = merchantUrl, + accessToken = token, + savePassword = true, ) Log.d("MainActivity", "Config URL: $merchantUrl") - - //add check that there was no config beforehand model.configManager.config = newConfig + model.configManager.fetchConfig(newConfig, true) + navigateTo(PosDestination.ConfigFetcher) - // Kick off the exact same pipeline the Settings screen would start - model.configManager.fetchConfig(newConfig, /*save =*/ true) - - // Show the spinner immediately - if (nav.currentDestination?.id != R.id.configFetcher) { - nav.navigate(R.id.action_global_configFetcher) - } - - // Observe for result model.configManager.configUpdateResult.observe(this) { result -> if (result is ConfigUpdateResult.Success) { Log.d("MainActivity", "Config loaded successfully") @@ -249,9 +253,405 @@ class MainActivity : AppCompatActivity(), OnNavigationItemSelectedListener { } else if (result is ConfigUpdateResult.Error) { Log.e("MainActivity", "Config failed: ${result.msg}") model.configManager.configUpdateResult.removeObservers(this) - Toast.makeText(this, result.msg, Toast.LENGTH_LONG).show() + showPosError(result.msg) } } } + private fun determineStartDestination(): PosDestination { + return when { + !model.configManager.config.isValid() -> PosDestination.Config + model.configManager.merchantConfig == null || model.configManager.currency == null -> + PosDestination.ConfigFetcher + else -> model.configManager.initialDestination() + } + } + + private fun handleExitRequest(currentRoute: String?) { + if (currentRoute == PosDestination.Order.route || currentRoute == PosDestination.AmountEntry.route) { + if (reallyExit) { + super.onBackPressedDispatcher.onBackPressed() + } 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 { + navigateBack() + } + } else { + navigateBack() + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun MerchantTerminalApp( + viewModel: MainViewModel, + 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 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 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) } + val preferredInitialDestination by viewModel.configManager.initialOrderScreenLiveData.observeAsState( + viewModel.configManager.initialOrderScreen + ) + val context = LocalContext.current + 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 { + context.getString(R.string.order_label_title, it.title) + } ?: stringResource(R.string.menu_order) + else -> stringResource(currentRoute.titleResId()) + } + + 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 + } + } + + LaunchedEffect(navController) { + onNavControllerReady(navController) + } + + BackHandler { + if (drawerState.isOpen) { + scope.launch { drawerState.close() } + } else { + onExitRequested(currentRoute) + } + } + + ModalNavigationDrawer( + drawerState = drawerState, + gesturesEnabled = !lockMerchantSettingsNavigation, + drawerContent = { + ModalDrawerSheet { + Column( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Image( + painter = painterResource(R.drawable.ic_talerpos_logo), + contentDescription = null, + modifier = Modifier + .fillMaxWidth() + .height(72.dp) + .padding(horizontal = 8.dp, vertical = 8.dp), + ) + drawerItems.forEach { item -> + NavigationDrawerItem( + icon = { + Icon( + painter = painterResource(item.drawerIconResId()), + contentDescription = null, + ) + }, + label = { + Text( + text = stringResource(item.labelResId()), + fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold, + ) + }, + selected = currentRoute == item.route, + onClick = { + scope.launch { drawerState.close() } + navController.navigate(item.route) { + launchSingleTop = true + popUpTo(navController.graph.startDestinationId) { + inclusive = false + } + } + }, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.large, + badge = null, + colors = androidx.compose.material3.NavigationDrawerItemDefaults.colors(), + ) + } + } + } + }, + ) { + Scaffold( + topBar = { + if (currentRoute == PosDestination.Order.route) { + Surface(shadowElevation = 2.dp) { + Row( + modifier = Modifier + .fillMaxWidth() + .statusBarsPadding() + .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.width(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 = restartState != DISABLED, + 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, + context.getString(R.string.toast_reloading), + Toast.LENGTH_LONG, + ).show() + }, + colors = topBarOrderButtonColors(), + modifier = Modifier.heightIn(min = 40.dp), + ) { + Text(stringResource(R.string.menu_reload)) + } + } + } + } + } else if (currentRoute == PosDestination.History.route) { + Surface(shadowElevation = 2.dp) { + Row( + modifier = Modifier + .fillMaxWidth() + .statusBarsPadding() + .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.width(48.dp)) + } + Text( + text = screenTitle, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.titleLarge, + ) + Button( + onClick = { viewModel.historyManager.fetchHistory() }, + colors = topBarOrderButtonColors(), + modifier = Modifier.heightIn(min = 40.dp), + ) { + Text(stringResource(R.string.history_refresh)) + } + } + } + } else { + Surface(shadowElevation = 2.dp) { + Row( + modifier = Modifier + .fillMaxWidth() + .statusBarsPadding() + .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.width(48.dp)) + } + Text( + text = screenTitle, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.titleLarge, + ) + } + } + } + }, + ) { innerPadding -> + NavHost( + navController = navController, + startDestination = startDestination.route, + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + composable(PosDestination.AmountEntry.route) { + FragmentScreenHost("amount-entry") { AmountEntryFragment() } + } + composable(PosDestination.Order.route) { + FragmentScreenHost("order") { OrderFragment() } + } + composable(PosDestination.History.route) { + FragmentScreenHost("history") { HistoryFragment() } + } + composable(PosDestination.Settings.route) { + FragmentScreenHost("settings") { GeneralSettingsFragment() } + } + composable(PosDestination.Config.route) { + FragmentScreenHost("config") { ConfigFragment() } + } + composable(PosDestination.ConfigFetcher.route) { + FragmentScreenHost("config-fetcher") { ConfigFetcherFragment() } + } + composable(PosDestination.ProcessPayment.route) { + FragmentScreenHost("process-payment") { ProcessPaymentFragment() } + } + composable(PosDestination.PaymentSuccess.route) { + FragmentScreenHost("payment-success") { PaymentSuccessFragment() } + } + composable(PosDestination.Refund.route) { + FragmentScreenHost("refund") { RefundFragment() } + } + composable(PosDestination.RefundUri.route) { + FragmentScreenHost("refund-uri") { RefundUriFragment() } + } + } + } + } +} + +@Composable +private fun topBarOrderButtonColors() = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, +) + +@Composable +private fun FragmentScreenHost( + routeTag: String, + createFragment: () -> Fragment, +) { + val activity = LocalContext.current as MainActivity + val fragmentManager = activity.supportFragmentManager + val containerId = remember(routeTag) { View.generateViewId() } + val fragmentTag = remember(routeTag, containerId) { "$routeTag-$containerId" } + + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { context -> + FrameLayout(context).apply { + id = containerId + layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT) + } + }, + ) + + DisposableEffect(fragmentTag) { + if (fragmentManager.findFragmentByTag(fragmentTag) == null) { + fragmentManager.commitNow { + replace(containerId, createFragment(), fragmentTag) + } + } + onDispose { + fragmentManager.findFragmentByTag(fragmentTag)?.let { fragment -> + if (fragment.isAdded && !fragmentManager.isStateSaved) { + fragmentManager.commitNow { + remove(fragment) + } + } + } + } + } +} + +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 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 } diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/PosDestination.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/PosDestination.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.merchantpos + +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 Settings : PosDestination("settings") + data object Config : PosDestination("config") + data object ConfigFetcher : PosDestination("config-fetcher") + data object ProcessPayment : PosDestination("process-payment") + data object PaymentSuccess : PosDestination("payment-success") + data object Refund : PosDestination("refund") + data object RefundUri : PosDestination("refund-uri") +} + +fun ConfigManager.initialDestination(): PosDestination { + return 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 @@ -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 + +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 { + 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 + } + return MaterialAlertDialogBuilder(requireContext()) + .setTitle(titleText) + .setMessage(messageText) + .setPositiveButton(android.R.string.ok, null) + .create() + } + + 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 FragmentActivity.showPosError(mainText: String, detailText: String = "") { + (supportFragmentManager.findFragmentByTag(POS_ERROR_DIALOG_TAG) as? DialogFragment)?.dismissAllowingStateLoss() + PosErrorDialogFragment.newInstance(mainText, detailText) + .show(supportFragmentManager, POS_ERROR_DIALOG_TAG) +} + +fun FragmentActivity.showPosError(@StringRes mainId: Int, detailText: String = "") { + showPosError(getString(mainId), detailText) +} + +fun Fragment.showPosError(mainText: String, detailText: String = "") { + requireActivity().showPosError(mainText, detailText) +} + +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,7 +20,6 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.Toast import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -32,6 +31,7 @@ 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 @@ -61,6 +61,7 @@ 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 @@ -69,15 +70,13 @@ import androidx.compose.ui.unit.TextUnit import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import net.taler.common.Amount -import net.taler.lib.android.navigate +import net.taler.merchantpos.PosDestination import net.taler.merchantpos.MainViewModel import net.taler.merchantpos.R -import net.taler.merchantpos.amount.AmountEntryFragmentDirections.Companion.actionAmountEntryToProcessPayment -import net.taler.merchantpos.amount.AmountEntryFragmentDirections.Companion.actionGlobalConfigFetcher -import net.taler.merchantpos.amount.AmountEntryFragmentDirections.Companion.actionGlobalMerchantSettings import net.taler.merchantpos.compose.PosTheme import net.taler.merchantpos.config.ConfigProduct import net.taler.merchantpos.order.Order +import net.taler.merchantpos.showPosError private const val QUICK_AMOUNT_ORDER_ID = -1 private const val QUICK_AMOUNT_PRODUCT_ID = "quick_amount" @@ -117,9 +116,9 @@ class AmountEntryFragment : Fragment() { override fun onStart() { super.onStart() if (!viewModel.configManager.config.isValid()) { - navigate(actionGlobalMerchantSettings()) + (requireActivity() as net.taler.merchantpos.MainActivity).navigateTo(PosDestination.Config) } else if (viewModel.configManager.currency == null) { - navigate(actionGlobalConfigFetcher()) + (requireActivity() as net.taler.merchantpos.MainActivity).navigateTo(PosDestination.ConfigFetcher) } } @@ -161,7 +160,7 @@ class AmountEntryFragment : Fragment() { private fun onChargePressed() { val configuredCurrency = viewModel.configManager.currency ?: run { - navigate(actionGlobalConfigFetcher()) + (requireActivity() as net.taler.merchantpos.MainActivity).navigateTo(PosDestination.ConfigFetcher) return } val enteredCurrency = selectedCurrency ?: configuredCurrency @@ -169,13 +168,11 @@ class AmountEntryFragment : Fragment() { ?: Amount.zero(enteredCurrency).withSpec(viewModel.configManager.currencySpec) if (enteredAmount.isZero()) { - Toast.makeText(requireContext(), R.string.amount_entry_error_zero, Toast.LENGTH_LONG) - .show() + requireActivity().showPosError(R.string.amount_entry_error_zero) return } if (enteredCurrency != configuredCurrency) { - Toast.makeText(requireContext(), R.string.amount_entry_error_wrong_currency, Toast.LENGTH_LONG) - .show() + requireActivity().showPosError(R.string.amount_entry_error_wrong_currency) return } @@ -191,11 +188,11 @@ class AmountEntryFragment : Fragment() { price = enteredAmount.withSpec(viewModel.configManager.currencySpec), categories = listOf(Int.MIN_VALUE), ) - order + product + val orderWithProduct = order + product // Backend doesn't require products; omit them for this "quick amount" flow. - paymentManager.createPayment(order, includeProducts = false) - navigate(actionAmountEntryToProcessPayment()) + paymentManager.createPayment(orderWithProduct, includeProducts = false) + (requireActivity() as net.taler.merchantpos.MainActivity).navigateTo(PosDestination.ProcessPayment) } } @@ -213,8 +210,13 @@ private fun AmountEntryScreen( onChargePressed: () -> Unit, ) { PosTheme { - val isTabletLayout = LocalConfiguration.current.smallestScreenWidthDp >= 600 - Box(modifier = Modifier.fillMaxSize()) { + val configuration = LocalConfiguration.current + val isTabletLayout = configuration.smallestScreenWidthDp >= 600 + Box( + modifier = Modifier + .fillMaxSize() + .statusBarsPadding(), + ) { if (!isTabletLayout) { Row( modifier = Modifier @@ -229,8 +231,8 @@ private fun AmountEntryScreen( isTabletLayout = false, onCurrencySelected = onCurrencySelected, modifier = Modifier - .weight(0.3f) - .padding(8.dp), + .weight(0.32f) + .padding(horizontal = 8.dp, vertical = 4.dp), ) KeypadPane( isTabletLayout = false, @@ -240,7 +242,7 @@ private fun AmountEntryScreen( onBackspacePressed = onBackspacePressed, onChargePressed = onChargePressed, modifier = Modifier - .weight(0.7f) + .weight(0.68f) .padding(4.dp), ) } @@ -360,7 +362,7 @@ private fun AmountPane( Column( modifier = modifier, - verticalArrangement = Arrangement.Center, + verticalArrangement = Arrangement.Top, horizontalAlignment = Alignment.CenterHorizontally, ) { Text( @@ -373,6 +375,7 @@ private fun AmountPane( Spacer(modifier = Modifier.height(12.dp)) dropdownComposable() + Spacer(modifier = Modifier.weight(1f)) } } } @@ -481,7 +484,10 @@ private fun KeypadPane( disabledContainerColor = colorResource(R.color.colorSecondary).copy(alpha = 0.12f), ), ) { - Text(stringResource(R.string.amount_entry_create_order_charge)) + Text( + text = stringResource(R.string.amount_entry_create_order_charge), + fontWeight = FontWeight.SemiBold, + ) } } } @@ -510,8 +516,14 @@ private fun KeyButton( maxLines = 1, softWrap = false, overflow = TextOverflow.Ellipsis, - style = fontSize?.let { MaterialTheme.typography.headlineMedium.copy(fontSize = it) } - ?: MaterialTheme.typography.headlineMedium, + 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/config/ConfigFetcherFragment.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/config/ConfigFetcherFragment.kt @@ -17,62 +17,89 @@ package net.taler.merchantpos.config 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.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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 androidx.navigation.fragment.findNavController -import com.google.android.material.snackbar.BaseTransientBottomBar.LENGTH_SHORT -import com.google.android.material.snackbar.Snackbar -import net.taler.lib.android.navigate import net.taler.merchantpos.MainViewModel -import net.taler.merchantpos.config.ConfigFetcherFragmentDirections.Companion.actionConfigFetcherToMerchantSettings -import net.taler.merchantpos.databinding.FragmentConfigFetcherBinding +import net.taler.merchantpos.MainActivity +import net.taler.merchantpos.PosDestination import net.taler.merchantpos.R -import net.taler.merchantpos.navigateToInitialOrderScreen +import net.taler.merchantpos.compose.PosTheme +import net.taler.merchantpos.showPosError class ConfigFetcherFragment : Fragment() { private val model: MainViewModel by activityViewModels() private val configManager by lazy { model.configManager } - private lateinit var ui: FragmentConfigFetcherBinding - - private var navigating: Boolean = false + private var navigating = false override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, + inflater: android.view.LayoutInflater, + container: android.view.ViewGroup?, savedInstanceState: Bundle?, - ): View { - ui = FragmentConfigFetcherBinding.inflate(inflater) - return ui.root + ) = ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + ConfigFetcherScreen() + } } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + override fun onViewCreated(view: android.view.View, savedInstanceState: Bundle?) { if (configManager.config.savePassword()) { configManager.fetchConfig(configManager.config, false) } else if (!navigating) { navigating = true - navigate(actionConfigFetcherToMerchantSettings()) + (requireActivity() as MainActivity).navigateTo(PosDestination.Config, clearBackStack = true) } configManager.configUpdateResult.observe(viewLifecycleOwner) { result -> when (result) { null -> return@observe - is ConfigUpdateResult.Error -> onNetworkError(result.msg) + is ConfigUpdateResult.Error -> { + requireActivity().showPosError(result.msg) + } + is ConfigUpdateResult.Success -> { if (!navigating) { navigating = true - findNavController().navigateToInitialOrderScreen(configManager) + (requireActivity() as MainActivity).navigateToInitialOrderScreen() } } } } } +} - private fun onNetworkError(msg: String) { - Snackbar.make(requireView(), msg, LENGTH_SHORT).show() +@Composable +private fun ConfigFetcherScreen() { + PosTheme { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top, + ) { + CircularProgressIndicator() + Text( + text = stringResource(R.string.config_fetching), + modifier = Modifier.padding(top = 16.dp), + style = MaterialTheme.typography.titleMedium, + ) + } } - } 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 @@ -22,14 +22,7 @@ import android.content.pm.PackageManager import android.media.Image import android.os.Bundle import android.util.Log -import android.view.LayoutInflater -import android.view.View -import android.view.View.GONE -import android.view.View.INVISIBLE -import android.view.View.VISIBLE -import android.view.ViewGroup import android.widget.Toast -import androidx.core.content.res.use import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.OptIn import androidx.camera.core.CameraSelector @@ -37,230 +30,224 @@ import androidx.camera.core.ExperimentalGetImage import androidx.camera.core.ImageAnalysis import androidx.camera.core.Preview import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +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.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.Alignment +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.platform.ComposeView +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.stringResource +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.core.content.ContextCompat import androidx.core.net.toUri import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope -import androidx.navigation.fragment.findNavController -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.snackbar.BaseTransientBottomBar.LENGTH_LONG -import com.google.android.material.snackbar.Snackbar +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.common.HybridBinarizer import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import net.taler.merchantpos.MainViewModel -import net.taler.merchantpos.R -import net.taler.merchantpos.databinding.FragmentMerchantConfigBinding -import net.taler.merchantpos.navigateToInitialOrderScreen -import androidx.core.view.isVisible -import com.google.android.material.button.MaterialButtonToggleGroup -import com.google.zxing.* -import net.taler.merchantpos.MainActivity -import com.google.zxing.common.HybridBinarizer import net.taler.common.TokenDuration import net.taler.lib.android.ChallengeCancelledException import net.taler.lib.android.handleChallengeResponse +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 + +private enum class ConfigMode { Manual, Qr } -/** - * Fragment that displays merchant settings, either by scanning a QR code - * or by manual token entry. - */ class ConfigFragment : Fragment() { private val model: MainViewModel by activityViewModels() private val configManager by lazy { model.configManager } - private lateinit var ui: FragmentMerchantConfigBinding 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 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" - )) + setHints( + mapOf( + DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE), + DecodeHintType.CHARACTER_SET to "UTF-8", + ), + ) } override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, + inflater: android.view.LayoutInflater, + container: android.view.ViewGroup?, savedInstanceState: Bundle?, - ): View { - ui = FragmentMerchantConfigBinding.inflate(inflater, container, false) - return ui.root + ) = ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + initializeState(savedInstanceState == null) + setContent { + ConfigScreen( + mode = mode, + merchantUrl = merchantUrlText, + username = usernameText, + token = tokenText, + saveToken = saveToken, + isSubmitting = isSubmitting, + isQrLoading = isQrLoading, + onModeChanged = { + mode = it + if (it == ConfigMode.Qr) requestCameraIfNeeded() else stopCamera() + }, + 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() + } + } + }, + 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: View, savedInstanceState: Bundle?) { + override fun onViewCreated(view: android.view.View, savedInstanceState: Bundle?) { configManager.configUpdateResult.observe(viewLifecycleOwner) { result -> onConfigUpdate(result) } - - // 1) Views - // set initial toggle - ui.configToggle.check(R.id.newConfigButton) - - // wire up toggle group for QR vs manual - ui.configToggle.addOnButtonCheckedListener { _: MaterialButtonToggleGroup, checkedId: Int, isChecked: Boolean -> - if (!isChecked) return@addOnButtonCheckedListener - - when (checkedId) { - R.id.qrConfigButton -> showQrConfig() - R.id.newConfigButton -> showManualConfig() - } - } - - // 1) Extract base URL and username if pasted with /instances/username - // Only parse URL when user finishes editing (focus lost) - ui.merchantUrlView.editText!!.setOnFocusChangeListener { v, hasFocus -> - if (!hasFocus) { - sanitizeMerchantUrlAndUpdateFields() - } - } - - // manual configuration OK button - ui.okNewButton.setOnClickListener { - // launch coroutine to fetch limited token before config update - lifecycleScope.launch { - // prepare UI - ui.progressBarNew.visibility = VISIBLE - ui.okNewButton.visibility = INVISIBLE - - // normalize URL - val url = sanitizeMerchantUrlAndUpdateFields() - - // retrieve username (may have been set by listener) - val username = ui.usernameView.editText!!.text.toString().trim() - // initial secret/token from user - val initialSecret = ui.tokenView.editText!!.text.toString().trim() - - val duration = TokenDuration.Forever - - // fetch limited write token (with optional 2FA) - val limitedToken = try { - fetchLimitedAccessTokenWithMfa(url, username, initialSecret, duration) - } catch (e: ChallengeCancelledException) { - ui.progressBarNew.visibility = INVISIBLE - ui.okNewButton.visibility = VISIBLE - return@launch - } catch (e: Exception) { - ui.progressBarNew.visibility = INVISIBLE - ui.okNewButton.visibility = VISIBLE - Log.e("ConfigFragment", "Error fetching limited token: ${e.message}") - Snackbar.make(requireView(), getString(R.string.config_error_network), LENGTH_LONG).show() - return@launch - } - - val configUrl = "$url/instances/$username" - - // proceed with normal config fetch using limited token - val config = Config.New( - merchantUrl = configUrl, - accessToken = limitedToken, - savePassword = ui.saveTokenCheckBox.isChecked - ) - awaitingConfigUpdate = true - configManager.fetchConfig(config, true) - } - } - - updateView(savedInstanceState == null) - } - - override fun onStart() { - super.onStart() - } override fun onResume() { super.onResume() - // if QR form is showing, re-request camera - if (ui.qrConfigForm.isVisible) { + if (mode == ConfigMode.Qr) { requestCameraIfNeeded() } } override fun onDestroyView() { - // ensure camera is released stopCamera() + previewView = null super.onDestroyView() } - private fun showQrConfig() { - Log.d("ConfigFragment", "showQrConfig() → requesting camera") - ui.qrConfigForm.visibility = VISIBLE - ui.newConfigForm.visibility = GONE - requestCameraIfNeeded() - } - - private fun showManualConfig() { - ui.qrConfigForm.visibility = GONE - ui.newConfigForm.visibility = VISIBLE - stopCamera() - } - - private fun updateView(isInitialization: Boolean = false) { + private fun initializeState(isInitialization: Boolean) { val cfg = configManager.config if (isInitialization) { - ui.merchantUrlView.editText!!.setText(NEW_CONFIG_URL_DEMO) - - if (cfg is Config.New) { - if (cfg.merchantUrl.isNotBlank()) { - ui.merchantUrlView.editText!!.setText(cfg.merchantUrl) - sanitizeMerchantUrlAndUpdateFields() - } - ui.saveTokenCheckBox.isChecked = cfg.savePassword + merchantUrlText = NEW_CONFIG_URL_DEMO + saveToken = cfg.savePassword() + if (cfg is Config.New && cfg.merchantUrl.isNotBlank()) { + merchantUrlText = cfg.merchantUrl + sanitizeMerchantUrlAndUpdateFields() } } + } - ui.forgetTokenButton.visibility = GONE - - when (cfg) { - is Config.New -> { - ui.configToggle.check(R.id.newConfigButton) - showManualConfig() + 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}") + requireActivity().showPosError(R.string.config_error_network) + return@launch } - } - } - private fun onConfigUpdate(result: ConfigUpdateResult?) { - if (!awaitingConfigUpdate) return - when (result) { - null -> Unit - is ConfigUpdateResult.Error -> { - awaitingConfigUpdate = false - onError(result.msg) - } - is ConfigUpdateResult.Success -> { - awaitingConfigUpdate = false - onConfigReceived(result.currency) - } + val configUrl = "$baseUrl/instances/$username" + val config = Config.New( + merchantUrl = configUrl, + accessToken = limitedToken, + savePassword = saveToken, + ) + awaitingConfigUpdate = true + configManager.fetchConfig(config, true) } } - private fun onConfigReceived(currency: String) { - onResultReceived() - updateView() - Snackbar.make(requireView(), getString(R.string.config_changed, currency), LENGTH_LONG).show() - findNavController().navigateToInitialOrderScreen(configManager) - } - - private fun onError(msg: String) { - onResultReceived() - Snackbar.make(requireView(), msg, LENGTH_LONG).show() - configManager.configUpdateResult.removeObservers(viewLifecycleOwner) - } - - private fun onResultReceived() { - ui.progressBarNew.visibility = INVISIBLE - ui.okNewButton.visibility = VISIBLE - } - private fun sanitizeMerchantUrlAndUpdateFields(): String { - val rawInput = ui.merchantUrlView.editText!!.text.toString().trim() + val rawInput = merchantUrlText.trim() if (rawInput.isEmpty()) return "" val normalizedInput = if (rawInput.startsWith("http://") || rawInput.startsWith("https://")) { @@ -273,21 +260,42 @@ class ConfigFragment : Fragment() { 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)) { - ui.usernameView.editText!!.setText(segments[1]) + usernameText = segments[1] } - - ui.merchantUrlView.editText!!.setText(baseHost) + merchantUrlText = baseHost 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 + 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() + (requireActivity() as MainActivity).navigateToInitialOrderScreen() + } + } + } + private suspend fun fetchLimitedAccessTokenWithMfa( baseUrl: String, username: String, initialSecret: String, - duration: TokenDuration + duration: TokenDuration, ): String { var challengeIds: List<String> = emptyList() while (true) { @@ -298,7 +306,7 @@ class ConfigFragment : Fragment() { username, initialSecret, duration, - challengeIds + challengeIds, ) } } catch (e: ChallengeRequiredException) { @@ -314,81 +322,71 @@ class ConfigFragment : Fragment() { withContext(Dispatchers.IO) { configManager.confirmChallenge(baseUrl, username, challengeId, tan) } - } + }, ) - if (solvedIds.isEmpty()) { - throw ChallengeCancelledException() - } + if (solvedIds.isEmpty()) throw ChallengeCancelledException() challengeIds = solvedIds } } } - // ─── CameraX integration ─────────────────────────────────────────── - - // 1) permission launcher private val requestCameraPerm = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> - Log.d("ConfigFragment", "CAMERA permission granted? $granted") if (granted) startCamera() - else Toast.makeText(requireContext(), - R.string.config_fragment_camera_needed_text, Toast.LENGTH_SHORT).show() + else Toast.makeText( + requireContext(), + R.string.config_fragment_camera_needed_text, + Toast.LENGTH_SHORT, + ).show() } - // 2) request if needed private fun requestCameraIfNeeded() { - if (ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.CAMERA) - == PackageManager.PERMISSION_GRANTED) { + if (ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.CAMERA) == + PackageManager.PERMISSION_GRANTED + ) { startCamera() } else { requestCameraPerm.launch(Manifest.permission.CAMERA) } } - // 3) start CameraX preview @OptIn(ExperimentalGetImage::class) private fun startCamera() { - Log.d("ConfigFragment", "startCamera() called") + val previewTarget = previewView ?: return val providerFuture = ProcessCameraProvider.getInstance(requireContext()) providerFuture.addListener({ val provider = providerFuture.get() - val preview = Preview.Builder().build().also { - it.setSurfaceProvider(ui.previewView.surfaceProvider) + 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) { - // 1) Convert YUV_420_888 to a ZXing-friendly NV21 byte array val nv21 = yuv420888ToNv21(mediaImage) - val width = mediaImage.width + val width = mediaImage.width val height = mediaImage.height - - // 2) Build ZXing’s LuminanceSource val source = PlanarYUVLuminanceSource( - nv21, width, height, - 0, 0, width, height, - false + nv21, + width, + height, + 0, + 0, + width, + height, + false, ) - - //Rotate the image val rotated = when (proxy.imageInfo.rotationDegrees) { - 90 -> source.rotateCounterClockwise() - 270 -> source.rotateCounterClockwise() + 90, 270 -> source.rotateCounterClockwise() else -> source } - - // 3) Try to decode val bitmap = BinaryBitmap(HybridBinarizer(rotated)) try { val result = qrReader.decodeWithState(bitmap) onQrDecoded(result.text) - } catch (e: NotFoundException) { - // no QR code in this frame + } catch (_: NotFoundException) { } finally { proxy.close() } @@ -397,13 +395,12 @@ class ConfigFragment : Fragment() { } } } - provider.unbindAll() provider.bindToLifecycle( viewLifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, - analysis + analysis, ) }, cameraExecutor) } @@ -412,39 +409,321 @@ class ConfigFragment : Fragment() { 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) - - // U and V are swapped yPlane.get(nv21, 0, ySize) vPlane.get(nv21, ySize, vSize) uPlane.get(nv21, ySize + vSize, uSize) - return nv21 } - private fun onQrDecoded(raw: String) { - if (!raw.startsWith("taler-pos://")) return // guard - - stopCamera() // freeze picture - // Re-use the rock-solid parsing inside MainActivity - val intent = Intent(Intent.ACTION_VIEW, raw.toUri()) - (requireActivity() as MainActivity).handleSetupIntent(intent) - - // show loader until ConfigFetcherFragment takes over - ui.progressBarQr.visibility = VISIBLE - ui.previewView.visibility = View.INVISIBLE - } - - // 4) release camera + private fun onQrDecoded(raw: String) { + if (!raw.startsWith("taler-pos://")) return + stopCamera() + isQrLoading = true + val intent = Intent(Intent.ACTION_VIEW, raw.toUri()) + (requireActivity() as MainActivity).handleSetupIntent(intent) + } + private fun stopCamera() { try { - ProcessCameraProvider.getInstance(requireContext()) - .get() - .unbindAll() - } catch (_: Exception) { /* no-op */ } + ProcessCameraProvider.getInstance(requireContext()).get().unbindAll() + } catch (_: Exception) { + } + } +} + +@Composable +private fun ConfigScreen( + mode: ConfigMode, + merchantUrl: String, + username: String, + token: String, + saveToken: Boolean, + isSubmitting: Boolean, + isQrLoading: Boolean, + onModeChanged: (ConfigMode) -> Unit, + onMerchantUrlChanged: (String) -> Unit, + onUsernameChanged: (String) -> Unit, + onTokenChanged: (String) -> Unit, + onSaveTokenChanged: (Boolean) -> Unit, + previewContent: @Composable () -> Unit, + onConnect: () -> Unit, +) { + PosTheme { + val isTabletLayout = LocalConfiguration.current.smallestScreenWidthDp >= 720 + val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current + val formListState = rememberLazyListState() + Box( + modifier = Modifier + .fillMaxSize() + .statusBarsPadding(), + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + RowButtons( + mode = mode, + onManual = { onModeChanged(ConfigMode.Manual) }, + onQr = { onModeChanged(ConfigMode.Qr) }, + ) + if (mode == ConfigMode.Manual) { + ManualConfigScreen( + modifier = Modifier + .fillMaxWidth() + .weight(1f, fill = false), + merchantUrl = merchantUrl, + username = username, + token = token, + saveToken = saveToken, + isSubmitting = isSubmitting, + formListState = formListState, + onMerchantUrlChanged = onMerchantUrlChanged, + onUsernameChanged = onUsernameChanged, + onTokenChanged = onTokenChanged, + onSaveTokenChanged = onSaveTokenChanged, + onConnect = onConnect, + focusManager = focusManager, + keyboardController = keyboardController, + ) + } else { + if (isTabletLayout) { + TabletQrConfigScreen( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + isQrLoading = isQrLoading, + previewContent = previewContent, + ) + } else { + PhoneQrConfigScreen( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + isQrLoading = isQrLoading, + previewContent = previewContent, + ) + } + } + } + + if (isSubmitting) { + Dialog(onDismissRequest = {}) { + Surface( + shape = MaterialTheme.shapes.large, + tonalElevation = 6.dp, + ) { + Box( + modifier = Modifier.padding(24.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + } + } + } + } +} + +@Composable +private fun ManualConfigScreen( + modifier: Modifier, + merchantUrl: String, + username: String, + token: String, + saveToken: Boolean, + isSubmitting: Boolean, + formListState: androidx.compose.foundation.lazy.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?, +) { + LazyColumn( + modifier = modifier, + state = formListState, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + item { + OutlinedTextField( + value = merchantUrl, + 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) }, + ), + ) + } + item { + OutlinedTextField( + value = username, + onValueChange = onUsernameChanged, + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.config_username)) }, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Next, + ), + keyboardActions = KeyboardActions( + onNext = { focusManager.moveFocus(FocusDirection.Down) }, + ), + ) + } + item { + OutlinedTextField( + value = token, + onValueChange = onTokenChanged, + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.config_password)) }, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions( + onDone = { + focusManager.clearFocus() + keyboardController?.hide() + }, + ), + ) + } + item { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = saveToken, + onCheckedChange = { onSaveTokenChanged(it) }, + ) + Text(stringResource(R.string.config_save_password)) + } + Button( + onClick = onConnect, + enabled = !isSubmitting, + ) { + Text(stringResource(R.string.config_ok)) + } + } + } + } +} + +@Composable +private fun TabletQrConfigScreen( + modifier: Modifier, + isQrLoading: Boolean, + previewContent: @Composable () -> Unit, +) { + BoxWithConstraints( + modifier = modifier, + contentAlignment = Alignment.Center, + ) { + val previewSize = minOf(maxWidth * 0.7f, maxHeight * 0.7f) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Box( + modifier = Modifier + .size(previewSize) + .clipToBounds(), + ) { + previewContent() + } + Text(stringResource(R.string.scan_qr_hint)) + if (isQrLoading) { + CircularProgressIndicator() + } + } + } +} + +@Composable +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), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Box( + modifier = Modifier + .size(previewSize) + .clipToBounds(), + ) { + previewContent() + } + Text(stringResource(R.string.scan_qr_hint)) + if (isQrLoading) { + CircularProgressIndicator() + } + } + } +} + +@Composable +private fun RowButtons( + mode: ConfigMode, + onManual: () -> Unit, + onQr: () -> Unit, +) { + SingleChoiceSegmentedButtonRow( + modifier = Modifier.fillMaxWidth(), + ) { + SegmentedButton( + selected = mode == ConfigMode.Manual, + onClick = onManual, + icon = {}, + shape = SegmentedButtonDefaults.itemShape( + index = 0, + count = 2, + ), + modifier = Modifier.weight(1f), + ) { + Text(stringResource(R.string.config_manual_label)) + } + SegmentedButton( + selected = mode == ConfigMode.Qr, + onClick = onQr, + icon = {}, + shape = SegmentedButtonDefaults.itemShape( + index = 1, + count = 2, + ), + modifier = Modifier.weight(1f), + ) { + Text(stringResource(R.string.config_qr_label)) + } } } 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 @@ -44,12 +44,14 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.serialization.KSerializer +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 @@ -92,10 +94,15 @@ internal const val OLD_CONFIG_PASSWORD_DEMO = "" private const val SETTINGS_MERCHANT_URL = "merchantUrl" private const val SETTINGS_ACCESS_TOKEN = "accessToken" private const val SETTINGS_INITIAL_ORDER_SCREEN = "initialOrderScreen" +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 TAG = ConfigManager::class.java.simpleName @@ -117,6 +124,14 @@ private data class MerchantBackendConfigResponse( val currencies: Map<String, CurrencySpecification> = emptyMap(), ) +@Serializable +private data class CachedRuntimeConfig( + val posConfig: PosConfig, + val merchantConfig: MerchantConfig, + val currency: String, + val currencySpec: CurrencySpecification? = null, +) + /* -- Limited access token -- */ @kotlinx.serialization.Serializable private data class LimitedTokenResponse( @@ -162,9 +177,10 @@ class ConfigManager( private val prefs = context.getSharedPreferences(SETTINGS_NAME, MODE_PRIVATE) private val configurationReceivers = ArrayList<ConfigurationReceiver>() private var inventoryRefreshJob: Job? = null + private var cachedRuntimeConfig: CachedRuntimeConfig? = null init { - migrateLegacyPrefsIfNeeded(); + migrateLegacyPrefsIfNeeded() } @Volatile @@ -175,6 +191,10 @@ class ConfigManager( savePassword = prefs.getBoolean(SETTINGS_SAVE_PASSWORD, true), ) + init { + restoreCachedRuntimeConfig() + } + @Volatile var merchantConfig: MerchantConfig? = null private set @@ -187,14 +207,20 @@ class ConfigManager( var currencySpec: CurrencySpecification? = null private set - var initialOrderScreen: InitialOrderScreen - get() = InitialOrderScreen.fromPrefValue( + 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() .putString(SETTINGS_INITIAL_ORDER_SCREEN, value.prefValue) .apply() + mInitialOrderScreen.value = value } private val mConfigUpdateResult = MutableLiveData<ConfigUpdateResult?>() @@ -202,6 +228,11 @@ class ConfigManager( fun addConfigurationReceiver(receiver: ConfigurationReceiver) { configurationReceivers.add(receiver) + cachedRuntimeConfig?.let { cached -> + scope.launch { + receiver.onConfigurationReceived(cached.posConfig, cached.currency, cached.currencySpec) + } + } } private fun migrateLegacyPrefsIfNeeded() { @@ -217,6 +248,12 @@ class ConfigManager( } @UiThread + fun refreshConfigInBackground() { + if (!config.isValid() || !config.hasPassword()) return + fetchConfig(config, save = false, inventoryOnly = false, silent = true) + } + + @UiThread fun refreshInventory() { inventoryRefreshJob?.cancel() inventoryRefreshJob = scope.launch { @@ -338,6 +375,14 @@ class ConfigManager( this@ConfigManager.merchantConfig = merchantConfig this@ConfigManager.currency = configResponse.currency this@ConfigManager.currencySpec = currencySpec + saveCachedRuntimeConfig( + CachedRuntimeConfig( + posConfig = posConfig, + merchantConfig = merchantConfig, + currency = configResponse.currency, + currencySpec = currencySpec, + ) + ) if (!silent) { mConfigUpdateResult.value = ConfigUpdateResult.Success(configResponse.currency) } @@ -434,6 +479,7 @@ class ConfigManager( is Config.New -> c.copy(accessToken = "") } saveConfig(config) + clearCachedRuntimeConfig() merchantConfig = null currency = null currencySpec = null @@ -449,6 +495,7 @@ class ConfigManager( savePassword = savePassword, ) saveConfig(config) + clearCachedRuntimeConfig() merchantConfig = null currency = null currencySpec = null @@ -478,6 +525,37 @@ class ConfigManager( mConfigUpdateResult.value = ConfigUpdateResult.Error(msg) } + 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 + } + cachedRuntimeConfig = restored + merchantConfig = restored.merchantConfig + currency = restored.currency + currencySpec = restored.currencySpec + } + + private fun saveCachedRuntimeConfig(snapshot: CachedRuntimeConfig) { + cachedRuntimeConfig = snapshot + prefs.edit() + .putString(SETTINGS_CACHED_RUNTIME_CONFIG, json.encodeToString(CachedRuntimeConfig.serializer(), snapshot)) + .apply() + } + + 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) 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 @@ -28,9 +28,9 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuDefaults @@ -53,8 +53,6 @@ import androidx.compose.runtime.setValue import androidx.core.os.LocaleListCompat import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels -import androidx.navigation.NavOptions -import androidx.navigation.fragment.findNavController import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Shape import androidx.compose.ui.platform.ComposeView @@ -66,6 +64,7 @@ import androidx.compose.ui.unit.dp import net.taler.merchantpos.compose.PosOutlinedCard import net.taler.merchantpos.compose.PosTheme import net.taler.merchantpos.R +import net.taler.merchantpos.PosDestination import java.util.Locale private data class LanguageOption( @@ -104,17 +103,13 @@ class GeneralSettingsFragment : Fragment() { initialSelectedOrderScreen = configManager.initialOrderScreen, onInitialOrderSelected = { configManager.initialOrderScreen = it }, onInstanceSettingsClick = { - findNavController().navigate(R.id.nav_instanceSettings) + (requireActivity() as net.taler.merchantpos.MainActivity).navigateTo(PosDestination.Config) }, onLogoutClick = { configManager.logout() - findNavController().navigate( - R.id.nav_instanceSettings, - null, - NavOptions.Builder() - .setLaunchSingleTop(true) - .setPopUpTo(R.id.nav_graph, true) - .build(), + (requireActivity() as net.taler.merchantpos.MainActivity).navigateTo( + PosDestination.Config, + clearBackStack = true, ) }, ) @@ -192,78 +187,82 @@ private fun GeneralSettingsScreen( ?: initialOrderOptions.firstOrNull()?.label.orEmpty() PosTheme { - Column( + LazyColumn( modifier = Modifier .fillMaxSize() - .verticalScroll(rememberScrollState()) + .statusBarsPadding() .padding(20.dp), verticalArrangement = Arrangement.spacedBy(14.dp), ) { - 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) - }, - ) - } - - 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, - ), + 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 = onLogoutClick, - shape = SettingsControlShape, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.errorContainer, - contentColor = MaterialTheme.colorScheme.onErrorContainer, - ), + } + + item { + SettingsCard( + title = stringResource(R.string.settings_instance_title), + description = stringResource(R.string.settings_instance_description), ) { - Text( - text = stringResource(R.string.settings_logout_button), - ) + Button( + modifier = Modifier.fillMaxWidth(), + onClick = onInstanceSettingsClick, + shape = SettingsControlShape, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + ) { + Icon( + painter = painterResource(R.drawable.ic_menu_manage), + contentDescription = null, + ) + Spacer(Modifier.width(8.dp)) + Text( + text = stringResource(R.string.settings_instance_button), + ) + } + Button( + modifier = Modifier.fillMaxWidth(), + onClick = onLogoutClick, + shape = SettingsControlShape, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + ), + ) { + Text( + text = stringResource(R.string.settings_logout_button), + ) + } } } } 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 @@ -114,15 +114,15 @@ data class ConfigProduct( val totalPrice: Amount get() = (price * quantity).withSpec(price.spec) private val normalizedProductName: String? - get() = productName?.trim()?.takeIf { it.isNotEmpty() } + get() = productName?.sanitizeVisibleText()?.takeIf { it.isNotEmpty() } private val normalizedDescription: String - get() = localizedDescription.trim() + get() = localizedDescription.sanitizeVisibleText() val displayName: String get() = normalizedProductName ?: normalizedDescription val displayDescription: String? get() = normalizedProductName ?.takeIf { it != normalizedDescription } - ?.let { normalizedDescription } + ?.let { normalizedDescription.takeIf(String::isNotEmpty) } val displayPrice: String get() = price.toString() val stableKey: String @@ -157,7 +157,17 @@ data class ConfigProduct( taxes = taxes?.takeIf { it.isNotEmpty() }, quantity = quantity ) +} - override fun equals(other: Any?) = other is ConfigProduct && id == other.id - override fun hashCode() = id.hashCode() +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 + } + }.trim() } 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,81 +17,103 @@ package net.taler.merchantpos.history import android.os.Bundle -import android.util.Log -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.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.statusBarsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Button +import androidx.compose.material3.Card +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.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +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.recyclerview.widget.DividerItemDecoration -import androidx.recyclerview.widget.DividerItemDecoration.VERTICAL -import androidx.recyclerview.widget.LinearLayoutManager -import net.taler.lib.android.exhaustive -import net.taler.lib.android.navigate -import net.taler.lib.android.showError +import androidx.compose.runtime.livedata.observeAsState +import kotlinx.coroutines.flow.collectLatest +import net.taler.lib.android.toRelativeTime +import net.taler.merchantpos.MainActivity import net.taler.merchantlib.OrderHistoryEntry import net.taler.merchantpos.MainViewModel +import net.taler.merchantpos.PosDestination import net.taler.merchantpos.R -import net.taler.merchantpos.databinding.FragmentMerchantHistoryBinding -import net.taler.merchantpos.history.HistoryFragmentDirections.Companion.actionGlobalMerchantSettings -import net.taler.merchantpos.history.HistoryFragmentDirections.Companion.actionNavHistoryToRefundFragment +import net.taler.merchantpos.compose.PosTheme +import net.taler.merchantpos.payment.Payment +import net.taler.merchantpos.showPosError internal interface HistoryActionListener { fun onRefundClicked(item: OrderHistoryEntry) fun onDeleteClicked(item: OrderHistoryEntry) + fun onShowPaymentClicked(item: OrderHistoryEntry) + fun onShowRefundClicked(item: OrderHistoryEntry) } -/** - * Fragment to display the merchant's payment history, received from the backend. - */ class HistoryFragment : Fragment(), HistoryActionListener { - companion object { - const val TAG = "taler-merchant" - } - private val model: MainViewModel by activityViewModels() private val historyManager by lazy { model.historyManager } private val refundManager by lazy { model.refundManager } - private lateinit var ui: FragmentMerchantHistoryBinding - private val historyListAdapter = HistoryItemAdapter(this) - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - ui = FragmentMerchantHistoryBinding.inflate(inflater, container, false) - return ui.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - ui.listHistory.apply { - layoutManager = LinearLayoutManager(requireContext()) - addItemDecoration(DividerItemDecoration(context, VERTICAL)) - adapter = historyListAdapter + inflater: android.view.LayoutInflater, + container: android.view.ViewGroup?, + savedInstanceState: Bundle?, + ) = ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + val isLoading by historyManager.isLoading.observeAsState(false) + val isLoadingMore by historyManager.isLoadingMore.observeAsState(false) + val result by historyManager.items.observeAsState() + val pendingRefundOrderId by refundManager.pendingRefundOrderId.observeAsState() + val activePayment by model.paymentManager.payment.observeAsState() + HistoryScreen( + isLoading = isLoading, + isLoadingMore = isLoadingMore, + result = result, + pendingRefundOrderId = pendingRefundOrderId, + activePayment = activePayment, + onRefresh = { historyManager.fetchHistory() }, + onLoadMore = historyManager::loadMoreHistoryIfNeeded, + onRefundClicked = ::onRefundClicked, + onDeleteClicked = ::onDeleteClicked, + onShowPaymentClicked = ::onShowPaymentClicked, + onShowRefundClicked = ::onShowRefundClicked, + ) } + } - ui.swipeRefresh.setOnRefreshListener { - Log.v(TAG, "refreshing!") - historyManager.fetchHistory() + override fun onViewCreated(view: android.view.View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + historyManager.error.observe(viewLifecycleOwner) { error -> + if (error != null) { + requireActivity().showPosError(error.mainResId, error.msg) + historyManager.clearError() + } } - historyManager.isLoading.observe(viewLifecycleOwner, { loading -> - Log.v(TAG, "setting refreshing to $loading") - ui.swipeRefresh.isRefreshing = loading - }) - historyManager.items.observe(viewLifecycleOwner, { result -> - when (result) { - is HistoryResult.Error -> requireActivity().showError(result.mainResId, result.msg) - is HistoryResult.Success -> historyListAdapter.setData(result.items) - }.exhaustive - }) } override fun onStart() { super.onStart() if (model.configManager.merchantConfig?.baseUrl == null) { - navigate(actionGlobalMerchantSettings()) + (requireActivity() as MainActivity).navigateTo(PosDestination.Config) } else { historyManager.fetchHistory() } @@ -99,11 +121,248 @@ class HistoryFragment : Fragment(), HistoryActionListener { override fun onRefundClicked(item: OrderHistoryEntry) { refundManager.startRefund(item) - navigate(actionNavHistoryToRefundFragment()) + (requireActivity() as MainActivity).navigateTo(PosDestination.Refund) } override fun onDeleteClicked(item: OrderHistoryEntry) { historyManager.deleteOrder(item.orderId) } + override fun onShowPaymentClicked(item: OrderHistoryEntry) { + model.paymentManager.resumePayment(item) + (requireActivity() as MainActivity).navigateTo(PosDestination.ProcessPayment) + } + + override fun onShowRefundClicked(item: OrderHistoryEntry) { + if (refundManager.resumeRefund(item)) { + (requireActivity() as MainActivity).navigateTo(PosDestination.RefundUri) + } else { + requireActivity().showPosError(R.string.refund_state_missing) + historyManager.fetchHistory() + } + } +} + +@Composable +private fun HistoryScreen( + isLoading: Boolean, + isLoadingMore: Boolean, + result: HistoryResult?, + pendingRefundOrderId: String?, + activePayment: Payment?, + onRefresh: () -> 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) { + listState.scrollToItem(0) + } + } + + LaunchedEffect(listState, historyItemKeys.size) { + snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index } + .collectLatest { index -> + if (index != null) onLoadMore(index) + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .statusBarsPadding() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + 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 -> + HistoryItemCard( + item = item, + hasPendingRefund = pendingRefundOrderId == item.orderId, + activePayment = activePayment?.takeIf { it.orderId == item.orderId }, + onRefundClicked = { onRefundClicked(item) }, + onDeleteClicked = { onDeleteClicked(item) }, + onShowPaymentClicked = { onShowPaymentClicked(item) }, + onShowRefundClicked = { onShowRefundClicked(item) }, + ) + } + if (isLoadingMore) { + item(key = "loading-more") { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + ) { + CircularProgressIndicator( + modifier = Modifier.align(androidx.compose.ui.Alignment.Center), + ) + } + } + } + } + } + + null -> Unit + } + } + } +} + +@Composable +private fun HistoryItemCard( + item: OrderHistoryEntry, + 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 + } + + Card { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = item.summary, + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = item.amount.toString(), + style = MaterialTheme.typography.bodyLarge, + ) + } + Column( + horizontalAlignment = androidx.compose.ui.Alignment.End, + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + HistoryStatusBadge(status = status) + if (item.hasRefund && item.refundAmount != null) { + HistoryAmountBadge( + amount = item.refundAmount.toString(), + status = status, + ) + } + } + } + Text( + item.timestamp.ms.toRelativeTime(androidx.compose.ui.platform.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) { + Button(onClick = onShowRefundClicked) { + Text(stringResource(R.string.history_show_refund)) + } + } else if (item.refundable) { + Button(onClick = onRefundClicked) { + Text(stringResource(R.string.history_refund)) + } + } else if (!item.paid) { + Button(onClick = onShowPaymentClicked) { + Text(stringResource(R.string.history_show_payment)) + } + OutlinedButton(onClick = onDeleteClicked) { + Text(stringResource(R.string.order_delete)) + } + } + } + } + } +} + +private enum class HistoryStatus( + val labelResId: Int, +) { + Unpaid(R.string.history_status_unpaid), + Paid(R.string.history_status_paid), + PaymentPending(R.string.history_status_payment_pending), + PaymentClaimed(R.string.history_status_payment_claimed), + RefundPending(R.string.history_status_refund_pending), + Refunded(R.string.history_status_refunded), +} + +@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, + 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 + } + Surface( + color = colors.first, + contentColor = colors.second, + shape = MaterialTheme.shapes.small, + ) { + Text( + text = amount, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), + style = MaterialTheme.typography.labelLarge, + ) + } } diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/history/HistoryItemAdapter.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/history/HistoryItemAdapter.kt @@ -1,100 +0,0 @@ -/* - * This file is part of GNU Taler - * (C) 2020 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.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.ImageButton -import android.widget.TextView -import androidx.core.content.ContextCompat.getColor -import androidx.recyclerview.widget.RecyclerView -import androidx.recyclerview.widget.RecyclerView.Adapter -import com.google.android.material.button.MaterialButton -import net.taler.merchantlib.OrderHistoryEntry -import net.taler.merchantpos.R -import net.taler.merchantpos.history.HistoryItemAdapter.HistoryItemViewHolder -import net.taler.lib.android.toRelativeTime - - -internal class HistoryItemAdapter(private val listener: HistoryActionListener) : - Adapter<HistoryItemViewHolder>() { - - private val items = ArrayList<OrderHistoryEntry>() - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HistoryItemViewHolder { - val v = - LayoutInflater.from(parent.context).inflate(R.layout.list_item_history, parent, false) - return HistoryItemViewHolder(v) - } - - override fun getItemCount() = items.size - - override fun onBindViewHolder(holder: HistoryItemViewHolder, position: Int) { - holder.bind(items[position]) - } - - fun setData(items: List<OrderHistoryEntry>) { - this.items.clear() - this.items.addAll(items) - this.notifyDataSetChanged() - } - - internal inner class HistoryItemViewHolder(private val v: View) : RecyclerView.ViewHolder(v) { - - private val orderSummaryView: TextView = v.findViewById(R.id.orderSummaryView) - private val orderAmountView: TextView = v.findViewById(R.id.orderAmountView) - private val orderTimeView: TextView = v.findViewById(R.id.orderTimeView) - private val orderIdView: TextView = v.findViewById(R.id.orderIdView) - private val refundButton: ImageButton = v.findViewById(R.id.refundButton) - private val deleteButton: MaterialButton = v.findViewById(R.id.deleteButton) - - private val orderIdColor = orderIdView.currentTextColor - - fun bind(item: OrderHistoryEntry) { - orderSummaryView.text = item.summary - val amount = item.amount - orderAmountView.text = amount.toString() - orderTimeView.text = item.timestamp.ms.toRelativeTime(v.context) - if (item.paid) { - orderIdView.text = v.context.getString(R.string.history_ref_no, item.orderId) - orderIdView.setTextColor(orderIdColor) - } else { - orderIdView.text = v.context.getString(R.string.history_unpaid) - orderIdView.setTextColor(getColor(v.context, R.color.colorError)) - } - if (item.refundable) { - refundButton.visibility = View.VISIBLE - deleteButton.visibility = View.GONE - refundButton.setOnClickListener { listener.onRefundClicked(item) } - deleteButton.setOnClickListener(null) - } else if (!item.paid) { - refundButton.visibility = View.GONE - deleteButton.visibility = View.VISIBLE - deleteButton.setOnClickListener { listener.onDeleteClicked(item) } - refundButton.setOnClickListener(null) - } else { - refundButton.visibility = View.GONE - deleteButton.visibility = View.GONE - refundButton.setOnClickListener(null) - deleteButton.setOnClickListener(null) - } - } - - } - -} diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/history/HistoryManager.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/history/HistoryManager.kt @@ -23,45 +23,98 @@ import androidx.lifecycle.MutableLiveData import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import net.taler.lib.android.assertUiThread +import net.taler.merchantlib.CheckPaymentResponse import net.taler.merchantlib.MerchantApi import net.taler.merchantlib.OrderHistoryEntry import net.taler.merchantpos.R import net.taler.merchantpos.config.ConfigManager sealed class HistoryResult { - class Error( - @StringRes val mainResId: Int, - val msg: String, - ) : HistoryResult() class Success(val items: List<OrderHistoryEntry>) : HistoryResult() } +class HistoryError( + @StringRes val mainResId: Int, + val msg: String, +) + class HistoryManager( private val configManager: ConfigManager, private val scope: CoroutineScope, private val api: MerchantApi ) { + companion object { + private const val PAGE_SIZE = 20 + private const val LOAD_MORE_THRESHOLD = 5 + } 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) + val error: LiveData<HistoryError?> = mError + + private val loadedItems = mutableListOf<OrderHistoryEntry>() + private var nextOffset: Long? = null + private var reachedEnd = false @UiThread - internal fun fetchHistory() = scope.launch { - mIsLoading.value = true + internal fun fetchHistory() = fetchHistoryPage(reset = true) + + @UiThread + internal fun loadMoreHistoryIfNeeded(lastVisibleIndex: Int) { + val currentItems = (mItems.value as? HistoryResult.Success)?.items ?: return + if (mIsLoading.value == true || mIsLoadingMore.value == true || reachedEnd) return + if (currentItems.isEmpty()) return + if (lastVisibleIndex < currentItems.lastIndex - LOAD_MORE_THRESHOLD) return + fetchHistoryPage(reset = false) + } + + @UiThread + private fun fetchHistoryPage(reset: Boolean) = scope.launch { + if (reset) { + mIsLoading.value = true + mIsLoadingMore.value = false + } else { + mIsLoadingMore.value = true + } val merchantConfig = configManager.merchantConfig!! - api.getOrderHistory(merchantConfig).handle({ onError(R.string.error_history, it) }) { + val offset = if (reset) null else nextOffset + api.getOrderHistory( + merchantConfig = merchantConfig, + limit = -PAGE_SIZE, + offset = offset, + ).handle({ onError(R.string.error_history, it) }) { response -> assertUiThread() mIsLoading.value = false - mItems.value = HistoryResult.Success(it.orders) + mIsLoadingMore.value = false + if (reset) { + loadedItems.clear() + reachedEnd = false + } + val page = response.orders + val existingOrderIds = loadedItems.mapTo(mutableSetOf()) { it.orderId } + val newItems = page.filterNot { it.orderId in existingOrderIds } + if (newItems.isNotEmpty()) { + loadedItems += newItems + nextOffset = newItems.lastOrNull()?.rowId ?: loadedItems.lastOrNull()?.rowId + } + if (page.size < PAGE_SIZE || newItems.isEmpty()) { + reachedEnd = true + } + publishItems() + enrichOrders(newItems) } } @UiThread internal fun deleteOrder(orderId: String) = scope.launch { mIsLoading.value = true + mIsLoadingMore.value = false val merchantConfig = configManager.merchantConfig!! api.deleteOrder(merchantConfig, orderId).handle({ onError(R.string.error_delete_order, it) }) { assertUiThread() @@ -70,9 +123,49 @@ class HistoryManager( } } + @UiThread + internal fun clearError() { + mError.value = null + } + + private fun publishItems() { + mItems.value = HistoryResult.Success(loadedItems.toList()) + } + + private fun enrichOrders(items: List<OrderHistoryEntry>) = scope.launch { + val merchantConfig = configManager.merchantConfig ?: return@launch + items.filter { it.paid }.forEach { item -> + 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, + ) + } + ) + } + } + } + + private fun updateItem( + orderId: String, + transform: (OrderHistoryEntry) -> OrderHistoryEntry, + ) { + val index = loadedItems.indexOfFirst { it.orderId == orderId } + if (index == -1) return + loadedItems[index] = transform(loadedItems[index]) + publishItems() + } + private fun onError(@StringRes mainResId: Int, msg: String) { assertUiThread() mIsLoading.value = false - mItems.value = HistoryResult.Error(mainResId, msg) + mIsLoadingMore.value = false + mError.value = HistoryError(mainResId, msg) } } diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/order/CategoriesFragment.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/order/CategoriesFragment.kt @@ -1,68 +0,0 @@ -/* - * This file is part of GNU Taler - * (C) 2020 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.order - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.View.INVISIBLE -import android.view.ViewGroup -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.recyclerview.widget.LinearLayoutManager -import net.taler.merchantpos.MainViewModel -import net.taler.merchantpos.config.Category -import net.taler.merchantpos.databinding.FragmentCategoriesBinding - -interface CategorySelectionListener { - fun onCategorySelected(category: Category) -} - -class CategoriesFragment : Fragment(), CategorySelectionListener { - - private val viewModel: MainViewModel by activityViewModels() - private val orderManager by lazy { viewModel.orderManager } - - private lateinit var ui: FragmentCategoriesBinding - private val adapter = CategoryAdapter(this) - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - ui = FragmentCategoriesBinding.inflate(inflater, container, false) - return ui.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - ui.categoriesList.apply { - adapter = this@CategoriesFragment.adapter - layoutManager = LinearLayoutManager(requireContext()) - } - - orderManager.categories.observe(viewLifecycleOwner) { categories -> - adapter.setItems(categories) - ui.progressBar.visibility = INVISIBLE - } - } - - override fun onCategorySelected(category: Category) { - orderManager.setCurrentCategory(category) - } - -} diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/order/CategoryAdapter.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/order/CategoryAdapter.kt @@ -1,62 +0,0 @@ -/* - * This file is part of GNU Taler - * (C) 2020 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.order - -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.Button -import androidx.recyclerview.widget.RecyclerView -import androidx.recyclerview.widget.RecyclerView.Adapter -import net.taler.merchantpos.R -import net.taler.merchantpos.config.Category -import net.taler.merchantpos.order.CategoryAdapter.CategoryViewHolder - -internal class CategoryAdapter(private val listener: CategorySelectionListener) : - Adapter<CategoryViewHolder>() { - - private val categories = ArrayList<Category>() - - override fun getItemCount() = categories.size - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CategoryViewHolder { - val view = - LayoutInflater.from(parent.context).inflate(R.layout.list_item_category, parent, false) - return CategoryViewHolder(view) - } - - override fun onBindViewHolder(holder: CategoryViewHolder, position: Int) { - holder.bind(categories[position]) - } - - fun setItems(items: List<Category>) { - categories.clear() - categories.addAll(items) - notifyDataSetChanged() - } - - internal inner class CategoryViewHolder(v: View) : RecyclerView.ViewHolder(v) { - private val button: Button = v.findViewById(R.id.button) - - fun bind(category: Category) { - button.text = category.localizedName - button.isPressed = category.selected - button.setOnClickListener { listener.onCategorySelected(category) } - } - } - -} diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/order/CustomDialogFragment.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/order/CustomDialogFragment.kt @@ -1,80 +0,0 @@ -/* - * This file is part of GNU Taler - * (C) 2023 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.order - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.Toast -import android.widget.Toast.LENGTH_LONG -import androidx.fragment.app.DialogFragment -import androidx.fragment.app.activityViewModels -import net.taler.common.AmountParserException -import net.taler.common.Amount -import net.taler.merchantpos.MainViewModel -import net.taler.merchantpos.R -import net.taler.merchantpos.config.ConfigProduct -import net.taler.merchantpos.databinding.FragmentCustomDialogBinding - -class CustomDialogFragment : DialogFragment() { - - companion object { - const val TAG = "CustomDialogFragment" - } - - private val viewModel: MainViewModel by activityViewModels() - - private lateinit var ui: FragmentCustomDialogBinding - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View { - ui = FragmentCustomDialogBinding.inflate(inflater, container, false) - return ui.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - val currency = viewModel.configManager.currency ?: error("No currency") - val currencySpec = viewModel.configManager.currencySpec - ui.currencyView.text = currency - ui.addButton.setOnClickListener { - val currentOrderId = - viewModel.orderManager.currentOrderId.value ?: return@setOnClickListener - val amount = try { - Amount.fromString(currency, ui.amountLayout.editText!!.text.toString()) - .withSpec(currencySpec) - } catch (e: AmountParserException) { - Toast.makeText(requireContext(), R.string.refund_error_invalid_amount, LENGTH_LONG) - .show() - return@setOnClickListener - } - val product = ConfigProduct( - description = ui.productNameLayout.editText!!.text.toString(), - price = amount, - categories = listOf(Int.MIN_VALUE), - ) - viewModel.orderManager.addProduct(currentOrderId, product) - dismiss() - } - ui.cancelButton.setOnClickListener { - dismiss() - } - } -} 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 @@ -26,8 +26,8 @@ data class Order( val currency: String, val currencySpec: CurrencySpecification?, val availableCategories: Map<Int, Category>, + val products: List<ConfigProduct> = emptyList(), ) { - val products = ArrayList<ConfigProduct>() val title: String = id.toString() val summary: String get() { @@ -46,26 +46,28 @@ data class Order( } operator fun plus(product: ConfigProduct): Order { - val i = products.indexOf(product) + val updatedProducts = products.toMutableList() + val i = updatedProducts.indexOfFirst { it.id == product.id } if (i == -1) { - products.add(product.copy(quantity = 1)) + updatedProducts.add(product.copy(quantity = 1)) } else { - val quantity = products[i].quantity - products[i] = products[i].copy(quantity = quantity + 1) + val quantity = updatedProducts[i].quantity + updatedProducts[i] = updatedProducts[i].copy(quantity = quantity + 1) } - return this + return copy(products = updatedProducts) } operator fun minus(product: ConfigProduct): Order { - val i = products.indexOf(product) + val updatedProducts = products.toMutableList() + val i = updatedProducts.indexOfFirst { it.id == product.id } if (i == -1) return this - val quantity = products[i].quantity + val quantity = updatedProducts[i].quantity if (quantity <= 1) { - products.remove(product) + updatedProducts.removeAt(i) } else { - products[i] = products[i].copy(quantity = quantity - 1) + updatedProducts[i] = updatedProducts[i].copy(quantity = quantity - 1) } - return this + return copy(products = updatedProducts) } private fun getCategoryQuantities(): HashMap<Category, Int> { diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/order/OrderAdapter.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/order/OrderAdapter.kt @@ -1,136 +0,0 @@ -/* - * This file is part of GNU Taler - * (C) 2020 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.order - -import android.view.LayoutInflater -import android.view.MotionEvent -import android.view.View -import android.view.View.GONE -import android.view.View.VISIBLE -import android.view.ViewGroup -import android.widget.ImageView -import android.widget.TextView -import androidx.recyclerview.selection.ItemDetailsLookup -import androidx.recyclerview.selection.ItemKeyProvider -import androidx.recyclerview.selection.SelectionTracker -import androidx.recyclerview.widget.AsyncListDiffer -import androidx.recyclerview.widget.DiffUtil.ItemCallback -import androidx.recyclerview.widget.RecyclerView -import androidx.recyclerview.widget.RecyclerView.Adapter -import net.taler.lib.android.base64Bitmap -import net.taler.merchantpos.R -import net.taler.merchantpos.config.ConfigProduct -import net.taler.merchantpos.order.OrderAdapter.OrderViewHolder - -internal class OrderAdapter : Adapter<OrderViewHolder>() { - - lateinit var tracker: SelectionTracker<String> - val keyProvider = OrderKeyProvider() - private val itemCallback = object : ItemCallback<ConfigProduct>() { - override fun areItemsTheSame(oldItem: ConfigProduct, newItem: ConfigProduct): Boolean { - return oldItem == newItem - } - - override fun areContentsTheSame(oldItem: ConfigProduct, newItem: ConfigProduct): Boolean { - return oldItem.quantity == newItem.quantity - } - } - private val differ = AsyncListDiffer(this, itemCallback) - - override fun getItemCount() = differ.currentList.size - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): OrderViewHolder { - val view = - LayoutInflater.from(parent.context).inflate(R.layout.list_item_order, parent, false) - return OrderViewHolder(view) - } - - override fun onBindViewHolder(holder: OrderViewHolder, position: Int) { - val item = getItem(position)!! - holder.bind(item, tracker.isSelected(item.id)) - } - - fun setItems(items: List<ConfigProduct>, commitCallback: () -> Unit) { - // toMutableList() is needed for some reason, otherwise doesn't update adapter - differ.submitList(items.toMutableList(), commitCallback) - } - - fun getItem(position: Int): ConfigProduct? = differ.currentList[position] - - fun getItemByKey(key: String): ConfigProduct? { - return differ.currentList.find { it.id == key } - } - - fun findPosition(product: ConfigProduct): Int { - return differ.currentList.indexOf(product) - } - - internal inner class OrderViewHolder(private val v: View) : RecyclerView.ViewHolder(v) { - private val quantity: TextView = v.findViewById(R.id.quantity) - private val name: TextView = v.findViewById(R.id.name) - private val description: TextView = v.findViewById(R.id.description) - private val price: TextView = v.findViewById(R.id.price) - private val image: ImageView = v.findViewById(R.id.image) - - fun bind(product: ConfigProduct, selected: Boolean) { - v.isActivated = selected - quantity.text = product.quantity.toString() - name.text = product.displayName - val productDescription = product.displayDescription - if (productDescription == null) { - description.visibility = GONE - } else { - description.visibility = VISIBLE - description.text = productDescription - } - price.text = product.totalPrice.toString() - - // base64 encoded image - val bitmap = product.image?.base64Bitmap - if (bitmap == null) { - image.visibility = GONE - } else { - image.visibility = VISIBLE - image.setImageBitmap(bitmap) - } - } - } - - internal inner class OrderKeyProvider : ItemKeyProvider<String>(SCOPE_MAPPED) { - override fun getKey(position: Int) = getItem(position)!!.id - override fun getPosition(key: String): Int { - return differ.currentList.indexOfFirst { it.id == key } - } - } - - internal class OrderLineLookup(private val list: RecyclerView) : ItemDetailsLookup<String>() { - override fun getItemDetails(e: MotionEvent): ItemDetails<String>? { - list.findChildViewUnder(e.x, e.y)?.let { view -> - val holder = list.getChildViewHolder(view) - val adapter = list.adapter as OrderAdapter - val position = holder.bindingAdapterPosition - return object : ItemDetails<String>() { - override fun getPosition(): Int = position - override fun getSelectionKey(): String = adapter.keyProvider.getKey(position) - override fun inSelectionHotspot(e: MotionEvent) = true - } - } - return 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,27 +17,76 @@ package net.taler.merchantpos.order import android.os.Bundle -import android.view.LayoutInflater -import android.view.Menu -import android.view.MenuInflater -import android.view.MenuItem -import android.view.View -import android.view.ViewGroup -import android.widget.Toast -import androidx.core.view.MenuProvider +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.layout.Arrangement +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.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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +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.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.HorizontalDivider +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.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.saveable.rememberSaveable +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.graphics.asImageBitmap +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.text.style.TextOverflow +import androidx.compose.ui.unit.dp import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels -import androidx.lifecycle.Lifecycle -import androidx.transition.TransitionManager.beginDelayedTransition -import net.taler.lib.android.navigate +import net.taler.common.Amount +import net.taler.common.AmountParserException +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.databinding.FragmentOrderBinding -import net.taler.merchantpos.order.OrderFragmentDirections.Companion.actionGlobalConfigFetcher -import net.taler.merchantpos.order.OrderFragmentDirections.Companion.actionOrderToMerchantSettings -import net.taler.merchantpos.order.OrderFragmentDirections.Companion.actionOrderToProcessPayment -import net.taler.merchantpos.order.RestartState.ENABLED -import net.taler.merchantpos.order.RestartState.UNDO +import net.taler.merchantpos.compose.PosTheme +import net.taler.merchantpos.config.Category +import net.taler.merchantpos.config.ConfigProduct +import net.taler.merchantpos.showPosError +import net.taler.merchantpos.order.RestartState.DISABLED +import androidx.compose.foundation.shape.RoundedCornerShape class OrderFragment : Fragment() { @@ -45,157 +94,774 @@ class OrderFragment : Fragment() { private val orderManager by lazy { viewModel.orderManager } private val paymentManager by lazy { viewModel.paymentManager } - private lateinit var ui: FragmentOrderBinding - private var billLabel: String = "" - private var currentOrderId: Int? = null - private var currentLiveOrder: LiveOrder? = null - private var restartOrderItem: MenuItem? = null - private var deleteOrderItem: MenuItem? = null - private var previousOrderItem: MenuItem? = null - private var nextOrderItem: MenuItem? = null - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, + inflater: android.view.LayoutInflater, + container: android.view.ViewGroup?, savedInstanceState: Bundle?, - ): View { - ui = FragmentOrderBinding.inflate(inflater, container, false) - return ui.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - billLabel = getString(R.string.order_complete) - ui.completeButton.text = billLabel - - requireActivity().addMenuProvider(object: MenuProvider { - override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { - menuInflater.inflate(R.menu.order, menu) - restartOrderItem = menu.findItem(R.id.orderRestart) - deleteOrderItem = menu.findItem(R.id.orderDelete) - previousOrderItem = menu.findItem(R.id.orderPrevious) - nextOrderItem = menu.findItem(R.id.orderNext) - updateOrderNavigationActions(currentOrderId) - } - - override fun onMenuItemSelected(menuItem: MenuItem): Boolean { - return when(menuItem.itemId) { - R.id.orderRestart -> { - currentLiveOrder?.restartOrUndo() - true - } - R.id.orderDelete -> { - orderManager.deleteCurrentOrder() - true - } - - R.id.orderPrevious -> { - orderManager.previousOrder() - true - } - - R.id.orderNext -> { - orderManager.nextOrder() - true - } - - R.id.reload -> { - viewModel.configManager.reloadConfig() - Toast.makeText( - requireContext(), - getString(R.string.toast_reloading), - Toast.LENGTH_LONG, - ).show() - true - } - - else -> false - } - } - }, viewLifecycleOwner, Lifecycle.State.RESUMED) - - orderManager.currentOrderId.observe(viewLifecycleOwner) { orderId -> - val liveOrder = orderManager.getOrder(orderId) - onOrderSwitched(orderId, liveOrder) - // add a new OrderStateFragment for each order - // as switching its internals (like we do here) would be too messy - childFragmentManager.beginTransaction() - .replace(R.id.fragment1, OrderStateFragment()) - .commit() + ) = ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OrderRoute( + viewModel = viewModel, + onNavigate = { destination -> + (requireActivity() as MainActivity).navigateTo(destination) + }, + onShowMessage = { message -> + requireActivity().showPosError(message) + }, + ) } } override fun onStart() { super.onStart() if (!viewModel.configManager.config.isValid()) { - navigate(actionOrderToMerchantSettings()) + (requireActivity() as MainActivity).navigateTo(PosDestination.Config) } else if (viewModel.configManager.currency == null) { - navigate(actionGlobalConfigFetcher()) + (requireActivity() as MainActivity).navigateTo(PosDestination.ConfigFetcher) } } +} + +@Composable +private fun OrderRoute( + viewModel: MainViewModel, + onNavigate: (PosDestination) -> Unit, + onShowMessage: (String) -> Unit, +) { + val orderManager = remember { viewModel.orderManager } + val paymentManager = remember { viewModel.paymentManager } + val currentOrderId by orderManager.currentOrderId.observeAsState() + val categories by orderManager.categories.observeAsState(emptyList()) + val products by orderManager.products.observeAsState(emptyList()) + val currency = viewModel.configManager.currency + val currencySpec = viewModel.configManager.currencySpec + + val orderId = currentOrderId ?: return + val liveOrder = remember(orderId) { orderManager.getOrder(orderId) } + val order by liveOrder.order.observeAsState() + 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) } + var selectedCategoryId by rememberSaveable { + mutableStateOf(categories.firstOrNull { it.selected }?.id) + } + var showCustomDialog by rememberSaveable { mutableStateOf(false) } + val reloadingText = stringResource(R.string.toast_reloading) + + 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) + } - private fun onOrderSwitched(orderId: Int, liveOrder: LiveOrder) { - currentOrderId = orderId - currentLiveOrder = liveOrder - updateOrderNavigationActions(orderId) - // order title - liveOrder.order.observe(viewLifecycleOwner) { order -> - if (order == null) return@observe - activity?.title = getString(R.string.order_label_title, order.title) + LaunchedEffect(categories.map { it.id }) { + if (selectedCategoryId == null) { + selectedCategoryId = categories.firstOrNull { it.selected }?.id + } else if (categories.none { it.id == selectedCategoryId }) { + selectedCategoryId = categories.firstOrNull { it.selected }?.id } - // restart action - liveOrder.restartState.observe(viewLifecycleOwner) { state -> - beginDelayedTransition(view as ViewGroup) - if (state == UNDO) { - restartOrderItem?.setTitle(R.string.order_undo) - restartOrderItem?.isEnabled = true - ui.completeButton.isEnabled = false + } + + 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), + ) + orderManager.addProduct(orderId, product) + showCustomDialog = false + }, + ) + } + + PosTheme { + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .statusBarsPadding(), + ) { + val isTabletLayout = LocalConfiguration.current.smallestScreenWidthDp >= 720 + if (isTabletLayout) { + TabletOrderScreen( + categories = categories, + selectedCategoryId = selectedCategoryId, + products = products, + order = order, + increaseAllowed = increaseAllowed, + modifyAllowed = modifyAllowed, + orderTotal = orderTotal.toString(), + selectedProductKey = selectedProductKey, + onCategorySelected = { category -> + selectedCategoryId = category.id + orderManager.setCurrentCategory(category) + }, + onProductSelected = { product -> + orderManager.addProduct(orderId, product) + viewModel.configManager.refreshInventory() + }, + onSelectProduct = { + selectedProductKey = it?.id + liveOrder.selectOrderLine(it) + }, + onIncrease = { liveOrder.increaseSelectedOrderLine() }, + onDecrease = { liveOrder.decreaseSelectedOrderLine() }, + onAddCustom = { showCustomDialog = true }, + onComplete = { + val currentOrder = order ?: return@TabletOrderScreen + paymentManager.createPayment(currentOrder) + onNavigate(PosDestination.ProcessPayment) + }, + ) } else { - restartOrderItem?.setTitle(R.string.order_restart) - restartOrderItem?.isEnabled = state == ENABLED - ui.completeButton.isEnabled = state == ENABLED + PhoneOrderScreen( + categories = categories, + selectedCategoryId = selectedCategoryId, + products = products, + order = order, + increaseAllowed = increaseAllowed, + modifyAllowed = modifyAllowed, + orderTotal = orderTotal.toString(), + selectedProductKey = selectedProductKey, + onCategorySelected = { category -> + selectedCategoryId = category.id + orderManager.setCurrentCategory(category) + }, + onProductSelected = { product -> + orderManager.addProduct(orderId, product) + viewModel.configManager.refreshInventory() + }, + onSelectProduct = { + selectedProductKey = it?.id + liveOrder.selectOrderLine(it) + }, + onIncrease = { liveOrder.increaseSelectedOrderLine() }, + onDecrease = { liveOrder.decreaseSelectedOrderLine() }, + onAddCustom = { showCustomDialog = true }, + onComplete = { + val currentOrder = order ?: return@PhoneOrderScreen + paymentManager.createPayment(currentOrder) + onNavigate(PosDestination.ProcessPayment) + }, + ) } - deleteOrderItem?.isEnabled = - state != RestartState.DISABLED || - orderManager.hasPreviousOrder(orderId) || - (orderManager.hasNextOrder(orderId).value == true) } - liveOrder.orderTotal.observe(viewLifecycleOwner) { orderTotal -> - ui.completeButton.text = if (orderTotal.isZero()) { - billLabel - } else { - getString(R.string.order_complete_with_amount, orderTotal) + } +} + +@Composable +private fun TabletOrderScreen( + categories: List<Category>, + selectedCategoryId: Int?, + products: List<ConfigProduct>, + order: Order?, + increaseAllowed: Boolean, + modifyAllowed: Boolean, + orderTotal: String, + selectedProductKey: String?, + onCategorySelected: (Category) -> Unit, + onProductSelected: (ConfigProduct) -> Unit, + onSelectProduct: (ConfigProduct?) -> Unit, + onIncrease: () -> Unit, + onDecrease: () -> Unit, + onAddCustom: () -> Unit, + onComplete: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxSize(), + horizontalArrangement = Arrangement.spacedBy(0.dp), + ) { + CategoriesPane( + categories = categories, + selectedCategoryId = selectedCategoryId, + modifier = Modifier.weight(0.25f), + compact = false, + onCategorySelected = onCategorySelected, + ) + Spacer( + modifier = Modifier + .width(1.dp) + .fillMaxHeight() + .background(MaterialTheme.colorScheme.outlineVariant), + ) + ProductsPane( + products = products, + modifier = Modifier.weight(0.50f), + compact = false, + onProductSelected = onProductSelected, + ) + Spacer( + modifier = Modifier + .width(1.dp) + .fillMaxHeight() + .background(MaterialTheme.colorScheme.outlineVariant), + ) + OrderColumnPane( + order = order, + increaseAllowed = increaseAllowed, + modifyAllowed = modifyAllowed, + orderTotal = orderTotal, + orderIsEmpty = order?.total?.isZero() != false, + selectedProductKey = selectedProductKey, + modifier = Modifier.weight(0.25f), + compact = false, + onSelectProduct = onSelectProduct, + onIncrease = onIncrease, + onDecrease = onDecrease, + onAddCustom = onAddCustom, + onComplete = onComplete, + ) + } +} + +@Composable +private fun PhoneOrderScreen( + categories: List<Category>, + selectedCategoryId: Int?, + products: List<ConfigProduct>, + order: Order?, + increaseAllowed: Boolean, + modifyAllowed: Boolean, + orderTotal: String, + selectedProductKey: String?, + onCategorySelected: (Category) -> Unit, + onProductSelected: (ConfigProduct) -> Unit, + onSelectProduct: (ConfigProduct?) -> Unit, + onIncrease: () -> Unit, + onDecrease: () -> Unit, + onAddCustom: () -> Unit, + onComplete: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 8.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(0.dp), + ) { + CategoriesPane( + categories = categories, + selectedCategoryId = selectedCategoryId, + modifier = Modifier.weight(0.22f), + compact = true, + onCategorySelected = onCategorySelected, + ) + Spacer( + modifier = Modifier + .width(1.dp) + .fillMaxHeight() + .background(MaterialTheme.colorScheme.outlineVariant), + ) + ProductsPane( + products = products, + modifier = Modifier.weight(0.43f), + compact = true, + onProductSelected = onProductSelected, + ) + Spacer( + modifier = Modifier + .width(1.dp) + .fillMaxHeight() + .background(MaterialTheme.colorScheme.outlineVariant), + ) + OrderColumnPane( + order = order, + increaseAllowed = increaseAllowed, + modifyAllowed = modifyAllowed, + orderTotal = orderTotal, + orderIsEmpty = order?.total?.isZero() != false, + selectedProductKey = selectedProductKey, + modifier = Modifier.weight(0.35f), + compact = true, + onSelectProduct = onSelectProduct, + onIncrease = onIncrease, + onDecrease = onDecrease, + onAddCustom = onAddCustom, + onComplete = onComplete, + ) + } +} + +@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, + ), + verticalArrangement = Arrangement.spacedBy(if (compact) 6.dp else 8.dp), + ) { + items(categories, key = { it.id }) { category -> + CategoryButton( + text = category.localizedName, + selected = category.id == selectedCategoryId, + compact = compact, + onClick = { onCategorySelected(category) }, + ) } } - // -1 and +1 buttons - liveOrder.modifyOrderAllowed.observe(viewLifecycleOwner) { allowed -> - ui.minusButton.isEnabled = allowed + } +} + +@Composable +private fun CategoryButton( + text: String, + 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 + }, + 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, + ), + textAlign = TextAlign.Center, + fontWeight = FontWeight.SemiBold, + style = if (compact) MaterialTheme.typography.bodySmall else MaterialTheme.typography.bodyMedium, + ) + } +} + +@Composable +private fun ProductsPane( + products: List<ConfigProduct>, + modifier: Modifier = Modifier, + compact: 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, + ), + ) { + val spacing = if (compact) 6.dp else 8.dp + val minTileWidth = if (compact) 96.dp else 150.dp + val columns = maxOf(1, ((maxWidth + spacing) / (minTileWidth + spacing)).toInt()) + val productRows = remember(products, columns) { products.chunked(columns) } + + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(spacing), + ) { + productRows.forEach { rowProducts -> + val rowHasImage = rowProducts.any { !it.image.isNullOrBlank() } + Row( + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min), + horizontalArrangement = Arrangement.spacedBy(spacing), + ) { + rowProducts.forEach { product -> + ProductCard( + product = product, + rowHasImage = rowHasImage, + compact = compact, + onClick = { onProductSelected(product) }, + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + ) + } + repeat(columns - rowProducts.size) { + Spacer(modifier = Modifier.weight(1f)) + } + } + } + } } - liveOrder.increaseOrderAllowed.observe(viewLifecycleOwner) { allowed -> - ui.plusButton.isEnabled = allowed + } +} + +@Composable +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) + } + Card( + 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), + 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) { + Image( + bitmap = productBitmap.asImageBitmap(), + contentDescription = product.displayName, + modifier = Modifier + .size(imageSize) + .align(Alignment.CenterHorizontally), + ) + } else if (rowHasImage) { + Spacer( + modifier = Modifier + .height(imageSize) + .fillMaxWidth(), + ) + } + Text( + text = product.displayName, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + style = if (compact) MaterialTheme.typography.bodySmall else MaterialTheme.typography.bodyMedium, + ) + product.displayDescription?.let { + Text( + text = it, + style = if (compact) MaterialTheme.typography.labelSmall else MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + ) + } + Spacer(modifier = Modifier.weight(1f)) + Text( + text = product.displayPrice, + style = if (compact) MaterialTheme.typography.bodySmall else MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + if (!product.availableToSell) { + Text( + text = if (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, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } +} + +@Composable +private fun OrderPane( + order: Order?, + selectedProductKey: String?, + modifier: Modifier = Modifier, + compact: Boolean = false, + onSelectProduct: (ConfigProduct?) -> Unit, +) { + Surface(modifier = modifier.fillMaxWidth()) { + Column(modifier = Modifier.fillMaxSize()) { + LazyColumn( + modifier = Modifier.weight(1f), + ) { + items(order?.products.orEmpty(), key = { it.id }) { product -> + val selected = selectedProductKey == product.id + OrderRow( + product = product, + selected = selected, + compact = compact, + onClick = { onSelectProduct(product) }, + ) + HorizontalDivider() + } + } } - ui.minusButton.setOnClickListener { liveOrder.decreaseSelectedOrderLine() } - ui.plusButton.setOnClickListener { liveOrder.increaseSelectedOrderLine() } - ui.tipButton.setOnClickListener { - CustomDialogFragment().show(childFragmentManager, CustomDialogFragment.TAG) + } +} + +@Composable +private fun OrderRow( + product: ConfigProduct, + 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), + horizontalArrangement = Arrangement.spacedBy(if (compact) 6.dp else 8.dp), + verticalAlignment = Alignment.Top, + ) { + Text( + text = product.quantity.toString(), + modifier = Modifier.width(countWidth), + style = if (compact) MaterialTheme.typography.bodySmall else MaterialTheme.typography.bodyMedium, + ) + product.image?.base64Bitmap?.let { bitmap -> + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = product.displayName, + modifier = Modifier.size(imageSize), + ) + } ?: Spacer(Modifier.width(imageSize)) + Column(modifier = Modifier.weight(1f)) { + Text( + product.displayName, + 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) + } } - // previous and next order actions - orderManager.hasNextOrder(orderId).observe(viewLifecycleOwner) { hasNextOrder -> - if (currentOrderId == orderId) nextOrderItem?.isEnabled = hasNextOrder + Text( + product.totalPrice.toString(), + style = if (compact) MaterialTheme.typography.bodySmall else MaterialTheme.typography.bodyMedium, + ) + } +} + +@Composable +private fun OrderActionBar( + modifyAllowed: Boolean, + increaseAllowed: Boolean, + orderTotal: String, + orderIsEmpty: Boolean, + modifier: Modifier = Modifier, + compact: Boolean = false, + onIncrease: () -> Unit, + onDecrease: () -> Unit, + onAddCustom: () -> Unit, + onComplete: () -> Unit, +) { + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + 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) + } + Button(onClick = onDecrease, enabled = modifyAllowed, colors = orderControlButtonColors()) { + Text("-1", style = if (compact) MaterialTheme.typography.labelLarge else MaterialTheme.typography.bodyMedium) + } + Button(onClick = onAddCustom, colors = orderControlButtonColors()) { + Text( + stringResource(R.string.order_custom_product_default), + style = if (compact) MaterialTheme.typography.labelLarge else MaterialTheme.typography.bodyMedium, + ) + } } - // complete button - ui.completeButton.setOnClickListener { - val order = liveOrder.order.value ?: return@setOnClickListener - paymentManager.createPayment(order) - navigate(actionOrderToProcessPayment()) + Button( + onClick = onComplete, + enabled = !orderIsEmpty, + modifier = Modifier + .fillMaxWidth() + .height(if (compact) 72.dp else 96.dp), + colors = completeButtonColors(), + ) { + Text( + if (orderIsEmpty) { + stringResource(R.string.order_complete) + } else { + stringResource(R.string.order_complete_with_amount, orderTotal) + }, + style = if (compact) MaterialTheme.typography.bodyLarge else MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + ) } } +} - private fun updateOrderNavigationActions(orderId: Int?) { - previousOrderItem?.isEnabled = orderId?.let { orderManager.hasPreviousOrder(it) } ?: false - nextOrderItem?.isEnabled = orderId?.let { - orderManager.hasNextOrder(it).value ?: false - } ?: false +@Composable +private fun OrderColumnPane( + order: Order?, + increaseAllowed: Boolean, + modifyAllowed: Boolean, + orderTotal: String, + orderIsEmpty: Boolean, + selectedProductKey: String?, + modifier: Modifier = Modifier, + compact: Boolean = false, + onSelectProduct: (ConfigProduct?) -> Unit, + onIncrease: () -> Unit, + onDecrease: () -> Unit, + onAddCustom: () -> Unit, + onComplete: () -> Unit, +) { + Column( + modifier = modifier + .fillMaxSize() + .padding(top = 12.dp, end = 12.dp, bottom = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + OrderPane( + order = order, + selectedProductKey = selectedProductKey, + modifier = Modifier.weight(1f), + compact = compact, + onSelectProduct = onSelectProduct, + ) + OrderActionBar( + modifyAllowed = modifyAllowed, + increaseAllowed = increaseAllowed, + orderTotal = orderTotal, + orderIsEmpty = orderIsEmpty, + modifier = Modifier.padding(start = 12.dp), + compact = compact, + onIncrease = onIncrease, + onDecrease = onDecrease, + onAddCustom = onAddCustom, + onComplete = onComplete, + ) } +} + +@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?, + onDismiss: () -> Unit, + onAdd: (String, Amount) -> Unit, +) { + val defaultDescription = stringResource(R.string.order_custom_product_default) + val invalidAmountText = stringResource(R.string.refund_error_invalid_amount) + var description by rememberSaveable { mutableStateOf(defaultDescription) } + var amountText by rememberSaveable { mutableStateOf("") } + var errorText by rememberSaveable { mutableStateOf<String?>(null) } + AlertDialog( + 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 + } + onAdd(description, amount) + }) { + Text(stringResource(R.string.order_custom_add_button)) + } + }, + dismissButton = { + OutlinedButton(onClick = onDismiss) { + Text(stringResource(R.string.refund_abort)) + } + }, + ) } 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 @@ -226,11 +226,14 @@ class OrderManager(private val context: Context) : ConfigurationReceiver { internal fun setCurrentCategory(category: Category) { currentCategory = category - val newCategories = categories.value?.apply { - forEach { if (it.selected) it.selected = false } - category.selected = true + val currentCategories = categories.value.orEmpty() + val newCategories = currentCategories.map { existing -> + existing.copy().also { copied -> + copied.selected = existing.id == category.id + } } - mCategories.postValue(newCategories ?: emptyList()) + currentCategory = newCategories.firstOrNull { it.id == category.id } ?: category + mCategories.postValue(newCategories) updateVisibleProducts() } diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/order/OrderStateFragment.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/order/OrderStateFragment.kt @@ -1,119 +0,0 @@ -/* - * This file is part of GNU Taler - * (C) 2020 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.order - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.recyclerview.selection.SelectionPredicates -import androidx.recyclerview.selection.SelectionTracker -import androidx.recyclerview.selection.StorageStrategy -import androidx.recyclerview.widget.LinearLayoutManager -import net.taler.merchantpos.MainViewModel -import net.taler.merchantpos.databinding.FragmentOrderStateBinding -import net.taler.merchantpos.order.OrderAdapter.OrderLineLookup - -class OrderStateFragment : Fragment() { - - private val viewModel: MainViewModel by activityViewModels() - private val orderManager by lazy { viewModel.orderManager } - private val liveOrder by lazy { orderManager.getOrder(orderManager.currentOrderId.value!!) } - - private lateinit var ui: FragmentOrderStateBinding - private val adapter = OrderAdapter() - private var tracker: SelectionTracker<String>? = null - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - ui = FragmentOrderStateBinding.inflate(inflater, container, false) - return ui.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - ui.orderList.apply { - adapter = this@OrderStateFragment.adapter - layoutManager = LinearLayoutManager(requireContext()) - } - val detailsLookup = OrderLineLookup(ui.orderList) - val tracker = SelectionTracker.Builder( - "order-selection-id", - ui.orderList, - adapter.keyProvider, - detailsLookup, - StorageStrategy.createStringStorage() - ).withSelectionPredicate( - SelectionPredicates.createSelectSingleAnything() - ).build() - savedInstanceState?.let { tracker.onRestoreInstanceState(it) } - adapter.tracker = tracker - this.tracker = tracker - if (savedInstanceState == null) { - // select last selected order line when re-creating this fragment - // do it before attaching the tracker observer - liveOrder.selectedProductKey?.let { tracker.select(it) } - } - tracker.addObserver(object : SelectionTracker.SelectionObserver<String>() { - override fun onItemStateChanged(key: String, selected: Boolean) { - super.onItemStateChanged(key, selected) - val item = if (selected) adapter.getItemByKey(key) else null - liveOrder.selectOrderLine(item) - } - }) - liveOrder.order.observe(viewLifecycleOwner) { order -> - if (order == null) return@observe - onOrderChanged(order, tracker) - } - } - - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - tracker?.onSaveInstanceState(outState) - } - - private fun onOrderChanged(order: Order, tracker: SelectionTracker<String>) { - adapter.setItems(order.products) { - liveOrder.lastAddedProduct?.let { - val position = adapter.findPosition(it) - if (position >= 0) { - ui.orderList.scrollToPosition(position) - ui.orderList.post { this.tracker?.select(it.id) } - return@setItems - } - } - val selectedKey = tracker.selection.firstOrNull() - val selectedProduct = selectedKey?.let { key -> - order.products.find { it.id == key } - } - if (selectedProduct == null) { - val fallbackProduct = order.products.lastOrNull() - tracker.clearSelection() - if (fallbackProduct == null) { - liveOrder.selectOrderLine(null) - } else { - ui.orderList.post { this.tracker?.select(fallbackProduct.id) } - } - } - } - } - -} diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/order/ProductsFragment.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/order/ProductsFragment.kt @@ -1,173 +0,0 @@ -/* - * This file is part of GNU Taler - * (C) 2020 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.order - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.View.GONE -import android.view.View.INVISIBLE -import android.view.View.VISIBLE -import android.view.ViewGroup -import android.widget.ImageView -import android.widget.TextView -import com.google.android.material.card.MaterialCardView -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.recyclerview.widget.AsyncListDiffer -import androidx.recyclerview.widget.DiffUtil.ItemCallback -import androidx.recyclerview.widget.GridLayoutManager -import androidx.recyclerview.widget.RecyclerView.Adapter -import androidx.recyclerview.widget.RecyclerView.ViewHolder -import net.taler.lib.android.base64Bitmap -import net.taler.merchantpos.MainViewModel -import net.taler.merchantpos.R -import net.taler.merchantpos.config.ConfigProduct -import net.taler.merchantpos.databinding.FragmentProductsBinding -import net.taler.merchantpos.order.ProductAdapter.ProductViewHolder - -interface ProductSelectionListener { - fun onProductSelected(product: ConfigProduct) -} - -class ProductsFragment : Fragment(), ProductSelectionListener { - - private val viewModel: MainViewModel by activityViewModels() - private val orderManager by lazy { viewModel.orderManager } - private val adapter = ProductAdapter(this) - - private lateinit var ui: FragmentProductsBinding - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - ui = FragmentProductsBinding.inflate(inflater, container, false) - return ui.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - ui.productsList.apply { - adapter = this@ProductsFragment.adapter - layoutManager = GridLayoutManager(requireContext(), 3) - } - - orderManager.products.observe(viewLifecycleOwner, { products -> - if (products == null) { - adapter.setItems(emptyList()) - } else { - adapter.setItems(products) - } - ui.progressBar.visibility = INVISIBLE - }) - } - - override fun onProductSelected(product: ConfigProduct) { - orderManager.addProduct(orderManager.currentOrderId.value!!, product) - viewModel.configManager.refreshInventory() - } - -} - -private class ProductAdapter( - private val listener: ProductSelectionListener -) : Adapter<ProductViewHolder>() { - init { - setHasStableIds(true) - } - - private val itemCallback = object : ItemCallback<ConfigProduct>() { - override fun areItemsTheSame(oldItem: ConfigProduct, newItem: ConfigProduct): Boolean { - return oldItem.stableKey == newItem.stableKey - } - - override fun areContentsTheSame(oldItem: ConfigProduct, newItem: ConfigProduct): Boolean { - return oldItem.displayName == newItem.displayName && - oldItem.displayDescription == newItem.displayDescription && - oldItem.displayPrice == newItem.displayPrice && - oldItem.image == newItem.image && - oldItem.availableToSell == newItem.availableToSell && - oldItem.remainingStock == newItem.remainingStock - } - } - private val differ = AsyncListDiffer(this, itemCallback) - - override fun getItemCount() = differ.currentList.size - - override fun getItemId(position: Int): Long { - return differ.currentList[position].stableKey.hashCode().toLong() - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ProductViewHolder { - val view = - LayoutInflater.from(parent.context).inflate(R.layout.list_item_product, parent, false) - return ProductViewHolder(view) - } - - override fun onBindViewHolder(holder: ProductViewHolder, position: Int) { - holder.bind(differ.currentList[position]) - } - - fun setItems(items: List<ConfigProduct>) { - differ.submitList(items.toList()) - } - - inner class ProductViewHolder(private val v: View) : ViewHolder(v) { - private val name: TextView = v.findViewById(R.id.name) - private val description: TextView = v.findViewById(R.id.description) - private val price: TextView = v.findViewById(R.id.price) - private val image: ImageView = v.findViewById(R.id.image) - private val unavailable: TextView = v.findViewById(R.id.unavailableLabel) - private val card: MaterialCardView = v as MaterialCardView - - fun bind(product: ConfigProduct) { - name.text = product.displayName - val productDescription = product.displayDescription - if (productDescription == null) { - description.visibility = GONE - } else { - description.visibility = VISIBLE - description.text = productDescription - } - price.text = product.displayPrice - - // base64 encoded image - val bitmap = product.image?.base64Bitmap - if (bitmap == null) { - image.visibility = GONE - } else { - image.visibility = VISIBLE - image.setImageBitmap(bitmap) - } - - unavailable.visibility = if (product.availableToSell) GONE else VISIBLE - unavailable.text = when { - product.availableToSell -> "" - product.remainingStock == 0 -> v.context.getString(R.string.product_out_of_stock) - else -> v.context.getString(R.string.product_unavailable) - } - card.isEnabled = product.availableToSell - v.isEnabled = product.availableToSell - v.alpha = if (product.availableToSell) 1f else 0.5f - v.setOnClickListener { - if (product.availableToSell) listener.onProductSelected(product) - } - } - } - -} diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/payment/PaymentManager.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/payment/PaymentManager.kt @@ -32,10 +32,12 @@ 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.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 @@ -107,6 +109,44 @@ class PaymentManager( } } + @UiThread + fun resumePayment(item: OrderHistoryEntry) { + val current = mPayment.value + 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) { + checkJob = checkPayment(item.orderId) + checkTimer.start() + } + } + private fun checkPayment(orderId: String) = scope.launch { val merchantConfig = configManager.merchantConfig!! api.checkOrder(merchantConfig, orderId).handle({ error -> 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,31 +17,120 @@ 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.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.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.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.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.unit.dp import androidx.fragment.app.Fragment -import androidx.navigation.fragment.findNavController -import net.taler.merchantpos.databinding.FragmentPaymentSuccessBinding +import net.taler.merchantpos.R +import net.taler.merchantpos.MainActivity +import net.taler.merchantpos.compose.PosTheme +import androidx.compose.foundation.Image +import androidx.compose.ui.graphics.ColorFilter class PaymentSuccessFragment : Fragment() { - private lateinit var ui: FragmentPaymentSuccessBinding - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - ui = FragmentPaymentSuccessBinding.inflate(inflater, container, false) - return ui.root + inflater: android.view.LayoutInflater, + container: android.view.ViewGroup?, + savedInstanceState: Bundle?, + ) = ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + PaymentSuccessScreen( + onContinue = { + (requireActivity() as MainActivity).apply { + navigateBack() + navigateBack() + } + }, + ) + } } +} - @Deprecated("Deprecated in Java") - override fun onActivityCreated(savedInstanceState: Bundle?) { - super.onActivityCreated(savedInstanceState) - ui.paymentButton.setOnClickListener { - findNavController().navigateUp() +@Composable +private fun PaymentSuccessScreen( + onContinue: () -> Unit, +) { + PosTheme { + BoxWithConstraints( + modifier = Modifier.fillMaxSize(), + ) { + val verticalPadding = (maxHeight * 0.045f).coerceIn(16.dp, 32.dp) + val heroSpacing = (maxHeight * 0.035f).coerceIn(12.dp, 24.dp) + 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 + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(vertical = verticalPadding, horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.SpaceBetween, + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(heroSpacing), + ) { + Image( + painter = painterResource(R.drawable.ic_check_circle), + contentDescription = null, + modifier = Modifier.size(iconSize), + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary), + ) + Text( + text = stringResource(R.string.payment_received), + modifier = Modifier.fillMaxWidth(), + style = titleStyle, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + textAlign = TextAlign.Center, + ) + } + } + Button( + onClick = onContinue, + modifier = Modifier + .fillMaxWidth(buttonWidthFraction) + .heightIn(min = buttonMinHeight), + ) { + Text( + text = stringResource(R.string.payment_back_button), + fontWeight = FontWeight.SemiBold, + ) + } + } } } - } 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 @@ -16,218 +16,107 @@ package net.taler.merchantpos.payment -import android.graphics.Bitmap import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.activity.OnBackPressedCallback +import androidx.compose.foundation.layout.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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +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.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +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.style.TextAlign +import androidx.compose.ui.unit.dp import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels -import androidx.navigation.NavOptions -import androidx.navigation.fragment.findNavController -import androidx.core.content.ContextCompat -import androidx.lifecycle.lifecycleScope -import com.google.android.material.snackbar.BaseTransientBottomBar.LENGTH_LONG -import com.google.android.material.snackbar.Snackbar -import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel -import kotlinx.coroutines.launch -import net.taler.lib.android.QrCodeManager.makeQrCode -import net.taler.lib.android.copyToClipBoard -import net.taler.lib.android.fadeIn -import net.taler.lib.android.fadeOut -import net.taler.lib.android.shareText -import net.taler.lib.android.showError +import androidx.compose.runtime.livedata.observeAsState import net.taler.lib.android.AnimatedQrCodeComposable import net.taler.lib.android.TalerNfcService.Companion.hasNfc +import net.taler.lib.android.copyToClipBoard +import net.taler.lib.android.shareText +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.databinding.FragmentProcessPaymentBinding +import net.taler.merchantpos.showPosError class ProcessPaymentFragment : Fragment() { private val model: MainViewModel by activityViewModels() private val paymentManager by lazy { model.paymentManager } - private lateinit var ui: FragmentProcessPaymentBinding - private lateinit var qrPreviewBackCallback: OnBackPressedCallback - private var currentPayUri: String? = null - private var currentQrBitmap: Bitmap? = null - private var deviceHasNfc: Boolean = false + private var deviceHasNfc = false override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, + inflater: android.view.LayoutInflater, + container: android.view.ViewGroup?, savedInstanceState: Bundle?, - ): View { - ui = FragmentProcessPaymentBinding.inflate(inflater, container, false) - return ui.root + ) = ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + val payment by paymentManager.payment.observeAsState() + payment?.let { + ProcessPaymentScreen( + payment = it, + deviceHasNfc = deviceHasNfc, + onCancel = ::onPaymentCancel, + onShare = { uri -> requireContext().shareText(uri) }, + onCopy = { uri -> + copyToClipBoard(requireContext(), "Payment URI", uri) + }, + ) + } + } } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + override fun onViewCreated(view: android.view.View, savedInstanceState: Bundle?) { deviceHasNfc = hasNfc(requireContext()) - ui.payIntroView.setText(R.string.payment_intro) - // Show only a simple loader before the first QR bitmap is rendered. - ui.qrcodeLayout.visibility = View.INVISIBLE - ui.qrcodeView.visibility = View.INVISIBLE - ui.progressBar.visibility = View.VISIBLE - ui.shareButton.isEnabled = false - ui.copyButton.isEnabled = false paymentManager.payment.observe(viewLifecycleOwner) { payment -> onPaymentStateChanged(payment) } - ui.qrcodeView.setOnClickListener { - showQrPreview() - } - ui.qrPreviewOverlay.setOnClickListener { - hideQrPreview() - } - qrPreviewBackCallback = object : OnBackPressedCallback(false) { - override fun handleOnBackPressed() { - hideQrPreview() - } - } - requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, qrPreviewBackCallback) - ui.cancelPaymentButton.setOnClickListener { - onPaymentCancel() - } } override fun onDestroy() { super.onDestroy() - paymentManager.cancelPayment() } private fun onPaymentStateChanged(payment: Payment) { - val previewShouldClose = - payment.error != null || - payment.paid || - payment.claimed || - (currentPayUri != null && payment.talerPayUri != currentPayUri) - if (previewShouldClose) { - hideQrPreview() - } if (payment.error != null) { val (mainText, detailText) = getPaymentErrorDisplay(payment) - requireActivity().showError(mainText, detailText) - findNavController().navigateUp() + requireActivity().showPosError(mainText, detailText) + (requireActivity() as MainActivity).navigateBack() return } if (payment.paid) { model.orderManager.onOrderPaid(payment.order.id) - val nav = findNavController() - val previousDestinationId = nav.previousBackStackEntry?.destination?.id - val options = previousDestinationId?.let { - NavOptions.Builder() - .setPopUpTo(it, false) - .build() - } - nav.navigate(R.id.paymentSuccess, null, options) - return - } - if (payment.claimed) { - ui.qrcodeLayout.fadeOut() - ui.payIntroView.setText(R.string.payment_claimed) - } else { - val introRes = - if (deviceHasNfc && payment.talerPayUri != null) { - R.string.payment_intro_nfc - } else { - R.string.payment_intro - } - ui.payIntroView.setText(introRes) - payment.talerPayUri?.let { - val uriChanged = it != currentPayUri - if (uriChanged) { - currentPayUri = it - renderPaymentQrCode(it) { - ui.qrcodeView.visibility = View.VISIBLE - if (ui.qrcodeLayout.visibility != View.VISIBLE) { - ui.qrcodeLayout.fadeIn() - } - ui.progressBar.fadeOut() - } - ui.shareButton.setOnClickListener { _ -> - requireContext().shareText(it) - } - ui.copyButton.setOnClickListener { _ -> - copyToClipBoard(requireContext(), "Payment URI", it) - } - } else { - if (ui.qrcodeLayout.visibility != View.VISIBLE) { - ui.qrcodeLayout.fadeIn() - } - ui.qrcodeView.visibility = View.VISIBLE - ui.progressBar.fadeOut() - } - ui.shareButton.isEnabled = true - ui.copyButton.isEnabled = true - } - } - ui.payIntroView.fadeIn() - ui.amountView.text = payment.order.total.toString() - payment.orderId?.let { - ui.orderRefView.text = getString(R.string.payment_order_id, it) - ui.orderRefView.fadeIn() + (requireActivity() as MainActivity).navigateTo(PosDestination.PaymentSuccess) } } private fun onPaymentCancel() { paymentManager.cancelPayment() - findNavController().navigateUp() - Snackbar.make(requireView(), R.string.payment_canceled, LENGTH_LONG).show() - } - - private fun showQrPreview() { - val qrBitmap = currentQrBitmap ?: return - ui.qrPreviewImage.setImageBitmap(qrBitmap) - ui.qrPreviewOverlay.visibility = View.VISIBLE - qrPreviewBackCallback.isEnabled = true - } - - private fun hideQrPreview() { - if (ui.qrPreviewOverlay.visibility != View.VISIBLE) return - ui.qrPreviewOverlay.visibility = View.GONE - ui.qrPreviewImage.setImageDrawable(null) - qrPreviewBackCallback.isEnabled = false - } - - private fun renderPaymentQrCode(text: String, onRendered: (() -> Unit)? = null) { - ui.qrcodeView.post { - val blockSize = minOf(ui.qrcodeView.width, ui.qrcodeView.height).coerceAtLeast(256) - val qrSize = (blockSize * 0.88f).toInt().coerceAtLeast(256) - lifecycleScope.launch { - currentQrBitmap = makePaymentQrCode(text, qrSize) - } - ui.qrcodeView.setContent { - PosTheme { - AnimatedQrCodeComposable( - link = text, - logoPainter = painterResource(R.drawable.ic_taler_logo_qr), - modifier = Modifier.fillMaxSize(), - ) - } - } - onRendered?.invoke() - } - } - - private suspend fun makePaymentQrCode(text: String, size: Int): Bitmap { - return makeQrCode( - text = text, - size = size, - margin = 0, - errorCorrection = ErrorCorrectionLevel.H, - centerLogo = null, - centerLogoSize = null, - drawBackground = true, - darkColor = android.graphics.Color.BLACK, - lightColor = ContextCompat.getColor(requireContext(), R.color.colorSurfaceVariant), - trimQuietZone = true, - ) + (requireActivity() as MainActivity).navigateBack() } private fun getPaymentErrorDisplay(payment: Payment): Pair<String, String> { @@ -243,11 +132,255 @@ class ProcessPaymentFragment : Fragment() { "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 } } +} + +@Composable +private fun ProcessPaymentScreen( + payment: Payment, + deviceHasNfc: Boolean, + onCancel: () -> Unit, + onShare: (String) -> Unit, + onCopy: (String) -> Unit, +) { + 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 isTabletLayout = LocalConfiguration.current.smallestScreenWidthDp >= 720 + + if (isTabletLayout) { + TabletProcessPaymentScreen(payment, introText, onCancel, onShare, onCopy) + } else { + PhoneProcessPaymentScreen(payment, introText, onCancel, onShare, onCopy) + } + } +} + +@Composable +private fun TabletProcessPaymentScreen( + payment: Payment, + introText: String, + onCancel: () -> Unit, + onShare: (String) -> Unit, + onCopy: (String) -> Unit, +) { + Row( + modifier = Modifier + .fillMaxSize() + .statusBarsPadding() + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Column( + modifier = Modifier + .weight(0.54f) + .fillMaxSize(), + 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 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(), + ) + } + } + } + } + + payment.talerPayUri?.let { payUri -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + 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)) + } + } + } + } + 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 = 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, + ) + } + OutlinedButton( + onClick = onCancel, + modifier = Modifier.align(Alignment.Start), + colors = ButtonDefaults.outlinedButtonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + ) { + Text(stringResource(R.string.payment_cancel)) + } + } + } +} +@Composable +private fun PhoneProcessPaymentScreen( + payment: Payment, + introText: String, + onCancel: () -> Unit, + onShare: (String) -> Unit, + onCopy: (String) -> Unit, +) { + Row( + modifier = Modifier + .fillMaxSize() + .statusBarsPadding() + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Column( + modifier = Modifier + .weight(0.5f) + .fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentAlignment = Alignment.Center, + ) { + 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(), + ) + } + } + } + } + } + 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), + ) { + Button( + onClick = { onShare(payUri) }, + modifier = Modifier.weight(1f), + ) { + Text(stringResource(R.string.share)) + } + OutlinedButton( + onClick = { onCopy(payUri) }, + modifier = Modifier.weight(1f), + ) { + Text(stringResource(R.string.copy)) + } + } + } + OutlinedButton( + onClick = onCancel, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + ) { + Text(stringResource(R.string.payment_cancel)) + } + } + } } 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,100 +17,222 @@ package net.taler.merchantpos.refund import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.annotation.StringRes +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +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.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +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.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.navigation.fragment.findNavController +import androidx.compose.runtime.livedata.observeAsState +import net.taler.merchantpos.MainActivity import net.taler.common.Amount import net.taler.common.AmountParserException -import net.taler.lib.android.fadeIn -import net.taler.lib.android.fadeOut -import net.taler.lib.android.navigate -import net.taler.lib.android.showError import net.taler.merchantlib.OrderHistoryEntry import net.taler.merchantpos.MainViewModel +import net.taler.merchantpos.PosDestination import net.taler.merchantpos.R -import net.taler.merchantpos.databinding.FragmentRefundBinding -import net.taler.merchantpos.refund.RefundFragmentDirections.Companion.actionRefundFragmentToRefundUriFragment +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.showPosError class RefundFragment : Fragment() { private val model: MainViewModel by activityViewModels() private val refundManager by lazy { model.refundManager } - private lateinit var ui: FragmentRefundBinding - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - ui = FragmentRefundBinding.inflate(inflater, container, false) - return ui.root + inflater: android.view.LayoutInflater, + container: android.view.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() + return@setContent + } + RefundScreen( + item = item, + currencySpec = model.configManager.currencySpec, + onAbort = { (requireActivity() as MainActivity).navigateBack() }, + onRefund = ::onRefundButtonClicked, + ) + } + } + + override fun onViewCreated(view: android.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 + } + } } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - val item = refundManager.toBeRefunded ?: throw IllegalStateException() - val amount = item.amount.withSpec(model.configManager.currencySpec) - ui.amountInputView.setText(amount.toString(showSymbol = false)) - ui.currencyView.text = item.amount.currency - ui.abortButton.setOnClickListener { findNavController().navigateUp() } - ui.refundButton.setOnClickListener { onRefundButtonClicked(item) } + private fun onRefundButtonClicked(item: OrderHistoryEntry, amount: Amount, reason: String) { + refundManager.refund(item, amount, reason) + } - refundManager.refundResult.observe(viewLifecycleOwner, { result -> - onRefundResultChanged(result) - }) + private fun onError(mainResId: Int, details: String = "") { + requireActivity().showPosError(mainResId, details) } +} - private fun onRefundButtonClicked(item: OrderHistoryEntry) { - val maxAmount = item.amount.withSpec(model.configManager.currencySpec) +@Composable +private fun RefundScreen( + item: OrderHistoryEntry, + currencySpec: net.taler.common.CurrencySpecification?, + onAbort: () -> Unit, + onRefund: (OrderHistoryEntry, Amount, String) -> Unit, +) { + var amountText by remember { + mutableStateOf(item.amount.withSpec(currencySpec).amountStr) + } + var reason by remember { mutableStateOf("") } + 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 { - Amount.fromString(item.amount.currency, ui.amountInputView.text.toString()) - .withSpec(model.configManager.currencySpec) - } catch (e: AmountParserException) { - ui.amountView.error = getString(R.string.refund_error_invalid_amount) - return + if (normalizedAmountText.isEmpty()) { + maxAmount + } else { + Amount.fromString(item.amount.currency, normalizedAmountText).withSpec(currencySpec) + } + } catch (_: AmountParserException) { + errorText = invalidAmountText + return@submit } if (inputAmount > maxAmount) { - ui.amountView.error = getString( - R.string.refund_error_max_amount, - maxAmount.toString(showSymbol = false), - ) - return + errorText = maxAmountTemplate.replace("%s", maxAmount.toString(showSymbol = false)) + return@submit } if (inputAmount.isZero()) { - ui.amountView.error = getString(R.string.refund_error_zero) - return + errorText = zeroAmountText + return@submit } - ui.amountView.error = null - ui.refundButton.fadeOut() - // ui.progressBar.fadeIn() - refundManager.refund(item, inputAmount, ui.reasonInputView.text.toString()) + focusManager.clearFocus(force = true) + keyboardController?.hide() + onRefund(item, inputAmount, reason) } - private fun onRefundResultChanged(result: RefundResult?): Any = 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 -> { - ui.progressBar.fadeOut() - ui.refundButton.fadeIn() - navigate(actionRefundFragmentToRefundUriFragment()) + PosTheme { + LaunchedEffect(Unit) { + amountFocusRequester.requestFocus() } - null -> { // no-op + 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 + .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(), + ) { + Text(stringResource(R.string.refund_confirm)) + } + OutlinedButton( + onClick = onAbort, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.refund_abort)) + } + Spacer( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .pointerInput(Unit) { + detectTapGestures( + onTap = { + focusManager.clearFocus(force = true) + keyboardController?.hide() + }, + ) + }, + ) } } - - private fun onError(@StringRes main: Int, details: String = "") { - requireActivity().showError(main, details) - ui.progressBar.fadeOut() - ui.refundButton.fadeIn() - } - } 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 @@ -20,11 +20,13 @@ import androidx.annotation.UiThread 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.MerchantApi import net.taler.merchantlib.OrderHistoryEntry +import net.taler.merchantlib.MerchantApi import net.taler.merchantlib.RefundRequest import net.taler.merchantpos.config.ConfigManager @@ -45,23 +47,55 @@ class RefundManager( private val scope: CoroutineScope, private val api: MerchantApi ) { + private var refundStatusJob: Job? = null var toBeRefunded: OrderHistoryEntry? = null private set 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 refundReceived: LiveData<Boolean> = mRefundReceived @UiThread internal fun startRefund(item: OrderHistoryEntry) { + refundStatusJob?.cancel() toBeRefunded = item mRefundResult.value = null + mPendingRefundOrderId.value = null + mRefundReceived.value = false } @UiThread internal fun abortRefund() { + refundStatusJob?.cancel() + toBeRefunded = null + mRefundResult.value = null + mPendingRefundOrderId.value = null + mRefundReceived.value = false + } + + @UiThread + internal fun completeRefund() { + refundStatusJob?.cancel() toBeRefunded = null mRefundResult.value = null + mPendingRefundOrderId.value = null + mRefundReceived.value = false + } + + @UiThread + internal fun resumeRefund(item: OrderHistoryEntry): Boolean { + val current = mRefundResult.value as? RefundResult.Success ?: return false + if (current.item.orderId != item.orderId) return false + toBeRefunded = item + mPendingRefundOrderId.value = item.orderId + if (mRefundReceived.value != true) { + observeRefundStatus(item.orderId) + } + return true } @UiThread @@ -76,14 +110,46 @@ class RefundManager( amount = amount, reason = reason ) + mPendingRefundOrderId.value = item.orderId + mRefundReceived.value = false + observeRefundStatus(item.orderId) } } @UiThread private fun onRefundError(msg: String) { assertUiThread() + refundStatusJob?.cancel() + mPendingRefundOrderId.postValue(null) + mRefundReceived.postValue(false) if (msg.contains("2602")) { mRefundResult.postValue(RefundResult.AlreadyRefunded) } else mRefundResult.postValue(RefundResult.Error(msg)) } + + @UiThread + 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 + } + } + if (wasRefunded || mRefundReceived.value == true) break + delay(2_000) + } + } + } } 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 @@ -17,74 +17,311 @@ package net.taler.merchantpos.refund 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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material3.Button +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.ui.Alignment +import androidx.compose.ui.Modifier +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.style.TextAlign +import androidx.compose.ui.unit.dp import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels -import androidx.lifecycle.lifecycleScope -import androidx.navigation.fragment.findNavController -import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel -import kotlinx.coroutines.launch -import net.taler.lib.android.QrCodeManager.makeQrCode +import net.taler.merchantpos.MainActivity +import net.taler.lib.android.AnimatedQrCodeComposable import net.taler.lib.android.TalerNfcService.Companion.hasNfc import net.taler.merchantpos.MainViewModel import net.taler.merchantpos.R -import net.taler.merchantpos.databinding.FragmentRefundUriBinding +import net.taler.merchantpos.compose.PosTheme +import net.taler.merchantpos.showPosError class RefundUriFragment : Fragment() { private val model: MainViewModel by activityViewModels() private val refundManager by lazy { model.refundManager } - private lateinit var ui: FragmentRefundUriBinding - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - ui = FragmentRefundUriBinding.inflate(inflater, container, false) - return ui.root + inflater: android.view.LayoutInflater, + container: android.view.ViewGroup?, + savedInstanceState: Bundle?, + ) = ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + val result = refundManager.refundResult.value as? RefundResult.Success + if (result == null) { + requireActivity().showPosError(R.string.refund_state_missing) + (requireActivity() as MainActivity).navigateBack() + return@apply + } + setContent { + RefundUriScreen( + result = result, + deviceHasNfc = hasNfc(requireContext()), + onAbort = { + refundManager.abortRefund() + (requireActivity() as MainActivity).navigateBack() + }, + ) + } } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + override fun onViewCreated(view: android.view.View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - val result = refundManager.refundResult.value - if (result !is RefundResult.Success) throw IllegalStateException() - - lifecycleScope.launch { - ui.refundQrcodeView.setImageBitmap( - makeQrCode( - text = result.refundUri, - size = 256, - margin = 2, - errorCorrection = ErrorCorrectionLevel.M, - centerLogo = null, - centerLogoSize = null, - drawBackground = false, - darkColor = android.graphics.Color.BLACK, - lightColor = android.graphics.Color.WHITE, - trimQuietZone = false, - ) - ) + refundManager.refundReceived.observe(viewLifecycleOwner) { received -> + if (received == true) { + refundManager.completeRefund() + (requireActivity() as MainActivity).apply { + navigateBack() + navigateBack() + } + } } + } +} - val introRes = - if (hasNfc(requireContext())) R.string.refund_intro_nfc else R.string.refund_intro - ui.refundIntroView.setText(introRes) +@Composable +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 isTabletLayout = LocalConfiguration.current.smallestScreenWidthDp >= 720 - ui.refundAmountView.text = result.amount.toString() + if (isTabletLayout) { + TabletRefundUriScreen(result, introText, onAbort) + } else { + PhoneRefundUriScreen(result, introText, onAbort) + } + } +} - ui.refundRefView.text = - getString(R.string.refund_order_ref, result.item.orderId, result.reason) +@Composable +private fun TabletRefundUriScreen( + 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, + ) { + 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(), + ) + } + } + } + } - ui.cancelRefundButton.setOnClickListener { findNavController().navigateUp() } - ui.completeButton.setOnClickListener { findNavController().navigateUp() } + 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), + ) { + OutlinedButton( + onClick = onAbort, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.outlinedButtonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + ) { + Text(stringResource(R.string.refund_abort)) + } + } + } } +} - override fun onDestroy() { - super.onDestroy() - refundManager.abortRefund() - } +@Composable +private fun PhoneRefundUriScreen( + 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.5f) + .fillMaxSize(), + verticalArrangement = Arrangement.Center, + 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)) + } + } + } + } + } } diff --git a/merchant-terminal/src/main/res/drawable/ic_talerpos_logo.xml b/merchant-terminal/src/main/res/drawable/ic_talerpos_logo.xml @@ -0,0 +1,36 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="334dp" + android:height="104dp" + android:viewportWidth="1670" + 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" /> + <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" /> + <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="#000000" + android:pathData="M442.782,199.19H495.88V169.995H360.027V199.19H413.126V349.256H442.782V199.19Z" /> + <path + android:fillColor="#000000" + 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="#000000" + 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="#000000" + android:pathData="M966.376,169.997H842.482V349.258H967.572V320.833H871.662V272.943H955.613V244.518H871.662V198.422H966.376V169.997Z" /> + <path + android:fillColor="#000000" + 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="#000000" + 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" /> +</vector> diff --git a/merchant-terminal/src/main/res/layout/activity_main.xml b/merchant-terminal/src/main/res/layout/activity_main.xml @@ -1,41 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ This file is part of GNU Taler - ~ (C) 2020 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/> - --> - -<androidx.drawerlayout.widget.DrawerLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - android:id="@+id/drawer_layout" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:fitsSystemWindows="true" - tools:openDrawer="start"> - - <include - android:id="@+id/main" - layout="@layout/app_bar_main" - android:layout_width="match_parent" - android:layout_height="match_parent" /> - - <com.google.android.material.navigation.NavigationView - android:id="@+id/nav_view" - android:layout_width="wrap_content" - android:layout_height="match_parent" - android:layout_gravity="start" - android:fitsSystemWindows="true" - app:headerLayout="@layout/nav_header_main" - app:menu="@menu/activity_main_drawer" /> - -</androidx.drawerlayout.widget.DrawerLayout> diff --git a/merchant-terminal/src/main/res/layout/app_bar_main.xml b/merchant-terminal/src/main/res/layout/app_bar_main.xml @@ -1,51 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ This file is part of GNU Taler - ~ (C) 2020 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/> - --> - -<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="match_parent" - tools:context=".MainActivity"> - - <com.google.android.material.appbar.AppBarLayout - android:layout_width="match_parent" - android:layout_height="wrap_content"> - - <com.google.android.material.appbar.MaterialToolbar - android:id="@+id/toolbar" - android:layout_width="match_parent" - android:layout_height="?attr/actionBarSize" - android:background="?attr/colorSurface" - app:titleTextColor="?attr/colorOnSurface" /> - - </com.google.android.material.appbar.AppBarLayout> - - <androidx.fragment.app.FragmentContainerView - android:id="@+id/navHostFragment" - android:name="androidx.navigation.fragment.NavHostFragment" - android:layout_width="match_parent" - android:layout_height="match_parent" - app:defaultNavHost="true" - app:layout_behavior="@string/appbar_scrolling_view_behavior" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintLeft_toLeftOf="parent" - app:layout_constraintRight_toRightOf="parent" - app:layout_constraintTop_toTopOf="parent" - app:layout_insetEdge="top" - app:navGraph="@navigation/nav_graph" /> - -</androidx.coordinatorlayout.widget.CoordinatorLayout> diff --git a/merchant-terminal/src/main/res/layout/fragment_amount_entry.xml b/merchant-terminal/src/main/res/layout/fragment_amount_entry.xml @@ -1,291 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> - -<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="match_parent"> - - <androidx.constraintlayout.widget.Guideline - android:id="@+id/guidelineSplit" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:orientation="vertical" - app:layout_constraintGuide_percent="0.3" /> - - <androidx.constraintlayout.widget.ConstraintLayout - android:id="@+id/amountPane" - android:layout_width="0dp" - android:layout_height="0dp" - android:padding="16dp" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintEnd_toStartOf="@+id/guidelineSplit" - app:layout_constraintTop_toTopOf="parent" - app:layout_constraintBottom_toBottomOf="parent"> - - <TextView - android:id="@+id/amountView" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:ellipsize="start" - android:gravity="end" - android:maxLines="1" - android:paddingHorizontal="12dp" - android:textAppearance="?attr/textAppearanceHeadlineLarge" - android:textSize="56sp" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintTop_toTopOf="parent" - app:layout_constraintBottom_toTopOf="@+id/currencyLayout" - app:layout_constraintVertical_chainStyle="packed" - tools:text="12.34" /> - - <com.google.android.material.textfield.TextInputLayout - android:id="@+id/currencyLayout" - style="@style/Widget.Material3.TextInputLayout.OutlinedBox.Dense" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - app:boxBackgroundMode="outline" - app:endIconMode="dropdown_menu" - app:layout_constraintTop_toBottomOf="@+id/amountView" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toEndOf="parent"> - - <com.google.android.material.textfield.MaterialAutoCompleteTextView - android:id="@+id/currencyView" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:inputType="none" - android:paddingHorizontal="16dp" - tools:text="EUR" /> - </com.google.android.material.textfield.TextInputLayout> - </androidx.constraintlayout.widget.ConstraintLayout> - - <androidx.constraintlayout.widget.ConstraintLayout - android:id="@+id/keypadPane" - android:layout_width="0dp" - android:layout_height="0dp" - android:padding="8dp" - app:layout_constraintStart_toStartOf="@+id/guidelineSplit" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintTop_toTopOf="parent" - app:layout_constraintBottom_toBottomOf="parent"> - - <androidx.constraintlayout.widget.ConstraintLayout - android:id="@+id/numpad" - android:layout_width="0dp" - android:layout_height="0dp" - android:layout_marginBottom="4dp" - app:layout_constraintTop_toTopOf="parent" - app:layout_constraintBottom_toTopOf="@+id/chargeButton" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintEnd_toEndOf="parent"> - - <com.google.android.material.button.MaterialButton - android:id="@+id/key1" - style="@style/Widget.AmountEntry.Key" - android:layout_width="0dp" - android:layout_height="0dp" - android:layout_margin="2dp" - android:text="1" - android:textColor="@color/amount_entry_key_text" - android:textSize="@dimen/amount_entry_key_text_size" - app:backgroundTint="@color/amount_entry_key_background" - app:layout_constraintEnd_toStartOf="@+id/key2" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent" - app:layout_constraintBottom_toTopOf="@+id/key4" - app:layout_constraintHorizontal_chainStyle="spread" - app:layout_constraintVertical_chainStyle="spread" /> - - <com.google.android.material.button.MaterialButton - android:id="@+id/key2" - style="@style/Widget.AmountEntry.Key" - android:layout_width="0dp" - android:layout_height="0dp" - android:layout_marginHorizontal="2dp" - android:text="2" - android:textColor="@color/amount_entry_key_text" - android:textSize="@dimen/amount_entry_key_text_size" - app:backgroundTint="@color/amount_entry_key_background" - app:layout_constraintEnd_toStartOf="@+id/key3" - app:layout_constraintStart_toEndOf="@+id/key1" - app:layout_constraintTop_toTopOf="@+id/key1" - app:layout_constraintBottom_toBottomOf="@+id/key1" /> - - <com.google.android.material.button.MaterialButton - android:id="@+id/key3" - style="@style/Widget.AmountEntry.Key" - android:layout_width="0dp" - android:layout_height="0dp" - android:layout_marginHorizontal="2dp" - android:text="3" - android:textColor="@color/amount_entry_key_text" - android:textSize="@dimen/amount_entry_key_text_size" - app:backgroundTint="@color/amount_entry_key_background" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toEndOf="@+id/key2" - app:layout_constraintTop_toTopOf="@+id/key1" - app:layout_constraintBottom_toBottomOf="@+id/key1" /> - - <com.google.android.material.button.MaterialButton - android:id="@+id/key4" - style="@style/Widget.AmountEntry.Key" - android:layout_width="0dp" - android:layout_height="0dp" - android:layout_margin="2dp" - android:text="4" - android:textColor="@color/amount_entry_key_text" - android:textSize="@dimen/amount_entry_key_text_size" - app:backgroundTint="@color/amount_entry_key_background" - app:layout_constraintEnd_toStartOf="@+id/key5" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@+id/key1" - app:layout_constraintBottom_toTopOf="@+id/key7" - app:layout_constraintHorizontal_chainStyle="spread" /> - - <com.google.android.material.button.MaterialButton - android:id="@+id/key5" - style="@style/Widget.AmountEntry.Key" - android:layout_width="0dp" - android:layout_height="0dp" - android:layout_marginHorizontal="2dp" - android:text="5" - android:textColor="@color/amount_entry_key_text" - android:textSize="@dimen/amount_entry_key_text_size" - app:backgroundTint="@color/amount_entry_key_background" - app:layout_constraintEnd_toStartOf="@+id/key6" - app:layout_constraintStart_toEndOf="@+id/key4" - app:layout_constraintTop_toTopOf="@+id/key4" - app:layout_constraintBottom_toBottomOf="@+id/key4" /> - - <com.google.android.material.button.MaterialButton - android:id="@+id/key6" - style="@style/Widget.AmountEntry.Key" - android:layout_width="0dp" - android:layout_height="0dp" - android:layout_marginHorizontal="2dp" - android:text="6" - android:textColor="@color/amount_entry_key_text" - android:textSize="@dimen/amount_entry_key_text_size" - app:backgroundTint="@color/amount_entry_key_background" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toEndOf="@+id/key5" - app:layout_constraintTop_toTopOf="@+id/key4" - app:layout_constraintBottom_toBottomOf="@+id/key4" /> - - <com.google.android.material.button.MaterialButton - android:id="@+id/key7" - style="@style/Widget.AmountEntry.Key" - android:layout_width="0dp" - android:layout_height="0dp" - android:layout_margin="2dp" - android:text="7" - android:textColor="@color/amount_entry_key_text" - android:textSize="@dimen/amount_entry_key_text_size" - app:backgroundTint="@color/amount_entry_key_background" - app:layout_constraintEnd_toStartOf="@+id/key8" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@+id/key4" - app:layout_constraintBottom_toTopOf="@+id/keyClear" - app:layout_constraintHorizontal_chainStyle="spread" /> - - <com.google.android.material.button.MaterialButton - android:id="@+id/key8" - style="@style/Widget.AmountEntry.Key" - android:layout_width="0dp" - android:layout_height="0dp" - android:layout_marginHorizontal="2dp" - android:text="8" - android:textColor="@color/amount_entry_key_text" - android:textSize="@dimen/amount_entry_key_text_size" - app:backgroundTint="@color/amount_entry_key_background" - app:layout_constraintEnd_toStartOf="@+id/key9" - app:layout_constraintStart_toEndOf="@+id/key7" - app:layout_constraintTop_toTopOf="@+id/key7" - app:layout_constraintBottom_toBottomOf="@+id/key7" /> - - <com.google.android.material.button.MaterialButton - android:id="@+id/key9" - style="@style/Widget.AmountEntry.Key" - android:layout_width="0dp" - android:layout_height="0dp" - android:layout_marginHorizontal="2dp" - android:text="9" - android:textColor="@color/amount_entry_key_text" - android:textSize="@dimen/amount_entry_key_text_size" - app:backgroundTint="@color/amount_entry_key_background" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toEndOf="@+id/key8" - app:layout_constraintTop_toTopOf="@+id/key7" - app:layout_constraintBottom_toBottomOf="@+id/key7" /> - - <com.google.android.material.button.MaterialButton - android:id="@+id/keyClear" - style="@style/Widget.AmountEntry.Key" - android:layout_width="0dp" - android:layout_height="0dp" - android:layout_margin="2dp" - android:singleLine="true" - android:text="@string/amount_entry_clear" - android:textColor="@color/amount_entry_key_text" - android:textSize="22sp" - app:backgroundTint="@color/amount_entry_key_background" - app:layout_constraintEnd_toStartOf="@+id/key0" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@+id/key7" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintHorizontal_chainStyle="spread" /> - - <com.google.android.material.button.MaterialButton - android:id="@+id/key0" - style="@style/Widget.AmountEntry.Key" - android:layout_width="0dp" - android:layout_height="0dp" - android:layout_marginHorizontal="2dp" - android:text="0" - android:textColor="@color/amount_entry_key_text" - android:textSize="@dimen/amount_entry_key_text_size" - app:backgroundTint="@color/amount_entry_key_background" - app:layout_constraintEnd_toStartOf="@+id/keyBackspace" - app:layout_constraintStart_toEndOf="@+id/keyClear" - app:layout_constraintTop_toTopOf="@+id/keyClear" - app:layout_constraintBottom_toBottomOf="@+id/keyClear" /> - - <com.google.android.material.button.MaterialButton - android:id="@+id/keyBackspace" - style="@style/Widget.AmountEntry.Key" - android:layout_width="0dp" - android:layout_height="0dp" - android:layout_marginHorizontal="2dp" - android:contentDescription="@string/amount_entry_backspace" - android:gravity="center" - android:singleLine="true" - android:text="" - app:backgroundTint="@color/amount_entry_key_background" - app:icon="@drawable/ic_backspace" - app:iconGravity="textStart" - app:iconPadding="0dp" - app:iconSize="34dp" - app:iconTint="@color/amount_entry_key_text" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toEndOf="@+id/key0" - app:layout_constraintTop_toTopOf="@+id/keyClear" - app:layout_constraintBottom_toBottomOf="@+id/keyClear" /> - </androidx.constraintlayout.widget.ConstraintLayout> - - <Button - android:id="@+id/chargeButton" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:backgroundTint="@color/complete_button_bottom" - android:text="@string/amount_entry_create_order_charge" - android:textAllCaps="false" - android:textSize="20sp" - app:layout_constraintTop_toBottomOf="@+id/numpad" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintEnd_toEndOf="parent" /> - </androidx.constraintlayout.widget.ConstraintLayout> - -</androidx.constraintlayout.widget.ConstraintLayout> -\ No newline at end of file diff --git a/merchant-terminal/src/main/res/layout/fragment_categories.xml b/merchant-terminal/src/main/res/layout/fragment_categories.xml @@ -1,45 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ This file is part of GNU Taler - ~ (C) 2020 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/> - --> - -<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:layout_marginStart="8dp" - android:layout_marginEnd="8dp"> - - <androidx.recyclerview.widget.RecyclerView - android:id="@+id/categoriesList" - android:layout_width="0dp" - android:layout_height="0dp" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent" - tools:listitem="@layout/list_item_category" /> - - <ProgressBar - android:id="@+id/progressBar" - style="?android:attr/progressBarStyleLarge" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent" /> - -</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/merchant-terminal/src/main/res/layout/fragment_config_fetcher.xml b/merchant-terminal/src/main/res/layout/fragment_config_fetcher.xml @@ -1,44 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ This file is part of GNU Taler - ~ (C) 2020 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/> - --> - -<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:layout_margin="16dp"> - - <TextView - android:id="@+id/titleView" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_margin="16dp" - android:text="@string/config_fetching" - android:textAppearance="@style/TextAppearance.AppCompat.Headline" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent" /> - - <ProgressBar - android:id="@+id/progressBar" - style="?android:attr/progressBarStyleLarge" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_margin="16dp" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@+id/titleView" /> - -</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/merchant-terminal/src/main/res/layout/fragment_custom_dialog.xml b/merchant-terminal/src/main/res/layout/fragment_custom_dialog.xml @@ -1,117 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ This file is part of GNU Taler - ~ (C) 2023 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/> - --> - -<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="match_parent"> - - <TextView - android:id="@+id/titleView" - style="@style/TextAppearance.Material3.TitleMedium" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginStart="16dp" - android:layout_marginTop="16dp" - android:layout_marginEnd="16dp" - android:text="@string/order_custom" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent" /> - - <com.google.android.material.textfield.TextInputLayout - android:id="@+id/productNameLayout" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginStart="16dp" - android:layout_marginTop="16dp" - android:layout_marginEnd="16dp" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@+id/titleView"> - - <com.google.android.material.textfield.TextInputEditText - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:hint="@string/order_custom_product" - android:inputType="textShortMessage" - android:singleLine="true" - android:text="@string/order_custom_product_default" /> - </com.google.android.material.textfield.TextInputLayout> - - <com.google.android.material.textfield.TextInputLayout - android:id="@+id/amountLayout" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginStart="16dp" - android:layout_marginTop="16dp" - android:layout_marginEnd="8dp" - android:minEms="5" - app:layout_constraintEnd_toStartOf="@+id/currencyView" - app:layout_constraintHorizontal_bias="1.0" - app:layout_constraintHorizontal_chainStyle="packed" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@+id/productNameLayout"> - - <com.google.android.material.textfield.TextInputEditText - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:hint="@string/refund_amount" - android:inputType="numberDecimal"> - - <requestFocus /> - </com.google.android.material.textfield.TextInputEditText> - </com.google.android.material.textfield.TextInputLayout> - - <TextView - android:id="@+id/currencyView" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginEnd="16dp" - app:layout_constraintBottom_toBottomOf="@+id/amountLayout" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintHorizontal_bias="0.5" - app:layout_constraintStart_toEndOf="@+id/amountLayout" - app:layout_constraintTop_toTopOf="@+id/amountLayout" - tools:text="TESTKUDOS" /> - - <Button - android:id="@+id/addButton" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginTop="16dp" - android:layout_marginEnd="16dp" - android:layout_marginBottom="16dp" - android:text="@string/order_custom_add_button" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintTop_toBottomOf="@+id/amountLayout" - app:layout_constraintVertical_bias="0.0" /> - - <Button - android:id="@+id/cancelButton" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginStart="16dp" - android:layout_marginTop="16dp" - android:layout_marginEnd="16dp" - android:text="@android:string/cancel" - app:layout_constraintEnd_toStartOf="@+id/addButton" - app:layout_constraintHorizontal_bias="1.0" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@+id/amountLayout" /> -</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/merchant-terminal/src/main/res/layout/fragment_merchant_config.xml b/merchant-terminal/src/main/res/layout/fragment_merchant_config.xml @@ -1,223 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ This file is part of GNU Taler - ~ (C) 2020 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/> - --> - -<ScrollView xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:fillViewport="true"> - - <LinearLayout - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:orientation="vertical"> - - <!-- ─── 1) mode toggle ─────────────────────────────────────────────── --> - <com.google.android.material.button.MaterialButtonToggleGroup - android:id="@+id/configToggle" - android:layout_width="match_parent" - android:layout_height="wrap_content" - app:singleSelection="true" - android:padding="4dp" - app:layout_constraintTop_toTopOf="parent" - app:checkedButton="@id/newConfigButton"> - - <Button - style="?attr/materialButtonOutlinedStyle" - android:id="@+id/qrConfigButton" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_weight="1" - android:text="@string/config_qr_label" /> - - <Button - style="?attr/materialButtonOutlinedStyle" - android:id="@+id/newConfigButton" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_weight="1" - android:text="@string/config_setup_password" /> - </com.google.android.material.button.MaterialButtonToggleGroup> - - <!-- ─── 2) QR-scanner form ──────────────────────────────── --> - <androidx.constraintlayout.widget.ConstraintLayout - android:id="@+id/qrConfigForm" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:padding="24dp" - android:visibility="gone"> - - <!-- CameraX preview – now 50 % width, 60 % height --> - <androidx.camera.view.PreviewView - android:id="@+id/previewView" - android:layout_width="0dp" - android:layout_height="0dp" - android:scaleType="fitCenter" - - app:layout_constraintWidth_percent="0.5" - app:layout_constraintHeight_percent="0.8" - - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintTop_toTopOf="parent" - app:layout_constraintBottom_toBottomOf="parent" /> - - <!-- help / status text --> - <TextView - android:id="@+id/hintView" - style="@style/TextAppearance.Material3.BodyMedium" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginTop="16dp" - android:text="@string/scan_qr_hint" - app:layout_constraintTop_toBottomOf="@id/previewView" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintEnd_toEndOf="parent" /> - - <!-- progress spinner --> - <ProgressBar - android:id="@+id/progressBarQr" - style="?android:attr/progressBarStyleLarge" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:visibility="invisible" - app:layout_constraintTop_toTopOf="@id/previewView" - app:layout_constraintBottom_toBottomOf="@id/previewView" - app:layout_constraintStart_toStartOf="@id/previewView" - app:layout_constraintEnd_toEndOf="@id/previewView" /> - - </androidx.constraintlayout.widget.ConstraintLayout> - - <!-- ─── 3) Manual-token form (unchanged) ──────────────────────────── --> - <androidx.constraintlayout.widget.ConstraintLayout - android:id="@+id/newConfigForm" - android:layout_width="match_parent" - android:layout_height="wrap_content"> - - <!-- Merchant-URL field --> - <com.google.android.material.textfield.TextInputLayout - android:id="@+id/merchantUrlView" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_margin="16dp" - android:hint="@string/config_merchant_url" - app:boxBackgroundMode="outline" - app:boxBackgroundColor="@android:color/transparent" - app:prefixText="https://" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintTop_toTopOf="parent"> - - <com.google.android.material.textfield.TextInputEditText - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:inputType="textUri" /> - </com.google.android.material.textfield.TextInputLayout> - - <!-- Adding Username-URL field --> - <com.google.android.material.textfield.TextInputLayout - android:id="@+id/usernameView" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_margin="16dp" - android:hint="@string/config_username" - app:boxBackgroundMode="outline" - app:boxBackgroundColor="@android:color/transparent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintTop_toBottomOf="@id/merchantUrlView"> - - <com.google.android.material.textfield.TextInputEditText - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:inputType="textUri" /> - </com.google.android.material.textfield.TextInputLayout> - - <!-- Access-token field --> - <com.google.android.material.textfield.TextInputLayout - android:id="@+id/tokenView" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_margin="16dp" - android:hint="@string/config_password" - app:boxBackgroundMode="outline" - app:boxBackgroundColor="@android:color/transparent" - app:endIconMode="password_toggle" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintEnd_toStartOf="@+id/forgetTokenButton" - app:layout_constraintTop_toBottomOf="@id/usernameView"> - - <com.google.android.material.textfield.TextInputEditText - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:inputType="textWebPassword" /> - </com.google.android.material.textfield.TextInputLayout> - - <!-- “Forget” token button --> - <Button - android:id="@+id/forgetTokenButton" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_margin="16dp" - android:text="@string/config_forget_password" - android:visibility="gone" - app:layout_constraintTop_toTopOf="@id/tokenView" - app:layout_constraintBottom_toBottomOf="@id/tokenView" - app:layout_constraintEnd_toEndOf="parent" /> - - <!-- save-password checkbox --> - <CheckBox - android:id="@+id/saveTokenCheckBox" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginStart="16dp" - android:layout_marginTop="16dp" - android:layout_marginBottom="16dp" - android:checked="true" - android:text="@string/config_save_password" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintEnd_toStartOf="@+id/okNewButton" - app:layout_constraintTop_toBottomOf="@id/tokenView" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintHorizontal_chainStyle="spread_inside" /> - - <!-- “OK” button --> - <com.google.android.material.button.MaterialButton - android:id="@+id/okNewButton" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_margin="16dp" - android:text="@string/config_ok" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintTop_toBottomOf="@id/tokenView" - app:layout_constraintBottom_toBottomOf="parent" /> - - <!-- progress spinner --> - <ProgressBar - android:id="@+id/progressBarNew" - style="?android:attr/progressBarStyle" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:visibility="invisible" - app:layout_constraintStart_toStartOf="@id/okNewButton" - app:layout_constraintEnd_toEndOf="@id/okNewButton" - app:layout_constraintTop_toTopOf="@id/okNewButton" - app:layout_constraintBottom_toBottomOf="@id/okNewButton" /> - - </androidx.constraintlayout.widget.ConstraintLayout> - - </LinearLayout> -</ScrollView> diff --git a/merchant-terminal/src/main/res/layout/fragment_merchant_history.xml b/merchant-terminal/src/main/res/layout/fragment_merchant_history.xml @@ -1,28 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ This file is part of GNU Taler - ~ (C) 2020 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/> - --> - -<androidx.swiperefreshlayout.widget.SwipeRefreshLayout xmlns:android="http://schemas.android.com/apk/res/android" - android:id="@+id/swipeRefresh" - android:layout_width="match_parent" - android:layout_height="match_parent"> - - <androidx.recyclerview.widget.RecyclerView - android:id="@+id/list_history" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:scrollbars="vertical" /> - -</androidx.swiperefreshlayout.widget.SwipeRefreshLayout> diff --git a/merchant-terminal/src/main/res/layout/fragment_order.xml b/merchant-terminal/src/main/res/layout/fragment_order.xml @@ -1,154 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ This file is part of GNU Taler - ~ (C) 2020 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/> - --> - -<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="match_parent"> - - <androidx.fragment.app.FragmentContainerView - android:id="@+id/fragment1" - android:layout_width="0dp" - android:layout_height="0dp" - android:layout_marginBottom="8dp" - app:layout_constraintBottom_toTopOf="@+id/orderControlsBar" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="@+id/guideline2" - app:layout_constraintTop_toTopOf="parent" - tools:layout="@layout/fragment_order_state" /> - - <androidx.constraintlayout.widget.Guideline - android:id="@+id/guideline1" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:orientation="vertical" - app:layout_constraintGuide_percent="0.25" /> - - <androidx.fragment.app.FragmentContainerView - android:id="@+id/fragment2" - android:name="net.taler.merchantpos.order.ProductsFragment" - android:layout_width="0dp" - android:layout_height="0dp" - android:layout_marginBottom="8dp" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toStartOf="@+id/guideline2" - app:layout_constraintStart_toStartOf="@+id/guideline1" - app:layout_constraintTop_toTopOf="parent" - tools:layout="@layout/fragment_products" /> - - <androidx.constraintlayout.widget.Guideline - android:id="@+id/guideline2" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:orientation="vertical" - app:layout_constraintGuide_percent="0.75" /> - - <View - android:id="@+id/orderDivider" - android:layout_width="1dp" - android:layout_height="0dp" - android:background="?attr/colorOutlineVariant" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toEndOf="@id/guideline2" - app:layout_constraintStart_toStartOf="@id/guideline2" - app:layout_constraintTop_toTopOf="parent" /> - - <androidx.fragment.app.FragmentContainerView - android:id="@+id/fragment3" - android:name="net.taler.merchantpos.order.CategoriesFragment" - android:layout_width="0dp" - android:layout_height="0dp" - android:layout_marginBottom="8dp" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toStartOf="@+id/guideline1" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent" - tools:layout="@layout/fragment_categories" /> - - <HorizontalScrollView - android:id="@+id/orderControlsBar" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginBottom="12dp" - android:fadeScrollbars="false" - android:scrollbars="horizontal" - app:layout_constraintBottom_toTopOf="@id/completeButton" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="@id/guideline2"> - - <LinearLayout - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:orientation="horizontal"> - - <Button - android:id="@+id/plusButton" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginStart="12dp" - android:backgroundTint="@color/order_control_button_background" - android:minWidth="48dp" - android:text="+1" - android:textColor="@color/order_control_button_text" - tools:ignore="HardcodedText" /> - - <Button - android:id="@+id/minusButton" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginStart="12dp" - android:backgroundTint="@color/order_control_button_background" - android:minWidth="48dp" - android:text="-1" - android:textColor="@color/order_control_button_text" - tools:ignore="HardcodedText" /> - - <Button - android:id="@+id/tipButton" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginStart="12dp" - android:layout_marginEnd="12dp" - android:backgroundTint="@color/order_control_button_background" - android:minWidth="48dp" - android:text="@string/order_custom_product_default" - android:textColor="@color/order_control_button_text" /> - </LinearLayout> - - </HorizontalScrollView> - - <com.google.android.material.button.MaterialButton - android:id="@+id/completeButton" - android:layout_width="0dp" - android:layout_height="96dp" - android:backgroundTint="@color/complete_button_bottom" - android:insetLeft="0dp" - android:insetTop="0dp" - android:insetRight="0dp" - android:insetBottom="0dp" - android:maxLines="1" - android:minWidth="0dp" - android:minHeight="0dp" - android:text="@string/order_complete" - android:textSize="18sp" - android:textStyle="bold" - app:cornerRadius="0dp" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="@+id/guideline2" /> - -</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/merchant-terminal/src/main/res/layout/fragment_order_state.xml b/merchant-terminal/src/main/res/layout/fragment_order_state.xml @@ -1,33 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ This file is part of GNU Taler - ~ (C) 2020 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/> - --> - -<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="match_parent"> - - <androidx.recyclerview.widget.RecyclerView - android:id="@+id/orderList" - android:layout_width="0dp" - android:layout_height="0dp" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent" - tools:listitem="@layout/list_item_order" /> - -</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/merchant-terminal/src/main/res/layout/fragment_payment_success.xml b/merchant-terminal/src/main/res/layout/fragment_payment_success.xml @@ -1,82 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ This file is part of GNU Taler - ~ (C) 2020 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/> - --> - -<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="match_parent" - tools:context=".payment.PaymentSuccessFragment"> - - <ImageView - android:id="@+id/paymentIcon" - android:layout_width="0dp" - android:layout_height="0dp" - android:layout_margin="16dp" - android:src="@drawable/ic_check_circle" - app:layout_constraintBottom_toTopOf="@+id/paymentLabel" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintHorizontal_bias="0.5" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent" - app:layout_constraintVertical_chainStyle="spread_inside" - app:layout_constraintVertical_weight="1.6" - tools:ignore="ContentDescription" /> - - <TextView - android:id="@+id/paymentLabel" - android:layout_width="0dp" - android:layout_height="0dp" - android:layout_margin="16dp" - android:gravity="center_horizontal|top" - android:text="@string/payment_received" - android:textColor="@color/green" - app:autoSizeMaxTextSize="42sp" - app:autoSizeTextType="uniform" - app:layout_constraintVertical_weight="1.0" - app:layout_constraintBottom_toTopOf="@+id/paymentButton" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@+id/paymentIcon" /> - - <androidx.constraintlayout.widget.Guideline - android:id="@+id/guidelineLeft" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:orientation="vertical" - app:layout_constraintGuide_percent="0.2" /> - - <androidx.constraintlayout.widget.Guideline - android:id="@+id/guidelineRight" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:orientation="vertical" - app:layout_constraintGuide_percent="0.8" /> - - <Button - android:id="@+id/paymentButton" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_margin="16dp" - android:minHeight="64dp" - android:paddingVertical="14dp" - android:text="@string/payment_back_button" - android:textSize="20sp" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toStartOf="@+id/guidelineRight" - app:layout_constraintStart_toStartOf="@+id/guidelineLeft" /> - -</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/merchant-terminal/src/main/res/layout/fragment_process_payment.xml b/merchant-terminal/src/main/res/layout/fragment_process_payment.xml @@ -1,173 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ This file is part of GNU Taler - ~ (C) 2020 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/> - --> - -<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="match_parent" - tools:context=".payment.ProcessPaymentFragment"> - - <androidx.constraintlayout.widget.ConstraintLayout - android:id="@+id/qrcodeLayout" - android:layout_width="0dp" - android:layout_height="0dp" - android:layout_margin="12dp" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toStartOf="@+id/guideline" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent" - android:visibility="invisible" - tools:visibility="visible"> - - <FrameLayout - android:id="@+id/qrcodeContainer" - android:layout_width="0dp" - android:layout_height="0dp" - android:layout_margin="12dp" - app:layout_constraintDimensionRatio="1:1" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintTop_toTopOf="parent" - app:layout_constraintBottom_toTopOf="@id/shareButton"> - - <androidx.compose.ui.platform.ComposeView - android:id="@+id/qrcodeView" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:layout_margin="6dp" - tools:ignore="ContentDescription" /> - - </FrameLayout> - - <Button - android:id="@+id/shareButton" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintEnd_toStartOf="@id/copyButton" - android:text="@string/share"/> - - <Button - android:id="@+id/copyButton" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toEndOf="@id/shareButton" - android:text="@string/copy"/> - - </androidx.constraintlayout.widget.ConstraintLayout> - - <ProgressBar - android:id="@+id/progressBar" - style="?android:attr/progressBarStyleLarge" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:indeterminateTint="@color/colorPrimary" - app:layout_constraintBottom_toBottomOf="@+id/qrcodeLayout" - app:layout_constraintEnd_toEndOf="@+id/qrcodeLayout" - app:layout_constraintStart_toStartOf="@+id/qrcodeLayout" - app:layout_constraintTop_toTopOf="@+id/qrcodeLayout" /> - - <androidx.constraintlayout.widget.Guideline - android:id="@+id/guideline" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:orientation="vertical" - app:layout_constraintGuide_percent="0.54" /> - - <TextView - android:id="@+id/payIntroView" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_margin="16dp" - android:text="@string/payment_intro_nfc" - android:textAlignment="center" - android:textSize="22sp" - android:visibility="invisible" - app:layout_constraintBottom_toTopOf="@+id/amountView" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="@+id/guideline" - app:layout_constraintTop_toTopOf="parent" - app:layout_constraintVertical_chainStyle="spread" - tools:visibility="visible" /> - - <TextView - android:id="@+id/amountView" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_margin="16dp" - android:textAppearance="@style/TextAppearance.AppCompat.Headline" - app:layout_constraintBottom_toTopOf="@+id/orderRefView" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="@+id/guideline" - app:layout_constraintTop_toBottomOf="@+id/payIntroView" - tools:text="10.49 TESTKUDOS" /> - - <TextView - android:id="@+id/orderRefView" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_margin="16dp" - android:textAlignment="center" - android:visibility="invisible" - app:layout_constraintBottom_toTopOf="@id/cancelPaymentButton" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="@+id/guideline" - app:layout_constraintTop_toBottomOf="@+id/amountView" - tools:text="@string/payment_order_id" - tools:visibility="visible" /> - - <Button - android:id="@+id/cancelPaymentButton" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_margin="16dp" - android:backgroundTint="@color/red" - android:textColor="?attr/colorOnError" - android:text="@string/payment_cancel" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintHorizontal_bias="0.0" - app:layout_constraintStart_toStartOf="@+id/guideline" /> - - <FrameLayout - android:id="@+id/qrPreviewOverlay" - android:layout_width="0dp" - android:layout_height="0dp" - android:background="#CC000000" - android:clickable="true" - android:focusable="true" - android:visibility="gone" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent"> - - <ImageView - android:id="@+id/qrPreviewImage" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:layout_gravity="center" - android:layout_margin="24dp" - android:adjustViewBounds="true" - android:scaleType="fitCenter" - tools:src="@tools:sample/avatars" /> - </FrameLayout> - -</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/merchant-terminal/src/main/res/layout/fragment_products.xml b/merchant-terminal/src/main/res/layout/fragment_products.xml @@ -1,43 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ This file is part of GNU Taler - ~ (C) 2020 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/> - --> - -<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="match_parent"> - - <androidx.recyclerview.widget.RecyclerView - android:id="@+id/productsList" - android:layout_width="0dp" - android:layout_height="0dp" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent" - tools:listitem="@layout/list_item_product" /> - - <ProgressBar - android:id="@+id/progressBar" - style="?android:attr/progressBarStyleLarge" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent" /> - -</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/merchant-terminal/src/main/res/layout/fragment_refund.xml b/merchant-terminal/src/main/res/layout/fragment_refund.xml @@ -1,122 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ This file is part of GNU Taler - ~ (C) 2020 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/> - --> - -<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="match_parent" - tools:context=".refund.RefundFragment"> - - <com.google.android.material.textfield.TextInputLayout - android:id="@+id/amountView" - style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.Dense" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_margin="16dp" - android:hint="@string/refund_amount" - app:boxBackgroundMode="outline" - app:endIconMode="clear_text" - app:endIconTint="?attr/colorControlNormal" - app:layout_constraintBottom_toTopOf="@+id/reasonView" - app:layout_constraintEnd_toStartOf="@+id/currencyView" - app:layout_constraintHorizontal_chainStyle="packed" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent" - app:layout_constraintVertical_chainStyle="spread"> - - <com.google.android.material.textfield.TextInputEditText - android:id="@+id/amountInputView" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:ems="6" - android:inputType="numberDecimal" - tools:text="23.42" /> - - </com.google.android.material.textfield.TextInputLayout> - - <TextView - android:id="@+id/currencyView" - android:layout_width="wrap_content" - android:layout_height="0dp" - android:layout_marginStart="8dp" - android:gravity="start|center_vertical" - app:layout_constraintBottom_toBottomOf="@+id/amountView" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toEndOf="@+id/amountView" - app:layout_constraintTop_toTopOf="@+id/amountView" - tools:text="TESTKUDOS" /> - - <com.google.android.material.textfield.TextInputLayout - android:id="@+id/reasonView" - style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.Dense" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_margin="16dp" - android:hint="@string/refund_reason" - app:endIconMode="clear_text" - app:layout_constraintBottom_toTopOf="@+id/abortButton" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintHorizontal_bias="0.5" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@+id/amountView"> - - <com.google.android.material.textfield.TextInputEditText - android:id="@+id/reasonInputView" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:inputType="textAutoComplete|textAutoCorrect|textMultiLine" /> - - </com.google.android.material.textfield.TextInputLayout> - - <Button - android:id="@+id/abortButton" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_margin="16dp" - android:backgroundTint="@color/red" - android:text="@string/refund_abort" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toStartOf="@+id/refundButton" - app:layout_constraintHorizontal_bias="0.76" - app:layout_constraintHorizontal_chainStyle="spread_inside" - app:layout_constraintStart_toStartOf="parent" /> - - <Button - android:id="@+id/refundButton" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_margin="16dp" - android:backgroundTint="@color/green" - android:text="@string/refund_confirm" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintHorizontal_bias="0.5" - app:layout_constraintStart_toEndOf="@+id/abortButton" /> - - <ProgressBar - android:id="@+id/progressBar" - style="?android:attr/progressBarStyle" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:visibility="invisible" - app:layout_constraintBottom_toBottomOf="@+id/refundButton" - app:layout_constraintEnd_toEndOf="@+id/refundButton" - app:layout_constraintStart_toStartOf="@+id/refundButton" - app:layout_constraintTop_toTopOf="@+id/refundButton" - tools:visibility="visible" /> - -</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/merchant-terminal/src/main/res/layout/fragment_refund_uri.xml b/merchant-terminal/src/main/res/layout/fragment_refund_uri.xml @@ -1,106 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ This file is part of GNU Taler - ~ (C) 2020 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/> - --> - -<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="match_parent" - tools:context=".payment.ProcessPaymentFragment"> - - <ImageView - android:id="@+id/refundQrcodeView" - android:layout_width="0dp" - android:layout_height="0dp" - android:layout_margin="32dp" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toStartOf="@+id/guideline" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent" - tools:ignore="ContentDescription" - tools:src="@tools:sample/avatars" /> - - <androidx.constraintlayout.widget.Guideline - android:id="@+id/guideline" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:orientation="vertical" - app:layout_constraintGuide_percent="0.54" /> - - <TextView - android:id="@+id/refundIntroView" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_margin="16dp" - android:text="@string/refund_intro_nfc" - android:textAlignment="center" - android:textSize="22sp" - app:layout_constraintBottom_toTopOf="@+id/refundAmountView" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="@+id/guideline" - app:layout_constraintTop_toTopOf="parent" - app:layout_constraintVertical_chainStyle="spread" /> - - <TextView - android:id="@+id/refundAmountView" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_margin="16dp" - android:textAppearance="@style/TextAppearance.AppCompat.Headline" - app:layout_constraintBottom_toTopOf="@+id/refundRefView" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="@+id/guideline" - app:layout_constraintTop_toBottomOf="@+id/refundIntroView" - tools:text="10.49 TESTKUDOS" /> - - <TextView - android:id="@+id/refundRefView" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_margin="16dp" - android:textAlignment="center" - app:layout_constraintBottom_toTopOf="@id/cancelRefundButton" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="@+id/guideline" - app:layout_constraintTop_toBottomOf="@+id/refundAmountView" - tools:text="@string/refund_order_ref" /> - - <Button - android:id="@+id/cancelRefundButton" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_margin="16dp" - android:backgroundTint="@color/red" - android:text="@string/refund_abort" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintEnd_toStartOf="@+id/completeButton" - app:layout_constraintHorizontal_chainStyle="spread_inside" - app:layout_constraintStart_toEndOf="@+id/refundQrcodeView" - app:layout_constraintStart_toStartOf="@+id/guideline" /> - - <Button - android:id="@+id/completeButton" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_margin="16dp" - android:backgroundTint="@color/green" - android:text="@string/refund_complete" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toEndOf="@+id/cancelRefundButton" /> - -</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/merchant-terminal/src/main/res/layout/list_item_category.xml b/merchant-terminal/src/main/res/layout/list_item_category.xml @@ -1,34 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ This file is part of GNU Taler - ~ (C) 2020 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/> - --> - -<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="wrap_content"> - - <Button - android:id="@+id/button" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:backgroundTint="?attr/colorSecondaryContainer" - android:textColor="?attr/colorOnSecondaryContainer" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent" - tools:text="Snacks" /> - -</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/merchant-terminal/src/main/res/layout/list_item_history.xml b/merchant-terminal/src/main/res/layout/list_item_history.xml @@ -1,112 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ This file is part of GNU Taler - ~ (C) 2020 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/> - --> - -<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:padding="16dp"> - - <TextView - android:id="@+id/orderSummaryView" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:textColor="?android:attr/textColorPrimary" - android:textSize="20sp" - android:textStyle="bold" - app:layout_constraintEnd_toStartOf="@+id/orderAmountView" - app:layout_constraintHorizontal_bias="1.0" - app:layout_constraintHorizontal_chainStyle="spread_inside" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent" - tools:text="One Cappuccino or another name that can be so long that it spans more than one line" /> - - <TextView - android:id="@+id/orderAmountView" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginStart="8dp" - android:layout_marginEnd="16dp" - android:textColor="?android:attr/textColorPrimary" - android:textSize="20sp" - android:textStyle="bold" - app:layout_constraintBottom_toBottomOf="@+id/orderSummaryView" - app:layout_constraintEnd_toStartOf="@+id/actionContainer" - app:layout_constraintStart_toEndOf="@+id/orderSummaryView" - app:layout_constraintTop_toTopOf="parent" - app:layout_constraintVertical_bias="0.0" - tools:text="23.42 TESTKUDOS" /> - - <TextView - android:id="@+id/orderIdView" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginTop="8dp" - android:text="@string/history_ref_no" - android:textAllCaps="false" - android:textSize="20sp" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toStartOf="@+id/orderTimeView" - app:layout_constraintHorizontal_bias="0.5" - app:layout_constraintHorizontal_chainStyle="spread_inside" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@+id/orderSummaryView" /> - - <TextView - android:id="@+id/orderTimeView" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginStart="8dp" - android:layout_marginTop="8dp" - android:layout_marginEnd="16dp" - android:textSize="20sp" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toStartOf="@+id/actionContainer" - app:layout_constraintStart_toEndOf="@+id/orderIdView" - app:layout_constraintTop_toBottomOf="@+id/orderAmountView" - app:layout_constraintVertical_bias="1.0" - tools:text="3 hrs. ago" /> - - <FrameLayout - android:id="@+id/actionContainer" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintTop_toTopOf="parent"> - - <ImageButton - android:id="@+id/refundButton" - android:layout_width="48dp" - android:layout_height="48dp" - android:backgroundTint="?colorPrimary" - android:contentDescription="@string/history_refund" - app:srcCompat="@drawable/ic_cash_refund" - app:tint="?attr/colorOnPrimary" /> - - <com.google.android.material.button.MaterialButton - android:id="@+id/deleteButton" - style="@style/Widget.MaterialComponents.Button" - android:layout_width="wrap_content" - android:layout_height="48dp" - android:minWidth="0dp" - android:text="@string/order_delete" - android:textAllCaps="false" - android:visibility="gone" /> - </FrameLayout> - -</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/merchant-terminal/src/main/res/layout/list_item_order.xml b/merchant-terminal/src/main/res/layout/list_item_order.xml @@ -1,88 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ This file is part of GNU Taler - ~ (C) 2020 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/> - --> - -<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:background="@drawable/selectable_background" - android:minHeight="48dp" - android:padding="8dp"> - - <TextView - android:id="@+id/quantity" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:gravity="end" - android:minWidth="24dp" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="@+id/name" - app:layout_constraintVertical_bias="0.0" - tools:text="31" /> - - <ImageView - android:id="@+id/image" - android:layout_width="32dp" - android:layout_height="32dp" - android:layout_marginHorizontal="10dp" - app:layout_constraintTop_toTopOf="@id/name" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintStart_toEndOf="@id/quantity" - android:visibility="gone" - android:contentDescription="@string/product_image" - app:layout_constraintVertical_bias="0.0" - tools:visibility="visible" - tools:src="@drawable/ic_launcher_background"/> - - <TextView - android:id="@+id/name" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginStart="8dp" - android:layout_marginEnd="8dp" - app:layout_constraintEnd_toStartOf="@+id/price" - app:layout_constraintStart_toEndOf="@+id/image" - app:layout_constraintTop_toTopOf="parent" - tools:text="An order product item that in some cases could have a very long name" /> - - <TextView - android:id="@+id/description" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginStart="8dp" - android:layout_marginTop="2dp" - android:layout_marginEnd="8dp" - android:textColor="?android:textColorSecondary" - android:visibility="gone" - app:layout_constraintEnd_toStartOf="@+id/price" - app:layout_constraintStart_toEndOf="@+id/image" - app:layout_constraintTop_toBottomOf="@+id/name" - tools:text="Subtitle" - tools:visibility="visible" /> - - <TextView - android:id="@+id/price" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintTop_toTopOf="@+id/name" - app:layout_constraintVertical_bias="0.0" - tools:text="23.42" /> - -</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/merchant-terminal/src/main/res/layout/list_item_product.xml b/merchant-terminal/src/main/res/layout/list_item_product.xml @@ -1,96 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ This file is part of GNU Taler - ~ (C) 2020 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/> - --> - -<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_margin="4dp" - android:clickable="true" - android:focusable="true" - app:cardUseCompatPadding="true"> - - <androidx.constraintlayout.widget.ConstraintLayout - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:padding="8dp"> - - <ImageView - android:id="@+id/image" - android:layout_width="64dp" - android:layout_height="64dp" - android:padding="10dp" - android:contentDescription="@string/product_image" - android:visibility="gone" - app:layout_constraintTop_toTopOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintEnd_toEndOf="parent" - tools:visibility="visible" - tools:src="@drawable/ic_launcher_background"/> - - <TextView - android:id="@+id/name" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:textColor="?android:textColorPrimary" - android:textStyle="bold" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/image" - tools:text="Steak and two Eggs" /> - - <TextView - android:id="@+id/description" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginTop="4dp" - android:textColor="?android:textColorSecondary" - android:visibility="gone" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/name" - tools:text="Two eggs, potatoes, and steak" - tools:visibility="visible" /> - - <TextView - android:id="@+id/price" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginTop="8dp" - android:textColor="?android:textColorSecondary" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintTop_toBottomOf="@+id/description" - tools:text="7.95" /> - - <TextView - android:id="@+id/unavailableLabel" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginTop="4dp" - android:text="@string/product_unavailable" - android:textColor="?attr/colorError" - android:textStyle="bold" - android:visibility="gone" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/price" - tools:visibility="visible" /> - - </androidx.constraintlayout.widget.ConstraintLayout> - -</com.google.android.material.card.MaterialCardView> diff --git a/merchant-terminal/src/main/res/layout/nav_header_main.xml b/merchant-terminal/src/main/res/layout/nav_header_main.xml @@ -1,54 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ This file is part of GNU Taler - ~ (C) 2020 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/> - --> - -<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="@dimen/nav_header_height" - android:background="@drawable/side_nav_bar" - android:gravity="bottom" - android:orientation="vertical" - android:paddingLeft="@dimen/activity_horizontal_margin" - android:paddingTop="@dimen/activity_vertical_margin" - android:paddingRight="@dimen/activity_horizontal_margin" - android:paddingBottom="@dimen/activity_vertical_margin" - android:theme="@style/AppTheme"> - - <ImageView - android:id="@+id/imageView" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:paddingTop="@dimen/nav_header_vertical_spacing" - app:srcCompat="@mipmap/ic_taler_logo_round" - tools:ignore="ContentDescription" /> - - <TextView - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:paddingTop="@dimen/nav_header_vertical_spacing" - android:text="@string/project_name" - android:textAppearance="@style/TextAppearance.AppCompat.Body1" - android:textColor="#FFF" /> - - <TextView - android:id="@+id/textView" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:text="@string/app_name_short" - android:textColor="#FFF" /> - -</LinearLayout> diff --git a/merchant-terminal/src/main/res/menu/activity_main_drawer.xml b/merchant-terminal/src/main/res/menu/activity_main_drawer.xml @@ -1,40 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ This file is part of GNU Taler - ~ (C) 2020 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/> - --> - -<menu xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:tools="http://schemas.android.com/tools" - tools:showIn="navigation_view"> - - <group android:checkableBehavior="single"> - <item - android:id="@+id/nav_amountEntry" - android:checked="true" - android:icon="@drawable/ic_dialpad" - android:title="@string/menu_amount_entry" /> - <item - android:id="@+id/nav_order" - android:icon="@drawable/ic_move_money_24dp" - android:title="@string/menu_order" /> - <item - android:id="@+id/nav_history" - android:icon="@drawable/ic_history_black_24dp" - android:title="@string/menu_history" /> - <item - android:id="@+id/nav_settings" - android:icon="@drawable/ic_menu_manage" - android:title="@string/menu_settings" /> - </group> -</menu> diff --git a/merchant-terminal/src/main/res/menu/order.xml b/merchant-terminal/src/main/res/menu/order.xml @@ -1,39 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ This file is part of GNU Taler - ~ (C) 2024 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/> - --> - -<menu xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto"> - <item - android:id="@+id/orderRestart" - android:title="@string/order_restart" - app:showAsAction="always" /> - <item - android:id="@+id/orderDelete" - android:title="@string/order_delete" - app:showAsAction="never" /> - <item - android:id="@+id/orderPrevious" - android:title="@string/order_previous" - app:showAsAction="always" /> - <item - android:id="@+id/orderNext" - android:title="@string/order_next" - app:showAsAction="always" /> - <item - android:id="@+id/reload" - android:title="@string/menu_reload" - app:showAsAction="always|withText" /> -</menu> diff --git a/merchant-terminal/src/main/res/navigation/nav_graph.xml b/merchant-terminal/src/main/res/navigation/nav_graph.xml @@ -1,162 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ This file is part of GNU Taler - ~ (C) 2020 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/> - --> - -<navigation xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - android:id="@+id/nav_graph" - app:startDestination="@+id/nav_amountEntry" - tools:ignore="UnusedNavigation"> - - <fragment - android:id="@+id/nav_order" - android:name="net.taler.merchantpos.order.OrderFragment" - android:label="" - tools:layout="@layout/fragment_order"> - <action - android:id="@+id/action_order_to_merchantSettings" - app:destination="@+id/nav_settings" - app:launchSingleTop="true" - app:popUpTo="@+id/nav_graph" - app:popUpToInclusive="true" /> - <action - android:id="@+id/action_order_self" - app:destination="@+id/nav_order" - app:popUpTo="@+id/nav_graph" /> - <action - android:id="@+id/action_order_to_processPayment" - app:destination="@+id/processPayment" /> - </fragment> - - <fragment - android:id="@+id/nav_amountEntry" - android:name="net.taler.merchantpos.amount.AmountEntryFragment" - android:label="@string/amount_entry_label" - tools:layout="@layout/fragment_amount_entry"> - <action - android:id="@+id/action_amountEntry_to_processPayment" - app:destination="@+id/processPayment" /> - </fragment> - - <fragment - android:id="@+id/processPayment" - android:name="net.taler.merchantpos.payment.ProcessPaymentFragment" - android:label="@string/payment_process_label" - tools:layout="@layout/fragment_process_payment"> - <action - android:id="@+id/action_processPayment_to_paymentSuccess" - app:destination="@+id/paymentSuccess" - app:popUpTo="@id/nav_order" /> - </fragment> - - <fragment - android:id="@+id/nav_history" - android:name="net.taler.merchantpos.history.HistoryFragment" - android:label="@string/history_label" - tools:layout="@layout/fragment_merchant_history"> - <action - android:id="@+id/action_nav_history_to_refundFragment" - app:destination="@id/refundFragment" /> - </fragment> - - <fragment - android:id="@+id/refundFragment" - android:name="net.taler.merchantpos.refund.RefundFragment" - android:label="@string/history_refund" - tools:layout="@layout/fragment_refund"> - <action - android:id="@+id/action_refundFragment_to_refundUriFragment" - app:destination="@id/refundUriFragment" - app:popUpTo="@id/nav_history" /> - </fragment> - - <fragment - android:id="@+id/refundUriFragment" - android:name="net.taler.merchantpos.refund.RefundUriFragment" - android:label="@string/history_refund" - tools:layout="@layout/fragment_refund_uri" /> - - <fragment - android:id="@+id/nav_settings" - android:name="net.taler.merchantpos.config.GeneralSettingsFragment" - android:label="@string/menu_settings"> - <action - android:id="@+id/action_settings_to_instanceSettings" - app:destination="@+id/nav_instanceSettings" /> - </fragment> - - <fragment - android:id="@+id/nav_instanceSettings" - android:name="net.taler.merchantpos.config.ConfigFragment" - android:label="@string/config_label" - tools:layout="@layout/fragment_merchant_config"> - <action - android:id="@+id/action_instanceSettings_to_amountEntry" - app:destination="@+id/nav_amountEntry" - app:launchSingleTop="true" - app:popUpTo="@+id/nav_graph" - app:popUpToInclusive="true" /> - </fragment> - - <fragment - android:id="@+id/configFetcher" - android:name="net.taler.merchantpos.config.ConfigFetcherFragment" - android:label="@string/config_fetching_label" - tools:layout="@layout/fragment_config_fetcher"> - <action - android:id="@+id/action_configFetcher_to_merchantSettings" - app:destination="@+id/nav_instanceSettings" - app:launchSingleTop="true" - app:popUpTo="@+id/nav_graph" - app:popUpToInclusive="true" /> - <action - android:id="@+id/action_configFetcher_to_amountEntry" - app:destination="@+id/nav_amountEntry" - app:launchSingleTop="true" - app:popUpTo="@+id/nav_graph" - app:popUpToInclusive="true" /> - </fragment> - - <fragment - android:id="@+id/paymentSuccess" - android:name="net.taler.merchantpos.payment.PaymentSuccessFragment" - android:label="@string/payment_received" - tools:layout="@layout/fragment_payment_success" /> - - <action - android:id="@+id/action_global_order" - app:destination="@+id/nav_order" - app:launchSingleTop="true" - app:popUpTo="@+id/nav_graph" /> - <action - android:id="@+id/action_global_amountEntry" - app:destination="@+id/nav_amountEntry" - app:launchSingleTop="true" - app:popUpTo="@+id/nav_graph" /> - <action - android:id="@+id/action_global_merchantHistory" - app:destination="@+id/nav_history" - app:launchSingleTop="true" /> - <action - android:id="@+id/action_global_merchantSettings" - app:destination="@+id/nav_settings" - app:launchSingleTop="true" /> - <action - android:id="@+id/action_global_configFetcher" - app:destination="@+id/configFetcher" - app:launchSingleTop="true" /> - -</navigation> diff --git a/merchant-terminal/src/main/res/values/strings.xml b/merchant-terminal/src/main/res/values/strings.xml @@ -60,6 +60,7 @@ <string name="config_merchant_url">Merchant portal URL</string> <string name="config_username">Username</string> <string name="config_password">Password</string> + <string name="config_manual_label">Manual config</string> <string name="config_token">Access token</string> <string name="config_ok">Connect</string> <string name="config_auth_error">Error: Invalid username or password</string> @@ -86,10 +87,21 @@ <string name="payment_order_id">Receipt #%s</string> <string name="payment_process_label">Payment required</string> <string name="payment_canceled">Payment canceled</string> + <string name="payment_share_uri">Share payment URI</string> + <string name="payment_copy_uri">Copy payment URI</string> <string name="history_label">Payment history</string> + <string name="history_refresh">Refresh history</string> <string name="history_ref_no" translatable="false">@string/payment_order_id</string> <string name="history_unpaid">Unpaid</string> + <string name="history_status_unpaid">Unpaid</string> + <string name="history_status_paid">Paid</string> + <string name="history_status_payment_pending">Payment pending</string> + <string name="history_status_payment_claimed">Payment claimed</string> + <string name="history_status_refund_pending">Refund pending</string> + <string name="history_status_refunded">Refunded</string> + <string name="history_show_payment">Show payment</string> + <string name="history_show_refund">Show refund</string> <string name="history_refund">Refund</string> <string name="refund_amount">Amount</string> <string name="refund_reason">Refund reason</string> @@ -102,6 +114,7 @@ <string name="refund_error_backend">Error processing refund</string> <string name="refund_error_deadline">Refund deadline has passed</string> <string name="refund_error_already_refunded">Already refunded</string> + <string name="refund_state_missing">Refund state is no longer available</string> <string name="refund_intro_nfc">Please let customer scan QR Code or use NFC to offer refund</string> <string name="refund_intro">Please let customer scan QR Code to offer refund</string> <string name="refund_order_ref">Purchase reference: %1$s\n\n%2$s</string>