From a4796ec47d89a851b260b6fc195494547208a025 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Wed, 18 Mar 2020 14:24:41 -0300 Subject: Merge all three apps into one repository --- merchant-terminal/src/main/AndroidManifest.xml | 56 +++++ merchant-terminal/src/main/ic_taler_logo-web.png | Bin 0 -> 25951 bytes .../src/main/java/net/taler/merchantpos/Amount.kt | 48 +++++ .../java/net/taler/merchantpos/MainActivity.kt | 123 +++++++++++ .../java/net/taler/merchantpos/MainViewModel.kt | 51 +++++ .../main/java/net/taler/merchantpos/NfcManager.kt | 233 +++++++++++++++++++++ .../java/net/taler/merchantpos/QrCodeManager.kt | 42 ++++ .../src/main/java/net/taler/merchantpos/Utils.kt | 155 ++++++++++++++ .../merchantpos/config/ConfigFetcherFragment.kt | 66 ++++++ .../net/taler/merchantpos/config/ConfigManager.kt | 181 ++++++++++++++++ .../net/taler/merchantpos/config/MerchantConfig.kt | 47 +++++ .../merchantpos/config/MerchantConfigFragment.kt | 165 +++++++++++++++ .../taler/merchantpos/config/MerchantRequest.kt | 41 ++++ .../taler/merchantpos/history/HistoryManager.kt | 106 ++++++++++ .../merchantpos/history/MerchantHistoryFragment.kt | 160 ++++++++++++++ .../taler/merchantpos/history/RefundFragment.kt | 99 +++++++++ .../net/taler/merchantpos/history/RefundManager.kt | 111 ++++++++++ .../taler/merchantpos/history/RefundUriFragment.kt | 65 ++++++ .../taler/merchantpos/order/CategoriesFragment.kt | 106 ++++++++++ .../net/taler/merchantpos/order/Definitions.kt | 205 ++++++++++++++++++ .../java/net/taler/merchantpos/order/LiveOrder.kt | 109 ++++++++++ .../net/taler/merchantpos/order/OrderFragment.kt | 115 ++++++++++ .../net/taler/merchantpos/order/OrderManager.kt | 196 +++++++++++++++++ .../taler/merchantpos/order/OrderStateFragment.kt | 213 +++++++++++++++++++ .../taler/merchantpos/order/ProductsFragment.kt | 111 ++++++++++ .../java/net/taler/merchantpos/payment/Payment.kt | 29 +++ .../taler/merchantpos/payment/PaymentManager.kt | 154 ++++++++++++++ .../merchantpos/payment/PaymentSuccessFragment.kt | 44 ++++ .../merchantpos/payment/ProcessPaymentFragment.kt | 96 +++++++++ .../src/main/res/color/button_bottom.xml | 5 + .../src/main/res/drawable/ic_cash_refund.xml | 9 + .../src/main/res/drawable/ic_check_circle.xml | 10 + .../main/res/drawable/ic_history_black_24dp.xml | 9 + .../main/res/drawable/ic_launcher_background.xml | 74 +++++++ .../src/main/res/drawable/ic_menu_manage.xml | 9 + .../src/main/res/drawable/ic_move_money_24dp.xml | 9 + .../main/res/drawable/selectable_background.xml | 5 + .../src/main/res/drawable/side_nav_bar.xml | 9 + .../src/main/res/layout/activity_main.xml | 42 ++++ .../src/main/res/layout/app_bar_main.xml | 53 +++++ .../src/main/res/layout/fragment_categories.xml | 46 ++++ .../main/res/layout/fragment_config_fetcher.xml | 45 ++++ .../main/res/layout/fragment_merchant_config.xml | 152 ++++++++++++++ .../main/res/layout/fragment_merchant_history.xml | 29 +++ .../src/main/res/layout/fragment_order.xml | 138 ++++++++++++ .../src/main/res/layout/fragment_order_state.xml | 52 +++++ .../main/res/layout/fragment_payment_success.xml | 78 +++++++ .../main/res/layout/fragment_process_payment.xml | 110 ++++++++++ .../src/main/res/layout/fragment_products.xml | 44 ++++ .../src/main/res/layout/fragment_refund.xml | 122 +++++++++++ .../src/main/res/layout/fragment_refund_uri.xml | 93 ++++++++ .../src/main/res/layout/list_item_category.xml | 33 +++ .../src/main/res/layout/list_item_history.xml | 97 +++++++++ .../src/main/res/layout/list_item_order.xml | 61 ++++++ .../src/main/res/layout/list_item_product.xml | 56 +++++ .../src/main/res/layout/nav_header_main.xml | 55 +++++ .../src/main/res/menu/activity_main_drawer.xml | 36 ++++ .../main/res/mipmap-anydpi-v26/ic_taler_logo.xml | 5 + .../res/mipmap-anydpi-v26/ic_taler_logo_round.xml | 5 + .../res/mipmap-hdpi/ic_launcher_foreground.png | Bin 0 -> 4307 bytes .../src/main/res/mipmap-hdpi/ic_taler_logo.png | Bin 0 -> 2347 bytes .../main/res/mipmap-hdpi/ic_taler_logo_round.png | Bin 0 -> 3638 bytes .../res/mipmap-mdpi/ic_launcher_foreground.png | Bin 0 -> 2625 bytes .../src/main/res/mipmap-mdpi/ic_taler_logo.png | Bin 0 -> 1532 bytes .../main/res/mipmap-mdpi/ic_taler_logo_round.png | Bin 0 -> 2240 bytes .../res/mipmap-xhdpi/ic_launcher_foreground.png | Bin 0 -> 6077 bytes .../src/main/res/mipmap-xhdpi/ic_taler_logo.png | Bin 0 -> 3336 bytes .../main/res/mipmap-xhdpi/ic_taler_logo_round.png | Bin 0 -> 5273 bytes .../res/mipmap-xxhdpi/ic_launcher_foreground.png | Bin 0 -> 10228 bytes .../src/main/res/mipmap-xxhdpi/ic_taler_logo.png | Bin 0 -> 5422 bytes .../main/res/mipmap-xxhdpi/ic_taler_logo_round.png | Bin 0 -> 8454 bytes .../res/mipmap-xxxhdpi/ic_launcher_foreground.png | Bin 0 -> 14083 bytes .../src/main/res/mipmap-xxxhdpi/ic_taler_logo.png | Bin 0 -> 7786 bytes .../res/mipmap-xxxhdpi/ic_taler_logo_round.png | Bin 0 -> 12377 bytes .../src/main/res/navigation/nav_graph.xml | 137 ++++++++++++ .../src/main/res/values-night/colors.xml | 5 + merchant-terminal/src/main/res/values/colors.xml | 14 ++ merchant-terminal/src/main/res/values/dimens.xml | 6 + merchant-terminal/src/main/res/values/strings.xml | 68 ++++++ merchant-terminal/src/main/res/values/styles.xml | 21 ++ .../src/main/res/xml/backup_descriptor.xml | 4 + .../taler/merchantpos/order/OrderManagerTest.kt | 151 +++++++++++++ 82 files changed, 5024 insertions(+) create mode 100644 merchant-terminal/src/main/AndroidManifest.xml create mode 100644 merchant-terminal/src/main/ic_taler_logo-web.png create mode 100644 merchant-terminal/src/main/java/net/taler/merchantpos/Amount.kt create mode 100644 merchant-terminal/src/main/java/net/taler/merchantpos/MainActivity.kt create mode 100644 merchant-terminal/src/main/java/net/taler/merchantpos/MainViewModel.kt create mode 100644 merchant-terminal/src/main/java/net/taler/merchantpos/NfcManager.kt create mode 100644 merchant-terminal/src/main/java/net/taler/merchantpos/QrCodeManager.kt create mode 100644 merchant-terminal/src/main/java/net/taler/merchantpos/Utils.kt create mode 100644 merchant-terminal/src/main/java/net/taler/merchantpos/config/ConfigFetcherFragment.kt create mode 100644 merchant-terminal/src/main/java/net/taler/merchantpos/config/ConfigManager.kt create mode 100644 merchant-terminal/src/main/java/net/taler/merchantpos/config/MerchantConfig.kt create mode 100644 merchant-terminal/src/main/java/net/taler/merchantpos/config/MerchantConfigFragment.kt create mode 100644 merchant-terminal/src/main/java/net/taler/merchantpos/config/MerchantRequest.kt create mode 100644 merchant-terminal/src/main/java/net/taler/merchantpos/history/HistoryManager.kt create mode 100644 merchant-terminal/src/main/java/net/taler/merchantpos/history/MerchantHistoryFragment.kt create mode 100644 merchant-terminal/src/main/java/net/taler/merchantpos/history/RefundFragment.kt create mode 100644 merchant-terminal/src/main/java/net/taler/merchantpos/history/RefundManager.kt create mode 100644 merchant-terminal/src/main/java/net/taler/merchantpos/history/RefundUriFragment.kt create mode 100644 merchant-terminal/src/main/java/net/taler/merchantpos/order/CategoriesFragment.kt create mode 100644 merchant-terminal/src/main/java/net/taler/merchantpos/order/Definitions.kt create mode 100644 merchant-terminal/src/main/java/net/taler/merchantpos/order/LiveOrder.kt create mode 100644 merchant-terminal/src/main/java/net/taler/merchantpos/order/OrderFragment.kt create mode 100644 merchant-terminal/src/main/java/net/taler/merchantpos/order/OrderManager.kt create mode 100644 merchant-terminal/src/main/java/net/taler/merchantpos/order/OrderStateFragment.kt create mode 100644 merchant-terminal/src/main/java/net/taler/merchantpos/order/ProductsFragment.kt create mode 100644 merchant-terminal/src/main/java/net/taler/merchantpos/payment/Payment.kt create mode 100644 merchant-terminal/src/main/java/net/taler/merchantpos/payment/PaymentManager.kt create mode 100644 merchant-terminal/src/main/java/net/taler/merchantpos/payment/PaymentSuccessFragment.kt create mode 100644 merchant-terminal/src/main/java/net/taler/merchantpos/payment/ProcessPaymentFragment.kt create mode 100644 merchant-terminal/src/main/res/color/button_bottom.xml create mode 100644 merchant-terminal/src/main/res/drawable/ic_cash_refund.xml create mode 100644 merchant-terminal/src/main/res/drawable/ic_check_circle.xml create mode 100644 merchant-terminal/src/main/res/drawable/ic_history_black_24dp.xml create mode 100644 merchant-terminal/src/main/res/drawable/ic_launcher_background.xml create mode 100644 merchant-terminal/src/main/res/drawable/ic_menu_manage.xml create mode 100644 merchant-terminal/src/main/res/drawable/ic_move_money_24dp.xml create mode 100644 merchant-terminal/src/main/res/drawable/selectable_background.xml create mode 100644 merchant-terminal/src/main/res/drawable/side_nav_bar.xml create mode 100644 merchant-terminal/src/main/res/layout/activity_main.xml create mode 100644 merchant-terminal/src/main/res/layout/app_bar_main.xml create mode 100644 merchant-terminal/src/main/res/layout/fragment_categories.xml create mode 100644 merchant-terminal/src/main/res/layout/fragment_config_fetcher.xml create mode 100644 merchant-terminal/src/main/res/layout/fragment_merchant_config.xml create mode 100644 merchant-terminal/src/main/res/layout/fragment_merchant_history.xml create mode 100644 merchant-terminal/src/main/res/layout/fragment_order.xml create mode 100644 merchant-terminal/src/main/res/layout/fragment_order_state.xml create mode 100644 merchant-terminal/src/main/res/layout/fragment_payment_success.xml create mode 100644 merchant-terminal/src/main/res/layout/fragment_process_payment.xml create mode 100644 merchant-terminal/src/main/res/layout/fragment_products.xml create mode 100644 merchant-terminal/src/main/res/layout/fragment_refund.xml create mode 100644 merchant-terminal/src/main/res/layout/fragment_refund_uri.xml create mode 100644 merchant-terminal/src/main/res/layout/list_item_category.xml create mode 100644 merchant-terminal/src/main/res/layout/list_item_history.xml create mode 100644 merchant-terminal/src/main/res/layout/list_item_order.xml create mode 100644 merchant-terminal/src/main/res/layout/list_item_product.xml create mode 100644 merchant-terminal/src/main/res/layout/nav_header_main.xml create mode 100644 merchant-terminal/src/main/res/menu/activity_main_drawer.xml create mode 100644 merchant-terminal/src/main/res/mipmap-anydpi-v26/ic_taler_logo.xml create mode 100644 merchant-terminal/src/main/res/mipmap-anydpi-v26/ic_taler_logo_round.xml create mode 100644 merchant-terminal/src/main/res/mipmap-hdpi/ic_launcher_foreground.png create mode 100644 merchant-terminal/src/main/res/mipmap-hdpi/ic_taler_logo.png create mode 100644 merchant-terminal/src/main/res/mipmap-hdpi/ic_taler_logo_round.png create mode 100644 merchant-terminal/src/main/res/mipmap-mdpi/ic_launcher_foreground.png create mode 100644 merchant-terminal/src/main/res/mipmap-mdpi/ic_taler_logo.png create mode 100644 merchant-terminal/src/main/res/mipmap-mdpi/ic_taler_logo_round.png create mode 100644 merchant-terminal/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png create mode 100644 merchant-terminal/src/main/res/mipmap-xhdpi/ic_taler_logo.png create mode 100644 merchant-terminal/src/main/res/mipmap-xhdpi/ic_taler_logo_round.png create mode 100644 merchant-terminal/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png create mode 100644 merchant-terminal/src/main/res/mipmap-xxhdpi/ic_taler_logo.png create mode 100644 merchant-terminal/src/main/res/mipmap-xxhdpi/ic_taler_logo_round.png create mode 100644 merchant-terminal/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png create mode 100644 merchant-terminal/src/main/res/mipmap-xxxhdpi/ic_taler_logo.png create mode 100644 merchant-terminal/src/main/res/mipmap-xxxhdpi/ic_taler_logo_round.png create mode 100644 merchant-terminal/src/main/res/navigation/nav_graph.xml create mode 100644 merchant-terminal/src/main/res/values-night/colors.xml create mode 100644 merchant-terminal/src/main/res/values/colors.xml create mode 100644 merchant-terminal/src/main/res/values/dimens.xml create mode 100644 merchant-terminal/src/main/res/values/strings.xml create mode 100644 merchant-terminal/src/main/res/values/styles.xml create mode 100644 merchant-terminal/src/main/res/xml/backup_descriptor.xml create mode 100644 merchant-terminal/src/test/java/net/taler/merchantpos/order/OrderManagerTest.kt (limited to 'merchant-terminal/src') 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 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/merchant-terminal/src/main/ic_taler_logo-web.png b/merchant-terminal/src/main/ic_taler_logo-web.png new file mode 100644 index 0000000..e3b8075 Binary files /dev/null and b/merchant-terminal/src/main/ic_taler_logo-web.png differ diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/Amount.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/Amount.kt new file mode 100644 index 0000000..17ddd61 --- /dev/null +++ b/merchant-terminal/src/main/java/net/taler/merchantpos/Amount.kt @@ -0,0 +1,48 @@ +/* + * This file is part of GNU Taler + * (C) 2020 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see + */ + +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 + */ + +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 + */ + +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 + */ + +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 + */ + +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 + */ + +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( + source1: LiveData, + source2: LiveData, + private val combine: (data1: T?, data2: K?) -> S +) : MediatorLiveData() { + + 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 addSource(source: LiveData, onChanged: Observer) { + throw UnsupportedOperationException() + } + + override fun removeSource(toRemote: LiveData) { + throw UnsupportedOperationException() + } +} + +/** + * Use this with 'when' expressions when you need it to handle all possibilities/branches. + */ +val 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 + */ + +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 + */ + +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() + + 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() + val configUpdateResult: LiveData = 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 { + 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 + */ + +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 { + 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 + */ + +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 + */ + +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?, + jsonRequest: JSONObject?, + listener: Response.Listener, + errorListener: Response.ErrorListener +) : + JsonObjectRequest(method, merchantConfig.urlFor(endpoint, params), jsonRequest, listener, errorListener) { + + override fun getHeaders(): MutableMap { + val headerMap = ArrayMap() + 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 + */ + +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) : HistoryResult() +} + +class HistoryManager( + private val configManager: ConfigManager, + private val queue: RequestQueue, + private val mapper: ObjectMapper +) { + + private val mIsLoading = MutableLiveData(false) + val isLoading: LiveData = mIsLoading + + private val mItems = MutableLiveData() + val items: LiveData = 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() + 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 + */ + +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() { + + private val items = ArrayList() + + 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) { + 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 + */ + +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 + */ + +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() + internal val refundResult: LiveData = 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 + */ + +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 + */ + +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() { + + private val categories = ArrayList() + + 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) { + 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 + */ + +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? +) { + 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? + 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?, + override val price: String, + override val location: String?, + override val image: String?, + val categories: List, + @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?, + 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?, 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(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) { + val products = ArrayList() + 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 { + val categories = HashMap() + 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? + 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 + */ + +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 + val orderTotal: LiveData + val restartState: LiveData + val modifyOrderAllowed: LiveData + 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> +) : LiveOrder { + private val availableCategories: Map + get() = productsByCategory.keys.map { it.id to it }.toMap() + override val order: MutableLiveData = MutableLiveData(Order(id, availableCategories)) + override val orderTotal: LiveData = Transformations.map(order) { it.total } + override val restartState = MutableLiveData(DISABLED) + private val selectedOrderLine = MutableLiveData() + 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 + */ + +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 + */ + +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() + internal val currentOrderId: LiveData = mCurrentOrderId + + private val productsByCategory = HashMap>() + + private val orders = LinkedHashMap() + + private val mProducts = MutableLiveData>() + internal val products: LiveData> = mProducts + + private val mCategories = MutableLiveData>() + internal val categories: LiveData> = mCategories + + override suspend fun onConfigurationReceived(json: JSONObject, currency: String): String? { + // parse categories + val categoriesStr = json.getJSONArray("categories").toString() + val categoriesType = object : TypeReference>() {} + val categories: List = 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>() {} + val products: List = 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().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 + */ + +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? = 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() { + 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) { + 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() { + + lateinit var tracker: SelectionTracker + val keyProvider = OrderKeyProvider() + private val itemCallback = object : DiffUtil.ItemCallback() { + 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, 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(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() { + override fun getItemDetails(e: MotionEvent): ItemDetails? { + 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() { + 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 + */ + +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() { + + private val products = ArrayList() + + 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) { + 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 + */ + +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 + */ + +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() + val payment: LiveData = 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 + */ + +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 + */ + +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 @@ + + + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + + \ 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 @@ + + + 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 @@ + + + + + \ 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 @@ + + + \ 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 @@ + + + + + + + + + + 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 @@ + + + + + + + + + + + + + + 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 @@ + + + + + + + + + + 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 @@ + + + + + + + + + + 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + +