From 37cbb816e58da79ce54a3b0e8395f52c6f1a3314 Mon Sep 17 00:00:00 2001 From: Tronax Date: Sat, 22 Aug 2026 10:16:37 +0200 Subject: [PATCH] Feature: Bring-style emoji & category icons for items Every shopping item now gets a visual like in Bring: a product emoji on a softly tinted category tile. - ItemVisuals.kt: pure client-side classifier. German dictionary (~180 entries) maps item names to emoji + one of 13 categories (produce, drinks, bakery, dairy, meat&fish, frozen, pantry, sweets, household, drugstore, baby, pet, other), each with an accent color. Longest-keyword-first matching so compounds resolve to their most specific entry (Wassermelone is produce, not a drink); juice compounds (Apfelsaft, Orangensaft, ...) are listed explicitly because their fruit prefix is longer than 'saft'. Unknown items fall back to the generic cart on neutral grey. Works fully offline, nothing leaves the device. - ListDetailScreen: emoji tile in every item row, emoji in the autocomplete suggestions dropdown, and a live emoji preview as the text field's leading icon while typing. - ItemVisualsTest: longest-match priority, case/whitespace insensitivity, fallbacks, category coverage. Verified: assembleDebug + unit tests green (JDK 21). --- AGENTS.md | 9 +- README.md | 1 + .../mitbringsl/ui/detail/ItemVisuals.kt | 254 ++++++++++++++++++ .../mitbringsl/ui/detail/ListDetailScreen.kt | 40 ++- .../mitbringsl/ui/detail/ItemVisualsTest.kt | 78 ++++++ 5 files changed, 375 insertions(+), 7 deletions(-) create mode 100644 android/app/src/main/java/com/example/mitbringsl/ui/detail/ItemVisuals.kt create mode 100644 android/app/src/test/java/com/example/mitbringsl/ui/detail/ItemVisualsTest.kt diff --git a/AGENTS.md b/AGENTS.md index e0b3d85..328bc98 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -180,7 +180,7 @@ mitbringsl/ ├── di/ # Hilt Modules ├── ui/auth/ # AuthScreen (ConnectView: Server-URL + /api/config-Discovery, dann LoginView) ├── ui/lists/ # ListsScreen/ViewModel (Join-Dialog, Offline-Banner, Settings-Zahnrad) - ├── ui/detail/ # ListDetailScreen/ViewModel (Share-Button, Autocomplete) + ├── ui/detail/ # ListDetailScreen/ViewModel (Share-Button, Autocomplete), ItemVisuals (Emoji-/Kategorie-Icons) ├── ui/settings/ # SettingsScreen/ViewModel (Account, Profil, Theme, Reset, About) └── util/ # NetworkMonitor ``` @@ -247,6 +247,13 @@ Legende: ✅ erledigt · 🚧 in Arbeit · ⬜ offen - ✅ **Post-MVP – Settings-Screen + Profil:** `GET/PUT /api/me` (`UserStore.UpdateDisplayName`), `SettingsScreen`/`SettingsViewModel` (Account&Sync, Profil, Theme, Reset, About), Theme-Pref in SessionManager. +- ✅ **Post-MVP – Produkt-Icons (Bring-Style):** `ui/detail/ItemVisuals.kt` – + rein clientseitiger Classifier: deutsches Wörterbuch (~180 Einträge) mappt + Item-Namen auf **Emoji + Kategorie** (13 Kategorien mit Akzentfarbe). + Longest-Keyword-Match („Apfelsaft" → Getränk, nicht Obst; Saft-Komposita + explizit gelistet), unbekannte Items → 🛒/Sonstiges. UI: Icon-Kachel in + ItemRow, Icons im Autocomplete-Dropdown, Live-Emoji im Eingabefeld + (`leadingIcon`). Unit-Tests in `ItemVisualsTest.kt`. - ✅ **Post-MVP – Build hinter restriktiven Proxies:** foojay-Resolver & jvmToolchain auskommentiert, Gradle-Wrapper 9.6.1, `.ssl-work/` ignoriert. diff --git a/README.md b/README.md index 75e101a..a5c5262 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Mitbringsl ist eine moderne, schnelle und werbefreie Einkaufslisten-App mit **Lo - 📱 **Android App**: Kotlin 2.x, Jetpack Compose Material 3, Room, Hilt, WorkManager. - 🔌 **Local-Only by Default**: Kein Account nötig — die App startet direkt ohne Login; Sync & Account sind optional (auch Self-Hosted, Server-URL in der App konfigurierbar). - 👥 **Geteilte Listen**: Listen per 8-Zeichen-Invite-Code teilen und gemeinsam bearbeiten; Membership wird serverseitig bei jedem Sync geprüft. +- 🎨 **Produkt-Icons (Bring-Style)**: Jeder Artikel bekommt automatisch ein passendes Emoji mit Kategorie-Farbe – Milch 🥛, Brot 🍞, Äpfel 🍎 … auch im Autocomplete und live beim Tippen. Komplett offline über ein deutsches Wörterbuch, kein Tracking. - ⚡ **Local-First Sync Engine**: Hybrid Logical Clock (HLC), append-only `op_log`, Last-Write-Wins (LWW) Projektionen, Idempotente Push/Pull-Algorithmen. - 🔐 **Datenschutz & Auth**: Argon2id Passwort-Hashing, opaque Session-Tokens, OIDC-Unterstützung (Google & eigene IdPs wie Authentik/Keycloak) — Login-Methoden werden serverseitig per `/api/config` entdeckt, Passwort-Login lässt sich per `AUTH_PASSWORD_ENABLED=false` komplett abschalten. - ⚙️ **Settings & Themes**: Account-/Profil-Verwaltung (`/api/me`), Theme-Umschalter (System/Hell/Dunkel), lokaler Daten-Reset — alles direkt in der App. diff --git a/android/app/src/main/java/com/example/mitbringsl/ui/detail/ItemVisuals.kt b/android/app/src/main/java/com/example/mitbringsl/ui/detail/ItemVisuals.kt new file mode 100644 index 0000000..9c7a0b3 --- /dev/null +++ b/android/app/src/main/java/com/example/mitbringsl/ui/detail/ItemVisuals.kt @@ -0,0 +1,254 @@ +package com.example.mitbringsl.ui.detail + +import androidx.compose.ui.graphics.Color +import com.example.mitbringsl.ui.detail.ItemCategory.* + +/** + * Bring-style visual classification of shopping items: every item gets an emoji + * plus a category with an accent color, resolved purely client-side from a + * German keyword dictionary (works offline, no server round-trip). + * + * Matching is longest-keyword-first substring matching, so compound groceries + * resolve to their most specific entry ("Apfelsaft" -> drinks, not the apple + * entry it contains). + */ +enum class ItemCategory(val label: String, val colorHex: Long, val fallbackEmoji: String) { + PRODUCE("Obst & Gemüse", 0xFF43A047, "🥕"), + DRINKS("Getränke", 0xFF2563EB, "🧃"), + BAKERY("Backwaren", 0xFFD97706, "🥖"), + DAIRY("Milchprodukte & Eier", 0xFF60A5FA, "🥛"), + MEAT_FISH("Fleisch & Fisch", 0xFFE53935, "🥩"), + FROZEN("Tiefkühl", 0xFF06B6D4, "🧊"), + PANTRY("Vorrat", 0xFF8D6E63, "🥫"), + SWEETS("Süßes & Snacks", 0xFF9C27B0, "🍫"), + HOUSEHOLD("Haushalt", 0xFF78909C, "🧽"), + DRUGSTORE("Drogerie", 0xFF26A69A, "🧴"), + BABY("Baby", 0xFFF48FB1, "🍼"), + PET("Haustier", 0xFF795548, "🐾"), + OTHER("Sonstiges", 0xFF9E9E9E, "🛒"), +} + +/** Accent color of a category, used as a soft tint behind the emoji tile. */ +val ItemCategory.color: Color get() = Color(colorHex) + +data class ItemVisual(val emoji: String, val category: ItemCategory) + +private fun v(emoji: String, category: ItemCategory) = ItemVisual(emoji, category) + +// German grocery dictionary. Order within a category is irrelevant (matching +// sorts by keyword length); compound-specific entries must simply exist. +private val dictionary: List> = listOf( + // --- Obst & Gemüse --- + "obst" to v("🍎", PRODUCE), "gemüse" to v("🥬", PRODUCE), + "apfel" to v("🍎", PRODUCE), "granatapfel" to v("🍎", PRODUCE), + "banane" to v("🍌", PRODUCE), "birne" to v("🍐", PRODUCE), + "orange" to v("🍊", PRODUCE), "mandarine" to v("🍊", PRODUCE), + "clementine" to v("🍊", PRODUCE), "zitron" to v("🍋", PRODUCE), + "limette" to v("🍋", PRODUCE), "grapefruit" to v("🍊", PRODUCE), + "pfirsich" to v("🍑", PRODUCE), "aprikose" to v("🍑", PRODUCE), + "nectarine" to v("🍑", PRODUCE), "pflaume" to v("🍑", PRODUCE), + "zwetschge" to v("🍑", PRODUCE), "kirsche" to v("🍒", PRODUCE), + "trauben" to v("🍇", PRODUCE), "erdbeer" to v("🍓", PRODUCE), + "himbeer" to v("🍓", PRODUCE), "brombeer" to v("🍓", PRODUCE), + "blaubeer" to v("🫐", PRODUCE), "heidelbeer" to v("🫐", PRODUCE), + "johannisbeer" to v("🫐", PRODUCE), "kiwi" to v("🥝", PRODUCE), + "ananas" to v("🍍", PRODUCE), "mango" to v("🥭", PRODUCE), + "melone" to v("🍈", PRODUCE), "wassermelone" to v("🍉", PRODUCE), + "honigmelone" to v("🍈", PRODUCE), "avocado" to v("🥑", PRODUCE), + "avokado" to v("🥑", PRODUCE), "tomat" to v("🍅", PRODUCE), + "eisbergsalat" to v("🥬", PRODUCE), "gurke" to v("🥒", PRODUCE), + "paprika" to v("🫑", PRODUCE), "chili" to v("🌶️", PRODUCE), + "peperoni" to v("🌶️", PRODUCE), "zwiebel" to v("🧅", PRODUCE), + "knoblauch" to v("🧄", PRODUCE), "lauch" to v("🧅", PRODUCE), + "porree" to v("🧅", PRODUCE), "schnittlauch" to v("🌿", PRODUCE), + "karotte" to v("🥕", PRODUCE), "möhre" to v("🥕", PRODUCE), + "moehre" to v("🥕", PRODUCE), "mohrrübe" to v("🥕", PRODUCE), + "kartoffel" to v("🥔", PRODUCE), "süßkartoffel" to v("🥔", PRODUCE), + "süsskartoffel" to v("🥔", PRODUCE), "salat" to v("🥬", PRODUCE), + "rucola" to v("🥬", PRODUCE), "spinat" to v("🥬", PRODUCE), + "mangold" to v("🥬", PRODUCE), "brokkoli" to v("🥦", PRODUCE), + "blumenkohl" to v("🥦", PRODUCE), "kohlrabi" to v("🥦", PRODUCE), + "rosenkohl" to v("🥦", PRODUCE), "zucchini" to v("🥒", PRODUCE), + "kürbis" to v("🎃", PRODUCE), "aubergine" to v("🍆", PRODUCE), + "pilz" to v("🍄", PRODUCE), "champignon" to v("🍄", PRODUCE), + "mais" to v("🌽", PRODUCE), "erbsen" to v("🥬", PRODUCE), + "bohnen" to v("🥬", PRODUCE), "ingwer" to v("🥕", PRODUCE), + "radieschen" to v("🥬", PRODUCE), "sellerie" to v("🥬", PRODUCE), + "kräuter" to v("🌿", PRODUCE), "petersilie" to v("🌿", PRODUCE), + "basilikum" to v("🌿", PRODUCE), "minze" to v("🌿", PRODUCE), + "rosmarin" to v("🌿", PRODUCE), "thymian" to v("🌿", PRODUCE), + "oregano" to v("🌿", PRODUCE), "dill" to v("🌿", PRODUCE), + + // --- Getränke --- + // Juice compounds listed explicitly: longest-match alone would resolve + // "apfelsaft" to the apple entry ("apfel" is longer than "saft"). + "apfelsaft" to v("🧃", DRINKS), "apfelschorle" to v("🧃", DRINKS), + "orangensaft" to v("🧃", DRINKS), "traubensaft" to v("🧃", DRINKS), + "kirschsaft" to v("🧃", DRINKS), "bananensaft" to v("🧃", DRINKS), + "tomatensaft" to v("🧃", DRINKS), "granatapfelsaft" to v("🧃", DRINKS), + "johannisbeersaft" to v("🧃", DRINKS), "maracujasaft" to v("🧃", DRINKS), + "wasser" to v("💧", DRINKS), "saft" to v("🧃", DRINKS), + "obstsaft" to v("🧃", DRINKS), "fruchtsaft" to v("🧃", DRINKS), + "limonade" to v("🥤", DRINKS), "cola" to v("🥤", DRINKS), + "bier" to v("🍺", DRINKS), "radler" to v("🍺", DRINKS), + "wein" to v("🍷", DRINKS), "prosecco" to v("🍾", DRINKS), + "sekt" to v("🍾", DRINKS), "champagner" to v("🍾", DRINKS), + "kaffee" to v("☕", DRINKS), "espresso" to v("☕", DRINKS), + "cappuccino" to v("☕", DRINKS), "tee" to v("🍵", DRINKS), + "eistee" to v("🧃", DRINKS), "smoothie" to v("🥤", DRINKS), + "kakao" to v("☕", DRINKS), "energy" to v("🥤", DRINKS), + "sprudel" to v("💧", DRINKS), "schnaps" to v("🥃", DRINKS), + "wodka" to v("🥃", DRINKS), "gin" to v("🥃", DRINKS), + "whisky" to v("🥃", DRINKS), "whiskey" to v("🥃", DRINKS), + "rum" to v("🥃", DRINKS), "flasche" to v("🍾", DRINKS), + + // --- Backwaren --- + "knoblauchbrot" to v("🥖", BAKERY), "brot" to v("🍞", BAKERY), + "toast" to v("🍞", BAKERY), "brötchen" to v("🥖", BAKERY), + "broetchen" to v("🥖", BAKERY), "semmel" to v("🥖", BAKERY), + "baguette" to v("🥖", BAKERY), "ciabatta" to v("🥖", BAKERY), + "croissant" to v("🥐", BAKERY), "bagel" to v("🥯", BAKERY), + "brezel" to v("🥨", BAKERY), "kuchen" to v("🍰", BAKERY), + "torte" to v("🍰", BAKERY), "donut" to v("🍩", BAKERY), + "berliner" to v("🍩", BAKERY), "krapfen" to v("🍩", BAKERY), + "muffin" to v("🧁", BAKERY), "cupcake" to v("🧁", BAKERY), + "stollen" to v("🍰", BAKERY), "pfannkuchen" to v("🥞", BAKERY), + "crepe" to v("🥞", BAKERY), "waffel" to v("🥞", BAKERY), + "zwieback" to v("🍞", BAKERY), + + // --- Milchprodukte & Eier --- + "milch" to v("🥛", DAIRY), "butter" to v("🧈", DAIRY), + "käse" to v("🧀", DAIRY), "camembert" to v("🧀", DAIRY), + "gouda" to v("🧀", DAIRY), "mozzarella" to v("🧀", DAIRY), + "feta" to v("🧀", DAIRY), "halloumi" to v("🧀", DAIRY), + "parmesan" to v("🧀", DAIRY), "mascarpone" to v("🧀", DAIRY), + "ricotta" to v("🧀", DAIRY), "quark" to v("🥛", DAIRY), + "joghurt" to v("🥛", DAIRY), "sahne" to v("🥛", DAIRY), + "schmand" to v("🥛", DAIRY), "fraiche" to v("🥛", DAIRY), + "fraîche" to v("🥛", DAIRY), "eier" to v("🥚", DAIRY), + "eigelb" to v("🥚", DAIRY), "eiweiß" to v("🥚", DAIRY), + "ei" to v("🥚", DAIRY), "pudding" to v("🍮", DAIRY), + + // --- Fleisch & Fisch --- + "hackfleisch" to v("🥩", MEAT_FISH), "gehacktes" to v("🥩", MEAT_FISH), + "fleisch" to v("🥩", MEAT_FISH), "steak" to v("🥩", MEAT_FISH), + "filet" to v("🥩", MEAT_FISH), "schwein" to v("🥩", MEAT_FISH), + "hähnchen" to v("🍗", MEAT_FISH), "haehnchen" to v("🍗", MEAT_FISH), + "huhn" to v("🍗", MEAT_FISH), "pute" to v("🍗", MEAT_FISH), + "truthahn" to v("🦃", MEAT_FISH), "ente" to v("🦆", MEAT_FISH), + "schinken" to v("🥓", MEAT_FISH), "speck" to v("🥓", MEAT_FISH), + "bacon" to v("🥓", MEAT_FISH), "wurst" to v("🌭", MEAT_FISH), + "salami" to v("🌭", MEAT_FISH), "wiener" to v("🌭", MEAT_FISH), + "frankfurter" to v("🌭", MEAT_FISH), "mett" to v("🥩", MEAT_FISH), + "gyros" to v("🥙", MEAT_FISH), "döner" to v("🥙", MEAT_FISH), + "kebab" to v("🥙", MEAT_FISH), "kebap" to v("🥙", MEAT_FISH), + "schnitzel" to v("🍖", MEAT_FISH), "fisch" to v("🐟", MEAT_FISH), + "lachs" to v("🐟", MEAT_FISH), "forelle" to v("🐟", MEAT_FISH), + "karpfen" to v("🐟", MEAT_FISH), "hering" to v("🐟", MEAT_FISH), + "makrele" to v("🐟", MEAT_FISH), "garnelen" to v("🦐", MEAT_FISH), + "krabben" to v("🦐", MEAT_FISH), "scampi" to v("🦐", MEAT_FISH), + "shrimp" to v("🦐", MEAT_FISH), "muscheln" to v("🦪", MEAT_FISH), + "austern" to v("🦪", MEAT_FISH), + + // --- Tiefkühl --- + "tiefkühl" to v("🧊", FROZEN), "gefroren" to v("🧊", FROZEN), + "eiscreme" to v("🍦", FROZEN), "eis" to v("🍦", FROZEN), + "pommes" to v("🍟", FROZEN), "pizza" to v("🍕", FROZEN), + + // --- Vorrat --- + "nudeln" to v("🍝", PANTRY), "pasta" to v("🍝", PANTRY), + "spaghetti" to v("🍝", PANTRY), "penne" to v("🍝", PANTRY), + "lasagne" to v("🍝", PANTRY), "reis" to v("🍚", PANTRY), + "risotto" to v("🍚", PANTRY), "couscous" to v("🍚", PANTRY), + "bulgur" to v("🍚", PANTRY), "quinoa" to v("🍚", PANTRY), + "mehl" to v("🌾", PANTRY), "zucker" to v("🍚", PANTRY), + "salz" to v("🧂", PANTRY), "pfeffer" to v("🧂", PANTRY), + "gewürz" to v("🧂", PANTRY), "curry" to v("🧂", PANTRY), + "paprikapulver" to v("🧂", PANTRY), "öl" to v("🫒", PANTRY), + "olive" to v("🫒", PANTRY), "essig" to v("🍶", PANTRY), + "balsamico" to v("🍶", PANTRY), "sojasauce" to v("🍶", PANTRY), + "sauce" to v("🍶", PANTRY), "soße" to v("🍶", PANTRY), + "sosse" to v("🍶", PANTRY), "senf" to v("🥫", PANTRY), + "ketchup" to v("🍅", PANTRY), "mayo" to v("🥫", PANTRY), + "mayonnaise" to v("🥫", PANTRY), "marmelade" to v("🍯", PANTRY), + "honig" to v("🍯", PANTRY), "müsli" to v("🥣", PANTRY), + "haferflocken" to v("🥣", PANTRY), "cornflakes" to v("🥣", PANTRY), + "erdnuss" to v("🥜", PANTRY), "nuss" to v("🥜", PANTRY), + "mandel" to v("🥜", PANTRY), "pistazie" to v("🥜", PANTRY), + "cashew" to v("🥜", PANTRY), "linsen" to v("🥣", PANTRY), + "kichererbsen" to v("🥣", PANTRY), "passierte" to v("🥫", PANTRY), + "tomatenmark" to v("🥫", PANTRY), "tomatensauce" to v("🥫", PANTRY), + "tomatensoße" to v("🥫", PANTRY), "tomatensose" to v("🥫", PANTRY), + "dose" to v("🥫", PANTRY), "kokosmilch" to v("🥥", PANTRY), + "kokos" to v("🥥", PANTRY), "suppe" to v("🥫", PANTRY), + "brühe" to v("🥫", PANTRY), "bruehe" to v("🥫", PANTRY), + "backpulver" to v("🥣", PANTRY), + + // --- Süßes & Snacks --- + "schokolade" to v("🍫", SWEETS), "riegel" to v("🍫", SWEETS), + "gummibärchen" to v("🍬", SWEETS), "gummi" to v("🍬", SWEETS), + "bonbon" to v("🍬", SWEETS), "lutscher" to v("🍭", SWEETS), + "karamell" to v("🍬", SWEETS), "marzipan" to v("🍬", SWEETS), + "pralinen" to v("🍫", SWEETS), "keks" to v("🍪", SWEETS), + "plätzchen" to v("🍪", SWEETS), "biscuit" to v("🍪", SWEETS), + "chips" to v("🍿", SWEETS), "popcorn" to v("🍿", SWEETS), + "salzstangen" to v("🍿", SWEETS), "studentenfutter" to v("🥜", SWEETS), + + // --- Haushalt --- + "spülmittel" to v("🧽", HOUSEHOLD), "spüli" to v("🧽", HOUSEHOLD), + "waschmittel" to v("🧼", HOUSEHOLD), "weichspüler" to v("🧺", HOUSEHOLD), + "reiniger" to v("🧽", HOUSEHOLD), "putzmittel" to v("🧽", HOUSEHOLD), + "geschirr" to v("🍽️", HOUSEHOLD), "toilettenpapier" to v("🧻", HOUSEHOLD), + "klopapier" to v("🧻", HOUSEHOLD), "backpapier" to v("🧻", HOUSEHOLD), + "papier" to v("🧻", HOUSEHOLD), "küchenrolle" to v("🧻", HOUSEHOLD), + "müll" to v("🗑️", HOUSEHOLD), "abfall" to v("🗑️", HOUSEHOLD), + "besen" to v("🧹", HOUSEHOLD), "schwamm" to v("🧽", HOUSEHOLD), + "servietten" to v("🍽️", HOUSEHOLD), "folie" to v("🥡", HOUSEHOLD), + "batterie" to v("🔋", HOUSEHOLD), "glühbirne" to v("💡", HOUSEHOLD), + "kerze" to v("🕯️", HOUSEHOLD), "schneidebrett" to v("🍽️", HOUSEHOLD), + "eimer" to v("🧽", HOUSEHOLD), + + // --- Drogerie --- + "zahnpasta" to v("🪥", DRUGSTORE), "zahncreme" to v("🪥", DRUGSTORE), + "zahnbürste" to v("🪥", DRUGSTORE), "zahn" to v("🪥", DRUGSTORE), + "shampoo" to v("🧴", DRUGSTORE), "duschgel" to v("🧴", DRUGSTORE), + "duschbad" to v("🧴", DRUGSTORE), "seife" to v("🧼", DRUGSTORE), + "deo" to v("🧴", DRUGSTORE), "rasier" to v("🪒", DRUGSTORE), + "pflaster" to v("🩹", DRUGSTORE), "salbe" to v("🩹", DRUGSTORE), + "tabletten" to v("💊", DRUGSTORE), "medikament" to v("💊", DRUGSTORE), + "vitamin" to v("💊", DRUGSTORE), "hustensaft" to v("💊", DRUGSTORE), + "sonnencreme" to v("🧴", DRUGSTORE), "sonnenmilch" to v("🧴", DRUGSTORE), + "creme" to v("🧴", DRUGSTORE), "lotion" to v("🧴", DRUGSTORE), + "binden" to v("🩹", DRUGSTORE), "tampon" to v("🩹", DRUGSTORE), + + // --- Baby --- + "babyflasche" to v("🍼", BABY), "babybrei" to v("🍼", BABY), + "baby" to v("👶", BABY), "windel" to v("👶", BABY), + "brei" to v("🍼", BABY), "milchpulver" to v("🍼", BABY), + "folgemilch" to v("🍼", BABY), "schnuller" to v("🍼", BABY), + "feuchttücher" to v("👶", BABY), + + // --- Haustier --- + "hundefutter" to v("🐕", PET), "katzenfutter" to v("🐈", PET), + "katzenstreu" to v("🐈", PET), "futter" to v("🐾", PET), + "hund" to v("🐕", PET), "katze" to v("🐈", PET), + "leckerli" to v("🐾", PET), +) + +// Longest keyword first: specific compound entries win over their generic +// substrings ("apfelsaft" beats "apfel", "kokosmilch" beats "milch"). +private val sortedEntries: List> = + dictionary.sortedByDescending { it.first.length } + +/** + * Resolves the visual (emoji + category) for an item name. Unknown items fall + * back to the generic 🛒 / Sonstiges. + */ +fun itemVisualFor(name: String): ItemVisual { + val n = name.trim().lowercase() + if (n.isEmpty()) return ItemVisual(ItemCategory.OTHER.fallbackEmoji, ItemCategory.OTHER) + for ((keyword, visual) in sortedEntries) { + if (keyword in n) return visual + } + return ItemVisual(ItemCategory.OTHER.fallbackEmoji, ItemCategory.OTHER) +} diff --git a/android/app/src/main/java/com/example/mitbringsl/ui/detail/ListDetailScreen.kt b/android/app/src/main/java/com/example/mitbringsl/ui/detail/ListDetailScreen.kt index 21389a6..e343c87 100644 --- a/android/app/src/main/java/com/example/mitbringsl/ui/detail/ListDetailScreen.kt +++ b/android/app/src/main/java/com/example/mitbringsl/ui/detail/ListDetailScreen.kt @@ -108,17 +108,24 @@ fun ListDetailScreen( ) { Column { suggestions.take(4).forEach { suggestion -> - Text( - text = suggestion, - style = MaterialTheme.typography.bodyMedium, + Row( + verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() .clickable { viewModel.addItem(suggestion, quantityInput) quantityInput = "" } - .padding(horizontal = 16.dp, vertical = 12.dp) - ) + .padding(horizontal = 16.dp, vertical = 10.dp) + ) { + val visual = remember(suggestion) { itemVisualFor(suggestion) } + Text(text = visual.emoji, fontSize = 18.sp) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = suggestion, + style = MaterialTheme.typography.bodyMedium + ) + } HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)) } } @@ -135,6 +142,14 @@ fun ListDetailScreen( placeholder = { Text("Artikel hinzufügen...") }, singleLine = true, shape = RoundedCornerShape(16.dp), + leadingIcon = if (query.isBlank()) null else { + { + Text( + text = itemVisualFor(query).emoji, + fontSize = 18.sp + ) + } + }, modifier = Modifier.weight(1.5f), colors = OutlinedTextFieldDefaults.colors( focusedContainerColor = MaterialTheme.colorScheme.surface, @@ -341,10 +356,23 @@ fun ItemRow( onCheckedChange = { onToggle() } ) + val visual = remember(item.name) { itemVisualFor(item.name) } + Box( + modifier = Modifier + .size(40.dp) + .background( + color = visual.category.color.copy(alpha = 0.15f), + shape = RoundedCornerShape(12.dp) + ), + contentAlignment = Alignment.Center + ) { + Text(text = visual.emoji, fontSize = 18.sp) + } + Column( modifier = Modifier .weight(1f) - .padding(start = 8.dp) + .padding(start = 12.dp) ) { Text( text = item.name, diff --git a/android/app/src/test/java/com/example/mitbringsl/ui/detail/ItemVisualsTest.kt b/android/app/src/test/java/com/example/mitbringsl/ui/detail/ItemVisualsTest.kt new file mode 100644 index 0000000..51005d6 --- /dev/null +++ b/android/app/src/test/java/com/example/mitbringsl/ui/detail/ItemVisualsTest.kt @@ -0,0 +1,78 @@ +package com.example.mitbringsl.ui.detail + +import com.example.mitbringsl.ui.detail.ItemCategory.BABY +import com.example.mitbringsl.ui.detail.ItemCategory.BAKERY +import com.example.mitbringsl.ui.detail.ItemCategory.DAIRY +import com.example.mitbringsl.ui.detail.ItemCategory.DRINKS +import com.example.mitbringsl.ui.detail.ItemCategory.DRUGSTORE +import com.example.mitbringsl.ui.detail.ItemCategory.FROZEN +import com.example.mitbringsl.ui.detail.ItemCategory.HOUSEHOLD +import com.example.mitbringsl.ui.detail.ItemCategory.MEAT_FISH +import com.example.mitbringsl.ui.detail.ItemCategory.OTHER +import com.example.mitbringsl.ui.detail.ItemCategory.PANTRY +import com.example.mitbringsl.ui.detail.ItemCategory.PET +import com.example.mitbringsl.ui.detail.ItemCategory.PRODUCE +import com.example.mitbringsl.ui.detail.ItemCategory.SWEETS +import org.junit.Assert.assertEquals +import org.junit.Test + +class ItemVisualsTest { + + @Test + fun simpleProductsResolveToTheirEmoji() { + assertEquals("🥛", itemVisualFor("Milch").emoji) + assertEquals(DAIRY, itemVisualFor("Milch").category) + assertEquals("🍞", itemVisualFor("Brot").emoji) + assertEquals(PRODUCE, itemVisualFor("Banane").category) + } + + @Test + fun longestKeywordWinsOverContainedGeneric() { + // "Apfelsaft" contains "apfel" but must resolve as a drink. + assertEquals(DRINKS, itemVisualFor("Apfelsaft").category) + // "Wassermelone" contains "wasser" but is produce. + assertEquals(PRODUCE, itemVisualFor("Wassermelone").category) + // "Kokosmilch" must not fall back to dairy milk. + assertEquals(PANTRY, itemVisualFor("Kokosmilch").category) + // "Eisbergsalat" must not be frozen. + assertEquals(PRODUCE, itemVisualFor("Eisbergsalat").category) + // "Teewurst": "wurst" (5) beats "tee" (3). + assertEquals(MEAT_FISH, itemVisualFor("Teewurst").category) + } + + @Test + fun matchingIsCaseAndWhitespaceInsensitive() { + assertEquals(itemVisualFor("banane"), itemVisualFor("BANANE")) + assertEquals(itemVisualFor("banane"), itemVisualFor(" Banane ")) + } + + @Test + fun unknownAndBlankItemsFallBackToGenericCart() { + assertEquals(OTHER, itemVisualFor("Dingsbums").category) + assertEquals("🛒", itemVisualFor("Dingsbums").emoji) + assertEquals(OTHER, itemVisualFor("").category) + assertEquals(OTHER, itemVisualFor(" ").category) + } + + @Test + fun categoriesCoverCommonSupermarketSections() { + mapOf( + "Brot" to BAKERY, + "Brötchen" to BAKERY, + "Hackfleisch" to MEAT_FISH, + "Lachs" to MEAT_FISH, + "Nudeln" to PANTRY, + "Tomatenmark" to PANTRY, + "Schokolade" to SWEETS, + "Pommes" to FROZEN, + "Spülmittel" to HOUSEHOLD, + "Klopapier" to HOUSEHOLD, + "Zahnpasta" to DRUGSTORE, + "Hundefutter" to PET, + "Windeln" to BABY, + "Eier" to DAIRY, + ).forEach { (name, category) -> + assertEquals("$name should be $category", category, itemVisualFor(name).category) + } + } +}