summaryrefslogtreecommitdiff
path: root/merchant-terminal/src/main/java/net/taler/merchantpos/order
diff options
context:
space:
mode:
authorTorsten Grote <t@grobox.de>2020-03-18 14:24:41 -0300
committerTorsten Grote <t@grobox.de>2020-03-18 14:24:41 -0300
commita4796ec47d89a851b260b6fc195494547208a025 (patch)
treed2941b68ff2ce22c523e7aa634965033b1100560 /merchant-terminal/src/main/java/net/taler/merchantpos/order
downloadtaler-android-a4796ec47d89a851b260b6fc195494547208a025.tar.gz
taler-android-a4796ec47d89a851b260b6fc195494547208a025.tar.bz2
taler-android-a4796ec47d89a851b260b6fc195494547208a025.zip
Merge all three apps into one repository
Diffstat (limited to 'merchant-terminal/src/main/java/net/taler/merchantpos/order')
-rw-r--r--merchant-terminal/src/main/java/net/taler/merchantpos/order/CategoriesFragment.kt106
-rw-r--r--merchant-terminal/src/main/java/net/taler/merchantpos/order/Definitions.kt205
-rw-r--r--merchant-terminal/src/main/java/net/taler/merchantpos/order/LiveOrder.kt109
-rw-r--r--merchant-terminal/src/main/java/net/taler/merchantpos/order/OrderFragment.kt115
-rw-r--r--merchant-terminal/src/main/java/net/taler/merchantpos/order/OrderManager.kt196
-rw-r--r--merchant-terminal/src/main/java/net/taler/merchantpos/order/OrderStateFragment.kt213
-rw-r--r--merchant-terminal/src/main/java/net/taler/merchantpos/order/ProductsFragment.kt111
7 files changed, 1055 insertions, 0 deletions
diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/order/CategoriesFragment.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/order/CategoriesFragment.kt
new file mode 100644
index 0000000..34b97c0
--- /dev/null
+++ b/merchant-terminal/src/main/java/net/taler/merchantpos/order/CategoriesFragment.kt
@@ -0,0 +1,106 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2020 Taler Systems S.A.
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+package net.taler.merchantpos.order
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.View.INVISIBLE
+import android.view.ViewGroup
+import android.widget.Button
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import androidx.lifecycle.Observer
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import androidx.recyclerview.widget.RecyclerView.Adapter
+import kotlinx.android.synthetic.main.fragment_categories.*
+import net.taler.merchantpos.MainViewModel
+import net.taler.merchantpos.R
+import net.taler.merchantpos.order.CategoryAdapter.CategoryViewHolder
+
+interface CategorySelectionListener {
+ fun onCategorySelected(category: Category)
+}
+
+class CategoriesFragment : Fragment(), CategorySelectionListener {
+
+ private val viewModel: MainViewModel by activityViewModels()
+ private val orderManager by lazy { viewModel.orderManager }
+ private val adapter = CategoryAdapter(this)
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View? {
+ return inflater.inflate(R.layout.fragment_categories, container, false)
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ categoriesList.apply {
+ adapter = this@CategoriesFragment.adapter
+ layoutManager = LinearLayoutManager(requireContext())
+ }
+
+ orderManager.categories.observe(viewLifecycleOwner, Observer { categories ->
+ adapter.setItems(categories)
+ progressBar.visibility = INVISIBLE
+ })
+ }
+
+ override fun onCategorySelected(category: Category) {
+ orderManager.setCurrentCategory(category)
+ }
+
+}
+
+private class CategoryAdapter(
+ private val listener: CategorySelectionListener
+) : Adapter<CategoryViewHolder>() {
+
+ private val categories = ArrayList<Category>()
+
+ override fun getItemCount() = categories.size
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CategoryViewHolder {
+ val view =
+ LayoutInflater.from(parent.context).inflate(R.layout.list_item_category, parent, false)
+ return CategoryViewHolder(view)
+ }
+
+ override fun onBindViewHolder(holder: CategoryViewHolder, position: Int) {
+ holder.bind(categories[position])
+ }
+
+ fun setItems(items: List<Category>) {
+ categories.clear()
+ categories.addAll(items)
+ notifyDataSetChanged()
+ }
+
+ private inner class CategoryViewHolder(v: View) : RecyclerView.ViewHolder(v) {
+ private val button: Button = v.findViewById(R.id.button)
+
+ fun bind(category: Category) {
+ button.text = category.localizedName
+ button.isPressed = category.selected
+ button.setOnClickListener { listener.onCategorySelected(category) }
+ }
+ }
+
+}
diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/order/Definitions.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/order/Definitions.kt
new file mode 100644
index 0000000..63eda17
--- /dev/null
+++ b/merchant-terminal/src/main/java/net/taler/merchantpos/order/Definitions.kt
@@ -0,0 +1,205 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2020 Taler Systems S.A.
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+package net.taler.merchantpos.order
+
+import androidx.core.os.LocaleListCompat
+import com.fasterxml.jackson.annotation.JsonIgnore
+import com.fasterxml.jackson.annotation.JsonInclude
+import com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL
+import com.fasterxml.jackson.annotation.JsonProperty
+import net.taler.merchantpos.Amount
+import java.util.*
+import java.util.Locale.LanguageRange
+import kotlin.collections.ArrayList
+import kotlin.collections.HashMap
+
+data class Category(
+ val id: Int,
+ val name: String,
+ @JsonProperty("name_i18n")
+ val nameI18n: Map<String, String>?
+) {
+ var selected: Boolean = false
+ val localizedName: String get() = getLocalizedString(nameI18n, name)
+}
+
+@JsonInclude(NON_NULL)
+abstract class Product {
+ @get:JsonProperty("product_id")
+ abstract val productId: String?
+ abstract val description: String
+ @get:JsonProperty("description_i18n")
+ abstract val descriptionI18n: Map<String, String>?
+ abstract val price: String
+ @get:JsonProperty("delivery_location")
+ abstract val location: String?
+ abstract val image: String?
+ @get:JsonIgnore
+ val localizedDescription: String
+ get() = getLocalizedString(descriptionI18n, description)
+}
+
+data class ConfigProduct(
+ @JsonIgnore
+ val id: String = UUID.randomUUID().toString(),
+ override val productId: String?,
+ override val description: String,
+ override val descriptionI18n: Map<String, String>?,
+ override val price: String,
+ override val location: String?,
+ override val image: String?,
+ val categories: List<Int>,
+ @JsonIgnore
+ val quantity: Int = 0
+) : Product() {
+ val priceAsDouble by lazy { Amount.fromString(price).amount.toDouble() }
+
+ override fun equals(other: Any?) = other is ConfigProduct && id == other.id
+ override fun hashCode() = id.hashCode()
+}
+
+data class ContractProduct(
+ override val productId: String?,
+ override val description: String,
+ override val descriptionI18n: Map<String, String>?,
+ override val price: String,
+ override val location: String?,
+ override val image: String?,
+ val quantity: Int
+) : Product() {
+ constructor(product: ConfigProduct) : this(
+ product.productId,
+ product.description,
+ product.descriptionI18n,
+ product.price,
+ product.location,
+ product.image,
+ product.quantity
+ )
+}
+
+private fun getLocalizedString(map: Map<String, String>?, default: String): String {
+ // just return the default, if it is the only element
+ if (map == null) return default
+ // create a priority list of language ranges from system locales
+ val locales = LocaleListCompat.getDefault()
+ val priorityList = ArrayList<LanguageRange>(locales.size())
+ for (i in 0 until locales.size()) {
+ priorityList.add(LanguageRange(locales[i].toLanguageTag()))
+ }
+ // create a list of locales available in the given map
+ val availableLocales = map.keys.mapNotNull {
+ if (it == "_") return@mapNotNull null
+ val list = it.split("_")
+ when (list.size) {
+ 1 -> Locale(list[0])
+ 2 -> Locale(list[0], list[1])
+ 3 -> Locale(list[0], list[1], list[2])
+ else -> null
+ }
+ }
+ val match = Locale.lookup(priorityList, availableLocales)
+ return match?.toString()?.let { map[it] } ?: default
+}
+
+data class Order(val id: Int, val availableCategories: Map<Int, Category>) {
+ val products = ArrayList<ConfigProduct>()
+ val title: String = id.toString()
+ val summary: String
+ get() {
+ if (products.size == 1) return products[0].description
+ return getCategoryQuantities().map { (category: Category, quantity: Int) ->
+ "$quantity x ${category.localizedName}"
+ }.joinToString()
+ }
+ val total: Double
+ get() {
+ var total = 0.0
+ products.forEach { product ->
+ val price = product.priceAsDouble
+ total += price * product.quantity
+ }
+ return total
+ }
+ val totalAsString: String
+ get() = String.format("%.2f", total)
+
+ operator fun plus(product: ConfigProduct): Order {
+ val i = products.indexOf(product)
+ if (i == -1) {
+ products.add(product.copy(quantity = 1))
+ } else {
+ val quantity = products[i].quantity
+ products[i] = products[i].copy(quantity = quantity + 1)
+ }
+ return this
+ }
+
+ operator fun minus(product: ConfigProduct): Order {
+ val i = products.indexOf(product)
+ if (i == -1) return this
+ val quantity = products[i].quantity
+ if (quantity <= 1) {
+ products.remove(product)
+ } else {
+ products[i] = products[i].copy(quantity = quantity - 1)
+ }
+ return this
+ }
+
+ private fun getCategoryQuantities(): HashMap<Category, Int> {
+ val categories = HashMap<Category, Int>()
+ products.forEach { product ->
+ val categoryId = product.categories[0]
+ val category = availableCategories.getValue(categoryId)
+ val oldQuantity = categories[category] ?: 0
+ categories[category] = oldQuantity + product.quantity
+ }
+ return categories
+ }
+
+ /**
+ * Returns a map of i18n summaries for each locale present in *all* given [Category]s
+ * or null if there's no locale that fulfills this criteria.
+ */
+ val summaryI18n: Map<String, String>?
+ get() {
+ if (products.size == 1) return products[0].descriptionI18n
+ val categoryQuantities = getCategoryQuantities()
+ // get all available locales
+ val availableLocales = categoryQuantities.mapNotNull { (category, _) ->
+ val nameI18n = category.nameI18n
+ // if one category doesn't have locales, we can return null here already
+ nameI18n?.keys ?: return null
+ }.flatten().toHashSet()
+ // remove all locales not supported by all categories
+ categoryQuantities.forEach { (category, _) ->
+ // category.nameI18n should be non-null now
+ availableLocales.retainAll(category.nameI18n!!.keys)
+ if (availableLocales.isEmpty()) return null
+ }
+ return availableLocales.map { locale ->
+ Pair(
+ locale, categoryQuantities.map { (category, quantity) ->
+ // category.nameI18n should be non-null now
+ "$quantity x ${category.nameI18n!![locale]}"
+ }.joinToString()
+ )
+ }.toMap()
+ }
+
+}
diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/order/LiveOrder.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/order/LiveOrder.kt
new file mode 100644
index 0000000..ff6061a
--- /dev/null
+++ b/merchant-terminal/src/main/java/net/taler/merchantpos/order/LiveOrder.kt
@@ -0,0 +1,109 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2020 Taler Systems S.A.
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+package net.taler.merchantpos.order
+
+import androidx.annotation.UiThread
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.Transformations
+import net.taler.merchantpos.CombinedLiveData
+import net.taler.merchantpos.order.RestartState.DISABLED
+import net.taler.merchantpos.order.RestartState.ENABLED
+import net.taler.merchantpos.order.RestartState.UNDO
+
+internal enum class RestartState { ENABLED, DISABLED, UNDO }
+
+internal interface LiveOrder {
+ val order: LiveData<Order>
+ val orderTotal: LiveData<Double>
+ val restartState: LiveData<RestartState>
+ val modifyOrderAllowed: LiveData<Boolean>
+ val lastAddedProduct: ConfigProduct?
+ val selectedProductKey: String?
+ fun restartOrUndo()
+ fun selectOrderLine(product: ConfigProduct?)
+ fun increaseSelectedOrderLine()
+ fun decreaseSelectedOrderLine()
+}
+
+internal class MutableLiveOrder(
+ val id: Int,
+ private val productsByCategory: HashMap<Category, ArrayList<ConfigProduct>>
+) : LiveOrder {
+ private val availableCategories: Map<Int, Category>
+ get() = productsByCategory.keys.map { it.id to it }.toMap()
+ override val order: MutableLiveData<Order> = MutableLiveData(Order(id, availableCategories))
+ override val orderTotal: LiveData<Double> = Transformations.map(order) { it.total }
+ override val restartState = MutableLiveData(DISABLED)
+ private val selectedOrderLine = MutableLiveData<ConfigProduct>()
+ override val selectedProductKey: String?
+ get() = selectedOrderLine.value?.id
+ override val modifyOrderAllowed =
+ CombinedLiveData(restartState, selectedOrderLine) { restartState, selectedOrderLine ->
+ restartState != DISABLED && selectedOrderLine != null
+ }
+ override var lastAddedProduct: ConfigProduct? = null
+ private var undoOrder: Order? = null
+
+ @UiThread
+ internal fun addProduct(product: ConfigProduct) {
+ lastAddedProduct = product
+ order.value = order.value!! + product
+ restartState.value = ENABLED
+ }
+
+ @UiThread
+ internal fun removeProduct(product: ConfigProduct) {
+ val modifiedOrder = order.value!! - product
+ order.value = modifiedOrder
+ restartState.value = if (modifiedOrder.products.isEmpty()) DISABLED else ENABLED
+ }
+
+ @UiThread
+ internal fun isEmpty() = order.value!!.products.isEmpty()
+
+ @UiThread
+ override fun restartOrUndo() {
+ if (restartState.value == UNDO) {
+ order.value = undoOrder
+ restartState.value = ENABLED
+ undoOrder = null
+ } else {
+ undoOrder = order.value
+ order.value = Order(id, availableCategories)
+ restartState.value = UNDO
+ }
+ }
+
+ @UiThread
+ override fun selectOrderLine(product: ConfigProduct?) {
+ selectedOrderLine.value = product
+ }
+
+ @UiThread
+ override fun increaseSelectedOrderLine() {
+ val orderLine = selectedOrderLine.value ?: throw IllegalStateException()
+ addProduct(orderLine)
+ }
+
+ @UiThread
+ override fun decreaseSelectedOrderLine() {
+ val orderLine = selectedOrderLine.value ?: throw IllegalStateException()
+ removeProduct(orderLine)
+ }
+
+}
diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/order/OrderFragment.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/order/OrderFragment.kt
new file mode 100644
index 0000000..49f7cf2
--- /dev/null
+++ b/merchant-terminal/src/main/java/net/taler/merchantpos/order/OrderFragment.kt
@@ -0,0 +1,115 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2020 Taler Systems S.A.
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+package net.taler.merchantpos.order
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import androidx.lifecycle.Observer
+import androidx.navigation.fragment.findNavController
+import androidx.transition.TransitionManager.beginDelayedTransition
+import kotlinx.android.synthetic.main.fragment_order.*
+import net.taler.merchantpos.MainViewModel
+import net.taler.merchantpos.R
+import net.taler.merchantpos.navigate
+import net.taler.merchantpos.order.OrderFragmentDirections.Companion.actionGlobalConfigFetcher
+import net.taler.merchantpos.order.OrderFragmentDirections.Companion.actionOrderToMerchantSettings
+import net.taler.merchantpos.order.OrderFragmentDirections.Companion.actionOrderToProcessPayment
+import net.taler.merchantpos.order.RestartState.ENABLED
+import net.taler.merchantpos.order.RestartState.UNDO
+
+class OrderFragment : Fragment() {
+
+ private val viewModel: MainViewModel by activityViewModels()
+ private val orderManager by lazy { viewModel.orderManager }
+ private val paymentManager by lazy { viewModel.paymentManager }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View? {
+ return inflater.inflate(R.layout.fragment_order, container, false)
+ }
+
+ override fun onActivityCreated(savedInstanceState: Bundle?) {
+ super.onActivityCreated(savedInstanceState)
+ orderManager.currentOrderId.observe(viewLifecycleOwner, Observer { orderId ->
+ val liveOrder = orderManager.getOrder(orderId)
+ onOrderSwitched(orderId, liveOrder)
+ // add a new OrderStateFragment for each order
+ // as switching its internals (like we do here) would be too messy
+ childFragmentManager.beginTransaction()
+ .replace(R.id.fragment1, OrderStateFragment())
+ .commit()
+ })
+ }
+
+ override fun onStart() {
+ super.onStart()
+ if (!viewModel.configManager.config.isValid()) {
+ actionOrderToMerchantSettings().navigate(findNavController())
+ } else if (viewModel.configManager.merchantConfig?.currency == null) {
+ actionGlobalConfigFetcher().navigate(findNavController())
+ }
+ }
+
+ private fun onOrderSwitched(orderId: Int, liveOrder: LiveOrder) {
+ // order title
+ liveOrder.order.observe(viewLifecycleOwner, Observer { order ->
+ activity?.title = getString(R.string.order_label_title, order.title)
+ })
+ // restart button
+ restartButton.setOnClickListener { liveOrder.restartOrUndo() }
+ liveOrder.restartState.observe(viewLifecycleOwner, Observer { state ->
+ beginDelayedTransition(view as ViewGroup)
+ if (state == UNDO) {
+ restartButton.setText(R.string.order_undo)
+ restartButton.isEnabled = true
+ completeButton.isEnabled = false
+ } else {
+ restartButton.setText(R.string.order_restart)
+ restartButton.isEnabled = state == ENABLED
+ completeButton.isEnabled = state == ENABLED
+ }
+ })
+ // -1 and +1 buttons
+ liveOrder.modifyOrderAllowed.observe(viewLifecycleOwner, Observer { allowed ->
+ minusButton.isEnabled = allowed
+ plusButton.isEnabled = allowed
+ })
+ minusButton.setOnClickListener { liveOrder.decreaseSelectedOrderLine() }
+ plusButton.setOnClickListener { liveOrder.increaseSelectedOrderLine() }
+ // previous and next button
+ prevButton.isEnabled = orderManager.hasPreviousOrder(orderId)
+ orderManager.hasNextOrder(orderId).observe(viewLifecycleOwner, Observer { hasNextOrder ->
+ nextButton.isEnabled = hasNextOrder
+ })
+ prevButton.setOnClickListener { orderManager.previousOrder() }
+ nextButton.setOnClickListener { orderManager.nextOrder() }
+ // complete button
+ completeButton.setOnClickListener {
+ val order = liveOrder.order.value ?: return@setOnClickListener
+ paymentManager.createPayment(order)
+ actionOrderToProcessPayment().navigate(findNavController())
+ }
+ }
+
+}
diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/order/OrderManager.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/order/OrderManager.kt
new file mode 100644
index 0000000..48ddc57
--- /dev/null
+++ b/merchant-terminal/src/main/java/net/taler/merchantpos/order/OrderManager.kt
@@ -0,0 +1,196 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2020 Taler Systems S.A.
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+package net.taler.merchantpos.order
+
+import android.content.Context
+import android.util.Log
+import androidx.annotation.UiThread
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.Transformations.map
+import com.fasterxml.jackson.core.type.TypeReference
+import com.fasterxml.jackson.databind.ObjectMapper
+import net.taler.merchantpos.Amount.Companion.fromString
+import net.taler.merchantpos.R
+import net.taler.merchantpos.config.ConfigurationReceiver
+import net.taler.merchantpos.order.RestartState.ENABLED
+import org.json.JSONObject
+
+class OrderManager(
+ private val context: Context,
+ private val mapper: ObjectMapper
+) : ConfigurationReceiver {
+
+ companion object {
+ val TAG = OrderManager::class.java.simpleName
+ }
+
+ private var orderCounter: Int = 0
+ private val mCurrentOrderId = MutableLiveData<Int>()
+ internal val currentOrderId: LiveData<Int> = mCurrentOrderId
+
+ private val productsByCategory = HashMap<Category, ArrayList<ConfigProduct>>()
+
+ private val orders = LinkedHashMap<Int, MutableLiveOrder>()
+
+ private val mProducts = MutableLiveData<List<ConfigProduct>>()
+ internal val products: LiveData<List<ConfigProduct>> = mProducts
+
+ private val mCategories = MutableLiveData<List<Category>>()
+ internal val categories: LiveData<List<Category>> = mCategories
+
+ override suspend fun onConfigurationReceived(json: JSONObject, currency: String): String? {
+ // parse categories
+ val categoriesStr = json.getJSONArray("categories").toString()
+ val categoriesType = object : TypeReference<List<Category>>() {}
+ val categories: List<Category> = mapper.readValue(categoriesStr, categoriesType)
+ if (categories.isEmpty()) {
+ Log.e(TAG, "No valid category found.")
+ return context.getString(R.string.config_error_category)
+ }
+ // pre-select the first category
+ categories[0].selected = true
+
+ // parse products (live data gets updated in setCurrentCategory())
+ val productsStr = json.getJSONArray("products").toString()
+ val productsType = object : TypeReference<List<ConfigProduct>>() {}
+ val products: List<ConfigProduct> = mapper.readValue(productsStr, productsType)
+
+ // group products by categories
+ productsByCategory.clear()
+ products.forEach { product ->
+ val productCurrency = fromString(product.price).currency
+ if (productCurrency != currency) {
+ Log.e(TAG, "Product $product has currency $productCurrency, $currency expected")
+ return context.getString(
+ R.string.config_error_currency, product.description, productCurrency, currency
+ )
+ }
+ product.categories.forEach { categoryId ->
+ val category = categories.find { it.id == categoryId }
+ if (category == null) {
+ Log.e(TAG, "Product $product has unknown category $categoryId")
+ return context.getString(
+ R.string.config_error_product_category_id, product.description, categoryId
+ )
+ }
+ if (productsByCategory.containsKey(category)) {
+ productsByCategory[category]?.add(product)
+ } else {
+ productsByCategory[category] = ArrayList<ConfigProduct>().apply { add(product) }
+ }
+ }
+ }
+ return if (productsByCategory.size > 0) {
+ mCategories.postValue(categories)
+ mProducts.postValue(productsByCategory[categories[0]])
+ // Initialize first empty order, note this won't work when updating config mid-flight
+ if (orders.isEmpty()) {
+ val id = orderCounter++
+ orders[id] = MutableLiveOrder(id, productsByCategory)
+ mCurrentOrderId.postValue(id)
+ }
+ null // success, no error string
+ } else context.getString(R.string.config_error_product_zero)
+ }
+
+ @UiThread
+ internal fun getOrder(orderId: Int): LiveOrder {
+ return orders[orderId] ?: throw IllegalArgumentException()
+ }
+
+ @UiThread
+ internal fun nextOrder() {
+ val currentId = currentOrderId.value!!
+ var foundCurrentOrder = false
+ var nextId: Int? = null
+ for (orderId in orders.keys) {
+ if (foundCurrentOrder) {
+ nextId = orderId
+ break
+ }
+ if (orderId == currentId) foundCurrentOrder = true
+ }
+ if (nextId == null) {
+ nextId = orderCounter++
+ orders[nextId] = MutableLiveOrder(nextId, productsByCategory)
+ }
+ val currentOrder = order(currentId)
+ if (currentOrder.isEmpty()) orders.remove(currentId)
+ else currentOrder.lastAddedProduct = null // not needed anymore and it would get selected
+ mCurrentOrderId.value = nextId
+ }
+
+ @UiThread
+ internal fun previousOrder() {
+ val currentId = currentOrderId.value!!
+ var previousId: Int? = null
+ var foundCurrentOrder = false
+ for (orderId in orders.keys) {
+ if (orderId == currentId) {
+ foundCurrentOrder = true
+ break
+ }
+ previousId = orderId
+ }
+ if (previousId == null || !foundCurrentOrder) {
+ throw AssertionError("Could not find previous order for $currentId")
+ }
+ val currentOrder = order(currentId)
+ // remove current order if empty, or lastAddedProduct as it is not needed anymore
+ // and would get selected when navigating back instead of last selection
+ if (currentOrder.isEmpty()) orders.remove(currentId)
+ else currentOrder.lastAddedProduct = null
+ mCurrentOrderId.value = previousId
+ }
+
+ fun hasPreviousOrder(currentOrderId: Int): Boolean {
+ return currentOrderId != orders.keys.first()
+ }
+
+ fun hasNextOrder(currentOrderId: Int) = map(order(currentOrderId).restartState) { state ->
+ state == ENABLED || currentOrderId != orders.keys.last()
+ }
+
+ internal fun setCurrentCategory(category: Category) {
+ val newCategories = categories.value?.apply {
+ forEach { if (it.selected) it.selected = false }
+ category.selected = true
+ }
+ mCategories.postValue(newCategories)
+ mProducts.postValue(productsByCategory[category])
+ }
+
+ @UiThread
+ internal fun addProduct(orderId: Int, product: ConfigProduct) {
+ order(orderId).addProduct(product)
+ }
+
+ @UiThread
+ internal fun onOrderPaid(orderId: Int) {
+ if (currentOrderId.value == orderId) {
+ if (hasPreviousOrder(orderId)) previousOrder()
+ else nextOrder()
+ }
+ orders.remove(orderId)
+ }
+
+ private fun order(orderId: Int): MutableLiveOrder {
+ return orders[orderId] ?: throw IllegalStateException()
+ }
+
+}
diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/order/OrderStateFragment.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/order/OrderStateFragment.kt
new file mode 100644
index 0000000..1b70016
--- /dev/null
+++ b/merchant-terminal/src/main/java/net/taler/merchantpos/order/OrderStateFragment.kt
@@ -0,0 +1,213 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2020 Taler Systems S.A.
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+package net.taler.merchantpos.order
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.MotionEvent
+import android.view.View
+import android.view.ViewGroup
+import android.widget.TextView
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import androidx.lifecycle.Observer
+import androidx.recyclerview.selection.ItemDetailsLookup
+import androidx.recyclerview.selection.ItemKeyProvider
+import androidx.recyclerview.selection.SelectionPredicates
+import androidx.recyclerview.selection.SelectionTracker
+import androidx.recyclerview.selection.StorageStrategy
+import androidx.recyclerview.widget.AsyncListDiffer
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import androidx.recyclerview.widget.RecyclerView.Adapter
+import androidx.recyclerview.widget.RecyclerView.ViewHolder
+import kotlinx.android.synthetic.main.fragment_order_state.*
+import net.taler.merchantpos.MainViewModel
+import net.taler.merchantpos.R
+import net.taler.merchantpos.fadeIn
+import net.taler.merchantpos.fadeOut
+import net.taler.merchantpos.order.OrderAdapter.OrderLineLookup
+import net.taler.merchantpos.order.OrderAdapter.OrderViewHolder
+
+class OrderStateFragment : Fragment() {
+
+ private val viewModel: MainViewModel by activityViewModels()
+ private val orderManager by lazy { viewModel.orderManager }
+ private val liveOrder by lazy { orderManager.getOrder(orderManager.currentOrderId.value!!) }
+ private val adapter = OrderAdapter()
+ private var tracker: SelectionTracker<String>? = null
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View? {
+ return inflater.inflate(R.layout.fragment_order_state, container, false)
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ orderList.apply {
+ adapter = this@OrderStateFragment.adapter
+ layoutManager = LinearLayoutManager(requireContext())
+ }
+ val detailsLookup = OrderLineLookup(orderList)
+ val tracker = SelectionTracker.Builder(
+ "order-selection-id",
+ orderList,
+ adapter.keyProvider,
+ detailsLookup,
+ StorageStrategy.createStringStorage()
+ ).withSelectionPredicate(
+ SelectionPredicates.createSelectSingleAnything()
+ ).build()
+ savedInstanceState?.let { tracker.onRestoreInstanceState(it) }
+ adapter.tracker = tracker
+ this.tracker = tracker
+ if (savedInstanceState == null) {
+ // select last selected order line when re-creating this fragment
+ // do it before attaching the tracker observer
+ liveOrder.selectedProductKey?.let { tracker.select(it) }
+ }
+ tracker.addObserver(object : SelectionTracker.SelectionObserver<String>() {
+ override fun onItemStateChanged(key: String, selected: Boolean) {
+ super.onItemStateChanged(key, selected)
+ val item = if (selected) adapter.getItemByKey(key) else null
+ liveOrder.selectOrderLine(item)
+ }
+ })
+ liveOrder.order.observe(viewLifecycleOwner, Observer { order ->
+ onOrderChanged(order, tracker)
+ })
+ liveOrder.orderTotal.observe(viewLifecycleOwner, Observer { orderTotal ->
+ if (orderTotal == 0.0) {
+ totalView.fadeOut()
+ totalView.text = null
+ } else {
+ val currency = viewModel.configManager.merchantConfig?.currency
+ totalView.text = getString(R.string.order_total, orderTotal, currency)
+ totalView.fadeIn()
+ }
+ })
+ }
+
+ override fun onSaveInstanceState(outState: Bundle) {
+ super.onSaveInstanceState(outState)
+ tracker?.onSaveInstanceState(outState)
+ }
+
+ private fun onOrderChanged(order: Order, tracker: SelectionTracker<String>) {
+ adapter.setItems(order.products) {
+ liveOrder.lastAddedProduct?.let {
+ val position = adapter.findPosition(it)
+ if (position >= 0) {
+ // orderList can be null m(
+ orderList?.scrollToPosition(position)
+ orderList?.post { this.tracker?.select(it.id) }
+ }
+ }
+ // workaround for bug: SelectionObserver doesn't update when removing selected item
+ if (tracker.hasSelection()) {
+ val key = tracker.selection.first()
+ val product = order.products.find { it.id == key }
+ if (product == null) tracker.clearSelection()
+ }
+ }
+ }
+
+}
+
+private class OrderAdapter : Adapter<OrderViewHolder>() {
+
+ lateinit var tracker: SelectionTracker<String>
+ val keyProvider = OrderKeyProvider()
+ private val itemCallback = object : DiffUtil.ItemCallback<ConfigProduct>() {
+ override fun areItemsTheSame(oldItem: ConfigProduct, newItem: ConfigProduct): Boolean {
+ return oldItem == newItem
+ }
+
+ override fun areContentsTheSame(oldItem: ConfigProduct, newItem: ConfigProduct): Boolean {
+ return oldItem.quantity == newItem.quantity
+ }
+ }
+ private val differ = AsyncListDiffer(this, itemCallback)
+
+ override fun getItemCount() = differ.currentList.size
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): OrderViewHolder {
+ val view =
+ LayoutInflater.from(parent.context).inflate(R.layout.list_item_order, parent, false)
+ return OrderViewHolder(view)
+ }
+
+ override fun onBindViewHolder(holder: OrderViewHolder, position: Int) {
+ val item = getItem(position)!!
+ holder.bind(item, tracker.isSelected(item.id))
+ }
+
+ fun setItems(items: List<ConfigProduct>, commitCallback: () -> Unit) {
+ // toMutableList() is needed for some reason, otherwise doesn't update adapter
+ differ.submitList(items.toMutableList(), commitCallback)
+ }
+
+ fun getItem(position: Int): ConfigProduct? = differ.currentList[position]
+
+ fun getItemByKey(key: String): ConfigProduct? {
+ return differ.currentList.find { it.id == key }
+ }
+
+ fun findPosition(product: ConfigProduct): Int {
+ return differ.currentList.indexOf(product)
+ }
+
+ private inner class OrderViewHolder(private val v: View) : ViewHolder(v) {
+ private val quantity: TextView = v.findViewById(R.id.quantity)
+ private val name: TextView = v.findViewById(R.id.name)
+ private val price: TextView = v.findViewById(R.id.price)
+
+ fun bind(product: ConfigProduct, selected: Boolean) {
+ v.isActivated = selected
+ quantity.text = product.quantity.toString()
+ name.text = product.localizedDescription
+ price.text = String.format("%.2f", product.priceAsDouble * product.quantity)
+ }
+ }
+
+ private inner class OrderKeyProvider : ItemKeyProvider<String>(SCOPE_MAPPED) {
+ override fun getKey(position: Int) = getItem(position)!!.id
+ override fun getPosition(key: String): Int {
+ return differ.currentList.indexOfFirst { it.id == key }
+ }
+ }
+
+ internal class OrderLineLookup(private val list: RecyclerView) : ItemDetailsLookup<String>() {
+ override fun getItemDetails(e: MotionEvent): ItemDetails<String>? {
+ list.findChildViewUnder(e.x, e.y)?.let { view ->
+ val holder = list.getChildViewHolder(view)
+ val adapter = list.adapter as OrderAdapter
+ val position = holder.adapterPosition
+ return object : ItemDetails<String>() {
+ override fun getPosition(): Int = position
+ override fun getSelectionKey(): String = adapter.keyProvider.getKey(position)
+ override fun inSelectionHotspot(e: MotionEvent) = true
+ }
+ }
+ return null
+ }
+ }
+
+}
diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/order/ProductsFragment.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/order/ProductsFragment.kt
new file mode 100644
index 0000000..4704ad0
--- /dev/null
+++ b/merchant-terminal/src/main/java/net/taler/merchantpos/order/ProductsFragment.kt
@@ -0,0 +1,111 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2020 Taler Systems S.A.
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+package net.taler.merchantpos.order
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.View.INVISIBLE
+import android.view.ViewGroup
+import android.widget.TextView
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import androidx.lifecycle.Observer
+import androidx.recyclerview.widget.GridLayoutManager
+import androidx.recyclerview.widget.RecyclerView.Adapter
+import androidx.recyclerview.widget.RecyclerView.ViewHolder
+import kotlinx.android.synthetic.main.fragment_products.*
+import net.taler.merchantpos.MainViewModel
+import net.taler.merchantpos.R
+import net.taler.merchantpos.order.ProductAdapter.ProductViewHolder
+
+interface ProductSelectionListener {
+ fun onProductSelected(product: ConfigProduct)
+}
+
+class ProductsFragment : Fragment(), ProductSelectionListener {
+
+ private val viewModel: MainViewModel by activityViewModels()
+ private val orderManager by lazy { viewModel.orderManager }
+ private val adapter = ProductAdapter(this)
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View? {
+ return inflater.inflate(R.layout.fragment_products, container, false)
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ productsList.apply {
+ adapter = this@ProductsFragment.adapter
+ layoutManager = GridLayoutManager(requireContext(), 3)
+ }
+
+ orderManager.products.observe(viewLifecycleOwner, Observer { products ->
+ if (products == null) {
+ adapter.setItems(emptyList())
+ } else {
+ adapter.setItems(products)
+ }
+ progressBar.visibility = INVISIBLE
+ })
+ }
+
+ override fun onProductSelected(product: ConfigProduct) {
+ orderManager.addProduct(orderManager.currentOrderId.value!!, product)
+ }
+
+}
+
+private class ProductAdapter(
+ private val listener: ProductSelectionListener
+) : Adapter<ProductViewHolder>() {
+
+ private val products = ArrayList<ConfigProduct>()
+
+ override fun getItemCount() = products.size
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ProductViewHolder {
+ val view =
+ LayoutInflater.from(parent.context).inflate(R.layout.list_item_product, parent, false)
+ return ProductViewHolder(view)
+ }
+
+ override fun onBindViewHolder(holder: ProductViewHolder, position: Int) {
+ holder.bind(products[position])
+ }
+
+ fun setItems(items: List<ConfigProduct>) {
+ products.clear()
+ products.addAll(items)
+ notifyDataSetChanged()
+ }
+
+ private inner class ProductViewHolder(private val v: View) : ViewHolder(v) {
+ private val name: TextView = v.findViewById(R.id.name)
+ private val price: TextView = v.findViewById(R.id.price)
+
+ fun bind(product: ConfigProduct) {
+ name.text = product.localizedDescription
+ price.text = product.priceAsDouble.toString()
+ v.setOnClickListener { listener.onProductSelected(product) }
+ }
+ }
+
+}