summaryrefslogtreecommitdiff
path: root/merchant-terminal/src/main/java/net/taler
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/main/java/net/taler
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/main/java/net/taler')
-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
27 files changed, 3071 insertions, 0 deletions
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)
+ }
+
+}