aboutsummaryrefslogtreecommitdiff
path: root/merchant-terminal/src
diff options
context:
space:
mode:
authorTorsten Grote <t@grobox.de>2020-03-18 14:24:41 -0300
committerTorsten Grote <t@grobox.de>2020-03-18 14:24:41 -0300
commita4796ec47d89a851b260b6fc195494547208a025 (patch)
treed2941b68ff2ce22c523e7aa634965033b1100560 /merchant-terminal/src
downloadtaler-android-a4796ec47d89a851b260b6fc195494547208a025.tar.gz
taler-android-a4796ec47d89a851b260b6fc195494547208a025.tar.bz2
taler-android-a4796ec47d89a851b260b6fc195494547208a025.zip
Merge all three apps into one repository
Diffstat (limited to 'merchant-terminal/src')
-rw-r--r--merchant-terminal/src/main/AndroidManifest.xml56
-rw-r--r--merchant-terminal/src/main/ic_taler_logo-web.pngbin0 -> 25951 bytes
-rw-r--r--merchant-terminal/src/main/java/net/taler/merchantpos/Amount.kt48
-rw-r--r--merchant-terminal/src/main/java/net/taler/merchantpos/MainActivity.kt123
-rw-r--r--merchant-terminal/src/main/java/net/taler/merchantpos/MainViewModel.kt51
-rw-r--r--merchant-terminal/src/main/java/net/taler/merchantpos/NfcManager.kt233
-rw-r--r--merchant-terminal/src/main/java/net/taler/merchantpos/QrCodeManager.kt42
-rw-r--r--merchant-terminal/src/main/java/net/taler/merchantpos/Utils.kt155
-rw-r--r--merchant-terminal/src/main/java/net/taler/merchantpos/config/ConfigFetcherFragment.kt66
-rw-r--r--merchant-terminal/src/main/java/net/taler/merchantpos/config/ConfigManager.kt181
-rw-r--r--merchant-terminal/src/main/java/net/taler/merchantpos/config/MerchantConfig.kt47
-rw-r--r--merchant-terminal/src/main/java/net/taler/merchantpos/config/MerchantConfigFragment.kt165
-rw-r--r--merchant-terminal/src/main/java/net/taler/merchantpos/config/MerchantRequest.kt41
-rw-r--r--merchant-terminal/src/main/java/net/taler/merchantpos/history/HistoryManager.kt106
-rw-r--r--merchant-terminal/src/main/java/net/taler/merchantpos/history/MerchantHistoryFragment.kt160
-rw-r--r--merchant-terminal/src/main/java/net/taler/merchantpos/history/RefundFragment.kt99
-rw-r--r--merchant-terminal/src/main/java/net/taler/merchantpos/history/RefundManager.kt111
-rw-r--r--merchant-terminal/src/main/java/net/taler/merchantpos/history/RefundUriFragment.kt65
-rw-r--r--merchant-terminal/src/main/java/net/taler/merchantpos/order/CategoriesFragment.kt106
-rw-r--r--merchant-terminal/src/main/java/net/taler/merchantpos/order/Definitions.kt205
-rw-r--r--merchant-terminal/src/main/java/net/taler/merchantpos/order/LiveOrder.kt109
-rw-r--r--merchant-terminal/src/main/java/net/taler/merchantpos/order/OrderFragment.kt115
-rw-r--r--merchant-terminal/src/main/java/net/taler/merchantpos/order/OrderManager.kt196
-rw-r--r--merchant-terminal/src/main/java/net/taler/merchantpos/order/OrderStateFragment.kt213
-rw-r--r--merchant-terminal/src/main/java/net/taler/merchantpos/order/ProductsFragment.kt111
-rw-r--r--merchant-terminal/src/main/java/net/taler/merchantpos/payment/Payment.kt29
-rw-r--r--merchant-terminal/src/main/java/net/taler/merchantpos/payment/PaymentManager.kt154
-rw-r--r--merchant-terminal/src/main/java/net/taler/merchantpos/payment/PaymentSuccessFragment.kt44
-rw-r--r--merchant-terminal/src/main/java/net/taler/merchantpos/payment/ProcessPaymentFragment.kt96
-rw-r--r--merchant-terminal/src/main/res/color/button_bottom.xml5
-rw-r--r--merchant-terminal/src/main/res/drawable/ic_cash_refund.xml9
-rw-r--r--merchant-terminal/src/main/res/drawable/ic_check_circle.xml10
-rw-r--r--merchant-terminal/src/main/res/drawable/ic_history_black_24dp.xml9
-rw-r--r--merchant-terminal/src/main/res/drawable/ic_launcher_background.xml74
-rw-r--r--merchant-terminal/src/main/res/drawable/ic_menu_manage.xml9
-rw-r--r--merchant-terminal/src/main/res/drawable/ic_move_money_24dp.xml9
-rw-r--r--merchant-terminal/src/main/res/drawable/selectable_background.xml5
-rw-r--r--merchant-terminal/src/main/res/drawable/side_nav_bar.xml9
-rw-r--r--merchant-terminal/src/main/res/layout/activity_main.xml42
-rw-r--r--merchant-terminal/src/main/res/layout/app_bar_main.xml53
-rw-r--r--merchant-terminal/src/main/res/layout/fragment_categories.xml46
-rw-r--r--merchant-terminal/src/main/res/layout/fragment_config_fetcher.xml45
-rw-r--r--merchant-terminal/src/main/res/layout/fragment_merchant_config.xml152
-rw-r--r--merchant-terminal/src/main/res/layout/fragment_merchant_history.xml29
-rw-r--r--merchant-terminal/src/main/res/layout/fragment_order.xml138
-rw-r--r--merchant-terminal/src/main/res/layout/fragment_order_state.xml52
-rw-r--r--merchant-terminal/src/main/res/layout/fragment_payment_success.xml78
-rw-r--r--merchant-terminal/src/main/res/layout/fragment_process_payment.xml110
-rw-r--r--merchant-terminal/src/main/res/layout/fragment_products.xml44
-rw-r--r--merchant-terminal/src/main/res/layout/fragment_refund.xml122
-rw-r--r--merchant-terminal/src/main/res/layout/fragment_refund_uri.xml93
-rw-r--r--merchant-terminal/src/main/res/layout/list_item_category.xml33
-rw-r--r--merchant-terminal/src/main/res/layout/list_item_history.xml97
-rw-r--r--merchant-terminal/src/main/res/layout/list_item_order.xml61
-rw-r--r--merchant-terminal/src/main/res/layout/list_item_product.xml56
-rw-r--r--merchant-terminal/src/main/res/layout/nav_header_main.xml55
-rw-r--r--merchant-terminal/src/main/res/menu/activity_main_drawer.xml36
-rw-r--r--merchant-terminal/src/main/res/mipmap-anydpi-v26/ic_taler_logo.xml5
-rw-r--r--merchant-terminal/src/main/res/mipmap-anydpi-v26/ic_taler_logo_round.xml5
-rw-r--r--merchant-terminal/src/main/res/mipmap-hdpi/ic_launcher_foreground.pngbin0 -> 4307 bytes
-rw-r--r--merchant-terminal/src/main/res/mipmap-hdpi/ic_taler_logo.pngbin0 -> 2347 bytes
-rw-r--r--merchant-terminal/src/main/res/mipmap-hdpi/ic_taler_logo_round.pngbin0 -> 3638 bytes
-rw-r--r--merchant-terminal/src/main/res/mipmap-mdpi/ic_launcher_foreground.pngbin0 -> 2625 bytes
-rw-r--r--merchant-terminal/src/main/res/mipmap-mdpi/ic_taler_logo.pngbin0 -> 1532 bytes
-rw-r--r--merchant-terminal/src/main/res/mipmap-mdpi/ic_taler_logo_round.pngbin0 -> 2240 bytes
-rw-r--r--merchant-terminal/src/main/res/mipmap-xhdpi/ic_launcher_foreground.pngbin0 -> 6077 bytes
-rw-r--r--merchant-terminal/src/main/res/mipmap-xhdpi/ic_taler_logo.pngbin0 -> 3336 bytes
-rw-r--r--merchant-terminal/src/main/res/mipmap-xhdpi/ic_taler_logo_round.pngbin0 -> 5273 bytes
-rw-r--r--merchant-terminal/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.pngbin0 -> 10228 bytes
-rw-r--r--merchant-terminal/src/main/res/mipmap-xxhdpi/ic_taler_logo.pngbin0 -> 5422 bytes
-rw-r--r--merchant-terminal/src/main/res/mipmap-xxhdpi/ic_taler_logo_round.pngbin0 -> 8454 bytes
-rw-r--r--merchant-terminal/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.pngbin0 -> 14083 bytes
-rw-r--r--merchant-terminal/src/main/res/mipmap-xxxhdpi/ic_taler_logo.pngbin0 -> 7786 bytes
-rw-r--r--merchant-terminal/src/main/res/mipmap-xxxhdpi/ic_taler_logo_round.pngbin0 -> 12377 bytes
-rw-r--r--merchant-terminal/src/main/res/navigation/nav_graph.xml137
-rw-r--r--merchant-terminal/src/main/res/values-night/colors.xml5
-rw-r--r--merchant-terminal/src/main/res/values/colors.xml14
-rw-r--r--merchant-terminal/src/main/res/values/dimens.xml6
-rw-r--r--merchant-terminal/src/main/res/values/strings.xml68
-rw-r--r--merchant-terminal/src/main/res/values/styles.xml21
-rw-r--r--merchant-terminal/src/main/res/xml/backup_descriptor.xml4
-rw-r--r--merchant-terminal/src/test/java/net/taler/merchantpos/order/OrderManagerTest.kt151
82 files changed, 5024 insertions, 0 deletions
diff --git a/merchant-terminal/src/main/AndroidManifest.xml b/merchant-terminal/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..f52995f
--- /dev/null
+++ b/merchant-terminal/src/main/AndroidManifest.xml
@@ -0,0 +1,56 @@
+<?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/>
+ -->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:tools="http://schemas.android.com/tools"
+ package="net.taler.merchantpos">
+
+ <uses-permission android:name="android.permission.NFC" />
+ <uses-permission android:name="android.permission.INTERNET" />
+
+ <uses-feature
+ android:name="android.hardware.nfc"
+ android:required="false" />
+
+ <uses-feature
+ android:name="android.hardware.telephony"
+ android:required="false" />
+
+ <application
+ android:allowBackup="true"
+ android:fullBackupContent="@xml/backup_descriptor"
+ android:icon="@mipmap/ic_taler_logo"
+ android:label="@string/app_name"
+ android:roundIcon="@mipmap/ic_taler_logo_round"
+ android:supportsRtl="true"
+ android:theme="@style/AppTheme"
+ tools:ignore="GoogleAppIndexingWarning">
+ <activity
+ android:name=".MainActivity"
+ android:label="@string/app_name"
+ android:screenOrientation="landscape"
+ android:theme="@style/AppTheme.NoActionBar"
+ tools:ignore="LockedOrientationActivity">
+ <intent-filter>
+ <action android:name="android.intent.action.MAIN" />
+
+ <category android:name="android.intent.category.LAUNCHER" />
+ </intent-filter>
+ </activity>
+ </application>
+
+</manifest>
diff --git a/merchant-terminal/src/main/ic_taler_logo-web.png b/merchant-terminal/src/main/ic_taler_logo-web.png
new file mode 100644
index 0000000..e3b8075
--- /dev/null
+++ b/merchant-terminal/src/main/ic_taler_logo-web.png
Binary files differ
diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/Amount.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/Amount.kt
new file mode 100644
index 0000000..17ddd61
--- /dev/null
+++ b/merchant-terminal/src/main/java/net/taler/merchantpos/Amount.kt
@@ -0,0 +1,48 @@
+/*
+ * 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
+
+import org.json.JSONObject
+
+data class Amount(val currency: String, val amount: String) {
+ @Suppress("unused")
+ fun isZero(): Boolean {
+ return amount.toDouble() == 0.0
+ }
+
+ companion object {
+ private const val FRACTIONAL_BASE = 1e8
+
+ @Suppress("unused")
+ fun fromJson(jsonAmount: JSONObject): Amount {
+ val amountCurrency = jsonAmount.getString("currency")
+ val amountValue = jsonAmount.getString("value")
+ val amountFraction = jsonAmount.getString("fraction")
+ val amountIntValue = Integer.parseInt(amountValue)
+ val amountIntFraction = Integer.parseInt(amountFraction)
+ return Amount(
+ amountCurrency,
+ (amountIntValue + amountIntFraction / FRACTIONAL_BASE).toString()
+ )
+ }
+
+ fun fromString(strAmount: String): Amount {
+ val components = strAmount.split(":")
+ return Amount(components[0], components[1])
+ }
+ }
+}
diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/MainActivity.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/MainActivity.kt
new file mode 100644
index 0000000..0c6bdfa
--- /dev/null
+++ b/merchant-terminal/src/main/java/net/taler/merchantpos/MainActivity.kt
@@ -0,0 +1,123 @@
+/*
+ * 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
+
+import android.content.Intent
+import android.content.Intent.ACTION_MAIN
+import android.content.Intent.CATEGORY_HOME
+import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
+import android.os.Bundle
+import android.os.Handler
+import android.view.MenuItem
+import android.widget.Toast
+import android.widget.Toast.LENGTH_SHORT
+import androidx.activity.viewModels
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.view.GravityCompat.START
+import androidx.lifecycle.Observer
+import androidx.navigation.NavController
+import androidx.navigation.fragment.NavHostFragment
+import androidx.navigation.ui.AppBarConfiguration
+import androidx.navigation.ui.setupWithNavController
+import com.google.android.material.navigation.NavigationView.OnNavigationItemSelectedListener
+import kotlinx.android.synthetic.main.activity_main.*
+import kotlinx.android.synthetic.main.app_bar_main.*
+
+class MainActivity : AppCompatActivity(), OnNavigationItemSelectedListener {
+
+ private val model: MainViewModel by viewModels()
+ private val nfcManager = NfcManager()
+
+ private lateinit var nav: NavController
+
+ private var reallyExit = false
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.activity_main)
+
+ model.paymentManager.payment.observe(this, Observer { payment ->
+ payment?.talerPayUri?.let {
+ nfcManager.setTagString(it)
+ }
+ })
+
+ val navHostFragment =
+ supportFragmentManager.findFragmentById(R.id.navHostFragment) as NavHostFragment
+ nav = navHostFragment.navController
+
+ nav_view.setupWithNavController(nav)
+ nav_view.setNavigationItemSelectedListener(this)
+
+ setSupportActionBar(toolbar)
+ val appBarConfiguration = AppBarConfiguration(nav.graph, drawer_layout)
+ toolbar.setupWithNavController(nav, appBarConfiguration)
+ }
+
+ override fun onStart() {
+ super.onStart()
+ if (!model.configManager.config.isValid() && nav.currentDestination?.id != R.id.nav_settings) {
+ nav.navigate(R.id.action_global_merchantSettings)
+ } else if (model.configManager.merchantConfig == null && nav.currentDestination?.id != R.id.configFetcher) {
+ nav.navigate(R.id.action_global_configFetcher)
+ }
+ }
+
+ public override fun onResume() {
+ super.onResume()
+ // TODO should we only read tags when a payment is to be made?
+ NfcManager.start(this, nfcManager)
+ }
+
+ public override fun onPause() {
+ super.onPause()
+ NfcManager.stop(this)
+ }
+
+ override fun onNavigationItemSelected(item: MenuItem): Boolean {
+ when (item.itemId) {
+ R.id.nav_order -> nav.navigate(R.id.action_global_order)
+ R.id.nav_history -> nav.navigate(R.id.action_global_merchantHistory)
+ R.id.nav_settings -> nav.navigate(R.id.action_global_merchantSettings)
+ }
+ drawer_layout.closeDrawer(START)
+ return true
+ }
+
+ override fun onBackPressed() {
+ val currentDestination = nav.currentDestination?.id
+ if (drawer_layout.isDrawerOpen(START)) {
+ drawer_layout.closeDrawer(START)
+ } else if (currentDestination == R.id.nav_settings && !model.configManager.config.isValid()) {
+ // we are in the configuration screen and need a 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) {
+ 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()
+ }
+
+}
diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/MainViewModel.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/MainViewModel.kt
new file mode 100644
index 0000000..3fe472d
--- /dev/null
+++ b/merchant-terminal/src/main/java/net/taler/merchantpos/MainViewModel.kt
@@ -0,0 +1,51 @@
+/*
+ * 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
+
+import android.app.Application
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import com.android.volley.toolbox.Volley
+import com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES
+import com.fasterxml.jackson.databind.ObjectMapper
+import com.fasterxml.jackson.module.kotlin.KotlinModule
+import net.taler.merchantpos.config.ConfigManager
+import net.taler.merchantpos.history.HistoryManager
+import net.taler.merchantpos.history.RefundManager
+import net.taler.merchantpos.order.OrderManager
+import net.taler.merchantpos.payment.PaymentManager
+
+class MainViewModel(app: Application) : AndroidViewModel(app) {
+
+ private val mapper = ObjectMapper()
+ .registerModule(KotlinModule())
+ .configure(FAIL_ON_UNKNOWN_PROPERTIES, false)
+ private val queue = Volley.newRequestQueue(app)
+
+ val orderManager = OrderManager(app, mapper)
+ val configManager = ConfigManager(app, viewModelScope, mapper, queue).apply {
+ addConfigurationReceiver(orderManager)
+ }
+ val paymentManager = PaymentManager(configManager, queue, mapper)
+ val historyManager = HistoryManager(configManager, queue, mapper)
+ val refundManager = RefundManager(configManager, queue)
+
+ override fun onCleared() {
+ queue.cancelAll { !it.isCanceled }
+ }
+
+}
diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/NfcManager.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/NfcManager.kt
new file mode 100644
index 0000000..09c1470
--- /dev/null
+++ b/merchant-terminal/src/main/java/net/taler/merchantpos/NfcManager.kt
@@ -0,0 +1,233 @@
+/*
+ * 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
+
+import android.app.Activity
+import android.content.Context
+import android.nfc.NfcAdapter
+import android.nfc.NfcAdapter.FLAG_READER_NFC_A
+import android.nfc.NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK
+import android.nfc.Tag
+import android.nfc.tech.IsoDep
+import android.util.Log
+import net.taler.merchantpos.Utils.hexStringToByteArray
+import org.json.JSONObject
+import java.io.ByteArrayOutputStream
+import java.net.URL
+import javax.net.ssl.HttpsURLConnection
+
+@Suppress("unused")
+private const val TALER_AID = "A0000002471001"
+
+class NfcManager : NfcAdapter.ReaderCallback {
+
+ companion object {
+ const val TAG = "taler-merchant"
+
+ /**
+ * Returns true if NFC is supported and false otherwise.
+ */
+ fun hasNfc(context: Context): Boolean {
+ return getNfcAdapter(context) != null
+ }
+
+ /**
+ * Enables NFC reader mode. Don't forget to call [stop] afterwards.
+ */
+ fun start(activity: Activity, nfcManager: NfcManager) {
+ getNfcAdapter(activity)?.enableReaderMode(activity, nfcManager, nfcManager.flags, null)
+ }
+
+ /**
+ * Disables NFC reader mode. Call after [start].
+ */
+ fun stop(activity: Activity) {
+ getNfcAdapter(activity)?.disableReaderMode(activity)
+ }
+
+ private fun getNfcAdapter(context: Context): NfcAdapter? {
+ return NfcAdapter.getDefaultAdapter(context)
+ }
+ }
+
+ private val flags = FLAG_READER_NFC_A or FLAG_READER_SKIP_NDEF_CHECK
+
+ private var tagString: String? = null
+ private var currentTag: IsoDep? = null
+
+ fun setTagString(tagString: String) {
+ this.tagString = tagString
+ }
+
+ override fun onTagDiscovered(tag: Tag?) {
+
+ Log.v(TAG, "tag discovered")
+
+ val isoDep = IsoDep.get(tag)
+ isoDep.connect()
+
+ currentTag = isoDep
+
+ isoDep.transceive(apduSelectFile())
+
+ val tagString: String? = tagString
+ if (tagString != null) {
+ isoDep.transceive(apduPutTalerData(1, tagString.toByteArray()))
+ }
+
+ // FIXME: use better pattern for sleeps in between requests
+ // -> start with fast polling, poll more slowly if no requests are coming
+
+ while (true) {
+ try {
+ val reqFrame = isoDep.transceive(apduGetData())
+ if (reqFrame.size < 2) {
+ Log.v(TAG, "request frame too small")
+ break
+ }
+ val req = ByteArray(reqFrame.size - 2)
+ if (req.isEmpty()) {
+ continue
+ }
+ reqFrame.copyInto(req, 0, 0, reqFrame.size - 2)
+ val jsonReq = JSONObject(req.toString(Charsets.UTF_8))
+ val reqId = jsonReq.getInt("id")
+ Log.v(TAG, "got request $jsonReq")
+ val jsonInnerReq = jsonReq.getJSONObject("request")
+ val method = jsonInnerReq.getString("method")
+ val urlStr = jsonInnerReq.getString("url")
+ Log.v(TAG, "url '$urlStr'")
+ Log.v(TAG, "method '$method'")
+ val url = URL(urlStr)
+ val conn: HttpsURLConnection = url.openConnection() as HttpsURLConnection
+ conn.setRequestProperty("Accept", "application/json")
+ conn.connectTimeout = 5000
+ conn.doInput = true
+ when (method) {
+ "get" -> {
+ conn.requestMethod = "GET"
+ }
+ "postJson" -> {
+ conn.requestMethod = "POST"
+ conn.doOutput = true
+ conn.setRequestProperty("Content-Type", "application/json; utf-8")
+ val body = jsonInnerReq.getString("body")
+ conn.outputStream.write(body.toByteArray(Charsets.UTF_8))
+ }
+ else -> {
+ throw Exception("method not supported")
+ }
+ }
+ Log.v(TAG, "connecting")
+ conn.connect()
+ Log.v(TAG, "connected")
+
+ val statusCode = conn.responseCode
+ val tunnelResp = JSONObject()
+ tunnelResp.put("id", reqId)
+ tunnelResp.put("status", conn.responseCode)
+
+ if (statusCode == 200) {
+ val stream = conn.inputStream
+ val httpResp = stream.buffered().readBytes()
+ tunnelResp.put("responseJson", JSONObject(httpResp.toString(Charsets.UTF_8)))
+ }
+
+ Log.v(TAG, "sending: $tunnelResp")
+
+ isoDep.transceive(apduPutTalerData(2, tunnelResp.toString().toByteArray()))
+ } catch (e: Exception) {
+ Log.v(TAG, "exception during NFC loop: $e")
+ break
+ }
+ }
+
+ isoDep.close()
+ }
+
+ private fun writeApduLength(stream: ByteArrayOutputStream, size: Int) {
+ when {
+ size == 0 -> {
+ // No size field needed!
+ }
+ size <= 255 -> // One byte size field
+ stream.write(size)
+ size <= 65535 -> {
+ stream.write(0)
+ // FIXME: is this supposed to be little or big endian?
+ stream.write(size and 0xFF)
+ stream.write((size ushr 8) and 0xFF)
+ }
+ else -> throw Error("payload too big")
+ }
+ }
+
+ private fun apduSelectFile(): ByteArray {
+ return hexStringToByteArray("00A4040007A0000002471001")
+ }
+
+ private fun apduPutData(payload: ByteArray): ByteArray {
+ val stream = ByteArrayOutputStream()
+
+ // Class
+ stream.write(0x00)
+
+ // Instruction 0xDA = put data
+ stream.write(0xDA)
+
+ // Instruction parameters
+ // (proprietary encoding)
+ stream.write(0x01)
+ stream.write(0x00)
+
+ writeApduLength(stream, payload.size)
+
+ stream.write(payload)
+
+ return stream.toByteArray()
+ }
+
+ private fun apduPutTalerData(talerInst: Int, payload: ByteArray): ByteArray {
+ val realPayload = ByteArrayOutputStream()
+ realPayload.write(talerInst)
+ realPayload.write(payload)
+ return apduPutData(realPayload.toByteArray())
+ }
+
+ private fun apduGetData(): ByteArray {
+ val stream = ByteArrayOutputStream()
+
+ // Class
+ stream.write(0x00)
+
+ // Instruction 0xCA = get data
+ stream.write(0xCA)
+
+ // Instruction parameters
+ // (proprietary encoding)
+ stream.write(0x01)
+ stream.write(0x00)
+
+ // Max expected response size, two
+ // zero bytes denotes 65536
+ stream.write(0x0)
+ stream.write(0x0)
+
+ return stream.toByteArray()
+ }
+
+}
diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/QrCodeManager.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/QrCodeManager.kt
new file mode 100644
index 0000000..595e7ac
--- /dev/null
+++ b/merchant-terminal/src/main/java/net/taler/merchantpos/QrCodeManager.kt
@@ -0,0 +1,42 @@
+/*
+ * 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
+
+import android.graphics.Bitmap
+import android.graphics.Bitmap.Config.RGB_565
+import android.graphics.Color.BLACK
+import android.graphics.Color.WHITE
+import com.google.zxing.BarcodeFormat.QR_CODE
+import com.google.zxing.qrcode.QRCodeWriter
+
+object QrCodeManager {
+
+ fun makeQrCode(text: String, size: Int = 256): Bitmap {
+ val qrCodeWriter = QRCodeWriter()
+ val bitMatrix = qrCodeWriter.encode(text, QR_CODE, size, size)
+ val height = bitMatrix.height
+ val width = bitMatrix.width
+ val bmp = Bitmap.createBitmap(width, height, RGB_565)
+ for (x in 0 until width) {
+ for (y in 0 until height) {
+ bmp.setPixel(x, y, if (bitMatrix.get(x, y)) BLACK else WHITE)
+ }
+ }
+ return bmp
+ }
+
+}
diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/Utils.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/Utils.kt
new file mode 100644
index 0000000..a0c30d6
--- /dev/null
+++ b/merchant-terminal/src/main/java/net/taler/merchantpos/Utils.kt
@@ -0,0 +1,155 @@
+/*
+ * 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
+
+import android.content.Context
+import android.text.format.DateUtils.DAY_IN_MILLIS
+import android.text.format.DateUtils.FORMAT_ABBREV_MONTH
+import android.text.format.DateUtils.FORMAT_ABBREV_RELATIVE
+import android.text.format.DateUtils.FORMAT_NO_YEAR
+import android.text.format.DateUtils.FORMAT_SHOW_DATE
+import android.text.format.DateUtils.FORMAT_SHOW_TIME
+import android.text.format.DateUtils.MINUTE_IN_MILLIS
+import android.text.format.DateUtils.formatDateTime
+import android.text.format.DateUtils.getRelativeTimeSpanString
+import android.view.View
+import android.view.View.INVISIBLE
+import android.view.View.VISIBLE
+import androidx.annotation.StringRes
+import androidx.fragment.app.Fragment
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MediatorLiveData
+import androidx.lifecycle.Observer
+import androidx.navigation.NavController
+import androidx.navigation.NavDirections
+import androidx.navigation.fragment.findNavController
+import com.google.android.material.snackbar.BaseTransientBottomBar.ANIMATION_MODE_FADE
+import com.google.android.material.snackbar.BaseTransientBottomBar.Duration
+import com.google.android.material.snackbar.Snackbar.make
+
+object Utils {
+
+ private const val HEX_CHARS = "0123456789ABCDEF"
+
+ fun hexStringToByteArray(data: String): ByteArray {
+ val result = ByteArray(data.length / 2)
+
+ for (i in data.indices step 2) {
+ val firstIndex = HEX_CHARS.indexOf(data[i])
+ val secondIndex = HEX_CHARS.indexOf(data[i + 1])
+
+ val octet = firstIndex.shl(4).or(secondIndex)
+ result[i.shr(1)] = octet.toByte()
+ }
+ return result
+ }
+
+
+ private val HEX_CHARS_ARRAY = HEX_CHARS.toCharArray()
+
+ @Suppress("unused")
+ fun toHex(byteArray: ByteArray): String {
+ val result = StringBuffer()
+
+ byteArray.forEach {
+ val octet = it.toInt()
+ val firstIndex = (octet and 0xF0).ushr(4)
+ val secondIndex = octet and 0x0F
+ result.append(HEX_CHARS_ARRAY[firstIndex])
+ result.append(HEX_CHARS_ARRAY[secondIndex])
+ }
+ return result.toString()
+ }
+
+}
+
+fun View.fadeIn(endAction: () -> Unit = {}) {
+ if (visibility == VISIBLE) return
+ alpha = 0f
+ visibility = VISIBLE
+ animate().alpha(1f).withEndAction {
+ if (context != null) endAction.invoke()
+ }.start()
+}
+
+fun View.fadeOut(endAction: () -> Unit = {}) {
+ if (visibility == INVISIBLE) return
+ animate().alpha(0f).withEndAction {
+ if (context == null) return@withEndAction
+ visibility = INVISIBLE
+ alpha = 1f
+ endAction.invoke()
+ }.start()
+}
+
+fun topSnackbar(view: View, text: CharSequence, @Duration duration: Int) {
+ make(view, text, duration)
+ .setAnimationMode(ANIMATION_MODE_FADE)
+ .setAnchorView(R.id.navHostFragment)
+ .show()
+}
+
+fun topSnackbar(view: View, @StringRes resId: Int, @Duration duration: Int) {
+ topSnackbar(view, view.resources.getText(resId), duration)
+}
+
+fun NavDirections.navigate(nav: NavController) = nav.navigate(this)
+
+fun Fragment.navigate(directions: NavDirections) = findNavController().navigate(directions)
+
+fun Long.toRelativeTime(context: Context): CharSequence {
+ val now = System.currentTimeMillis()
+ return if (now - this > DAY_IN_MILLIS * 2) {
+ val flags = FORMAT_SHOW_TIME or FORMAT_SHOW_DATE or FORMAT_ABBREV_MONTH or FORMAT_NO_YEAR
+ formatDateTime(context, this, flags)
+ } else getRelativeTimeSpanString(this, now, MINUTE_IN_MILLIS, FORMAT_ABBREV_RELATIVE)
+}
+
+class CombinedLiveData<T, K, S>(
+ source1: LiveData<T>,
+ source2: LiveData<K>,
+ private val combine: (data1: T?, data2: K?) -> S
+) : MediatorLiveData<S>() {
+
+ private var data1: T? = null
+ private var data2: K? = null
+
+ init {
+ super.addSource(source1) { t ->
+ data1 = t
+ value = combine(data1, data2)
+ }
+ super.addSource(source2) { k ->
+ data2 = k
+ value = combine(data1, data2)
+ }
+ }
+
+ override fun <S : Any?> addSource(source: LiveData<S>, onChanged: Observer<in S>) {
+ throw UnsupportedOperationException()
+ }
+
+ override fun <T : Any?> removeSource(toRemote: LiveData<T>) {
+ throw UnsupportedOperationException()
+ }
+}
+
+/**
+ * Use this with 'when' expressions when you need it to handle all possibilities/branches.
+ */
+val <T> T.exhaustive: T
+ get() = this
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
new file mode 100644
index 0000000..c370e33
--- /dev/null
+++ b/merchant-terminal/src/main/java/net/taler/merchantpos/config/ConfigFetcherFragment.kt
@@ -0,0 +1,66 @@
+/*
+ * 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.config
+
+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.lifecycle.Observer
+import androidx.navigation.fragment.findNavController
+import com.google.android.material.snackbar.Snackbar
+import com.google.android.material.snackbar.Snackbar.LENGTH_SHORT
+import net.taler.merchantpos.MainViewModel
+import net.taler.merchantpos.R
+import net.taler.merchantpos.config.ConfigFetcherFragmentDirections.Companion.actionConfigFetcherToMerchantSettings
+import net.taler.merchantpos.config.ConfigFetcherFragmentDirections.Companion.actionConfigFetcherToOrder
+import net.taler.merchantpos.navigate
+
+class ConfigFetcherFragment : Fragment() {
+
+ private val model: MainViewModel by activityViewModels()
+ private val configManager by lazy { model.configManager }
+
+ override fun onCreateView(
+ inflater: LayoutInflater, container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View? {
+ return inflater.inflate(R.layout.fragment_config_fetcher, container, false)
+ }
+
+ override fun onActivityCreated(savedInstanceState: Bundle?) {
+ super.onActivityCreated(savedInstanceState)
+ configManager.fetchConfig(configManager.config, false)
+ configManager.configUpdateResult.observe(viewLifecycleOwner, Observer { result ->
+ when (result) {
+ null -> return@Observer
+ is ConfigUpdateResult.Error -> onNetworkError(result.msg)
+ is ConfigUpdateResult.Success -> {
+ actionConfigFetcherToOrder().navigate(findNavController())
+ }
+ }
+ })
+ }
+
+ private fun onNetworkError(msg: String) {
+ Snackbar.make(view!!, msg, LENGTH_SHORT).show()
+ actionConfigFetcherToMerchantSettings().navigate(findNavController())
+ }
+
+}
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
new file mode 100644
index 0000000..edb8059
--- /dev/null
+++ b/merchant-terminal/src/main/java/net/taler/merchantpos/config/ConfigManager.kt
@@ -0,0 +1,181 @@
+/*
+ * 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.config
+
+import android.content.Context
+import android.content.Context.MODE_PRIVATE
+import android.util.Base64.NO_WRAP
+import android.util.Base64.encodeToString
+import android.util.Log
+import androidx.annotation.UiThread
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import com.android.volley.Request.Method.GET
+import com.android.volley.RequestQueue
+import com.android.volley.Response.ErrorListener
+import com.android.volley.Response.Listener
+import com.android.volley.VolleyError
+import com.android.volley.toolbox.JsonObjectRequest
+import com.fasterxml.jackson.databind.ObjectMapper
+import com.fasterxml.jackson.module.kotlin.readValue
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import net.taler.merchantpos.R
+import org.json.JSONObject
+
+private const val SETTINGS_NAME = "taler-merchant-terminal"
+
+private const val SETTINGS_CONFIG_URL = "configUrl"
+private const val SETTINGS_USERNAME = "username"
+private const val SETTINGS_PASSWORD = "password"
+
+internal const val CONFIG_URL_DEMO = "https://docs.taler.net/_static/sample-pos-config.json"
+internal const val CONFIG_USERNAME_DEMO = ""
+internal const val CONFIG_PASSWORD_DEMO = ""
+
+private val TAG = ConfigManager::class.java.simpleName
+
+interface ConfigurationReceiver {
+ /**
+ * Returns null if the configuration was valid, or a error string for user display otherwise.
+ */
+ suspend fun onConfigurationReceived(json: JSONObject, currency: String): String?
+}
+
+class ConfigManager(
+ private val context: Context,
+ private val scope: CoroutineScope,
+ private val mapper: ObjectMapper,
+ private val queue: RequestQueue
+) {
+
+ private val prefs = context.getSharedPreferences(SETTINGS_NAME, MODE_PRIVATE)
+ private val configurationReceivers = ArrayList<ConfigurationReceiver>()
+
+ var config = Config(
+ configUrl = prefs.getString(SETTINGS_CONFIG_URL, CONFIG_URL_DEMO)!!,
+ username = prefs.getString(SETTINGS_USERNAME, CONFIG_USERNAME_DEMO)!!,
+ password = prefs.getString(SETTINGS_PASSWORD, CONFIG_PASSWORD_DEMO)!!
+ )
+ var merchantConfig: MerchantConfig? = null
+ private set
+
+ private val mConfigUpdateResult = MutableLiveData<ConfigUpdateResult>()
+ val configUpdateResult: LiveData<ConfigUpdateResult> = mConfigUpdateResult
+
+ fun addConfigurationReceiver(receiver: ConfigurationReceiver) {
+ configurationReceivers.add(receiver)
+ }
+
+ @UiThread
+ fun fetchConfig(config: Config, save: Boolean, savePassword: Boolean = false) {
+ mConfigUpdateResult.value = null
+ val configToSave = if (save) {
+ if (savePassword) config else config.copy(password = "")
+ } else null
+
+ val stringRequest = object : JsonObjectRequest(GET, config.configUrl, null,
+ Listener { onConfigReceived(it, configToSave) },
+ ErrorListener { onNetworkError(it) }
+ ) {
+ // send basic auth header
+ override fun getHeaders(): MutableMap<String, String> {
+ val credentials = "${config.username}:${config.password}"
+ val auth = ("Basic ${encodeToString(credentials.toByteArray(), NO_WRAP)}")
+ return mutableMapOf("Authorization" to auth)
+ }
+ }
+ queue.add(stringRequest)
+ }
+
+ @UiThread
+ private fun onConfigReceived(json: JSONObject, config: Config?) {
+ val merchantConfig: MerchantConfig = try {
+ mapper.readValue(json.getString("config"))
+ } catch (e: Exception) {
+ Log.e(TAG, "Error parsing merchant config", e)
+ val msg = context.getString(R.string.config_error_malformed)
+ mConfigUpdateResult.value = ConfigUpdateResult.Error(msg)
+ return
+ }
+
+ val params = mapOf("instance" to merchantConfig.instance)
+ val req = MerchantRequest(GET, merchantConfig, "config", params, null,
+ Listener { onMerchantConfigReceived(config, json, merchantConfig, it) },
+ ErrorListener { onNetworkError(it) }
+ )
+ queue.add(req)
+ }
+
+ private fun onMerchantConfigReceived(
+ newConfig: Config?,
+ configJson: JSONObject,
+ merchantConfig: MerchantConfig,
+ json: JSONObject
+ ) = scope.launch(Dispatchers.Default) {
+ val currency = json.getString("currency")
+
+ for (receiver in configurationReceivers) {
+ val result = try {
+ receiver.onConfigurationReceived(configJson, currency)
+ } catch (e: Exception) {
+ Log.e(TAG, "Error handling configuration by ${receiver::class.java.simpleName}", e)
+ context.getString(R.string.config_error_unknown)
+ }
+ if (result != null) { // error
+ mConfigUpdateResult.postValue(ConfigUpdateResult.Error(result))
+ return@launch
+ }
+ }
+ newConfig?.let {
+ config = it
+ saveConfig(it)
+ }
+ this@ConfigManager.merchantConfig = merchantConfig.copy(currency = currency)
+ mConfigUpdateResult.postValue(ConfigUpdateResult.Success(currency))
+ }
+
+ fun forgetPassword() {
+ config = config.copy(password = "")
+ saveConfig(config)
+ merchantConfig = null
+ }
+
+ private fun saveConfig(config: Config) {
+ prefs.edit()
+ .putString(SETTINGS_CONFIG_URL, config.configUrl)
+ .putString(SETTINGS_USERNAME, config.username)
+ .putString(SETTINGS_PASSWORD, config.password)
+ .apply()
+ }
+
+ @UiThread
+ private fun onNetworkError(it: VolleyError?) {
+ val msg = context.getString(
+ if (it?.networkResponse?.statusCode == 401) R.string.config_auth_error
+ else R.string.config_error_network
+ )
+ mConfigUpdateResult.value = ConfigUpdateResult.Error(msg)
+ }
+
+}
+
+sealed class ConfigUpdateResult {
+ data class Error(val msg: String) : ConfigUpdateResult()
+ data class Success(val currency: String) : ConfigUpdateResult()
+}
diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/config/MerchantConfig.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/config/MerchantConfig.kt
new file mode 100644
index 0000000..2050e28
--- /dev/null
+++ b/merchant-terminal/src/main/java/net/taler/merchantpos/config/MerchantConfig.kt
@@ -0,0 +1,47 @@
+/*
+ * 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.config
+
+import android.net.Uri
+import com.fasterxml.jackson.annotation.JsonProperty
+
+data class Config(
+ val configUrl: String,
+ val username: String,
+ val password: String
+) {
+ fun isValid() = !configUrl.isBlank()
+ fun hasPassword() = !password.isBlank()
+}
+
+data class MerchantConfig(
+ @JsonProperty("base_url")
+ val baseUrl: String,
+ val instance: String,
+ @JsonProperty("api_key")
+ val apiKey: String,
+ val currency: String?
+) {
+ fun urlFor(endpoint: String, params: Map<String, String>?): String {
+ val uriBuilder = Uri.parse(baseUrl).buildUpon()
+ uriBuilder.appendPath(endpoint)
+ params?.forEach {
+ uriBuilder.appendQueryParameter(it.key, it.value)
+ }
+ return uriBuilder.toString()
+ }
+}
diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/config/MerchantConfigFragment.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/config/MerchantConfigFragment.kt
new file mode 100644
index 0000000..aad1c93
--- /dev/null
+++ b/merchant-terminal/src/main/java/net/taler/merchantpos/config/MerchantConfigFragment.kt
@@ -0,0 +1,165 @@
+/*
+ * 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.config
+
+import android.net.Uri
+import android.os.Bundle
+import android.text.method.LinkMovementMethod
+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 androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import androidx.lifecycle.Observer
+import androidx.navigation.fragment.findNavController
+import com.google.android.material.snackbar.BaseTransientBottomBar.LENGTH_LONG
+import com.google.android.material.snackbar.Snackbar
+import kotlinx.android.synthetic.main.fragment_merchant_config.*
+import net.taler.merchantpos.MainViewModel
+import net.taler.merchantpos.R
+import net.taler.merchantpos.config.MerchantConfigFragmentDirections.Companion.actionSettingsToOrder
+import net.taler.merchantpos.navigate
+import net.taler.merchantpos.topSnackbar
+
+/**
+ * Fragment that displays merchant settings.
+ */
+class MerchantConfigFragment : Fragment() {
+
+ private val model: MainViewModel by activityViewModels()
+ private val configManager by lazy { model.configManager }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View? {
+ return inflater.inflate(R.layout.fragment_merchant_config, container, false)
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ configUrlView.editText!!.setOnFocusChangeListener { _, hasFocus ->
+ if (!hasFocus) checkForUrlCredentials()
+ }
+ okButton.setOnClickListener {
+ checkForUrlCredentials()
+ val inputUrl = configUrlView.editText!!.text
+ val url = if (inputUrl.startsWith("http")) {
+ inputUrl.toString()
+ } else {
+ "https://$inputUrl".also { configUrlView.editText!!.setText(it) }
+ }
+ progressBar.visibility = VISIBLE
+ okButton.visibility = INVISIBLE
+ val config = Config(
+ configUrl = url,
+ username = usernameView.editText!!.text.toString(),
+ password = passwordView.editText!!.text.toString()
+ )
+ configManager.fetchConfig(config, true, savePasswordCheckBox.isChecked)
+ configManager.configUpdateResult.observe(viewLifecycleOwner, Observer { result ->
+ if (onConfigUpdate(result)) {
+ configManager.configUpdateResult.removeObservers(viewLifecycleOwner)
+ }
+ })
+ }
+ forgetPasswordButton.setOnClickListener {
+ configManager.forgetPassword()
+ passwordView.editText!!.text = null
+ forgetPasswordButton.visibility = GONE
+ }
+ configDocsView.movementMethod = LinkMovementMethod.getInstance()
+ updateView(savedInstanceState == null)
+ }
+
+ override fun onStart() {
+ super.onStart()
+ // focus password if this is the only empty field
+ if (passwordView.editText!!.text.isBlank()
+ && !configUrlView.editText!!.text.isBlank()
+ && !usernameView.editText!!.text.isBlank()
+ ) {
+ passwordView.requestFocus()
+ }
+ }
+
+ private fun updateView(isInitialization: Boolean = false) {
+ val config = configManager.config
+ configUrlView.editText!!.setText(
+ if (isInitialization && config.configUrl.isBlank()) CONFIG_URL_DEMO
+ else config.configUrl
+ )
+ usernameView.editText!!.setText(
+ if (isInitialization && config.username.isBlank()) CONFIG_USERNAME_DEMO
+ else config.username
+ )
+ passwordView.editText!!.setText(
+ if (isInitialization && config.password.isBlank()) CONFIG_PASSWORD_DEMO
+ else config.password
+ )
+ forgetPasswordButton.visibility = if (config.hasPassword()) VISIBLE else GONE
+ }
+
+ private fun checkForUrlCredentials() {
+ val text = configUrlView.editText!!.text.toString()
+ Uri.parse(text)?.userInfo?.let { userInfo ->
+ if (userInfo.contains(':')) {
+ val (user, pass) = userInfo.split(':')
+ val strippedUrl = text.replace("${userInfo}@", "")
+ configUrlView.editText!!.setText(strippedUrl)
+ usernameView.editText!!.setText(user)
+ passwordView.editText!!.setText(pass)
+ }
+ }
+ }
+
+ /**
+ * Processes updated config and returns true, if observer can be removed.
+ */
+ private fun onConfigUpdate(result: ConfigUpdateResult?) = when (result) {
+ null -> false
+ is ConfigUpdateResult.Error -> {
+ onError(result.msg)
+ true
+ }
+ is ConfigUpdateResult.Success -> {
+ onConfigReceived(result.currency)
+ true
+ }
+ }
+
+ private fun onConfigReceived(currency: String) {
+ onResultReceived()
+ updateView()
+ topSnackbar(view!!, getString(R.string.config_changed, currency), LENGTH_LONG)
+ actionSettingsToOrder().navigate(findNavController())
+ }
+
+ private fun onError(msg: String) {
+ onResultReceived()
+ Snackbar.make(view!!, msg, LENGTH_LONG).show()
+ }
+
+ private fun onResultReceived() {
+ progressBar.visibility = INVISIBLE
+ okButton.visibility = VISIBLE
+ }
+
+}
diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/config/MerchantRequest.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/config/MerchantRequest.kt
new file mode 100644
index 0000000..8d95378
--- /dev/null
+++ b/merchant-terminal/src/main/java/net/taler/merchantpos/config/MerchantRequest.kt
@@ -0,0 +1,41 @@
+/*
+ * 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.config
+
+
+import android.util.ArrayMap
+import com.android.volley.Response
+import com.android.volley.toolbox.JsonObjectRequest
+import org.json.JSONObject
+
+class MerchantRequest(
+ method: Int,
+ private val merchantConfig: MerchantConfig,
+ endpoint: String,
+ params: Map<String, String>?,
+ jsonRequest: JSONObject?,
+ listener: Response.Listener<JSONObject>,
+ errorListener: Response.ErrorListener
+) :
+ JsonObjectRequest(method, merchantConfig.urlFor(endpoint, params), jsonRequest, listener, errorListener) {
+
+ override fun getHeaders(): MutableMap<String, String> {
+ val headerMap = ArrayMap<String, String>()
+ headerMap["Authorization"] = "ApiKey " + merchantConfig.apiKey
+ return headerMap
+ }
+}
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
new file mode 100644
index 0000000..594e7cc
--- /dev/null
+++ b/merchant-terminal/src/main/java/net/taler/merchantpos/history/HistoryManager.kt
@@ -0,0 +1,106 @@
+/*
+ * 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.util.Log
+import androidx.annotation.UiThread
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import com.android.volley.Request.Method.GET
+import com.android.volley.Request.Method.POST
+import com.android.volley.RequestQueue
+import com.android.volley.Response.ErrorListener
+import com.android.volley.Response.Listener
+import com.fasterxml.jackson.annotation.JsonIgnore
+import com.fasterxml.jackson.annotation.JsonInclude
+import com.fasterxml.jackson.annotation.JsonInclude.Include.NON_EMPTY
+import com.fasterxml.jackson.annotation.JsonProperty
+import com.fasterxml.jackson.databind.ObjectMapper
+import com.fasterxml.jackson.module.kotlin.readValue
+import net.taler.merchantpos.Amount
+import net.taler.merchantpos.config.ConfigManager
+import net.taler.merchantpos.config.MerchantRequest
+import org.json.JSONObject
+
+@JsonInclude(NON_EMPTY)
+class Timestamp(
+ @JsonProperty("t_ms")
+ val ms: Long
+)
+
+data class HistoryItem(
+ @JsonProperty("order_id")
+ val orderId: String,
+ @JsonProperty("amount")
+ val amountStr: String,
+ val summary: String,
+ val timestamp: Timestamp
+) {
+ @get:JsonIgnore
+ val amount: Amount by lazy { Amount.fromString(amountStr) }
+
+ @get:JsonIgnore
+ val time = timestamp.ms
+}
+
+sealed class HistoryResult {
+ object Error : HistoryResult()
+ class Success(val items: List<HistoryItem>) : HistoryResult()
+}
+
+class HistoryManager(
+ private val configManager: ConfigManager,
+ private val queue: RequestQueue,
+ private val mapper: ObjectMapper
+) {
+
+ private val mIsLoading = MutableLiveData(false)
+ val isLoading: LiveData<Boolean> = mIsLoading
+
+ private val mItems = MutableLiveData<HistoryResult>()
+ val items: LiveData<HistoryResult> = mItems
+
+ @UiThread
+ internal fun fetchHistory() {
+ mIsLoading.value = true
+ val merchantConfig = configManager.merchantConfig!!
+ val params = mapOf("instance" to merchantConfig.instance)
+ val req = MerchantRequest(GET, merchantConfig, "history", params, null,
+ Listener { onHistoryResponse(it) },
+ ErrorListener { onHistoryError() })
+ queue.add(req)
+ }
+
+ @UiThread
+ private fun onHistoryResponse(body: JSONObject) {
+ mIsLoading.value = false
+ val items = arrayListOf<HistoryItem>()
+ val historyJson = body.getJSONArray("history")
+ for (i in 0 until historyJson.length()) {
+ val historyItem: HistoryItem = mapper.readValue(historyJson.getString(i))
+ items.add(historyItem)
+ }
+ mItems.value = HistoryResult.Success(items)
+ }
+
+ @UiThread
+ private fun onHistoryError() {
+ mIsLoading.value = false
+ mItems.value = HistoryResult.Error
+ }
+
+}
diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/history/MerchantHistoryFragment.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/history/MerchantHistoryFragment.kt
new file mode 100644
index 0000000..0c53f71
--- /dev/null
+++ b/merchant-terminal/src/main/java/net/taler/merchantpos/history/MerchantHistoryFragment.kt
@@ -0,0 +1,160 @@
+/*
+ * 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.annotation.SuppressLint
+import android.os.Bundle
+import android.util.Log
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.ImageButton
+import android.widget.TextView
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import androidx.lifecycle.Observer
+import androidx.recyclerview.widget.DividerItemDecoration
+import androidx.recyclerview.widget.DividerItemDecoration.VERTICAL
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView.Adapter
+import androidx.recyclerview.widget.RecyclerView.ViewHolder
+import com.google.android.material.snackbar.Snackbar
+import com.google.android.material.snackbar.Snackbar.LENGTH_SHORT
+import kotlinx.android.synthetic.main.fragment_merchant_history.*
+import net.taler.merchantpos.MainViewModel
+import net.taler.merchantpos.R
+import net.taler.merchantpos.exhaustive
+import net.taler.merchantpos.history.HistoryItemAdapter.HistoryItemViewHolder
+import net.taler.merchantpos.history.MerchantHistoryFragmentDirections.Companion.actionGlobalMerchantSettings
+import net.taler.merchantpos.history.MerchantHistoryFragmentDirections.Companion.actionNavHistoryToRefundFragment
+import net.taler.merchantpos.navigate
+import net.taler.merchantpos.toRelativeTime
+import java.util.*
+
+private interface RefundClickListener {
+ fun onRefundClicked(item: HistoryItem)
+}
+
+/**
+ * Fragment to display the merchant's payment history, received from the backend.
+ */
+class MerchantHistoryFragment : Fragment(), RefundClickListener {
+
+ 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 val historyListAdapter = HistoryItemAdapter(this)
+
+ override fun onCreateView(
+ inflater: LayoutInflater, container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View? {
+ return inflater.inflate(R.layout.fragment_merchant_history, container, false)
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ list_history.apply {
+ layoutManager = LinearLayoutManager(requireContext())
+ addItemDecoration(DividerItemDecoration(context, VERTICAL))
+ adapter = historyListAdapter
+ }
+
+ swipeRefresh.setOnRefreshListener {
+ Log.v(TAG, "refreshing!")
+ historyManager.fetchHistory()
+ }
+ historyManager.isLoading.observe(viewLifecycleOwner, Observer { loading ->
+ Log.v(TAG, "setting refreshing to $loading")
+ swipeRefresh.isRefreshing = loading
+ })
+ historyManager.items.observe(viewLifecycleOwner, Observer { result ->
+ when (result) {
+ is HistoryResult.Error -> onError()
+ is HistoryResult.Success -> historyListAdapter.setData(result.items)
+ }.exhaustive
+ })
+ }
+
+ override fun onStart() {
+ super.onStart()
+ if (model.configManager.merchantConfig?.instance == null) {
+ navigate(actionGlobalMerchantSettings())
+ } else {
+ historyManager.fetchHistory()
+ }
+ }
+
+ private fun onError() {
+ Snackbar.make(view!!, R.string.error_network, LENGTH_SHORT).show()
+ }
+
+ override fun onRefundClicked(item: HistoryItem) {
+ refundManager.startRefund(item)
+ navigate(actionNavHistoryToRefundFragment())
+ }
+
+}
+
+private class HistoryItemAdapter(private val listener: RefundClickListener) :
+ Adapter<HistoryItemViewHolder>() {
+
+ private val items = ArrayList<HistoryItem>()
+
+ 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<HistoryItem>) {
+ this.items.clear()
+ this.items.addAll(items)
+ this.notifyDataSetChanged()
+ }
+
+ private inner class HistoryItemViewHolder(private val v: View) : 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)
+
+ fun bind(item: HistoryItem) {
+ orderSummaryView.text = item.summary
+ val amount = item.amount
+ @SuppressLint("SetTextI18n")
+ orderAmountView.text = "${amount.amount} ${amount.currency}"
+ orderIdView.text = v.context.getString(R.string.history_ref_no, item.orderId)
+ orderTimeView.text = item.time.toRelativeTime(v.context)
+ refundButton.setOnClickListener { listener.onRefundClicked(item) }
+ }
+
+ }
+
+}
diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/history/RefundFragment.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/history/RefundFragment.kt
new file mode 100644
index 0000000..1797cea
--- /dev/null
+++ b/merchant-terminal/src/main/java/net/taler/merchantpos/history/RefundFragment.kt
@@ -0,0 +1,99 @@
+/*
+ * 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.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.annotation.StringRes
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import androidx.lifecycle.Observer
+import androidx.navigation.fragment.findNavController
+import com.google.android.material.snackbar.BaseTransientBottomBar.LENGTH_LONG
+import com.google.android.material.snackbar.Snackbar
+import kotlinx.android.synthetic.main.fragment_refund.*
+import net.taler.merchantpos.MainViewModel
+import net.taler.merchantpos.R
+import net.taler.merchantpos.fadeIn
+import net.taler.merchantpos.fadeOut
+import net.taler.merchantpos.history.RefundFragmentDirections.Companion.actionRefundFragmentToRefundUriFragment
+import net.taler.merchantpos.history.RefundResult.Error
+import net.taler.merchantpos.history.RefundResult.PastDeadline
+import net.taler.merchantpos.history.RefundResult.Success
+import net.taler.merchantpos.navigate
+
+class RefundFragment : Fragment() {
+
+ private val model: MainViewModel by activityViewModels()
+ private val refundManager by lazy { model.refundManager }
+
+ override fun onCreateView(
+ inflater: LayoutInflater, container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View? {
+ return inflater.inflate(R.layout.fragment_refund, container, false)
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ val item = refundManager.toBeRefunded ?: throw IllegalStateException()
+ amountInputView.setText(item.amount.amount)
+ currencyView.text = item.amount.currency
+ abortButton.setOnClickListener { findNavController().navigateUp() }
+ refundButton.setOnClickListener { onRefundButtonClicked(item) }
+
+ refundManager.refundResult.observe(viewLifecycleOwner, Observer { result ->
+ onRefundResultChanged(result)
+ })
+ }
+
+ private fun onRefundButtonClicked(item: HistoryItem) {
+ val inputAmount = amountInputView.text.toString().toDouble()
+ if (inputAmount > item.amount.amount.toDouble()) {
+ amountView.error = getString(R.string.refund_error_max_amount, item.amount.amount)
+ return
+ }
+ if (inputAmount <= 0.0) {
+ amountView.error = getString(R.string.refund_error_zero)
+ return
+ }
+ amountView.error = null
+ refundButton.fadeOut()
+ progressBar.fadeIn()
+ refundManager.refund(item, inputAmount, reasonInputView.text.toString())
+ }
+
+ private fun onRefundResultChanged(result: RefundResult?): Any = when (result) {
+ Error -> onError(R.string.refund_error_backend)
+ PastDeadline -> onError(R.string.refund_error_deadline)
+ is Success -> {
+ progressBar.fadeOut()
+ refundButton.fadeIn()
+ navigate(actionRefundFragmentToRefundUriFragment())
+ }
+ null -> { // no-op
+ }
+ }
+
+ private fun onError(@StringRes res: Int) {
+ Snackbar.make(view!!, res, LENGTH_LONG).show()
+ progressBar.fadeOut()
+ refundButton.fadeIn()
+ }
+
+}
diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/history/RefundManager.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/history/RefundManager.kt
new file mode 100644
index 0000000..270b3b8
--- /dev/null
+++ b/merchant-terminal/src/main/java/net/taler/merchantpos/history/RefundManager.kt
@@ -0,0 +1,111 @@
+/*
+ * 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.util.Log
+import androidx.annotation.UiThread
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import com.android.volley.Request.Method.POST
+import com.android.volley.RequestQueue
+import com.android.volley.Response.ErrorListener
+import com.android.volley.Response.Listener
+import net.taler.merchantpos.config.ConfigManager
+import net.taler.merchantpos.config.MerchantRequest
+import org.json.JSONObject
+
+sealed class RefundResult {
+ object Error : RefundResult()
+ object PastDeadline : RefundResult()
+ class Success(
+ val refundUri: String,
+ val item: HistoryItem,
+ val amount: Double,
+ val reason: String
+ ) : RefundResult()
+}
+
+class RefundManager(
+ private val configManager: ConfigManager,
+ private val queue: RequestQueue
+) {
+
+ var toBeRefunded: HistoryItem? = null
+ private set
+
+ private val mRefundResult = MutableLiveData<RefundResult>()
+ internal val refundResult: LiveData<RefundResult> = mRefundResult
+
+ @UiThread
+ internal fun startRefund(item: HistoryItem) {
+ toBeRefunded = item
+ mRefundResult.value = null
+ }
+
+ @UiThread
+ internal fun refund(item: HistoryItem, amount: Double, reason: String) {
+ val merchantConfig = configManager.merchantConfig!!
+ val refundRequest = mapOf(
+ "order_id" to item.orderId,
+ "refund" to "${item.amount.currency}:$amount",
+ "reason" to reason
+ )
+ val body = JSONObject(refundRequest)
+ val req = MerchantRequest(POST, merchantConfig, "refund", null, body,
+ Listener { onRefundResponse(it, item, amount, reason) },
+ ErrorListener { onRefundError() }
+ )
+ queue.add(req)
+ }
+
+ @UiThread
+ private fun onRefundResponse(
+ json: JSONObject,
+ item: HistoryItem,
+ amount: Double,
+ reason: String
+ ) {
+ if (!json.has("contract_terms")) {
+ Log.e("TEST", "json: $json")
+ onRefundError()
+ return
+ }
+
+ val contractTerms = json.getJSONObject("contract_terms")
+ val refundDeadline = if (contractTerms.has("refund_deadline")) {
+ contractTerms.getJSONObject("refund_deadline").getLong("t_ms")
+ } else null
+ val autoRefund = contractTerms.has("auto_refund")
+ val refundUri = json.getString("taler_refund_uri")
+
+ Log.e("TEST", "refundDeadline: $refundDeadline")
+ if (refundDeadline != null) Log.e(
+ "TEST",
+ "refundDeadline passed: ${System.currentTimeMillis() > refundDeadline}"
+ )
+ Log.e("TEST", "autoRefund: $autoRefund")
+ Log.e("TEST", "refundUri: $refundUri")
+
+ mRefundResult.value = RefundResult.Success(refundUri, item, amount, reason)
+ }
+
+ @UiThread
+ private fun onRefundError() {
+ mRefundResult.value = RefundResult.Error
+ }
+
+}
diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/history/RefundUriFragment.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/history/RefundUriFragment.kt
new file mode 100644
index 0000000..f2bd569
--- /dev/null
+++ b/merchant-terminal/src/main/java/net/taler/merchantpos/history/RefundUriFragment.kt
@@ -0,0 +1,65 @@
+/*
+ * 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.annotation.SuppressLint
+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.navigation.fragment.findNavController
+import kotlinx.android.synthetic.main.fragment_refund_uri.*
+import net.taler.merchantpos.MainViewModel
+import net.taler.merchantpos.NfcManager.Companion.hasNfc
+import net.taler.merchantpos.QrCodeManager.makeQrCode
+import net.taler.merchantpos.R
+
+class RefundUriFragment : Fragment() {
+
+ private val model: MainViewModel by activityViewModels()
+ private val refundManager by lazy { model.refundManager }
+
+ override fun onCreateView(
+ inflater: LayoutInflater, container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View? {
+ return inflater.inflate(R.layout.fragment_refund_uri, container, false)
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ val result = refundManager.refundResult.value
+ if (result !is RefundResult.Success) throw IllegalStateException()
+
+ refundQrcodeView.setImageBitmap(makeQrCode(result.refundUri))
+
+ val introRes =
+ if (hasNfc(requireContext())) R.string.refund_intro_nfc else R.string.refund_intro
+ refundIntroView.setText(introRes)
+
+ @SuppressLint("SetTextI18n")
+ refundAmountView.text = "${result.amount} ${result.item.amount.currency}"
+
+ refundRefView.text =
+ getString(R.string.refund_order_ref, result.item.orderId, result.reason)
+
+ cancelRefundButton.setOnClickListener { findNavController().navigateUp() }
+ }
+
+}
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
new file mode 100644
index 0000000..34b97c0
--- /dev/null
+++ b/merchant-terminal/src/main/java/net/taler/merchantpos/order/CategoriesFragment.kt
@@ -0,0 +1,106 @@
+/*
+ * 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 android.widget.Button
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import androidx.lifecycle.Observer
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import androidx.recyclerview.widget.RecyclerView.Adapter
+import kotlinx.android.synthetic.main.fragment_categories.*
+import net.taler.merchantpos.MainViewModel
+import net.taler.merchantpos.R
+import net.taler.merchantpos.order.CategoryAdapter.CategoryViewHolder
+
+interface CategorySelectionListener {
+ fun onCategorySelected(category: Category)
+}
+
+class CategoriesFragment : Fragment(), CategorySelectionListener {
+
+ private val viewModel: MainViewModel by activityViewModels()
+ private val orderManager by lazy { viewModel.orderManager }
+ private val adapter = CategoryAdapter(this)
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View? {
+ return inflater.inflate(R.layout.fragment_categories, container, false)
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ categoriesList.apply {
+ adapter = this@CategoriesFragment.adapter
+ layoutManager = LinearLayoutManager(requireContext())
+ }
+
+ orderManager.categories.observe(viewLifecycleOwner, Observer { categories ->
+ adapter.setItems(categories)
+ progressBar.visibility = INVISIBLE
+ })
+ }
+
+ override fun onCategorySelected(category: Category) {
+ orderManager.setCurrentCategory(category)
+ }
+
+}
+
+private 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()
+ }
+
+ private 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/Definitions.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/order/Definitions.kt
new file mode 100644
index 0000000..63eda17
--- /dev/null
+++ b/merchant-terminal/src/main/java/net/taler/merchantpos/order/Definitions.kt
@@ -0,0 +1,205 @@
+/*
+ * 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 androidx.core.os.LocaleListCompat
+import com.fasterxml.jackson.annotation.JsonIgnore
+import com.fasterxml.jackson.annotation.JsonInclude
+import com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL
+import com.fasterxml.jackson.annotation.JsonProperty
+import net.taler.merchantpos.Amount
+import java.util.*
+import java.util.Locale.LanguageRange
+import kotlin.collections.ArrayList
+import kotlin.collections.HashMap
+
+data class Category(
+ val id: Int,
+ val name: String,
+ @JsonProperty("name_i18n")
+ val nameI18n: Map<String, String>?
+) {
+ var selected: Boolean = false
+ val localizedName: String get() = getLocalizedString(nameI18n, name)
+}
+
+@JsonInclude(NON_NULL)
+abstract class Product {
+ @get:JsonProperty("product_id")
+ abstract val productId: String?
+ abstract val description: String
+ @get:JsonProperty("description_i18n")
+ abstract val descriptionI18n: Map<String, String>?
+ abstract val price: String
+ @get:JsonProperty("delivery_location")
+ abstract val location: String?
+ abstract val image: String?
+ @get:JsonIgnore
+ val localizedDescription: String
+ get() = getLocalizedString(descriptionI18n, description)
+}
+
+data class ConfigProduct(
+ @JsonIgnore
+ val id: String = UUID.randomUUID().toString(),
+ override val productId: String?,
+ override val description: String,
+ override val descriptionI18n: Map<String, String>?,
+ override val price: String,
+ override val location: String?,
+ override val image: String?,
+ val categories: List<Int>,
+ @JsonIgnore
+ val quantity: Int = 0
+) : Product() {
+ val priceAsDouble by lazy { Amount.fromString(price).amount.toDouble() }
+
+ override fun equals(other: Any?) = other is ConfigProduct && id == other.id
+ override fun hashCode() = id.hashCode()
+}
+
+data class ContractProduct(
+ override val productId: String?,
+ override val description: String,
+ override val descriptionI18n: Map<String, String>?,
+ override val price: String,
+ override val location: String?,
+ override val image: String?,
+ val quantity: Int
+) : Product() {
+ constructor(product: ConfigProduct) : this(
+ product.productId,
+ product.description,
+ product.descriptionI18n,
+ product.price,
+ product.location,
+ product.image,
+ product.quantity
+ )
+}
+
+private fun getLocalizedString(map: Map<String, String>?, default: String): String {
+ // just return the default, if it is the only element
+ if (map == null) return default
+ // create a priority list of language ranges from system locales
+ val locales = LocaleListCompat.getDefault()
+ val priorityList = ArrayList<LanguageRange>(locales.size())
+ for (i in 0 until locales.size()) {
+ priorityList.add(LanguageRange(locales[i].toLanguageTag()))
+ }
+ // create a list of locales available in the given map
+ val availableLocales = map.keys.mapNotNull {
+ if (it == "_") return@mapNotNull null
+ val list = it.split("_")
+ when (list.size) {
+ 1 -> Locale(list[0])
+ 2 -> Locale(list[0], list[1])
+ 3 -> Locale(list[0], list[1], list[2])
+ else -> null
+ }
+ }
+ val match = Locale.lookup(priorityList, availableLocales)
+ return match?.toString()?.let { map[it] } ?: default
+}
+
+data class Order(val id: Int, val availableCategories: Map<Int, Category>) {
+ val products = ArrayList<ConfigProduct>()
+ val title: String = id.toString()
+ val summary: String
+ get() {
+ if (products.size == 1) return products[0].description
+ return getCategoryQuantities().map { (category: Category, quantity: Int) ->
+ "$quantity x ${category.localizedName}"
+ }.joinToString()
+ }
+ val total: Double
+ get() {
+ var total = 0.0
+ products.forEach { product ->
+ val price = product.priceAsDouble
+ total += price * product.quantity
+ }
+ return total
+ }
+ val totalAsString: String
+ get() = String.format("%.2f", total)
+
+ operator fun plus(product: ConfigProduct): Order {
+ val i = products.indexOf(product)
+ if (i == -1) {
+ products.add(product.copy(quantity = 1))
+ } else {
+ val quantity = products[i].quantity
+ products[i] = products[i].copy(quantity = quantity + 1)
+ }
+ return this
+ }
+
+ operator fun minus(product: ConfigProduct): Order {
+ val i = products.indexOf(product)
+ if (i == -1) return this
+ val quantity = products[i].quantity
+ if (quantity <= 1) {
+ products.remove(product)
+ } else {
+ products[i] = products[i].copy(quantity = quantity - 1)
+ }
+ return this
+ }
+
+ private fun getCategoryQuantities(): HashMap<Category, Int> {
+ val categories = HashMap<Category, Int>()
+ products.forEach { product ->
+ val categoryId = product.categories[0]
+ val category = availableCategories.getValue(categoryId)
+ val oldQuantity = categories[category] ?: 0
+ categories[category] = oldQuantity + product.quantity
+ }
+ return categories
+ }
+
+ /**
+ * Returns a map of i18n summaries for each locale present in *all* given [Category]s
+ * or null if there's no locale that fulfills this criteria.
+ */
+ val summaryI18n: Map<String, String>?
+ get() {
+ if (products.size == 1) return products[0].descriptionI18n
+ val categoryQuantities = getCategoryQuantities()
+ // get all available locales
+ val availableLocales = categoryQuantities.mapNotNull { (category, _) ->
+ val nameI18n = category.nameI18n
+ // if one category doesn't have locales, we can return null here already
+ nameI18n?.keys ?: return null
+ }.flatten().toHashSet()
+ // remove all locales not supported by all categories
+ categoryQuantities.forEach { (category, _) ->
+ // category.nameI18n should be non-null now
+ availableLocales.retainAll(category.nameI18n!!.keys)
+ if (availableLocales.isEmpty()) return null
+ }
+ return availableLocales.map { locale ->
+ Pair(
+ locale, categoryQuantities.map { (category, quantity) ->
+ // category.nameI18n should be non-null now
+ "$quantity x ${category.nameI18n!![locale]}"
+ }.joinToString()
+ )
+ }.toMap()
+ }
+
+}
diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/order/LiveOrder.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/order/LiveOrder.kt
new file mode 100644
index 0000000..ff6061a
--- /dev/null
+++ b/merchant-terminal/src/main/java/net/taler/merchantpos/order/LiveOrder.kt
@@ -0,0 +1,109 @@
+/*
+ * 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 androidx.annotation.UiThread
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.Transformations
+import net.taler.merchantpos.CombinedLiveData
+import net.taler.merchantpos.order.RestartState.DISABLED
+import net.taler.merchantpos.order.RestartState.ENABLED
+import net.taler.merchantpos.order.RestartState.UNDO
+
+internal enum class RestartState { ENABLED, DISABLED, UNDO }
+
+internal interface LiveOrder {
+ val order: LiveData<Order>
+ val orderTotal: LiveData<Double>
+ val restartState: LiveData<RestartState>
+ val modifyOrderAllowed: LiveData<Boolean>
+ val lastAddedProduct: ConfigProduct?
+ val selectedProductKey: String?
+ fun restartOrUndo()
+ fun selectOrderLine(product: ConfigProduct?)
+ fun increaseSelectedOrderLine()
+ fun decreaseSelectedOrderLine()
+}
+
+internal class MutableLiveOrder(
+ val id: Int,
+ private val productsByCategory: HashMap<Category, ArrayList<ConfigProduct>>
+) : LiveOrder {
+ private val availableCategories: Map<Int, Category>
+ get() = productsByCategory.keys.map { it.id to it }.toMap()
+ override val order: MutableLiveData<Order> = MutableLiveData(Order(id, availableCategories))
+ override val orderTotal: LiveData<Double> = Transformations.map(order) { it.total }
+ override val restartState = MutableLiveData(DISABLED)
+ private val selectedOrderLine = MutableLiveData<ConfigProduct>()
+ override val selectedProductKey: String?
+ get() = selectedOrderLine.value?.id
+ override val modifyOrderAllowed =
+ CombinedLiveData(restartState, selectedOrderLine) { restartState, selectedOrderLine ->
+ restartState != DISABLED && selectedOrderLine != null
+ }
+ override var lastAddedProduct: ConfigProduct? = null
+ private var undoOrder: Order? = null
+
+ @UiThread
+ internal fun addProduct(product: ConfigProduct) {
+ lastAddedProduct = product
+ order.value = order.value!! + product
+ restartState.value = ENABLED
+ }
+
+ @UiThread
+ internal fun removeProduct(product: ConfigProduct) {
+ val modifiedOrder = order.value!! - product
+ order.value = modifiedOrder
+ restartState.value = if (modifiedOrder.products.isEmpty()) DISABLED else ENABLED
+ }
+
+ @UiThread
+ internal fun isEmpty() = order.value!!.products.isEmpty()
+
+ @UiThread
+ override fun restartOrUndo() {
+ if (restartState.value == UNDO) {
+ order.value = undoOrder
+ restartState.value = ENABLED
+ undoOrder = null
+ } else {
+ undoOrder = order.value
+ order.value = Order(id, availableCategories)
+ restartState.value = UNDO
+ }
+ }
+
+ @UiThread
+ override fun selectOrderLine(product: ConfigProduct?) {
+ selectedOrderLine.value = product
+ }
+
+ @UiThread
+ override fun increaseSelectedOrderLine() {
+ val orderLine = selectedOrderLine.value ?: throw IllegalStateException()
+ addProduct(orderLine)
+ }
+
+ @UiThread
+ override fun decreaseSelectedOrderLine() {
+ val orderLine = selectedOrderLine.value ?: throw IllegalStateException()
+ removeProduct(orderLine)
+ }
+
+}
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
new file mode 100644
index 0000000..49f7cf2
--- /dev/null
+++ b/merchant-terminal/src/main/java/net/taler/merchantpos/order/OrderFragment.kt
@@ -0,0 +1,115 @@
+/*
+ * 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.lifecycle.Observer
+import androidx.navigation.fragment.findNavController
+import androidx.transition.TransitionManager.beginDelayedTransition
+import kotlinx.android.synthetic.main.fragment_order.*
+import net.taler.merchantpos.MainViewModel
+import net.taler.merchantpos.R
+import net.taler.merchantpos.navigate
+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
+
+class OrderFragment : Fragment() {
+
+ private val viewModel: MainViewModel by activityViewModels()
+ private val orderManager by lazy { viewModel.orderManager }
+ private val paymentManager by lazy { viewModel.paymentManager }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View? {
+ return inflater.inflate(R.layout.fragment_order, container, false)
+ }
+
+ override fun onActivityCreated(savedInstanceState: Bundle?) {
+ super.onActivityCreated(savedInstanceState)
+ orderManager.currentOrderId.observe(viewLifecycleOwner, Observer { 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()
+ })
+ }
+
+ override fun onStart() {
+ super.onStart()
+ if (!viewModel.configManager.config.isValid()) {
+ actionOrderToMerchantSettings().navigate(findNavController())
+ } else if (viewModel.configManager.merchantConfig?.currency == null) {
+ actionGlobalConfigFetcher().navigate(findNavController())
+ }
+ }
+
+ private fun onOrderSwitched(orderId: Int, liveOrder: LiveOrder) {
+ // order title
+ liveOrder.order.observe(viewLifecycleOwner, Observer { order ->
+ activity?.title = getString(R.string.order_label_title, order.title)
+ })
+ // restart button
+ restartButton.setOnClickListener { liveOrder.restartOrUndo() }
+ liveOrder.restartState.observe(viewLifecycleOwner, Observer { state ->
+ beginDelayedTransition(view as ViewGroup)
+ if (state == UNDO) {
+ restartButton.setText(R.string.order_undo)
+ restartButton.isEnabled = true
+ completeButton.isEnabled = false
+ } else {
+ restartButton.setText(R.string.order_restart)
+ restartButton.isEnabled = state == ENABLED
+ completeButton.isEnabled = state == ENABLED
+ }
+ })
+ // -1 and +1 buttons
+ liveOrder.modifyOrderAllowed.observe(viewLifecycleOwner, Observer { allowed ->
+ minusButton.isEnabled = allowed
+ plusButton.isEnabled = allowed
+ })
+ minusButton.setOnClickListener { liveOrder.decreaseSelectedOrderLine() }
+ plusButton.setOnClickListener { liveOrder.increaseSelectedOrderLine() }
+ // previous and next button
+ prevButton.isEnabled = orderManager.hasPreviousOrder(orderId)
+ orderManager.hasNextOrder(orderId).observe(viewLifecycleOwner, Observer { hasNextOrder ->
+ nextButton.isEnabled = hasNextOrder
+ })
+ prevButton.setOnClickListener { orderManager.previousOrder() }
+ nextButton.setOnClickListener { orderManager.nextOrder() }
+ // complete button
+ completeButton.setOnClickListener {
+ val order = liveOrder.order.value ?: return@setOnClickListener
+ paymentManager.createPayment(order)
+ actionOrderToProcessPayment().navigate(findNavController())
+ }
+ }
+
+}
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
new file mode 100644
index 0000000..48ddc57
--- /dev/null
+++ b/merchant-terminal/src/main/java/net/taler/merchantpos/order/OrderManager.kt
@@ -0,0 +1,196 @@
+/*
+ * 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.content.Context
+import android.util.Log
+import androidx.annotation.UiThread
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.Transformations.map
+import com.fasterxml.jackson.core.type.TypeReference
+import com.fasterxml.jackson.databind.ObjectMapper
+import net.taler.merchantpos.Amount.Companion.fromString
+import net.taler.merchantpos.R
+import net.taler.merchantpos.config.ConfigurationReceiver
+import net.taler.merchantpos.order.RestartState.ENABLED
+import org.json.JSONObject
+
+class OrderManager(
+ private val context: Context,
+ private val mapper: ObjectMapper
+) : ConfigurationReceiver {
+
+ companion object {
+ val TAG = OrderManager::class.java.simpleName
+ }
+
+ private var orderCounter: Int = 0
+ private val mCurrentOrderId = MutableLiveData<Int>()
+ internal val currentOrderId: LiveData<Int> = mCurrentOrderId
+
+ private val productsByCategory = HashMap<Category, ArrayList<ConfigProduct>>()
+
+ private val orders = LinkedHashMap<Int, MutableLiveOrder>()
+
+ private val mProducts = MutableLiveData<List<ConfigProduct>>()
+ internal val products: LiveData<List<ConfigProduct>> = mProducts
+
+ private val mCategories = MutableLiveData<List<Category>>()
+ internal val categories: LiveData<List<Category>> = mCategories
+
+ override suspend fun onConfigurationReceived(json: JSONObject, currency: String): String? {
+ // parse categories
+ val categoriesStr = json.getJSONArray("categories").toString()
+ val categoriesType = object : TypeReference<List<Category>>() {}
+ val categories: List<Category> = mapper.readValue(categoriesStr, categoriesType)
+ if (categories.isEmpty()) {
+ Log.e(TAG, "No valid category found.")
+ return context.getString(R.string.config_error_category)
+ }
+ // pre-select the first category
+ categories[0].selected = true
+
+ // parse products (live data gets updated in setCurrentCategory())
+ val productsStr = json.getJSONArray("products").toString()
+ val productsType = object : TypeReference<List<ConfigProduct>>() {}
+ val products: List<ConfigProduct> = mapper.readValue(productsStr, productsType)
+
+ // group products by categories
+ productsByCategory.clear()
+ products.forEach { product ->
+ val productCurrency = fromString(product.price).currency
+ if (productCurrency != currency) {
+ Log.e(TAG, "Product $product has currency $productCurrency, $currency expected")
+ return context.getString(
+ R.string.config_error_currency, product.description, productCurrency, currency
+ )
+ }
+ product.categories.forEach { categoryId ->
+ val category = categories.find { it.id == categoryId }
+ if (category == null) {
+ Log.e(TAG, "Product $product has unknown category $categoryId")
+ return context.getString(
+ R.string.config_error_product_category_id, product.description, categoryId
+ )
+ }
+ if (productsByCategory.containsKey(category)) {
+ productsByCategory[category]?.add(product)
+ } else {
+ productsByCategory[category] = ArrayList<ConfigProduct>().apply { add(product) }
+ }
+ }
+ }
+ return if (productsByCategory.size > 0) {
+ mCategories.postValue(categories)
+ mProducts.postValue(productsByCategory[categories[0]])
+ // Initialize first empty order, note this won't work when updating config mid-flight
+ if (orders.isEmpty()) {
+ val id = orderCounter++
+ orders[id] = MutableLiveOrder(id, productsByCategory)
+ mCurrentOrderId.postValue(id)
+ }
+ null // success, no error string
+ } else context.getString(R.string.config_error_product_zero)
+ }
+
+ @UiThread
+ internal fun getOrder(orderId: Int): LiveOrder {
+ return orders[orderId] ?: throw IllegalArgumentException()
+ }
+
+ @UiThread
+ internal fun nextOrder() {
+ val currentId = currentOrderId.value!!
+ var foundCurrentOrder = false
+ var nextId: Int? = null
+ for (orderId in orders.keys) {
+ if (foundCurrentOrder) {
+ nextId = orderId
+ break
+ }
+ if (orderId == currentId) foundCurrentOrder = true
+ }
+ if (nextId == null) {
+ nextId = orderCounter++
+ orders[nextId] = MutableLiveOrder(nextId, productsByCategory)
+ }
+ val currentOrder = order(currentId)
+ if (currentOrder.isEmpty()) orders.remove(currentId)
+ else currentOrder.lastAddedProduct = null // not needed anymore and it would get selected
+ mCurrentOrderId.value = nextId
+ }
+
+ @UiThread
+ internal fun previousOrder() {
+ val currentId = currentOrderId.value!!
+ var previousId: Int? = null
+ var foundCurrentOrder = false
+ for (orderId in orders.keys) {
+ if (orderId == currentId) {
+ foundCurrentOrder = true
+ break
+ }
+ previousId = orderId
+ }
+ if (previousId == null || !foundCurrentOrder) {
+ throw AssertionError("Could not find previous order for $currentId")
+ }
+ val currentOrder = order(currentId)
+ // remove current order if empty, or lastAddedProduct as it is not needed anymore
+ // and would get selected when navigating back instead of last selection
+ if (currentOrder.isEmpty()) orders.remove(currentId)
+ else currentOrder.lastAddedProduct = null
+ mCurrentOrderId.value = previousId
+ }
+
+ fun hasPreviousOrder(currentOrderId: Int): Boolean {
+ return currentOrderId != orders.keys.first()
+ }
+
+ fun hasNextOrder(currentOrderId: Int) = map(order(currentOrderId).restartState) { state ->
+ state == ENABLED || currentOrderId != orders.keys.last()
+ }
+
+ internal fun setCurrentCategory(category: Category) {
+ val newCategories = categories.value?.apply {
+ forEach { if (it.selected) it.selected = false }
+ category.selected = true
+ }
+ mCategories.postValue(newCategories)
+ mProducts.postValue(productsByCategory[category])
+ }
+
+ @UiThread
+ internal fun addProduct(orderId: Int, product: ConfigProduct) {
+ order(orderId).addProduct(product)
+ }
+
+ @UiThread
+ internal fun onOrderPaid(orderId: Int) {
+ if (currentOrderId.value == orderId) {
+ if (hasPreviousOrder(orderId)) previousOrder()
+ else nextOrder()
+ }
+ orders.remove(orderId)
+ }
+
+ private fun order(orderId: Int): MutableLiveOrder {
+ return orders[orderId] ?: throw IllegalStateException()
+ }
+
+}
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
new file mode 100644
index 0000000..1b70016
--- /dev/null
+++ b/merchant-terminal/src/main/java/net/taler/merchantpos/order/OrderStateFragment.kt
@@ -0,0 +1,213 @@
+/*
+ * 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.MotionEvent
+import android.view.View
+import android.view.ViewGroup
+import android.widget.TextView
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import androidx.lifecycle.Observer
+import androidx.recyclerview.selection.ItemDetailsLookup
+import androidx.recyclerview.selection.ItemKeyProvider
+import androidx.recyclerview.selection.SelectionPredicates
+import androidx.recyclerview.selection.SelectionTracker
+import androidx.recyclerview.selection.StorageStrategy
+import androidx.recyclerview.widget.AsyncListDiffer
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import androidx.recyclerview.widget.RecyclerView.Adapter
+import androidx.recyclerview.widget.RecyclerView.ViewHolder
+import kotlinx.android.synthetic.main.fragment_order_state.*
+import net.taler.merchantpos.MainViewModel
+import net.taler.merchantpos.R
+import net.taler.merchantpos.fadeIn
+import net.taler.merchantpos.fadeOut
+import net.taler.merchantpos.order.OrderAdapter.OrderLineLookup
+import net.taler.merchantpos.order.OrderAdapter.OrderViewHolder
+
+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 val adapter = OrderAdapter()
+ private var tracker: SelectionTracker<String>? = null
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View? {
+ return inflater.inflate(R.layout.fragment_order_state, container, false)
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ orderList.apply {
+ adapter = this@OrderStateFragment.adapter
+ layoutManager = LinearLayoutManager(requireContext())
+ }
+ val detailsLookup = OrderLineLookup(orderList)
+ val tracker = SelectionTracker.Builder(
+ "order-selection-id",
+ 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, Observer { order ->
+ onOrderChanged(order, tracker)
+ })
+ liveOrder.orderTotal.observe(viewLifecycleOwner, Observer { orderTotal ->
+ if (orderTotal == 0.0) {
+ totalView.fadeOut()
+ totalView.text = null
+ } else {
+ val currency = viewModel.configManager.merchantConfig?.currency
+ totalView.text = getString(R.string.order_total, orderTotal, currency)
+ totalView.fadeIn()
+ }
+ })
+ }
+
+ 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) {
+ // orderList can be null m(
+ orderList?.scrollToPosition(position)
+ orderList?.post { this.tracker?.select(it.id) }
+ }
+ }
+ // workaround for bug: SelectionObserver doesn't update when removing selected item
+ if (tracker.hasSelection()) {
+ val key = tracker.selection.first()
+ val product = order.products.find { it.id == key }
+ if (product == null) tracker.clearSelection()
+ }
+ }
+ }
+
+}
+
+private class OrderAdapter : Adapter<OrderViewHolder>() {
+
+ lateinit var tracker: SelectionTracker<String>
+ val keyProvider = OrderKeyProvider()
+ private val itemCallback = object : DiffUtil.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)
+ }
+
+ private inner class OrderViewHolder(private val v: View) : ViewHolder(v) {
+ private val quantity: TextView = v.findViewById(R.id.quantity)
+ private val name: TextView = v.findViewById(R.id.name)
+ private val price: TextView = v.findViewById(R.id.price)
+
+ fun bind(product: ConfigProduct, selected: Boolean) {
+ v.isActivated = selected
+ quantity.text = product.quantity.toString()
+ name.text = product.localizedDescription
+ price.text = String.format("%.2f", product.priceAsDouble * product.quantity)
+ }
+ }
+
+ private 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.adapterPosition
+ 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/ProductsFragment.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/order/ProductsFragment.kt
new file mode 100644
index 0000000..4704ad0
--- /dev/null
+++ b/merchant-terminal/src/main/java/net/taler/merchantpos/order/ProductsFragment.kt
@@ -0,0 +1,111 @@
+/*
+ * 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 android.widget.TextView
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import androidx.lifecycle.Observer
+import androidx.recyclerview.widget.GridLayoutManager
+import androidx.recyclerview.widget.RecyclerView.Adapter
+import androidx.recyclerview.widget.RecyclerView.ViewHolder
+import kotlinx.android.synthetic.main.fragment_products.*
+import net.taler.merchantpos.MainViewModel
+import net.taler.merchantpos.R
+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)
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View? {
+ return inflater.inflate(R.layout.fragment_products, container, false)
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ productsList.apply {
+ adapter = this@ProductsFragment.adapter
+ layoutManager = GridLayoutManager(requireContext(), 3)
+ }
+
+ orderManager.products.observe(viewLifecycleOwner, Observer { products ->
+ if (products == null) {
+ adapter.setItems(emptyList())
+ } else {
+ adapter.setItems(products)
+ }
+ progressBar.visibility = INVISIBLE
+ })
+ }
+
+ override fun onProductSelected(product: ConfigProduct) {
+ orderManager.addProduct(orderManager.currentOrderId.value!!, product)
+ }
+
+}
+
+private class ProductAdapter(
+ private val listener: ProductSelectionListener
+) : Adapter<ProductViewHolder>() {
+
+ private val products = ArrayList<ConfigProduct>()
+
+ override fun getItemCount() = products.size
+
+ 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(products[position])
+ }
+
+ fun setItems(items: List<ConfigProduct>) {
+ products.clear()
+ products.addAll(items)
+ notifyDataSetChanged()
+ }
+
+ private inner class ProductViewHolder(private val v: View) : ViewHolder(v) {
+ private val name: TextView = v.findViewById(R.id.name)
+ private val price: TextView = v.findViewById(R.id.price)
+
+ fun bind(product: ConfigProduct) {
+ name.text = product.localizedDescription
+ price.text = product.priceAsDouble.toString()
+ v.setOnClickListener { listener.onProductSelected(product) }
+ }
+ }
+
+}
diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/payment/Payment.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/payment/Payment.kt
new file mode 100644
index 0000000..b7e4a4b
--- /dev/null
+++ b/merchant-terminal/src/main/java/net/taler/merchantpos/payment/Payment.kt
@@ -0,0 +1,29 @@
+/*
+ * 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.payment
+
+import net.taler.merchantpos.order.Order
+
+data class Payment(
+ val order: Order,
+ val summary: String,
+ val currency: String,
+ val orderId: String? = null,
+ val talerPayUri: String? = null,
+ val paid: Boolean = false,
+ val error: Boolean = false
+)
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
new file mode 100644
index 0000000..7f15816
--- /dev/null
+++ b/merchant-terminal/src/main/java/net/taler/merchantpos/payment/PaymentManager.kt
@@ -0,0 +1,154 @@
+/*
+ * 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.payment
+
+import android.os.CountDownTimer
+import android.util.Log
+import androidx.annotation.UiThread
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import com.android.volley.Request.Method.GET
+import com.android.volley.Request.Method.POST
+import com.android.volley.RequestQueue
+import com.android.volley.Response.ErrorListener
+import com.android.volley.Response.Listener
+import com.android.volley.VolleyError
+import com.fasterxml.jackson.databind.ObjectMapper
+import net.taler.merchantpos.config.ConfigManager
+import net.taler.merchantpos.config.MerchantRequest
+import net.taler.merchantpos.order.ContractProduct
+import net.taler.merchantpos.order.Order
+import org.json.JSONArray
+import org.json.JSONObject
+import java.net.URLEncoder
+import java.util.concurrent.TimeUnit.MINUTES
+import java.util.concurrent.TimeUnit.SECONDS
+
+private val TIMEOUT = MINUTES.toMillis(2)
+private val CHECK_INTERVAL = SECONDS.toMillis(1)
+private const val FULFILLMENT_PREFIX = "taler://fulfillment-success/"
+
+class PaymentManager(
+ private val configManager: ConfigManager,
+ private val queue: RequestQueue,
+ private val mapper: ObjectMapper
+) {
+
+ companion object {
+ val TAG = PaymentManager::class.java.simpleName
+ }
+
+ private val mPayment = MutableLiveData<Payment>()
+ val payment: LiveData<Payment> = mPayment
+
+ private val checkTimer = object : CountDownTimer(TIMEOUT, CHECK_INTERVAL) {
+ override fun onTick(millisUntilFinished: Long) {
+ val orderId = payment.value?.orderId
+ if (orderId == null) cancel()
+ else checkPayment(orderId)
+ }
+
+ override fun onFinish() {
+ payment.value?.copy(error = true)?.let { mPayment.value = it }
+ }
+ }
+
+ @UiThread
+ fun createPayment(order: Order) {
+ val merchantConfig = configManager.merchantConfig!!
+
+ val currency = merchantConfig.currency!!
+ val amount = "$currency:${order.totalAsString}"
+ val summary = order.summary
+ val summaryI18n = order.summaryI18n
+
+ mPayment.value = Payment(order, summary, currency)
+
+ val fulfillmentId = "${System.currentTimeMillis()}-${order.hashCode()}"
+ val fulfillmentUrl =
+ "${FULFILLMENT_PREFIX}${URLEncoder.encode(summary, "UTF-8")}#$fulfillmentId"
+ val body = JSONObject().apply {
+ put("order", JSONObject().apply {
+ put("amount", amount)
+ put("summary", summary)
+ if (summaryI18n != null) put("summary_i18n", order.summaryI18n)
+ // fulfillment_url needs to be unique per order
+ put("fulfillment_url", fulfillmentUrl)
+ put("instance", "default")
+ put("products", order.getProductsJson())
+ })
+ }
+
+ Log.d(TAG, body.toString(4))
+
+ val req = MerchantRequest(POST, merchantConfig, "order", null, body,
+ Listener { onOrderCreated(it) },
+ ErrorListener { onNetworkError(it) }
+ )
+ queue.add(req)
+ }
+
+ private fun Order.getProductsJson(): JSONArray {
+ val contractProducts = products.map { ContractProduct(it) }
+ val productsStr = mapper.writeValueAsString(contractProducts)
+ return JSONArray(productsStr)
+ }
+
+ private fun onOrderCreated(orderResponse: JSONObject) {
+ val orderId = orderResponse.getString("order_id")
+ mPayment.value = mPayment.value!!.copy(orderId = orderId)
+ checkTimer.start()
+ }
+
+ private fun checkPayment(orderId: String) {
+ val merchantConfig = configManager.merchantConfig!!
+ val params = mapOf(
+ "order_id" to orderId,
+ "instance" to merchantConfig.instance
+ )
+
+ val req = MerchantRequest(GET, merchantConfig, "check-payment", params, null,
+ Listener { onPaymentChecked(it) },
+ ErrorListener { onNetworkError(it) })
+ queue.add(req)
+ }
+
+ /**
+ * Called when the /check-payment response gave a result.
+ */
+ private fun onPaymentChecked(checkPaymentResponse: JSONObject) {
+ val currentValue = requireNotNull(mPayment.value)
+ if (checkPaymentResponse.getBoolean("paid")) {
+ mPayment.value = currentValue.copy(paid = true)
+ checkTimer.cancel()
+ } else if (currentValue.talerPayUri == null) {
+ val talerPayUri = checkPaymentResponse.getString("taler_pay_uri")
+ mPayment.value = currentValue.copy(talerPayUri = talerPayUri)
+ }
+ }
+
+ private fun onNetworkError(volleyError: VolleyError) {
+ Log.e(PaymentManager::class.java.simpleName, volleyError.toString())
+ cancelPayment()
+ }
+
+ fun cancelPayment() {
+ mPayment.value = mPayment.value!!.copy(error = true)
+ checkTimer.cancel()
+ }
+
+}
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
new file mode 100644
index 0000000..10d538d
--- /dev/null
+++ b/merchant-terminal/src/main/java/net/taler/merchantpos/payment/PaymentSuccessFragment.kt
@@ -0,0 +1,44 @@
+/*
+ * 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.payment
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.fragment.app.Fragment
+import androidx.navigation.fragment.findNavController
+import kotlinx.android.synthetic.main.fragment_payment_success.*
+import net.taler.merchantpos.R
+
+class PaymentSuccessFragment : Fragment() {
+
+ override fun onCreateView(
+ inflater: LayoutInflater, container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View? {
+ return inflater.inflate(R.layout.fragment_payment_success, container, false)
+ }
+
+ override fun onActivityCreated(savedInstanceState: Bundle?) {
+ super.onActivityCreated(savedInstanceState)
+ paymentButton.setOnClickListener {
+ findNavController().navigateUp()
+ }
+ }
+
+}
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
new file mode 100644
index 0000000..24f67f1
--- /dev/null
+++ b/merchant-terminal/src/main/java/net/taler/merchantpos/payment/ProcessPaymentFragment.kt
@@ -0,0 +1,96 @@
+/*
+ * 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.payment
+
+import android.annotation.SuppressLint
+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.lifecycle.Observer
+import androidx.navigation.fragment.findNavController
+import com.google.android.material.snackbar.BaseTransientBottomBar.LENGTH_LONG
+import kotlinx.android.synthetic.main.fragment_process_payment.*
+import net.taler.merchantpos.MainViewModel
+import net.taler.merchantpos.NfcManager.Companion.hasNfc
+import net.taler.merchantpos.QrCodeManager.makeQrCode
+import net.taler.merchantpos.R
+import net.taler.merchantpos.fadeIn
+import net.taler.merchantpos.fadeOut
+import net.taler.merchantpos.navigate
+import net.taler.merchantpos.payment.ProcessPaymentFragmentDirections.Companion.actionProcessPaymentToPaymentSuccess
+import net.taler.merchantpos.topSnackbar
+
+class ProcessPaymentFragment : Fragment() {
+
+ private val model: MainViewModel by activityViewModels()
+ private val paymentManager by lazy { model.paymentManager }
+
+ override fun onCreateView(
+ inflater: LayoutInflater, container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View? {
+ return inflater.inflate(R.layout.fragment_process_payment, container, false)
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ val introRes =
+ if (hasNfc(requireContext())) R.string.payment_intro_nfc else R.string.payment_intro
+ payIntroView.setText(introRes)
+ paymentManager.payment.observe(viewLifecycleOwner, Observer { payment ->
+ onPaymentStateChanged(payment)
+ })
+ cancelPaymentButton.setOnClickListener {
+ onPaymentCancel()
+ }
+ }
+
+ private fun onPaymentStateChanged(payment: Payment) {
+ if (payment.error) {
+ topSnackbar(view!!, R.string.error_network, LENGTH_LONG)
+ findNavController().navigateUp()
+ return
+ }
+ if (payment.paid) {
+ model.orderManager.onOrderPaid(payment.order.id)
+ actionProcessPaymentToPaymentSuccess().navigate(findNavController())
+ return
+ }
+ payIntroView.fadeIn()
+ @SuppressLint("SetTextI18n")
+ amountView.text = "${payment.order.totalAsString} ${payment.currency}"
+ payment.orderId?.let {
+ orderRefView.text = getString(R.string.payment_order_ref, it)
+ orderRefView.fadeIn()
+ }
+ payment.talerPayUri?.let {
+ val qrcodeBitmap = makeQrCode(it)
+ qrcodeView.setImageBitmap(qrcodeBitmap)
+ qrcodeView.fadeIn()
+ progressBar.fadeOut()
+ }
+ }
+
+ private fun onPaymentCancel() {
+ paymentManager.cancelPayment()
+ findNavController().navigateUp()
+ topSnackbar(view!!, R.string.payment_canceled, LENGTH_LONG)
+ }
+
+}
diff --git a/merchant-terminal/src/main/res/color/button_bottom.xml b/merchant-terminal/src/main/res/color/button_bottom.xml
new file mode 100644
index 0000000..83363e9
--- /dev/null
+++ b/merchant-terminal/src/main/res/color/button_bottom.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<selector xmlns:android="http://schemas.android.com/apk/res/android">
+ <item android:color="@color/bottomButtons" android:state_enabled="true" />
+ <item android:alpha="0.12" android:color="?attr/colorOnSurface" />
+</selector>
diff --git a/merchant-terminal/src/main/res/drawable/ic_cash_refund.xml b/merchant-terminal/src/main/res/drawable/ic_cash_refund.xml
new file mode 100644
index 0000000..7359ca3
--- /dev/null
+++ b/merchant-terminal/src/main/res/drawable/ic_cash_refund.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+ <path
+ android:fillColor="#000000"
+ android:pathData="M3,11H21V23H3V11M12,15A2,2 0 0,1 14,17A2,2 0 0,1 12,19A2,2 0 0,1 10,17A2,2 0 0,1 12,15M7,13A2,2 0 0,1 5,15V19A2,2 0 0,1 7,21H17A2,2 0 0,1 19,19V15A2,2 0 0,1 17,13H7M17,5V10H15.5V6.5H9.88L12.3,8.93L11.24,10L7,5.75L11.24,1.5L12.3,2.57L9.88,5H17Z" />
+</vector>
diff --git a/merchant-terminal/src/main/res/drawable/ic_check_circle.xml b/merchant-terminal/src/main/res/drawable/ic_check_circle.xml
new file mode 100644
index 0000000..61e1b5a
--- /dev/null
+++ b/merchant-terminal/src/main/res/drawable/ic_check_circle.xml
@@ -0,0 +1,10 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:alpha="0.56"
+ android:viewportWidth="24.0"
+ android:viewportHeight="24.0">
+ <path
+ android:fillColor="@color/green"
+ android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM10,17l-5,-5 1.41,-1.41L10,14.17l7.59,-7.59L19,8l-9,9z" />
+</vector>
diff --git a/merchant-terminal/src/main/res/drawable/ic_history_black_24dp.xml b/merchant-terminal/src/main/res/drawable/ic_history_black_24dp.xml
new file mode 100644
index 0000000..a61de1b
--- /dev/null
+++ b/merchant-terminal/src/main/res/drawable/ic_history_black_24dp.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24.0"
+ android:viewportHeight="24.0">
+ <path
+ android:fillColor="#FF000000"
+ android:pathData="M13,3c-4.97,0 -9,4.03 -9,9L1,12l3.89,3.89 0.07,0.14L9,12L6,12c0,-3.87 3.13,-7 7,-7s7,3.13 7,7 -3.13,7 -7,7c-1.93,0 -3.68,-0.79 -4.94,-2.06l-1.42,1.42C8.27,19.99 10.51,21 13,21c4.97,0 9,-4.03 9,-9s-4.03,-9 -9,-9zM12,8v5l4.28,2.54 0.72,-1.21 -3.5,-2.08L13.5,8L12,8z"/>
+</vector>
diff --git a/merchant-terminal/src/main/res/drawable/ic_launcher_background.xml b/merchant-terminal/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 0000000..2408e30
--- /dev/null
+++ b/merchant-terminal/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,74 @@
+<?xml version="1.0" encoding="utf-8"?>
+<vector
+ android:height="108dp"
+ android:width="108dp"
+ android:viewportHeight="108"
+ android:viewportWidth="108"
+ xmlns:android="http://schemas.android.com/apk/res/android">
+ <path android:fillColor="#008577"
+ android:pathData="M0,0h108v108h-108z"/>
+ <path android:fillColor="#00000000" android:pathData="M9,0L9,108"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M19,0L19,108"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M29,0L29,108"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M39,0L39,108"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M49,0L49,108"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M59,0L59,108"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M69,0L69,108"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M79,0L79,108"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M89,0L89,108"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M99,0L99,108"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M0,9L108,9"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M0,19L108,19"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M0,29L108,29"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M0,39L108,39"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M0,49L108,49"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M0,59L108,59"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M0,69L108,69"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M0,79L108,79"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M0,89L108,89"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M0,99L108,99"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M19,29L89,29"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M19,39L89,39"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M19,49L89,49"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M19,59L89,59"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M19,69L89,69"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M19,79L89,79"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M29,19L29,89"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M39,19L39,89"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M49,19L49,89"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M59,19L59,89"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M69,19L69,89"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M79,19L79,89"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+</vector>
diff --git a/merchant-terminal/src/main/res/drawable/ic_menu_manage.xml b/merchant-terminal/src/main/res/drawable/ic_menu_manage.xml
new file mode 100644
index 0000000..a0e423c
--- /dev/null
+++ b/merchant-terminal/src/main/res/drawable/ic_menu_manage.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24.0"
+ android:viewportHeight="24.0">
+ <path
+ android:fillColor="#FF000000"
+ android:pathData="M22.7,19l-9.1,-9.1c0.9,-2.3 0.4,-5 -1.5,-6.9 -2,-2 -5,-2.4 -7.4,-1.3L9,6 6,9 1.6,4.7C0.4,7.1 0.9,10.1 2.9,12.1c1.9,1.9 4.6,2.4 6.9,1.5l9.1,9.1c0.4,0.4 1,0.4 1.4,0l2.3,-2.3c0.5,-0.4 0.5,-1.1 0.1,-1.4z"/>
+</vector> \ No newline at end of file
diff --git a/merchant-terminal/src/main/res/drawable/ic_move_money_24dp.xml b/merchant-terminal/src/main/res/drawable/ic_move_money_24dp.xml
new file mode 100644
index 0000000..349f48f
--- /dev/null
+++ b/merchant-terminal/src/main/res/drawable/ic_move_money_24dp.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24.0"
+ android:viewportHeight="24.0">
+ <path
+ android:fillColor="#FF000000"
+ android:pathData="M19,3L4.99,3c-1.11,0 -1.98,0.9 -1.98,2L3,19c0,1.1 0.88,2 1.99,2L19,21c1.1,0 2,-0.9 2,-2L21,5c0,-1.1 -0.9,-2 -2,-2zM19,15h-4c0,1.66 -1.35,3 -3,3s-3,-1.34 -3,-3L4.99,15L4.99,5L19,5v10zM16,10h-2L14,7h-4v3L8,10l4,4 4,-4z"/>
+</vector>
diff --git a/merchant-terminal/src/main/res/drawable/selectable_background.xml b/merchant-terminal/src/main/res/drawable/selectable_background.xml
new file mode 100644
index 0000000..b82de92
--- /dev/null
+++ b/merchant-terminal/src/main/res/drawable/selectable_background.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<selector xmlns:android="http://schemas.android.com/apk/res/android">
+ <item android:drawable="@color/selectedBackground" android:state_activated="true" />
+ <item android:drawable="@android:color/transparent" />
+</selector> \ No newline at end of file
diff --git a/merchant-terminal/src/main/res/drawable/side_nav_bar.xml b/merchant-terminal/src/main/res/drawable/side_nav_bar.xml
new file mode 100644
index 0000000..50dc048
--- /dev/null
+++ b/merchant-terminal/src/main/res/drawable/side_nav_bar.xml
@@ -0,0 +1,9 @@
+<shape xmlns:android="http://schemas.android.com/apk/res/android"
+ android:shape="rectangle">
+ <gradient
+ android:angle="135"
+ android:centerColor="@color/colorPrimaryDark"
+ android:endColor="@color/colorPrimaryDark"
+ android:startColor="@color/colorPrimary"
+ android:type="linear"/>
+</shape> \ No newline at end of file
diff --git a/merchant-terminal/src/main/res/layout/activity_main.xml b/merchant-terminal/src/main/res/layout/activity_main.xml
new file mode 100644
index 0000000..6523caa
--- /dev/null
+++ b/merchant-terminal/src/main/res/layout/activity_main.xml
@@ -0,0 +1,42 @@
+<?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
+ 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:menu="@menu/activity_main_drawer"
+ app:headerLayout="@layout/nav_header_main" />
+
+</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
new file mode 100644
index 0000000..0254c71
--- /dev/null
+++ b/merchant-terminal/src/main/res/layout/app_bar_main.xml
@@ -0,0 +1,53 @@
+<?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"
+ android:theme="@style/AppTheme.AppBarOverlay">
+
+ <androidx.appcompat.widget.Toolbar
+ android:id="@+id/toolbar"
+ android:layout_width="match_parent"
+ android:layout_height="?attr/actionBarSize"
+ android:background="?attr/colorPrimary"
+ app:popupTheme="@style/AppTheme.PopupOverlay" />
+
+ </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_insetEdge="top"
+ 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:navGraph="@navigation/nav_graph" />
+
+</androidx.coordinatorlayout.widget.CoordinatorLayout>
diff --git a/merchant-terminal/src/main/res/layout/fragment_categories.xml b/merchant-terminal/src/main/res/layout/fragment_categories.xml
new file mode 100644
index 0000000..a90585f
--- /dev/null
+++ b/merchant-terminal/src/main/res/layout/fragment_categories.xml
@@ -0,0 +1,46 @@
+<?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"
+ xmlns:tools="http://schemas.android.com/tools"
+ android:layout_marginStart="8dp"
+ android:layout_marginEnd="8dp"
+ android:layout_height="match_parent">
+
+ <androidx.recyclerview.widget.RecyclerView
+ android:id="@+id/categoriesList"
+ android:layout_width="0dp"
+ tools:listitem="@layout/list_item_category"
+ android:layout_height="0dp"
+ app:layout_constraintBottom_toBottomOf="parent"
+ 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"
+ 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
new file mode 100644
index 0000000..af7dcaf
--- /dev/null
+++ b/merchant-terminal/src/main/res/layout/fragment_config_fetcher.xml
@@ -0,0 +1,45 @@
+<?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_merchant_config.xml b/merchant-terminal/src/main/res/layout/fragment_merchant_config.xml
new file mode 100644
index 0000000..2541887
--- /dev/null
+++ b/merchant-terminal/src/main/res/layout/fragment_merchant_config.xml
@@ -0,0 +1,152 @@
+<?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"
+ xmlns:tools="http://schemas.android.com/tools"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:fillViewport="true">
+
+ <androidx.constraintlayout.widget.ConstraintLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ tools:context=".config.MerchantConfigFragment">
+
+ <com.google.android.material.textfield.TextInputLayout
+ android:id="@+id/configUrlView"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_margin="16dp"
+ android:hint="@string/config_url"
+ app:boxBackgroundColor="@android:color/transparent"
+ app:boxBackgroundMode="outline"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="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>
+
+ <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:boxBackgroundColor="@android:color/transparent"
+ app:boxBackgroundMode="outline"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@+id/configUrlView">
+
+ <com.google.android.material.textfield.TextInputEditText
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:inputType="text" />
+
+ </com.google.android.material.textfield.TextInputLayout>
+
+ <com.google.android.material.textfield.TextInputLayout
+ android:id="@+id/passwordView"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_margin="16dp"
+ android:hint="@string/config_password"
+ app:boxBackgroundColor="@android:color/transparent"
+ app:boxBackgroundMode="outline"
+ app:endIconMode="password_toggle"
+ app:layout_constraintEnd_toStartOf="@+id/forgetPasswordButton"
+ app:layout_constraintStart_toStartOf="parent"
+ 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>
+
+ <Button
+ android:id="@+id/forgetPasswordButton"
+ 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_constraintBottom_toBottomOf="@+id/passwordView"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintTop_toTopOf="@+id/passwordView"
+ tools:visibility="visible" />
+
+ <CheckBox
+ android:id="@+id/savePasswordCheckBox"
+ 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_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toStartOf="@+id/okButton"
+ app:layout_constraintHorizontal_chainStyle="spread_inside"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@+id/passwordView"
+ app:layout_constraintVertical_bias="0.0" />
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/okButton"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_margin="16dp"
+ android:text="@string/config_ok"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toEndOf="@+id/savePasswordCheckBox"
+ app:layout_constraintTop_toBottomOf="@+id/passwordView"
+ app:layout_constraintVertical_bias="0.0" />
+
+ <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/okButton"
+ app:layout_constraintEnd_toEndOf="@+id/okButton"
+ app:layout_constraintStart_toStartOf="@+id/okButton"
+ app:layout_constraintTop_toTopOf="@+id/okButton"
+ tools:visibility="visible" />
+
+ <TextView
+ android:id="@+id/configDocsView"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_margin="16dp"
+ android:text="@string/config_docs"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@+id/okButton" />
+
+ </androidx.constraintlayout.widget.ConstraintLayout>
+
+</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
new file mode 100644
index 0000000..21e6f08
--- /dev/null
+++ b/merchant-terminal/src/main/res/layout/fragment_merchant_history.xml
@@ -0,0 +1,29 @@
+<?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
new file mode 100644
index 0000000..4af9c77
--- /dev/null
+++ b/merchant-terminal/src/main/res/layout/fragment_order.xml
@@ -0,0 +1,138 @@
+<?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/restartButton"
+ app:layout_constraintEnd_toStartOf="@+id/guideline1"
+ app:layout_constraintStart_toStartOf="parent"
+ 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_toTopOf="@+id/restartButton"
+ 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" />
+
+ <androidx.fragment.app.FragmentContainerView
+ android:id="@+id/fragment3"
+ android:name="net.taler.merchantpos.order.CategoriesFragment"
+ android:layout_width="0dp"
+ android:layout_height="0dp"
+ android:layout_marginBottom="8dp"
+ app:layout_constraintBottom_toTopOf="@+id/restartButton"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="@+id/guideline2"
+ app:layout_constraintTop_toTopOf="parent"
+ tools:layout="@layout/fragment_categories" />
+
+ <Button
+ android:id="@+id/restartButton"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginStart="8dp"
+ android:backgroundTint="@color/button_bottom"
+ android:text="@string/order_restart"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintStart_toStartOf="parent" />
+
+ <Button
+ android:id="@+id/plusButton"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginStart="16dp"
+ android:minWidth="48dp"
+ android:text="+1"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintStart_toEndOf="@+id/minusButton"
+ tools:ignore="HardcodedText" />
+
+ <Button
+ android:id="@+id/minusButton"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginStart="32dp"
+ android:minWidth="48dp"
+ android:text="-1"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintStart_toEndOf="@+id/restartButton"
+ tools:ignore="HardcodedText" />
+
+ <Button
+ android:id="@+id/prevButton"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginStart="32dp"
+ android:backgroundTint="@color/button_bottom"
+ android:text="@string/order_previous"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintStart_toEndOf="@+id/plusButton" />
+
+ <Button
+ android:id="@+id/nextButton"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginStart="16dp"
+ android:backgroundTint="@color/button_bottom"
+ android:text="@string/order_next"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintStart_toEndOf="@+id/prevButton" />
+
+ <Button
+ android:id="@+id/completeButton"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginStart="32dp"
+ android:layout_marginEnd="8dp"
+ android:backgroundTint="@color/button_bottom"
+ android:text="@string/order_complete"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintHorizontal_bias="1.0"
+ app:layout_constraintStart_toEndOf="@+id/nextButton" />
+
+</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
new file mode 100644
index 0000000..7d6b258
--- /dev/null
+++ b/merchant-terminal/src/main/res/layout/fragment_order_state.xml
@@ -0,0 +1,52 @@
+<?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_toTopOf="@+id/totalView"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toTopOf="parent"
+ tools:listitem="@layout/list_item_order" />
+
+ <TextView
+ android:id="@+id/totalView"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:background="@color/highlightedBackground"
+ android:elevation="2dp"
+ android:gravity="center_vertical|end"
+ android:padding="8dp"
+ android:textColor="?android:textColorPrimary"
+ android:textSize="16sp"
+ android:visibility="invisible"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@+id/orderList"
+ tools:text="Total: 23.75 TESTKUDOS"
+ tools:visibility="visible" />
+
+</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/merchant-terminal/src/main/res/layout/fragment_payment_success.xml b/merchant-terminal/src/main/res/layout/fragment_payment_success.xml
new file mode 100644
index 0000000..1bc9be7
--- /dev/null
+++ b/merchant-terminal/src/main/res/layout/fragment_payment_success.xml
@@ -0,0 +1,78 @@
+<?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"
+ 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_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.25" />
+
+ <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.75" />
+
+ <Button
+ android:id="@+id/paymentButton"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_margin="16dp"
+ android:text="@string/payment_back_button"
+ 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
new file mode 100644
index 0000000..6cd8ea1
--- /dev/null
+++ b/merchant-terminal/src/main/res/layout/fragment_process_payment.xml
@@ -0,0 +1,110 @@
+<?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/qrcodeView"
+ android:layout_width="0dp"
+ android:layout_height="0dp"
+ android:layout_margin="32dp"
+ android:visibility="invisible"
+ 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"
+ tools:visibility="visible" />
+
+ <ProgressBar
+ android:id="@+id/progressBar"
+ style="?android:attr/progressBarStyleLarge"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ app:layout_constraintBottom_toBottomOf="@+id/qrcodeView"
+ app:layout_constraintEnd_toEndOf="@+id/qrcodeView"
+ app:layout_constraintStart_toStartOf="@+id/qrcodeView"
+ app:layout_constraintTop_toTopOf="@+id/qrcodeView" />
+
+ <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="24sp"
+ 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_ref"
+ 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: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" />
+
+</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
new file mode 100644
index 0000000..f0e86e7
--- /dev/null
+++ b/merchant-terminal/src/main/res/layout/fragment_products.xml
@@ -0,0 +1,44 @@
+<?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"
+ xmlns:tools="http://schemas.android.com/tools"
+ android:layout_height="match_parent">
+
+ <androidx.recyclerview.widget.RecyclerView
+ android:id="@+id/productsList"
+ android:layout_width="0dp"
+ android:layout_height="0dp"
+ app:layout_constraintTop_toTopOf="parent"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="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
new file mode 100644
index 0000000..5a78cdd
--- /dev/null
+++ b/merchant-terminal/src/main/res/layout/fragment_refund.xml
@@ -0,0 +1,122 @@
+<?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=".history.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
new file mode 100644
index 0000000..8447d28
--- /dev/null
+++ b/merchant-terminal/src/main/res/layout/fragment_refund_uri.xml
@@ -0,0 +1,93 @@
+<?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="24sp"
+ 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_constraintHorizontal_bias="0.0"
+ app:layout_constraintStart_toStartOf="@+id/guideline" />
+
+</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
new file mode 100644
index 0000000..cbdbd34
--- /dev/null
+++ b/merchant-terminal/src/main/res/layout/list_item_category.xml
@@ -0,0 +1,33 @@
+<?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"
+ 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
new file mode 100644
index 0000000..fe485ba
--- /dev/null
+++ b/merchant-terminal/src/main/res/layout/list_item_history.xml
@@ -0,0 +1,97 @@
+<?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/refundButton"
+ 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/refundButton"
+ app:layout_constraintStart_toEndOf="@+id/orderIdView"
+ app:layout_constraintTop_toBottomOf="@+id/orderAmountView"
+ app:layout_constraintVertical_bias="1.0"
+ tools:text="3 hrs. ago" />
+
+ <ImageButton
+ android:id="@+id/refundButton"
+ android:layout_width="48dp"
+ android:layout_height="48dp"
+ android:backgroundTint="?colorPrimary"
+ android:contentDescription="@string/history_refund"
+ android:tint="?attr/colorOnPrimary"
+ android:visibility="gone"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintTop_toTopOf="parent"
+ app:srcCompat="@drawable/ic_cash_refund" />
+
+</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
new file mode 100644
index 0000000..f88364d
--- /dev/null
+++ b/merchant-terminal/src/main/res/layout/list_item_order.xml
@@ -0,0 +1,61 @@
+<?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" />
+
+ <TextView
+ android:id="@+id/name"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_marginStart="8dp"
+ android:layout_marginEnd="8dp"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toStartOf="@+id/price"
+ app:layout_constraintStart_toEndOf="@+id/quantity"
+ 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/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
new file mode 100644
index 0000000..1037bef
--- /dev/null
+++ b/merchant-terminal/src/main/res/layout/list_item_product.xml
@@ -0,0 +1,56 @@
+<?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">
+
+ <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_toTopOf="parent"
+ tools:text="Steak and two Eggs" />
+
+ <TextView
+ android:id="@+id/price"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="8dp"
+ android:textColor="?android:textColorSecondary"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintTop_toBottomOf="@+id/name"
+ tools:text="7.95" />
+
+ </androidx.constraintlayout.widget.ConstraintLayout>
+
+</com.google.android.material.card.MaterialCardView> \ No newline at end of file
diff --git a/merchant-terminal/src/main/res/layout/nav_header_main.xml b/merchant-terminal/src/main/res/layout/nav_header_main.xml
new file mode 100644
index 0000000..14bbd51
--- /dev/null
+++ b/merchant-terminal/src/main/res/layout/nav_header_main.xml
@@ -0,0 +1,55 @@
+<?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
new file mode 100644
index 0000000..1303605
--- /dev/null
+++ b/merchant-terminal/src/main/res/menu/activity_main_drawer.xml
@@ -0,0 +1,36 @@
+<?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_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/mipmap-anydpi-v26/ic_taler_logo.xml b/merchant-terminal/src/main/res/mipmap-anydpi-v26/ic_taler_logo.xml
new file mode 100644
index 0000000..c4a603d
--- /dev/null
+++ b/merchant-terminal/src/main/res/mipmap-anydpi-v26/ic_taler_logo.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
+ <background android:drawable="@drawable/ic_launcher_background"/>
+ <foreground android:drawable="@mipmap/ic_launcher_foreground"/>
+</adaptive-icon> \ No newline at end of file
diff --git a/merchant-terminal/src/main/res/mipmap-anydpi-v26/ic_taler_logo_round.xml b/merchant-terminal/src/main/res/mipmap-anydpi-v26/ic_taler_logo_round.xml
new file mode 100644
index 0000000..c4a603d
--- /dev/null
+++ b/merchant-terminal/src/main/res/mipmap-anydpi-v26/ic_taler_logo_round.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
+ <background android:drawable="@drawable/ic_launcher_background"/>
+ <foreground android:drawable="@mipmap/ic_launcher_foreground"/>
+</adaptive-icon> \ No newline at end of file
diff --git a/merchant-terminal/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/merchant-terminal/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..75273ec
--- /dev/null
+++ b/merchant-terminal/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
Binary files differ
diff --git a/merchant-terminal/src/main/res/mipmap-hdpi/ic_taler_logo.png b/merchant-terminal/src/main/res/mipmap-hdpi/ic_taler_logo.png
new file mode 100644
index 0000000..eaecede
--- /dev/null
+++ b/merchant-terminal/src/main/res/mipmap-hdpi/ic_taler_logo.png
Binary files differ
diff --git a/merchant-terminal/src/main/res/mipmap-hdpi/ic_taler_logo_round.png b/merchant-terminal/src/main/res/mipmap-hdpi/ic_taler_logo_round.png
new file mode 100644
index 0000000..caa2a3e
--- /dev/null
+++ b/merchant-terminal/src/main/res/mipmap-hdpi/ic_taler_logo_round.png
Binary files differ
diff --git a/merchant-terminal/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/merchant-terminal/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..a450287
--- /dev/null
+++ b/merchant-terminal/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
Binary files differ
diff --git a/merchant-terminal/src/main/res/mipmap-mdpi/ic_taler_logo.png b/merchant-terminal/src/main/res/mipmap-mdpi/ic_taler_logo.png
new file mode 100644
index 0000000..e1f7374
--- /dev/null
+++ b/merchant-terminal/src/main/res/mipmap-mdpi/ic_taler_logo.png
Binary files differ
diff --git a/merchant-terminal/src/main/res/mipmap-mdpi/ic_taler_logo_round.png b/merchant-terminal/src/main/res/mipmap-mdpi/ic_taler_logo_round.png
new file mode 100644
index 0000000..e92d2d3
--- /dev/null
+++ b/merchant-terminal/src/main/res/mipmap-mdpi/ic_taler_logo_round.png
Binary files differ
diff --git a/merchant-terminal/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/merchant-terminal/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..a5e875c
--- /dev/null
+++ b/merchant-terminal/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
Binary files differ
diff --git a/merchant-terminal/src/main/res/mipmap-xhdpi/ic_taler_logo.png b/merchant-terminal/src/main/res/mipmap-xhdpi/ic_taler_logo.png
new file mode 100644
index 0000000..5ca4409
--- /dev/null
+++ b/merchant-terminal/src/main/res/mipmap-xhdpi/ic_taler_logo.png
Binary files differ
diff --git a/merchant-terminal/src/main/res/mipmap-xhdpi/ic_taler_logo_round.png b/merchant-terminal/src/main/res/mipmap-xhdpi/ic_taler_logo_round.png
new file mode 100644
index 0000000..12b9056
--- /dev/null
+++ b/merchant-terminal/src/main/res/mipmap-xhdpi/ic_taler_logo_round.png
Binary files differ
diff --git a/merchant-terminal/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/merchant-terminal/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..e9d1fc9
--- /dev/null
+++ b/merchant-terminal/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
Binary files differ
diff --git a/merchant-terminal/src/main/res/mipmap-xxhdpi/ic_taler_logo.png b/merchant-terminal/src/main/res/mipmap-xxhdpi/ic_taler_logo.png
new file mode 100644
index 0000000..a786efa
--- /dev/null
+++ b/merchant-terminal/src/main/res/mipmap-xxhdpi/ic_taler_logo.png
Binary files differ
diff --git a/merchant-terminal/src/main/res/mipmap-xxhdpi/ic_taler_logo_round.png b/merchant-terminal/src/main/res/mipmap-xxhdpi/ic_taler_logo_round.png
new file mode 100644
index 0000000..b22a84e
--- /dev/null
+++ b/merchant-terminal/src/main/res/mipmap-xxhdpi/ic_taler_logo_round.png
Binary files differ
diff --git a/merchant-terminal/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/merchant-terminal/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..f8037d1
--- /dev/null
+++ b/merchant-terminal/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
Binary files differ
diff --git a/merchant-terminal/src/main/res/mipmap-xxxhdpi/ic_taler_logo.png b/merchant-terminal/src/main/res/mipmap-xxxhdpi/ic_taler_logo.png
new file mode 100644
index 0000000..0e9df6a
--- /dev/null
+++ b/merchant-terminal/src/main/res/mipmap-xxxhdpi/ic_taler_logo.png
Binary files differ
diff --git a/merchant-terminal/src/main/res/mipmap-xxxhdpi/ic_taler_logo_round.png b/merchant-terminal/src/main/res/mipmap-xxxhdpi/ic_taler_logo_round.png
new file mode 100644
index 0000000..6bef9bd
--- /dev/null
+++ b/merchant-terminal/src/main/res/mipmap-xxxhdpi/ic_taler_logo_round.png
Binary files differ
diff --git a/merchant-terminal/src/main/res/navigation/nav_graph.xml b/merchant-terminal/src/main/res/navigation/nav_graph.xml
new file mode 100644
index 0000000..2e337f2
--- /dev/null
+++ b/merchant-terminal/src/main/res/navigation/nav_graph.xml
@@ -0,0 +1,137 @@
+<?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_order"
+ 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/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.MerchantHistoryFragment"
+ 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.history.RefundFragment"
+ android:label="@string/history_refund"
+ tools:layout="@layout/fragment_refund">
+ <action
+ android:id="@+id/action_refundFragment_to_refundUriFragment"
+ app:destination="@id/refundUriFragment" />
+ </fragment>
+
+ <fragment
+ android:id="@+id/refundUriFragment"
+ android:name="net.taler.merchantpos.history.RefundUriFragment"
+ android:label="@string/history_refund"
+ tools:layout="@layout/fragment_refund_uri" />
+
+ <fragment
+ android:id="@+id/nav_settings"
+ android:name="net.taler.merchantpos.config.MerchantConfigFragment"
+ android:label="@string/config_label"
+ tools:layout="@layout/fragment_merchant_config">
+ <action
+ android:id="@+id/action_settings_to_order"
+ app:destination="@+id/nav_order"
+ 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_settings"
+ app:launchSingleTop="true"
+ app:popUpTo="@+id/nav_graph"
+ app:popUpToInclusive="true" />
+ <action
+ android:id="@+id/action_configFetcher_to_order"
+ app:destination="@+id/nav_order"
+ 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_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-night/colors.xml b/merchant-terminal/src/main/res/values-night/colors.xml
new file mode 100644
index 0000000..10bdbb9
--- /dev/null
+++ b/merchant-terminal/src/main/res/values-night/colors.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <color name="highlightedBackground">#2E2E2E</color>
+ <color name="selectedBackground">#363636</color>
+</resources>
diff --git a/merchant-terminal/src/main/res/values/colors.xml b/merchant-terminal/src/main/res/values/colors.xml
new file mode 100644
index 0000000..bf0c849
--- /dev/null
+++ b/merchant-terminal/src/main/res/values/colors.xml
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <color name="colorPrimary">#795548</color>
+ <color name="colorPrimaryDark">#5D4037</color>
+ <color name="colorAccent">#FFEB3B</color>
+
+ <color name="highlightedBackground">#E4E4E4</color>
+ <color name="selectedBackground">#DADADA</color>
+ <color name="bottomButtons">#9E9D24</color>
+
+ <color name="green">#388E3C</color>
+ <color name="red">#C62828</color>
+
+</resources>
diff --git a/merchant-terminal/src/main/res/values/dimens.xml b/merchant-terminal/src/main/res/values/dimens.xml
new file mode 100644
index 0000000..eedc3c6
--- /dev/null
+++ b/merchant-terminal/src/main/res/values/dimens.xml
@@ -0,0 +1,6 @@
+<resources>
+ <dimen name="activity_horizontal_margin">16dp</dimen>
+ <dimen name="activity_vertical_margin">16dp</dimen>
+ <dimen name="nav_header_vertical_spacing">8dp</dimen>
+ <dimen name="nav_header_height">176dp</dimen>
+</resources> \ No newline at end of file
diff --git a/merchant-terminal/src/main/res/values/strings.xml b/merchant-terminal/src/main/res/values/strings.xml
new file mode 100644
index 0000000..77c7e03
--- /dev/null
+++ b/merchant-terminal/src/main/res/values/strings.xml
@@ -0,0 +1,68 @@
+<resources>
+ <string name="app_name">Taler Merchant PoS Terminal</string>
+ <string name="app_name_short">Merchant Terminal</string>
+ <string name="project_name">GNU Taler</string>
+
+ <string name="menu_order">Orders</string>
+ <string name="menu_history">History</string>
+ <string name="menu_settings">Settings</string>
+
+ <string name="order_label_title">Order #%s</string>
+ <!-- The first placeholder is the amount and the second the currency -->
+ <string name="order_total">Total: %1$.2f %2$s</string>
+ <string name="order_restart">Restart</string>
+ <string name="order_undo">Undo</string>
+ <string name="order_previous">Prev</string>
+ <string name="order_next">Next</string>
+ <string name="order_complete">Complete</string>
+
+ <string name="config_label">Merchant Settings</string>
+ <string name="config_url">Configuration URL</string>
+ <string name="config_username">Username</string>
+ <string name="config_password">Password</string>
+ <string name="config_ok">Fetch Configuration</string>
+ <string name="config_auth_error">Error: Invalid username or password</string>
+ <string name="config_error_network">Error: Could not connect to configuration server</string>
+ <string name="config_error_category">Error: No valid product category found</string>
+ <string name="config_error_malformed">Error: The configuration JSON is malformed</string>
+ <string name="config_error_currency">Error: Product %1$s has currency %2$s, but %3$s expected</string>
+ <string name="config_error_product_category_id">Error: Product %1$s references unknown category ID %2$d</string>
+ <string name="config_error_product_zero">Error: No valid products found</string>
+ <string name="config_error_unknown">Error: Invalid Configuration</string>
+ <string name="config_fetching">Fetching Configuration…</string>
+ <string name="config_save_password">Remember Password</string>
+ <string name="config_forget_password">Forget</string>
+ <string name="config_changed">Changed to new merchant using %s</string>
+ <string name="config_fetching_label">Fetching Configuration</string>
+ <string name="config_docs">Please refer to <a href="https://docs.taler.net/taler-merchant-pos-terminal.html#apis-and-data-formats">the documentation</a> for the configuration format.</string>
+
+ <string name="payment_intro_nfc">Please scan QR Code or use NFC to pay</string>
+ <string name="payment_intro">Please scan QR Code to pay</string>
+ <string name="payment_cancel">Cancel Payment</string>
+ <string name="payment_received">Payment received</string>
+ <string name="payment_back_button">Continue</string>
+ <string name="payment_order_ref">Order Reference: %s</string>
+ <string name="payment_process_label">Customer Payment Required</string>
+ <string name="payment_canceled">Payment Canceled</string>
+
+ <string name="history_label">Payment History</string>
+ <string name="history_received_at">Received at</string>
+ <string name="history_ref_no">Ref. No: %s</string>
+ <string name="history_refund">Refund Order</string>
+ <string name="refund_amount">Amount</string>
+ <string name="refund_reason">Refund reason</string>
+ <string name="refund_abort">Abort</string>
+ <string name="refund_confirm">Give Refund</string>
+ <string name="refund_error_max_amount">Greater than order amount of %s</string>
+ <string name="refund_error_zero">Needs to be positive amount</string>
+ <string name="refund_error_backend">Error processing refund</string>
+ <string name="refund_error_deadline">Refund deadline has passed</string>
+ <string name="refund_intro_nfc">Please scan QR Code or use NFC to give refund</string>
+ <string name="refund_intro">Please scan QR Code to give refund</string>
+ <string name="refund_order_ref">Order Reference: %1$s\n\n%2$s</string>
+
+ <string name="error_network">Network Error</string>
+
+ <string name="toast_back_to_exit">Click BACK again to exit</string>
+
+</resources>
diff --git a/merchant-terminal/src/main/res/values/styles.xml b/merchant-terminal/src/main/res/values/styles.xml
new file mode 100644
index 0000000..4445a01
--- /dev/null
+++ b/merchant-terminal/src/main/res/values/styles.xml
@@ -0,0 +1,21 @@
+<resources>
+ <!-- Base application theme. -->
+ <style name="AppTheme" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
+ <!-- Customize your theme here. -->
+ <item name="colorPrimary">@color/colorPrimary</item>
+ <item name="colorOnPrimary">@android:color/white</item>
+ <item name="colorPrimaryDark">@color/colorPrimaryDark</item>
+ <item name="colorAccent">@color/colorAccent</item>
+ </style>
+
+ <style name="AppTheme.NoActionBar">
+ <item name="windowActionBar">false</item>
+ <item name="windowNoTitle">true</item>
+ <item name="android:statusBarColor">@android:color/transparent</item>
+ </style>
+
+ <style name="AppTheme.AppBarOverlay" parent="ThemeOverlay.AppCompat.Dark.ActionBar" />
+
+ <style name="AppTheme.PopupOverlay" parent="ThemeOverlay.AppCompat.Light" />
+
+</resources>
diff --git a/merchant-terminal/src/main/res/xml/backup_descriptor.xml b/merchant-terminal/src/main/res/xml/backup_descriptor.xml
new file mode 100644
index 0000000..6fd6103
--- /dev/null
+++ b/merchant-terminal/src/main/res/xml/backup_descriptor.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<full-backup-content>
+ <!-- Exclude specific shared preferences that contain GCM registration Id -->
+</full-backup-content>
diff --git a/merchant-terminal/src/test/java/net/taler/merchantpos/order/OrderManagerTest.kt b/merchant-terminal/src/test/java/net/taler/merchantpos/order/OrderManagerTest.kt
new file mode 100644
index 0000000..cdb928a
--- /dev/null
+++ b/merchant-terminal/src/test/java/net/taler/merchantpos/order/OrderManagerTest.kt
@@ -0,0 +1,151 @@
+/*
+ * 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.app.Application
+import androidx.test.core.app.ApplicationProvider.getApplicationContext
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES
+import com.fasterxml.jackson.databind.ObjectMapper
+import com.fasterxml.jackson.module.kotlin.KotlinModule
+import kotlinx.coroutines.runBlocking
+import net.taler.merchantpos.R
+import org.json.JSONObject
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class OrderManagerTest {
+
+ private val mapper = ObjectMapper()
+ .registerModule(KotlinModule())
+ .configure(FAIL_ON_UNKNOWN_PROPERTIES, false)
+
+ private val app: Application = getApplicationContext()
+ private val orderManager = OrderManager(app, mapper)
+
+ @Test
+ fun `config test missing categories`() = runBlocking {
+ val json = JSONObject(
+ """
+ { "categories": [] }
+ """.trimIndent()
+ )
+ val result = orderManager.onConfigurationReceived(json, "KUDOS")
+ assertEquals(app.getString(R.string.config_error_category), result)
+ }
+
+ @Test
+ fun `config test currency mismatch`() = runBlocking {
+ val json = JSONObject(
+ """{
+ "categories": [
+ {
+ "id": 1,
+ "name": "Snacks"
+ }
+ ],
+ "products": [
+ {
+ "product_id": "631361561",
+ "description": "Chips",
+ "price": "WRONGCURRENCY:1.00",
+ "categories": [ 1 ],
+ "delivery_location": "cafeteria"
+ }
+ ]
+ }""".trimIndent()
+ )
+ val result = orderManager.onConfigurationReceived(json, "KUDOS")
+ val expectedStr = app.getString(
+ R.string.config_error_currency, "Chips", "WRONGCURRENCY", "KUDOS"
+ )
+ assertEquals(expectedStr, result)
+ }
+
+ @Test
+ fun `config test unknown category ID`() = runBlocking {
+ val json = JSONObject(
+ """{
+ "categories": [
+ {
+ "id": 1,
+ "name": "Snacks"
+ }
+ ],
+ "products": [
+ {
+ "product_id": "631361561",
+ "description": "Chips",
+ "price": "KUDOS:1.00",
+ "categories": [ 2 ]
+ }
+ ]
+ }""".trimIndent()
+ )
+ val result = orderManager.onConfigurationReceived(json, "KUDOS")
+ val expectedStr = app.getString(
+ R.string.config_error_product_category_id, "Chips", 2
+ )
+ assertEquals(expectedStr, result)
+ }
+
+ @Test
+ fun `config test no products`() = runBlocking {
+ val json = JSONObject(
+ """{
+ "categories": [
+ {
+ "id": 1,
+ "name": "Snacks"
+ }
+ ],
+ "products": []
+ }""".trimIndent()
+ )
+ val result = orderManager.onConfigurationReceived(json, "KUDOS")
+ val expectedStr = app.getString(R.string.config_error_product_zero)
+ assertEquals(expectedStr, result)
+ }
+
+ @Test
+ fun `config test valid config gets accepted`() = runBlocking {
+ val json = JSONObject(
+ """{
+ "categories": [
+ {
+ "id": 1,
+ "name": "Snacks"
+ }
+ ],
+ "products": [
+ {
+ "product_id": "631361561",
+ "description": "Chips",
+ "price": "KUDOS:1.00",
+ "categories": [ 1 ]
+ }
+ ]
+ }""".trimIndent()
+ )
+ val result = orderManager.onConfigurationReceived(json, "KUDOS")
+ assertNull(result)
+ }
+
+}