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) + } + } +}