diff options
Diffstat (limited to 'merchant-terminal/src/main/java/net/taler/merchantpos/order')
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) } + } + } + +} |