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