taler-android

Android apps for GNU Taler (wallet, PoS, cashier)
Log | Files | Refs | README | LICENSE

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:
Mmerchant-terminal/src/main/java/net/taler/merchantpos/config/PosConfig.kt | 12++++++++++++
Mmerchant-terminal/src/main/java/net/taler/merchantpos/order/OrderManager.kt | 65++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Mmerchant-terminal/src/main/java/net/taler/merchantpos/order/ProductsFragment.kt | 12++++++++++--
Mmerchant-terminal/src/main/res/layout/list_item_product.xml | 18+++++++++++++++---
Mmerchant-terminal/src/main/res/values/strings.xml | 1+
Amerchant-terminal/src/test/java/net/taler/merchantpos/config/ConfigProductTest.kt | 33+++++++++++++++++++++++++++++++++
Mmerchant-terminal/src/test/java/net/taler/merchantpos/order/OrderManagerTest.kt | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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) }