From cabc9a326d22b4f69d05cc2953910b624539ea6e Mon Sep 17 00:00:00 2001 From: Andrew Steinmetz Date: Sun, 19 Jul 2026 23:24:41 -0700 Subject: [PATCH 1/6] fix(grocery): categorize cold brew as a beverage "Cold brew" wasn't in the IngredientParser vocabulary, so it fell through to OTHER. Add it as a BEVERAGES keyword; sorted longest-first, it also takes precedence over the generic "coffee" keyword. Co-Authored-By: Claude Opus 4.8 --- .../com/plusmobileapps/chefmate/grocery/data/IngredientParser.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/client/grocery/data/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/data/IngredientParser.kt b/client/grocery/data/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/data/IngredientParser.kt index c27fb42e0..6e611e425 100644 --- a/client/grocery/data/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/data/IngredientParser.kt +++ b/client/grocery/data/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/data/IngredientParser.kt @@ -482,6 +482,7 @@ object IngredientParser { "soda" to GroceryCategory.BEVERAGES, "water" to GroceryCategory.BEVERAGES, "coffee" to GroceryCategory.BEVERAGES, + "cold brew" to GroceryCategory.BEVERAGES, "tea" to GroceryCategory.BEVERAGES, "beer" to GroceryCategory.BEVERAGES, "wine" to GroceryCategory.BEVERAGES, From 22b929015742a0f3244672d29087fb2b815775b3 Mon Sep 17 00:00:00 2001 From: Andrew Steinmetz Date: Sun, 19 Jul 2026 23:24:57 -0700 Subject: [PATCH 2/6] =?UTF-8?q?feat(grocery):=20name=E2=86=92aisle=20overr?= =?UTF-8?q?ide=20table=20+=20repository?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds persistent "always file under " rules so users can retag items the built-in IngredientParser sends to the wrong aisle (or to OTHER). A new GroceryCategoryOverride table + GroceryCategoryOverrideRepository back the rules, mirroring the GroceryAutocompleteItem sync scaffolding so a later Supabase sync is data-only; the feature is local-only for now. GroceryRepositoryImpl resolves each item's aisle as: per-item stored aisle → user name rule → parser default, combining the rule map into the grocery list flows so rule changes re-emit. Sign-in/sign-out clear the rules alongside the other local repositories. Co-Authored-By: Claude Opus 4.8 --- .../usecase/impl/DeleteAccountUseCaseImpl.kt | 5 + .../auth/usecase/impl/SignInUseCaseImpl.kt | 4 + .../auth/usecase/impl/SignOutUseCaseImpl.kt | 5 + .../impl/DeleteAccountUseCaseImplTest.kt | 26 ++++ .../usecase/impl/SignOutUseCaseImplTest.kt | 15 +++ .../client/database/di/DatabaseComponent.kt | 6 + .../plusmobileapps/chefmate/database/11.sqm | 15 +++ .../database/GroceryCategoryOverride.sq | 81 ++++++++++++ .../GroceryCategoryOverrideRepositoryImpl.kt | 87 +++++++++++++ .../data/impl/GroceryRepositoryImpl.kt | 42 ++++-- ...oceryCategoryOverrideRepositoryImplTest.kt | 123 ++++++++++++++++++ .../data/impl/GroceryRepositoryImplTest.kt | 70 ++++++++++ .../data/GroceryCategoryOverrideRepository.kt | 42 ++++++ .../FakeGroceryCategoryOverrideRepository.kt | 52 ++++++++ 14 files changed, 563 insertions(+), 10 deletions(-) create mode 100644 client/database/core/src/commonMain/sqldelight/com/plusmobileapps/chefmate/database/11.sqm create mode 100644 client/database/core/src/commonMain/sqldelight/com/plusmobileapps/chefmate/database/GroceryCategoryOverride.sq create mode 100644 client/grocery/data/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/data/impl/GroceryCategoryOverrideRepositoryImpl.kt create mode 100644 client/grocery/data/impl/src/commonTest/kotlin/com/plusmobileapps/chefmate/grocery/data/impl/GroceryCategoryOverrideRepositoryImplTest.kt create mode 100644 client/grocery/data/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/data/GroceryCategoryOverrideRepository.kt create mode 100644 client/grocery/data/testing/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/data/testing/FakeGroceryCategoryOverrideRepository.kt diff --git a/client/auth/usecase/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/auth/usecase/impl/DeleteAccountUseCaseImpl.kt b/client/auth/usecase/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/auth/usecase/impl/DeleteAccountUseCaseImpl.kt index ba4d205cc..a4a1c7642 100644 --- a/client/auth/usecase/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/auth/usecase/impl/DeleteAccountUseCaseImpl.kt +++ b/client/auth/usecase/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/auth/usecase/impl/DeleteAccountUseCaseImpl.kt @@ -4,6 +4,7 @@ import com.plusmobileapps.chefmate.auth.data.AuthenticationRepository import com.plusmobileapps.chefmate.auth.usecase.DeleteAccountUseCase import com.plusmobileapps.chefmate.auth.usecase.SignOutUseCase import com.plusmobileapps.chefmate.di.AppScope +import com.plusmobileapps.chefmate.grocery.data.GroceryCategoryOverrideRepository import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject import dev.zacsweers.metro.SingleIn @@ -14,6 +15,7 @@ import dev.zacsweers.metro.SingleIn class DeleteAccountUseCaseImpl( private val authenticationRepository: AuthenticationRepository, private val signOutUseCase: SignOutUseCase, + private val groceryCategoryOverrideRepository: GroceryCategoryOverrideRepository, ) : DeleteAccountUseCase { override suspend fun invoke(): Result { // Delete the remote account first. Only if that succeeds do we tear down the local session @@ -21,6 +23,9 @@ class DeleteAccountUseCaseImpl( val result = authenticationRepository.deleteAccount() if (result.isFailure) return result signOutUseCase() + // Sign-out deliberately preserves the device-local grocery category rules, but account + // deletion is explicit erasure — so wipe them here. + groceryCategoryOverrideRepository.clearLocalData() return Result.success(Unit) } } diff --git a/client/auth/usecase/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/auth/usecase/impl/SignInUseCaseImpl.kt b/client/auth/usecase/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/auth/usecase/impl/SignInUseCaseImpl.kt index 24fb8d461..763c9970e 100644 --- a/client/auth/usecase/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/auth/usecase/impl/SignInUseCaseImpl.kt +++ b/client/auth/usecase/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/auth/usecase/impl/SignInUseCaseImpl.kt @@ -49,6 +49,10 @@ class SignInUseCaseImpl( recipeRepository.clearLocalData() categoryRepository.clearLocalData() groceryAutocompleteRepository.clearLocalData() + // Grocery category rules are deliberately NOT cleared. The wipe above exists to avoid + // reconciling anon-owned rows against the new account's pull — but rules never sync, so + // there is no pull to reconcile and clearing would just destroy a guest's rules the + // moment they sign up. See SignOutUseCaseImpl for the full rationale. groceryRepository.clearLocalData() groceryRepository.ensureDefaultList() aiChatLocalDataCleaner.clearLocalData() diff --git a/client/auth/usecase/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/auth/usecase/impl/SignOutUseCaseImpl.kt b/client/auth/usecase/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/auth/usecase/impl/SignOutUseCaseImpl.kt index 20e23616d..6ad439d51 100644 --- a/client/auth/usecase/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/auth/usecase/impl/SignOutUseCaseImpl.kt +++ b/client/auth/usecase/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/auth/usecase/impl/SignOutUseCaseImpl.kt @@ -34,6 +34,11 @@ class SignOutUseCaseImpl( recipeBookRepository.clearLocalData() categoryRepository.clearLocalData() groceryAutocompleteRepository.clearLocalData() + // Grocery category rules are deliberately NOT cleared here. Every other repository above + // is server-backed, so wiping it locally is recoverable on the next sign-in. Category + // rules are device-local (no Supabase table yet), so clearing them would destroy the + // user's rules permanently. They're treated as a device preference that outlives the + // session; DeleteAccountUseCase still wipes them, since that's explicit erasure. groceryRepository.clearLocalData() groceryRepository.ensureDefaultList() aiChatLocalDataCleaner.clearLocalData() diff --git a/client/auth/usecase/impl/src/commonTest/kotlin/com/plusmobileapps/chefmate/auth/usecase/impl/DeleteAccountUseCaseImplTest.kt b/client/auth/usecase/impl/src/commonTest/kotlin/com/plusmobileapps/chefmate/auth/usecase/impl/DeleteAccountUseCaseImplTest.kt index 60838a439..7de644f25 100644 --- a/client/auth/usecase/impl/src/commonTest/kotlin/com/plusmobileapps/chefmate/auth/usecase/impl/DeleteAccountUseCaseImplTest.kt +++ b/client/auth/usecase/impl/src/commonTest/kotlin/com/plusmobileapps/chefmate/auth/usecase/impl/DeleteAccountUseCaseImplTest.kt @@ -4,8 +4,11 @@ package com.plusmobileapps.chefmate.auth.usecase.impl import com.plusmobileapps.chefmate.auth.data.testing.FakeAuthenticationRepository import com.plusmobileapps.chefmate.auth.usecase.SignOutUseCase +import com.plusmobileapps.chefmate.grocery.data.GroceryCategory +import com.plusmobileapps.chefmate.grocery.data.testing.FakeGroceryCategoryOverrideRepository import io.kotest.matchers.shouldBe import kotlin.test.Test +import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest class DeleteAccountUseCaseImplTest { @@ -13,11 +16,13 @@ class DeleteAccountUseCaseImplTest { private val authenticationRepository = FakeAuthenticationRepository() private var signedOut = false private val signOutUseCase = SignOutUseCase { signedOut = true } + private val groceryCategoryOverrideRepository = FakeGroceryCategoryOverrideRepository() private val useCase = DeleteAccountUseCaseImpl( authenticationRepository = authenticationRepository, signOutUseCase = signOutUseCase, + groceryCategoryOverrideRepository = groceryCategoryOverrideRepository, ) @Test @@ -38,4 +43,25 @@ class DeleteAccountUseCaseImplTest { result.isFailure shouldBe true signedOut shouldBe false } + + @Test + fun When_account_is_deleted_Then_grocery_category_rules_are_wiped() = runTest { + // Sign-out preserves the device-local rules, but deleting the account is explicit erasure. + groceryCategoryOverrideRepository.setOverride("Cold brew", GroceryCategory.BEVERAGES) + + useCase() + + groceryCategoryOverrideRepository.observeOverrides().first() shouldBe emptyList() + } + + @Test + fun When_remote_deletion_fails_Then_grocery_category_rules_are_preserved() = runTest { + groceryCategoryOverrideRepository.setOverride("Cold brew", GroceryCategory.BEVERAGES) + authenticationRepository.deleteAccountResult = Result.failure(RuntimeException("boom")) + + useCase() + + groceryCategoryOverrideRepository.observeOverrideMap().first() shouldBe + mapOf("cold brew" to GroceryCategory.BEVERAGES) + } } diff --git a/client/auth/usecase/impl/src/commonTest/kotlin/com/plusmobileapps/chefmate/auth/usecase/impl/SignOutUseCaseImplTest.kt b/client/auth/usecase/impl/src/commonTest/kotlin/com/plusmobileapps/chefmate/auth/usecase/impl/SignOutUseCaseImplTest.kt index af20590b4..9afa9e676 100644 --- a/client/auth/usecase/impl/src/commonTest/kotlin/com/plusmobileapps/chefmate/auth/usecase/impl/SignOutUseCaseImplTest.kt +++ b/client/auth/usecase/impl/src/commonTest/kotlin/com/plusmobileapps/chefmate/auth/usecase/impl/SignOutUseCaseImplTest.kt @@ -4,7 +4,9 @@ package com.plusmobileapps.chefmate.auth.usecase.impl import com.plusmobileapps.chefmate.aichat.AiChatLocalDataCleaner import com.plusmobileapps.chefmate.auth.data.testing.FakeAuthenticationRepository +import com.plusmobileapps.chefmate.grocery.data.GroceryCategory import com.plusmobileapps.chefmate.grocery.data.testing.FakeGroceryAutocompleteRepository +import com.plusmobileapps.chefmate.grocery.data.testing.FakeGroceryCategoryOverrideRepository import com.plusmobileapps.chefmate.grocery.data.testing.FakeGroceryRepository import com.plusmobileapps.chefmate.meal.data.testing.FakeMealPlanRepository import com.plusmobileapps.chefmate.recipe.data.Category @@ -35,6 +37,7 @@ class SignOutUseCaseImplTest { ) private val categoryRepository = FakeCategoryRepository(categories) private val groceryAutocompleteRepository = FakeGroceryAutocompleteRepository() + private val groceryCategoryOverrideRepository = FakeGroceryCategoryOverrideRepository() private var aiChatCleared = false private val aiChatLocalDataCleaner = AiChatLocalDataCleaner { aiChatCleared = true } @@ -70,4 +73,16 @@ class SignOutUseCaseImplTest { recipeBookRepository.getRecipeBooks().first() shouldBe emptyList() } + + @Test + fun When_signing_out_Then_grocery_category_rules_are_preserved() = runTest { + // Category rules are device-local (no backend table), so wiping them on sign-out would + // destroy them permanently rather than restoring them on the next sign-in. + groceryCategoryOverrideRepository.setOverride("Cold brew", GroceryCategory.BEVERAGES) + + useCase() + + groceryCategoryOverrideRepository.observeOverrideMap().first() shouldBe + mapOf("cold brew" to GroceryCategory.BEVERAGES) + } } diff --git a/client/database/core/src/commonMain/kotlin/com/plusmobileapps/chefmate/client/database/di/DatabaseComponent.kt b/client/database/core/src/commonMain/kotlin/com/plusmobileapps/chefmate/client/database/di/DatabaseComponent.kt index 92ca93208..d81ab7b87 100644 --- a/client/database/core/src/commonMain/kotlin/com/plusmobileapps/chefmate/client/database/di/DatabaseComponent.kt +++ b/client/database/core/src/commonMain/kotlin/com/plusmobileapps/chefmate/client/database/di/DatabaseComponent.kt @@ -8,6 +8,7 @@ import com.plusmobileapps.chefmate.database.CategoryQueries import com.plusmobileapps.chefmate.database.CookingSessionQueries import com.plusmobileapps.chefmate.database.Database import com.plusmobileapps.chefmate.database.GroceryAutocompleteItemQueries +import com.plusmobileapps.chefmate.database.GroceryCategoryOverrideQueries import com.plusmobileapps.chefmate.database.GroceryListMemberQueries import com.plusmobileapps.chefmate.database.GroceryListQueries import com.plusmobileapps.chefmate.database.GroceryQueries @@ -94,4 +95,9 @@ interface DatabaseComponent { @Provides fun providesGroceryAutocompleteItemQueries(database: Database): GroceryAutocompleteItemQueries = database.groceryAutocompleteItemQueries + + @SingleIn(AppScope::class) + @Provides + fun providesGroceryCategoryOverrideQueries(database: Database): GroceryCategoryOverrideQueries = + database.groceryCategoryOverrideQueries } diff --git a/client/database/core/src/commonMain/sqldelight/com/plusmobileapps/chefmate/database/11.sqm b/client/database/core/src/commonMain/sqldelight/com/plusmobileapps/chefmate/database/11.sqm new file mode 100644 index 000000000..655c831eb --- /dev/null +++ b/client/database/core/src/commonMain/sqldelight/com/plusmobileapps/chefmate/database/11.sqm @@ -0,0 +1,15 @@ +-- User-defined "always file under " rules (see GroceryCategoryOverride.sq). +-- Sync-ready columns (remoteId, clientId, isDirty, ownerId) are unused while the +-- feature is local-only; a follow-up wires Supabase sync without a migration. +CREATE TABLE GroceryCategoryOverride ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + categoryKey TEXT NOT NULL, + createdAt TEXT NOT NULL DEFAULT (datetime('now')), + remoteId TEXT UNIQUE, + clientId TEXT, + isDirty INTEGER NOT NULL DEFAULT 0, + ownerId TEXT +); + +CREATE UNIQUE INDEX idx_grocery_category_override_name ON GroceryCategoryOverride(name COLLATE NOCASE); diff --git a/client/database/core/src/commonMain/sqldelight/com/plusmobileapps/chefmate/database/GroceryCategoryOverride.sq b/client/database/core/src/commonMain/sqldelight/com/plusmobileapps/chefmate/database/GroceryCategoryOverride.sq new file mode 100644 index 000000000..104e587db --- /dev/null +++ b/client/database/core/src/commonMain/sqldelight/com/plusmobileapps/chefmate/database/GroceryCategoryOverride.sq @@ -0,0 +1,81 @@ +import kotlin.Boolean; + +-- User-defined "always file under " rules. When a grocery item's +-- name matches a rule, that rule's category wins over the IngredientParser guess +-- (but a per-item stored aisle on the Grocery row still wins over the rule). +-- +-- `categoryKey` stores a GroceryCategory enum name today. It is a free-form TEXT +-- column so a future "custom aisle" (Phase 2) can store its own key without a +-- schema migration. +-- +-- `remoteId`, `clientId`, `isDirty`, and `ownerId` mirror the GroceryAutocompleteItem +-- table's sync scaffolding. The feature is local-only for now; a follow-up wires +-- Supabase sync without another migration. +CREATE TABLE GroceryCategoryOverride ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + categoryKey TEXT NOT NULL, + createdAt TEXT NOT NULL DEFAULT (datetime('now')), + remoteId TEXT UNIQUE, + clientId TEXT, + isDirty INTEGER AS Boolean NOT NULL DEFAULT 0, + ownerId TEXT +); + +-- Case-insensitive uniqueness: one rule per name, so "Cold Brew" and "cold brew" +-- can't both hold conflicting rules. +CREATE UNIQUE INDEX idx_grocery_category_override_name ON GroceryCategoryOverride(name COLLATE NOCASE); + +getAll: +SELECT * FROM GroceryCategoryOverride ORDER BY name COLLATE NOCASE ASC; + +getById: +SELECT * FROM GroceryCategoryOverride WHERE id = ?; + +getByName: +SELECT * FROM GroceryCategoryOverride WHERE name = ? COLLATE NOCASE; + +getByRemoteId: +SELECT * FROM GroceryCategoryOverride WHERE remoteId = ?; + +getByClientId: +SELECT * FROM GroceryCategoryOverride WHERE clientId = ?; + +-- Upsert on the case-insensitive name so re-tagging an existing name updates its +-- aisle instead of failing the unique index. Marked dirty for a future sync push. +upsert: +INSERT INTO GroceryCategoryOverride (name, categoryKey, clientId, ownerId, isDirty) +VALUES (?, ?, ?, ?, 1) +ON CONFLICT(name COLLATE NOCASE) DO UPDATE SET + categoryKey = excluded.categoryKey, + isDirty = 1; + +createWithRemoteId: +INSERT INTO GroceryCategoryOverride (name, categoryKey, remoteId, clientId, ownerId) +VALUES (?, ?, ?, ?, ?) +ON CONFLICT(remoteId) DO UPDATE SET + name = excluded.name, + categoryKey = excluded.categoryKey, + clientId = excluded.clientId, + ownerId = excluded.ownerId; + +lastInsertId: +SELECT MAX(id) FROM GroceryCategoryOverride; + +getUnsynced: +SELECT * FROM GroceryCategoryOverride WHERE remoteId IS NULL OR isDirty = 1; + +updateRemoteId: +UPDATE GroceryCategoryOverride SET remoteId = ?, isDirty = 0 WHERE id = ?; + +updateClientId: +UPDATE GroceryCategoryOverride SET clientId = ? WHERE id = ?; + +deleteById: +DELETE FROM GroceryCategoryOverride WHERE id = ?; + +deleteByName: +DELETE FROM GroceryCategoryOverride WHERE name = ? COLLATE NOCASE; + +deleteAll: +DELETE FROM GroceryCategoryOverride; diff --git a/client/grocery/data/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/data/impl/GroceryCategoryOverrideRepositoryImpl.kt b/client/grocery/data/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/data/impl/GroceryCategoryOverrideRepositoryImpl.kt new file mode 100644 index 000000000..e0fd23064 --- /dev/null +++ b/client/grocery/data/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/data/impl/GroceryCategoryOverrideRepositoryImpl.kt @@ -0,0 +1,87 @@ +package com.plusmobileapps.chefmate.grocery.data.impl + +import app.cash.sqldelight.coroutines.asFlow +import com.plusmobileapps.chefmate.auth.data.AuthState +import com.plusmobileapps.chefmate.auth.data.AuthenticationRepository +import com.plusmobileapps.chefmate.database.GroceryCategoryOverride as DbOverride +import com.plusmobileapps.chefmate.database.GroceryCategoryOverrideQueries +import com.plusmobileapps.chefmate.di.AppScope +import com.plusmobileapps.chefmate.di.IO +import com.plusmobileapps.chefmate.grocery.data.GroceryCategory +import com.plusmobileapps.chefmate.grocery.data.GroceryCategoryOverride +import com.plusmobileapps.chefmate.grocery.data.GroceryCategoryOverrideRepository +import com.plusmobileapps.chefmate.util.Unique +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext + +/** + * Local-only store of name→aisle rules. The `remoteId`/`clientId`/`isDirty`/`ownerId` columns are + * populated so a future Supabase sync is a data-only follow-up, but no remote data source is wired + * yet — writes stay on-device. + */ +@SingleIn(AppScope::class) +@Inject +@ContributesBinding(AppScope::class) +class GroceryCategoryOverrideRepositoryImpl( + private val db: GroceryCategoryOverrideQueries, + @IO private val ioContext: CoroutineContext, + private val unique: Unique, + private val authRepository: AuthenticationRepository, +) : GroceryCategoryOverrideRepository { + + override fun observeOverrides(): Flow> = + db.getAll() + .asFlow() + .map { query -> query.executeAsList().mapNotNull { it.toModel() } } + .flowOn(ioContext) + + override fun observeOverrideMap(): Flow> = + observeOverrides().map { overrides -> + overrides.associate { it.name.lowercase() to it.category } + } + + override suspend fun setOverride(name: String, category: GroceryCategory) { + val trimmed = name.trim() + if (trimmed.isBlank()) return + val ownerId = authRepository.state.value.userIdOrNull() + val clientId = unique.generate() + withContext(ioContext) { + db.upsert( + name = trimmed, + categoryKey = category.name, + clientId = clientId, + ownerId = ownerId, + ) + } + } + + override suspend fun removeOverride(id: Long) { + withContext(ioContext) { db.deleteById(id) } + } + + override suspend fun removeOverrideByName(name: String) { + val trimmed = name.trim() + if (trimmed.isBlank()) return + withContext(ioContext) { db.deleteByName(trimmed) } + } + + override suspend fun clearLocalData() { + withContext(ioContext) { db.deleteAll() } + } + + private fun AuthState.userIdOrNull(): String? = (this as? AuthState.Authenticated)?.user?.userId + + // Rows with an unrecognized categoryKey (e.g. a Phase 2 custom aisle synced from another + // device that this build doesn't know) are dropped rather than crashing. + private fun DbOverride.toModel(): GroceryCategoryOverride? { + val category = + runCatching { GroceryCategory.valueOf(categoryKey) }.getOrNull() ?: return null + return GroceryCategoryOverride(id = id, name = name, category = category) + } +} diff --git a/client/grocery/data/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/data/impl/GroceryRepositoryImpl.kt b/client/grocery/data/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/data/impl/GroceryRepositoryImpl.kt index 83c082b3a..43e3a07ac 100644 --- a/client/grocery/data/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/data/impl/GroceryRepositoryImpl.kt +++ b/client/grocery/data/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/data/impl/GroceryRepositoryImpl.kt @@ -13,6 +13,7 @@ import com.plusmobileapps.chefmate.di.AppScope import com.plusmobileapps.chefmate.di.IO import com.plusmobileapps.chefmate.grocery.data.CollaborationStatus import com.plusmobileapps.chefmate.grocery.data.GroceryCategory +import com.plusmobileapps.chefmate.grocery.data.GroceryCategoryOverrideRepository import com.plusmobileapps.chefmate.grocery.data.GroceryItem import com.plusmobileapps.chefmate.grocery.data.GroceryListInvite import com.plusmobileapps.chefmate.grocery.data.GroceryListModel @@ -67,12 +68,18 @@ class GroceryRepositoryImpl( private val dateTimeUtil: DateTimeUtil, private val remoteDataSource: GroceryRemoteDataSource, private val authRepository: AuthenticationRepository, + private val categoryOverrideRepository: GroceryCategoryOverrideRepository, ) : GroceryRepository { private val scope = CoroutineScope(ioContext + SupervisorJob()) private val syncMutex = Mutex() private val syncingIds = MutableStateFlow>(emptySet()) + // Latest name→aisle rules, cached so the synchronous [getGrocery] read can apply them without + // re-querying. The [getGroceries] flows below re-read this via [combine] so rule changes + // re-emit. + private val categoryOverrides = MutableStateFlow>(emptyMap()) + private var realtimeJob: Job? = null private var realtimeUserId: String? = null @@ -87,6 +94,9 @@ class GroceryRepositoryImpl( } } } + scope.launch { + categoryOverrideRepository.observeOverrideMap().collect { categoryOverrides.value = it } + } } /** @@ -123,17 +133,22 @@ class GroceryRepositoryImpl( } override fun getGroceries(): Flow> = - combine(queries.readAll().asFlow().map { it.executeAsList() }, syncingIds) { items, syncing - -> - items.map { fromEntity(it, syncing) } + combine( + queries.readAll().asFlow().map { it.executeAsList() }, + syncingIds, + categoryOverrides, + ) { items, syncing, overrides -> + items.map { fromEntity(it, syncing, overrides) } } .flowOn(ioContext) override fun getGroceries(listId: Long): Flow> = - combine(queries.readByListId(listId).asFlow().map { it.executeAsList() }, syncingIds) { - items, - syncing -> - items.map { fromEntity(it, syncing) } + combine( + queries.readByListId(listId).asFlow().map { it.executeAsList() }, + syncingIds, + categoryOverrides, + ) { items, syncing, overrides -> + items.map { fromEntity(it, syncing, overrides) } } .flowOn(ioContext) @@ -291,7 +306,7 @@ class GroceryRepositoryImpl( override suspend fun getGrocery(id: Long): GroceryItem? = withContext(ioContext) { queries.getGroceryById(id).executeAsOneOrNull()?.let { - fromEntity(it, syncingIds.value) + fromEntity(it, syncingIds.value, categoryOverrides.value) } } @@ -1028,7 +1043,11 @@ class GroceryRepositoryImpl( else -> ListRole.OWNER } - private fun fromEntity(entity: Grocery, syncing: Set): GroceryItem { + private fun fromEntity( + entity: Grocery, + syncing: Set, + overrides: Map, + ): GroceryItem { val syncStatus = when { entity.id in syncing -> SyncStatus.SYNCING @@ -1039,12 +1058,15 @@ class GroceryRepositoryImpl( val parsed = IngredientParser.parse(entity.name) val storedAisle = entity.aisle?.let { runCatching { GroceryCategory.valueOf(it) }.getOrNull() } + // Resolution order: a per-item stored aisle wins, then the user's name→aisle rule, then the + // parser's guess. + val category = storedAisle ?: overrides[parsed.name.lowercase()] ?: parsed.category return GroceryItem( id = entity.id, name = entity.name, displayName = parsed.name, quantity = parsed.quantity, - category = storedAisle ?: parsed.category, + category = category, isChecked = entity.isChecked, syncStatus = syncStatus, recipeName = entity.recipeName, diff --git a/client/grocery/data/impl/src/commonTest/kotlin/com/plusmobileapps/chefmate/grocery/data/impl/GroceryCategoryOverrideRepositoryImplTest.kt b/client/grocery/data/impl/src/commonTest/kotlin/com/plusmobileapps/chefmate/grocery/data/impl/GroceryCategoryOverrideRepositoryImplTest.kt new file mode 100644 index 000000000..c26285219 --- /dev/null +++ b/client/grocery/data/impl/src/commonTest/kotlin/com/plusmobileapps/chefmate/grocery/data/impl/GroceryCategoryOverrideRepositoryImplTest.kt @@ -0,0 +1,123 @@ +@file:Suppress("FunctionName") +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.plusmobileapps.chefmate.grocery.data.impl + +import app.cash.turbine.test +import com.plusmobileapps.chefmate.auth.data.testing.FakeAuthenticationRepository +import com.plusmobileapps.chefmate.database.Database +import com.plusmobileapps.chefmate.database.testing.createTestDatabase +import com.plusmobileapps.chefmate.grocery.data.GroceryCategory +import com.plusmobileapps.chefmate.util.testing.FakeUnique +import io.kotest.matchers.shouldBe +import kotlin.test.Test +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest + +class GroceryCategoryOverrideRepositoryImplTest { + + private val testDispatcher = UnconfinedTestDispatcher() + private val db: Database = createTestDatabase() + private val fakeAuth = FakeAuthenticationRepository() + + private val repository = + GroceryCategoryOverrideRepositoryImpl( + db = db.groceryCategoryOverrideQueries, + ioContext = testDispatcher, + unique = FakeUnique(), + authRepository = fakeAuth, + ) + + @Test + fun setOverride_inserts_a_rule_surfaced_by_observeOverrides() = + runTest(testDispatcher) { + repository.setOverride("Cold Brew", GroceryCategory.BEVERAGES) + + repository.observeOverrides().test { + val rules = awaitItem() + rules.size shouldBe 1 + rules.first().name shouldBe "Cold Brew" + rules.first().category shouldBe GroceryCategory.BEVERAGES + } + } + + @Test + fun setOverride_trims_and_ignores_blanks() = + runTest(testDispatcher) { + repository.setOverride(" ", GroceryCategory.PRODUCE) + + repository.observeOverrides().test { awaitItem() shouldBe emptyList() } + } + + @Test + fun setOverride_updates_the_aisle_for_an_existing_name_case_insensitively() = + runTest(testDispatcher) { + repository.setOverride("Cold Brew", GroceryCategory.BEVERAGES) + repository.setOverride("cold brew", GroceryCategory.SNACKS) + + repository.observeOverrides().test { + val rules = awaitItem() + rules.size shouldBe 1 + rules.first().category shouldBe GroceryCategory.SNACKS + } + } + + @Test + fun observeOverrideMap_keys_are_lowercased_names() = + runTest(testDispatcher) { + repository.setOverride("Cold Brew", GroceryCategory.BEVERAGES) + + repository.observeOverrideMap().test { + awaitItem() shouldBe mapOf("cold brew" to GroceryCategory.BEVERAGES) + } + } + + @Test + fun removeOverride_removes_it_from_observed_list() = + runTest(testDispatcher) { + repository.setOverride("Cold Brew", GroceryCategory.BEVERAGES) + val id = repository.observeOverrides().first().first().id + + repository.removeOverride(id) + + repository.observeOverrides().test { awaitItem() shouldBe emptyList() } + } + + @Test + fun removeOverrideByName_removes_matching_rule_case_insensitively() = + runTest(testDispatcher) { + repository.setOverride("Cold Brew", GroceryCategory.BEVERAGES) + + repository.removeOverrideByName("cold brew") + + repository.observeOverrides().test { awaitItem() shouldBe emptyList() } + } + + @Test + fun clearLocalData_removes_everything() = + runTest(testDispatcher) { + repository.setOverride("Cold Brew", GroceryCategory.BEVERAGES) + repository.setOverride("Seltzer", GroceryCategory.BEVERAGES) + + repository.clearLocalData() + + repository.observeOverrides().test { awaitItem() shouldBe emptyList() } + } + + @Test + fun unrecognized_categoryKey_rows_are_dropped() = + runTest(testDispatcher) { + // Simulate a Phase 2 custom-aisle row synced from a newer client this build can't + // decode. + db.groceryCategoryOverrideQueries.upsert( + name = "Firewood", + categoryKey = "custom:some-uuid", + clientId = "client-1", + ownerId = null, + ) + + repository.observeOverrides().test { awaitItem() shouldBe emptyList() } + } +} diff --git a/client/grocery/data/impl/src/commonTest/kotlin/com/plusmobileapps/chefmate/grocery/data/impl/GroceryRepositoryImplTest.kt b/client/grocery/data/impl/src/commonTest/kotlin/com/plusmobileapps/chefmate/grocery/data/impl/GroceryRepositoryImplTest.kt index df67d2a9c..3bd88d70c 100644 --- a/client/grocery/data/impl/src/commonTest/kotlin/com/plusmobileapps/chefmate/grocery/data/impl/GroceryRepositoryImplTest.kt +++ b/client/grocery/data/impl/src/commonTest/kotlin/com/plusmobileapps/chefmate/grocery/data/impl/GroceryRepositoryImplTest.kt @@ -14,6 +14,7 @@ import com.plusmobileapps.chefmate.grocery.data.GroceryCategory import com.plusmobileapps.chefmate.grocery.data.remote.RemoteGroceryItem import com.plusmobileapps.chefmate.grocery.data.remote.RemoteGroceryList import com.plusmobileapps.chefmate.grocery.data.remote.RemoteGroceryListMember +import com.plusmobileapps.chefmate.grocery.data.testing.FakeGroceryCategoryOverrideRepository import com.plusmobileapps.chefmate.grocery.data.testing.FakeGroceryRemoteDataSource import com.plusmobileapps.chefmate.util.testing.FakeDateTimeUtil import io.kotest.matchers.shouldBe @@ -33,6 +34,7 @@ class GroceryRepositoryImplTest { private val fakeAuth = FakeAuthenticationRepository() private val fakeRemote = FakeGroceryRemoteDataSource() private val dateTimeUtil = FakeDateTimeUtil() + private val fakeOverrides = FakeGroceryCategoryOverrideRepository() private val repository = GroceryRepositoryImpl( @@ -43,6 +45,7 @@ class GroceryRepositoryImplTest { dateTimeUtil = dateTimeUtil, remoteDataSource = fakeRemote, authRepository = fakeAuth, + categoryOverrideRepository = fakeOverrides, ) @Test @@ -1011,4 +1014,71 @@ class GroceryRepositoryImplTest { val remote = fakeRemote.remoteItems.values.flatten().first { it.name == "Milk" } remote.isChecked shouldBe true } + + @Test + fun categoryOverride_wins_over_parser_default() = + runTest(testDispatcher) { + // "Paper Towels" isn't in the IngredientParser vocabulary, so it defaults to OTHER. + repository.addGrocery("Paper Towels") + repository.getGroceries().first().first { it.name == "Paper Towels" }.category shouldBe + GroceryCategory.OTHER + + fakeOverrides.setOverride("Paper Towels", GroceryCategory.SNACKS) + advanceUntilIdle() + + repository.getGroceries().test { + awaitItem().first { it.name == "Paper Towels" }.category shouldBe + GroceryCategory.SNACKS + } + } + + @Test + fun categoryOverride_matches_parsed_name_case_insensitively() = + runTest(testDispatcher) { + // The stored name carries a quantity; the rule is keyed off the parsed name ("cold + // brew"). + repository.addGrocery("2 cans Cold Brew") + fakeOverrides.setOverride("cold brew", GroceryCategory.BEVERAGES) + advanceUntilIdle() + + repository.getGroceries().test { + awaitItem().first { it.name == "2 cans Cold Brew" }.category shouldBe + GroceryCategory.BEVERAGES + } + } + + @Test + fun per_item_stored_aisle_wins_over_category_override() = + runTest(testDispatcher) { + repository.addGrocery("Paper Towels") + val item = repository.getGroceries().first().first { it.name == "Paper Towels" } + // A per-item aisle set on this row (e.g. via the detail screen) pins it to + // CANNED_GOODS. + repository.updateGrocery(item.copy(category = GroceryCategory.CANNED_GOODS)) + // A conflicting name rule should NOT override the explicit per-item choice. + fakeOverrides.setOverride("Paper Towels", GroceryCategory.SNACKS) + advanceUntilIdle() + + repository.getGroceries().test { + awaitItem().first { it.name == "Paper Towels" }.category shouldBe + GroceryCategory.CANNED_GOODS + } + } + + @Test + fun removing_a_category_override_reverts_to_parser_default() = + runTest(testDispatcher) { + repository.addGrocery("Paper Towels") + fakeOverrides.setOverride("Paper Towels", GroceryCategory.SNACKS) + advanceUntilIdle() + val id = fakeOverrides.observeOverrides().first().first().id + + fakeOverrides.removeOverride(id) + advanceUntilIdle() + + repository.getGroceries().test { + awaitItem().first { it.name == "Paper Towels" }.category shouldBe + GroceryCategory.OTHER + } + } } diff --git a/client/grocery/data/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/data/GroceryCategoryOverrideRepository.kt b/client/grocery/data/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/data/GroceryCategoryOverrideRepository.kt new file mode 100644 index 000000000..971ca3481 --- /dev/null +++ b/client/grocery/data/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/data/GroceryCategoryOverrideRepository.kt @@ -0,0 +1,42 @@ +package com.plusmobileapps.chefmate.grocery.data + +import kotlinx.coroutines.flow.Flow + +/** A single user-defined "always file [name] under [category]" rule. */ +data class GroceryCategoryOverride(val id: Long, val name: String, val category: GroceryCategory) + +/** + * Manages the user's persistent name→aisle rules. When a grocery item's parsed name matches a rule, + * the rule's [GroceryCategory] overrides the built-in [IngredientParser] guess (a per-item stored + * aisle on the grocery row still wins over the rule). Rules are created inline from the grocery + * item detail screen and managed from the grocery category rules settings screen. + */ +interface GroceryCategoryOverrideRepository { + /** Emits the saved rules, sorted case-insensitively by name. */ + fun observeOverrides(): Flow> + + /** + * Emits the rules as a lookup map keyed by the lowercased name, for fast categorization while + * mapping stored grocery rows. + */ + fun observeOverrideMap(): Flow> + + /** + * Adds or updates the rule for [name] → [category]. Trimmed; blanks are ignored. Matching an + * existing name (case-insensitively) updates its aisle. + */ + suspend fun setOverride(name: String, category: GroceryCategory) + + suspend fun removeOverride(id: Long) + + /** Removes any rule matching [name] (case-insensitive). No-op if none exists. */ + suspend fun removeOverrideByName(name: String) + + /** + * Wipes every rule. Unlike the other grocery repositories, this is **not** called on sign-in or + * sign-out: rules are device-local (there is no backend table yet), so clearing them on a + * routine session change would destroy them permanently instead of restoring them on the next + * sync. Only explicit erasure — `DeleteAccountUseCase` — calls this. + */ + suspend fun clearLocalData() +} diff --git a/client/grocery/data/testing/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/data/testing/FakeGroceryCategoryOverrideRepository.kt b/client/grocery/data/testing/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/data/testing/FakeGroceryCategoryOverrideRepository.kt new file mode 100644 index 000000000..39822c6f0 --- /dev/null +++ b/client/grocery/data/testing/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/data/testing/FakeGroceryCategoryOverrideRepository.kt @@ -0,0 +1,52 @@ +package com.plusmobileapps.chefmate.grocery.data.testing + +import com.plusmobileapps.chefmate.grocery.data.GroceryCategory +import com.plusmobileapps.chefmate.grocery.data.GroceryCategoryOverride +import com.plusmobileapps.chefmate.grocery.data.GroceryCategoryOverrideRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map + +class FakeGroceryCategoryOverrideRepository( + private val overrides: MutableStateFlow> = + MutableStateFlow(emptyList()) +) : GroceryCategoryOverrideRepository { + + // Tests are single-threaded; non-atomic counter is fine. + private var nextId: Long = 1L + + override fun observeOverrides(): Flow> = overrides + + override fun observeOverrideMap(): Flow> = overrides.map { list -> + list.associate { it.name.lowercase() to it.category } + } + + override suspend fun setOverride(name: String, category: GroceryCategory) { + val trimmed = name.trim() + if (trimmed.isBlank()) return + val existing = overrides.value.firstOrNull { it.name.equals(trimmed, ignoreCase = true) } + overrides.value = + if (existing != null) { + overrides.value.map { + if (it.id == existing.id) it.copy(category = category) else it + } + } else { + overrides.value + + GroceryCategoryOverride(id = nextId++, name = trimmed, category = category) + } + .sortedBy { it.name.lowercase() } + } + + override suspend fun removeOverride(id: Long) { + overrides.value = overrides.value.filterNot { it.id == id } + } + + override suspend fun removeOverrideByName(name: String) { + val trimmed = name.trim() + overrides.value = overrides.value.filterNot { it.name.equals(trimmed, ignoreCase = true) } + } + + override suspend fun clearLocalData() { + overrides.value = emptyList() + } +} From c0b48501f080bf9e84a1b6b208cdeaedd24a277e Mon Sep 17 00:00:00 2001 From: Andrew Steinmetz Date: Sun, 19 Jul 2026 23:29:59 -0700 Subject: [PATCH 3/6] feat(grocery): "always file here" hook on item detail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a checkbox under the item detail aisle picker: "Always file in ". Toggling it on writes a persistent name→aisle rule (and keeps it synced if the aisle is then changed); toggling off removes it. The box initializes checked when a rule for this item's name already points at the shown aisle. Co-Authored-By: Claude Opus 4.8 --- .../core/impl/detail/GroceryDetailBlocImpl.kt | 9 ++- .../impl/detail/GroceryDetailViewModel.kt | 39 ++++++++-- .../impl/detail/ui/GroceryDetailPreviews.kt | 4 ++ .../core/impl/detail/GroceryDetailBlocTest.kt | 72 +++++++++++++++++++ .../composeResources/values/strings.xml | 1 + .../grocery/core/detail/GroceryDetailBloc.kt | 12 +++- .../core/detail/GroceryDetailScreen.kt | 34 ++++++++- .../core/detail/GroceryDetailTestTags.kt | 1 + 8 files changed, 164 insertions(+), 8 deletions(-) diff --git a/client/grocery/core/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/core/impl/detail/GroceryDetailBlocImpl.kt b/client/grocery/core/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/core/impl/detail/GroceryDetailBlocImpl.kt index 38b3083dc..13b6243db 100644 --- a/client/grocery/core/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/core/impl/detail/GroceryDetailBlocImpl.kt +++ b/client/grocery/core/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/core/impl/detail/GroceryDetailBlocImpl.kt @@ -35,7 +35,10 @@ class GroceryDetailBlocImpl( if (it.isLoading) { GroceryDetailBloc.Model.Loading } else { - GroceryDetailBloc.Model.Loaded(it.groceryItem) + GroceryDetailBloc.Model.Loaded( + item = it.groceryItem, + alwaysFileHere = it.alwaysFileHere, + ) } } @@ -65,6 +68,10 @@ class GroceryDetailBlocImpl( viewModel.onAisleChanged(category) } + override fun onAlwaysFileHereToggled(enabled: Boolean) { + viewModel.onAlwaysFileHereToggled(enabled) + } + override fun onSaveClicked() { viewModel.save() } diff --git a/client/grocery/core/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/core/impl/detail/GroceryDetailViewModel.kt b/client/grocery/core/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/core/impl/detail/GroceryDetailViewModel.kt index 77aacfa30..a928237bd 100644 --- a/client/grocery/core/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/core/impl/detail/GroceryDetailViewModel.kt +++ b/client/grocery/core/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/core/impl/detail/GroceryDetailViewModel.kt @@ -3,6 +3,7 @@ package com.plusmobileapps.chefmate.grocery.core.impl.detail import com.plusmobileapps.chefmate.ViewModel import com.plusmobileapps.chefmate.di.Main import com.plusmobileapps.chefmate.grocery.data.GroceryCategory +import com.plusmobileapps.chefmate.grocery.data.GroceryCategoryOverrideRepository import com.plusmobileapps.chefmate.grocery.data.GroceryItem import com.plusmobileapps.chefmate.grocery.data.GroceryRepository import dev.zacsweers.metro.Assisted @@ -14,6 +15,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch @@ -22,6 +24,7 @@ class GroceryDetailViewModel( @Assisted id: Long, @Main mainContext: CoroutineContext, private val repository: GroceryRepository, + private val categoryOverrideRepository: GroceryCategoryOverrideRepository, ) : ViewModel(mainContext) { private val _state = MutableStateFlow(State()) private val output = Channel(Channel.BUFFERED) @@ -57,8 +60,27 @@ class GroceryDetailViewModel( } fun onAisleChanged(category: GroceryCategory) { - _state.value = - _state.value.copy(groceryItem = _state.value.groceryItem.copy(category = category)) + val current = _state.value + _state.value = current.copy(groceryItem = current.groceryItem.copy(category = category)) + // Keep an active rule pointing at whatever aisle is now selected. + if (current.alwaysFileHere) { + val name = current.groceryItem.displayName + scope.launch { categoryOverrideRepository.setOverride(name, category) } + } + } + + fun onAlwaysFileHereToggled(enabled: Boolean) { + val current = _state.value + val name = current.groceryItem.displayName + if (name.isBlank()) return + _state.value = current.copy(alwaysFileHere = enabled) + scope.launch { + if (enabled) { + categoryOverrideRepository.setOverride(name, current.groceryItem.category) + } else { + categoryOverrideRepository.removeOverrideByName(name) + } + } } fun save() { @@ -79,14 +101,23 @@ class GroceryDetailViewModel( private fun loadGrocery(id: Long) { scope.launch { - val grocery = repository.getGrocery(id) - _state.value = State(isLoading = false, groceryItem = grocery ?: GroceryItem.empty) + val grocery = repository.getGrocery(id) ?: GroceryItem.empty + val overrides = categoryOverrideRepository.observeOverrideMap().first() + // The checkbox is on only when a rule for this name points at the aisle now shown. + val alwaysFileHere = overrides[grocery.displayName.lowercase()] == grocery.category + _state.value = + State( + isLoading = false, + groceryItem = grocery, + alwaysFileHere = alwaysFileHere, + ) } } data class State( val isLoading: Boolean = true, val groceryItem: GroceryItem = GroceryItem.empty, + val alwaysFileHere: Boolean = false, ) sealed class Output { diff --git a/client/grocery/core/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/core/impl/detail/ui/GroceryDetailPreviews.kt b/client/grocery/core/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/core/impl/detail/ui/GroceryDetailPreviews.kt index 3e6fb3661..86bcaa37e 100644 --- a/client/grocery/core/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/core/impl/detail/ui/GroceryDetailPreviews.kt +++ b/client/grocery/core/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/core/impl/detail/ui/GroceryDetailPreviews.kt @@ -23,6 +23,8 @@ private fun groceryDetailBloc(item: GroceryItem): GroceryDetailBloc = override fun onAisleChanged(category: GroceryCategory) = Unit + override fun onAlwaysFileHereToggled(enabled: Boolean) = Unit + override fun onSaveClicked() = Unit override fun onBackClicked() = Unit @@ -68,6 +70,8 @@ val previewGroceryDetailBlocLoading: GroceryDetailBloc = override fun onAisleChanged(category: GroceryCategory) = Unit + override fun onAlwaysFileHereToggled(enabled: Boolean) = Unit + override fun onSaveClicked() = Unit override fun onBackClicked() = Unit diff --git a/client/grocery/core/impl/src/commonTest/kotlin/com/plusmobileapps/chefmate/grocery/core/impl/detail/GroceryDetailBlocTest.kt b/client/grocery/core/impl/src/commonTest/kotlin/com/plusmobileapps/chefmate/grocery/core/impl/detail/GroceryDetailBlocTest.kt index ebf6352fe..688db1b0d 100644 --- a/client/grocery/core/impl/src/commonTest/kotlin/com/plusmobileapps/chefmate/grocery/core/impl/detail/GroceryDetailBlocTest.kt +++ b/client/grocery/core/impl/src/commonTest/kotlin/com/plusmobileapps/chefmate/grocery/core/impl/detail/GroceryDetailBlocTest.kt @@ -7,6 +7,7 @@ import com.plusmobileapps.chefmate.grocery.core.detail.GroceryDetailBloc import com.plusmobileapps.chefmate.grocery.data.GroceryCategory import com.plusmobileapps.chefmate.grocery.data.GroceryItem import com.plusmobileapps.chefmate.grocery.data.GroceryRepository +import com.plusmobileapps.chefmate.grocery.data.testing.FakeGroceryCategoryOverrideRepository import com.plusmobileapps.chefmate.testing.TestBlocContext import com.plusmobileapps.chefmate.testing.TestConsumer import dev.mokkery.answering.returns @@ -17,12 +18,14 @@ import dev.mokkery.mock import dev.mokkery.verifySuspend import io.kotest.matchers.shouldBe import kotlin.test.Test +import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest class GroceryDetailBlocTest { val context = TestBlocContext.create() val groceryItem = GroceryItem(id = 1L, name = "Milk", isChecked = false) val repository: GroceryRepository = mock { everySuspend { getGrocery(1L) } returns groceryItem } + val overrideRepository = FakeGroceryCategoryOverrideRepository() val testConsumer = TestConsumer() val bloc = @@ -35,6 +38,7 @@ class GroceryDetailBlocTest { id = id, mainContext = context.mainContext, repository = repository, + categoryOverrideRepository = overrideRepository, ) }, ) @@ -163,4 +167,72 @@ class GroceryDetailBlocTest { testConsumer.lastValue shouldBe GroceryDetailBloc.Output.Finished } } + + @Test + fun WHEN_always_file_here_toggled_on_THEN_persist_rule_and_check_the_box() { + runTest { + bloc.onAisleChanged(GroceryCategory.DAIRY) + bloc.onAlwaysFileHereToggled(true) + + overrideRepository.observeOverrideMap().first() shouldBe + mapOf("milk" to GroceryCategory.DAIRY) + bloc.models.test { + (awaitItem() as GroceryDetailBloc.Model.Loaded).alwaysFileHere shouldBe true + } + } + } + + @Test + fun WHEN_always_file_here_toggled_off_THEN_remove_the_rule() { + runTest { + bloc.onAlwaysFileHereToggled(true) + bloc.onAlwaysFileHereToggled(false) + + overrideRepository.observeOverrideMap().first() shouldBe emptyMap() + bloc.models.test { + (awaitItem() as GroceryDetailBloc.Model.Loaded).alwaysFileHere shouldBe false + } + } + } + + @Test + fun GIVEN_rule_active_WHEN_aisle_changed_THEN_rule_follows_the_new_aisle() { + runTest { + bloc.onAlwaysFileHereToggled(true) + bloc.onAisleChanged(GroceryCategory.BEVERAGES) + + overrideRepository.observeOverrideMap().first() shouldBe + mapOf("milk" to GroceryCategory.BEVERAGES) + } + } + + @Test + fun GIVEN_existing_rule_for_name_WHEN_loaded_THEN_box_is_checked() { + runTest { + val itemInOverriddenAisle = groceryItem.copy(category = GroceryCategory.BEVERAGES) + val repo: GroceryRepository = mock { + everySuspend { getGrocery(1L) } returns itemInOverriddenAisle + } + val overrides = FakeGroceryCategoryOverrideRepository() + overrides.setOverride("Milk", GroceryCategory.BEVERAGES) + val blocWithRule = + GroceryDetailBlocImpl( + context = TestBlocContext.create(), + id = 1L, + output = TestConsumer(), + viewModelFactory = { id -> + GroceryDetailViewModel( + id = id, + mainContext = context.mainContext, + repository = repo, + categoryOverrideRepository = overrides, + ) + }, + ) + + blocWithRule.models.test { + (awaitItem() as GroceryDetailBloc.Model.Loaded).alwaysFileHere shouldBe true + } + } + } } diff --git a/client/grocery/core/public/src/commonMain/composeResources/values/strings.xml b/client/grocery/core/public/src/commonMain/composeResources/values/strings.xml index fea91f2b0..aca0e9282 100644 --- a/client/grocery/core/public/src/commonMain/composeResources/values/strings.xml +++ b/client/grocery/core/public/src/commonMain/composeResources/values/strings.xml @@ -4,6 +4,7 @@ Name Quantity Aisle + Always file {name} in {aisle} Purchased Checked Not Checked diff --git a/client/grocery/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/core/detail/GroceryDetailBloc.kt b/client/grocery/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/core/detail/GroceryDetailBloc.kt index f234e302e..af962690b 100644 --- a/client/grocery/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/core/detail/GroceryDetailBloc.kt +++ b/client/grocery/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/core/detail/GroceryDetailBloc.kt @@ -26,12 +26,22 @@ interface GroceryDetailBloc : BackClickBloc, ComposeScreen { fun onAisleChanged(category: GroceryCategory) + /** + * Toggles a persistent "always file this item's name under the selected aisle" rule. When on, + * every future item with the same name lands in this aisle by default. + */ + fun onAlwaysFileHereToggled(enabled: Boolean) + fun onSaveClicked() sealed class Model { object Loading : Model() - data class Loaded(val item: GroceryItem) : Model() + /** + * @param alwaysFileHere whether a name→aisle rule for this item's name currently points at + * the selected aisle (drives the "always file here" checkbox). + */ + data class Loaded(val item: GroceryItem, val alwaysFileHere: Boolean = false) : Model() } sealed class Output { diff --git a/client/grocery/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/core/detail/GroceryDetailScreen.kt b/client/grocery/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/core/detail/GroceryDetailScreen.kt index 856d2b14b..2e00e8f07 100644 --- a/client/grocery/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/core/detail/GroceryDetailScreen.kt +++ b/client/grocery/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/core/detail/GroceryDetailScreen.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import chefmate.client.grocery.core.public.generated.resources.Res import chefmate.client.grocery.core.public.generated.resources.grocery_detail_aisle_label +import chefmate.client.grocery.core.public.generated.resources.grocery_detail_always_file_here import chefmate.client.grocery.core.public.generated.resources.grocery_detail_name_label import chefmate.client.grocery.core.public.generated.resources.grocery_detail_quantity_label import chefmate.client.grocery.core.public.generated.resources.grocery_recipe_source @@ -75,7 +76,11 @@ fun GroceryDetailSheetContent(bloc: GroceryDetailBloc, modifier: Modifier = Modi PlusLoadingIndicator() } is GroceryDetailBloc.Model.Loaded -> { - GroceryDetailFields(item = model.item, bloc = bloc) + GroceryDetailFields( + item = model.item, + alwaysFileHere = model.alwaysFileHere, + bloc = bloc, + ) Button(onClick = bloc::onSaveClicked, modifier = Modifier.fillMaxWidth()) { Text(stringResource(CommonRes.string.save)) } @@ -85,7 +90,11 @@ fun GroceryDetailSheetContent(bloc: GroceryDetailBloc, modifier: Modifier = Modi } @Composable -private fun GroceryDetailFields(item: GroceryItem, bloc: GroceryDetailBloc) { +private fun GroceryDetailFields( + item: GroceryItem, + alwaysFileHere: Boolean, + bloc: GroceryDetailBloc, +) { Column( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(ChefMateTheme.dimens.paddingSmall), @@ -106,6 +115,27 @@ private fun GroceryDetailFields(item: GroceryItem, bloc: GroceryDetailBloc) { ) } AisleDropdown(selected = item.category, onSelected = bloc::onAisleChanged) + if (item.displayName.isNotBlank()) { + Row( + modifier = + Modifier.fillMaxWidth().testTag(GroceryDetailTestTags.ALWAYS_FILE_HERE).clickable { + bloc.onAlwaysFileHereToggled(!alwaysFileHere) + }, + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox(checked = alwaysFileHere, onCheckedChange = bloc::onAlwaysFileHereToggled) + Text( + text = + PhraseModel( + Res.string.grocery_detail_always_file_here, + "name" to FixedString(item.displayName), + "aisle" to item.category.displayName(), + ) + .localized(), + style = MaterialTheme.typography.bodyMedium, + ) + } + } val recipeName = item.recipeName if (recipeName != null) { Text( diff --git a/client/grocery/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/core/detail/GroceryDetailTestTags.kt b/client/grocery/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/core/detail/GroceryDetailTestTags.kt index 822afd293..404a7ce56 100644 --- a/client/grocery/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/core/detail/GroceryDetailTestTags.kt +++ b/client/grocery/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/core/detail/GroceryDetailTestTags.kt @@ -3,4 +3,5 @@ package com.plusmobileapps.chefmate.grocery.core.detail object GroceryDetailTestTags { const val SHEET = "grocery_detail_sheet" const val AISLE_DROPDOWN = "grocery_detail_aisle_dropdown" + const val ALWAYS_FILE_HERE = "grocery_detail_always_file_here" } From 0c9ef707ba8d0fdc2c89b4dce59be7154dc762e3 Mon Sep 17 00:00:00 2001 From: Andrew Steinmetz Date: Sun, 19 Jul 2026 23:40:18 -0700 Subject: [PATCH 4/6] feat(grocery): category rules management screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Settings → Grocery → Category rules screen for reviewing and removing name→aisle rules (created here or via the item detail "always file here" hook). New grocery/category-rules feature module (bloc/impl/viewmodel/screen) mirrors the grocery autocomplete settings module; the create row pairs an item name with an aisle picker, and each rule row offers delete-with-confirm. Wires the screen into settings navigation (SettingsRoot child + app settings row) and registers the impl module in the DI graph. Co-Authored-By: Claude Opus 4.8 --- client/composeApp/build.gradle.kts | 2 + .../category-rules/impl/build.gradle.kts | 24 + .../impl/GroceryCategoryRulesBlocImpl.kt | 67 +++ .../impl/GroceryCategoryRulesViewModel.kt | 91 ++++ .../impl/GroceryCategoryRulesViewModelTest.kt | 73 +++ .../category-rules/public/build.gradle.kts | 25 ++ .../composeResources/values/strings.xml | 18 + .../categoryrules/GroceryCategoryRulesBloc.kt | 77 ++++ .../GroceryCategoryRulesScreen.kt | 417 ++++++++++++++++++ .../GroceryCategoryRulesTestTags.kt | 9 + .../settings/impl/AppSettingsBlocImpl.kt | 4 + .../settings/impl/ui/AppSettingsPreviews.kt | 2 + .../settings/impl/AppSettingsBlocImplTest.kt | 7 + .../composeResources/values/strings.xml | 1 + .../chefmate/settings/AppSettingsBloc.kt | 4 + .../chefmate/settings/ui/AppSettingsScreen.kt | 6 + client/settings/root/impl/build.gradle.kts | 1 + .../root/impl/SettingsRootBlocImpl.kt | 21 + .../root/impl/SettingsRootBlocImplTest.kt | 20 + client/settings/root/public/build.gradle.kts | 1 + .../settings/root/SettingsRootBloc.kt | 3 + settings.gradle.kts | 4 + 22 files changed, 877 insertions(+) create mode 100644 client/grocery/category-rules/impl/build.gradle.kts create mode 100644 client/grocery/category-rules/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/categoryrules/impl/GroceryCategoryRulesBlocImpl.kt create mode 100644 client/grocery/category-rules/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/categoryrules/impl/GroceryCategoryRulesViewModel.kt create mode 100644 client/grocery/category-rules/impl/src/commonTest/kotlin/com/plusmobileapps/chefmate/grocery/categoryrules/impl/GroceryCategoryRulesViewModelTest.kt create mode 100644 client/grocery/category-rules/public/build.gradle.kts create mode 100644 client/grocery/category-rules/public/src/commonMain/composeResources/values/strings.xml create mode 100644 client/grocery/category-rules/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/categoryrules/GroceryCategoryRulesBloc.kt create mode 100644 client/grocery/category-rules/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/categoryrules/GroceryCategoryRulesScreen.kt create mode 100644 client/grocery/category-rules/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/categoryrules/GroceryCategoryRulesTestTags.kt diff --git a/client/composeApp/build.gradle.kts b/client/composeApp/build.gradle.kts index 2f08ae64b..e5adbcf39 100644 --- a/client/composeApp/build.gradle.kts +++ b/client/composeApp/build.gradle.kts @@ -94,6 +94,8 @@ kotlin { api(projects.client.featureflag.public) api(projects.client.grocery.autocomplete.impl) api(projects.client.grocery.autocomplete.public) + api(projects.client.grocery.categoryRules.impl) + api(projects.client.grocery.categoryRules.public) api(projects.client.grocery.data.impl) api(projects.client.grocery.core.impl) api(projects.client.grocery.core.public) diff --git a/client/grocery/category-rules/impl/build.gradle.kts b/client/grocery/category-rules/impl/build.gradle.kts new file mode 100644 index 000000000..a3d600940 --- /dev/null +++ b/client/grocery/category-rules/impl/build.gradle.kts @@ -0,0 +1,24 @@ +plugins { + alias(libs.plugins.kmpLibrary) + alias(libs.plugins.compose) +} + +kotlin { + sourceSets { + commonMain.dependencies { + implementation(projects.client.grocery.categoryRules.public) + implementation(projects.client.grocery.data.public) + implementation(projects.client.shared) + implementation(projects.client.ui.public) + implementation(libs.arkivanov.decompose.core) + implementation(libs.arkivanov.decompose.compose.extensions) + } + commonTest.dependencies { implementation(projects.client.grocery.data.testing) } + } +} + +plusLibrary { + namespace = "com.plusmobileapps.chefmate.grocery.categoryrules.impl" + enableDi = true + enableTesting = true +} diff --git a/client/grocery/category-rules/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/categoryrules/impl/GroceryCategoryRulesBlocImpl.kt b/client/grocery/category-rules/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/categoryrules/impl/GroceryCategoryRulesBlocImpl.kt new file mode 100644 index 000000000..16c066891 --- /dev/null +++ b/client/grocery/category-rules/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/categoryrules/impl/GroceryCategoryRulesBlocImpl.kt @@ -0,0 +1,67 @@ +package com.plusmobileapps.chefmate.grocery.categoryrules.impl + +import com.plusmobileapps.chefmate.BlocContext +import com.plusmobileapps.chefmate.Consumer +import com.plusmobileapps.chefmate.di.AppScope +import com.plusmobileapps.chefmate.getViewModel +import com.plusmobileapps.chefmate.grocery.categoryrules.GroceryCategoryRulesBloc +import com.plusmobileapps.chefmate.grocery.categoryrules.GroceryCategoryRulesBloc.Output +import com.plusmobileapps.chefmate.grocery.categoryrules.GroceryCategoryRulesBloc.Rule +import com.plusmobileapps.chefmate.grocery.data.GroceryCategory +import com.plusmobileapps.metro.extensions.assistedfactory.ContributesAssistedFactory +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import dev.zacsweers.metro.Provider +import kotlinx.coroutines.flow.StateFlow + +@AssistedInject +@ContributesAssistedFactory( + scope = AppScope::class, + assistedFactory = GroceryCategoryRulesBloc.Factory::class, +) +class GroceryCategoryRulesBlocImpl( + @Assisted context: BlocContext, + @Assisted private val output: Consumer, + viewModelFactory: Provider, +) : GroceryCategoryRulesBloc, BlocContext by context { + + private val viewModel = instanceKeeper.getViewModel { viewModelFactory() } + + override val state: StateFlow = viewModel.state + + override fun onBackClicked() { + output.onNext(Output.Back) + } + + override fun onCreateClicked() { + viewModel.openCreateField() + } + + override fun onCreateCancelled() { + viewModel.closeCreateField() + } + + override fun onCreateNameChanged(name: String) { + viewModel.updateCreateName(name) + } + + override fun onCreateCategorySelected(category: GroceryCategory) { + viewModel.updateCreateCategory(category) + } + + override fun onCreateSubmitted() { + viewModel.submitCreate() + } + + override fun onDeleteRequested(rule: Rule) { + viewModel.showDeleteDialog(rule) + } + + override fun onDeleteConfirmed() { + viewModel.confirmDelete() + } + + override fun onDeleteDismissed() { + viewModel.dismissDialog() + } +} diff --git a/client/grocery/category-rules/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/categoryrules/impl/GroceryCategoryRulesViewModel.kt b/client/grocery/category-rules/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/categoryrules/impl/GroceryCategoryRulesViewModel.kt new file mode 100644 index 000000000..d1721fa1f --- /dev/null +++ b/client/grocery/category-rules/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/categoryrules/impl/GroceryCategoryRulesViewModel.kt @@ -0,0 +1,91 @@ +package com.plusmobileapps.chefmate.grocery.categoryrules.impl + +import com.plusmobileapps.chefmate.ViewModel +import com.plusmobileapps.chefmate.di.Main +import com.plusmobileapps.chefmate.grocery.categoryrules.GroceryCategoryRulesBloc.CreateState +import com.plusmobileapps.chefmate.grocery.categoryrules.GroceryCategoryRulesBloc.DialogState +import com.plusmobileapps.chefmate.grocery.categoryrules.GroceryCategoryRulesBloc.Model +import com.plusmobileapps.chefmate.grocery.categoryrules.GroceryCategoryRulesBloc.Rule +import com.plusmobileapps.chefmate.grocery.data.GroceryCategory +import com.plusmobileapps.chefmate.grocery.data.GroceryCategoryOverrideRepository +import dev.zacsweers.metro.Inject +import kotlin.coroutines.CoroutineContext +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +@Inject +class GroceryCategoryRulesViewModel( + @Main mainContext: CoroutineContext, + private val repository: GroceryCategoryOverrideRepository, +) : ViewModel(mainContext) { + + private val _state = MutableStateFlow(Model()) + val state: StateFlow = _state.asStateFlow() + + init { + scope.launch { + repository.observeOverrides().collect { overrides -> + _state.update { current -> + current.copy( + rules = + overrides + .map { Rule(id = it.id, name = it.name, category = it.category) } + .toImmutableList(), + isLoading = false, + ) + } + } + } + } + + fun openCreateField() { + _state.update { current -> + if (current.createState is CreateState.Editing) current + else current.copy(createState = CreateState.Editing(name = "")) + } + } + + fun closeCreateField() { + _state.update { it.copy(createState = CreateState.Hidden) } + } + + fun updateCreateName(name: String) { + _state.update { current -> + val editing = current.createState as? CreateState.Editing ?: return@update current + current.copy(createState = editing.copy(name = name)) + } + } + + fun updateCreateCategory(category: GroceryCategory) { + _state.update { current -> + val editing = current.createState as? CreateState.Editing ?: return@update current + current.copy(createState = editing.copy(category = category)) + } + } + + fun submitCreate() { + val editing = _state.value.createState as? CreateState.Editing ?: return + val name = editing.name.trim() + if (name.isBlank()) return + _state.update { it.copy(createState = CreateState.Hidden) } + scope.launch { repository.setOverride(name, editing.category) } + } + + fun showDeleteDialog(rule: Rule) { + _state.update { it.copy(dialog = DialogState.Delete(rule)) } + } + + fun confirmDelete() { + val target = (_state.value.dialog as? DialogState.Delete)?.target ?: return + _state.update { it.copy(dialog = DialogState.None) } + scope.launch { repository.removeOverride(target.id) } + } + + fun dismissDialog() { + _state.update { it.copy(dialog = DialogState.None) } + } +} diff --git a/client/grocery/category-rules/impl/src/commonTest/kotlin/com/plusmobileapps/chefmate/grocery/categoryrules/impl/GroceryCategoryRulesViewModelTest.kt b/client/grocery/category-rules/impl/src/commonTest/kotlin/com/plusmobileapps/chefmate/grocery/categoryrules/impl/GroceryCategoryRulesViewModelTest.kt new file mode 100644 index 000000000..88ffaa870 --- /dev/null +++ b/client/grocery/category-rules/impl/src/commonTest/kotlin/com/plusmobileapps/chefmate/grocery/categoryrules/impl/GroceryCategoryRulesViewModelTest.kt @@ -0,0 +1,73 @@ +@file:Suppress("FunctionName") +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.plusmobileapps.chefmate.grocery.categoryrules.impl + +import com.plusmobileapps.chefmate.grocery.categoryrules.GroceryCategoryRulesBloc.CreateState +import com.plusmobileapps.chefmate.grocery.categoryrules.GroceryCategoryRulesBloc.DialogState +import com.plusmobileapps.chefmate.grocery.data.GroceryCategory +import com.plusmobileapps.chefmate.grocery.data.GroceryCategoryOverride +import com.plusmobileapps.chefmate.grocery.data.testing.FakeGroceryCategoryOverrideRepository +import io.kotest.matchers.shouldBe +import kotlin.test.Test +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher + +class GroceryCategoryRulesViewModelTest { + + private val overrides = MutableStateFlow>(emptyList()) + private val repository = FakeGroceryCategoryOverrideRepository(overrides) + private val mainContext = UnconfinedTestDispatcher() + + private fun createViewModel() = GroceryCategoryRulesViewModel(mainContext, repository) + + @Test + fun loads_existing_rules() { + overrides.value = + listOf(GroceryCategoryOverride(id = 1L, name = "Cold brew", GroceryCategory.BEVERAGES)) + val vm = createViewModel() + + vm.state.value.rules.map { it.name to it.category } shouldBe + listOf("Cold brew" to GroceryCategory.BEVERAGES) + vm.state.value.isLoading shouldBe false + } + + @Test + fun open_create_then_submit_adds_the_trimmed_rule_with_selected_category() { + val vm = createViewModel() + vm.openCreateField() + vm.updateCreateName(" Paper towels ") + vm.updateCreateCategory(GroceryCategory.OTHER) + vm.submitCreate() + + vm.state.value.createState shouldBe CreateState.Hidden + vm.state.value.rules.map { it.name to it.category } shouldBe + listOf("Paper towels" to GroceryCategory.OTHER) + } + + @Test + fun submit_blank_name_is_ignored() { + val vm = createViewModel() + vm.openCreateField() + vm.updateCreateName(" ") + vm.submitCreate() + + vm.state.value.rules shouldBe emptyList() + } + + @Test + fun delete_flow_removes_the_rule() { + overrides.value = + listOf(GroceryCategoryOverride(id = 7L, name = "Cold brew", GroceryCategory.BEVERAGES)) + val vm = createViewModel() + val rule = vm.state.value.rules.first() + + vm.showDeleteDialog(rule) + vm.state.value.dialog shouldBe DialogState.Delete(rule) + vm.confirmDelete() + + vm.state.value.dialog shouldBe DialogState.None + vm.state.value.rules shouldBe emptyList() + } +} diff --git a/client/grocery/category-rules/public/build.gradle.kts b/client/grocery/category-rules/public/build.gradle.kts new file mode 100644 index 000000000..25df288d7 --- /dev/null +++ b/client/grocery/category-rules/public/build.gradle.kts @@ -0,0 +1,25 @@ +plugins { + alias(libs.plugins.kmpLibrary) + alias(libs.plugins.compose) + alias(libs.plugins.kotlinSerialization) +} + +kotlin { + sourceSets { + commonMain.dependencies { + api(libs.kotlin.coroutines.core) + api(libs.arkivanov.decompose.core) + api(projects.client.shared) + api(projects.client.text.public) + api(projects.client.grocery.data.public) + implementation(projects.client.grocery.core.public) + implementation(projects.client.ui.public) + implementation(libs.arkivanov.decompose.compose.extensions) + implementation(compose.components.resources) + } + } +} + +compose { resources { publicResClass = true } } + +plusLibrary { namespace = "com.plusmobileapps.chefmate.grocery.categoryrules" } diff --git a/client/grocery/category-rules/public/src/commonMain/composeResources/values/strings.xml b/client/grocery/category-rules/public/src/commonMain/composeResources/values/strings.xml new file mode 100644 index 000000000..10758b9b0 --- /dev/null +++ b/client/grocery/category-rules/public/src/commonMain/composeResources/values/strings.xml @@ -0,0 +1,18 @@ + + + Category rules + Your rules + Add rule + Item name + Save rule + Cancel + Aisle + No rules yet — tap the + button to always file an item under a chosen aisle. + {name} → {aisle} + More actions for {name} + Delete + Delete rule? + {name} will go back to being categorized automatically. + Delete + Cancel + diff --git a/client/grocery/category-rules/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/categoryrules/GroceryCategoryRulesBloc.kt b/client/grocery/category-rules/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/categoryrules/GroceryCategoryRulesBloc.kt new file mode 100644 index 000000000..08d1f13af --- /dev/null +++ b/client/grocery/category-rules/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/categoryrules/GroceryCategoryRulesBloc.kt @@ -0,0 +1,77 @@ +package com.plusmobileapps.chefmate.grocery.categoryrules + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.arkivanov.essenty.backhandler.BackHandlerOwner +import com.plusmobileapps.chefmate.BackClickBloc +import com.plusmobileapps.chefmate.BlocContext +import com.plusmobileapps.chefmate.Consumer +import com.plusmobileapps.chefmate.grocery.data.GroceryCategory +import com.plusmobileapps.chefmate.ui.ComposeScreen +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.StateFlow + +/** + * Settings screen for managing persistent "always file under " rules. Each rule + * retags every future grocery item whose parsed name matches, overriding the built-in + * `IngredientParser` guess. + */ +interface GroceryCategoryRulesBloc : BackHandlerOwner, BackClickBloc, ComposeScreen { + val state: StateFlow + + @Composable + override fun Content(modifier: Modifier) { + GroceryCategoryRulesScreen(bloc = this, modifier = modifier) + } + + fun onCreateClicked() + + fun onCreateCancelled() + + fun onCreateNameChanged(name: String) + + fun onCreateCategorySelected(category: GroceryCategory) + + fun onCreateSubmitted() + + fun onDeleteRequested(rule: Rule) + + fun onDeleteConfirmed() + + fun onDeleteDismissed() + + data class Model( + val rules: ImmutableList = persistentListOf(), + val isLoading: Boolean = true, + val createState: CreateState = CreateState.Hidden, + val dialog: DialogState = DialogState.None, + ) + + /** One user-defined name→aisle rule. */ + data class Rule(val id: Long, val name: String, val category: GroceryCategory) + + sealed class CreateState { + data object Hidden : CreateState() + + /** The in-progress new rule: a name and the aisle it will file under. */ + data class Editing( + val name: String, + val category: GroceryCategory = GroceryCategory.OTHER, + ) : CreateState() + } + + sealed class DialogState { + data object None : DialogState() + + data class Delete(val target: Rule) : DialogState() + } + + sealed class Output { + data object Back : Output() + } + + fun interface Factory { + fun create(context: BlocContext, output: Consumer): GroceryCategoryRulesBloc + } +} diff --git a/client/grocery/category-rules/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/categoryrules/GroceryCategoryRulesScreen.kt b/client/grocery/category-rules/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/categoryrules/GroceryCategoryRulesScreen.kt new file mode 100644 index 000000000..05e8a1086 --- /dev/null +++ b/client/grocery/category-rules/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/categoryrules/GroceryCategoryRulesScreen.kt @@ -0,0 +1,417 @@ +@file:OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) + +package com.plusmobileapps.chefmate.grocery.categoryrules + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuAnchorType +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import chefmate.client.grocery.category_rules.public.generated.resources.Res +import chefmate.client.grocery.category_rules.public.generated.resources.grocery_category_rules_category_label +import chefmate.client.grocery.category_rules.public.generated.resources.grocery_category_rules_create_a11y +import chefmate.client.grocery.category_rules.public.generated.resources.grocery_category_rules_create_cancel_a11y +import chefmate.client.grocery.category_rules.public.generated.resources.grocery_category_rules_create_confirm_a11y +import chefmate.client.grocery.category_rules.public.generated.resources.grocery_category_rules_delete +import chefmate.client.grocery.category_rules.public.generated.resources.grocery_category_rules_delete_cancel +import chefmate.client.grocery.category_rules.public.generated.resources.grocery_category_rules_delete_confirm +import chefmate.client.grocery.category_rules.public.generated.resources.grocery_category_rules_delete_message +import chefmate.client.grocery.category_rules.public.generated.resources.grocery_category_rules_delete_title +import chefmate.client.grocery.category_rules.public.generated.resources.grocery_category_rules_empty +import chefmate.client.grocery.category_rules.public.generated.resources.grocery_category_rules_more_a11y +import chefmate.client.grocery.category_rules.public.generated.resources.grocery_category_rules_name_placeholder +import chefmate.client.grocery.category_rules.public.generated.resources.grocery_category_rules_row_summary +import chefmate.client.grocery.category_rules.public.generated.resources.grocery_category_rules_section +import chefmate.client.grocery.category_rules.public.generated.resources.grocery_category_rules_title +import com.plusmobileapps.chefmate.grocery.categoryrules.GroceryCategoryRulesBloc.CreateState +import com.plusmobileapps.chefmate.grocery.categoryrules.GroceryCategoryRulesBloc.DialogState +import com.plusmobileapps.chefmate.grocery.categoryrules.GroceryCategoryRulesBloc.Rule +import com.plusmobileapps.chefmate.grocery.core.displayName +import com.plusmobileapps.chefmate.grocery.data.GroceryCategory +import com.plusmobileapps.chefmate.text.FixedString +import com.plusmobileapps.chefmate.text.PhraseModel +import com.plusmobileapps.chefmate.text.ResourceString +import com.plusmobileapps.chefmate.text.asTextData +import com.plusmobileapps.chefmate.ui.components.PlusDialog +import com.plusmobileapps.chefmate.ui.components.PlusHeaderContainer +import com.plusmobileapps.chefmate.ui.components.PlusHeaderContainerDefaults +import com.plusmobileapps.chefmate.ui.components.PlusHeaderData +import com.plusmobileapps.chefmate.ui.theme.ChefMateTheme +import org.jetbrains.compose.resources.stringResource + +@Composable +fun GroceryCategoryRulesScreen(bloc: GroceryCategoryRulesBloc, modifier: Modifier = Modifier) { + val model by bloc.state.collectAsState() + GroceryCategoryRulesContent(model = model, handlers = blocHandlers(bloc), modifier = modifier) +} + +internal data class GroceryCategoryRulesHandlers( + val onBackClicked: () -> Unit, + val onCreateClicked: () -> Unit, + val onCreateCancelled: () -> Unit, + val onCreateNameChanged: (String) -> Unit, + val onCreateCategorySelected: (GroceryCategory) -> Unit, + val onCreateSubmitted: () -> Unit, + val onDeleteRequested: (Rule) -> Unit, + val onDeleteConfirmed: () -> Unit, + val onDeleteDismissed: () -> Unit, +) + +private fun blocHandlers(bloc: GroceryCategoryRulesBloc): GroceryCategoryRulesHandlers = + GroceryCategoryRulesHandlers( + onBackClicked = bloc::onBackClicked, + onCreateClicked = bloc::onCreateClicked, + onCreateCancelled = bloc::onCreateCancelled, + onCreateNameChanged = bloc::onCreateNameChanged, + onCreateCategorySelected = bloc::onCreateCategorySelected, + onCreateSubmitted = bloc::onCreateSubmitted, + onDeleteRequested = bloc::onDeleteRequested, + onDeleteConfirmed = bloc::onDeleteConfirmed, + onDeleteDismissed = bloc::onDeleteDismissed, + ) + +@Composable +internal fun GroceryCategoryRulesContent( + model: GroceryCategoryRulesBloc.Model, + handlers: GroceryCategoryRulesHandlers, + modifier: Modifier = Modifier, +) { + PlusHeaderContainer( + modifier = modifier.testTag(GroceryCategoryRulesTestTags.SCREEN), + data = + PlusHeaderData.Child( + title = Res.string.grocery_category_rules_title.asTextData(), + onBackClick = handlers.onBackClicked, + ), + // Disable the container's outer scroll so the inner LazyColumn owns scrolling (Compose + // throws on a scrollable nested under infinite-height constraints). + scrollEnabled = false, + maxContentWidth = Dp.Unspecified, + horizontalAlignment = Alignment.CenterHorizontally, + content = { + CreateRuleRow( + createState = model.createState, + onNameChanged = handlers.onCreateNameChanged, + onCategorySelected = handlers.onCreateCategorySelected, + onSubmit = handlers.onCreateSubmitted, + onCancel = handlers.onCreateCancelled, + ) + RuleList( + rules = model.rules, + createOpen = model.createState is CreateState.Editing, + onCreateClicked = handlers.onCreateClicked, + onDeleteRequested = handlers.onDeleteRequested, + ) + }, + ) + + when (val dialog = model.dialog) { + DialogState.None -> Unit + is DialogState.Delete -> + PlusDialog( + title = ResourceString(Res.string.grocery_category_rules_delete_title), + message = + PhraseModel( + Res.string.grocery_category_rules_delete_message, + "name" to FixedString(dialog.target.name), + ), + confirmButtonText = + ResourceString(Res.string.grocery_category_rules_delete_confirm), + dismissButtonText = ResourceString(Res.string.grocery_category_rules_delete_cancel), + onConfirmClick = handlers.onDeleteConfirmed, + onDismissRequest = handlers.onDeleteDismissed, + ) + } +} + +@Composable +private fun CreateRuleRow( + createState: CreateState, + onNameChanged: (String) -> Unit, + onCategorySelected: (GroceryCategory) -> Unit, + onSubmit: () -> Unit, + onCancel: () -> Unit, +) { + AnimatedContent( + targetState = createState, + modifier = + Modifier.widthIn(max = PlusHeaderContainerDefaults.MaxContentWidth).fillMaxWidth(), + contentKey = { it is CreateState.Editing }, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + ) { state -> + if (state is CreateState.Editing) { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { focusRequester.requestFocus() } + Column( + modifier = + Modifier.fillMaxWidth() + .padding( + start = ChefMateTheme.dimens.paddingNormal, + end = ChefMateTheme.dimens.paddingSmall, + top = ChefMateTheme.dimens.paddingSmall, + ), + verticalArrangement = Arrangement.spacedBy(ChefMateTheme.dimens.paddingSmall), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + OutlinedTextField( + value = state.name, + onValueChange = onNameChanged, + placeholder = { + Text(stringResource(Res.string.grocery_category_rules_name_placeholder)) + }, + singleLine = true, + keyboardOptions = + KeyboardOptions(imeAction = ImeAction.Done, autoCorrectEnabled = false), + keyboardActions = KeyboardActions(onDone = { onSubmit() }), + modifier = + Modifier.weight(1f) + .focusRequester(focusRequester) + .testTag(GroceryCategoryRulesTestTags.CREATE_FIELD), + ) + IconButton(onClick = { if (state.name.isNotBlank()) onSubmit() }) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = + stringResource( + Res.string.grocery_category_rules_create_confirm_a11y + ), + ) + } + IconButton(onClick = onCancel) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = + stringResource( + Res.string.grocery_category_rules_create_cancel_a11y + ), + ) + } + } + CategoryDropdown(selected = state.category, onSelected = onCategorySelected) + } + } else { + Box(modifier = Modifier.fillMaxWidth()) + } + } +} + +@Composable +private fun CategoryDropdown( + selected: GroceryCategory, + onSelected: (GroceryCategory) -> Unit, + modifier: Modifier = Modifier, +) { + var expanded by remember { mutableStateOf(false) } + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it }, + modifier = modifier.fillMaxWidth().testTag(GroceryCategoryRulesTestTags.CREATE_CATEGORY), + ) { + OutlinedTextField( + value = selected.displayName().localized(), + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(Res.string.grocery_category_rules_category_label)) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = + Modifier.menuAnchor( + ExposedDropdownMenuAnchorType.PrimaryNotEditable, + enabled = true, + ) + .fillMaxWidth(), + ) + ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + GroceryCategory.entries.forEach { category -> + DropdownMenuItem( + text = { Text(category.displayName().localized()) }, + onClick = { + onSelected(category) + expanded = false + }, + ) + } + } + } +} + +@Composable +private fun RuleList( + rules: List, + createOpen: Boolean, + onCreateClicked: () -> Unit, + onDeleteRequested: (Rule) -> Unit, +) { + LazyColumn( + modifier = Modifier.fillMaxWidth().fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + stickyHeader(key = "header") { + SectionHeader( + title = stringResource(Res.string.grocery_category_rules_section), + trailing = { + if (!createOpen) { + IconButton( + onClick = onCreateClicked, + modifier = Modifier.testTag(GroceryCategoryRulesTestTags.ADD_BUTTON), + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = + stringResource(Res.string.grocery_category_rules_create_a11y), + ) + } + } + }, + ) + } + if (rules.isEmpty()) { + item(key = "empty") { EmptyHint() } + } else { + items(rules, key = { "rule-${it.id}" }) { rule -> + RuleRow(rule = rule, onDeleteClicked = { onDeleteRequested(rule) }) + } + } + } +} + +@Composable +private fun SectionHeader(title: String, trailing: @Composable () -> Unit) { + Surface( + modifier = + Modifier.widthIn(max = PlusHeaderContainerDefaults.MaxContentWidth).fillMaxWidth(), + color = ChefMateTheme.colorScheme.background, + ) { + Row( + modifier = + Modifier.fillMaxWidth() + .heightIn(min = 48.dp) + .padding(start = ChefMateTheme.dimens.paddingNormal), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = title, + style = ChefMateTheme.typography.titleSmall, + color = ChefMateTheme.colorScheme.primary, + modifier = Modifier.weight(1f), + ) + trailing() + } + } +} + +@Composable +private fun EmptyHint() { + Box( + modifier = + Modifier.widthIn(max = PlusHeaderContainerDefaults.MaxContentWidth) + .fillMaxWidth() + .padding( + horizontal = ChefMateTheme.dimens.paddingNormal, + vertical = ChefMateTheme.dimens.paddingSmall, + ) + ) { + Text( + text = stringResource(Res.string.grocery_category_rules_empty), + style = MaterialTheme.typography.bodyMedium, + color = ChefMateTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun RuleRow(rule: Rule, onDeleteClicked: () -> Unit) { + Row( + modifier = + Modifier.widthIn(max = PlusHeaderContainerDefaults.MaxContentWidth) + .fillMaxWidth() + .heightIn(min = 56.dp) + .padding(start = ChefMateTheme.dimens.paddingNormal) + .testTag(GroceryCategoryRulesTestTags.RULE_ROW), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = + PhraseModel( + Res.string.grocery_category_rules_row_summary, + "name" to FixedString(rule.name), + "aisle" to rule.category.displayName(), + ) + .localized(), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f), + ) + RuleOverflowMenu(ruleName = rule.name, onDeleteClicked = onDeleteClicked) + } +} + +@Composable +private fun RuleOverflowMenu(ruleName: String, onDeleteClicked: () -> Unit) { + var expanded by remember { mutableStateOf(false) } + Box { + IconButton(onClick = { expanded = true }) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = + PhraseModel( + Res.string.grocery_category_rules_more_a11y, + "name" to FixedString(ruleName), + ) + .localized(), + ) + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + DropdownMenuItem( + text = { Text(stringResource(Res.string.grocery_category_rules_delete)) }, + onClick = { + expanded = false + onDeleteClicked() + }, + ) + } + } +} diff --git a/client/grocery/category-rules/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/categoryrules/GroceryCategoryRulesTestTags.kt b/client/grocery/category-rules/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/categoryrules/GroceryCategoryRulesTestTags.kt new file mode 100644 index 000000000..199627994 --- /dev/null +++ b/client/grocery/category-rules/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/categoryrules/GroceryCategoryRulesTestTags.kt @@ -0,0 +1,9 @@ +package com.plusmobileapps.chefmate.grocery.categoryrules + +object GroceryCategoryRulesTestTags { + const val SCREEN = "grocery_category_rules_screen" + const val ADD_BUTTON = "grocery_category_rules_add_button" + const val CREATE_FIELD = "grocery_category_rules_create_field" + const val CREATE_CATEGORY = "grocery_category_rules_create_category" + const val RULE_ROW = "grocery_category_rules_rule_row" +} diff --git a/client/settings/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/settings/impl/AppSettingsBlocImpl.kt b/client/settings/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/settings/impl/AppSettingsBlocImpl.kt index 6a97606e7..6fb04a79a 100644 --- a/client/settings/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/settings/impl/AppSettingsBlocImpl.kt +++ b/client/settings/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/settings/impl/AppSettingsBlocImpl.kt @@ -79,4 +79,8 @@ class AppSettingsBlocImpl( override fun onGroceryAutocompleteClicked() { output.onNext(Output.OpenGroceryAutocomplete) } + + override fun onGroceryCategoryRulesClicked() { + output.onNext(Output.OpenGroceryCategoryRules) + } } diff --git a/client/settings/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/settings/impl/ui/AppSettingsPreviews.kt b/client/settings/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/settings/impl/ui/AppSettingsPreviews.kt index 3fd8450cf..b11554c2f 100644 --- a/client/settings/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/settings/impl/ui/AppSettingsPreviews.kt +++ b/client/settings/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/settings/impl/ui/AppSettingsPreviews.kt @@ -40,6 +40,8 @@ private fun appSettingsBloc(model: AppSettingsBloc.Model): AppSettingsBloc = override fun onRecipeCategoriesClicked() = Unit override fun onGroceryAutocompleteClicked() = Unit + + override fun onGroceryCategoryRulesClicked() = Unit } val previewAppSettingsBloc: AppSettingsBloc = diff --git a/client/settings/impl/src/commonTest/kotlin/com/plusmobileapps/chefmate/settings/impl/AppSettingsBlocImplTest.kt b/client/settings/impl/src/commonTest/kotlin/com/plusmobileapps/chefmate/settings/impl/AppSettingsBlocImplTest.kt index 12ed8079d..042120f01 100644 --- a/client/settings/impl/src/commonTest/kotlin/com/plusmobileapps/chefmate/settings/impl/AppSettingsBlocImplTest.kt +++ b/client/settings/impl/src/commonTest/kotlin/com/plusmobileapps/chefmate/settings/impl/AppSettingsBlocImplTest.kt @@ -146,4 +146,11 @@ class AppSettingsBlocImplTest { bloc.onExportRecipesClicked() output.lastValue shouldBe AppSettingsBloc.Output.OpenExportRecipes } + + @Test + fun When_grocery_category_rules_clicked_Then_open_grocery_category_rules_output_emitted() { + val bloc = createBloc() + bloc.onGroceryCategoryRulesClicked() + output.lastValue shouldBe AppSettingsBloc.Output.OpenGroceryCategoryRules + } } diff --git a/client/settings/public/src/commonMain/composeResources/values/strings.xml b/client/settings/public/src/commonMain/composeResources/values/strings.xml index 8bf54eb01..2b8d3d1ee 100644 --- a/client/settings/public/src/commonMain/composeResources/values/strings.xml +++ b/client/settings/public/src/commonMain/composeResources/values/strings.xml @@ -39,6 +39,7 @@ Categories Grocery Autocomplete items + Category rules Developer Settings Version {version} \ No newline at end of file diff --git a/client/settings/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/settings/AppSettingsBloc.kt b/client/settings/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/settings/AppSettingsBloc.kt index 3b47704b1..fa4d83dd4 100644 --- a/client/settings/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/settings/AppSettingsBloc.kt +++ b/client/settings/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/settings/AppSettingsBloc.kt @@ -44,6 +44,8 @@ interface AppSettingsBloc : BackHandlerOwner, ComposeScreen { fun onGroceryAutocompleteClicked() + fun onGroceryCategoryRulesClicked() + data class Model( val isHistoryEnabled: Boolean = true, val showClearHistoryDialog: Boolean = false, @@ -64,6 +66,8 @@ interface AppSettingsBloc : BackHandlerOwner, ComposeScreen { data object OpenRecipeCategories : Output() data object OpenGroceryAutocomplete : Output() + + data object OpenGroceryCategoryRules : Output() } fun interface Factory { diff --git a/client/settings/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/settings/ui/AppSettingsScreen.kt b/client/settings/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/settings/ui/AppSettingsScreen.kt index ae51df8fe..70760e36b 100644 --- a/client/settings/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/settings/ui/AppSettingsScreen.kt +++ b/client/settings/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/settings/ui/AppSettingsScreen.kt @@ -35,6 +35,7 @@ import chefmate.client.settings.public.generated.resources.app_settings_default_ import chefmate.client.settings.public.generated.resources.app_settings_default_search_engine_dialog_title import chefmate.client.settings.public.generated.resources.app_settings_default_search_engine_none import chefmate.client.settings.public.generated.resources.app_settings_grocery_autocomplete +import chefmate.client.settings.public.generated.resources.app_settings_grocery_category_rules import chefmate.client.settings.public.generated.resources.app_settings_grocery_section import chefmate.client.settings.public.generated.resources.app_settings_navigation_section import chefmate.client.settings.public.generated.resources.app_settings_recipe_categories @@ -119,6 +120,11 @@ fun AppSettingsScreen(bloc: AppSettingsBloc, modifier: Modifier = Modifier) { onClick = bloc::onGroceryAutocompleteClicked, ) HorizontalDivider() + SettingsRow( + name = Res.string.app_settings_grocery_category_rules.asTextData(), + onClick = bloc::onGroceryCategoryRulesClicked, + ) + HorizontalDivider() SectionHeader(name = Res.string.app_settings_backup_section.asTextData()) SettingsRow( name = Res.string.import_recipes.asTextData(), diff --git a/client/settings/root/impl/build.gradle.kts b/client/settings/root/impl/build.gradle.kts index 85030bc88..65b670f9f 100644 --- a/client/settings/root/impl/build.gradle.kts +++ b/client/settings/root/impl/build.gradle.kts @@ -11,6 +11,7 @@ kotlin { implementation(projects.client.settings.public) implementation(projects.client.bottomnav.public) implementation(projects.client.grocery.autocomplete.public) + implementation(projects.client.grocery.categoryRules.public) implementation(projects.client.recipe.categories.public) implementation(projects.client.recipe.exporter.public) implementation(projects.client.recipe.importer.public) diff --git a/client/settings/root/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/settings/root/impl/SettingsRootBlocImpl.kt b/client/settings/root/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/settings/root/impl/SettingsRootBlocImpl.kt index cf3e8116d..bf3be2196 100644 --- a/client/settings/root/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/settings/root/impl/SettingsRootBlocImpl.kt +++ b/client/settings/root/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/settings/root/impl/SettingsRootBlocImpl.kt @@ -13,6 +13,7 @@ import com.plusmobileapps.chefmate.BlocContext import com.plusmobileapps.chefmate.Consumer import com.plusmobileapps.chefmate.di.AppScope import com.plusmobileapps.chefmate.grocery.autocomplete.GroceryAutocompleteBloc +import com.plusmobileapps.chefmate.grocery.categoryrules.GroceryCategoryRulesBloc import com.plusmobileapps.chefmate.recipe.bottomnav.BottomNavOrderBloc import com.plusmobileapps.chefmate.recipe.categories.RecipeCategoriesBloc import com.plusmobileapps.chefmate.recipe.exporter.ExportRecipesBloc @@ -40,6 +41,7 @@ class SettingsRootBlocImpl( private val exportRecipes: ExportRecipesBloc.Factory, private val recipeCategories: RecipeCategoriesBloc.Factory, private val groceryAutocomplete: GroceryAutocompleteBloc.Factory, + private val groceryCategoryRules: GroceryCategoryRulesBloc.Factory, ) : SettingsRootBloc, BlocContext by context { private val navigation = StackNavigation() @@ -121,6 +123,15 @@ class SettingsRootBlocImpl( output = ::handleGroceryAutocompleteOutput, ) ) + + Configuration.GroceryCategoryRules -> + SettingsRootBloc.Child.GroceryCategoryRules( + bloc = + groceryCategoryRules.create( + context = context, + output = ::handleGroceryCategoryRulesOutput, + ) + ) } private fun handleAppSettingsOutput(output: AppSettingsBloc.Output) { @@ -136,6 +147,8 @@ class SettingsRootBlocImpl( navigation.bringToFront(Configuration.RecipeCategories) AppSettingsBloc.Output.OpenGroceryAutocomplete -> navigation.bringToFront(Configuration.GroceryAutocomplete) + AppSettingsBloc.Output.OpenGroceryCategoryRules -> + navigation.bringToFront(Configuration.GroceryCategoryRules) } } @@ -172,6 +185,12 @@ class SettingsRootBlocImpl( } } + private fun handleGroceryCategoryRulesOutput(output: GroceryCategoryRulesBloc.Output) { + when (output) { + GroceryCategoryRulesBloc.Output.Back -> navigation.pop() + } + } + @Serializable private sealed class Configuration { @Serializable data object AppSettings : Configuration() @@ -185,5 +204,7 @@ class SettingsRootBlocImpl( @Serializable data object RecipeCategories : Configuration() @Serializable data object GroceryAutocomplete : Configuration() + + @Serializable data object GroceryCategoryRules : Configuration() } } diff --git a/client/settings/root/impl/src/commonTest/kotlin/com/plusmobileapps/chefmate/settings/root/impl/SettingsRootBlocImplTest.kt b/client/settings/root/impl/src/commonTest/kotlin/com/plusmobileapps/chefmate/settings/root/impl/SettingsRootBlocImplTest.kt index aef6fb50b..c07ed3da9 100644 --- a/client/settings/root/impl/src/commonTest/kotlin/com/plusmobileapps/chefmate/settings/root/impl/SettingsRootBlocImplTest.kt +++ b/client/settings/root/impl/src/commonTest/kotlin/com/plusmobileapps/chefmate/settings/root/impl/SettingsRootBlocImplTest.kt @@ -5,6 +5,7 @@ package com.plusmobileapps.chefmate.settings.root.impl import com.plusmobileapps.chefmate.BlocContext import com.plusmobileapps.chefmate.Consumer import com.plusmobileapps.chefmate.grocery.autocomplete.GroceryAutocompleteBloc +import com.plusmobileapps.chefmate.grocery.categoryrules.GroceryCategoryRulesBloc import com.plusmobileapps.chefmate.recipe.bottomnav.BottomNavOrderBloc import com.plusmobileapps.chefmate.recipe.categories.RecipeCategoriesBloc import com.plusmobileapps.chefmate.recipe.exporter.ExportRecipesBloc @@ -27,6 +28,7 @@ class SettingsRootBlocImplTest { var exportRecipesOutput: Consumer = Consumer {} var recipeCategoriesOutput: Consumer = Consumer {} var groceryAutocompleteOutput: Consumer = Consumer {} + var groceryCategoryRulesOutput: Consumer = Consumer {} var rootOutput: SettingsRootBloc.Output? = null @@ -66,6 +68,10 @@ class SettingsRootBlocImplTest { groceryAutocompleteOutput = output mock() }, + groceryCategoryRules = { _, output -> + groceryCategoryRulesOutput = output + mock() + }, ) fun SettingsRootBloc.instance(): SettingsRootBloc.Child = routerState.value.active.instance @@ -155,6 +161,20 @@ class SettingsRootBlocImplTest { deepLinked.routerState.value.backStack.size shouldBe 1 } + @Test + fun When_app_settings_opens_grocery_category_rules_Then_grocery_category_rules_is_shown() { + appSettingsOutput.onNext(AppSettingsBloc.Output.OpenGroceryCategoryRules) + bloc.instance() should instanceOf() + bloc.routerState.value.backStack.size shouldBe 1 + } + + @Test + fun Given_grocery_category_rules_When_it_outputs_back_Then_app_settings_is_shown() { + appSettingsOutput.onNext(AppSettingsBloc.Output.OpenGroceryCategoryRules) + groceryCategoryRulesOutput.onNext(GroceryCategoryRulesBloc.Output.Back) + bloc.instance() should instanceOf() + } + @Test fun When_app_settings_outputs_back_Then_root_back_is_emitted() { appSettingsOutput.onNext(AppSettingsBloc.Output.Back) diff --git a/client/settings/root/public/build.gradle.kts b/client/settings/root/public/build.gradle.kts index d8ac44928..f39c9363e 100644 --- a/client/settings/root/public/build.gradle.kts +++ b/client/settings/root/public/build.gradle.kts @@ -9,6 +9,7 @@ kotlin { api(projects.client.settings.public) api(projects.client.bottomnav.public) api(projects.client.grocery.autocomplete.public) + api(projects.client.grocery.categoryRules.public) api(projects.client.recipe.categories.public) api(projects.client.recipe.exporter.public) api(projects.client.recipe.importer.public) diff --git a/client/settings/root/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/settings/root/SettingsRootBloc.kt b/client/settings/root/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/settings/root/SettingsRootBloc.kt index 8f12258b7..5a59cba75 100644 --- a/client/settings/root/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/settings/root/SettingsRootBloc.kt +++ b/client/settings/root/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/settings/root/SettingsRootBloc.kt @@ -9,6 +9,7 @@ import com.plusmobileapps.chefmate.BackClickBloc import com.plusmobileapps.chefmate.BlocContext import com.plusmobileapps.chefmate.Consumer import com.plusmobileapps.chefmate.grocery.autocomplete.GroceryAutocompleteBloc +import com.plusmobileapps.chefmate.grocery.categoryrules.GroceryCategoryRulesBloc import com.plusmobileapps.chefmate.recipe.bottomnav.BottomNavOrderBloc import com.plusmobileapps.chefmate.recipe.categories.RecipeCategoriesBloc import com.plusmobileapps.chefmate.recipe.exporter.ExportRecipesBloc @@ -40,6 +41,8 @@ interface SettingsRootBloc : BackHandlerOwner, BackClickBloc, ComposeScreen { data class RecipeCategories(override val bloc: RecipeCategoriesBloc) : Child() data class GroceryAutocomplete(override val bloc: GroceryAutocompleteBloc) : Child() + + data class GroceryCategoryRules(override val bloc: GroceryCategoryRulesBloc) : Child() } sealed class Output { diff --git a/settings.gradle.kts b/settings.gradle.kts index 8037941d8..2daebfedf 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -90,6 +90,10 @@ include(":client:grocery:autocomplete:impl-robots") include(":client:grocery:autocomplete:public") +include(":client:grocery:category-rules:impl") + +include(":client:grocery:category-rules:public") + include(":client:grocery:core:impl") include(":client:grocery:core:impl-robots") From 248133c83f715dc1b0a7b2e6e0d1c215c116a2d1 Mon Sep 17 00:00:00 2001 From: Andrew Steinmetz Date: Sun, 19 Jul 2026 23:44:02 -0700 Subject: [PATCH 5/6] test(grocery): snapshots for category rules + detail hook Adds Compose previews and screenshot-test coverage for the new category rules screen (populated, empty, creating, dark) and the item detail "always file here" checked state. Re-records the grocery detail, app settings, and settings root references to include the new checkbox row and the "Category rules" settings entry. Co-Authored-By: Claude Opus 4.8 --- .../GroceryCategoryRulesPreviews.kt | 66 +++++++++++++++++++ .../impl/detail/ui/GroceryDetailPreviews.kt | 13 +++- client/ui/screenshot-test/build.gradle.kts | 1 + .../GroceryCategoryRulesScreenshotTest.kt | 41 ++++++++++++ .../screenshot/GroceryDetailScreenshotTest.kt | 8 +++ .../AppSettingsDarkScreenshot_4d30ee2f_0.png | 4 +- .../AppSettingsLightScreenshot_748aa731_0.png | 4 +- ...goryRulesCreatingScreenshot_7c0f57d6_0.png | 3 + ...CategoryRulesDarkScreenshot_658f3c84_0.png | 3 + ...ategoryRulesEmptyScreenshot_7c0f57d6_0.png | 3 + ...ceryCategoryRulesScreenshot_7c0f57d6_0.png | 3 + ...ialogExpandedDarkScreenshot_3eb56d23_0.png | 4 +- ...alogExpandedLightScreenshot_beb5a611_0.png | 4 +- ...waysFileHereLightScreenshot_5a383ab2_0.png | 3 + ...etFromRecipeLightScreenshot_5a383ab2_0.png | 4 +- ...ilSheetLoadedDarkScreenshot_8542c94c_0.png | 4 +- ...lSheetLoadedLightScreenshot_5a383ab2_0.png | 4 +- ...eetPurchasedLightScreenshot_5a383ab2_0.png | 4 +- ...otAppSettingsDarkScreenshot_4d30ee2f_0.png | 4 +- ...tAppSettingsLightScreenshot_748aa731_0.png | 4 +- 20 files changed, 162 insertions(+), 22 deletions(-) create mode 100644 client/grocery/category-rules/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/categoryrules/GroceryCategoryRulesPreviews.kt create mode 100644 client/ui/screenshot-test/src/screenshotTest/kotlin/com/plusmobileapps/chefmate/ui/screenshot/GroceryCategoryRulesScreenshotTest.kt create mode 100644 client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryCategoryRulesScreenshotTestKt/GroceryCategoryRulesCreatingScreenshot_7c0f57d6_0.png create mode 100644 client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryCategoryRulesScreenshotTestKt/GroceryCategoryRulesDarkScreenshot_658f3c84_0.png create mode 100644 client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryCategoryRulesScreenshotTestKt/GroceryCategoryRulesEmptyScreenshot_7c0f57d6_0.png create mode 100644 client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryCategoryRulesScreenshotTestKt/GroceryCategoryRulesScreenshot_7c0f57d6_0.png create mode 100644 client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryDetailScreenshotTestKt/GroceryDetailSheetAlwaysFileHereLightScreenshot_5a383ab2_0.png diff --git a/client/grocery/category-rules/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/categoryrules/GroceryCategoryRulesPreviews.kt b/client/grocery/category-rules/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/categoryrules/GroceryCategoryRulesPreviews.kt new file mode 100644 index 000000000..2964c890c --- /dev/null +++ b/client/grocery/category-rules/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/categoryrules/GroceryCategoryRulesPreviews.kt @@ -0,0 +1,66 @@ +package com.plusmobileapps.chefmate.grocery.categoryrules + +import com.arkivanov.essenty.backhandler.BackDispatcher +import com.arkivanov.essenty.backhandler.BackHandler +import com.plusmobileapps.chefmate.grocery.categoryrules.GroceryCategoryRulesBloc.CreateState +import com.plusmobileapps.chefmate.grocery.categoryrules.GroceryCategoryRulesBloc.Model +import com.plusmobileapps.chefmate.grocery.categoryrules.GroceryCategoryRulesBloc.Rule +import com.plusmobileapps.chefmate.grocery.data.GroceryCategory +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.MutableStateFlow + +private fun bloc(model: Model): GroceryCategoryRulesBloc = + object : GroceryCategoryRulesBloc { + override val backHandler: BackHandler = BackDispatcher() + override val state = MutableStateFlow(model) + + override fun onBackClicked() = Unit + + override fun onCreateClicked() = Unit + + override fun onCreateCancelled() = Unit + + override fun onCreateNameChanged(name: String) = Unit + + override fun onCreateCategorySelected(category: GroceryCategory) = Unit + + override fun onCreateSubmitted() = Unit + + override fun onDeleteRequested(rule: Rule) = Unit + + override fun onDeleteConfirmed() = Unit + + override fun onDeleteDismissed() = Unit + } + +private val sampleRules = + persistentListOf( + Rule(id = 1L, name = "Cold brew", category = GroceryCategory.BEVERAGES), + Rule(id = 2L, name = "Paper towels", category = GroceryCategory.OTHER), + Rule(id = 3L, name = "Protein powder", category = GroceryCategory.BAKING), + ) + +val previewGroceryCategoryRulesBloc: GroceryCategoryRulesBloc = + bloc(Model(rules = sampleRules, isLoading = false)) + +val previewGroceryCategoryRulesBlocEmpty: GroceryCategoryRulesBloc = + bloc(Model(rules = persistentListOf(), isLoading = false)) + +val previewGroceryCategoryRulesBlocCreating: GroceryCategoryRulesBloc = + bloc( + Model( + rules = sampleRules, + isLoading = false, + createState = + CreateState.Editing(name = "Sparkling water", category = GroceryCategory.BEVERAGES), + ) + ) + +val previewGroceryCategoryRulesBlocDeleteDialog: GroceryCategoryRulesBloc = + bloc( + Model( + rules = sampleRules, + isLoading = false, + dialog = GroceryCategoryRulesBloc.DialogState.Delete(sampleRules.first()), + ) + ) diff --git a/client/grocery/core/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/core/impl/detail/ui/GroceryDetailPreviews.kt b/client/grocery/core/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/core/impl/detail/ui/GroceryDetailPreviews.kt index 86bcaa37e..017b90348 100644 --- a/client/grocery/core/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/core/impl/detail/ui/GroceryDetailPreviews.kt +++ b/client/grocery/core/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/core/impl/detail/ui/GroceryDetailPreviews.kt @@ -10,10 +10,15 @@ import com.plusmobileapps.chefmate.grocery.data.GroceryItem import com.plusmobileapps.chefmate.ui.theme.ChefMateTheme import kotlinx.coroutines.flow.MutableStateFlow -private fun groceryDetailBloc(item: GroceryItem): GroceryDetailBloc = +private fun groceryDetailBloc( + item: GroceryItem, + alwaysFileHere: Boolean = false, +): GroceryDetailBloc = object : GroceryDetailBloc { override val models = - MutableStateFlow(GroceryDetailBloc.Model.Loaded(item)) + MutableStateFlow( + GroceryDetailBloc.Model.Loaded(item = item, alwaysFileHere = alwaysFileHere) + ) override fun onGroceryNameChanged(name: String) = Unit @@ -56,6 +61,10 @@ val previewGroceryDetailBlocPurchased: GroceryDetailBloc = val previewGroceryDetailBlocFromRecipe: GroceryDetailBloc = groceryDetailBloc(sampleItem.copy(recipeName = "Pancakes")) +/** "Always file here" rule active — exercises the checked category-rule row. */ +val previewGroceryDetailBlocAlwaysFileHere: GroceryDetailBloc = + groceryDetailBloc(sampleItem, alwaysFileHere = true) + /** Loading state — exercises the spinner. */ val previewGroceryDetailBlocLoading: GroceryDetailBloc = object : GroceryDetailBloc { diff --git a/client/ui/screenshot-test/build.gradle.kts b/client/ui/screenshot-test/build.gradle.kts index 0dceed209..6e2f0931e 100644 --- a/client/ui/screenshot-test/build.gradle.kts +++ b/client/ui/screenshot-test/build.gradle.kts @@ -46,6 +46,7 @@ dependencies { implementation(project(":client:grocery:core:public")) implementation(project(":client:grocery:core:impl")) implementation(project(":client:grocery:autocomplete:public")) + implementation(project(":client:grocery:category-rules:public")) implementation(project(":client:meal:core:public")) implementation(project(":client:meal:core:impl")) implementation(project(":client:meal:data:public")) diff --git a/client/ui/screenshot-test/src/screenshotTest/kotlin/com/plusmobileapps/chefmate/ui/screenshot/GroceryCategoryRulesScreenshotTest.kt b/client/ui/screenshot-test/src/screenshotTest/kotlin/com/plusmobileapps/chefmate/ui/screenshot/GroceryCategoryRulesScreenshotTest.kt new file mode 100644 index 000000000..a5825fdfe --- /dev/null +++ b/client/ui/screenshot-test/src/screenshotTest/kotlin/com/plusmobileapps/chefmate/ui/screenshot/GroceryCategoryRulesScreenshotTest.kt @@ -0,0 +1,41 @@ +package com.plusmobileapps.chefmate.ui.screenshot + +import android.content.res.Configuration +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import com.android.tools.screenshot.PreviewTest +import com.plusmobileapps.chefmate.grocery.categoryrules.GroceryCategoryRulesScreen +import com.plusmobileapps.chefmate.grocery.categoryrules.previewGroceryCategoryRulesBloc +import com.plusmobileapps.chefmate.grocery.categoryrules.previewGroceryCategoryRulesBlocCreating +import com.plusmobileapps.chefmate.grocery.categoryrules.previewGroceryCategoryRulesBlocEmpty +import com.plusmobileapps.chefmate.ui.theme.ChefMateTheme + +@PreviewTest +@Preview(showBackground = true, heightDp = 900) +@Composable +fun GroceryCategoryRulesScreenshot() { + ChefMateTheme { GroceryCategoryRulesScreen(bloc = previewGroceryCategoryRulesBloc) } +} + +@PreviewTest +@Preview(showBackground = true, heightDp = 900, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun GroceryCategoryRulesDarkScreenshot() { + ChefMateTheme(darkTheme = true) { + GroceryCategoryRulesScreen(bloc = previewGroceryCategoryRulesBloc) + } +} + +@PreviewTest +@Preview(showBackground = true, heightDp = 900) +@Composable +fun GroceryCategoryRulesEmptyScreenshot() { + ChefMateTheme { GroceryCategoryRulesScreen(bloc = previewGroceryCategoryRulesBlocEmpty) } +} + +@PreviewTest +@Preview(showBackground = true, heightDp = 900) +@Composable +fun GroceryCategoryRulesCreatingScreenshot() { + ChefMateTheme { GroceryCategoryRulesScreen(bloc = previewGroceryCategoryRulesBlocCreating) } +} diff --git a/client/ui/screenshot-test/src/screenshotTest/kotlin/com/plusmobileapps/chefmate/ui/screenshot/GroceryDetailScreenshotTest.kt b/client/ui/screenshot-test/src/screenshotTest/kotlin/com/plusmobileapps/chefmate/ui/screenshot/GroceryDetailScreenshotTest.kt index b499923e6..3dcef1779 100644 --- a/client/ui/screenshot-test/src/screenshotTest/kotlin/com/plusmobileapps/chefmate/ui/screenshot/GroceryDetailScreenshotTest.kt +++ b/client/ui/screenshot-test/src/screenshotTest/kotlin/com/plusmobileapps/chefmate/ui/screenshot/GroceryDetailScreenshotTest.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.tooling.preview.Preview import com.android.tools.screenshot.PreviewTest import com.plusmobileapps.chefmate.grocery.core.detail.GroceryDetailBloc import com.plusmobileapps.chefmate.grocery.core.detail.GroceryDetailSheetContent +import com.plusmobileapps.chefmate.grocery.core.impl.detail.ui.previewGroceryDetailBlocAlwaysFileHere import com.plusmobileapps.chefmate.grocery.core.impl.detail.ui.previewGroceryDetailBlocFromRecipe import com.plusmobileapps.chefmate.grocery.core.impl.detail.ui.previewGroceryDetailBlocLoaded import com.plusmobileapps.chefmate.grocery.core.impl.detail.ui.previewGroceryDetailBlocLoading @@ -68,6 +69,13 @@ fun GroceryDetailSheetLoadingLightScreenshot() { GroceryDetailSheet(bloc = previewGroceryDetailBlocLoading) } +@PreviewTest +@Preview(showBackground = true, heightDp = 500) +@Composable +fun GroceryDetailSheetAlwaysFileHereLightScreenshot() { + GroceryDetailSheet(bloc = previewGroceryDetailBlocAlwaysFileHere) +} + // ── EXPANDED-width dialog variant ────────────────────────────────────────── // // On windows ≥ 840dp wide, PlusResponsiveModal switches to a centered dialog. Dialog itself diff --git a/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/AppSettingsScreenshotTestKt/AppSettingsDarkScreenshot_4d30ee2f_0.png b/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/AppSettingsScreenshotTestKt/AppSettingsDarkScreenshot_4d30ee2f_0.png index 99ce27b65..9e5f1c08b 100644 --- a/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/AppSettingsScreenshotTestKt/AppSettingsDarkScreenshot_4d30ee2f_0.png +++ b/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/AppSettingsScreenshotTestKt/AppSettingsDarkScreenshot_4d30ee2f_0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:38bbc66a8f757ed678d3d77f6171d587dc46a34e4b18437fdc6ff65776ac36d2 -size 86424 +oid sha256:3dcff87a71bfa8aeb06f340901d9e8a46550591a02b294b923ea49a5af314d20 +size 91451 diff --git a/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/AppSettingsScreenshotTestKt/AppSettingsLightScreenshot_748aa731_0.png b/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/AppSettingsScreenshotTestKt/AppSettingsLightScreenshot_748aa731_0.png index 020732d4f..c3bb4bf3e 100644 --- a/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/AppSettingsScreenshotTestKt/AppSettingsLightScreenshot_748aa731_0.png +++ b/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/AppSettingsScreenshotTestKt/AppSettingsLightScreenshot_748aa731_0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:af6d57fe3f1367c0542cfb3c76c89023fb381bc54649f2f88bf7344b72f97047 -size 85531 +oid sha256:0990d4fd052f4ab5ef5037d0bbb4f223abcc1c86391fcd0f6cc06b0a49a19533 +size 90629 diff --git a/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryCategoryRulesScreenshotTestKt/GroceryCategoryRulesCreatingScreenshot_7c0f57d6_0.png b/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryCategoryRulesScreenshotTestKt/GroceryCategoryRulesCreatingScreenshot_7c0f57d6_0.png new file mode 100644 index 000000000..549740322 --- /dev/null +++ b/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryCategoryRulesScreenshotTestKt/GroceryCategoryRulesCreatingScreenshot_7c0f57d6_0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:799f4707828b50f1c19d2cb5e6b66029f47cf692db1a7b5cfb5ca45d59607283 +size 60817 diff --git a/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryCategoryRulesScreenshotTestKt/GroceryCategoryRulesDarkScreenshot_658f3c84_0.png b/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryCategoryRulesScreenshotTestKt/GroceryCategoryRulesDarkScreenshot_658f3c84_0.png new file mode 100644 index 000000000..4cf893f3f --- /dev/null +++ b/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryCategoryRulesScreenshotTestKt/GroceryCategoryRulesDarkScreenshot_658f3c84_0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e177643f69a7a3f12c9d45af290502c1cc20ede67dc57aae12f2e626af82a017 +size 47317 diff --git a/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryCategoryRulesScreenshotTestKt/GroceryCategoryRulesEmptyScreenshot_7c0f57d6_0.png b/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryCategoryRulesScreenshotTestKt/GroceryCategoryRulesEmptyScreenshot_7c0f57d6_0.png new file mode 100644 index 000000000..f3ec30c09 --- /dev/null +++ b/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryCategoryRulesScreenshotTestKt/GroceryCategoryRulesEmptyScreenshot_7c0f57d6_0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1b473c2f8cd0ba8d3846c29cbe7c56dd0c8366556fce9c076e2c9134cd3da2cd +size 37998 diff --git a/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryCategoryRulesScreenshotTestKt/GroceryCategoryRulesScreenshot_7c0f57d6_0.png b/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryCategoryRulesScreenshotTestKt/GroceryCategoryRulesScreenshot_7c0f57d6_0.png new file mode 100644 index 000000000..7c3c25bea --- /dev/null +++ b/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryCategoryRulesScreenshotTestKt/GroceryCategoryRulesScreenshot_7c0f57d6_0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a2a5cb663bbff31b2f2c6a866c18f28495b903452348e271e2d84ff535e9b456 +size 46905 diff --git a/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryDetailScreenshotTestKt/GroceryDetailDialogExpandedDarkScreenshot_3eb56d23_0.png b/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryDetailScreenshotTestKt/GroceryDetailDialogExpandedDarkScreenshot_3eb56d23_0.png index 079e69ae9..1212e9c02 100644 --- a/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryDetailScreenshotTestKt/GroceryDetailDialogExpandedDarkScreenshot_3eb56d23_0.png +++ b/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryDetailScreenshotTestKt/GroceryDetailDialogExpandedDarkScreenshot_3eb56d23_0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:eed774e99bf3def45b3bffcd64469c97bfc2233ee3ffca381094e7cc5268e971 -size 68848 +oid sha256:435f9c0be3178da93080417c09ce010e5dab58aed78e218ea951531959d1ca59 +size 78091 diff --git a/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryDetailScreenshotTestKt/GroceryDetailDialogExpandedLightScreenshot_beb5a611_0.png b/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryDetailScreenshotTestKt/GroceryDetailDialogExpandedLightScreenshot_beb5a611_0.png index 564cdd21b..cf13c47e2 100644 --- a/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryDetailScreenshotTestKt/GroceryDetailDialogExpandedLightScreenshot_beb5a611_0.png +++ b/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryDetailScreenshotTestKt/GroceryDetailDialogExpandedLightScreenshot_beb5a611_0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0ae294c23b38cf3213ea4b0bc388e7d3b066d76db316b046e5fd3c502975977e -size 68308 +oid sha256:b8d6d8bbce7d9a37ef70ea5711de1bb8a89b9d15be7bc225530b7af2d13ac690 +size 77583 diff --git a/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryDetailScreenshotTestKt/GroceryDetailSheetAlwaysFileHereLightScreenshot_5a383ab2_0.png b/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryDetailScreenshotTestKt/GroceryDetailSheetAlwaysFileHereLightScreenshot_5a383ab2_0.png new file mode 100644 index 000000000..a6b4c9032 --- /dev/null +++ b/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryDetailScreenshotTestKt/GroceryDetailSheetAlwaysFileHereLightScreenshot_5a383ab2_0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:df29e327054a308846b847658226704538041d440c26ef76381c87fd15024b80 +size 41148 diff --git a/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryDetailScreenshotTestKt/GroceryDetailSheetFromRecipeLightScreenshot_5a383ab2_0.png b/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryDetailScreenshotTestKt/GroceryDetailSheetFromRecipeLightScreenshot_5a383ab2_0.png index d12a46d2c..f91aa8335 100644 --- a/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryDetailScreenshotTestKt/GroceryDetailSheetFromRecipeLightScreenshot_5a383ab2_0.png +++ b/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryDetailScreenshotTestKt/GroceryDetailSheetFromRecipeLightScreenshot_5a383ab2_0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7cae323f800f32c0efdebbc0e3cd0a9d1c7c83940c091a2d39a0652372dc1d4f -size 36642 +oid sha256:5884ba8d3c6c0911638ec145987ce9d057e2e750dc5004e30281dc402b663170 +size 44042 diff --git a/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryDetailScreenshotTestKt/GroceryDetailSheetLoadedDarkScreenshot_8542c94c_0.png b/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryDetailScreenshotTestKt/GroceryDetailSheetLoadedDarkScreenshot_8542c94c_0.png index 17ebfefe0..ba24ea0cb 100644 --- a/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryDetailScreenshotTestKt/GroceryDetailSheetLoadedDarkScreenshot_8542c94c_0.png +++ b/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryDetailScreenshotTestKt/GroceryDetailSheetLoadedDarkScreenshot_8542c94c_0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9c5024eff0c7db884e90d69b3b622af24bc132490c1de746218a8626cdaf6cad -size 33550 +oid sha256:98e0584b1f9cf75bf3e85cb8b09326730d2ab51842ab93175e8cc44c931f8649 +size 41033 diff --git a/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryDetailScreenshotTestKt/GroceryDetailSheetLoadedLightScreenshot_5a383ab2_0.png b/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryDetailScreenshotTestKt/GroceryDetailSheetLoadedLightScreenshot_5a383ab2_0.png index 074b147a2..46c2f9dab 100644 --- a/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryDetailScreenshotTestKt/GroceryDetailSheetLoadedLightScreenshot_5a383ab2_0.png +++ b/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryDetailScreenshotTestKt/GroceryDetailSheetLoadedLightScreenshot_5a383ab2_0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:35bca052b26d6269340feac1bf44b005d7dab06c995a01a125cfad5a32a478b8 -size 33418 +oid sha256:5cec8ce7506078191279ec8a9126ce6e8920745e89cf5a9cf07b039cd48b1486 +size 40883 diff --git a/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryDetailScreenshotTestKt/GroceryDetailSheetPurchasedLightScreenshot_5a383ab2_0.png b/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryDetailScreenshotTestKt/GroceryDetailSheetPurchasedLightScreenshot_5a383ab2_0.png index efcca77bc..c952666b0 100644 --- a/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryDetailScreenshotTestKt/GroceryDetailSheetPurchasedLightScreenshot_5a383ab2_0.png +++ b/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryDetailScreenshotTestKt/GroceryDetailSheetPurchasedLightScreenshot_5a383ab2_0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:773b6d30975252148c21876c43012f24371a0695c426e30d4121d31fd5689de5 -size 33653 +oid sha256:c574af63f8ad961a3f7007af45a8eb233be7706c5f645d5fc7ba4960f2cf91e2 +size 41138 diff --git a/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/SettingsRootScreenshotTestKt/SettingsRootAppSettingsDarkScreenshot_4d30ee2f_0.png b/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/SettingsRootScreenshotTestKt/SettingsRootAppSettingsDarkScreenshot_4d30ee2f_0.png index 99ce27b65..9e5f1c08b 100644 --- a/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/SettingsRootScreenshotTestKt/SettingsRootAppSettingsDarkScreenshot_4d30ee2f_0.png +++ b/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/SettingsRootScreenshotTestKt/SettingsRootAppSettingsDarkScreenshot_4d30ee2f_0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:38bbc66a8f757ed678d3d77f6171d587dc46a34e4b18437fdc6ff65776ac36d2 -size 86424 +oid sha256:3dcff87a71bfa8aeb06f340901d9e8a46550591a02b294b923ea49a5af314d20 +size 91451 diff --git a/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/SettingsRootScreenshotTestKt/SettingsRootAppSettingsLightScreenshot_748aa731_0.png b/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/SettingsRootScreenshotTestKt/SettingsRootAppSettingsLightScreenshot_748aa731_0.png index 020732d4f..c3bb4bf3e 100644 --- a/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/SettingsRootScreenshotTestKt/SettingsRootAppSettingsLightScreenshot_748aa731_0.png +++ b/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/SettingsRootScreenshotTestKt/SettingsRootAppSettingsLightScreenshot_748aa731_0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:af6d57fe3f1367c0542cfb3c76c89023fb381bc54649f2f88bf7344b72f97047 -size 85531 +oid sha256:0990d4fd052f4ab5ef5037d0bbb4f223abcc1c86391fcd0f6cc06b0a49a19533 +size 90629 From d9490b73f50ba0cd7536d79e75377ae8c968814a Mon Sep 17 00:00:00 2001 From: Andrew Steinmetz Date: Sun, 19 Jul 2026 23:47:13 -0700 Subject: [PATCH 6/6] test(grocery): robot UI test for category rules navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the grocery/category-rules impl-robots module and a runRootBlocTest flow that opens Settings → Category rules and asserts the management screen loads. Registers the new GroceryCategoryOverrideQueries in the test DI graph, which the grocery repository now depends on. Co-Authored-By: Claude Opus 4.8 --- client/composeApp/build.gradle.kts | 1 + .../di/BaseTestApplicationComponent.kt | 5 +++ .../GroceryCategoryRulesNavigationUiTest.kt | 25 +++++++++++ .../impl-robots/build.gradle.kts | 17 +++++++ .../robots/GroceryCategoryRulesRobot.kt | 45 +++++++++++++++++++ settings.gradle.kts | 2 + 6 files changed, 95 insertions(+) create mode 100644 client/composeApp/src/commonTest/kotlin/com/plusmobileapps/chefmate/tests/GroceryCategoryRulesNavigationUiTest.kt create mode 100644 client/grocery/category-rules/impl-robots/build.gradle.kts create mode 100644 client/grocery/category-rules/impl-robots/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/categoryrules/robots/GroceryCategoryRulesRobot.kt diff --git a/client/composeApp/build.gradle.kts b/client/composeApp/build.gradle.kts index e5adbcf39..daa8c4a8a 100644 --- a/client/composeApp/build.gradle.kts +++ b/client/composeApp/build.gradle.kts @@ -170,6 +170,7 @@ kotlin { implementation(projects.client.subscription.testing) implementation(projects.client.recipe.categories.implRobots) implementation(projects.client.grocery.autocomplete.implRobots) + implementation(projects.client.grocery.categoryRules.implRobots) implementation(projects.client.grocery.core.implRobots) implementation(projects.client.recipe.core.implRobots) implementation(projects.client.recipe.list.implRobots) diff --git a/client/composeApp/src/commonTest/kotlin/com/plusmobileapps/chefmate/di/BaseTestApplicationComponent.kt b/client/composeApp/src/commonTest/kotlin/com/plusmobileapps/chefmate/di/BaseTestApplicationComponent.kt index c12ff713d..bf9764535 100644 --- a/client/composeApp/src/commonTest/kotlin/com/plusmobileapps/chefmate/di/BaseTestApplicationComponent.kt +++ b/client/composeApp/src/commonTest/kotlin/com/plusmobileapps/chefmate/di/BaseTestApplicationComponent.kt @@ -7,6 +7,7 @@ import com.plusmobileapps.chefmate.database.CategoryQueries import com.plusmobileapps.chefmate.database.CookingSessionQueries import com.plusmobileapps.chefmate.database.Database import com.plusmobileapps.chefmate.database.GroceryAutocompleteItemQueries +import com.plusmobileapps.chefmate.database.GroceryCategoryOverrideQueries import com.plusmobileapps.chefmate.database.GroceryListMemberQueries import com.plusmobileapps.chefmate.database.GroceryListQueries import com.plusmobileapps.chefmate.database.GroceryQueries @@ -50,6 +51,10 @@ abstract class BaseTestApplicationComponent : TestApplicationComponent { fun providesGroceryAutocompleteItemQueries(database: Database): GroceryAutocompleteItemQueries = database.groceryAutocompleteItemQueries + @Provides + fun providesGroceryCategoryOverrideQueries(database: Database): GroceryCategoryOverrideQueries = + database.groceryCategoryOverrideQueries + @Provides fun providesMealPlanQueries(database: Database): MealPlanQueries = database.mealPlanQueries diff --git a/client/composeApp/src/commonTest/kotlin/com/plusmobileapps/chefmate/tests/GroceryCategoryRulesNavigationUiTest.kt b/client/composeApp/src/commonTest/kotlin/com/plusmobileapps/chefmate/tests/GroceryCategoryRulesNavigationUiTest.kt new file mode 100644 index 000000000..ccc78aa56 --- /dev/null +++ b/client/composeApp/src/commonTest/kotlin/com/plusmobileapps/chefmate/tests/GroceryCategoryRulesNavigationUiTest.kt @@ -0,0 +1,25 @@ +package com.plusmobileapps.chefmate.tests + +import androidx.compose.ui.test.ExperimentalTestApi +import com.plusmobileapps.chefmate.grocery.categoryrules.robots.groceryCategoryRules +import com.plusmobileapps.chefmate.harness.runRootBlocTest +import com.plusmobileapps.chefmate.recipe.bottomnav.robots.bottomNav +import com.plusmobileapps.chefmate.settings.robots.more +import com.plusmobileapps.chefmate.settings.root.robots.settingsRoot +import kotlin.test.Test + +@OptIn(ExperimentalTestApi::class) +class GroceryCategoryRulesNavigationUiTest { + + @Test + fun opening_settings_then_category_rules_lands_on_the_management_screen() = runRootBlocTest { + bottomNav().clickMoreTab() + more().awaitDisplayed().clickAppSettingsRow() + + settingsRoot().awaitDisplayed().clickRow("Category rules") + + // The "Your rules" section header renders near the top of the list, so it's a stable + // signal the management screen loaded. + groceryCategoryRules().awaitDisplayed().assertTextDisplayed("Your rules") + } +} diff --git a/client/grocery/category-rules/impl-robots/build.gradle.kts b/client/grocery/category-rules/impl-robots/build.gradle.kts new file mode 100644 index 000000000..40a070daf --- /dev/null +++ b/client/grocery/category-rules/impl-robots/build.gradle.kts @@ -0,0 +1,17 @@ +plugins { + alias(libs.plugins.kmpLibrary) + alias(libs.plugins.compose) +} + +kotlin { + sourceSets { + commonMain.dependencies { + implementation(projects.client.grocery.categoryRules.public) + } + } +} + +plusLibrary { + namespace = "com.plusmobileapps.chefmate.grocery.categoryrules.robots" + uiTest = true +} diff --git a/client/grocery/category-rules/impl-robots/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/categoryrules/robots/GroceryCategoryRulesRobot.kt b/client/grocery/category-rules/impl-robots/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/categoryrules/robots/GroceryCategoryRulesRobot.kt new file mode 100644 index 000000000..79e380edd --- /dev/null +++ b/client/grocery/category-rules/impl-robots/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/categoryrules/robots/GroceryCategoryRulesRobot.kt @@ -0,0 +1,45 @@ +@file:OptIn(ExperimentalTestApi::class) + +package com.plusmobileapps.chefmate.grocery.categoryrules.robots + +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.waitUntilExactlyOneExists +import com.plusmobileapps.chefmate.grocery.categoryrules.GroceryCategoryRulesTestTags + +/** + * Robot for the Settings → Grocery → Category rules management screen. Every node lookup is scoped + * under [GroceryCategoryRulesTestTags.SCREEN] so a rule label here never matches a like-named node + * on another screen (e.g. the same item name rendered in the grocery list). + */ +class GroceryCategoryRulesRobot(private val test: ComposeUiTest) { + + private val onScreen = hasAnyAncestor(hasTestTag(GroceryCategoryRulesTestTags.SCREEN)) + + fun awaitDisplayed(): GroceryCategoryRulesRobot = apply { + test.waitUntilExactlyOneExists(hasTestTag(GroceryCategoryRulesTestTags.SCREEN)) + } + + fun assertTextDisplayed(text: String): GroceryCategoryRulesRobot = apply { + test.onNode(hasText(text, substring = true) and onScreen).assertIsDisplayed() + } + + fun openAddField(): GroceryCategoryRulesRobot = apply { + test.onNode(hasTestTag(GroceryCategoryRulesTestTags.ADD_BUTTON) and onScreen).performClick() + } + + fun typeRuleName(name: String): GroceryCategoryRulesRobot = apply { + test + .onNode(hasTestTag(GroceryCategoryRulesTestTags.CREATE_FIELD) and onScreen) + .performTextInput(name) + } +} + +fun ComposeUiTest.groceryCategoryRules(): GroceryCategoryRulesRobot = + GroceryCategoryRulesRobot(this) diff --git a/settings.gradle.kts b/settings.gradle.kts index 2daebfedf..ede2042db 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -92,6 +92,8 @@ include(":client:grocery:autocomplete:public") include(":client:grocery:category-rules:impl") +include(":client:grocery:category-rules:impl-robots") + include(":client:grocery:category-rules:public") include(":client:grocery:core:impl")