commit 6a2504e64b61127c36e3a344f5241deb3004e3d9
parent b5c6d4290c36eb9c4d9701d00f5d554875cebb09
Author: Bohdan Potuzhnyi <bohdan.potuzhnyi@gmail.com>
Date: Tue, 14 Apr 2026 00:15:38 +0200
[merchant-terminal] Vladas requested updates
Diffstat:
7 files changed, 212 insertions(+), 20 deletions(-)
diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/config/PosConfig.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/config/PosConfig.kt
@@ -105,6 +105,18 @@ data class ConfigProduct(
val quantity: Int = 0
) : OrderProduct() {
val totalPrice by lazy { price * quantity }
+ private val normalizedProductName: String?
+ get() = productName?.trim()?.takeIf { it.isNotEmpty() }
+ private val normalizedDescription: String
+ get() = localizedDescription.trim()
+ val displayName: String
+ get() = normalizedProductName ?: normalizedDescription
+ val displayDescription: String?
+ get() = normalizedProductName
+ ?.takeIf { it != normalizedDescription }
+ ?.let { normalizedDescription }
+ val displayPrice: String
+ get() = "${price.toString(showSymbol = false)} ${price.currency}"
fun toContractProduct() = ContractProduct(
productId = productId,
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
@@ -33,6 +33,9 @@ class OrderManager(private val context: Context) : ConfigurationReceiver {
companion object {
val TAG: String = OrderManager::class.java.simpleName
+ private const val ALL_PRODUCTS_CATEGORY_ID = -1
+ private const val UNCATEGORIZED_CATEGORY_ID = -2
+ private const val LEGACY_DEFAULT_CATEGORY_NAME = "Default"
}
private lateinit var currency: String
@@ -56,15 +59,30 @@ class OrderManager(private val context: Context) : ConfigurationReceiver {
Log.e(TAG, "No valid category found.")
return context.getString(R.string.config_error_category)
}
- // pre-select the first category
- posConfig.categories[0].selected = true
+ val allProductsCategory = Category(
+ ALL_PRODUCTS_CATEGORY_ID,
+ context.getString(R.string.product_category_all_objects)
+ ).apply {
+ selected = true
+ }
+ val uncategorizedCategory = Category(
+ UNCATEGORIZED_CATEGORY_ID,
+ context.getString(R.string.product_category_uncategorized)
+ )
+ val visibleCategories = posConfig.categories.filterNot(::isLegacyDefaultCategory)
+ val legacyDefaultCategoryIds = posConfig.categories
+ .filter(::isLegacyDefaultCategory)
+ .map { it.id }
+ .toSet()
// group products by categories
productsByCategory.clear()
- val unknownCategory = Category(-1, context.getString(R.string.product_category_uncategorized))
- posConfig.categories.forEach { category ->
+ productsByCategory[allProductsCategory] = ArrayList()
+ visibleCategories.forEach { category ->
+ category.selected = false
productsByCategory[category] = ArrayList()
}
+ productsByCategory[uncategorizedCategory] = ArrayList()
posConfig.products.forEach { product ->
val productCurrency = product.price.currency
if (productCurrency != currency) {
@@ -73,23 +91,36 @@ class OrderManager(private val context: Context) : ConfigurationReceiver {
R.string.config_error_currency, product.description, productCurrency, currency
)
}
+ productsByCategory.getValue(allProductsCategory).add(product)
+ if (product.categories.isEmpty()) {
+ productsByCategory.getValue(uncategorizedCategory).add(product)
+ }
product.categories.forEach { categoryId ->
- val category = posConfig.categories.find { it.id == categoryId } ?: let {
+ if (categoryId in legacyDefaultCategoryIds) {
+ productsByCategory.getValue(uncategorizedCategory).add(product)
+ return@forEach
+ }
+ val category = visibleCategories.find { it.id == categoryId }
+ if (category == null) {
Log.e(TAG, "Product $product has unknown category $categoryId")
- unknownCategory
+ productsByCategory.getValue(uncategorizedCategory).add(product)
+ } else {
+ productsByCategory.getValue(category).add(product)
}
-
- productsByCategory.getOrPut(category) { ArrayList() }.add(product)
}
}
this.currency = currency
- mCategories.postValue(posConfig.categories +
- if(productsByCategory.containsKey(unknownCategory)) {
- listOf(unknownCategory)
- } else {
- emptyList()
- })
- mProducts.postValue(productsByCategory[posConfig.categories[0]] ?: emptyList())
+ val categoryList = buildList {
+ add(allProductsCategory)
+ addAll(visibleCategories)
+ if (productsByCategory.getValue(uncategorizedCategory).isNotEmpty()) {
+ add(uncategorizedCategory)
+ } else {
+ productsByCategory.remove(uncategorizedCategory)
+ }
+ }
+ mCategories.postValue(categoryList)
+ mProducts.postValue(productsByCategory[allProductsCategory] ?: emptyList())
orders.clear()
orderCounter = 0
orders[0] = MutableLiveOrder(0, currency, productsByCategory)
@@ -182,4 +213,8 @@ class OrderManager(private val context: Context) : ConfigurationReceiver {
return orders[orderId] ?: throw IllegalStateException()
}
+ private fun isLegacyDefaultCategory(category: Category): Boolean {
+ return category.name.equals(LEGACY_DEFAULT_CATEGORY_NAME, ignoreCase = true)
+ }
+
}
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
@@ -106,12 +106,20 @@ private class ProductAdapter(
inner class ProductViewHolder(private val v: View) : ViewHolder(v) {
private val name: TextView = v.findViewById(R.id.name)
+ private val description: TextView = v.findViewById(R.id.description)
private val price: TextView = v.findViewById(R.id.price)
private val image: ImageView = v.findViewById(R.id.image)
fun bind(product: ConfigProduct) {
- name.text = product.localizedDescription
- price.text = product.price.amountStr
+ name.text = product.displayName
+ val productDescription = product.displayDescription
+ if (productDescription == null) {
+ description.visibility = GONE
+ } else {
+ description.visibility = VISIBLE
+ description.text = productDescription
+ }
+ price.text = product.displayPrice
// base64 encoded image
val bitmap = product.image?.base64Bitmap
diff --git a/merchant-terminal/src/main/res/layout/list_item_product.xml b/merchant-terminal/src/main/res/layout/list_item_product.xml
@@ -54,15 +54,28 @@
tools:text="Steak and two Eggs" />
<TextView
+ android:id="@+id/description"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="4dp"
+ android:textColor="?android:textColorSecondary"
+ android:visibility="gone"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@id/name"
+ tools:text="Two eggs, potatoes, and steak"
+ tools:visibility="visible" />
+
+ <TextView
android:id="@+id/price"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textColor="?android:textColorSecondary"
app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintTop_toBottomOf="@+id/name"
+ app:layout_constraintTop_toBottomOf="@+id/description"
tools:text="7.95" />
</androidx.constraintlayout.widget.ConstraintLayout>
-</com.google.android.material.card.MaterialCardView>
-\ No newline at end of file
+</com.google.android.material.card.MaterialCardView>
diff --git a/merchant-terminal/src/main/res/values/strings.xml b/merchant-terminal/src/main/res/values/strings.xml
@@ -10,6 +10,7 @@
<string name="menu_reload">Reload</string>
<string name="product_image">Product image</string>
+ <string name="product_category_all_objects">All objects</string>
<string name="product_category_uncategorized">Uncategorized</string>
<string name="order_label_title">Order #%s</string>
diff --git a/merchant-terminal/src/test/java/net/taler/merchantpos/config/ConfigProductTest.kt b/merchant-terminal/src/test/java/net/taler/merchantpos/config/ConfigProductTest.kt
@@ -0,0 +1,33 @@
+package net.taler.merchantpos.config
+
+import net.taler.common.Amount
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+class ConfigProductTest {
+
+ @Test
+ fun `display description is hidden when same as product name`() {
+ val product = ConfigProduct(
+ productName = "Coffee",
+ description = "Coffee",
+ price = Amount("KUDOS", 2, 0),
+ categories = listOf(1)
+ )
+
+ assertEquals("Coffee", product.displayName)
+ assertNull(product.displayDescription)
+ }
+
+ @Test
+ fun `display price appends currency code`() {
+ val product = ConfigProduct(
+ description = "Coffee",
+ price = Amount("KUDOS", 2, 50000000),
+ categories = listOf(1)
+ )
+
+ assertEquals("2.50 KUDOS", product.displayPrice)
+ }
+}
diff --git a/merchant-terminal/src/test/java/net/taler/merchantpos/order/OrderManagerTest.kt b/merchant-terminal/src/test/java/net/taler/merchantpos/order/OrderManagerTest.kt
@@ -17,6 +17,8 @@
package net.taler.merchantpos.order
import android.app.Application
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.Observer
import androidx.test.core.app.ApplicationProvider.getApplicationContext
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.runBlocking
@@ -27,10 +29,16 @@ import net.taler.merchantpos.config.Category
import net.taler.merchantpos.config.ConfigProduct
import net.taler.merchantpos.config.PosConfig
import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
+import org.robolectric.shadows.ShadowLooper.shadowMainLooper
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
@Config(sdk = [28]) // API 29 needs at least Java 9
@RunWith(AndroidJUnit4::class)
@@ -98,4 +106,87 @@ class OrderManagerTest {
assertNull(result)
}
+ @Test
+ fun `all objects is selected by default and shown first`() = runBlocking {
+ orderManager.onConfigurationReceived(posConfig, "KUDOS")
+ shadowMainLooper().idle()
+
+ val categories = orderManager.categories.awaitValue()
+ val products = orderManager.products.awaitValue()
+
+ assertNotNull(categories)
+ assertNotNull(products)
+ assertEquals(app.getString(R.string.product_category_all_objects), categories[0].name)
+ assertTrue(categories[0].selected)
+ assertFalse(categories[1].selected)
+ assertEquals(posConfig.products, products)
+ }
+
+ @Test
+ fun `uncategorized is appended at the end when needed`() = runBlocking {
+ val uncategorizedProduct = ConfigProduct(
+ description = "baz",
+ price = Amount("KUDOS", 2, 0),
+ categories = emptyList()
+ )
+ val config = posConfig.copy(products = posConfig.products + uncategorizedProduct)
+
+ orderManager.onConfigurationReceived(config, "KUDOS")
+ shadowMainLooper().idle()
+
+ val categories = orderManager.categories.awaitValue()
+ val uncategorized = categories.last()
+
+ assertEquals(app.getString(R.string.product_category_uncategorized), uncategorized.name)
+ orderManager.setCurrentCategory(uncategorized)
+ shadowMainLooper().idle()
+ assertEquals(listOf(uncategorizedProduct), orderManager.products.awaitValue())
+ }
+
+ @Test
+ fun `legacy default category is hidden and mapped to uncategorized`() = runBlocking {
+ val defaultCategory = Category(3, "Default")
+ val defaultProduct = ConfigProduct(
+ description = "legacy",
+ price = Amount("KUDOS", 3, 0),
+ categories = listOf(3)
+ )
+ val config = posConfig.copy(
+ categories = posConfig.categories + defaultCategory,
+ products = posConfig.products + defaultProduct
+ )
+
+ orderManager.onConfigurationReceived(config, "KUDOS")
+ shadowMainLooper().idle()
+
+ val categories = orderManager.categories.awaitValue()
+ assertFalse(categories.any { it.name == "Default" })
+ assertEquals(app.getString(R.string.product_category_uncategorized), categories.last().name)
+
+ orderManager.setCurrentCategory(categories.last())
+ shadowMainLooper().idle()
+ assertEquals(listOf(defaultProduct), orderManager.products.awaitValue())
+ }
+
+}
+
+private fun <T> LiveData<T>.awaitValue(timeout: Long = 2, unit: TimeUnit = TimeUnit.SECONDS): T {
+ val latch = CountDownLatch(1)
+ var result: T? = null
+ val observer = object : Observer<T> {
+ override fun onChanged(value: T) {
+ result = value
+ latch.countDown()
+ removeObserver(this)
+ }
+ }
+ observeForever(observer)
+ if (this.value != null) {
+ result = this.value
+ removeObserver(observer)
+ } else if (!latch.await(timeout, unit)) {
+ removeObserver(observer)
+ throw AssertionError("LiveData value was never set.")
+ }
+ return requireNotNull(result)
}